"""Step definitions for session_cli_coverage_boost.feature.

These steps target specific uncovered lines in session.py:
- Lines 156-162: create command DatabaseError handler
- Lines 184-190: list_sessions command DatabaseError handler
- Lines 306-317: show command cost_budget display (with max_cost and unlimited)
- Lines 134-153: create command happy path (rich and json formats)
- Lines 192-200: list_sessions empty sessions path
- Lines 205-231: list_sessions with sessions (rich table + summary)
- Lines 254-303: show command rich output (messages, plans, token usage)
- Lines 327-336: show command error handlers
- Lines 362-382: delete command paths
- Lines 410-441: export command paths
- Lines 459-492: import command paths
- Lines 495-567: tell command paths (stream, no stream, actor override)
"""

from __future__ import annotations

import json
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

from behave import given, then, when
from typer.testing import CliRunner

from cleveragents.cli.commands.session import app as session_app
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.cost_budget import SessionCostBudget
from cleveragents.domain.models.core.session import (
    MessageRole,
    Session,
    SessionExportError,
    SessionImportError,
    SessionMessage,
    SessionNotFoundError,
    SessionService,
    SessionTokenUsage,
)

_runner = CliRunner()

# Valid ULIDs for tests (Crockford base32, 26 chars)
_ULID1 = "01SCVBST000000000000000001"
_ULID2 = "01SCVBST000000000000000002"
_ULID3 = "01SCVBST000000000000000003"
_PLAN_ID1 = "01PLANID0000000000000000A1"
_PLAN_ID2 = "01PLANID0000000000000000A2"
_NOW = datetime(2025, 7, 1, 10, 30, 0)


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


def _make_session(
    *,
    session_id: str = _ULID1,
    actor_name: str | None = None,
    namespace: str = "local",
    messages: list[SessionMessage] | None = None,
    linked_plan_ids: list[str] | None = None,
    token_usage: SessionTokenUsage | None = None,
    metadata: dict[str, Any] | None = None,
    cost_budget: SessionCostBudget | None = None,
) -> Session:
    return Session(
        session_id=session_id,
        actor_name=actor_name,
        namespace=namespace,
        messages=messages or [],
        linked_plan_ids=linked_plan_ids or [],
        token_usage=token_usage or SessionTokenUsage(),
        created_at=_NOW,
        updated_at=_NOW,
        metadata=metadata or {},
        cost_budget=cost_budget,
    )


def _make_message(
    content: str = "hello world",
    role: MessageRole = MessageRole.USER,
    sequence: int = 0,
) -> SessionMessage:
    return SessionMessage(
        message_id=_ULID2,
        role=role,
        content=content,
        sequence=sequence,
        timestamp=_NOW,
    )


def _mock_service() -> MagicMock:
    return MagicMock(spec=SessionService)


def _patch_service(context, svc):
    patcher = patch("cleveragents.cli.commands.session._service", svc)
    patcher.start()
    context._scvbst_cleanups.append(patcher.stop)


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


@given("the session CLI coverage boost module is set up")
def step_background(context):
    context._scvbst_cleanups = []

    def cleanup():
        for fn in reversed(context._scvbst_cleanups):
            fn()

    context.add_cleanup(cleanup)


# ---------------------------------------------------------------------------
# create: happy path rich
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that creates a session")
def step_create_service(context):
    svc = _mock_service()
    session = _make_session(actor_name="openai/gpt-4")
    svc.create.return_value = session
    _patch_service(context, svc)


@when("session coverage boost I invoke the create command with rich format")
def step_invoke_create_rich(context):
    context.result = _runner.invoke(session_app, ["create", "--format", "rich"])


@when("session coverage boost I invoke the create command with json format")
def step_invoke_create_json(context):
    context.result = _runner.invoke(session_app, ["create", "--format", "json"])


