"""The four-verdict taxonomy (docs/05-statistics.md).

Every comparison resolves to exactly one of four verdicts. This is the core UX
decision of the product: it is what makes "no effect" a usable answer instead
of a disappointment.

The two null-ish verdicts are the point. Frequentist convention collapses
"we looked hard and there is nothing there" and "we did not look hard enough"
into the same non-rejection, and that conflation is the specific thing that
makes people distrust eval tools. Here they are opposite findings:

    EQUIVALENT    a positive result. The effect is bounded within ±margin.
                  This is the finding that licenses deletion.
    INCONCLUSIVE  an admission. The data are consistent with too much, and
                  the verdict carries the price of resolving it.

So EQUIVALENT is deliberately not grey, and never shares a colour or a
sentence with INCONCLUSIVE.
"""

from __future__ import annotations

from dataclasses import dataclass
from enum import Enum

from ablate.stats.bootstrap import BootstrapResult
from ablate.stats.power import DesignRequirement, required_design

#: Default equivalence margins. Below this, a difference is treated as
#: practically nil — the TOST band, in bootstrap clothing.
DEFAULT_MARGIN_PASS_RATE = 0.05  # 5 percentage points
DEFAULT_MARGIN_CONTINUOUS = 0.05  # 5% of the baseline


class Verdict(Enum):
    """Named from arm A's perspective: A is the config under test, B the baseline.

    That orientation is what makes `--fail-on regression` mean something
    unambiguous in CI, where A is the PR's config and B is the base branch.
    """

    IMPROVEMENT = "IMPROVEMENT"
    REGRESSION = "REGRESSION"
    EQUIVALENT = "EQUIVALENT"
    INCONCLUSIVE = "INCONCLUSIVE"

    @property
    def colour(self) -> str:
        return {
            Verdict.IMPROVEMENT: "green",
            Verdict.REGRESSION: "red",
            Verdict.EQUIVALENT: "blue",
            Verdict.INCONCLUSIVE: "yellow",
        }[self]

    @property
    def glyph(self) -> str:
        return "●"

    @property
    def is_decisive(self) -> bool:
        return self in (Verdict.IMPROVEMENT, Verdict.REGRESSION)


@dataclass(frozen=True)
class MetricVerdict:
    """A verdict plus everything needed to state it honestly.

    `benefit_lo`/`benefit_hi` are the interval re-expressed so that positive
    always means "better for A", regardless of whether the underlying metric
    is one where higher or lower wins. Report code should print the raw delta
    but reason about the verdict from these.
    """

    metric: str
    verdict: Verdict
    delta: float
    lo: float | None
    hi: float | None
    benefit_lo: float | None
    benefit_hi: float | None
    margin: float
    higher_is_better: bool
    relative: bool
    result: BootstrapResult
    requirement: DesignRequirement | None = None

    @property
    def has_ci(self) -> bool:
        return self.lo is not None and self.hi is not None

    @property
    def excludes_zero(self) -> bool:
        """The interval is entirely on one side of zero.

        An INCONCLUSIVE verdict can mean two opposite things, and they need
        different sentences. Either the data are too noisy to see anything, or
        — as here — there is plainly an effect and the only open question is
        whether it clears the margin the user set. Calling the second one
        "underpowered" reads as nonsense next to an interval like [−57, −28].
        """
        return self.has_ci and (self.benefit_lo > 0.0 or self.benefit_hi < 0.0)


