"""Tests for the Codex adapter.

Q1 is resolved: `codex exec --json` (codex-cli 0.145.0) reports token usage and
does not report cost. These tests are written against a stream captured from
the real binary, plus the degradation cases — the adapter must report
"unmeasured" rather than "zero", because zero is a number and downstream code
will treat it as one.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from ablate.agents import ADAPTERS
from ablate.agents.base import TrialSpec
from ablate.agents.codex import CodexAdapter, parse_events
from ablate.record import AssertionRecord, TelemetryRecord, TrialRecord
from ablate.report.analysis import analyze


def _stream(*events: dict) -> str:
    return "\n".join(json.dumps(e) for e in events) + "\n"


#: Captured verbatim from `codex exec --json "say ok"`, codex-cli 0.145.0,
#: 2026-07-27. The chatter line and the benign error item are both real.
REAL_STREAM = """Reading additional input from stdin...
{"type":"thread.started","thread_id":"019fa2b5-3bc5-7471-93bf-36092f972413"}
{"type":"turn.started"}
{"type":"item.completed","item":{"id":"item_0","type":"error","message":"Skill \
descriptions were shortened to fit the 2% skills context budget."}}
{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"ok"}}
{"type":"turn.completed","usage":{"input_tokens":16138,"cached_input_tokens":5504,\
"cache_write_input_tokens":0,"output_tokens":5,"reasoning_output_tokens":0}}
"""


# --- against the real captured stream ---------------------------------------


def test_real_stream_parses_completely():
    t = parse_events(REAL_STREAM)
    assert t.turns == 1
    assert t.input_tokens == 16138
    assert t.cache_read == 5504
    assert t.cache_write == 0
    assert t.output_tokens == 5
    assert t.raw_result == "ok"
    assert t.session_id == "019fa2b5-3bc5-7471-93bf-36092f972413"


def test_a_routine_error_item_does_not_fail_the_trial():
    """codex emits a skills-budget notice as `item.type == "error"` on every
    run. Treating error items as fatal would have marked every Codex trial
    failed -- a bug that would have looked like a config effect."""
    assert parse_events(REAL_STREAM).error is None


def test_reasoning_tokens_count_as_output():
    """They are billed as output and reported separately, so dropping them
    would understate the primary metric on every trial."""
    stream = _stream(
        {"type": "turn.completed",
         "usage": {"output_tokens": 5, "reasoning_output_tokens": 100}}
    )
    assert parse_events(stream).output_tokens == 105


def test_agent_message_and_error_items_are_not_tool_calls():
    assert parse_events(REAL_STREAM).tool_calls == 0


def test_real_tool_items_are_counted():
    stream = _stream(
        {"type": "item.completed", "item": {"type": "command_execution"}},
        {"type": "item.completed", "item": {"type": "file_change"}},
        {"type": "item.completed", "item": {"type": "agent_message", "text": "done"}},
        {"type": "turn.completed", "usage": {"input_tokens": 1}},
    )
    assert parse_events(stream).tool_calls == 2


def test_a_stream_with_no_completed_turn_is_a_failure():
    """The process may exit 0 having done nothing."""
    stream = _stream({"type": "thread.started"}, {"type": "turn.started"})
    assert parse_events(stream).error is not None


def test_cost_is_absent_because_codex_does_not_report_it():
    """Q1's answer. cost_usd stays None so --max-spend reports that it could
    not account for these trials rather than treating them as free."""
    assert parse_events(REAL_STREAM).cost_usd is None


def _spec(tmp_path: Path, config: str | None = "# Conventions\n") -> TrialSpec:
    return TrialSpec(prompt="fix the bug", cwd=tmp_path, config_variant_text=config)


# --- registration -----------------------------------------------------------


def test_codex_is_a_registered_agent():
    assert ADAPTERS["codex"] is CodexAdapter
    assert CodexAdapter.name == "codex"


# --- command ----------------------------------------------------------------


def test_command_uses_exec_json(tmp_path: Path):
    cmd = CodexAdapter().build_command(_spec(tmp_path))
    assert cmd[:3] == ["codex", "exec", "--json"]


def test_command_carries_no_working_directory_flag(tmp_path: Path):
    """Q1 could not confirm --cd/-C exists; the subprocess cwd is used instead
    and is more robust anyway (ADR-0003)."""
    cmd = CodexAdapter().build_command(_spec(tmp_path))
    assert "--cd" not in cmd and "-C" not in cmd


def test_config_is_delivered_in_the_prompt(tmp_path: Path):
    cmd = CodexAdapter().build_command(_spec(tmp_path, "# Conventions\n\nUse snake_case.\n"))
    assert "Use snake_case." in cmd[-1]
    assert "fix the bug" in cmd[-1]


def test_no_config_leaves_the_prompt_alone(tmp_path: Path):
    assert CodexAdapter().build_command(_spec(tmp_path, None))[-1] == "fix the bug"


def test_model_is_passed_through(tmp_path: Path):
    spec = TrialSpec(
        prompt="p", cwd=tmp_path, config_variant_text=None, model="gpt-5-codex"
    )
    cmd = CodexAdapter().build_command(spec)
    assert "--model" in cmd and "gpt-5-codex" in cmd


# --- parsing, with usage reported -------------------------------------------


def test_turns_are_counted():
    stream = _stream(
        {"type": "turn.completed"},
        {"type": "turn.completed"},
        {"type": "turn.completed"},
    )
    assert parse_events(stream).turns == 3


def test_usage_is_read_when_present():
    stream = _stream(
        {"type": "turn.completed", "usage": {"input_tokens": 100, "output_tokens": 20}}
    )
    telemetry = parse_events(stream)
    assert telemetry.input_tokens == 100
    assert telemetry.output_tokens == 20


def test_usage_is_found_at_any_depth():
    """The event schema is undocumented and has moved before, so the parser
    looks for the shape rather than a fixed path."""
    stream = _stream(
        {"type": "turn.completed", "turn": {"info": {"prompt_tokens": 42, "completion_tokens": 7}}}
    )
    telemetry = parse_events(stream)
    assert telemetry.input_tokens == 42
    assert telemetry.output_tokens == 7


def test_alternate_field_spellings_are_accepted():
    stream = _stream(
        {"type": "turn.completed", "usage": {"cached_input_tokens": 900, "prompt_tokens": 10}}
    )
    assert parse_events(stream).cache_read == 900


def test_cost_is_used_when_codex_reports_it():
    stream = _stream({"type": "turn.completed", "usage": {"input_tokens": 5, "cost_usd": 0.42}})
    assert parse_events(stream).cost_usd == 0.42


# --- parsing, with nothing reported (Q1's pessimistic case) -----------------


def test_stdin_is_closed_so_codex_does_not_block(tmp_path: Path):
    """`codex exec` reads stdin; left attached it waits forever."""
    import inspect

    from ablate.agents import codex

    assert "stdin=subprocess.DEVNULL" in inspect.getsource(codex.CodexAdapter.invoke)


def test_missing_usage_yields_no_cost_rather_than_zero():
    """--max-spend cannot budget against an agent that reports no cost, and
    coercing None to 0 would silently disable the ceiling."""
    telemetry = parse_events(_stream({"type": "turn.completed"}))
    assert telemetry.cost_usd is None
    assert telemetry.input_tokens == 0


def test_cost_is_never_derived_from_token_counts():
    """Q1: do not print an estimated dollar figure for Codex. A ceiling
    enforced against a guess is worse than none, because it looks like it is
    working."""
    stream = _stream(
        {"type": "turn.completed", "usage": {"input_tokens": 1_000_000, "output_tokens": 500_000}}
    )
    assert parse_events(stream).cost_usd is None


def test_turns_survive_even_with_no_usage():
    """Turn count is a real measurement either way, so a Codex arm always has
    some usable continuous metric."""
    telemetry = parse_events(_stream({"type": "turn.completed"}, {"type": "turn.completed"}))
    assert telemetry.turns == 2


def test_unreported_tokens_are_excluded_from_the_metric_not_read_as_zero():
    """The whole point: an unmeasured arm must not look like a free one."""
    records = []
    for i in range(10):
        for r in range(5):
            for arm in ("A", "B"):
                records.append(
                    TrialRecord(
                        trial_id=f"{i}-{arm}-{r}",
                        run_id="run1",
                        task_id=f"task-{i}",
                        variant_id=arm,
                        variant_hash="sha256:x",
                        replicate=r,
                        agent="codex",
                        started_at="2026-07-27T00:00:00Z",
                        duration_s=1.0,
                        # Turns reported, tokens not — exactly Q1's bad case.
                        telemetry=TelemetryRecord(turns=6, cost_usd=None),
                        assertions=[AssertionRecord(id="a", type="exit_code", passed=True)],
                        passed=True,
                    )
                )

    analysis = analyze(records, ("A", "B"), seed=1)
    tokens = analysis.metric("tokens_per_turn")
    assert not tokens.verdict.has_ci
    # This line used to assert 0.0 — the assertion contradicted the test's own
    # name, and the renderers duly printed "0 tokens" for an arm that reported
    # none, which is the favourable direction for whichever config is being
    # defended.
    assert tokens.arm_values["A"] is None
    assert tokens.arm_values["B"] is None
    assert not tokens.measured
    assert analysis.metric("turns").verdict.has_ci  # but turns still works
    assert analysis.metric("turns").measured
    assert not analysis.cost_known


# --- robustness -------------------------------------------------------------


def test_non_json_chatter_is_ignored():
    stream = "starting up\n" + _stream({"type": "turn.completed"}) + "done\n"
    assert parse_events(stream).turns == 1


def test_empty_output_is_a_failure_not_a_silent_pass():
    """A run that produced no output produced no turn, and a trial that never
    ran must not be scored as one."""
    telemetry = parse_events("")
    assert telemetry.turns == 0
    assert telemetry.cost_usd is None
    assert telemetry.error is not None


def test_a_stream_that_never_completes_a_turn_reports_the_diagnostics():
    stream = _stream(
        {"type": "item.completed", "item": {"type": "error", "message": "rate limited"}}
    )
    assert "rate limited" in parse_events(stream).error


def test_session_is_captured_from_thread_id():
    """The real stream uses `thread_id` on thread.started, not `session_id`."""
    stream = _stream(
        {"type": "thread.started", "thread_id": "abc"},
        {"type": "turn.completed", "usage": {"input_tokens": 1}},
    )
    assert parse_events(stream).session_id == "abc"


def test_preflight_reports_a_missing_binary(monkeypatch):
    monkeypatch.setattr("ablate.agents.codex.shutil.which", lambda _: None)
    result = CodexAdapter().preflight()
    assert not result.ok
    assert "not found" in result.detail


@pytest.mark.parametrize("payload", ['{"type": "turn.completed"', "null", "[]", "3"])
def test_malformed_lines_do_not_crash(payload):
    parse_events(payload + "\n")
