"""
Tests for schematic analysis tools (Tools 2–5).

Unit tests use mock data / synthetic S-expressions.
Integration tests parse real .kicad_sch files via sexpdata.
"""

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

import pytest
import sexpdata
from sexpdata import Symbol

# Ensure the python/ package is importable
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "python"))

from commands.schematic_analysis import (
    _aabb_overlap,
    _check_wire_overlap,
    _compute_symbol_bbox_direct,
    _distance,
    _extract_lib_symbols,
    _line_segment_intersects_aabb,
    _load_sexp,
    _parse_labels,
    _parse_lib_symbol_graphics,
    _parse_symbols,
    _parse_wires,
    _point_in_rect,
    _transform_local_point,
    compute_symbol_bbox,
    find_orphaned_wires,
    find_overlapping_elements,
    find_wires_crossing_symbols,
    get_elements_in_region,
)

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

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


def _make_temp_schematic(extra_sexp: str = "") -> Path:
    """Copy empty.kicad_sch to a temp file and optionally append S-expression content."""
    tmp = Path(tempfile.mkdtemp()) / "test.kicad_sch"
    shutil.copy(TEMPLATE_PATH, tmp)
    if extra_sexp:
        content = tmp.read_text(encoding="utf-8")
        # Insert before the final closing paren
        idx = content.rfind(")")
        content = content[:idx] + "\n" + extra_sexp + "\n)"
        tmp.write_text(content, encoding="utf-8")
    return tmp


import uuid as _uuid


def _make_resistor_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
    """Generate a proper Device:R symbol S-expression that skip can parse."""
    u = str(_uuid.uuid4())
    return 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))
      )
    )
  )
"""


def _make_led_sexp(ref: str, x: float, y: float, rotation: float = 0) -> str:
    """Generate a proper Device:LED symbol S-expression (horizontal pin spread)."""
    u = str(_uuid.uuid4())
    return f"""
  (symbol (lib_id "Device:LED") (at {x} {y} {rotation}) (unit 1)
    (in_bom yes) (on_board yes) (dnp no)
    (uuid "{u}")
    (property "Reference" "{ref}" (at {x} {y - 2.54} 0)
      (effects (font (size 1.27 1.27)))
    )
    (property "Value" "LED" (at {x} {y + 2.54} 0)
      (effects (font (size 1.27 1.27)))
    )
    (property "Footprint" "" (at {x} {y} 0)
      (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))
      )
    )
  )
