"""
Ground-truth regression for WireDragger.pin_world_xy + label snap.

The oracle is eeschema itself, accessed via `kicad-cli sch export netlist`.
For each (rotation, mirror) corner case, we:
  1. Build a schematic with a polarized component (Device:D, K=pin1, A=pin2).
  2. Apply the transform via the MCP rotate handler.
  3. Snap a label "<ref>_K" to pin 1 and "<ref>_A" to pin 2 via PinLocator coords.
  4. Run kicad-cli to extract the netlist.
  5. Assert each label's net binds to the *named* pin in the netlist —
     i.e. label "_K" must end up on pin 1 (K), not pin 2 (A).

If our pin coords agree with eeschema's render, the labels land on the
intended pins. If they disagree, the netlist swaps them, exposing the bug.

Skips if kicad-cli or the system Device library aren't available.
"""

import os
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

import pytest

PYTHON_DIR = Path(__file__).parent.parent / "python"
sys.path.insert(0, str(PYTHON_DIR))

from commands.component_schematic import ComponentManager  # noqa: E402
from commands.pin_locator import PinLocator  # noqa: E402
from commands.schematic import SchematicManager  # noqa: E402
from commands.wire_dragger import WireDragger  # noqa: E402

_KICAD_CLI = shutil.which("kicad-cli")
_DEVICE_LIB = Path("/usr/share/kicad/symbols/Device.kicad_sym")

pytestmark = [
    pytest.mark.integration,
    pytest.mark.skipif(_KICAD_CLI is None, reason="kicad-cli not on PATH"),
    pytest.mark.skipif(not _DEVICE_LIB.exists(), reason="Device lib not installed"),
]


def _add_labels_to_file(sch_path: Path, labels: list[tuple[str, float, float]]) -> None:
    """Inject (label ...) tokens before the closing ')' of a .kicad_sch file."""
    text = sch_path.read_text()
    block = "\n"
    for name, x, y in labels:
        block += (
            f'  (label "{name}"\n'
            f"    (at {x} {y} 0)\n"
            f"    (effects (font (size 1.27 1.27)) (justify left bottom))\n"
            f'    (uuid "00000000-0000-0000-0000-{abs(hash(name)) % 10**12:012d}")\n'
            f"  )\n"
        )
    last = text.rstrip().rfind(")")
    sch_path.write_text(text[:last] + block + text[last:])


def _extract_pin_to_net(netlist_xml: Path) -> dict:
    """Return {(ref, pin_num): net_name} from a kicad XML netlist."""
    tree = ET.parse(netlist_xml)
    root = tree.getroot()
    out = {}
    for net in root.findall(".//net"):
        net_name = net.attrib.get("name", "").lstrip("/")
        for node in net.findall("node"):
            ref = node.attrib.get("ref")
            pin = node.attrib.get("pin")
            if ref and pin:
                out[(ref, pin)] = net_name
    return out


def _build_diode_case(tmp: Path, rotation: int) -> tuple[Path, dict]:
    """Place a Device:D, rotate, snap labels, save. Returns (sch_path, expected_map)."""
    sch_path = tmp / f"diode_rot{rotation}.kicad_sch"
    template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch"
    shutil.copy(template, sch_path)

    sch = SchematicManager.load_schematic(str(sch_path))
    ComponentManager.add_component(
        sch,
        {
            "type": "D",
            "reference": "D1",
            "value": "1N4148",
            "x": 100.0,
            "y": 100.0,
            "rotation": rotation,
        },
        sch_path,
    )
    SchematicManager.save_schematic(sch, str(sch_path))

    locator = PinLocator()
    p_k = locator.get_pin_location(sch_path, "D1", "1")
    p_a = locator.get_pin_location(sch_path, "D1", "2")
    assert p_k is not None and p_a is not None

    _add_labels_to_file(sch_path, [("D1_K", p_k[0], p_k[1]), ("D1_A", p_a[0], p_a[1])])

    return sch_path, {("D1", "1"): "D1_K", ("D1", "2"): "D1_A"}


def _apply_mirror_to_file(sch_path: Path, reference: str, axis: str) -> None:
    """Apply (mirror x|y) to a placed symbol via direct sexpr mutation.

    ComponentManager.add_component silently drops a 'mirror' kwarg, so this
    fixture goes around it via the same low-level helper rotate_schematic_component
    uses (WireDragger.update_symbol_rotation_mirror)."""
    import sexpdata

    sch_data = sexpdata.loads(sch_path.read_text())
    if not WireDragger.update_symbol_rotation_mirror(sch_data, reference, 0, axis):
        raise RuntimeError(f"Failed to apply mirror={axis} to {reference}")
    sch_path.write_text(sexpdata.dumps(sch_data))


