"""Lab 01 — The operating model, as code.

Two-in-a-box is a named operating model with specific mechanics, and shared accountability
without shared **instruments** is two people blaming each other after an incident.

This file builds the instruments. The organizing idea throughout:

    **A standard in code is a control. A standard in a wiki is a suggestion.**

Work top to bottom; each section's tests pass once that section is done:

  1.  ORR criteria
  2.  ORR scoring
  3.  the error-budget policy
  4.  the decision router
  5.  the disagreement protocol
  6.  the ADR store
  7.  the design-review checklist
  8.  incident tracking
  9.  the forum playbook

    pytest                     # against your work
    LAB_MODULE=solution pytest # against the reference

Determinism rules: the clock is injected, identifiers are derived, and every collection
you return is sorted.
"""

from __future__ import annotations

import hashlib
import json
from collections import defaultdict
from dataclasses import dataclass, field, replace
from enum import Enum
from typing import (Any, Callable, Dict, FrozenSet, Iterable, List, Mapping, Optional,
                    Sequence, Set, Tuple)

# ======================================================================================
# 1. The operational readiness review
# ======================================================================================


class Criticality(str, Enum):
    """Mandatory criteria are **not** weighted — they are gates.

    A weighted system where everything is weighted lets a high total paper over a missing
    runbook. Splitting the two is what makes the ORR a gate rather than a grade.
    """

    MANDATORY = "mandatory"
    ADVISORY = "advisory"


@dataclass(frozen=True)
class OrrCriterion:
    criterion_id: str
    question: str
    criticality: Criticality
    weight: int                       # advisory only; mandatory criteria are pass/fail
    category: str
    evidence_required: str            # WHAT proves it — not "confirm that you have"

    def __post_init__(self) -> None:
        """TODO: raise if a MANDATORY criterion carries a non-zero weight.

        Weighting a gate invites trading it away, which is exactly what must not happen.
        """
        raise NotImplementedError


#: TODO: the gate. The tests expect:
#:   * unique ids, and ``evidence_required`` on **every** row;
#:   * at least one criterion in the ``"agent"`` category;
#:   * an alerts criterion whose question contains "inject", MANDATORY;
#:   * a runbook criterion, MANDATORY;
#:   * advisory criteria carrying positive weights.
#:
#: Suggested mandatory set: SLOs instrumented · alerts tested **by injecting failure** ·
#: runbook rehearsed by someone outside the team · rollback tested · dependencies mapped
#: with blast radius · capacity headroom against the **provider** limit · on-call trained
#: · degradation ladder documented — plus the six agent-specific rows: eval suite passing,
#: red-team suite passing on **containment**, tool scopes reviewed, per-tenant cost
#: ceiling, autonomy band assigned, evidence pack generable.
#:
#: ``evidence_required`` is the field that turns a belief into a check.
ORR_CRITERIA: Tuple[OrrCriterion, ...] = ()


@dataclass(frozen=True)
class OrrAnswer:
    criterion_id: str
    satisfied: bool
    evidence: str = ""
    note: str = ""


@dataclass(frozen=True)
class OrrResult:
    service: str
    passed: bool
    advisory_score: int
    advisory_max: int
    mandatory_failures: Tuple[str, ...]
    unanswered: Tuple[str, ...]
    evidence_gaps: Tuple[str, ...]
    reasons: Tuple[str, ...]

    @property
    def advisory_percent(self) -> float:
        raise NotImplementedError

    def format(self) -> str:
        raise NotImplementedError


class OrrScorer:
    """Score an ORR. **Any mandatory failure is a fail, at any advisory score.**"""

    def __init__(self, criteria: Sequence[OrrCriterion] = ORR_CRITERIA, *,
                 advisory_threshold: float = 70.0,
                 require_evidence: bool = True) -> None:
        raise NotImplementedError

    def score(self, service: str, answers: Sequence[OrrAnswer]) -> OrrResult:
        """TODO: for each criterion —

          * **unanswered** -> record it, and for a MANDATORY criterion it is a
            **failure**. Silence is not a pass, or an ORR is completed by omission;
          * **not satisfied** -> a failure if mandatory;
          * **satisfied with no evidence** (when ``require_evidence``) -> a failure if
            mandatory, and the reason names what evidence was required. "Yes" with no
            artifact is a belief;
          * satisfied advisory -> add its weight.

        Pass requires **no mandatory failures** AND the advisory percentage at or above
        the threshold. Collect every reason, sorted.
        """
        raise NotImplementedError


