"""Tests for the dashboard widget system: service-bound + built-in widgets."""

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import patch

import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient

from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import (
    AlertmanagerWidgetSource,
    BackupsWidgetSource,
    JellyfinWidgetSource,
    ServiceRecord,
    StaticWidgetSource,
)

TEST_KEY = Fernet.generate_key().decode()


@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
    monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
    yield


@pytest.fixture
def client(tmp_path):
    """FastAPI test client with a fresh settings store and auth disabled."""
    store = SettingsStore(tmp_path / "settings.sqlite")
    store.ensure_defaults()
    app.dependency_overrides[get_settings_store] = lambda: store
    auth_settings = SimpleNamespace(auth_enabled=False)
    with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
        yield TestClient(app)
    app.dependency_overrides.clear()


def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
    config = {"base_url": "https://prometheus.example.com"}
    config.update(config_overrides)
    return client.post(
        "/api/services/instances",
        json={"service_type": "prometheus", "name": name, "config": config, "enabled": True},
    ).json()


# ---------------------------------------------------------------------------
# Built-in kinds + built-in widget CRUD
# ---------------------------------------------------------------------------


def test_list_builtin_kinds(client):
    response = client.get("/api/widgets/builtin")
    assert response.status_code == 200
    kinds = {item["kind"] for item in response.json()}
    assert kinds == {"backups", "static"}


def test_create_and_read_static_widget(client):
    response = client.post(
        "/api/widgets/instances",
        json={
            "widget_kind": "static",
            "title": "Note",
            "config": {"text": "hello"},
        },
    )
    assert response.status_code == 201
    created = response.json()
    assert created["widget_kind"] == "static"
    assert created["service_id"] is None
    assert created["config"]["text"] == "hello"

    listed = client.get("/api/widgets/instances").json()
    assert len(listed) == 1
    assert listed[0]["id"] == created["id"]


def test_create_backups_widget(client):
    response = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "backups", "title": "Backups", "config": {}},
    )
    assert response.status_code == 201


