from __future__ import annotations

import ast
import dataclasses
from pathlib import Path

import pytest

from generate.contract_residuals import (
    ContractResidual,
    ResidualKind,
    ResidualSourceAxis,
    project_contract_residuals,
)
from generate.kernel_facts import SourceSpan
from generate.problem_frame_contracts import ContractAssessment


def _assessment(
    *,
    candidate_organ: str = "unary_delta_transition",
    missing_bindings: tuple[str, ...] = (),
    unresolved_hazards: tuple[str, ...] = (),
    runnable: bool = False,
    explanation: str = "diagnostic candidate is not runnable",
    evidence_spans: tuple[SourceSpan, ...] = (),
) -> ContractAssessment:
    return ContractAssessment(
        candidate_organ=candidate_organ,
        missing_bindings=missing_bindings,
        unresolved_hazards=unresolved_hazards,
        runnable=runnable,
        explanation=explanation,
        evidence_spans=evidence_spans,
    )


def _entries(
    kind: ResidualKind,
    axis: ResidualSourceAxis,
    *labels: str,
) -> tuple[tuple[str, ResidualKind, ResidualSourceAxis], ...]:
    return tuple((label, kind, axis) for label in labels)


EXPECTED_LABEL_MAP: tuple[
    tuple[str, ResidualKind, ResidualSourceAxis],
    ...,
] = (
    *_entries(
        ResidualKind.MISSING_PROPOSAL,
        ResidualSourceAxis.PROPOSAL,
        "quantity_entity_proposal_required",
        "unary_delta_proposal_required",
    ),
    *_entries(
        ResidualKind.MISSING_RELATION,
        ResidualSourceAxis.RELATION,
        "local_binding_relation_unbound",
    ),
    *_entries(
        ResidualKind.MISSING_ROLE,
        ResidualSourceAxis.ROLE,
        "quantity_unbound",
        "entity_unbound",
        "quantity_kind_unresolved",
        "base_quantity_unbound",
        "scale_unbound",
        "state_entity_unbound",
        "grounded_partition_subgroup",
        "grounded_whole_entity",
        "original_whole_unbound",
        "action_cue_unbound",
        "delta_quantity_unbound",
        "changed_object_unbound",
        "direction_unbound",
    ),
    *_entries(
        ResidualKind.AMBIGUOUS_RELATION,
        ResidualSourceAxis.RELATION,
        "local_binding_relation_ambiguous",
        "decrease_relation_ambiguous",
        "unary_delta_relation_ambiguous",
    ),
    *_entries(
        ResidualKind.AMBIGUOUS_ROLE,
        ResidualSourceAxis.ROLE,
        "quantity_ambiguous",
        "entity_ambiguous",
        "multiple_original_whole_candidates",
        "delta_quantity_ambiguous",
        "changed_object_ambiguous",
    ),
    *_entries(
        ResidualKind.INEXACT_PROVENANCE,
        ResidualSourceAxis.PROVENANCE,
        "provenance_span_inexact",
        "base_quantity_provenance_missing",
        "scale_provenance_missing",
    ),
    *_entries(
        ResidualKind.NONLOCAL_BINDING,
        ResidualSourceAxis.LOCALITY,
        "quantity_entity_nonlocal",
        "pronoun_antecedent_unresolved",
    ),
    *_entries(
        ResidualKind.UNSUPPORTED_TOPOLOGY,
        ResidualSourceAxis.TOPOLOGY,
        "percent_subgroup_links_incomplete",
        "inverse_topology_unlicensed",
        "event_assertion_unlicensed",
        "passive_voice_unsupported",
        "multiple_actor_surface",
    ),
    *_entries(
        ResidualKind.UNIT_OBJECT_CONFLICT,
        ResidualSourceAxis.UNIT_OBJECT,
        "unit_kind_conflict",
        "unit_continuity_unproven",
        "unit_object_conflict",
    ),
    *_entries(
        ResidualKind.HAZARD_BLOCKED,
        ResidualSourceAxis.HAZARD,
        "competing_family_context",
        "percent_change_vs_percent_of",
        "state_entity_continuity_unproven",
        "scale_out_of_range",
        "partition_subgroups_not_distinct",
    ),
    *_entries(
        ResidualKind.TARGET_UNBOUND,
        ResidualSourceAxis.TARGET,
        "delta_decrease_target_unbound",
        "delta_decrease_target_required",
        "grounded_question_target",
        "forward_aggregate_target_required",
    ),
    *_entries(
        ResidualKind.CONTRACT_GAP_UNCLASSIFIED,
        ResidualSourceAxis.UNKNOWN,
        "unbound_base_quantity",
        "container",
        "content",
        "count_per",
        "worker",
        "rate",
        "duration",
    ),
)


@pytest.mark.parametrize(("label", "kind", "axis"), EXPECTED_LABEL_MAP)
def test_contract_residual_exact_label_mapping(
    label: str,
    kind: ResidualKind,
    axis: ResidualSourceAxis,
) -> None:
    residuals = project_contract_residuals(
        (_assessment(missing_bindings=(label,)),)
    )

    assert len(residuals) == 1
    residual = residuals[0]
    assert residual.residual_kind is kind
    assert residual.source_axis is axis
    assert residual.residual_code == label


