from __future__ import annotations

from collections.abc import Mapping
from copy import deepcopy

import pytest

from icsd.core.errors import InvariantViolationError
from icsd.core.invariants import Invariant, InvariantProfile, InvariantProfileRegistry, InvariantSet
from icsd.core.transition import Transition

ABILITIES = ("str", "dex", "con", "int", "wis", "cha")
ALLOWED_CONDITIONS = {"blessed", "poisoned", "prone", "stunned"}


def _combat_invariants() -> InvariantSet:
    return InvariantSet(
        [
            Invariant(
                name="hp_in_bounds",
                check=lambda state: 0 <= int(state["hp"]) <= int(state["max_hp"]),
            ),
            Invariant(
                name="conditions_allowed",
                check=lambda state: (
                    isinstance(state.get("conditions"), list)
                    and all(
                        isinstance(value, str) and value in ALLOWED_CONDITIONS
                        for value in state["conditions"]
                    )
                ),
            ),
            Invariant(
                name="damage_amount_non_negative",
                check=lambda state: (
                    state.get("last_action") != "take_damage" or int(state["last_amount"]) >= 0
                ),
            ),
        ]
    )


def _take_damage(amount: int):
    def _mutate(state: dict[str, object]) -> None:
        state.update(
            {
                "last_action": "take_damage",
                "last_amount": amount,
                "hp": int(state["hp"]) - amount,
            }
        )

    return _mutate


def _expected_proficiency(level: int) -> int:
    return 2 + (max(1, min(level, 20)) - 1) // 4


def _character_sheet_invariants() -> InvariantSet:
    return InvariantSet(
        [
            Invariant(
                name="ability_scores_in_range",
                check=lambda state: all(1 <= int(state[ability]) <= 20 for ability in ABILITIES),
            ),
            Invariant(
                name="proficiency_matches_level_band",
                check=lambda state: (
                    int(state["proficiency_bonus"]) == _expected_proficiency(int(state["level"]))
                ),
            ),
            Invariant(
                name="point_buy_within_cap",
                check=lambda state: (
                    sum(int(state[ability]) for ability in ABILITIES) <= int(state["point_buy_cap"])
                ),
            ),
        ]
    )


def _level_up(state: dict[str, object]) -> None:
    next_level = int(state["level"]) + 1
    state.update({"level": next_level, "proficiency_bonus": _expected_proficiency(next_level)})


def _rules_v202601() -> InvariantSet:
    return InvariantSet(
        [
            Invariant(
                name="stage_known_v202601",
                check=lambda state: state.get("stage") in {"ready", "critical_declared"},
            ),
            Invariant(
                name="critical_roll_threshold_v202601",
                check=lambda state: (
                    state.get("stage") != "critical_declared" or int(state["attack_roll"]) >= 19
                ),
            ),
        ]
    )


def _rules_v202606() -> InvariantSet:
    return InvariantSet(
        [
            Invariant(
                name="stage_known_v202606",
                check=lambda state: state.get("stage") in {"ready", "critical_declared"},
            ),
            Invariant(
                name="critical_roll_threshold_v202606",
                check=lambda state: (
                    state.get("stage") != "critical_declared" or int(state["attack_roll"]) >= 20
                ),
            ),
        ]
    )


def _rules_registry() -> InvariantProfileRegistry:
    return InvariantProfileRegistry(
        [
            InvariantProfile(
                name="dnd-critical-rules",
                policy="table/homebrew-critical-hit",
                policy_version="2026.01",
                active_from="2026-01-01T00:00:00Z",
                active_until="2026-06-01T00:00:00Z",
                invariants=_rules_v202601(),
            ),
            InvariantProfile(
                name="dnd-critical-rules",
                policy="table/homebrew-critical-hit",
                policy_version="2026.06",
                active_from="2026-06-01T00:00:00Z",
                invariants=_rules_v202606(),
            ),
        ]
    )


