from __future__ import annotations

import asyncio
import hashlib
import json
import threading
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any

import pytest
from fastapi import APIRouter, HTTPException
from fastapi.routing import APIRoute

from k1link.device_plugins.xgrids_k1 import xgrids_k1_archive_source
from k1link.device_plugins.xgrids_k1.mqtt.capture import FRAME_HEADER, RAW_MAGIC
from k1link.sessions import (
    SessionRecordingMaterializer,
    SessionRecordingPreparationManager,
    SessionStore,
)
from k1link.web import app as app_module
from k1link.web.session_api import build_session_router


def _make_completed_session(root: Path, session_id: str) -> Path:
    capture = root / session_id / "captures" / "mqtt_live"
    capture.mkdir(parents=True)
    topics = (
        "lixel/application/report/lio_pcl",
        "lixel/application/report/lio_pose",
    )
    raw = bytearray(RAW_MAGIC)
    metadata: list[dict[str, object]] = []
    for sequence, topic in enumerate(topics, start=1):
        encoded_topic = topic.encode("utf-8")
        payload = b"fixture"
        frame_offset = len(raw)
        raw.extend(FRAME_HEADER.pack(len(encoded_topic), len(payload)))
        raw.extend(encoded_topic)
        raw.extend(payload)
        metadata.append(
            {
                "record_type": "message",
                "sequence": sequence,
                "received_at_epoch_ns": 1_000_000_000 + sequence,
                "received_monotonic_ns": 2_000_000_000 + sequence,
                "topic": topic,
                "payload_bytes": len(payload),
                "raw_frame_offset": frame_offset,
                "raw_payload_offset": frame_offset + FRAME_HEADER.size + len(encoded_topic),
                "raw_frame_bytes": FRAME_HEADER.size + len(encoded_topic) + len(payload),
            }
        )
    (capture / "mqtt.raw.k1mqtt").write_bytes(raw)
    metadata_path = capture / "mqtt.metadata.jsonl"
    metadata_path.write_text(
        "".join(json.dumps(record) + "\n" for record in metadata),
        encoding="utf-8",
    )
    (capture / "mqtt.summary.json").write_text(
        json.dumps(
            {
                "created_at_utc": "2026-07-16T20:56:32.699Z",
                "completed_at_utc": "2026-07-16T20:57:02.699Z",
                "capture_elapsed_seconds": 30.0,
                "stop_reason": "external_stop",
                "error": None,
                "message_count": 2,
                "raw_bytes": len(raw),
                "topic_counts": {topic: 1 for topic in topics},
                "artifact_hashes": {
                    "raw_sha256": hashlib.sha256(raw).hexdigest(),
                    "metadata_jsonl_sha256": hashlib.sha256(metadata_path.read_bytes()).hexdigest(),
                },
            }
        ),
        encoding="utf-8",
    )
    return capture.parents[1]


def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
    return next(
        route.endpoint
        for route in router.routes
        if isinstance(route, APIRoute) and route.path == path and method in route.methods
    )


def test_list_refresh_discovers_new_completed_session_without_server_restart(
    tmp_path: Path,
) -> None:
    repository = tmp_path / "repo"
    sessions = repository / "sessions"
    store = SessionStore(repository, data_dir=tmp_path / "data")
    refresh_calls = 0

    def refresh() -> tuple[str, ...]:
        nonlocal refresh_calls
        refresh_calls += 1
        return store.reconcile_archive(xgrids_k1_archive_source(sessions))

    router = build_session_router(store, catalog_refresher=refresh)
    list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")

    assert list_route(limit=20, cursor=None) == {"items": []}
    session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
    listing = list_route(limit=20, cursor=None)

    assert refresh_calls == 2
    assert [item["id"] for item in listing["items"]] == [session.name]


