"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch

import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.models import Design, Node, PendingDevice, ScanRun
from app.services.scanner import _cancelled_runs, request_cancel, run_scan


@pytest.fixture
async def headers(client: AsyncClient):
    res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
    token = res.json()["access_token"]
    return {"Authorization": f"Bearer {token}"}


@pytest.fixture
async def pending_device(db_session):
    import uuid
    device = PendingDevice(
        id=str(uuid.uuid4()),
        ip="192.168.1.100",
        mac="aa:bb:cc:dd:ee:ff",
        hostname="my-server",
        os="Linux",
        services=[{"port": 22, "name": "ssh"}],
        suggested_type="server",
        status="pending",
    )
    db_session.add(device)
    await db_session.commit()
    await db_session.refresh(device)
    return device


# --- _background_scan error handling ---

@pytest.fixture
async def mem_db():
    from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

    from app.db.database import Base
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    factory = async_sessionmaker(engine, expire_on_commit=False)
    yield factory
    await engine.dispose()


@pytest.mark.asyncio
async def test_background_scan_marks_run_failed_on_exception(mem_db):
    """If run_scan() raises, the ScanRun must transition running → failed and the
    session rollback path must execute without a follow-on exception."""
    from app.api.routes.scan import _background_scan

    async with mem_db() as session:
        run = ScanRun(status="running", ranges=["10.0.0.0/24"])
        session.add(run)
        await session.commit()
        run_id = run.id

    with (
        patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
        patch(
            "app.api.routes.scan.run_scan",
            new_callable=AsyncMock,
            side_effect=RuntimeError("boom"),
        ),
    ):
        await _background_scan(run_id, ["10.0.0.0/24"])

    async with mem_db() as session:
        refreshed = await session.get(ScanRun, run_id)
        assert refreshed is not None
        assert refreshed.status == "failed"


@pytest.mark.asyncio
async def test_background_scan_leaves_non_running_status_alone(mem_db):
    """If the run was already stopped/cancelled before run_scan failed, _background_scan
    must NOT overwrite that terminal status with 'failed'."""
    from app.api.routes.scan import _background_scan

    async with mem_db() as session:
        run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
        session.add(run)
        await session.commit()
        run_id = run.id

    with (
        patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
        patch(
            "app.api.routes.scan.run_scan",
            new_callable=AsyncMock,
            side_effect=RuntimeError("boom"),
        ),
    ):
        await _background_scan(run_id, ["10.0.0.0/24"])

    async with mem_db() as session:
        refreshed = await session.get(ScanRun, run_id)
        assert refreshed is not None
        assert refreshed.status == "cancelled"


@pytest.mark.asyncio
async def test_background_scan_success_path_invokes_run_scan(mem_db):
    from app.api.routes.scan import _background_scan

    async with mem_db() as session:
        run = ScanRun(status="running", ranges=["10.0.0.0/24"])
        session.add(run)
        await session.commit()
        run_id = run.id

    with (
        patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
        patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
    ):
        from app.services.scanner import DeepScanOptions
        await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
        mock_run_scan.assert_awaited_once()


# --- Trigger scan ---

@pytest.mark.asyncio
async def test_trigger_scan_requires_auth(client: AsyncClient):
    res = await client.post("/api/v1/scan/trigger")
    assert res.status_code == 401


