"""
Tests for move_schematic_component with wire preservation (WireDragger).

Unit tests use synthetic sexpdata lists — no disk I/O, no KiCAD install needed.
Integration tests copy empty.kicad_sch to a tempdir and exercise the full handler.
"""

import shutil
import sys
import tempfile
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch

import pytest
import sexpdata
from sexpdata import Symbol

# Make python/ importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))

from commands.wire_dragger import EPS, WireDragger, _coords_match, _rotate

TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "python" / "templates" / "empty.kicad_sch"


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _sym(name: str) -> Symbol:
    return Symbol(name)


def _make_wire(x1: Any, y1: Any, x2: Any, y2: Any) -> Any:
    return [
        _sym("wire"),
        [_sym("pts"), [_sym("xy"), x1, y1], [_sym("xy"), x2, y2]],
        [_sym("stroke"), [_sym("width"), 0], [_sym("type"), _sym("default")]],
        [_sym("uuid"), "00000000-0000-0000-0000-000000000000"],
    ]


def _make_junction(x: Any, y: Any) -> Any:
    return [
        _sym("junction"),
        [_sym("at"), x, y],
        [_sym("diameter"), 0],
        [_sym("color"), 0, 0, 0, 0],
        [_sym("uuid"), "00000000-0000-0000-0000-000000000001"],
    ]


def _make_symbol(
    ref: Any, x: Any, y: Any, rotation: Any = 0, lib_id: str = "Device:R", mirror: Any = None
) -> Any:
    """Build a minimal placed-symbol s-expression."""
    item = [
        _sym("symbol"),
        [_sym("lib_id"), lib_id],
        [_sym("at"), x, y, rotation],
        [_sym("unit"), 1],
        [_sym("property"), "Reference", ref, [_sym("at"), x + 2, y, 0]],
        [_sym("property"), "Value", "10k", [_sym("at"), x, y, 0]],
    ]
    if mirror:
        item.append([_sym("mirror"), _sym(mirror)])
    return item


def _make_lib_symbol_r() -> Any:
    """Minimal Device:R lib_symbols entry — pins at (0, 3.81) and (0, -3.81)."""
    return [
        _sym("symbol"),
        "Device:R",
        [
            _sym("symbol"),
            "R_1_1",
            [
                _sym("pin"),
                _sym("passive"),
                _sym("line"),
                [_sym("at"), 0, 3.81, 270],
                [_sym("length"), 1.27],
                [
                    _sym("name"),
                    "~",
                    [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
                ],
                [
                    _sym("number"),
                    "1",
                    [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
                ],
            ],
            [
                _sym("pin"),
                _sym("passive"),
                _sym("line"),
                [_sym("at"), 0, -3.81, 90],
                [_sym("length"), 1.27],
                [
                    _sym("name"),
                    "~",
                    [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
                ],
                [
                    _sym("number"),
                    "2",
                    [_sym("effects"), [_sym("font"), [_sym("size"), 1.27, 1.27]]],
                ],
            ],
        ],
    ]


def _make_sch_data(extra_items: Any = None) -> Any:
    """Build a minimal sch_data list with lib_symbols and sheet_instances."""
    data = [
        _sym("kicad_sch"),
        [_sym("lib_symbols"), _make_lib_symbol_r()],
        [_sym("sheet_instances"), [_sym("path"), "/", [_sym("page"), "1"]]],
    ]
    if extra_items:
        # Insert before sheet_instances (last item)
        for item in extra_items:
            data.insert(len(data) - 1, item)
    return data


# ---------------------------------------------------------------------------
# TestRotatePoint
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestRotatePoint:
    def test_zero_rotation(self) -> None:
        assert _rotate(1.0, 2.0, 0) == (1.0, 2.0)

    def test_90_degrees(self) -> None:
        rx, ry = _rotate(1.0, 0.0, 90)
        assert abs(rx - 0.0) < 1e-9
        assert abs(ry - 1.0) < 1e-9

    def test_180_degrees(self) -> None:
        rx, ry = _rotate(1.0, 0.0, 180)
        assert abs(rx - (-1.0)) < 1e-9
        assert abs(ry - 0.0) < 1e-9

    def test_270_degrees(self) -> None:
        rx, ry = _rotate(0.0, 1.0, 270)
        assert abs(rx - 1.0) < 1e-6
        assert abs(ry - 0.0) < 1e-6


# ---------------------------------------------------------------------------
# TestFindSymbol
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestFindSymbol:
    def test_returns_none_for_missing_reference(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 10, 20)])
        assert WireDragger.find_symbol(sch, "R99") is None

    def test_returns_item_and_position(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 10.5, 20.5, rotation=90)])
        result = WireDragger.find_symbol(sch, "R1")
        assert result is not None
        _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = result
        assert abs(old_x - 10.5) < EPS
        assert abs(old_y - 20.5) < EPS
        assert abs(rotation - 90) < EPS
        assert lib_id == "Device:R"
        assert mirror_x is False
        assert mirror_y is False

    def test_detects_mirror_x(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="x")])
        result = WireDragger.find_symbol(sch, "R1")
        assert result is not None
        assert result[5] is True  # mirror_x
        assert result[6] is False  # mirror_y

    def test_detects_mirror_y(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 0, 0, mirror="y")])
        result = WireDragger.find_symbol(sch, "R1")
        assert result is not None
        assert result[5] is False  # mirror_x
        assert result[6] is True  # mirror_y


