"""Step definitions for session_list_error.feature (bug #554).

TDD regression tests for ``agents session list`` after ``agents init``.
These scenarios assert the correct expected behaviour and will fail until
the DI container fix is applied.

Design rationale
~~~~~~~~~~~~~~~~
``_get_session_service()`` calls ``container.db()`` but the DI ``Container``
class has no ``db`` provider, raising ``AttributeError``.

We reset ``_service`` to ``None`` so the real ``_get_session_service()`` is
exercised.  A file-based SQLite database and ``CLEVERAGENTS_DATABASE_URL``
override ensure the commands can reach the database once the fix lands.

Private API access
~~~~~~~~~~~~~~~~~~
This module accesses ``session_mod._service`` (module-level singleton cache)
to force the real ``_get_session_service()`` code path during tests.  This is
intentional: the public API (``CliRunner.invoke``) does not expose the DI
wiring that triggers the bug, so we must bypass the cache to exercise it.
"""

from __future__ import annotations

import json
import logging
import os
import shutil
import tempfile
from typing import Any

import yaml
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from typer.testing import CliRunner

from cleveragents.application.container import reset_container
from cleveragents.application.services.session_service import (
    PersistentSessionService,
)
from cleveragents.cli.commands import session as session_mod
from cleveragents.cli.commands.session import app as session_app
from cleveragents.config.settings import Settings
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
    SessionMessageRepository,
    SessionRepository,
)

runner = CliRunner()

_ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"}


def _unwrap_envelope(parsed: Any) -> Any:
    """Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is."""
    if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()):
        return parsed["data"]
    return parsed


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


def _suppress_structlog_stdout() -> tuple[int, bool]:
    """Prevent structlog debug lines from contaminating CLI stdout.

    See ``tdd_session_shared_steps._suppress_structlog_stdout`` for the
    full rationale.
    """
    import structlog

    root = logging.getLogger()
    prev_level = root.level
    root.setLevel(logging.WARNING)

    prev_config = structlog.get_config()
    prev_cache: bool = prev_config.get("cache_logger_on_first_use", True)  # type: ignore[assignment]
    structlog.configure(
        logger_factory=structlog.stdlib.LoggerFactory(),
        wrapper_class=structlog.stdlib.BoundLogger,
        cache_logger_on_first_use=False,
    )

    return prev_level, prev_cache


def _restore_structlog(prev_level: int, prev_cache: bool) -> None:
    """Undo the suppression applied by :func:`_suppress_structlog_stdout`."""
    import structlog

    logging.getLogger().setLevel(prev_level)
    structlog.configure(cache_logger_on_first_use=prev_cache)


def _setup_real_di_path(context: Context) -> None:
    """Prepare a temp dir with a fresh SQLite DB and override container."""
    # Store original _service so cleanup can restore it.
    context.sle_original_service = session_mod._service

    # Reset any stale DI container singleton before configuring the env var
    # so that cached providers don't carry over from a prior test suite (F17).
    reset_container()
    Settings._instance = None  # type: ignore[attr-defined]

    # Suppress structlog debug output so CliRunner captures clean output.
    prev_level, prev_cache = _suppress_structlog_stdout()
    context.sle_log_prev = (prev_level, prev_cache)

    context.sle_tmpdir = tempfile.mkdtemp(prefix="session_list_err_554_")

    # Register cleanup immediately after mkdtemp so the temp directory is
    # always removed even if the rest of setup fails (F21).
    context.add_cleanup(_cleanup_sle, context)

    context.sle_db_path = os.path.join(context.sle_tmpdir, "test.db")
    db_url = f"sqlite:///{context.sle_db_path}"

    # Create schema so the DB file exists with all tables.
    engine = create_engine(db_url, echo=False)
    try:
        Base.metadata.create_all(engine)
    finally:
        engine.dispose()

    # Override the container's database_url so real DI can find the DB.
    os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url

    # Reset the module-level _service so _get_session_service() is used.
    # This direct attribute mutation is fragile — if the module's internal
    # caching mechanism changes (e.g. lazy singleton via descriptor), this
    # line will need to be updated.  See module docstring for rationale.
    session_mod._service = None

    context.sle_result = None


