"""Tests for the operating model.

Run against your own work:      pytest
Run against the solution:       LAB_MODULE=solution pytest
"""

from __future__ import annotations

import importlib
import os
from typing import List, Sequence

import pytest

lab = importlib.import_module(os.environ.get("LAB_MODULE", "lab"))

Criticality = lab.Criticality
OrrCriterion = lab.OrrCriterion
ORR_CRITERIA = lab.ORR_CRITERIA
OrrAnswer = lab.OrrAnswer
OrrScorer = lab.OrrScorer
BudgetState = lab.BudgetState
ChangeClass = lab.ChangeClass
STATE_POLICY = lab.STATE_POLICY
PolicyError = lab.PolicyError
ErrorBudgetPolicy = lab.ErrorBudgetPolicy
DecisionClass = lab.DecisionClass
classify_decision = lab.classify_decision
REQUIRED_SIGNERS = lab.REQUIRED_SIGNERS
DisagreementKind = lab.DisagreementKind
Position = lab.Position
classify_disagreement = lab.classify_disagreement
AdrStatus = lab.AdrStatus
AdrError = lab.AdrError
AdrStore = lab.AdrStore
DesignDocument = lab.DesignDocument
STANDING_RULES = lab.STANDING_RULES
DesignReview = lab.DesignReview
ActionStatus = lab.ActionStatus
Incident = lab.Incident
IncidentTracker = lab.IncidentTracker
FORUMS = lab.FORUMS
forum_brief = lab.forum_brief


# ======================================================================================
# helpers
# ======================================================================================


def clock(start: int = 0, step: int = 1):
    state = {"t": start - step}

    def now() -> int:
        state["t"] += step
        return state["t"]

    return now


def frozen(value: int = 100):
    return lambda: value


OWNERS = ("alice", "bob")


def answers(*, fail: Sequence[str] = (), no_evidence: Sequence[str] = (),
            omit: Sequence[str] = ()) -> List[OrrAnswer]:
    out = []
    for criterion in ORR_CRITERIA:
        if criterion.criterion_id in omit:
            continue
        out.append(OrrAnswer(criterion.criterion_id,
                             criterion.criterion_id not in fail,
                             "" if criterion.criterion_id in no_evidence else "e://x"))
    return out


def policy(**kw) -> ErrorBudgetPolicy:
    kw.setdefault("owners", OWNERS)
    kw.setdefault("now", frozen(100))
    return ErrorBudgetPolicy(**kw)


def store(**kw) -> AdrStore:
    kw.setdefault("owners", OWNERS)
    kw.setdefault("now", clock())
    return AdrStore(**kw)


def proposal(s: AdrStore, **kw):
    base = dict(title="t", context="c", options=["a", "b"], decision="a",
                positive=["p"], negative=["n"],
                decision_class=DecisionClass.REVERSIBLE_INTERNAL)
    base.update(kw)
    return s.propose(**base)


def design(**kw) -> DesignDocument:
    base = dict(title="d", author="a", what_it_denies="x", blast_radius="x",
                degradation_behaviour="x", artifacts_emitted=("x",),
                operator_at_3am="x", dependencies=("x",), slo="x")
    base.update(kw)
    return DesignDocument(**base)


def tracker(**kw) -> IncidentTracker:
    kw.setdefault("now", clock(start=1_000))
    return IncidentTracker(**kw)


def running_incident(t: IncidentTracker, iid: str = "INC-1") -> str:
    t.record(Incident(iid, "x", "sev2", 1_000))
    return iid


# ======================================================================================
# 1. ORR criteria
# ======================================================================================


def test_every_criterion_states_what_evidence_is_required():
    for criterion in ORR_CRITERIA:
        assert criterion.evidence_required


def test_criterion_ids_are_unique():
    ids = [c.criterion_id for c in ORR_CRITERIA]
    assert len(ids) == len(set(ids))


def test_a_mandatory_criterion_cannot_carry_a_weight():
    with pytest.raises(ValueError, match="weight"):
        OrrCriterion("X", "q", Criticality.MANDATORY, 10, "c", "e")


