from __future__ import annotations

import pytest

from icsd.core.errors import (
    InvariantFailure,
    InvariantViolationError,
    PolicyProfileNotFoundError,
    PolicyVersionMismatchError,
)
from icsd.core.invariants import (
    Invariant,
    InvariantProfile,
    InvariantProfileRegistry,
    InvariantSet,
)


def test_invariant_validate_returns_no_failures_for_true_result() -> None:
    invariant = Invariant(name="value_non_negative", check=lambda state: state["value"] >= 0)

    assert invariant.validate({"value": 1}) == ()


def test_invariant_validate_false_result_uses_default_failure_payload() -> None:
    invariant = Invariant(name="value_non_negative", check=lambda state: state["value"] >= 0)

    failures = invariant.validate({"value": -1})

    assert len(failures) == 1
    assert failures[0] == InvariantFailure(
        invariant="value_non_negative",
        code="invariant_failed",
        message="Invariant 'value_non_negative' failed.",
    )


def test_invariant_validate_accepts_structured_failure_return() -> None:
    failure = InvariantFailure(
        invariant="transaction_limit",
        code="amount_too_high",
        message="Transaction amount exceeds the configured threshold.",
        details={"limit": 1000, "actual": 1250},
    )
    invariant = Invariant(name="transaction_limit", check=lambda _: failure)

    assert invariant.validate({"amount": 1250}) == (failure,)


def test_invariant_failure_trims_required_string_fields() -> None:
    failure = InvariantFailure(
        invariant=" requires_owner ",
        code=" missing_owner ",
        message=" Owner must be set. ",
    )

    assert failure.as_dict() == {
        "invariant": "requires_owner",
        "code": "missing_owner",
        "message": "Owner must be set.",
    }


@pytest.mark.parametrize(
    ("field_name", "value"),
    [
        ("invariant", ""),
        ("invariant", "   "),
        ("message", ""),
        ("message", "   "),
        ("code", ""),
        ("code", "   "),
    ],
)
def test_invariant_failure_rejects_blank_required_string_fields(
    field_name: str,
    value: str,
) -> None:
    kwargs = {
        "invariant": "requires_owner",
        "code": "missing_owner",
        "message": "Owner must be set.",
    }
    kwargs[field_name] = value

    with pytest.raises(ValueError, match=rf"{field_name} must be a non-empty string\."):
        InvariantFailure(**kwargs)


def test_invariant_failure_rejects_non_mapping_details() -> None:
    with pytest.raises(TypeError, match="details must be a mapping"):
        InvariantFailure(
            invariant="requires_owner",
            code="missing_owner",
            message="Owner must be set.",
            details=[("source", "policy")],  # type: ignore[arg-type]
        )


def test_invariant_failure_deep_copies_nested_details_on_input() -> None:
    details = {"meta": {"reason": "threshold"}, "steps": ["validate"]}

    failure = InvariantFailure(
        invariant="transaction_limit",
        code="amount_too_high",
        message="Transaction amount exceeds the configured threshold.",
        details=details,
    )

    details["meta"]["reason"] = "mutated"
    details["steps"].append("tampered")

    assert failure.as_dict()["details"] == {
        "meta": {"reason": "threshold"},
        "steps": ["validate"],
    }


def test_invariant_failure_as_dict_deep_copies_nested_details_output() -> None:
    failure = InvariantFailure(
        invariant="transaction_limit",
        code="amount_too_high",
        message="Transaction amount exceeds the configured threshold.",
        details={"meta": {"reason": "threshold"}, "steps": ["validate"]},
    )

    payload = failure.as_dict()
    payload["details"]["meta"]["reason"] = "changed"
    payload["details"]["steps"].append("mutated")

    assert failure.as_dict()["details"] == {
        "meta": {"reason": "threshold"},
        "steps": ["validate"],
    }


def test_invariant_rejects_invalid_check_return_type() -> None:
    invariant = Invariant(name="type_guard", check=lambda _: "invalid")

    with pytest.raises(TypeError, match="must return bool, None, InvariantFailure"):
        invariant.validate({})


def test_invariant_set_validate_collects_failures_in_declaration_order() -> None:
    invariants = InvariantSet(
        [
            Invariant(name="first", check=lambda _: False),
            Invariant(name="second", check=lambda _: True),
            Invariant(
                name="third",
                check=lambda _: [
                    InvariantFailure(
                        invariant="third",
                        code="third_failure",
                        message="Third rule failed.",
                    )
                ],
            ),
        ]
    )

    failures = invariants.validate({})

    assert [failure.invariant for failure in failures] == ["first", "third"]
    assert [failure.code for failure in failures] == ["invariant_failed", "third_failure"]


