"""Machine- and human-readable report formats (docs/08-surfaces.md).

Three outputs beyond the terminal:

- **markdown** — for PR comments, blog posts and the launch artifact. Carries
  Method and Reproduce blocks, which are *not optional*: they are the
  difference between a screenshot people believe and a screenshot people
  argue with.
- **json** — the whole analysis, for scripting.
- **junit** — so CI shows a familiar red or green.

Every format states the MDE for the design that produced it. A green check
that does not say how much reassurance it is worth is theatre, and every other
CI eval integration omits this.
"""

from __future__ import annotations

import json
import math
from xml.etree import ElementTree as ET

from ablate.config.variant import ConfigVariant
from ablate.report.analysis import Analysis, MetricAnalysis
from ablate.stats import Verdict, describe, mde_continuous, mde_pass_rate

FORMATS = ("text", "md", "json", "junit", "pr-comment")

#: What every format prints where a number cannot be justified. One shared
#: token, so "unmeasured" is visually distinct from any real value and cannot
#: be mistaken for one at a glance.
NOT_REPORTED = "—"


def _finite(value: float | None) -> bool:
    return value is not None and math.isfinite(value)


def _fmt(value: float | None, relative: bool) -> str:
    """Format a delta, or say it was never measured.

    NaN reaches here whenever no task reported the metric in both arms —
    `bootstrap_delta` uses it deliberately, because zero would be a
    measurement. Formatting it with `%+.1f` printed "+nanpp" into terminals,
    PR comments and JUnit alike.
    """
    if not _finite(value):
        return NOT_REPORTED
    return f"{value * 100:+.1f}%" if relative else f"{value * 100:+.1f}pp"


def fmt_arm(metric: MetricAnalysis, arm: str) -> str:
    """One arm's value, or `—` when that arm reported nothing."""
    value = metric.arm_values.get(arm)
    return NOT_REPORTED if value is None else metric.spec.fmt(value)


def fmt_mde(value: float, relative: bool = False) -> str:
    """An MDE, or `—` when the design cannot resolve anything at all.

    With zero usable tasks the minimum detectable effect is infinite, which is
    the truthful answer and a useless thing to print as "infpp".
    """
    if not _finite(value):
        return NOT_REPORTED
    return f"{value * 100:.0f}%" if relative else f"{value * 100:.0f}pp"


def _interval(metric: MetricAnalysis) -> str:
    verdict = metric.verdict
    if not verdict.has_ci:
        return f"no interval — {verdict.result.ci_withheld}"
    return f"[{_fmt(verdict.lo, verdict.relative)}, {_fmt(verdict.hi, verdict.relative)}]"


def _design_mde(analysis: Analysis) -> tuple[float, float]:
    pass_rate = analysis.metric("pass_rate")
    rates = [v for v in (pass_rate.arm_values.values() if pass_rate else []) if v is not None]
    p_a, p_b = (rates + [0.5, 0.5])[:2]
    return (
        mde_pass_rate(analysis.n_tasks, analysis.n_trials, p_a=p_a, p_b=p_b),
        mde_continuous(analysis.n_tasks, analysis.n_trials),
    )


