"""Provider-neutral, immutable contracts for private historical activities."""

from __future__ import annotations

import json
import re
from dataclasses import asdict, dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any

ACTIVITY_TAXONOMY_VERSION = "activity_taxonomy_v2"


def _aware(value: str, label: str) -> None:
    instant = datetime.fromisoformat(value)
    if instant.tzinfo is None or instant.utcoffset() is None:
        raise ValueError(f"{label} must be timezone-aware")


class ActivityFamily(str, Enum):
    """Stable, provider-neutral answer to what activity was performed."""

    WALKING = "WALKING"
    HIKING = "HIKING"
    RUNNING = "RUNNING"
    CYCLING = "CYCLING"
    STRENGTH = "STRENGTH"
    MIND_BODY = "MIND_BODY"
    CLIMBING = "CLIMBING"
    MOUNTAINEERING = "MOUNTAINEERING"
    PADDLING = "PADDLING"
    SWIMMING = "SWIMMING"
    WINTER_SPORT = "WINTER_SPORT"
    WATER_SPORT = "WATER_SPORT"
    EQUESTRIAN = "EQUESTRIAN"
    SKATING = "SKATING"
    FITNESS = "FITNESS"
    DANCE = "DANCE"
    ADAPTIVE_SPORT = "ADAPTIVE_SPORT"
    REHABILITATION = "REHABILITATION"
    BALANCE_SKILL = "BALANCE_SKILL"
    SPORT = "SPORT"
    OTHER = "OTHER"
    UNKNOWN = "UNKNOWN"


class ActivityType(str, Enum):
    """Provider-neutral, concrete human activity suitable for future localization."""

    UNKNOWN = "UNKNOWN"
    OTHER = "OTHER"
    WALKING = "WALKING"
    NORDIC_WALKING = "NORDIC_WALKING"
    HIKING = "HIKING"
    TREKKING = "TREKKING"
    RUNNING = "RUNNING"
    TRAIL_RUNNING = "TRAIL_RUNNING"
    CYCLING = "CYCLING"
    ROAD_CYCLING = "ROAD_CYCLING"
    GRAVEL_CYCLING = "GRAVEL_CYCLING"
    MOUNTAIN_BIKING = "MOUNTAIN_BIKING"
    E_BIKING = "E_BIKING"
    INDOOR_CYCLING = "INDOOR_CYCLING"
    MOUNTAINEERING = "MOUNTAINEERING"
    ROCK_CLIMBING = "ROCK_CLIMBING"
    BOULDERING = "BOULDERING"
    ICE_CLIMBING = "ICE_CLIMBING"
    VIA_FERRATA = "VIA_FERRATA"
    ALPINE_SKIING = "ALPINE_SKIING"
    CROSS_COUNTRY_SKIING = "CROSS_COUNTRY_SKIING"
    SKI_TOURING = "SKI_TOURING"
    SNOWBOARDING = "SNOWBOARDING"
    SNOWSHOEING = "SNOWSHOEING"
    ICE_SKATING = "ICE_SKATING"
    SLEDDING = "SLEDDING"
    ROLLER_SKIING = "ROLLER_SKIING"
    KAYAKING = "KAYAKING"
    CANOEING = "CANOEING"
    ROWING = "ROWING"
    STAND_UP_PADDLING = "STAND_UP_PADDLING"
    SAILING = "SAILING"
    SURFING = "SURFING"
    KITESURFING = "KITESURFING"
    WINDSURFING = "WINDSURFING"
    SWIMMING = "SWIMMING"
    OPEN_WATER_SWIMMING = "OPEN_WATER_SWIMMING"
    STRENGTH_TRAINING = "STRENGTH_TRAINING"
    CROSSFIT = "CROSSFIT"
    HIIT = "HIIT"
    GENERAL_WORKOUT = "GENERAL_WORKOUT"
    ELLIPTICAL = "ELLIPTICAL"
    STAIR_STEPPER = "STAIR_STEPPER"
    YOGA = "YOGA"
    PILATES = "PILATES"
    HORSEBACK_RIDING = "HORSEBACK_RIDING"
    SLACKLINING = "SLACKLINING"
    INLINE_SKATING = "INLINE_SKATING"
    ROLLER_SKATING = "ROLLER_SKATING"
    SKATEBOARDING = "SKATEBOARDING"
    TEAM_SPORT = "TEAM_SPORT"
    RACKET_SPORT = "RACKET_SPORT"
    SOCCER = "SOCCER"
    BASKETBALL = "BASKETBALL"
    VOLLEYBALL = "VOLLEYBALL"
    CRICKET = "CRICKET"
    TENNIS = "TENNIS"
    BADMINTON = "BADMINTON"
    PADEL = "PADEL"
    PICKLEBALL = "PICKLEBALL"
    RACQUETBALL = "RACQUETBALL"
    SQUASH = "SQUASH"
    TABLE_TENNIS = "TABLE_TENNIS"
    GOLF = "GOLF"
    DANCE = "DANCE"
    ADAPTIVE_SPORT = "ADAPTIVE_SPORT"
    REHABILITATION = "REHABILITATION"
    GYMNASTICS = "GYMNASTICS"
    MARTIAL_ARTS = "MARTIAL_ARTS"
    MOTORSPORT = "MOTORSPORT"


