"""Step definitions for bug #987 regression coverage.

These scenarios verify the fixed behavior for all public
``AutomationProfileRepository`` methods (``get_by_name()``,
``list_all()``, ``upsert()``, and ``delete()``): when ``auto_commit``
mode is enabled, each method must close its session in a ``finally``
block, matching the existing ``SessionRepository`` pattern.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

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

from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.automation_profile import AutomationProfile
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
    AutomationProfileRepository,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_profile(name: str = "local/leak-test-profile") -> AutomationProfile:
    """Create a minimal valid ``AutomationProfile`` for testing."""
    return AutomationProfile(
        name=name,
        description="TDD test profile for session leak bug #987",
    )


class _TrackingSession(Session):
    """A thin ``Session`` subclass that records whether ``close()`` was called.

    Uses keyword-only ``bind`` parameter to match
    ``sqlalchemy.orm.Session.__init__`` without requiring ``type: ignore``.
    """

    close_called: bool

    def __init__(self, **kwargs: Any) -> None:
        super().__init__(**kwargs)
        self.close_called = False

    def close(self) -> None:
        self.close_called = True
        super().close()


class _FailingFlushTrackingSession(_TrackingSession):
    """A ``_TrackingSession`` whose ``flush()`` always raises.

    Used to test the error path without monkey-patching, eliminating the
    need for a ``type: ignore[assignment]`` annotation.
    """

    def flush(self, objects: Sequence[Any] | None = None) -> None:
        raise OperationalError("simulated failure", params=None, orig=Exception("boom"))


class _FailingQueryTrackingSession(_TrackingSession):
    """A ``_TrackingSession`` whose ``query()`` always raises.

    Used to test the error path for read-only methods (``get_by_name``,
    ``list_all``) without monkey-patching.
    """

    def query(self, *entities: Any, **kwargs: Any) -> Any:
        raise OperationalError(
            "simulated query failure",
            params=None,
            orig=Exception("boom"),
        )


# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------


@given(
    "an AutomationProfileRepository with auto_commit enabled"
    " and a tracking session factory"
)
def step_given_repo_with_tracking_factory(context: Context) -> None:
    """Set up a real in-memory SQLite database with a tracking session."""
    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)

    # We need to keep a reference to the tracking session so we can
    # inspect ``close_called`` later.
    tracking_session = _TrackingSession(bind=engine)
    context.tracking_session = tracking_session
    context.db_engine = engine

    def session_factory() -> Session:
        return tracking_session

    context.repo = AutomationProfileRepository(
        session_factory=session_factory,
        auto_commit=True,
    )

    # Register cleanup handlers for engine disposal and session close
    # so resources are released even if the scenario fails mid-way.
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(tracking_session.close)
    context._cleanup_handlers.append(engine.dispose)


@given('a persisted automation profile named "{name}"')
def step_given_persisted_profile(context: Context, name: str) -> None:
    """Pre-populate a profile so that delete can find it."""
    profile = _make_profile(name)
    # Use a fresh session to insert the profile directly, bypassing
    # the repository under test so we don't conflate setup with the
    # action being tested.
    fresh_factory = sessionmaker(bind=context.db_engine)
    setup_session = fresh_factory()
    setup_repo = AutomationProfileRepository(
        session_factory=lambda: setup_session,
        auto_commit=True,
    )
    setup_repo.upsert(profile)
    # Explicitly close the setup session to avoid leaking it.
    setup_session.close()

    # Reset the tracking session's close flag so the test only
    # measures the ``delete()`` call.
    context.tracking_session.close_called = False


@given(
    "an AutomationProfileRepository with auto_commit enabled"
    " and a failing session factory"
)
def step_given_repo_with_failing_factory(context: Context) -> None:
    """Set up a repository whose session will raise on flush."""
    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)

    tracking_session = _FailingFlushTrackingSession(bind=engine)
    context.tracking_session = tracking_session
    context.db_engine = engine

    def session_factory() -> Session:
        return tracking_session

    context.repo = AutomationProfileRepository(
        session_factory=session_factory,
        auto_commit=True,
    )

    # Register cleanup handlers for engine disposal and session close.
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(tracking_session.close)
    context._cleanup_handlers.append(engine.dispose)


@given(
    "an AutomationProfileRepository with auto_commit enabled,"
    " a pre-populated profile,"
    " and a failing-flush tracking session"
)
def step_given_repo_with_prepopulated_and_failing(context: Context) -> None:
    """Set up a repo with existing data and a session that fails on flush.

    This exercises the ``delete()`` error path: the query succeeds (finds
    the profile) but the subsequent ``flush()`` after ``session.delete()``
    raises an ``OperationalError``.
    """
    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)

    # Pre-populate a profile using a normal session.
    setup_factory = sessionmaker(bind=engine)
    setup_session = setup_factory()
    setup_repo = AutomationProfileRepository(
        session_factory=lambda: setup_session,
        auto_commit=True,
    )
    setup_repo.upsert(_make_profile())
    setup_session.close()

    # Now create the failing-flush tracking session for the test.
    tracking_session = _FailingFlushTrackingSession(bind=engine)
    context.tracking_session = tracking_session
    context.db_engine = engine

    def session_factory() -> Session:
        return tracking_session

    context.repo = AutomationProfileRepository(
        session_factory=session_factory,
        auto_commit=True,
    )

    # Register cleanup handlers for engine disposal and session close.
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(tracking_session.close)
    context._cleanup_handlers.append(engine.dispose)


@given(
    "an AutomationProfileRepository with auto_commit enabled"
    " and a failing-query session factory"
)
def step_given_repo_with_failing_query_factory(context: Context) -> None:
    """Set up a repository whose session will raise on query."""
    engine = create_engine("sqlite:///:memory:", echo=False)
    Base.metadata.create_all(engine)

    tracking_session = _FailingQueryTrackingSession(bind=engine)
    context.tracking_session = tracking_session
    context.db_engine = engine

    def session_factory() -> Session:
        return tracking_session

    context.repo = AutomationProfileRepository(
        session_factory=session_factory,
        auto_commit=True,
    )

    # Register cleanup handlers for engine disposal and session close.
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(tracking_session.close)
    context._cleanup_handlers.append(engine.dispose)


# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------


@when("I upsert a valid automation profile via the repository")
def step_when_upsert_profile(context: Context) -> None:
    """Call upsert on the repository under test."""
    profile = _make_profile()
    context.repo.upsert(profile)


@when('I delete the automation profile "{name}" via the repository')
def step_when_delete_profile(context: Context, name: str) -> None:
    """Call delete on the repository under test."""
    context.repo.delete(name)


@when("I attempt to upsert a profile that triggers a database error")
def step_when_upsert_triggers_error(context: Context) -> None:
    """Attempt an upsert that will fail due to the failing flush."""
    profile = _make_profile("local/error-profile")
    context.upsert_error = None
    try:
        context.repo.upsert(profile)
    except DatabaseError as exc:
        context.upsert_error = exc


@when("I attempt to delete a profile that triggers a database error")
def step_when_delete_triggers_error(context: Context) -> None:
    """Attempt a delete that will fail due to the failing flush."""
    context.delete_error = None
    try:
        context.repo.delete("local/leak-test-profile")
    except DatabaseError as exc:
        context.delete_error = exc


@when("I look up a profile by name via the repository")
def step_when_get_by_name(context: Context) -> None:
    """Call get_by_name on the repository under test."""
    context.repo.get_by_name("local/nonexistent-profile")


@when("I list all profiles via the repository")
def step_when_list_all(context: Context) -> None:
    """Call list_all on the repository under test."""
    context.repo.list_all()


@when("I attempt to look up a profile that triggers a database error")
def step_when_get_by_name_triggers_error(context: Context) -> None:
    """Attempt a get_by_name that will fail due to the failing query."""
    context.get_by_name_error = None
    try:
        context.repo.get_by_name("local/nonexistent-profile")
    except DatabaseError as exc:
        context.get_by_name_error = exc


@when("I attempt to list profiles that triggers a database error")
def step_when_list_all_triggers_error(context: Context) -> None:
    """Attempt a list_all that will fail due to the failing query."""
    context.list_all_error = None
    try:
        context.repo.list_all()
    except DatabaseError as exc:
        context.list_all_error = exc


# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------


@then("the tracking session should have been closed")
def step_then_session_closed(context: Context) -> None:
    """Assert that close() was called on the tracking session.

    Regression guard for bug #987: verifies that
    AutomationProfileRepository calls session.close() in a finally
    block when auto_commit is True.
    """
    assert context.tracking_session.close_called, (
        "Expected session.close() to have been called in auto_commit mode, "
        "but it was NOT called. This confirms bug #987: "
        "AutomationProfileRepository leaks sessions."
    )


@then("the tracking session should have been closed despite the error")
def step_then_session_closed_despite_error(context: Context) -> None:
    """Assert close() was called even when an error occurred.

    The finally block should ensure session cleanup regardless of
    whether the operation succeeded or failed.
    """
    assert context.upsert_error is not None, (
        "Expected a DatabaseError from the failing upsert, but none was raised."
    )
    assert context.tracking_session.close_called, (
        "Expected session.close() to have been called even after a database "
        "error in auto_commit mode, but it was NOT called. This confirms "
        "bug #987: AutomationProfileRepository leaks sessions on error."
    )


@then("the tracking session should have been closed despite the delete error")
def step_then_session_closed_despite_delete_error(context: Context) -> None:
    """Assert close() was called even when a delete error occurred.

    The finally block should ensure session cleanup regardless of
    whether the operation succeeded or failed.
    """
    assert context.delete_error is not None, (
        "Expected a DatabaseError from the failing delete, but none was raised."
    )
    assert context.tracking_session.close_called, (
        "Expected session.close() to have been called even after a database "
        "error in auto_commit mode, but it was NOT called. This confirms "
        "bug #987: AutomationProfileRepository leaks sessions on error."
    )


@then("the tracking session should have been closed despite the get_by_name error")
def step_then_session_closed_despite_get_by_name_error(context: Context) -> None:
    """Assert close() was called even when a get_by_name error occurred.

    The finally block should ensure session cleanup regardless of
    whether the query succeeded or failed.
    """
    assert context.get_by_name_error is not None, (
        "Expected a DatabaseError from the failing get_by_name, but none was raised."
    )
    assert context.tracking_session.close_called, (
        "Expected session.close() to have been called even after a database "
        "error in auto_commit mode, but it was NOT called. This confirms "
        "bug #987: AutomationProfileRepository leaks sessions on error."
    )


@then("the tracking session should have been closed despite the list_all error")
def step_then_session_closed_despite_list_all_error(context: Context) -> None:
    """Assert close() was called even when a list_all error occurred.

    The finally block should ensure session cleanup regardless of
    whether the query succeeded or failed.
    """
    assert context.list_all_error is not None, (
        "Expected a DatabaseError from the failing list_all, but none was raised."
    )
    assert context.tracking_session.close_called, (
        "Expected session.close() to have been called even after a database "
        "error in auto_commit mode, but it was NOT called. This confirms "
        "bug #987: AutomationProfileRepository leaks sessions on error."
    )
