"""Typed calibration config schema."""

from __future__ import annotations

from pathlib import Path
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from calibrex.core.exceptions import ConfigError
from calibrex.core.io import read_mapping
from calibrex.core.koide_runner import koide_runner_config_from_options
from calibrex.core.solid_state import SolidStateEvaluationConfig, SolidStateLidarProfile

CONFIG_SCHEMA_VERSION: Literal["slac.config/v0.1"] = "slac.config/v0.1"

SensorType = Literal["camera", "lidar", "imu", "radar"]
DatasetType = Literal[
    "a2d2_lidar",
    "filesystem",
    "kitti_raw",
    "livox_pcd",
    "mcap",
    "nuscenes",
    "rosbag1",
    "rosbag2",
    "tum_rgbd",
]
DomainType = Literal["robotics", "autonomous_driving", "industrial", "research", "other"]
OdometryBurstPolicy = Literal["preserve", "keep_first", "keep_last"]


class StrictModel(BaseModel):
    """Base model with stable, explicit fields."""

    model_config = ConfigDict(extra="forbid")


class ProjectConfig(StrictModel):
    name: str = "slac_project"
    description: str | None = None
    output_dir: str = "outputs/default"
    domain: DomainType = "robotics"


class OdometryPreprocessingConfig(StrictModel):
    """Explicit preprocessing for bursty pose streams used as odometry."""

    burst_policy: OdometryBurstPolicy = Field(
        default="preserve",
        description=(
            "how to handle consecutive odometry poses closer than min_interval_s; "
            "preserve keeps the source stream unchanged"
        ),
    )
    min_interval_s: float = Field(
        default=0.001,
        gt=0.0,
        le=1.0,
        description="minimum retained pose spacing for keep_first/keep_last policies",
    )


class DatasetConfig(StrictModel):
    type: DatasetType
    path: str
    time_base: str = "sensor_time_ns"
    odometry_topic: str | None = Field(
        default=None,
        description=(
            "optional nav_msgs/msg/Odometry topic for online motion compensation "
            "from rosbag1/rosbag2 replays; unset preserves the static-rig path"
        ),
    )
    odometry_preprocessing: OdometryPreprocessingConfig | None = Field(
        default=None,
        description=(
            "optional explicit pose-stream preprocessing for rosbag1/rosbag2 "
            "odometry; the raw dataset is never modified"
        ),
    )
    sample_limit: int | None = Field(
        default=None,
        ge=1,
        description=(
            "override the number of frames/files sampled for public-dataset diagnostics "
            "(a2d2_lidar, livox_pcd, kitti_raw); unset preserves each dataset type's "
            "current default"
        ),
    )


class CameraIntrinsicsConfig(StrictModel):
    estimate: bool = False
    fx: float | None = None
    fy: float | None = None
    cx: float | None = None
    cy: float | None = None
    distortion_model: str = "none"
    distortion: list[float] = Field(default_factory=list)


class ImuNoiseConfig(StrictModel):
    gyro_noise_density: float | None = None
    accel_noise_density: float | None = None
    gyro_random_walk: float | None = None
    accel_random_walk: float | None = None