def test_detail_and_replay_refresh_catalog_before_lookup(tmp_path: Path) -> None:
    repository = tmp_path / "repo"
    sessions = repository / "sessions"
    session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
    store = SessionStore(repository, data_dir=tmp_path / "data")
    refresh_calls = 0

    def refresh() -> tuple[str, ...]:
        nonlocal refresh_calls
        refresh_calls += 1
        return store.reconcile_archive(xgrids_k1_archive_source(sessions))

    router = build_session_router(store, catalog_refresher=refresh)
    detail_route = _endpoint(
        router,
        "/api/v1/observation-sessions/{session_id}",
        "GET",
    )
    replay_route = _endpoint(
        router,
        "/api/v1/observation-sessions/{session_id}/replay",
        "POST",
    )

    detail = detail_route(session_id=session.name)
    replay = asyncio.run(replay_route(session_id=session.name, request=None))

    assert detail["session_id"] == session.name
    assert replay["launch"]["session_id"] == session.name
    assert refresh_calls == 2


def test_catalog_refresh_failure_is_a_stable_service_error(tmp_path: Path) -> None:
    store = SessionStore(tmp_path / "repo", data_dir=tmp_path / "data")

    def fail_refresh() -> None:
        raise OSError("private local path must not escape")

    router = build_session_router(store, catalog_refresher=fail_refresh)
    list_route = _endpoint(router, "/api/v1/observation-sessions", "GET")

    with pytest.raises(HTTPException) as error:
        list_route(limit=20, cursor=None)
    assert error.value.status_code == 503
    assert "private local path" not in str(error.value.detail)


