"""FastAPI router for Proxmox VE import + auto-sync config.

Fetches hosts/VMs/LXC from the Proxmox REST API and upserts them into the
pending inventory (same review→approve flow as scans and mesh imports).

Credentials: the API token comes from the request body when provided, else
falls back to the server-configured env token (``settings.proxmox_token_*``).
The token is never persisted by the app and never returned by any endpoint.
"""

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

from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import delete as sa_delete
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.proxmox import (
    ProxmoxConfig,
    ProxmoxConnectionRequest,
    ProxmoxEdgeOut,
    ProxmoxImportPendingResponse,
    ProxmoxImportResponse,
    ProxmoxNodeOut,
    ProxmoxSyncConfig,
    ProxmoxTestConnectionResponse,
)
from app.schemas.scan import ScanRunResponse
from app.services.discovery_sources import add_source
from app.services.mac_utils import normalize_mac
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.proxmox_service import (
    build_proxmox_cluster_links,
    build_proxmox_properties,
    fetch_proxmox_inventory,
    merge_proxmox_properties,
    test_proxmox_connection,
)

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

# Discovery sources for the two proxmox link shapes. Host→guest links render as
# 'virtual' edges; host↔host cluster links render as 'cluster' edges.
_PROXMOX_GUEST_SOURCE = "proxmox"
_PROXMOX_CLUSTER_SOURCE = "proxmox_cluster"


def _resolve_credentials(payload: ProxmoxConnectionRequest) -> tuple[str, str]:
    """Pick the API token: request body first, else server env config.

    Raises HTTP 400 when neither carries a token.
    """
    token_id = payload.token_id or settings.proxmox_token_id
    token_secret = payload.token_secret or settings.proxmox_token_secret
    if not token_id or not token_secret:
        raise HTTPException(
            status_code=400,
            detail="No Proxmox API token provided and none configured on the server.",
        )
    return token_id, token_secret


@router.post("/test-connection", response_model=ProxmoxTestConnectionResponse)
async def test_connection_endpoint(
    payload: ProxmoxConnectionRequest,
    _: str = Depends(get_current_user),
) -> ProxmoxTestConnectionResponse:
    """Validate host reachability + token before importing."""
    token_id, token_secret = _resolve_credentials(payload)
    connected, message = await test_proxmox_connection(
        host=payload.host,
        port=payload.port,
        token_id=token_id,
        token_secret=token_secret,
        verify_tls=payload.verify_tls,
    )
    return ProxmoxTestConnectionResponse(connected=connected, message=message)


@router.post("/import", response_model=ProxmoxImportResponse)
async def import_proxmox(
    payload: ProxmoxConnectionRequest,
    _: str = Depends(get_current_user),
) -> ProxmoxImportResponse:
    """Fetch the inventory and return nodes + edges ready for canvas drop."""
    token_id, token_secret = _resolve_credentials(payload)
    try:
        nodes_raw, edges_raw = await fetch_proxmox_inventory(
            host=payload.host,
            port=payload.port,
            token_id=token_id,
            token_secret=token_secret,
            verify_tls=payload.verify_tls,
        )
    except ConnectionError as exc:
        raise HTTPException(status_code=502, detail=str(exc)) from exc
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc
    except Exception as exc:
        logger.exception("Unexpected error during Proxmox import")
        raise HTTPException(status_code=500, detail="Unexpected error during Proxmox import") from exc

    nodes = [ProxmoxNodeOut(**n) for n in nodes_raw]
    edges = [ProxmoxEdgeOut(**e) for e in edges_raw]
    return ProxmoxImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))


