"""Step definitions for ActionRepository persistence coverage.

Targets uncovered lines 711-998 and partial branches at lines 145 and 215
in ``src/cleveragents/infrastructure/database/repositories.py``.
"""

from __future__ import annotations

from datetime import datetime

from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker

from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import NamespacedName
from cleveragents.infrastructure.database.models import (
    Base,
    LifecyclePlanModel,
)
from cleveragents.infrastructure.database.repositories import (
    ActionInUseError,
    ActionRepository,
    DuplicateActionError,
    PlanRepository,
    ProjectRepository,
)

# Valid ULIDs for deterministic tests (Crockford base32, 26 chars)
# Crockford base32 alphabet: 0123456789ABCDEFGHJKMNPQRSTVWXYZ
_ULID_COUNTER = 0
_CB32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"


def _next_ulid() -> str:
    """Return a unique, valid ULID string for each call."""
    global _ULID_COUNTER
    _ULID_COUNTER += 1
    # Encode the counter into the last 8 Crockford base32 digits
    n = _ULID_COUNTER
    suffix = ""
    for _ in range(8):
        suffix = _CB32[n % 32] + suffix
        n //= 32
    return f"01HGZ6FE0AQDYTR4BX{suffix}"


def _make_action(
    name: str = "local/test-action",
    state: str = "available",
) -> Action:
    """Create a minimal valid Action domain object."""
    parts = name.split("/", 1)
    namespace = parts[0] if len(parts) == 2 else "local"
    short_name = parts[1] if len(parts) == 2 else parts[0]

    return Action(
        namespaced_name=NamespacedName(
            namespace=namespace,
            name=short_name,
        ),
        description=f"Test action {short_name}",
        long_description=None,
        definition_of_done=f"Verify {short_name} completes",
        strategy_actor="local/strategist",
        execution_actor="local/executor",
        estimation_actor=None,
        review_actor=None,
        arguments=[],
        reusable=True,
        read_only=False,
        state=ActionState(state),
        created_at=datetime.now(),
        updated_at=datetime.now(),
        created_by=None,
        tags=[],
    )


def _get_session(context: Context) -> Session:
    """Return the shared session stored on the behave context."""
    return context.db_session


# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a clean in-memory database with the lifecycle schema")
def step_clean_db(context: Context) -> None:
    """Create a fresh in-memory SQLite database with all tables.

    The ``ActionRepository`` uses a session-factory pattern: each public
    method calls ``self._session()`` to obtain a session.  For the tests
    to exercise commit / rollback semantics correctly the factory must
    return the **same** session instance so that ``session.flush()``
    inside the repository and ``session.commit()`` in the test step
    operate on the same transaction.
    """
    engine = create_engine("sqlite:///:memory:")
    Base.metadata.create_all(engine)
    context.db_engine = engine
    # Canonical session shared by all callers within one scenario
    session = sessionmaker(bind=engine)()
    context.db_session = session
    context.db_session_factory = lambda: session


@given("an action repository backed by the database")
def step_action_repo(context: Context) -> None:
    """Instantiate an ActionRepository using the session factory."""
    context.action_repo = ActionRepository(
        session_factory=context.db_session_factory,
    )
    context.saved_action = None
    context.result_action = None
    context.error = None
    context.delete_result = None


# ---------------------------------------------------------------------------
# Creating actions
# ---------------------------------------------------------------------------


@given('a valid action domain object named "{name}"')
def step_make_action(context: Context, name: str) -> None:
    """Build an Action domain object with the given namespaced name."""
    context.action = _make_action(name=name)


@given("the action has already been saved once")
def step_save_action_once(context: Context) -> None:
    """Persist the current action so a subsequent save will conflict."""
    context.action_repo.create(context.action)
    context.db_session.commit()


@when("the action is saved through the repository")
def step_save_action(context: Context) -> None:
    """Persist the action and capture any errors."""
    try:
        context.saved_action = context.action_repo.create(context.action)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@when('a second action with the same name "{name}" is saved')
