"""Core data model for crash test channels and test data.

All channel objects are immutable — transforms produce new Channel instances
rather than modifying existing ones.
"""

from __future__ import annotations

import fnmatch
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import date
from pathlib import Path
from typing import Any, Iterator

import numpy as np
from numpy.typing import NDArray

from impakt.channel.code import ChannelCode


# ---------------------------------------------------------------------------
# Metadata models
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class VehicleInfo:
    """Vehicle identification and physical properties."""

    make: str = ""
    model: str = ""
    year: int = 0
    vin: str = ""
    mass_kg: float = 0.0
    vehicle_type: str = ""


@dataclass(frozen=True)
class DummyInfo:
    """Crash test dummy identification."""

    dummy_type: str = ""
    serial: str = ""
    position: str = ""
    mass_kg: float = 0.0

    @property
    def is_hybrid3_50m(self) -> bool:
        return "50" in self.dummy_type and ("H3" in self.dummy_type or "Hybrid" in self.dummy_type)

    @property
    def is_hybrid3_5f(self) -> bool:
        return "5" in self.dummy_type and "F" in self.dummy_type.upper()

    @property
    def is_thor(self) -> bool:
        return "THOR" in self.dummy_type.upper()


@dataclass(frozen=True)
class ImpactConfig:
    """Test impact configuration."""

    test_type: str = ""
    speed_kmh: float = 0.0
    barrier_type: str = ""
    impact_angle_deg: float = 0.0
    overlap_percent: float = 0.0
    impact_side: str = ""
    standard: str = ""


@dataclass(frozen=True)
class TestMetadata:
    """Top-level test metadata parsed from MME.ini."""

    test_number: str = ""
    test_date: date | None = None
    test_facility: str = ""
    description: str = ""
    vehicle: VehicleInfo = field(default_factory=VehicleInfo)
    dummy: DummyInfo = field(default_factory=DummyInfo)
    impact: ImpactConfig = field(default_factory=ImpactConfig)
    extra: dict[str, Any] = field(default_factory=dict)


# ---------------------------------------------------------------------------
# Channel model
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class Channel:
    """An immutable time-series data channel.

    Attributes:
        name: Raw channel code string (e.g., '11HEAD0000H3ACXA').
        code: Parsed ChannelCode with structured field access.
        data: NumPy array of sample values.
        time: NumPy array of time values (seconds), same length as data.
        unit: Physical unit of the data values.
        sample_rate: Sampling rate in Hz.
        cfc_class: CFC filter class applied to this data, if any.
        metadata: Additional key-value metadata from the .chn header.
        source_test_id: ID of the test this channel belongs to.
        transform_history: List of transform descriptions applied.
    """

    name: str
    code: ChannelCode
    data: NDArray[np.floating[Any]]
    time: NDArray[np.floating[Any]]
    unit: str = ""
    sample_rate: float = 0.0
    cfc_class: int | None = None
    metadata: dict[str, Any] = field(default_factory=dict)
    source_test_id: str = ""
    transform_history: tuple[str, ...] = ()

    def __post_init__(self) -> None:
        if len(self.data) != len(self.time):
            raise ValueError(
                f"Channel {self.name}: data length ({len(self.data)}) != "
                f"time length ({len(self.time)})"
            )

    @property
    def duration(self) -> float:
        """Duration of the channel in seconds."""
        if len(self.time) < 2:
            return 0.0
        return float(self.time[-1] - self.time[0])

    @property
    def n_samples(self) -> int:
        return len(self.data)

    @property
    def dt(self) -> float:
        """Time step between samples."""
        if self.sample_rate > 0:
            return 1.0 / self.sample_rate
        if len(self.time) >= 2:
            return float(self.time[1] - self.time[0])
        return 0.0

    @property
    def peak(self) -> float:
        """Absolute peak value."""
        return float(np.max(np.abs(self.data)))

    @property
    def peak_time(self) -> float:
        """Time of the absolute peak value."""
        idx = int(np.argmax(np.abs(self.data)))
        return float(self.time[idx])

    def value_at(self, t: float) -> float:
        """Interpolated value at a specific time.

        Uses linear interpolation between the two nearest samples.
        """
        return float(np.interp(t, self.time, self.data))

    def with_data(
        self,
        data: NDArray[np.floating[Any]],
        time: NDArray[np.floating[Any]] | None = None,
        *,
        unit: str | None = None,
        cfc_class: int | None = ...,  # type: ignore[assignment]
        transform_note: str = "",
    ) -> Channel:
        """Create a new Channel with different data, preserving metadata.

        This is the primary mechanism for non-destructive transforms.
        """
        history = self.transform_history
        if transform_note:
            history = (*history, transform_note)

        # Sentinel check for cfc_class (allow explicit None)
        new_cfc = self.cfc_class if cfc_class is ... else cfc_class

        return Channel(
            name=self.name,
            code=self.code,
            data=data,
            time=time if time is not None else self.time,
            unit=unit if unit is not None else self.unit,
            sample_rate=self.sample_rate,
            cfc_class=new_cfc,
            metadata=self.metadata,
            source_test_id=self.source_test_id,
            transform_history=history,
        )

    def __repr__(self) -> str:
        transforms = (
            f" [{len(self.transform_history)} transforms]" if self.transform_history else ""
        )
        return f"Channel({self.name}, {self.n_samples} pts, {self.unit}{transforms})"


