"""Parser for LTspice .raw waveform files.

LTspice binary .raw format:
- Header: ASCII text with metadata (title, date, plotname, flags, variables, points)
- Data: Binary IEEE float64 (real) or float64 pairs (complex) depending on analysis type
"""

import struct
from dataclasses import dataclass
from pathlib import Path

import numpy as np


@dataclass
class Variable:
    """A variable (signal) in the raw file."""

    index: int
    name: str
    type: str  # e.g., "voltage", "current", "time", "frequency"


@dataclass
class RawFile:
    """Parsed LTspice .raw file."""

    title: str
    date: str
    plotname: str
    flags: list[str]
    variables: list[Variable]
    points: int
    data: np.ndarray  # Shape: (n_variables, n_points)
    n_runs: int = 1  # Number of runs (>1 for .step/.mc/.temp)
    run_boundaries: list[int] | None = None  # Start index of each run

    def get_variable(self, name: str, run: int | None = None) -> np.ndarray | None:
        """Get data for a variable by name (case-insensitive partial match).

        Args:
            name: Signal name (partial match OK)
            run: If stepped data, return only this run (0-indexed). None = all data.
        """
        name_lower = name.lower()
        for var in self.variables:
            if name_lower in var.name.lower():
                arr = self.data[var.index]
                if run is not None and self.run_boundaries:
                    start, end = self._run_slice(run)
                    return arr[start:end]
                return arr
        return None

    def get_time(self, run: int | None = None) -> np.ndarray | None:
        """Get the time axis (for transient analysis)."""
        return self.get_variable("time", run=run)

    def get_frequency(self, run: int | None = None) -> np.ndarray | None:
        """Get the frequency axis (for AC analysis)."""
        return self.get_variable("frequency", run=run)

    def get_run_data(self, run: int) -> "RawFile":
        """Extract a single run as a new RawFile (for stepped simulations)."""
        if not self.run_boundaries or run >= self.n_runs:
            return self
        start, end = self._run_slice(run)
        return RawFile(
            title=self.title,
            date=self.date,
            plotname=self.plotname,
            flags=self.flags,
            variables=self.variables,
            points=end - start,
            data=self.data[:, start:end].copy(),
            n_runs=1,
            run_boundaries=None,
        )

    @property
    def is_stepped(self) -> bool:
        return self.n_runs > 1

    def _run_slice(self, run: int) -> tuple[int, int]:
        """Get (start, end) indices for a given run."""
        if not self.run_boundaries:
            return 0, self.points
        start = self.run_boundaries[run]
        if run + 1 < len(self.run_boundaries):
            end = self.run_boundaries[run + 1]
        else:
            end = self.data.shape[1]
        return start, end


def parse_raw_file(path: Path | str) -> RawFile:
    """Parse an LTspice .raw file.

    Args:
        path: Path to the .raw file

    Returns:
        RawFile with parsed metadata and waveform data

    Raises:
        ValueError: If file format is not recognized
        FileNotFoundError: If file doesn't exist
    """
    path = Path(path)

    with open(path, "rb") as f:
        content = f.read()

    # Detect encoding: UTF-16 LE (Windows) vs ASCII
    # UTF-16 LE has null bytes between characters
    is_utf16 = content[1:2] == b"\x00" and content[3:4] == b"\x00"

    if is_utf16:
        # UTF-16 LE encoding - decode header portion, find Binary marker
        # "Binary:\n" in UTF-16 LE
        binary_marker = "Binary:\n".encode("utf-16-le")
        ascii_marker = "Values:\n".encode("utf-16-le")
    else:
        binary_marker = b"Binary:\n"
        ascii_marker = b"Values:\n"

    binary_offset = content.find(binary_marker)
    ascii_offset = content.find(ascii_marker)

    if binary_offset != -1:
        header_end = binary_offset + len(binary_marker)
        is_binary = True
    elif ascii_offset != -1:
        header_end = ascii_offset + len(ascii_marker)
        is_binary = False
    else:
        raise ValueError("Could not find data section marker in .raw file")

    # Parse header (handle encoding)
    if is_utf16:
        header = content[:header_end].decode("utf-16-le", errors="replace")
    else:
        header = content[:header_end].decode("utf-8", errors="replace")
    header_lines = header.strip().split("\n")

    title = ""
    date = ""
    plotname = ""
    flags: list[str] = []
    variables: list[Variable] = []
    points = 0

    in_variables = False

    for line in header_lines:
        line = line.strip()

        if line.startswith("Title:"):
            title = line[6:].strip()
        elif line.startswith("Date:"):
            date = line[5:].strip()
        elif line.startswith("Plotname:"):
            plotname = line[9:].strip()
        elif line.startswith("Flags:"):
            flags = line[6:].strip().split()
        elif line.startswith("No. Variables:"):
            pass  # Parsed from Variables section instead
        elif line.startswith("No. Points:"):
            points = int(line[11:].strip())
        elif line.startswith("Variables:"):
            in_variables = True
        elif (
            in_variables
            and line
            and not line.startswith("Binary")
            and not line.startswith("Values")
        ):
            # Parse variable line: "index name type"
            parts = line.split()
            if len(parts) >= 3:
                idx = int(parts[0])
                name = parts[1]
                vtype = parts[2]
                variables.append(Variable(idx, name, vtype))

    if not variables:
        raise ValueError("No variables found in .raw file header")

    # Parse data section
    data_bytes = content[header_end:]
    n_vars = len(variables)

    is_complex = "complex" in flags
    is_stepped = "stepped" in flags
    # "forward" flag indicates FastAccess format (unused for now)

    if is_binary:
        data = _parse_binary_data(data_bytes, n_vars, points, is_complex)
    else:
        data = _parse_ascii_data(data_bytes.decode("utf-8"), n_vars, points, is_complex)

    # Detect run boundaries for stepped simulations
    n_runs = 1
    run_boundaries = None
    if is_stepped and data.shape[1] > 1:
        run_boundaries = _detect_run_boundaries(data[0])
        n_runs = len(run_boundaries)

    actual_points = data.shape[1]

    return RawFile(
        title=title,
        date=date,
        plotname=plotname,
        flags=flags,
        variables=variables,
        points=actual_points,
        data=data,
        n_runs=n_runs,
        run_boundaries=run_boundaries,
    )


