#!/usr/bin/env python3
"""
Batch-rename MP3 files based on a spoken date in the first 12 seconds.

Dependencies (install via pip):
    pip install faster-whisper pydub audioop-lts   # audioop-lts needed on Python 3.13+

ffmpeg must be installed separately and available on PATH:
    sudo apt install ffmpeg   # Debian/Ubuntu
    brew install ffmpeg       # macOS

Usage:
    python rename_by_date.py
    python rename_by_date.py --dry-run
    python rename_by_date.py --model medium
"""

from __future__ import annotations

import argparse
import re
import subprocess
import sys
import tempfile
import unicodedata
from pathlib import Path

# ---------------------------------------------------------------------------
# Spanish month names -> zero-padded month number
# ---------------------------------------------------------------------------
MONTHS: dict[str, str] = {
    "enero": "01",
    "febrero": "02",
    "marzo": "03",
    "abril": "04",
    "mayo": "05",
    "junio": "06",
    "julio": "07",
    "agosto": "08",
    "septiembre": "09",
    "setiembre": "09",  # alternate spelling
    "octubre": "10",
    "noviembre": "11",
    "diciembre": "12",
}

# Spoken day numbers in Spanish (1-31). Keys are normalized (lowercase, no accents).
DAY_WORDS: dict[str, int] = {
    "uno": 1,
    "un": 1,
    "primero": 1,
    "primera": 1,
    "dos": 2,
    "tres": 3,
    "cuatro": 4,
    "cinco": 5,
    "seis": 6,
    "siete": 7,
    "ocho": 8,
    "nueve": 9,
    "diez": 10,
    "once": 11,
    "doce": 12,
    "trece": 13,
    "catorce": 14,
    "quince": 15,
    "dieciseis": 16,
    "diecisiete": 17,
    "dieciocho": 18,
    "diecinueve": 19,
    "veinte": 20,
    "veintiuno": 21,
    "veintiun": 21,
    "veintidos": 22,
    "veintitres": 23,
    "veinticuatro": 24,
    "veinticinco": 25,
    "veintiseis": 26,
    "veintisiete": 27,
    "veintiocho": 28,
    "veintinueve": 29,
    "treinta": 30,
    "treinta y uno": 31,
    "treinta y un": 31,
}

# Files already named MM-DD.mp3 are skipped to avoid re-processing.
ALREADY_RENAMED_RE = re.compile(r"^\d{2}-\d{2}\.mp3$", re.IGNORECASE)

# Pattern: "<day> de <month>" optionally followed by " de <year>".
# Day group accepts digits or a run of Spanish number words (including "treinta y uno").
_MONTH_ALT = "|".join(re.escape(m) for m in MONTHS)
_DATE_RE = re.compile(
    rf"(?P<day>\d{{1,2}}|[a-záéíóúñü]+(?:\s+y\s+[a-záéíóúñü]+)?)\s+de\s+"
    rf"(?P<month>{_MONTH_ALT})"
    rf"(?:\s+de\s+\d{{4}})?",
    re.IGNORECASE,
)

TRIM_SECONDS = 12


def _normalize(text: str) -> str:
    """Lowercase and strip accents for fuzzy matching."""
    decomposed = unicodedata.normalize("NFD", text.lower())
    return "".join(ch for ch in decomposed if unicodedata.category(ch) != "Mn")


def _parse_day(day_str: str) -> int | None:
    """Convert a day token (digit or Spanish word) to an integer 1-31."""
    day_str = day_str.strip()
    if day_str.isdigit():
        value = int(day_str)
        return value if 1 <= value <= 31 else None

    normalized = _normalize(day_str)
    if normalized in DAY_WORDS:
        return DAY_WORDS[normalized]

    # Handle accented variants not in the static map (e.g. "veintidós").
    for word, num in DAY_WORDS.items():
        if normalized == _normalize(word):
            return num

    return None


def extract_date_from_text(text: str) -> tuple[str, str] | None:
    """
    Find a spoken Spanish date like "doce de febrero" or "12 de febrero de 2024".

    Returns (month, day) as zero-padded strings ("02", "12"), or None.
    """
    if not text:
        return None

    normalized_text = _normalize(text)

    for match in _DATE_RE.finditer(normalized_text):
        month_key = _normalize(match.group("month"))
        month = MONTHS.get(month_key)
        if month is None:
            continue

        day_num = _parse_day(match.group("day"))
        if day_num is None or not (1 <= day_num <= 31):
            continue

        return month, f"{day_num:02d}"

    return None