def test_all_currently_emitted_labels_are_accounted_for() -> None:
    assert len(EXPECTED_LABEL_MAP) == 53
    assert len({label for label, _, _ in EXPECTED_LABEL_MAP}) == 53


@pytest.mark.parametrize(
    "label",
    ("Quantity_Unbound", "quantity_unbound ", "unknown_label"),
)
def test_arbitrary_and_case_altered_labels_fall_back_without_fuzzy_match(
    label: str,
) -> None:
    residuals = project_contract_residuals(
        (_assessment(missing_bindings=(label,)),)
    )

    assert len(residuals) == 1
    assert residuals[0].residual_code == label
    assert residuals[0].residual_kind is ResidualKind.CONTRACT_GAP_UNCLASSIFIED
    assert residuals[0].source_axis is ResidualSourceAxis.UNKNOWN


def test_duplicate_code_across_missing_and_hazard_fields_emits_once() -> None:
    residuals = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("quantity_unbound",),
                unresolved_hazards=("quantity_unbound",),
            ),
        )
    )

    assert len(residuals) == 1
    assert residuals[0].residual_code == "quantity_unbound"


def test_runnable_assessment_produces_no_residual_even_with_labels() -> None:
    residuals = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                unresolved_hazards=("unit_object_conflict",),
                runnable=True,
            ),
        )
    )

    assert residuals == ()


def test_refused_assessment_without_labels_produces_no_invented_residual() -> None:
    assert project_contract_residuals((_assessment(),)) == ()


def test_residual_preserves_code_spans_and_explanation_verbatim() -> None:
    span_a = SourceSpan("gained", 4, 10, 0)
    span_b = SourceSpan("3", 11, 12, 0)
    explanation = "diagnostic candidate is not runnable: changed_object_unbound"
    residuals = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                explanation=explanation,
                evidence_spans=(span_a, span_b),
            ),
        )
    )

    assert len(residuals) == 1
    residual = residuals[0]
    assert residual.residual_code == "changed_object_unbound"
    assert residual.evidence_spans == (span_a, span_b)
    assert residual.explanation == explanation


def test_family_id_is_resolved_from_candidate_organ() -> None:
    residual = project_contract_residuals(
        (
            _assessment(
                candidate_organ="unary_delta_transition",
                missing_bindings=("changed_object_unbound",),
            ),
        )
    )[0]

    assert residual.family_id == "state_change.unary_delta"


def test_unknown_candidate_organ_keeps_none_family_id() -> None:
    residual = project_contract_residuals(
        (
            _assessment(
                candidate_organ="future_unknown_organ",
                missing_bindings=("unknown_label",),
            ),
        )
    )[0]

    assert residual.family_id is None


def test_replay_stability_and_sorted_output_are_independent_of_input_order() -> None:
    assessment_a = _assessment(missing_bindings=("changed_object_unbound",))
    assessment_b = _assessment(
        candidate_organ="quantity_entity_binding",
        missing_bindings=("unit_kind_conflict",),
    )

    forward = project_contract_residuals((assessment_a, assessment_b))
    reversed_input = project_contract_residuals((assessment_b, assessment_a))

    assert forward == reversed_input
    assert tuple(residual.residual_id for residual in forward) == tuple(
        sorted(residual.residual_id for residual in forward)
    )


def test_known_sha256_vector_and_explanation_independence() -> None:
    span = SourceSpan("gained", 4, 10, 0)
    residual_a = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                explanation="first explanation",
                evidence_spans=(span,),
            ),
        )
    )[0]
    residual_b = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                explanation="second explanation",
                evidence_spans=(span,),
            ),
        )
    )[0]

    assert residual_a.residual_id == (
        "e56fa420ce0acc64071fc5ef819d740d54f1ab17f1a268728a0ef490784ffe32"
    )
    assert residual_a.residual_id == residual_b.residual_id


def test_evidence_span_order_and_sentence_index_participate_in_identity() -> None:
    span_a = SourceSpan("gained", 4, 10, 0)
    span_b = SourceSpan("3", 11, 12, 0)
    residual_ordered = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                evidence_spans=(span_a, span_b),
            ),
        )
    )[0]
    residual_reversed = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                evidence_spans=(span_b, span_a),
            ),
        )
    )[0]
    residual_no_sentence = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                evidence_spans=(SourceSpan("gained", 4, 10), span_b),
            ),
        )
    )[0]

    assert residual_ordered.residual_id != residual_reversed.residual_id
    assert residual_ordered.residual_id != residual_no_sentence.residual_id


def test_empty_evidence_is_lawful_and_does_not_synthesize_span() -> None:
    residual = project_contract_residuals(
        (
            _assessment(
                missing_bindings=("changed_object_unbound",),
                evidence_spans=(),
            ),
        )
    )[0]

    assert residual.evidence_spans == ()