@pytest.mark.asyncio
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
    with (
        patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
        patch("app.api.routes.scan.settings") as mock_settings,
    ):
        mock_settings.scanner_ranges = ["192.168.1.0/24"]
        res = await client.post("/api/v1/scan/trigger", headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert data["status"] == "running"
    assert data["ranges"] == ["192.168.1.0/24"]
    assert "id" in data


# --- Pending devices ---

@pytest.mark.asyncio
async def test_list_pending_empty(client: AsyncClient, headers):
    res = await client.get("/api/v1/scan/pending", headers=headers)
    assert res.status_code == 200
    assert res.json() == []


@pytest.mark.asyncio
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
    res = await client.get("/api/v1/scan/pending", headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert len(data) == 1
    assert data[0]["ip"] == "192.168.1.100"
    assert data[0]["hostname"] == "my-server"
    # No matching node → not on any canvas.
    assert data[0]["canvas_count"] == 0


# --- Canvas-presence correlation (canvas_count) ---

async def _add_design(db_session, name: str) -> str:
    design = Design(id=str(uuid.uuid4()), name=name)
    db_session.add(design)
    await db_session.commit()
    return design.id


def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
    return Node(
        id=str(uuid.uuid4()), label="n", type="server", status="online",
        ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
        design_id=design_id,
    )


@pytest.mark.asyncio
async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session, pending_device):
    # Node.ip holds several comma-separated addresses (IPv6 added first). The
    # device scanned as the plain IPv4 must still correlate (issue #258).
    d1 = await _add_design(db_session, "Home")
    db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
    await db_session.commit()

    data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
    assert data[0]["canvas_count"] == 1


@pytest.mark.asyncio
async def test_canvas_count_correlates_by_mac(client, headers, db_session, pending_device):
    # Node's ip differs entirely (user edited it) but the MAC still matches:
    # the device is on the canvas (issue #258, MAC is the stable identifier).
    d1 = await _add_design(db_session, "Home")
    db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
    await db_session.commit()

    data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
    assert data[0]["canvas_count"] == 1


@pytest.mark.asyncio
async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
    # Same IP placed on two different canvases → canvas_count == 2.
    d1 = await _add_design(db_session, "Home")
    d2 = await _add_design(db_session, "Lab")
    db_session.add(_node(d1, ip="192.168.1.100"))
    db_session.add(_node(d2, ip="192.168.1.100"))
    await db_session.commit()

    res = await client.get("/api/v1/scan/pending", headers=headers)
    data = res.json()
    assert len(data) == 1
    assert data[0]["canvas_count"] == 2


@pytest.mark.asyncio
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
    device = PendingDevice(
        id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
        suggested_type="zigbee_enddevice", services=[], status="pending",
    )
    db_session.add(device)
    d1 = await _add_design(db_session, "Zigbee")
    db_session.add(_node(d1, ieee="0x00124b001"))
    await db_session.commit()

    res = await client.get("/api/v1/scan/pending", headers=headers)
    by_id = {d["id"]: d for d in res.json()}
    assert by_id[device.id]["canvas_count"] == 1


@pytest.mark.asyncio
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
    # A node with no design_id is not "on a canvas".
    db_session.add(_node(None, ip="192.168.1.100"))
    await db_session.commit()

    res = await client.get("/api/v1/scan/pending", headers=headers)
    assert res.json()[0]["canvas_count"] == 0


# --- Linked-node timestamps on the inventory response ---

@pytest.mark.asyncio
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
    # No matching canvas node → node_* timestamps are all null; the device still
    # carries its own discovered_at for the "Discovered" fallback on the tile.
    data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
    assert data["discovered_at"] is not None
    assert data["node_created_at"] is None
    assert data["node_last_scan"] is None
    assert data["node_last_modified"] is None
    assert data["node_last_seen"] is None


@pytest.mark.asyncio
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
    d1 = await _add_design(db_session, "Home")
    node = _node(d1, ip="192.168.1.100")
    node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
    node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
    db_session.add(node)
    await db_session.commit()

    data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
    assert data["node_created_at"] is not None      # defaulted on insert
    assert data["node_last_modified"] is not None    # updated_at defaulted on insert
    assert data["node_last_scan"].startswith("2026-06-01")
    assert data["node_last_seen"].startswith("2026-06-25")


@pytest.mark.asyncio
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
    # Two canvas nodes share the device IP: created_at takes the OLDEST,
    # last_scan takes the NEWEST.
    d1 = await _add_design(db_session, "Home")
    d2 = await _add_design(db_session, "Lab")
    older = _node(d1, ip="192.168.1.100")
    older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
    older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
    newer = _node(d2, ip="192.168.1.100")
    newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
    newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
    db_session.add_all([older, newer])
    await db_session.commit()

    data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
    assert data["node_created_at"].startswith("2026-01-01")  # oldest
    assert data["node_last_scan"].startswith("2026-06-01")   # newest


# --- Approve device ---

@pytest.mark.asyncio
async def test_approve_device(client: AsyncClient, headers, pending_device):
    node_payload = {
        "label": "My Server",
        "type": "server",
        "ip": "192.168.1.100",
        "hostname": "my-server",
        "status": "unknown",
        "services": [],
    }
    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json=node_payload,
        headers=headers,
    )
    assert res.status_code == 200
    data = res.json()
    assert data["approved"] is True
    assert "node_id" in data

    # Approved devices stay in the inventory (status != "hidden") so they keep
    # showing with an "In N canvas" badge — they are no longer dropped.
    pending_res = await client.get("/api/v1/scan/pending", headers=headers)
    inventory = pending_res.json()
    assert len(inventory) == 1
    assert inventory[0]["id"] == pending_device.id
    assert inventory[0]["status"] == "approved"


@pytest.mark.asyncio
async def test_approve_device_conflicts_on_existing_ieee_same_design(
    client: AsyncClient, headers, db_session
):
    """Approving a device whose IEEE is already on the target design prompts the
    user (409) instead of silently merging/replacing — same UX as ip/mac."""
    design = Design(name="d1")
    db_session.add(design)
    await db_session.flush()
    existing = Node(
        label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
        services=[], design_id=design.id,
    )
    db_session.add(existing)
    device = PendingDevice(
        id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
        status="pending", discovery_source="zigbee",
    )
    db_session.add(device)
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{device.id}/approve",
        json={
            "label": "sensor", "type": "zigbee_enddevice",
            "status": "online", "services": [], "design_id": design.id,
        },
        headers=headers,
    )
    assert res.status_code == 409
    detail = res.json()["detail"]
    assert detail["duplicate"] is True
    assert detail["existing_node_id"] == existing.id
    assert detail["match"] == "ieee"
    assert detail["value"] == "0xZZZ"

    # No second node created; device stays pending until the user decides.
    nodes = (
        await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
    ).scalars().all()
    assert len(nodes) == 1


@pytest.mark.asyncio
async def test_approve_device_force_creates_duplicate_ieee(
    client: AsyncClient, headers, db_session
):
    """force=True lets the user place a second card for the same IEEE."""
    design = Design(name="d1")
    db_session.add(design)
    await db_session.flush()
    db_session.add(Node(
        label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
        services=[], design_id=design.id,
    ))
    device = PendingDevice(
        id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
        status="pending", discovery_source="zigbee",
    )
    db_session.add(device)
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{device.id}/approve",
        json={
            "label": "sensor", "type": "zigbee_enddevice", "status": "online",
            "services": [], "design_id": design.id, "force": True,
        },
        headers=headers,
    )
    assert res.status_code == 200
    nodes = (
        await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
    ).scalars().all()
    assert len(nodes) == 2


@pytest.mark.asyncio
async def test_approve_device_conflicts_on_existing_ip(
    client: AsyncClient, headers, db_session, pending_device
):
    """An ordinary host whose ip already sits on the target design is NOT
    silently duplicated: the approve returns 409 with the existing node so the
    UI can ask the user."""
    design = await _add_design(db_session, "Home")
    existing = _node(design, ip="192.168.1.100")
    db_session.add(existing)
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "dup", "type": "server", "ip": "192.168.1.100",
              "status": "unknown", "services": [], "design_id": design},
        headers=headers,
    )
    assert res.status_code == 409
    detail = res.json()["detail"]
    assert detail["duplicate"] is True
    assert detail["existing_node_id"] == existing.id
    assert detail["match"] == "ip"
    assert detail["value"] == "192.168.1.100"

    # No node created, device left pending (user hasn't decided yet).
    nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
    assert len(nodes) == 1
    await db_session.refresh(pending_device)
    assert pending_device.status == "pending"


