# Copyright 2026 Gaofeng Fan
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Configuration classes for schematic visualization."""

import os
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Tuple


# =============================================================================
# Centralized Color Configuration (Cadence-style)
# =============================================================================
# These colors are used globally across all symbol rendering for consistency.
# Update these values to change the color scheme for the entire visualization.

class SchematicColors:
    """
    Centralized color constants for schematic visualization.

    Color scheme follows Cadence Virtuoso conventions:
    - Green: Device symbols, cell names, signal wires
    - Red: Pin markers
    - Blue: Net labels, port names
    - Black: Pin names, parameter text
    - Orange: Instance names
    - Gray: Secondary labels (cell type, annotations)
    """
    # Primary symbol colors
    SYMBOL_OUTLINE = '#006600'      # Green - box outline, device body
    CELL_NAME = '#006600'           # Green - cell/model name inside symbol
    INSTANCE_NAME = '#FF6600'       # Orange - instance name outside symbol

    # Pin colors
    PIN_MARKER = '#CC0000'          # Red - filled square at pin location
    PIN_LABEL = '#000000'           # Black - pin name text (p, n, etc.)

    # Wire and net colors
    WIRE = '#0066CC'                # Blue - connection wires (same as net labels)
    NET_LABEL = '#0066CC'           # Blue - net name labels

    # Port colors (boundary pins)
    PORT_MARKER = '#CC0000'         # Red - port dots at boundary

    # Secondary labels
    CELL_TYPE = '#666666'           # Gray - type annotation (LUT, iprobe, etc.)
    PARAMETER_TEXT = '#666666'      # Gray - parameter values

    # Fill colors
    DEVICE_FILL = '#FFFFFF'         # White - device box fill
    PROBE_FILL = '#F8F8F8'          # Slightly off-white - probe devices

    # Background color for generated images
    BACKGROUND = '#FFFFFF'          # White background for contrast


# Convenience alias for importing
COLORS = SchematicColors


# =============================================================================
# Centralized Layout Configuration
# =============================================================================
# These parameters control spacing, sizing, and positioning of symbol elements.
# Adjust these values to fix overlap issues or change symbol appearance globally.

class SymbolLayout:
    """
    Centralized layout parameters for schematic symbols.

    All values are base units (will be multiplied by scale factor).
    Adjust these to fix overlap issues or change symbol spacing globally.
    """
    # Pin/terminal markers
    PIN_SIZE = 0.08               # Size of pin marker square
    LEAD_LENGTH = 0.3             # Length of lead wire from symbol body to pin

    # Text sizing
    CHAR_WIDTH = 0.10             # Approximate width per character for spacing layout (conservative)
    CHAR_WIDTH_RENDERED = 0.18    # Actual rendered character width (measured from matplotlib, fontsize=10)
    CHAR_HEIGHT_RENDERED = 0.28   # Actual rendered character height (measured from matplotlib, fontsize=10)
    LABEL_PADDING = 0.30          # Padding between pin marker and label
    SCHEMATIC_FONTSIZE = 10       # Font size for schematic drawings (instance names, params, port labels)
    NET_LABEL_FONTSIZE = 12       # Font size for net name labels (larger for readability)
    DRAWING_UNIT = 1.2            # Base unit for schemdraw Drawing (default)

    # Box symbol dimensions
    BOX_MIN_WIDTH = 2.0           # Minimum box width
    BOX_MIN_HEIGHT = 1.2          # Minimum box height
    BOX_TEXT_PADDING = 0.6        # Extra padding for text inside box
    BOX_PORT_SPACING = 0.5        # Vertical spacing between ports

    # Label offsets (from box edge or symbol center)
    INSTANCE_LABEL_OFFSET = 1.35  # Instance name offset above box (was 0.25)
    INSTANCE_LABEL_POS = 'top-right'  # Global instance label position: 'top-right', 'top-left', 'above'
    MODEL_LABEL_OFFSET = 0.15     # Model name offset from center
    TYPE_LABEL_OFFSET = 0.25      # Type annotation offset below model (was 0.2)
    LABEL_X_OFFSET = 0          # Horizontal offset for labels beside symbols (was 1.2)
    LABEL_Y_SPACING = 0.35        # Vertical spacing between stacked labels (was 0.3)
    NET_LABEL_OFFSET = 0.4        # change to 0.3 for inverter pmos gate
    #NET_LABEL_OFFSET = -1.1        # Distance from net label to wire/node (was 0.6)
    NET_LABEL_OFFSET_Y = 0.3      # Vertical offset for net labels (positive = up, negative = down)

    # Column spacing control for schematics
    MAX_LABEL_SPACING_CONTRIBUTION = 5.0  # Max label width contribution to column spacing (was 0.5)
    MAX_SYMBOL_WIDTH_FOR_SPACING = 3.0    # Max symbol width used for column spacing calculation (was 2.5)

    # Cell symbol dimensions
    CELL_MIN_WIDTH = 2.0          # Minimum cell symbol width
    CELL_MIN_HEIGHT = 1.5         # Minimum cell symbol height
    CELL_PORT_SPACING = 0.4       # Spacing between ports on cell symbol
    CELL_SEPARATOR_GAP = 0.3      # Gap between port type groups

    # Schematic layout dimensions
    SCHEMATIC_COL_SPACING = 4.5   # Column spacing in cell schematic (was 3.5)
    SCHEMATIC_ROW_SPACING = 4.0   # Row spacing in cell schematic (was 3.5)

    # Wire routing parameters
    WIRE_CHANNEL_WIDTH = 0.35     # Spacing between parallel wire channels
    WIRE_SYMBOL_MARGIN = 0.6      # Margin around symbols for wire routing
    WIRE_ROUTING_OFFSET = 0.5     # Additional offset for wire routing area

    # Enable/disable automatic symbol collision avoidance for wires
    SCHEMATIC_SMART_ROUTING = False

    # Schematic connection style
    # 'wires': Draw explicit wires between all same-net connections
    # 'labels': Show net name labels at all ports (implicit connection by name)
    # 'hybrid_clean': Smart routing - wires for close connections, labels for far ones
    SCHEMATIC_CONNECTION_STYLE = 'labels'

    # For hybrid mode: max column span before switching to label mode
    # If net spans more columns than this, use labels instead of wires
    HYBRID_MAX_COLUMN_SPAN = 1    # 0 = same column only, 1 = adjacent columns OK

    # Port layout in schematic view
    # 'top-bottom': Power at top, ground at bottom (traditional)
    # 'top-separated': Both power and ground at top, visually separated
    SCHEMATIC_PORT_LAYOUT = 'top-separated'

    # Component layout strategy
    # 'sequential': Place components in order they appear (default)
    # 'by-net': Group components that share nets together for shorter wires
    SCHEMATIC_COMPONENT_LAYOUT = 'sequential'  # Simple sequential layout

    # Source symbol dimensions
    SOURCE_RADIUS = 0.4           # Radius for circular source symbols

    # Transistor dimensions
    MOSFET_BODY_HEIGHT = 0.6      # MOSFET body height
    MOSFET_BODY_WIDTH = 0.3       # MOSFET body width

    # Per-symbol label adjustments (when default offset isn't enough)
    # These are ADDITIONAL offsets on top of LABEL_X_OFFSET
    CAPACITOR_LABEL_EXTRA_X = 0.25  # Extra x offset for capacitor labels
    CCCS_LABEL_EXTRA_X = 0.0        # Extra x offset for cccs labels (probe param)
    CCVS_LABEL_EXTRA_X = 0.0        # Extra x offset for ccvs labels (probe param)
    NMOS_LABEL_EXTRA_Y = 0.3        # Extra y offset for nmos labels (move up)
    PMOS_LABEL_EXTRA_Y = 0.3        # Extra y offset for pmos labels (move up)

    # Coordinate axis display (for schematic and symbol views)
    #SHOW_AXES = False            # Show X/Y coordinate axes (frame style)
    SHOW_AXES = True              # Show X/Y coordinate axes (frame style)
    AXIS_COLOR = '#444444'        # Dark gray for axis frame lines
    AXIS_TICK_SPACING = 2.0       # Spacing between tick marks
    AXIS_TICK_SIZE = 0.2          # Length of tick marks (pointing outward)
    AXIS_LABEL_COLOR = '#333333'  # Dark gray for axis labels (readable)
    AXIS_LABEL_FONTSIZE = 10      # Font size for axis tick labels
    AXIS_EXTEND = 0.5             # Extra extension beyond content bounds
    AXIS_MIRROR = True            # Draw mirrored axes (top/right edges)
    SHOW_GRID = True              # Show dotted grid lines
    GRID_COLOR = '#E0E0E0'        # Light gray for grid

    # Bus grouping
    BUS_SLASH_SIZE = 0.25         # Diagonal slash mark size for collapsed bus symbols
    BUS_MIN_GROUP_SIZE = 2        # Minimum instances to form a bus group


# Convenience alias for importing
LAYOUT = SymbolLayout



# Parameter names that typically contain Verilog-A model file paths
VERILOGA_FILE_PARAMS = ['tmdata', 'tmCVdata', 'file', 'modelfile', 'datafile']


def extract_veriloga_filename(params: Dict) -> Optional[str]:
    """
    Extract Verilog-A filename from instance parameters.

    Looks for common parameter names that contain file paths
    (tmdata, tmCVdata, file, modelfile, datafile) and returns
    just the filename (without path).

    Args:
        params: Dictionary of instance parameters

    Returns:
        Filename string (e.g., "model.va") or None if not found
    """
    if not params:
        return None

    for param_name in VERILOGA_FILE_PARAMS:
        if param_name in params:
            filepath = str(params[param_name])
            # Extract just the filename, no path
            filename = os.path.basename(filepath)
            if filename:
                return filename

    return None


class SymbolStyle(Enum):
    """Symbol rendering styles."""
    MINIMAL = "minimal"      # Just model name in box
    STANDARD = "standard"    # Model name + port labels
    DETAILED = "detailed"    # Model name + ports + parameters


class PortType(Enum):
    """Port type for symbol placement."""
    POWER = "power"      # Top of symbol
    GROUND = "ground"    # Bottom of symbol
    INPUT = "input"      # Left side
    OUTPUT = "output"    # Right side
    INOUT = "inout"      # Left side (below inputs)
    UNKNOWN = "unknown"  # Right side (below outputs)


# Patterns for inferring port type from name
PORT_TYPE_PATTERNS = {
    PortType.POWER: [
        # Standalone terminal names (2-terminal devices) - placed on TOP
        r'^p$',                   # p (positive terminal)
        r'^plus$',                # plus
        r'^pos$',                 # pos (positive)
        # Power supply names
        r'^v?dd[a-z0-9]*$',      # vdd, avdd, dvdd, vdda
        r'^v?cc[a-z0-9]*$',      # vcc, avcc, dvcc
        r'^supply[0-9]*$',       # supply, supply1
        r'^pwr[a-z0-9]*$',       # pwr, pwr1
        r'^anode$',              # anode (for diodes/OLEDs)
        r'.*_vdd$',              # xxx_vdd
        r'.*_pwr$',              # xxx_pwr
    ],
    PortType.GROUND: [
        # Standalone terminal names (2-terminal devices) - placed on BOTTOM
        r'^n$',                   # n (negative terminal)
        r'^m$',                   # m (minus, common in some conventions)
        r'^minus$',               # minus
        r'^neg$',                 # neg (negative)
        # Ground/reference names
        r'^v?ss[a-z0-9]*$',      # vss, avss, dvss, vssa
        r'^gnd[a-z0-9]*$',       # gnd, gnda, gnd1
        r'^ground$',             # ground
        r'^substrate$',          # substrate
        r'^sub$',                 # sub (substrate, common in analog)
        r'.*substrate$',          # xxx_substrate, loooong_substrate
        r'^0$',                  # 0
        r'^elvss$',              # elvss (OLED ground)
        r'^cathode$',            # cathode
        r'.*_vss$',              # xxx_vss
        r'.*_gnd$',              # xxx_gnd
    ],
    PortType.OUTPUT: [
        # Differential outputs - outp/outn, out_p/out_n
        r'^out[a-z0-9_]*$',      # out, out1, outp, outn, out_p, out_n
        r'^q[a-z0-9]*$',         # q, qb, q0
        r'^y[0-9]*$',            # y, y0, y1
        r'^dout[a-z0-9]*$',      # dout, dout0
        r'.*_out$',              # xxx_out
        r'.*_output$',           # xxx_output
        r'.*_o$',                # xxx_o
    ],
    PortType.INPUT: [
        # Note: inp/inn, in_p/in_n are matched by ^in[a-z0-9]*$ pattern
        r'^in[a-z0-9_]*$',       # in, in1, inp, inn, in_p, in_n, in_m
        r'^clk[a-z0-9]*$',       # clk, clk1, clkp
        r'^en[a-z0-9]*$',        # en, enable
        r'^rst[a-z0-9]*$',       # rst, reset, rstn
        r'^din[a-z0-9]*$',       # din, din0
        r'^a[0-9]+$',            # a0, a1 (address) - but NOT standalone 'a'
        r'^b[0-9]+$',            # b0, b1 - but NOT standalone 'b'
        r'^d[0-9]+$',            # d0, d1 (data) - but NOT standalone 'd'
        r'^sel[a-z0-9]*$',       # sel, sel0
        r'.*_in$',               # xxx_in
        r'.*_i$',                # xxx_i
    ],
    PortType.INOUT: [
        r'^io[a-z0-9]*$',        # io, io0
        r'^sda[0-9]*$',          # sda
        r'^scl[0-9]*$',          # scl
        r'^data[0-9]*$',         # data, data0
        r'^bus[a-z0-9]*$',       # bus, bus0
        r'.*_io$',               # xxx_io
    ],
}


def infer_port_type(port_name: str) -> PortType:
    """
    Infer port type from port name using naming conventions.

    If the port has an explicit :direction suffix (e.g., "inp:input",
    "out:output"), that takes priority. Otherwise, pattern matching
    is used on the port name.

    Returns:
        PortType enum indicating where port should be placed on symbol
    """
    name_lower = port_name.lower()

    # Explicit :direction suffix takes priority
    if ':' in name_lower:
        name_part, direction = name_lower.split(':', 1)
        direction_map = {
            'input': PortType.INPUT,
            'output': PortType.OUTPUT,
            'inout': PortType.INOUT,
            'power': PortType.POWER,
            'ground': PortType.GROUND,
        }
        if direction in direction_map:
            return direction_map[direction]
        name_lower = name_part  # fallback to pattern matching on name part

    for port_type, patterns in PORT_TYPE_PATTERNS.items():
        for pattern in patterns:
            if re.match(pattern, name_lower):
                return port_type

    return PortType.UNKNOWN


def group_ports_by_type(ports: List[str],
                        overrides: Dict[str, PortType] = None) -> Dict[PortType, List[str]]:
    """
    Group ports by their inferred or overridden type.

    Args:
        ports: List of port names (order is preserved from cell definition)
        overrides: Optional dict of port_name -> PortType to override inference

    Returns:
        Dict mapping PortType to list of port names (preserving definition order)
    """
    overrides = overrides or {}
    grouped = {pt: [] for pt in PortType}

    # Preserve original definition order - just group by type
    for port in ports:
        if port in overrides:
            port_type = overrides[port]
        else:
            port_type = infer_port_type(port)
        grouped[port_type].append(port)

    return grouped


@dataclass
class SymbolConfig:
    """Configuration for a specific symbol."""
    style: SymbolStyle = SymbolStyle.DETAILED
    label: Optional[str] = None          # Custom label (overrides model name)
    color: str = "#E8E8E8"               # Fill color
    border_color: str = "#000000"        # Border color
    port_names: Optional[Dict[str, str]] = None  # Cell port → symbol port mapping
                                                  # e.g. {"ANODE": "p", "ELVSS": "n"}
    template: Optional[str] = None       # Use built-in template (e.g., "capacitor")
    width: float = 2.0                   # Symbol width in inches
    height: float = 1.0                  # Symbol height in inches
    is_custom: bool = False              # Whether this config came from a custom override
    image: Optional[str] = None          # Path to PNG/JPG background image (box template only)


@dataclass
class LayoutHints:
    """Layout hints for positioning components."""
    positions: Dict[str, Tuple[float, float]] = field(default_factory=dict)
    rows: Dict[int, List[str]] = field(default_factory=dict)
    relative: Dict[str, Dict[str, str]] = field(default_factory=dict)

    def position(self, instance_name: str, x: float, y: float) -> 'LayoutHints':
        """Set absolute position for an instance."""
        self.positions[instance_name] = (x, y)
        return self

    def row(self, row_num: int, instances: List[str]) -> 'LayoutHints':
        """Place instances in a row."""
        self.rows[row_num] = instances
        return self

    def below(self, instance: str, reference: str) -> 'LayoutHints':
        """Place instance below reference."""
        if instance not in self.relative:
            self.relative[instance] = {}
        self.relative[instance]["below"] = reference
        return self

    def right_of(self, instance: str, reference: str) -> 'LayoutHints':
        """Place instance to the right of reference."""
        if instance not in self.relative:
            self.relative[instance] = {}
        self.relative[instance]["right_of"] = reference
        return self


@dataclass
class BookConfig:
    """Configuration for the entire schematic book."""
    title: str = "Schematic Documentation"
    author: str = ""
    default_style: SymbolStyle = SymbolStyle.DETAILED
    show_parameters: bool = True
    show_port_names: bool = True
    page_width: float = 11.0             # inches (Letter landscape)
    page_height: float = 8.5              # inches
    margin: float = 0.75                 # inches

    # Model-specific configurations
    model_configs: Dict[str, SymbolConfig] = field(default_factory=dict)

    # Layout configurations per circuit
    layout_hints: Dict[str, LayoutHints] = field(default_factory=dict)


# =============================================================================
# Schematic Layout Model (per-instance label tracking)
# =============================================================================


# Priority for collision avoidance: lower = more willing to move
LABEL_PRIORITY = {
    'net_label': 0,     # Most flexible — can move in multiple directions
    'pin_label': 1,     # Slightly anchored to pin
    'parameter': 2,     # Tied to instance, but can shift vertically
    'instance_name': 3, # Anchored above/beside symbol
    'cell_name': 4,     # Most anchored — defines the symbol identity
    'port_label': 5,    # Header port labels — fixed
}


@dataclass
class LabelPlacement:
    """A placed text label with position, anchor, and collision metadata."""
    text: str
    pos: Tuple[float, float]            # Current placed position (x, y)
    anchor: Tuple[float, float]         # The point this label is tied to (pin pos, symbol center, etc.)
    halign: str = 'left'                # 'left', 'right', 'center'
    fontsize: Optional[float] = None    # None = use default for category
    category: str = 'default'           # 'inst_name', 'cell_name', 'parameter', 'net_label', 'pin_label', 'port_label'

    @property
    def priority(self) -> int:
        """Lower = more willing to be moved by collision avoidance."""
        return LABEL_PRIORITY.get(self.category, 99)


@dataclass
class PortPlacement:
    """A port/pin on an instance with its pin position and associated net label."""
    port_name: str                              # Symbol port name (e.g., 'd', 'g', 's', 'b')
    pin_pos: Tuple[float, float]                # Pin marker position in schematic coords
    net_name: Optional[str] = None              # Connected net name (e.g., 'out', 'inp')
    pin_label: Optional[LabelPlacement] = None  # Pin name label (e.g., 'd', 'g')
    net_label: Optional[LabelPlacement] = None  # Net name label (e.g., 'out', 'inp')


@dataclass
class InstanceLayout:
    """Complete layout record for one instance in a schematic."""
    instance_name: str
    cell_name: str
    center: Tuple[float, float]                         # Symbol center (x, y)
    symbol_bbox: Optional[Tuple[float, float, float, float]] = None  # (x0, y0, x1, y1)

    inst_name_label: Optional[LabelPlacement] = None    # Instance name (e.g., 'MP')
    cell_name_label: Optional[LabelPlacement] = None    # Cell/model name (e.g., 'pmos')
    param_labels: Dict[str, LabelPlacement] = field(default_factory=dict)  # param_name → LabelPlacement
    ports: Dict[str, PortPlacement] = field(default_factory=dict)          # port_name → PortPlacement

    def all_labels(self) -> List[LabelPlacement]:
        """Return all labels belonging to this instance (for collision checks)."""
        labels = []
        if self.inst_name_label:
            labels.append(self.inst_name_label)
        if self.cell_name_label:
            labels.append(self.cell_name_label)
        labels.extend(self.param_labels.values())
        for port in self.ports.values():
            if port.pin_label:
                labels.append(port.pin_label)
            if port.net_label:
                labels.append(port.net_label)
        return labels


@dataclass
class WireLabelPlacement:
    """A net label placed on a wire segment (not tied to a single instance port)."""
    label: LabelPlacement
    net_name: str
    connected_ports: List[Tuple[str, str]] = field(default_factory=list)  # [(inst_name, port_name), ...]


@dataclass
class SchematicLayout:
    """Complete layout model for a circuit schematic.

    Flat registry of all placed elements — not a tree.
    Iterate `instances` for per-instance collision checks,
    or call `all_labels()` for global overlap detection.
    """
    circuit_name: str = ''
    instances: Dict[str, InstanceLayout] = field(default_factory=dict)          # inst_name → InstanceLayout
    header_labels: Dict[str, LabelPlacement] = field(default_factory=dict)      # port_name → LabelPlacement
    wire_labels: List[WireLabelPlacement] = field(default_factory=list)         # labels on wired nets
    wire_segments: List[Tuple[float, float, float, float]] = field(default_factory=list)  # drawn wire segments (x1, y1, x2, y2)

    def all_labels(self) -> List[Tuple[str, LabelPlacement]]:
        """Return all labels with their owner context as (owner_key, label) pairs.

        owner_key format: 'header:vdd', 'wire:out', 'MP:inst_name', 'MP:cell_name',
                          'MP:param:w', 'MP:port:d:pin', 'MP:port:d:net'
        """
        result = []
        for port_name, label in self.header_labels.items():
            result.append((f'header:{port_name}', label))
        for wlp in self.wire_labels:
            result.append((f'wire:{wlp.net_name}', wlp.label))
        for inst_name, inst in self.instances.items():
            for lbl in inst.all_labels():
                # Build owner key from instance + category
                if lbl is inst.inst_name_label:
                    result.append((f'{inst_name}:inst_name', lbl))
                elif lbl is inst.cell_name_label:
                    result.append((f'{inst_name}:cell_name', lbl))
                elif lbl in inst.param_labels.values():
                    for pk, pv in inst.param_labels.items():
                        if pv is lbl:
                            result.append((f'{inst_name}:param:{pk}', lbl))
                            break
                else:
                    for pn, pp in inst.ports.items():
                        if lbl is pp.pin_label:
                            result.append((f'{inst_name}:port:{pn}:pin', lbl))
                            break
                        if lbl is pp.net_label:
                            result.append((f'{inst_name}:port:{pn}:net', lbl))
                            break
        return result

    def find_collisions(self, margin: float = 0.0) -> List[Tuple[str, str, float]]:
        """Find all overlapping label pairs using estimated text bboxes.

        Returns list of (owner_key_a, owner_key_b, overlap_area).
        """
        all_labels = self.all_labels()
        collisions = []
        for i in range(len(all_labels)):
            ka, la = all_labels[i]
            ba = _estimated_label_bbox(la, margin)
            for j in range(i + 1, len(all_labels)):
                kb, lb = all_labels[j]
                bb = _estimated_label_bbox(lb, margin)
                area = _overlap_area(ba, bb)
                if area > 0:
                    collisions.append((ka, kb, area))
        return collisions

    def resolve_collisions(self, max_iterations: int = 3) -> List[Tuple[str, 'LabelPlacement', Tuple[float, float], Tuple[float, float]]]:
        """Run collision avoidance and return the list of moves made.

        Iterates up to max_iterations times (since moving one label may
        create new collisions). Each iteration finds remaining collisions,
        picks the lower-priority label to move, and tries candidate positions.

        Returns:
            List of (owner_key, label, old_pos, new_pos) for each move.
        """
        all_moves = []

        for iteration in range(max_iterations):
            collisions = self.find_collisions()
            if not collisions:
                break

            # Sort by area descending — fix worst overlaps first
            collisions.sort(key=lambda c: -c[2])

            # Build label lookup
            label_map = {k: lbl for k, lbl in self.all_labels()}

            moved_this_round = False
            for ka, kb, area in collisions:
                la = label_map.get(ka)
                lb = label_map.get(kb)
                if la is None or lb is None:
                    continue

                # Pick the lower-priority (more movable) label
                if la.priority <= lb.priority:
                    mover_key, mover = ka, la
                    other = lb
                else:
                    mover_key, mover = kb, lb
                    other = la

                # Generate candidate positions around the anchor
                candidates = _generate_candidates(mover)

                # Collect all other label bboxes (everything except the mover)
                other_bboxes = []
                for ok, ol in self.all_labels():
                    if ol is not mover:
                        other_bboxes.append(_estimated_label_bbox(ol))

                # Collect symbol body bboxes as no-go zones.
                # Use the convex hull of port pin positions (which tightly enclose the
                # actual symbol shape) rather than the layout-spacing estimate.
                symbol_padding = 0.1
                for inst in self.instances.values():
                    pin_positions = [pp.pin_pos for pp in inst.ports.values()
                                     if pp.pin_pos is not None]
                    if len(pin_positions) >= 2:
                        xs = [p[0] for p in pin_positions]
                        ys = [p[1] for p in pin_positions]
                        other_bboxes.append((min(xs) - symbol_padding,
                                             min(ys) - symbol_padding,
                                             max(xs) + symbol_padding,
                                             max(ys) + symbol_padding))

                # Collect wire segments as no-go zones (thin rectangles with padding)
                wire_padding = 0.12
                for seg in self.wire_segments:
                    sx1, sy1, sx2, sy2 = seg
                    seg_bbox = (min(sx1, sx2) - wire_padding,
                                min(sy1, sy2) - wire_padding,
                                max(sx1, sx2) + wire_padding,
                                max(sy1, sy2) + wire_padding)
                    other_bboxes.append(seg_bbox)

                # Try each candidate — pick the first with no collisions
                for cand_pos, cand_halign in candidates:
                    old_pos, old_halign = mover.pos, mover.halign
                    mover.pos = cand_pos
                    mover.halign = cand_halign
                    mover_bbox = _estimated_label_bbox(mover)

                    has_collision = False
                    for ob in other_bboxes:
                        if _overlap_area(mover_bbox, ob) > 0:
                            has_collision = True
                            break

                    if not has_collision:
                        all_moves.append((mover_key, mover, old_pos, cand_pos))
                        moved_this_round = True
                        break
                    else:
                        # Revert
                        mover.pos = old_pos
                        mover.halign = old_halign

            if not moved_this_round:
                break  # No progress — stop

        return all_moves


def _estimated_label_bbox(lbl: 'LabelPlacement', margin: float = 0.0) -> Tuple[float, float, float, float]:
    """Compute estimated bounding box for a label using calibrated character metrics."""
    fs = lbl.fontsize or LAYOUT.SCHEMATIC_FONTSIZE
    cw = LAYOUT.CHAR_WIDTH_RENDERED * fs / 10.0
    ch = LAYOUT.CHAR_HEIGHT_RENDERED * fs / 10.0
    tw = len(lbl.text) * cw
    x, y = lbl.pos
    if lbl.halign == 'left':
        x0, x1 = x, x + tw
    elif lbl.halign == 'right':
        x0, x1 = x - tw, x
    else:
        x0, x1 = x - tw / 2, x + tw / 2
    y0, y1 = y - ch / 2, y + ch / 2
    return (x0 - margin, y0 - margin, x1 + margin, y1 + margin)


def _overlap_area(ba: Tuple[float, float, float, float],
                  bb: Tuple[float, float, float, float]) -> float:
    """Compute overlap area between two (x0, y0, x1, y1) bounding boxes."""
    ox0 = max(ba[0], bb[0])
    oy0 = max(ba[1], bb[1])
    ox1 = min(ba[2], bb[2])
    oy1 = min(ba[3], bb[3])
    if ox0 < ox1 and oy0 < oy1:
        return (ox1 - ox0) * (oy1 - oy0)
    return 0.0


def _generate_candidates(label: 'LabelPlacement') -> List[Tuple[Tuple[float, float], str]]:
    """Generate candidate (pos, halign) pairs for relocating a label.

    Tries offsets relative to the label's anchor point. The strategy
    depends on the label category.

    Returns:
        List of ((x, y), halign) candidates, best-first.
    """
    ax, ay = label.anchor
    dx = label.pos[0] - ax
    dy = label.pos[1] - ay
    halign = label.halign

    # Flip halign for mirrored candidates
    flip_h = {'left': 'right', 'right': 'left', 'center': 'center'}

    candidates = []

    if label.category == 'net_label':
        # Net labels — prioritize vertical moves to stay near the wire/pin
        # horizontally. Horizontal shifts push labels far from their wire.
        abs_dx = abs(dx) if abs(dx) > 0.01 else 0.4
        abs_dy = abs(dy) if abs(dy) > 0.01 else 0.3
        sign_dx = 1 if dx >= 0 else -1
        sign_dy = 1 if dy >= 0 else -1

        # 1. Vertical moves first (stay near wire horizontally)
        # Mirror y (below pin instead of above, or vice versa)
        candidates.append(((ax + dx, ay - dy), halign))
        # Shift vertically (double y offset — further in original direction)
        candidates.append(((ax + dx, ay + dy * 2.0), halign))
        # Small vertical nudges at same x
        step_y = abs_dy * 0.8
        candidates.append(((ax + dx, ay + dy - step_y), halign))
        candidates.append(((ax + dx, ay + dy + step_y), halign))
        # Directly above/below anchor
        candidates.append(((ax + dx, ay + abs_dy * 2.5), halign))
        candidates.append(((ax + dx, ay - abs_dy * 2.5), halign))

        # 2. Horizontal moves (further from wire — lower priority)
        # Mirror x (other side of pin)
        candidates.append(((ax - dx, ay + dy), flip_h[halign]))
        # Mirror both x and y
        candidates.append(((ax - dx, ay - dy), flip_h[halign]))
        # Move further away in same direction
        candidates.append(((ax + dx * 1.8, ay + dy), halign))
        # Move further away mirrored x
        candidates.append(((ax - dx * 1.8, ay + dy), flip_h[halign]))

        # 3. Diagonal moves
        candidates.append(((ax + abs_dx * 2.0, ay + abs_dy * 2.0), halign))
        candidates.append(((ax + abs_dx * 2.0, ay - abs_dy * 2.0), halign))
        candidates.append(((ax - abs_dx * 2.0, ay + abs_dy * 2.0), flip_h[halign]))
        candidates.append(((ax - abs_dx * 2.0, ay - abs_dy * 2.0), flip_h[halign]))

        # 4. Far candidates (last resort)
        candidates.append(((ax + abs_dx * 3.0, ay), halign))
        candidates.append(((ax - abs_dx * 3.0, ay), flip_h[halign]))
        candidates.append(((ax + abs_dx * 4.5, ay), halign))
        candidates.append(((ax - abs_dx * 4.5, ay), flip_h[halign]))

    elif label.category == 'parameter':
        # Parameters stack vertically — try shifting down or up
        step = LAYOUT.CHAR_HEIGHT_RENDERED * (label.fontsize or LAYOUT.SCHEMATIC_FONTSIZE) / 10.0 * 1.2
        candidates.append(((ax + dx, ay + dy - step), halign))
        candidates.append(((ax + dx, ay + dy + step), halign))
        candidates.append(((ax + dx, ay + dy - 2 * step), halign))
        candidates.append(((ax + dx, ay + dy + 2 * step), halign))
        # Try other side
        candidates.append(((ax - dx, ay + dy), flip_h[halign]))

    elif label.category == 'instance_name':
        # Instance names — try other side of symbol
        candidates.append(((ax - dx, ay + dy), flip_h[halign]))
        # Try above/below
        step = LAYOUT.CHAR_HEIGHT_RENDERED * (label.fontsize or LAYOUT.SCHEMATIC_FONTSIZE) / 10.0 * 1.5
        candidates.append(((ax + dx, ay + dy + step), halign))
        candidates.append(((ax + dx, ay + dy - step), halign))

    elif label.category == 'pin_label':
        # Pin labels — try other side of pin, or further away
        candidates.append(((ax - dx, ay + dy), flip_h[halign]))
        candidates.append(((ax + dx * 1.5, ay + dy), halign))
        candidates.append(((ax + dx, ay - dy), halign))

    else:
        # Generic fallback: mirror x, shift y
        candidates.append(((ax - dx, ay + dy), flip_h[halign]))
        candidates.append(((ax + dx, ay + dy * 2.0), halign))
        candidates.append(((ax - dx, ay - dy), flip_h[halign]))

    return candidates