# ---------------------------------------------------------------------------
# TestComputePinPositions
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestComputePinPositions:
    def test_resistor_at_origin_no_rotation(self) -> None:
        """Device:R at (0, 0) rot=0. Lib pins are Y-up; schematic is Y-down,
        so pin 1 (lib y=+3.81) lands at world y=-3.81 and pin 2 (lib y=-3.81)
        at world y=+3.81. See test_pin_locator_y_flip for the canonical case."""
        sch = _make_sch_data([_make_symbol("R1", 0, 0)])
        positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
        assert "1" in positions and "2" in positions
        old1, new1 = positions["1"]
        old2, new2 = positions["2"]
        # Pin 1 old: (0, 0 - 3.81)
        assert abs(old1[0] - 0) < 1e-4
        assert abs(old1[1] - (-3.81)) < 1e-4
        # Pin 2 old: (0, 0 + 3.81)
        assert abs(old2[0] - 0) < 1e-4
        assert abs(old2[1] - 3.81) < 1e-4
        # New positions shifted by (10, 20)
        assert abs(new1[0] - 10) < 1e-4
        assert abs(new1[1] - 16.19) < 1e-4
        assert abs(new2[0] - 10) < 1e-4
        assert abs(new2[1] - 23.81) < 1e-4

    def test_resistor_rotated_90(self) -> None:
        """Device:R at (100, 100) rot=90. Pin 1 lib (0, +3.81), Y-flip → (0, -3.81),
        eeschema rot=90 TRANSFORM(0,1,-1,0): (0*0+1*-3.81, -1*0+0*-3.81) = (-3.81, 0).
        World (96.19, 100). Verified vs kicad-cli netlist."""
        sch = _make_sch_data([_make_symbol("R1", 100, 100, rotation=90)])
        positions = WireDragger.compute_pin_positions(sch, "R1", 100, 100)
        old1, _ = positions["1"]
        old2, _ = positions["2"]
        assert abs(old1[0] - 96.19) < 1e-3
        assert abs(old1[1] - 100) < 1e-3

    def test_returns_empty_for_missing_component(self) -> None:
        sch = _make_sch_data()
        result = WireDragger.compute_pin_positions(sch, "MISSING", 0, 0)
        assert result == {}

    def test_delta_is_consistent(self) -> None:
        """new_xy - old_xy should equal (new_x - old_x, new_y - old_y) for any rotation."""
        sch = _make_sch_data([_make_symbol("R1", 50, 50, rotation=45)])
        positions = WireDragger.compute_pin_positions(sch, "R1", 60, 70)
        for pin_num, (old_xy, new_xy) in positions.items():
            dx = new_xy[0] - old_xy[0]
            dy = new_xy[1] - old_xy[1]
            assert abs(dx - 10) < 1e-4, f"Pin {pin_num}: dx={dx}"
            assert abs(dy - 20) < 1e-4, f"Pin {pin_num}: dy={dy}"