def test_advisory_criteria_carry_weights():
    advisory = [c for c in ORR_CRITERIA if c.criticality is Criticality.ADVISORY]
    assert advisory and all(c.weight > 0 for c in advisory)


def test_the_gate_includes_agent_specific_criteria():
    categories = {c.category for c in ORR_CRITERIA}
    assert "agent" in categories


def test_alerts_must_be_tested_by_injecting_failure():
    alert = next(c for c in ORR_CRITERIA if "alert" in c.question.lower())
    assert "inject" in alert.question.lower() and \
        alert.criticality is Criticality.MANDATORY


def test_the_runbook_must_be_rehearsed_by_someone_outside_the_team():
    runbook = next(c for c in ORR_CRITERIA if "runbook" in c.question.lower())
    assert runbook.criticality is Criticality.MANDATORY


# ======================================================================================
# 2. ORR scoring
# ======================================================================================


def test_a_complete_orr_passes():
    assert OrrScorer().score("s", answers()).passed


def test_any_mandatory_failure_fails_at_any_score():
    result = OrrScorer().score("s", answers(fail=["ORR-03"]))
    assert not result.passed and result.advisory_percent == 100.0


def test_the_failure_names_the_criterion():
    result = OrrScorer().score("s", answers(fail=["ORR-03"]))
    assert "ORR-03" in result.mandatory_failures and any("ORR-03" in r
                                                          for r in result.reasons)


def test_an_unanswered_mandatory_criterion_is_a_failure():
    result = OrrScorer().score("s", answers(omit=["ORR-01"]))
    assert "ORR-01" in result.mandatory_failures


def test_an_unanswered_criterion_is_reported_separately():
    result = OrrScorer().score("s", answers(omit=["ORR-01"]))
    assert "ORR-01" in result.unanswered


def test_a_mandatory_claim_without_evidence_fails():
    result = OrrScorer().score("s", answers(no_evidence=["ORR-02"]))
    assert "ORR-02" in result.mandatory_failures and "ORR-02" in result.evidence_gaps


def test_the_evidence_failure_names_what_was_required():
    result = OrrScorer().score("s", answers(no_evidence=["ORR-02"]))
    assert any("fault-injection" in r for r in result.reasons)


def test_evidence_can_be_switched_off_for_a_dry_run():
    scorer = OrrScorer(require_evidence=False)
    assert scorer.score("s", answers(no_evidence=["ORR-02"])).passed


def test_an_advisory_failure_does_not_block_above_the_threshold():
    result = OrrScorer(advisory_threshold=50).score("s", answers(fail=["ORR-17"]))
    assert result.passed


def test_a_low_advisory_score_blocks():
    advisory = [c.criterion_id for c in ORR_CRITERIA
                if c.criticality is Criticality.ADVISORY]
    result = OrrScorer(advisory_threshold=70).score("s", answers(fail=advisory[:-1]))
    assert not result.passed and any("threshold" in r for r in result.reasons)


def test_the_advisory_score_sums_the_weights():
    result = OrrScorer().score("s", answers())
    expected = sum(c.weight for c in ORR_CRITERIA
                   if c.criticality is Criticality.ADVISORY)
    assert result.advisory_score == expected == result.advisory_max


def test_every_failure_is_reported_not_just_the_first():
    result = OrrScorer().score("s", answers(fail=["ORR-01", "ORR-03", "ORR-09"]))
    assert len(result.mandatory_failures) == 3


def test_an_empty_submission_fails_on_every_mandatory_criterion():
    result = OrrScorer().score("s", [])
    mandatory = [c.criterion_id for c in ORR_CRITERIA
                 if c.criticality is Criticality.MANDATORY]
    assert set(result.mandatory_failures) == set(mandatory)


def test_the_result_formats_readably():
    assert "PASS" in OrrScorer().score("s", answers()).format()


# ======================================================================================
# 3. the error-budget policy
# ======================================================================================


