"""Widget source adapters.

Adapters translate a widget instance into dashboard data. Service-bound widgets
are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
built-in widgets (backups, static) take ``service=None``.

Adapters never accept arbitrary commands and never store credentials — secrets
are decrypted in memory only for the duration of a fetch.
"""

from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Protocol

import requests

from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import (
    _map_sessions_to_activity_rows,
    build_backup_dashboard_summary,
)
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.prometheus_range import (
    WINDOW_PRESETS,
    normalize_prometheus_matrix,
    step_for_window,
)

logger = logging.getLogger(__name__)


@dataclass
class ServiceRecord:
    """Runtime view of a service instance with decrypted secrets."""

    id: str
    service_type: str
    name: str
    config: dict[str, Any] = field(default_factory=dict)
    secrets: dict[str, str] = field(default_factory=dict)
    enabled: bool = True


def build_service_record(store: SettingsStore, service_row: dict[str, Any]) -> ServiceRecord:
    """Build a :class:`ServiceRecord`, decrypting secrets in memory."""
    from media_library_viewer_api.services.secrets import decrypt_secrets

    return ServiceRecord(
        id=service_row["id"],
        service_type=service_row["service_type"],
        name=service_row["name"],
        config=service_row.get("config") or {},
        secrets=decrypt_secrets(service_row.get("secrets") or {}),
        enabled=bool(service_row.get("enabled", True)),
    )


class WidgetSource(Protocol):
    """Protocol for widget source adapters."""

    async def fetch(
        self,
        service: ServiceRecord | None,
        widget_kind: str,
        config: dict[str, Any],
    ) -> dict[str, Any]: ...


# ---------------------------------------------------------------------------
# Built-in (service-less) adapters
# ---------------------------------------------------------------------------


class BackupsWidgetSource:
    """Compute the backup dashboard summary from internal tables."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        try:
            store = get_settings_store()
            summary = build_backup_dashboard_summary(store)
            return summary.model_dump()
        except Exception as exc:
            logger.exception("backups adapter failed")
            return {"error": f"Backup summary failed: {exc}"}


class StaticWidgetSource:
    """Return static text/markdown unchanged."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        return {"text": config.get("text", "")}


# ---------------------------------------------------------------------------
# Service-bound adapters
# ---------------------------------------------------------------------------