@router.post("/import-pending", response_model=ScanRunResponse)
async def import_proxmox_to_pending(
    payload: ProxmoxConnectionRequest,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> ScanRun:
    """Queue a Proxmox pending import as a background scan run (kind=proxmox)."""
    token_id, token_secret = _resolve_credentials(payload)
    run = ScanRun(
        status="running",
        kind="proxmox",
        ranges=[f"{payload.host}:{payload.port}"],
    )
    db.add(run)
    await db.commit()
    await db.refresh(run)
    background_tasks.add_task(
        _background_proxmox_import,
        run.id,
        payload.host,
        payload.port,
        token_id,
        token_secret,
        payload.verify_tls,
    )
    return run


@router.post("/sync-now", response_model=ScanRunResponse)
async def sync_proxmox_now(
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db),
    _: str = Depends(get_current_user),
) -> ScanRun:
    """Trigger an immediate Proxmox inventory sync using the server env config.

    Same background flow as ``/import-pending`` but sources host + token from
    ``settings`` (env) rather than the request body — the manual counterpart to
    the scheduled auto-sync job. Requires the env token to be configured.
    """
    if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
        raise HTTPException(
            status_code=400,
            detail="Cannot sync: no Proxmox host/token configured on the server.",
        )
    run = ScanRun(
        status="running",
        kind="proxmox",
        ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
    )
    db.add(run)
    await db.commit()
    await db.refresh(run)
    background_tasks.add_task(
        _background_proxmox_import,
        run.id,
        settings.proxmox_host,
        settings.proxmox_port,
        settings.proxmox_token_id,
        settings.proxmox_token_secret,
        settings.proxmox_verify_tls,
    )
    return run


async def _background_proxmox_import(
    run_id: str,
    host: str,
    port: int,
    token_id: str,
    token_secret: str,
    verify_tls: bool,
) -> None:
    async with AsyncSessionLocal() as db:
        try:
            nodes_raw, edges_raw = await fetch_proxmox_inventory(
                host=host,
                port=port,
                token_id=token_id,
                token_secret=token_secret,
                verify_tls=verify_tls,
            )
            result = await _persist_pending_import(db, nodes_raw, edges_raw)
            run = await db.get(ScanRun, run_id)
            if run:
                run.status = "done"
                run.devices_found = result.device_count
                run.finished_at = datetime.now(timezone.utc)
                run.error = _guest_visibility_advisory(nodes_raw)
                await db.commit()
            # Nudge the frontend to reload the inventory (same signal the IP scan
            # emits) so imported/merged devices appear without a manual refresh.
            from app.api.routes.status import broadcast_scan_update
            await broadcast_scan_update(run_id=run_id, devices_found=result.device_count)
        except Exception as exc:
            logger.exception("Proxmox import %s failed", run_id)
            await db.rollback()
            run = await db.get(ScanRun, run_id)
            if run:
                run.status = "error"
                run.error = str(exc)[:500]
                run.finished_at = datetime.now(timezone.utc)
                await db.commit()


def _guest_visibility_advisory(nodes_raw: list[dict[str, Any]]) -> str | None:
    """Non-fatal advisory when hosts import but no VMs/LXC were visible.

    A Proxmox API token that lacks ``VM.Audit`` sees empty ``qemu``/``lxc`` lists
    (HTTP 200, no error), so only host nodes come through. The usual cause is a
    privilege-separated token whose effective rights are the *intersection* with
    the user's rights — granting PVEAuditor to the token alone is not enough when
    the user has none. Surface that instead of a silent success.
    """
    hosts = sum(1 for n in nodes_raw if n.get("type") == "proxmox")
    guests = len(nodes_raw) - hosts
    if hosts and not guests:
        return (
            f"Imported {hosts} host(s) but no VMs or LXC were visible to the API "
            "token. Grant the PVEAuditor role at path '/' to BOTH the token and "
            "the user (privilege-separated tokens get the intersection of token "
            "and user rights), then re-import."
        )
    return None


