"""Voice meta helpers, file utilities, and benchmark helpers."""
from __future__ import annotations

import json
import re
import shutil
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path

import requests
from fastapi import HTTPException

from core.constants import (
    _VOICES_DIR_DEFAULT, _OUTPUT_DIR_DEFAULT,
    _VOICE_TARGET_DBFS, _VOICE_PEAK_DBFS,
    _BENCHMARK_SENTENCES, _MAX_TTS_OUTPUT_SECONDS,
)
from core.audio import _duration, _export_normalized_wav

# ── File type constants ───────────────────────────────────────────────────────

_PICTURE_EXTS = [".jpg", ".jpeg", ".png", ".webp"]
_SOUND_ASSET_DIRS = {"sound", "sounds", "sfx", "effects", "sound_effects", "route_sounds"}
_SOUND_ASSET_PREFIXES = ("computerbeep", "beep", "ding", "chime", "notification")
_AUDIO_EXTS   = [".wav", ".mp3", ".m4a", ".flac", ".ogg", ".opus"]
_UPLOAD_EXTS  = _AUDIO_EXTS + [".mp4", ".mkv", ".webm", ".mov", ".avi"]
_AUDIO_MIME   = {
    ".wav":  "audio/wav",
    ".mp3":  "audio/mpeg",
    ".m4a":  "audio/mp4",
    ".flac": "audio/flac",
    ".ogg":  "audio/ogg",
    ".opus": "audio/ogg",
}

# Default flag (ISO country code) for each language code
_LANG_FLAG_DEFAULT = {
    "EN": "GB", "DE": "DE", "ZH": "CN", "FR": "FR", "ES": "ES",
    "JA": "JP", "KO": "KR", "IT": "IT", "PT": "BR", "RU": "RU",
    "AR": "SA", "PL": "PL", "NL": "NL", "SV": "SE", "TR": "TR", "HI": "IN",
}


# ── Voice audio discovery helpers ─────────────────────────────────────────────

def _is_internal_voice_file(path: Path) -> bool:
    stem = path.stem.lower()
    name = path.name.lower()
    return (
        stem.endswith(".original")
        or ".normalized.tmp" in stem
        or name.startswith(".")
        or name.endswith(".bak")
    )


def _is_sound_asset_file(path: Path) -> bool:
    stem = path.stem.lower()
    parent_names = {part.lower() for part in path.parts}
    return (
        any(part in _SOUND_ASSET_DIRS for part in parent_names)
        or stem.startswith(_SOUND_ASSET_PREFIXES)
    )


_AUDIO_EXTS_SET = frozenset(_AUDIO_EXTS)

def _voice_audio_files(root: Path):
    # Single rglob pass instead of one per extension (6× faster directory scan)
    for p in root.rglob("*"):
        if p.suffix.lower() in _AUDIO_EXTS_SET and not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
            yield p


def _find_voice_audio(voice_id: str, scan_dir: Path) -> Path | None:
    for ext in _AUDIO_EXTS:
        for p in sorted(scan_dir.rglob(f"{voice_id}{ext}")):
            if not _is_internal_voice_file(p) and not _is_sound_asset_file(p):
                return p
    return None


# ── Voice meta helpers ────────────────────────────────────────────────────────

def _meta_path(wav: Path) -> Path:
    return wav.with_suffix(".meta.json")


def _load_meta(wav: Path) -> dict:
    mp = _meta_path(wav)
    if mp.exists():
        try:
            return json.loads(mp.read_text())
        except Exception:
            pass
    # Auto-detect flag and gender from voice_id
    parts = wav.stem.split("_", 2)
    lang   = parts[0].upper() if parts else ""
    gender = parts[1].upper() if len(parts) >= 2 and parts[1].upper() in ("F", "M", "N") else ""
    return {
        "note":    "",
        "rating":  0,
        "flag":    _LANG_FLAG_DEFAULT.get(lang, ""),
        "gender":  gender,
        "enabled": True,
    }