@pytest.mark.asyncio
async def test_approve_device_conflicts_on_existing_mac(
    client: AsyncClient, headers, db_session, pending_device
):
    """MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
    design = await _add_design(db_session, "Home")
    existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
                    ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
    db_session.add(existing)
    await db_session.commit()

    # pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "dup", "type": "server", "ip": "192.168.1.55",
              "mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [],
              "design_id": design},
        headers=headers,
    )
    assert res.status_code == 409
    assert res.json()["detail"]["match"] == "mac"


@pytest.mark.asyncio
async def test_approve_device_conflicts_on_ip_in_comma_list(
    client: AsyncClient, headers, db_session, pending_device
):
    """The existing node's ip holds an IPv6 before the IPv4 the device scanned
    as. Exact-string matching missed it (issue #258); per-token matching catches
    the duplicate."""
    design = await _add_design(db_session, "Home")
    existing = _node(design, ip="fe80::1, 192.168.1.100")
    db_session.add(existing)
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "dup", "type": "server", "ip": "192.168.1.100",
              "status": "unknown", "services": [], "design_id": design},
        headers=headers,
    )
    assert res.status_code == 409
    detail = res.json()["detail"]
    assert detail["existing_node_id"] == existing.id
    assert detail["match"] == "ip"
    assert detail["value"] == "192.168.1.100"


@pytest.mark.asyncio
async def test_approve_device_no_conflict_on_ip_substring(
    client: AsyncClient, headers, db_session, pending_device
):
    """The ip guard must match whole addresses, not substrings: a node at
    10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
    design = await _add_design(db_session, "Home")
    db_session.add(_node(design, ip="10.0.0.40"))
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "new", "type": "server", "ip": "10.0.0.4",
              "mac": None, "status": "unknown", "services": [], "design_id": design},
        headers=headers,
    )
    assert res.status_code == 200


@pytest.mark.asyncio
async def test_approve_device_force_creates_duplicate(
    client: AsyncClient, headers, db_session, pending_device
):
    """force=True (user confirmed) bypasses the guard and creates the node."""
    design = await _add_design(db_session, "Home")
    db_session.add(_node(design, ip="192.168.1.100"))
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "dup", "type": "server", "ip": "192.168.1.100",
              "status": "unknown", "services": [], "design_id": design, "force": True},
        headers=headers,
    )
    assert res.status_code == 200
    nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
    assert len(nodes) == 2  # duplicate deliberately created


@pytest.mark.asyncio
async def test_approve_device_allows_same_ip_on_other_design(
    client: AsyncClient, headers, db_session, pending_device
):
    """The guard is per-design: the same host on a different canvas is fine."""
    other = await _add_design(db_session, "Lab")
    target = await _add_design(db_session, "Home")
    db_session.add(_node(other, ip="192.168.1.100"))  # exists on a DIFFERENT design
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "ok", "type": "server", "ip": "192.168.1.100",
              "status": "unknown", "services": [], "design_id": target},
        headers=headers,
    )
    assert res.status_code == 200


@pytest.mark.asyncio
async def test_approve_device_places_already_approved_on_another_design(
    client: AsyncClient, headers, db_session
):
    """A device already approved on ANOTHER canvas (global status="approved")
    must still be placeable on a new design — status is global, canvas
    membership is per-design (mirrors bulk_approve)."""
    other = await _add_design(db_session, "Other")
    target = await _add_design(db_session, "Network Topology")
    # Device is on `other` already (its global status is "approved").
    db_session.add(_node(other, ieee="0x00158d0005292b83"))
    device = PendingDevice(
        id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
        suggested_type="zigbee_enddevice", status="approved",
        discovery_source="zigbee",
    )
    db_session.add(device)
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{device.id}/approve",
        json={"label": "sensor", "type": "zigbee_enddevice", "status": "online",
              "services": [], "design_id": target},
        headers=headers,
    )
    assert res.status_code == 200
    # A node now exists on the target design too (one per canvas).
    nodes = (
        await db_session.execute(
            select(Node).where(Node.ieee_address == "0x00158d0005292b83")
        )
    ).scalars().all()
    assert {n.design_id for n in nodes} == {other, target}


@pytest.mark.asyncio
async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device):
    """A user-hidden device is not approvable via this endpoint."""
    pending_device.status = "hidden"
    db_session.add(pending_device)
    await db_session.commit()
    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "x", "type": "server", "status": "unknown", "services": []},
        headers=headers,
    )
    assert res.status_code == 409


@pytest.mark.asyncio
async def test_approve_nonexistent_device(client: AsyncClient, headers):
    node_payload = {
        "label": "Ghost",
        "type": "generic",
        "ip": "10.0.0.1",
        "status": "unknown",
        "services": [],
    }
    res = await client.post(
        "/api/v1/scan/pending/nonexistent-id/approve",
        json=node_payload,
        headers=headers,
    )
    assert res.status_code == 404


# --- Hide device ---

@pytest.mark.asyncio
async def test_hide_device(client: AsyncClient, headers, pending_device):
    res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
    assert res.status_code == 200
    assert res.json()["hidden"] is True

    # Should no longer appear in pending
    pending_res = await client.get("/api/v1/scan/pending", headers=headers)
    assert pending_res.json() == []

    # Should appear in hidden
    hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
    assert len(hidden_res.json()) == 1


# --- Restore hidden device ---

@pytest.mark.asyncio
async def test_restore_device(client: AsyncClient, headers, pending_device):
    # Hide first
    await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)

    # Restore
    res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
    assert res.status_code == 200
    assert res.json()["restored"] is True

    # Now back in pending, gone from hidden
    pending_res = await client.get("/api/v1/scan/pending", headers=headers)
    assert len(pending_res.json()) == 1
    hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
    assert hidden_res.json() == []


@pytest.mark.asyncio
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
    res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
    assert res.status_code == 409