def test_startup_scan_baselines_historical_sessions_without_enqueuing(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    repository = tmp_path / "repo"
    sessions = repository / "sessions"
    session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
    store = SessionStore(repository, data_dir=tmp_path / "data")
    store.reconcile_archive(xgrids_k1_archive_source(sessions))

    def exporter(source: Path, destination: Path) -> dict[str, object]:
        payload = b"startup-prepared-recording"
        destination.write_bytes(payload)
        return {
            "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
            "rrd_sha256": hashlib.sha256(payload).hexdigest(),
            "rrd_bytes": len(payload),
            "timeline": "session_time",
            "timeline_start_ns": 0,
            "timeline_end_ns": 1,
        }

    manager = SessionRecordingPreparationManager(
        SessionRecordingMaterializer(store.data_dir, exporter=exporter)
    )
    monkeypatch.setattr(app_module, "session_store", store)
    monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
    try:
        historical = set(app_module.finalized_replayable_recording_ids())
        newly_finalized = app_module.newly_finalized_recording_ids(None, historical)
        enqueued = app_module.enqueue_replayable_recordings(newly_finalized)

        assert historical == {session.name}
        assert newly_finalized == ()
        assert enqueued == ()
        assert manager.status(session.name) is None
    finally:
        manager.close()


def test_reconciliation_skips_one_stale_session_and_prepares_later_valid_session(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    repository = tmp_path / "repo"
    sessions = repository / "sessions"
    good = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
    stale = _make_completed_session(sessions, "20260716T205633Z_viewer_live")
    store = SessionStore(repository, data_dir=tmp_path / "data")
    store.reconcile_archive(xgrids_k1_archive_source(sessions))
    (stale / "captures" / "mqtt_live" / "mqtt.raw.k1mqtt").unlink()

    def exporter(source: Path, destination: Path) -> dict[str, object]:
        payload = b"prepared-after-stale-row"
        destination.write_bytes(payload)
        return {
            "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
            "rrd_sha256": hashlib.sha256(payload).hexdigest(),
            "rrd_bytes": len(payload),
            "timeline": "session_time",
            "timeline_start_ns": 0,
            "timeline_end_ns": 1,
        }

    manager = SessionRecordingPreparationManager(
        SessionRecordingMaterializer(store.data_dir, exporter=exporter)
    )
    monkeypatch.setattr(app_module, "session_store", store)
    monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
    try:
        enqueued = app_module.enqueue_replayable_recordings((stale.name, good.name))
        deadline = time.monotonic() + 2
        snapshot = manager.status(good.name)
        while snapshot is not None and snapshot.state != "ready" and time.monotonic() < deadline:
            time.sleep(0.005)
            snapshot = manager.status(good.name)

        assert enqueued == (good.name,)
        assert manager.status(stale.name) is None
        assert snapshot is not None and snapshot.state == "ready"
    finally:
        manager.close()


def test_reconciler_requeues_only_a_restart_interrupted_job(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    repository = tmp_path / "repo"
    sessions = repository / "sessions"
    session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
    store = SessionStore(repository, data_dir=tmp_path / "data")
    store.reconcile_archive(xgrids_k1_archive_source(sessions))
    started = threading.Event()
    release = threading.Event()
    calls = 0

    def exporter(source: Path, destination: Path) -> dict[str, object]:
        nonlocal calls
        calls += 1
        if calls == 1:
            started.set()
            assert release.wait(timeout=2)
        payload = f"restart-{calls}".encode()
        destination.write_bytes(payload)
        return {
            "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
            "rrd_sha256": hashlib.sha256(payload).hexdigest(),
            "rrd_bytes": len(payload),
            "timeline": "session_time",
            "timeline_start_ns": 0,
            "timeline_end_ns": 1,
        }

    manager = SessionRecordingPreparationManager(
        SessionRecordingMaterializer(store.data_dir, exporter=exporter)
    )
    monkeypatch.setattr(app_module, "session_store", store)
    monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
    try:
        app_module.enqueue_replayable_recordings((session.name,))
        assert started.wait(timeout=1)
        manager.close(timeout=0.001)
        manager.start()
        release.set()

        deadline = time.monotonic() + 2
        snapshot = manager.status(session.name)
        while (snapshot is None or snapshot.state != "cancelled") and time.monotonic() < deadline:
            time.sleep(0.005)
            snapshot = manager.status(session.name)
        assert snapshot is not None and snapshot.state == "cancelled"
        interrupted_id = snapshot.preparation_id

        app_module.enqueue_replayable_recordings((session.name,))
        deadline = time.monotonic() + 2
        while time.monotonic() < deadline:
            snapshot = manager.status(session.name)
            if snapshot is not None and snapshot.state == "ready":
                break
            time.sleep(0.005)
        assert snapshot is not None and snapshot.state == "ready"
        assert snapshot.preparation_id != interrupted_id
        assert calls == 2
    finally:
        release.set()
        manager.close()


def test_reconciler_does_not_loop_retry_a_genuine_failed_job(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    repository = tmp_path / "repo"
    sessions = repository / "sessions"
    session = _make_completed_session(sessions, "20260716T205632Z_viewer_live")
    store = SessionStore(repository, data_dir=tmp_path / "data")
    store.reconcile_archive(xgrids_k1_archive_source(sessions))
    calls = 0

    def exporter(_source: Path, _destination: Path) -> dict[str, object]:
        nonlocal calls
        calls += 1
        raise OSError("genuine export failure")

    manager = SessionRecordingPreparationManager(
        SessionRecordingMaterializer(store.data_dir, exporter=exporter)
    )
    monkeypatch.setattr(app_module, "session_store", store)
    monkeypatch.setattr(app_module, "session_recording_preparation_manager", manager)
    try:
        app_module.enqueue_replayable_recordings((session.name,))
        deadline = time.monotonic() + 2
        snapshot = manager.status(session.name)
        while (snapshot is None or snapshot.state != "failed") and time.monotonic() < deadline:
            time.sleep(0.005)
            snapshot = manager.status(session.name)
        assert snapshot is not None and snapshot.state == "failed"
        failed_id = snapshot.preparation_id

        app_module.enqueue_replayable_recordings((session.name,))
        time.sleep(0.05)
        unchanged = manager.status(session.name)
        assert unchanged is not None
        assert unchanged.preparation_id == failed_id
        assert unchanged.state == "failed"
        assert calls == 1
    finally:
        manager.close()