# ---------------------------------------------------------------------------
# TestDragWires
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestDragWires:
    def test_no_wires_returns_zero_counts(self) -> None:
        sch = _make_sch_data()
        result = WireDragger.drag_wires(sch, {(0.0, 0.0): (10.0, 10.0)})
        assert result["endpoints_moved"] == 0
        assert result["wires_removed"] == 0

    def test_wire_start_endpoint_moved(self) -> None:
        wire = _make_wire(0, 3.81, 0, 10)
        sch = _make_sch_data([wire])
        result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
        assert result["endpoints_moved"] == 1
        assert result["wires_removed"] == 0
        # Find the updated wire in sch_data
        updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire"))
        pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts"))
        xy1 = pts[1]
        assert abs(xy1[1] - 10.0) < EPS
        assert abs(xy1[2] - 23.81) < EPS

    def test_wire_end_endpoint_moved(self) -> None:
        wire = _make_wire(0, 10, 0, -3.81)
        sch = _make_sch_data([wire])
        result = WireDragger.drag_wires(sch, {(0.0, -3.81): (10.0, 16.19)})
        assert result["endpoints_moved"] == 1
        updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire"))
        pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts"))
        xy2 = pts[2]
        assert abs(xy2[1] - 10.0) < EPS
        assert abs(xy2[2] - 16.19) < EPS

    def test_zero_length_wire_removed(self) -> None:
        """When both endpoints of a wire are moved to the same point, wire is deleted."""
        wire = _make_wire(0, 3.81, 0, -3.81)
        sch = _make_sch_data([wire])
        # Both pins land at same position (degenerate move)
        result = WireDragger.drag_wires(
            sch,
            {
                (0.0, 3.81): (5.0, 5.0),
                (0.0, -3.81): (5.0, 5.0),
            },
        )
        assert result["wires_removed"] == 1
        wires_remaining = [i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire")]
        assert len(wires_remaining) == 0

    def test_unrelated_wire_not_touched(self) -> None:
        """A wire whose endpoints don't match any old pin is not changed."""
        wire = _make_wire(50, 50, 60, 50)
        sch = _make_sch_data([wire])
        original_start = (50.0, 50.0)
        result = WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
        assert result["endpoints_moved"] == 0
        updated = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("wire"))
        pts = next(s for s in updated[1:] if isinstance(s, list) and s and s[0] == Symbol("pts"))
        xy1 = pts[1]
        assert abs(xy1[1] - 50.0) < EPS
        assert abs(xy1[2] - 50.0) < EPS

    def test_both_endpoints_on_moved_component(self) -> None:
        """Wire connecting two pins of same component — both endpoints shift together."""
        wire = _make_wire(0, 3.81, 0, -3.81)
        sch = _make_sch_data([wire])
        result = WireDragger.drag_wires(
            sch,
            {
                (0.0, 3.81): (10.0, 23.81),
                (0.0, -3.81): (10.0, 16.19),
            },
        )
        assert result["endpoints_moved"] == 2
        assert result["wires_removed"] == 0

    def test_junction_moved_with_endpoint(self) -> None:
        junction = _make_junction(0, 3.81)
        sch = _make_sch_data([junction])
        WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
        updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction"))
        at_sub = next(
            s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at")
        )
        assert abs(at_sub[1] - 10.0) < EPS
        assert abs(at_sub[2] - 23.81) < EPS

    def test_junction_at_unrelated_position_not_touched(self) -> None:
        junction = _make_junction(99, 99)
        sch = _make_sch_data([junction])
        WireDragger.drag_wires(sch, {(0.0, 3.81): (10.0, 23.81)})
        updated_j = next(i for i in sch if isinstance(i, list) and i and i[0] == Symbol("junction"))
        at_sub = next(
            s for s in updated_j[1:] if isinstance(s, list) and s and s[0] == Symbol("at")
        )
        assert abs(at_sub[1] - 99.0) < EPS
        assert abs(at_sub[2] - 99.0) < EPS


