"""Compact, deterministic contracts for Photographer Intelligence v0.1."""

from __future__ import annotations

import hashlib
import json
import math
from dataclasses import asdict, dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Mapping

from mountain_twin.analysis_contract import AnalysisIdentity
from mountain_twin.photographer.policy import (
    ConventionSupportState,
    PhotographerPolicy,
    PhotographicConvention,
)
from mountain_twin.rci.contracts import CoverageState, EvidenceQuality, ThresholdProvenance
from mountain_twin.solar.states import TerrainSolarVisibility


class ConventionMembership(str, Enum):
    """A convention sample state; UNKNOWN is deliberately not OUTSIDE_WINDOW."""

    IN_WINDOW = "IN_WINDOW"
    OUTSIDE_WINDOW = "OUTSIDE_WINDOW"
    UNKNOWN = "UNKNOWN"
    NOT_APPLICABLE = "NOT_APPLICABLE"


class BoundarySemantics(str, Enum):
    """Location semantics for compact route-window boundaries."""

    ANALYSIS_WINDOW_START = "ANALYSIS_WINDOW_START"
    ANALYSIS_WINDOW_END = "ANALYSIS_WINDOW_END"
    TRANSITION_BRACKET = "TRANSITION_BRACKET"
    DATA_GAP_BRACKET = "DATA_GAP_BRACKET"
    SAMPLED_POINT = "SAMPLED_POINT"


class TerrainLightEventType(str, Enum):
    """Factual terrain-light encounters, never quality or safety conclusions."""

    FIRST_DIRECT_TERRAIN_LIGHT = "FIRST_DIRECT_TERRAIN_LIGHT"
    LAST_DIRECT_TERRAIN_LIGHT = "LAST_DIRECT_TERRAIN_LIGHT"
    DIRECT_LIGHT_ENTRY = "DIRECT_LIGHT_ENTRY"
    DIRECT_LIGHT_EXIT = "DIRECT_LIGHT_EXIT"


class AtmosphericContextScopeType(str, Enum):
    """Existing factual selections to which compact weather context may refer."""

    GOLDEN_HOUR_WINDOW = "GOLDEN_HOUR_WINDOW"
    BLUE_HOUR_WINDOW = "BLUE_HOUR_WINDOW"
    TERRAIN_DIRECT_SAMPLES = "TERRAIN_DIRECT_SAMPLES"


@dataclass(frozen=True)
class RouteSampleReference:
    """One existing Unified route point, retained only when a window needs it."""

    route_point_index: int
    route_distance_m: float
    planned_time: str
    elapsed_planned_seconds: float

    def __post_init__(self) -> None:
        if self.route_point_index < 0:
            raise ValueError("route point index must be non-negative")
        if not all(math.isfinite(value) for value in (self.route_distance_m, self.elapsed_planned_seconds)):
            raise ValueError("route sample reference values must be finite")
        if self.route_distance_m < 0 or self.elapsed_planned_seconds < 0:
            raise ValueError("route sample reference values must be non-negative")
        parsed = datetime.fromisoformat(self.planned_time)
        if parsed.tzinfo is None or parsed.utcoffset() is None:
            raise ValueError("planned time must include a timezone offset")

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


@dataclass(frozen=True)
class RouteBoundary:
    """A sampled endpoint or adjacent-sample bracket, never an exact crossing."""

    semantics: BoundarySemantics
    samples: tuple[RouteSampleReference, ...]

    def __post_init__(self) -> None:
        expected = (
            2
            if self.semantics
            in {BoundarySemantics.TRANSITION_BRACKET, BoundarySemantics.DATA_GAP_BRACKET}
            else 1
        )
        if len(self.samples) != expected:
            raise ValueError("boundary sample count does not match its semantics")
        if len(self.samples) == 2:
            before, after = self.samples
            if after.route_point_index <= before.route_point_index:
                raise ValueError("transition bracket route points must increase")
            if after.route_distance_m < before.route_distance_m:
                raise ValueError("transition bracket distance must be monotonic")
            if after.elapsed_planned_seconds < before.elapsed_planned_seconds:
                raise ValueError("transition bracket elapsed time must be monotonic")

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


@dataclass(frozen=True)
class PhotographicWindow:
    """A contiguous run of in-window samples with truthful endpoint semantics."""

    window_id: str
    convention: PhotographicConvention
    first_in_window: RouteSampleReference
    last_in_window: RouteSampleReference
    start_boundary: RouteBoundary
    end_boundary: RouteBoundary

    def __post_init__(self) -> None:
        if not self.window_id:
            raise ValueError("photographic window requires a stable ID")
        if self.last_in_window.route_point_index < self.first_in_window.route_point_index:
            raise ValueError("window route points must be ordered")
        if self.last_in_window.route_distance_m < self.first_in_window.route_distance_m:
            raise ValueError("window distances must be monotonic")
        if self.last_in_window.elapsed_planned_seconds < self.first_in_window.elapsed_planned_seconds:
            raise ValueError("window elapsed times must be monotonic")

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


