"""Step definitions for the Session CLI feature."""

from __future__ import annotations

import json
import os
import re
import tempfile
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch

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

from cleveragents.application.services.session_workflow import TellResult
from cleveragents.cli.commands.session import app as session_app
from cleveragents.domain.models.core.session import (
    MessageRole,
    Session,
    SessionImportError,
    SessionMessage,
    SessionNotFoundError,
    SessionTokenUsage,
)

_SESSION_ID = str(ULID())
_SESSION_ID_2 = str(ULID())

_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


def _make_session(
    *,
    session_id: str | None = None,
    actor_name: str | None = None,
    messages: list[SessionMessage] | None = None,
) -> Session:
    """Create a test Session instance."""
    return Session(
        session_id=session_id or str(ULID()),
        actor_name=actor_name,
        namespace="local",
        messages=messages or [],
        token_usage=SessionTokenUsage(
            input_tokens=100,
            output_tokens=50,
            estimated_cost=0.005,
        ),
        created_at=datetime.now(),
        updated_at=datetime.now(),
    )


def _make_message(
    role: MessageRole = MessageRole.USER,
    content: str = "Hello",
    sequence: int = 0,
) -> SessionMessage:
    """Create a test SessionMessage."""
    return SessionMessage(
        message_id=str(ULID()),
        role=role,
        content=content,
        sequence=sequence,
        timestamp=datetime.now(),
    )


def _cleanup(context: Context) -> None:
    """Clean up temporary files."""
    for path in getattr(context, "_cleanup_paths", []):
        if os.path.exists(path):
            os.unlink(path)


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