# ---------------------------------------------------------------------------
# TestUpdateSymbolPosition
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestUpdateSymbolPosition:
    def test_updates_position(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 10, 20)])
        result = WireDragger.update_symbol_position(sch, "R1", 30, 40)
        assert result is True
        found = WireDragger.find_symbol(sch, "R1")
        assert abs(found[1] - 30) < EPS
        assert abs(found[2] - 40) < EPS

    def test_returns_false_for_missing(self) -> None:
        sch = _make_sch_data()
        assert WireDragger.update_symbol_position(sch, "MISSING", 0, 0) is False

    def test_preserves_rotation(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 10, 20, rotation=90)])
        WireDragger.update_symbol_position(sch, "R1", 30, 40)
        found = WireDragger.find_symbol(sch, "R1")
        assert abs(found[3] - 90) < EPS  # rotation preserved

    def test_property_labels_follow_symbol_move(self) -> None:
        """Property (at ...) positions must shift by the same delta as the symbol."""
        sym = _make_symbol("R1", 100, 80)
        sch = _make_sch_data([sym])

        # Record initial property positions
        prop_k = _sym("property")
        at_k = _sym("at")
        initial_positions = {}
        for sub in sym[1:]:
            if isinstance(sub, list) and sub and sub[0] == prop_k:
                name = sub[1]
                for psub in sub[2:]:
                    if isinstance(psub, list) and psub and psub[0] == at_k:
                        initial_positions[name] = (psub[1], psub[2])
                        break
        assert len(initial_positions) >= 2  # Reference and Value at minimum

        # Move component from (100, 80) to (120, 100) — delta (20, 20)
        result = WireDragger.update_symbol_position(sch, "R1", 120, 100)
        assert result is True

        # Verify each property shifted by (20, 20)
        for sub in sym[1:]:
            if isinstance(sub, list) and sub and sub[0] == prop_k:
                name = sub[1]
                for psub in sub[2:]:
                    if isinstance(psub, list) and psub and psub[0] == at_k:
                        expected_x = initial_positions[name][0] + 20
                        expected_y = initial_positions[name][1] + 20
                        assert (
                            abs(psub[1] - expected_x) < EPS
                        ), f"{name} x: expected {expected_x}, got {psub[1]}"
                        assert (
                            abs(psub[2] - expected_y) < EPS
                        ), f"{name} y: expected {expected_y}, got {psub[2]}"
                        break


