"""Search and parse LTspice SPICE model libraries."""

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

from .config import LTSPICE_LIB

# Known SPICE model types and their categories
_DISCRETE_TYPES = frozenset(
    {
        "NPN",
        "PNP",  # BJTs
        "NMOS",
        "PMOS",
        "VDMOS",  # MOSFETs
        "D",  # Diodes
        "NJF",
        "PJF",  # JFETs
    }
)

# Module-level cache
_cache: tuple[list["SpiceModel"], list["SpiceSubcircuit"]] | None = None


@dataclass
class SpiceModel:
    """A .model definition."""

    name: str  # e.g., "2N2222"
    type: str  # e.g., "NPN", "D", "NMOS", "PMOS", "PNP"
    parameters: dict[str, str] = field(default_factory=dict)
    source_file: str = ""


@dataclass
class SpiceSubcircuit:
    """A .subckt definition."""

    name: str  # e.g., "LT1001"
    pins: list[str] = field(default_factory=list)
    pin_names: list[str] = field(default_factory=list)  # From comments
    description: str = ""
    source_file: str = ""
    n_components: int = 0


def search_models(
    search: str | None = None,
    model_type: str | None = None,
    limit: int = 50,
) -> list[SpiceModel]:
    """Search for .model definitions in the library.

    Args:
        search: Search term for model name (case-insensitive)
        model_type: Filter by type: NPN, PNP, NMOS, PMOS, D, etc.
        limit: Maximum results

    Returns:
        List of matching SpiceModel objects
    """
    all_models, _ = _scan_all_libraries()

    results: list[SpiceModel] = []
    search_upper = search.upper() if search else None
    type_upper = model_type.upper() if model_type else None

    for model in all_models:
        if type_upper and model.type.upper() != type_upper:
            continue
        if search_upper and search_upper not in model.name.upper():
            continue
        results.append(model)
        if len(results) >= limit:
            break

    return results


def search_subcircuits(
    search: str | None = None,
    limit: int = 50,
) -> list[SpiceSubcircuit]:
    """Search for .subckt definitions in the library.

    Args:
        search: Search term for subcircuit name
        limit: Maximum results

    Returns:
        List of matching SpiceSubcircuit objects
    """
    _, all_subcircuits = _scan_all_libraries()

    results: list[SpiceSubcircuit] = []
    search_upper = search.upper() if search else None

    for subckt in all_subcircuits:
        if search_upper and search_upper not in subckt.name.upper():
            continue
        results.append(subckt)
        if len(results) >= limit:
            break

    return results


def get_model_details(name: str) -> SpiceModel | SpiceSubcircuit | None:
    """Get detailed information about a specific model or subcircuit.

    Searches all library files for exact match (case-insensitive).
    """
    all_models, all_subcircuits = _scan_all_libraries()
    name_upper = name.upper()

    for model in all_models:
        if model.name.upper() == name_upper:
            return model

    for subckt in all_subcircuits:
        if subckt.name.upper() == name_upper:
            return subckt

    return None


def _read_file_text(path: Path) -> str:
    """Read a library file, handling both UTF-16-LE and ASCII/Latin-1 encodings.

    LTspice stores some files (especially .bjt, .mos, .jft, etc.) as
    UTF-16-LE without a BOM. Others are plain ASCII or Latin-1.
    Binary/encrypted .sub files will raise on decode; callers handle that.
    """
    raw = path.read_bytes()
    if not raw:
        return ""

    # Detect UTF-16-LE: check if every other byte (odd positions) is 0x00
    # for the first several bytes. This is a strong indicator of UTF-16-LE
    # ASCII text without a BOM.
    if len(raw) >= 20:
        sample = raw[:40]
        null_positions = sum(1 for i in range(1, len(sample), 2) if sample[i] == 0)
        total_pairs = len(sample) // 2
        if total_pairs > 0 and null_positions / total_pairs > 0.7:
            return raw.decode("utf-16-le", errors="replace")

    # Fall back to latin-1 which never fails (maps bytes 1:1 to codepoints)
    return raw.decode("latin-1")


# Regex patterns compiled once
_MODEL_RE = re.compile(
    r"^\s*\.model\s+"  # .model keyword
    r"(\S+)\s+"  # model name
    r"(?:ako:\S+\s+)?"  # optional ako:reference
    r"(\w+)"  # type (NPN, D, VDMOS, etc.)
    r"(?:\s*\(([^)]*)\))?"  # optional (params)
    r"(.*)",  # trailing params outside parens
    re.IGNORECASE,
)

_SUBCKT_RE = re.compile(
    r"^\s*\.subckt\s+"
    r"(\S+)"  # subcircuit name
    r"((?:\s+\S+)*)",  # pins (space-separated)
    re.IGNORECASE,
)

_ENDS_RE = re.compile(r"^\s*\.ends\b", re.IGNORECASE)

_PIN_COMMENT_RE = re.compile(
    r"^\s*\*\s*[Pp]in\s+\S+\s*[:=]?\s*(.*)",
)


def _parse_params(param_str: str) -> dict[str, str]:
    """Parse SPICE parameter string into a dict.

    Handles formats like: IS=14.34f BF=200 NF=1 VAF=74.03
    """
    params: dict[str, str] = {}
    if not param_str:
        return params

    for match in re.finditer(r"(\w+)\s*=\s*(\S+)", param_str):
        params[match.group(1)] = match.group(2)

    return params