def render_markdown(
    analysis: Analysis,
    variants: list[ConfigVariant],
    *,
    command: str = "",
    version: str = "",
    repo: str | None = None,
    log_path: str | None = None,
) -> str:
    """The markdown report, with its Method and Reproduce blocks."""
    label_a = variants[0].source if variants and variants[0].text is not None else "no config"
    label_b = (
        variants[1].source if len(variants) > 1 and variants[1].text is not None else "none"
    )

    pass_rate = analysis.metric("pass_rate")
    tokens = analysis.metric("tokens_per_turn")

    if not any(m.measured for m in analysis.metrics):
        headline = "Verdict: nothing measured — this run produced no usable data"
    else:
        headline = "Verdict: "
        if pass_rate:
            headline += f"{pass_rate.verdict.verdict.value} on pass rate"
        if tokens and tokens.verdict.has_ci:
            headline += f", {_fmt(tokens.verdict.delta, True)} tokens per turn"

    lines = [
        f"# ablate report — {label_a} vs. {label_b}",
        "",
        f"**{headline}.**",
        "",
        "| Metric | A | B | Δ | 95% CI | Verdict |",
        "|---|---:|---:|---:|---|---|",
    ]

    a, b = analysis.variant_ids
    for metric in analysis.metrics:
        verdict = metric.verdict
        lines.append(
            f"| {metric.spec.label.title()} "
            f"| {fmt_arm(metric, a)} "
            f"| {fmt_arm(metric, b)} "
            f"| {_fmt(verdict.delta, verdict.relative)} "
            f"| {_interval(metric)} "
            f"| ● {verdict.verdict.value} |"
        )

    lines += ["", "<details><summary>Per-task breakdown (descriptive)</summary>", ""]
    lines += [f"| Task | {a} | {b} | Δ |", "|---|---:|---:|---:|"]
    for row in analysis.tasks:
        delta = "—" if row.delta is None else f"{row.delta * 100:+.0f}pp"
        flag = f" ⚠ {row.degenerate}" if row.degenerate else ""
        lines.append(
            f"| {row.task_id}{flag} | {row.passed[a]}/{row.total.get(a, 0)} "
            f"| {row.passed[b]}/{row.total.get(b, 0)} | {delta} |"
        )
    lines += ["", "No per-task tests are performed. These rows are descriptive.", "</details>", ""]

    if analysis.warnings:
        lines.append("### Warnings")
        lines.append("")
        lines += [f"- {w}" for w in analysis.warnings]
        lines.append("")

    for metric in analysis.metrics:
        if metric.verdict.verdict in (Verdict.EQUIVALENT, Verdict.INCONCLUSIVE):
            lines.append(f"**{metric.spec.label.title()}.** {describe(metric.verdict, label_a=label_a)}")
            lines.append("")

    pass_mde, token_mde = _design_mde(analysis)
    lines += [
        "## Method",
        "",
        "Paired cluster bootstrap (10,000 resamples, BCa), pairing at task level,",
        "resampling tasks then trials within arm. Δ is an average over this suite,",
        "for this agent at this model version — not a general claim about the config.",
        "",
        f"Equivalence margin ±{(pass_rate.verdict.margin * 100 if pass_rate else 5):.0f}pp. "
        f"α = {analysis.alpha}.",
        f"Minimum detectable effect for this design: **{fmt_mde(pass_mde)}** on pass rate, "
        f"**{fmt_mde(token_mde, relative=True)}** on tokens.",
        "A change smaller than that would not be visible here.",
        "",
        "## Reproduce",
        "",
        "```",
        command or "ablate compare ...",
        "```",
        "",
    ]

    provenance = []
    if analysis.agent:
        provenance.append(
            f"agent `{analysis.agent} {analysis.agent_version}`"
            if analysis.agent_version
            else f"agent `{analysis.agent}`"
        )
    if analysis.models:
        provenance.append(f"model `{', '.join(analysis.models)}`")
    if repo:
        provenance.append(f"repo `{repo}`")
    if version:
        provenance.append(f"ablate `{version}`")
    if provenance:
        lines.append(" · ".join(provenance))

    tail = f"{analysis.n_records} trials"
    if analysis.cost_known and analysis.total_cost_usd:
        tail += f" · ${analysis.total_cost_usd:.2f}"
    if log_path:
        tail += f" · raw log: [`{log_path}`]({log_path})"
    lines += [tail, ""]

    return "\n".join(lines)


#: Lets the Action find its own previous comment and edit it in place, instead
#: of adding a new one on every push.
STICKY_MARKER = "<!-- ablate-report -->"