@given("a session CLI runner with mocked service")
def step_session_cli_runner(context: Context) -> None:
    """Set up CLI runner with a mocked session service."""
    context.runner = CliRunner()
    context.mock_service = MagicMock()
    context._cleanup_paths: list[str] = []

    # Default: create returns a session
    default_session = _make_session(session_id=_SESSION_ID)
    context.mock_service.create.return_value = default_session
    # get_messages returns empty list by default (used by SessionWorkflow)
    context.mock_service.get_messages.return_value = []

    # Patch _get_session_service so tests are safe for parallel workers
    # and do not mutate the module-level _service cache (M9).
    svc_patcher = patch(
        "cleveragents.cli.commands.session._get_session_service",
        return_value=context.mock_service,
    )
    svc_patcher.start()

    # Patch _build_session_workflow to return a controllable mock workflow.
    # This lets tell-specific tests configure exact return values without
    # needing a real LLM or provider registry in the test environment.
    context.mock_workflow = MagicMock()
    _default_tell_result = TellResult(
        session_id=_SESSION_ID,
        user_message="",
        assistant_message="Acknowledged",
        input_tokens=10,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.mock_workflow.tell.return_value = _default_tell_result
    context.mock_workflow.tell_stream.return_value = iter(["Acknowledged"])

    workflow_patcher = patch(
        "cleveragents.cli.commands.session._build_session_workflow",
        return_value=context.mock_workflow,
    )
    workflow_patcher.start()

    def cleanup() -> None:
        svc_patcher.stop()
        workflow_patcher.stop()
        _cleanup(context)

    context.add_cleanup(cleanup)


# ---------------------------------------------------------------------------
# Create
# ---------------------------------------------------------------------------


@when("I run session CLI create with no arguments")
def step_create_no_args(context: Context) -> None:
    context.result = context.runner.invoke(session_app, ["create"])


@when('I run session CLI create with --actor "{actor}"')
def step_create_with_actor(context: Context, actor: str) -> None:
    session = _make_session(session_id=_SESSION_ID, actor_name=actor)
    context.mock_service.create.return_value = session
    context.result = context.runner.invoke(session_app, ["create", "--actor", actor])


@when("I run session CLI create with --format json")
def step_create_json(context: Context) -> None:
    context.result = context.runner.invoke(session_app, ["create", "--format", "json"])


@then("the session CLI create should succeed")
def step_create_succeeds(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )


# ---------------------------------------------------------------------------
# List
# ---------------------------------------------------------------------------


@given("there are no mocked sessions")
def step_no_sessions(context: Context) -> None:
    context.mock_service.list.return_value = []


@given("there are mocked existing sessions")
def step_existing_sessions(context: Context) -> None:
    sessions = [
        _make_session(
            session_id=_SESSION_ID,
            actor_name="openai/gpt-4",
            messages=[_make_message(sequence=0)],
        ),
        _make_session(session_id=_SESSION_ID_2),
    ]
    context.mock_service.list.return_value = sessions


@given("there are mocked existing named sessions")
def step_existing_named_sessions(context: Context) -> None:
    """Set up mocked sessions with names for Summary panel name-display test."""
    sessions = [
        _make_session(
            session_id=_SESSION_ID,
            actor_name="openai/gpt-4",
            messages=[_make_message(sequence=0)],
        ),
        _make_session(session_id=_SESSION_ID_2),
    ]
    sessions[0].name = "weekly-planning"
    sessions[1].name = "refactor-sprint"
    context.mock_service.list.return_value = sessions


@when("I run session CLI list")
def step_list(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["list"], env={"COLUMNS": "200"}
    )


@when("I run session CLI list with --format json")
def step_list_json(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["list", "--format", "json"], env={"COLUMNS": "200"}
    )


@then("the session CLI should show all sessions in a table")
def step_list_shows_table(context: Context) -> None:
    assert context.result.exit_code == 0
    assert "Sessions" in context.result.output


@then("the session CLI rich table should display full session ULIDs")
def step_list_rich_table_full_ulids(context: Context) -> None:
    """Verify the Rich table displays the full 26-character session ULIDs."""
    assert context.result.exit_code == 0
    output = context.result.output
    # Restrict assertion to the table region (before the Summary panel) so
    # that a regression back to 8-char truncation is not masked by the full
    # ULID appearing elsewhere (e.g. in the Summary panel).
    summary_idx = output.find("Summary")
    table_output = output[:summary_idx] if summary_idx != -1 else output
    assert _SESSION_ID in table_output, (
        f"Full ULID {_SESSION_ID} not found in Rich table output:\n"
        f"{context.result.output}"
    )
    assert _SESSION_ID_2 in table_output, (
        f"Full ULID {_SESSION_ID_2} not found in Rich table output:\n"
        f"{context.result.output}"
    )
    # Negative guard: ensure the table contains full 26-character ULIDs,
    # not the old 8-character truncated form.  A standalone 8-char prefix
    # (not as part of the full ULID) would indicate the [:8] slice was not
    # removed.
    table_without_full_ids = table_output.replace(_SESSION_ID, "").replace(
        _SESSION_ID_2, ""
    )
    assert _SESSION_ID[:8] not in table_without_full_ids, (
        f"Truncated 8-char ID {_SESSION_ID[:8]} found in Rich table output:\n"
        f"{context.result.output}"
    )
    assert _SESSION_ID_2[:8] not in table_without_full_ids, (
        f"Truncated 8-char ID {_SESSION_ID_2[:8]} found in Rich table output:\n"
        f"{context.result.output}"
    )


@then("the session CLI summary panel should contain full session ULIDs")
def step_list_summary_full_ulids(context: Context) -> None:
    """Verify the Summary panel shows full ULIDs for unnamed sessions."""
    assert context.result.exit_code == 0
    output = context.result.output
    assert "Summary" in output, f"Summary panel not found in output:\n{output}"
    # Extract the Summary panel region to avoid a false pass from the Rich
    # table also containing the same full ULIDs.
    summary_idx = output.find("Summary")
    summary_output = output[summary_idx:]
    # Both Most Recent and Oldest entries should display full ULIDs when
    # sessions are unnamed.  Asserting only one ID would allow a regression
    # that re-introduced [:8] on one fallback path while keeping the other
    # intact to pass the test undetected.
    assert _SESSION_ID in summary_output, (
        f"Full ULID {_SESSION_ID} not found in Summary panel:\n{output}"
    )
    assert _SESSION_ID_2 in summary_output, (
        f"Full ULID {_SESSION_ID_2} not found in Summary panel:\n{output}"
    )


@then("the session CLI summary panel should show session names")
def step_list_summary_shows_names(context: Context) -> None:
    """Verify the Summary panel shows session names (not ULIDs) for named sessions."""
    assert context.result.exit_code == 0
    output = context.result.output
    summary_idx = output.find("Summary")
    assert summary_idx != -1, f"Summary panel not found in output:\n{output}"
    summary_output = output[summary_idx:]
    assert "weekly-planning" in summary_output, (
        f"'weekly-planning' not found in Summary panel:\n{output}"
    )
    assert "refactor-sprint" in summary_output, (
        f"'refactor-sprint' not found in Summary panel:\n{output}"
    )
    # The Summary should NOT show the raw ULID when session names are present
    assert _SESSION_ID not in summary_output, (
        f"Full ULID {_SESSION_ID} unexpectedly found in Summary panel:\n{output}"
    )
    assert _SESSION_ID_2 not in summary_output, (
        f"Full ULID {_SESSION_ID_2} unexpectedly found in Summary panel:\n{output}"
    )


@when("I capture the first session full ULID from the output")
def step_capture_first_ulid(context: Context) -> None:
    """Parse the first session's full ULID from the output and store it
    for subsequent steps (round-trip tell test)."""
    assert context.result.exit_code == 0
    output = context.result.output
    # Restrict the search to the table region (before the Summary panel)
    # so that a ULID appearing in the Summary panel is not accidentally
    # captured as the "first" session ID.
    summary_idx = output.find("Summary")
    search_region = output[:summary_idx] if summary_idx != -1 else output
    match = re.search(r"[0-9A-HJKMNP-TV-Z]{26}", search_region)
    assert match is not None, (
        f"No 26-character ULID found in table output:\n{search_region}"
    )
    context.full_session_id = match.group()
    assert len(context.full_session_id) == 26, (
        f"Parsed ULID '{context.full_session_id}' is not 26 characters"
    )
    # Sanity check: the captured ID should match one of the known fixture IDs
    assert context.full_session_id in (_SESSION_ID, _SESSION_ID_2), (
        f"Captured ULID '{context.full_session_id}' does not match any fixture ID"
    )


@when('I run session CLI tell with the full session ID and prompt "{prompt}"')
def step_tell_with_stored_ulid(context: Context, prompt: str) -> None:
    """Run session tell using the full ULID stored from session list."""
    session_id = context.full_session_id
    # Configure mock workflow for this prompt.
    context.mock_workflow.tell.return_value = TellResult(
        session_id=session_id,
        user_message=prompt,
        assistant_message=f"Acknowledged: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", session_id, prompt],
    )