_ACTIVITY_TYPE_FAMILIES = {
    ActivityType.UNKNOWN: ActivityFamily.UNKNOWN, ActivityType.OTHER: ActivityFamily.OTHER,
    ActivityType.WALKING: ActivityFamily.WALKING, ActivityType.NORDIC_WALKING: ActivityFamily.WALKING,
    ActivityType.HIKING: ActivityFamily.HIKING, ActivityType.TREKKING: ActivityFamily.HIKING,
    ActivityType.RUNNING: ActivityFamily.RUNNING, ActivityType.TRAIL_RUNNING: ActivityFamily.RUNNING,
    ActivityType.CYCLING: ActivityFamily.CYCLING, ActivityType.ROAD_CYCLING: ActivityFamily.CYCLING,
    ActivityType.GRAVEL_CYCLING: ActivityFamily.CYCLING, ActivityType.MOUNTAIN_BIKING: ActivityFamily.CYCLING,
    ActivityType.E_BIKING: ActivityFamily.CYCLING, ActivityType.INDOOR_CYCLING: ActivityFamily.CYCLING,
    ActivityType.MOUNTAINEERING: ActivityFamily.MOUNTAINEERING,
    ActivityType.ROCK_CLIMBING: ActivityFamily.CLIMBING, ActivityType.BOULDERING: ActivityFamily.CLIMBING,
    ActivityType.ICE_CLIMBING: ActivityFamily.CLIMBING, ActivityType.VIA_FERRATA: ActivityFamily.CLIMBING,
    ActivityType.ALPINE_SKIING: ActivityFamily.WINTER_SPORT, ActivityType.CROSS_COUNTRY_SKIING: ActivityFamily.WINTER_SPORT,
    ActivityType.SKI_TOURING: ActivityFamily.WINTER_SPORT, ActivityType.SNOWBOARDING: ActivityFamily.WINTER_SPORT,
    ActivityType.SNOWSHOEING: ActivityFamily.WINTER_SPORT, ActivityType.SLEDDING: ActivityFamily.WINTER_SPORT,
    ActivityType.ROLLER_SKIING: ActivityFamily.WINTER_SPORT, ActivityType.ICE_SKATING: ActivityFamily.SKATING,
    ActivityType.KAYAKING: ActivityFamily.PADDLING, ActivityType.CANOEING: ActivityFamily.PADDLING,
    ActivityType.ROWING: ActivityFamily.PADDLING, ActivityType.STAND_UP_PADDLING: ActivityFamily.PADDLING,
    ActivityType.SAILING: ActivityFamily.WATER_SPORT, ActivityType.SURFING: ActivityFamily.WATER_SPORT,
    ActivityType.KITESURFING: ActivityFamily.WATER_SPORT, ActivityType.WINDSURFING: ActivityFamily.WATER_SPORT,
    ActivityType.SWIMMING: ActivityFamily.SWIMMING, ActivityType.OPEN_WATER_SWIMMING: ActivityFamily.SWIMMING,
    ActivityType.STRENGTH_TRAINING: ActivityFamily.STRENGTH, ActivityType.CROSSFIT: ActivityFamily.STRENGTH,
    ActivityType.HIIT: ActivityFamily.FITNESS, ActivityType.GENERAL_WORKOUT: ActivityFamily.FITNESS,
    ActivityType.ELLIPTICAL: ActivityFamily.FITNESS, ActivityType.STAIR_STEPPER: ActivityFamily.FITNESS,
    ActivityType.YOGA: ActivityFamily.MIND_BODY, ActivityType.PILATES: ActivityFamily.MIND_BODY,
    ActivityType.HORSEBACK_RIDING: ActivityFamily.EQUESTRIAN, ActivityType.SLACKLINING: ActivityFamily.BALANCE_SKILL,
    ActivityType.INLINE_SKATING: ActivityFamily.SKATING, ActivityType.ROLLER_SKATING: ActivityFamily.SKATING,
    ActivityType.SKATEBOARDING: ActivityFamily.SKATING, ActivityType.TEAM_SPORT: ActivityFamily.SPORT,
    ActivityType.RACKET_SPORT: ActivityFamily.SPORT, ActivityType.SOCCER: ActivityFamily.SPORT,
    ActivityType.BASKETBALL: ActivityFamily.SPORT, ActivityType.VOLLEYBALL: ActivityFamily.SPORT,
    ActivityType.CRICKET: ActivityFamily.SPORT, ActivityType.TENNIS: ActivityFamily.SPORT,
    ActivityType.BADMINTON: ActivityFamily.SPORT, ActivityType.PADEL: ActivityFamily.SPORT,
    ActivityType.PICKLEBALL: ActivityFamily.SPORT, ActivityType.RACQUETBALL: ActivityFamily.SPORT,
    ActivityType.SQUASH: ActivityFamily.SPORT, ActivityType.TABLE_TENNIS: ActivityFamily.SPORT,
    ActivityType.GOLF: ActivityFamily.SPORT,
    ActivityType.DANCE: ActivityFamily.DANCE, ActivityType.ADAPTIVE_SPORT: ActivityFamily.ADAPTIVE_SPORT,
    ActivityType.REHABILITATION: ActivityFamily.REHABILITATION, ActivityType.GYMNASTICS: ActivityFamily.FITNESS,
    ActivityType.MARTIAL_ARTS: ActivityFamily.SPORT, ActivityType.MOTORSPORT: ActivityFamily.SPORT,
}
if set(_ACTIVITY_TYPE_FAMILIES) != set(ActivityType):
    raise RuntimeError("activity type catalog must derive every family")


