"""Click entrypoints. Nine commands (docs/08-surfaces.md).

Eight are implemented. `mine` is post-v1 and still registers, so `--help`
shows the whole surface and it names its milestone rather than failing with
`Error: No such command`.

The ordering of the commands is a cost gradient, and it is deliberate:
`demo` and `cost` are free, `try` is capped at $2, and only `compare`,
`ablate` and `apply` spend real money — each behind an estimate that states
what the run can actually resolve before it asks to start.
"""

from __future__ import annotations

import subprocess
import sys
from datetime import UTC, datetime
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as pkg_version
from pathlib import Path

import click
from rich.console import Console

from ablate.ablation import (
    DEFAULT_CONFIRMATIONS,
    EXTRA_VARIANTS,
    AblationResult,
    ablatable,
    confirm,
    plan_apply,
    screen,
    select_candidates,
    write_apply,
)
from ablate.agents import ADAPTERS
from ablate.agents.claude_code import (
    INJECTION_APPEND,
    INJECTION_MODES,
    INJECTION_NATIVE,
    NO_BARE_AUTH,
    bare_auth_available,
)
from ablate.config.parse import check_size, split_sections
from ablate.config.tokens import ACCURACY, estimate_tokens
from ablate.config.variant import ConfigVariant
from ablate.cost import (
    DEFAULT_MAX_SPEND_USD,
    PRICES_VERIFIED,
    SpendTracker,
    config_cost_per_turn,
    estimate_run,
    resolve_model,
)
from ablate.demo import DEMO_DIR, SYNTHETIC_NOTICE
from ablate.errors import AblateError
from ablate.init import run_init
from ablate.report.ablation import render_ablation
from ablate.report.analysis import analyze
from ablate.report.formats import (
    FORMATS,
    render_json,
    render_junit,
    render_markdown,
    render_pr_comment,
    should_fail,
)
from ablate.report.terminal import render_report
from ablate.runner.schedule import build_jobs, run_jobs
from ablate.stats import mde_continuous, mde_pass_rate
from ablate.store.index import TrialIndex, job_memo_key
from ablate.store.log import TrialLog
from ablate.store.run import RunManifest, VariantRef, list_runs, resolve_run
from ablate.task import load_tasks, select_tasks

console = Console()


#: The distribution is `ablate-cli` even though the import package and the
#: command are both `ablate` — `ablate` was already taken on PyPI. Looking up
#: the wrong one here silently reports 0.0.0 in every report footer.
DISTRIBUTION = "ablate-cli"


def _version() -> str:
    try:
        return pkg_version(DISTRIBUTION)
    except PackageNotFoundError:  # pragma: no cover — editable installs always resolve
        return "0.0.0"


def _repo_root() -> Path:
    try:
        out = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            capture_output=True, text=True, check=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError) as e:
        raise AblateError("not inside a git repository") from e
    return Path(out.stdout.strip())


def _repo_root_or_cwd() -> Path:
    """The repo root when there is one, otherwise here."""
    try:
        return _repo_root()
    except AblateError:
        return Path.cwd()


def _margin_overrides(margin: float | None) -> dict[str, float]:
    """`--margin` applies to every metric: it is one statement about how large
    a difference has to be before the user would act on it."""
    if margin is None:
        return {}
    return {"pass_rate": margin, "tokens_per_turn": margin, "turns": margin}


def _build_adapter(agent: str, injection: str):
    """Instantiate an adapter, passing `injection` only where it means something.

    Only the Claude Code adapter has two delivery modes; `codex` takes no
    constructor arguments and would raise on an unexpected keyword.
    """
    if agent == "claude-code":
        return ADAPTERS[agent](injection=injection)
    return ADAPTERS[agent]()


def _preflight_or_exit(agent: str, injection: str, *, yes: bool):  # noqa: ANN202 — adapter pair
    """Return an adapter that can actually authenticate, or exit with a sentence.

    The auth case gets its own branch because it is the one preflight failure
    with a real choice behind it, and because getting it wrong is silent: with
    a subscription login and no API key, every `--bare` trial fails "Not logged
    in", the run completes, and the report renders green over zero data. The
    prompt exists so that trading isolation away for a working login is a
    decision the user makes, never a fallback the tool takes on their behalf.
    """
    adapter = _build_adapter(agent, injection)
    result = adapter.preflight()
    if result.ok:
        return adapter, result

    if result.detail != NO_BARE_AUTH:
        console.print(f"[red]{adapter.name} preflight failed:[/red] {result.detail}")
        sys.exit(1)

    console.print()
    console.print("  [bold yellow]No credential that `claude --bare` can use.[/bold yellow]")
    console.print(
        "  [dim]`ablate` runs trials with --bare so that only the config under test is\n"
        "  in play. That mode reads ANTHROPIC_API_KEY and provider variables only —\n"
        "  it never reads the OAuth login from `claude /login` or the keychain. Your\n"
        "  interactive `claude` works; a --bare trial would fail 'Not logged in'.[/dim]"
    )
    console.print()
    console.print("  [bold]1[/bold]  Set an API key      [dim]export ANTHROPIC_API_KEY=sk-ant-…[/dim]")
    console.print("     [dim]Keeps full isolation. This is the measurement `ablate` is designed for.[/dim]")
    console.print("  [bold]2[/bold]  Use my existing login   [dim]--injection native (drops --bare)[/dim]")
    console.print(
        "     [yellow]Your own hooks, plugins, MCP servers and user-level CLAUDE.md\n"
        "     enter every trial. The delta then reflects your whole environment, not\n"
        "     the config under test. Usable, but it is a weaker claim — say so if you\n"
        "     report the number.[/yellow]"
    )
    console.print()

    # In CI or under --yes there is no one to answer, and silently choosing
    # either route would be wrong: one cannot run, the other changes what is
    # being measured.
    if yes or not sys.stdin.isatty():
        console.print(
            "  [red]Refusing to start.[/red] Set ANTHROPIC_API_KEY, or pass "
            "--injection native to accept the reduced isolation explicitly."
        )
        sys.exit(1)

    choice = click.prompt("  Choose [1/2/q]", default="q", show_default=False).strip().lower()
    if choice == "2":
        adapter = _build_adapter(agent, INJECTION_NATIVE)
        result = adapter.preflight()
        if not result.ok:
            console.print(f"[red]{adapter.name} preflight failed:[/red] {result.detail}")
            sys.exit(1)
        console.print(
            "  [yellow]Running without --bare. Ambient config is in scope for every "
            "trial.[/yellow]\n"
        )
        return adapter, result

    if choice == "1":
        console.print(
            "\n  Set the key and re-run:\n"
            "    [bold]export ANTHROPIC_API_KEY=sk-ant-…[/bold]\n"
        )
    sys.exit(1)