# ---------------------------------------------------------------------------
# Integration tests
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestMoveWithWirePreservation:
    """Integration tests using a real .kicad_sch file."""

    def _make_schematic(self, extra_sexp: Any = "") -> Any:
        """Copy empty.kicad_sch to a temp file and optionally append content."""
        tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
        shutil.copy(TEMPLATE_PATH, tmp)
        if extra_sexp:
            content = tmp.read_text(encoding="utf-8")
            idx = content.rfind(")")
            content = content[:idx] + "\n" + extra_sexp + "\n)"
            tmp.write_text(content, encoding="utf-8")
        return tmp

    def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path:
        """Append a Device:R symbol to the schematic file."""
        import uuid

        u = str(uuid.uuid4())
        sexp = f"""
  (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
    (in_bom yes) (on_board yes) (dnp no)
    (uuid "{u}")
    (property "Reference" "{ref}" (at {x + 2.032} {y} 90)
      (effects (font (size 1.27 1.27)))
    )
    (property "Value" "10k" (at {x} {y} 90)
      (effects (font (size 1.27 1.27)))
    )
    (property "Footprint" "" (at {x - 1.778} {y} 90)
      (effects (font (size 1.27 1.27)) hide)
    )
    (property "Datasheet" "~" (at {x} {y} 0)
      (effects (font (size 1.27 1.27)) hide)
    )
    (pin "1" (uuid "{uuid.uuid4()}"))
    (pin "2" (uuid "{uuid.uuid4()}"))
    (instances (project "test" (path "/" (reference "{ref}") (unit 1))))
  )"""
        content = path.read_text(encoding="utf-8")
        idx = content.rfind(")")
        path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
        return path

    def _add_wire(self, path: Path, x1: float, y1: float, x2: float, y2: float) -> Path:
        """Append a wire to the schematic file."""
        import uuid

        wire_sexp = f"""
  (wire (pts (xy {x1} {y1}) (xy {x2} {y2}))
    (stroke (width 0) (type default))
    (uuid "{uuid.uuid4()}")
  )"""
        content = path.read_text(encoding="utf-8")
        idx = content.rfind(")")
        path.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")
        return path

    def _parse_wires(self, path: Path) -> Any:
        """Return list of ((x1,y1),(x2,y2)) for every wire in the file."""
        content = path.read_text(encoding="utf-8")
        data = sexpdata.loads(content)
        wires = []
        for item in data:
            if not (isinstance(item, list) and item and item[0] == Symbol("wire")):
                continue
            pts = next(
                (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")),
                None,
            )
            if pts is None:
                continue
            xys = [
                p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
            ]
            if len(xys) >= 2:
                wires.append(
                    (
                        (float(xys[0][1]), float(xys[0][2])),
                        (float(xys[-1][1]), float(xys[-1][2])),
                    )
                )
        return wires

    def _get_symbol_pos(self, path: Path, ref: str) -> Any:
        content = path.read_text(encoding="utf-8")
        data = sexpdata.loads(content)
        found = WireDragger.find_symbol(data, ref)
        if found is None:
            return None
        return found[1], found[2]

    def test_symbol_position_updated(self) -> None:
        sch = self._make_schematic()
        self._add_resistor(sch, "R1", 100, 100)
        # Call handler directly
        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 120, "y": 130},
            },
        )
        assert result["success"], result.get("message")
        pos = self._get_symbol_pos(sch, "R1")
        assert abs(pos[0] - 120) < EPS
        assert abs(pos[1] - 130) < EPS

    def test_connected_wire_endpoint_follows_pin(self) -> None:
        """Wire endpoint at pin 1 of R1 should move with the component."""
        sch = self._make_schematic()
        # R1 at (100, 100) — pin 1 at (100, 103.81)
        self._add_resistor(sch, "R1", 100, 100)
        self._add_wire(sch, 100, 103.81, 100, 120)  # wire from pin 1 upward

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 100},
            },
        )
        assert result["success"], result.get("message")
        assert result["wiresMoved"] >= 1

        wires = self._parse_wires(sch)
        assert len(wires) == 1
        # Pin 1 new world position: (110 + 0, 100 + 3.81) = (110, 103.81)
        w = wires[0]
        endpoints = {w[0], w[1]}
        new_pin1 = (110.0, 103.81)
        assert any(
            abs(ep[0] - new_pin1[0]) < 0.01 and abs(ep[1] - new_pin1[1]) < 0.01 for ep in endpoints
        ), f"Expected pin endpoint near {new_pin1}, got {endpoints}"

    def test_unrelated_wire_unchanged(self) -> None:
        """A wire not connected to R1 must not be modified."""
        sch = self._make_schematic()
        self._add_resistor(sch, "R1", 100, 100)
        self._add_wire(sch, 50, 50, 60, 50)  # unrelated wire

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 110},
            },
        )

        wires = self._parse_wires(sch)
        unrelated = [(s, e) for s, e in wires if abs(s[0] - 50) < 0.01 and abs(s[1] - 50) < 0.01]
        assert len(unrelated) == 1

    def test_no_zero_length_wires_after_move(self) -> None:
        """No zero-length wires should appear in the file after a move."""
        sch = self._make_schematic()
        self._add_resistor(sch, "R1", 100, 100)
        # Wire from pin 1 to pin 2 of same component (intra-component wire)
        self._add_wire(sch, 100, 103.81, 100, 96.19)

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 100},
            },
        )

        wires = self._parse_wires(sch)
        for start, end in wires:
            assert not (
                abs(start[0] - end[0]) < EPS and abs(start[1] - end[1]) < EPS
            ), f"Zero-length wire found at {start}"

    def test_preserve_wires_false_skips_wire_update(self) -> None:
        """preserveWires=False should move the symbol but leave wires alone."""
        sch = self._make_schematic()
        self._add_resistor(sch, "R1", 100, 100)
        self._add_wire(sch, 100, 103.81, 100, 120)

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 100},
                "preserveWires": False,
            },
        )
        assert result["success"]
        assert result["wiresMoved"] == 0

        # Wire should still start at old pin position
        wires = self._parse_wires(sch)
        assert len(wires) == 1
        endpoints = {wires[0][0], wires[0][1]}
        old_pin1 = (100.0, 103.81)
        assert any(
            abs(ep[0] - old_pin1[0]) < 0.01 and abs(ep[1] - old_pin1[1]) < 0.01 for ep in endpoints
        ), f"Wire should still be at {old_pin1}, got {endpoints}"

    def test_missing_component_returns_error(self) -> None:
        sch = self._make_schematic()
        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "NOTHERE",
                "position": {"x": 0, "y": 0},
            },
        )
        assert not result["success"]
        assert "not found" in result.get("message", "").lower()


