import ipaddress
import logging
import uuid
from datetime import datetime, timezone
from typing import Any

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.services.zigbee_service import (
    build_zigbee_properties,
    merge_zigbee_properties,
)
from app.services.zwave_service import build_zwave_properties

_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}


def _ip_tokens(ip: str | None) -> list[str]:
    """Split a Node/device ``ip`` field into individual addresses.

    The canvas stores multiple addresses in one comma-separated string (e.g.
    ``"fe80::1, 192.168.1.5"`` once a user adds an IPv6 address). Matching a
    scanned device against that field must compare per-address, not against the
    whole string, or the device looks absent from the canvas (issue #258).
    """
    return [t.strip() for t in ip.split(",") if t.strip()] if ip else []


def _is_wireless(node_type: str | None) -> bool:
    """Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
    return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES


def _wireless_properties(
    node_type: str | None,
    ieee: str | None,
    vendor: str | None,
    model: str | None,
    lqi: int | None,
) -> list[dict[str, Any]]:
    """Build the right property rows for a mesh device (Z-Wave has no LQI)."""
    if node_type in _ZWAVE_TYPES:
        return build_zwave_properties(ieee, vendor, model)
    return build_zigbee_properties(ieee, vendor, model, lqi)


def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
    """Build a NodeProperty list carrying a device MAC address.

    Shape matches the frontend ``NodeProperty`` type
    (``{key, value, icon, visible}``). Hidden by default — the user opts in to
    showing it on the canvas card from the right panel. Returns an empty list
    when no MAC is known.
    """
    if not mac:
        return []
    return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]


def merge_mac_property(
    props: list[dict[str, Any]] | None, mac: str | None
) -> list[dict[str, Any]]:
    """Append a MAC NodeProperty to ``props`` unless one is already present.

    Preserves any user-supplied properties (and an existing MAC row's
    visibility) untouched. Used on approve so the scanned MAC is not lost.
    """
    out = [dict(p) for p in (props or [])]
    if not mac or any(p.get("key") == "MAC" for p in out):
        return out
    out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
    return out


class BulkActionRequest(BaseModel):
    device_ids: list[str]
    # Target design for approved nodes. Falls back to the first design when
    # omitted (keeps older clients working), but the UI should send the active
    # design so approved devices land on the canvas the user is looking at.
    design_id: str | None = None


def _check_port_ranges(v: list[str]) -> list[str]:
    for r in v:
        if not _valid_port_range(r.strip()):
            raise ValueError(f"Invalid port range: {r!r}")
    return v


class ScanConfig(BaseModel):
    """Persisted scan defaults (Options page). Deep-scan fields are optional."""

    ranges: list[str]
    http_ranges: list[str] = []
    http_probe_enabled: bool = False
    verify_tls: bool = False

    @field_validator("ranges")
    @classmethod
    def validate_cidr(cls, v: list[str]) -> list[str]:
        for r in v:
            try:
                ipaddress.ip_network(r, strict=False)
            except ValueError as exc:
                raise ValueError(f"Invalid CIDR range: {r!r}") from exc
        return v

    @field_validator("http_ranges")
    @classmethod
    def validate_http_ranges(cls, v: list[str]) -> list[str]:
        return _check_port_ranges(v)


class TriggerScanRequest(BaseModel):
    """Per-scan deep-scan overrides (scan dialog). None → use persisted default."""

    http_ranges: list[str] | None = None
    http_probe_enabled: bool | None = None
    verify_tls: bool | None = None

    @field_validator("http_ranges")
    @classmethod
    def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
        return None if v is None else _check_port_ranges(v)


logger = logging.getLogger(__name__)
router = APIRouter()


async def _background_scan(
    run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
) -> None:
    async with AsyncSessionLocal() as db:
        try:
            await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
        except Exception:
            logger.exception("Scan run %s failed unexpectedly", run_id)
            await db.rollback()
            run = await db.get(ScanRun, run_id)
            if run and run.status == "running":
                run.status = "failed"
                await db.commit()


def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
    """Merge per-scan overrides over persisted settings defaults."""
    p = payload or TriggerScanRequest()
    return DeepScanOptions(
        http_ranges=(
            p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
        ),
        http_probe_enabled=(
            p.http_probe_enabled
            if p.http_probe_enabled is not None
            else settings.scanner_http_probe_enabled
        ),
        verify_tls=(
            p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
        ),
    )


@router.post("/trigger", response_model=ScanRunResponse)
async def trigger_scan(
    background_tasks: BackgroundTasks,
    payload: TriggerScanRequest | None = None,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> ScanRun:
    ranges = settings.scanner_ranges
    deep_scan = _resolve_deep_scan(payload)
    run = ScanRun(status="running", ranges=ranges)
    db.add(run)
    await db.commit()
    await db.refresh(run)
    background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
    return run


@router.post("/{run_id}/stop", response_model=dict)
async def stop_scan(
    run_id: str,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, bool]:
    try:
        uuid.UUID(run_id)
    except ValueError:
        raise HTTPException(status_code=400, detail="Invalid run_id format") from None
    run = await db.get(ScanRun, run_id)
    if not run:
        raise HTTPException(status_code=404, detail="Scan run not found")
    if run.status != "running":
        raise HTTPException(status_code=409, detail="Scan is not running")
    request_cancel(run_id)
    # Flip status eagerly so the UI reflects the stop immediately, instead of
    # waiting for run_scan to reach its next cancellation checkpoint (which may
    # be blocked inside a long nmap call). run_scan converges to the same state.
    run.status = "cancelled"
    run.finished_at = datetime.now(timezone.utc)
    await db.commit()
    return {"stopping": True}


def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
    """Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
    present = [v for v in values if v is not None]
    if not present:
        return None
    return max(present) if newest else min(present)


