"""Tests for the evidence engine.

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 Any, List, Sequence

import pytest

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

RiskTier = lab.RiskTier
TierPolicy = lab.TierPolicy
TIER_POLICIES = lab.TIER_POLICIES
ImpactAssessment = lab.ImpactAssessment
assign_tier = lab.assign_tier
ValidationState = lab.ValidationState
ModelConfiguration = lab.ModelConfiguration
InventoryEntry = lab.InventoryEntry
InventoryError = lab.InventoryError
ModelInventory = lab.ModelInventory
ArtifactKind = lab.ArtifactKind
Artifact = lab.Artifact
LineageError = lab.LineageError
LineageGraph = lab.LineageGraph
ResidencyRule = lab.ResidencyRule
ResidencyChecker = lab.ResidencyChecker
REQUIRED_PINS = lab.REQUIRED_PINS
check_reproducibility = lab.check_reproducibility
ExitReadiness = lab.ExitReadiness
ThirdPartyModel = lab.ThirdPartyModel
ThirdPartyRegister = lab.ThirdPartyRegister
REQUIRED_ARTIFACTS = lab.REQUIRED_ARTIFACTS
EvidencePackError = lab.EvidencePackError
EvidenceGenerator = lab.EvidenceGenerator
Control = lab.Control
CONTROL_CATALOGUE = lab.CONTROL_CATALOGUE
control_coverage = lab.control_coverage
verify_control_evidence = lab.verify_control_evidence
build_trace = lab.build_trace


# ======================================================================================
# 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


def impact(**kw) -> ImpactAssessment:
    base = dict(max_financial_impact_micros=0, affects_customers=False,
                regulatory_reporting=False, irreversible_actions=False,
                processes_restricted_data=False)
    base.update(kw)
    return ImpactAssessment(**base)


def config(**kw) -> ModelConfiguration:
    base = dict(config_id="c", version="v1", base_model="m", base_model_version="m-1",
                prompt_version="p-1", retrieval_config_version="r-1",
                tool_set_version="t-1", guardrail_version="g-1", temperature=0.0)
    base.update(kw)
    return ModelConfiguration(**base)


def entry(**kw) -> InventoryEntry:
    base = dict(entry_id="M-1", name="n", owner="alice", business_sponsor="bob",
                purpose="p", tier=RiskTier.TIER_2, tier_reasons=("r",),
                configuration=config(), validation_state=ValidationState.NOT_SUBMITTED,
                eval_case_count=1_000)
    base.update(kw)
    return InventoryEntry(**base)


def inventory(**kw) -> ModelInventory:
    kw.setdefault("now", clock())
    return ModelInventory(**kw)


def graph_with(trace: str = "t", **kw) -> LineageGraph:
    g = LineageGraph()
    build_trace(g, trace, **kw)
    return g


RULES = [
    ResidencyRule("restricted", frozenset({"uaenorth"})),
    ResidencyRule("confidential", frozenset({"uaenorth", "uaecentral"})),
    ResidencyRule("internal", frozenset({"uaenorth", "westeurope"})),
]


def generator(g: LineageGraph) -> EvidenceGenerator:
    return EvidenceGenerator(graph=g, inventory=inventory(),
                             residency=ResidencyChecker(RULES), now=frozen(9_000))


# ======================================================================================
# 1. risk tiering
# ======================================================================================


def test_an_irreversible_action_is_tier_1():
    tier, _ = assign_tier(impact(irreversible_actions=True))
    assert tier is RiskTier.TIER_1


def test_large_financial_impact_is_tier_1():
    tier, _ = assign_tier(impact(max_financial_impact_micros=200_000_000_000))
    assert tier is RiskTier.TIER_1


def test_regulatory_reporting_is_tier_1():
    tier, _ = assign_tier(impact(regulatory_reporting=True))
    assert tier is RiskTier.TIER_1


def test_customer_impact_alone_is_tier_2():
    tier, _ = assign_tier(impact(affects_customers=True))
    assert tier is RiskTier.TIER_2


def test_restricted_data_alone_is_tier_2():
    tier, _ = assign_tier(impact(processes_restricted_data=True))
    assert tier is RiskTier.TIER_2


def test_no_material_impact_is_tier_3():
    tier, _ = assign_tier(impact())
    assert tier is RiskTier.TIER_3


def test_the_tier_always_carries_reasons():
    for assessment in (impact(), impact(irreversible_actions=True),
                       impact(affects_customers=True)):
        _, reasons = assign_tier(assessment)
        assert reasons


def test_reasons_are_sorted_for_stability():
    _, reasons = assign_tier(impact(irreversible_actions=True, regulatory_reporting=True))
    assert reasons == sorted(reasons)


def test_every_tier_has_a_policy():
    assert set(TIER_POLICIES) == set(RiskTier)


def test_tier_1_requires_independent_validation_and_board_approval():
    policy = TIER_POLICIES[RiskTier.TIER_1]
    assert policy.requires_independent_validation and policy.requires_board_approval


def test_tier_3_does_not_require_independent_validation():
    assert not TIER_POLICIES[RiskTier.TIER_3].requires_independent_validation


def test_a_higher_tier_caps_autonomy_lower():
    assert TIER_POLICIES[RiskTier.TIER_1].max_autonomy != \
        TIER_POLICIES[RiskTier.TIER_3].max_autonomy


def test_a_higher_tier_demands_more_eval_cases():
    assert TIER_POLICIES[RiskTier.TIER_1].min_eval_cases > \
        TIER_POLICIES[RiskTier.TIER_3].min_eval_cases


def test_a_higher_tier_is_monitored_more_often():
    assert TIER_POLICIES[RiskTier.TIER_1].monitoring_days < \
        TIER_POLICIES[RiskTier.TIER_3].monitoring_days


# ======================================================================================
# 2. the model configuration
# ======================================================================================


def test_the_fingerprint_is_stable():
    assert config().fingerprint() == config().fingerprint()


def test_the_fingerprint_ignores_the_config_id_and_version():
    assert config(config_id="a", version="v1").fingerprint() == \
        config(config_id="b", version="v9").fingerprint()


def test_a_prompt_change_changes_the_fingerprint():
    assert config().fingerprint() != config(prompt_version="p-2").fingerprint()


def test_a_retrieval_change_changes_the_fingerprint():
    assert config().fingerprint() != config(retrieval_config_version="r-2").fingerprint()


def test_a_guardrail_change_changes_the_fingerprint():
    assert config().fingerprint() != config(guardrail_version="g-2").fingerprint()


def test_a_temperature_change_changes_the_fingerprint():
    assert config().fingerprint() != config(temperature=0.7).fingerprint()


def test_differs_from_names_the_changed_fields():
    assert config().differs_from(config(prompt_version="p-2")) == ["prompt_version"]


def test_differs_from_is_empty_for_identical_configs():
    assert config().differs_from(config()) == []


def test_multiple_changes_are_all_named():
    other = config(prompt_version="p-2", tool_set_version="t-2")
    assert config().differs_from(other) == ["prompt_version", "tool_set_version"]


# ======================================================================================
# 3. the inventory
# ======================================================================================


def test_a_model_can_be_registered_and_read_back():
    inv = inventory()
    inv.register(entry())
    assert inv.get("M-1").name == "n"


def test_a_duplicate_registration_is_refused():
    inv = inventory()
    inv.register(entry())
    with pytest.raises(InventoryError, match="already"):
        inv.register(entry())


def test_a_model_with_no_owner_is_refused():
    with pytest.raises(InventoryError, match="owner"):
        inventory().register(entry(owner=""))


def test_a_model_with_no_business_sponsor_is_refused():
    with pytest.raises(InventoryError):
        inventory().register(entry(business_sponsor=""))


def test_a_model_with_no_purpose_is_refused():
    with pytest.raises(InventoryError, match="purpose"):
        inventory().register(entry(purpose=""))


def test_an_unknown_model_raises():
    with pytest.raises(InventoryError):
        inventory().get("ghost")


def test_the_owner_cannot_validate_their_own_model():
    inv = inventory()
    inv.register(entry(owner="alice"))
    with pytest.raises(InventoryError, match="independent"):
        inv.validate("M-1", validator="alice", state=ValidationState.APPROVED)


def test_an_independent_validator_can_validate():
    inv = inventory()
    inv.register(entry())
    updated = inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    assert updated.validation_state is ValidationState.APPROVED
    assert updated.validated_by == "carol"


def test_validation_conditions_are_recorded():
    inv = inventory()
    inv.register(entry())
    updated = inv.validate("M-1", validator="carol",
                           state=ValidationState.APPROVED_WITH_CONDITIONS,
                           conditions=("monthly monitoring",))
    assert updated.conditions == ("monthly monitoring",)


def test_an_unvalidated_tier_2_model_cannot_be_promoted():
    inv = inventory()
    inv.register(entry())
    with pytest.raises(InventoryError, match="validation"):
        inv.promote("M-1", autonomy_band="read_only")


def test_a_validated_model_can_be_promoted():
    inv = inventory()
    inv.register(entry())
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    assert inv.promote("M-1", autonomy_band="read_only").in_production


def test_approved_with_conditions_permits_promotion():
    inv = inventory()
    inv.register(entry())
    inv.validate("M-1", validator="carol",
                 state=ValidationState.APPROVED_WITH_CONDITIONS)
    assert inv.promote("M-1", autonomy_band="read_only").in_production


def test_a_tier_3_model_does_not_need_validation_to_promote():
    inv = inventory()
    inv.register(entry(tier=RiskTier.TIER_3))
    assert inv.promote("M-1", autonomy_band="read_only").in_production


def test_too_few_eval_cases_blocks_promotion():
    inv = inventory()
    inv.register(entry(eval_case_count=5))
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    with pytest.raises(InventoryError, match="eval"):
        inv.promote("M-1", autonomy_band="read_only")


def test_autonomy_above_the_tier_maximum_is_refused():
    inv = inventory()
    inv.register(entry(tier=RiskTier.TIER_1, eval_case_count=1_000))
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    with pytest.raises(InventoryError, match="autonomy"):
        inv.promote("M-1", autonomy_band="autonomous")


def test_autonomy_at_the_tier_maximum_is_permitted():
    inv = inventory()
    inv.register(entry(tier=RiskTier.TIER_1, eval_case_count=1_000))
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    assert inv.promote("M-1", autonomy_band="assisted").in_production


def test_every_promotion_blocker_is_reported_not_just_the_first():
    inv = inventory()
    inv.register(entry(tier=RiskTier.TIER_1, eval_case_count=5))
    with pytest.raises(InventoryError) as excinfo:
        inv.promote("M-1", autonomy_band="autonomous")
    message = str(excinfo.value)
    assert "validation" in message and "eval" in message and "autonomy" in message


def test_an_unknown_autonomy_band_raises():
    inv = inventory()
    inv.register(entry(tier=RiskTier.TIER_3))
    with pytest.raises(InventoryError):
        inv.promote("M-1", autonomy_band="godmode")


def test_a_prompt_change_invalidates_the_validation():
    inv = inventory()
    inv.register(entry())
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    inv.promote("M-1", autonomy_band="read_only")
    updated, changed = inv.record_change("M-1", config(prompt_version="p-2"))
    assert changed == ["prompt_version"]
    assert updated.validation_state is ValidationState.NOT_SUBMITTED
    assert not updated.in_production


def test_an_unchanged_config_does_not_invalidate():
    inv = inventory()
    inv.register(entry())
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    updated, changed = inv.record_change("M-1", config())
    assert changed == [] and updated.validation_state is ValidationState.APPROVED


def test_revalidation_is_due_after_the_tier_interval():
    now = clock(start=0)
    inv = ModelInventory(now=now)
    inv.register(entry(tier=RiskTier.TIER_1))
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    assert inv.due_for_revalidation() == []
    for _ in range(400):
        now()
    assert [e.entry_id for e in inv.due_for_revalidation()] == ["M-1"]


def test_a_never_validated_model_is_not_due_for_revalidation():
    inv = inventory()
    inv.register(entry())
    assert inv.due_for_revalidation() == []


def test_history_records_every_lifecycle_event():
    inv = inventory()
    inv.register(entry())
    inv.validate("M-1", validator="carol", state=ValidationState.APPROVED)
    inv.promote("M-1", autonomy_band="read_only")
    events = [e[2] for e in inv.history()]
    assert "registered" in events and any("promoted" in e for e in events)


def test_in_production_lists_only_promoted_models():
    inv = inventory()
    inv.register(entry(entry_id="M-1", tier=RiskTier.TIER_3))
    inv.register(entry(entry_id="M-2", tier=RiskTier.TIER_3))
    inv.promote("M-1", autonomy_band="read_only")
    assert [e.entry_id for e in inv.in_production()] == ["M-1"]


# ======================================================================================
# 4. lineage
# ======================================================================================


def art(aid: str, kind: ArtifactKind = ArtifactKind.EXECUTION_STEP, tick: int = 0,
        derived: Sequence[str] = (), trace: str = "t", **attrs) -> Artifact:
    return Artifact(aid, kind, trace, tick, "test", tuple(derived), attrs)


def test_an_artifact_can_be_added_and_read_back():
    g = LineageGraph()
    g.add(art("a"))
    assert g.get("a").artifact_id == "a"


def test_a_duplicate_artifact_is_refused():
    g = LineageGraph()
    g.add(art("a"))
    with pytest.raises(LineageError, match="duplicate"):
        g.add(art("a"))


def test_deriving_from_an_unknown_artifact_is_refused():
    g = LineageGraph()
    with pytest.raises(LineageError, match="causal order"):
        g.add(art("b", derived=["a"]))


def test_an_unknown_artifact_raises():
    with pytest.raises(LineageError):
        LineageGraph().get("ghost")


def test_a_trace_returns_its_artifacts_in_causal_order():
    g = LineageGraph()
    g.add(art("a", tick=1))
    g.add(art("b", tick=2, derived=["a"]))
    assert [x.artifact_id for x in g.for_trace("t")] == ["a", "b"]


def test_artifacts_from_other_traces_are_excluded():
    g = LineageGraph()
    g.add(art("a", trace="t1"))
    g.add(art("b", trace="t2"))
    assert [x.artifact_id for x in g.for_trace("t1")] == ["a"]


def test_ancestors_walk_backwards_transitively():
    g = LineageGraph()
    g.add(art("a", tick=1))
    g.add(art("b", tick=2, derived=["a"]))
    g.add(art("c", tick=3, derived=["b"]))
    assert [x.artifact_id for x in g.ancestors("c")] == ["a", "b"]


def test_ancestors_of_a_root_are_empty():
    g = LineageGraph()
    g.add(art("a"))
    assert g.ancestors("a") == []


def test_a_diamond_reports_each_ancestor_once():
    g = LineageGraph()
    g.add(art("a", tick=1))
    g.add(art("b", tick=2, derived=["a"]))
    g.add(art("c", tick=3, derived=["a"]))
    g.add(art("d", tick=4, derived=["b", "c"]))
    assert [x.artifact_id for x in g.ancestors("d")] == ["a", "b", "c"]


def test_descendants_walk_forwards():
    g = LineageGraph()
    g.add(art("a", tick=1))
    g.add(art("b", tick=2, derived=["a"]))
    g.add(art("c", tick=3, derived=["b"]))
    assert [x.artifact_id for x in g.descendants("a")] == ["b", "c"]


def test_descendants_of_a_leaf_are_empty():
    g = LineageGraph()
    g.add(art("a"))
    assert g.descendants("a") == []


def test_a_graph_built_in_causal_order_is_acyclic():
    graph_with().check_acyclic()


def test_a_cycle_is_detected():
    g = LineageGraph()
    g.add(art("a", tick=1))
    g.add(art("b", tick=2, derived=["a"]))
    # force a cycle past the ordering guard
    g._artifacts["a"] = lab.replace(g._artifacts["a"], derived_from=("b",))
    with pytest.raises(LineageError, match="cycle"):
        g.check_acyclic()


def test_artifacts_can_be_selected_by_kind():
    g = graph_with()
    assert len(g.of_kind("t", ArtifactKind.DOCUMENT)) == 2


def test_a_real_trace_links_the_action_back_to_the_session():
    g = graph_with()
    ancestors = {a.kind for a in g.ancestors("t:act")}
    assert ArtifactKind.SESSION in ancestors
    assert ArtifactKind.INFERENCE in ancestors
    assert ArtifactKind.RETRIEVAL in ancestors


def test_a_document_reaches_the_action_forwards():
    g = graph_with()
    assert "t:act" in {a.artifact_id for a in g.descendants("t:doc1")}


def test_an_orphaned_artifact_is_reported():
    g = graph_with()
    g.add(art("t:stray", ArtifactKind.TOOL_CALL, tick=99))
    assert [a.artifact_id for a in g.orphans("t")] == ["t:stray"]


def test_the_session_artifact_is_not_an_orphan():
    assert graph_with().orphans("t") == []


# ======================================================================================
# 5. residency
# ======================================================================================


def checker() -> ResidencyChecker:
    return ResidencyChecker(RULES)


def test_an_in_region_trace_has_no_violations():
    assert checker().check_trace(graph_with(), "t") == []


def test_an_out_of_region_inference_is_flagged():
    g = graph_with(region="westeurope")
    violations = checker().check_trace(g, "t")
    assert violations and all(v.region == "westeurope" for v in violations)


def test_the_violation_names_the_permitted_regions():
    g = graph_with(region="westeurope")
    assert "uaenorth" in checker().check_trace(g, "t")[0].permitted


def test_a_record_without_a_region_is_a_violation():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, data_classification="confidential"))
    violations = checker().check_trace(g, "t")
    assert violations and "unprovable" in violations[0].detail


def test_a_record_without_a_classification_is_a_violation():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, region="uaenorth"))
    assert checker().check_trace(g, "t")


def test_an_unknown_classification_raises():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, region="uaenorth",
              data_classification="mystery"))
    with pytest.raises(LineageError, match="no residency rule"):
        checker().check_trace(g, "t")


def test_non_processing_artifacts_are_not_checked():
    g = LineageGraph()
    g.add(art("t:s", ArtifactKind.SESSION))
    assert checker().check_trace(g, "t") == []


def test_transit_through_a_forbidden_region_is_flagged():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, region="uaenorth",
              data_classification="confidential",
              transit_regions=("westeurope",)))
    violations = checker().check_trace(g, "t")
    assert violations and "transited" in violations[0].detail


def test_transit_is_permitted_when_the_rule_allows_it():
    permissive = ResidencyChecker([
        ResidencyRule("confidential", frozenset({"uaenorth"}), permit_transit=True)])
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, region="uaenorth",
              data_classification="confidential", transit_regions=("westeurope",)))
    assert permissive.check_trace(g, "t") == []


def test_transit_within_permitted_regions_is_fine():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, region="uaenorth",
              data_classification="confidential",
              transit_regions=("uaecentral",)))
    assert checker().check_trace(g, "t") == []


# ======================================================================================
# 6. reproducibility
# ======================================================================================


def test_a_fully_pinned_trace_is_reproducible():
    assert check_reproducibility(graph_with(), "t").reproducible


def test_a_missing_retrieval_snapshot_makes_it_non_reproducible():
    report = check_reproducibility(graph_with(pin_retrieval=False), "t")
    assert not report.reproducible and "retrieval_snapshot" in report.missing


def test_the_report_names_every_missing_pin():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, base_model_version="m-1"))
    report = check_reproducibility(g, "t")
    assert len(report.missing) == len(REQUIRED_PINS) - 1


def test_the_report_lists_the_pins_it_found():
    assert set(check_reproducibility(graph_with(), "t").present) == set(REQUIRED_PINS)


def test_a_nonzero_temperature_is_a_caveat_not_a_failure():
    g = graph_with()
    g.add(art("t:i2", ArtifactKind.INFERENCE, tick=99, temperature=0.7))
    report = check_reproducibility(g, "t")
    assert report.reproducible and any("temperature" in c for c in report.caveats)


def test_a_provider_managed_version_is_a_caveat():
    g = graph_with()
    g.add(art("t:i2", ArtifactKind.INFERENCE, tick=99, provider_managed_version=True))
    assert any("provider" in c for c in check_reproducibility(g, "t").caveats)


def test_a_zero_temperature_produces_no_caveat():
    assert check_reproducibility(graph_with(), "t").caveats == ()


def test_an_empty_pin_value_does_not_count_as_present():
    g = LineageGraph()
    g.add(art("t:i", ArtifactKind.INFERENCE, **{p: "" for p in REQUIRED_PINS}))
    assert not check_reproducibility(g, "t").reproducible


# ======================================================================================
# 7. third-party governance
# ======================================================================================


def third_party(**kw) -> ThirdPartyModel:
    base = dict(provider="p", model="m", version="1", data_use_terms="no training",
                sub_processors=("s",), regions=("uaenorth",),
                deprecation_notice_days=180, contractual_sla=0.999,
                exit_readiness=ExitReadiness.LIVE, alternative="alt",
                last_exit_test_tick=90, traffic_share=0.5)
    base.update(kw)
    return ThirdPartyModel(**base)


def register(**kw) -> ThirdPartyRegister:
    kw.setdefault("now", frozen(100))
    return ThirdPartyRegister(**kw)


def test_a_provider_without_data_use_terms_is_refused():
    with pytest.raises(InventoryError, match="data-use"):
        register().register(third_party(data_use_terms=""))


def test_a_well_governed_provider_produces_no_high_findings():
    reg = register()
    reg.register(third_party(traffic_share=0.5))
    assert not any(f.severity == "high" for f in reg.assess_concentration())


def test_concentration_above_the_threshold_is_high_severity():
    reg = register(concentration_threshold=0.8)
    reg.register(third_party(traffic_share=0.9))
    findings = reg.assess_concentration()
    assert any(f.severity == "high" and "%" in f.detail for f in findings)


def test_no_exit_plan_is_a_finding():
    reg = register()
    reg.register(third_party(exit_readiness=ExitReadiness.NONE))
    assert any("exit readiness" in f.detail for f in reg.assess_concentration())


def test_an_identified_but_untested_exit_is_a_finding():
    reg = register()
    reg.register(third_party(exit_readiness=ExitReadiness.IDENTIFIED))
    assert any("document" in f.detail for f in reg.assess_concentration())


def test_a_stale_exit_test_is_a_finding():
    reg = register(now=frozen(1_000), exit_test_interval_days=180)
    reg.register(third_party(exit_readiness=ExitReadiness.TESTED,
                             last_exit_test_tick=100))
    assert any("last tested" in f.detail for f in reg.assess_concentration())


def test_a_recent_exit_test_is_not_a_finding():
    reg = register(now=frozen(200), exit_test_interval_days=180)
    reg.register(third_party(exit_readiness=ExitReadiness.TESTED,
                             last_exit_test_tick=100))
    assert not any("last tested" in f.detail for f in reg.assess_concentration())


def test_a_live_alternative_needs_no_exit_test():
    reg = register()
    reg.register(third_party(exit_readiness=ExitReadiness.LIVE,
                             last_exit_test_tick=None))
    assert not any("exit" in f.detail for f in reg.assess_concentration())


def test_short_deprecation_notice_is_a_finding():
    reg = register()
    reg.register(third_party(deprecation_notice_days=30))
    assert any("deprecation notice" in f.detail for f in reg.assess_concentration())


def test_undeclared_sub_processors_are_a_low_finding():
    reg = register()
    reg.register(third_party(sub_processors=()))
    assert any(f.severity == "low" for f in reg.assess_concentration())


def test_findings_are_sorted_most_severe_first():
    reg = register(concentration_threshold=0.5)
    reg.register(third_party(traffic_share=0.9, sub_processors=(),
                             deprecation_notice_days=30))
    severities = [f.severity for f in reg.assess_concentration()]
    assert severities == sorted(severities,
                                key={"high": 0, "medium": 1, "low": 2}.__getitem__)


def test_traffic_share_aggregates_per_provider():
    reg = register(concentration_threshold=0.8)
    reg.register(third_party(provider="p", model="a", traffic_share=0.5))
    reg.register(third_party(provider="p", model="b", traffic_share=0.45))
    assert any("95%" in f.detail for f in reg.assess_concentration())


# ======================================================================================
# 8. the evidence pack
# ======================================================================================


def test_a_complete_trace_produces_a_complete_pack():
    pack = generator(graph_with()).generate("t")
    assert pack.complete and pack.missing == ()


def test_the_pack_contains_every_artifact():
    g = graph_with()
    pack = generator(g).generate("t")
    assert len(pack.artifacts) == len(g.for_trace("t"))


def test_a_pack_for_an_unknown_trace_raises():
    with pytest.raises(EvidencePackError, match="no artifacts"):
        generator(graph_with()).generate("nope")


def test_a_high_value_action_without_approval_fails():
    with pytest.raises(EvidencePackError, match="approval"):
        generator(graph_with(with_approval=False)).generate("t")


def test_the_failure_names_the_missing_artifact():
    with pytest.raises(EvidencePackError) as excinfo:
        generator(graph_with(with_approval=False)).generate("t")
    assert "who reviewed and approved it" in str(excinfo.value)


def test_non_strict_mode_returns_an_incomplete_pack_rather_than_raising():
    pack = generator(graph_with(with_approval=False)).generate("t", strict=False)
    assert not pack.complete and pack.missing


def test_a_low_value_action_does_not_require_approval():
    g = LineageGraph()
    build_trace(g, "t", with_approval=False)
    # rewrite the action to be below the threshold
    action = g.get("t:act")
    g._artifacts["t:act"] = lab.replace(
        action, attributes={**action.attributes, "value_micros": 1_000})
    assert generator(g).generate("t").complete


def test_a_missing_required_kind_is_reported():
    g = LineageGraph()
    g.add(art("t:s", ArtifactKind.SESSION))
    pack = generator(g).generate("t", strict=False)
    assert len(pack.missing) == len(REQUIRED_ARTIFACTS) - 1


def test_the_pack_carries_the_reproducibility_report():
    assert generator(graph_with()).generate("t").reproducibility.reproducible


def test_a_non_reproducible_trace_still_produces_a_pack():
    pack = generator(graph_with(pin_retrieval=False)).generate("t")
    assert pack.complete and not pack.reproducibility.reproducible


def test_the_pack_carries_residency_violations():
    pack = generator(graph_with(region="westeurope")).generate("t")
    assert pack.residency_violations


def test_a_pack_verifies_against_its_own_chain():
    gen = generator(graph_with())
    assert gen.verify(gen.generate("t")) == (True, None)


def test_editing_an_artifact_breaks_verification():
    gen = generator(graph_with())
    pack = gen.generate("t")
    edited = list(pack.artifacts)
    edited[0] = lab.replace(edited[0], attributes={"tampered": True})
    ok, problem = gen.verify(lab.replace(pack, artifacts=tuple(edited)))
    assert not ok and "chain" in problem


def test_editing_the_signature_breaks_verification():
    gen = generator(graph_with())
    pack = gen.generate("t")
    ok, problem = gen.verify(lab.replace(pack, signature="deadbeef"))
    assert not ok and "signature" in problem


def test_dropping_an_artifact_breaks_verification():
    gen = generator(graph_with())
    pack = gen.generate("t")
    ok, _ = gen.verify(lab.replace(pack, artifacts=pack.artifacts[:-1]))
    assert not ok


def test_two_packs_for_the_same_trace_have_the_same_chain_head():
    g = graph_with()
    assert generator(g).generate("t").chain_head == generator(g).generate("t").chain_head


def test_the_pack_summary_states_completeness():
    assert "COMPLETE" in generator(graph_with()).generate("t").summary()


# ======================================================================================
# 9. control coverage
# ======================================================================================


def test_every_control_has_an_id_a_component_and_a_framework():
    for control in CONTROL_CATALOGUE:
        assert control.control_id and control.component and control.frameworks


def test_control_ids_are_unique():
    ids = [c.control_id for c in CONTROL_CATALOGUE]
    assert len(ids) == len(set(ids))


def test_coverage_has_a_row_per_framework():
    frameworks = {f for c in CONTROL_CATALOGUE for f in c.frameworks}
    assert {r.framework for r in control_coverage()} == frameworks


def test_coverage_rows_are_sorted():
    frameworks = [r.framework for r in control_coverage()]
    assert frameworks == sorted(frameworks)


def test_a_row_splits_evidence_emitting_from_silent_controls():
    for row in control_coverage():
        assert len(row.evidence_emitting) + len(row.silent) == len(row.controls)


def test_silent_controls_are_identified():
    silent = {c for row in control_coverage() for c in row.silent}
    assert silent


def test_cbuae_is_covered_by_several_controls():
    row = next(r for r in control_coverage() if r.framework == "CBUAE")
    assert len(row.controls) >= 5


def test_sr_11_7_is_covered():
    assert any(r.framework == "SR 11-7" for r in control_coverage())


def test_a_complete_trace_leaves_no_evidence_emitting_control_silent():
    missing = verify_control_evidence(graph_with(), "t")
    assert missing == []


def test_a_trace_missing_an_approval_reports_the_control():
    missing = verify_control_evidence(graph_with(with_approval=False), "t")
    assert any("C-05" in m for m in missing)


def test_the_report_names_the_control_and_the_artifact():
    missing = verify_control_evidence(graph_with(with_approval=False), "t")
    assert missing and "approval" in missing[0]


def test_coverage_is_generated_from_the_catalogue():
    subset = tuple(c for c in CONTROL_CATALOGUE if c.control_id != "C-02")
    rows = {r.framework: r for r in control_coverage(subset)}
    assert "C-02" not in rows["CBUAE"].controls