# ---------------------------------------------------------------------------
# create: DatabaseError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises DatabaseError on create")
def step_create_db_error(context):
    svc = _mock_service()
    svc.create.side_effect = DatabaseError("connection refused")
    _patch_service(context, svc)


# ---------------------------------------------------------------------------
# list: happy path with sessions
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that lists sessions")
def step_list_service(context):
    svc = _mock_service()
    sessions = [
        _make_session(session_id=_ULID1, actor_name="openai/gpt-4"),
        _make_session(session_id=_ULID2, actor_name=None),
    ]
    # Give the second session some messages for message_count variety
    sessions[0].messages = [_make_message(content="msg1", sequence=0)]
    svc.list.return_value = sessions
    _patch_service(context, svc)


@when("session coverage boost I invoke the list command with rich format")
def step_invoke_list_rich(context):
    context.result = _runner.invoke(session_app, ["list", "--format", "rich"])


@when("session coverage boost I invoke the list command with json format")
def step_invoke_list_json(context):
    context.result = _runner.invoke(session_app, ["list", "--format", "json"])


# ---------------------------------------------------------------------------
# list: empty sessions
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that lists empty sessions")
def step_list_empty_service(context):
    svc = _mock_service()
    svc.list.return_value = []
    _patch_service(context, svc)


# ---------------------------------------------------------------------------
# list: DatabaseError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises DatabaseError on list")
def step_list_db_error(context):
    svc = _mock_service()
    svc.list.side_effect = DatabaseError("disk full")
    _patch_service(context, svc)


# ---------------------------------------------------------------------------
# show: cost budget with max cost
# ---------------------------------------------------------------------------


@given(
    "session coverage boost a mock service returning a session with cost budget max 10"
)
def step_show_cost_budget_max(context):
    svc = _mock_service()
    budget = SessionCostBudget(max_cost_usd=10.0, total_cost=3.5)
    session = _make_session(
        cost_budget=budget,
        token_usage=SessionTokenUsage(
            input_tokens=100, output_tokens=50, estimated_cost=0.05
        ),
    )
    svc.get.return_value = session
    _patch_service(context, svc)
    context.scvbst_session_id = _ULID1


@when("session coverage boost I invoke the show command")
def step_invoke_show(context):
    sid = getattr(context, "scvbst_session_id", _ULID1)
    context.result = _runner.invoke(session_app, ["show", sid])


# ---------------------------------------------------------------------------
# show: cost budget unlimited
# ---------------------------------------------------------------------------


@given(
    "session coverage boost a mock service returning a session with unlimited cost budget"
)
def step_show_cost_budget_unlimited(context):
    svc = _mock_service()
    budget = SessionCostBudget(max_cost_usd=None, total_cost=1.23)
    session = _make_session(
        cost_budget=budget,
        token_usage=SessionTokenUsage(
            input_tokens=200, output_tokens=100, estimated_cost=0.1
        ),
    )
    svc.get.return_value = session
    _patch_service(context, svc)
    context.scvbst_session_id = _ULID1


# ---------------------------------------------------------------------------
# show: with messages and linked plans
# ---------------------------------------------------------------------------


@given(
    "session coverage boost a mock service returning a session with messages and plans"
)
def step_show_messages_plans(context):
    svc = _mock_service()
    msgs = [
        _make_message(content="Hello there", role=MessageRole.USER, sequence=0),
        SessionMessage(
            message_id=_ULID3,
            role=MessageRole.ASSISTANT,
            content="Hi! How can I help?",
            sequence=1,
            timestamp=_NOW,
        ),
    ]
    session = _make_session(
        messages=msgs,
        linked_plan_ids=[_PLAN_ID1, _PLAN_ID2],
        actor_name="openai/gpt-4",
        token_usage=SessionTokenUsage(
            input_tokens=500, output_tokens=300, estimated_cost=0.15
        ),
    )
    svc.get.return_value = session
    _patch_service(context, svc)
    context.scvbst_session_id = _ULID1