async def _persist_pending_import(
    db: AsyncSession,
    nodes_raw: list[dict[str, Any]],
    edges_raw: list[dict[str, Any]],
) -> ProxmoxImportPendingResponse:
    """Upsert Proxmox nodes/edges into pending_devices + pending_device_links.

    Two-tier identity (order matters):
      1. Match an existing canvas Node or pending row by **IP** (merge into a
         device previously found by a scan) — never duplicate.
      2. Else match by synthetic ``ieee_address`` (``pve-...``).

    Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
    """
    await dedupe_nodes_by_ieee(db)

    cluster_pairs = build_proxmox_cluster_links(nodes_raw)
    cluster_members = {ieee for pair in cluster_pairs for ieee in pair}

    pending_created = 0
    pending_updated = 0

    for n in nodes_raw:
        ieee = n.get("ieee_address")
        if not ieee:
            continue
        ip = n.get("ip")
        mac = normalize_mac(n.get("mac"))
        props = build_proxmox_properties(n)

        # 1) Already on a canvas? Match by ieee OR ip OR mac (the cross-source
        # dedup key — a stopped VM has no IP but its configured NIC MAC still
        # matches an ARP-scanned node). Refresh in place: merge properties, adopt
        # the pve identity onto a scanned node, backfill blank specs/hostname/mac.
        # Do NOT stomp user-set type/status.
        node_filter = [Node.ieee_address == ieee]
        if ip:
            node_filter.append(Node.ip == ip)
        if mac:
            node_filter.append(Node.mac == mac)
        existing_nodes = (
            await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
        ).scalars().all()

        if existing_nodes:
            for en in existing_nodes:
                en.properties = merge_proxmox_properties(en.properties, props)
                if not en.ieee_address:
                    en.ieee_address = ieee
                if ip and not en.ip:
                    en.ip = ip
                if mac and not en.mac:
                    en.mac = mac
                en.hostname = en.hostname or n.get("hostname")
                en.cpu_count = en.cpu_count or n.get("cpu_count")
                en.ram_gb = en.ram_gb or n.get("ram_gb")
                en.disk_gb = en.disk_gb or n.get("disk_gb")
                # A cluster host needs one left + one right handle for the
                # cluster edge endpoints (both default to 0).
                if ieee in cluster_members:
                    en.left_handles = max(en.left_handles or 0, 1)
                    en.right_handles = max(en.right_handles or 0, 1)
            await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
            pending_updated += 1
            continue

        # 2) Not on canvas — upsert the pending inventory row.
        pending = await _find_pending(db, ieee, ip, mac)
        if pending is None:
            db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
            pending_created += 1
        else:
            _refresh_pending(pending, ieee, ip, mac, n, props)
            pending_updated += 1

    links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
    await db.commit()

    return ProxmoxImportPendingResponse(
        pending_created=pending_created,
        pending_updated=pending_updated,
        links_recorded=links_recorded,
        device_count=len(nodes_raw),
    )


async def _find_pending(
    db: AsyncSession, ieee: str, ip: str | None, mac: str | None
) -> PendingDevice | None:
    filters = [PendingDevice.ieee_address == ieee]
    if ip:
        filters.append(PendingDevice.ip == ip)
    if mac:
        filters.append(PendingDevice.mac == mac)
    return (
        await db.execute(select(PendingDevice).where(or_(*filters)))
    ).scalars().first()


def _new_pending(
    ieee: str,
    ip: str | None,
    mac: str | None,
    n: dict[str, Any],
    props: list[dict[str, Any]],
    status: str,
) -> PendingDevice:
    return PendingDevice(
        ieee_address=ieee,
        ip=ip,
        mac=mac,
        hostname=n.get("hostname"),
        friendly_name=n.get("label"),
        suggested_type=n.get("type"),
        vendor=n.get("vendor"),
        model=n.get("model"),
        properties=props,
        status=status,
        discovery_source=_PROXMOX_GUEST_SOURCE,
        discovery_sources=[_PROXMOX_GUEST_SOURCE],
    )


def _sources_after_merge(row: PendingDevice) -> list[str]:
    """Discovery sources for an inventory row after a Proxmox import merges in.

    Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
    whether the row was originally a scanned device. Preserves the prior scan
    origin — including legacy rows created before ``discovery_sources`` existed
    (empty list) and possibly with a NULL ``discovery_source`` — so the IP tag
    survives the merge. A row that carries an IP but was not itself a Proxmox
    device (no ``pve-`` ieee) was found by a scan; keep an IP-scan source.
    """
    sources = add_source(row.discovery_sources, row.discovery_source)
    was_scanned = not (row.ieee_address or "").startswith("pve-")
    if was_scanned and row.ip and not any(s in ("arp", "mdns") for s in sources):
        sources = add_source(sources, "arp")
    return add_source(sources, _PROXMOX_GUEST_SOURCE)


def _refresh_pending(
    pending: PendingDevice,
    ieee: str,
    ip: str | None,
    mac: str | None,
    n: dict[str, Any],
    props: list[dict[str, Any]],
) -> None:
    # Compute sources before adopting the pve ieee (needs the pre-merge origin).
    pending.discovery_sources = _sources_after_merge(pending)
    pending.ieee_address = pending.ieee_address or ieee
    pending.ip = ip or pending.ip
    pending.mac = pending.mac or mac
    pending.hostname = n.get("hostname") or pending.hostname
    pending.friendly_name = n.get("label") or pending.friendly_name
    pending.suggested_type = n.get("type") or pending.suggested_type
    pending.vendor = n.get("vendor") or pending.vendor
    pending.model = n.get("model") or pending.model
    pending.properties = merge_proxmox_properties(list(pending.properties or []), props)
    if pending.status == "approved":
        # Approved earlier but the canvas Node is gone — revive so it reappears.
        pending.status = "pending"
    # hidden stays hidden.