async def _canvas_correlation(
    db: AsyncSession, devices: list[PendingDevice]
) -> dict[str, dict[str, Any]]:
    """Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
    or ``ip``.

    Returns, per device id: the number of distinct canvases (designs) it appears
    on, plus aggregated timestamps from every matching node — created_at (oldest),
    last_scan / updated_at / last_seen (newest). One node query, grouped in Python
    (node counts are small for a homelab), so no N+1 per device.

    IP matching is per-address: a node's ``ip`` may hold several comma-separated
    addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
    the raw string. MAC is a stable identifier immune to such IP edits, so it is
    matched too — cumulatively with ieee/ip (issue #258).
    """
    if not devices:
        return {}
    rows = (
        await db.execute(
            select(
                Node.ip,
                Node.mac,
                Node.ieee_address,
                Node.design_id,
                Node.created_at,
                Node.last_scan,
                Node.updated_at,
                Node.last_seen,
            ).where(Node.design_id.isnot(None))
        )
    ).all()
    # Index matching nodes by ip token, mac and ieee so a device can look up any.
    by_ip: dict[str, list[Any]] = {}
    by_mac: dict[str, list[Any]] = {}
    by_ieee: dict[str, list[Any]] = {}
    for row in rows:
        for tok in _ip_tokens(row.ip):
            by_ip.setdefault(tok, []).append(row)
        if row.mac:
            by_mac.setdefault(row.mac, []).append(row)
        if row.ieee_address:
            by_ieee.setdefault(row.ieee_address, []).append(row)

    info: dict[str, dict[str, Any]] = {}
    for d in devices:
        matched = []
        if d.ieee_address:
            matched += by_ieee.get(d.ieee_address, [])
        if d.mac:
            matched += by_mac.get(d.mac, [])
        for tok in _ip_tokens(d.ip):
            matched += by_ip.get(tok, [])
        # De-duplicate nodes matched by more than one identifier.
        matched = list({id(m): m for m in matched}.values())
        designs = {m.design_id for m in matched}
        info[d.id] = {
            "canvas_count": len(designs),
            "node_created_at": _agg([m.created_at for m in matched], newest=False),
            "node_last_scan": _agg([m.last_scan for m in matched], newest=True),
            "node_last_modified": _agg([m.updated_at for m in matched], newest=True),
            "node_last_seen": _agg([m.last_seen for m in matched], newest=True),
        }
    return info