def activity_family_for_type(activity_type: ActivityType) -> ActivityFamily:
    return _ACTIVITY_TYPE_FAMILIES[activity_type]


def canonical_activity_type(provider_native_type: str | None) -> ActivityType:
    """Generic provider-neutral fallback; adapters own detailed source mappings."""
    normalized = (provider_native_type or "").strip().upper().replace("-", "_").replace(" ", "_")
    return {
        "HIKE": ActivityType.HIKING, "HIKING": ActivityType.HIKING,
        "TRAIL_RUN": ActivityType.TRAIL_RUNNING, "TRAILRUN": ActivityType.TRAIL_RUNNING,
        "RUN": ActivityType.RUNNING, "RUNNING": ActivityType.RUNNING,
        "WALK": ActivityType.WALKING, "WALKING": ActivityType.WALKING,
        "RIDE": ActivityType.CYCLING, "CYCLING": ActivityType.CYCLING,
        "STRENGTH": ActivityType.STRENGTH_TRAINING, "YOGA": ActivityType.YOGA,
        "PILATES": ActivityType.PILATES, "CLIMBING": ActivityType.ROCK_CLIMBING,
        "MOUNTAINEERING": ActivityType.MOUNTAINEERING, "KAYAK": ActivityType.KAYAKING,
        "CANOE": ActivityType.CANOEING, "ROWING": ActivityType.ROWING,
        "SWIM": ActivityType.SWIMMING, "SWIMMING": ActivityType.SWIMMING,
        "SKI": ActivityType.ALPINE_SKIING, "SKIING": ActivityType.ALPINE_SKIING,
        "SKI_TOUR": ActivityType.SKI_TOURING, "SKI_TOURING": ActivityType.SKI_TOURING,
        "SNOWBOARD": ActivityType.SNOWBOARDING, "SNOWSHOE": ActivityType.SNOWSHOEING,
        "SAIL": ActivityType.SAILING, "SURF": ActivityType.SURFING,
        "HORSEBACK_RIDING": ActivityType.HORSEBACK_RIDING, "SLACKLINE": ActivityType.SLACKLINING,
    }.get(normalized, ActivityType.UNKNOWN if not normalized else ActivityType.OTHER)