def _not_yet(feature: str, milestone: str) -> None:
    console.print(f"[yellow]`{feature}` is not implemented yet — planned for {milestone}.[/yellow]")
    console.print("See docs/09-milestones.md for the roadmap.")
    sys.exit(1)


class AblateGroup(click.Group):
    """Turns an AblateError into a message rather than a traceback.

    Every one of these is a condition the tool anticipated -- not in a git
    repo, no tasks, a collinear design -- and each carries a sentence written
    for the user. A stack trace would bury that sentence and, worse, exit 0.
    """

    def invoke(self, ctx: click.Context):  # noqa: ANN201 — click's own signature
        try:
            return super().invoke(ctx)
        except AblateError as e:
            console.print(f"[red]{e}[/red]")
            sys.exit(1)


@click.group(cls=AblateGroup)
# package_name is not optional here: click infers the distribution from the
# command name, and this command is `ablate` while the distribution is
# `ablate-cli`. Left to infer, `--version` raises RuntimeError rather than
# printing a version.
@click.version_option(package_name=DISTRIBUTION)
def main() -> None:
    """ablate — measure whether your CLAUDE.md actually does anything."""


@main.command()
def init() -> None:
    """Detect the test command + config file, scaffold .ablate/ with 3 template tasks."""
    repo = _repo_root()
    try:
        test_cmd, config_file, written, skipped = run_init(repo)
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        sys.exit(1)

    console.print(f"[green]Detected test command:[/green] {test_cmd}")
    if config_file:
        console.print(f"[green]Detected config file:[/green] {config_file}")
    else:
        console.print(
            "[yellow]No CLAUDE.md / AGENTS.md / .cursorrules found.[/yellow] "
            "You can still compare against a config you write from scratch."
        )
    console.print()
    if written:
        console.print(f"Wrote {len(written)} template tasks — fill in the TODOs, then run:")
        for p in written:
            console.print(f"  {p.relative_to(repo)}")
    if skipped:
        console.print(
            f"[yellow]Kept {len(skipped)} existing task file(s)[/yellow] — not overwritten:"
        )
        for p in skipped:
            console.print(f"  {p.relative_to(repo)}")
    console.print()
    if config_file:
        console.print(f"  ablate cost {config_file}          # free, instant")
        console.print(f"  ablate compare {config_file} /dev/null --trials 3")
    else:
        console.print("  ablate compare <your-config-file> /dev/null --trials 3")
    console.print()
    console.print(
        "[dim]Three tasks cannot produce a confidence interval (8 minimum). Add more "
        "tasks before trusting any delta — see docs/05-statistics.md.[/dim]"
    )

    # Checked at setup rather than left for the first paid run, where it
    # surfaces as every trial failing at once.
    if not bare_auth_available():
        console.print()
        console.print(
            "[yellow]No ANTHROPIC_API_KEY set.[/yellow] Trials run `claude --bare`, which "
            "does not\nread the login from `claude /login`. Set a key before running "
            "`ablate try`,\nor pass --injection native to use your existing login and "
            "accept that your\nown hooks, plugins and CLAUDE.md will be present in every "
            "trial."
        )


@main.command(name="cost")
@click.argument("file", required=False)
@click.option("--model", default=None, help="Price against this model.")
@click.option("--cheap", is_flag=True, default=False, help="Price against Haiku 4.5.")
@click.option("--turns", default=8, show_default=True, help="Turns per session, for the total.")
def cost_cmd(file: str | None, model: str | None, cheap: bool, turns: int) -> None:
    """Static token cost per section. $0, no agent runs, no network.

    This is the one number the tool can always give you. It is arithmetic, not
    an experiment, so it cannot come back inconclusive — every other metric
    here can (ADR-0010).
    """
    # No git repo required. This is the zero-cost first-contact command and it
    # is pure arithmetic on a file -- demanding a checkout to count tokens
    # would be a gate with nothing behind it.
    target = Path(file) if file else None
    if target is None:
        root = _repo_root_or_cwd()
        for candidate in ("CLAUDE.md", "AGENTS.md", ".cursorrules"):
            if (root / candidate).exists():
                target = root / candidate
                break
    if target is None or not Path(target).exists():
        console.print("[red]No config file found.[/red] Pass one: `ablate cost CLAUDE.md`")
        sys.exit(1)

    text = Path(target).read_text()
    # Described as written: `ablate cost` answers "what is in my file and
    # what does it cost", so its rows must match the user's own headings.
    # Small-section merging belongs to ablation, where sections are design
    # units rather than a description of the file.
    sections = split_sections(text, merge_small=False)
    total = estimate_tokens(text)
    price = resolve_model(model, cheap=cheap)
    per_turn = config_cost_per_turn(total, price)

    console.print()
    console.print(f"  [bold]{target}[/bold]  {len(text.splitlines()):,} lines · ~{total:,} tokens")
    console.print()

    # Fixed columns: 4 indent + 5 id + 7 lines + 9 tokens + 8 share = 33. The
    # title takes what is left, so the table never wraps into an unreadable mess
    # in an 80-column terminal.
    longest = max((len(s.title) for s in sections), default=10)
    width = max(12, min(longest, console.width - 35))
    console.print(f"    [dim]{'id':<5}{'section':<{width + 2}}{'lines':>7}{'tokens':>9}{'share':>7}[/dim]")

    for section in sections:
        tokens = section.tokens
        share = tokens / total if total else 0.0
        title = section.title if len(section.title) <= width else section.title[: width - 1] + "…"
        console.print(
            f"    {section.id:<5}{title:<{width + 2}}{section.line_count:>7,}"
            f"{tokens:>9,}{share * 100:>7.1f}%"
        )

    console.print()
    # Quoted per 1,000 turns first, because that is the unit that survives
    # rounding. A single session of a modest config costs a fraction of a cent
    # and prints as "$0.000", which reads as free — the opposite of the point,
    # since the whole argument is that this is re-sent on every turn forever.
    session = per_turn * turns
    session_clause = f", ~${session:.3f} per {turns}-turn session" if session >= 0.001 else ""
    console.print(
        f"  Re-sent on every turn. At {price.label} cache-read rates that is "
        f"[bold]~${per_turn * 1000:.2f} per 1,000 turns[/bold]{session_clause}."
    )
    console.print(
        f"  [dim]Token counts are estimates (±{ACCURACY * 100:.0f}%); relative shares are "
        f"more reliable than absolute totals. Prices verified {PRICES_VERIFIED}.[/dim]"
    )
    console.print()
    console.print(
        "  [dim]This says what your config costs, not whether it earns it. "
        "For that: `ablate compare`.[/dim]"
    )
    console.print()


