"""Config dataclasses for E-CC-CONF (AZ-269 / AZ-246).

The outer `Config` aggregates one frozen nested dataclass per top-level
config block. Cross-cutting blocks (`log`, `fdr`, `runtime`) live here;
per-component blocks live with their own component epic and are
registered into `Config.components` via `register_component_block`.

Public surface frozen by `_docs/02_document/contracts/shared_config/composition_root_protocol.md` v1.0.0.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, field, fields, is_dataclass, replace
from typing import Any, Final

__all__ = [
    "DEFAULT_FORBIDDEN_RECORD_KINDS",
    "KNOWN_FC_STRATEGIES",
    "KNOWN_GCS_STRATEGIES",
    "Config",
    "ConfigError",
    "FcConfig",
    "FdrConfig",
    "FdrWriterConfig",
    "GcsConfig",
    "LogConfig",
    "RecordKindPolicyConfig",
    "RequiredFieldMissingError",
    "RuntimeConfig",
    "TileSnapshotConfig",
    "register_component_block",
]


KNOWN_FC_STRATEGIES: Final[frozenset[str]] = frozenset({"ardupilot_plane", "inav"})
KNOWN_GCS_STRATEGIES: Final[frozenset[str]] = frozenset({"qgc_mavlink"})


# Default raw-frame kinds that AZ-295's RecordKindPolicy must reject
# synchronously at the producer call site. Removing any of these from
# a Config requires an explicit `unsafe_remove_default_forbidden=True`
# flag (which is intentionally not present in any standard preset).
DEFAULT_FORBIDDEN_RECORD_KINDS: Final[frozenset[str]] = frozenset(
    {"raw_nav_frame", "raw_ai_cam_frame"}
)


class ConfigError(RuntimeError):
    """Base class for all config-loader errors that should reach the caller."""


class RequiredFieldMissingError(ConfigError):
    """Raised when a required configuration value is absent from env + YAML + defaults.

    Always names the missing variable and points at the runtime helper that
    documents the full set (``.env.example``).
    """


@dataclass(frozen=True)
class LogConfig:
    """Cross-cutting logging block (E-CC-LOG)."""

    level: str = "INFO"
    tier: int = 1
    sink: str = "console"


@dataclass(frozen=True)
class FdrWriterConfig:
    """C13 writer-thread block (E-C13 / AZ-291..AZ-296).

    ``segment_size_bytes`` controls per-segment rotation; the writer
    closes the current segment and opens the next once a record's
    serialised size would push the segment past this cap.

    ``batch_size`` bounds the per-producer ``drain(max_records=N)`` call
    so one slow producer cannot starve others.

    ``flight_cap_bytes`` is the AC-NEW-3 per-flight cap (default 64 GiB
    exactly). Lowered in tests to exercise the cap policy on small
    fixtures. There is no flag that disables cap enforcement (verified
    by C13-ST-01).

    ``debug_log_per_record`` enables a per-record DEBUG log line —
    OFF by default because a 100 Hz aggregate would flood logs.
    """

    segment_size_bytes: int = 64 * 1024 * 1024
    batch_size: int = 64
    flight_cap_bytes: int = 64 * 1024**3
    debug_log_per_record: bool = False


@dataclass(frozen=True)
class TileSnapshotConfig:
    """C13 mid-flight tile snapshot sidecar block (AZ-294).

    ``tile_snapshot_cap_bytes`` is the per-flight ceiling on the
    cumulative size of the ``tiles/`` subdirectory under the flight
    root (default 64 MiB to comfortably hold the worst-case ~50 MB
    from per-component description.md).

    ``jpeg_max_bytes`` rejects single tile JPEGs larger than this
    bound (default 256 KiB; description.md gives 50-200 KiB).
    """

    tile_snapshot_cap_bytes: int = 64 * 1024 * 1024
    jpeg_max_bytes: int = 256 * 1024


@dataclass(frozen=True)
class RecordKindPolicyConfig:
    """C13 record-kind policy block (AZ-295).

    ``forbidden_record_kinds`` lists FdrRecord ``kind`` values that
    the producer-side ``enforce_or_raise`` gate rejects with
    ``RawFrameWriteForbiddenError``. The default set
    (``DEFAULT_FORBIDDEN_RECORD_KINDS``) MUST be a subset of the
    configured set — removing defaults is a security-review-required
    path guarded by ``unsafe_remove_default_forbidden``.

    ``failed_tile_thumbnail_max_hz`` caps the writer-side rate of
    ``kind="failed_tile_thumbnail"`` records (default 0.1 Hz per
    AC-8.5 + description.md § 7). Setting this to 0 is rejected at
    config validation (would silence the kind entirely; that path is
    intentionally not exposed).
    """

    forbidden_record_kinds: frozenset[str] = field(
        default_factory=lambda: DEFAULT_FORBIDDEN_RECORD_KINDS
    )
    failed_tile_thumbnail_max_hz: float = 0.1
    unsafe_remove_default_forbidden: bool = False

    def __post_init__(self) -> None:
        if not isinstance(self.forbidden_record_kinds, frozenset):
            raise ConfigError(
                "RecordKindPolicyConfig.forbidden_record_kinds must be a frozenset; "
                f"got {type(self.forbidden_record_kinds).__name__}"
            )
        if not self.unsafe_remove_default_forbidden:
            missing_defaults = DEFAULT_FORBIDDEN_RECORD_KINDS - self.forbidden_record_kinds
            if missing_defaults:
                raise ConfigError(
                    "RecordKindPolicyConfig.forbidden_record_kinds removes default raw-frame "
                    f"kinds without unsafe_remove_default_forbidden=True: missing {sorted(missing_defaults)}"
                )
        if not (
            isinstance(self.failed_tile_thumbnail_max_hz, (int, float))
            and not isinstance(self.failed_tile_thumbnail_max_hz, bool)
        ):
            raise ConfigError(
                "RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be a number; "
                f"got {self.failed_tile_thumbnail_max_hz!r}"
            )
        if self.failed_tile_thumbnail_max_hz <= 0:
            raise ConfigError(
                "RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be > 0; "
                f"got {self.failed_tile_thumbnail_max_hz}"
            )
        if self.failed_tile_thumbnail_max_hz > 10.0:
            raise ConfigError(
                "RecordKindPolicyConfig.failed_tile_thumbnail_max_hz must be <= 10.0; "
                f"got {self.failed_tile_thumbnail_max_hz}"
            )


@dataclass(frozen=True)
class FdrConfig:
    """Cross-cutting Flight Data Recorder block (E-CC-FDR-CLIENT / AZ-247).

    ``queue_size`` is the documented default capacity for every producer.
    ``per_producer_capacity`` carries per-producer overrides keyed by
    producer slug (consumed by AZ-273 ``make_fdr_client``); blocks
    that omit a producer fall back to ``queue_size``.

    Sub-blocks (AZ-291..AZ-296): ``writer``, ``tile_snapshot``,
    ``record_policy``.
    """

    queue_size: int = 4096
    overrun_policy: str = "drop_oldest"
    path: str = "/var/lib/gps-denied/fdr"
    per_producer_capacity: Mapping[str, int] = field(default_factory=dict)
    writer: FdrWriterConfig = field(default_factory=FdrWriterConfig)
    tile_snapshot: TileSnapshotConfig = field(default_factory=TileSnapshotConfig)
    record_policy: RecordKindPolicyConfig = field(default_factory=RecordKindPolicyConfig)


@dataclass(frozen=True)
class FcConfig:
    """C8 flight-controller adapter block (AZ-390 / E-C8).

    ``adapter`` selects one of :data:`KNOWN_FC_STRATEGIES`; unknown
    strategy names are rejected at Config construction (AC-5). The
    build-time flag check (`BUILD_FC_<VARIANT>`) happens in the
    factory itself per AC-4 because flag state lives in the process
    env, not in the config object.

    ``signing_key_source`` is one of ``"none"`` (iNav default) or
    ``"ephemeral_per_flight"`` (AP default; AZ-395 owns the body).
    """

    adapter: str = "ardupilot_plane"
    port_device: str = "/dev/ttyTHS1"
    port_baud: int = 921600
    signing_key_source: str = "ephemeral_per_flight"
    dev_static_signing_key: str = ""
    signing_failure_threshold: int = 3
    spoof_recovery_source_set: int = 1
    source_set_switch_timeout_ms: int = 1500

    def __post_init__(self) -> None:
        if self.adapter not in KNOWN_FC_STRATEGIES:
            raise ConfigError(
                f"FcConfig.adapter={self.adapter!r} not in {sorted(KNOWN_FC_STRATEGIES)}"
            )
        if self.signing_key_source not in {
            "none",
            "ephemeral_per_flight",
            "dev_static",
        }:
            raise ConfigError(
                f"FcConfig.signing_key_source={self.signing_key_source!r} not in "
                f"['none', 'ephemeral_per_flight', 'dev_static']"
            )
        if self.adapter == "inav" and self.signing_key_source != "none":
            raise ConfigError(
                "FcConfig.signing_key_source must be 'none' when adapter='inav' "
                "(RESTRICT-COMM-2 — iNav has no signing)"
            )
        if self.signing_key_source == "dev_static" and not self.dev_static_signing_key:
            raise ConfigError(
                "FcConfig.dev_static_signing_key required when signing_key_source='dev_static'"
            )
        if self.signing_failure_threshold < 1:
            raise ConfigError(
                "FcConfig.signing_failure_threshold must be >= 1; got "
                f"{self.signing_failure_threshold}"
            )
        if self.spoof_recovery_source_set < 0:
            raise ConfigError(
                "FcConfig.spoof_recovery_source_set must be >= 0; got "
                f"{self.spoof_recovery_source_set}"
            )
        if self.source_set_switch_timeout_ms < 100:
            raise ConfigError(
                "FcConfig.source_set_switch_timeout_ms must be >= 100 ms; got "
                f"{self.source_set_switch_timeout_ms}"
            )


@dataclass(frozen=True)
class GcsConfig:
    """C8 GCS adapter block (AZ-390 / E-C8 — AZ-397 builds the body).

    ``adapter`` selects one of :data:`KNOWN_GCS_STRATEGIES`.
    ``summary_rate_hz`` is the per-emitter downsample target
    (Invariant 12; default 2 Hz; range [0.5, 5.0] per AZ-397 AC-10).
    """

    adapter: str = "qgc_mavlink"
    port_device: str = "/dev/ttyTHS2"
    port_baud: int = 921600
    summary_rate_hz: float = 2.0

    def __post_init__(self) -> None:
        if self.adapter not in KNOWN_GCS_STRATEGIES:
            raise ConfigError(
                f"GcsConfig.adapter={self.adapter!r} not in {sorted(KNOWN_GCS_STRATEGIES)}"
            )
        if not (0.5 <= self.summary_rate_hz <= 5.0):
            raise ConfigError(
                f"GcsConfig.summary_rate_hz must be in [0.5, 5.0]; got {self.summary_rate_hz}"
            )


@dataclass(frozen=True)
class RuntimeConfig:
    """Top-level runtime descriptors that don't belong to a single component."""

    fc_profile: str = "ardupilot_plane"
    tier: int = 1
    db_url: str = ""
    camera_calibration_path: str = ""
    inference_backend: str = "pytorch_fp16"
    tile_cache_path: str = "/var/lib/gps-denied/tiles"


