"""Saved records management — list, play, pause, resume, stop, rename, delete.

Manages WAV recordings saved via the typed replay engine.
"""

from __future__ import annotations

import json
import threading
from pathlib import Path
from typing import Any

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

from Project.Sanad.config import AUDIO_RECORDINGS_DIR
from Project.Sanad.dashboard.routes._safe_io import (
    safe_filename, safe_path_under, atomic_write_json,
)

router = APIRouter()

RECORDS_INDEX = AUDIO_RECORDINGS_DIR / "records.json"
_INDEX_LOCK = threading.Lock()

# Strong refs to fire-and-forget playback tasks. The event loop only keeps a
# weak reference to tasks, so an unreferenced create_task() result can be
# garbage-collected (cancelling playback) before it finishes. Mirror replay.py.
import asyncio as _asyncio  # noqa: E402
_BG_TASKS: set[_asyncio.Task] = set()


def _load_index() -> dict[str, Any]:
    if not RECORDS_INDEX.exists():
        return {"records": [], "total_records": 0, "last_updated": ""}
    try:
        with open(RECORDS_INDEX, "r", encoding="utf-8") as f:
            return json.load(f)
    except (json.JSONDecodeError, OSError):
        # Backup corrupt file rather than nuking it
        try:
            RECORDS_INDEX.rename(RECORDS_INDEX.with_suffix(".json.corrupt"))
        except OSError:
            pass
        return {"records": [], "total_records": 0, "last_updated": ""}


def _save_index(payload: dict[str, Any]):
    AUDIO_RECORDINGS_DIR.mkdir(parents=True, exist_ok=True)
    payload["total_records"] = len(payload.get("records", []))
    atomic_write_json(RECORDS_INDEX, payload)


def _resolve_path(path_str: str) -> Path:
    """Resolve record path — basename / relative / absolute.

    Legacy records stored absolute paths. New records store basenames.
    Both flavors resolve to a real file under AUDIO_RECORDINGS_DIR.
    """
    if not path_str:
        return AUDIO_RECORDINGS_DIR
    p = Path(path_str)
    if p.is_absolute():
        return p
    return AUDIO_RECORDINGS_DIR / p


def _reconcile(payload: dict[str, Any]) -> tuple[dict[str, Any], int]:
    kept, removed = [], 0
    for entry in payload.get("records", []):
        try:
            sp = _resolve_path(entry["files"]["speaker_recording"]["path"])
            rp = _resolve_path(entry["files"]["gemini_raw_output"]["path"])
            if sp.exists() and rp.exists():
                kept.append(entry)
            else:
                removed += 1
        except (KeyError, TypeError):
            removed += 1
    payload["records"] = kept
    payload["total_records"] = len(kept)
    return payload, removed


@router.get("/")
async def list_records():
    with _INDEX_LOCK:
        payload = _load_index()
        payload, removed = _reconcile(payload)
        if removed:
            _save_index(payload)
    return payload


class RecordPlay(BaseModel):
    record_name: str
    file_kind: str = "speaker"  # speaker | raw


@router.post("/play")
async def play_record(payload: RecordPlay):
    with _INDEX_LOCK:
        index = _load_index()
    entry = next((r for r in index.get("records", []) if r.get("record_name") == payload.record_name), None)
    if entry is None:
        raise HTTPException(404, f"Record not found: {payload.record_name}")

    file_key = "speaker_recording" if payload.file_kind == "speaker" else "gemini_raw_output"
    raw_path = _resolve_path(entry["files"][file_key]["path"]).resolve()
    base = AUDIO_RECORDINGS_DIR.resolve()
    try:
        raw_path.relative_to(base)
    except ValueError:
        raise HTTPException(400, "Record path outside recordings directory.")
    if not raw_path.exists():
        raise HTTPException(404, f"File not found: {raw_path.name}")

    from Project.Sanad.main import audio_mgr
    import threading
    # Fire-and-forget on a DEDICATED daemon thread — NOT asyncio.to_thread.
    # to_thread runs on the shared default executor, which gets starved while
    # the dashboard services the live-voice child's reconnect chatter; that
    # delayed record playback by several seconds (clip silent, counter parked).
    # A dedicated thread starts immediately regardless of executor/event-loop
    # load. play_wav blocks for the clip duration and serves pause/stop via
    # _play_state; the UI stays responsive because this handler returns now.
    # Python keeps running threads alive, so no ref is needed to prevent GC.
    threading.Thread(
        target=audio_mgr.play_wav, args=(raw_path, payload.record_name),
        name="record-playback", daemon=True,
    ).start()
    return {"ok": True, "record_name": payload.record_name,
            "file_kind": payload.file_kind, "path": str(raw_path)}


@router.post("/pause")
async def pause_playback():
    from Project.Sanad.main import audio_mgr
    return audio_mgr.pause_playback()


@router.post("/resume")
async def resume_playback():
    from Project.Sanad.main import audio_mgr
    return audio_mgr.resume_playback()


