"""DefaultNetwork - Main coordinator for reactive behaviors.

The DefaultNetwork is the central component that:
1. Subscribes to YOLO detection stream
2. Runs behavior modules in parallel
3. Arbitrates between competing behaviors
4. Respects inhibition signals from deliberative layer
5. Publishes winning actions to motor queue
6. Acts as Thalamic Gate, filtering percepts for escalation
"""

from __future__ import annotations

import logging
import threading
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable

from maxim.default_network.arbiter import ArbiterConfig, PriorityArbiter
from maxim.attention import AttentionConfig, AttentionNetwork
from maxim.attention import SalienceMap, SalienceMapConfig
from maxim.attention import GazeController, GazeControllerConfig, GazeCommand, GazeState
from maxim.attention import SceneContextDetector, SceneContextConfig
from maxim.default_network.behaviors.base import Behavior, BehaviorState
from maxim.default_network.behaviors.idle import ReturnToCenter
from maxim.default_network.behaviors.turn_around import TurnAround
from maxim.default_network.gaze_manager import GazeManagerMixin
from maxim.default_network.gate import (
    AdaptiveThresholdConfig,
    GateConfig,
    ThalamicGate,
)
from maxim.default_network.inhibition import InhibitionMixin
from maxim.attention import GazeHistory, GazeHistoryConfig
from maxim.default_network.messages import (
    ActionProposal,
    DNActionProposal,
    FilteredPercept,
)
from maxim.default_network.background_tasks import (
    BackgroundTaskConfig,
    BackgroundTaskManager,
    ThrottleConfig,
    create_throttlers,
)
from maxim.default_network.movement_utils import (
    MovementConfig,
    compute_dynamic_duration,
    compute_opposite_position,
)
from maxim.salience import ThreadSafeNoveltyTracker
from maxim.salience import SalienceConfig, SalienceNetwork
from maxim.salience import MovementConfig as SalienceMovementConfig, MovementDetector
from maxim.spatial import SpatialMap, SpatialMapConfig, WorkspaceBoundsConfig, WorkspaceBoundsLearner
from maxim.proprioception import PainConfig, PainDetector, FocusLearner, FocusLearnerConfig
from maxim.bridges.pain_bridge import PainCircuitBridge, PainBridgeConfig

if TYPE_CHECKING:
    from maxim.agents.bus import AgentBus, Percept
    from maxim.agents.fear_agent import FearAgent
    from maxim.decisions.nac import NAc
    from maxim.proprioception.pain_bus import PainBus

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class DefaultNetworkConfig:
    """Configuration for the Default Network.

    Attributes:
        enabled: Whether DN is active.
        update_hz: Target update rate in Hz.
        auto_release_timeout: Max inhibition duration before auto-release.
        publish_actions: Whether to publish DNActionProposal to bus.
        fear_gate_enabled: Whether to gate actions through FearAgent.
    """

    enabled: bool = True
    update_hz: float = 30.0
    auto_release_timeout: float = 5.0
    publish_actions: bool = True
    fear_gate_enabled: bool = True

    # Sub-component configs
    arbiter: ArbiterConfig = field(default_factory=ArbiterConfig)
    gate: GateConfig = field(default_factory=GateConfig)
    adaptive_threshold: AdaptiveThresholdConfig = field(default_factory=AdaptiveThresholdConfig)
    gaze_history: GazeHistoryConfig = field(default_factory=GazeHistoryConfig)

    # Gaze salience gating - prevents excessive movements
    gaze_salience_enabled: bool = True

    # Idle exploration - random movements when scene is boring
    idle_exploration_enabled: bool = True
    idle_exploration_min_seconds: float = 3.0  # Min time before exploring
    idle_exploration_max_seconds: float = 8.0  # Max time before exploring
    idle_novelty_threshold: float = 0.3  # Below this = not interesting
    # UV range for random exploration targets. Expanded to (0.15, 0.85) to cover
    # more of the visual field while staying within safe servo limits.
    # Coordinates outside this range are clamped by selfy.look_at_image().
    idle_exploration_range: tuple[float, float] = (0.15, 0.85)

    # Spatial map - learns reachable positions and remembers interesting locations
    spatial_map_enabled: bool = True
    spatial_map: SpatialMapConfig = field(default_factory=SpatialMapConfig)

    # WorkspaceBoundsLearner - learns actual joint-space movement limits
    bounds_learning_enabled: bool = True
    bounds: WorkspaceBoundsConfig = field(default_factory=WorkspaceBoundsConfig)

    # AttentionNetwork - DataFrame-backed spatial attention management
    attention_network_enabled: bool = True
    attention: AttentionConfig = field(default_factory=AttentionConfig)

    # SalienceNetwork - DataFrame-backed object salience tracking
    salience_network_enabled: bool = True
    salience: SalienceConfig = field(default_factory=SalienceConfig)

    # Percept source mode — determines which WhereCoord type is used.
    # "auto" detects from available hardware (vision if camera, narrative if sim).
    # Explicit values: "vision", "narrative", "sem", "null".
    percept_source_mode: str = "auto"

    # MovementDetector - tracks object motion for salience boosting
    movement_detection_enabled: bool = True
    movement_detection: SalienceMovementConfig = field(default_factory=SalienceMovementConfig)

    # SalienceMap - unified spatial salience combining all attention factors
    salience_map_enabled: bool = True
    salience_map_config: SalienceMapConfig = field(default_factory=SalienceMapConfig)

    # GazeController - human-like saccade-fixate dynamics
    gaze_controller_enabled: bool = True
    gaze_controller: GazeControllerConfig = field(default_factory=GazeControllerConfig)

    # SceneContextDetector - detect major scene changes for exploration
    scene_context_enabled: bool = True
    scene_context: SceneContextConfig = field(default_factory=SceneContextConfig)

    # Movement dynamics - duration, jitter, reachability
    movement: MovementConfig = field(default_factory=MovementConfig)
    reachability_check_enabled: bool = True
    min_reachability_threshold: float = 0.3  # Below this = don't attempt movement

    # Throttling - reduce update rates for expensive operations
    throttle: ThrottleConfig = field(default_factory=ThrottleConfig)

    # Background tasks - cleanup, stats collection
    background_tasks: BackgroundTaskConfig = field(default_factory=BackgroundTaskConfig)

    # Pain detection - aversive learning from movement
    pain_detection_enabled: bool = True
    pain_angular_velocity_threshold: float = 100.0  # deg/sec triggers pain
    pain_translation_velocity_threshold: float = 50.0  # mm/sec triggers pain
    pain_prediction_threshold: float = 0.4  # Confidence for pain predictions

    # Focus learning - adaptive movement gain from focus feedback
    focus_learning_enabled: bool = True
    focus_learner: FocusLearnerConfig = field(default_factory=FocusLearnerConfig)