# ======================================================================================
# 2. The error-budget policy machine
# ======================================================================================


class BudgetState(str, Enum):
    NORMAL = "normal"                       # > 50% remaining
    ELEVATED = "elevated"                   # 20-50%
    RELIABILITY_FOCUS = "reliability_focus" # < 20%
    FREEZE = "freeze"                       # exhausted


class ChangeClass(str, Enum):
    """Ordered by risk. The policy permits a *prefix* of this list."""

    EMERGENCY_FIX = "emergency_fix"     # always permitted; the platform is broken
    RELIABILITY = "reliability"         # makes the platform more reliable
    BUG_FIX = "bug_fix"
    CONFIG = "config"
    FEATURE = "feature"
    EXPERIMENT = "experiment"           # the riskiest, and the first to go


#: TODO: what each state permits. The tests pin:
#:   * NORMAL permits everything;
#:   * each tighter state's set is a **subset** of the looser one;
#:   * **FREEZE still permits EMERGENCY_FIX and RELIABILITY** — a freeze that blocks
#:     reliability work is a freeze that extends itself;
#:   * FREEZE forbids FEATURE.
STATE_POLICY: Mapping[BudgetState, FrozenSet[ChangeClass]] = {}


@dataclass(frozen=True)
class Exception_:
    """An exception to the policy. **Deliberately expensive.**

    Four properties, each closing a way a policy dies: it **expires**; **both owners**
    must approve; it names a **real reason**; and it is **counted**, because the exception
    rate is the health metric for the policy itself.
    """

    exception_id: str
    change_class: ChangeClass
    reason: str
    approved_by: Tuple[str, ...]
    granted_at: int
    expires_at: int
    used: bool = False

    def is_live(self, tick: int) -> bool:
        raise NotImplementedError


class PolicyError(Exception):
    pass


@dataclass(frozen=True)
class ChangeVerdict:
    permitted: bool
    state: BudgetState
    change_class: ChangeClass
    reason: str
    exception_used: Optional[str] = None


class ErrorBudgetPolicy:
    """State from budget remaining; permission from state; exceptions, expensively.

    **Signed before the first breach.** A policy agreed while the budget is healthy is a
    rule; one negotiated during a breach is an argument somebody wins on seniority.
    """

    def __init__(self, *, owners: Tuple[str, str], now: Callable[[], int],
                 thresholds: Mapping[BudgetState, float] = None,
                 max_exceptions_per_window: int = 2,
                 exception_window_ticks: int = 90) -> None:
        """TODO: raise ``PolicyError`` unless there are **two distinct** owners."""
        raise NotImplementedError

    def state_for(self, budget_remaining: float) -> BudgetState:
        """TODO: > 50% normal, > 20% elevated, > 0% reliability-focus, else freeze."""
        raise NotImplementedError

    def grant_exception(self, change_class: ChangeClass, *, reason: str,
                        approved_by: Sequence[str], ttl_ticks: int = 7) -> Exception_:
        """TODO: refuse unless **both owners** approve; refuse a reason under five words
        ("business need" is not a reason); refuse past ``max_exceptions_per_window`` in
        the window, saying the policy is being replaced by a habit and should be
        renegotiated instead. Derive the id (``exc-1``)."""
        raise NotImplementedError

    def recent_exceptions(self) -> List[Exception_]:
        raise NotImplementedError

    def live_exceptions(self) -> List[Exception_]:
        raise NotImplementedError

    def may_ship(self, change_class: ChangeClass, budget_remaining: float,
                 *, consume_exception: bool = True) -> ChangeVerdict:
        """TODO: permitted by the state, or by a **live, matching, unconsumed**
        exception — which is then marked used. Always give a reason, and record the
        verdict in ``decisions``."""
        raise NotImplementedError

    def exception_rate(self) -> float:
        """TODO: recent exceptions over the window limit. **The health metric for the
        policy itself** — two a quarter is a working policy, ten is a habit."""
        raise NotImplementedError


# ======================================================================================
# 3. The decision router and the disagreement protocol
# ======================================================================================


class DecisionClass(str, Enum):
    """Reversibility and visibility decide who must sign.

    A pair that requires both signatures on everything cannot move; one that requires
    neither is two people who will disagree in public later.
    """

    REVERSIBLE_INTERNAL = "reversible_internal"     # one owner
    REVERSIBLE_EXTERNAL = "reversible_external"     # one owner, the other informed
    IRREVERSIBLE_INTERNAL = "irreversible_internal" # both
    IRREVERSIBLE_EXTERNAL = "irreversible_external" # both, plus a forum