# ---------------------------------------------------------------------------
# Channel grouping
# ---------------------------------------------------------------------------


@dataclass
class ChannelGroup:
    """A group of related channels (typically X/Y/Z components).

    Enables one-call resultant computation and component access.
    """

    key: str
    x: Channel | None = None
    y: Channel | None = None
    z: Channel | None = None

    def components(self) -> list[Channel]:
        """Return all available component channels."""
        return [ch for ch in (self.x, self.y, self.z) if ch is not None]

    def resultant(self) -> Channel:
        """Compute the vector magnitude (resultant) from available components.

        Returns a new Channel with direction='R' in its code.
        """
        comps = self.components()
        if not comps:
            raise ValueError(f"Group {self.key} has no component channels")

        # Sum of squares
        result = np.zeros_like(comps[0].data)
        for ch in comps:
            result += ch.data**2
        result = np.sqrt(result)

        # Build a resultant channel code
        ref = comps[0]
        resultant_name = (
            ref.name[:12] + "R" + ref.name[13:] if len(ref.name) >= 13 else ref.name + "_R"
        )

        resultant_code = ChannelCode(
            raw=resultant_name,
            test_object=ref.code.test_object,
            main_location=ref.code.main_location,
            fine_location=ref.code.fine_location,
            measurement=ref.code.measurement,
            direction="R",
            sense=ref.code.sense,
            filter_class=ref.code.filter_class,
            _valid=True,
        )

        axis_labels = "".join(ch.code.direction for ch in comps)
        return Channel(
            name=resultant_name,
            code=resultant_code,
            data=result,
            time=ref.time,
            unit=ref.unit,
            sample_rate=ref.sample_rate,
            cfc_class=ref.cfc_class,
            metadata=ref.metadata,
            source_test_id=ref.source_test_id,
            transform_history=ref.transform_history + (f"resultant({axis_labels})",),
        )

    @property
    def description(self) -> str:
        """Human-readable group description."""
        ref = next((ch for ch in (self.x, self.y, self.z) if ch is not None), None)
        if ref is None:
            return self.key
        return (
            f"{ref.code.test_object_label} {ref.code.location_label} — {ref.code.measurement_label}"
        )

    def __repr__(self) -> str:
        axes = []
        if self.x is not None:
            axes.append("X")
        if self.y is not None:
            axes.append("Y")
        if self.z is not None:
            axes.append("Z")
        return f"ChannelGroup({self.key}, [{'/'.join(axes)}])"


# ---------------------------------------------------------------------------
# Test data container
# ---------------------------------------------------------------------------