def test_invariant_set_assert_valid_raises_structured_violation_error() -> None:
    invariants = InvariantSet([Invariant(name="requires_signature", check=lambda _: False)])

    with pytest.raises(InvariantViolationError) as exc_info:
        invariants.assert_valid({})

    error = exc_info.value
    assert len(error.failures) == 1
    assert error.as_dict() == {
        "error": "invariant_violation",
        "failures": [
            {
                "invariant": "requires_signature",
                "code": "invariant_failed",
                "message": "Invariant 'requires_signature' failed.",
            }
        ],
    }


def test_invariant_profile_builds_authority_reference() -> None:
    profile = InvariantProfile(
        name="uk-payroll",
        policy="policy/payroll-tax",
        policy_version="2026.03",
        invariants=[Invariant(name="non_negative", check=lambda state: state["value"] >= 0)],
    )

    assert profile.authority == "policy/payroll-tax@2026.03"
    assert profile.invariants.is_valid({"value": 1})


def test_invariant_profile_registry_resolves_profile_and_version() -> None:
    profile = InvariantProfile(
        name="uk-payroll",
        policy="policy/payroll-tax",
        policy_version="2026.03",
        invariants=[Invariant(name="non_negative", check=lambda state: state["value"] >= 0)],
    )
    registry = InvariantProfileRegistry([profile])

    resolved = registry.resolve(profile="uk-payroll", policy_version="2026.03")

    assert resolved is profile
    assert registry.profile_names == ("uk-payroll",)


def test_invariant_profile_registry_rejects_unknown_profile() -> None:
    registry = InvariantProfileRegistry(
        [
            InvariantProfile(
                name="uk-payroll",
                policy="policy/payroll-tax",
                policy_version="2026.03",
                invariants=[
                    Invariant(name="non_negative", check=lambda state: state["value"] >= 0)
                ],
            )
        ]
    )

    with pytest.raises(PolicyProfileNotFoundError, match="Unknown invariant profile"):
        registry.resolve(profile="de-payroll")


def test_invariant_profile_registry_rejects_policy_version_mismatch() -> None:
    registry = InvariantProfileRegistry(
        [
            InvariantProfile(
                name="uk-payroll",
                policy="policy/payroll-tax",
                policy_version="2026.03",
                invariants=[
                    Invariant(name="non_negative", check=lambda state: state["value"] >= 0)
                ],
            )
        ]
    )

    with pytest.raises(PolicyVersionMismatchError, match="Policy version mismatch"):
        registry.resolve(profile="uk-payroll", policy_version="2026.02")


def test_invariant_profile_registry_rejects_duplicate_profile_names() -> None:
    profile_one = InvariantProfile(
        name="uk-payroll",
        policy="policy/payroll-tax",
        policy_version="2026.03",
        invariants=[Invariant(name="non_negative", check=lambda state: state["value"] >= 0)],
    )
    profile_two = InvariantProfile(
        name="uk-payroll",
        policy="policy/payroll-tax",
        policy_version="2026.04",
        invariants=[Invariant(name="non_negative", check=lambda state: state["value"] >= 0)],
    )

    with pytest.raises(ValueError, match="Duplicate invariant profile name"):
        InvariantProfileRegistry([profile_one, profile_two])


def test_invariant_set_construct_state_returns_validated_copy() -> None:
    invariants = InvariantSet(
        [Invariant(name="non_negative", check=lambda state: state["value"] >= 0)]
    )
    original = {"value": 2}

    constructed = invariants.construct_state(original)

    assert constructed == {"value": 2}
    assert constructed is not original


def test_invariant_set_construct_state_rejects_invalid_state() -> None:
    invariants = InvariantSet(
        [Invariant(name="non_negative", check=lambda state: state["value"] >= 0)]
    )

    with pytest.raises(InvariantViolationError):
        invariants.construct_state({"value": -1})


def test_invariant_set_construct_state_rejects_non_mapping_input() -> None:
    invariants = InvariantSet(
        [Invariant(name="non_negative", check=lambda state: state["value"] >= 0)]
    )

    with pytest.raises(TypeError, match="must be a mapping"):
        invariants.construct_state(["not", "a", "mapping"])  # type: ignore[arg-type]