@dataclass(frozen=True)
class ConventionWindowResult:
    """Compact convention windows; no route-scale sample matrix is retained."""

    convention: PhotographicConvention
    support_state: ConventionSupportState
    coverage: CoverageState
    intersects_route: bool | None
    windows: tuple[PhotographicWindow, ...] = ()
    threshold_provenance: ThresholdProvenance | None = None
    reason_codes: tuple[str, ...] = ()
    sampled_membership_counts: Mapping[str, int] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if self.support_state is ConventionSupportState.NOT_CONFIGURED:
            if self.intersects_route is not None or self.windows or self.threshold_provenance is not None:
                raise ValueError("not-configured convention cannot have evaluated windows or thresholds")
        if self.support_state is ConventionSupportState.SUPPORTED:
            if self.threshold_provenance is None:
                raise ValueError("supported convention requires threshold provenance")
            if self.coverage is CoverageState.UNAVAILABLE and self.intersects_route is not None:
                raise ValueError("unavailable convention coverage cannot claim route intersection")
        if self.intersects_route is False and self.windows:
            raise ValueError("non-intersecting convention cannot contain windows")
        if self.intersects_route is True and not self.windows:
            raise ValueError("intersecting convention requires a window")
        if any(value < 0 for value in self.sampled_membership_counts.values()):
            raise ValueError("sample membership counts must be non-negative")

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


@dataclass(frozen=True)
class TerrainLightEvent:
    """A sampled direct encounter or an adjacent DIRECT/SHADOW transition."""

    event_id: str
    event_type: TerrainLightEventType
    boundary: RouteBoundary
    before_state: TerrainSolarVisibility | None
    after_state: TerrainSolarVisibility
    source_references: tuple[str, ...] = ()
    analysis_window_edge: str | None = None

    def __post_init__(self) -> None:
        if not self.event_id:
            raise ValueError("terrain-light event requires a stable ID")
        if self.event_type in {
            TerrainLightEventType.FIRST_DIRECT_TERRAIN_LIGHT,
            TerrainLightEventType.LAST_DIRECT_TERRAIN_LIGHT,
        }:
            if self.boundary.semantics is not BoundarySemantics.SAMPLED_POINT:
                raise ValueError("first/last direct event must be a sampled point")
            if self.before_state is not None or self.after_state is not TerrainSolarVisibility.DIRECT:
                raise ValueError("first/last direct event must identify a DIRECT sample")
        if self.event_type is TerrainLightEventType.DIRECT_LIGHT_ENTRY:
            if self.boundary.semantics is not BoundarySemantics.TRANSITION_BRACKET:
                raise ValueError("direct-light entry must be a transition bracket")
            if (self.before_state, self.after_state) != (
                TerrainSolarVisibility.SHADOW,
                TerrainSolarVisibility.DIRECT,
            ):
                raise ValueError("direct-light entry must be SHADOW to DIRECT")
        if self.event_type is TerrainLightEventType.DIRECT_LIGHT_EXIT:
            if self.boundary.semantics is not BoundarySemantics.TRANSITION_BRACKET:
                raise ValueError("direct-light exit must be a transition bracket")
            if (self.before_state, self.after_state) != (
                TerrainSolarVisibility.DIRECT,
                TerrainSolarVisibility.SHADOW,
            ):
                raise ValueError("direct-light exit must be DIRECT to SHADOW")

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


@dataclass(frozen=True)
class TerrainLightResult:
    """Compact terrain-light facts referencing, never copying, Unified samples."""

    coverage: CoverageState
    source_reference: str
    state_counts: Mapping[str, int]
    events: tuple[TerrainLightEvent, ...] = ()
    first_direct_event_id: str | None = None
    last_direct_event_id: str | None = None
    reason_codes: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        if not self.source_reference:
            raise ValueError("terrain-light result requires an upstream source reference")
        if any(value < 0 for value in self.state_counts.values()):
            raise ValueError("terrain-light state counts must be non-negative")
        event_ids = {event.event_id for event in self.events}
        if len(event_ids) != len(self.events):
            raise ValueError("terrain-light event IDs must be unique")
        first = [event for event in self.events if event.event_type is TerrainLightEventType.FIRST_DIRECT_TERRAIN_LIGHT]
        last = [event for event in self.events if event.event_type is TerrainLightEventType.LAST_DIRECT_TERRAIN_LIGHT]
        if bool(first) != bool(self.first_direct_event_id) or bool(last) != bool(self.last_direct_event_id):
            raise ValueError("first/last direct event references must agree with events")
        if first and first[0].event_id != self.first_direct_event_id:
            raise ValueError("first direct event reference is invalid")
        if last and last[0].event_id != self.last_direct_event_id:
            raise ValueError("last direct event reference is invalid")
        if first and last:
            first_index = first[0].boundary.samples[0].route_point_index
            last_index = last[0].boundary.samples[0].route_point_index
            if first_index > last_index:
                raise ValueError("first direct encounter cannot follow last direct encounter")

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