def _scan_lib_file(path: Path) -> tuple[list[SpiceModel], list[SpiceSubcircuit]]:
    """Scan a single library file for model and subcircuit definitions.

    Handles multi-line continuation (lines starting with +).
    Silently skips binary/encrypted files that can't be decoded.
    """
    models: list[SpiceModel] = []
    subcircuits: list[SpiceSubcircuit] = []

    try:
        text = _read_file_text(path)
    except Exception:
        return models, subcircuits

    if not text:
        return models, subcircuits

    # Join continuation lines (+ at start of line continues previous line)
    # Work through lines, merging continuations
    raw_lines = text.splitlines()
    lines: list[str] = []
    for line in raw_lines:
        stripped = line.strip()
        if stripped.startswith("+") and lines:
            # Continuation: append to previous line (strip the +)
            lines[-1] = lines[-1] + " " + stripped[1:].strip()
        else:
            lines.append(line)

    source = str(path.relative_to(LTSPICE_LIB)) if _is_under(path, LTSPICE_LIB) else path.name

    # State tracking for subcircuit parsing
    in_subckt = False
    current_subckt: SpiceSubcircuit | None = None
    component_count = 0
    pin_comments: list[str] = []
    pre_subckt_comments: list[str] = []

    for line in lines:
        stripped = line.strip()

        # Track comments that might describe pins (before or after .subckt)
        if stripped.startswith("*"):
            if in_subckt:
                pin_match = _PIN_COMMENT_RE.match(stripped)
                if pin_match and current_subckt is not None:
                    pin_comments.append(pin_match.group(1).strip())
            else:
                pre_subckt_comments.append(stripped)
            continue

        if not stripped:
            if not in_subckt:
                pre_subckt_comments.clear()
            continue

        # Check for .model
        model_match = _MODEL_RE.match(stripped)
        if model_match:
            name = model_match.group(1)
            mtype = model_match.group(2).upper()
            param_str = (model_match.group(3) or "") + " " + (model_match.group(4) or "")
            params = _parse_params(param_str)

            models.append(
                SpiceModel(
                    name=name,
                    type=mtype,
                    parameters=params,
                    source_file=source,
                )
            )
            continue

        # Check for .subckt
        subckt_match = _SUBCKT_RE.match(stripped)
        if subckt_match and not in_subckt:
            name = subckt_match.group(1)
            pin_str = subckt_match.group(2).strip()
            pins = pin_str.split() if pin_str else []

            # Extract description from preceding comments
            description = ""
            for comment in pre_subckt_comments:
                cleaned = comment.lstrip("* ").strip()
                if cleaned and not cleaned.startswith("Copyright") and len(cleaned) > 3:
                    description = cleaned
                    break

            current_subckt = SpiceSubcircuit(
                name=name,
                pins=pins,
                description=description,
                source_file=source,
            )
            in_subckt = True
            component_count = 0
            pin_comments.clear()
            pre_subckt_comments.clear()
            continue

        # Check for .ends
        if _ENDS_RE.match(stripped):
            if current_subckt is not None:
                current_subckt.n_components = component_count
                current_subckt.pin_names = pin_comments[:]
                subcircuits.append(current_subckt)
            in_subckt = False
            current_subckt = None
            pin_comments.clear()
            continue

        # Count component lines inside a subcircuit
        if in_subckt and stripped and not stripped.startswith("."):
            # Component lines typically start with a letter (R, C, L, M, Q, D, etc.)
            if stripped[0].isalpha():
                component_count += 1

    return models, subcircuits


def _is_under(path: Path, parent: Path) -> bool:
    """Check if path is under parent directory."""
    try:
        path.relative_to(parent)
        return True
    except ValueError:
        return False


def _collect_lib_files() -> list[Path]:
    """Collect all scannable library files from known directories."""
    files: list[Path] = []
    extensions = {".lib", ".sub", ".mod", ".bjt", ".dio", ".mos", ".jft"}

    # Scan lib/cmp/ for component model files
    cmp_dir = LTSPICE_LIB / "cmp"
    if cmp_dir.is_dir():
        for f in cmp_dir.iterdir():
            if f.is_file() and f.suffix.lower() in extensions:
                files.append(f)

    # Scan lib/sub/ for subcircuit files (recursive to include Contrib)
    sub_dir = LTSPICE_LIB / "sub"
    if sub_dir.is_dir():
        for f in sub_dir.rglob("*"):
            if f.is_file() and f.suffix.lower() in extensions:
                files.append(f)

    return sorted(files)


def _scan_all_libraries() -> tuple[list[SpiceModel], list[SpiceSubcircuit]]:
    """Scan all library files. Results are cached after first call."""
    global _cache
    if _cache is not None:
        return _cache

    all_models: list[SpiceModel] = []
    all_subcircuits: list[SpiceSubcircuit] = []

    for path in _collect_lib_files():
        models, subcircuits = _scan_lib_file(path)
        all_models.extend(models)
        all_subcircuits.extend(subcircuits)

    # Sort for consistent ordering
    all_models.sort(key=lambda m: m.name.upper())
    all_subcircuits.sort(key=lambda s: s.name.upper())

    _cache = (all_models, all_subcircuits)
    return _cache
