"""Parse LTspice simulation log files."""

import re
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class Measurement:
    """A .meas result from the log."""

    name: str
    value: float | None  # None if FAILED
    failed: bool = False


@dataclass
class SimulationLog:
    """Parsed contents of an LTspice .log file."""

    measurements: list[Measurement] = field(default_factory=list)
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)
    elapsed_time: float | None = None
    n_equations: int | None = None
    n_steps: int | None = None
    raw_text: str = ""
    operating_point: dict[str, float] = field(default_factory=dict)
    transfer_function: dict[str, float] = field(default_factory=dict)

    def get_measurement(self, name: str) -> Measurement | None:
        """Get a measurement by name (case-insensitive)."""
        name_lower = name.lower()
        for m in self.measurements:
            if m.name.lower() == name_lower:
                return m
        return None

    def get_all_measurements(self) -> dict[str, float | None]:
        """Return dict of measurement name -> value."""
        return {m.name: m.value for m in self.measurements}


# Patterns for measurement results.
# LTspice emits results in several formats depending on version and OS:
#   name=1.23456e-006           (no spaces around '=')
#   name = 1.23456e-006         (spaces around '=')
#   name: 1.23456e-006          (colon separator)
#   name: FAILED                (measurement could not be computed)
#   name=FAILED
_MEAS_VALUE_RE = re.compile(
    r"^(?P<name>\S+?)\s*[=:]\s*(?P<value>[+-]?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\s*$",
    re.IGNORECASE,
)
_MEAS_FAILED_RE = re.compile(
    r"^(?P<name>\S+?)\s*[=:]?\s*FAILED\s*$",
    re.IGNORECASE,
)

# Simulation statistics patterns.
_ELAPSED_TIME_RE = re.compile(
    r"Total elapsed time:\s*(?P<seconds>[+-]?\d+(?:\.\d+)?)\s*seconds?",
    re.IGNORECASE,
)
_N_EQUATIONS_RE = re.compile(
    r"N-of-equations:\s*(?P<n>\d+)",
    re.IGNORECASE,
)
_N_STEPS_RE = re.compile(
    r"N-of-steps:\s*(?P<n>\d+)",
    re.IGNORECASE,
)

# Lines starting with ".meas" are directive echoes, not results -- skip them.
_MEAS_DIRECTIVE_RE = re.compile(r"^\s*\.meas\s", re.IGNORECASE)

# Operating point / transfer function lines:  "V(out):\t 2.5\t voltage"
# These have a name, colon, value, then optional trailing text (units/type).
_OP_TF_VALUE_RE = re.compile(
    r"^(?P<name>\S+?):\s+(?P<value>[+-]?\d+(?:\.\d+)?(?:e[+-]?\d+)?)\s*",
    re.IGNORECASE,
)

# Section headers in LTspice log files
_OP_SECTION_RE = re.compile(r"---\s*Operating\s+Point\s*---", re.IGNORECASE)
_TF_SECTION_RE = re.compile(r"---\s*Transfer\s+Function\s*---", re.IGNORECASE)


def _is_error_line(line: str) -> bool:
    """Return True if the line reports an error."""
    return bool(re.search(r"\bError\b", line, re.IGNORECASE))


def _is_warning_line(line: str) -> bool:
    """Return True if the line reports a warning."""
    return bool(re.search(r"\bWarning\b", line, re.IGNORECASE))


def parse_log(path: Path | str) -> SimulationLog:
    """Parse an LTspice .log file.

    Reads the file at *path* and extracts measurement results, errors,
    warnings, and basic simulation statistics.  The parser is intentionally
    lenient -- unknown lines are silently ignored so that it works across
    different LTspice versions and simulation types (transient, AC, DC, etc.).
    """
    path = Path(path)

    # LTspice log files may be encoded as UTF-8 or Latin-1 depending on the
    # platform.  Try UTF-8 first, fall back to Latin-1 which never raises.
    for encoding in ("utf-8", "latin-1"):
        try:
            raw_text = path.read_text(encoding=encoding)
            break
        except UnicodeDecodeError:
            continue
    else:
        raw_text = path.read_text(encoding="latin-1", errors="replace")

    log = SimulationLog(raw_text=raw_text)

    # Track which section we're currently parsing
    current_section: str | None = None  # "op", "tf", or None

    for line in raw_text.splitlines():
        stripped = line.strip()
        if not stripped:
            continue

        # Detect section headers
        if _OP_SECTION_RE.search(stripped):
            current_section = "op"
            continue
        if _TF_SECTION_RE.search(stripped):
            current_section = "tf"
            continue

        # A new section header (any "---...---" line) ends the current section
        if stripped.startswith("---") and stripped.endswith("---"):
            current_section = None
            continue

        # Parse .op / .tf section values
        if current_section in ("op", "tf"):
            m = _OP_TF_VALUE_RE.match(stripped)
            if m:
                try:
                    val = float(m.group("value"))
                except ValueError:
                    continue
                target = log.operating_point if current_section == "op" else log.transfer_function
                target[m.group("name")] = val
                continue

        # Skip echoed .meas directives -- they are not results.
        if _MEAS_DIRECTIVE_RE.match(stripped):
            continue

        # Errors and warnings.
        if _is_error_line(stripped):
            log.errors.append(stripped)
        if _is_warning_line(stripped):
            log.warnings.append(stripped)

        # Measurement: failed.
        m = _MEAS_FAILED_RE.match(stripped)
        if m:
            log.measurements.append(Measurement(name=m.group("name"), value=None, failed=True))
            continue

        # Measurement: numeric value.
        m = _MEAS_VALUE_RE.match(stripped)
        if m:
            try:
                value = float(m.group("value"))
            except ValueError:
                value = None
            log.measurements.append(
                Measurement(name=m.group("name"), value=value, failed=(value is None))
            )
            continue

        # Elapsed time.
        m = _ELAPSED_TIME_RE.search(stripped)
        if m:
            try:
                log.elapsed_time = float(m.group("seconds"))
            except ValueError:
                pass
            continue

        # Number of equations.
        m = _N_EQUATIONS_RE.search(stripped)
        if m:
            try:
                log.n_equations = int(m.group("n"))
            except ValueError:
                pass
            continue

        # Number of steps / iterations.
        m = _N_STEPS_RE.search(stripped)
        if m:
            try:
                log.n_steps = int(m.group("n"))
            except ValueError:
                pass
            continue

    return log