def _cleanup_sle(context: Context) -> None:
    """Remove temp dir, restore env, original _service, and container."""
    session_mod._service = context.sle_original_service
    os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)

    # Reset the DI container singleton to avoid polluting later scenarios.
    reset_container()
    Settings._instance = None  # type: ignore[attr-defined]

    # Restore structlog / logging state.
    prev_level, prev_cache = context.sle_log_prev
    _restore_structlog(prev_level, prev_cache)

    shutil.rmtree(context.sle_tmpdir, ignore_errors=True)


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


@given("a session-list-error CLI runner using the real DI path")
def step_session_list_error_runner(context: Context) -> None:
    _setup_real_di_path(context)


# ---------------------------------------------------------------------------
# Given - pre-populated session
# ---------------------------------------------------------------------------


@given("a session-list-error service with a pre-populated session")
def step_pre_populate_session(context: Context) -> None:
    """Insert a session directly via the repository so list has data."""
    db_url = f"sqlite:///{context.sle_db_path}"
    engine = create_engine(db_url, echo=False)
    try:
        factory = sessionmaker(bind=engine, expire_on_commit=False)
        repo = SessionRepository(session_factory=factory, auto_commit=True)
        msg_repo = SessionMessageRepository(session_factory=factory, auto_commit=True)
        svc = PersistentSessionService(repo, msg_repo)
        svc.create(actor_name="openai/gpt-4")
    finally:
        engine.dispose()


# ---------------------------------------------------------------------------
# When - list
# ---------------------------------------------------------------------------


@when("I invoke session-list-error list with default format")
def step_invoke_list_default(context: Context) -> None:
    context.sle_result = runner.invoke(session_app, ["list"])


@when('I invoke session-list-error list with format "{fmt}"')
def step_invoke_list_format(context: Context, fmt: str) -> None:
    context.sle_result = runner.invoke(session_app, ["list", "--format", fmt])


# ---------------------------------------------------------------------------
# Then - assertions
# ---------------------------------------------------------------------------


@then("the session-list-error command should exit successfully")
def step_exit_success(context: Context) -> None:
    result = context.sle_result
    assert result is not None, "No command was invoked"
    assert result.exit_code == 0, (
        f"Expected exit code 0, got {result.exit_code}.\n"
        f"Output: {result.output}\n"
        f"Exception: {result.exception!r}"
    )


@then('the session-list-error output should contain "{text}"')
def step_output_contains(context: Context, text: str) -> None:
    result = context.sle_result
    assert result is not None, "No command was invoked"
    assert text in result.output, (
        f"Expected '{text}' in output but got:\n{result.output}"
    )


@then('the session-list-error output should not contain "{text}"')
def step_output_not_contains(context: Context, text: str) -> None:
    result = context.sle_result
    assert result is not None, "No command was invoked"
    assert text not in result.output, (
        f"Did not expect '{text}' in output but found it:\n{result.output}"
    )


@then('the session-list-error output should be valid JSON containing "{key}"')
def step_output_json_key(context: Context, key: str) -> None:
    result = context.sle_result
    assert result is not None, "No command was invoked"
    try:
        parsed = json.loads(result.output)
    except json.JSONDecodeError as exc:
        raise AssertionError(f"Output is not valid JSON:\n{result.output}") from exc
    data = _unwrap_envelope(parsed)
    assert isinstance(data, dict), f"Expected JSON object, got {type(data)}: {data}"
    assert key in data, f"Key '{key}' not in JSON: {data}"
    assert isinstance(data[key], list), (
        f"Expected '{key}' to be a list, got {type(data[key])}: {data[key]}"
    )


@then('the session-list-error output should be valid YAML containing "{key}"')
def step_output_yaml_key(context: Context, key: str) -> None:
    result = context.sle_result
    assert result is not None, "No command was invoked"
    try:
        data = yaml.safe_load(result.output)
    except yaml.YAMLError as exc:
        raise AssertionError(f"Output is not valid YAML:\n{result.output}") from exc
    assert isinstance(data, dict), f"Expected YAML dict, got {type(data)}: {data}"
    assert key in data, f"Key '{key}' not in YAML: {data}"
    assert isinstance(data[key], list), (
        f"Expected '{key}' to be a list, got {type(data[key])}: {data[key]}"
    )