async def _with_canvas_counts(
    db: AsyncSession, devices: list[PendingDevice]
) -> list[PendingDevice]:
    """Attach transient canvas count + linked-node timestamps for the response."""
    info = await _canvas_correlation(db, devices)
    for d in devices:
        meta = info.get(d.id, {})
        d.canvas_count = meta.get("canvas_count", 0)
        d.node_created_at = meta.get("node_created_at")
        d.node_last_scan = meta.get("node_last_scan")
        d.node_last_modified = meta.get("node_last_modified")
        d.node_last_seen = meta.get("node_last_seen")
    return devices


@router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
    # Inventory: every scanned device except the user-hidden ones. Approved devices
    # stay listed so they keep showing with a canvas-presence badge.
    result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
    return await _with_canvas_counts(db, list(result.scalars().all()))


@router.delete("/pending", response_model=dict)
async def clear_pending(
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, int]:
    from sqlalchemy import delete as sa_delete
    result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
    await db.commit()
    return {"deleted": result.rowcount}


@router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
    result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
    return await _with_canvas_counts(db, list(result.scalars().all()))


@router.post("/pending/bulk-approve", response_model=dict)
async def bulk_approve_devices(
    payload: BulkActionRequest,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, Any]:
    # Repair any legacy same-canvas duplicate nodes before placing more.
    await dedupe_nodes_by_ieee(db)

    # Target the design the user is on; fall back to the first design.
    default_design_id = payload.design_id
    if default_design_id is None:
        first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
        default_design_id = first_design.id if first_design else None

    # Accept every selected device that isn't user-hidden. We intentionally do NOT
    # filter on status == "pending": a device's status is global, but canvas
    # membership is per-design. A device approved onto another canvas (or whose
    # node was later deleted) must still be placeable on THIS design. Duplicates
    # are guarded per-design below, not by the global status flag.
    result = await db.execute(
        select(PendingDevice).where(
            PendingDevice.id.in_(payload.device_ids),
            PendingDevice.status != "hidden",
        )
    )
    devices = result.scalars().all()

    # What already sits on the target canvas, so we skip devices already placed
    # here (by ip, mac or ieee_address) instead of creating duplicate nodes. We
    # map to the existing node id so the skip report can point the user at it. A
    # value may be a Node still pending flush (in-batch duplicate) — resolved to
    # its id after the flush below. IPs are indexed per comma-separated token so
    # a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
    # 192.168.1.5 (issue #258).
    existing = (
        await db.execute(
            select(Node.id, Node.ip, Node.mac, Node.ieee_address).where(
                Node.design_id == default_design_id
            )
        )
    ).all()
    placed_ips: dict[str, Any] = {
        tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
    }
    placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
    placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}

    created_nodes: list[Node] = []
    approved_devices: list[PendingDevice] = []
    skipped_devices: list[dict[str, Any]] = []
    for device in devices:
        # Record which identifier collided so the caller can explain each skip
        # (and, for existing on-canvas nodes, link to the node already there).
        ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
        if ip_hit is not None:
            skipped_devices.append({
                "device_id": device.id,
                "label": device.hostname or device.friendly_name or device.ip or "device",
                "match": "ip", "value": ip_hit, "_ref": placed_ips[ip_hit],
            })
            continue
        if device.ieee_address is not None and device.ieee_address in placed_ieee:
            skipped_devices.append({
                "device_id": device.id,
                "label": device.hostname or device.friendly_name or device.ieee_address or "device",
                "match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
            })
            continue
        if device.mac is not None and device.mac in placed_mac:
            skipped_devices.append({
                "device_id": device.id,
                "label": device.hostname or device.friendly_name or device.mac or "device",
                "match": "mac", "value": device.mac, "_ref": placed_mac[device.mac],
            })
            continue
        device.status = "approved"
        node_type = device.suggested_type or "generic"
        is_wireless = _is_wireless(node_type)
        cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
        node = Node(
            label=device.hostname or device.friendly_name or device.ip or "device",
            type=node_type,
            ip=device.ip,
            mac=device.mac,
            hostname=device.hostname,
            status="online" if is_wireless else "unknown",
            services=device.services or [],
            ieee_address=device.ieee_address,
            properties=_wireless_properties(
                node_type, device.ieee_address, device.vendor, device.model, device.lqi
            ) if is_wireless else merge_mac_property(list(device.properties or []), device.mac),
            # Default to ping so the status checker actually polls the new node.
            # Without this the scheduler skips it (check_method NULL → no check).
            check_method="none" if is_wireless else ("ping" if device.ip else None),
            # Cluster hosts get side handles for their host↔host cluster edge.
            left_handles=1 if cluster_host else 0,
            right_handles=1 if cluster_host else 0,
            design_id=default_design_id,
        )
        db.add(node)
        created_nodes.append(node)
        approved_devices.append(device)
        # Track within this batch so a duplicate selection (same ip/mac/ieee) is
        # not placed twice on the same canvas. Store the Node so a later in-batch
        # skip can resolve to its id after flush.
        for tok in _ip_tokens(device.ip):
            placed_ips[tok] = node
        if device.mac:
            placed_mac[device.mac] = node
        if device.ieee_address:
            placed_ieee[device.ieee_address] = node
    await db.flush()  # populates node.id from Python-side default before reading
    # node_ids and approved_device_ids stay index-aligned for the client's mapping.
    node_ids = [n.id for n in created_nodes]
    approved_device_ids = [d.id for d in approved_devices]

    # Resolve each skip's existing-node reference to a concrete id now that any
    # in-batch nodes have been flushed, and expose it under a clean key.
    for entry in skipped_devices:
        ref = entry.pop("_ref")
        entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref

    all_edges: list[dict[str, str]] = []
    for device in approved_devices:
        all_edges.extend(
            await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
        )

    await db.commit()
    return {
        "approved": len(node_ids),
        "node_ids": node_ids,
        "device_ids": approved_device_ids,
        "edges_created": len(all_edges),
        "edges": all_edges,
        "skipped": len(payload.device_ids) - len(node_ids),
        "skipped_devices": skipped_devices,
    }