def step_save_duplicate(context: Context, name: str) -> None:
    """Attempt to persist a second action with the same namespaced name.

    The ``create`` method is wrapped in a retry decorator that may
    re-attempt before surfacing the error.  We catch the final exception
    after all retries are exhausted.
    """
    # Build a new Action that shares the same namespaced name but has a
    # different action_id (the unique constraint is on the ``name`` column).
    dup = _make_action(name=name)
    try:
        context.action_repo.create(dup)
        context.db_session.commit()
    except (DuplicateActionError, DatabaseError) as exc:
        # The retry decorator may wrap the DuplicateActionError inside a
        # RetryError.  Unwrap if necessary.
        context.error = exc
    except Exception as exc:
        # tenacity.RetryError wraps the last attempt's exception.
        cause = exc.__cause__ or exc
        context.error = cause


@then("the repository should not raise an error")
def step_no_error(context: Context) -> None:
    assert context.error is None, f"Unexpected error: {context.error}"


@then('the persisted action should retain the name "{name}"')
def step_verify_name(context: Context, name: str) -> None:
    assert context.saved_action is not None
    assert str(context.saved_action.namespaced_name) == name, (
        f"Expected '{name}', got '{context.saved_action.namespaced_name}'"
    )


@then('a DuplicateActionError should be raised mentioning "{name}"')
def step_verify_dup_error(context: Context, name: str) -> None:
    assert context.error is not None, "Expected DuplicateActionError"
    assert isinstance(context.error, DuplicateActionError), (
        f"Expected DuplicateActionError, got {type(context.error).__name__}"
    )
    assert name in str(context.error), (
        f"Error message should mention '{name}': {context.error}"
    )


# ---------------------------------------------------------------------------
# Retrieving actions by identifier
# ---------------------------------------------------------------------------


@given("the action has been saved through the repository")
def step_save_for_lookup(context: Context) -> None:
    """Persist the action for subsequent retrieval tests."""
    context.saved_action = context.action_repo.create(context.action)
    context.db_session.commit()


@when("the action is looked up by its identifier")
def step_get_by_id(context: Context) -> None:
    context.result_action = context.action_repo.get_by_id(
        str(context.action.namespaced_name),
    )


@when('an action is looked up by the identifier "{action_id}"')
def step_get_by_id_direct(context: Context, action_id: str) -> None:
    context.result_action = context.action_repo.get_by_id(action_id)


@then('the returned action should have the name "{name}"')
def step_verify_returned_name(context: Context, name: str) -> None:
    assert context.result_action is not None, "Expected an action, got None"
    assert str(context.result_action.namespaced_name) == name


@then("no action should be returned")
def step_verify_none(context: Context) -> None:
    assert context.result_action is None, f"Expected None, got {context.result_action}"


# ---------------------------------------------------------------------------
# Retrieving actions by name
# ---------------------------------------------------------------------------


@when('the action is looked up by name "{name}"')
def step_get_by_name(context: Context, name: str) -> None:
    context.result_action = context.action_repo.get_by_name(name)


# ---------------------------------------------------------------------------
# Listing actions by namespace
# ---------------------------------------------------------------------------


@given("the following actions have been saved:")
def step_save_multiple(context: Context) -> None:
    """Persist several actions described in a Behave table."""
    assert context.table is not None, "Step requires a data table"
    for row in context.table:
        name = row["name"]
        state = row.get("state", "available")
        action = _make_action(name=name, state=state)
        context.action_repo.create(action)
    context.db_session.commit()


@when('actions in the "{namespace}" namespace are listed')
def step_list_namespace(context: Context, namespace: str) -> None:
    context.result_list = context.action_repo.get_by_namespace(namespace)


@when('actions in the "{namespace}" namespace are listed with state "{state}"')
def step_list_namespace_state(context: Context, namespace: str, state: str) -> None:
    context.result_list = context.action_repo.get_by_namespace(
        namespace,
        state=state,
    )