def test_two_distinct_owners_are_required():
    with pytest.raises(PolicyError):
        ErrorBudgetPolicy(owners=("alice", "alice"), now=frozen())


def test_a_healthy_budget_is_normal():
    assert policy().state_for(0.8) is BudgetState.NORMAL


def test_a_depleted_budget_is_elevated():
    assert policy().state_for(0.35) is BudgetState.ELEVATED


def test_a_low_budget_is_reliability_focus():
    assert policy().state_for(0.10) is BudgetState.RELIABILITY_FOCUS


def test_an_exhausted_budget_is_a_freeze():
    assert policy().state_for(0.0) is BudgetState.FREEZE


def test_normal_permits_everything():
    assert STATE_POLICY[BudgetState.NORMAL] == frozenset(ChangeClass)


def test_a_freeze_still_permits_emergency_fixes():
    assert ChangeClass.EMERGENCY_FIX in STATE_POLICY[BudgetState.FREEZE]


def test_a_freeze_still_permits_reliability_work():
    assert ChangeClass.RELIABILITY in STATE_POLICY[BudgetState.FREEZE]


def test_a_freeze_forbids_features():
    assert ChangeClass.FEATURE not in STATE_POLICY[BudgetState.FREEZE]


def test_each_state_permits_a_subset_of_the_looser_one():
    order = [BudgetState.FREEZE, BudgetState.RELIABILITY_FOCUS, BudgetState.ELEVATED,
             BudgetState.NORMAL]
    for tighter, looser in zip(order, order[1:]):
        assert STATE_POLICY[tighter] <= STATE_POLICY[looser]


def test_a_permitted_change_ships():
    assert policy().may_ship(ChangeClass.BUG_FIX, 0.10).permitted


def test_a_forbidden_change_is_blocked_with_a_reason():
    verdict = policy().may_ship(ChangeClass.FEATURE, 0.10)
    assert not verdict.permitted and verdict.reason


def test_an_exception_needs_both_owners():
    with pytest.raises(PolicyError, match="both owners"):
        policy().grant_exception(ChangeClass.FEATURE, reason="a genuine stated reason "
                                 "here", approved_by=["alice"])


def test_an_exception_needs_a_real_reason():
    with pytest.raises(PolicyError, match="reason"):
        policy().grant_exception(ChangeClass.FEATURE, reason="urgent",
                                 approved_by=list(OWNERS))


def test_an_exception_permits_an_otherwise_blocked_change():
    p = policy()
    p.grant_exception(ChangeClass.FEATURE,
                      reason="a regulatory deadline requires this to ship",
                      approved_by=list(OWNERS))
    verdict = p.may_ship(ChangeClass.FEATURE, 0.10)
    assert verdict.permitted and verdict.exception_used


def test_an_exception_is_consumed_once():
    p = policy()
    p.grant_exception(ChangeClass.FEATURE,
                      reason="a regulatory deadline requires this to ship",
                      approved_by=list(OWNERS))
    p.may_ship(ChangeClass.FEATURE, 0.10)
    assert not p.may_ship(ChangeClass.FEATURE, 0.10).permitted


def test_an_expired_exception_does_not_apply():
    now = clock(start=0)
    p = ErrorBudgetPolicy(owners=OWNERS, now=now)
    p.grant_exception(ChangeClass.FEATURE,
                      reason="a regulatory deadline requires this to ship",
                      approved_by=list(OWNERS), ttl_ticks=2)
    for _ in range(10):
        now()
    assert not p.may_ship(ChangeClass.FEATURE, 0.10).permitted


def test_an_exception_only_covers_its_own_change_class():
    p = policy()
    p.grant_exception(ChangeClass.FEATURE,
                      reason="a regulatory deadline requires this to ship",
                      approved_by=list(OWNERS))
    assert not p.may_ship(ChangeClass.EXPERIMENT, 0.10).permitted