@dataclass(frozen=True)
class WeatherSourceContext:
    """Compact resolver/source metadata retained from Unified provenance."""

    resolution_mode: str | None
    provider: str | None
    product: str | None
    source_type: str | None
    model_selection: str | None
    model_identity: str | None
    source_artifact: str | None
    cache_identity: str | None
    valid_start: str | None
    valid_end: str | None
    fetched_at: str | None
    model_run_at: str | None
    intended_use: str | None
    freshness_state: str | None
    temporal_coverage: str | None
    reason_codes: tuple[str, ...] = ()

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


@dataclass(frozen=True)
class AtmosphericVariableSummary:
    """Sampled modelled range for one source variable, not an optical judgment."""

    variable_id: str
    unit: str | None
    coverage: CoverageState
    selected_sample_count: int
    available_sample_count: int
    minimum: float | None
    maximum: float | None
    representation_counts: Mapping[str, int] = field(default_factory=dict)
    temporal_representation_counts: Mapping[str, int] = field(default_factory=dict)
    limitation_codes: tuple[str, ...] = ()
    reason_codes: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        if not self.variable_id:
            raise ValueError("atmospheric variable requires an ID")
        if self.selected_sample_count < 0 or self.available_sample_count < 0:
            raise ValueError("atmospheric sample counts must be non-negative")
        if self.available_sample_count > self.selected_sample_count:
            raise ValueError("available atmospheric samples cannot exceed selected samples")
        if (self.minimum is None) != (self.maximum is None):
            raise ValueError("atmospheric range endpoints must be both present or absent")
        if self.minimum is not None:
            if not math.isfinite(self.minimum) or not math.isfinite(self.maximum):
                raise ValueError("atmospheric range values must be finite")
            if self.minimum > self.maximum:
                raise ValueError("atmospheric range must be ordered")
        if any(value < 0 for value in self.representation_counts.values()):
            raise ValueError("atmospheric representation counts must be non-negative")
        if any(value < 0 for value in self.temporal_representation_counts.values()):
            raise ValueError("atmospheric temporal representation counts must be non-negative")

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


@dataclass(frozen=True)
class AtmosphericContextScope:
    """Compact weather summaries for a route selection already defined upstream."""

    context_id: str
    scope_type: AtmosphericContextScopeType
    scope_reference: str
    selected_sample_count: int
    coverage: CoverageState
    variables: tuple[AtmosphericVariableSummary, ...]
    route_start_point_index: int | None = None
    route_end_point_index: int | None = None
    reason_codes: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        if not self.context_id or not self.scope_reference:
            raise ValueError("atmospheric context scope requires stable identities")
        if self.selected_sample_count < 0:
            raise ValueError("atmospheric scope sample count must be non-negative")
        if self.selected_sample_count == 0:
            if self.route_start_point_index is not None or self.route_end_point_index is not None:
                raise ValueError("empty atmospheric scope cannot claim route endpoints")
        elif self.route_start_point_index is None or self.route_end_point_index is None:
            raise ValueError("non-empty atmospheric scope requires route endpoints")
        elif self.route_end_point_index < self.route_start_point_index:
            raise ValueError("atmospheric scope route endpoints must be ordered")

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


@dataclass(frozen=True)
class AtmosphericContextResult:
    """Provider-labelled weather context, distinct from Atmospheric Optics."""

    context_version: str
    coverage: CoverageState
    source_reference: str
    source: WeatherSourceContext
    route_variables: tuple[AtmosphericVariableSummary, ...]
    scopes: tuple[AtmosphericContextScope, ...] = ()
    reason_codes: tuple[str, ...] = ()
    limitations: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        if self.context_version != "v0_1":
            raise ValueError("unsupported atmospheric context version")
        if not self.source_reference:
            raise ValueError("atmospheric context requires an upstream source reference")
        if len({item.variable_id for item in self.route_variables}) != len(self.route_variables):
            raise ValueError("route atmospheric variable IDs must be unique")
        if len({item.context_id for item in self.scopes}) != len(self.scopes):
            raise ValueError("atmospheric context scope IDs must be unique")

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