def test_projection_leaves_assessment_unchanged() -> None:
    span = SourceSpan("lost", 8, 12, 0)
    assessment = _assessment(
        missing_bindings=("delta_quantity_unbound",),
        evidence_spans=(span,),
    )
    before = assessment

    project_contract_residuals((assessment,))

    assert assessment == before


def test_contract_residual_has_no_authority_fields() -> None:
    disallowed = {
        "runnable",
        "verdict",
        "answer",
        "proof",
        "search_eligible",
        "retryable",
        "priority",
        "budget",
        "action",
        "serving_allowed",
        "mutation",
        "recommended_migration_target",
    }

    assert disallowed.isdisjoint(
        {field.name for field in dataclasses.fields(ContractResidual)}
    )


def test_public_api_is_exactly_the_read_model_projection() -> None:
    import generate.contract_residuals as contract_residuals

    assert tuple(contract_residuals.__all__) == (
        "ResidualKind",
        "ResidualSourceAxis",
        "ContractResidual",
        "project_contract_residuals",
    )


def test_family_examples_project_expected_residuals() -> None:
    examples = (
        (
            _assessment(
                candidate_organ="unary_delta_transition",
                missing_bindings=(
                    "action_cue_unbound",
                    "direction_unbound",
                    "changed_object_unbound",
                ),
            ),
            {
                "action_cue_unbound": ResidualKind.MISSING_ROLE,
                "direction_unbound": ResidualKind.MISSING_ROLE,
                "changed_object_unbound": ResidualKind.MISSING_ROLE,
            },
        ),
        (
            _assessment(
                candidate_organ="quantity_entity_binding",
                missing_bindings=("quantity_kind_unresolved",),
                unresolved_hazards=("unit_kind_conflict",),
            ),
            {
                "quantity_kind_unresolved": ResidualKind.MISSING_ROLE,
                "unit_kind_conflict": ResidualKind.UNIT_OBJECT_CONFLICT,
            },
        ),
        (
            _assessment(
                candidate_organ="fraction_decrease",
                missing_bindings=("delta_decrease_target_unbound",),
            ),
            {"delta_decrease_target_unbound": ResidualKind.TARGET_UNBOUND},
        ),
        (
            _assessment(
                candidate_organ="percent_partition",
                missing_bindings=("percent_subgroup_links_incomplete",),
                unresolved_hazards=(
                    "partition_subgroups_not_distinct",
                    "percent_change_vs_percent_of",
                ),
            ),
            {
                "partition_subgroups_not_distinct": ResidualKind.HAZARD_BLOCKED,
                "percent_subgroup_links_incomplete": (
                    ResidualKind.UNSUPPORTED_TOPOLOGY
                ),
                "percent_change_vs_percent_of": ResidualKind.HAZARD_BLOCKED,
            },
        ),
    )

    for assessment, expected in examples:
        residuals = project_contract_residuals((assessment,))
        assert {residual.residual_code for residual in residuals} == set(expected)
        for residual in residuals:
            assert residual.residual_kind is expected[residual.residual_code]


def test_positive_family_controls_project_to_empty_tuple() -> None:
    runnable_assessments = (
        _assessment(candidate_organ="unary_delta_transition", runnable=True),
        _assessment(candidate_organ="quantity_entity_binding", runnable=True),
        _assessment(candidate_organ="fraction_decrease", runnable=True),
        _assessment(candidate_organ="percent_partition", runnable=True),
    )

    assert project_contract_residuals(runnable_assessments) == ()


def test_module_imports_are_leaf_read_model_allowlist() -> None:
    source = Path("generate/contract_residuals.py").read_text()
    tree = ast.parse(source)
    imports: set[str] = set()
    calls: set[str] = set()

    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            imports.update(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom):
            imports.add(node.module or "")
        elif isinstance(node, ast.Call):
            if isinstance(node.func, ast.Name):
                calls.add(node.func.id)
            elif isinstance(node.func, ast.Attribute):
                calls.add(node.func.attr)

    assert imports <= {
        "__future__",
        "hashlib",
        "json",
        "dataclasses",
        "enum",
        "generate.kernel_facts",
        "generate.problem_frame_contracts",
    }

    forbidden_calls = {
        "build_problem_frame",
        "assess_contracts",
        "determine",
        "store",
        "write",
        "repair",
        "search",
        "serve",
        "SourceSpan",
    }
    assert forbidden_calls.isdisjoint(calls)


def test_no_reverse_imports_into_contract_residuals() -> None:
    for path in Path("generate").glob("*.py"):
        if path.name in ("contract_residuals.py", "search_gate.py"):
            continue
        assert "contract_residuals" not in path.read_text()


def test_enum_values_are_lower_snake_case() -> None:
    for enum_cls in (ResidualKind, ResidualSourceAxis):
        for member in enum_cls:
            assert member.value == member.value.lower()
            assert " " not in member.value
            assert "-" not in member.value
