"""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 dataclasses import dataclass

# 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).
#
# The legacy `slow` marker (TS-18) completed its deprecation cycle
# and was REMOVED in M2 Wave-2 (2026-07): a test is slow BECAUSE of
# what it does (perf / stress / e2e), or auto-tier promotes it from
# observed durations. Do not re-add it.
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
}
# Keep in lockstep with mgf.common.testing.MARKER_TIER —
# tests/unit/test_alignment_with_mgf_common.py pins the alignment
# from this side, tests/unit/testing/test_supervisor_alignment.py
# from mgf-common's. Per TS-23 the supervisor stays stdlib-only,
# hence the duplication.
DEFAULT_TIER = 1


@dataclass(frozen=True)
class TierBudget:
    """Per-tier wall-clock budget + semantic intent.

    Field names are the federation-unified shape — identical to
    ``mgf.common.testing.TierBudget`` (M2 Wave-2 unification; the
    supervisor previously carried a NamedTuple with divergent field
    names ``typical_test_s`` / ``total_budget_s`` / ``intent``).
    The alignment tests pin names AND values on both sides.

    ``per_test_seconds`` 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.

    ``suite_seconds`` 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", per_test_seconds=0.1, suite_seconds=1.0,
                   description="Build-broken signal")
    """

    name: str
    per_test_seconds: float
    suite_seconds: float
    description: 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, ...] | list[str],
    override: int | None = None,
    mapping: dict[str, int] | None = None,
) -> int:
    """The effective tier for a test with these markers.

    Semantics match ``mgf.common.testing.effective_tier`` exactly
    (M2 Wave-2 unification; the alignment tests pin the equivalence):

    1. If ``override`` is not None, return it (an explicit
       ``@pytest.mark.tier(N)`` on the test). Out-of-range
       overrides raise ``ValueError``.
    2. Otherwise 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. Unknown markers are silently ignored.
    3. No mapped markers → :data:`DEFAULT_TIER`.

    ``mapping`` lets callers supply a project-local override of
    :data:`MARKER_TIER` (the federation default).


    Example::

        tier_for_markers(("smoke", "stress"))  # -> 3 (highest wins)
        tier_for_markers(("e2e",), override=0)  # -> 0 (explicit override)
    """
    if override is not None:
        if not 0 <= override <= 3:
            raise ValueError(f"tier override must be 0..3, got {override}")
        return override
    table = mapping or MARKER_TIER
    explicit = [table[m] for m in markers if m in table]
    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.

    ``base_tier`` is the MARKER-derived tier (``tier_for_markers``),
    not a constant: an ``integration`` test (tier-2) that overruns
    its budget is promoted to tier-3. Pre-Wave-2 callers hardcoded
    base 1, which capped every auto-promotion at tier-2 and ignored
    the marker floor entirely.


    Example::

        auto_tier(base_tier=0, durations_s=[12.0, 11.5, 13.0, 12.2, 11.9])
        # -> (1, 'observed mean 12.12s exceeds tier-0 typical 0.10s by 5x; auto-promoted')
    """
    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].per_test_seconds
    # 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",
]