@when("session coverage boost I invoke the show command with json format")
def step_invoke_show_json(context):
    sid = getattr(context, "scvbst_session_id", _ULID1)
    context.result = _runner.invoke(session_app, ["show", sid, "--format", "json"])


# ---------------------------------------------------------------------------
# show: SessionNotFoundError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises SessionNotFoundError on get")
def step_show_not_found(context):
    svc = _mock_service()
    svc.get.side_effect = SessionNotFoundError("no such session")
    _patch_service(context, svc)
    context.scvbst_session_id = _ULID1


# ---------------------------------------------------------------------------
# show: DatabaseError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises DatabaseError on get")
def step_show_db_error(context):
    svc = _mock_service()
    svc.get.side_effect = DatabaseError("connection timeout")
    _patch_service(context, svc)
    context.scvbst_session_id = _ULID1


# ---------------------------------------------------------------------------
# show: long message truncation
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service returning a session with a long message")
def step_show_long_message(context):
    svc = _mock_service()
    long_content = "X" * 120  # > 80 chars, triggers truncation
    msgs = [_make_message(content=long_content, sequence=0)]
    session = _make_session(messages=msgs)
    svc.get.return_value = session
    _patch_service(context, svc)
    context.scvbst_session_id = _ULID1


# ---------------------------------------------------------------------------
# delete: happy path with --yes
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service for delete")
def step_delete_service(context):
    svc = _mock_service()
    session = _make_session()
    svc.get.return_value = session
    svc.delete.return_value = None
    _patch_service(context, svc)


@when("session coverage boost I invoke the delete command with yes")
def step_invoke_delete_yes(context):
    context.result = _runner.invoke(session_app, ["delete", _ULID1, "--yes"])


@when("session coverage boost I invoke the delete command without yes answering no")
def step_invoke_delete_no(context):
    context.result = _runner.invoke(session_app, ["delete", _ULID1], input="n\n")


# ---------------------------------------------------------------------------
# delete: DatabaseError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises DatabaseError on delete")
def step_delete_db_error(context):
    svc = _mock_service()
    session = _make_session()
    svc.get.return_value = session
    svc.delete.side_effect = DatabaseError("lock timeout")
    _patch_service(context, svc)


# ---------------------------------------------------------------------------
# export: happy path
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service for export")
def step_export_service(context):
    svc = _mock_service()
    svc.export_session.return_value = {
        "schema_version": 1,
        "session_id": _ULID1,
        "actor_name": None,
        "messages": [],
        "checksum": "abc123",
    }
    _patch_service(context, svc)


@when("session coverage boost I invoke the export command to stdout")
def step_invoke_export_stdout(context):
    context.result = _runner.invoke(session_app, ["export", _ULID1])


@given("session coverage boost a temporary directory for export")
def step_export_tmpdir(context):
    tmpdir = tempfile.mkdtemp()
    context.scvbst_export_path = Path(tmpdir) / "export.json"
    context._scvbst_cleanups.append(
        lambda: context.scvbst_export_path.unlink(missing_ok=True)
    )


@when("session coverage boost I invoke the export command to a file")
def step_invoke_export_file(context):
    context.result = _runner.invoke(
        session_app,
        ["export", _ULID1, "--output", str(context.scvbst_export_path)],
    )


@given("session coverage boost a temporary directory with existing file")
def step_export_existing_file(context):
    tmpdir = tempfile.mkdtemp()
    context.scvbst_export_path = Path(tmpdir) / "export.json"
    context.scvbst_export_path.write_text("{}")
    context._scvbst_cleanups.append(
        lambda: context.scvbst_export_path.unlink(missing_ok=True)
    )


@when(
    "session coverage boost I invoke the export command to existing file without force"
)
def step_invoke_export_no_force(context):
    context.result = _runner.invoke(
        session_app,
        ["export", _ULID1, "--output", str(context.scvbst_export_path)],
    )


# ---------------------------------------------------------------------------
# export: errors
# ---------------------------------------------------------------------------