"""


# ===================================================================
# Unit tests — geometry helpers
# ===================================================================


class TestGeometryHelpers:
    """Test low-level geometry utilities."""

    def test_point_in_rect_inside(self) -> None:
        assert _point_in_rect(5, 5, 0, 0, 10, 10) is True

    def test_point_in_rect_outside(self) -> None:
        assert _point_in_rect(15, 5, 0, 0, 10, 10) is False

    def test_point_in_rect_boundary(self) -> None:
        assert _point_in_rect(0, 0, 0, 0, 10, 10) is True

    def test_distance_zero(self) -> None:
        assert _distance((0, 0), (0, 0)) == 0

    def test_distance_unit(self) -> None:
        assert abs(_distance((0, 0), (3, 4)) - 5.0) < 1e-9

    def test_aabb_intersection_crossing(self) -> None:
        # Line from (0,5) to (10,5) should intersect box (2,2)-(8,8)
        assert _line_segment_intersects_aabb(0, 5, 10, 5, 2, 2, 8, 8) is True

    def test_aabb_intersection_miss(self) -> None:
        # Line from (0,0) to (10,0) should miss box (2,2)-(8,8)
        assert _line_segment_intersects_aabb(0, 0, 10, 0, 2, 2, 8, 8) is False

    def test_aabb_intersection_inside(self) -> None:
        # Line entirely inside the box
        assert _line_segment_intersects_aabb(3, 3, 7, 7, 2, 2, 8, 8) is True

    def test_aabb_intersection_diagonal(self) -> None:
        # Diagonal line crossing through box
        assert _line_segment_intersects_aabb(0, 0, 10, 10, 2, 2, 8, 8) is True

    def test_aabb_intersection_parallel_outside(self) -> None:
        # Horizontal line above the box
        assert _line_segment_intersects_aabb(0, 9, 10, 9, 2, 2, 8, 8) is False

    def test_aabb_intersection_touching_edge(self) -> None:
        # Line ending exactly at box edge
        assert _line_segment_intersects_aabb(0, 2, 2, 2, 2, 2, 8, 8) is True


# ===================================================================
# Unit tests — S-expression parsers
# ===================================================================


class TestSexpParsers:
    """Test S-expression parsing functions with synthetic data."""

    def test_parse_wires_basic(self) -> None:
        sexp = sexpdata.loads("""(kicad_sch
            (wire (pts (xy 10 20) (xy 30 40))
                (stroke (width 0) (type default))
                (uuid "abc"))
        )""")
        wires = _parse_wires(sexp)
        assert len(wires) == 1
        assert wires[0]["start"] == (10.0, 20.0)
        assert wires[0]["end"] == (30.0, 40.0)

    def test_parse_wires_empty(self) -> None:
        sexp = sexpdata.loads("(kicad_sch)")
        assert _parse_wires(sexp) == []

    def test_parse_labels_both_types(self) -> None:
        sexp = sexpdata.loads("""(kicad_sch
            (label "VCC" (at 10 20 0))
            (global_label "GND" (at 30 40 0))
        )""")
        labels = _parse_labels(sexp)
        assert len(labels) == 2
        assert labels[0]["name"] == "VCC"
        assert labels[0]["type"] == "label"
        assert labels[1]["name"] == "GND"
        assert labels[1]["type"] == "global_label"

    def test_parse_symbols(self) -> None:
        sexp = sexpdata.loads("""(kicad_sch
            (symbol (lib_id "Device:R") (at 100 100 0)
                (property "Reference" "R1" (at 0 0 0)))
            (symbol (lib_id "power:VCC") (at 50 50 0)
                (property "Reference" "#PWR01" (at 0 0 0)))
        )""")
        symbols = _parse_symbols(sexp)
        assert len(symbols) == 2
        assert symbols[0]["reference"] == "R1"
        assert symbols[0]["is_power"] is False
        assert symbols[1]["reference"] == "#PWR01"
        assert symbols[1]["is_power"] is True


# ===================================================================
# Unit tests — analysis functions with mocked PinLocator
# ===================================================================


class TestAABBOverlap:
    """Test AABB overlap helper."""

    def test_overlapping_boxes(self) -> None:
        assert _aabb_overlap((0, 0, 10, 10), (5, 5, 15, 15)) is True

    def test_non_overlapping_boxes(self) -> None:
        assert _aabb_overlap((0, 0, 10, 10), (20, 20, 30, 30)) is False

    def test_touching_boxes_no_overlap(self) -> None:
        # Touching edges are not overlapping (strict inequality)
        assert _aabb_overlap((0, 0, 10, 10), (10, 0, 20, 10)) is False

    def test_contained_box(self) -> None:
        assert _aabb_overlap((0, 0, 20, 20), (5, 5, 15, 15)) is True

    def test_overlap_one_axis_only(self) -> None:
        # Overlap in X but not Y
        assert _aabb_overlap((0, 0, 10, 10), (5, 15, 15, 25)) is False


class TestFindOverlappingElements:
    """Test overlapping detection logic."""

    def test_no_overlaps_in_empty_schematic(self) -> None:
        tmp = _make_temp_schematic()
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert result["totalOverlaps"] == 0

    def test_overlapping_symbols_detected(self) -> None:
        # Two resistors at nearly the same position — bboxes fully overlap
        extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert result["totalOverlaps"] >= 1
        assert len(result["overlappingSymbols"]) >= 1

    def test_well_separated_symbols_not_flagged(self) -> None:
        extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200)
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert result["totalOverlaps"] == 0

    def test_collinear_wire_overlap(self) -> None:
        extra = """
        (wire (pts (xy 10 50) (xy 30 50))
            (stroke (width 0) (type default))
            (uuid "w1"))
        (wire (pts (xy 20 50) (xy 40 50))
            (stroke (width 0) (type default))
            (uuid "w2"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert len(result["overlappingWires"]) >= 1

    def test_overlapping_bodies_different_centers(self) -> None:
        """Two resistors whose bodies overlap even though centers are ~5mm apart.

        Device:R pins are at y ±3.81 relative to center, so the body spans
        ~7.62mm vertically. Two resistors at the same X but 5mm apart in Y
        have overlapping bodies — this is the bug the center-distance approach missed.
        """
        # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
        # These overlap in Y from 101.19 to 103.81
        extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105)
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert result["totalOverlaps"] >= 1, (
            "Should detect overlap when component bodies intersect, "
            "even if centers are far apart"
        )
        assert len(result["overlappingSymbols"]) >= 1

    def test_adjacent_resistors_no_overlap(self) -> None:
        """Two vertical resistors side by side should not overlap.

        R pins at y ±3.81, but different X positions far enough apart.
        """
        extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100)
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert result["totalOverlaps"] == 0

    def test_resistor_and_led_overlapping_bodies(self) -> None:
        """A resistor and an LED placed close enough that bodies overlap.

        LED pins at x ±3.81, R pins at y ±3.81. Place LED at same position
        as R — bodies clearly overlap.
        """
        extra = _make_resistor_sexp("R1", 100, 100) + _make_led_sexp("D1", 100, 100)
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert result["totalOverlaps"] >= 1


