"""ISO 13499 MME format reader.

Reads the real ISO/TS 13499 directory structure:
    <TestID>/
        <TestID>.mme           # Master metadata (key :value format)
        Channel/
            <TestID>.chn       # Channel index (lists all channels)
            <TestID>.001       # Channel 1: header + data in one file
            <TestID>.002       # Channel 2: header + data in one file
            ...

Also supports the simplified format used by our synthetic fixture generator:
    <TestID>/
        MME.ini                # Master metadata (INI format)
        channels/
            <ChannelCode>.chn  # Per-channel header
            <ChannelCode>.dat  # Per-channel data
"""

from __future__ import annotations

import configparser
import logging
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any

import numpy as np
from numpy.typing import NDArray

from impakt.channel.code import ChannelCode
from impakt.channel.lookup import normalize_unit
from impakt.channel.model import (
    Channel,
    DummyInfo,
    ImpactConfig,
    TestData,
    TestMetadata,
    VehicleInfo,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _parse_float(value: str, default: float = 0.0) -> float:
    try:
        v = value.strip()
        if v.upper() in ("NOVALUE", "", "UNKNOWN"):
            return default
        return float(v)
    except (ValueError, AttributeError):
        return default


def _parse_int(value: str, default: int = 0) -> int:
    try:
        v = value.strip()
        if v.upper() in ("NOVALUE", "", "UNKNOWN"):
            return default
        return int(float(v))
    except (ValueError, AttributeError):
        return default


def _novalue(s: str) -> str:
    """Return empty string if the value is NOVALUE or empty."""
    stripped = s.strip()
    if stripped.upper() in ("NOVALUE", ""):
        return ""
    return stripped


UNIT_MAP: dict[str, str] = {
    "m/(s*s)": "m/s²",
    "m/s**2": "m/s²",
    "m/s^2": "m/s²",
    "m/s2": "m/s²",
    "m/(s²)": "m/s²",
    "g": "g",
    "G": "g",
    "N": "N",
    "kN": "kN",
    "Nm": "N·m",
    "N.m": "N·m",
    "N*m": "N·m",
    "Nm": "N·m",
    "mm": "mm",
    "m": "m",
    "cm": "cm",
    "m/s": "m/s",
    "km/h": "km/h",
    "deg": "deg",
    "rad": "rad",
    "rad/s": "rad/s",
    "kPa": "kPa",
    "1": "",
    "-": "",
}


def _normalize_unit(raw: str) -> str:
    stripped = raw.strip()
    return UNIT_MAP.get(stripped, normalize_unit(stripped))


# ---------------------------------------------------------------------------
# ISO 13499 key:value parser
# ---------------------------------------------------------------------------


def _parse_mme_keyvalue(text: str) -> dict[str, str | list[str]]:
    """Parse the ISO 13499 key:value format.

    Lines are formatted as:
        Key name padded with spaces  :value

    Some keys (e.g., Comments) can appear multiple times.
    """
    result: dict[str, str | list[str]] = {}
    for line in text.splitlines():
        line = line.rstrip()
        if not line or line.startswith("#") or line.startswith(";"):
            continue
        # Split at first colon, but the key may have a colon in padded area
        # The format is: key (padded to ~30 chars) + ":" + value
        match = re.match(r"^(.+?)\s*:(.*)$", line)
        if not match:
            continue
        key = match.group(1).strip().lower()
        value = match.group(2).strip()

        if key in result:
            existing = result[key]
            if isinstance(existing, list):
                existing.append(value)
            else:
                result[key] = [existing, value]
        else:
            result[key] = value

    return result


def _get_val(data: dict[str, str | list[str]], key: str, default: str = "") -> str:
    """Get a single string value from parsed MME data."""
    val = data.get(key, default)
    if isinstance(val, list):
        return val[0] if val else default
    return _novalue(str(val)) if val else default


def _get_list(data: dict[str, str | list[str]], key: str) -> list[str]:
    """Get a list of values for a repeated key."""
    val = data.get(key, [])
    if isinstance(val, list):
        return val
    return [val] if val else []


# ---------------------------------------------------------------------------
# .mme master file parser
# ---------------------------------------------------------------------------


def _parse_master_mme(path: Path) -> TestMetadata:
    """Parse the .mme master metadata file (ISO 13499 format)."""
    text = path.read_text(encoding="utf-8", errors="replace")
    data = _parse_mme_keyvalue(text)

    # Parse date
    test_date: date | None = None
    date_str = _get_val(data, "date of the test")
    if date_str:
        for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y%m%d"):
            try:
                test_date = datetime.strptime(date_str, fmt).date()
                break
            except ValueError:
                continue

    # Vehicle info — from test object 1
    vehicle_name = _get_val(data, "name of test object 1")
    velocity_str = _get_val(data, "velocity test object 1")
    mass_str = _get_val(data, "mass test object 1")

    # Try to extract make/model from the vehicle name
    make, model = "", ""
    if vehicle_name:
        parts = vehicle_name.split(None, 1)
        if len(parts) >= 2:
            make, model = parts[0], parts[1]
        elif parts:
            make = parts[0]

    # Speed: the .mme stores in m/s, convert to km/h
    speed_ms = _parse_float(velocity_str)
    speed_kmh = speed_ms * 3.6

    vehicle = VehicleInfo(
        make=make,
        model=model,
        mass_kg=_parse_float(mass_str),
    )

    # Impact config
    test_type = _get_val(data, "type of the test") or _get_val(data, "subtype of the test")
    regulation = _get_val(data, "regulation")
    offset = _get_val(data, ".offset 1")

    impact = ImpactConfig(
        test_type=test_type,
        speed_kmh=speed_kmh,
        overlap_percent=_parse_float(offset),
        standard=regulation,
    )

    # Dummy info — inferred from channel codes later; basic from .mme
    dummy = DummyInfo()

    # Test number
    test_number = (
        _get_val(data, "customer test ref. number")
        or _get_val(data, "laboratory test ref. number")
        or path.stem
    )

    # Comments
    comments = _get_list(data, "comments")
    description = " ".join(comments) if comments else ""

    extra: dict[str, Any] = {}
    for k, v in data.items():
        if isinstance(v, list):
            extra[k] = "; ".join(v)
        else:
            extra[k] = v

    return TestMetadata(
        test_number=test_number,
        test_date=test_date,
        test_facility=_get_val(data, "laboratory name"),
        description=description or _get_val(data, "title"),
        vehicle=vehicle,
        dummy=dummy,
        impact=impact,
        extra=extra,
    )


# ---------------------------------------------------------------------------
# .chn channel index parser
# ---------------------------------------------------------------------------


def _parse_channel_index(path: Path) -> list[tuple[int, str, str]]:
    """Parse the .chn channel index file.

    Returns list of (channel_number, channel_code, channel_label).
    """
    text = path.read_text(encoding="utf-8", errors="replace")
    data = _parse_mme_keyvalue(text)
    channels: list[tuple[int, str, str]] = []

    # Parse "Name of channel NNN" entries
    for key, value in data.items():
        match = re.match(r"name of channel (\d+)", key)
        if match:
            num = int(match.group(1))
            val = str(value) if not isinstance(value, list) else value[0]
            # Value format: "11HEAD0000H3ACXP / HDCG" or just "11HEAD0000H3ACXP"
            parts = val.split("/", 1)
            code = parts[0].strip()
            label = parts[1].strip() if len(parts) > 1 else ""
            channels.append((num, code, label))

    channels.sort(key=lambda x: x[0])
    return channels


# ---------------------------------------------------------------------------
# .NNN individual channel data file parser
# ---------------------------------------------------------------------------


def _parse_channel_file(path: Path) -> tuple[dict[str, str], NDArray[np.float64]]:
    """Parse an individual channel data file (.001, .002, etc.).

    These files contain a header section (key:value) followed by
    numerical data (one value per line).

    Returns (header_dict, data_array).
    """
    text = path.read_text(encoding="utf-8", errors="replace")
    lines = text.splitlines()

    header: dict[str, str] = {}
    data_lines: list[str] = []
    in_data = False

    for line in lines:
        line = line.strip()
        if not line:
            continue

        if not in_data:
            # Try to parse as key:value
            match = re.match(r"^(.+?)\s*:(.*)$", line)
            if match:
                key = match.group(1).strip().lower()
                value = match.group(2).strip()
                header[key] = value
            else:
                # This might be the start of data
                try:
                    float(line)
                    in_data = True
                    data_lines.append(line)
                except ValueError:
                    # Skip unparseable lines
                    pass
        else:
            data_lines.append(line)

    # Parse data values
    values: list[float] = []
    for dl in data_lines:
        try:
            values.append(float(dl))
        except ValueError:
            pass

    data = np.array(values, dtype=np.float64) if values else np.array([], dtype=np.float64)

    return header, data


# ---------------------------------------------------------------------------
# INI-format master file parser (for our synthetic fixtures)
# ---------------------------------------------------------------------------


def _parse_master_ini(path: Path) -> TestMetadata:
    """Parse an INI-style master metadata file (synthetic fixture format)."""
    config = configparser.ConfigParser(interpolation=None)
    try:
        config.read(str(path), encoding="utf-8")
    except (configparser.Error, UnicodeDecodeError):
        try:
            config.read(str(path), encoding="latin-1")
        except configparser.Error:
            return TestMetadata(test_number=path.parent.name)

    def get_value(*keys: str) -> str:
        for section in config.sections():
            for key in keys:
                try:
                    return config.get(section, key)
                except (configparser.NoOptionError, configparser.NoSectionError):
                    continue
        return ""

    test_date: date | None = None
    date_str = get_value("test_date", "testdate", "date")
    if date_str:
        for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
            try:
                test_date = datetime.strptime(date_str.strip(), fmt).date()
                break
            except ValueError:
                continue

    vehicle = VehicleInfo(
        make=get_value("vehicle_make", "vehiclemake", "make"),
        model=get_value("vehicle_model", "vehiclemodel", "model"),
        year=_parse_int(get_value("vehicle_year", "vehicleyear", "year")),
        vin=get_value("vin", "vehicle_vin"),
        mass_kg=_parse_float(get_value("vehicle_mass", "vehiclemass", "mass_kg")),
        vehicle_type=get_value("vehicle_type", "vehicletype"),
    )
    dummy = DummyInfo(
        dummy_type=get_value("dummy_type", "dummytype", "dummy"),
        serial=get_value("dummy_serial", "dummyserial"),
        position=get_value("dummy_position", "dummyposition", "position"),
        mass_kg=_parse_float(get_value("dummy_mass", "dummymass")),
    )
    impact = ImpactConfig(
        test_type=get_value("test_type", "testtype", "impact_type"),
        speed_kmh=_parse_float(get_value("test_speed", "testspeed", "impact_speed", "speed")),
        barrier_type=get_value("barrier_type", "barriertype", "barrier"),
        overlap_percent=_parse_float(get_value("overlap", "overlap_percent", "overlappercent")),
        standard=get_value("test_standard", "teststandard", "standard", "regulation"),
    )

    return TestMetadata(
        test_number=get_value("test_number", "testnumber", "test_id") or path.parent.name,
        test_date=test_date,
        test_facility=get_value("test_facility", "testfacility", "facility", "lab"),
        description=get_value("description", "test_description", "comment"),
        vehicle=vehicle,
        dummy=dummy,
        impact=impact,
    )


# ---------------------------------------------------------------------------
# INI-format channel reader (synthetic fixtures)
# ---------------------------------------------------------------------------


def _read_ini_channel(chn_path: Path, test_id: str) -> Channel | None:
    """Read a channel from separate .chn header + .dat data files (synthetic)."""
    config = configparser.ConfigParser(interpolation=None)
    try:
        config.read(str(chn_path), encoding="utf-8")
    except configparser.Error:
        return None

    # Extract fields from any section
    items: dict[str, str] = {}
    for section in config.sections():
        items.update(dict(config.items(section)))

    channel_code = items.get("channel_code", items.get("code", chn_path.stem))
    unit = _normalize_unit(items.get("unit", items.get("units", "")))
    sample_rate = _parse_float(items.get("sample_rate", items.get("samplerate", "0")))
    dt = _parse_float(items.get("dt", items.get("time_step", "0")))
    pre_trigger = _parse_int(items.get("pre_trigger", items.get("pretrigger", "0")))
    cfc_class_val = _parse_int(items.get("cfc", items.get("cfc_class", "-1")), -1)
    cfc_class = cfc_class_val if cfc_class_val > 0 else None

    if sample_rate == 0 and dt > 0:
        sample_rate = 1.0 / dt
    elif dt == 0 and sample_rate > 0:
        dt = 1.0 / sample_rate

    # Find data file
    dat_path = chn_path.with_suffix(".dat")
    if not dat_path.exists():
        for ext in (".DAT", ".bin", ".BIN"):
            candidate = chn_path.with_suffix(ext)
            if candidate.exists():
                dat_path = candidate
                break
        else:
            return None

    data = np.loadtxt(str(dat_path), dtype=np.float64)
    if data.ndim > 1:
        data = data[:, -1]
    if len(data) == 0:
        return None

    num_samples = len(data)
    if dt <= 0:
        dt = 1.0 / 20000.0
        sample_rate = 20000.0

    t_start = -pre_trigger * dt
    time = np.arange(num_samples, dtype=np.float64) * dt + t_start

    code = ChannelCode.parse(channel_code)

    return Channel(
        name=channel_code,
        code=code,
        data=data,
        time=time,
        unit=unit or code.measurement_unit,
        sample_rate=sample_rate,
        cfc_class=cfc_class,
        metadata={},
        source_test_id=test_id,
    )


# ---------------------------------------------------------------------------
# MME Reader
# ---------------------------------------------------------------------------


class MMEReader:
    """Reader for ISO 13499 MME format.

    Supports two variants:
    1. Real ISO 13499: .mme master + Channel/<TestID>.chn index + .NNN data files
    2. Simplified: MME.ini master + channels/<Code>.chn + <Code>.dat pairs
    """

    @property
    def format_name(self) -> str:
        return "ISO 13499 MME"

    def supports(self, path: Path) -> bool:
        path = Path(path)
        if not path.is_dir():
            return False
        # Check for real ISO 13499 format: *.mme file
        if list(path.glob("*.mme")) or list(path.glob("*.MME")):
            return True
        # Check for INI format: MME.ini
        for name in ("MME.ini", "mme.ini", "MME.INI"):
            if (path / name).exists():
                return True
        # Check for .chn files
        if list(path.rglob("*.chn")) or list(path.rglob("*.CHN")):
            return True
        return False

    def metadata(self, path: Path) -> TestMetadata:
        path = Path(path).resolve()
        master = self._find_master(path)
        if master is None:
            return TestMetadata(test_number=path.name)
        if master.suffix.lower() == ".mme":
            return _parse_master_mme(master)
        else:
            return _parse_master_ini(master)

    def read(self, path: Path) -> TestData:
        path = Path(path).resolve()
        metadata = self.metadata(path)
        test_id = metadata.test_number or path.name

        # Determine which format variant
        master = self._find_master(path)
        if master and master.suffix.lower() == ".mme":
            channels = self._read_iso_channels(path, test_id)
        else:
            channels = self._read_ini_channels(path, test_id)

        logger.info("Loaded %d channels from %s (%s)", len(channels), path.name, test_id)

        return TestData(
            test_id=test_id,
            metadata=metadata,
            channels=channels,
            path=path,
        )

    # ----- Format detection -----

    def _find_master(self, path: Path) -> Path | None:
        """Find the master metadata file."""
        # Real ISO format: *.mme
        for f in sorted(path.glob("*.mme")) + sorted(path.glob("*.MME")):
            if f.is_file():
                return f
        # INI format
        for name in ("MME.ini", "mme.ini", "MME.INI"):
            candidate = path / name
            if candidate.exists():
                return candidate
        return None

    def _find_channel_dir(self, path: Path) -> Path | None:
        """Find the Channel/ directory."""
        for name in ("Channel", "channel", "CHANNEL", "channels"):
            candidate = path / name
            if candidate.is_dir():
                return candidate
        return None

    # ----- ISO 13499 format reading -----

    def _read_iso_channels(self, path: Path, test_id: str) -> dict[str, Channel]:
        """Read channels in real ISO 13499 format."""
        ch_dir = self._find_channel_dir(path)
        if ch_dir is None:
            logger.warning("No Channel directory found in %s", path)
            return {}

        # Find and parse the .chn index file
        chn_files = list(ch_dir.glob("*.chn")) + list(ch_dir.glob("*.CHN"))
        if not chn_files:
            logger.warning("No .chn index file found in %s", ch_dir)
            return {}

        chn_index = _parse_channel_index(chn_files[0])
        stem = chn_files[0].stem  # e.g., "3239" or "AK3T02FO"

        channels: dict[str, Channel] = {}
        for ch_num, ch_code, ch_label in chn_index:
            # Data file: <stem>.<NNN>
            ext = f".{ch_num:03d}"
            data_path = ch_dir / f"{stem}{ext}"
            if not data_path.exists():
                # Try uppercase stem
                for candidate in ch_dir.glob(f"*{ext}"):
                    data_path = candidate
                    break
                else:
                    logger.debug("Data file not found for channel %d (%s)", ch_num, ch_code)
                    continue

            try:
                ch = self._read_iso_channel_file(data_path, ch_code, ch_label, test_id)
                if ch is not None:
                    channels[ch.name] = ch
            except Exception as e:
                logger.warning("Failed to read channel %s from %s: %s", ch_code, data_path, e)

        return channels

    def _read_iso_channel_file(
        self,
        path: Path,
        expected_code: str,
        label: str,
        test_id: str,
    ) -> Channel | None:
        """Read a single ISO 13499 channel data file (.001, .002, etc.)."""
        header, data = _parse_channel_file(path)

        if len(data) == 0:
            logger.debug("Empty data in %s", path)
            return None

        # Extract header fields
        channel_code = header.get("channel code", expected_code).strip()
        unit = _normalize_unit(header.get("unit", ""))
        dt = _parse_float(header.get("sampling interval", "0"))
        t_first = _parse_float(header.get("time of first sample", "0"))
        num_samples_declared = _parse_int(header.get("number of samples", "0"))
        cfc_str = header.get("channel frequency class", "")
        cfc_class: int | None = None
        if cfc_str and cfc_str.upper() != "NOVALUE":
            cfc_val = _parse_int(cfc_str, -1)
            if cfc_val > 0:
                cfc_class = cfc_val

        # Sample rate from sampling interval
        sample_rate = 1.0 / dt if dt > 0 else 0.0

        # Build time vector
        num_samples = len(data)
        if dt > 0:
            time = np.arange(num_samples, dtype=np.float64) * dt + t_first
        else:
            # Fallback: assume 10kHz
            sample_rate = 10000.0
            dt = 1.0 / sample_rate
            time = np.arange(num_samples, dtype=np.float64) * dt

        # Parse the channel code
        code = ChannelCode.parse(channel_code)

        # Build metadata from header
        ch_metadata: dict[str, Any] = {}
        for k, v in header.items():
            if v.upper() != "NOVALUE":
                ch_metadata[k] = v
        if label:
            ch_metadata["label"] = label

        return Channel(
            name=channel_code,
            code=code,
            data=data,
            time=time,
            unit=unit or code.measurement_unit,
            sample_rate=sample_rate,
            cfc_class=cfc_class,
            metadata=ch_metadata,
            source_test_id=test_id,
        )

    # ----- INI format reading (synthetic fixtures) -----

    def _read_ini_channels(self, path: Path, test_id: str) -> dict[str, Channel]:
        """Read channels from INI-style .chn/.dat file pairs."""
        ch_dir = self._find_channel_dir(path)
        if ch_dir is None:
            logger.warning("No channel directory found in %s", path)
            return {}

        chn_files = sorted(ch_dir.glob("*.chn")) + sorted(ch_dir.glob("*.CHN"))
        channels: dict[str, Channel] = {}

        for chn_path in chn_files:
            try:
                ch = _read_ini_channel(chn_path, test_id)
                if ch is not None:
                    channels[ch.name] = ch
            except Exception as e:
                logger.warning("Failed to read channel %s: %s", chn_path, e)

        return channels
