from __future__ import annotations

import pytest

from icsd.adapters.authority_adapter import AuthorityProposal
from icsd.core.errors import InvariantViolationError
from icsd.core.invariants import Invariant, InvariantSet
from icsd.core.transition import Transition


def _balance_invariants() -> InvariantSet:
    return InvariantSet(
        [Invariant(name="non_negative_balance", check=lambda state: state["balance"] >= 0)]
    )


class _ProposalAdapter:
    def __init__(self, proposal_output: object) -> None:
        self._proposal_output = proposal_output
        self.calls: list[tuple[dict[str, object], dict[str, object]]] = []

    def propose(
        self,
        *,
        current_state: dict[str, object],
        authority_response: dict[str, object],
    ) -> object:
        self.calls.append((dict(current_state), dict(authority_response)))
        return self._proposal_output


def test_invariant_set_construct_state_from_proposal_merges_and_validates() -> None:
    invariants = _balance_invariants()

    candidate = invariants.construct_state_from_proposal(
        current_state={"balance": 100, "owner": "ops"},
        proposal_changes={"balance": 80},
    )

    assert candidate == {"balance": 80, "owner": "ops"}


def test_transition_validates_authority_proposal_without_treating_it_as_truth() -> None:
    adapter = _ProposalAdapter({"changes": {"balance": 60}})
    transition = Transition(
        name="debit_account",
        mutate=lambda state: state.update({"balance": state["balance"] - 5}),
        invariants=_balance_invariants(),
        authority_adapter=adapter,
    )

    result = transition.apply(
        state={"balance": 100},
        entity_type="account",
        entity_id="acct-001",
        actor="user:ops",
        authority="policy/account-debit",
        authority_response={"approved_balance": 60},
    )

    assert result.before_state == {"balance": 100}
    assert result.after_state == {"balance": 95}
    assert adapter.calls == [({"balance": 100}, {"approved_balance": 60})]


def test_transition_rejects_authority_response_without_adapter() -> None:
    transition = Transition(
        name="debit_account",
        mutate=lambda state: state,
        invariants=_balance_invariants(),
    )

    with pytest.raises(ValueError, match="authority_response requires authority_adapter"):
        transition.apply(
            state={"balance": 100},
            entity_type="account",
            entity_id="acct-001",
            actor="user:ops",
            authority="policy/account-debit",
            authority_response={"approved_balance": 90},
        )


def test_transition_rejects_invariant_violating_authority_proposal_before_mutation() -> None:
    mutate_calls = 0
    adapter = _ProposalAdapter({"changes": {"balance": -1}})

    def mutate(state: dict[str, int]) -> None:
        nonlocal mutate_calls
        mutate_calls += 1
        state["balance"] -= 5

    transition = Transition(
        name="debit_account",
        mutate=mutate,
        invariants=_balance_invariants(),
        authority_adapter=adapter,
    )

    with pytest.raises(InvariantViolationError):
        transition.apply(
            state={"balance": 100},
            entity_type="account",
            entity_id="acct-001",
            actor="user:ops",
            authority="policy/account-debit",
            authority_response={"approved_balance": -1},
        )

    assert mutate_calls == 0


def test_transition_rejects_invalid_authority_adapter_type() -> None:
    with pytest.raises(TypeError, match="authority_adapter must implement"):
        Transition(
            name="debit_account",
            mutate=lambda state: state,
            invariants=_balance_invariants(),
            authority_adapter=object(),  # type: ignore[arg-type]
        )


def test_transition_rejects_invalid_authority_proposal_output() -> None:
    adapter = _ProposalAdapter(["not-a-mapping"])
    transition = Transition(
        name="debit_account",
        mutate=lambda state: state,
        invariants=_balance_invariants(),
        authority_adapter=adapter,
    )

    with pytest.raises(ValueError, match="Authority proposal must be"):
        transition.apply(
            state={"balance": 100},
            entity_type="account",
            entity_id="acct-001",
            actor="user:ops",
            authority="policy/account-debit",
            authority_response={"approved_balance": 90},
        )


def test_authority_proposal_deep_copies_nested_changes_and_details() -> None:
    changes = {"account": {"balance": 80}, "flags": ["approved"]}
    details = {"meta": {"provider": "hr-idp"}, "tags": ["authority"]}
    proposal = AuthorityProposal(
        changes=changes,
        source="authority-api/hr-idp",
        reference="resp-001",
        details=details,
    )

    changes["account"]["balance"] = -1
    changes["flags"].append("mutated")
    details["meta"]["provider"] = "tampered"
    details["tags"].append("mutated")

    assert proposal.changes == {
        "account": {"balance": 80},
        "flags": ["approved"],
    }
    assert proposal.details == {
        "meta": {"provider": "hr-idp"},
        "tags": ["authority"],
    }
