"""Composition root (AZ-270 / E-CC-CONF; ADR-009 interface-first DI).

The single module allowed to import concrete strategy implementations across
components. Per-binary ``compose_*`` functions resolve the ``Config``-selected
strategies, validate them against the registry (ADR-002 gate #3), and assemble
the component graph in dependency order.

Per-binary entrypoints:

* :func:`compose_root`     - airborne runtime
* :func:`compose_operator` - operator-side tooling (pre-flight, post-landing)
* :func:`compose_replay`   - replay-cli runtime (extension owned by AZ-401)

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

from __future__ import annotations

import os
import sys
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final, Literal, get_args

from gps_denied_onboard.config import Config, load_config
from gps_denied_onboard.runtime_root.c12_factory import (
    build_flights_api_client,
)
from gps_denied_onboard.runtime_root.fc_factory import (
    OutboundThreadAlreadyBoundError,
    bind_outbound_emit_thread,
    build_fc_adapter,
    build_gcs_adapter,
    clear_outbound_thread_binding,
    clear_strategy_registries,
    list_registered_fc_strategies,
    list_registered_gcs_strategies,
    register_fc_adapter,
    register_gcs_adapter,
)
from gps_denied_onboard.runtime_root.pose_factory import (
    build_pose_estimator,
    clear_pose_registry,
    list_registered_pose_strategies,
    register_pose_estimator,
)
from gps_denied_onboard.runtime_root.spoof_recovery_sink import (
    SpoofRecoveryPublisher,
    SpoofRecoverySink,
)
from gps_denied_onboard.runtime_root.state_factory import (
    StateIngestThreadAlreadyBoundError,
    bind_state_ingest_thread,
    build_state_estimator,
    clear_state_ingest_binding,
    clear_state_registry,
    list_registered_state_strategies,
    register_state_estimator,
)

if TYPE_CHECKING:
    from gps_denied_onboard.components.c13_fdr.headers import FlightHeader
    from gps_denied_onboard.components.c13_fdr.writer import FileFdrWriter

__all__ = [
    "EXIT_FDR_OPEN_FAILURE",
    "EXIT_GENERIC_FAILURE",
    "REQUIRED_ENV_VARS",
    "ConfigurationError",
    "OperatorRoot",
    "OutboundThreadAlreadyBoundError",
    "RuntimeRoot",
    "SpoofRecoveryPublisher",
    "SpoofRecoverySink",
    "StateIngestThreadAlreadyBoundError",
    "StrategyNotLinkedError",
    "StrategyTier",
    "TakeoffResult",
    "bind_outbound_emit_thread",
    "bind_state_ingest_thread",
    "build_fc_adapter",
    "build_flights_api_client",
    "build_gcs_adapter",
    "build_pose_estimator",
    "build_state_estimator",
    "clear_outbound_thread_binding",
    "clear_pose_registry",
    "clear_state_ingest_binding",
    "clear_state_registry",
    "clear_strategy_registries",
    "clear_strategy_registry",
    "compose_operator",
    "compose_replay",
    "compose_root",
    "list_registered_fc_strategies",
    "list_registered_gcs_strategies",
    "list_registered_pose_strategies",
    "list_registered_state_strategies",
    "list_registered_strategies",
    "main",
    "register_fc_adapter",
    "register_gcs_adapter",
    "register_pose_estimator",
    "register_state_estimator",
    "register_strategy",
    "take_off",
]


EXIT_GENERIC_FAILURE: Final[int] = 1
EXIT_FDR_OPEN_FAILURE: Final[int] = 2

StrategyTier = Literal["airborne", "operator", "shared"]
_ALL_TIERS: tuple[StrategyTier, ...] = get_args(StrategyTier)


class ConfigurationError(RuntimeError):
    """Raised when a required environment variable is missing.

    AC-8 (Bootstrap / AZ-263).
    """


class StrategyNotLinkedError(RuntimeError):
    """Raised when the config selects a strategy that is not linked into this binary.

    Carries the offending strategy name, the owning component slug, and the
    actually-linked strategies for that component — gives the operator a
    clear next step (build with the right flag, or pick a different strategy).
    """

    def __init__(
        self,
        strategy_name: str,
        component_slug: str,
        available_strategies: Iterable[str],
        *,
        reason: str = "not linked",
    ) -> None:
        self.strategy_name: str = strategy_name
        self.component_slug: str = component_slug
        self.available_strategies: list[str] = sorted(set(available_strategies))
        self.reason: str = reason
        available_text = ", ".join(self.available_strategies) or "<none>"
        super().__init__(
            f"strategy {strategy_name!r} requested for component {component_slug!r} is "
            f"{reason}; available strategies: [{available_text}]"
        )


REQUIRED_ENV_VARS: tuple[str, ...] = (
    "GPS_DENIED_FC_PROFILE",
    "GPS_DENIED_TIER",
    "DB_URL",
    "CAMERA_CALIBRATION_PATH",
    "LOG_LEVEL",
    "LOG_SINK",
    "INFERENCE_BACKEND",
    "FDR_PATH",
    "TILE_CACHE_PATH",
)


@dataclass(frozen=True)
class _Registration:
    component_slug: str
    strategy_name: str
    factory: Callable[..., Any]
    tier: StrategyTier
    depends_on: tuple[str, ...]


_STRATEGY_REGISTRY: dict[tuple[str, str], _Registration] = {}


def register_strategy(
    component_slug: str,
    strategy_name: str,
    factory: Callable[..., Any],
    *,
    tier: StrategyTier = "shared",
    depends_on: Iterable[str] = (),
) -> None:
    """Register a concrete strategy implementation for ``component_slug``.

    The single allowed call site is the composition root or a binary-specific
    bootstrap module (one module per ``BUILD_*`` flag combination). Calling
    this from a component module is an architecture violation; AC-6 catches
    that statically.
    """
    if tier not in _ALL_TIERS:
        raise ValueError(f"tier must be one of {_ALL_TIERS}; got {tier!r}")
    key = (component_slug, strategy_name)
    existing = _STRATEGY_REGISTRY.get(key)
    incoming = _Registration(
        component_slug=component_slug,
        strategy_name=strategy_name,
        factory=factory,
        tier=tier,
        depends_on=tuple(depends_on),
    )
    if existing is not None and existing != incoming:
        raise StrategyNotLinkedError(
            strategy_name=strategy_name,
            component_slug=component_slug,
            available_strategies=list_registered_strategies(component_slug),
            reason="duplicate registration with conflicting attributes",
        )
    _STRATEGY_REGISTRY[key] = incoming


def clear_strategy_registry() -> None:
    """Reset the global registry; intended for unit-test isolation only."""
    _STRATEGY_REGISTRY.clear()


def list_registered_strategies(component_slug: str) -> list[str]:
    """Return the strategy names registered for ``component_slug`` (sorted)."""
    return sorted(
        reg.strategy_name
        for (slug, _name), reg in _STRATEGY_REGISTRY.items()
        if slug == component_slug
    )


@dataclass(frozen=True)
class RuntimeRoot:
    """Composed airborne runtime graph (every component slot populated)."""

    binary: str
    profile: str
    components: Mapping[str, Any] = field(default_factory=dict)
    construction_order: tuple[str, ...] = ()


@dataclass(frozen=True)
class OperatorRoot:
    """Composed operator-side runtime graph (operator-tier components only)."""

    binary: str
    profile: str
    components: Mapping[str, Any] = field(default_factory=dict)
    construction_order: tuple[str, ...] = ()


def _check_required_env(extra_required: Iterable[str] = ()) -> None:
    missing = [name for name in (*REQUIRED_ENV_VARS, *extra_required) if name not in os.environ]
    if missing:
        raise ConfigurationError(
            "Missing required environment variable(s): "
            + ", ".join(missing)
            + ". See `.env.example` for the full list."
        )


def _resolve_strategy(
    component_slug: str,
    strategy_name: str,
    allowed_tiers: frozenset[StrategyTier],
) -> _Registration:
    """Look up ``(component_slug, strategy_name)`` in the registry and gate by tier."""
    key = (component_slug, strategy_name)
    registration = _STRATEGY_REGISTRY.get(key)
    if registration is None:
        raise StrategyNotLinkedError(
            strategy_name=strategy_name,
            component_slug=component_slug,
            available_strategies=list_registered_strategies(component_slug),
        )
    if registration.tier not in allowed_tiers:
        raise StrategyNotLinkedError(
            strategy_name=strategy_name,
            component_slug=component_slug,
            available_strategies=list_registered_strategies(component_slug),
            reason=(
                f"registered under tier={registration.tier!r}; "
                f"not allowed in this binary (allowed tiers: {sorted(allowed_tiers)})"
            ),
        )
    return registration


def _topo_order(
    target_slugs: Iterable[str], registrations: Mapping[str, _Registration]
) -> list[str]:
    """Return ``target_slugs`` in a dependency-respecting order (Kahn's algorithm)."""
    ordered: list[str] = []
    remaining = set(target_slugs)
    visited: set[str] = set()

    def visit(slug: str, stack: tuple[str, ...]) -> None:
        if slug in visited:
            return
        if slug in stack:
            raise StrategyNotLinkedError(
                strategy_name=registrations[slug].strategy_name,
                component_slug=slug,
                available_strategies=list_registered_strategies(slug),
                reason=f"dependency cycle detected: {' -> '.join((*stack, slug))}",
            )
        if slug not in registrations:
            return
        for dep in registrations[slug].depends_on:
            visit(dep, (*stack, slug))
        visited.add(slug)
        ordered.append(slug)

    for slug in sorted(remaining):
        visit(slug, ())
    return [slug for slug in ordered if slug in remaining]


def _compose(
    config: Config,
    *,
    binary: str,
    allowed_tiers: frozenset[StrategyTier],
    extra_required_env: Iterable[str],
) -> tuple[dict[str, Any], tuple[str, ...]]:
    """Shared composition path used by ``compose_root`` / ``compose_operator``."""
    _check_required_env(extra_required=extra_required_env)
    selections = _resolve_component_strategies(config, allowed_tiers)
    resolved: dict[str, _Registration] = {
        slug: _resolve_strategy(slug, strategy, allowed_tiers)
        for slug, strategy in selections.items()
    }
    order = _topo_order(resolved.keys(), resolved)
    constructed: dict[str, Any] = {}
    for slug in order:
        registration = resolved[slug]
        try:
            constructed[slug] = registration.factory(config, constructed)
        except Exception:
            # All-or-nothing: close anything already built before re-raising.
            _close_partial_instances(constructed)
            raise
    _ = binary  # documented but unused beyond labelling the returned root
    return constructed, tuple(order)


def _close_partial_instances(instances: Mapping[str, Any]) -> None:
    """Best-effort cleanup of partially-constructed components on failure.

    Calls ``.close()`` on each instance that exposes one; swallows individual
    close failures so the original exception propagates to the caller.
    """
    for inst in instances.values():
        close = getattr(inst, "close", None)
        if callable(close):
            try:
                close()
            except Exception:
                continue


def _resolve_component_strategies(
    config: Config, allowed_tiers: frozenset[StrategyTier]
) -> dict[str, str]:
    """Translate ``config.components[slug]`` into ``{slug: strategy_name}``.

    Two recognised shapes for a per-component block:

    * a dataclass with a ``strategy`` field (preferred);
    * a mapping with a ``"strategy"`` key (fallback for raw YAML).

    Blocks without a ``strategy`` field are skipped — they configure a
    component whose strategy was hard-wired by the binary's bootstrap.
    """
    _ = allowed_tiers  # tier filtering happens in ``_resolve_strategy``
    selections: dict[str, str] = {}
    for slug, block in (config.components or {}).items():
        strategy = _read_strategy_attr(block)
        if strategy is None:
            continue
        if not isinstance(strategy, str) or not strategy:
            raise StrategyNotLinkedError(
                strategy_name=str(strategy),
                component_slug=slug,
                available_strategies=list_registered_strategies(slug),
                reason="config.components[slug].strategy must be a non-empty string",
            )
        selections[slug] = strategy
    return selections


def _read_strategy_attr(block: Any) -> Any:
    if hasattr(block, "strategy"):
        return block.strategy
    if isinstance(block, Mapping):
        return block.get("strategy")
    return None


def compose_root(config: Config) -> RuntimeRoot:
    """Compose the airborne runtime graph (per contract v1.0.0)."""
    components, order = _compose(
        config,
        binary="airborne",
        allowed_tiers=frozenset({"airborne", "shared"}),
        extra_required_env=("MAVLINK_SIGNING_KEY",),
    )
    return RuntimeRoot(
        binary="airborne",
        profile=os.environ["GPS_DENIED_FC_PROFILE"],
        components=components,
        construction_order=order,
    )


def compose_operator(config: Config) -> OperatorRoot:
    """Compose the operator-tooling runtime graph (per contract v1.0.0)."""
    components, order = _compose(
        config,
        binary="operator-tooling",
        allowed_tiers=frozenset({"operator", "shared"}),
        extra_required_env=("SATELLITE_PROVIDER_URL",),
    )
    return OperatorRoot(
        binary="operator-tooling",
        profile=os.environ["GPS_DENIED_FC_PROFILE"],
        components=components,
        construction_order=order,
    )


def compose_replay(config: Config) -> RuntimeRoot:
    """Compose the replay-cli runtime graph. Concrete wiring is owned by AZ-401."""
    components, order = _compose(
        config,
        binary="replay-cli",
        allowed_tiers=frozenset({"airborne", "shared"}),
        extra_required_env=(),
    )
    return RuntimeRoot(
        binary="replay-cli",
        profile=os.environ["GPS_DENIED_FC_PROFILE"],
        components=components,
        construction_order=order,
    )


@dataclass(frozen=True)
class TakeoffResult:
    """Successful takeoff: writer is open, FC adapter is wired, components started.

    Returned by :func:`take_off` on the success path. The abort path
    never returns — it calls :func:`sys.exit` with
    :data:`EXIT_FDR_OPEN_FAILURE`.
    """

    writer: Any
    fc_adapter: Any
    other_components: Mapping[str, Any] = field(default_factory=dict)


def take_off(
    config: Config,
    *,
    writer_factory: Callable[[Config], FileFdrWriter],
    flight_header_factory: Callable[[Config], FlightHeader],
    fc_adapter_factory: Callable[[Config, Any], Any],
    other_components_factory: Callable[[Config, Any, Any], Mapping[str, Any]] | None = None,
    flight_root_for_message: str | None = None,
) -> TakeoffResult:
    """Run the strict airborne takeoff sequence (AZ-296).

    Order: ``writer_factory`` → ``writer.start()`` →
    ``writer.open_flight(header)`` → (only on success) ``fc_adapter_factory``
    → ``other_components_factory``.

    On :exc:`FdrOpenError` from ``open_flight``, this function logs ONE
    structured ERROR, calls ``writer.stop()`` (best-effort), prints the
    fixed FATAL line to stderr, and exits the process with
    :data:`EXIT_FDR_OPEN_FAILURE`. It never returns on that path.

    Other exceptions propagate up unchanged; they reach :func:`main`
    which exits with :data:`EXIT_GENERIC_FAILURE`.

    Tests inject factories; production wiring builds factories from
    :func:`compose_root`.
    """
    from gps_denied_onboard.components.c13_fdr.errors import FdrOpenError

    writer = writer_factory(config)
    writer.start()
    try:
        writer.open_flight(flight_header_factory(config))
    except FdrOpenError as exc:
        _abort_takeoff_on_fdr_open_error(
            writer=writer,
            config=config,
            exc=exc,
            flight_root=flight_root_for_message,
        )
        raise AssertionError(  # pragma: no cover — abort helper must exit
            "unreachable: _abort_takeoff_on_fdr_open_error must exit"
        ) from None
    fc_adapter = fc_adapter_factory(config, writer)
    other: Mapping[str, Any] = {}
    if other_components_factory is not None:
        other = other_components_factory(config, writer, fc_adapter)
    return TakeoffResult(writer=writer, fc_adapter=fc_adapter, other_components=other)


def _abort_takeoff_on_fdr_open_error(
    *,
    writer: Any,
    config: Config,
    exc: BaseException,
    flight_root: str | None,
) -> None:
    """Execute the documented abort path; never returns."""
    from gps_denied_onboard.logging import get_logger

    resolved_root = flight_root if flight_root is not None else _read_flight_root(config)
    underlying = str(exc)
    log = get_logger("composition_root")
    try:
        log.error(
            "composition_root.takeoff_aborted",
            extra={
                "kind": "composition_root.takeoff_aborted",
                "kv": {
                    "reason": "fdr_open_error",
                    "underlying": underlying,
                    "flight_root": resolved_root,
                },
            },
        )
    except Exception:
        # Logging must never block the abort path.
        pass
    try:
        writer.stop()
    except Exception as stop_exc:
        try:
            log.error(
                "composition_root.takeoff_abort_stop_failed",
                extra={
                    "kind": "composition_root.takeoff_abort_stop_failed",
                    "kv": {"error": repr(stop_exc)},
                },
            )
        except Exception:
            # Even the error-logging failed during abort; we still have the
            # stderr print below to surface the original failure to the
            # operator. Swallowing keeps the abort path single-exit.
            pass
    print(
        f"FATAL: cannot open FDR at {resolved_root}: {underlying}; aborting takeoff (exit 2)",
        file=sys.stderr,
        flush=True,
    )
    # sys.exit raises SystemExit, which propagates to the process boundary.
    # In the unlikely event that some intermediate frame catches SystemExit
    # (e.g. a misbehaving test harness), the fallback below ensures the
    # process still terminates with the documented exit code.
    sys.exit(EXIT_FDR_OPEN_FAILURE)
    os._exit(EXIT_FDR_OPEN_FAILURE)  # pragma: no cover — only reached if SystemExit is intercepted


def _read_flight_root(config: Config) -> str:
    fdr = getattr(config, "fdr", None)
    if fdr is None:
        return "<unknown>"
    path = getattr(fdr, "path", None)
    return str(path) if path is not None else "<unknown>"


def main() -> int:  # pragma: no cover — guarded entrypoint
    try:
        config = load_config(env=os.environ, paths=())
        compose_root(config)
    except (ConfigurationError, StrategyNotLinkedError, RuntimeError) as exc:
        print(f"runtime_root: {exc}", file=sys.stderr)
        return EXIT_GENERIC_FAILURE
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