class TestData:
    """Container for all data from a single crash test.

    Provides channel access, pattern-based search, and automatic grouping
    based on ISO naming conventions.
    """

    def __init__(
        self,
        test_id: str,
        metadata: TestMetadata,
        channels: dict[str, Channel],
        path: Path | None = None,
    ) -> None:
        self.test_id = test_id
        self.metadata = metadata
        self._channels = dict(channels)
        self.path = path
        self._groups: dict[str, ChannelGroup] | None = None

    @property
    def channels(self) -> dict[str, Channel]:
        """All channels, keyed by name."""
        return dict(self._channels)

    @property
    def channel_names(self) -> list[str]:
        """Sorted list of channel names."""
        return sorted(self._channels.keys())

    def get(self, name: str) -> Channel:
        """Get a channel by exact name.

        Raises KeyError if not found.
        """
        if name in self._channels:
            return self._channels[name]
        # Try case-insensitive lookup
        for key, ch in self._channels.items():
            if key.upper() == name.upper():
                return ch
        raise KeyError(f"Channel '{name}' not found in test {self.test_id}")

    def __getitem__(self, name: str) -> Channel:
        return self.get(name)

    def find(self, pattern: str) -> list[Channel]:
        """Find channels matching a glob-like pattern.

        Supports ``*`` wildcards and ``{X,Y,Z}`` set notation.

        Examples:
            ``test.find("11HEAD0000AC*")``
            ``test.find("**CHST****DC*A")``
            ``test.find("11HEAD0000AC{X,Y,Z}A")``
        """
        results = []
        for ch in self._channels.values():
            if ch.code.is_valid and ch.code.matches(pattern):
                results.append(ch)
            elif fnmatch.fnmatch(ch.name.upper(), pattern.upper()):
                results.append(ch)
        return sorted(results, key=lambda c: c.name)

    def groups(self) -> dict[str, ChannelGroup]:
        """Auto-group channels into X/Y/Z component groups.

        Groups are cached after first computation. Channels are grouped
        by their ``group_key()`` — shared test object, location, measurement,
        and sense, differing only in direction (X/Y/Z).
        """
        if self._groups is not None:
            return dict(self._groups)

        group_map: dict[str, ChannelGroup] = {}
        temp: dict[str, dict[str, Channel]] = defaultdict(dict)

        for ch in self._channels.values():
            if not ch.code.is_valid or not ch.code.is_component():
                continue
            gkey = ch.code.group_key()
            axis = ch.code.direction
            temp[gkey][axis] = ch

        for gkey, axes in temp.items():
            group_map[gkey] = ChannelGroup(
                key=gkey,
                x=axes.get("X"),
                y=axes.get("Y"),
                z=axes.get("Z"),
            )

        self._groups = group_map
        return dict(self._groups)

    def group(self, pattern: str) -> ChannelGroup:
        """Find a channel group matching a pattern.

        The pattern should match the group key (without direction).

        Example: ``test.group("11HEAD0000AC")`` finds the head acceleration group.
        """
        all_groups = self.groups()

        # Direct match
        if pattern in all_groups:
            return all_groups[pattern]

        # Glob match on group keys
        for key, grp in all_groups.items():
            if fnmatch.fnmatch(key.upper(), f"*{pattern.upper()}*"):
                return grp

        raise KeyError(f"No channel group matching '{pattern}' in test {self.test_id}")

    def channel_tree(self) -> dict[str, dict[str, dict[str, list[Channel]]]]:
        """Build a hierarchical tree of channels.

        Returns: {test_object: {main_location: {measurement: [channels]}}}

        Used by the web UI for the channel browser sidebar.
        """
        tree: dict[str, dict[str, dict[str, list[Channel]]]] = {}
        for ch in self._channels.values():
            if not ch.code.is_valid:
                obj = "Other"
                loc = "Unknown"
                meas = "Unknown"
            else:
                obj = ch.code.test_object_label
                loc = ch.code.location_label
                meas = ch.code.measurement_label

            tree.setdefault(obj, {}).setdefault(loc, {}).setdefault(meas, []).append(ch)

        # Sort channels within each leaf
        for obj in tree.values():
            for loc in obj.values():
                for meas_key in loc:
                    loc[meas_key] = sorted(loc[meas_key], key=lambda c: c.name)

        return tree

    def __len__(self) -> int:
        return len(self._channels)

    def __iter__(self) -> Iterator[Channel]:
        return iter(self._channels.values())

    def __contains__(self, name: str) -> bool:
        return name in self._channels or name.upper() in {k.upper() for k in self._channels}

    def __repr__(self) -> str:
        return (
            f"TestData({self.test_id!r}, {len(self._channels)} channels, "
            f"metadata={self.metadata.test_number!r})"
        )
