"""
Snap-to-grid tool for KiCAD schematics.

Snaps wire endpoints, junction positions, net labels, and optionally component
positions to the nearest grid point. Modifies the schematic file in place.

The standard KiCAD schematic grid is 50 mil (1.27 mm). Component pins are
placed at multiples of 1.27 mm relative to the symbol origin, so absolute pin
coordinates end up as odd multiples of 1.27 mm (e.g. 26.67 mm = 21 × 1.27 mm).
These are valid on-grid positions that must not be moved.

The coarser 2.54 mm (100-mil) grid is a common mistake: exactly half of all
valid 1.27 mm positions are not multiples of 2.54 mm and would be displaced by
1.27 mm — moving labels or wire endpoints off their pins and breaking
connectivity.

Off-grid coordinates cause wires that appear visually connected to fail ERC
connectivity checks because KiCAD uses exact integer (IU) matching internally.
"""

import logging
from pathlib import Path
from typing import Any, Dict, List, Optional

import sexpdata
from sexpdata import Symbol
from utils.sexpr_format import dumps as kicad_dumps

logger = logging.getLogger("kicad_interface")

_DEFAULT_GRID_MM: float = 1.27

# Element type names exposed in the public API
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})

# Tags treated as net labels (all have (at x y angle) structure)
_LABEL_TAGS = frozenset(
    {
        Symbol("label"),
        Symbol("global_label"),
        Symbol("hierarchical_label"),
        Symbol("net_tie"),
        Symbol("no_connect"),
    }
)


def _snap_mm(value: float, grid_mm: float) -> float:
    """Snap a single coordinate to the nearest grid multiple."""
    return round(value / grid_mm) * grid_mm


def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
    """Return True if *value* is already within *eps* of a grid point."""
    snapped = _snap_mm(value, grid_mm)
    return abs(value - snapped) < eps


def _snap_xy_pair(item: list, grid_mm: float) -> int:
    """
    Snap a ``(xy x y)`` S-expression item in place.
    Returns 1 if at least one coordinate changed, 0 otherwise.
    """
    if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
        return 0
    x_orig, y_orig = float(item[1]), float(item[2])
    x_new = _snap_mm(x_orig, grid_mm)
    y_new = _snap_mm(y_orig, grid_mm)
    changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
    item[1] = x_new
    item[2] = y_new
    return 1 if changed else 0


def _snap_at_xy(item: list, grid_mm: float) -> int:
    """
    Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
    Preserves rotation / angle at index 3+ unchanged.
    Returns 1 if at least one coordinate changed, 0 otherwise.
    """
    if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
        return 0
    x_orig, y_orig = float(item[1]), float(item[2])
    x_new = _snap_mm(x_orig, grid_mm)
    y_new = _snap_mm(y_orig, grid_mm)
    changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
    item[1] = x_new
    item[2] = y_new
    return 1 if changed else 0


def snap_to_grid(
    schematic_path: Path,
    grid_size: float = _DEFAULT_GRID_MM,
    elements: Optional[List[str]] = None,
) -> Dict[str, Any]:
    """
    Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.

    Modifies the file in place and returns statistics.

    Args:
        schematic_path: Path to the ``.kicad_sch`` file.
        grid_size:      Grid spacing in mm (default 1.27 mm = 50 mil).
                        Do NOT use 2.54 mm — half of all valid KiCAD pin
                        positions fall between 2.54 mm grid lines and would
                        be displaced 1.27 mm, breaking connectivity.
        elements:       List of element types to snap.  Valid values:
                        ``"wires"``, ``"junctions"``, ``"labels"``,
                        ``"components"``.  Defaults to
                        ``["wires", "junctions", "labels"]`` when ``None``.

    Returns:
        ``{"snapped": int, "already_on_grid": int, "grid_size": float}``
        where *snapped* is the number of elements that had at least one
        coordinate moved.
    """
    if grid_size <= 0:
        raise ValueError(f"grid_size must be positive, got {grid_size}")

    if elements is None:
        active: frozenset = frozenset({"wires", "junctions", "labels"})
    else:
        unknown = set(elements) - _VALID_ELEMENTS
        if unknown:
            raise ValueError(
                f"Unknown element type(s): {sorted(unknown)}. "
                f"Valid types: {sorted(_VALID_ELEMENTS)}"
            )
        active = frozenset(elements)

    with open(schematic_path, "r", encoding="utf-8") as fh:
        sch_data = sexpdata.loads(fh.read())

    snapped = 0
    already_on_grid = 0

    snap_wires = "wires" in active
    snap_junctions = "junctions" in active
    snap_labels = "labels" in active
    snap_components = "components" in active

    for item in sch_data:
        if not isinstance(item, list) or not item:
            continue
        tag = item[0]

        # -----------------------------------------------------------------
        # Wires: (wire (pts (xy x y) (xy x y)) ...)
        # -----------------------------------------------------------------
        if snap_wires and tag == Symbol("wire"):
            changed = 0
            for sub in item[1:]:
                if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
                    for pt in sub[1:]:
                        changed += _snap_xy_pair(pt, grid_size)
            if changed:
                snapped += 1
            else:
                already_on_grid += 1
            continue

        # -----------------------------------------------------------------
        # Junctions: (junction (at x y) ...)
        # -----------------------------------------------------------------
        if snap_junctions and tag == Symbol("junction"):
            changed = 0
            for sub in item[1:]:
                changed += _snap_at_xy(sub, grid_size)
            if changed:
                snapped += 1
            else:
                already_on_grid += 1
            continue

        # -----------------------------------------------------------------
        # Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
        # -----------------------------------------------------------------
        if snap_labels and tag in _LABEL_TAGS:
            changed = 0
            for sub in item[1:]:
                changed += _snap_at_xy(sub, grid_size)
            if changed:
                snapped += 1
            else:
                already_on_grid += 1
            continue

        # -----------------------------------------------------------------
        # Components: (symbol (lib_id …) (at x y rotation) …)
        # Snap only the top-level (at …) — not property sub-positions.
        # -----------------------------------------------------------------
        if snap_components and tag == Symbol("symbol"):
            changed = 0
            for sub in item[1:]:
                if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
                    changed += _snap_at_xy(sub, grid_size)
                    break  # only the first (at …) belongs to the symbol itself
            if changed:
                snapped += 1
            else:
                already_on_grid += 1
            continue

    with open(schematic_path, "w", encoding="utf-8") as fh:
        fh.write(kicad_dumps(sch_data))

    return {
        "snapped": snapped,
        "already_on_grid": already_on_grid,
        "grid_size": grid_size,
    }