@pytest.mark.asyncio
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
    # Hide
    await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)

    res = await client.post(
        "/api/v1/scan/pending/bulk-restore",
        headers=headers,
        json={"device_ids": [pending_device.id]},
    )
    assert res.status_code == 200
    assert res.json()["restored"] == 1
    assert res.json()["skipped"] == 0

    pending_res = await client.get("/api/v1/scan/pending", headers=headers)
    assert len(pending_res.json()) == 1


# --- Ignore device ---

@pytest.mark.asyncio
async def test_ignore_device(client: AsyncClient, headers, pending_device):
    res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
    assert res.status_code == 200
    assert res.json()["ignored"] is True

    # Device should be gone from both pending and hidden
    pending_res = await client.get("/api/v1/scan/pending", headers=headers)
    assert pending_res.json() == []
    hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
    assert hidden_res.json() == []


# --- Scan runs ---

@pytest.mark.asyncio
async def test_list_runs_empty(client: AsyncClient, headers):
    res = await client.get("/api/v1/scan/runs", headers=headers)
    assert res.status_code == 200
    assert res.json() == []


# --- run_scan: re-scan updates existing pending devices ---

MOCK_HOST = {
    "ip": "192.168.1.50",
    "mac": "aa:bb:cc:dd:ee:ff",
    "hostname": "myhost.lan",
    "os": "Linux",
    "open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
}


@pytest.mark.asyncio
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    result = await db_session.execute(
        select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
    )
    device = result.scalar_one_or_none()
    assert device is not None
    assert device.hostname == "myhost.lan"
    assert any(s["port"] == 8096 for s in device.services)
    assert device.suggested_type == "server"


@pytest.mark.asyncio
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
    """Pending devices whose IP is already on a canvas are NOT purged — they stay
    in the inventory and are surfaced with an "In N canvas" badge."""
    node = Node(
        id=str(uuid.uuid4()),
        label="Existing Server",
        type="server",
        ip="192.168.1.50",
        status="online",
        services=[],
        pos_x=0.0,
        pos_y=0.0,
    )
    stale = PendingDevice(
        id=str(uuid.uuid4()),
        ip="192.168.1.50",
        mac=None,
        hostname=None,
        os=None,
        services=[],
        suggested_type="generic",
        status="pending",
    )
    db_session.add(node)
    db_session.add(stale)
    await db_session.commit()

    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    result = await db_session.execute(
        select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
    )
    assert result.scalar_one_or_none() is not None


@pytest.mark.asyncio
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
    """A scanned IP that already exists as a canvas Node still produces a pending
    device (no longer suppressed)."""
    node = Node(
        id=str(uuid.uuid4()),
        label="Existing Server",
        type="server",
        ip="192.168.1.50",
        status="online",
        services=[],
        pos_x=0.0,
        pos_y=0.0,
    )
    db_session.add(node)
    await db_session.commit()

    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    result = await db_session.execute(
        select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
    )
    device = result.scalar_one_or_none()
    assert device is not None
    assert device.status == "pending"


@pytest.mark.asyncio
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
    """Re-scanning an already-approved device updates its row in place instead of
    spawning a fresh pending duplicate, and keeps it approved."""
    approved = PendingDevice(
        id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
        os=None, services=[], suggested_type="server", status="approved",
    )
    db_session.add(approved)
    run_id = str(uuid.uuid4())
    db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    rows = (await db_session.execute(
        select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
    )).scalars().all()
    assert len(rows) == 1
    assert rows[0].status == "approved"
    assert rows[0].hostname == "myhost.lan"  # refreshed from the scan


@pytest.mark.asyncio
async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
    """Pre-existing duplicate inventory rows for one IP are collapsed to a single
    row at scan start, even if the device is not re-discovered."""
    for status in ("approved", "pending", "pending"):
        db_session.add(PendingDevice(
            id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
            os=None, services=[], suggested_type="server", status=status,
        ))
    run_id = str(uuid.uuid4())
    db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    rows = (await db_session.execute(
        select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
    )).scalars().all()
    assert len(rows) == 1
    assert rows[0].status == "approved"  # approved row is the one kept


@pytest.mark.asyncio
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
    """Devices previously hidden by the user must not re-appear in pending on re-scan."""
    hidden = PendingDevice(
        id=str(uuid.uuid4()),
        ip="192.168.1.50",
        mac=None,
        hostname=None,
        os=None,
        services=[],
        suggested_type="generic",
        status="hidden",
    )
    db_session.add(hidden)
    await db_session.commit()

    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    result = await db_session.execute(
        select(PendingDevice).where(
            PendingDevice.ip == "192.168.1.50",
            PendingDevice.status == "pending",
        )
    )
    assert result.scalar_one_or_none() is None


# --- Stop scan ---

@pytest.mark.asyncio
async def test_stop_scan_requires_auth(client: AsyncClient):
    res = await client.post("/api/v1/scan/fake-id/stop")
    assert res.status_code == 401


@pytest.mark.asyncio
async def test_stop_scan_not_found(client: AsyncClient, headers):
    import uuid as _uuid
    res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
    assert res.status_code == 404


@pytest.mark.asyncio
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
    run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
    assert res.status_code == 409


@pytest.mark.asyncio
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
    run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
    assert res.status_code == 200
    assert res.json() == {"stopping": True}
    # run_id added to cancel set
    assert run.id in _cancelled_runs
    # status flipped eagerly so the UI reacts without waiting for a checkpoint
    await db_session.refresh(run)
    assert run.status == "cancelled"
    assert run.finished_at is not None
    # cleanup for other tests
    _cancelled_runs.discard(run.id)


# --- run_scan cancellation ---

@pytest.mark.asyncio
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
    """When cancel is requested before the scan starts, status becomes 'cancelled'."""
    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    request_cancel(run_id)

    with (
        patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)
        # nmap should not have been called — cancelled before first range
        mock_nmap.assert_not_called()

    await db_session.refresh(run)
    assert run.status == "cancelled"
    assert run.finished_at is not None