def _build_mirror_case(tmp: Path, axis: str) -> tuple[Path, dict]:
    sch_path = tmp / f"resistor_mirror_{axis}.kicad_sch"
    template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch"
    shutil.copy(template, sch_path)

    sch = SchematicManager.load_schematic(str(sch_path))
    ComponentManager.add_component(
        sch,
        {"type": "R", "reference": "R1", "value": "10k", "x": 100.0, "y": 100.0, "rotation": 0},
        sch_path,
    )
    SchematicManager.save_schematic(sch, str(sch_path))

    _apply_mirror_to_file(sch_path, "R1", axis)
    if f"(mirror {axis})" not in sch_path.read_text():
        raise RuntimeError(
            f"Fixture failed to write (mirror {axis}) — the kicad-cli oracle would "
            f"silently match our pin coords for an unmirrored symbol."
        )

    locator = PinLocator()
    p1 = locator.get_pin_location(sch_path, "R1", "1")
    p2 = locator.get_pin_location(sch_path, "R1", "2")
    if p1 is None or p2 is None:
        raise RuntimeError(f"PinLocator returned None for R1 mirror={axis}")

    _add_labels_to_file(sch_path, [("R1_PIN1", p1[0], p1[1]), ("R1_PIN2", p2[0], p2[1])])

    return sch_path, {("R1", "1"): "R1_PIN1", ("R1", "2"): "R1_PIN2"}


def _run_netlist(sch_path: Path) -> dict:
    out = sch_path.with_suffix(".net")
    env = {**os.environ, "KICAD_SYMBOL_DIR": "/usr/share/kicad/symbols"}
    subprocess.run(
        [
            _KICAD_CLI,
            "sch",
            "export",
            "netlist",
            "--format",
            "kicadxml",
            "-o",
            str(out),
            str(sch_path),
        ],
        check=True,
        capture_output=True,
        env=env,
    )
    return _extract_pin_to_net(out)


@pytest.mark.parametrize("rotation", [0, 90, 180, 270])
def test_diode_label_polarity_through_eeschema(rotation):
    """Snap-labelled K must show up on pin 1 in the kicad-cli netlist."""
    with tempfile.TemporaryDirectory() as td:
        sch_path, expected = _build_diode_case(Path(td), rotation)
        actual = _run_netlist(sch_path)
        for (ref, pin), net in expected.items():
            assert actual.get((ref, pin)) == net, (
                f"rotation={rotation}: D1.{pin} label landed on wrong pin. "
                f"Expected net={net}, got {actual.get((ref, pin))}. "
                f"Full mapping: {actual}"
            )


def _build_diode_rot_mirror_case(tmp: Path, rotation: int, mirror: str) -> tuple[Path, dict]:
    """Place a Device:D, apply BOTH rotation and mirror, snap labels by PinLocator coords."""
    import sexpdata

    sch_path = tmp / f"diode_rot{rotation}_mir{mirror}.kicad_sch"
    template = PYTHON_DIR / "templates" / "template_with_symbols.kicad_sch"
    shutil.copy(template, sch_path)

    sch = SchematicManager.load_schematic(str(sch_path))
    ComponentManager.add_component(
        sch,
        {"type": "D", "reference": "D1", "value": "1N4148", "x": 100.0, "y": 100.0, "rotation": 0},
        sch_path,
    )
    SchematicManager.save_schematic(sch, str(sch_path))

    # add_component drops a 'mirror' kwarg, so set rotation + mirror via the same
    # low-level helper the rotate handler uses.
    sch_data = sexpdata.loads(sch_path.read_text())
    if not WireDragger.update_symbol_rotation_mirror(sch_data, "D1", rotation, mirror):
        raise RuntimeError(f"Failed to set rotation={rotation} mirror={mirror} on D1")
    sch_path.write_text(sexpdata.dumps(sch_data))
    if f"(mirror {mirror})" not in sch_path.read_text():
        raise RuntimeError(
            f"Fixture failed to write (mirror {mirror}) — the oracle would "
            f"silently match our coords for an unmirrored symbol."
        )

    locator = PinLocator()
    p_k = locator.get_pin_location(sch_path, "D1", "1")
    p_a = locator.get_pin_location(sch_path, "D1", "2")
    assert p_k is not None and p_a is not None
    _add_labels_to_file(sch_path, [("D1_K", p_k[0], p_k[1]), ("D1_A", p_a[0], p_a[1])])
    return sch_path, {("D1", "1"): "D1_K", ("D1", "2"): "D1_A"}