@given(
    "session coverage boost a mock service that raises SessionNotFoundError on export"
)
def step_export_not_found(context):
    svc = _mock_service()
    svc.export_session.side_effect = SessionNotFoundError("gone")
    _patch_service(context, svc)


@given("session coverage boost a mock service that raises SessionExportError on export")
def step_export_error(context):
    svc = _mock_service()
    svc.export_session.side_effect = SessionExportError("checksum fail")
    _patch_service(context, svc)


@given("session coverage boost a mock service that raises DatabaseError on export")
def step_export_db_error(context):
    svc = _mock_service()
    svc.export_session.side_effect = DatabaseError("db locked")
    _patch_service(context, svc)


# ---------------------------------------------------------------------------
# import: happy path
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service for import")
def step_import_service(context):
    svc = _mock_service()
    session = _make_session(actor_name="test/actor")
    svc.import_session.return_value = session
    _patch_service(context, svc)


@given("session coverage boost a temporary import file with valid JSON")
def step_import_valid_json(context):
    tmpdir = tempfile.mkdtemp()
    context.scvbst_import_path = Path(tmpdir) / "import.json"
    data = {
        "schema_version": 1,
        "session_id": _ULID1,
        "actor_name": "test/actor",
        "messages": [],
    }
    context.scvbst_import_path.write_text(json.dumps(data))
    context._scvbst_cleanups.append(
        lambda: context.scvbst_import_path.unlink(missing_ok=True)
    )


@when("session coverage boost I invoke the import command")
def step_invoke_import(context):
    context.result = _runner.invoke(
        session_app,
        ["import", "--input", str(context.scvbst_import_path)],
    )


# ---------------------------------------------------------------------------
# import: file not found
# ---------------------------------------------------------------------------


@when("session coverage boost I invoke the import command with missing file")
def step_invoke_import_missing(context):
    context.result = _runner.invoke(
        session_app,
        ["import", "--input", "/nonexistent/path/session.json"],
    )


# ---------------------------------------------------------------------------
# import: invalid JSON
# ---------------------------------------------------------------------------


@given("session coverage boost a temporary import file with invalid JSON")
def step_import_invalid_json(context):
    tmpdir = tempfile.mkdtemp()
    context.scvbst_import_invalid_path = Path(tmpdir) / "bad.json"
    context.scvbst_import_invalid_path.write_text("{not valid json!!!")
    context._scvbst_cleanups.append(
        lambda: context.scvbst_import_invalid_path.unlink(missing_ok=True)
    )


@when("session coverage boost I invoke the import command with invalid json")
def step_invoke_import_invalid(context):
    context.result = _runner.invoke(
        session_app,
        ["import", "--input", str(context.scvbst_import_invalid_path)],
    )


# ---------------------------------------------------------------------------
# import: SessionImportError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises SessionImportError on import")
def step_import_error(context):
    svc = _mock_service()
    svc.import_session.side_effect = SessionImportError("schema mismatch")
    _patch_service(context, svc)


@when("session coverage boost I invoke the import command expecting import error")
def step_invoke_import_error(context):
    context.result = _runner.invoke(
        session_app,
        ["import", "--input", str(context.scvbst_import_path)],
    )


# ---------------------------------------------------------------------------
# import: DatabaseError
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service that raises DatabaseError on import")
def step_import_db_error(context):
    svc = _mock_service()
    svc.import_session.side_effect = DatabaseError("import db fail")
    _patch_service(context, svc)


@when("session coverage boost I invoke the import command expecting database error")
def step_invoke_import_db_error(context):
    context.result = _runner.invoke(
        session_app,
        ["import", "--input", str(context.scvbst_import_path)],
    )


# ---------------------------------------------------------------------------
# tell: happy path without stream
# ---------------------------------------------------------------------------


@given("session coverage boost a mock service for tell")
def step_tell_service(context):
    svc = _mock_service()
    svc.append_message.return_value = None
    _patch_service(context, svc)