class ActivitySourceKind(str, Enum):
    PROVIDER = "PROVIDER"
    FILE_IMPORT = "FILE_IMPORT"
    MANUAL = "MANUAL"


class AvailabilityState(str, Enum):
    AVAILABLE = "AVAILABLE"
    PARTIAL = "PARTIAL"
    UNAVAILABLE = "UNAVAILABLE"
    UNKNOWN = "UNKNOWN"


class DuplicateRelationKind(str, Enum):
    POSSIBLE_DUPLICATE = "POSSIBLE_DUPLICATE"
    CONFIRMED_SAME_EVENT = "CONFIRMED_SAME_EVENT"
    CONFIRMED_DISTINCT = "CONFIRMED_DISTINCT"


@dataclass(frozen=True)
class User:
    """Application identity only; it deliberately has no provider fields."""

    user_id: str
    created_at: str

    def __post_init__(self) -> None:
        if not self.user_id:
            raise ValueError("user requires stable identity")
        _aware(self.created_at, "user creation")


@dataclass(frozen=True)
class UserProfile:
    """Optional profile and preferences, kept outside account identity."""

    user_id: str
    display_name: str | None = None
    preferred_timezone: str | None = None
    locale: str | None = None


@dataclass(frozen=True)
class ExternalProviderIdentity:
    """A provider account reference; tokens never belong in this contract."""

    provider_identity_id: str
    user_id: str
    provider: str
    external_subject_id: str
    connected_at: str

    def __post_init__(self) -> None:
        if not all((self.provider_identity_id, self.user_id, self.provider, self.external_subject_id)):
            raise ValueError("provider identity requires stable source identity")
        _aware(self.connected_at, "provider identity connection")


@dataclass(frozen=True)
class ActivityPrivacy:
    """Private-by-default policy; detailed privacy transformations are future work."""

    is_private: bool = True
    redact_start_end_locations: bool = True
    allow_precise_location_in_product: bool = False


@dataclass(frozen=True)
class ExternalActivityReference:
    source_kind: ActivitySourceKind
    provider: str | None
    source_activity_id: str | None
    raw_record_id: str | None

    def __post_init__(self) -> None:
        if not self.raw_record_id:
            raise ValueError("activity source reference requires raw record identity")
        if self.source_kind is ActivitySourceKind.PROVIDER and not all((self.provider, self.source_activity_id)):
            raise ValueError("provider activity reference requires provider and source activity ID")


@dataclass(frozen=True)
class RawProviderRecord:
    """Immutable source record metadata; raw payload stays in protected storage."""

    raw_record_id: str
    user_id: str
    source_kind: ActivitySourceKind
    provider: str | None
    source_activity_id: str | None
    fetched_at: str
    payload_reference: str
    payload_sha256: str
    source_schema_version: str | None = None
    record_kind: str = "UNSPECIFIED"

    def __post_init__(self) -> None:
        if not all((
            self.raw_record_id, self.user_id, self.payload_reference,
            self.payload_sha256, self.record_kind,
        )):
            raise ValueError("raw record requires stable identity and protected payload reference")
        if self.source_kind is ActivitySourceKind.PROVIDER and not all((self.provider, self.source_activity_id)):
            raise ValueError("provider raw record requires provider identity")
        if not re.fullmatch(r"[0-9a-f]{64}", self.payload_sha256):
            raise ValueError("raw record payload_sha256 must be a lowercase SHA-256 hex digest")
        _aware(self.fetched_at, "raw record fetch")


@dataclass(frozen=True)
class FieldAvailability:
    field_name: str
    state: AvailabilityState
    reason_codes: tuple[str, ...] = ()


@dataclass(frozen=True)
class GeoPoint:
    latitude: float
    longitude: float
    elevation_m: float | None = None

    def __post_init__(self) -> None:
        if not -90 <= self.latitude <= 90 or not -180 <= self.longitude <= 180:
            raise ValueError("location is outside geographic bounds")