# ---------------------------------------------------------------------------
# TestSynthesizeTouchingPinWires  (unit)
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestSynthesizeTouchingPinWires:
    """Unit tests for WireDragger.synthesize_touching_pin_wires."""

    def _make_two_resistors(self, r1_x: Any, r1_y: Any, r2_x: Any, r2_y: Any) -> Any:
        """Build sch_data with R1 and R2, each Device:R."""
        return _make_sch_data(
            [
                _make_symbol("R1", r1_x, r1_y),
                _make_symbol("R2", r2_x, r2_y),
            ]
        )

    def test_no_stationary_symbols_returns_zero(self) -> None:
        """With only the moved component in sch_data, nothing is synthesized."""
        sch = _make_sch_data([_make_symbol("R1", 0, 0)])
        pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 20)
        count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
        assert count == 0

    def test_touching_pin_gap_generates_wire(self) -> None:
        """
        With Y-flip applied (lib Y-up → schematic Y-down):
          R1 at (0, 0) pin2 (lib y=-3.81) lands at world (0, +3.81).
          R2 at (0, +7.62) pin1 (lib y=+3.81) lands at world (0, +3.81).  ← pins touch
        Moving R1 to (10, 0) drags pin2 to (10, +3.81).
        A wire from (0, +3.81) to (10, +3.81) should be synthesized.
        """
        sch = self._make_two_resistors(0, 0, 0, 7.62)

        pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
        old2, new2 = pin_positions["2"]
        assert abs(old2[0] - 0) < 1e-3 and abs(old2[1] - 3.81) < 1e-3
        assert abs(new2[0] - 10) < 1e-3 and abs(new2[1] - 3.81) < 1e-3

        wire_count_before = sum(
            1 for item in sch if isinstance(item, list) and item and item[0] == _sym("wire")
        )
        count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
        assert count == 1, f"Expected 1 synthesized wire, got {count}"

        wires = [
            item for item in sch if isinstance(item, list) and item and item[0] == _sym("wire")
        ]
        assert len(wires) == wire_count_before + 1

        new_wire = wires[-1]
        pts = next(s for s in new_wire[1:] if isinstance(s, list) and s and s[0] == _sym("pts"))
        xys = [p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == _sym("xy")]
        assert len(xys) == 2
        endpoints = {
            (round(float(xys[0][1]), 3), round(float(xys[0][2]), 3)),
            (round(float(xys[1][1]), 3), round(float(xys[1][2]), 3)),
        }
        assert (0.0, 3.81) in endpoints, f"Expected (0, 3.81) in wire endpoints, got {endpoints}"
        assert (
            10.0,
            3.81,
        ) in endpoints, f"Expected (10, 3.81) in wire endpoints, got {endpoints}"

    def test_no_wire_when_pin_didnt_move(self) -> None:
        """
        If old_xy == new_xy for a touching pin (component moved but this pin stayed put),
        no wire should be synthesized.
        """
        # R1 at (0, 0), R2 at (0, -7.62) — pin2 of R1 and pin1 of R2 touch at (0, -3.81)
        sch = self._make_two_resistors(0, 0, 0, -7.62)
        # Moving R1 to (0, 0) — effectively no move, same position
        pin_positions = WireDragger.compute_pin_positions(sch, "R1", 0, 0)
        count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
        assert count == 0

    def test_no_wire_when_rejoins_other_stationary_pin(self) -> None:
        """
        If the moved pin's new position coincides with another stationary pin,
        no wire should be synthesized (they touch again).
        """
        # R1 at (0, 0), R2 at (0, -7.62), R3 at (10, -7.62)
        # R1 pin2 was touching R2 pin1 at (0, -3.81).
        # Moving R1 to (10, 0): pin2 lands at (10, -3.81) which is R3 pin1.
        sch = _make_sch_data(
            [
                _make_symbol("R1", 0, 0),
                _make_symbol("R2", 0, -7.62),
                _make_symbol("R3", 10, -7.62),
            ]
        )
        pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
        count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
        assert count == 0, f"Expected 0 synthesized wires (rejoined), got {count}"

    def test_empty_pin_positions_returns_zero(self) -> None:
        sch = _make_sch_data([_make_symbol("R1", 0, 0)])
        count = WireDragger.synthesize_touching_pin_wires(sch, "R1", {})
        assert count == 0

    def test_non_touching_pins_not_affected(self) -> None:
        """
        When R1 and R2 are NOT touching (different positions), no wire is synthesized.
        """
        # R1 at (0, 0), R2 at (100, 100) — far apart
        sch = self._make_two_resistors(0, 0, 100, 100)
        pin_positions = WireDragger.compute_pin_positions(sch, "R1", 10, 0)
        count = WireDragger.synthesize_touching_pin_wires(sch, "R1", pin_positions)
        assert count == 0