@router.post("/pending/bulk-hide", response_model=dict)
async def bulk_hide_devices(
    payload: BulkActionRequest,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, Any]:
    result = await db.execute(
        select(PendingDevice).where(
            PendingDevice.id.in_(payload.device_ids),
            PendingDevice.status == "pending",
        )
    )
    devices = result.scalars().all()
    for device in devices:
        device.status = "hidden"
    await db.commit()
    return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}


@router.post("/pending/{device_id}/restore", response_model=dict)
async def restore_device(
    device_id: str,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, Any]:
    device = await db.get(PendingDevice, device_id)
    if not device:
        raise HTTPException(status_code=404, detail="Device not found")
    if device.status != "hidden":
        raise HTTPException(status_code=409, detail="Device is not hidden")
    device.status = "pending"
    await db.commit()
    return {"restored": True, "device_id": device_id}


@router.post("/pending/bulk-restore", response_model=dict)
async def bulk_restore_devices(
    payload: BulkActionRequest,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, Any]:
    result = await db.execute(
        select(PendingDevice).where(
            PendingDevice.id.in_(payload.device_ids),
            PendingDevice.status == "hidden",
        )
    )
    devices = result.scalars().all()
    for device in devices:
        device.status = "pending"
    await db.commit()
    return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}


@router.post("/pending/{device_id}/approve", response_model=dict)
async def approve_device(
    device_id: str,
    node_data: NodeCreate,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> dict[str, Any]:
    # Determine target design
    node_design_id = node_data.design_id
    if node_design_id is None:
        first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
        node_design_id = first.id if first else None

    device = await db.get(PendingDevice, device_id)
    if not device:
        raise HTTPException(status_code=404, detail="Device not found")
    # A device's status is GLOBAL — it flips to "approved" the moment it lands on
    # ANY canvas — but canvas membership is per-design. Approving onto a NEW
    # design must therefore work even when the device already sits on another
    # one (mirroring bulk_approve_devices, which deliberately does not filter on
    # status == "pending"). Same-design duplicates are caught by the per-design
    # IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
    # device is off-limits here.
    if device.status == "hidden":
        raise HTTPException(status_code=409, detail="Device is hidden")
    wireless = _is_wireless(node_data.type)

    # A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
    # again automatically: the user might genuinely want a second card, or might
    # be re-approving by mistake. Reject with 409 + the existing node so the UI
    # can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
    # hosts. force=True (set after the user confirms) skips this and creates it.
    # The same device on a *different* design is valid (one Node per canvas), so
    # this is scoped to node_design_id.
    if not node_data.force:
        conflict = await find_duplicate_node(
            db, node_design_id,
            node_data.ip or device.ip,
            node_data.mac or device.mac,
            ieee=device.ieee_address,
        )
        if conflict is not None:
            raise HTTPException(status_code=409, detail=conflict)

    device.status = "approved"
    # Prefer the MAC discovered during the scan (stored on the pending device);
    # fall back to whatever the approve payload carried.
    _mac = device.mac or node_data.mac
    cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
    node = Node(
        label=node_data.label,
        type=node_data.type,
        ip=node_data.ip,
        mac=_mac,
        hostname=node_data.hostname,
        status="online" if wireless else node_data.status,
        services=node_data.services or [],
        ieee_address=device.ieee_address,
        properties=_wireless_properties(
            node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
        ) if wireless else merge_mac_property(
            merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
            _mac,
        ),
        check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
        check_target=None if wireless else node_data.check_target,
        # Cluster hosts get side handles for their host↔host cluster edge.
        left_handles=1 if cluster_host else 0,
        right_handles=1 if cluster_host else 0,
        design_id=node_design_id,
    )
    db.add(node)
    await db.flush()
    node_id = node.id

    edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)

    await db.commit()
    return {
        "approved": True,
        "node_id": node_id,
        "edges_created": len(edges),
        "edges": edges,
    }