def _trustless_invariants() -> InvariantSet:
    return InvariantSet(
        [
            Invariant(
                name="stage_known",
                check=lambda state: state.get("stage") in {"awaiting_roll", "resolved"},
            ),
            Invariant(
                name="committed_roll_in_range",
                check=lambda state: (
                    isinstance(state.get("committed_roll"), int)
                    and 1 <= int(state["committed_roll"]) <= 20
                ),
            ),
            Invariant(
                name="proposed_roll_in_range",
                check=lambda state: (
                    state.get("proposed_roll") is None
                    or (
                        isinstance(state.get("proposed_roll"), int)
                        and 1 <= int(state["proposed_roll"]) <= 20
                    )
                ),
            ),
            Invariant(
                name="proposed_roll_matches_commitment",
                check=lambda state: (
                    state.get("proposed_roll") is None
                    or int(state["proposed_roll"]) == int(state["committed_roll"])
                ),
            ),
            Invariant(
                name="resolved_requires_applied_roll",
                check=lambda state: (
                    state.get("stage") != "resolved"
                    or (
                        isinstance(state.get("applied_roll"), int)
                        and 1 <= int(state["applied_roll"]) <= 20
                    )
                ),
            ),
        ]
    )


class _ExternalDiceRollerAdapter:
    def __init__(self) -> None:
        self.last_proposal: dict[str, object] | None = None

    def propose(
        self,
        *,
        current_state: Mapping[str, object],
        authority_response: Mapping[str, object],
    ) -> dict[str, object]:
        proposal = {
            "changes": {"proposed_roll": int(authority_response["rolled_value"])},
            "source": "plugin/dice-roller",
            "reference": str(authority_response["event_id"]),
            "details": {
                "plugin_version": str(authority_response.get("plugin_version", "unknown")),
                "state_commitment": int(current_state["committed_roll"]),
            },
        }
        self.last_proposal = deepcopy(proposal)
        return proposal


def test_dnd_combat_conformance_rejection_is_non_mutating_and_deterministic() -> None:
    invariants = _combat_invariants()
    base_state = invariants.construct_state(
        {
            "character_id": "DND-COMBAT-CONF-001",
            "hp": 18,
            "max_hp": 20,
            "ac": 15,
            "conditions": ["blessed"],
            "last_action": "setup",
            "last_amount": 0,
        }
    )
    successful = Transition.simple(
        name="take_damage",
        mutate=_take_damage(5),
        invariants=invariants,
    ).apply(
        state=base_state,
        entity_type="dnd_combat_state",
        entity_id="DND-COMBAT-CONF-001",
        actor="player:fighter",
        authority="table/dnd-lite-combat",
        timestamp="2026-03-14T18:00:00Z",
    )
    assert successful.after_state["hp"] == 13
    assert successful.evidence.verify_integrity(
        before_state=successful.before_state,
        after_state=successful.after_state,
    )

    stable_input = successful.after_state
    before_rejection = deepcopy(stable_input)
    with pytest.raises(InvariantViolationError) as exc_info:
        Transition.simple(
            name="take_damage",
            mutate=_take_damage(-2),
            invariants=invariants,
        ).apply(
            state=stable_input,
            entity_type="dnd_combat_state",
            entity_id="DND-COMBAT-CONF-001",
            actor="player:fighter",
            authority="table/dnd-lite-combat",
            timestamp="2026-03-14T18:00:10Z",
        )
    assert stable_input == before_rejection
    assert tuple(failure.invariant for failure in exc_info.value.failures) == (
        "damage_amount_non_negative",
    )


def test_dnd_character_sheet_conformance_is_non_mutating_and_deterministic() -> None:
    invariants = _character_sheet_invariants()
    base_state = invariants.construct_state(
        {
            "character_id": "DND-SHEET-CONF-001",
            "level": 4,
            "proficiency_bonus": 2,
            "point_buy_cap": 70,
            "str": 8,
            "dex": 8,
            "con": 8,
            "int": 8,
            "wis": 8,
            "cha": 8,
        }
    )
    leveled = Transition.simple(
        name="level_up",
        mutate=_level_up,
        invariants=invariants,
    ).apply(
        state=base_state,
        entity_type="dnd_character_sheet",
        entity_id="DND-SHEET-CONF-001",
        actor="player:wizard",
        authority="table/dnd-lite-character-sheet",
        timestamp="2026-03-14T19:00:00Z",
    )
    assert leveled.after_state["level"] == 5
    assert leveled.after_state["proficiency_bonus"] == 3
    assert leveled.evidence.verify_integrity(
        before_state=leveled.before_state,
        after_state=leveled.after_state,
    )

    stable_input = leveled.after_state
    before_rejection = deepcopy(stable_input)
    with pytest.raises(InvariantViolationError) as exc_info:
        Transition.simple(
            name="allocate_points",
            mutate=lambda state: state.update({"str": int(state["str"]) + 13}),
            invariants=invariants,
        ).apply(
            state=stable_input,
            entity_type="dnd_character_sheet",
            entity_id="DND-SHEET-CONF-001",
            actor="player:wizard",
            authority="table/dnd-lite-character-sheet",
            timestamp="2026-03-14T19:00:15Z",
        )
    assert stable_input == before_rejection
    assert tuple(failure.invariant for failure in exc_info.value.failures) == (
        "ability_scores_in_range",
    )