# ---------------------------------------------------------------------------
# TestOldToNewCollision  (unit) — regression for the duplicate-pin-position bug
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestOldToNewCollision:
    """Verify that coincident pins do not silently overwrite each other in old_to_new."""

    def test_handler_logs_warning_on_collision(self) -> None:
        """
        When two pins share the same old position, a warning should be logged
        and the *first* mapping should be kept (not overwritten by the second).
        """
        import logging

        # Capture on the "kicad_interface" logger with our own handler rather
        # than pytest's ``caplog``: the package calls
        # ``logging.basicConfig(..., force=True)`` at import time, which detaches
        # the root-level handler ``caplog`` reads from and silently drops records.
        records = []

        class _Capture(logging.Handler):
            def emit(self, record: logging.LogRecord) -> None:
                records.append(record)

        kicad_logger = logging.getLogger("kicad_interface")
        handler = _Capture(level=logging.WARNING)
        prev_level = kicad_logger.level
        prev_propagate = kicad_logger.propagate
        kicad_logger.addHandler(handler)
        kicad_logger.setLevel(logging.WARNING)
        kicad_logger.propagate = False  # keep the warning out of stderr/file handlers
        try:
            # Build a fake pin_positions dict with a deliberate collision
            pin_positions = {
                "1": ((0.0, 3.81), (10.0, 23.81)),
                "2": ((0.0, 3.81), (10.0, 16.19)),  # same old_xy as pin "1"
            }

            old_to_new = {}
            for _pin, (old_xy, new_xy) in pin_positions.items():
                if old_xy in old_to_new:
                    kicad_logger.warning(
                        f"move_schematic_component: pin {_pin!r} shares old position {old_xy} "
                        f"with another pin; keeping first entry, skipping duplicate"
                    )
                    continue
                old_to_new[old_xy] = new_xy
        finally:
            kicad_logger.removeHandler(handler)
            kicad_logger.setLevel(prev_level)
            kicad_logger.propagate = prev_propagate

        # Only one entry should exist, and it should be the first one
        assert len(old_to_new) == 1
        assert old_to_new[(0.0, 3.81)] == (10.0, 23.81)
        # Warning should have been logged
        assert any("skipping duplicate" in r.getMessage() for r in records)