def _save_meta(wav: Path, meta: dict) -> None:
    _meta_path(wav).write_text(json.dumps(meta, indent=2))


def _picture_path(wav: Path) -> Path | None:
    for ext in _PICTURE_EXTS:
        p = wav.with_suffix(ext)
        if p.exists():
            return p
    return None


def _picture_mime(path: Path) -> str:
    return {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
            ".png": "image/png", ".webp": "image/webp"}.get(path.suffix.lower(), "image/jpeg")


def _hidden_voices_dir(settings: dict) -> Path:
    return Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) / "hidden_voices"


def _active_voices_dir(settings: dict) -> Path:
    return Path(settings.get("output_dir", _OUTPUT_DIR_DEFAULT))


def _backup_path(audio: Path) -> Path:
    return audio.with_name(f".{audio.stem}.original{audio.suffix}.bak")


def _legacy_backup_path(audio: Path) -> Path:
    return audio.with_name(f"{audio.stem}.original{audio.suffix}")


def _backup_candidates(audio: Path, meta: dict | None = None) -> list[Path]:
    candidates = [_backup_path(audio), _legacy_backup_path(audio)]
    if meta and meta.get("original_backup"):
        candidates.insert(0, Path(str(meta["original_backup"])))

    seen: set[Path] = set()
    unique = []
    for candidate in candidates:
        try:
            key = candidate.resolve() if candidate.exists() else candidate
        except Exception:
            key = candidate
        if key not in seen:
            seen.add(key)
            unique.append(candidate)
    return unique


def _backup_audio_suffix(backup: Path, voice_id: str) -> str | None:
    name = backup.name
    for ext in _AUDIO_EXTS:
        if name == f".{voice_id}.original{ext}.bak" or name == f"{voice_id}.original{ext}":
            return ext
    return None


def _remove_audio_variants(parent: Path, voice_id: str, keep: Path | None = None) -> None:
    keep_resolved = keep.resolve() if keep and keep.exists() else None
    for ext in _AUDIO_EXTS:
        p = parent / f"{voice_id}{ext}"
        if p.exists() and not _is_internal_voice_file(p) and (keep_resolved is None or p.resolve() != keep_resolved):
            p.unlink()


def _voice_package_paths(audio: Path) -> list[Path]:
    parent = audio.parent
    paths = []
    for sfx in _AUDIO_EXTS + [".reference.txt", ".meta.json"] + _PICTURE_EXTS:
        p = parent / f"{audio.stem}{sfx}"
        if p.exists():
            paths.append(p)
    paths.extend(
        p for p in _backup_candidates(audio, _load_meta(audio))
        if p.exists() and p.parent.resolve() == parent.resolve()
    )
    return paths


def _remove_voice_package(audio: Path, keep: set[Path] | None = None) -> None:
    keep_resolved = {p.resolve() for p in (keep or set()) if p.exists()}
    for p in _voice_package_paths(audio):
        if p.exists() and p.resolve() not in keep_resolved:
            p.unlink()


def _backup_original_voice(audio: Path) -> Path | None:
    if not audio.exists() or audio.suffix.lower() not in _AUDIO_EXTS:
        return None
    backup = _backup_path(audio)
    if not backup.exists():
        legacy = _legacy_backup_path(audio)
        if legacy.exists():
            shutil.move(str(legacy), str(backup))
        else:
            shutil.copy2(str(audio), str(backup))
    return backup


def _voice_audio_from_request(data: dict, scan_dir: Path) -> Path | None:
    from core.validation import _safe_child_path
    requested_path = data.get("path")
    if requested_path:
        p = _safe_child_path(scan_dir, Path(requested_path))
        if not p.exists() or not p.is_file() or p.suffix.lower() not in _AUDIO_EXTS:
            raise HTTPException(404, "Voice file not found")
        return p

    voice_id = data.get("voice_id", "")
    if not voice_id:
        raise HTTPException(400, "voice_id or path is required")
    return _find_voice_audio(voice_id, scan_dir)