@pytest.mark.asyncio
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
    """Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    call_count = 0

    def nmap_side_effect(target: str, port_spec: str | None = None, run_id: str | None = None):
        nonlocal call_count
        call_count += 1
        # Signal cancellation after the first CIDR scan completes
        if call_count == 1:
            request_cancel(run_id)
        return []

    with (
        patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)

    assert call_count == 1  # second CIDR was skipped
    await db_session.refresh(run)
    assert run.status == "cancelled"


@pytest.mark.asyncio
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
    """Re-scanning the same IP updates services instead of creating a duplicate."""
    # Pre-existing pending device with no services
    existing = PendingDevice(
        id=str(uuid.uuid4()),
        ip="192.168.1.50",
        mac=None,
        hostname=None,
        os=None,
        services=[],
        suggested_type="generic",
        status="pending",
    )
    db_session.add(existing)
    await db_session.commit()

    run_id = str(uuid.uuid4())
    run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
    db_session.add(run)
    await db_session.commit()

    with (
        patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
        patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
    ):
        await run_scan(["192.168.1.0/24"], db_session, run_id)

    # Should still be only one device
    result = await db_session.execute(
        select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
    )
    devices = list(result.scalars().all())
    assert len(devices) == 1
    device = devices[0]
    # Services and hostname should be updated
    assert device.hostname == "myhost.lan"
    assert any(s["port"] == 8096 for s in device.services)


# --- Bulk approve ---

@pytest.fixture
async def two_pending_devices(db_session):
    devices = []
    for i in range(2):
        d = PendingDevice(
            id=str(uuid.uuid4()),
            ip=f"192.168.1.{10 + i}",
            mac=None,
            hostname=f"host-{i}",
            os=None,
            services=[],
            suggested_type="generic",
            status="pending",
        )
        db_session.add(d)
        devices.append(d)
    await db_session.commit()
    for d in devices:
        await db_session.refresh(d)
    return devices


@pytest.mark.asyncio
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
    ids = [d.id for d in two_pending_devices]
    res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert data["approved"] == 2
    assert len(data["node_ids"]) == 2
    assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
    assert len(data["device_ids"]) == 2
    assert data["skipped"] == 0
    # Approved devices stay in the inventory, now marked "approved".
    pending_res = await client.get("/api/v1/scan/pending", headers=headers)
    inventory = pending_res.json()
    assert len(inventory) == 2
    assert all(d["status"] == "approved" for d in inventory)


@pytest.mark.asyncio
async def test_bulk_approve_places_already_approved_device_on_another_design(
    client: AsyncClient, headers, db_session, two_pending_devices
):
    """Regression: a device already approved (status='approved', e.g. placed on
    another canvas) must still get a node on the design being approved onto.

    Previously bulk-approve filtered status=='pending', so selecting an
    already-approved device created no node — the user saw fewer nodes than
    they selected."""
    ids = [d.id for d in two_pending_devices]
    design_a = await _add_design(db_session, "Canvas A")
    design_b = await _add_design(db_session, "Canvas B")

    # Approve both onto design A.
    res_a = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": ids, "design_id": design_a},
        headers=headers,
    )
    assert res_a.json()["approved"] == 2

    # Re-approve the same (now status='approved') devices onto design B.
    res_b = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": ids, "design_id": design_b},
        headers=headers,
    )
    data_b = res_b.json()
    assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
    assert data_b["skipped"] == 0

    # Two nodes now exist on each design.
    from app.db.models import Node as NodeModel
    nodes_b = (
        await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
    ).scalars().all()
    assert len(nodes_b) == 2


@pytest.mark.asyncio
async def test_bulk_approve_skips_device_already_on_target_design(
    client: AsyncClient, headers, db_session, two_pending_devices
):
    """A device already on the target canvas (same ip) is not placed twice."""
    ids = [d.id for d in two_pending_devices]
    design = await _add_design(db_session, "Canvas")
    # First device already sits on the canvas (matched by ip).
    db_session.add(_node(design, ip="192.168.1.10"))
    await db_session.commit()

    res = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": ids, "design_id": design},
        headers=headers,
    )
    data = res.json()
    assert data["approved"] == 1   # only the second device (192.168.1.11)
    assert data["skipped"] == 1

    from app.db.models import Node as NodeModel
    nodes = (
        await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
    ).scalars().all()
    # The pre-existing node plus the one newly approved — no duplicate for .10.
    assert len(nodes) == 2
    assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]


@pytest.mark.asyncio
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
    client: AsyncClient, headers, db_session, two_pending_devices
):
    """The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
    as the plain IPv4 is still recognised as already placed (issue #258)."""
    ids = [d.id for d in two_pending_devices]
    design = await _add_design(db_session, "Canvas")
    db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
    await db_session.commit()

    res = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": ids, "design_id": design},
        headers=headers,
    )
    data = res.json()
    assert data["approved"] == 1   # only the second device (192.168.1.11)
    assert data["skipped"] == 1
    assert data["skipped_devices"][0]["value"] == "192.168.1.10"


@pytest.mark.asyncio
async def test_bulk_approve_reports_skipped_devices(
    client: AsyncClient, headers, db_session, two_pending_devices
):
    """Bulk can't prompt per-device, so it reports each duplicate it skipped
    (with the existing node id) instead of silently dropping it."""
    ids = [d.id for d in two_pending_devices]
    design = await _add_design(db_session, "Canvas")
    existing = _node(design, ip="192.168.1.10")
    db_session.add(existing)
    await db_session.commit()

    res = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": ids, "design_id": design},
        headers=headers,
    )
    data = res.json()
    assert data["approved"] == 1
    skipped = data["skipped_devices"]
    assert len(skipped) == 1
    entry = skipped[0]
    assert entry["match"] == "ip"
    assert entry["value"] == "192.168.1.10"
    assert entry["existing_node_id"] == existing.id
    assert entry["device_id"] in ids