def test_widget_filtering_by_service_id_and_scope(client):
    """Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
    service = _make_prometheus_service(client)
    # Create a dashboard-scoped (built-in) widget + a service-scoped widget.
    client.post(
        "/api/widgets/instances",
        json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
    )
    client.post(
        "/api/widgets/instances",
        json={
            "service_id": service["id"],
            "widget_kind": "metric",
            "title": "Dash",
            "config": {"promql": "up"},
        },
    )

    # No filter: both widgets.
    all_widgets = client.get("/api/widgets/instances").json()
    assert len(all_widgets) == 2

    # Filter by service_id: only the service-scoped one.
    by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
    assert len(by_service) == 1
    assert by_service[0]["service_id"] == service["id"]

    # scope=dashboard: only the built-in (NULL service_id).
    dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
    assert len(dashboard_scope) == 1
    assert dashboard_scope[0]["service_id"] is None

    # scope=service: only the non-null service_id widget.
    service_scope = client.get("/api/widgets/instances?scope=service").json()
    assert len(service_scope) == 1
    assert service_scope[0]["service_id"] == service["id"]


def test_unknown_builtin_kind_rejected(client):
    response = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "bogus", "title": "x", "config": {}},
    )
    assert response.status_code == 422


def test_credential_key_in_config_rejected(client):
    response = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
    )
    assert response.status_code == 422


# ---------------------------------------------------------------------------
# Service-bound widget CRUD
# ---------------------------------------------------------------------------


def test_create_service_bound_widget(client):
    service = _make_prometheus_service(client)
    response = client.post(
        "/api/widgets/instances",
        json={
            "service_id": service["id"],
            "widget_kind": "metric",
            "title": "Metrics",
            "config": {"promql": "up"},
        },
    )
    assert response.status_code == 201
    created = response.json()
    assert created["service_id"] == service["id"]
    assert created["widget_kind"] == "metric"


def test_service_bound_widget_unknown_kind_rejected(client):
    service = _make_prometheus_service(client)
    response = client.post(
        "/api/widgets/instances",
        json={
            "service_id": service["id"],
            "widget_kind": "nonexistent_kind",
            "title": "x",
            "config": {},
        },
    )
    assert response.status_code == 422


def test_service_bound_widget_service_not_found_rejected(client):
    response = client.post(
        "/api/widgets/instances",
        json={
            "service_id": "missing",
            "widget_kind": "metric",
            "title": "x",
            "config": {"promql": "up"},
        },
    )
    assert response.status_code == 422


def test_service_bound_widget_invalid_config_rejected(client):
    service = _make_prometheus_service(client)
    response = client.post(
        "/api/widgets/instances",
        json={
            "service_id": service["id"],
            "widget_kind": "metric",
            "title": "x",
            "config": {"promql": 123},  # bad type: promql must be a string
        },
    )
    assert response.status_code == 422


def test_update_and_delete_widget(client):
    created = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
    ).json()
    updated = client.put(
        f"/api/widgets/instances/{created['id']}",
        json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
    ).json()
    assert updated["title"] == "Note2"

    assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
    assert client.get("/api/widgets/instances").json() == []


def test_update_nonexistent_returns_404(client):
    response = client.put(
        "/api/widgets/instances/missing",
        json={"widget_kind": "static", "title": "x", "config": {}},
    )
    assert response.status_code == 404


def test_update_id_mismatch_returns_400(client):
    created = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "static", "title": "x", "config": {}},
    ).json()
    response = client.put(
        f"/api/widgets/instances/{created['id']}",
        json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
    )
    assert response.status_code == 400


# ---------------------------------------------------------------------------
# Data endpoint
# ---------------------------------------------------------------------------


def test_fetch_static_widget_data(client):
    created = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
    ).json()
    response = client.get(f"/api/widgets/instances/{created['id']}/data")
    assert response.status_code == 200
    body = response.json()
    assert body["data"]["text"] == "hello"
    assert body["error"] is None


def test_fetch_backups_widget_data(client):
    created = client.post(
        "/api/widgets/instances",
        json={"widget_kind": "backups", "title": "Backups", "config": {}},
    ).json()
    response = client.get(f"/api/widgets/instances/{created['id']}/data")
    assert response.status_code == 200
    assert "total_jobs" in response.json()["data"]


def test_fetch_widget_service_not_found(client):
    service = _make_prometheus_service(client)
    created = client.post(
        "/api/widgets/instances",
        json={
            "service_id": service["id"],
            "widget_kind": "metric",
            "title": "x",
            "config": {"promql": "up"},
        },
    ).json()
    # Deleting the service cascade-deletes its widgets, so the widget is gone.
    client.delete(f"/api/services/instances/{service['id']}")
    assert client.get("/api/widgets/instances").json() == []
    assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404


def test_fetch_widget_service_disabled(client):
    service = _make_prometheus_service(client)
    created = client.post(
        "/api/widgets/instances",
        json={
            "service_id": service["id"],
            "widget_kind": "metric",
            "title": "x",
            "config": {"promql": "up"},
        },
    ).json()
    client.put(
        f"/api/services/instances/{service['id']}",
        json={
            "service_type": "prometheus",
            "name": service["name"],
            "config": {"base_url": "https://prometheus.example.com"},
            "enabled": False,
        },
    )
    response = client.get(f"/api/widgets/instances/{created['id']}/data")
    assert response.status_code == 200
    assert "disabled" in response.json()["error"]


def test_fetch_widget_not_found(client):
    assert client.get("/api/widgets/instances/missing/data").status_code == 404


# ---------------------------------------------------------------------------
# Adapter unit tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_alertmanager_adapter_summarizes_alerts():
    adapter = AlertmanagerWidgetSource()
    service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "status": "success",
            "data": [
                {
                    "labels": {"alertname": "DiskFull", "severity": "critical"},
                    "annotations": {"summary": "disk full"},
                    "startsAt": "2026-06-23T00:00:00Z",
                    "status": "firing",
                }
            ],
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
        result = await adapter.fetch(service, "active_alerts", {})
    assert result["total"] == 1
    assert result["by_severity"]["critical"] == 1
    assert result["alerts"][0]["name"] == "DiskFull"


@pytest.mark.asyncio
async def test_alertmanager_adapter_applies_severity_filter():
    adapter = AlertmanagerWidgetSource()
    service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "status": "success",
            "data": [
                {"labels": {"alertname": "A", "severity": "critical"}, "annotations": {}, "status": "firing"},
                {"labels": {"alertname": "B", "severity": "warning"}, "annotations": {}, "status": "firing"},
            ],
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
        result = await adapter.fetch(service, "active_alerts", {"severity_filter": "critical"})
    assert result["total"] == 1
    assert result["alerts"][0]["name"] == "A"


@pytest.mark.asyncio
async def test_alertmanager_adapter_sends_bearer_token():
    adapter = AlertmanagerWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="alertmanager",
        name="am",
        config={"base_url": "http://am:9093"},
        secrets={"api_key": "tok"},
    )
    payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []})
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
        result = await adapter.fetch(service, "active_alerts", {})
    assert result["total"] == 0
    _, kwargs = mock_get.call_args
    assert kwargs["headers"]["Authorization"] == "Bearer tok"


@pytest.mark.asyncio
async def test_alertmanager_adapter_missing_service():
    adapter = AlertmanagerWidgetSource()
    result = await adapter.fetch(None, "active_alerts", {})
    assert "error" in result


@pytest.mark.asyncio
async def test_static_adapter():
    adapter = StaticWidgetSource()
    result = await adapter.fetch(None, "static", {"text": "hi"})
    assert result == {"text": "hi"}


@pytest.mark.asyncio
async def test_backups_adapter(client):
    store = app.dependency_overrides[get_settings_store]()
    with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
        adapter = BackupsWidgetSource()
        result = await adapter.fetch(None, "backups", {})
    assert "total_jobs" in result


@pytest.mark.asyncio
async def test_ssh_task_adapter_missing_service():
    from media_library_viewer_api.widgets.sources import SshTaskWidgetSource

    adapter = SshTaskWidgetSource()
    result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
    assert "error" in result


@pytest.mark.asyncio
async def test_ssh_task_adapter_records_history_on_run(client):
    store = app.dependency_overrides[get_settings_store]()
    # Save a task and an ssh_tasks service instance.
    task = store.upsert_task(
        {
            "name": "echo",
            "task_type": "shell",
            "content": "echo hi",
            "enabled": True,
            "default_service_id": "",
        }
    )
    service = store.upsert_service(
        {"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
    )

    fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
    fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)

    from media_library_viewer_api.widgets.sources import SshTaskWidgetSource

    adapter = SshTaskWidgetSource()
    service_record = ServiceRecord(
        id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
    )
    with (
        patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
        patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=fake_client),
    ):
        result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})

    assert result["exit_status"] == 0
    runs = store.list_service_task_runs(service_id=service["id"])
    assert len(runs) == 1
    assert runs[0]["status"] == "success"


# ---------------------------------------------------------------------------
# New widget kind tests (jellyfin now_playing)
# ---------------------------------------------------------------------------


def test_jellyfin_definition_has_now_playing_widget():
    from media_library_viewer_api.integrations.registry import get_service_definition

    definition = get_service_definition("jellyfin")
    kinds = {wk.kind for wk in definition.widget_kinds}
    assert "now_playing" in kinds
    assert "activity" in kinds


@pytest.mark.asyncio
async def test_prometheus_chart_adapter_runs_range_query():
    """SC-101: chart kind hits /api/v1/query_range and returns {series}."""
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090", "timeout_seconds": 5},
    )
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "data": {
                "result": [
                    {
                        "metric": {"__name__": "up", "instance": "h:9100"},
                        "values": [[100, "1"], [130, "1"]],
                    }
                ]
            }
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
        result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})

    # query_range endpoint + window-derived start/end/step params.
    call = mock_get.call_args
    assert call.args[0].endswith("/api/v1/query_range")
    params = call.kwargs["params"]
    assert params["query"] == "up"
    assert {"start", "end", "step"}.issubset(params)
    # {series} shape with the shared normalization (label drops __name__).
    assert "series" in result
    assert result["series"][0]["label"] == "instance=h:9100"
    assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]


@pytest.mark.asyncio
async def test_prometheus_chart_adapter_requires_promql():
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
    result = await adapter.fetch(service, "chart", {"promql": ""})
    assert result == {"error": "promql is required"}


@pytest.mark.asyncio
async def test_prometheus_chart_adapter_degrades_on_http_error():
    """SC-103: a connection error returns {error} rather than raising."""
    import requests as req_mod

    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
        result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
    assert "error" in result
    assert "failed" in result["error"].lower()


@pytest.mark.asyncio
async def test_jellyfin_now_playing_filters_active_sessions():
    """now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
    adapter = JellyfinWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="jellyfin",
        name="jf",
        config={"base_url": "http://jf:8096"},
        secrets={"api_key": "k"},
    )
    playing_session = {
        "UserName": "alice",
        "NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
        "PlayState": {"IsPaused": False},
        "DeviceName": "Web",
    }
    paused_session = {
        "UserName": "bob",
        "NowPlayingItem": {"Name": "Show", "Type": "Episode"},
        "PlayState": {"IsPaused": True},
        "DeviceName": "TV",
    }
    idle_session = {
        "UserName": "carol",
        "PlayState": {"IsPaused": False},
        "DeviceName": "Phone",
    }
    mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
    with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
        result = await adapter.fetch(service, "now_playing", {})
    sessions = result["sessions"]
    assert len(sessions) == 1
    assert sessions[0]["user"] == "alice"
    assert sessions[0]["state"] == "playing"


