"""Claude Code adapter.

--bare is load-bearing (docs/03-architecture.md): it skips auto-discovery of
hooks, plugins, MCP servers, auto-memory, and CLAUDE.md, which is the only
way to guarantee the config under test is the only config in play. Because
--bare skips CLAUDE.md, the variant is delivered via
--append-system-prompt-file pointed at the copy the config-swap step already
wrote into the worktree.

That substitution is an **assumption**, and it is the one everything else rests
on ([Q2](../../docs/12-open-questions.md)). An appended system prompt is not
byte-identical to how Claude Code renders a project `CLAUDE.md`: the position
in the prompt differs, the surrounding framing differs, and it is not marked as
project memory. If the two differ materially, `ablate` measures the effect of
*a system-prompt appendix* and reports it as the effect of *your CLAUDE.md* — a
correctness bug wearing a design decision's clothes, and no amount of care in
the statistics layer would compensate for it.

`injection` exists to settle that empirically rather than by argument:

- `"append"` — the production path. `--bare` plus `--append-system-prompt-file`.
- `"native"` — no `--bare`, no injection flag. The config-swap step has already
  written the variant into the worktree, so the CLI discovers it the way a
  user's own session would.

Running the same config through both arms and asking for an EQUIVALENT verdict
is exactly the comparison this tool is built to make. `tools/q2_injection_equivalence.py`
does it.
"""

from __future__ import annotations

import json
import os
import shutil
import subprocess

from ablate.agents.base import PreflightResult, TrialSpec, TrialTelemetry

#: The production path, and the one every other command uses.
INJECTION_APPEND = "append"

#: Validation only. Drops --bare, so ambient config (hooks, plugins, MCP
#: servers, the user's own auto-memory) can leak into the trial. That is
#: precisely why it is not the default: it measures a realistic session at the
#: cost of no longer isolating what is being measured.
INJECTION_NATIVE = "native"

INJECTION_MODES = (INJECTION_APPEND, INJECTION_NATIVE)

#: Credentials `--bare` will actually read. It documents its own contract:
#: "Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via
#: --settings (OAuth and keychain are never read). 3P providers
#: (Bedrock/Vertex/Foundry) use their own credentials." `ablate` never passes
#: --settings, so apiKeyHelper is unreachable and these variables are the
#: whole set.
#:
#: The consequence is easy to miss and expensive to discover: a user logged in
#: with a Claude subscription has working OAuth credentials, `claude --version`
#: succeeds, and every single `--bare` trial still fails "Not logged in". That
#: is a 100% failure rate that looks like data, so it is checked here — for
#: free, before the first worktree — rather than found in the report.
#: Sentinel `detail`, not prose: the CLI branches on it to offer the choice
#: between the two authentication routes interactively. Matching on a message
#: string would break the moment the wording changed.
NO_BARE_AUTH = "no-bare-auth"

BARE_AUTH_ENV = (
    "ANTHROPIC_API_KEY",
    "ANTHROPIC_AUTH_TOKEN",
    "CLAUDE_CODE_USE_BEDROCK",
    "CLAUDE_CODE_USE_VERTEX",
    "CLAUDE_CODE_USE_FOUNDRY",
)


def bare_auth_available(env: dict[str, str] | None = None) -> bool:
    """Whether `--bare` has a credential it will actually use.

    Env-only by design: no network call, no spend, and no false confidence. It
    mirrors the documented `--bare` contract exactly, so it cannot pass and
    then have every trial fail.
    """
    source = os.environ if env is None else env
    return any(source.get(name) for name in BARE_AUTH_ENV)