async def _ensure_inventory_row(
    db: AsyncSession,
    ieee: str,
    ip: str | None,
    mac: str | None,
    n: dict[str, Any],
    props: list[dict[str, Any]],
    approved: bool,
) -> None:
    """Ensure an inventory row exists for a device already on a canvas, so it
    shows in the inventory with an 'In N canvas' badge. Never changes status."""
    inv = await _find_pending(db, ieee, ip, mac)
    if inv is None:
        db.add(_new_pending(ieee, ip, mac, n, props, status="approved" if approved else "pending"))
    else:
        # Compute sources before adopting the pve ieee (needs the pre-merge origin).
        inv.discovery_sources = _sources_after_merge(inv)
        inv.ieee_address = inv.ieee_address or ieee
        inv.ip = ip or inv.ip
        inv.mac = inv.mac or mac
        inv.hostname = n.get("hostname") or inv.hostname
        inv.suggested_type = n.get("type") or inv.suggested_type
        inv.properties = merge_proxmox_properties(list(inv.properties or []), props)


async def _replace_links(
    db: AsyncSession,
    edges_raw: list[dict[str, Any]],
    cluster_pairs: list[tuple[str, str]],
) -> int:
    """Wipe all proxmox-source links and re-insert the freshly discovered set.

    Two link shapes: host→guest (``proxmox`` → 'virtual' edges) and host↔host
    (``proxmox_cluster`` → 'cluster' edges).
    """
    await db.execute(
        sa_delete(PendingDeviceLink).where(
            PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
        )
    )
    recorded = 0
    seen: set[tuple[str, str]] = set()

    def _add(src: str | None, tgt: str | None, source: str) -> None:
        nonlocal recorded
        if not src or not tgt or (src, tgt) in seen:
            return
        seen.add((src, tgt))
        db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
        recorded += 1

    for e in edges_raw:
        _add(e.get("source"), e.get("target"), _PROXMOX_GUEST_SOURCE)
    for src, tgt in cluster_pairs:
        _add(src, tgt, _PROXMOX_CLUSTER_SOURCE)

    return recorded


@router.get("/config", response_model=ProxmoxConfig)
async def get_proxmox_config(_: str = Depends(get_current_user)) -> ProxmoxConfig:
    """Return non-secret Proxmox config. Never includes the token — only whether
    one is configured on the server."""
    return ProxmoxConfig(
        host=settings.proxmox_host,
        port=settings.proxmox_port,
        verify_tls=settings.proxmox_verify_tls,
        sync_enabled=settings.proxmox_sync_enabled,
        sync_interval=settings.proxmox_sync_interval,
        token_configured=bool(settings.proxmox_token_id and settings.proxmox_token_secret),
    )


@router.post("/config", response_model=ProxmoxConfig)
async def save_proxmox_config(
    payload: ProxmoxSyncConfig,
    _: str = Depends(get_current_user),
) -> ProxmoxConfig:
    """Persist the auto-sync activation (enabled + interval) and apply it live.

    This is the ONLY Proxmox config the app writes. Connection settings
    (host, port, token, verify_tls) are env-only and are never accepted or
    persisted here — enabling auto-sync requires host + token already set in the
    server env, since the scheduled job reads them from there.
    """
    if payload.sync_enabled and not (
        settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret
    ):
        raise HTTPException(
            status_code=400,
            detail="Cannot enable auto-sync: no Proxmox host/token configured in the server env.",
        )
    try:
        settings.proxmox_sync_enabled = payload.sync_enabled
        settings.proxmox_sync_interval = payload.sync_interval
        settings.save_overrides()
        set_proxmox_sync_enabled(payload.sync_enabled)
        if payload.sync_enabled:
            reschedule_proxmox_sync(payload.sync_interval)
    except HTTPException:
        raise
    except Exception as exc:
        raise HTTPException(status_code=500, detail=str(exc)) from exc

    return await get_proxmox_config()