def test_too_many_exceptions_in_a_window_are_refused():
    p = policy(max_exceptions_per_window=1)
    p.grant_exception(ChangeClass.FEATURE, reason="a genuine reason stated in full",
                      approved_by=list(OWNERS))
    with pytest.raises(PolicyError, match="habit"):
        p.grant_exception(ChangeClass.FEATURE, reason="another reason stated in full",
                          approved_by=list(OWNERS))


def test_the_exception_rate_is_reported():
    p = policy(max_exceptions_per_window=2)
    p.grant_exception(ChangeClass.FEATURE, reason="a genuine reason stated in full",
                      approved_by=list(OWNERS))
    assert p.exception_rate() == pytest.approx(0.5)


def test_decisions_are_recorded():
    p = policy()
    p.may_ship(ChangeClass.FEATURE, 0.9)
    p.may_ship(ChangeClass.FEATURE, 0.05)
    assert len(p.decisions) == 2


# ======================================================================================
# 4. the decision router
# ======================================================================================


def test_a_reversible_internal_decision_needs_one_signer():
    cls = classify_decision(reversible=True, externally_visible=False)
    assert REQUIRED_SIGNERS[cls] == 1


def test_an_irreversible_decision_needs_two_signers():
    cls = classify_decision(reversible=False, externally_visible=False)
    assert REQUIRED_SIGNERS[cls] == 2


def test_an_externally_visible_irreversible_decision_needs_two():
    cls = classify_decision(reversible=False, externally_visible=True)
    assert REQUIRED_SIGNERS[cls] == 2


def test_reversibility_is_what_drives_the_signer_count():
    reversible = classify_decision(reversible=True, externally_visible=True)
    irreversible = classify_decision(reversible=False, externally_visible=False)
    assert REQUIRED_SIGNERS[reversible] < REQUIRED_SIGNERS[irreversible]


def test_every_decision_class_has_a_signer_requirement():
    assert set(REQUIRED_SIGNERS) == set(DecisionClass)


# ======================================================================================
# 5. the disagreement protocol
# ======================================================================================


def position(owner: str = "alice", falsifier: str = "evidence") -> Position:
    return Position(owner, "s", "r", falsifier)


def test_both_sides_naming_a_falsifier_is_a_factual_disagreement():
    assert classify_disagreement([position("alice"), position("bob")]) is \
        DisagreementKind.FACTUAL


def test_neither_side_naming_one_is_a_values_disagreement():
    assert classify_disagreement([position("alice", ""), position("bob", "")]) is \
        DisagreementKind.VALUES


def test_one_side_naming_one_is_unclear():
    assert classify_disagreement([position("alice"), position("bob", "")]) is \
        DisagreementKind.UNCLEAR


def test_whitespace_does_not_count_as_a_falsifier():
    assert classify_disagreement([position("alice", "   "), position("bob", "  ")]) is \
        DisagreementKind.VALUES


def test_a_disagreement_needs_two_positions():
    with pytest.raises(ValueError):
        classify_disagreement([position()])


# ======================================================================================
# 6. the ADR store
# ======================================================================================


def test_an_adr_needs_at_least_two_options():
    with pytest.raises(AdrError, match="two options"):
        store().propose(title="t", context="c", options=["only"], decision="only",
                        positive=["p"], negative=["n"],
                        decision_class=DecisionClass.REVERSIBLE_INTERNAL)


def test_a_proposed_adr_starts_proposed():
    assert proposal(store()).status is AdrStatus.PROPOSED


def test_adr_ids_are_derived_and_sequential():
    s = store()
    assert [proposal(s).adr_id for _ in range(2)] == ["ADR-0001", "ADR-0002"]


def test_an_adr_without_negative_consequences_cannot_be_accepted():
    s = store()
    adr = proposal(s, negative=[])
    with pytest.raises(AdrError, match="negative"):
        s.accept(adr.adr_id, signers=["alice"])


def test_a_reversible_adr_needs_one_signer():
    s = store()
    adr = proposal(s)
    assert s.accept(adr.adr_id, signers=["alice"]).status is AdrStatus.ACCEPTED