# ---------------------------------------------------------------------------
# Show
# ---------------------------------------------------------------------------


@given("there is a mocked session with messages")
def step_session_with_messages(context: Context) -> None:
    messages = [
        _make_message(MessageRole.USER, "Hello", 0),
        _make_message(MessageRole.ASSISTANT, "Hi there", 1),
    ]
    session = _make_session(
        session_id=_SESSION_ID,
        actor_name="openai/gpt-4",
        messages=messages,
    )
    context.mock_service.get.return_value = session
    context.session_id = _SESSION_ID


@when("I run session CLI show with a valid session ID")
def step_show_valid(context: Context) -> None:
    context.result = context.runner.invoke(session_app, ["show", context.session_id])


@when("I run session CLI show with --format json")
def step_show_json(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["show", context.session_id, "--format", "json"]
    )


@when("I run session CLI show with an invalid session ID")
def step_show_invalid(context: Context) -> None:
    context.mock_service.get.side_effect = SessionNotFoundError(
        "Session 'INVALID' not found"
    )
    context.result = context.runner.invoke(session_app, ["show", "INVALID"])


@then("the session CLI show should succeed")
def step_show_succeeds(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )


# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------


@given("there is a mocked session to delete")
def step_session_to_delete(context: Context) -> None:
    session = _make_session(session_id=_SESSION_ID)
    context.mock_service.get.return_value = session
    context.mock_service.delete.return_value = None
    context.session_id = _SESSION_ID


@when("I run session CLI delete with --yes")
def step_delete_yes(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["delete", context.session_id, "--yes"]
    )


@when("I run session CLI delete with --yes and --format json")
def step_delete_yes_json(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["delete", context.session_id, "--yes", "--format", "json"]
    )


@when("I run session CLI delete with --yes and --format color")
def step_delete_yes_color(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["delete", context.session_id, "--yes", "--format", "color"]
    )