@main.command()
@click.argument("config_a")
@click.argument("config_b", default="/dev/null")
@click.option("--trials", default=5, show_default=True, help="Replicates per (task, variant).")
@click.option("--tasks", "tasks_glob", default=".ablate/tasks", help="Directory of task YAML files.")
@click.option("--tasks-only", "tasks_only", default=None,
              help="Run only these task ids or globs, e.g. 'fix-*,add-cli-flag'.")
@click.option("--tasks-tag", "tasks_tag", default=None,
              help="Run only tasks carrying any of these tags, e.g. 'smoke'.")
@click.option("--agent", default="claude-code", show_default=True,
              type=click.Choice(list(ADAPTERS)))
@click.option("--injection", default=INJECTION_APPEND, show_default=True,
              type=click.Choice(INJECTION_MODES),
              help="How the config reaches the agent. 'native' drops --bare: it works with "
                   "a subscription login but lets ambient config into every trial.")
@click.option("--model", default=None, help="Model id to run against.")
@click.option("--cheap", is_flag=True, default=False, help="Run on Haiku 4.5 (~3.5x cheaper).")
@click.option("--concurrency", default=4, show_default=True)
@click.option("--max-turns", default=40, show_default=True)
@click.option("--max-spend", default=DEFAULT_MAX_SPEND_USD, show_default=True, help="Hard ceiling, USD.")
@click.option("--margin", default=None, type=float, help="Equivalence margin, e.g. 0.03 for ±3pp.")
@click.option("--alpha", default=0.05, show_default=True, help="Significance level.")
@click.option("--seed", default=None, type=int, help="Shuffle and bootstrap seed. Not agent sampling.")
@click.option("--yes", is_flag=True, default=False, help="Skip the confirm prompt. Never bypasses --max-spend.")
@click.option("--keep-worktrees", is_flag=True, default=False)
@click.option("--fresh", is_flag=True, default=False, help="Ignore cached trials and re-run everything.")
@click.option("--format", "fmt", default="text", show_default=True, type=click.Choice(FORMATS),
              help="Also write a machine-readable report into the run directory.")
@click.option("--fail-on", "fail_on", default="never", show_default=True,
              type=click.Choice(["regression", "inconclusive", "never"]),
              help="Exit non-zero on this verdict. For CI.")