def classify_decision(*, reversible: bool, externally_visible: bool) -> DecisionClass:
    raise NotImplementedError


#: TODO: 1 signer for reversible, 2 for irreversible. Every class needs an entry.
REQUIRED_SIGNERS: Mapping[DecisionClass, int] = {}


class DisagreementKind(str, Enum):
    """Separating these is the protocol's central move.

    A **factual** disagreement has an answer: measure it. Most disagreements that feel
    like values turn out to be factual once somebody asks "what would change your mind?"

    A **values** disagreement does not, and the failure mode there is **averaging** — a
    design at the midpoint of two coherent positions is worse than either. Escalate both
    written positions instead.
    """

    FACTUAL = "factual"
    VALUES = "values"
    UNCLEAR = "unclear"


@dataclass(frozen=True)
class Position:
    owner: str
    summary: str
    rationale: str
    what_would_change_my_mind: str    # ← the field that classifies the disagreement


@dataclass(frozen=True)
class Disagreement:
    disagreement_id: str
    topic: str
    positions: Tuple[Position, ...]
    kind: DisagreementKind
    resolution: Optional[str] = None
    resolved_by: Optional[str] = None
    escalated_to: Optional[str] = None
    committed_by: Tuple[str, ...] = ()


def classify_disagreement(positions: Sequence[Position]) -> DisagreementKind:
    """TODO: FACTUAL when **every** position names a falsifier; VALUES when none does;
    UNCLEAR otherwise. Whitespace is not a falsifier. Raise on fewer than two positions.

    That is the operational test, and it is a good one: a position that nothing would
    change is not an engineering position, and asking surfaces it in a minute rather than
    a meeting.
    """
    raise NotImplementedError


# ======================================================================================
# 4. The ADR store
# ======================================================================================


class AdrStatus(str, Enum):
    PROPOSED = "proposed"
    ACCEPTED = "accepted"
    SUPERSEDED = "superseded"
    REJECTED = "rejected"


@dataclass(frozen=True)
class Adr:
    """One decision. Immutable once accepted; **superseded, never edited.**

    Editing an accepted ADR destroys what it exists for: a record of what was decided
    *and why, at the time, with the information then available*. A superseded ADR plus its
    successor says the decision changed and why; an edited one says neither.
    """

    adr_id: str
    title: str
    status: AdrStatus
    context: str
    options: Tuple[str, ...]
    decision: str
    positive_consequences: Tuple[str, ...]
    negative_consequences: Tuple[str, ...]     # ← required at acceptance
    decision_class: DecisionClass
    signers: Tuple[str, ...]
    created_at: int
    accepted_at: Optional[int] = None
    superseded_by: Optional[str] = None
    disagreement_id: Optional[str] = None

    def digest(self) -> str:
        """TODO: a stable digest over the content."""
        raise NotImplementedError


class AdrError(Exception):
    pass


class AdrStore:
    """Architectural memory that outlives both owners.

    Which is the actual justification: two-in-a-box means two people hold the
    architecture in their heads, and heads leave. The ADR is how the *reasoning*
    survives — and the reasoning is what a successor needs, not the decision.
    """

    def __init__(self, *, owners: Tuple[str, str], now: Callable[[], int]) -> None:
        raise NotImplementedError

    def propose(self, *, title: str, context: str, options: Sequence[str],
                decision: str, positive: Sequence[str], negative: Sequence[str],
                decision_class: DecisionClass,
                disagreement_id: Optional[str] = None) -> Adr:
        """TODO: refuse fewer than two options — one option is not a decision, it is a
        description. Ids are derived (``ADR-0001``)."""
        raise NotImplementedError

    def accept(self, adr_id: str, *, signers: Sequence[str]) -> Adr:
        """TODO: refuse unless PROPOSED; refuse with **no negative consequences**; refuse
        with fewer distinct signers than the decision class requires; refuse a signer who
        is not an owner.

        The negative-consequences requirement is the one that changes ADR quality. Every
        real decision costs something, and naming the cost is what lets a successor tell
        whether the trade-off still holds.
        """
        raise NotImplementedError

    def reject(self, adr_id: str, *, signers: Sequence[str]) -> Adr:
        raise NotImplementedError

    def supersede(self, adr_id: str, *, by: str) -> Adr:
        """TODO: only an ACCEPTED ADR can be superseded, and only **by** an accepted
        one."""
        raise NotImplementedError

    def amend(self, adr_id: str, **_: Any) -> Adr:
        """TODO: **always raise.** The method exists so the refusal is discoverable —
        somebody will look for a way to edit, and finding an explicit "no" with the reason
        is better than finding nothing."""
        raise NotImplementedError

    def get(self, adr_id: str) -> Adr:
        raise NotImplementedError

    def accepted(self) -> List[Adr]:
        raise NotImplementedError

    def all(self) -> List[Adr]:
        raise NotImplementedError

    def chain(self, adr_id: str) -> List[Adr]:
        """TODO: the supersession chain, oldest first. Raise on a cycle."""
        raise NotImplementedError