@dataclass(frozen=True)
class TrackReference:
    track_reference_id: str
    point_count: int | None = None
    geometry_fingerprint: str | None = None

    def __post_init__(self) -> None:
        if not self.track_reference_id:
            raise ValueError("track reference requires identity")
        if self.point_count is not None and self.point_count < 0:
            raise ValueError("track point count cannot be negative")


@dataclass(frozen=True)
class ActivityProvenance:
    source_references: tuple[ExternalActivityReference, ...]
    normalized_from_raw_record_ids: tuple[str, ...]
    normalization_version: str
    normalized_at: str
    provider_native_activity_type: str | None = None

    def __post_init__(self) -> None:
        if not self.source_references or not self.normalized_from_raw_record_ids or not self.normalization_version:
            raise ValueError("activity provenance requires source records and a normalizer version")
        source_raw_ids = tuple(reference.raw_record_id for reference in self.source_references)
        if len(set(source_raw_ids)) != len(source_raw_ids):
            raise ValueError("activity provenance source references must be unique by raw record")
        if len(set(self.normalized_from_raw_record_ids)) != len(self.normalized_from_raw_record_ids):
            raise ValueError("activity provenance raw record identities must be unique")
        if set(source_raw_ids) != set(self.normalized_from_raw_record_ids):
            raise ValueError("activity provenance source references must match normalized raw records")
        _aware(self.normalized_at, "activity normalization")


@dataclass(frozen=True)
class Activity:
    """Canonical observed event. Optional fields remain absent when unsupported."""

    activity_id: str
    user_id: str
    activity_type: ActivityType
    provenance: ActivityProvenance
    started_at: str | None = None
    timezone: str | None = None
    title: str | None = None
    elapsed_duration_s: float | None = None
    moving_duration_s: float | None = None
    distance_m: float | None = None
    elevation_gain_m: float | None = None
    elevation_loss_m: float | None = None
    min_elevation_m: float | None = None
    max_elevation_m: float | None = None
    average_speed_mps: float | None = None
    max_speed_mps: float | None = None
    average_pace_s_per_km: float | None = None
    average_heart_rate_bpm: float | None = None
    max_heart_rate_bpm: float | None = None
    track: TrackReference | None = None
    start_location: GeoPoint | None = None
    end_location: GeoPoint | None = None
    privacy: ActivityPrivacy = field(default_factory=ActivityPrivacy)
    field_availability: tuple[FieldAvailability, ...] = ()

    def __post_init__(self) -> None:
        if not all((self.activity_id, self.user_id)):
            raise ValueError("activity requires stable identity")
        if self.started_at is not None:
            _aware(self.started_at, "activity start")
        for label, value in (
            ("elapsed duration", self.elapsed_duration_s), ("moving duration", self.moving_duration_s),
            ("distance", self.distance_m), ("elevation gain", self.elevation_gain_m),
            ("elevation loss", self.elevation_loss_m), ("average speed", self.average_speed_mps),
            ("maximum speed", self.max_speed_mps), ("average pace", self.average_pace_s_per_km),
            ("average heart rate", self.average_heart_rate_bpm), ("maximum heart rate", self.max_heart_rate_bpm),
        ):
            if value is not None and value < 0:
                raise ValueError(f"{label} cannot be negative")
        if self.moving_duration_s is not None and self.elapsed_duration_s is not None and self.moving_duration_s > self.elapsed_duration_s:
            raise ValueError("moving duration cannot exceed elapsed duration")
        if self.min_elevation_m is not None and self.max_elevation_m is not None and self.min_elevation_m > self.max_elevation_m:
            raise ValueError("minimum elevation cannot exceed maximum elevation")
        availability_fields = {item.field_name for item in self.field_availability}
        if len(availability_fields) != len(self.field_availability):
            raise ValueError("field availability entries must be unique")
        known_observations = {
            "started_at", "elapsed_duration_s", "moving_duration_s", "distance_m",
            "elevation_gain_m", "elevation_loss_m", "min_elevation_m", "max_elevation_m",
            "average_speed_mps", "max_speed_mps", "average_pace_s_per_km",
            "average_heart_rate_bpm", "max_heart_rate_bpm", "track", "start_location",
            "end_location",
        }
        for availability in self.field_availability:
            if availability.field_name not in known_observations:
                raise ValueError("field availability must name a canonical activity observation")
            value = getattr(self, availability.field_name)
            if availability.state is AvailabilityState.AVAILABLE and value is None:
                raise ValueError("available field must have a canonical value")
            if availability.state is AvailabilityState.UNAVAILABLE and value is not None:
                raise ValueError("unavailable field cannot have a canonical value")

    @property
    def activity_family(self) -> ActivityFamily:
        """Derived grouping; never independently stored beside the concrete type."""
        return activity_family_for_type(self.activity_type)