class DefaultNetwork(GazeManagerMixin, InhibitionMixin):
    """Reactive behavior layer that generates actions from raw perception.

    The DefaultNetwork continuously processes YOLO detections and generates
    reactive movements without LLM involvement. It also acts as a Thalamic
    Gate, filtering which percepts are escalated to the deliberative layer.

    Example:
        dn = DefaultNetwork(
            maxim=maxim_instance,
            bus=agent_bus,
            behaviors=[OrientingResponse(), SocialAttention()],
        )
        dn.start()

        # Later, when deliberative layer needs focus:
        dn.inhibit(duration=2.0)

        # Cleanup
        dn.stop()
    """

    def __init__(
        self,
        maxim: Any,  # Maxim instance for motor control (None = headless/sim)
        bus: "AgentBus | None" = None,
        behaviors: list[Behavior] | None = None,
        config: DefaultNetworkConfig | None = None,
        fear_agent: "FearAgent | None" = None,
        novelty_tracker: ThreadSafeNoveltyTracker | None = None,
        nac: "NAc | None" = None,
        pain_bus: "PainBus | None" = None,
    ) -> None:
        """Initialize the Default Network.

        Args:
            maxim: Maxim instance for executing movements. ``None`` for
                headless/sim mode — DN still provides pain detection and
                novelty tracking, just no motor commands.
            bus: AgentBus for publishing messages.
            behaviors: List of behavior modules. If None, uses defaults.
            config: Network configuration.
            fear_agent: FearAgent for movement gating within DN. For tool
                call gating, use FearGatedExecutor (independent of DN).
            novelty_tracker: Shared novelty tracker. If None, creates new one.
            nac: NAc instance for causal learning (used by pain detection).
            pain_bus: Injected PainBus instance. When provided, DN uses
                this bus instead of constructing its own internally.
                The injected bus should already have hippocampus + NAc
                subscribers wired via ``build_pain_bus``. When ``None``,
                DN constructs its own PainBus internally (backward compat
                for tests and callers that haven't migrated). See
                ``docs/plans/default_network_unification.md`` Gap B.
        """
        # 1. Core state — config, callbacks, runtime flags, stats
        self._init_core_state(maxim, bus, behaviors, config, fear_agent, novelty_tracker)

        # 2. Decision pipeline — arbiter + thalamic gate
        self._arbiter = PriorityArbiter(self._config.arbiter)
        self._gate = ThalamicGate(
            config=self._config.gate,
            adaptive_config=self._config.adaptive_threshold,
        )

        # 3. Spatial subsystems — gaze history, spatial map, workspace bounds learner
        self._init_spatial_subsystems()

        # 4. Attention/salience subsystems
        self._init_attention_subsystems()

        # 5. Throttlers (CPU-load reducer for periodic operations)
        self._throttlers = create_throttlers(self._config.throttle)

        # 6. Background task manager — depends on spatial + attention + salience
        self._background_tasks = BackgroundTaskManager(
            config=self._config.background_tasks,
            spatial_map=self._spatial_map,
            attention_network=self._attention_network,
            salience_network=self._salience_network,
            novelty_tracker=self._novelty_tracker,
        )

        # 7. Pain circuit + 8. Focus learner (both depend on nac)
        self._init_pain_circuit(nac, pain_bus=pain_bus)
        self._init_focus_learner(nac)

        # 9. Auto-subscribe to bus
        self._maybe_subscribe_to_bus()

    # ── __init__ helpers ───────────────────────────────────────────────────

    def _init_core_state(
        self,
        maxim: Any,
        bus: "AgentBus | None",
        behaviors: list[Behavior] | None,
        config: DefaultNetworkConfig | None,
        fear_agent: "FearAgent | None",
        novelty_tracker: ThreadSafeNoveltyTracker | None,
    ) -> None:
        """Set up plain-data fields: config, locks, callbacks, stats, runtime flags."""
        self._maxim = maxim
        self._bus = bus
        self._config = config or DefaultNetworkConfig()
        self._fear_agent = fear_agent

        self._novelty_tracker = novelty_tracker or ThreadSafeNoveltyTracker()
        self._behaviors = behaviors or []

        # Idle exploration state
        self._last_interesting_time: float = time.time()
        self._next_exploration_time: float = self._schedule_next_exploration()

        # Runtime flags
        self._running = False
        self._inhibited = False
        self._inhibit_until: float = 0.0
        self._behavior_overrides: dict[str, float] = {}

        # Detection buffer (latest from capture)
        self._latest_detections: list[dict] = []
        self._latest_percept: "Percept | None" = None
        self._detection_lock = threading.Lock()

        # Thread management
        self._thread: threading.Thread | None = None
        self._update_interval = 1.0 / self._config.update_hz

        # Current interests (class IDs to prioritize)
        self._interests: frozenset[int] = frozenset()

        # Callbacks for integration
        self._on_action_callbacks: list[Callable[[ActionProposal], None]] = []
        self._on_escalation_callbacks: list[Callable[[FilteredPercept], None]] = []

        # Statistics
        self._stats = {
            "ticks": 0,
            "actions_proposed": 0,
            "actions_executed": 0,
            "actions_blocked": 0,
            "actions_salience_blocked": 0,
            "actions_reachability_blocked": 0,
            "idle_explorations": 0,
            "percepts_received": 0,
            "percepts_escalated": 0,
            "percepts_filtered": 0,
        }
        self._stats_lock = threading.Lock()

        # Subscribe state — populated by _maybe_subscribe_to_bus
        self._subscribed_to_bus = False

    def _init_spatial_subsystems(self) -> None:
        """Gaze history + (optional) spatial map + workspace bounds learner."""
        self._gaze_history = GazeHistory(self._config.gaze_history)

        self._spatial_map: SpatialMap | None = None
        if self._config.spatial_map_enabled:
            self._spatial_map = SpatialMap(self._config.spatial_map)

        self._bounds_learner: WorkspaceBoundsLearner | None = None
        if self._config.bounds_learning_enabled:
            self._bounds_learner = WorkspaceBoundsLearner(self._config.bounds)

    def _init_attention_subsystems(self) -> None:
        """Attention network, salience network, movement detector, salience map, gaze, scene context."""
        self._attention_network: AttentionNetwork | None = None
        if self._config.attention_network_enabled:
            self._attention_network = AttentionNetwork(self._config.attention)

        self._salience_network: SalienceNetwork | None = None
        if self._config.salience_network_enabled:
            self._salience_network = SalienceNetwork(self._config.salience)

        self._movement_detector: MovementDetector | None = None
        if self._config.movement_detection_enabled:
            self._movement_detector = MovementDetector(self._config.movement_detection)

        self._salience_map_unified: SalienceMap | None = None
        if self._config.salience_map_enabled:
            self._salience_map_unified = SalienceMap(self._config.salience_map_config)

        self._gaze_controller: GazeController | None = None
        if self._config.gaze_controller_enabled:
            self._gaze_controller = GazeController(
                salience_map=self._salience_map_unified,
                config=self._config.gaze_controller,
            )

        self._scene_context: SceneContextDetector | None = None
        if self._config.scene_context_enabled:
            self._scene_context = SceneContextDetector(self._config.scene_context)

    def _init_pain_circuit(self, nac: "NAc | None", pain_bus: "PainBus | None" = None) -> None:
        """PainBus + PainDetector + PainCircuitBridge wiring (skips if NAc absent or disabled).

        When ``pain_bus`` is provided, DN uses the injected bus rather
        than constructing its own. The injected bus should already have
        hippocampus + NAc subscribers wired via ``build_pain_bus`` — DN
        becomes a bus **consumer** (publishes pain signals, wires
        PainDetector + PainCircuitBridge to it) rather than a bus
        **constructor**. This closes the split-subscriber-ownership
        Gap B from ``docs/plans/pain_bus_unification.md``.

        When ``pain_bus`` is ``None``, DN constructs its own ``PainBus()``
        internally (backward compat for tests and pre-migration callers).
        """
        self._pain_bridge: PainCircuitBridge | None = None
        self._pain_bus = None
        if not (self._config.pain_detection_enabled and nac is not None):
            return

        # Use injected bus or construct our own (backward compat).
        if pain_bus is not None:
            self._pain_bus = pain_bus
        else:
            from maxim.proprioception.pain_bus import PainBus

            # C6: DefaultNetwork's raw PainBus construction is deferred to the
            # Wave-2 memory_hub_unification follow-on (split-subscriber-ownership
            # fix). For now, opt out of the deprecation warning explicitly so
            # the legitimate-but-temporary raw call site stays loud about why.
            # TODO(wave-2): route via build_pain_bus once DN owns/consumes a
            # MemoryHub; tracked in docs/plans/pain_bus_unification.md "Latent
            # bridge × subscriber attribution-asymmetry trap" + v1_refinement.md
            # §1.1-T4 C6 prerequisite.
            self._pain_bus = PainBus(_allow_raw=True)

        pain_config = PainConfig(
            angular_velocity_pain=self._config.pain_angular_velocity_threshold,
            translation_velocity_pain=self._config.pain_translation_velocity_threshold,
        )
        pain_detector = PainDetector(config=pain_config, pain_bus=self._pain_bus)
        self._pain_bridge = PainCircuitBridge(
            nac=nac,
            pain_detector=pain_detector,
            config=PainBridgeConfig(
                prediction_threshold=self._config.pain_prediction_threshold,
            ),
            pain_bus=self._pain_bus,
        )
        if self._bounds_learner is not None:
            self._pain_bridge.set_bounds_learner(self._bounds_learner)
        logger.info(
            "PainCircuitBridge initialized (pain_bus=%s, angular_velocity_threshold=%.1f)",
            "injected" if pain_bus is not None else "internal",
            self._config.pain_angular_velocity_threshold,
        )

    def _init_focus_learner(self, nac: "NAc | None") -> None:
        """Adaptive movement-correction learner (depends on NAc; skips if disabled)."""
        self._focus_learner: FocusLearner | None = None
        if not self._config.focus_learning_enabled:
            return
        self._focus_learner = FocusLearner(
            config=self._config.focus_learner,
            nac=nac,
            image_center=(320.0, 240.0),  # Standard frame center
        )
        logger.info(
            "FocusLearner initialized with initial_gain=%.2f",
            self._config.focus_learner.initial_gain,
        )

    def _maybe_subscribe_to_bus(self) -> None:
        """Auto-subscribe to ``Percept`` messages if a bus was provided."""
        if self._bus is None:
            return
        try:
            from maxim.agents.bus import Percept

            self._bus.subscribe(Percept, self.on_percept)
            self._subscribed_to_bus = True
            logger.debug("DefaultNetwork subscribed to Percept messages")
        except Exception as e:
            logger.warning("Failed to subscribe to bus: %s", e)

    @property
    def novelty_tracker(self) -> ThreadSafeNoveltyTracker:
        """Get the novelty tracker (for sharing with other components)."""
        return self._novelty_tracker

    @property
    def gate(self) -> ThalamicGate:
        """Get the thalamic gate for configuration."""
        return self._gate

    @property
    def arbiter(self) -> PriorityArbiter:
        """Get the arbiter for configuration."""
        return self._arbiter

    @property
    def gaze_history(self) -> GazeHistory:
        """Get the gaze history for monitoring/configuration."""
        return self._gaze_history

    @property
    def spatial_map(self) -> SpatialMap | None:
        """Get the spatial map for monitoring (may be None if disabled)."""
        return self._spatial_map

    @property
    def attention_network(self) -> AttentionNetwork | None:
        """Get the AttentionNetwork for queries (may be None if disabled)."""
        return self._attention_network

    @property
    def salience_network(self) -> SalienceNetwork | None:
        """Get the SalienceNetwork for queries (may be None if disabled)."""
        return self._salience_network

    @property
    def movement_detector(self) -> MovementDetector | None:
        """Get the MovementDetector for motion tracking (may be None if disabled)."""
        return self._movement_detector

    @property
    def salience_map_unified(self) -> SalienceMap | None:
        """Get the unified SalienceMap for gaze decisions (may be None if disabled)."""
        return self._salience_map_unified

    @property
    def gaze_controller(self) -> GazeController | None:
        """Get the GazeController for saccade-fixate dynamics (may be None if disabled)."""
        return self._gaze_controller

    @property
    def scene_context(self) -> SceneContextDetector | None:
        """Get the SceneContextDetector (may be None if disabled)."""
        return self._scene_context

    @property
    def pain_bridge(self) -> PainCircuitBridge | None:
        """Get the PainCircuitBridge for pain detection/learning (may be None if disabled)."""
        return self._pain_bridge

    @property
    def pain_bus(self) -> Any:
        """Get the PainBus for centralized pain pub/sub (may be None if pain disabled)."""
        return self._pain_bus

    @property
    def focus_learner(self) -> FocusLearner | None:
        """Get the FocusLearner for adaptive movement correction (may be None if disabled)."""
        return self._focus_learner

    @property
    def is_running(self) -> bool:
        """Whether the DN processing loop is active."""
        return self._running

    @property
    def is_inhibited(self) -> bool:
        """Whether DN output is currently suppressed."""
        return self._inhibited

    def imagination_allowed(self) -> bool:
        """Whether the imagination system may fire right now.

        Returns True only during low-arousal states — you don't daydream
        while fighting. Checks:
        1. DN is not inhibited (deliberative layer hasn't suppressed us).
        2. Enough idle time has elapsed since last interesting event
           (proxy for "no active reactive behavior" — behaviors update
           ``_last_interesting_time`` when they fire).

        When DN is not running (e.g., sim mode without a robot), returns
        True — imagination is allowed by default when there's no arousal
        signal to gate on.
        """
        if not self._running:
            return True
        if self._inhibited:
            return False
        # Low arousal = sufficient idle time since last interesting event
        idle_duration = time.time() - self._last_interesting_time
        min_idle = self._config.idle_exploration_min_seconds
        return idle_duration >= min_idle

    def start(self) -> None:
        """Start the default network processing loop."""
        if self._running:
            logger.warning("DefaultNetwork already running")
            return

        if not self._config.enabled:
            logger.info("DefaultNetwork disabled in config")
            return

        self._running = True
        self._thread = threading.Thread(
            target=self._run_loop,
            name="DefaultNetwork",
            daemon=True,
        )
        self._thread.start()

        # Start background maintenance tasks
        self._background_tasks.start()

        # Initial sync with hardware position to start with accurate tracking
        self._last_position_sync = 0.0  # Force immediate sync on first tick
        sync_fn = getattr(self._maxim, "sync_head_position", None)
        if sync_fn is not None and callable(sync_fn):
            try:
                if sync_fn():
                    logger.info(
                        "Initial position sync: yaw=%.1f°, pitch=%.1f°",
                        getattr(self._maxim, "yaw", 0),
                        getattr(self._maxim, "pitch", 0),
                    )
            except Exception as e:
                logger.debug("Initial position sync failed: %s", e)

        logger.info("DefaultNetwork started (%.1f Hz)", self._config.update_hz)

    def stop(self) -> None:
        """Stop the default network."""
        self._running = False

        # Stop background tasks first
        self._background_tasks.stop()

        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=1.0)

        # Unsubscribe from bus
        if self._subscribed_to_bus and self._bus is not None:
            try:
                from maxim.agents.bus import Percept

                self._bus.unsubscribe(Percept, self.on_percept)
                self._subscribed_to_bus = False
            except Exception:
                pass

        logger.info("DefaultNetwork stopped")

    # inhibit, release, boost_behavior, clear_behavior_overrides,
    # set_interests, set_active_goal  →  InhibitionMixin

    def on_percept(self, percept: "Percept") -> None:
        """Handler for incoming percepts from AgentBus.

        This method should be called when a new Percept is available.
        Thread-safe - can be called from any thread.

        Args:
            percept: The incoming percept.
        """
        with self._detection_lock:
            self._latest_percept = percept
            # Extract detections if available
            detections = getattr(percept, "detections", [])
            self._latest_detections = list(detections)

        # Track novelty for named entities in text percepts (non-vision)
        # This enables novelty/habituation for DM campaigns and SEM entities
        sensory = getattr(percept, "sensory", None)
        if sensory is not None and self._novelty_tracker is not None:
            entity_source = getattr(sensory, "entity_source", "")
            if entity_source:
                modality = getattr(sensory, "modality", None)
                entity_type = modality.value if modality else "unknown"
                self._novelty_tracker.update_with_entity(entity_source, entity_type)
        elif self._novelty_tracker is not None:
            # Fallback: check percept metadata for entity hints
            metadata = getattr(percept, "metadata", None) or {}
            entity_source = metadata.get("entity_source", "")
            if entity_source:
                entity_type = metadata.get("entity_type", "unknown")
                self._novelty_tracker.update_with_entity(entity_source, entity_type)

        with self._stats_lock:
            self._stats["percepts_received"] += 1

    def on_detections(self, detections: list[dict]) -> None:
        """Handler for raw YOLO detections.

        Alternative to on_percept() for direct detection stream.

        Args:
            detections: List of detection dicts.
        """
        with self._detection_lock:
            self._latest_detections = list(detections)

    def add_action_callback(
        self,
        callback: Callable[[ActionProposal], None],
    ) -> None:
        """Add callback to be notified of executed actions.

        Args:
            callback: Function called with ActionProposal when action executes.
        """
        self._on_action_callbacks.append(callback)

    def add_escalation_callback(
        self,
        callback: Callable[[FilteredPercept], None],
    ) -> None:
        """Add callback to be notified of escalated percepts.

        Args:
            callback: Function called with FilteredPercept when escalating.
        """
        self._on_escalation_callbacks.append(callback)

    def get_stats(self) -> dict[str, Any]:
        """Get network statistics including sub-network stats."""
        with self._stats_lock:
            stats = dict(self._stats)

        # Include spatial map stats if available
        if self._spatial_map is not None:
            spatial_stats = self._spatial_map.get_stats()
            stats["spatial_map"] = spatial_stats

        # Include attention network stats
        if self._attention_network is not None:
            stats["attention_network"] = self._attention_network.get_stats()

        # Include salience network stats
        if self._salience_network is not None:
            stats["salience_network"] = self._salience_network.get_stats()

        # Include unified salience map stats
        if self._salience_map_unified is not None:
            stats["salience_map"] = self._salience_map_unified.get_stats()

        # Include movement detector stats
        if self._movement_detector is not None:
            stats["movement_detector"] = self._movement_detector.get_stats()

        # Include gaze controller stats
        if self._gaze_controller is not None:
            stats["gaze_controller"] = self._gaze_controller.get_stats()

        # Include scene context stats
        if self._scene_context is not None:
            stats["scene_context"] = self._scene_context.get_stats()

        return stats

    def to_context_str(self, max_attention_items: int = 5, max_salience_items: int = 5) -> str:
        """Generate combined context string for LLM consumption.

        Provides a concise summary of both attention and salience states
        that can be efficiently injected into LLM prompts.

        Args:
            max_attention_items: Max attention positions to include.
            max_salience_items: Max salient objects to include.

        Returns:
            Human-readable context string combining attention and salience.
        """
        lines = []

        # Attention state
        if self._attention_network is not None:
            lines.append(self._attention_network.to_context_str(max_items=max_attention_items))
        else:
            # Fallback to basic gaze history info
            lines.append("[ATTENTION STATE]")
            current = self._gaze_history.get_current_position()
            if current:
                lines.append(f"Focus: ({current[0]:.0f}, {current[1]:.0f})")
            else:
                lines.append("Focus: None")

        lines.append("")  # Blank line separator

        # Salience state
        if self._salience_network is not None:
            lines.append(self._salience_network.to_context_str(max_items=max_salience_items))
        else:
            lines.append("[SALIENCE STATE]")
            lines.append("Salience tracking not enabled")

        lines.append("")  # Blank line separator

        # Unified salience map state
        if self._salience_map_unified is not None:
            lines.append(self._salience_map_unified.to_context_str(max_targets=3))

        # Gaze controller state
        if self._gaze_controller is not None:
            lines.append("")
            lines.append("[GAZE DYNAMICS]")
            gc_stats = self._gaze_controller.get_stats()
            lines.append(f"State: {gc_stats.get('state', 'unknown')}")
            if gc_stats.get("current_target"):
                t = gc_stats["current_target"]
                lines.append(f"Target: ({t[0]:.0f}, {t[1]:.0f})")
            if gc_stats.get("state") == "fixating":
                lines.append(
                    f"Fixation: {gc_stats.get('fixation_elapsed_ms', 0):.0f}ms / {gc_stats.get('fixation_duration_ms', 0):.0f}ms"
                )

        # Scene context state
        if self._scene_context is not None:
            lines.append("")
            lines.append("[SCENE CONTEXT]")
            sc_stats = self._scene_context.get_stats()
            if sc_stats.get("is_scanning"):
                lines.append("Status: Scanning (scene recently changed)")
            else:
                age = sc_stats.get("scene_age_seconds", float("inf"))
                if age < 60:
                    lines.append(f"Status: Stable ({age:.0f}s since last change)")
                else:
                    lines.append("Status: Stable")

        # Add exploration hints
        lines.append("")
        lines.append("[EXPLORATION]")
        current = self._gaze_history.get_current_position()
        if current:
            opposite = compute_opposite_position(
                current,
                self._config.movement.image_width,
                self._config.movement.image_height,
            )
            lines.append(f"Current: ({current[0]:.0f}, {current[1]:.0f})")
            lines.append(f"Opposite side: ({opposite[0]:.0f}, {opposite[1]:.0f})")
            lines.append("Tip: Use look_opposite() to see the other side of the view")
        else:
            lines.append("No current focus position")

        return "\n".join(lines)

    # look_opposite, get_opposite_position, get_salience_target,
    # get_salience_info, get_gaze_command, is_scene_scanning,
    # get_scene_age, force_scene_scan, _schedule_next_exploration
    #   →  GazeManagerMixin

    def _update_head_position_for_behaviors(self) -> None:
        """Update behaviors that need current head position.

        This enables ReturnToCenter to know when the head is at extreme
        positions and needs to drift back toward center. Without this,
        the robot can slowly drift to one side and get stuck at the limit.

        Also periodically syncs position from hardware to prevent drift
        between software tracking and actual head position.
        """
        # Periodically sync with hardware position (every 3 seconds)
        # This prevents accumulated drift between software tracking and reality
        now = time.time()
        sync_interval = 3.0  # seconds
        if not hasattr(self, "_last_position_sync"):
            self._last_position_sync = 0.0

        if (now - self._last_position_sync) >= sync_interval:
            sync_fn = getattr(self._maxim, "sync_head_position", None)
            if sync_fn is not None and callable(sync_fn):
                try:
                    synced = sync_fn()
                    if synced:
                        logger.debug(
                            "Periodic position sync: yaw=%.1f°, pitch=%.1f°",
                            getattr(self._maxim, "yaw", 0),
                            getattr(self._maxim, "pitch", 0),
                        )
                except Exception as e:
                    logger.debug("Periodic position sync failed: %s", e)
            self._last_position_sync = now

        # Get current head position from maxim
        yaw = getattr(self._maxim, "yaw", None)
        pitch = getattr(self._maxim, "pitch", None)

        if yaw is None or pitch is None:
            return

        try:
            yaw = float(yaw)
            pitch = float(pitch)
        except (TypeError, ValueError):
            return

        # Get full 6D position if available
        x = getattr(self._maxim, "x", 0.0) or 0.0
        y_trans = getattr(self._maxim, "y", 0.0) or 0.0
        z = getattr(self._maxim, "z", 0.0) or 0.0
        roll = getattr(self._maxim, "roll", 0.0) or 0.0

        try:
            x = float(x)
            y_trans = float(y_trans)
            z = float(z)
            roll = float(roll)
        except (TypeError, ValueError):
            x, y_trans, z, roll = 0.0, 0.0, 0.0, 0.0

        # Update behaviors that need current head position
        for behavior in self._behaviors:
            if isinstance(behavior, ReturnToCenter):
                try:
                    # Set full 6D position for translation-first approach
                    behavior.set_head_position_6d(x, y_trans, z, roll, pitch, yaw)
                except Exception as e:
                    logger.debug("Failed to update ReturnToCenter position: %s", e)
            elif isinstance(behavior, TurnAround):
                try:
                    behavior.set_head_yaw(yaw)
                    behavior.set_interests(self._interests)
                except Exception as e:
                    logger.debug("Failed to update TurnAround position: %s", e)

    # _has_interesting_content, _generate_exploration_target  →  GazeManagerMixin

    def _run_loop(self) -> None:
        """Main processing loop."""
        while self._running:
            start = time.perf_counter()

            # Check auto-release of inhibition (InhibitionMixin)
            self._check_auto_release()

            if not self._inhibited:
                self._process_tick()

            # Maintain target update rate
            elapsed = time.perf_counter() - start
            sleep_time = self._update_interval - elapsed
            if sleep_time > 0:
                time.sleep(sleep_time)

            with self._stats_lock:
                self._stats["ticks"] += 1

    def _process_tick(self) -> None:
        """Single processing cycle."""
        with self._detection_lock:
            detections = list(self._latest_detections)
            percept = self._latest_percept

        now = time.time()

        # Idle exploration: short-circuit if nothing interesting for a while
        if self._maybe_run_idle_exploration(detections, now):
            return

        if not detections:
            return

        # Update perception subsystems (novelty / spatial / salience / movement / scene)
        self._update_perception_subsystems(detections, now)

        # Update head-position behaviors (drift correction)
        self._update_head_position_for_behaviors()

        # Behavior evaluation + arbitration
        winner = self._evaluate_behaviors(detections, now)
        if winner is not None:
            with self._stats_lock:
                self._stats["actions_proposed"] += 1
            self._execute_action(winner)

        # Percept escalation runs independently of behavior execution
        if percept:
            self._evaluate_escalation(percept)

    def _maybe_run_idle_exploration(self, detections: list[dict], now: float) -> bool:
        """Trigger an idle-exploration ``look_at`` if nothing interesting recently.

        Returns True if an exploration was executed (caller should skip the
        normal behavior pipeline for this tick).
        """
        has_interesting = self._has_interesting_content(detections) if detections else False
        if has_interesting:
            self._last_interesting_time = now
            self._next_exploration_time = self._schedule_next_exploration()

        if not (self._config.idle_exploration_enabled and not has_interesting and now >= self._next_exploration_time):
            return False

        target = self._generate_exploration_target()
        exploration_proposal = ActionProposal(
            behavior_name="idle_exploration",
            action_type="look_at",
            target=target,
            priority=0.3,  # Low priority - can be overridden by behaviors
            confidence=0.5,
            metadata={"reason": "idle_exploration"},
        )
        logger.debug(
            "Idle exploration triggered after %.1fs of no interesting content -> %s",
            now - self._last_interesting_time,
            target,
        )
        with self._stats_lock:
            self._stats["idle_explorations"] += 1
        self._execute_action(exploration_proposal)
        self._next_exploration_time = self._schedule_next_exploration()
        return True

    def _update_perception_subsystems(self, detections: list[dict], now: float) -> None:
        """Throttled feed-forward into novelty / spatial / salience / movement / scene."""
        # 4a. Novelty tracker (10Hz)
        if self._throttlers["novelty"].should_run():
            for d in detections:
                track_id = d.get("track_id")
                class_id = d.get("class_id")
                if track_id is not None and class_id is not None:
                    self._novelty_tracker.update_with_class(track_id, class_id)
                elif track_id is not None:
                    self._novelty_tracker.update(track_id)

        # 4b. Spatial map observations (5Hz)
        if self._spatial_map is not None and self._throttlers["spatial_map"].should_run():
            current_pos = self._gaze_history.get_current_position() or (320.0, 240.0)
            novelty_scores = {}
            for det in detections:
                track_id = det.get("track_id")
                if track_id is not None:
                    novelty_scores[track_id] = self._novelty_tracker.get_novelty(
                        track_id,
                        class_id=det.get("class_id"),
                    )
            self._spatial_map.record_observation(
                position=current_pos,
                detections=detections,
                novelty_scores=novelty_scores,
            )

        # 4c. SalienceNetwork update (10Hz)
        if self._salience_network is not None and self._throttlers["salience"].should_run():
            self._salience_network.update_from_detections(detections, timestamp=now)

        # 4d. Movement scores (every frame for responsiveness)
        if self._movement_detector is not None:
            current_gaze = self._gaze_history.get_current_position()
            frame_center = current_gaze or (320.0, 240.0)
            movement_scores = self._movement_detector.update(detections, frame_center)
            for det in detections:
                track_id = det.get("track_id")
                if track_id is not None:
                    det["movement_score"] = movement_scores.get(track_id, 0.0)

        # 4e. Unified salience map (10Hz)
        if self._salience_map_unified is not None and self._throttlers["salience"].should_run():
            self._salience_map_unified.update(
                detections=detections,
                gaze_history=self._gaze_history,
                novelty_tracker=self._novelty_tracker,
                movement_detector=self._movement_detector,
                current_time=now,
            )

        # 4f. Scene-change detection
        if self._scene_context is not None:
            self._update_scene_context(detections, now)

    def _update_scene_context(self, detections: list[dict], now: float) -> None:
        """Run the SceneContextDetector and reset idle/gaze state on changes."""
        head_yaw = getattr(self._maxim, "yaw", None)
        head_pitch = getattr(self._maxim, "pitch", None)
        try:
            head_yaw = float(head_yaw) if head_yaw is not None else None
            head_pitch = float(head_pitch) if head_pitch is not None else None
        except (TypeError, ValueError):
            head_yaw, head_pitch = None, None

        scene_changed = self._scene_context.update(
            detections=detections,
            head_yaw=head_yaw,
            head_pitch=head_pitch,
        )
        if scene_changed:
            logger.info("Scene change detected - triggering exploration")
            self._last_interesting_time = 0.0
            self._next_exploration_time = now
            if self._gaze_controller is not None:
                self._gaze_controller.note_activity()

    def _evaluate_behaviors(self, detections: list[dict], now: float) -> ActionProposal | None:
        """Run all enabled behaviors against ``detections`` and arbitrate the winner."""
        state = BehaviorState(
            inhibited_behaviors=frozenset(name for name, mod in self._behavior_overrides.items() if mod <= 0),
            priority_modifiers=self._behavior_overrides,
            interests=self._interests,
            frame_timestamp=now,
            salience_map=self._salience_map_unified,
            focus_learner=self._focus_learner,
        )

        proposals: list[ActionProposal] = []
        if self._throttlers["behaviors"].should_run():
            for behavior in self._behaviors:
                if not behavior.enabled or behavior.name in state.inhibited_behaviors:
                    continue
                try:
                    proposal = behavior.evaluate(detections, state)
                    if proposal:
                        modifier = self._behavior_overrides.get(behavior.name, 1.0)
                        if modifier != 1.0:
                            proposal = ActionProposal(
                                behavior_name=proposal.behavior_name,
                                action_type=proposal.action_type,
                                target=proposal.target,
                                priority=proposal.priority * modifier,
                                confidence=proposal.confidence,
                                metadata=proposal.metadata,
                            )
                        proposals.append(proposal)
                except Exception as e:
                    logger.warning("Behavior '%s' failed: %s", behavior.name, e)

        if not proposals:
            return None

        if logger.isEnabledFor(logging.DEBUG):
            proposal_summary = [(p.behavior_name, p.priority, p.confidence, p.target) for p in proposals]
            logger.debug("Behavior proposals: %s", proposal_summary)

        winner = self._arbiter.select(proposals)
        if winner is not None:
            logger.debug(
                "Arbiter selected: %s (priority=%.2f, conf=%.2f) -> %s",
                winner.behavior_name,
                winner.priority,
                winner.confidence,
                winner.target,
            )
        return winner

    def _execute_action(self, proposal: ActionProposal) -> None:
        """Execute the winning action proposal.

        Args:
            proposal: The action to execute.
        """
        # Three gates: fear, salience (inhibition-of-return), reachability.
        # Each can short-circuit by publishing the proposal as ``executed=False``.
        if not self._gate_action_through_fear(proposal):
            return
        if not self._gate_action_through_salience(proposal):
            return
        if not self._gate_action_through_reachability(proposal):
            return

        # Compute dynamic duration based on distance (+ random jitter)
        current_pos = self._gaze_history.get_current_position()
        if proposal.target:
            duration = compute_dynamic_duration(
                current_pos,
                proposal.target,
                self._config.movement,
                add_jitter=True,
            )
        else:
            duration = self._config.movement.base_duration

        executed, movement_failed = self._dispatch_action_to_motor(proposal, duration)
        self._record_movement_result(proposal, executed, movement_failed)

        if executed:
            with self._stats_lock:
                self._stats["actions_executed"] += 1
            track_id = proposal.metadata.get("track_id")
            if track_id is not None and self._novelty_tracker is not None:
                self._novelty_tracker.focus(track_id)
            if self._config.gaze_salience_enabled and proposal.target:
                self._gaze_history.record_gaze(proposal.target)

        self._publish_action(proposal, executed=executed)

    # ── _execute_action gates ──────────────────────────────────────────────

    def _gate_action_through_fear(self, proposal: ActionProposal) -> bool:
        """Return True if the action passes the FearAgent gate (or gate disabled)."""
        if not (self._config.fear_gate_enabled and self._fear_agent):
            return True
        try:
            action_sig = ""
            if proposal.target:
                current = self._gaze_history.get_current_position()
                if current:
                    du = abs(proposal.target[0] - current[0])
                    dv = abs(proposal.target[1] - current[1])
                    # Rough conversion: pixels to degrees (~0.1 deg/pixel)
                    dyaw = du * 0.1
                    dpitch = dv * 0.1
                    action_sig = f"look_at:dy={dyaw:.0f}:dp={dpitch:.0f}"

            review = self._fear_agent.review_action(
                action_type="dn_movement",
                action_params={
                    "behavior": proposal.behavior_name,
                    "target": proposal.target,
                    "priority": proposal.priority,
                    "action_signature": action_sig,
                },
                agent_id="default_network",
                pain_bridge=self._pain_bridge,
            )
            if not review.allow:
                logger.debug(
                    "FearAgent blocked DN action: %s (risk=%s)",
                    proposal.behavior_name,
                    getattr(review, "risk_level", "unknown"),
                )
                with self._stats_lock:
                    self._stats["actions_blocked"] += 1
                self._publish_action(proposal, executed=False)
                return False
        except Exception as e:
            logger.warning("FearAgent check failed: %s", e)
        return True

    def _gate_action_through_salience(self, proposal: ActionProposal) -> bool:
        """Return True if the proposal passes the inhibition-of-return gate."""
        if not (self._config.gaze_salience_enabled and proposal.target):
            return True
        should_move, salience, reason = self._gaze_history.should_move(proposal.target)
        if should_move:
            return True
        logger.debug(
            "Salience blocked DN action: %s -> %s (salience=%.2f, reason=%s)",
            proposal.behavior_name,
            proposal.target,
            salience,
            reason,
        )
        with self._stats_lock:
            self._stats["actions_salience_blocked"] += 1
        self._publish_action(proposal, executed=False)
        return False

    def _gate_action_through_reachability(self, proposal: ActionProposal) -> bool:
        """Return True if the target is reachable enough (or check disabled)."""
        if not (self._config.reachability_check_enabled and proposal.target):
            return True

        reachability = 1.0
        if self._attention_network is not None:
            reachability = self._attention_network.get_reachability(proposal.target)
        elif self._spatial_map is not None:
            reachability = self._spatial_map.get_reachability(proposal.target)

        if reachability >= self._config.min_reachability_threshold:
            return True

        logger.debug(
            "Reachability blocked DN action: %s -> %s (reach=%.2f < %.2f)",
            proposal.behavior_name,
            proposal.target,
            reachability,
            self._config.min_reachability_threshold,
        )
        with self._stats_lock:
            self._stats["actions_reachability_blocked"] += 1
        self._publish_action(proposal, executed=False)
        return False

    # ── _execute_action dispatch ───────────────────────────────────────────

    def _dispatch_action_to_motor(
        self,
        proposal: ActionProposal,
        duration: float,
    ) -> tuple[bool, bool]:
        """Dispatch the proposal to the appropriate Maxim motor command.

        Returns ``(executed, movement_failed)`` — ``movement_failed`` is True
        only for IK/collision errors (so the spatial map can learn from them).
        """
        executed = False
        movement_failed = False

        try:
            if proposal.action_type == "look_at" and proposal.target:
                u, v = proposal.target
                if hasattr(self._maxim, "look_at_image"):
                    self._maxim.look_at_image(u, v, duration=duration)
                    executed = True
            elif proposal.action_type == "scan":
                # Exploration: prefer look_at_image (smooth waypoint motion),
                # fall back to move_relative for ReturnToCenter etc.
                if proposal.target and hasattr(self._maxim, "look_at_image"):
                    u, v = proposal.target
                    explore_duration = max(0.5, duration * 0.8)
                    self._maxim.look_at_image(u, v, duration=explore_duration)
                    executed = True
                elif hasattr(self._maxim, "move_relative"):
                    delta = proposal.metadata.get("delta", (0, 0))
                    self._maxim.move_relative(delta)
                    executed = True
            elif proposal.action_type == "track" and proposal.target:
                u, v = proposal.target
                if hasattr(self._maxim, "look_at_image"):
                    track_duration = max(0.1, duration * 0.5)  # Half duration for tracking
                    if proposal.behavior_name == "social":
                        cur_yaw = float(getattr(self._maxim, "yaw", 0.0) or 0.0)
                        direction = "RIGHT" if u > 320 else "LEFT" if u < 320 else "CENTER"
                        logger.debug(
                            "SocialTrack: target=(%.0f,%.0f) %s, yaw=%.1f, track_id=%s",
                            u,
                            v,
                            direction,
                            cur_yaw,
                            proposal.metadata.get("track_id"),
                        )
                    self._maxim.look_at_image(u, v, duration=track_duration)
                    executed = True
            elif proposal.action_type == "turn_around":
                turn_angle = proposal.metadata.get("turn_angle", 90.0)
                turn_duration = proposal.metadata.get("duration", 5.0)
                if hasattr(self._maxim, "turn_around"):
                    self._maxim.turn_around(turn_angle, duration=turn_duration)
                    executed = True
                    logger.info("Executing turn_around: %.0f° over %.1fs", turn_angle, turn_duration)
        except Exception as e:
            error_msg = str(e).lower()
            if "collision" in error_msg or "ik" in error_msg or "not achievable" in error_msg:
                movement_failed = True
                logger.debug("Movement to %s failed (IK/collision): %s", proposal.target, e)
            else:
                logger.warning("Failed to execute DN action: %s", e)

        return executed, movement_failed

    def _record_movement_result(
        self,
        proposal: ActionProposal,
        executed: bool,
        movement_failed: bool,
    ) -> None:
        """Update spatial map + attention network with the movement outcome."""
        if not proposal.target:
            return

        if self._spatial_map is not None:
            if executed:
                self._spatial_map.record_movement(proposal.target, success=True)
            elif movement_failed:
                self._spatial_map.record_movement(proposal.target, success=False)

        if self._attention_network is not None:
            if executed:
                self._attention_network.record_gaze(proposal.target, success=True)
            elif movement_failed:
                self._attention_network.record_gaze(proposal.target, success=False)

    def _publish_action(self, proposal: ActionProposal, executed: bool) -> None:
        """Publish action to bus and notify callbacks.

        Args:
            proposal: The action proposal.
            executed: Whether it was actually executed.
        """
        # Notify callbacks
        for callback in self._on_action_callbacks:
            try:
                callback(proposal)
            except Exception as e:
                logger.warning("Action callback failed: %s", e)

        # Publish to bus
        if self._config.publish_actions and self._bus:
            action_msg = DNActionProposal(
                behavior=proposal.behavior_name,
                action_type=proposal.action_type,
                target=proposal.target,
                timestamp=time.time(),
                confidence=proposal.confidence,
                was_executed=executed,
            )
            try:
                self._bus.publish(action_msg)
            except Exception as e:
                logger.debug("Failed to publish DNActionProposal: %s", e)

    def _evaluate_escalation(self, percept: "Percept") -> None:
        """Evaluate whether percept should be escalated.

        Args:
            percept: The percept to evaluate.
        """
        result = self._gate.evaluate(percept)

        if result.escalated:
            with self._stats_lock:
                self._stats["percepts_escalated"] += 1

            filtered = self._gate.create_filtered_percept(percept, result)

            # Notify callbacks
            for callback in self._on_escalation_callbacks:
                try:
                    callback(filtered)
                except Exception as e:
                    logger.warning("Escalation callback failed: %s", e)

            # Publish to bus
            if self._bus:
                try:
                    self._bus.publish(filtered)
                except Exception as e:
                    logger.debug("Failed to publish FilteredPercept: %s", e)
        else:
            with self._stats_lock:
                self._stats["percepts_filtered"] += 1

        # Periodically adapt thresholds
        self._gate.adapt_thresholds()


__all__ = [
    "DefaultNetwork",
    "DefaultNetworkConfig",
    # Re-exported for convenience
    "GazeCommand",
    "GazeState",
]
