"""Tests for immutable domain contracts and disjoint vocabularies."""

from __future__ import annotations

import pytest

from osm_lead_source_service.domain.enums import (
    AcceptedMappingOutcome,
    CandidateReviewOutcome,
    OsmObjectType,
    PlannedActionType,
    ProfileEligibilityState,
    SourcePresenceState,
)
from osm_lead_source_service.domain.models import (
    OsmIdentity,
    PlannedAction,
    ProfileEligibilityRecord,
    ProfileVersion,
    RegionScopedIdentity,
    SourcePresenceRecord,
)
from osm_lead_source_service.errors import ContractViolation, UnsupportedOperation


def test_source_presence_and_profile_eligibility_are_disjoint() -> None:
    assert {state.value for state in SourcePresenceState} == {
        "present",
        "missing",
        "stale_confirmed",
        "remap_candidate",
        "deleted_in_osm",
    }
    assert {state.value for state in ProfileEligibilityState} == {
        "active",
        "out_of_scope",
        "excluded",
        "pending_review",
    }
    assert not set(SourcePresenceState) & set(ProfileEligibilityState)
    assert not {"active", "out_of_scope", "excluded", "pending_review"} & {
        state.value for state in SourcePresenceState
    }
    assert not {"missing", "stale_confirmed", "remap_candidate", "deleted_in_osm"} & {
        state.value for state in ProfileEligibilityState
    }


def test_osm_object_type_vocabulary_is_closed() -> None:
    assert [member.value for member in OsmObjectType] == ["node", "way", "relation"]
    with pytest.raises(ValueError):
        OsmObjectType("area")


def test_osm_identity_requires_positive_integer_id() -> None:
    identity = OsmIdentity(OsmObjectType.NODE, 42)
    assert identity.osm_id == 42
    assert identity.osm_type is OsmObjectType.NODE
    with pytest.raises(ContractViolation, match="positive integer"):
        OsmIdentity(OsmObjectType.NODE, 0)
    with pytest.raises(ContractViolation, match="positive integer"):
        OsmIdentity(OsmObjectType.NODE, -1)
    with pytest.raises(ContractViolation, match="positive integer"):
        OsmIdentity(OsmObjectType.NODE, True)  # type: ignore[arg-type]
    with pytest.raises(ContractViolation, match="supported value"):
        OsmIdentity("area", 42)  # type: ignore[arg-type]


def test_region_scoped_identity_requires_nonblank_region() -> None:
    identity = RegionScopedIdentity("  europe  ", OsmObjectType.WAY, 7)
    assert identity.region_id == "europe"
    assert identity.osm_identity == OsmIdentity(OsmObjectType.WAY, 7)
    with pytest.raises(ContractViolation, match="region_id"):
        RegionScopedIdentity("  ", OsmObjectType.WAY, 7)


def test_present_object_can_have_profile_specific_eligibility() -> None:
    identity = RegionScopedIdentity("region-a", OsmObjectType.NODE, 100)
    presence = SourcePresenceRecord(
        identity=identity,
        state=SourcePresenceState.PRESENT,
        snapshot_references=("snapshot-1",),
        evidence=("object found",),
    )
    luxeillum = ProfileEligibilityRecord(
        identity=identity,
        profile=ProfileVersion("luxeillum", "1"),
        state=ProfileEligibilityState.ACTIVE,
        rule_evidence=("matches profile",),
    )
    saas = ProfileEligibilityRecord(
        identity=identity,
        profile=ProfileVersion("saas_redesign", "1"),
        state=ProfileEligibilityState.OUT_OF_SCOPE,
        rule_evidence=("outside category",),
    )
    assert presence.state is SourcePresenceState.PRESENT
    assert luxeillum.state is ProfileEligibilityState.ACTIVE
    assert saas.state is ProfileEligibilityState.OUT_OF_SCOPE
    assert luxeillum.identity == saas.identity == presence.identity


def test_out_of_scope_does_not_change_source_presence() -> None:
    identity = RegionScopedIdentity("region-a", OsmObjectType.RELATION, 8)
    presence = SourcePresenceRecord(identity, SourcePresenceState.PRESENT)
    eligibility = ProfileEligibilityRecord(
        identity,
        ProfileVersion("profile", "2026-01"),
        ProfileEligibilityState.OUT_OF_SCOPE,
    )
    assert presence.state is SourcePresenceState.PRESENT
    assert eligibility.state is ProfileEligibilityState.OUT_OF_SCOPE


def test_adoption_outcomes_remain_separate() -> None:
    assert {outcome.value for outcome in AcceptedMappingOutcome} == {
        "exact",
        "high_confidence",
        "remapped",
    }
    assert {outcome.value for outcome in CandidateReviewOutcome} == {
        "ambiguous",
        "unmatched",
        "rejected",
        "collision",
        "needs_review",
        "accepted",
    }
    assert not {outcome.value for outcome in AcceptedMappingOutcome} & {
        outcome.value for outcome in CandidateReviewOutcome
    }


def test_planned_action_requires_reason_and_evidence() -> None:
    with pytest.raises(ContractViolation, match="reason_codes"):
        PlannedAction(PlannedActionType.NO_CHANGE, (), ("snapshot",))
    with pytest.raises(ContractViolation, match="evidence"):
        PlannedAction(PlannedActionType.NO_CHANGE, ("same",), ())


def test_planned_action_is_deterministic_and_non_executable() -> None:
    first = PlannedAction(
        PlannedActionType.DELETE_SAFE_STALE,
        ("stale-confirmed",),
        ("snapshot-1", "snapshot-2"),
    )
    second = PlannedAction(
        PlannedActionType.DELETE_SAFE_STALE,
        ("stale-confirmed",),
        ("snapshot-1", "snapshot-2"),
    )
    assert first.idempotency_key == second.idempotency_key
    assert first.planning_key == first.idempotency_key
    assert first.executable is False
    with pytest.raises(UnsupportedOperation, match="report-only"):
        first.execute()