def _move_voice_package(audio: Path, target_dir: Path) -> Path:
    if audio.parent.resolve() == target_dir.resolve():
        return audio

    target_dir.mkdir(parents=True, exist_ok=True)
    paths = _voice_package_paths(audio)
    for src in paths:
        dest = target_dir / src.name
        if dest.exists() and dest.resolve() != src.resolve():
            raise HTTPException(409, f"Target file already exists: {dest}")

    moved_audio = target_dir / audio.name
    for src in paths:
        dest = target_dir / src.name
        if dest.resolve() != src.resolve():
            shutil.move(str(src), str(dest))
    return moved_audio


def _read_reference_text(audio: Path) -> tuple[bool, str]:
    ref = audio.with_suffix(".reference.txt")
    if not ref.exists():
        return False, ""
    return True, ref.read_text(encoding="utf-8").strip()


# ── Voice health and entry ────────────────────────────────────────────────────

def _voice_health(
    p: Path,
    *,
    meta: dict | None = None,
    duration: float | None = None,
    transcript: str | None = None,
) -> dict:
    if transcript is None:
        _has_ref, transcript = _read_reference_text(p)
    if duration is None:
        try:
            duration = float(_duration(p))
        except Exception:
            duration = 0.0
    else:
        duration = float(duration)
    if meta is None:
        meta = _load_meta(p)
    words = re.findall(r"\b[\w'-]+\b", transcript, flags=re.UNICODE)
    word_count = len(words)
    words_per_sec = (word_count / duration) if duration > 0 else 0.0
    warnings: list[str] = []
    if not transcript:
        warnings.append("Missing reference transcript")
    if duration > 25:
        warnings.append("Reference audio is longer than the recommended 10-20 seconds")
    if duration > 0 and word_count and words_per_sec > 4.5:
        warnings.append("Reference transcript is too long for the audio; re-transcribe or shorten it")
    from core.audio import _loudness_info as _li
    loudness = meta.get("loudness", {})
    peak = loudness.get("peak_dbfs")
    if peak is not None and float(peak) > -0.1:
        warnings.append("Reference audio is clipping or too loud")
    return {
        "ok": not warnings,
        "warnings": warnings,
        "duration": round(duration, 2) if duration else None,
        "word_count": word_count,
        "words_per_sec": round(words_per_sec, 2) if words_per_sec else 0,
        "loudness": loudness,
    }


def _voice_entry(p: Path) -> dict:
    meta = _load_meta(p)
    parts = p.stem.split("_", 2)
    lang = parts[0].upper() if parts else ""
    try:
        duration = round(_duration(p), 2)
    except Exception:
        duration = None
    has_ref, transcript = _read_reference_text(p)
    loudness = meta.get("loudness", {})
    benchmark = meta.get("benchmark", {})
    if not meta.get("flag") and lang in _LANG_FLAG_DEFAULT:
        meta["flag"] = _LANG_FLAG_DEFAULT[lang]
    health = _voice_health(p, meta=meta, duration=duration, transcript=transcript)
    return {
        "id":          p.stem,
        "path":        str(p),
        "file_type":   p.suffix.lower().lstrip("."),
        "duration":    duration,
        "loudness":    loudness,
        "benchmark":   benchmark,
        "has_ref":     has_ref,
        "transcript":  transcript,
        "has_picture": _picture_path(p) is not None,
        "health":      health,
        "lang":        lang,
        **meta,
    }


# ── Benchmark helpers ─────────────────────────────────────────────────────────

