"""Composition-root factory for C5 (AZ-381 / E-C5).

Mirrors the C8 factory shape (``runtime_root.fc_factory``): per-binary
bootstrap modules register concrete strategy factories under their
``BUILD_STATE_<variant>`` flag; :func:`build_state_estimator` resolves
the configured strategy, gates by build flag, constructs the
estimator, and returns the tuple ``(StateEstimator,
ISam2GraphHandle)`` so the runtime root can inject the handle into
C4.

Single-writer-thread binding for C5 + C4 is enforced via
:func:`bind_state_ingest_thread`; the second binding from a different
thread raises :class:`StateIngestThreadAlreadyBoundError`. The runtime
root binds C4 + C5 to the SAME thread.

ADR-002 build-flag gating: ``config.components["c5_state"].strategy ==
"gtsam_isam2"`` requires ``BUILD_STATE_GTSAM_ISAM2=ON``. Default is
``ON`` (most binaries link the production-default); the operator-side
binary may set ``OFF`` and only link the ESKF baseline.
"""

from __future__ import annotations

import os
import threading
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final

from gps_denied_onboard.components.c5_state._isam2_handle import ISam2GraphHandle
from gps_denied_onboard.components.c5_state.config import (
    KNOWN_STATE_STRATEGIES,
    C5StateConfig,
)
from gps_denied_onboard.components.c5_state.errors import StateEstimatorConfigError
from gps_denied_onboard.components.c5_state.interface import StateEstimator
from gps_denied_onboard.config import Config
from gps_denied_onboard.logging import get_logger

if TYPE_CHECKING:
    pass

__all__ = [
    "StateEstimatorFactory",
    "StateIngestThreadAlreadyBoundError",
    "bind_state_ingest_thread",
    "build_state_estimator",
    "clear_state_ingest_binding",
    "clear_state_registry",
    "list_registered_state_strategies",
    "register_state_estimator",
]


StateEstimatorFactory = Callable[..., tuple[StateEstimator, ISam2GraphHandle]]

_STATE_REGISTRY: dict[str, StateEstimatorFactory] = {}

_STATE_BUILD_FLAGS: Final[dict[str, str]] = {
    "gtsam_isam2": "BUILD_STATE_GTSAM_ISAM2",
    "eskf": "BUILD_STATE_ESKF",
}


def register_state_estimator(strategy: str, factory: StateEstimatorFactory) -> None:
    """Register a concrete `StateEstimator` strategy.

    Called by per-binary bootstrap modules under the matching
    ``BUILD_STATE_<variant>`` flag. Duplicate registration with a
    different factory raises :class:`StateEstimatorConfigError`.
    """
    existing = _STATE_REGISTRY.get(strategy)
    if existing is not None and existing is not factory:
        raise StateEstimatorConfigError(
            f"duplicate StateEstimator registration for strategy {strategy!r}"
        )
    _STATE_REGISTRY[strategy] = factory


def clear_state_registry() -> None:
    """Reset the state registry; unit-test isolation only."""
    _STATE_REGISTRY.clear()


def list_registered_state_strategies() -> list[str]:
    return sorted(_STATE_REGISTRY)


# ----------------------------------------------------------------------
# Single-writer state-ingest thread (Invariant 1; C4 + C5 share it).


class StateIngestThreadAlreadyBoundError(RuntimeError):
    """Raised on a second :func:`bind_state_ingest_thread` call from a different thread."""


_ingest_lock = threading.Lock()
_ingest_bound_thread: int | None = None