def test_dnd_rules_cutover_conformance_matches_policy_transition() -> None:
    transition = Transition(
        name="declare_critical_hit",
        mutate=lambda state: state.update(
            {"stage": "critical_declared", "declared_critical": True}
        ),
        profile_registry=_rules_registry(),
        default_profile="dnd-critical-rules",
    )
    base_state = _rules_v202601().construct_state(
        {
            "encounter_id": "DND-RULES-CONF-001",
            "stage": "ready",
            "attack_roll": 19,
            "declared_critical": False,
            "weapon": "longsword",
        }
    )
    pre_cutover = transition.apply(
        state=base_state,
        entity_type="dnd_encounter",
        entity_id="DND-RULES-CONF-001",
        actor="player:ranger",
        profile="dnd-critical-rules",
        timestamp="2026-05-31T21:00:00Z",
    )
    assert pre_cutover.after_state["stage"] == "critical_declared"
    assert pre_cutover.evidence.verify_integrity(
        before_state=pre_cutover.before_state,
        after_state=pre_cutover.after_state,
    )

    stable_input = base_state
    before_rejection = deepcopy(stable_input)
    with pytest.raises(InvariantViolationError) as exc_info:
        transition.apply(
            state=stable_input,
            entity_type="dnd_encounter",
            entity_id="DND-RULES-CONF-001",
            actor="player:ranger",
            profile="dnd-critical-rules",
            timestamp="2026-06-02T21:00:00Z",
        )
    assert stable_input == before_rejection
    assert tuple(failure.invariant for failure in exc_info.value.failures) == (
        "critical_roll_threshold_v202606",
    )


def test_dnd_trustless_conformance_rejects_invalid_plugin_proposal_pre_mutation() -> None:
    adapter = _ExternalDiceRollerAdapter()
    mutate_calls = 0

    def _resolve_attack(state: dict[str, object]) -> None:
        nonlocal mutate_calls
        mutate_calls += 1
        committed_roll = int(state["committed_roll"])
        state.update(
            {
                "stage": "resolved",
                "applied_roll": committed_roll,
                "hit": committed_roll >= int(state["target_ac"]),
            }
        )

    transition = Transition(
        name="resolve_attack",
        mutate=_resolve_attack,
        invariants=_trustless_invariants(),
        authority_adapter=adapter,
    )
    stable_state = _trustless_invariants().construct_state(
        {
            "encounter_id": "DND-TRUSTLESS-CONF-001",
            "stage": "awaiting_roll",
            "committed_roll": 14,
            "proposed_roll": None,
            "applied_roll": None,
            "target_ac": 13,
            "hit": False,
        }
    )

    before_rejection = deepcopy(stable_state)
    with pytest.raises(InvariantViolationError) as exc_info:
        transition.apply(
            state=stable_state,
            entity_type="dnd_encounter",
            entity_id="DND-TRUSTLESS-CONF-001",
            actor="gm:table-host",
            authority="table/dnd-trustless-dice-plugin",
            authority_response={
                "rolled_value": 20,
                "event_id": "roll-conf-001",
                "plugin_version": "0.9.4",
            },
            timestamp="2026-03-15T20:00:00Z",
        )
    assert stable_state == before_rejection
    assert mutate_calls == 0
    assert tuple(failure.invariant for failure in exc_info.value.failures) == (
        "proposed_roll_matches_commitment",
    )
    assert adapter.last_proposal is not None
    assert adapter.last_proposal["changes"] == {"proposed_roll": 20}

    accepted = transition.apply(
        state=stable_state,
        entity_type="dnd_encounter",
        entity_id="DND-TRUSTLESS-CONF-001",
        actor="gm:table-host",
        authority="table/dnd-trustless-dice-plugin",
        authority_response={
            "rolled_value": 14,
            "event_id": "roll-conf-002",
            "plugin_version": "0.9.4",
        },
        timestamp="2026-03-15T20:00:30Z",
    )
    assert mutate_calls == 1
    assert accepted.after_state["stage"] == "resolved"
    assert accepted.after_state["hit"] is True
    assert accepted.evidence.verify_integrity(
        before_state=accepted.before_state,
        after_state=accepted.after_state,
    )