# ---------------------------------------------------------------------------
# TestTouchingPinIntegration  (integration)
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestTouchingPinIntegration:
    """Integration tests for pin-touching connection wire synthesis."""

    def _make_schematic(self, extra_sexp: Any = "") -> Any:
        """Copy empty.kicad_sch to a temp file."""
        tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
        shutil.copy(TEMPLATE_PATH, tmp)
        if extra_sexp:
            content = tmp.read_text(encoding="utf-8")
            idx = content.rfind(")")
            content = content[:idx] + "\n" + extra_sexp + "\n)"
            tmp.write_text(content, encoding="utf-8")
        return tmp

    def _add_resistor(self, path: Path, ref: str, x: float, y: float, rotation: float = 0) -> Path:
        import uuid as _uuid

        u = str(_uuid.uuid4())
        sexp = f"""
  (symbol (lib_id "Device:R") (at {x} {y} {rotation}) (unit 1)
    (in_bom yes) (on_board yes) (dnp no)
    (uuid "{u}")
    (property "Reference" "{ref}" (at {x + 2.032} {y} 90)
      (effects (font (size 1.27 1.27)))
    )
    (property "Value" "10k" (at {x} {y} 90)
      (effects (font (size 1.27 1.27)))
    )
    (property "Footprint" "" (at {x - 1.778} {y} 90)
      (effects (font (size 1.27 1.27)) hide)
    )
    (property "Datasheet" "~" (at {x} {y} 0)
      (effects (font (size 1.27 1.27)) hide)
    )
    (pin "1" (uuid "{_uuid.uuid4()}"))
    (pin "2" (uuid "{_uuid.uuid4()}"))
    (instances (project "test" (path "/" (reference "{ref}") (unit 1))))
  )"""
        content = path.read_text(encoding="utf-8")
        idx = content.rfind(")")
        path.write_text(content[:idx] + "\n" + sexp + "\n)", encoding="utf-8")
        return path

    def _parse_wires(self, path: Path) -> Any:
        content = path.read_text(encoding="utf-8")
        data = sexpdata.loads(content)
        wires = []
        for item in data:
            if not (isinstance(item, list) and item and item[0] == Symbol("wire")):
                continue
            pts = next(
                (s for s in item[1:] if isinstance(s, list) and s and s[0] == Symbol("pts")),
                None,
            )
            if pts is None:
                continue
            xys = [
                p for p in pts[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
            ]
            if len(xys) >= 2:
                wires.append(
                    (
                        (float(xys[0][1]), float(xys[0][2])),
                        (float(xys[-1][1]), float(xys[-1][2])),
                    )
                )
        return wires

    def test_touching_pin_wire_created_on_move(self) -> None:
        """
        R1 at (100, 100) and R2 at (100, 92.38) share a touching pin:
          R1 pin2 = (100, 96.19), R2 pin1 = (100, 96.19).
        Moving R1 to (110, 100) should synthesize a wire from (100, 96.19) to (110, 96.19).
        """
        sch = self._make_schematic()
        # R1 pin2 world position = 100 + (-3.81) = 96.19
        # R2 pin1 world position = 92.38 + 3.81 = 96.19
        self._add_resistor(sch, "R1", 100, 100)
        self._add_resistor(sch, "R2", 100, 92.38)

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 100},
            },
        )
        assert result["success"], result.get("message")
        assert (
            result.get("wiresSynthesized", 0) >= 1
        ), f"Expected at least 1 synthesized wire, got {result}"

        wires = self._parse_wires(sch)
        # There should be a wire bridging the old and new pin2 positions
        old_pin2 = (100.0, 96.19)
        new_pin2 = (110.0, 96.19)
        bridging = [
            (s, e)
            for s, e in wires
            if (
                (
                    abs(s[0] - old_pin2[0]) < 0.05
                    and abs(s[1] - old_pin2[1]) < 0.05
                    and abs(e[0] - new_pin2[0]) < 0.05
                    and abs(e[1] - new_pin2[1]) < 0.05
                )
                or (
                    abs(e[0] - old_pin2[0]) < 0.05
                    and abs(e[1] - old_pin2[1]) < 0.05
                    and abs(s[0] - new_pin2[0]) < 0.05
                    and abs(s[1] - new_pin2[1]) < 0.05
                )
            )
        ]
        assert (
            len(bridging) >= 1
        ), f"Expected a bridging wire from {old_pin2} to {new_pin2}, got wires: {wires}"

    def test_no_wire_synthesized_when_no_touching_pins(self) -> None:
        """
        Two resistors with no pin overlap should not generate any synthesized wires.
        """
        sch = self._make_schematic()
        self._add_resistor(sch, "R1", 100, 100)
        self._add_resistor(sch, "R2", 150, 150)  # far away

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 100},
            },
        )
        assert result["success"], result.get("message")
        assert result.get("wiresSynthesized", 0) == 0

    def test_existing_wires_still_dragged_with_touching_pins(self) -> None:
        """
        When R1 has both an explicit wire AND a touching-pin connection,
        both should be handled: the wire dragged and the touching-pin bridged.
        """
        sch = self._make_schematic()
        # R1 at (100, 100), R2 at (100, 92.38) — pin2 of R1 touches pin1 of R2
        self._add_resistor(sch, "R1", 100, 100)
        self._add_resistor(sch, "R2", 100, 92.38)

        # Also add an explicit wire at pin1 of R1 (100, 103.81) going up
        import uuid as _uuid

        wire_sexp = f"""
  (wire (pts (xy 100 103.81) (xy 100 115))
    (stroke (width 0) (type default))
    (uuid "{_uuid.uuid4()}")
  )"""
        content = sch.read_text(encoding="utf-8")
        idx = content.rfind(")")
        sch.write_text(content[:idx] + "\n" + wire_sexp + "\n)", encoding="utf-8")

        sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))
        from kicad_interface import KiCADInterface

        iface = KiCADInterface()
        result = iface.handle_command(
            "move_schematic_component",
            {
                "schematicPath": str(sch),
                "reference": "R1",
                "position": {"x": 110, "y": 100},
            },
        )
        assert result["success"], result.get("message")
        assert result.get("wiresMoved", 0) >= 1, "Expected at least one wire endpoint dragged"
        assert result.get("wiresSynthesized", 0) >= 1, "Expected at least one touching-pin wire"