@click.option("--limit-tasks", default=None, type=int, hidden=True, help="Used by `ablate try`.")
def compare(
    config_a: str,
    config_b: str,
    trials: int,
    tasks_glob: str,
    tasks_only: str | None,
    tasks_tag: str | None,
    agent: str,
    injection: str,
    model: str | None,
    cheap: bool,
    concurrency: int,
    max_turns: int,
    max_spend: float,
    margin: float | None,
    alpha: float,
    seed: int | None,
    yes: bool,
    keep_worktrees: bool,
    fresh: bool,
    fmt: str,
    fail_on: str,
    limit_tasks: int | None,
) -> None:
    """Run TASKS under CONFIG_A and CONFIG_B, N trials each, and report the delta.

    CONFIG_B defaults to /dev/null (no config) — the "is this file doing
    anything at all" question.
    """
    repo = _repo_root()

    try:
        tasks = load_tasks(repo / tasks_glob)
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        console.print("Run `ablate init` first, or point --tasks at a directory of task YAML files.")
        sys.exit(1)

    tasks = select_tasks(tasks, only=tasks_only, tag=tasks_tag)
    if limit_tasks is not None:
        tasks = tasks[:limit_tasks]

    variants = [
        ConfigVariant.from_path("A", repo / config_a if config_a != "/dev/null" else config_a),
        ConfigVariant.from_path("B", repo / config_b if config_b != "/dev/null" else config_b),
    ]

    adapter, preflight = _preflight_or_exit(agent, injection, yes=yes)

    price = resolve_model(model, cheap=cheap)
    jobs = build_jobs(tasks, variants, trials, seed=seed)
    # Reuse whatever this design point has already been paid for. Editing one
    # section of a config changes that arm's hash and nothing else, so a
    # re-run should only buy the trials that actually changed.
    index = TrialIndex(repo / ".ablate" / "index.db")
    reused: list = []
    if not fresh:
        cached = index.lookup(
            job_memo_key(job, adapter.name, price.model_id) for job in jobs
        )
        if cached:
            pending = []
            for job in jobs:
                hit = cached.get(job_memo_key(job, adapter.name, price.model_id))
                if hit is None:
                    pending.append(job)
                else:
                    reused.append(hit)
            jobs = pending
            console.print(
                f"  [green]Reusing {len(reused)} trials[/green] from earlier runs "
                f"— {len(jobs)} left to run. [dim]--fresh to re-run everything.[/dim]"
            )

    total = len(jobs)
    if total == 0:
        console.print("  [green]Nothing to run — every trial was already cached.[/green]")

    if total and not _confirm_spend(
        n_tasks=len(tasks), n_trials=trials, n_runs=total, price=price,
        concurrency=concurrency, max_spend=max_spend, variants=variants, yes=yes,
    ):
        sys.exit(1)

    run_id = datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%SZ")
    run_dir = repo / ".ablate" / "runs" / run_id
    log = TrialLog(run_dir)
    tracker = SpendTracker(max_spend, total)
    done = 0

    # Reused trials are written into this run's log too, so the run directory
    # is a complete, self-contained record of the analysis rather than a
    # fragment that only makes sense with the index alongside it.
    for record in reused:
        log.append(record)

    def on_result(record) -> None:  # noqa: ANN001 — TrialRecord, kept loose to dodge a cycle
        nonlocal done
        done += 1
        log.append(record)
        tracker.record(record.telemetry.cost_usd)
        status = "[green]pass[/green]" if record.passed else "[red]fail[/red]"
        spent = f"${tracker.spent:.2f}" if tracker.spent else ""
        console.print(f"  [{done}/{total}] {record.trial_id}  {status}  [dim]{spent}[/dim]")

    def should_stop() -> bool:
        return tracker.ceiling is not None and tracker.spent >= tracker.ceiling

    aborted: list[str] = []

    def on_abort(reason: str) -> None:
        aborted.append(reason)
        console.print(f"\n  [red]Aborted.[/red] {reason}\n")

    fresh_records, stopped = run_jobs(
        jobs=jobs,
        repo=repo,
        worktrees_root=repo / ".ablate" / "worktrees",
        adapter=adapter,
        run_id=run_id,
        concurrency=concurrency,
        max_turns=max_turns,
        keep_worktrees=keep_worktrees,
        model=price.model_id,
        on_result=on_result,
        should_stop=should_stop,
        on_abort=on_abort,
    )
    index.add(fresh_records)
    records = reused + fresh_records

    # Nothing worked, so there is nothing to analyze. Rendering a report here
    # is what produced a green "no regression detected" over a run in which
    # every trial failed to authenticate.
    if aborted:
        console.print(
            "  [dim]No report: this run measured nothing. Fix the error above and "
            "re-run — cached trials from earlier runs are still reused.[/dim]\n"
        )
        sys.exit(1)

    partial = None
    if stopped:
        partial = (
            f"{len(records)}/{total} trials completed, ${tracker.spent:.2f} of the "
            f"${max_spend:.2f} ceiling spent. The queue was shuffled, so this is a "
            f"roughly balanced partial design and is still analyzable."
        )

    RunManifest(
        run_id=run_id,
        command=f"ablate compare {config_a} {config_b} --trials {trials}",
        agent=adapter.name,
        agent_version=preflight.version,
        model=price.model_id,
        variants=[VariantRef(id=v.id, source=v.source, text=v.text) for v in variants],
        n_tasks=len(tasks),
        n_trials=trials,
        seed=seed,
        partial=partial,
    ).save(run_dir)

    overrides = _margin_overrides(margin)

    analysis = analyze(
        records, ("A", "B"), run_id=run_id, margin_overrides=overrides,
        alpha=alpha, seed=seed, agent=adapter.name, agent_version=preflight.version,
    )

    render_report(
        console=console,
        analysis=analysis,
        variants=variants,
        command=f"ablate compare {config_a} {config_b} --trials {trials}",
        version=_version(),
        price=price,
        run_path=str((run_dir / "trials.jsonl").relative_to(repo)),
        partial=partial,
    )

    _write_format(
        analysis, variants, fmt=fmt, run_dir=run_dir, repo=repo,
        command=f"ablate compare {config_a} {config_b} --trials {trials}",
        fail_on=fail_on,
    )

    if tracker.unpriced:
        console.print(
            f"  [yellow]{tracker.unpriced} trials reported no cost — the spend ceiling "
            f"could not account for them.[/yellow]\n"
        )
    if stopped:
        sys.exit(2)
    _exit_on_verdict(analysis, fail_on)


def _write_format(
    analysis,  # noqa: ANN001 — Analysis
    variants: list[ConfigVariant],
    *,
    fmt: str,
    run_dir: Path,
    repo: Path,
    command: str,
    fail_on: str,
) -> None:
    """Write the machine-readable artifact alongside the terminal report.

    The terminal report is always rendered — it is the human view, and a CI log
    with only a JUnit file in it is unreadable when something goes wrong.
    """
    if fmt == "text":
        return

    renderers = {
        "md": lambda: render_markdown(
            analysis, variants, command=command, version=_version(),
            repo=_head_commit(repo), log_path="trials.jsonl",
        ),
        "json": lambda: render_json(analysis, variants, command=command, version=_version()),
        "junit": lambda: render_junit(analysis, fail_on=fail_on),
        "pr-comment": lambda: render_pr_comment(analysis, variants, fail_on=fail_on),
    }
    suffix = {
        "md": "report.md",
        "json": "report.json",
        "junit": "junit.xml",
        "pr-comment": "comment.md",
    }[fmt]

    path = run_dir / suffix
    path.write_text(renderers[fmt]())
    console.print(f"  [dim]Wrote {path.relative_to(repo)}[/dim]\n")


def _exit_on_verdict(analysis, fail_on: str) -> None:  # noqa: ANN001 — Analysis
    """Exit non-zero when a metric trips `--fail-on`.

    Checked across every metric, not just pass rate: a config that holds pass
    rate steady while doubling token cost is a regression, and a gate that only
    watched pass rate would wave it through.
    """
    if fail_on == "never":
        return
    tripped = [
        m for m in analysis.metrics if should_fail(m.verdict.verdict, fail_on)
    ]
    if not tripped:
        return
    console.print(
        f"  [red]--fail-on {fail_on}:[/red] "
        + ", ".join(f"{m.spec.label.lower()} is {m.verdict.verdict.value}" for m in tripped)
    )
    console.print()
    sys.exit(1)