@router.post("/seek")
async def seek_playback(position_sec: float):
    """Jump to a position (seconds) in the currently-playing clip — used by the
    waveform scrubber. No-op (ok=False) if nothing is playing."""
    from Project.Sanad.main import audio_mgr
    return audio_mgr.seek_playback(position_sec)


@router.post("/stop")
async def stop_playback():
    from Project.Sanad.main import audio_mgr
    import asyncio
    await asyncio.to_thread(audio_mgr.stop_playback)
    return {"ok": True, "stopped": True}


@router.get("/playback-status")
async def playback_status():
    from Project.Sanad.main import audio_mgr
    return audio_mgr.playback_status()


@router.post("/live-hold")
async def set_live_hold(on: bool):
    """Manual hold for the live-Gemini pause. on=True pauses the live voice and
    keeps it paused (records won't resume it) until on=False is sent. Default
    behaviour (on=False) is AUTO: records pause Gemini only for the clip."""
    from Project.Sanad.main import audio_mgr
    return {"live_hold": audio_mgr.set_live_voice_hold(on)}


class RecordRename(BaseModel):
    record_name: str
    new_name: str


@router.post("/rename")
async def rename_record(payload: RecordRename):
    new_name = safe_filename(payload.new_name)
    # Strip any extension the user provided — we add our own
    if new_name.lower().endswith(".wav"):
        new_name = new_name[:-4]
    if not new_name or new_name.startswith("."):
        raise HTTPException(400, "Invalid new name.")

    with _INDEX_LOCK:
        index = _load_index()
        entry = next(
            (r for r in index.get("records", []) if r.get("record_name") == payload.record_name),
            None,
        )
        if entry is None:
            raise HTTPException(404, f"Record not found: {payload.record_name}")

        base = AUDIO_RECORDINGS_DIR.resolve()
        for key in ("speaker_recording", "gemini_raw_output"):
            try:
                old_path = _resolve_path(entry["files"][key]["path"]).resolve()
                old_path.relative_to(base)  # ensure inside recordings dir
            except (KeyError, ValueError):
                continue
            if not old_path.exists():
                continue
            suffix = "_raw.wav" if key == "gemini_raw_output" else ".wav"
            new_path = safe_path_under(AUDIO_RECORDINGS_DIR, f"{new_name}{suffix}")
            if new_path.exists():
                raise HTTPException(409, f"File already exists: {new_path.name}")
            old_path.rename(new_path)
            entry["files"][key]["path"] = new_path.name  # basename — portable
            entry["files"][key]["name"] = new_path.name

        entry["record_name"] = new_name
        _save_index(index)
    return {"ok": True, "record": entry}


class RecordDelete(BaseModel):
    record_name: str


@router.post("/delete")
async def delete_record(payload: RecordDelete):
    with _INDEX_LOCK:
        index = _load_index()
        kept = []
        deleted_entry = None
        for r in index.get("records", []):
            if r.get("record_name") == payload.record_name and deleted_entry is None:
                deleted_entry = r
            else:
                kept.append(r)

        if deleted_entry is None:
            raise HTTPException(404, f"Record not found: {payload.record_name}")

        base = AUDIO_RECORDINGS_DIR.resolve()
        deleted_files = []
        for fi in deleted_entry.get("files", {}).values():
            try:
                # _resolve_path handles new-style basenames (resolved under
                # AUDIO_RECORDINGS_DIR) as well as legacy absolute paths.
                # A raw Path(basename) would resolve vs CWD and fall outside
                # base, so the relative_to guard would skip the unlink and the
                # WAV would be orphaned on disk. Mirror play_record/rename_record.
                p = _resolve_path(fi.get("path", "")).resolve()
                p.relative_to(base)  # never delete files outside recordings dir
            except (ValueError, OSError):
                continue
            if p.exists():
                p.unlink()
                deleted_files.append(str(p))

        index["records"] = kept
        _save_index(index)
    return {"ok": True, "deleted": payload.record_name, "deleted_files": deleted_files}


class RecordBulkDelete(BaseModel):
    record_names: list[str] | None = None
    all: bool = False


@router.post("/delete-bulk")
async def delete_bulk(payload: RecordBulkDelete):
    """Delete many records in one call. all=True wipes every record; otherwise
    only those in record_names. Files are unlinked, guarded to the recordings
    dir (same safety as /delete)."""
    names = set(payload.record_names or [])
    with _INDEX_LOCK:
        index = _load_index()
        base = AUDIO_RECORDINGS_DIR.resolve()
        kept: list = []
        removed: list = []
        deleted_files = 0
        for r in index.get("records", []):
            if payload.all or r.get("record_name") in names:
                removed.append(r.get("record_name"))
                for fi in r.get("files", {}).values():
                    try:
                        p = _resolve_path(fi.get("path", "")).resolve()
                        p.relative_to(base)  # never delete outside recordings dir
                    except (ValueError, OSError):
                        continue
                    if p.exists():
                        try:
                            p.unlink()
                            deleted_files += 1
                        except OSError:
                            pass
            else:
                kept.append(r)
        index["records"] = kept
        _save_index(index)
    return {"ok": True, "deleted": removed, "deleted_count": len(removed),
            "deleted_files": deleted_files}