@pytest.fixture
async def zigbee_pending_device(db_session):
    device = PendingDevice(
        id=str(uuid.uuid4()),
        ip=None,
        mac=None,
        hostname=None,
        friendly_name="bulb_1",
        services=[],
        suggested_type="zigbee_enddevice",
        device_subtype="EndDevice",
        ieee_address="0xABCDEF",
        vendor="IKEA",
        model="TRADFRI",
        lqi=180,
        status="pending",
        discovery_source="zigbee",
    )
    db_session.add(device)
    await db_session.commit()
    await db_session.refresh(device)
    return device


@pytest.mark.asyncio
async def test_approve_zigbee_device_populates_properties(
    client: AsyncClient, headers, zigbee_pending_device, db_session
):
    """Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    payload = {
        "label": "bulb_1",
        "type": "zigbee_enddevice",
        "status": "online",
        "services": [],
        "check_method": "none",
    }
    res = await client.post(
        f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
        json=payload,
        headers=headers,
    )
    assert res.status_code == 200
    node = (
        await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
    ).scalar_one()
    keys = {p["key"]: p["value"] for p in node.properties}
    assert keys == {
        "IEEE": "0xABCDEF",
        "Vendor": "IKEA",
        "Model": "TRADFRI",
        "LQI": "180",
    }


@pytest.mark.asyncio
async def test_bulk_approve_zigbee_populates_properties(
    client: AsyncClient, headers, zigbee_pending_device, db_session
):
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    res = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": [zigbee_pending_device.id]},
        headers=headers,
    )
    assert res.status_code == 200
    node = (
        await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
    ).scalar_one()
    keys = {p["key"]: p["value"] for p in node.properties}
    assert keys["IEEE"] == "0xABCDEF"
    assert keys["Vendor"] == "IKEA"
    assert keys["Model"] == "TRADFRI"
    assert keys["LQI"] == "180"
    assert node.check_method == "none"


# --- MAC address propagation on approve (issue #168) ---

def test_build_mac_property_returns_hidden_row():
    from app.api.routes.scan import build_mac_property

    assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
        {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
    ]


def test_build_mac_property_empty_when_no_mac():
    from app.api.routes.scan import build_mac_property

    assert build_mac_property(None) == []
    assert build_mac_property("") == []


def test_merge_mac_property_appends_when_absent():
    from app.api.routes.scan import merge_mac_property

    existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
    merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
    assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
    # Existing prop preserved untouched.
    assert existing[0] in merged


def test_merge_mac_property_idempotent_and_preserves_visibility():
    from app.api.routes.scan import merge_mac_property

    existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
    merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
    # No duplicate MAC row; user's visible=True choice kept.
    macs = [p for p in merged if p["key"] == "MAC"]
    assert len(macs) == 1
    assert macs[0]["visible"] is True


def test_merge_mac_property_noop_without_mac():
    from app.api.routes.scan import merge_mac_property

    existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
    assert merge_mac_property(existing, None) == existing


@pytest.mark.asyncio
async def test_approve_device_copies_mac_to_node_and_properties(
    client: AsyncClient, headers, pending_device, db_session
):
    """Approving a scanned device must carry its MAC onto the node + properties."""
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    # Payload intentionally omits mac — it must come from the pending device.
    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
        headers=headers,
    )
    assert res.status_code == 200
    node = (
        await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
    ).scalar_one()
    assert node.mac == "aa:bb:cc:dd:ee:ff"
    mac_props = [p for p in node.properties if p["key"] == "MAC"]
    assert mac_props == [
        {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
    ]


@pytest.mark.asyncio
async def test_approve_device_does_not_duplicate_mac_property(
    client: AsyncClient, headers, pending_device, db_session
):
    """If the approve payload already carries a MAC prop, don't add a second one."""
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={
            "label": "My Server",
            "type": "server",
            "ip": "192.168.1.100",
            "status": "unknown",
            "services": [],
            "properties": [
                {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
            ],
        },
        headers=headers,
    )
    assert res.status_code == 200
    node = (
        await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
    ).scalar_one()
    mac_props = [p for p in node.properties if p["key"] == "MAC"]
    assert len(mac_props) == 1
    # User's visibility choice is preserved.
    assert mac_props[0]["visible"] is True