def _confirm_spend(
    *,
    n_tasks: int,
    n_trials: int,
    n_runs: int,
    price,  # ModelPrice
    concurrency: int,
    max_spend: float,
    variants: list[ConfigVariant],
    yes: bool,
) -> bool:
    """Show the estimate, the MDE and the ceiling before spending anything.

    Cost, run count and — critically — what the design can actually resolve are
    shown *before* the confirm prompt. Spending $28 to learn "inconclusive" is
    the worst outcome and it is avoidable here (docs/06-cost.md).
    """
    estimate = estimate_run(n_runs, price, concurrency=concurrency)
    pass_mde = mde_pass_rate(n_tasks, n_trials)
    token_mde = mde_continuous(n_tasks, n_trials)

    console.print()
    for variant in variants:
        label = variant.source if variant.text is not None else "(no config)"
        console.print(
            f"  [bold]{variant.id}[/bold]  {label:<24}"
            f"{variant.line_count:>5} lines · {estimate_tokens(variant.text):>7,} tokens/turn"
        )
    console.print()
    console.print(
        f"  {n_tasks} tasks × {n_trials} trials × {len(variants)} arms = "
        f"[bold]{n_runs} runs[/bold] on {price.label}"
    )
    console.print(
        f"  Estimated: [bold]${estimate.total_usd:.2f}[/bold] "
        f"(~${estimate.per_trial_usd:.2f}/trial) · ~{estimate.minutes:.0f} min "
        f"at --concurrency {concurrency}"
    )
    console.print()
    console.print(
        f"  [dim]This design can resolve a pass-rate change of "
        f"[/dim][bold]{pass_mde * 100:.0f}pp[/bold][dim] or larger, and a token change of "
        f"[/dim][bold]{token_mde * 100:.0f}%[/bold][dim] or larger.[/dim]"
    )
    if pass_mde > 0.15:
        console.print(
            "  [yellow]Published pass-rate effects of config changes are 3-4pp. "
            "This design cannot see them; treat pass rate as a guardrail, not a "
            "headline.[/yellow]"
        )
    console.print()

    if estimate.total_usd <= max_spend:
        if yes:
            return True
        return click.confirm("  Run?", default=True)

    console.print(
        f"  [yellow]Estimated ${estimate.total_usd:.2f} exceeds --max-spend "
        f"${max_spend:.2f}.[/yellow]"
    )
    if yes:
        # `--yes` never bypasses the ceiling. In CI a runaway loop bills a
        # credit card unattended, so exceeding the budget is a hard failure
        # with a non-zero exit, never a silent continue (docs/06-cost.md).
        console.print(
            "  [red]Refusing to start.[/red] Raise --max-spend or lower --trials."
        )
        return False

    affordable = max(1, int(max_spend / (estimate.per_trial_usd * n_tasks * 2)))
    console.print(
        f"  [c] continue anyway   [r] raise ceiling to ${estimate.total_usd:.2f}   "
        f"[t] reduce to --trials {affordable}   [q] quit"
    )
    choice = click.prompt("  >", default="q", show_default=False).strip().lower()
    if choice == "c":
        return True
    if choice == "r":
        return True
    if choice == "t":
        console.print(f"  Re-run with: ablate compare ... --trials {affordable}")
        return False
    return False


@main.command(name="try")
@click.argument("config_a", required=False)
@click.option("--tasks", "tasks_glob", default=".ablate/tasks", help="Directory of task YAML files.")
@click.option("--tasks-only", "tasks_only", default=None,
              help="Run only these task ids or globs, e.g. 'fix-*,add-cli-flag'.")
@click.option("--tasks-tag", "tasks_tag", default=None,
              help="Run only tasks carrying any of these tags, e.g. 'smoke'.")
@click.option("--injection", default=INJECTION_APPEND, show_default=True,
              type=click.Choice(INJECTION_MODES),
              help="How the config reaches the agent. 'native' drops --bare: it works with "
                   "a subscription login but lets ambient config into every trial.")
@click.option("--concurrency", default=3, show_default=True)
@click.option("--yes", is_flag=True, default=False)
@click.pass_context
def try_cmd(
    ctx: click.Context,
    config_a: str | None,
    tasks_glob: str,
    tasks_only: str | None,
    tasks_tag: str | None,
    injection: str,
    concurrency: int,
    yes: bool,
) -> None:
    """Smallest real run: 3 tasks × 3 trials × 2 arms on the cheap model, <= $2.

    Deliberately underpowered, and deliberately honest about it. Three tasks
    cannot produce a confidence interval at all (eight minimum), so this
    proves the plumbing works against a real agent — it does not measure
    anything. A tool whose selling point is statistical conservatism cannot
    open with a demo that quietly produces a believable number.
    """
    repo = _repo_root()

    if config_a is None:
        for candidate in ("CLAUDE.md", "AGENTS.md", ".cursorrules"):
            if (repo / candidate).exists():
                config_a = candidate
                break
    if config_a is None:
        console.print("[red]No config file found.[/red] Pass one: `ablate try CLAUDE.md`")
        sys.exit(1)

    console.print()
    console.print("  [bold yellow]SMOKE TEST — not a measurement.[/bold yellow]")
    console.print(
        "  [dim]3 tasks × 3 trials on Haiku 4.5. Below the 8-task floor for a "
        "confidence interval, so every verdict will read INCONCLUSIVE. That is "
        "the correct answer at this size, not a failure.[/dim]"
    )

    ctx.invoke(
        compare,
        config_a=config_a,
        config_b="/dev/null",
        trials=3,
        tasks_glob=tasks_glob,
        tasks_only=tasks_only,
        tasks_tag=tasks_tag,
        agent="claude-code",
        injection=injection,
        model=None,
        cheap=True,
        concurrency=concurrency,
        max_turns=40,
        max_spend=2.0,
        margin=None,
        alpha=0.05,
        seed=None,
        yes=yes,
        keep_worktrees=False,
        fresh=False,
        fmt="text",
        fail_on="never",
        limit_tasks=3,
    )


@main.command()
@click.option("--margin", default=None, type=float, help="Equivalence margin, e.g. 0.03 for ±3pp.")
def demo(margin: float | None) -> None:
    """Replay the bundled demo run. $0, no API key, no network.

    The first thing anyone runs should cost nothing, so the decision to spend
    is made after seeing what the output looks like (docs/06-cost.md).
    """
    manifest = RunManifest.load(DEMO_DIR)
    records = TrialLog(DEMO_DIR).read_all()

    console.print()
    console.print(f"  [bold yellow]{SYNTHETIC_NOTICE}[/bold yellow]")

    render_report(
        console=console,
        analysis=analyze(
            records,
            ("A", "B"),
            run_id=manifest.run_id,
            margin_overrides=_margin_overrides(margin),
            seed=manifest.seed,
            agent=manifest.agent,
            agent_version=manifest.agent_version,
        ),
        variants=manifest.to_variants(),
        command="ablate compare CLAUDE.md /dev/null --trials 5",
        version=_version(),
        price=resolve_model(manifest.model),
    )
    console.print(f"  [yellow]{SYNTHETIC_NOTICE}[/yellow]")
    console.print(
        "  [dim]To measure your own config: `ablate init`, then `ablate try`.[/dim]"
    )
    console.print()