@when("session coverage boost I invoke the tell command without stream")
def step_invoke_tell_no_stream(context):
    context.result = _runner.invoke(
        session_app,
        ["tell", "--session", _ULID1, "Hello world"],
    )


@when("session coverage boost I invoke the tell command with stream")
def step_invoke_tell_stream(context):
    context.result = _runner.invoke(
        session_app,
        ["tell", "--session", _ULID1, "--stream", "Hello stream"],
    )


@when("session coverage boost I invoke the tell command with actor override")
def step_invoke_tell_actor(context):
    context.result = _runner.invoke(
        session_app,
        [
            "tell",
            "--session",
            _ULID1,
            "--actor",
            "openai/gpt-4",
            "Plan a feature",
        ],
    )


# ---------------------------------------------------------------------------
# tell: errors
# ---------------------------------------------------------------------------


@given(
    "session coverage boost a mock service that raises SessionNotFoundError on append"
)
def step_tell_not_found(context):
    svc = _mock_service()
    svc.append_message.side_effect = SessionNotFoundError("no session")
    _patch_service(context, svc)


@given("session coverage boost a mock service that raises DatabaseError on append")
def step_tell_db_error(context):
    svc = _mock_service()
    svc.append_message.side_effect = DatabaseError("tell db fail")
    _patch_service(context, svc)


# ---------------------------------------------------------------------------
# Debug log verification steps (for _log.debug call consistency checks)
# ---------------------------------------------------------------------------


@when("session coverage boost I invoke the export command to stdout with log capture")
def step_invoke_export_stdout_log_capture(context):

    with patch("cleveragents.cli.commands.session._log") as mock_log:
        context.result = _runner.invoke(session_app, ["export", _ULID1])
        context.scvbst_mock_log = mock_log


@when(
    "session coverage boost I invoke the import command expecting database error with log capture"
)
def step_invoke_import_db_error_log_capture(context):
    with patch("cleveragents.cli.commands.session._log") as mock_log:
        context.result = _runner.invoke(
            session_app,
            ["import", "--input", str(context.scvbst_import_path)],
        )
        context.scvbst_mock_log = mock_log


@when(
    "session coverage boost I invoke the tell command without stream with log capture"
)
def step_invoke_tell_no_stream_log_capture(context):
    with patch("cleveragents.cli.commands.session._log") as mock_log:
        context.result = _runner.invoke(
            session_app,
            ["tell", "--session", _ULID1, "Hello world"],
        )
        context.scvbst_mock_log = mock_log


@then('session coverage boost the debug log "{message}" was emitted')
def step_assert_debug_log_emitted(context, message):
    mock_log = context.scvbst_mock_log
    calls = mock_log.debug.call_args_list
    messages = [str(c.args[0]) if c.args else "" for c in calls]
    assert any(message in m for m in messages), (
        f"Expected _log.debug called with '{message}'. Actual debug calls: {calls}"
    )


# ---------------------------------------------------------------------------
# Shared assertions
# ---------------------------------------------------------------------------


@then("session coverage boost the exit code is {code:d}")
def step_assert_exit_code(context, code):
    assert context.result.exit_code == code, (
        f"Expected exit code {code}, got {context.result.exit_code}. "
        f"Output:\n{context.result.output}"
    )


@then('session coverage boost the output contains "{text}"')
def step_assert_output_contains(context, text):
    combined = context.result.output
    assert text in combined, f"Expected '{text}' in output. Got:\n{combined}"


@then("session coverage boost the output contains truncated message indicator")
def step_assert_truncated(context):
    """Check that long message content was truncated (by code or Rich)."""
    combined = context.result.output
    # The code truncates to content[:77] + "..." but Rich table may further
    # truncate with a Unicode ellipsis "…".  Either indicates truncation.
    assert "..." in combined or "\u2026" in combined, (
        f"Expected truncation indicator ('...' or '…') in output. Got:\n{combined}"
    )