@then("{count:d} action should be returned")
def step_verify_count_singular(context: Context, count: int) -> None:
    assert len(context.result_list) == count, (
        f"Expected {count}, got {len(context.result_list)}"
    )


@then("{count:d} actions should be returned")
def step_verify_count_plural(context: Context, count: int) -> None:
    assert len(context.result_list) == count, (
        f"Expected {count}, got {len(context.result_list)}"
    )


@then('the returned action names should include "{name}"')
def step_verify_includes_name(context: Context, name: str) -> None:
    names = [str(a.namespaced_name) for a in context.result_list]
    assert name in names, f"Expected '{name}' in {names}"


# ---------------------------------------------------------------------------
# Listing actions by state
# ---------------------------------------------------------------------------


@when('actions in the "{state}" state are listed')
def step_list_by_state(context: Context, state: str) -> None:
    context.result_list = context.action_repo.get_by_state(state)


# ---------------------------------------------------------------------------
# Listing available actions
# ---------------------------------------------------------------------------


@when("all available actions are listed")
def step_list_available(context: Context) -> None:
    context.result_list = context.action_repo.list_available()


@when('available actions in the "{namespace}" namespace are listed')
def step_list_available_ns(context: Context, namespace: str) -> None:
    context.result_list = context.action_repo.list_available(
        namespace=namespace,
    )


# ---------------------------------------------------------------------------
# Updating actions
# ---------------------------------------------------------------------------


@when('the action description is changed to "{new_dod}"')
def step_change_dod(context: Context, new_dod: str) -> None:
    context.action.definition_of_done = new_dod


@when("the action is updated through the repository")
def step_update_action(context: Context) -> None:
    try:
        context.result_action = context.action_repo.update(context.action)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@when("the phantom action is updated without being saved first")
def step_update_unsaved(context: Context) -> None:
    try:
        context.action_repo.update(context.action)
    except Exception as exc:
        context.error = exc


@then("a DatabaseError should be raised about the missing action")
def step_verify_db_error(context: Context) -> None:
    assert context.error is not None, "Expected DatabaseError"
    assert isinstance(context.error, DatabaseError), (
        f"Expected DatabaseError, got {type(context.error).__name__}"
    )


# ---------------------------------------------------------------------------
# Deleting actions
# ---------------------------------------------------------------------------


@when("the action is deleted by its identifier")
def step_delete_action(context: Context) -> None:
    context.delete_result = context.action_repo.delete(
        str(context.action.namespaced_name),
    )
    context.db_session.commit()


@when('an action with identifier "{action_id}" is deleted')
def step_delete_by_id(context: Context, action_id: str) -> None:
    context.delete_result = context.action_repo.delete(action_id)


@then("the delete operation should return true")
def step_verify_deleted(context: Context) -> None:
    assert context.delete_result is True, f"Expected True, got {context.delete_result}"


@then("the delete operation should return false")
def step_verify_not_deleted(context: Context) -> None:
    assert context.delete_result is False, (
        f"Expected False, got {context.delete_result}"
    )


@then("looking up the deleted action by identifier should return nothing")
def step_verify_gone(context: Context) -> None:
    found = context.action_repo.get_by_id(str(context.action.namespaced_name))
    assert found is None, f"Expected None, got {found}"


@given("a lifecycle plan references that action")
def step_create_referencing_plan(context: Context) -> None:
    """Insert a LifecyclePlanModel row that references the saved action."""
    session = _get_session(context)
    now_iso = datetime.now().isoformat()
    pid = _next_ulid()
    plan_model = LifecyclePlanModel(
        plan_id=pid,
        root_plan_id=pid,
        action_name=str(context.action.namespaced_name),
        phase="strategize",
        processing_state="queued",
        attempt=1,
        namespaced_name="local/test-plan",
        namespace="local",
        description="Plan referencing the action under test",
        effective_profile_snapshot="{}",
        created_at=now_iso,
        updated_at=now_iso,
        tags_json="[]",
    )
    session.add(plan_model)
    session.flush()
    session.commit()