def test_an_irreversible_adr_needs_two_signers():
    s = store()
    adr = proposal(s, decision_class=DecisionClass.IRREVERSIBLE_INTERNAL)
    with pytest.raises(AdrError, match="signer"):
        s.accept(adr.adr_id, signers=["alice"])


def test_the_same_signer_twice_is_one_signer():
    s = store()
    adr = proposal(s, decision_class=DecisionClass.IRREVERSIBLE_INTERNAL)
    with pytest.raises(AdrError):
        s.accept(adr.adr_id, signers=["alice", "alice"])


def test_both_owners_can_accept_an_irreversible_adr():
    s = store()
    adr = proposal(s, decision_class=DecisionClass.IRREVERSIBLE_INTERNAL)
    assert s.accept(adr.adr_id, signers=list(OWNERS)).status is AdrStatus.ACCEPTED


def test_a_non_owner_cannot_sign():
    s = store()
    adr = proposal(s)
    with pytest.raises(AdrError, match="not owners"):
        s.accept(adr.adr_id, signers=["mallory"])


def test_an_accepted_adr_cannot_be_accepted_again():
    s = store()
    adr = proposal(s)
    s.accept(adr.adr_id, signers=["alice"])
    with pytest.raises(AdrError):
        s.accept(adr.adr_id, signers=["alice"])


def test_an_accepted_adr_cannot_be_edited():
    s = store()
    adr = proposal(s)
    s.accept(adr.adr_id, signers=["alice"])
    with pytest.raises(AdrError, match="immutable"):
        s.amend(adr.adr_id, decision="something else")


def test_a_proposed_adr_also_cannot_be_edited():
    s = store()
    adr = proposal(s)
    with pytest.raises(AdrError):
        s.amend(adr.adr_id)


def test_an_accepted_adr_can_be_superseded():
    s = store()
    first = proposal(s)
    s.accept(first.adr_id, signers=["alice"])
    second = proposal(s, title="successor")
    s.accept(second.adr_id, signers=["alice"])
    assert s.supersede(first.adr_id, by=second.adr_id).status is AdrStatus.SUPERSEDED


def test_a_proposed_adr_cannot_be_superseded():
    s = store()
    first = proposal(s)
    second = proposal(s)
    s.accept(second.adr_id, signers=["alice"])
    with pytest.raises(AdrError, match="accepted"):
        s.supersede(first.adr_id, by=second.adr_id)


def test_the_successor_must_itself_be_accepted():
    s = store()
    first = proposal(s)
    s.accept(first.adr_id, signers=["alice"])
    second = proposal(s)
    with pytest.raises(AdrError):
        s.supersede(first.adr_id, by=second.adr_id)


def test_the_supersession_chain_is_walkable():
    s = store()
    ids = []
    for i in range(3):
        adr = proposal(s, title=f"v{i}")
        s.accept(adr.adr_id, signers=["alice"])
        ids.append(adr.adr_id)
    s.supersede(ids[0], by=ids[1])
    s.supersede(ids[1], by=ids[2])
    assert [a.adr_id for a in s.chain(ids[0])] == ids


def test_accepted_lists_only_accepted_adrs():
    s = store()
    a = proposal(s)
    proposal(s)
    s.accept(a.adr_id, signers=["alice"])
    assert [x.adr_id for x in s.accepted()] == [a.adr_id]


def test_a_rejected_adr_cannot_be_accepted():
    s = store()
    adr = proposal(s)
    s.reject(adr.adr_id, signers=["alice"])
    with pytest.raises(AdrError):
        s.accept(adr.adr_id, signers=["alice"])


def test_the_digest_is_stable():
    s = store()
    adr = proposal(s)
    assert adr.digest() == adr.digest()


def test_an_unknown_adr_raises():
    with pytest.raises(AdrError):
        store().get("ADR-9999")


# ======================================================================================
# 7. the design-review checklist
# ======================================================================================


def review(**kw):
    return DesignReview().review(design(**kw))


def test_a_complete_design_is_approved():
    assert review().approved