@when("I run session CLI delete with a non-existent ID")
def step_delete_nonexistent(context: Context) -> None:
    context.mock_service.get.side_effect = SessionNotFoundError("Session not found")
    context.result = context.runner.invoke(
        session_app, ["delete", "NONEXISTENT", "--yes"]
    )


@then("the session CLI delete should succeed")
def step_delete_succeeds(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )


# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------


@given("there is a mocked session for export")
def step_session_for_export(context: Context) -> None:
    session = _make_session(session_id=_SESSION_ID)
    context.mock_service.get.return_value = session
    context.mock_service.export_session.return_value = session.as_export_dict()
    context.session_id = _SESSION_ID


@given("there is an existing export file")
def step_existing_export_file(context: Context) -> None:
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w") as fh:
        fh.write("{}")
    context.existing_export_path = path
    context._cleanup_paths.append(path)


@when("I run session CLI export with no output file")
def step_export_stdout(context: Context) -> None:
    context.result = context.runner.invoke(session_app, ["export", context.session_id])


@when("I run session CLI export with --output-format json")
def step_export_output_format_json(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["export", context.session_id, "--output-format", "json"]
    )


@when("I run session CLI export with --output to a temp file")
def step_export_to_file(context: Context) -> None:
    fd, path = tempfile.mkstemp(suffix=".json")
    os.close(fd)
    os.unlink(path)  # Remove so export can create it
    context.export_path = path
    context._cleanup_paths.append(path)
    context.result = context.runner.invoke(
        session_app, ["export", context.session_id, "--output", path]
    )


@when("I run session CLI export to an existing file without --force")
def step_export_no_force(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app,
        ["export", context.session_id, "--output", context.existing_export_path],
    )


@when("I run session CLI export to an existing file with --force")
def step_export_with_force(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app,
        [
            "export",
            context.session_id,
            "--output",
            context.existing_export_path,
            "--force",
        ],
    )


@when("I run session CLI export with a non-existent session ID")
def step_export_nonexistent(context: Context) -> None:
    context.mock_service.export_session.side_effect = SessionNotFoundError(
        "Session not found"
    )
    context.result = context.runner.invoke(session_app, ["export", "NONEXISTENT"])


@then("the session CLI export should succeed")
def step_export_succeeds(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )


@then("the exported file should exist")
def step_exported_file_exists(context: Context) -> None:
    assert os.path.exists(context.export_path), (
        f"Export file not found: {context.export_path}"
    )


# ---------------------------------------------------------------------------
# Import
# ---------------------------------------------------------------------------


@given("there is a valid session export file")
def step_valid_export_file(context: Context) -> None:
    session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
    export_data = session.as_export_dict()
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w") as fh:
        json.dump(export_data, fh, default=str)
    context.import_path = path
    context._cleanup_paths.append(path)

    imported = _make_session(session_id=str(ULID()), actor_name="openai/gpt-4")
    context.mock_service.import_session.return_value = imported


@given("there is a corrupt session export file")
def step_corrupt_export_file(context: Context) -> None:
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w") as fh:
        json.dump({"schema_version": "1.0", "bad": "data"}, fh)
    context.corrupt_path = path
    context._cleanup_paths.append(path)
    context.mock_service.import_session.side_effect = SessionImportError(
        "Checksum verification failed"
    )


@given("there is an invalid JSON file")
def step_invalid_json_file(context: Context) -> None:
    fd, path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(fd, "w") as fh:
        fh.write("{not valid json")
    context.invalid_json_path = path
    context._cleanup_paths.append(path)


@when("I run session CLI import with the export file")
def step_import_valid(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["import", "--input", context.import_path]
    )


@when("I run session CLI import with the export file and --format json")
def step_import_valid_json(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["import", "--input", context.import_path, "--format", "json"]
    )


@when("I run session CLI import with a non-existent file")
def step_import_nonexistent(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["import", "--input", "/tmp/nonexistent_session_file.json"]
    )


@when("I run session CLI import with the corrupt file")
def step_import_corrupt(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["import", "--input", context.corrupt_path]
    )


@when("I run session CLI import with the invalid JSON file")
def step_import_invalid_json(context: Context) -> None:
    context.result = context.runner.invoke(
        session_app, ["import", "--input", context.invalid_json_path]
    )


@then("the session CLI import should succeed")
def step_import_succeeds(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )


# ---------------------------------------------------------------------------
# Tell
# ---------------------------------------------------------------------------