class SensorConfig(StrictModel):
    type: SensorType
    model: str = "generic"
    topic: str | None = None
    frame_id: str | None = None
    camera_info_topic: str | None = None
    intrinsics: CameraIntrinsicsConfig | None = None
    fields: list[str] = Field(default_factory=list)
    noise: ImuNoiseConfig | None = None
    solid_state: SolidStateLidarProfile | None = Field(
        default=None,
        description=(
            "optional acquisition and internal-calibration metadata for a "
            "solid-state/non-repetitive LiDAR; retained in the result provenance"
        ),
    )
    odometry_preprocessing: OdometryPreprocessingConfig | None = Field(
        default=None,
        description=(
            "optional explicit pose-stream preprocessing for rosbag1/rosbag2 "
            "odometry; the raw dataset is never modified"
        ),
    )
    point_time_field: str | None = Field(
        default=None,
        description=(
            "optional PointCloud2 field name for per-point capture-time offsets "
            "relative to the message stamp (seconds or integer nanoseconds); "
            "enables per-point deskew during rosbag2 online motion compensation"
        ),
    )
    capture_time_reference: Literal["scan_start", "scan_midpoint", "scan_end"] | None = Field(
        default=None,
        description=(
            "declared position of a LiDAR message stamp within its sweep; when the "
            "sensor is not deskewed per point, motion-compensated online replay maps "
            "each message to its mid-sweep capture time (stamp + scan_period_s / 2 "
            "for scan_start, stamp - scan_period_s / 2 for scan_end); with per-point "
            "time offsets the offsets already carry the convention. Unset keeps the "
            "stamp as the capture time"
        ),
    )
    scan_period_s: float | None = Field(
        default=None,
        gt=0.0,
        description=(
            "declared sweep period in seconds used by capture_time_reference "
            "scan_start or scan_end when per-point offsets are not applied"
        ),
    )

    @model_validator(mode="after")
    def validate_sensor_specifics(self) -> SensorConfig:
        if self.solid_state is not None and self.type != "lidar":
            msg = "solid_state metadata is only valid for lidar sensors"
            raise ValueError(msg)
        if self.solid_state is not None:
            if self.point_time_field is None:
                self.point_time_field = self.solid_state.point_time_field
            elif self.solid_state.point_time_field is None:
                self.solid_state.point_time_field = self.point_time_field
            elif self.solid_state.point_time_field != self.point_time_field:
                msg = "solid_state.point_time_field must match sensor point_time_field"
                raise ValueError(msg)
        if self.capture_time_reference is not None and self.type != "lidar":
            msg = "capture_time_reference is only valid for lidar sensors"
            raise ValueError(msg)
        if self.capture_time_reference in {"scan_start", "scan_end"} and (
            self.scan_period_s is None
        ):
            msg = f"capture_time_reference {self.capture_time_reference} requires scan_period_s"
            raise ValueError(msg)
        if self.type == "camera" and self.intrinsics is None:
            self.intrinsics = CameraIntrinsicsConfig()
        if self.type == "lidar" and not self.fields:
            self.fields = ["x", "y", "z"]
        if self.type == "imu" and not self.topic:
            msg = "imu sensors require topic"
            raise ValueError(msg)
        return self


class TransformInitialConfig(StrictModel):
    translation: list[float] = Field(
        default_factory=lambda: [0.0, 0.0, 0.0],
        min_length=3,
        max_length=3,
    )
    rotation_quat_xyzw: list[float] = Field(
        default_factory=lambda: [0.0, 0.0, 0.0, 1.0],
        min_length=4,
        max_length=4,
    )


class TransformPriorSigmaConfig(StrictModel):
    translation_m: float | None = None
    rotation_deg: float | None = None


class TransformConfig(StrictModel):
    estimate: bool = True
    initial: TransformInitialConfig = Field(default_factory=TransformInitialConfig)
    prior_sigma: TransformPriorSigmaConfig | None = None


class FrameConfig(StrictModel):
    root: bool = False
    parent: str | None = None
    transform: TransformConfig | None = None

    @model_validator(mode="after")
    def validate_parent(self) -> FrameConfig:
        if not self.root and self.parent is None:
            msg = "non-root frames require parent"
            raise ValueError(msg)
        return self


class TimeOffsetConfig(StrictModel):
    estimate: bool = False
    initial_sec: float = 0.0
    prior_sigma_sec: float | None = None


class FactorConfig(StrictModel):
    enabled: bool = True
    weight: float | None = None
    options: dict[str, Any] = Field(default_factory=dict)


class PipelineConfig(StrictModel):
    type: str = "multi_sensor_slac"
    frontends: list[str] = Field(default_factory=list)
    factors: dict[str, FactorConfig] = Field(default_factory=dict)

    @model_validator(mode="after")
    def validate_typed_external_adapters(self) -> PipelineConfig:
        """Validate first-class external adapter options without closing the generic map.

        ``FactorConfig.options`` intentionally remains backward-compatible and
        open-ended for third-party factors.  Koide options, however, have a
        public typed boundary so unsafe commercial automatic-initial-guess
        configurations are rejected as early as config loading.
        """

        for name in (
            "koide_lidar_camera",
            "direct_visual_lidar_calibration",
            "lidar_camera_targetless_baseline",
        ):
            factor = self.factors.get(name)
            if factor is not None and factor.enabled:
                koide_runner_config_from_options(factor.options)
        return self


class SolverConfig(StrictModel):
    backend: str = "scipy"
    max_iterations: int = Field(default=50, ge=0)
    robust_loss: str = "huber"
    convergence_tolerance: float = Field(default=1.0e-6, gt=0.0)
    seed: int | None = None