def test_a_missing_blast_radius_blocks():
    result = review(blast_radius="")
    assert not result.approved and any(f.rule == "blast-radius" for f in result.blockers)


def test_a_design_that_denies_nothing_blocks():
    assert any(f.rule == "what-does-it-deny" for f in review(what_it_denies="").blockers)


def test_a_missing_degradation_behaviour_blocks():
    assert any(f.rule == "degradation"
               for f in review(degradation_behaviour="").blockers)


def test_a_design_emitting_no_artifacts_blocks():
    assert any(f.rule == "emits-nothing" for f in review(artifacts_emitted=()).blockers)


def test_no_named_operator_blocks():
    assert any(f.rule == "who-operates-it" for f in review(operator_at_3am="").blockers)


def test_side_effecting_tools_without_idempotency_block():
    result = review(side_effecting_tools=("pay",), autonomy_band="assisted")
    assert any(f.rule == "idempotency" for f in result.blockers)


def test_side_effecting_tools_without_a_retry_policy_are_a_major_finding():
    result = review(side_effecting_tools=("pay",), idempotency="keyed",
                    autonomy_band="assisted")
    assert any(f.rule == "retry-policy" and f.severity == "major"
               for f in result.findings)


def test_restricted_data_without_residency_blocks():
    assert any(f.rule == "residency"
               for f in review(data_classifications=("restricted",)).blockers)


def test_internal_data_needs_no_residency_statement():
    assert review(data_classifications=("internal",)).approved


def test_side_effecting_tools_without_an_autonomy_band_block():
    result = review(side_effecting_tools=("pay",), idempotency="keyed",
                    retry_policy="none")
    assert any(f.rule == "autonomy-band" for f in result.blockers)


def test_an_irreversible_design_without_a_band_blocks():
    result = review(reversible=False)
    assert any(f.rule == "irreversible-unbanded" for f in result.blockers)


def test_a_missing_slo_is_a_major_finding_not_a_blocker():
    result = review(slo="")
    assert result.approved and any(f.rule == "slo" and f.severity == "major"
                                   for f in result.findings)


def test_no_dependencies_is_only_a_minor_finding():
    result = review(dependencies=())
    assert result.approved and any(f.severity == "minor" for f in result.findings)


def test_findings_are_sorted_most_severe_first():
    result = review(blast_radius="", slo="", dependencies=())
    severities = [f.severity for f in result.findings]
    assert severities == sorted(severities,
                                key={"blocker": 0, "major": 1, "minor": 2}.__getitem__)


def test_every_blocker_blocks():
    result = review(blast_radius="", what_it_denies="")
    assert not result.approved and len(result.blockers) == 2


def test_a_rule_that_raises_produces_a_blocker():
    def explodes(d):
        raise RuntimeError("rule bug")

    result = DesignReview([explodes]).review(design())
    assert not result.approved and any(f.rule == "rule-error" for f in result.blockers)


def test_the_standing_rules_cover_the_five_questions():
    result = DesignReview().review(DesignDocument(title="empty", author="a"))
    rules = {f.rule for f in result.blockers}
    assert {"what-does-it-deny", "blast-radius", "degradation", "emits-nothing",
            "who-operates-it"} <= rules


# ======================================================================================
# 8. incident tracking
# ======================================================================================


def test_an_incident_cannot_be_resolved_before_it_is_mitigated():
    t = tracker()
    running_incident(t)
    with pytest.raises(ValueError, match="mitigated"):
        t.resolve("INC-1")


def test_mitigation_then_resolution_records_both_times():
    t = tracker()
    running_incident(t)
    t.mitigate("INC-1")
    incident = t.resolve("INC-1")
    assert incident.time_to_mitigate is not None
    assert incident.time_to_resolve >= incident.time_to_mitigate


def test_an_action_needs_a_named_owner():
    t = tracker()
    running_incident(t)
    with pytest.raises(ValueError, match="owner"):
        t.add_action("INC-1", description="d", owner="", due_in=5)