async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
    """True if ``ieee`` participates in a proxmox_cluster link (host↔host).

    Such a host needs one left + one right handle for the cluster edge endpoints
    (both default to 0). Checked at approve time, before the link is consumed by
    ``_resolve_pending_links_for_ieee``.
    """
    if not ieee:
        return False
    found = (
        await db.execute(
            select(PendingDeviceLink.id)
            .where(
                PendingDeviceLink.discovery_source == "proxmox_cluster",
                (PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
            )
            .limit(1)
        )
    ).scalar()
    return found is not None


async def _resolve_pending_links_for_ieee(
    db: AsyncSession, ieee: str | None, design_id: str | None
) -> list[dict[str, str]]:
    """Materialize edges for any pending_device_links involving ``ieee`` on the
    canvas identified by ``design_id``.

    For each link where the other endpoint already exists as a Node *on this
    design* (matched by ``Node.ieee_address`` + ``Node.design_id``), create the
    Edge. Links are **never** deleted here: they describe the discovered mesh /
    cluster topology and are wiped+reinserted wholesale on the next import
    (zigbee/zwave/proxmox). Keeping them lets the same devices be re-approved
    onto a second canvas with their edges intact.
    """
    if not ieee:
        return []

    links_q = await db.execute(
        select(PendingDeviceLink).where(
            (PendingDeviceLink.source_ieee == ieee)
            | (PendingDeviceLink.target_ieee == ieee)
        )
    )
    links = list(links_q.scalars().all())
    if not links:
        return []

    # Map every relevant ieee → Node *on the target design* (single query).
    # Scoping by design is what makes a re-approve onto a second canvas link the
    # nodes of THAT canvas, not stale nodes left on another one.
    other_ieees = {
        link.target_ieee if link.source_ieee == ieee else link.source_ieee
        for link in links
    }
    other_ieees.add(ieee)
    nodes_q = await db.execute(
        select(Node).where(
            Node.ieee_address.in_(other_ieees),
            Node.design_id == design_id,
        )
    )
    by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}

    self_node = by_ieee.get(ieee)
    if self_node is None:
        return []

    # Pre-fetch existing edges between these node ids so we don't create dups
    # if the user re-approves a device or had drawn the link manually.
    candidate_node_ids = [n.id for n in by_ieee.values()]
    existing_q = await db.execute(
        select(Edge).where(
            Edge.source.in_(candidate_node_ids),
            Edge.target.in_(candidate_node_ids),
        )
    )
    existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}

    created: list[dict[str, str]] = []
    for link in links:
        other_ieee = (
            link.target_ieee if link.source_ieee == ieee else link.source_ieee
        )
        other_node = by_ieee.get(other_ieee)
        if other_node is None:
            continue
        if link.source_ieee == ieee:
            src_id, tgt_id = self_node.id, other_node.id
        else:
            src_id, tgt_id = other_node.id, self_node.id
        # Skip if either direction already exists on this design (re-approve or
        # a manually drawn link). The link row is kept for other designs.
        if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
            continue
        # Edge lands on the design we're approving into (the nodes above are
        # already scoped to it).
        edge_design_id = design_id or (self_node.design_id if self_node else None)
        # Edge shape by link source. Handle IDs are the *bare* slot-0 side names
        # (the canonical stored form — the save path normalizes '<side>-t' → the
        # bare source id, and React Flow resolves the bare id to that side). A
        # '-t' target id does not resolve here and RF falls back to the top
        # handle, so never emit one.
        #   proxmox         → 'virtual' host→guest, vertical (bottom → top)
        #   proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
        #   anything else   → 'iot' mesh link, vertical
        if link.discovery_source == "proxmox":
            edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
        elif link.discovery_source == "proxmox_cluster":
            edge_type, src_handle, tgt_handle = "cluster", "right", "left"
        else:
            edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
        edge = Edge(
            source=src_id,
            target=tgt_id,
            type=edge_type,
            source_handle=src_handle,
            target_handle=tgt_handle,
            design_id=edge_design_id,
        )
        db.add(edge)
        await db.flush()
        existing_pairs.add((src_id, tgt_id))
        # Return the edge's type + handles so the client injects it faithfully
        # (a cluster edge must keep its right→left handles, not the iot default).
        created.append({
            "id": edge.id,
            "source": src_id,
            "target": tgt_id,
            "type": edge_type,
            "source_handle": src_handle,
            "target_handle": tgt_handle,
        })

    return created


