"""Typed, evidence-led contracts for Trail / Terrain Character v0.1."""

from __future__ import annotations

from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any, Mapping


class EvidenceFamily(str, Enum):
    """Semantic origin family, never a quality or suitability classification."""

    TERRAIN_GEOMETRY = "TERRAIN_GEOMETRY"
    MAPPED_ROUTE_CHARACTER = "MAPPED_ROUTE_CHARACTER"


class EvidenceOrigin(str, Enum):
    """Whether a fact is retained input, sampled source data, or MT-derived."""

    ROUTE_INPUT = "ROUTE_INPUT"
    TERRAIN_SAMPLE = "TERRAIN_SAMPLE"
    MT_DERIVED = "MT_DERIVED"


class CharacterCoverage(str, Enum):
    """Coverage remains separate from a factual value and preserves unavailable support."""

    FULL = "FULL"
    PARTIAL = "PARTIAL"
    UNAVAILABLE = "UNAVAILABLE"
    UNKNOWN = "UNKNOWN"


class AssociationState(str, Enum):
    """Geometric/source association state, not physical trail state or quality."""

    MATCHED = "MATCHED"
    AMBIGUOUS = "AMBIGUOUS"
    UNMATCHED = "UNMATCHED"
    UNKNOWN = "UNKNOWN"


class AssociationContinuityKind(str, Enum):
    """Derived grouping kind; never a replacement association state."""

    EXACT_RAW_ASSOCIATION = "EXACT_RAW_ASSOCIATION"
    SAME_CANDIDATE_WITH_SHORT_RAW_UNMATCHED_GAPS = "SAME_CANDIDATE_WITH_SHORT_RAW_UNMATCHED_GAPS"


class MappedValueState(str, Enum):
    """Factual mapped-value state; distinct from an association state."""

    KNOWN = "KNOWN"
    UNKNOWN = "UNKNOWN"
    AMBIGUOUS = "AMBIGUOUS"
    UNMATCHED = "UNMATCHED"


@dataclass(frozen=True)
class EvidenceReference:
    """Compact provenance for one fact family/input; no confidence field exists."""

    evidence_id: str
    family: EvidenceFamily
    origin: EvidenceOrigin
    source_reference: str
    method_id: str | None = None
    method_version: str | None = None
    coverage: CharacterCoverage = CharacterCoverage.UNKNOWN
    limitations: tuple[str, ...] = ()


@dataclass(frozen=True)
class RouteReference:
    route_id: str
    point_count: int
    distance_m: float | None
    route_input_reference: str | None = None


@dataclass(frozen=True)
class ElevationSummary:
    """Coverage-aware summary of retained GPX route elevation values."""

    source_evidence_id: str
    unit: str
    supported_point_count: int
    total_point_count: int
    coverage: CharacterCoverage
    minimum_m: float | None
    maximum_m: float | None
    missing_reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class GradeReference:
    """Reference to existing Pace grade intervals, without copying their matrix."""

    source_evidence_id: str
    unit: str
    sign_semantics: str
    definition: str
    preprocessing_policy: str
    preprocessing_distance_m: float
    interval_count: int
    coverage: CharacterCoverage
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class AscentDescentSummary:
    source_evidence_id: str
    ascent_m: float | None
    descent_m: float | None
    coverage: CharacterCoverage
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class TerrainCharacter:
    elevation: ElevationSummary
    grade: GradeReference
    ascent_descent: AscentDescentSummary


@dataclass(frozen=True)
class MappedCharacterExtension:
    """Compact mapped-character composition over immutable association evidence."""

    family: EvidenceFamily = EvidenceFamily.MAPPED_ROUTE_CHARACTER
    coverage: CharacterCoverage = CharacterCoverage.UNAVAILABLE
    populated: bool = False
    data_gaps: tuple[str, ...] = ("MAPPED_ROUTE_CHARACTER_NOT_IMPLEMENTED_WP1",)
    association: "RouteAssociationResult | None" = None
    continuity: "RouteAssociationContinuityResult | None" = None
    normalization_policy: "MappedNormalizationPolicy | None" = None
    characteristics: tuple["MappedCharacteristicSummary", ...] = ()


@dataclass(frozen=True)
class GeoCoordinate:
    """WGS84 geographic coordinate; metric association remains derived evidence."""

    latitude: float
    longitude: float


@dataclass(frozen=True)
class MappedSource:
    """Source metadata as supplied; absent times are never invented."""

    source_id: str
    provider: str
    dataset_snapshot_reference: str | None = None
    retrieved_at: str | None = None
    attribution: str | None = None
    license: str | None = None