class PrometheusWidgetSource:
    """Run PromQL queries against a Prometheus service (instant + range)."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        try:
            if service is None:
                return {"error": "Prometheus widget is missing its service"}
            base_url = str(service.config.get("base_url") or "").rstrip("/")
            timeout = int(service.config.get("timeout_seconds") or 10)
            if widget_kind == "chart":
                return await self._fetch_chart(base_url, timeout, config)
            if widget_kind == "gauge":
                return await self._fetch_gauge(base_url, timeout, config)
            if widget_kind == "mean":
                return await self._fetch_mean(base_url, timeout, config)
            # Default: instant-query metric path (unchanged).
            raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
            return raw
        except Exception as exc:
            logger.exception("prometheus adapter failed")
            return {"error": f"Prometheus query failed: {exc}"}

    async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
        """Run a Prometheus ``/api/v1/query_range`` over a window (seconds).

        Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
        ``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
        per SC-103).
        """
        step = step_for_window(window)
        end = int(time.time())
        start = end - window
        try:
            response = await asyncio.wait_for(
                asyncio.to_thread(
                    requests.get,
                    f"{base_url}/api/v1/query_range",
                    params={"query": promql, "start": start, "end": end, "step": step},
                    timeout=timeout,
                ),
                timeout=timeout,
            )
            response.raise_for_status()
            payload = response.json()
        except asyncio.TimeoutError:
            return {"error": "Prometheus query timed out"}
        except requests.RequestException as exc:
            logger.exception("prometheus range query failed")
            return {"error": f"Prometheus query failed: {exc}"}
        result = payload.get("data", {}).get("result", [])
        return {"matrix": result}

    async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
        """Run a Prometheus ``/api/v1/query`` instant query.

        Shared by the ``metric`` and ``gauge`` widget kinds. Returns
        ``{"result": data}`` on success or ``{"error": str}`` (never raises,
        per SC-103).
        """
        if not promql:
            return {"error": "promql is required"}
        try:
            response = await asyncio.wait_for(
                asyncio.to_thread(
                    requests.get,
                    f"{base_url}/api/v1/query",
                    params={"query": promql},
                    timeout=timeout,
                ),
                timeout=timeout,
            )
            response.raise_for_status()
            payload = response.json()
        except asyncio.TimeoutError:
            return {"error": "Prometheus query timed out"}
        except requests.RequestException as exc:
            logger.exception("prometheus instant query failed")
            return {"error": f"Prometheus query failed: {exc}"}
        return {"result": payload.get("data", {})}

    async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
        """Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
        promql = config.get("promql")
        if not promql:
            return {"error": "promql is required"}
        window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
        raw = await self._range_query(base_url, timeout, promql, window)
        if "error" in raw:
            return raw
        return {"series": normalize_prometheus_matrix(raw["matrix"])}

    async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
        """Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).

        Scalar-only: a multi-series query returns an error (SC-111). Threshold
        config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
        through for the frontend renderer.
        """
        raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
        if "error" in raw:
            return raw
        result = raw["result"].get("result", [])
        if len(result) != 1:
            return {"error": "Gauge requires a single-series query; refine your PromQL"}
        try:
            value = float(result[0]["value"][1])
        except (KeyError, IndexError, ValueError, TypeError):
            return {"error": "Gauge query returned no scalar value"}
        return {
            "value": value,
            "warn_at": config.get("warn_at"),
            "crit_at": config.get("crit_at"),
            "min": config.get("min"),
            "max": config.get("max"),
            "unit": config.get("unit"),
        }

    async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
        """Range query → client-side mean for the mean widget (SC-112..SC-114).

        Runs ``query_range`` over the configured window preset, averages all
        non-null numeric samples of the single series, and returns a scalar.
        Scalar-only: a multi-series query returns an error (SC-114).
        """
        promql = config.get("promql")
        if not promql:
            return {"error": "promql is required"}
        window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
        raw = await self._range_query(base_url, timeout, promql, window)
        if "error" in raw:
            return raw
        result = raw["matrix"]
        if len(result) != 1:
            return {"error": "Mean requires a single-series query; refine your PromQL"}
        points = result[0].get("values") or []
        nums: list[float] = []
        for _, v in points:
            if v in (None, "NaN", "+Inf", "-Inf"):
                continue
            try:
                nums.append(float(v))
            except (TypeError, ValueError):
                continue
        if not nums:
            return {"error": "Mean query returned no numeric samples in the window"}
        mean = sum(nums) / len(nums)
        return {"value": mean, "unit": config.get("unit")}


class AlertmanagerWidgetSource:
    """Fetch firing alerts from an Alertmanager service and summarize them."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        try:
            if service is None:
                return {"error": "Alertmanager widget is missing its service"}
            base_url = str(service.config.get("base_url") or "").rstrip("/")
            timeout = int(service.config.get("timeout_seconds") or 5)
            severity_filter = config.get("severity_filter") or None
            headers: dict[str, str] = {}
            api_key = str(service.secrets.get("api_key") or "")
            if api_key:
                headers["Authorization"] = f"Bearer {api_key}"
            response = await asyncio.wait_for(
                asyncio.to_thread(
                    requests.get,
                    f"{base_url}/api/v1/alerts",
                    headers=headers,
                    timeout=timeout,
                ),
                timeout=timeout,
            )
            response.raise_for_status()
            payload = response.json()
            alerts = payload.get("data", []) if isinstance(payload, dict) else []
            return summarize_alerts(alerts, severity_filter=severity_filter)
        except asyncio.TimeoutError:
            return {"error": "Widget data fetch timed out"}
        except requests.RequestException as exc:
            logger.exception("alertmanager adapter failed")
            return {"error": f"Alertmanager query failed: {exc}"}
        except Exception as exc:
            logger.exception("alertmanager adapter failed")
            return {"error": f"Alertmanager query failed: {exc}"}


class JellyfinWidgetSource:
    """Fetch Jellyfin sessions and map them to activity rows."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        timeout = 10
        try:
            if service is None:
                return {"error": "Jellyfin widget is missing its service"}
            base_url = str(service.config.get("base_url") or "")
            api_key = str(service.secrets.get("api_key") or "")
            timeout = int(service.config.get("timeout_seconds") or 10)
            client = await asyncio.wait_for(
                asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
                timeout=timeout,
            )
            sessions = await asyncio.wait_for(
                asyncio.to_thread(client.sessions),
                timeout=timeout,
            )
            if widget_kind == "now_playing":
                sessions = [
                    s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True)
                ]
            rows = _map_sessions_to_activity_rows(sessions)
            return {"sessions": rows}
        except asyncio.TimeoutError:
            return {"error": "Widget data fetch timed out"}
        except Exception as exc:
            logger.exception("jellyfin adapter failed")
            return {"error": f"Jellyfin data fetch failed: {exc}"}


