"""Audio waveform extraction endpoint.

Extracts audio from video files via ffmpeg and returns min/max peak pairs
for rendering waveform visualizations. Results are cached as hidden JSON
files alongside the video to avoid re-extraction.
"""

from __future__ import annotations

import json
import logging
import subprocess
from pathlib import Path

from fastapi import APIRouter, HTTPException, Query

from sltk.api.dependencies import safe_error_detail, validate_path
from sltk.api.models import WaveformResponse

router = APIRouter(prefix="/api/audio", tags=["audio"])
logger = logging.getLogger(__name__)

VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
DEFAULT_SAMPLE_RATE = 16000
DEFAULT_NUM_PEAKS = 8000


def _cache_path(video_path: Path) -> Path:
    """Return the hidden JSON cache path for a video's waveform data."""
    return video_path.parent / f".{video_path.stem}.waveform.json"


def _is_cache_valid(cache: Path, video: Path) -> bool:
    """Check if cache exists and is at least as new as the video."""
    if not cache.exists():
        return False
    return cache.stat().st_mtime >= video.stat().st_mtime


def _extract_audio_raw(video_path: Path) -> tuple[bytes, float]:
    """Extract mono 16kHz f32le audio from a video via ffmpeg.

    Returns:
        Tuple of (raw_bytes, duration_seconds).

    Raises:
        HTTPException: If ffmpeg fails or produces no audio.
    """
    # First get duration via ffprobe
    probe_cmd = [
        "ffprobe",
        "-v",
        "error",
        "-show_entries",
        "format=duration",
        "-of",
        "default=noprint_wrappers=1:nokey=1",
        str(video_path),
    ]
    try:
        probe_result = subprocess.run(
            probe_cmd,
            capture_output=True,
            timeout=30,
        )
        duration = float(probe_result.stdout.decode().strip())
    except (subprocess.TimeoutExpired, ValueError, OSError):
        duration = 0.0

    # Extract raw audio
    cmd = [
        "ffmpeg",
        "-i",
        str(video_path),
        "-f",
        "f32le",
        "-acodec",
        "pcm_f32le",
        "-ac",
        "1",
        "-ar",
        str(DEFAULT_SAMPLE_RATE),
        "-v",
        "error",
        "-",
    ]
    try:
        result = subprocess.run(
            cmd,
            capture_output=True,
            timeout=120,
        )
    except subprocess.TimeoutExpired:
        raise HTTPException(500, "Audio extraction timed out")
    except OSError as exc:
        raise HTTPException(500, safe_error_detail("ffmpeg execution failed", exc))

    if result.returncode != 0:
        stderr = result.stderr.decode(errors="replace")[:200]
        logger.error("ffmpeg failed (rc=%d): %s", result.returncode, stderr)
        # Detect "no audio stream" errors
        stderr_lower = stderr.lower()
        if "does not contain any stream" in stderr_lower or "no such file" in stderr_lower:
            raise HTTPException(400, "No audio track found in video")
        raise HTTPException(500, "Audio extraction failed")

    raw = result.stdout
    if not raw:
        raise HTTPException(400, "No audio track found in video")

    # Compute duration from sample count if ffprobe failed
    if duration <= 0:
        num_floats = len(raw) // 4
        duration = num_floats / DEFAULT_SAMPLE_RATE if num_floats > 0 else 0.0

    return raw, duration


def _compute_peaks(raw_bytes: bytes, num_peaks: int) -> list[list[float]]:
    """Compute min/max peak pairs from raw f32le audio bytes.

    Divides the audio into `num_peaks` windows and returns the
    [min, max] amplitude for each window.
    """
    import numpy as np

    samples = np.frombuffer(raw_bytes, dtype=np.float32)
    total = len(samples)

    if total == 0:
        return []

    # Clamp num_peaks to be at most total samples
    num_peaks = min(num_peaks, total)
    if num_peaks <= 0:
        return []

    window = total / num_peaks
    peaks: list[list[float]] = []
    for i in range(num_peaks):
        start = int(i * window)
        end = int((i + 1) * window)
        end = min(end, total)
        if start >= end:
            peaks.append([0.0, 0.0])
            continue
        chunk = samples[start:end]
        peaks.append([round(float(chunk.min()), 5), round(float(chunk.max()), 5)])

    return peaks


@router.get("/waveform", response_model=WaveformResponse)
async def get_waveform(
    path: str = Query(..., description="Path to video file"),
    samples: int = Query(DEFAULT_NUM_PEAKS, ge=100, le=50000, description="Number of peak pairs"),
) -> WaveformResponse:
    """Extract audio waveform peaks from a video file.

    Returns min/max peak pairs suitable for rendering a waveform display.
    Results are cached as a hidden JSON file next to the video.
    """
    import asyncio

    video_path = validate_path(path, must_exist=True)

    # Validate it looks like a video file
    if video_path.suffix.lower() not in VIDEO_EXTENSIONS:
        raise HTTPException(
            400,
            f"Not a supported video file (got {video_path.suffix})",
        )

    cache = _cache_path(video_path)

    # Check cache
    if _is_cache_valid(cache, video_path):
        try:
            data = await asyncio.to_thread(_read_cache, cache, samples)
            if data is not None:
                return data
        except Exception:
            logger.debug("Cache read failed, will re-extract", exc_info=True)

    # Extract audio in thread pool to avoid blocking the event loop
    raw, duration = await asyncio.to_thread(_extract_audio_raw, video_path)
    peaks = await asyncio.to_thread(_compute_peaks, raw, samples)

    response = WaveformResponse(
        peaks=peaks,
        duration=round(duration, 4),
        sample_rate=DEFAULT_SAMPLE_RATE,
        num_samples=len(peaks),
    )

    # Write cache (fire-and-forget, don't fail if write fails)
    try:
        await asyncio.to_thread(_write_cache, cache, response, samples)
    except Exception:
        logger.debug("Cache write failed", exc_info=True)

    return response


def _read_cache(cache: Path, requested_samples: int) -> WaveformResponse | None:
    """Read cached waveform data. Returns None if samples count doesn't match."""
    with open(cache) as f:
        data = json.load(f)
    # Only use cache if sample count matches
    if data.get("num_samples") != requested_samples:
        return None
    return WaveformResponse(**data)


def _write_cache(cache: Path, response: WaveformResponse, samples: int) -> None:
    """Write waveform data to cache file."""
    data = response.model_dump()
    # Write atomically via temp file to prevent partial reads
    tmp = cache.with_suffix(".tmp")
    try:
        with open(tmp, "w") as f:
            json.dump(data, f, separators=(",", ":"))
        tmp.rename(cache)
    except Exception:
        # Clean up temp file on failure
        try:
            tmp.unlink(missing_ok=True)
        except OSError:
            pass
        raise