def classify(
    result: BootstrapResult,
    *,
    metric: str,
    margin: float,
    higher_is_better: bool = True,
    p_a: float = 0.5,
    p_b: float = 0.5,
    n_trials: int = 5,
) -> MetricVerdict:
    """Resolve one metric's interval into one of the four verdicts.

    Without an interval the verdict is always INCONCLUSIVE. A point estimate
    can never establish equivalence, and treating a missing interval as "no
    difference found" would invert the tool's entire purpose.
    """
    sign = 1.0 if higher_is_better else -1.0

    if not result.has_ci:
        benefit_lo = benefit_hi = None
        verdict = Verdict.INCONCLUSIVE
    else:
        # Flip to the "benefit to A" scale; flipping reverses the endpoints.
        endpoints = sorted((sign * result.lo, sign * result.hi))
        benefit_lo, benefit_hi = endpoints

        if benefit_lo > margin:
            verdict = Verdict.IMPROVEMENT
        elif benefit_hi < -margin:
            verdict = Verdict.REGRESSION
        elif -margin <= benefit_lo and benefit_hi <= margin:
            verdict = Verdict.EQUIVALENT
        else:
            verdict = Verdict.INCONCLUSIVE

    # Only INCONCLUSIVE needs a price quote: it is the one verdict that is an
    # admission rather than a finding, and rule 3 of "how to say
    # indistinguishable from noise" says it must always carry the cost of an
    # answer and the command that buys it.
    #
    # But a price is only honest when a bigger design would actually help. When
    # the interval already excludes zero and merely straddles the margin, more
    # trials are not the constraint — the margin is — and quoting a design
    # *smaller* than the one just run (which the arithmetic will happily do for
    # a wide margin) would be absurd.
    requirement = None
    if verdict is Verdict.INCONCLUSIVE:
        candidate = required_design(margin, n_trials=max(n_trials, 1), p_a=p_a, p_b=p_b)
        already_run = result.n_tasks * max(n_trials, 1) * 2
        if candidate.n_runs > already_run:
            requirement = candidate

    return MetricVerdict(
        metric=metric,
        verdict=verdict,
        delta=result.delta,
        lo=result.lo,
        hi=result.hi,
        benefit_lo=benefit_lo,
        benefit_hi=benefit_hi,
        margin=margin,
        higher_is_better=higher_is_better,
        relative=result.relative,
        result=result,
        requirement=requirement,
    )


def _fmt(value: float, relative: bool) -> str:
    """Percentage points for rates, percent for relative metrics."""
    if relative:
        return f"{value * 100:+.1f}%"
    return f"{value * 100:+.1f}pp"


def describe(mv: MetricVerdict, *, label_a: str = "A", label_b: str = "B") -> str:
    """One sentence stating the finding, in the register docs/05 prescribes.

    Rule 1 of that section: never print a bare "no significant difference".
    Every sentence here either names an interval or names what it would cost
    to have one.
    """
    if not mv.has_ci:
        reason = mv.result.ci_withheld or "no interval available"
        return f"No interval: {reason}"

    interval = f"[{_fmt(mv.lo, mv.relative)}, {_fmt(mv.hi, mv.relative)}]"
    magnitude = _fmt(mv.delta, mv.relative)
    margin = _fmt(mv.margin, mv.relative).lstrip("+")

    # "regresses by +47.2%" is a contradiction to read: on a lower-is-better
    # metric the delta's sign points the opposite way to the verdict. Stating
    # the direction as a verb and the delta as a plain signed quantity keeps
    # the sentence true whichever way the metric runs.
    if mv.verdict in (Verdict.IMPROVEMENT, Verdict.REGRESSION):
        verb = "improves" if mv.verdict is Verdict.IMPROVEMENT else "regresses"
        direction = "up" if mv.delta > 0 else "down"
        return (
            f"{label_a} measurably {verb} {mv.metric}: {direction} {magnitude.lstrip('+-')} "
            f"{interval}."
        )
    if mv.verdict is Verdict.EQUIVALENT:
        return (
            f"No detectable difference. The effect is at most ±{margin} in either "
            f"direction {interval}. Safe to prefer the cheaper config."
        )

    if mv.excludes_zero:
        # There is an effect; the question is only whether it clears the
        # margin. More trials are not what is missing.
        return (
            f"{label_a} changes {mv.metric} by {magnitude} {interval} — a real "
            f"effect, but the interval straddles the ±{margin} margin, so this run "
            f"cannot say whether the change is larger than the threshold you set. "
            f"Lower --margin to decide it on this data."
        )

    sentence = (
        f"Underpowered. The data are consistent with anything from "
        f"{_fmt(mv.lo, mv.relative)} to {_fmt(mv.hi, mv.relative)}."
    )
    if mv.requirement is not None:
        req = mv.requirement
        if req.feasible:
            sentence += (
                f" To resolve to ±{margin} you would need roughly "
                f"{req.n_trials} trials × {req.n_tasks} tasks ({req.n_runs:,} runs)."
            )
        else:
            sentence += (
                f" Resolving to ±{margin} is not reachable at any practical suite "
                f"size — between-task variation alone exceeds it."
            )
    return sentence