@pytest.mark.parametrize("rotation", [0, 90, 180, 270])
@pytest.mark.parametrize("mirror", ["x", "y"])
def test_diode_polarity_rotation_and_mirror_through_eeschema(rotation, mirror):
    """Polarized symbol with rotation AND mirror together.

    Regression for the pin_world_xy mirror/rotation ORDER bug: the mirror was
    applied before the rotation, so rotation 90/270 combined with any mirror
    swapped the pins. The snapped "_K" label must still bind to pin 1 (K) in
    eeschema's own netlist. (rotation-only and mirror-only are covered above; the
    combination is the case the previous tests missed.)
    """
    with tempfile.TemporaryDirectory() as td:
        sch_path, expected = _build_diode_rot_mirror_case(Path(td), rotation, mirror)
        actual = _run_netlist(sch_path)
        for (ref, pin), net in expected.items():
            assert actual.get((ref, pin)) == net, (
                f"rotation={rotation}, mirror={mirror}: D1.{pin} label landed on "
                f"the wrong pin. Expected net={net}, got {actual.get((ref, pin))}. "
                f"Full mapping: {actual}"
            )


@pytest.mark.parametrize("axis", ["x", "y"])
def test_mirrored_resistor_label_through_eeschema(axis):
    """Snap-labelled pin 1 must show up on pin 1 after (mirror x) / (mirror y)."""
    with tempfile.TemporaryDirectory() as td:
        sch_path, expected = _build_mirror_case(Path(td), axis)
        actual = _run_netlist(sch_path)
        for (ref, pin), net in expected.items():
            assert actual.get((ref, pin)) == net, (
                f"mirror={axis}: R1.{pin} label landed on wrong pin. "
                f"Expected net={net}, got {actual.get((ref, pin))}. "
                f"Full mapping: {actual}"
            )


def test_pin_world_xy_rot90_matches_eeschema_transform():
    """Pure-math regression: pin_world_xy for Device:R rot=90 must match
    eeschema's TRANSFORM(0,1,-1,0) applied to internal Y-flipped pin."""
    # Device:R pin 1: lib (0, +3.81). parseXY(invertY=true) → internal (0, -3.81).
    # TRANSFORM(0,1,-1,0) applied: (0*0 + 1*-3.81, -1*0 + 0*-3.81) = (-3.81, 0).
    # Symbol at (100, 100) → world (96.19, 100).
    wx, wy = WireDragger.pin_world_xy(0.0, 3.81, 100.0, 100.0, 90, False, False)
    assert wx == pytest.approx(96.19), f"rot=90 X wrong: {wx} (expected 96.19)"
    assert wy == pytest.approx(100.0), f"rot=90 Y wrong: {wy} (expected 100.0)"


def test_pin_world_xy_mirror_x_matches_eeschema():
    """(mirror x) = SYM_MIRROR_X = TRANSFORM(1,0,0,-1) → negates internal Y.
    Device:R pin 1 lib (0, +3.81) → internal (0, -3.81) → mirror_x → (0, 3.81)
        → symbol (100, 100) → world (100, 103.81). Pin should NOT be at (100, 96.19)."""
    wx, wy = WireDragger.pin_world_xy(0.0, 3.81, 100.0, 100.0, 0, True, False)
    assert wx == pytest.approx(100.0)
    assert wy == pytest.approx(103.81), f"mirror_x Y wrong: {wy} (expected 103.81)"


def test_pin_world_xy_mirror_y_matches_eeschema():
    """(mirror y) = SYM_MIRROR_Y = TRANSFORM(-1,0,0,1) → negates internal X.
    Device:R pin 1 lib (0, +3.81) → internal (0, -3.81) → mirror_y → (0, -3.81)
        → world (100, 96.19). Y of pin 1 is unchanged by mirror across Y axis."""
    wx, wy = WireDragger.pin_world_xy(0.0, 3.81, 100.0, 100.0, 0, False, True)
    assert wx == pytest.approx(100.0)
    assert wy == pytest.approx(96.19), f"mirror_y Y wrong: {wy} (expected 96.19)"