def render_pr_comment(
    analysis: Analysis,
    variants: list[ConfigVariant],
    *,
    fail_on: str = "regression",
    report_url: str | None = None,
    log_url: str | None = None,
) -> str:
    """The sticky PR comment (docs/08-surfaces.md).

    Shorter than the full markdown report — a reviewer reads this inline — but
    it keeps the two things that make it trustworthy rather than decorative:
    an interval on every row, and the MDE in the footer. The MDE is what tells
    a reviewer how much a green check is actually worth, and every other CI
    eval integration omits it.
    """
    label_a = variants[0].source if variants and variants[0].text is not None else "config"
    tripped = [m for m in analysis.metrics if should_fail(m.verdict.verdict, fail_on)]

    # A green check requires having measured something. When every trial
    # errored — a bad API key, an exhausted quota, an unavailable model — no
    # metric trips `--fail-on`, and this line used to read "no regression
    # detected" on the PR. That is the single most damaging output this tool
    # can produce: it converts a total failure into a reviewer's approval, and
    # it is the exact claim the project promises never to make.
    if tripped:
        status = f"❌ {', '.join(m.spec.label.lower() for m in tripped)}"
    elif not any(m.measured for m in analysis.metrics):
        status = "⚠️ nothing measured — this run produced no usable data"
    else:
        status = "✅ no regression detected"

    a, b = analysis.variant_ids
    lines = [
        STICKY_MARKER,
        f"### ablate · `{label_a}` changed · {status}",
        "",
        "|  | base | this PR | Δ | 95% CI |",
        "|---|---:|---:|---:|---|",
    ]
    for metric in analysis.metrics:
        verdict = metric.verdict
        lines.append(
            f"| {metric.spec.label.title()} "
            f"| {fmt_arm(metric, b)} "
            f"| {fmt_arm(metric, a)} "
            f"| {_fmt(verdict.delta, verdict.relative)} "
            f"| {_interval(metric)} ● {verdict.verdict.value} |"
        )

    lines.append("")
    # Carried here as well as in the full report: a reviewer reads this inline
    # and may never open the artifact, so "every trial errored" has to be
    # visible at the point the decision is made.
    if analysis.warnings:
        lines += [f"> ⚠ {w}" for w in analysis.warnings]
        lines.append("")

    for metric in analysis.metrics:
        if metric.verdict.verdict in (Verdict.EQUIVALENT, Verdict.INCONCLUSIVE):
            lines.append(f"{describe(metric.verdict, label_a=label_a)}")
            lines.append("")

    pass_mde, _ = _design_mde(analysis)
    footer = (
        f"<sub>{analysis.n_tasks} tasks × {analysis.n_trials} trials × "
        f"{len(analysis.variant_ids)} arms · {analysis.n_records} runs"
    )
    if analysis.cost_known and analysis.total_cost_usd:
        footer += f" · ${analysis.total_cost_usd:.2f}"
    if analysis.models:
        footer += f" · {', '.join(analysis.models)}"
    footer += (
        f" · MDE for this design: {fmt_mde(pass_mde)}. A change smaller than "
        f"that would not be visible here."
    )
    if report_url:
        footer += f" [full report]({report_url})"
    if log_url:
        footer += f" · [raw log]({log_url})"
    lines.append(footer + "</sub>")

    return "\n".join(lines) + "\n"