class TestGetElementsInRegion:
    """Test region query logic."""

    def test_elements_inside_region_found(self) -> None:
        extra = """
        (symbol (lib_id "Device:R") (at 50 50 0)
            (property "Reference" "R1" (at 0 0 0))
            (property "Value" "10k" (at 0 0 0)))
        (wire (pts (xy 45 50) (xy 55 50))
            (stroke (width 0) (type default))
            (uuid "w1"))
        (label "NET1" (at 50 50 0))
        """
        tmp = _make_temp_schematic(extra)
        result = get_elements_in_region(tmp, 40, 40, 60, 60)
        assert result["counts"]["symbols"] >= 1
        assert result["counts"]["wires"] >= 1
        assert result["counts"]["labels"] >= 1

    def test_elements_outside_region_excluded(self) -> None:
        extra = """
        (symbol (lib_id "Device:R") (at 200 200 0)
            (property "Reference" "R1" (at 0 0 0))
            (property "Value" "10k" (at 0 0 0)))
        """
        tmp = _make_temp_schematic(extra)
        result = get_elements_in_region(tmp, 0, 0, 50, 50)
        assert result["counts"]["symbols"] == 0


class TestComputeSymbolBbox:
    """Test bounding box computation."""

    def test_returns_none_for_unknown_symbol(self) -> None:
        tmp = _make_temp_schematic()
        from commands.pin_locator import PinLocator

        locator = PinLocator()
        result = compute_symbol_bbox(tmp, "NONEXISTENT", locator)
        assert result is None


# ===================================================================
# Integration tests — full schematic parsing
# ===================================================================