class ClaudeCodeAdapter:
    name = "claude-code"

    def __init__(self, injection: str = INJECTION_APPEND) -> None:
        if injection not in INJECTION_MODES:
            raise ValueError(
                f"unknown injection mode {injection!r} — use one of {INJECTION_MODES}"
            )
        self.injection = injection

    def preflight(self) -> PreflightResult:
        binary = shutil.which("claude")
        if not binary:
            return PreflightResult(ok=False, detail="`claude` not found on PATH")
        proc = subprocess.run(["claude", "--version"], capture_output=True, text=True)
        if proc.returncode != 0:
            return PreflightResult(ok=False, detail=proc.stderr.strip() or "claude --version failed")
        version = proc.stdout.strip()

        # `claude --version` succeeding says nothing about whether a trial can
        # authenticate: it reports the installed binary, not the credentials.
        # Checking here is what turns a 100%-failed run that still renders a
        # green report into one sentence before anything is spent.
        if self.injection == INJECTION_APPEND and not bare_auth_available():
            return PreflightResult(ok=False, version=version, detail=NO_BARE_AUTH)

        return PreflightResult(ok=True, version=version)

    def build_command(self, spec: TrialSpec) -> list[str]:
        cmd = ["claude"]
        if self.injection == INJECTION_APPEND:
            cmd.append("--bare")
        cmd += [
            "-p",
            spec.prompt,
            "--output-format",
            "json",
            "--permission-mode",
            "acceptEdits",
            "--allowedTools",
            ",".join(spec.allowed_tools),
            "--max-turns",
            str(spec.max_turns),
        ]
        if spec.model:
            cmd += ["--model", spec.model]
        if self.injection == INJECTION_APPEND and spec.config_variant_text is not None:
            # The config-swap step already wrote the variant here; --bare means
            # the CLI will not read it on its own.
            cmd += ["--append-system-prompt-file", str(spec.cwd / spec.config_path)]
        # In native mode nothing is passed: the variant is on disk in the
        # worktree and the CLI discovers it exactly as a user's session would.
        return cmd

    def invoke(self, spec: TrialSpec) -> TrialTelemetry:
        cmd = self.build_command(spec)
        try:
            proc = subprocess.run(
                cmd,
                cwd=spec.cwd,
                capture_output=True,
                text=True,
                timeout=spec.timeout_s,
            )
        except subprocess.TimeoutExpired:
            return TrialTelemetry(exit_status=124, error=f"timed out after {spec.timeout_s}s")

        if proc.returncode != 0:
            return TrialTelemetry(
                exit_status=proc.returncode,
                error=(proc.stderr or proc.stdout)[-4000:] or f"claude exited {proc.returncode}",
            )

        try:
            payload = json.loads(proc.stdout)
        except json.JSONDecodeError:
            return TrialTelemetry(
                exit_status=proc.returncode,
                error=f"could not parse --output-format json output:\n{proc.stdout[-2000:]}",
            )

        return _parse_result(payload, proc.returncode)


def _parse_result(payload: dict, exit_status: int) -> TrialTelemetry:
    usage = payload.get("usage") or {}
    return TrialTelemetry(
        input_tokens=usage.get("input_tokens", 0),
        output_tokens=usage.get("output_tokens", 0),
        cache_read=usage.get("cache_read_input_tokens", usage.get("cache_read", 0)),
        cache_write=usage.get("cache_creation_input_tokens", usage.get("cache_write", 0)),
        cost_usd=payload.get("total_cost_usd"),
        turns=payload.get("num_turns", 0),
        tool_calls=0,  # not present in --output-format json; needs stream-json
        session_id=payload.get("session_id"),
        model=_reported_model(payload),
        exit_status=exit_status,
        raw_result=str(payload.get("result", "")),
        error=payload.get("error"),
    )


def _reported_model(payload: dict) -> str | None:
    """The model the agent says it used.

    `--output-format json` reports per-model usage under `modelUsage`, keyed by
    model id. Reading it back rather than trusting the flag catches a
    provider-side substitution, which would otherwise let a single comparison
    silently span two models — the variance floor in docs/05-statistics.md.
    """
    if isinstance(payload.get("model"), str):
        return payload["model"]
    usage = payload.get("modelUsage")
    if isinstance(usage, dict) and usage:
        return sorted(usage)[0]
    return None