@main.command(name="report")
@click.argument("run", required=False)
@click.option("--last", is_flag=True, default=False, help="Re-analyze the most recent run.")
@click.option("--margin", default=None, type=float, help="Equivalence margin, e.g. 0.03 for ±3pp.")
@click.option("--alpha", default=None, type=float, help="Significance level.")
@click.option("--seed", default=None, type=int, help="Bootstrap seed.")
@click.option("--list", "list_runs_flag", is_flag=True, default=False, help="List stored runs.")
@click.option("--format", "fmt", default="text", show_default=True, type=click.Choice(FORMATS),
              help="Also write a machine-readable report into the run directory.")
@click.option("--fail-on", "fail_on", default="never", show_default=True,
              type=click.Choice(["regression", "inconclusive", "never"]))
def report_cmd(
    run: str | None,
    last: bool,
    margin: float | None,
    alpha: float | None,
    seed: int | None,
    list_runs_flag: bool,
    fmt: str,
    fail_on: str,
) -> None:
    """Re-analyze a stored run. Zero API calls, zero cost.

    Changing `--margin` or `--alpha` here re-decides the verdict from data you
    already paid for. That is the intended use, and it is also how a dataset
    gets tortured, so every re-analysis is counted and the count is printed.
    """
    repo = _repo_root()

    if list_runs_flag:
        stored = list_runs(repo)
        if not stored:
            console.print("[yellow]No stored runs.[/yellow] Run `ablate compare` first.")
            return
        console.print()
        for path in stored:
            try:
                manifest = RunManifest.load(path)
            except AblateError:
                console.print(f"  {path.name}  [dim](no manifest)[/dim]")
                continue
            console.print(
                f"  [bold]{manifest.run_id}[/bold]  {manifest.n_tasks} tasks × "
                f"{manifest.n_trials} trials  [dim]{manifest.command}[/dim]"
            )
        console.print()
        return

    try:
        run_dir = resolve_run(repo, None if last else run)
        manifest = RunManifest.load(run_dir)
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        sys.exit(1)

    records = TrialLog(run_dir).read_all()
    if not records:
        console.print(f"[red]{run_dir.name} contains no trials.[/red]")
        sys.exit(1)

    manifest.analyses += 1
    manifest.save(run_dir)

    analysis = analyze(
        records,
        ("A", "B"),
        run_id=manifest.run_id,
        margin_overrides=_margin_overrides(margin),
        alpha=alpha if alpha is not None else 0.05,
        seed=seed if seed is not None else manifest.seed,
        agent=manifest.agent,
        agent_version=manifest.agent_version,
    )

    render_report(
        console=console,
        analysis=analysis,
        variants=manifest.to_variants(),
        command=f"ablate report {manifest.run_id}",
        version=_version(),
        price=resolve_model(manifest.model),
        run_path=str((run_dir / "trials.jsonl").relative_to(repo)),
        partial=manifest.partial,
        analyses=manifest.analyses,
    )

    _write_format(
        analysis, manifest.to_variants(), fmt=fmt, run_dir=run_dir, repo=repo,
        command=manifest.command, fail_on=fail_on,
    )
    _exit_on_verdict(analysis, fail_on)


@main.command(name="ablate")
@click.argument("file", required=False)
@click.option("--tasks", "tasks_glob", default=".ablate/tasks", help="Directory of task YAML files.")
@click.option("--tasks-only", "tasks_only", default=None,
              help="Run only these task ids or globs, e.g. 'fix-*,add-cli-flag'.")
@click.option("--tasks-tag", "tasks_tag", default=None,
              help="Run only tasks carrying any of these tags, e.g. 'smoke'.")
@click.option("--trials", default=5, show_default=True, help="Replicates per cell, confirmatory stage.")
@click.option("--agent", default="claude-code", show_default=True,
              type=click.Choice(list(ADAPTERS)))
@click.option("--injection", default=INJECTION_APPEND, show_default=True,
              type=click.Choice(INJECTION_MODES),
              help="How the config reaches the agent. 'native' drops --bare: it works with "
                   "a subscription login but lets ambient config into every trial.")