@dataclass(frozen=True)
class TelemetrySample:
    """An unresampled source observation; streams may have different cadence."""

    source_stream: str
    source_sample_index: int
    observed_at: str | None = None
    elapsed_offset_s: float | None = None
    distance_m: float | None = None
    latitude: float | None = None
    longitude: float | None = None
    elevation_m: float | None = None
    heart_rate_bpm: float | None = None
    speed_mps: float | None = None
    pace_s_per_km: float | None = None
    cadence_rpm: float | None = None
    temperature_c: float | None = None

    def __post_init__(self) -> None:
        if not self.source_stream or self.source_sample_index < 0:
            raise ValueError("telemetry sample requires source stream and non-negative index")
        if self.observed_at is None and self.elapsed_offset_s is None:
            raise ValueError("telemetry sample requires source timestamp or elapsed offset")
        if self.observed_at is not None:
            _aware(self.observed_at, "telemetry observation")
        if (self.latitude is None) != (self.longitude is None):
            raise ValueError("telemetry position requires latitude and longitude together")
        if self.latitude is not None:
            GeoPoint(self.latitude, self.longitude)  # validates bounds
        for label, value in (("elapsed offset", self.elapsed_offset_s), ("distance", self.distance_m), ("heart rate", self.heart_rate_bpm), ("speed", self.speed_mps), ("pace", self.pace_s_per_km), ("cadence", self.cadence_rpm)):
            if value is not None and value < 0:
                raise ValueError(f"telemetry {label} cannot be negative")


@dataclass(frozen=True)
class TelemetryProvenance:
    """Immutable source and normalizer identity for one telemetry dataset."""

    source_reference: ExternalActivityReference
    raw_record_id: str
    normalization_version: str
    normalized_at: str

    def __post_init__(self) -> None:
        if not all((self.raw_record_id, self.normalization_version)):
            raise ValueError("telemetry provenance requires source record and normalizer version")
        if self.source_reference.raw_record_id != self.raw_record_id:
            raise ValueError("telemetry provenance source reference must match raw record")
        _aware(self.normalized_at, "telemetry normalization")


@dataclass(frozen=True)
class ActivityTelemetry:
    """One immutable, unmerged telemetry dataset for a canonical activity."""

    telemetry_id: str
    activity_id: str
    provenance: TelemetryProvenance
    samples: tuple[TelemetrySample, ...]

    def __post_init__(self) -> None:
        if not all((self.telemetry_id, self.activity_id)):
            raise ValueError("telemetry requires identity and canonical activity")
        keys = {(sample.source_stream, sample.source_sample_index) for sample in self.samples}
        if len(keys) != len(self.samples):
            raise ValueError("telemetry source samples must be unique within a stream")


@dataclass(frozen=True)
class ActivityDuplicateRelationship:
    relationship_id: str
    activity_id: str
    related_activity_id: str
    relation: DuplicateRelationKind
    recorded_at: str
    basis: str

    def __post_init__(self) -> None:
        if not all((self.relationship_id, self.activity_id, self.related_activity_id, self.basis)):
            raise ValueError("duplicate relationship requires stable identity and basis")
        if self.activity_id == self.related_activity_id:
            raise ValueError("duplicate relationship requires distinct activities")
        _aware(self.recorded_at, "duplicate relationship recording")


@dataclass(frozen=True)
class ProductActivityProjection:
    """Privacy-safe Activity facts for product/browser consumers."""

    activity_id: str
    activity_type: ActivityType
    started_at: str | None
    timezone: str | None
    title: str | None
    elapsed_duration_s: float | None
    moving_duration_s: float | None
    distance_m: float | None
    elevation_gain_m: float | None
    elevation_loss_m: float | None
    min_elevation_m: float | None
    max_elevation_m: float | None
    average_speed_mps: float | None
    max_speed_mps: float | None
    average_pace_s_per_km: float | None
    average_heart_rate_bpm: float | None
    max_heart_rate_bpm: float | None
    field_availability: tuple[FieldAvailability, ...]
    start_location: GeoPoint | None
    end_location: GeoPoint | None
    track: TrackReference | None
    location_redacted: bool
    track_redacted: bool

    @property
    def activity_family(self) -> ActivityFamily:
        return activity_family_for_type(self.activity_type)