def bind_state_ingest_thread(thread_ident: int | None = None) -> int:
    """Bind ``thread_ident`` (defaults to the caller) as the sole state-ingest thread.

    A second call from a different thread raises
    :class:`StateIngestThreadAlreadyBoundError`. C4 + C5 + the shared
    GTSAM substrate live on this thread per ADR-003. Repeated binding
    from the SAME thread is permitted (idempotent for re-entrant
    composition under tests).
    """
    global _ingest_bound_thread
    ident = thread_ident if thread_ident is not None else threading.get_ident()
    with _ingest_lock:
        if _ingest_bound_thread is not None and _ingest_bound_thread != ident:
            raise StateIngestThreadAlreadyBoundError(
                f"state ingest thread already bound to {_ingest_bound_thread}; "
                f"refused to re-bind to {ident}"
            )
        _ingest_bound_thread = ident
        return ident


def clear_state_ingest_binding() -> None:
    """Reset the state-ingest-thread binding; unit-test isolation only."""
    global _ingest_bound_thread
    with _ingest_lock:
        _ingest_bound_thread = None


# ----------------------------------------------------------------------
# Public composition-root factory.


def build_state_estimator(
    config: Config,
    *,
    imu_preintegrator: Any,
    se3_utils: Any,
    wgs_converter: Any,
    fdr_client: Any,
) -> tuple[StateEstimator, ISam2GraphHandle]:
    """Resolve + build the configured state estimator.

    Returns the ``(StateEstimator, ISam2GraphHandle)`` tuple so the
    runtime root can inject the handle into C4 via
    ``build_pose_estimator``.

    Validation order: config block lookup → build-flag gate → factory
    lookup. The first failure surfaces a :class:`StateEstimatorConfigError`
    with the offending strategy + flag name so the operator gets a
    clear next step.
    """
    block = _read_state_block(config)
    strategy = block.strategy
    if strategy not in KNOWN_STATE_STRATEGIES:
        raise StateEstimatorConfigError(
            f"C5StateConfig.strategy={strategy!r} not in {sorted(KNOWN_STATE_STRATEGIES)}"
        )
    flag_name = _STATE_BUILD_FLAGS.get(strategy)
    if flag_name is None:
        raise StateEstimatorConfigError(
            f"state strategy {strategy!r} has no BUILD_STATE_* flag mapping"
        )
    if os.environ.get(flag_name, "ON").upper() == "OFF":
        raise StateEstimatorConfigError(
            f"{flag_name} is OFF — strategy {strategy!r} is not linked into this binary"
        )
    factory = _STATE_REGISTRY.get(strategy)
    if factory is None:
        raise StateEstimatorConfigError(
            f"state strategy {strategy!r} selected by config but not registered; "
            f"registered strategies: {list_registered_state_strategies()}"
        )
    estimator, handle = factory(
        config=config,
        imu_preintegrator=imu_preintegrator,
        se3_utils=se3_utils,
        wgs_converter=wgs_converter,
        fdr_client=fdr_client,
    )
    _log_strategy_loaded(
        strategy=strategy,
        keyframe_window_size=block.keyframe_window_size,
    )
    return estimator, handle


def _read_state_block(config: Config) -> C5StateConfig:
    """Pull the c5_state block out of ``config.components`` (or fall back to defaults)."""
    components = getattr(config, "components", None) or {}
    block = components.get("c5_state") if isinstance(components, dict) else None
    if block is None:
        # Allow missing block to mean "documented defaults" — same shape
        # as the cross-cutting blocks. Tests that exercise the factory
        # without YAML/env see defaults.
        return C5StateConfig()
    if isinstance(block, C5StateConfig):
        return block
    raise StateEstimatorConfigError(
        f"config.components['c5_state'] must be a C5StateConfig; got {type(block).__name__}"
    )


def _log_strategy_loaded(*, strategy: str, keyframe_window_size: int) -> None:
    log = get_logger("runtime_root.state_factory")
    log.info(
        f"c5.state.strategy_loaded: strategy={strategy} "
        f"keyframe_window_size={keyframe_window_size}",
        extra={
            "kind": "c5.state.strategy_loaded",
            "kv": {
                "strategy": strategy,
                "keyframe_window_size": keyframe_window_size,
            },
        },
    )