@dataclass(frozen=True)
class RouteTimelineReference:
    """References the upstream route/timeline rather than copying its point matrix."""

    route_id: str
    scenario_name: str
    upstream_analysis_reference: str
    timeline_reference: str
    pace_reference: str | None

    def __post_init__(self) -> None:
        if not all((self.route_id, self.scenario_name, self.upstream_analysis_reference, self.timeline_reference)):
            raise ValueError("route timeline reference requires stable identities")

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


@dataclass(frozen=True)
class PhotographerIntelligenceResult:
    """One compact, policy-labelled result for one existing route scenario."""

    identity: AnalysisIdentity
    analysis_metadata: Mapping[str, Any]
    route_timeline_reference: RouteTimelineReference
    policy: PhotographerPolicy
    golden_hour: ConventionWindowResult
    blue_hour: ConventionWindowResult
    terrain_light: TerrainLightResult
    atmospheric_context: AtmosphericContextResult
    quality: EvidenceQuality
    provenance: Mapping[str, Any]
    diagnostics: tuple[str, ...] = ()
    contract_version: str = "0.1"

    def __post_init__(self) -> None:
        if self.identity.analysis_type != "photographer_intelligence":
            raise ValueError("photographer result analysis type is invalid")
        if self.identity.semantic_type != "route_photographic_light_context":
            raise ValueError("photographer result semantic type is invalid")
        if self.contract_version != "0.1":
            raise ValueError("unsupported photographer contract version")
        if self.golden_hour.convention is not PhotographicConvention.GOLDEN_HOUR:
            raise ValueError("golden-hour result must use GOLDEN_HOUR")
        if self.blue_hour.convention is not PhotographicConvention.BLUE_HOUR:
            raise ValueError("blue-hour result must use BLUE_HOUR")
        if self.blue_hour.support_state is not ConventionSupportState.SUPPORTED:
            raise ValueError("Blue Hour must use the accepted v0.1 policy")
        if self.blue_hour.threshold_provenance != self.policy.blue_hour.threshold_provenance:
            raise ValueError("Blue Hour result must preserve policy threshold provenance")
        if self.route_timeline_reference.route_id != self.identity.subject_id:
            raise ValueError("route timeline reference must match result subject")

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


def derive_photographic_window_id(
    *,
    upstream_analysis_reference: str,
    policy_id: str,
    policy_version: str,
    convention: PhotographicConvention,
    first_route_point_index: int,
    last_route_point_index: int,
) -> str:
    """Derive a stable window ID from semantic inputs only."""
    identity = {
        "convention": convention.value,
        "first_route_point_index": first_route_point_index,
        "last_route_point_index": last_route_point_index,
        "policy_id": policy_id,
        "policy_version": policy_version,
        "upstream_analysis_reference": upstream_analysis_reference,
    }
    encoded = json.dumps(identity, sort_keys=True, separators=(",", ":"), allow_nan=False)
    return "photographer-window-" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:24]


def derive_terrain_light_event_id(
    *,
    upstream_analysis_reference: str,
    policy_id: str,
    policy_version: str,
    event_type: TerrainLightEventType,
    route_point_indices: tuple[int, ...],
) -> str:
    """Derive a stable event ID from the sampled fact or bounded transition."""
    identity = {
        "event_type": event_type.value,
        "policy_id": policy_id,
        "policy_version": policy_version,
        "route_point_indices": route_point_indices,
        "upstream_analysis_reference": upstream_analysis_reference,
    }
    encoded = json.dumps(identity, sort_keys=True, separators=(",", ":"), allow_nan=False)
    return "photographer-terrain-event-" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:24]


def derive_atmospheric_context_id(
    *,
    upstream_analysis_reference: str,
    scope_type: AtmosphericContextScopeType,
    scope_reference: str,
) -> str:
    """Derive a stable compact-context identity without weather values themselves."""
    identity = {
        "scope_reference": scope_reference,
        "scope_type": scope_type.value,
        "upstream_analysis_reference": upstream_analysis_reference,
    }
    encoded = json.dumps(identity, sort_keys=True, separators=(",", ":"), allow_nan=False)
    return "photographer-atmosphere-" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()[:24]


def serialize_photographer_intelligence_result(result: PhotographerIntelligenceResult) -> str:
    """Canonical serialization without runtime clocks or duplicated upstream points."""
    return json.dumps(result.to_dict(), sort_keys=True, separators=(",", ":"), allow_nan=False)


def _json_value(value: Any) -> Any:
    if isinstance(value, Enum):
        return value.value
    if isinstance(value, Mapping):
        return {str(key): _json_value(value[key]) for key in sorted(value, key=str)}
    if isinstance(value, tuple):
        return [_json_value(item) for item in value]
    if isinstance(value, list):
        return [_json_value(item) for item in value]
    if hasattr(value, "__dataclass_fields__"):
        return _json_value(asdict(value))
    return value