# Documented defaults for cross-cutting blocks ONLY. Per-component defaults
# live with their own component epic. The registry below is the single
# source of truth so two components cannot silently claim the same key.
_DEFAULT_BLOCKS: Final[dict[str, type]] = {
    "log": LogConfig,
    "fdr": FdrConfig,
    "runtime": RuntimeConfig,
    "fc": FcConfig,
    "gcs": GcsConfig,
}


# Registry for per-component nested dataclasses. Each component epic
# calls ``register_component_block("c5_state", C5StateConfig)`` from its
# package import path; the composition root drives those imports before
# calling ``load_config``.
_COMPONENT_REGISTRY: dict[str, type] = {}


def register_component_block(slug: str, dataclass_type: type) -> None:
    """Register a per-component frozen dataclass under its component slug."""
    if not is_dataclass(dataclass_type):
        raise TypeError(
            f"register_component_block({slug!r}, ...): block must be a dataclass; "
            f"got {dataclass_type!r}"
        )
    existing = _COMPONENT_REGISTRY.get(slug)
    if existing is not None and existing is not dataclass_type:
        raise ConfigError(
            f"duplicate component config registration for slug {slug!r}: "
            f"{existing!r} vs {dataclass_type!r}"
        )
    _COMPONENT_REGISTRY[slug] = dataclass_type