@given("there is a mocked session for tell")
def step_session_for_tell(context: Context) -> None:
    # Session with actor so tell can proceed without --actor flag.
    session = _make_session(session_id=_SESSION_ID, actor_name="openai/gpt-4")
    context.mock_service.get.return_value = session
    context.mock_service.get_messages.return_value = []
    context.session_id = _SESSION_ID
    # Configure the mock workflow to return a canned TellResult.
    context.mock_workflow.tell.return_value = TellResult(
        session_id=_SESSION_ID,
        user_message="Hello, world",
        assistant_message="Acknowledged: Hello, world",
        input_tokens=10,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )


@when('I run session CLI tell with a prompt "{prompt}"')
def step_tell_prompt(context: Context, prompt: str) -> None:
    # Update the mock workflow result to reflect the current prompt.
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"Acknowledged: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, prompt],
    )


@when('I run session CLI tell with --actor "{actor}" and prompt "{prompt}"')
def step_tell_with_actor(context: Context, actor: str, prompt: str) -> None:
    # Update the mock workflow result to reflect actor and prompt.
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"[{actor}] Acknowledged: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, "--actor", actor, prompt],
    )


@when("I run session CLI tell to a non-existent session")
def step_tell_nonexistent(context: Context) -> None:
    # Configure the workflow to raise SessionNotFoundError.
    context.mock_workflow.tell.side_effect = SessionNotFoundError("Session not found")
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", "NONEXISTENT", "Hello"],
    )


@then("the session CLI tell should succeed")
def step_tell_succeeds(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )


# ---------------------------------------------------------------------------
# Generic assertions
# ---------------------------------------------------------------------------


@then('the session CLI output should contain "{text}"')
def step_output_contains(context: Context, text: str) -> None:
    assert text in context.result.output, (
        f"Expected '{text}' in output:\n{context.result.output}"
    )


@then('the session CLI output should not contain "{text}"')
def step_output_not_contains(context: Context, text: str) -> None:
    assert text not in context.result.output, (
        f"Expected '{text}' NOT in output, but it was found:\n{context.result.output}"
    )


@then("the session CLI output should be valid JSON")
def step_output_valid_json(context: Context) -> None:
    try:
        json.loads(context.result.output)
    except json.JSONDecodeError as exc:
        raise AssertionError(
            f"Output is not valid JSON:\n{context.result.output}"
        ) from exc


@then('the session CLI JSON should contain "{key}"')
def step_json_contains_key(context: Context, key: str) -> None:
    parsed = json.loads(context.result.output)
    data = _unwrap_envelope(parsed)
    assert key in data, f"Key '{key}' not found in JSON: {data}"


@then("the session CLI JSON token usage should include counts")
def step_json_show_token_usage_counts(context: Context) -> None:
    parsed = json.loads(context.result.output)
    data = _unwrap_envelope(parsed)
    token_usage = data.get("token_usage")
    assert isinstance(token_usage, dict), (
        f"token_usage missing or not an object: {token_usage}"
    )
    _assert_token_usage_counts_are_ints(token_usage)


@then("the session CLI JSON list entries should match the documented contract")
def step_json_list_contract(context: Context) -> None:
    parsed = json.loads(context.result.output)
    data = _unwrap_envelope(parsed)
    sessions = data.get("sessions")
    assert isinstance(sessions, list), f"sessions missing or not a list: {sessions}"
    for index, session in enumerate(sessions):
        assert isinstance(session, dict), (
            f"session entry at index {index} is not an object: {session!r}"
        )
        expected_keys = {"id", "name", "actor", "messages", "updated"}
        actual_keys = set(session.keys())
        missing = expected_keys - actual_keys
        extra = actual_keys - expected_keys
        assert not missing, (
            f"session entry {index} missing keys {sorted(missing)}: {session!r}"
        )
        assert not extra, (
            f"session entry {index} has undocumented keys {sorted(extra)}: {session!r}"
        )


def _assert_token_usage_counts_are_ints(token_usage: dict[str, Any]) -> None:
    input_tokens = token_usage.get("input_tokens")
    output_tokens = token_usage.get("output_tokens")
    assert isinstance(input_tokens, int), (
        f"input_tokens should be int, got {input_tokens!r}"
    )
    assert isinstance(output_tokens, int), (
        f"output_tokens should be int, got {output_tokens!r}"
    )
    assert input_tokens != "***REDACTED***", "input_tokens should not be redacted"
    assert output_tokens != "***REDACTED***", "output_tokens should not be redacted"


