"""Phase 5B: Synthetic health probes for marketplace nodes (issue #308).

Scheduler/background task runs every N minutes (default 5): fetches nodes from
catalog URL, GETs each node's tunnel_url/health, records latency and success
in SQLite (probe_results). Used for 30-day uptime and reputation tiers.
"""

from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any

import aiosqlite
import httpx

from llmhosts.marketplace.router import fetch_nodes

logger = logging.getLogger(__name__)

_PROBE_TIMEOUT = 10.0
_DEFAULT_INTERVAL_SECONDS = 5 * 60  # 5 minutes

# Minimum 30-day uptime percentage before a node is auto-removed from marketplace.
REPUTATION_REMOVAL_THRESHOLD = 30.0


@dataclass
class ProbeResult:
    """Single probe outcome for a device."""

    device_id: str
    timestamp: datetime
    latency_ms: float
    success: bool


# ---------------------------------------------------------------------------
# ProbeStore (SQLite)
# ---------------------------------------------------------------------------

_CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS probe_results (
    device_id     TEXT NOT NULL,
    timestamp     TEXT NOT NULL,
    latency_ms    REAL NOT NULL,
    success       INTEGER NOT NULL
);
"""

_CREATE_INDEXES = [
    "CREATE INDEX IF NOT EXISTS idx_probe_device ON probe_results (device_id);",
    "CREATE INDEX IF NOT EXISTS idx_probe_timestamp ON probe_results (timestamp);",
    "CREATE INDEX IF NOT EXISTS idx_probe_device_ts ON probe_results (device_id, timestamp);",
]

_INSERT = """
INSERT INTO probe_results (device_id, timestamp, latency_ms, success)
VALUES (?, ?, ?, ?)
"""


def _iso(dt: datetime) -> str:
    return dt.isoformat()


class ProbeStore:
    """SQLite store for synthetic probe results (device_id, timestamp, latency_ms, success)."""

    def __init__(self, db_path: Path | str) -> None:
        self._db_path = Path(db_path) if isinstance(db_path, str) else db_path
        self._db: aiosqlite.Connection | None = None

    async def initialize(self) -> None:
        """Create probe_results table and indexes. Enable WAL."""
        self._db_path.parent.mkdir(parents=True, exist_ok=True)
        self._db = await aiosqlite.connect(str(self._db_path))
        await self._db.execute("PRAGMA journal_mode=WAL;")
        await self._db.execute("PRAGMA synchronous=NORMAL;")
        await self._db.execute(_CREATE_TABLE)
        for idx_sql in _CREATE_INDEXES:
            await self._db.execute(idx_sql)
        await self._db.commit()

    async def append(self, result: ProbeResult) -> None:
        """Append one probe result."""
        if self._db is None:
            return
        await self._db.execute(
            _INSERT,
            (result.device_id, _iso(result.timestamp), result.latency_ms, 1 if result.success else 0),
        )
        await self._db.commit()

    async def close(self) -> None:
        """Close the database connection."""
        if self._db is not None:
            await self._db.close()
            self._db = None

    async def get_uptime_pct(self, device_id: str, days: int = 30) -> float | None:
        """Return success rate (0-100) for device over last *days* days. None if no data."""
        if self._db is None:
            return None
        since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
        sql = """
        SELECT SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*)
        FROM probe_results
        WHERE device_id = ? AND timestamp >= ?
        """
        async with self._db.execute(sql, (device_id, since)) as cursor:
            row = await cursor.fetchone()
        if row is None or row[0] is None:
            return None
        return round(float(row[0]), 2)

    async def uptime_30d(self, device_id: str) -> float:
        """Return 30-day uptime percentage (0-100) for *device_id*. Returns 100.0 if no data."""
        pct = await self.get_uptime_pct(device_id, days=30)
        return pct if pct is not None else 100.0

    async def get_uptime_map(
        self,
        device_ids: list[str] | None = None,
        days: int = 30,
    ) -> dict[str, float]:
        """Return { device_id: uptime_pct } for last *days* days."""
        out: dict[str, float] = {}
        if self._db is None:
            return out
        since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
        if device_ids:
            for did in device_ids:
                pct = await self.get_uptime_pct(did, days=days)
                if pct is not None:
                    out[did] = pct
            return out
        sql = """
        SELECT device_id,
               SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS uptime_pct
        FROM probe_results
        WHERE timestamp >= ?
        GROUP BY device_id
        """
        async with self._db.execute(sql, (since,)) as cursor:
            async for row in cursor:
                out[row[0]] = round(row[1], 2)
        return out


# ---------------------------------------------------------------------------
# run_probe
# ---------------------------------------------------------------------------


async def run_probe(node: dict[str, Any]) -> ProbeResult:
    """Probe one node: GET tunnel_url/health, record latency and success.

    Node dict must have 'id' (device_id) and 'tunnel_url'. Returns ProbeResult.
    """
    device_id = str(node.get("id", ""))
    tunnel_url = (node.get("tunnel_url") or "").rstrip("/")
    if not tunnel_url:
        return ProbeResult(
            device_id=device_id or "unknown",
            timestamp=datetime.now(timezone.utc),
            latency_ms=0.0,
            success=False,
        )
    url = f"{tunnel_url}/health"
    start = time.monotonic()
    try:
        async with httpx.AsyncClient(timeout=_PROBE_TIMEOUT) as client:
            resp = await client.get(url)
            latency_ms = (time.monotonic() - start) * 1000.0
            success = resp.status_code == 200
            return ProbeResult(
                device_id=device_id,
                timestamp=datetime.now(timezone.utc),
                latency_ms=round(latency_ms, 2),
                success=success,
            )
    except Exception as exc:
        latency_ms = (time.monotonic() - start) * 1000.0
        logger.debug("Probe failed for %s: %s", url, exc)
        return ProbeResult(
            device_id=device_id,
            timestamp=datetime.now(timezone.utc),
            latency_ms=round(latency_ms, 2),
            success=False,
        )


# ---------------------------------------------------------------------------
# Auto-removal of low-reputation nodes
# ---------------------------------------------------------------------------


async def _check_and_remove_low_reputation(
    results: list[ProbeResult],
    store: ProbeStore,
    *,
    control_plane_url: str,
    internal_secret: str,
) -> None:
    """After a probe cycle, disable marketplace nodes whose 30-day uptime is below threshold.

    Calls POST {control_plane_url}/api/internal/marketplace/auto-remove with the
    device IDs that are consistently failing probes.
    """
    if not results or not (control_plane_url or "").strip() or not (internal_secret or "").strip():
        return

    low_reputation_ids: list[str] = []
    for result in results:
        uptime = await store.uptime_30d(result.device_id)
        if uptime < REPUTATION_REMOVAL_THRESHOLD:
            low_reputation_ids.append(result.device_id)
            logger.info(
                "Auto-removing marketplace node %s: uptime=%.1f%% < %.1f%% threshold",
                result.device_id,
                uptime,
                REPUTATION_REMOVAL_THRESHOLD,
            )

    if not low_reputation_ids:
        return

    url = f"{control_plane_url.rstrip('/')}/api/internal/marketplace/auto-remove"
    async with httpx.AsyncClient() as client:
        try:
            resp = await client.post(
                url,
                json={"device_ids": low_reputation_ids},
                headers={"x-internal-secret": internal_secret},
                timeout=10.0,
            )
            logger.info(
                "Auto-remove API response: status=%d disabled=%s",
                resp.status_code,
                resp.json().get("disabled", "?") if resp.status_code == 200 else "err",
            )
        except Exception:
            logger.warning("Auto-remove API call failed", exc_info=True)


# ---------------------------------------------------------------------------
# Probe loop (fetch nodes -> probe each -> store)
# ---------------------------------------------------------------------------


async def run_probe_loop(
    catalog_url: str,
    store: ProbeStore,
    *,
    interval_seconds: int = _DEFAULT_INTERVAL_SECONDS,
    stop_event: asyncio.Event | None = None,
    control_plane_url: str = "",
    internal_secret: str = "",
) -> None:
    """Background loop: every *interval_seconds* fetch nodes from *catalog_url*, probe each, store results.

    Uses :func:`llmhosts.marketplace.router.fetch_nodes` and :func:`run_probe`.
    If *stop_event* is set, loop exits when the event is set (e.g. on app shutdown).
    If *control_plane_url* and *internal_secret* are provided, calls the auto-remove
    API after each cycle to disable nodes below REPUTATION_REMOVAL_THRESHOLD.
    """
    if not (catalog_url or "").strip():
        logger.warning("run_probe_loop: empty catalog_url, not starting")
        return
    if stop_event is None:
        stop_event = asyncio.Event()
    while True:
        try:
            nodes = await fetch_nodes(catalog_url)
            cycle_results: list[ProbeResult] = []
            for node in nodes:
                result = await run_probe(node)
                await store.append(result)
                cycle_results.append(result)
            logger.debug("Probe loop: probed %d nodes", len(nodes))
            if control_plane_url and internal_secret:
                await _check_and_remove_low_reputation(
                    cycle_results,
                    store,
                    control_plane_url=control_plane_url,
                    internal_secret=internal_secret,
                )
        except Exception:
            logger.warning("Probe loop iteration failed", exc_info=True)
        try:
            await asyncio.wait_for(stop_event.wait(), timeout=float(interval_seconds))
            break
        except asyncio.TimeoutError:
            pass
