"""Shared step definitions for TDD session DI bug tests.

Steps in this module are used by both ``tdd_session_create_di.feature`` and
``tdd_session_list_di.feature``.  Behave loads all step files globally, so
placing shared steps here avoids duplication and satisfies the CONTRIBUTING.md
rule that feature-specific steps live in matching ``*_steps.py`` files while
shared steps live in clearly named reusable modules.
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
from pathlib import Path

from behave import given, then
from behave.runner import Context
from typer.testing import CliRunner

from cleveragents.application.container import reset_container
from cleveragents.cli.commands import session as session_mod
from cleveragents.config.settings import Settings


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

    When ``behave-parallel`` runs multiple features in one process,
    another feature may (re)configure structlog with a ``ConsoleRenderer``
    or leave it at the default ``PrintLogger`` — both write to
    ``sys.stdout``.  The ``@database_retry`` decorator logs a *debug*
    event on every attempt, and ``CliRunner.invoke()`` captures
    ``sys.stdout``, so those debug lines end up in the captured output,
    breaking JSON/YAML assertions.

    Returns the previous root-logger level and ``cache_logger_on_first_use``
    flag so that the caller's cleanup function can restore them.
    """
    import structlog

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

    # Force structlog to route through stdlib logging (not PrintLogger)
    # and disable logger caching so the level change takes effect for
    # loggers that were already instantiated.
    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)


@given("a CLI runner using the real session DI path")
def step_real_di_runner(context: Context) -> None:
    """Set up a CLI runner that does NOT mock the session service.

    By ensuring ``session_mod._service`` is ``None``, the CLI will call
    ``_get_session_service()`` which hits the real DI container and
    resolves the ``session_service`` provider.
    """
    context.runner = CliRunner()

    # Ensure we go through the real DI path — no mock service.
    session_mod._service = None

    # Reset singletons before setup so stale state from a parallel
    # scenario does not leak in.
    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()

    # Provide a database URL so the container can be constructed.
    fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(fd)
    context._tdd_db_path = db_path

    db_url = f"sqlite:///{db_path}"
    os.environ["CLEVERAGENTS_DATABASE_URL"] = db_url

    # Create the schema so the database file is ready for use.
    from sqlalchemy import create_engine

    from cleveragents.infrastructure.database.models import Base

    engine = create_engine(db_url, echo=False)
    try:
        Base.metadata.create_all(engine)
    finally:
        engine.dispose()

    def _cleanup() -> None:
        session_mod._service = None
        os.environ.pop("CLEVERAGENTS_DATABASE_URL", None)
        reset_container()
        # Reset the Settings singleton so stale database URLs from this
        # scenario do not leak into subsequent scenarios.
        Settings._instance = None  # type: ignore[attr-defined]
        Path(db_path).unlink(missing_ok=True)
        _restore_structlog(prev_level, prev_cache)

    context.add_cleanup(_cleanup)


@then("the session {subcommand} command should exit successfully")
def step_session_exits_ok(context: Context, subcommand: str) -> None:
    """Assert the command exits with code 0."""
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}.\n"
        f"Output:\n{context.result.output}"
    )


@then("the session {subcommand} output should be valid JSON")
def step_session_output_json(context: Context, subcommand: str) -> None:
    """Assert the output is parseable JSON."""
    output = context.result.output
    # Strip ANSI color codes and MCP health check warning that may prepend to JSON
    output = output.lstrip()
    lines = output.split("\n")
    # Skip leading lines that are not JSON (like MCP warnings)
    json_start_idx = 0
    for i, line in enumerate(lines):
        stripped = line.strip()
        if stripped.startswith("{") or stripped.startswith("["):
            json_start_idx = i
            break
    output = "\n".join(lines[json_start_idx:])

    try:
        json.loads(output)
    except json.JSONDecodeError as exc:
        raise AssertionError(
            f"Output is not valid JSON:\n{context.result.output}"
        ) from exc


@then("the session create JSON response should match the session create spec")
def step_session_create_matches_spec(context: Context) -> None:
    """Assert the session create JSON envelope matches the specification."""

    raw_output = context.result.output
    json_start = raw_output.find("{")
    assert json_start >= 0, f"No JSON payload in output: {raw_output!r}"
    try:
        payload = json.loads(raw_output[json_start:])
    except json.JSONDecodeError as exc:  # pragma: no cover - defensive assertion
        raise AssertionError(
            f"Session create output is not valid JSON:\n{raw_output}"
        ) from exc

    expected_command = "agents session create --actor local/orchestrator --format json"
    assert payload.get("command") == expected_command, (
        f"Expected command '{expected_command}', got {payload.get('command')!r}"
    )
    assert payload.get("status") == "ok", payload
    assert payload.get("exit_code") == 0, payload

    timing = payload.get("timing")
    assert isinstance(timing, dict), f"timing not dict: {timing!r}"
    duration = timing.get("duration_ms")
    assert isinstance(duration, int) and duration >= 0, (
        f"duration_ms invalid: {duration!r}"
    )

    messages = payload.get("messages")
    assert isinstance(messages, list) and messages, messages
    assert any(msg.get("text") == "Session created" for msg in messages), messages

    data = payload.get("data")
    assert isinstance(data, dict), f"data not dict: {data!r}"

    session_block = data.get("session")
    assert isinstance(session_block, dict), f"session block missing: {session_block!r}"
    session_id = session_block.get("id")
    assert isinstance(session_id, str) and len(session_id) == 26, session_block
    assert session_block.get("actor") == "local/orchestrator", (
        f"Unexpected actor: {session_block.get('actor')!r}"
    )
    assert session_block.get("namespace") == "local", session_block
    created = session_block.get("created")
    assert isinstance(created, str) and created, session_block

    settings = data.get("settings")
    expected_settings = {
        "automation": "review",
        "streaming": "off",
        "context": "default",
        "memory": "enabled",
        "max_history": 50,
    }
    assert settings == expected_settings, f"Settings mismatch: {settings!r}"

    actor_details = data.get("actor_details")
    assert isinstance(actor_details, dict) and actor_details, actor_details
    assert (
        isinstance(actor_details.get("provider"), str) and actor_details["provider"]
    ), actor_details
    assert isinstance(actor_details.get("model"), str) and actor_details["model"], (
        actor_details
    )