def _benchmark_advice(audio: Path, elapsed_sec: float | None, audio_sec: float | None,
                      clipped: bool = False, error: str = "") -> tuple[bool, list[str]]:
    advice: list[str] = []
    try:
        ref_duration = float(_duration(audio))
    except Exception:
        ref_duration = 0.0

    name = audio.stem.lower()
    if ref_duration >= 40 or "privat_" in name or "privat-" in name:
        advice.append("Avoid for real-time assistants; long clone samples often benchmark slowly. Trim or remake as a 10-20 second voice.")
    elif ref_duration > 25:
        advice.append("Reference is longer than recommended. Trim to a clean 10-20 second sample.")
    if clipped:
        advice.append("Generated output hit the max-duration guard. Re-transcribe the reference exactly or remake this clone.")
    if elapsed_sec is not None and elapsed_sec > 12:
        advice.append("Slow synthesis. Prefer a shorter optimized voice for Open WebUI or Home Assistant.")
    if audio_sec and elapsed_sec:
        rtf = elapsed_sec / max(audio_sec, 0.01)
        if rtf > 2.0:
            advice.append("High real-time factor. Use a shorter reference, normalize volume, and remove silence/noise.")
    if error:
        advice.append("Benchmark failed. Check that Qwen3-TTS has rescanned this voice and that the reference files are valid.")

    realtime_ok = not error and not clipped and (elapsed_sec or 999) <= 8 and ref_duration <= 25
    return realtime_ok, advice


def _benchmark_summary(runs: list[dict]) -> dict:
    ok_runs = [r for r in runs if r.get("ok")]
    if not ok_runs:
        return {}

    def avg(key: str) -> float | None:
        vals = [float(r[key]) for r in ok_runs if r.get(key) is not None]
        return round(sum(vals) / len(vals), 3) if vals else None

    return {
        "avg_ttfa_ms": avg("ttfa_ms"),
        "avg_total_sec": avg("total_sec"),
        "avg_audio_sec": avg("audio_sec"),
        "avg_rtf": avg("rtf"),
        "avg_speed": avg("speed"),
    }


def _benchmark_voice(audio: Path, settings: dict, sentences: list[tuple[str, str]]) -> dict:
    from core.tts_helpers import _tts_benchmark_request
    runs: list[dict] = []
    for label, text in sentences:
        try:
            runs.append(_tts_benchmark_request(text, audio.stem, settings, label))
        except Exception as e:
            runs.append({"ok": False, "label": label, "text": text, "error": str(e)})

    ok_runs = [r for r in runs if r.get("ok")]
    summary = _benchmark_summary(runs)
    elapsed = summary.get("avg_total_sec") if summary else None
    audio_sec = summary.get("avg_audio_sec") if summary else None
    errors = [r.get("error", "Benchmark failed") for r in runs if not r.get("ok")]
    realtime_ok, advice = _benchmark_advice(audio, elapsed, audio_sec, error="; ".join(errors))
    if summary.get("avg_rtf") is not None and summary["avg_rtf"] > 2:
        advice.append("RTF is above 2.0. This voice is likely too slow for real-time assistants.")

    return {
        "ok": bool(ok_runs) and not errors,
        "realtime_ok": realtime_ok and not errors,
        "elapsed_sec": round(elapsed, 2) if elapsed is not None else None,
        "audio_sec": round(audio_sec, 2) if audio_sec is not None else None,
        "rtf": round(summary.get("avg_rtf"), 2) if summary.get("avg_rtf") is not None else None,
        "speed": round(summary.get("avg_speed"), 2) if summary.get("avg_speed") is not None else None,
        "ttfa_ms": round(summary.get("avg_ttfa_ms"), 0) if summary.get("avg_ttfa_ms") is not None else None,
        "bytes": sum(int(r.get("bytes") or 0) for r in ok_runs),
        "clipped": False,
        "benchmarked_at": datetime.now(timezone.utc).isoformat(),
        "text": sentences[0][1] if len(sentences) == 1 else "short / medium / long",
        "runs": runs,
        "summary": summary,
        "advice": advice,
        **({"error": "; ".join(errors)} if errors else {}),
    }