def _parse_binary_data(data: bytes, n_vars: int, points: int, is_complex: bool) -> np.ndarray:
    """Parse binary data section.

    LTspice binary formats:
    - Real transient: time (float64) + other vars (float32)
    - Real all-double: all variables as float64 (older format)
    - Complex AC: frequency (float64) + other vars as (float64, float64) pairs
    """
    if is_complex:
        # Complex data (AC analysis): ALL variables stored as complex128
        # Each value is (real float64, imag float64) = 16 bytes
        bytes_per_point = n_vars * 16
        actual_points = len(data) // bytes_per_point

        # Read as flat complex128 array and reshape
        flat = np.frombuffer(data[: actual_points * bytes_per_point], dtype=np.complex128)
        return flat.reshape(actual_points, n_vars).T.copy()

    # Real data - detect format from data size
    # Mixed format: time (8 bytes) + other vars (4 bytes each)
    bytes_per_point_mixed = 8 + (n_vars - 1) * 4
    bytes_per_point_double = n_vars * 8

    expected_mixed = bytes_per_point_mixed * points
    expected_double = bytes_per_point_double * points

    if abs(len(data) - expected_mixed) < abs(len(data) - expected_double):
        # Mixed precision format (common in newer LTspice)
        actual_points = len(data) // bytes_per_point_mixed
        result = np.zeros((n_vars, actual_points), dtype=np.float64)
        offset = 0

        for p in range(actual_points):
            # Time as double
            result[0, p] = struct.unpack("<d", data[offset : offset + 8])[0]
            offset += 8

            # Other variables as float32
            for v in range(1, n_vars):
                result[v, p] = struct.unpack("<f", data[offset : offset + 4])[0]
                offset += 4

        return result
    else:
        # All double format (older LTspice or specific settings)
        actual_points = len(data) // bytes_per_point_double

        flat = np.frombuffer(data[: actual_points * bytes_per_point_double], dtype=np.float64)
        # LTspice stores point-by-point
        return flat.reshape(actual_points, n_vars).T.copy()


def _parse_ascii_data(data: str, n_vars: int, points: int, is_complex: bool) -> np.ndarray:
    """Parse ASCII data section."""
    lines = data.strip().split("\n")

    if is_complex:
        result = np.zeros((n_vars, points), dtype=np.complex128)
    else:
        result = np.zeros((n_vars, points), dtype=np.float64)

    point_idx = 0
    var_idx = 0

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

        parts = line.split()
        if len(parts) >= 2:
            # Format: "var_idx value" or "var_idx real,imag"
            try:
                idx = int(parts[0])
                if idx == 0:
                    if var_idx > 0:
                        point_idx += 1
                    var_idx = 0

                if point_idx < points:
                    value_str = parts[1]
                    if "," in value_str:
                        # Complex value
                        real, imag = value_str.split(",")
                        result[var_idx, point_idx] = complex(float(real), float(imag))
                    else:
                        result[var_idx, point_idx] = float(value_str)

                var_idx += 1
            except (ValueError, IndexError):
                continue

    return result


def _detect_run_boundaries(x_axis: np.ndarray) -> list[int]:
    """Detect run boundaries in stepped simulation data.

    In stepped data, the independent variable (time or frequency) resets
    at the start of each new run. We detect this as a point where the
    value decreases (time goes backwards) or resets to near-zero.
    """
    x = np.real(x_axis) if np.iscomplexobj(x_axis) else x_axis

    boundaries = [0]  # First run always starts at index 0

    for i in range(1, len(x)):
        # Detect reset: value drops back to near the starting value
        if x[i] <= x[0] and x[i] < x[i - 1]:
            boundaries.append(i)

    return boundaries
