"""Step definitions for changeset persistence feature tests."""

from __future__ import annotations

import re
from collections.abc import Callable

from behave import given, then, when  # type: ignore[import-untyped]
from behave.runner import Context  # type: ignore[import-untyped]
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker

from cleveragents.application.services.plan_apply_service import (
    PlanApplyService,
)
from cleveragents.application.services.plan_lifecycle_service import (
    PlanLifecycleService,
)
from cleveragents.config.settings import Settings
from cleveragents.core.exceptions import ValidationError
from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    ToolInvocation,
)
from cleveragents.infrastructure.database.changeset_repository import (
    ChangeSetEntryRepository,
    SqliteChangeSetStore,
    ToolInvocationRepository,
)
from cleveragents.infrastructure.database.models import Base


def _make_in_memory_session_factory() -> tuple[Callable[[], Session], Session, Engine]:
    """Create an in-memory SQLite engine + shared session factory with schema.

    Uses a single shared session so that flush() data is visible across
    all repository calls within the same scenario.  Without this,
    short-lived sessions returned by sessionmaker() may be garbage-
    collected, causing the StaticPool's reset_on_return to discard
    uncommitted INSERTs.
    """
    engine = create_engine(
        "sqlite:///:memory:",
        echo=False,
        connect_args={"check_same_thread": False},
    )
    Base.metadata.create_all(engine)
    session: Session = sessionmaker(bind=engine)()
    return lambda: session, session, engine


def _make_entry(
    plan_id: str,
    operation: ChangeOperation = ChangeOperation.CREATE,
    path: str = "src/test.py",
    resource_id: str = "res-001",
) -> ChangeEntry:
    """Create a test ChangeEntry."""
    before_hash = "aaa111" if operation != ChangeOperation.CREATE else None
    after_hash = "bbb222" if operation != ChangeOperation.DELETE else None
    return ChangeEntry(
        plan_id=plan_id,
        resource_id=resource_id,
        tool_name="file-write",
        operation=operation,
        path=path,
        before_hash=before_hash,
        after_hash=after_hash,
    )


# ---- SqliteChangeSetStore steps ----