@when("the referenced action is deleted")
def step_delete_referenced(context: Context) -> None:
    try:
        context.action_repo.delete(str(context.action.namespaced_name))
    except Exception as exc:
        context.error = exc


@then("an ActionInUseError should be raised")
def step_verify_in_use(context: Context) -> None:
    assert context.error is not None, "Expected ActionInUseError"
    assert isinstance(context.error, ActionInUseError), (
        f"Expected ActionInUseError, got {type(context.error).__name__}"
    )


# ---------------------------------------------------------------------------
# Error classes
# ---------------------------------------------------------------------------


@when('a DuplicateActionError is created for name "{name}"')
def step_create_dup_error(context: Context, name: str) -> None:
    context.error_instance = DuplicateActionError(name)


@then('the error should contain the message "{text}"')
def step_verify_error_msg(context: Context, text: str) -> None:
    assert text in str(context.error_instance), (
        f"Expected '{text}' in '{context.error_instance}'"
    )


@then('the error should expose the action name "{name}"')
def step_verify_error_attr(context: Context, name: str) -> None:
    assert context.error_instance.action_name == name


@when(
    'an ActionInUseError is created for action "{aid}" with {count:d} referencing plans'
)
def step_create_in_use_error(context: Context, aid: str, count: int) -> None:
    context.error_instance = ActionInUseError(aid, count)


@then('the error should mention "{aid}" and "{plans}"')
def step_verify_in_use_msg(context: Context, aid: str, plans: str) -> None:
    msg = str(context.error_instance)
    assert aid in msg, f"Expected '{aid}' in '{msg}'"
    assert plans in msg, f"Expected '{plans}' in '{msg}'"


@then('the error should expose action identifier "{aid}" and plan count {count:d}')
def step_verify_in_use_attrs(context: Context, aid: str, count: int) -> None:
    assert context.error_instance.action_name == aid
    assert context.error_instance.plan_count == count


# ---------------------------------------------------------------------------
# Partial branch - ProjectRepository.delete (line 145)
# ---------------------------------------------------------------------------


@given("a project repository backed by the database")
def step_project_repo(context: Context) -> None:
    context.project_repo = ProjectRepository(
        session=_get_session(context),
    )
    context.error = None


@when("a project with identifier {pid:d} is deleted")
def step_delete_project(context: Context, pid: int) -> None:
    try:
        context.project_repo.delete(pid)
        context.db_session.commit()
    except Exception as exc:
        context.error = exc


@then("the delete operation should complete without error")
def step_verify_no_delete_error(context: Context) -> None:
    assert context.error is None, f"Unexpected error: {context.error}"


# ---------------------------------------------------------------------------
# Partial branch - PlanRepository.update (line 215)
# ---------------------------------------------------------------------------


@given("a plan repository backed by the database")
def step_plan_repo(context: Context) -> None:
    from cleveragents.domain.models.core import PlanStatus

    context.plan_repo = PlanRepository(session=_get_session(context))
    context.error = None
    context.plan_status_cls = PlanStatus


@given("a plan domain object with identifier {pid:d}")
def step_plan_with_id(context: Context, pid: int) -> None:
    from cleveragents.domain.models.core import Plan as LegacyPlan

    context.legacy_plan = LegacyPlan(
        id=pid,
        project_id=1,
        name="nonexistent-plan",
        prompt="test prompt",
        status=context.plan_status_cls.PENDING,
        current=False,
    )


@when("the non-existent plan is updated through the repository")
def step_update_missing_plan(context: Context) -> None:
    context.result_plan = context.plan_repo.update(context.legacy_plan)


@then("the plan object should be returned unchanged")
def step_verify_plan_unchanged(context: Context) -> None:
    assert context.result_plan is not None
    assert context.result_plan.id == context.legacy_plan.id
    assert context.result_plan.name == context.legacy_plan.name
