"""Test-set selection: tier filter + quarantine skip + budget knapsack.

`select_tests` is the one pure function the rest of the supervisor
needs. Given the full test inventory, the user's tier/budget flags,
and the quarantine + duration state, it returns the ordered list
of test_ids to actually run, plus a `SelectionReport` explaining
why each candidate test ended up included / skipped.

The knapsack is intentionally simple greedy — perfect optimality
isn't worth the complexity given typical test counts (< 10k). The
greedy heuristic: tier-0 ALWAYS included, then sort the remaining
in-tier candidates by `value / cost` (value = recency-of-failure
+ 1; cost = estimated duration), greedily admit until budget.
"""

from __future__ import annotations

from dataclasses import dataclass, field

from . import tiers as tier_mod


@dataclass(frozen=True, slots=True)
class TestCandidate:
    """One test, as seen by the selector. `estimated_duration_s`
    comes from the duration store; missing tests default to the
    tier's typical.

    Example::

        TestCandidate(test_id="t.py::test_x", markers=("smoke",),
                      effective_tier=0, estimated_duration_s=0.1, recency_score=1.0)
    """

    test_id: str
    markers: tuple[str, ...]
    effective_tier: int
    estimated_duration_s: float
    recency_score: float  # higher = ran-and-failed recently; surface this one first


@dataclass
class SelectionReport:
    """Audit trail for the contributor — every input test is
    accounted for. Printed before the run starts so the contributor
    can see what they're getting (and what they're missing).

    Example::

        report = select_tests(candidates, allowed_tiers={0},
                              skip_quarantined=True, quarantined_ids=frozenset(),
                              budget_s=None)
        report.selected  # -> [test_id, ...]
    """

    selected: list[str] = field(default_factory=list)
    skipped_wrong_tier: list[str] = field(default_factory=list)
    skipped_quarantined: list[str] = field(default_factory=list)
    skipped_budget: list[str] = field(default_factory=list)
    estimated_total_s: float = 0.0

    @property
    def total_input(self) -> int:
        return (
            len(self.selected)
            + len(self.skipped_wrong_tier)
            + len(self.skipped_quarantined)
            + len(self.skipped_budget)
        )


def select_tests(
    candidates: list[TestCandidate],
    *,
    allowed_tiers: set[int],
    skip_quarantined: bool,
    quarantined_ids: frozenset[str],
    budget_s: float | None,
) -> SelectionReport:
    """Filter + budget. Returns a SelectionReport with the final
    selection + explanations for every excluded test.

    Selection order in the final list: tier ascending (tier-0
    first), then by test_id alphabetic — deterministic so the same
    inputs always produce the same selection (helps debugging).


    Example::

        select_tests(candidates, allowed_tiers={0, 1}, skip_quarantined=True,
                     quarantined_ids=frozenset(), budget_s=30.0)
    """
    report = SelectionReport()

    in_tier: list[TestCandidate] = []
    for c in candidates:
        if c.effective_tier not in allowed_tiers:
            report.skipped_wrong_tier.append(c.test_id)
            continue
        if skip_quarantined and c.test_id in quarantined_ids:
            report.skipped_quarantined.append(c.test_id)
            continue
        in_tier.append(c)

    if budget_s is None:
        # No budget — take everything in tier.
        for c in in_tier:
            report.selected.append(c.test_id)
            report.estimated_total_s += c.estimated_duration_s
        _finalize_order(report, in_tier)
        return report

    # Budget mode. tier-0 always included; remaining sorted by
    # value/cost greedy.
    must_include = [c for c in in_tier if c.effective_tier == 0]
    optional = [c for c in in_tier if c.effective_tier > 0]

    spent = 0.0
    for c in must_include:
        report.selected.append(c.test_id)
        spent += c.estimated_duration_s
    report.estimated_total_s = spent

    if spent > budget_s:
        # Even tier-0 alone overspends — that's fine, we still run
        # them; budget is a soft hint, not a hard cap. Just don't
        # add anything else.
        for c in optional:
            report.skipped_budget.append(c.test_id)
        _finalize_order(report, must_include)
        return report

    # Greedy: sort by value/cost descending. Recent failures rank
    # high (we want to know if they're still broken); fast tests
    # also rank high (cost in denominator).
    def value_per_cost(c: TestCandidate) -> float:
        # +1 so a never-failed test still has positive value;
        # +0.001s in denominator to avoid div/0 on instant tests.
        return (c.recency_score + 1.0) / (c.estimated_duration_s + 0.001)

    optional.sort(key=value_per_cost, reverse=True)

    for c in optional:
        if spent + c.estimated_duration_s <= budget_s:
            report.selected.append(c.test_id)
            spent += c.estimated_duration_s
        else:
            report.skipped_budget.append(c.test_id)
    report.estimated_total_s = spent
    _finalize_order(report, [c for c in in_tier if c.test_id in report.selected])
    return report


def _finalize_order(report: SelectionReport, included: list[TestCandidate]) -> None:
    """Sort the selected list by (tier, test_id) for deterministic
    execution order. Tier-0 first so smoke runs early; alphabetic
    within tier so reruns are stable."""
    by_id = {c.test_id: c for c in included}
    report.selected.sort(key=lambda tid: (by_id[tid].effective_tier, tid))


def build_candidates(
    test_ids_with_markers: list[tuple[str, tuple[str, ...]]],
    durations: dict[str, float],
    recency: dict[str, float],
    auto_tier_overrides: dict[str, int],
) -> list[TestCandidate]:
    """Wrap raw pytest collection output + state files into
    TestCandidate records. The supervisor calls this once per run
    after collection.

    Example::

        cands = build_candidates(test_ids_with_markers, durations={}, recency={},
                                 auto_tier_overrides={})
    """
    out: list[TestCandidate] = []
    for test_id, markers in test_ids_with_markers:
        base = tier_mod.tier_for_markers(markers)
        effective = max(base, auto_tier_overrides.get(test_id, base))
        est = durations.get(test_id) or tier_mod.TIERS[effective].per_test_seconds
        out.append(
            TestCandidate(
                test_id=test_id,
                markers=markers,
                effective_tier=effective,
                estimated_duration_s=est,
                recency_score=recency.get(test_id, 0.0),
            )
        )
    return out


__all__ = [
    "SelectionReport",
    "TestCandidate",
    "build_candidates",
    "select_tests",
]