@click.option("--model", default=None, help="Target model for the confirmatory stage.")
@click.option("--screen-model", default=None, help="Model for screening. Defaults to Haiku 4.5.")
@click.option("--no-screen", is_flag=True, default=False, help="Screen on the target model (~4x the price).")
@click.option("--confirm", "n_confirm", default=DEFAULT_CONFIRMATIONS, show_default=True, help="Sections to confirm.")
@click.option("--granularity", default="section", type=click.Choice(["section", "bullet"]))
@click.option("--concurrency", default=4, show_default=True)
@click.option("--max-turns", default=40, show_default=True)
@click.option("--max-spend", default=DEFAULT_MAX_SPEND_USD, show_default=True, help="Hard ceiling, USD.")
@click.option("--margin", default=None, type=float, help="Equivalence margin for the confirmatory stage.")
@click.option("--alpha", default=0.05, show_default=True)
@click.option("--seed", default=None, type=int, help="Design and bootstrap seed.")
@click.option("--yes", is_flag=True, default=False)
@click.option("--fresh", is_flag=True, default=False, help="Ignore cached trials.")
@click.option("--keep-worktrees", is_flag=True, default=False)
def ablate_cmd(
    file: str | None,
    tasks_glob: str,
    tasks_only: str | None,
    tasks_tag: str | None,
    trials: int,
    agent: str,
    injection: str,
    model: str | None,
    screen_model: str | None,
    no_screen: bool,
    n_confirm: int,
    granularity: str,
    concurrency: int,
    max_turns: int,
    max_spend: float,
    margin: float | None,
    alpha: float,
    seed: int | None,
    yes: bool,
    fresh: bool,
    keep_worktrees: bool,
) -> None:
    """Full section attribution: which lines earn their keep.

    Screens every section from a randomized subset design on a cheap model,
    then confirms the shortlist with dedicated paired A/Bs on the target model.
    The screening stage is ranking only; only the confirmatory stage produces
    verdicts, and only an EQUIVALENT verdict licenses a deletion.
    """
    repo = _repo_root()
    target = _resolve_config(repo, file)

    try:
        tasks = load_tasks(repo / tasks_glob)
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        console.print("Run `ablate init` first, or point --tasks at a directory of task YAML files.")
        sys.exit(1)

    tasks = select_tasks(tasks, only=tasks_only, tag=tasks_tag)

    text = target.read_text()
    sections = split_sections(text, granularity=granularity)
    try:
        check_size(sections)
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        sys.exit(1)

    candidates = ablatable(sections)
    if not candidates:
        console.print(
            "[red]Every section is pinned with <!-- ablate:keep -->.[/red] "
            "Nothing can be ablated."
        )
        sys.exit(1)

    adapter, preflight = _preflight_or_exit(agent, injection, yes=yes)

    target_price = resolve_model(model)
    screen_price = target_price if no_screen else resolve_model(screen_model, cheap=True)

    n_variants = 2 * len(candidates) + EXTRA_VARIANTS
    screen_runs = n_variants * len(tasks)
    confirm_runs = (1 + n_confirm) * len(tasks) * trials

    if not _confirm_ablation_spend(
        config=str(target.relative_to(repo)),
        sections=sections,
        n_tasks=len(tasks),
        screen_runs=screen_runs,
        confirm_runs=confirm_runs,
        screen_price=screen_price,
        target_price=target_price,
        concurrency=concurrency,
        max_spend=max_spend,
        granularity=granularity,
        yes=yes,
    ):
        sys.exit(1)

    run_id = datetime.now(UTC).strftime("%Y-%m-%dT%H-%M-%SZ")
    run_dir = repo / ".ablate" / "runs" / run_id
    log = TrialLog(run_dir)
    index = TrialIndex(repo / ".ablate" / "index.db")
    tracker = SpendTracker(max_spend, screen_runs + confirm_runs)

    def make_runner(price):  # noqa: ANN001, ANN202 — ModelPrice, closure over CLI state
        def runner(variants, replicates, stage):
            jobs = build_jobs(tasks, list(variants), replicates, seed=seed)
            reused, pending = [], []
            if not fresh:
                cached = index.lookup(
                    job_memo_key(job, adapter.name, price.model_id) for job in jobs
                )
                for job in jobs:
                    hit = cached.get(job_memo_key(job, adapter.name, price.model_id))
                    (reused.append(hit) if hit else pending.append(job))
            else:
                pending = jobs

            total = len(pending)
            console.print(
                f"\n  [bold]{stage}[/bold] · {len(variants)} variants × {len(tasks)} tasks "
                f"× {replicates} trials on {price.label} — {total} to run"
                + (f", {len(reused)} reused" if reused else "")
            )
            for record in reused:
                log.append(record)

            done = 0

            def on_result(record) -> None:  # noqa: ANN001
                nonlocal done
                done += 1
                log.append(record)
                tracker.record(record.telemetry.cost_usd)
                status = "[green]pass[/green]" if record.passed else "[red]fail[/red]"
                console.print(
                    f"  [{done}/{total}] {record.trial_id}  {status}  "
                    f"[dim]${tracker.spent:.2f}[/dim]"
                )

            produced, stopped = run_jobs(
                jobs=pending,
                repo=repo,
                worktrees_root=repo / ".ablate" / "worktrees",
                adapter=adapter,
                run_id=run_id,
                concurrency=concurrency,
                max_turns=max_turns,
                keep_worktrees=keep_worktrees,
                model=price.model_id,
                on_result=on_result,
                should_stop=lambda: tracker.ceiling is not None
                and tracker.spent >= tracker.ceiling,
                on_abort=_abort_ablation,
            )
            index.add(produced)
            if stopped:
                console.print(
                    f"  [yellow]Spend ceiling reached at ${tracker.spent:.2f}.[/yellow]"
                )
            return reused + produced

        return runner

    try:
        screening = screen(
            sections, make_runner(screen_price), model=screen_price.label,
            seed=seed, alpha=alpha,
        )
        chosen = select_candidates(screening, limit=n_confirm)
        confirmations = confirm(
            sections, chosen, make_runner(target_price), trials=trials,
            margin_overrides=_margin_overrides(margin), alpha=alpha, seed=seed,
            agent=adapter.name, agent_version=preflight.version,
        )
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        sys.exit(1)

    result = AblationResult(
        sections=tuple(sections),
        screening=screening,
        confirmations=tuple(confirmations),
        config_path=str(target.relative_to(repo)),
        total_tokens=estimate_tokens(text),
    )

    render_ablation(
        console,
        result,
        command=f"ablate ablate {target.relative_to(repo)} --trials {trials}",
        version=_version(),
        price=target_price,
    )
    console.print(f"  [dim]Run stored at {run_dir.relative_to(repo)}/trials.jsonl[/dim]\n")


def _abort_ablation(reason: str) -> None:
    """Fail the ablation outright when no trial is succeeding.

    Raised rather than printed: a screening stage that returns nothing would
    otherwise flow into `select_candidates` and produce a ranking over no data.
    """
    raise AblateError(
        f"{reason}\n  Nothing was measured, so no attribution is possible. Fix the "
        f"error above and re-run — completed trials are cached and will be reused."
    )


def _resolve_config(repo: Path, file: str | None) -> Path:
    if file:
        path = Path(file)
        if not path.is_absolute():
            path = repo / file
        if not path.exists():
            console.print(f"[red]{file} not found.[/red]")
            sys.exit(1)
        return path

    for candidate in ("CLAUDE.md", "AGENTS.md", ".cursorrules"):
        if (repo / candidate).exists():
            return repo / candidate
    console.print("[red]No config file found.[/red] Pass one: `ablate ablate CLAUDE.md`")
    sys.exit(1)