@dataclass(frozen=True)
class ProductTelemetrySample:
    """Product-safe sample; precise position can be withheld without resampling."""

    source_stream: str
    source_sample_index: int
    observed_at: str | None = None
    elapsed_offset_s: float | None = None
    distance_m: float | None = None
    latitude: float | None = None
    longitude: float | None = None
    elevation_m: float | None = None
    heart_rate_bpm: float | None = None
    speed_mps: float | None = None
    pace_s_per_km: float | None = None
    cadence_rpm: float | None = None
    temperature_c: float | None = None


@dataclass(frozen=True)
class ProductActivityTelemetryProjection:
    """Privacy-safe telemetry dataset without raw-record or provider-payload references."""

    telemetry_id: str
    activity_id: str
    normalization_version: str
    samples: tuple[ProductTelemetrySample, ...]
    location_redacted: bool


def project_activity_for_product(activity: Activity) -> ProductActivityProjection:
    """Apply ActivityPrivacy without altering canonical internal observations."""
    expose_points = (
        activity.privacy.allow_precise_location_in_product
        and not activity.privacy.redact_start_end_locations
    )
    expose_track = activity.privacy.allow_precise_location_in_product
    return ProductActivityProjection(
        activity_id=activity.activity_id,
        activity_type=activity.activity_type,
        started_at=activity.started_at,
        timezone=activity.timezone,
        title=activity.title,
        elapsed_duration_s=activity.elapsed_duration_s,
        moving_duration_s=activity.moving_duration_s,
        distance_m=activity.distance_m,
        elevation_gain_m=activity.elevation_gain_m,
        elevation_loss_m=activity.elevation_loss_m,
        min_elevation_m=activity.min_elevation_m,
        max_elevation_m=activity.max_elevation_m,
        average_speed_mps=activity.average_speed_mps,
        max_speed_mps=activity.max_speed_mps,
        average_pace_s_per_km=activity.average_pace_s_per_km,
        average_heart_rate_bpm=activity.average_heart_rate_bpm,
        max_heart_rate_bpm=activity.max_heart_rate_bpm,
        field_availability=activity.field_availability,
        start_location=activity.start_location if expose_points else None,
        end_location=activity.end_location if expose_points else None,
        track=activity.track if expose_track else None,
        location_redacted=(activity.start_location is not None or activity.end_location is not None) and not expose_points,
        track_redacted=activity.track is not None and not expose_track,
    )


def project_telemetry_for_product(
    telemetry: ActivityTelemetry, privacy: ActivityPrivacy
) -> ProductActivityTelemetryProjection:
    """Preserve sample timing and metrics while withholding precise positions when required."""
    redact_locations = not privacy.allow_precise_location_in_product
    return ProductActivityTelemetryProjection(
        telemetry_id=telemetry.telemetry_id,
        activity_id=telemetry.activity_id,
        normalization_version=telemetry.provenance.normalization_version,
        samples=tuple(
            ProductTelemetrySample(
                source_stream=sample.source_stream,
                source_sample_index=sample.source_sample_index,
                observed_at=sample.observed_at,
                elapsed_offset_s=sample.elapsed_offset_s,
                distance_m=sample.distance_m,
                latitude=None if redact_locations else sample.latitude,
                longitude=None if redact_locations else sample.longitude,
                elevation_m=sample.elevation_m,
                heart_rate_bpm=sample.heart_rate_bpm,
                speed_mps=sample.speed_mps,
                pace_s_per_km=sample.pace_s_per_km,
                cadence_rpm=sample.cadence_rpm,
                temperature_c=sample.temperature_c,
            )
            for sample in telemetry.samples
        ),
        location_redacted=redact_locations and any(sample.latitude is not None for sample in telemetry.samples),
    )


def serialize_contract(value: Any) -> str:
    """Canonical serialization for reproducible local activity contracts."""
    return json.dumps(asdict(value), sort_keys=True, separators=(",", ":"), allow_nan=False)