class SshTaskWidgetSource:
    """Run a saved task on an SSH task runner instance and log the run."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        timeout = 30
        try:
            if service is None:
                return {"error": "SSH task widget is missing its service"}
            store = get_settings_store()
            task_id = config.get("task_id") or ""
            if not task_id:
                return {"error": "task_id is required"}
            task = store.get_task(task_id)
            if not task:
                return {"error": f"Task {task_id} not found"}
            if not task.get("enabled", True):
                return {"error": "Task is disabled"}

            timeout = int(service.config.get("timeout_seconds") or 30)
            result = await asyncio.wait_for(
                asyncio.to_thread(run_saved_task, store, task, service),
                timeout=timeout,
            )
            return {"exit_status": result.exit_status, "stdout": result.stdout, "stderr": result.stderr}
        except asyncio.TimeoutError:
            _record_timeout(service, config, timeout)
            return {"error": "Widget data fetch timed out"}
        except Exception as exc:
            logger.exception("ssh_task adapter failed")
            return {"error": f"SSH task failed: {exc}"}


def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
    try:
        store = get_settings_store()
        store.record_service_task_run(
            {
                "task_id": str(config.get("task_id") or ""),
                "service_id": service.id if service else "",
                "status": "timeout",
                "duration_ms": timeout * 1000,
                "error": f"Task timed out after {timeout}s",
            }
        )
    except Exception:  # pragma: no cover - logging best-effort
        logger.exception("failed to record ssh task timeout")


class QbittorrentWidgetSource:
    """Fetch qBittorrent data for totals, active, and speed widgets."""

    async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
        try:
            if service is None:
                return {"error": "qBittorrent widget is missing its service"}
            base_url = str(service.config.get("base_url") or "")
            username = str(service.secrets.get("username") or "")
            password = str(service.secrets.get("password") or "")
            timeout = int(service.config.get("timeout_seconds") or 10)
            if not base_url or not username or not password:
                return {"error": "qBittorrent service is missing base_url, username, or password"}

            client = QbittorrentClient(base_url, username, password, timeout)
            data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
            server_state = data.get("server_state", {})
            torrents = data.get("torrents", {})

            if widget_kind == "totals":
                by_state: dict[str, int] = {}
                for t in torrents.values():
                    state = str(t.get("state", "unknown"))
                    by_state[state] = by_state.get(state, 0) + 1
                return {"total": len(torrents), "by_state": by_state}

            if widget_kind == "active":
                active = [
                    {
                        "name": t.get("name"),
                        "state": t.get("state"),
                        "size": t.get("size"),
                        "progress": t.get("progress"),
                        "dl_speed": t.get("dlspeed"),
                        "up_speed": t.get("upspeed"),
                    }
                    for t in torrents.values()
                    if str(t.get("state", "")) in {"downloading", "uploading"}
                ]
                return {"torrents": active}

            if widget_kind == "speed":
                dl = int(server_state.get("dl_info_speed", 0))
                up = int(server_state.get("up_info_speed", 0))
                ts = int(time.time())
                store = QbittorrentSampleStore()
                store.append(service.id, ts, dl, up)
                samples = store.window(service.id)
                series = [
                    {"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
                    {"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
                ]
                return {"series": series}

            return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
        except asyncio.TimeoutError:
            return {"error": "qBittorrent data fetch timed out"}
        except Exception as exc:
            logger.exception("qbittorrent adapter failed")
            return {"error": f"qBittorrent fetch failed: {exc}"}


# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------

SERVICE_ADAPTERS: dict[str, WidgetSource] = {
    "prometheus": PrometheusWidgetSource(),
    "qbittorrent": QbittorrentWidgetSource(),
    "alertmanager": AlertmanagerWidgetSource(),
    "jellyfin": JellyfinWidgetSource(),
    "ssh_tasks": SshTaskWidgetSource(),
}

BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
    "backups": BackupsWidgetSource(),
    "static": StaticWidgetSource(),
}


def get_service_adapter(service_type: str) -> WidgetSource | None:
    return SERVICE_ADAPTERS.get(service_type)


def get_builtin_adapter(kind: str) -> WidgetSource | None:
    return BUILTIN_ADAPTERS.get(kind)