def render_json(
    analysis: Analysis, variants: list[ConfigVariant], *, command: str = "", version: str = ""
) -> str:
    """The whole analysis, for scripting.

    `lo`/`hi` are null exactly when `ci_withheld` explains why. Consumers must
    handle that rather than defaulting a missing interval to zero — the same
    rule the terminal report follows.

    Everything unmeasured is `null`, and the document is serialized with
    `allow_nan=False`. Python's json module emits bare `NaN` and `Infinity`
    tokens by default; RFC 8259 has no such literals, so `JSON.parse` and Go's
    `encoding/json` both reject the file outright. This is the CI-facing
    surface, so it now fails loudly at write time rather than producing a
    report no standard parser can read.
    """
    pass_mde, token_mde = _design_mde(analysis)
    a, b = analysis.variant_ids

    payload = {
        "ablate_version": version,
        "run_id": analysis.run_id,
        "command": command,
        "agent": analysis.agent,
        "agent_version": analysis.agent_version,
        "models": list(analysis.models),
        "design": {
            "tasks": analysis.n_tasks,
            "trials": analysis.n_trials,
            "arms": list(analysis.variant_ids),
            "records": analysis.n_records,
            "errors": analysis.errors,
            "alpha": analysis.alpha,
            "seed": analysis.seed,
            "mde_pass_rate": _jsonable(pass_mde),
            "mde_tokens": _jsonable(token_mde),
        },
        "variants": [
            {"id": v.id, "source": v.source, "lines": v.line_count, "empty": v.text is None}
            for v in variants
        ],
        "metrics": [
            {
                "key": m.spec.key,
                "label": m.spec.label,
                "higher_is_better": m.spec.higher_is_better,
                "relative": m.spec.relative,
                "margin": m.verdict.margin,
                "measured": m.measured,
                "arms": {k: _jsonable(v) for k, v in m.arm_values.items()},
                "delta": _jsonable(m.verdict.delta),
                "lo": _jsonable(m.verdict.lo),
                "hi": _jsonable(m.verdict.hi),
                "ci_withheld": m.verdict.result.ci_withheld,
                "verdict": m.verdict.verdict.value,
                "method": m.verdict.result.method,
                "statement": describe(m.verdict),
            }
            for m in analysis.metrics
        ],
        "tasks": [
            {
                "task_id": t.task_id,
                "passed": t.passed,
                "total": t.total,
                "delta": t.delta,
                "degenerate": t.degenerate,
            }
            for t in analysis.tasks
        ],
        "cost_usd": analysis.total_cost_usd if analysis.cost_known else None,
        "warnings": list(analysis.warnings),
    }
    del a, b
    return json.dumps(payload, indent=2, sort_keys=False, allow_nan=False) + "\n"


def _jsonable(value: float | None) -> float | None:
    """NaN and ±Infinity become null; they have no JSON literal."""
    return value if _finite(value) else None


def render_junit(analysis: Analysis, *, fail_on: str = "regression") -> str:
    """One test case per metric, so CI shows a familiar red or green.

    The MDE goes in every case's output. A green check that does not say how
    much reassurance it is worth is theatre.
    """
    pass_mde, token_mde = _design_mde(analysis)
    suite = ET.Element(
        "testsuite",
        name="ablate",
        tests=str(len(analysis.metrics)),
        errors=str(analysis.errors),
    )

    failures = 0
    for metric in analysis.metrics:
        case = ET.SubElement(
            suite, "testcase", classname="ablate", name=metric.spec.key,
        )
        statement = describe(metric.verdict)
        if should_fail(metric.verdict.verdict, fail_on):
            failures += 1
            failure = ET.SubElement(
                case, "failure", message=f"{metric.verdict.verdict.value}: {statement}"
            )
            failure.text = statement
        else:
            ET.SubElement(case, "system-out").text = (
                f"{metric.verdict.verdict.value}: {statement}"
            )

    summary = (
        f"Design: {analysis.n_tasks} tasks × {analysis.n_trials} trials × "
        f"{len(analysis.variant_ids)} arms ({analysis.n_records} runs). "
        f"MDE for this design: {fmt_mde(pass_mde)} on pass rate, "
        f"{fmt_mde(token_mde, relative=True)} on tokens. A change smaller than that "
        f"would not be visible here."
    )
    # A CI pane showing three green cases for a run that measured nothing reads
    # as a pass. Say it in the suite output, where the summary is rendered.
    if not any(m.measured for m in analysis.metrics):
        summary = "NOTHING MEASURED — this run produced no usable data. " + summary
    ET.SubElement(suite, "system-out").text = summary
    suite.set("failures", str(failures))

    return ET.tostring(suite, encoding="unicode") + "\n"


def should_fail(verdict: Verdict, fail_on: str) -> bool:
    """`--fail-on` semantics, mapped onto the four verdicts.

    `inconclusive` is the strict setting — "prove it didn't break anything" —
    and it is expensive on purpose: it fails whenever the run was not powered
    enough to rule out a regression, which is an honest thing for a gate to
    demand and a costly one to satisfy.
    """
    if fail_on == "never":
        return False
    if fail_on == "regression":
        return verdict is Verdict.REGRESSION
    if fail_on == "inconclusive":
        return verdict in (Verdict.REGRESSION, Verdict.INCONCLUSIVE)
    raise ValueError(f"unknown --fail-on value {fail_on!r}")