@pytest.mark.asyncio
async def test_bulk_approve_copies_mac_to_node_and_properties(
    client: AsyncClient, headers, db_session
):
    """Bulk approve must also propagate the scanned MAC to node + properties."""
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    device = PendingDevice(
        id=str(uuid.uuid4()),
        ip="192.168.1.55",
        mac="11:22:33:44:55:66",
        hostname="host-mac",
        services=[],
        suggested_type="generic",
        status="pending",
    )
    db_session.add(device)
    await db_session.commit()

    res = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": [device.id]},
        headers=headers,
    )
    assert res.status_code == 200
    node = (
        await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
    ).scalar_one()
    assert node.mac == "11:22:33:44:55:66"
    mac_props = [p for p in node.properties if p["key"] == "MAC"]
    assert mac_props == [
        {"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
    ]


@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
    """Approved devices with an IP must default to ping; otherwise scheduler skips them."""
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    ids = [d.id for d in two_pending_devices]
    res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
    assert res.status_code == 200
    nodes = (await db_session.execute(select(NodeModel))).scalars().all()
    for n in nodes:
        if n.ip:
            assert n.check_method == "ping", f"node {n.id} created without check_method"


@pytest.mark.asyncio
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
    from sqlalchemy import select

    from app.db.models import Node as NodeModel
    res = await client.post(
        f"/api/v1/scan/pending/{pending_device.id}/approve",
        json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
        headers=headers,
    )
    assert res.status_code == 200
    node = (await db_session.execute(select(NodeModel))).scalars().first()
    assert node is not None
    assert node.check_method == "ping"


@pytest.mark.asyncio
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
    ids = [d.id for d in two_pending_devices]
    # Approve first device individually first
    await client.post(
        f"/api/v1/scan/pending/{ids[0]}/approve",
        json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
        headers=headers,
    )
    # Bulk approve both — first one is already approved (not pending), should be skipped
    res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert data["approved"] == 1
    assert data["skipped"] == 1


@pytest.mark.asyncio
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
    ids = [d.id for d in two_pending_devices]
    res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
    assert res.status_code == 401


# --- Bulk hide ---

@pytest.mark.asyncio
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
    ids = [d.id for d in two_pending_devices]
    res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert data["hidden"] == 2
    assert data["skipped"] == 0
    # Should appear in hidden list
    hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
    assert len(hidden_res.json()) == 2


@pytest.mark.asyncio
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
    ids = [d.id for d in two_pending_devices]
    # Hide first device individually first
    await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
    # Bulk hide both — first is already hidden (not pending anymore)
    res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert data["hidden"] == 1
    assert data["skipped"] == 1


@pytest.mark.asyncio
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
    ids = [d.id for d in two_pending_devices]
    res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
    assert res.status_code == 401


# ---------------------------------------------------------------------------
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
# ---------------------------------------------------------------------------


async def _seed_zigbee_pending_pair(db_session):
    """Create a coordinator Node + a pending device + a link between them."""
    from app.db.models import Node, PendingDevice, PendingDeviceLink

    coord = Node(
        label="Coordinator",
        type="zigbee_coordinator",
        status="unknown",
        ieee_address="0xCOORD",
    )
    db_session.add(coord)

    pending = PendingDevice(
        ieee_address="0xR1",
        friendly_name="router_1",
        suggested_type="zigbee_router",
        device_subtype="Router",
        status="pending",
        discovery_source="zigbee",
    )
    db_session.add(pending)

    db_session.add(
        PendingDeviceLink(
            source_ieee="0xCOORD",
            target_ieee="0xR1",
            discovery_source="zigbee",
        )
    )
    await db_session.commit()
    return coord, pending


@pytest.mark.asyncio
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
    client: AsyncClient, headers, db_session
):
    from sqlalchemy import select

    from app.db.models import Edge

    coord, pending = await _seed_zigbee_pending_pair(db_session)

    res = await client.post(
        f"/api/v1/scan/pending/{pending.id}/approve",
        json={
            "label": "router_1",
            "type": "zigbee_router",
            "ip": None,
            "status": "unknown",
            "services": [],
        },
        headers=headers,
    )
    assert res.status_code == 200
    data = res.json()
    assert data["approved"] is True
    assert data["edges_created"] == 1

    edges = (await db_session.execute(select(Edge))).scalars().all()
    assert len(edges) == 1
    assert edges[0].source == coord.id
    assert edges[0].target == data["node_id"]
    assert edges[0].source_handle == "bottom"
    # Bare side name (canonical stored form); renders at the top like before.
    assert edges[0].target_handle == "top"
    assert edges[0].type == "iot"


@pytest.mark.asyncio
async def test_approve_zigbee_skips_duplicate_edge(
    client: AsyncClient, headers, db_session
):
    """Re-running the resolution does not create a second edge for the same pair."""
    from sqlalchemy import select

    from app.db.models import Edge, PendingDevice, PendingDeviceLink

    coord, pending = await _seed_zigbee_pending_pair(db_session)
    body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
    await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)

    # Simulate a second pending row + link between same coord and a new device,
    # but keep an existing edge in place to verify dedupe also handles
    # the swapped-direction case.
    new_pending = PendingDevice(
        ieee_address="0xR1B",
        friendly_name="r1b",
        suggested_type="zigbee_router",
        status="pending",
        discovery_source="zigbee",
    )
    db_session.add(new_pending)
    db_session.add(
        PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
    )
    await db_session.commit()
    res = await client.post(
        f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
    )
    assert res.json()["edges_created"] == 1  # only the new pair
    edges = (await db_session.execute(select(Edge))).scalars().all()
    assert len(edges) == 2  # original + new, no duplicate


@pytest.mark.asyncio
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
    client: AsyncClient, headers, db_session
):
    """Both endpoints pending → no edge yet, link row preserved for later."""
    from sqlalchemy import select

    from app.db.models import Edge, PendingDevice, PendingDeviceLink

    a = PendingDevice(
        ieee_address="0xA",
        friendly_name="a",
        suggested_type="zigbee_router",
        status="pending",
        discovery_source="zigbee",
    )
    b = PendingDevice(
        ieee_address="0xB",
        friendly_name="b",
        suggested_type="zigbee_enddevice",
        status="pending",
        discovery_source="zigbee",
    )
    db_session.add_all([a, b])
    db_session.add(
        PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
    )
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{a.id}/approve",
        json={
            "label": "a",
            "type": "zigbee_router",
            "ip": None,
            "status": "unknown",
            "services": [],
        },
        headers=headers,
    )
    assert res.status_code == 200
    assert res.json()["edges_created"] == 0

    edges = (await db_session.execute(select(Edge))).scalars().all()
    assert edges == []
    links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
    assert len(links) == 1  # preserved for later resolution


@pytest.mark.asyncio
async def test_approve_zigbee_resolves_link_after_second_approval(
    client: AsyncClient, headers, db_session
):
    """First approval keeps link (other endpoint pending); second approval
    creates the edge. The link row is retained afterwards so the same pair can
    be re-approved onto another canvas — it's topology, wiped only on reimport."""
    from sqlalchemy import select

    from app.db.models import Edge, PendingDevice, PendingDeviceLink

    a = PendingDevice(
        ieee_address="0xA",
        friendly_name="a",
        suggested_type="zigbee_router",
        status="pending",
        discovery_source="zigbee",
    )
    b = PendingDevice(
        ieee_address="0xB",
        friendly_name="b",
        suggested_type="zigbee_enddevice",
        status="pending",
        discovery_source="zigbee",
    )
    db_session.add_all([a, b])
    db_session.add(
        PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
    )
    await db_session.commit()

    body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
    await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
    res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
    assert res.json()["edges_created"] == 1

    edges = (await db_session.execute(select(Edge))).scalars().all()
    assert len(edges) == 1
    links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
    assert len(links) == 1  # retained for re-approval onto other canvases