def load_whisper_model(model_size: str):
    """Load faster-whisper if available, otherwise fall back to openai-whisper."""
    try:
        from faster_whisper import WhisperModel

        print(f"Loading faster-whisper model '{model_size}' …")
        return ("faster", WhisperModel(model_size, device="cpu", compute_type="int8"))
    except ImportError:
        pass

    try:
        import whisper

        print(f"faster-whisper not found; loading openai-whisper '{model_size}' …")
        return ("openai", whisper.load_model(model_size))
    except ImportError as exc:
        raise SystemExit(
            "Neither faster-whisper nor openai-whisper is installed.\n"
            "Run: pip install faster-whisper pydub"
        ) from exc


def transcribe_audio(model_info, audio_path: Path) -> str:
    """Transcribe *audio_path* in Spanish and return the full text."""
    backend, model = model_info

    if backend == "faster":
        segments, _ = model.transcribe(str(audio_path), language="es")
        return " ".join(seg.text.strip() for seg in segments).strip()

    result = model.transcribe(str(audio_path), language="es")
    return result.get("text", "").strip()


def _trim_with_ffmpeg(src: Path, dst: Path, seconds: float) -> None:
    """Trim via ffmpeg CLI (no pydub/audioop needed)."""
    result = subprocess.run(
        [
            "ffmpeg", "-y",
            "-i", str(src),
            "-t", str(seconds),
            "-acodec", "copy",
            str(dst),
        ],
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg failed: {result.stderr.strip()}")


def trim_audio(src: Path, dst: Path, seconds: float = TRIM_SECONDS) -> None:
    """Extract the first *seconds* of *src* into *dst* using pydub or ffmpeg."""
    try:
        from pydub import AudioSegment

        audio = AudioSegment.from_mp3(src)
        trimmed = audio[: int(seconds * 1000)]
        trimmed.export(dst, format="mp3")
    except ImportError:
        # Python 3.13 removed stdlib audioop; pydub needs audioop-lts unless we
        # fall back to invoking ffmpeg directly (still satisfies the ffmpeg requirement).
        _trim_with_ffmpeg(src, dst, seconds)


def unique_target_path(directory: Path, base_name: str) -> Path:
    """
    Return a non-colliding path for *base_name* in *directory*.

    Appends _2, _3, … before the extension when the name already exists.
    """
    candidate = directory / base_name
    if not candidate.exists():
        return candidate

    stem = Path(base_name).stem
    suffix = Path(base_name).suffix
    counter = 2
    while True:
        candidate = directory / f"{stem}_{counter}{suffix}"
        if not candidate.exists():
            return candidate
        counter += 1


def process_file(
    mp3_path: Path,
    model_info,
    dry_run: bool,
) -> None:
    """Trim, transcribe, extract date, and rename a single MP3."""
    original_name = mp3_path.name
    tmp_path: Path | None = None

    try:
        # --- trim first 12 seconds to a temp file ---
        with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as tmp:
            tmp_path = Path(tmp.name)

        trim_audio(mp3_path, tmp_path)

        # --- transcribe ---
        transcription = transcribe_audio(model_info, tmp_path)
        snippet = transcription[:120] + ("…" if len(transcription) > 120 else "")

        # --- extract date ---
        date = extract_date_from_text(transcription)

        if date is None:
            print(
                f"{original_name} -> \"{snippet}\" -> NO DATE FOUND\n"
                f"  (full transcription: {transcription!r})"
            )
            return

        month, day = date
        new_base = f"{month}-{day}.mp3"
        target = unique_target_path(mp3_path.parent, new_base)

        print(
            f"{original_name} -> \"{snippet}\" -> {month}-{day} -> {target.name}"
        )

        if not dry_run:
            mp3_path.rename(target)

    finally:
        if tmp_path is not None and tmp_path.exists():
            tmp_path.unlink()


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Rename MP3 files based on a spoken date in the first 12 seconds.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Show what would be renamed without changing any files.",
    )
    parser.add_argument(
        "--model",
        default="small",
        help="Whisper model size (default: small).",
    )
    args = parser.parse_args()

    cwd = Path.cwd()
    mp3_files = sorted(cwd.glob("*.mp3"))

    if not mp3_files:
        print("No .mp3 files found in the current directory.")
        return

    to_process = [f for f in mp3_files if not ALREADY_RENAMED_RE.match(f.name)]
    skipped = len(mp3_files) - len(to_process)

    if skipped:
        print(f"Skipping {skipped} file(s) already matching MM-DD.mp3 pattern.")

    if not to_process:
        print("Nothing to process.")
        return

    if args.dry_run:
        print("DRY RUN — no files will be renamed.\n")

    model_info = load_whisper_model(args.model)

    for mp3_path in to_process:
        try:
            process_file(mp3_path, model_info, dry_run=args.dry_run)
        except Exception as exc:  # noqa: BLE001 — continue batch on any failure
            print(f"ERROR processing {mp3_path.name}: {exc}", file=sys.stderr)


if __name__ == "__main__":
    main()