# ======================================================================================
# 5. The design-review checklist
# ======================================================================================


@dataclass(frozen=True)
class DesignDocument:
    """A structured design. **The fields are the review.**

    Requiring structure is itself the intervention: a prose document can omit the blast
    radius without anyone noticing, and a typed one cannot.
    """

    title: str
    author: str
    what_it_denies: str = ""            # the five questions from Phase 00
    blast_radius: str = ""
    degradation_behaviour: str = ""
    artifacts_emitted: Tuple[str, ...] = ()
    operator_at_3am: str = ""
    dependencies: Tuple[str, ...] = ()
    side_effecting_tools: Tuple[str, ...] = ()
    data_classifications: Tuple[str, ...] = ()
    autonomy_band: str = ""
    slo: str = ""
    retry_policy: str = ""
    idempotency: str = ""
    residency: str = ""
    reversible: bool = True


@dataclass(frozen=True)
class Finding:
    severity: str          # "blocker" | "major" | "minor"
    rule: str
    detail: str

    def __str__(self) -> str:
        raise NotImplementedError


#: A rule reads a design and returns a finding, or None.
ReviewRule = Callable[[DesignDocument], Optional[Finding]]


#: TODO: the standing red flags, assembled from every phase in this track. The tests pin
#: these rule NAMES and severities:
#:
#: | rule | severity | when |
#: |---|---|---|
#: | ``what-does-it-deny`` | blocker | no ``what_it_denies`` (Phase 00) |
#: | ``blast-radius`` | blocker | no ``blast_radius`` (Phase 00) |
#: | ``degradation`` | blocker | no ``degradation_behaviour`` (Phase 14) |
#: | ``emits-nothing`` | blocker | no ``artifacts_emitted`` (Phase 15) |
#: | ``who-operates-it`` | blocker | no ``operator_at_3am`` (Phase 14) |
#: | ``idempotency`` | blocker | side-effecting tools, no idempotency (Phase 10) |
#: | ``autonomy-band`` | blocker | side-effecting tools, no band (Phase 15) |
#: | ``residency`` | blocker | restricted/confidential data, no residency (Phase 15) |
#: | ``irreversible-unbanded`` | blocker | not reversible, no band |
#: | ``retry-policy`` | **major** | side-effecting tools, no retry policy (Phase 10) |
#: | ``slo`` | **major** | no SLO (Phase 14) |
#: | ``dependencies`` | **minor** | none listed |
STANDING_RULES: Tuple[ReviewRule, ...] = ()


@dataclass(frozen=True)
class ReviewResult:
    document: str
    approved: bool
    findings: Tuple[Finding, ...]

    @property
    def blockers(self) -> Tuple[Finding, ...]:
        raise NotImplementedError

    def format(self) -> str:
        raise NotImplementedError


class DesignReview:
    """Run the standing rules. **Any blocker blocks.**

    The value is not that the rules are clever — they are deliberately obvious. It is that
    they are *standing*: the same list every time, so a reviewer having a bad day still
    catches the missing blast radius, and an author knows in advance what will be asked.
    """

    def __init__(self, rules: Sequence[ReviewRule] = STANDING_RULES) -> None:
        raise NotImplementedError

    def review(self, document: DesignDocument) -> ReviewResult:
        """TODO: run every rule; a rule that **raises** produces a ``rule-error``
        blocker (fail closed, as in Phase 09). Sort findings most-severe first.
        """
        raise NotImplementedError


# ======================================================================================
# 6. Incident review tracking
# ======================================================================================


class ActionStatus(str, Enum):
    OPEN = "open"
    IN_PROGRESS = "in_progress"
    DONE = "done"
    DROPPED = "dropped"          # explicitly abandoned, with a reason