# --- Deep scan: trigger overrides + config persistence ---

@pytest.mark.asyncio
async def test_resolve_deep_scan_falls_back_to_settings():
    from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan

    with patch("app.api.routes.scan.settings") as mock_settings:
        mock_settings.scanner_http_ranges = ["7000-7100"]
        mock_settings.scanner_http_probe_enabled = True
        mock_settings.scanner_http_verify_tls = False
        # Empty payload → all values come from settings defaults
        ds = _resolve_deep_scan(TriggerScanRequest())
    assert ds.http_ranges == ["7000-7100"]
    assert ds.http_probe_enabled is True
    assert ds.verify_tls is False


@pytest.mark.asyncio
async def test_resolve_deep_scan_override_wins():
    from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan

    with patch("app.api.routes.scan.settings") as mock_settings:
        mock_settings.scanner_http_ranges = []
        mock_settings.scanner_http_probe_enabled = False
        mock_settings.scanner_http_verify_tls = False
        ds = _resolve_deep_scan(
            TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
        )
    assert ds.http_ranges == ["9000"]
    assert ds.http_probe_enabled is True
    assert ds.verify_tls is True


@pytest.mark.asyncio
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
    captured = {}

    async def fake_bg(run_id, ranges, deep_scan):
        captured["deep_scan"] = deep_scan

    with (
        patch("app.api.routes.scan._background_scan", new=fake_bg),
        patch("app.api.routes.scan.settings") as mock_settings,
    ):
        mock_settings.scanner_ranges = ["192.168.1.0/24"]
        mock_settings.scanner_http_ranges = []
        mock_settings.scanner_http_probe_enabled = False
        mock_settings.scanner_http_verify_tls = False
        res = await client.post(
            "/api/v1/scan/trigger",
            json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
            headers=headers,
        )
    assert res.status_code == 200
    assert captured["deep_scan"].http_probe_enabled is True
    assert captured["deep_scan"].http_ranges == ["8000-8100"]


@pytest.mark.asyncio
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
    with patch("app.api.routes.scan.settings") as mock_settings:
        mock_settings.scanner_ranges = ["192.168.1.0/24"]
        res = await client.post(
            "/api/v1/scan/trigger",
            json={"http_ranges": ["70000-80000"]},
            headers=headers,
        )
    assert res.status_code == 422


@pytest.mark.asyncio
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
    with patch("app.api.routes.scan.settings") as mock_settings:
        mock_settings.scanner_ranges = ["192.168.1.0/24"]
        mock_settings.scanner_http_ranges = ["8000-8100"]
        mock_settings.scanner_http_probe_enabled = True
        mock_settings.scanner_http_verify_tls = False
        res = await client.get("/api/v1/scan/config", headers=headers)
    assert res.status_code == 200
    data = res.json()
    assert data["http_ranges"] == ["8000-8100"]
    assert data["http_probe_enabled"] is True


@pytest.mark.asyncio
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
    saved = {}

    with patch("app.api.routes.scan.settings") as mock_settings:
        mock_settings.scanner_ranges = ["192.168.1.0/24"]
        mock_settings.scanner_http_ranges = []
        mock_settings.scanner_http_probe_enabled = False
        mock_settings.scanner_http_verify_tls = False
        mock_settings.save_overrides = lambda: saved.update(
            http_ranges=mock_settings.scanner_http_ranges,
            probe=mock_settings.scanner_http_probe_enabled,
        )
        res = await client.post(
            "/api/v1/scan/config",
            json={
                "ranges": ["192.168.1.0/24"],
                "http_ranges": ["9000-9100"],
                "http_probe_enabled": True,
                "verify_tls": True,
            },
            headers=headers,
        )
    assert res.status_code == 200
    assert saved == {"http_ranges": ["9000-9100"], "probe": True}


# --- Z-Wave approve: active design targeting + wireless props (regression) ---

@pytest.mark.asyncio
async def test_bulk_approve_targets_requested_design(client, headers, db_session):
    """bulk-approve must place nodes on the design_id sent by the UI, not the
    first design — otherwise approved devices land on the wrong canvas."""
    first = await _add_design(db_session, "Default")  # first design (fallback)
    active = await _add_design(db_session, "zwave")    # the design the user is on
    dev = PendingDevice(
        id=str(uuid.uuid4()),
        ieee_address="zwave-H-2",
        friendly_name="Living Room Plug",
        suggested_type="zwave_router",
        device_subtype="Router",
        vendor="Aeotec",
        model="ZW096",
        status="pending",
        discovery_source="zwave",
    )
    db_session.add(dev)
    await db_session.commit()

    res = await client.post(
        "/api/v1/scan/pending/bulk-approve",
        json={"device_ids": [dev.id], "design_id": active},
        headers=headers,
    )
    assert res.status_code == 200
    assert res.json()["approved"] == 1

    node = (
        await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
    ).scalar_one()
    assert node.design_id == active
    assert node.design_id != first
    # Z-Wave device → online + Z-Wave property rows, no ICMP check.
    assert node.status == "online"
    assert node.check_method == "none"
    assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}


@pytest.mark.asyncio
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
    active = await _add_design(db_session, "zwave")
    dev = PendingDevice(
        id=str(uuid.uuid4()),
        ieee_address="zwave-H-9",
        friendly_name="Door Sensor",
        suggested_type="zwave_enddevice",
        vendor="Aeotec",
        model="ZW120",
        status="pending",
        discovery_source="zwave",
    )
    db_session.add(dev)
    await db_session.commit()

    res = await client.post(
        f"/api/v1/scan/pending/{dev.id}/approve",
        json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active},
        headers=headers,
    )
    assert res.status_code == 200

    node = (
        await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
    ).scalar_one()
    assert node.design_id == active
    assert node.status == "online"
    assert node.check_method == "none"
    assert any(p["key"] == "Z-Wave ID" for p in node.properties)