def test_an_action_on_an_unknown_incident_raises():
    with pytest.raises(ValueError):
        tracker().add_action("INC-ghost", description="d", owner="alice", due_in=5)


def test_a_completed_action_counts_toward_the_rate():
    t = tracker()
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=5)
    t.complete(a.action_id)
    assert t.health().completion_rate == pytest.approx(1.0)


def test_an_open_action_lowers_the_rate():
    t = tracker()
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=5)
    t.add_action("INC-1", description="e", owner="bob", due_in=5)
    t.complete(a.action_id)
    assert t.health().completion_rate == pytest.approx(0.5)


def test_dropping_an_action_needs_a_reason():
    t = tracker()
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=5)
    with pytest.raises(ValueError, match="reason"):
        t.drop(a.action_id, reason="no")


def test_a_dropped_action_is_excluded_from_the_denominator():
    t = tracker()
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=5)
    b = t.add_action("INC-1", description="e", owner="bob", due_in=5)
    t.complete(a.action_id)
    t.drop(b.action_id, reason="superseded by the other action")
    assert t.health().completion_rate == pytest.approx(1.0)


def test_a_dropped_action_is_still_counted_as_dropped():
    t = tracker()
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=5)
    t.drop(a.action_id, reason="no longer relevant after the fix")
    assert t.health().dropped == 1


def test_an_overdue_action_is_reported():
    now = clock(start=0)
    t = IncidentTracker(now=now)
    running_incident(t)
    t.add_action("INC-1", description="d", owner="alice", due_in=2)
    for _ in range(10):
        now()
    assert len(t.overdue()) == 1


def test_a_completed_action_is_never_overdue():
    now = clock(start=0)
    t = IncidentTracker(now=now)
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=2)
    t.complete(a.action_id)
    for _ in range(10):
        now()
    assert t.overdue() == []


def test_a_dropped_action_is_never_overdue():
    now = clock(start=0)
    t = IncidentTracker(now=now)
    running_incident(t)
    a = t.add_action("INC-1", description="d", owner="alice", due_in=2)
    t.drop(a.action_id, reason="the underlying issue was fixed differently")
    for _ in range(10):
        now()
    assert t.overdue() == []


def test_a_review_that_was_held_is_counted():
    t = tracker()
    running_incident(t)
    t.mitigate("INC-1")
    t.resolve("INC-1")
    t.hold_review("INC-1")
    assert t.health().reviews_held == 1


def test_a_resolved_incident_becomes_review_due():
    now = clock(start=0)
    t = IncidentTracker(now=now, review_due_ticks=3)
    running_incident(t)
    t.mitigate("INC-1")
    t.resolve("INC-1")
    for _ in range(10):
        now()
    assert t.health().reviews_due == 1


def test_no_actions_is_a_full_completion_rate():
    assert tracker().health().completion_rate == pytest.approx(1.0)


def test_the_health_line_formats_readably():
    t = tracker()
    running_incident(t)
    t.add_action("INC-1", description="d", owner="alice", due_in=5)
    assert "actions complete" in t.health().format()


# ======================================================================================
# 9. the forum playbook
# ======================================================================================


def test_there_are_five_forums():
    assert len(FORUMS) == 5


def test_every_forum_names_what_it_wants_and_what_to_bring():
    for forum in FORUMS:
        assert forum.wants and forum.artifact and forum.fails_when


def test_every_forum_points_at_a_phase():
    for forum in FORUMS:
        assert "Phase" in forum.from_phase


def test_a_forum_can_be_looked_up_by_name():
    assert forum_brief("Cyber").name == "Cyber"


def test_forum_lookup_is_case_insensitive():
    assert forum_brief("internal audit").name == "Internal Audit"


def test_an_unknown_forum_raises():
    with pytest.raises(KeyError):
        forum_brief("Marketing")


def test_the_forums_include_the_five_the_jd_names():
    names = {f.name.lower() for f in FORUMS}
    assert "cyber" in names and "model risk" in names and "internal audit" in names