@pytest.mark.integration
class TestIntegrationFindWiresCrossingSymbols:
    """Integration test for wire crossing symbol detection."""

    def test_wire_not_touching_pins_is_collision(self) -> None:
        """A wire passing through a component bbox without pin contact → collision."""
        # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
        # Vertical wire from (100, 95) to (100, 105) crosses through the body
        # without touching either horizontal pin
        extra = _make_led_sexp("D1", 100, 100) + """
        (wire (pts (xy 100 95) (xy 100 105))
            (stroke (width 0) (type default))
            (uuid "w1"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_wires_crossing_symbols(tmp)
        d1_collisions = [c for c in result if c["component"]["reference"] == "D1"]
        assert len(d1_collisions) >= 1

    def test_unannotated_duplicates_not_over_reported(self) -> None:
        """
        Regression: two components with the same unannotated reference ("R?") at
        different positions should each produce independent bounding boxes.
        A wire crossing only one of them must produce exactly 1 collision, not 2.

        Before the fix, PinLocator.get_all_symbol_pins always resolved "R?" to
        the first match, so both symbols got identical bboxes and the same wire
        was counted against both.
        """
        # R? at (100, 100): Device:R pins are at (100, 96.19) and (100, 103.81).
        # Effective bbox (after expansion + margin) ≈ x=[99,101], y=[96.69,103.31].
        # R? at (200, 100): identical type but far away → no intersection with wire.
        r_at_100 = _make_resistor_sexp("R?", 100, 100)
        r_at_200 = _make_resistor_sexp("R?", 200, 100)
        # Horizontal wire crossing the body of the first R? only
        wire = """
        (wire (pts (xy 95 100) (xy 105 100))
            (stroke (width 0) (type default))
            (uuid "w-collision"))
        """
        tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
        result = find_wires_crossing_symbols(tmp)
        # The wire must not be reported against the far-away R? at (200, 100)
        collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5]
        assert len(collisions_at_200) == 0, (
            "Wire at x≈100 must not be flagged against the R? at x=200; "
            "likely caused by reference-lookup always returning the first 'R?'"
        )

    def test_wire_starting_at_pin_passing_through_body(self) -> None:
        """A wire that starts at a pin but continues through the component body
        must be flagged — this is the core bug where the old suppression logic
        treated any wire touching a pin as a valid connection."""
        # LED D1 at (100,100) → pin 1 at (96.19, 100), pin 2 at (103.81, 100)
        # Wire starts exactly at pin 1 and extends through the body to the right
        extra = _make_led_sexp("D1", 100, 100) + """
        (wire (pts (xy 96.19 100) (xy 110 100))
            (stroke (width 0) (type default))
            (uuid "w-through"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_wires_crossing_symbols(tmp)
        d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
        assert (
            len(d1_crossings) >= 1
        ), "Wire starting at pin but passing through body must be detected"

    def test_wire_terminating_at_pin_from_outside(self) -> None:
        """A wire that arrives at a pin from outside the component body
        is a valid connection and must NOT be flagged."""
        # LED D1 at (100,100) → pin 1 at (96.19, 100)
        # Wire comes from the left and terminates at pin 1
        extra = _make_led_sexp("D1", 100, 100) + """
        (wire (pts (xy 80 100) (xy 96.19 100))
            (stroke (width 0) (type default))
            (uuid "w-valid"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_wires_crossing_symbols(tmp)
        d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
        assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged"

    def test_wire_shorts_component_pins_detected_as_collision(self) -> None:
        """Regression: a wire connecting pin1→pin2 of the same component
        must be reported even though both endpoints land on pins."""
        r_sexp = _make_resistor_sexp("R_short", 100.0, 100.0)
        wire_sexp = (
            "(wire (pts (xy 100 103.81) (xy 100 96.19))\n"
            "  (stroke (width 0) (type default))\n"
            '  (uuid "aaaaaaaa-0000-0000-0000-000000000001"))'
        )
        sch = _make_temp_schematic(r_sexp + "\n" + wire_sexp)
        collisions = find_wires_crossing_symbols(sch)
        assert len(collisions) == 1
        w = collisions[0]["wire"]
        assert w["start"]["x"] == pytest.approx(100.0)
        assert w["start"]["y"] == pytest.approx(103.81)
        assert collisions[0]["component"]["reference"] == "R_short"


@pytest.mark.integration
class TestIntegrationGetElementsInRegion:
    """Integration test for region query."""

    def test_region_returns_pin_data(self) -> None:
        """Symbols in region should include pin position data."""
        extra = _make_resistor_sexp("R1", 100, 100)
        tmp = _make_temp_schematic(extra)
        result = get_elements_in_region(tmp, 90, 90, 110, 110)
        assert result["counts"]["symbols"] == 1
        sym = result["symbols"][0]
        assert "pins" in sym
        assert len(sym["pins"]) == 2  # Resistor has 2 pins

    def test_wire_passing_through_region_included(self) -> None:
        """A wire that passes through a region (no endpoints inside) should be included."""
        extra = """
        (wire (pts (xy 0 50) (xy 100 50))
            (stroke (width 0) (type default))
            (uuid "w-through"))
        """
        tmp = _make_temp_schematic(extra)
        result = get_elements_in_region(tmp, 40, 40, 60, 60)
        assert result["counts"]["wires"] == 1

    def test_wire_outside_region_excluded(self) -> None:
        """A wire entirely outside a region should not be included."""
        extra = """
        (wire (pts (xy 0 0) (xy 10 0))
            (stroke (width 0) (type default))
            (uuid "w-outside"))
        """
        tmp = _make_temp_schematic(extra)
        result = get_elements_in_region(tmp, 40, 40, 60, 60)
        assert result["counts"]["wires"] == 0


# ===================================================================
# Unit tests — _check_wire_overlap
# ===================================================================


class TestCheckWireOverlap:
    """Test wire overlap detection for horizontal, vertical, and diagonal cases."""

    def test_horizontal_overlap(self) -> None:
        w1 = {"start": (10, 50), "end": (30, 50)}
        w2 = {"start": (20, 50), "end": (40, 50)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is not None
        assert result["type"] == "collinear_overlap"

    def test_vertical_overlap(self) -> None:
        w1 = {"start": (50, 10), "end": (50, 30)}
        w2 = {"start": (50, 20), "end": (50, 40)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is not None
        assert result["type"] == "collinear_overlap"

    def test_diagonal_overlap(self) -> None:
        w1 = {"start": (0, 0), "end": (20, 20)}
        w2 = {"start": (10, 10), "end": (30, 30)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is not None
        assert result["type"] == "collinear_overlap"

    def test_horizontal_no_overlap(self) -> None:
        w1 = {"start": (10, 50), "end": (20, 50)}
        w2 = {"start": (30, 50), "end": (40, 50)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is None

    def test_parallel_offset_no_overlap(self) -> None:
        """Two parallel wires offset perpendicularly should not overlap."""
        w1 = {"start": (0, 0), "end": (20, 20)}
        w2 = {"start": (0, 5), "end": (20, 25)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is None

    def test_non_parallel_no_overlap(self) -> None:
        """Two wires at different angles should not overlap."""
        w1 = {"start": (0, 0), "end": (10, 10)}
        w2 = {"start": (0, 0), "end": (10, 0)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is None

    def test_zero_length_segment(self) -> None:
        w1 = {"start": (10, 10), "end": (10, 10)}
        w2 = {"start": (10, 10), "end": (20, 20)}
        result = _check_wire_overlap(w1, w2, 0.5)
        assert result is None


@pytest.mark.integration
class TestIntegrationDiagonalWireOverlap:
    """Integration tests for diagonal collinear wire overlap detection."""

    def test_diagonal_collinear_wire_overlap(self) -> None:
        """Two 45-degree wires that overlap should be detected."""
        extra = """
        (wire (pts (xy 0 0) (xy 20 20))
            (stroke (width 0) (type default))
            (uuid "w-diag1"))
        (wire (pts (xy 10 10) (xy 30 30))
            (stroke (width 0) (type default))
            (uuid "w-diag2"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert len(result["overlappingWires"]) >= 1

    def test_diagonal_parallel_no_overlap(self) -> None:
        """Two parallel 45-degree wires that are offset should not overlap."""
        extra = """
        (wire (pts (xy 0 0) (xy 20 20))
            (stroke (width 0) (type default))
            (uuid "w-diag1"))
        (wire (pts (xy 0 5) (xy 20 25))
            (stroke (width 0) (type default))
            (uuid "w-diag2"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert len(result["overlappingWires"]) == 0

    def test_diagonal_non_collinear_no_overlap(self) -> None:
        """Two wires at different angles crossing should not be flagged as collinear overlap."""
        extra = """
        (wire (pts (xy 0 0) (xy 20 20))
            (stroke (width 0) (type default))
            (uuid "w-diag1"))
        (wire (pts (xy 0 20) (xy 20 0))
            (stroke (width 0) (type default))
            (uuid "w-diag2"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_overlapping_elements(tmp, tolerance=0.5)
        assert len(result["overlappingWires"]) == 0


# ===================================================================
# Unit tests — _extract_lib_symbols
# ===================================================================


class TestExtractLibSymbols:
    """Test _extract_lib_symbols helper."""

    def test_extracts_pins_from_lib_symbols(self) -> None:
        sexp = sexpdata.loads("""(kicad_sch
            (lib_symbols
                (symbol "Device:R"
                    (symbol "Device:R_0_1"
                        (pin passive (at 0 3.81 270) (length 1.27)
                            (name "~" (effects (font (size 1.27 1.27))))
                            (number "1" (effects (font (size 1.27 1.27)))))
                        (pin passive (at 0 -3.81 90) (length 1.27)
                            (name "~" (effects (font (size 1.27 1.27))))
                            (number "2" (effects (font (size 1.27 1.27)))))))
            )
        )""")
        result = _extract_lib_symbols(sexp)
        assert "Device:R" in result
        pins = result["Device:R"]["pins"]
        assert "1" in pins
        assert "2" in pins
        assert pins["1"]["y"] == pytest.approx(3.81)

    def test_empty_schematic_returns_empty(self) -> None:
        sexp = sexpdata.loads("(kicad_sch)")
        result = _extract_lib_symbols(sexp)
        assert result == {}

    def test_no_lib_symbols_section(self) -> None:
        sexp = sexpdata.loads("""(kicad_sch
            (wire (pts (xy 0 0) (xy 10 10)))
        )""")
        result = _extract_lib_symbols(sexp)
        assert result == {}

    def test_extract_includes_graphics_points(self) -> None:
        """_extract_lib_symbols should return graphics_points from body shapes."""
        sexp = sexpdata.loads("""(kicad_sch
            (lib_symbols
                (symbol "Device:R"
                    (symbol "Device:R_0_1"
                        (rectangle (start -1.016 -2.54) (end 1.016 2.54)
                            (stroke (width 0.254) (type default))
                            (fill (type none))))
                    (symbol "Device:R_1_1"
                        (pin passive line (at 0 3.81 270) (length 1.27)
                            (name "~" (effects (font (size 1.27 1.27))))
                            (number "1" (effects (font (size 1.27 1.27)))))
                        (pin passive line (at 0 -3.81 90) (length 1.27)
                            (name "~" (effects (font (size 1.27 1.27))))
                            (number "2" (effects (font (size 1.27 1.27)))))))
            )
        )""")
        result = _extract_lib_symbols(sexp)
        lib_data = result["Device:R"]
        assert "graphics_points" in lib_data
        gfx = lib_data["graphics_points"]
        assert len(gfx) >= 2
        # Rectangle corners should be present
        xs = [p[0] for p in gfx]
        ys = [p[1] for p in gfx]
        assert pytest.approx(-1.016) in xs
        assert pytest.approx(1.016) in xs
        assert pytest.approx(-2.54) in ys
        assert pytest.approx(2.54) in ys


# ===================================================================
# Unit tests — _parse_lib_symbol_graphics
# ===================================================================


class TestParseLibSymbolGraphics:
    """Test graphics extraction from lib_symbol definitions."""

    def test_rectangle(self) -> None:
        sexp = sexpdata.loads("""(symbol "Device:R"
            (symbol "Device:R_0_1"
                (rectangle (start -1.016 -2.54) (end 1.016 2.54)
                    (stroke (width 0.254) (type default))
                    (fill (type none)))))""")
        pts = _parse_lib_symbol_graphics(sexp)
        assert len(pts) == 2
        assert (-1.016, -2.54) in pts
        assert (1.016, 2.54) in pts

    def test_polyline(self) -> None:
        sexp = sexpdata.loads("""(symbol "Device:C"
            (symbol "Device:C_0_1"
                (polyline
                    (pts (xy -2.032 -0.762) (xy 2.032 -0.762))
                    (stroke (width 0.508) (type default))
                    (fill (type none)))))""")
        pts = _parse_lib_symbol_graphics(sexp)
        assert (-2.032, -0.762) in pts
        assert (2.032, -0.762) in pts

    def test_circle(self) -> None:
        sexp = sexpdata.loads("""(symbol "Test:Circle"
            (symbol "Test:Circle_0_1"
                (circle (center 0 0) (radius 5)
                    (stroke (width 0.254) (type default))
                    (fill (type none)))))""")
        pts = _parse_lib_symbol_graphics(sexp)
        assert len(pts) == 2
        assert (-5.0, -5.0) in pts
        assert (5.0, 5.0) in pts

    def test_arc(self) -> None:
        sexp = sexpdata.loads("""(symbol "Test:Arc"
            (symbol "Test:Arc_0_1"
                (arc (start 1 0) (mid 0 1) (end -1 0)
                    (stroke (width 0.254) (type default))
                    (fill (type none)))))""")
        pts = _parse_lib_symbol_graphics(sexp)
        assert (1.0, 0.0) in pts
        assert (0.0, 1.0) in pts
        assert (-1.0, 0.0) in pts

    def test_no_graphics(self) -> None:
        sexp = sexpdata.loads("""(symbol "Test:Empty"
            (symbol "Test:Empty_1_1"
                (pin passive line (at 0 0 0) (length 1.27)
                    (name "~" (effects (font (size 1.27 1.27))))
                    (number "1" (effects (font (size 1.27 1.27)))))))""")
        pts = _parse_lib_symbol_graphics(sexp)
        assert pts == []


# ===================================================================
# Unit tests — _transform_local_point
# ===================================================================


class TestTransformLocalPoint:
    """Test local→absolute coordinate transform."""

    def test_no_transform(self) -> None:
        # ly is negated (lib y-up → schematic y-down)
        x, y = _transform_local_point(1.0, 2.0, 100.0, 200.0, 0, False, False)
        assert x == pytest.approx(101.0)
        assert y == pytest.approx(198.0)

    def test_mirror_x(self) -> None:
        # y-negate then mirror_x cancel out → net ly unchanged
        x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, True, False)
        assert x == pytest.approx(1.0)
        assert y == pytest.approx(2.0)

    def test_mirror_y(self) -> None:
        x, y = _transform_local_point(1.0, 2.0, 0.0, 0.0, 0, False, True)
        assert x == pytest.approx(-1.0)
        assert y == pytest.approx(-2.0)

    def test_rotation_90(self) -> None:
        # ly=0 negated is still 0, then rotate lx=1 by 90°
        x, y = _transform_local_point(1.0, 0.0, 0.0, 0.0, 90, False, False)
        assert x == pytest.approx(0.0, abs=1e-9)
        assert y == pytest.approx(1.0, abs=1e-9)


# ===================================================================
# Unit tests — _compute_symbol_bbox_direct with graphics
# ===================================================================


class TestComputeSymbolBboxWithGraphics:
    """Test that bounding box computation uses graphics points when available."""

    def test_resistor_bbox_from_graphics(self) -> None:
        """Device:R rectangle is (-1.016, -2.54) to (1.016, 2.54) in local coords.
        Pins at (0, ±3.81). Placed at (100, 100) with no rotation.
        Bbox should span from pin-to-pin in Y and use rectangle width in X."""
        sym = {
            "x": 100.0,
            "y": 100.0,
            "rotation": 0,
            "mirror_x": False,
            "mirror_y": False,
        }
        pin_defs = {
            "1": {
                "x": 0,
                "y": 3.81,
                "angle": 270,
                "length": 1.27,
                "name": "~",
                "type": "passive",
            },
            "2": {
                "x": 0,
                "y": -3.81,
                "angle": 90,
                "length": 1.27,
                "name": "~",
                "type": "passive",
            },
        }
        graphics_points = [(-1.016, -2.54), (1.016, 2.54)]

        bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
        assert bbox is not None
        min_x, min_y, max_x, max_y = bbox
        # X should come from rectangle: 100 ± 1.016
        assert min_x == pytest.approx(100 - 1.016)
        assert max_x == pytest.approx(100 + 1.016)
        # Y should come from pins (extending beyond rectangle): 100 ± 3.81
        assert min_y == pytest.approx(100 - 3.81)
        assert max_y == pytest.approx(100 + 3.81)

    def test_fallback_without_graphics(self) -> None:
        """Without graphics_points, should use the old degenerate expansion."""
        sym = {
            "x": 100.0,
            "y": 100.0,
            "rotation": 0,
            "mirror_x": False,
            "mirror_y": False,
        }
        pin_defs = {
            "1": {
                "x": 0,
                "y": 3.81,
                "angle": 270,
                "length": 1.27,
                "name": "~",
                "type": "passive",
            },
            "2": {
                "x": 0,
                "y": -3.81,
                "angle": 90,
                "length": 1.27,
                "name": "~",
                "type": "passive",
            },
        }

        bbox = _compute_symbol_bbox_direct(sym, pin_defs)
        assert bbox is not None
        min_x, min_y, max_x, max_y = bbox
        # X should be expanded with min_body=1.5: 100 ± 1.5
        assert min_x == pytest.approx(100 - 1.5)
        assert max_x == pytest.approx(100 + 1.5)

    def test_rotated_symbol_graphics(self) -> None:
        """Graphics points should be rotated along with the symbol."""
        sym = {
            "x": 100.0,
            "y": 100.0,
            "rotation": 90,
            "mirror_x": False,
            "mirror_y": False,
        }
        pin_defs = {
            "1": {
                "x": 0,
                "y": 3.81,
                "angle": 270,
                "length": 1.27,
                "name": "~",
                "type": "passive",
            },
            "2": {
                "x": 0,
                "y": -3.81,
                "angle": 90,
                "length": 1.27,
                "name": "~",
                "type": "passive",
            },
        }
        # Rectangle corners in local coords
        graphics_points = [(-1.016, -2.54), (1.016, 2.54)]

        bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
        assert bbox is not None
        min_x, min_y, max_x, max_y = bbox
        # After 90° rotation, X and Y swap roles
        # Pins now extend along X: 100 ± 3.81
        # Rectangle now extends along Y: 100 ± 1.016
        assert min_x == pytest.approx(100 - 3.81, abs=0.01)
        assert max_x == pytest.approx(100 + 3.81, abs=0.01)


@pytest.mark.integration
class TestIntegrationGraphicsBbox:
    """Integration tests verifying graphics-based bbox from real template data."""

    def test_resistor_bbox_uses_rectangle(self) -> None:
        """The template's Device:R has a rectangle body.
        Verify that the bbox for a placed resistor uses the actual
        rectangle width rather than the degenerate 1.5mm expansion."""
        extra = _make_resistor_sexp("R1", 100, 100)
        tmp = _make_temp_schematic(extra)
        sexp_data = _load_sexp(tmp)
        symbols = _parse_symbols(sexp_data)
        lib_defs = _extract_lib_symbols(sexp_data)

        r1 = [s for s in symbols if s["reference"] == "R1"][0]
        lib_data = lib_defs.get(r1["lib_id"], {})
        pin_defs = lib_data.get("pins", {})
        graphics_points = lib_data.get("graphics_points", [])

        assert len(graphics_points) >= 2, "Should have extracted rectangle points"

        bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points)
        assert bbox is not None
        min_x, min_y, max_x, max_y = bbox
        # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion
        assert max_x - min_x == pytest.approx(2 * 1.016, abs=0.01)

    def test_led_bbox_uses_polyline(self) -> None:
        """The template's Device:LED uses polylines for its body.
        Verify that the bbox uses polyline extents."""
        extra = _make_led_sexp("D1", 100, 100)
        tmp = _make_temp_schematic(extra)
        sexp_data = _load_sexp(tmp)
        symbols = _parse_symbols(sexp_data)
        lib_defs = _extract_lib_symbols(sexp_data)

        d1 = [s for s in symbols if s["reference"] == "D1"][0]
        lib_data = lib_defs.get(d1["lib_id"], {})
        graphics_points = lib_data.get("graphics_points", [])

        assert len(graphics_points) >= 4, "Should have extracted polyline points"
        # LED body polylines span from -1.27 to 1.27 in both X and Y
        xs = [p[0] for p in graphics_points]
        ys = [p[1] for p in graphics_points]
        assert min(xs) == pytest.approx(-1.27)
        assert max(xs) == pytest.approx(1.27)
        assert min(ys) == pytest.approx(-1.27)
        assert max(ys) == pytest.approx(1.27)


# ---------------------------------------------------------------------------
# TestFindOrphanedWires
# ---------------------------------------------------------------------------


@pytest.mark.integration
class TestFindOrphanedWires:
    """Integration tests for find_orphaned_wires."""

    def test_empty_schematic_no_orphans(self) -> None:
        """A schematic with no wires has no orphans."""
        tmp = _make_temp_schematic()
        result = find_orphaned_wires(tmp)
        assert result["count"] == 0
        assert result["orphaned_wires"] == []

    def test_isolated_wire_is_orphaned(self) -> None:
        """A single wire floating in empty space has both endpoints dangling."""
        extra = """
        (wire (pts (xy 10 20) (xy 30 20))
            (stroke (width 0) (type default))
            (uuid "w-isolated"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_orphaned_wires(tmp)
        assert result["count"] == 1
        w = result["orphaned_wires"][0]
        assert len(w["dangling_ends"]) == 2

    def test_wire_between_two_labels_not_orphaned(self) -> None:
        """A wire whose endpoints both land on net labels is fully connected."""
        extra = """
        (label "VCC" (at 10 20 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl1"))
        (label "GND" (at 30 20 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl2"))
        (wire (pts (xy 10 20) (xy 30 20))
            (stroke (width 0) (type default))
            (uuid "w-label-to-label"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_orphaned_wires(tmp)
        assert result["count"] == 0

    def test_wire_with_one_dangling_end(self) -> None:
        """A wire from a label to empty space has exactly one dangling end."""
        extra = """
        (label "SIG" (at 10 20 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl-sig"))
        (wire (pts (xy 10 20) (xy 40 20))
            (stroke (width 0) (type default))
            (uuid "w-stub"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_orphaned_wires(tmp)
        assert result["count"] == 1
        w = result["orphaned_wires"][0]
        assert len(w["dangling_ends"]) == 1
        # The dangling end is the far end at x=40, not the label end at x=10
        assert w["dangling_ends"][0]["x"] == pytest.approx(40.0)

    def test_connected_wires_not_orphaned(self) -> None:
        """Two wires sharing an endpoint are connected — neither is orphaned
        provided the remaining ends are also anchored."""
        # Wire A: (10,20)→(20,20), Wire B: (20,20)→(30,20)
        # Both share endpoint at (20,20). Anchor the outer ends with labels.
        extra = """
        (label "A" (at 10 20 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl-a"))
        (label "B" (at 30 20 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl-b"))
        (wire (pts (xy 10 20) (xy 20 20))
            (stroke (width 0) (type default))
            (uuid "w1"))
        (wire (pts (xy 20 20) (xy 30 20))
            (stroke (width 0) (type default))
            (uuid "w2"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_orphaned_wires(tmp)
        assert result["count"] == 0

    def test_t_junction_shared_endpoint_not_dangling(self) -> None:
        """Three wires meeting at a single point — the shared vertex is connected
        to multiple wires and must not be reported as dangling."""
        # Three wires all touching (50, 50). Outer ends get labels.
        extra = """
        (label "L1" (at 30 50 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl-t1"))
        (label "L2" (at 70 50 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl-t2"))
        (label "L3" (at 50 30 0)
            (effects (font (size 1.27 1.27)) (justify left bottom))
            (uuid "lbl-t3"))
        (wire (pts (xy 30 50) (xy 50 50))
            (stroke (width 0) (type default))
            (uuid "wt1"))
        (wire (pts (xy 50 50) (xy 70 50))
            (stroke (width 0) (type default))
            (uuid "wt2"))
        (wire (pts (xy 50 50) (xy 50 30))
            (stroke (width 0) (type default))
            (uuid "wt3"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_orphaned_wires(tmp)
        assert result["count"] == 0

    def test_multiple_isolated_wires_all_reported(self) -> None:
        """Two separate isolated wires are both reported."""
        extra = """
        (wire (pts (xy 10 10) (xy 20 10))
            (stroke (width 0) (type default))
            (uuid "wi1"))
        (wire (pts (xy 50 50) (xy 60 50))
            (stroke (width 0) (type default))
            (uuid "wi2"))
        """
        tmp = _make_temp_schematic(extra)
        result = find_orphaned_wires(tmp)
        assert result["count"] == 2