class KITTIEvaluationConfig(StrictModel):
    max_projection_pairs: int = Field(default=3, ge=0, le=500)
    projection_sample_points: int = Field(default=800, ge=1, le=200_000)
    perturbation_rotation_deg: list[float] = Field(default_factory=lambda: [0.5, 1.0])
    perturbation_translation_m: list[float] = Field(default_factory=lambda: [0.05, 0.10])
    use_frame_graph_candidate: bool = Field(
        default=False,
        description=(
            "evaluate camera-LiDAR projection evidence from frame-graph candidate "
            "extrinsics instead of the dataset calibration file"
        ),
    )
    evidence_gate_min_edge_alignment_holdout: float = Field(
        default=0.20,
        gt=0.0,
        le=1.0,
        description="minimum holdout edge-alignment fraction for PASS evidence",
    )
    evidence_gate_min_depth_edge_alignment_holdout: float = Field(
        default=0.20,
        gt=0.0,
        le=1.0,
        description="minimum holdout depth-edge alignment fraction for PASS evidence",
    )
    evidence_gate_min_perturbation_detectable_fraction: float = Field(
        default=0.50,
        gt=0.0,
        le=1.0,
        description="minimum known-bad perturbation detectable fraction for PASS evidence",
    )
    evidence_gate_min_mandatory_detectable_count: int = Field(
        default=8,
        ge=0,
        description=(
            "minimum mandatory ±1 deg / ±0.10 m perturbation cases that must "
            "worsen projection scores"
        ),
    )

    @field_validator("perturbation_rotation_deg", "perturbation_translation_m")
    @classmethod
    def validate_positive_perturbations(cls, value: list[float]) -> list[float]:
        if any(amount <= 0.0 for amount in value):
            msg = "perturbation values must be positive"
            raise ValueError(msg)
        return value


class RadarEvaluationConfig(StrictModel):
    max_frames_per_sensor: int = Field(default=30, ge=2, le=10_000)
    use_dataset_reference: bool = Field(
        default=False,
        description="evaluate dataset reference Radar extrinsics instead of configured candidates",
    )
    min_holdout_frames: int = Field(default=5, ge=1)
    min_holdout_static_returns: int = Field(default=50, ge=1)
    min_static_fraction: float = Field(default=0.20, ge=0.0, le=1.0)
    min_yaw_sensitivity_rms_mps_per_rad: float = Field(default=0.57, ge=0.0)
    min_mandatory_detectable_fraction: float = Field(default=1.0, ge=0.0, le=1.0)
    max_holdout_median_abs_residual_mps: float | None = Field(default=None, gt=0.0)
    max_holdout_rmse_mps: float | None = Field(default=None, gt=0.0)


class EvaluationConfig(StrictModel):
    holdout_ratio: float = Field(default=0.2, ge=0.0, le=0.9)
    metrics: list[str] = Field(default_factory=list)
    strict: bool = False
    kitti: KITTIEvaluationConfig = Field(default_factory=KITTIEvaluationConfig)
    radar: RadarEvaluationConfig = Field(default_factory=RadarEvaluationConfig)
    solid_state: SolidStateEvaluationConfig = Field(
        default_factory=SolidStateEvaluationConfig
    )


class OutputsConfig(StrictModel):
    result: str = "result.yaml"
    report: str = "report.html"
    artifacts_dir: str = "artifacts"


class CalibrationConfig(StrictModel):
    schema_version: Literal["slac.config/v0.1"] = CONFIG_SCHEMA_VERSION
    project: ProjectConfig = Field(default_factory=ProjectConfig)
    dataset: DatasetConfig
    sensors: dict[str, SensorConfig]
    frames: dict[str, FrameConfig]
    time_offsets: dict[str, TimeOffsetConfig] = Field(default_factory=dict)
    pipeline: PipelineConfig = Field(default_factory=PipelineConfig)
    solver: SolverConfig = Field(default_factory=SolverConfig)
    evaluation: EvaluationConfig = Field(default_factory=EvaluationConfig)
    outputs: OutputsConfig = Field(default_factory=OutputsConfig)

    @field_validator("sensors", "frames")
    @classmethod
    def require_non_empty_mapping(cls, value: dict[str, Any]) -> dict[str, Any]:
        if not value:
            msg = "mapping must not be empty"
            raise ValueError(msg)
        return value

    @model_validator(mode="after")
    def validate_sensor_frames(self) -> CalibrationConfig:
        missing = sorted(set(self.sensors) - set(self.frames))
        if missing:
            msg = f"sensor frames missing from frames section: {', '.join(missing)}"
            raise ValueError(msg)
        return self

    @property
    def output_dir(self) -> Path:
        return Path(self.project.output_dir)


def load_config(path: str | Path) -> CalibrationConfig:
    """Load and validate a Calibrex config."""

    config_path = Path(path)
    try:
        return CalibrationConfig.model_validate(read_mapping(config_path))
    except Exception as exc:
        raise ConfigError(f"invalid config {config_path}: {exc}") from exc


def config_json_schema() -> dict[str, Any]:
    """Return the JSON schema for config files."""

    return CalibrationConfig.model_json_schema()