@dataclass(frozen=True)
class MappedFeature:
    """Source-agnostic feature preserving raw source properties and WGS84 geometry."""

    source: MappedSource
    feature_id: str
    geometry: tuple[GeoCoordinate, ...]
    raw_properties: Mapping[str, Any]
    source_element_version: str | None = None
    source_updated_at: str | None = None
    crs: str = "EPSG:4326"


@dataclass(frozen=True)
class RouteAssociationPolicy:
    """Replaceable R&D geometric predicates, not calibrated production thresholds."""

    policy_id: str
    version: str
    threshold_origin: str
    maximum_lateral_distance_m: float
    maximum_direction_difference_deg: float


@dataclass(frozen=True)
class CandidateAssociationEvidence:
    feature_id: str
    source_id: str
    supported_route_length_m: float
    supported_route_fraction: float | None
    maximum_lateral_distance_m: float | None
    direction_compatible_length_m: float
    geometry_valid: bool
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class AssociationSpan:
    """One unbridged route interval with explicit geometric association state."""

    state: AssociationState
    start_route_distance_m: float
    end_route_distance_m: float
    candidate_feature_ids: tuple[str, ...] = ()
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class RouteAssociationResult:
    state: AssociationState
    coverage: CharacterCoverage
    policy: RouteAssociationPolicy
    candidate_evidence: tuple[CandidateAssociationEvidence, ...]
    spans: tuple[AssociationSpan, ...]
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class RouteAssociationContinuityPolicy:
    """Versioned presentation grouping policy, separate from association predicates."""

    policy_id: str
    version: str
    threshold_origin: str
    maximum_raw_unmatched_gap_m: float


@dataclass(frozen=True)
class AssociationStateDistance:
    """Raw association-state distance retained inside one derived continuity span."""

    state: AssociationState
    distance_m: float


@dataclass(frozen=True)
class AssociationContinuitySpan:
    """Distance-aware grouping that references immutable raw association spans."""

    kind: AssociationContinuityKind
    start_route_distance_m: float
    end_route_distance_m: float
    candidate_feature_ids: tuple[str, ...]
    raw_span_start_index: int
    raw_span_end_index: int
    raw_state_distances: tuple[AssociationStateDistance, ...]
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class RouteAssociationContinuityResult:
    """Raw association plus a derived continuity representation; neither overwrites evidence."""

    raw_association: RouteAssociationResult
    policy: RouteAssociationContinuityPolicy
    spans: tuple[AssociationContinuitySpan, ...]


@dataclass(frozen=True)
class MappedNormalizationPolicy:
    """Small, source-preserving normalization contract for WP3."""

    policy_id: str
    version: str
    route_class_vocabulary: tuple[str, ...]
    surface_vocabulary: tuple[str, ...]


@dataclass(frozen=True)
class MappedCharacteristicSegment:
    """One characteristic-specific route-distance interval with recoverable raw values."""

    characteristic_id: str
    start_route_distance_m: float
    end_route_distance_m: float
    value_state: MappedValueState
    normalized_value: str | None
    raw_values: tuple[str, ...]
    source_feature_ids: tuple[str, ...]
    raw_association_span_indices: tuple[int, ...]
    normalization_policy_id: str
    normalization_policy_version: str
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class MappedCharacteristicTotal:
    value_state: MappedValueState
    normalized_value: str | None
    distance_m: float


@dataclass(frozen=True)
class MappedCharacteristicSummary:
    characteristic_id: str
    segment_count: int
    route_distance_m: float
    totals: tuple[MappedCharacteristicTotal, ...]
    raw_values_encountered: tuple[str, ...]


@dataclass(frozen=True)
class TrailTerrainCharacterResult:
    """Canonical compact A5 result; factual character only, never trail status."""

    schema_version: str
    analysis_identity: Mapping[str, Any]
    route: RouteReference
    evidence: tuple[EvidenceReference, ...]
    terrain_character: TerrainCharacter
    mapped_character: MappedCharacterExtension
    mapped_character_segments: tuple[MappedCharacteristicSegment, ...] = ()
    limitations: tuple[str, ...] = ()
    data_gaps: tuple[str, ...] = ()
    diagnostics: tuple[str, ...] = ()

    def to_dict(self) -> dict[str, Any]:
        return _json_value(asdict(self))


def _json_value(value: Any) -> Any:
    if isinstance(value, Enum):
        return value.value
    if isinstance(value, Mapping):
        return {str(key): _json_value(item) for key, item in value.items()}
    if isinstance(value, (tuple, list)):
        return [_json_value(item) for item in value]
    return value