def _resolve_default_blocks() -> dict[str, Any]:
    """Instantiate every documented cross-cutting block with its defaults."""
    return {name: cls() for name, cls in _DEFAULT_BLOCKS.items()}


def _resolve_component_blocks() -> dict[str, Any]:
    """Instantiate every registered per-component block with its defaults."""
    return {slug: cls() for slug, cls in _COMPONENT_REGISTRY.items()}


@dataclass(frozen=True)
class Config:
    """Outer composition-root config (frozen end-to-end).

    Components consume only their own slice via ``config.components[slug]``;
    the runtime / log / fdr cross-cutting blocks are read directly via
    attribute access by the composition root.
    """

    runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
    log: LogConfig = field(default_factory=LogConfig)
    fdr: FdrConfig = field(default_factory=FdrConfig)
    fc: FcConfig = field(default_factory=FcConfig)
    gcs: GcsConfig = field(default_factory=GcsConfig)
    components: Mapping[str, Any] = field(default_factory=dict)

    @classmethod
    def with_blocks(cls, **blocks: Any) -> Config:
        """Build a `Config` from a flat name-to-instance map.

        Cross-cutting names (``log``, ``fdr``, ``runtime``, ``fc``, ``gcs``)
        become attributes; every other key is treated as a component slug
        and goes into ``components``.
        """
        runtime = blocks.pop("runtime", RuntimeConfig())
        log = blocks.pop("log", LogConfig())
        fdr = blocks.pop("fdr", FdrConfig())
        fc = blocks.pop("fc", FcConfig())
        gcs = blocks.pop("gcs", GcsConfig())
        return cls(
            runtime=runtime,
            log=log,
            fdr=fdr,
            fc=fc,
            gcs=gcs,
            components=dict(blocks),
        )


def _block_field_names(block: Any) -> tuple[str, ...]:
    return tuple(f.name for f in fields(block))


def _replace_block(block: Any, overrides: Mapping[str, Any]) -> Any:
    """Return ``replace(block, **overrides)`` after filtering unknown keys."""
    if not overrides:
        return block
    known = set(_block_field_names(block))
    filtered = {k: v for k, v in overrides.items() if k in known}
    if not filtered:
        return block
    return replace(block, **filtered)
