"""Test tier overlay + auto-tier from observed durations.

Tiers map tests onto time-budgets (a tier-0 test must fit in
sub-second budgets; a tier-3 test may take minutes). They are
ORTHOGONAL to markers: markers say "what KIND of test this is"
(taxonomy), tiers say "how MUCH TIME this test costs" (budget).
A `smoke` test is tier-0 because smoke tests are fast by definition;
a `stress` test is tier-3 because stress tests are slow by design.

The overlay is the DEFAULT assignment. The auto-tier override
kicks in after 5 observed runs: if a test is consistently 5×
slower than its tier's typical, it's bumped up (with a recorded
reason). The marker-suggested tier is the floor — tests are never
auto-bumped DOWN below their marker (otherwise a fluke fast run
could hide a stress test in tier-1).

Federation reuse: copy this file as-is. The only project-specific
knob is `MARKER_TIER` — adjust the marker → tier mapping for the
project's marker set.
"""

from __future__ import annotations

from typing import NamedTuple

# Marker → default tier. Unmarked tests (pure unit) fall back to
# DEFAULT_TIER. Multiple markers per test: the HIGHEST tier wins
# (a test marked both `smoke` and `e2e` is tier-3).
MARKER_TIER: dict[str, int] = {
    "smoke": 0,  # build-broken detector; <1s each
    "contract": 1,  # wire-format pins
    "property": 1,  # invariant under all inputs
    "regression": 1,  # cites a fixing commit
    "fuzz": 1,  # in-default fast fuzzers
    "conformance": 2,  # design promises (G1–G4 / S1–S5)
    "use_case": 2,  # consumer scenarios from FEEDBACK/recipes
    "concurrency": 2,  # real-threads contention tests
    "integration": 2,  # cross-module integration, may launch subprocesses (PAPER-62, v0.1.4+)
    "perf": 3,  # latency / throughput budgets
    "stress": 3,  # sustained high-load
    "e2e": 3,  # live subprocess broker
    "slow": 3,  # explicitly slow-by-design (PAPER-62, v0.1.4+)
}
# Keep in lockstep with mgf.common.testing.MARKER_TIER —
# tests/unit/testing/test_supervisor_alignment.py pins the
# alignment. Per TS-23 the supervisor stays stdlib-only, hence
# the duplication.
DEFAULT_TIER = 1


class TierBudget(NamedTuple):
    """Per-tier semantic intent + typical wall-clock budget.

    `typical_test_s` is the soft expectation for ONE test in this
    tier. Used by auto-tier to detect outliers: a test running at
    5× the typical gets bumped up.

    `total_budget_s` is the suite-wide soft cap when running all
    tests up through this tier (`mtest --tier 0-N`). Used by the
    contributor as a mental model, and as the default `--budget`
    for `mtest --tier N`.


    Example::

        TierBudget(name="tier-0", typical_test_s=0.1, total_budget_s=1.0,
                   intent="Build-broken signal")
    """

    name: str
    typical_test_s: float
    total_budget_s: float
    intent: str


# The four tiers — semantic + budget. Federation projects can adjust
# the budgets but the SEMANTIC intent (and the numbering 0..3) is
# pinned by MGF-TS-22.
TIERS: tuple[TierBudget, ...] = (
    TierBudget("tier-0", 0.10, 1.0, "Build-broken signal"),
    TierBudget("tier-1", 0.30, 30.0, "Daily contract"),
    TierBudget("tier-2", 2.00, 120.0, "Promise pins"),
    TierBudget("tier-3", 10.0, 1800.0, "Long signal (stress / perf / e2e)"),
)


def tier_for_markers(markers: tuple[str, ...]) -> int:
    """The default tier for a test with these markers.

    Highest-wins: a test marked both `smoke` and `stress` is tier-3.
    This is the conservative choice — better to over-budget a fast
    test than to under-budget a slow one.


    Example::

        tier_for_markers(("smoke", "slow"))  # -> highest tier among the markers
    """
    explicit = [MARKER_TIER[m] for m in markers if m in MARKER_TIER]
    if not explicit:
        return DEFAULT_TIER
    return max(explicit)


def auto_tier(
    base_tier: int,
    durations_s: list[float],
    min_observations: int = 5,
) -> tuple[int, str | None]:
    """Return (effective_tier, reason). Reason is None when the
    base tier is unchanged.

    Bumps UP only — never below the marker-suggested floor — when
    a test consistently runs 5× longer than its tier's typical.
    Requires `min_observations` runs of data; new tests (or tests
    whose marker just changed) start at the base tier and only
    drift after enough signal accumulates.


    Example::

        auto_tier(base_tier=0, durations_s=[12.0, 11.5, 13.0, 12.2, 11.9])
        # -> (3, 'auto-bumped: slow')
    """
    if len(durations_s) < min_observations:
        return base_tier, None
    # Simple mean. (Stdlib `statistics.mean` would work too but
    # this avoids the import for files that don't need it.)
    mean_s = sum(durations_s) / len(durations_s)
    typical = TIERS[base_tier].typical_test_s
    # Bump up if mean > 5x typical, stopping at tier-3.
    if mean_s > 5 * typical and base_tier < 3:
        new_tier = base_tier + 1
        return (
            new_tier,
            f"observed mean {mean_s:.2f}s exceeds tier-{base_tier} "
            f"typical {typical:.2f}s by 5x; auto-promoted",
        )
    return base_tier, None


def parse_tier_range(spec: str) -> set[int]:
    """Parse a tier spec like `0`, `0-2`, `1-3`, `2`.

    Returns the set of included tiers. Raises ValueError for
    malformed input (caller turns it into a friendly error).


    Example::

        parse_tier_range("0-2")  # -> {0, 1, 2}
        parse_tier_range("1")    # -> {1}
    """
    spec = spec.strip()
    if "-" in spec:
        lo_s, hi_s = spec.split("-", 1)
        lo, hi = int(lo_s), int(hi_s)
    else:
        lo = hi = int(spec)
    if not (0 <= lo <= hi <= 3):
        raise ValueError(f"tier spec {spec!r} out of range — must be 0..3 (saw lo={lo}, hi={hi})")
    return set(range(lo, hi + 1))


__all__ = [
    "DEFAULT_TIER",
    "MARKER_TIER",
    "TIERS",
    "TierBudget",
    "auto_tier",
    "parse_tier_range",
    "tier_for_markers",
]