@dataclass(frozen=True)
class ActionItem:
    action_id: str
    incident_id: str
    description: str
    owner: str                   # a named human
    due_tick: int
    status: ActionStatus = ActionStatus.OPEN
    closed_at: Optional[int] = None
    drop_reason: str = ""

    def is_overdue(self, tick: int) -> bool:
        """TODO: only OPEN or IN_PROGRESS items can be overdue."""
        raise NotImplementedError


@dataclass(frozen=True)
class Incident:
    incident_id: str
    title: str
    severity: str
    started_at: int
    mitigated_at: Optional[int] = None
    resolved_at: Optional[int] = None
    budget_consumed: float = 0.0
    blameless_review_at: Optional[int] = None

    @property
    def time_to_mitigate(self) -> Optional[int]:
        raise NotImplementedError

    @property
    def time_to_resolve(self) -> Optional[int]:
        raise NotImplementedError


@dataclass(frozen=True)
class ReviewHealth:
    """The honest measure of a post-mortem culture.

    Not "did we write post-mortems" — everybody writes post-mortems. **Did the actions get
    done?** The completion rate is the only number that distinguishes a process from a
    writing exercise.
    """

    total_actions: int
    completed: int
    overdue: int
    dropped: int
    completion_rate: float
    overdue_rate: float
    reviews_held: int
    reviews_due: int

    @property
    def counted(self) -> int:
        raise NotImplementedError

    def format(self) -> str:
        raise NotImplementedError


class IncidentTracker:
    def __init__(self, *, now: Callable[[], int], review_due_ticks: int = 5) -> None:
        raise NotImplementedError

    def record(self, incident: Incident) -> Incident:
        raise NotImplementedError

    def mitigate(self, incident_id: str) -> Incident:
        raise NotImplementedError

    def resolve(self, incident_id: str) -> Incident:
        """TODO: refuse to resolve an incident that was never mitigated.

        **Mitigation is not a fix.** Mitigation stops the bleeding; the fix removes the
        cause. Conflating them is how a mitigated incident is closed and then recurs.
        """
        raise NotImplementedError

    def hold_review(self, incident_id: str) -> Incident:
        raise NotImplementedError

    def add_action(self, incident_id: str, *, description: str, owner: str,
                   due_in: int) -> ActionItem:
        """TODO: refuse an unknown incident, and refuse an action with **no named
        owner** — "the team" completes nothing. Ids are derived (``AI-001``)."""
        raise NotImplementedError

    def complete(self, action_id: str) -> ActionItem:
        raise NotImplementedError

    def drop(self, action_id: str, *, reason: str) -> ActionItem:
        """TODO: refuse a reason under three words.

        Dropping is legitimate and must be explicit: an action quietly left open forever
        is worse than one dropped with a reason — the first corrupts the metric, the
        second is a decision.
        """
        raise NotImplementedError

    def overdue(self) -> List[ActionItem]:
        raise NotImplementedError

    def health(self) -> ReviewHealth:
        """TODO: **dropped items are excluded from the denominator** — they were decided,
        not missed. No actions at all is a completion rate of 1.0, not a
        ZeroDivisionError."""
        raise NotImplementedError


# ======================================================================================
# 7. The forum playbook
# ======================================================================================


@dataclass(frozen=True)
class Forum:
    """Five audiences who ask different questions and reward different answers.

    Bringing the same deck to all five is the standard mistake, and each rejects it for a
    different reason.
    """

    name: str
    wants: str
    artifact: str
    fails_when: str
    from_phase: str


#: TODO: the five the JD names — Enterprise Architecture, Cyber, Model Risk, Internal
#: Audit, Group CTTO. Each needs ``wants``, ``artifact``, ``fails_when`` and a phase
#: reference.
#:
#: Worth getting right: Cyber fails when you claim you *prevent* prompt injection; Model
#: Risk fails when "the model" means only the weights; Internal Audit fails when you
#: describe controls instead of showing their artifacts; the CTTO fails when you present
#: engineering detail instead of unit economics.
FORUMS: Tuple[Forum, ...] = ()


def forum_brief(forum_name: str) -> Forum:
    """TODO: case-insensitive lookup; ``KeyError`` for an unknown forum."""
    raise NotImplementedError


# ======================================================================================
# Worked example
# ======================================================================================


def main() -> None:  # pragma: no cover
    """TODO (optional): once the tests pass, build the eight-section demo.

    Compare against ``python solution.py`` — but only after your own runs.
    """
    raise NotImplementedError


if __name__ == "__main__":  # pragma: no cover
    main()