@router.post("/pending/{device_id}/hide")
async def hide_device(
    device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
    device = await db.get(PendingDevice, device_id)
    if not device:
        raise HTTPException(status_code=404, detail="Device not found")
    device.status = "hidden"
    await db.commit()
    return {"hidden": True}


@router.post("/pending/{device_id}/ignore")
async def ignore_device(
    device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
    device = await db.get(PendingDevice, device_id)
    if not device:
        raise HTTPException(status_code=404, detail="Device not found")
    await db.delete(device)
    await db.commit()
    return {"ignored": True}


@router.get("/runs", response_model=list[ScanRunResponse])
async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[ScanRun]:
    result = await db.execute(select(ScanRun).order_by(ScanRun.started_at.desc()).limit(20))
    return list(result.scalars().all())


@router.get("/config", response_model=ScanConfig)
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
    return ScanConfig(
        ranges=settings.scanner_ranges,
        http_ranges=settings.scanner_http_ranges,
        http_probe_enabled=settings.scanner_http_probe_enabled,
        verify_tls=settings.scanner_http_verify_tls,
    )


@router.post("/config", response_model=ScanConfig)
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
    previous = (
        settings.scanner_ranges,
        settings.scanner_http_ranges,
        settings.scanner_http_probe_enabled,
        settings.scanner_http_verify_tls,
    )
    settings.scanner_ranges = payload.ranges
    settings.scanner_http_ranges = payload.http_ranges
    settings.scanner_http_probe_enabled = payload.http_probe_enabled
    settings.scanner_http_verify_tls = payload.verify_tls
    try:
        settings.save_overrides()
        return payload
    except Exception as exc:
        (
            settings.scanner_ranges,
            settings.scanner_http_ranges,
            settings.scanner_http_probe_enabled,
            settings.scanner_http_verify_tls,
        ) = previous
        logger.error("Failed to save scan config: %s", exc)
        raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