@pytest.mark.asyncio
async def test_jellyfin_activity_shows_all_sessions():
    """activity (default) should include idle and paused sessions."""
    adapter = JellyfinWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="jellyfin",
        name="jf",
        config={"base_url": "http://jf:8096"},
        secrets={"api_key": "k"},
    )
    mock_client = SimpleNamespace(
        sessions=lambda: [
            {"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
            {"UserName": "bob", "PlayState": {"IsPaused": False}},
        ]
    )
    with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
        result = await adapter.fetch(service, "activity", {})
    assert len(result["sessions"]) == 2


# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------


@pytest.fixture
def widget_ref_client(monkeypatch):
    """TestClient with an isolated SettingsStore + encryption key."""
    monkeypatch.setenv(
        "MANAGE_ENCRYPTION_KEY",
        Fernet.generate_key().decode(),
    )
    from media_library_viewer_api.services.secrets import reset_encryption_key_cache

    reset_encryption_key_cache()
    import tempfile
    from pathlib import Path

    store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
    store.ensure_defaults()

    def get_store_override():
        return store

    app.dependency_overrides[get_settings_store] = get_store_override
    client = TestClient(app)
    yield client, store
    app.dependency_overrides.pop(get_settings_store, None)


def test_widget_reference_lifecycle(widget_ref_client):
    """Create a widget, reference it on 'main', verify it appears, delete reference."""
    client, store = widget_ref_client

    # Create a service-bound widget (simulating one on a Prometheus service).
    store.upsert_service(
        {
            "service_type": "prometheus",
            "name": "Prometheus",
            "config": {"base_url": "https://prometheus.example.com"},
            "secrets": {"api_key": "tok"},
            "enabled": True,
        },
    )
    service = store.list_services("prometheus")[0]
    widget = store.upsert_widget(
        {
            "service_id": service["id"],
            "widget_kind": "chart",
            "title": "CPU IOWait",
            "config": {"promql": "rate(cpu[5m])", "window": "1h"},
            "enabled": True,
            "sort_order": 0,
        }
    )

    # Reference it on "main" dashboard.
    resp = client.post(
        "/api/widgets/references",
        json={
            "dashboard_scope": "main",
            "widget_id": widget["id"],
            "sort_order": 5,
        },
    )
    assert resp.status_code == 201
    ref = resp.json()
    assert ref["dashboard_scope"] == "main"
    assert ref["widget_id"] == widget["id"]
    ref_id = ref["id"]

    # List references for "main" — should include our widget.
    resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
    assert resp.status_code == 200
    refs = resp.json()
    assert len(refs) == 1
    assert refs[0]["widget"]["title"] == "CPU IOWait"

    # Delete the reference.
    resp = client.delete(f"/api/widgets/references/{ref_id}")
    assert resp.status_code == 200
    assert resp.json()["status"] == "deleted"

    # Reference is gone, original widget still exists.
    resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
    assert len(resp.json()) == 0
    assert store.get_widget(widget["id"]) is not None


def test_widget_reference_detach(widget_ref_client):
    """Detach clones the widget into a standalone instance and removes the reference."""
    client, store = widget_ref_client

    store.upsert_service(
        {
            "service_type": "prometheus",
            "name": "Prometheus",
            "config": {"base_url": "https://prometheus.example.com"},
            "secrets": {"api_key": "tok"},
            "enabled": True,
        },
    )
    service = store.list_services("prometheus")[0]
    widget = store.upsert_widget(
        {
            "service_id": service["id"],
            "widget_kind": "chart",
            "title": "Memory",
            "config": {"promql": "mem", "window": "1h"},
            "enabled": True,
            "sort_order": 0,
        }
    )

    # Reference on "main".
    resp = client.post(
        "/api/widgets/references",
        json={
            "dashboard_scope": "main",
            "widget_id": widget["id"],
        },
    )
    ref_id = resp.json()["id"]

    # Detach.
    resp = client.post(f"/api/widgets/references/{ref_id}/detach")
    assert resp.status_code == 200
    cloned = resp.json()
    assert cloned["title"] == "Memory"
    assert cloned["widget_kind"] == "chart"
    assert cloned["service_id"] == service["id"]  # Fix 2: preserves service binding
    assert cloned["config"]["promql"] == "mem"
    assert cloned["id"] != widget["id"]  # new independent widget

    # Reference is gone.
    refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
    assert len(refs) == 0
    # Original still exists.
    assert store.get_widget(widget["id"]) is not None


def test_widget_reference_update_sort_order(widget_ref_client):
    """PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
    client, store = widget_ref_client

    widget_a = store.upsert_widget(
        {
            "service_id": None,
            "widget_kind": "static",
            "title": "A",
            "config": {"text": "a"},
            "enabled": True,
            "sort_order": 0,
        }
    )
    widget_b = store.upsert_widget(
        {
            "service_id": None,
            "widget_kind": "static",
            "title": "B",
            "config": {"text": "b"},
            "enabled": True,
            "sort_order": 1,
        }
    )

    # Two references on the same dashboard scope.
    resp = client.post(
        "/api/widgets/references",
        json={
            "dashboard_scope": "named:test",
            "widget_id": widget_a["id"],
            "sort_order": 0,
        },
    )
    ref_a = resp.json()
    resp = client.post(
        "/api/widgets/references",
        json={
            "dashboard_scope": "named:test",
            "widget_id": widget_b["id"],
            "sort_order": 1,
        },
    )
    ref_b = resp.json()

    # Swap sort orders via PUT (per-dashboard reorder).
    resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
    assert resp.status_code == 200
    assert resp.json()["sort_order"] == 1

    resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
    assert resp.status_code == 200
    assert resp.json()["sort_order"] == 0

    # Widget instances themselves are unchanged.
    assert store.get_widget(widget_a["id"])["sort_order"] == 0
    assert store.get_widget(widget_b["id"])["sort_order"] == 1


# ---------------------------------------------------------------------------
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_returns_scalar():
    """SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}."""
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090", "timeout_seconds": 5},
    )
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "data": {
                "result": [
                    {"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
                ]
            }
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
        result = await adapter.fetch(
            service,
            "gauge",
            {
                "promql": "cpu_usage",
                "warn_at": 0.8,
                "crit_at": 0.95,
                "unit": "%",
            },
        )
    call = mock_get.call_args
    assert call.args[0].endswith("/api/v1/query")
    assert call.kwargs["params"]["query"] == "cpu_usage"
    assert result["value"] == 0.75
    assert result["warn_at"] == 0.8
    assert result["crit_at"] == 0.95
    assert result["unit"] == "%"


@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_rejects_multi_series():
    """SC-111: gauge must be scalar-only; multi-series returns error."""
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090"},
    )
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "data": {
                "result": [
                    {"metric": {"instance": "a"}, "value": [100, "1"]},
                    {"metric": {"instance": "b"}, "value": [100, "2"]},
                ]
            }
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
        result = await adapter.fetch(service, "gauge", {"promql": "up"})
    assert "error" in result
    assert "single-series" in result["error"].lower()


@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_requires_promql():
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090"},
    )
    result = await adapter.fetch(service, "gauge", {"promql": ""})
    assert result == {"error": "promql is required"}


@pytest.mark.asyncio
async def test_prometheus_mean_adapter_computes_average():
    """SC-112: mean kind averages non-null values over the window."""
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090", "timeout_seconds": 5},
    )
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "data": {
                "result": [
                    {
                        "metric": {"__name__": "cpu"},
                        "values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
                    }
                ]
            }
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
        result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
    assert result["value"] == 2.0
    assert result["unit"] is None


@pytest.mark.asyncio
async def test_prometheus_mean_adapter_rejects_multi_series():
    """SC-114: mean must be scalar-only; multi-series returns error."""
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090"},
    )
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "data": {
                "result": [
                    {"metric": {"instance": "a"}, "values": [[100, "1"]]},
                    {"metric": {"instance": "b"}, "values": [[100, "2"]]},
                ]
            }
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
        result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
    assert "error" in result
    assert "single-series" in result["error"].lower()


@pytest.mark.asyncio
async def test_prometheus_mean_adapter_skips_nan_values():
    """SC-112: NaN / Inf values are excluded from the mean computation."""
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090"},
    )
    payload = SimpleNamespace(
        raise_for_status=lambda: None,
        json=lambda: {
            "data": {
                "result": [
                    {
                        "metric": {},
                        "values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
                    }
                ]
            }
        },
    )
    with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
        result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
    # (2.0 + 4.0) / 2 = 3.0 (NaN excluded)
    assert result["value"] == 3.0


@pytest.mark.asyncio
async def test_prometheus_mean_adapter_requires_promql():
    from media_library_viewer_api.widgets.sources import PrometheusWidgetSource

    adapter = PrometheusWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="prometheus",
        name="p",
        config={"base_url": "http://p:9090"},
    )
    result = await adapter.fetch(service, "mean", {"promql": ""})
    assert result == {"error": "promql is required"}


# ---------------------------------------------------------------------------
# qBittorrent widget source adapter
# ---------------------------------------------------------------------------


def _fake_qbit_maindata():
    """Return a mock maindata response (server_state + torrents dict)."""
    return {
        "server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
        "torrents": {
            "h1": {
                "name": "Movie.mkv",
                "state": "downloading",
                "size": 1000,
                "progress": 0.5,
                "dlspeed": 500,
                "upspeed": 10,
            },
            "h2": {
                "name": "Show.mkv",
                "state": "uploading",
                "size": 2000,
                "progress": 1.0,
                "dlspeed": 0,
                "upspeed": 100,
            },
            "h3": {
                "name": "Queued",
                "state": "queuedDL",
                "size": 3000,
                "progress": 0.0,
                "dlspeed": 0,
                "upspeed": 0,
            },
            "h4": {
                "name": "Paused",
                "state": "pausedDL",
                "size": 4000,
                "progress": 0.3,
                "dlspeed": 0,
                "upspeed": 0,
            },
        },
    }


@pytest.mark.asyncio
async def test_qbittorrent_totals_counts_all_torrents():
    """Totals kind returns count of all listed items + by_state breakdown."""
    from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource

    adapter = QbittorrentWidgetSource()
    service = ServiceRecord(
        id="svc-1",
        service_type="qbittorrent",
        name="qbit",
        config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
        secrets={"username": "admin", "password": "pass"},
    )
    with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
        mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
        result = await adapter.fetch(service, "totals", {})

    assert result["total"] == 4
    assert result["by_state"]["downloading"] == 1
    assert result["by_state"]["uploading"] == 1
    assert result["by_state"]["queuedDL"] == 1
    assert result["by_state"]["pausedDL"] == 1


@pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only():
    """Active kind returns only downloading/uploading torrents (Q3)."""
    from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource

    adapter = QbittorrentWidgetSource()
    service = ServiceRecord(
        id="svc-1",
        service_type="qbittorrent",
        name="qbit",
        config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
        secrets={"username": "admin", "password": "pass"},
    )
    with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
        mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
        result = await adapter.fetch(service, "active", {})

    active = result["torrents"]
    assert len(active) == 2
    names = [t["name"] for t in active]
    assert "Movie.mkv" in names
    assert "Show.mkv" in names
    # Queued and paused are excluded
    assert "Queued" not in names
    assert "Paused" not in names


@pytest.mark.asyncio
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
    """Speed kind appends a sample and returns {series} with two labeled series."""
    from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
    from media_library_viewer_api.services.service_data import ServiceDataHarness
    from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource

    # Isolated harness so we don't pollute the real DB
    harness = ServiceDataHarness(base_dir=str(tmp_path))
    harness.register(QBITTORRENT_CONCERN)
    harness.run_migrations()

    adapter = QbittorrentWidgetSource()
    service = ServiceRecord(
        id="svc-speed",
        service_type="qbittorrent",
        name="qbit",
        config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
        secrets={"username": "admin", "password": "pass"},
    )
    with (
        patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
        patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
    ):
        mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
        # Wire the mock store to a real isolated store
        real_store = QbittorrentSampleStore(harness)
        mock_store_cls.return_value = real_store
        result = await adapter.fetch(service, "speed", {})

    assert "series" in result
    labels = [s["label"] for s in result["series"]]
    assert labels == ["download", "upload"]
    # The sample just appended should be present
    dl_points = result["series"][0]["points"]
    assert len(dl_points) >= 1
    # timestamps multiplied by 1000 for JS epoch
    assert dl_points[-1]["v"] == 500000


@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_service():
    from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource

    adapter = QbittorrentWidgetSource()
    result = await adapter.fetch(None, "totals", {})
    assert "error" in result


@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_credentials():
    from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource

    adapter = QbittorrentWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="qbittorrent",
        name="qbit",
        config={"base_url": "http://qbit:8080"},
        secrets={"username": "", "password": ""},
    )
    result = await adapter.fetch(service, "totals", {})
    assert "error" in result


@pytest.mark.asyncio
async def test_qbittorrent_adapter_timeout():
    """A timeout returns {error} rather than raising."""
    from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource

    adapter = QbittorrentWidgetSource()
    service = ServiceRecord(
        id="s",
        service_type="qbittorrent",
        name="qbit",
        config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
        secrets={"username": "admin", "password": "pass"},
    )
    with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
        import asyncio as _asyncio

        async def _slow(*a, **kw):
            await _asyncio.sleep(10)

        # Make to_thread hang so wait_for times out
        mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
        result = await adapter.fetch(service, "totals", {})
    assert "error" in result
