"""Detection assembly — builds DetectionEvent records, writes audio snippets,
and persists detections, pings, and track updates to storage.

Extracted from FusionNode to isolate the detection-building logic as an
independently testable component.
"""

from __future__ import annotations

import asyncio
import math
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from minimappr.core.geo import LocalCoordinateFrame
from minimappr.core.node_registry import NodeRegistry
from minimappr.core.retention import RetentionPolicy
from minimappr.core.zones import ZoneMatcher
from minimappr.interfaces import StorageBackend
from minimappr.models import (
    DetectionEvent,
    LabelId,
    RetentionTier,
    TrackState,
)
from minimappr.utils.audio import rms, write_wav_mono


@dataclass(slots=True)
class AssemblyResult:
    """Result of assembling and persisting a detection."""

    detection: DetectionEvent
    track: TrackState | None
    suppressed_by_zone: bool
    suppression_reasons: list[str]


class DetectionAssembler:
    """Assembles DetectionEvent records from classification output.

    Responsibilities:
      - Zone matching and suppression evaluation
      - Track association (delegates to TrackManager)
      - SPL computation with per-node gain offset
      - Retention tier determination (delegates to RetentionPolicy)
      - Feature summary composition
      - Audio snippet writing
      - Storage persistence (detection, ping, track, track_update)
    """

    def __init__(
        self,
        *,
        storage: StorageBackend,
        coordinate_frame: LocalCoordinateFrame,
        zone_matcher: ZoneMatcher,
        registry: NodeRegistry,
        retention_policy: RetentionPolicy,
        snippet_dir: Path | str,
        snippet_retention_seconds: float,
        event_stale_seconds: float,
    ) -> None:
        self._storage = storage
        self._coordinate_frame = coordinate_frame
        self._zone_matcher = zone_matcher
        self._registry = registry
        self._retention_policy = retention_policy
        self._snippet_dir = Path(snippet_dir)
        self._snippet_retention_seconds = snippet_retention_seconds
        self._event_stale_seconds = event_stale_seconds

    async def assemble(
        self,
        *,
        # Localization data
        localization_position_m: tuple[float, float, float],
        localization_confidence: float,
        localization_gdop: float,
        reference_sensor: str,
        tdoa_s: dict[str, float],
        selected_sensor_ids: list[str],
        reference_signal,
        capability_tier: str,
        localization_method: str,
        environment: dict[str, Any],
        # Classification data
        classification_label: str,
        classification_confidence: float,
        classification_scores: dict[str, float],
        classification_features: dict[str, Any],
        classification_path: str,
        omni_confidence: float,
        beamformed_classification_confidence: float | None,
        beamformed_classification_label: str | None,
        beamforming_error: str | None,
        classification_signal,
        label_category: str,
        iff_category: str,
        label_id: LabelId | None,
        # Event metadata
        event_time_ns: int,
        source_type: str,
        time_quality,
        source_observation_ids: list[str],
        sample_rate_hz: int,
        # Tracker
        tracker,
        # Storage batch context
        storage_batch_ctx,
    ) -> AssemblyResult:
        """Build a DetectionEvent, persist it, and return the result."""
        detection_geo = self._coordinate_frame.local_to_geo(localization_position_m)

        # -- zone matching -----------------------------------------------------
        zone_ids = await self._zone_matcher.match_geo_point(
            lat=detection_geo.lat,
            lon=detection_geo.lon,
            now_ns=event_time_ns,
        )
        suppressed_by_zone, suppression_reasons = await self._zone_matcher.evaluate_detection_policies(
            zone_ids=zone_ids,
            label=classification_label,
            label_category=label_category,
            now_ns=event_time_ns,
        )

        # -- track update ------------------------------------------------------
        track: TrackState | None = None
        if not suppressed_by_zone and capability_tier != "alerting_only":
            track = await tracker.update(
                timestamp_ns=event_time_ns,
                position_m=localization_position_m,
                label=classification_label,
                label_category=label_category,
                iff_category=iff_category,
                label_id=label_id,
                confidence=classification_confidence,
                sensor_count=len(selected_sensor_ids),
                capability_tier=capability_tier,
            )
            track.position_geo = self._coordinate_frame.local_to_geo(track.position_m)

        # -- observation ID resolution ----------------------------------------
        latest_observation_ids = await self._registry.latest_observation_ids(selected_sensor_ids)
        all_observation_ids = list(
            dict.fromkeys([*source_observation_ids, *latest_observation_ids])
        )
        source_node_id = await self._registry.node_id_for_sensor(reference_sensor)

        # -- SPL ---------------------------------------------------------------
        gain_offset_db = await self._registry.gain_offset_db_for_sensor(reference_sensor)
        ref_rms = max(rms(reference_signal), 1e-9)
        spl_db = float(20.0 * math.log10(ref_rms)) + gain_offset_db

        # -- retention ---------------------------------------------------------
        retention_tier = self._retention_policy.tier_for_detection(
            label=classification_label,
            label_category=label_category,
            confidence=classification_confidence,
            suppressed_by_zone=suppressed_by_zone,
        )

        # -- feature summary ---------------------------------------------------
        feature_summary = dict(classification_features)
        feature_summary["capability_tier"] = capability_tier
        feature_summary["localization_method"] = localization_method
        feature_summary["classification_path"] = classification_path
        feature_summary["omni_confidence"] = omni_confidence
        feature_summary["environment"] = environment
        if beamformed_classification_confidence is not None:
            feature_summary["beamformed_confidence"] = beamformed_classification_confidence
            feature_summary["beamformed_label"] = beamformed_classification_label
        if beamforming_error:
            feature_summary["beamforming_error"] = beamforming_error
        if suppression_reasons:
            feature_summary["zone_suppression"] = suppression_reasons

        # -- build DetectionEvent ----------------------------------------------
        tor_ns = time.time_ns()
        stale_ns = event_time_ns + int(self._event_stale_seconds * 1_000_000_000)

        detection = DetectionEvent(
            id=f"det-{uuid.uuid4().hex[:12]}",
            source_type=source_type,
            source_node_id=source_node_id,
            timestamp_ns=event_time_ns,
            toa_ns=event_time_ns,
            tor_ns=tor_ns,
            time_quality=time_quality,
            stale_ns=stale_ns,
            position_m=localization_position_m,
            position_geo=detection_geo,
            position_covariance_m2=track.position_covariance_m2 if track is not None else None,
            confidence=localization_confidence,
            gdop=localization_gdop,
            label_id=label_id,
            label=classification_label,
            label_category=label_category,
            iff_category=iff_category,
            label_confidence=classification_confidence,
            spl_db=spl_db,
            track_id=track.id if track is not None and not suppressed_by_zone else None,
            source_sensors=sorted(selected_sensor_ids),
            source_observation_ids=all_observation_ids,
            zone_ids=zone_ids,
            reference_sensor=reference_sensor,
            tdoa_s=tdoa_s,
            classifier_scores=classification_scores,
            feature_summary=feature_summary,
            retention_tier=RetentionTier(retention_tier),
            snippet_path=None,
        )

        # -- snippet writing ---------------------------------------------------
        snippet_path: str | None = None
        snippet_expires_ns: int | None = None
        if self._snippet_retention_seconds > 0 and retention_tier not in {"ephemeral", "experiment"}:
            snippet_file = self._snippet_dir / f"{detection.id}.wav"
            await asyncio.to_thread(
                write_wav_mono, snippet_file, classification_signal, sample_rate_hz
            )
            snippet_path = str(snippet_file)
            snippet_expires_ns = event_time_ns + int(
                self._snippet_retention_seconds * 1_000_000_000
            )
            detection.snippet_path = snippet_path

        # -- storage persistence -----------------------------------------------
        async with storage_batch_ctx():
            if track is not None and not suppressed_by_zone:
                await self._storage.upsert_track(track)

            await self._storage.insert_detection(
                detection=detection,
                snippet_path=snippet_path,
                snippet_expires_ns=snippet_expires_ns,
                retention_tier=retention_tier,
            )
            await self._storage.insert_ping(
                timestamp_ns=detection.timestamp_ns,
                ping_type="acoustic",
                label=detection.label,
                label_id=detection.label_id,
                spl_db=detection.spl_db,
                position_m=detection.position_m,
                position_geo=detection.position_geo,
                source_detection_id=detection.id,
                source_observation_id=(
                    detection.source_observation_ids[0]
                    if detection.source_observation_ids
                    else None
                ),
                source_track_id=detection.track_id,
                retention_tier=retention_tier,
                metadata={
                    "label_category": detection.label_category,
                    "confidence": detection.label_confidence,
                    "zone_ids": detection.zone_ids,
                    "capability_tier": capability_tier,
                },
            )
            if track is not None and not suppressed_by_zone:
                await self._storage.insert_track_update(
                    track=track,
                    timestamp_ns=event_time_ns,
                    event_id=detection.event_id,
                    update_type="detection",
                    detection_id=detection.id,
                    observation_ids=all_observation_ids,
                    metadata={
                        "gdop": detection.gdop,
                        "reference_sensor": detection.reference_sensor,
                        "capability_tier": capability_tier,
                    },
                )

        return AssemblyResult(
            detection=detection,
            track=track,
            suppressed_by_zone=suppressed_by_zone,
            suppression_reasons=suppression_reasons,
        )