@then('the session CLI JSON envelope message should be "{expected}"')
def step_json_envelope_message(context: Context, expected: str) -> None:
    parsed = json.loads(context.result.output)
    assert isinstance(parsed, dict), (
        f"Expected envelope dict, got {type(parsed)}: {context.result.output}"
    )
    messages = parsed.get("messages")
    assert isinstance(messages, list), (
        f"Expected 'messages' list, got {type(messages)}: {messages}"
    )
    assert messages, "Envelope messages should not be empty"
    first = messages[0]
    assert isinstance(first, dict), f"Message entry should be dict, got {type(first)}"
    actual = first.get("text")
    assert actual == expected, (
        f"Expected message text '{expected}', got '{actual}' in {messages}"
    )


@then("the session CLI should exit with error")
def step_exit_with_error(context: Context) -> None:
    assert context.result.exit_code != 0, (
        f"Expected non-zero exit code, got {context.result.exit_code}: "
        f"{context.result.output}"
    )


# ---------------------------------------------------------------------------
# Tell format flag step definitions (issue #10466)
# ---------------------------------------------------------------------------


@when('I run session CLI tell with --format json and prompt "{prompt}"')
def step_tell_with_format_json(context: Context, prompt: str) -> None:
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"Response: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, "--format", "json", prompt],
    )


@when('I run session CLI tell with --format yaml and prompt "{prompt}"')
def step_tell_with_format_yaml(context: Context, prompt: str) -> None:
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"Response: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, "--format", "yaml", prompt],
    )


@when('I run session CLI tell with --format plain and prompt "{prompt}"')
def step_tell_with_format_plain(context: Context, prompt: str) -> None:
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"Response: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, "--format", "plain", prompt],
    )


@when('I run session CLI tell with --format table and prompt "{prompt}"')
def step_tell_with_format_table(context: Context, prompt: str) -> None:
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"Response: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, "--format", "table", prompt],
    )


@when('I run session CLI tell with short format flag {fmt} and prompt "{prompt}"')
def step_tell_with_short_format_flag(context: Context, fmt: str, prompt: str) -> None:
    """Run tell with the -f shorthand for --format."""
    context.mock_workflow.tell.return_value = TellResult(
        session_id=context.session_id,
        user_message=prompt,
        assistant_message=f"Response: {prompt}",
        input_tokens=len(prompt) // 4,
        output_tokens=5,
        cost=0.0,
        duration_ms=0.0,
        tool_calls_count=0,
    )
    context.result = context.runner.invoke(
        session_app,
        ["tell", "--session", context.session_id, "-f", fmt, prompt],
    )


@then("the session CLI tell JSON should contain a data envelope")
def step_tell_json_has_envelope(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )
    parsed = json.loads(context.result.output)
    envelope_keys = {"command", "status", "exit_code", "data", "timing", "messages"}
    assert envelope_keys.issubset(parsed.keys()), (
        f"JSON output missing envelope keys: {envelope_keys - set(parsed.keys())}. "
        f"Got keys: {parsed.keys()}"
    )


@then("the session CLI tell output should be valid YAML")
def step_tell_output_valid_yaml(context: Context) -> None:
    import yaml

    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )
    try:
        yaml.safe_load(context.result.output)
    except yaml.YAMLError as exc:
        raise AssertionError(f"Output is not valid YAML: {exc}") from exc


@then('the session CLI tell output should contain "session_id:"')
def step_tell_plain_contains_session_id(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )
    assert "session_id:" in context.result.output, (
        f"Expected 'session_id:' in plain output, got: {context.result.output}"
    )


@then("the session CLI tell output should contain a table border character")
def step_tell_table_has_border(context: Context) -> None:
    assert context.result.exit_code == 0, (
        f"Expected exit code 0, got {context.result.exit_code}: {context.result.output}"
    )
    # ASCII table uses border characters like | and +-+
    has_border = "|" in context.result.output or "+" in context.result.output
    assert has_border, (
        f"Expected table border character in output, got: {context.result.output}"
    )