def _confirm_ablation_spend(
    *,
    config: str,
    sections: list,
    n_tasks: int,
    screen_runs: int,
    confirm_runs: int,
    screen_price,  # ModelPrice
    target_price,  # ModelPrice
    concurrency: int,
    max_spend: float,
    granularity: str,
    yes: bool,
) -> bool:
    """Show stage 0 — the free, exact part — before asking to spend anything.

    Per-section token cost needs no agent runs and cannot come back
    inconclusive, so the user sees it whether or not they go on to pay for the
    rest (ADR-0010).
    """
    ablatable_sections = [s for s in sections if not s.required]
    pinned = len(sections) - len(ablatable_sections)
    total_tokens = sum(s.tokens for s in sections)

    console.print()
    console.print(
        f"  [bold]{config}[/bold] — {len(sections)} sections, ~{total_tokens:,} tokens/turn"
    )
    if pinned:
        console.print(f"  [dim]{pinned} pinned and never dropped[/dim]")
    console.print()

    width = min(max((len(s.title) for s in sections), default=20), console.width - 30)
    console.print(f"    [dim]{'id':<5}{'section':<{width + 2}}{'tokens':>8}{'share':>8}[/dim]")
    for section in sections:
        title = section.title if len(section.title) <= width else section.title[: width - 1] + "…"
        share = section.tokens / total_tokens if total_tokens else 0.0
        pin = " 📌" if section.required else ""
        console.print(
            f"    {section.id:<5}{title:<{width + 2}}{section.tokens:>8,}{share * 100:>7.1f}%{pin}"
        )

    screen_estimate = estimate_run(screen_runs, screen_price, concurrency=concurrency)
    confirm_estimate = estimate_run(confirm_runs, target_price, concurrency=concurrency)
    total_usd = screen_estimate.total_usd + confirm_estimate.total_usd
    minutes = screen_estimate.minutes + confirm_estimate.minutes

    console.print()
    console.print(
        f"  Screen:  {screen_runs:>4} runs on {screen_price.label:<10} "
        f"~${screen_estimate.total_usd:.2f}"
    )
    console.print(
        f"  Confirm: {confirm_runs:>4} runs on {target_price.label:<10} "
        f"~${confirm_estimate.total_usd:.2f}"
    )
    console.print(
        f"  [bold]Total:   {screen_runs + confirm_runs:>4} runs · "
        f"~${total_usd:.2f} · ~{minutes:.0f} min[/bold]"
    )
    console.print()

    if granularity == "bullet":
        console.print(
            "  [yellow]--granularity bullet typically triples K, and cost with it. "
            "The default section granularity is what most users act on.[/yellow]"
        )
    if screen_price.model_id != target_price.model_id:
        console.print(
            "  [dim]Screening runs on a cheaper model. Config sensitivity may not "
            "transfer across model tiers, which is what the confirmatory stage is "
            "for — it is not optional.[/dim]"
        )
    console.print(
        f"  [dim]Confirmatory MDE at {n_tasks} tasks: "
        f"{mde_pass_rate(n_tasks, 5) * 100:.0f}pp on pass rate.[/dim]"
    )
    console.print()

    if total_usd > max_spend:
        console.print(
            f"  [yellow]Estimated ${total_usd:.2f} exceeds --max-spend ${max_spend:.2f}.[/yellow]"
        )
        if yes:
            console.print("  [red]Refusing to start.[/red] Raise --max-spend or lower --confirm.")
            return False
        return click.confirm("  Continue anyway?", default=False)

    return True if yes else click.confirm("  Run?", default=True)


@main.command()
@click.argument("file", required=False)
@click.option("--drop", "drop", required=True, help="Section ids to remove, e.g. s11,s06.")
@click.option("--granularity", default="section", type=click.Choice(["section", "bullet"]),
              help="Must match the granularity `ablate ablate` was run with.")
@click.option("--write", "do_write", is_flag=True, default=False, help="Actually edit the file.")
def apply(file: str | None, drop: str, granularity: str, do_write: bool) -> None:
    """Remove sections from a config, archiving them first.

    Previews by default; `--write` performs the edit. Removed sections are
    saved under `.ablate/removed/` with a provenance header, so nothing here
    is unrecoverable.
    """
    repo = _repo_root()
    target = _resolve_config(repo, file)
    text = target.read_text()
    config_path = str(target.relative_to(repo))

    try:
        plan = plan_apply(
            text,
            split_sections(text, granularity=granularity),
            [s.strip() for s in drop.split(",") if s.strip()],
            config_path=config_path,
            command=f"ablate apply --drop {drop} {config_path}",
            commit=_head_commit(repo),
            version=_version(),
        )
    except AblateError as e:
        console.print(f"[red]{e}[/red]")
        sys.exit(1)

    console.print()
    console.print(f"  [bold]{config_path}[/bold] — removing {len(plan.dropped)} section(s)")
    console.print()
    for section in plan.dropped:
        console.print(
            f"    [red]−[/red] {section.id}  {section.title}  "
            f"[dim]lines {section.start_line}-{section.end_line}, "
            f"~{section.tokens:,} tokens[/dim]"
        )
    console.print()
    console.print(
        f"  {plan.lines_removed:,} lines, ~{plan.tokens_saved:,} tokens per turn."
    )

    if plan.removes_everything:
        console.print(
            "  [yellow]This removes every section — the file will be empty. "
            "`ablate compare CLAUDE.md /dev/null` measures that directly.[/yellow]"
        )

    if not do_write:
        console.print()
        console.print("  [dim]Preview only. Re-run with --write to apply.[/dim]")
        console.print()
        return

    archive = write_apply(plan, repo)
    console.print()
    console.print(f"  [green]Wrote[/green] {config_path}")
    console.print(f"  [green]Archived[/green] {archive.relative_to(repo)}")
    console.print(
        "  [dim]Nothing is lost — the archive holds every removed section "
        "verbatim, with a header saying where it came from.[/dim]"
    )
    console.print()


def _head_commit(repo: Path) -> str | None:
    try:
        out = subprocess.run(
            ["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
            capture_output=True, text=True, check=True,
        )
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None
    return out.stdout.strip() or None


# ADR-0009: `ablate ablate CLAUDE.md` reads badly, so the same command is
# registered as `ablate sections` and that is the form used in the docs and in
# any screenshot. Same callback, so the two can never drift.
main.add_command(ablate_cmd, name="sections")


@main.command()
def mine() -> None:
    """Experimental: propose tasks from git history."""
    _not_yet("ablate mine", "M5 (post-v1)")


if __name__ == "__main__":
    main()