@given("I have a sqlite-backed changeset store")
def step_sqlite_store(context: Context) -> None:
    factory, session, engine = _make_in_memory_session_factory()
    context.session_factory = factory
    context.engine = engine
    context.sqlite_store = SqliteChangeSetStore(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when('I persist-start a changeset for plan "{plan_id}"')
def step_start_changeset(context: Context, plan_id: str) -> None:
    context.changeset_id = context.sqlite_store.start(plan_id)
    context.plan_id = plan_id


@then("the persisted changeset ID should be a valid ULID")
def step_valid_ulid(context: Context) -> None:
    assert context.changeset_id is not None
    assert len(context.changeset_id) == 26
    assert re.match(r"^[0-9A-Z]{26}$", context.changeset_id)


@when("I persist-record a create entry in the changeset")
def step_record_create(context: Context) -> None:
    entry = _make_entry(context.plan_id, ChangeOperation.CREATE, "src/new.py")
    context.sqlite_store.record(context.changeset_id, entry)


@when("I persist-record a modify entry in the changeset")
def step_record_modify(context: Context) -> None:
    entry = _make_entry(context.plan_id, ChangeOperation.MODIFY, "src/existing.py")
    context.sqlite_store.record(context.changeset_id, entry)


@when("I persist-record a delete entry in the changeset")
def step_record_delete(context: Context) -> None:
    entry = _make_entry(context.plan_id, ChangeOperation.DELETE, "src/old.py")
    context.sqlite_store.record(context.changeset_id, entry)


@then("persisted changeset get should return {count:d} entries")
def step_get_entries(context: Context, count: int) -> None:
    cs = context.sqlite_store.get(context.changeset_id)
    assert cs is not None
    assert len(cs.entries) == count


@then('the persisted first entry operation should be "{op}"')
def step_first_op(context: Context, op: str) -> None:
    cs = context.sqlite_store.get(context.changeset_id)
    assert cs is not None
    assert cs.entries[0].operation == op


@then('the persisted second entry operation should be "{op}"')
def step_second_op(context: Context, op: str) -> None:
    cs = context.sqlite_store.get(context.changeset_id)
    assert cs is not None
    assert cs.entries[1].operation == op


@then('persisted changeset "{cid}" returns None on get')
def step_get_none(context: Context, cid: str) -> None:
    result = context.sqlite_store.get(cid)
    assert result is None


@when('I persist-start and populate a changeset for plan "{plan_id}"')
def step_start_and_populate(context: Context, plan_id: str) -> None:
    context.plan_id = plan_id
    context.changeset_id = context.sqlite_store.start(plan_id)
    entry = _make_entry(plan_id, ChangeOperation.CREATE, "src/file.py")
    context.sqlite_store.record(context.changeset_id, entry)


@then('persisted get_for_plan "{plan_id}" returns at least {count:d} changeset')
def step_get_for_plan(context: Context, plan_id: str, count: int) -> None:
    result = context.sqlite_store.get_for_plan(plan_id)
    assert len(result) >= count


@then("persisted summarize should show {count:d} total")
def step_summarize(context: Context, count: int) -> None:
    summary = context.sqlite_store.summarize(context.changeset_id)
    assert summary.get("total") == count


@then('persisted summarize of changeset "{cid}" returns empty dict')
def step_summarize_empty(context: Context, cid: str) -> None:
    result = context.sqlite_store.summarize(cid)
    assert result == {}


# ---- ChangeSetEntryRepository steps ----


@given("I have a persisted ChangeSetEntryRepository")
def step_entry_repo(context: Context) -> None:
    factory, session, engine = _make_in_memory_session_factory()
    context.session_factory = factory
    context.engine = engine
    context.entry_repo = ChangeSetEntryRepository(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when('I persist-save an entry for changeset "{cs_id}" plan "{plan_id}"')
def step_save_entry(context: Context, cs_id: str, plan_id: str) -> None:
    entry = _make_entry(plan_id, ChangeOperation.CREATE, "src/test.py")
    context.entry_repo.save_entry(cs_id, entry)


@then('persisted entries for changeset "{cs_id}" count is {count:d}')
def step_retrieve_for_changeset(context: Context, cs_id: str, count: int) -> None:
    entries = context.entry_repo.get_entries_for_changeset(cs_id)
    assert len(entries) == count


@when('I persist-save entries for plan "{plan_id}"')
def step_save_entries_for_plan(context: Context, plan_id: str) -> None:
    context.plan_id = plan_id
    for i in range(3):
        entry = _make_entry(plan_id, ChangeOperation.CREATE, f"src/f{i}.py")
        context.entry_repo.save_entry(f"cs-{i}", entry)


@when('I persist-delete entries for plan "{plan_id}"')
def step_delete_for_plan(context: Context, plan_id: str) -> None:
    context.entry_repo.delete_for_plan(plan_id)


@then('persisted entries for plan "{plan_id}" count is {count:d}')
def step_retrieve_for_plan(context: Context, plan_id: str, count: int) -> None:
    entries = context.entry_repo.get_entries_for_plan(plan_id)
    assert len(entries) == count


@when('I persist-delete entries for changeset "{cs_id}"')
def step_delete_for_changeset(context: Context, cs_id: str) -> None:
    context.entry_repo.delete_for_changeset(cs_id)


# ---- ToolInvocationRepository steps ----


@given("I have a persisted ToolInvocationRepository")
def step_inv_repo(context: Context) -> None:
    factory, session, engine = _make_in_memory_session_factory()
    context.session_factory = factory
    context.engine = engine
    context.inv_repo = ToolInvocationRepository(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())


@when('I persist-save an invocation for plan "{plan_id}"')
def step_save_invocation(context: Context, plan_id: str) -> None:
    context.plan_id = plan_id
    inv = ToolInvocation(
        plan_id=plan_id,
        tool_name="file-write",
        arguments={"path": "test.py"},
        success=True,
    )
    context.inv_repo.save_invocation(inv, changeset_id="cs-inv")


@then('persisted invocations for plan "{plan_id}" count is {count:d}')
def step_retrieve_invocations(context: Context, plan_id: str, count: int) -> None:
    invocations = context.inv_repo.get_invocations_for_plan(plan_id)
    assert len(invocations) == count


@when('I persist-delete invocations for plan "{plan_id}"')
def step_delete_invocations(context: Context, plan_id: str) -> None:
    context.inv_repo.delete_for_plan(plan_id)


# ---- PlanApplyService cleanup steps ----


@given("I have a PlanApplyService backed by sqlite changeset store")
def step_apply_service_with_store(context: Context) -> None:
    factory, session, engine = _make_in_memory_session_factory()
    context.session_factory = factory
    context.engine = engine
    context.sqlite_store = SqliteChangeSetStore(factory)
    context._cleanup_handlers.append(lambda: session.close())
    context._cleanup_handlers.append(lambda: engine.dispose())

    settings = Settings()
    lifecycle = PlanLifecycleService(settings=settings)
    context.apply_service = PlanApplyService(
        lifecycle_service=lifecycle,
        changeset_store=context.sqlite_store,
    )

    lifecycle.create_action(
        name="local/cleanup-test",
        description="Cleanup test action",
        definition_of_done="Tests pass",
        strategy_actor="local/planner",
        execution_actor="local/executor",
    )
    plan = lifecycle.use_action(action_name="local/cleanup-test")
    context.test_plan_id = plan.identity.plan_id


@when("I persist-start and populate a changeset for the test plan")
def step_populate_test_changeset(context: Context) -> None:
    cs_id = context.sqlite_store.start(context.test_plan_id)
    entry = _make_entry(context.test_plan_id, ChangeOperation.CREATE, "src/test.py")
    context.sqlite_store.record(cs_id, entry)
    context.test_changeset_id = cs_id


@when("I call cleanup_changeset on the persisted test plan")
def step_cleanup(context: Context) -> None:
    context.cleanup_count = context.apply_service.cleanup_changeset(
        context.test_plan_id,
    )


@then("the persisted changeset entries for the test plan are empty")
def step_entries_empty(context: Context) -> None:
    entries = context.sqlite_store.get_for_plan(context.test_plan_id)
    total_entries = sum(len(cs.entries) for cs in entries)
    assert total_entries == 0


# ---- Validation steps ----


@when("I try creating a persisted ChangeSetEntryRepository with None")
def step_entry_repo_none(context: Context) -> None:
    try:
        ChangeSetEntryRepository(None)  # type: ignore[arg-type]
        context.persist_raised = None
    except ValueError as exc:
        context.persist_raised = exc


@when("I try creating a persisted ToolInvocationRepository with None")
def step_inv_repo_none(context: Context) -> None:
    try:
        ToolInvocationRepository(None)  # type: ignore[arg-type]
        context.persist_raised = None
    except ValueError as exc:
        context.persist_raised = exc


@when("I try creating a persisted SqliteChangeSetStore with None")
def step_store_none(context: Context) -> None:
    try:
        SqliteChangeSetStore(None)  # type: ignore[arg-type]
        context.persist_raised = None
    except ValueError as exc:
        context.persist_raised = exc


@then("a persist ValueError should be raised")
def step_value_error(context: Context) -> None:
    assert isinstance(context.persist_raised, ValueError)


@when("I try to persist-start a changeset with empty plan_id")
def step_start_empty(context: Context) -> None:
    try:
        context.sqlite_store.start("")
        context.persist_raised = None
    except ValueError as exc:
        context.persist_raised = exc


@when("I try to persist-cleanup changeset with empty plan_id")
def step_cleanup_empty(context: Context) -> None:
    try:
        context.apply_service.cleanup_changeset("")
        context.persist_raised = None
    except (ValidationError, ValueError) as exc:
        context.persist_raised = exc


@then("a persist validation error should be raised")
def step_validation_error(context: Context) -> None:
    assert isinstance(context.persist_raised, (ValidationError, ValueError))
