"""Personal soundtrack library organized by mood (animada, triste, tensa...).

The person keeps ready tracks per feeling and, while editing, just says "trilha triste" (or picks the mood in
the preview): nothing is generated again.

Where it lives: OUTSIDE the skill folder, so it survives reinstalls and never ships with the product.
  macOS / Linux: ~/.editor-video/trilhas        Windows: %USERPROFILE%\\.editor-video\\trilhas
  EDITOR_VIDEO_TRILHAS overrides it (the tests point it to a temporary folder). A library inside a skill
  folder is refused (an install would delete it and a package would carry it).

Layout: one subfolder per mood id (triste/triste-1.wav ...), `_lixeira/` for removed tracks (nothing is ever
hard-deleted), `_recebidas/` for generated files waiting to enter, and the index `trilhas.json`:
  {"version": 1,
   "moods": {id: {"label", "when", "created_at"}},          custom moods only (presets: trilhas_humores.json)
   "tracks": [{"id", "mood", "file" (relative to the library), "title", "duration_s", "integrated_lufs",
               "energy", "full_at_s", "voice_band_share", "source", "license", "prompt", "added_at",
               "favorite", "original_name", "format", "sha256" (of the stored file)}],
   "lixeira": [same entries + "removed_at"],
   "ignoradas": [hand-edited entries without a text id, mood and file, kept aside instead of dropped]}
A "file" must be a plain relative path inside the library (library_file): anything else counts as missing.
Lossy files (mp3, m4a, aac, ogg, opus) are copied byte for byte (no second lossy generation); wav, flac,
aiff and the sound of a video are converted to 48 kHz stereo WAV (16-bit when the source is 16-bit, else
24-bit).

Measurements: integrated loudness (ffmpeg loudnorm, the same reading compose_final uses), duration, an energy
note from 0.5 s RMS windows ("cheia desde o início" when the level is within 4 dB of the track's 90th
percentile in the first second, else "começa baixa e cresce aos N s"; "; cai aos N s" for a dip of 12 dB or
more lasting 1.5 s before the last 3 s) and the voice-band share (energy between 300 and 3400 Hz over the
energy between 20 Hz and 12 kHz, mono at 24 kHz).

Choosing (`escolher` and compose_final's music.mood): the favorite of the mood; else the track with the lowest
score = 3 * uncovered share of (cut + 1.5 s) + late start share + voice-band share (ties: closest length to
cut + 2 s, then id). The choice is copied to <EDIT>/assets/trilha_<mood>.<ext> (never over a file this helper
did not write, checked by content) and recorded in <EDIT>/assets/trilhas_escolhidas.json, so the next render
of the same edit uses the same file even if the library changed or is gone. `escolher` always chooses again;
`--trocar` skips the track the edit has now; `--trilha ID` picks one.

Usage:
    uv run python helpers/trilhas.py onde
    uv run python helpers/trilhas.py humores [--json]
    uv run python helpers/trilhas.py listar [--humor ID] [--json]
    uv run python helpers/trilhas.py adicionar ARQUIVO --humor ID [--titulo T] [--favorita] [--fonte F]
        [--prompt P] [--licenca L] [--novo-humor] [--quando TEXTO]
    uv run python helpers/trilhas.py favorita ID [--tirar]
    uv run python helpers/trilhas.py remover ID
    uv run python helpers/trilhas.py restaurar ID
    uv run python helpers/trilhas.py escolher --humor ID --edit-dir EDIT [--duracao S] [--trilha ID] [--trocar]
"""

from __future__ import annotations

import argparse
import difflib
import hashlib
import json
import math
import os
import re
import shutil
import subprocess
import sys
import unicodedata
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from common import HELPERS_DIR, SKILL_ROOT, ffprobe_json, load_json, now_iso, save_json  # noqa: E402

LIBRARY_ENV = "EDITOR_VIDEO_TRILHAS"
INDEX_NAME = "trilhas.json"
TRASH_DIR = "_lixeira"
INBOX_DIR = "_recebidas"
PRESETS_PATH = HELPERS_DIR / "trilhas_humores.json"
LOCK_NAME = "trilhas_escolhidas.json"            # in <EDIT>/assets
LOSSY_KEEP = (".mp3", ".m4a", ".aac", ".ogg", ".opus")
LOSSLESS_CONVERT = (".wav", ".wave", ".flac", ".aif", ".aiff")
VIDEO_CONVERT = (".mp4", ".mov", ".m4v", ".mkv", ".webm")
AUDIO_EXT = LOSSY_KEEP + LOSSLESS_CONVERT
SOURCE_USER = "arquivo do usuário"
LICENSE_USER = "arquivo da pessoa: usar só com direito de uso (música própria ou licença comercial)"
MIN_SECONDS = 1.0
SR_MEASURE = 24000
WINDOW_S = 0.5
FULL_WITHIN_DB = 4.0
DIP_DB = 12.0
DIP_MIN_S = 1.5
TAIL_IGNORED_S = 3.0
VOICE_BAND = (300.0, 3400.0)
FULL_BAND = (20.0, 12000.0)
FADE_ROOM_S = 1.5                                 # compose fades the last 1.5 s: a track should cover cut + this
MOOD_ID = re.compile(r"^[a-z0-9][a-z0-9_]{0,39}$")
RESERVED_MOODS = ("off", "arquivo", "sem", "nenhum", "nenhuma")   # choices of the preview's Trilha control


class TrilhaError(Exception):
    def __init__(self, message: str, code: int = 2):
        super().__init__(message)
        self.code = code


# ------------------------------------------------------------------ text helpers

def slug(text) -> str:
    """'Épica' -> 'epica', 'Motivacional leve' -> 'motivacional_leve' (mood ids)."""
    t = unicodedata.normalize("NFKD", str(text or "")).encode("ascii", "ignore").decode("ascii").lower()
    return re.sub(r"[^a-z0-9]+", "_", t).strip("_")[:40].strip("_")


def num(value: float, digits: int = 1) -> str:
    """-14.25 -> '-14,2' (PT-BR decimal comma)."""
    text = f"{value:.{digits}f}"
    if "." in text:
        text = text.rstrip("0").rstrip(".")
    return text.replace(".", ",")


def dur(seconds: float) -> str:
    """64.3 -> '1:04'."""
    s = int(round(max(0.0, float(seconds))))
    return f"{s // 60}:{s % 60:02d}"


def skill_cmd(helper: str, args: str) -> str:
    return f"uv run --directory '{SKILL_ROOT}' python helpers/{helper} {args}"


# ------------------------------------------------------------------ location

def default_library() -> Path:
    return Path.home() / ".editor-video" / "trilhas"


def library_dir() -> Path:
    raw = (os.environ.get(LIBRARY_ENV) or "").strip()
    return Path(raw).expanduser().absolute() if raw else default_library()


def _looks_like_skill(folder: Path) -> bool:
    try:
        md = folder / "SKILL.md"
        return md.is_file() and "name: editor-video" in md.read_text(encoding="utf-8", errors="replace")[:600]
    except OSError:
        return False


def check_location(lib: Path | None = None) -> Path:
    """The library folder, refused when it sits inside a skill folder (an install would delete it)."""
    lib = Path(lib).expanduser().absolute() if lib else library_dir()
    try:
        resolved = lib.resolve()
    except OSError:
        resolved = lib
    skill = SKILL_ROOT.resolve()
    for folder in (resolved, *resolved.parents):
        if folder == skill or _looks_like_skill(folder):
            raise TrilhaError(f"a biblioteca de trilhas não pode ficar dentro da pasta de uma skill ({folder}): a "
                              "próxima instalação apagaria as músicas e o pacote da skill levaria junto. Use a pasta "
                              f"padrão ({default_library()}) ou aponte {LIBRARY_ENV} para uma pasta fora da skill.")
    return lib


# ------------------------------------------------------------------ presets and index

def load_presets() -> dict:
    try:
        data = load_json(PRESETS_PATH) or {}
    except (OSError, ValueError) as e:
        raise TrilhaError(f"não consegui ler {PRESETS_PATH.name} da skill ({e.__class__.__name__}). "
                          "Instale a skill de novo.", 1)
    return data if isinstance(data, dict) else {}


def preset_moods() -> list[dict]:
    return [m for m in (load_presets().get("moods") or []) if isinstance(m, dict) and m.get("id")]


def empty_index() -> dict:
    return {"version": 1, "moods": {}, "tracks": [], "lixeira": []}


def fill_hint(mood_id: str) -> str:
    return (f"Para pôr música no humor {mood_id}: (1) um arquivo da pessoa, com direito de uso: "
            f"{skill_cmd('trilhas.py', f'adicionar ARQUIVO --humor {mood_id}')} (2) ou uma gerada com IA na conta "
            f"dela, paga e só com o ok dela: {skill_cmd('gen_music.py', f'--humor {mood_id} --biblioteca --dry-run')}")


def missing_library_message(lib: Path, mood_id: str = "triste") -> str:
    return (f"a biblioteca de trilhas ainda não existe ({lib}). Ela é criada quando a primeira música entra. "
            f"{fill_hint(mood_id)}. Outra saída: usar um arquivo direto no edit_data.json "
            "(\"music\": {\"enabled\": true, \"file\": \"assets/minha_musica.mp3\"}).")


def load_index(lib: Path, required: bool = True) -> dict | None:
    path = lib / INDEX_NAME
    if not path.is_file():
        if required:
            raise TrilhaError(missing_library_message(lib))
        return None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as e:
        raise TrilhaError(f"não consegui ler o índice da biblioteca ({path}): {e.__class__.__name__}. Nada foi "
                          "mudado. Confira o arquivo antes de seguir.")
    if not isinstance(data, dict) or not isinstance(data.get("tracks", []), list) \
            or not isinstance(data.get("moods", {}), dict) or not isinstance(data.get("lixeira", []), list):
        raise TrilhaError(f"o índice da biblioteca ({path}) não tem o formato esperado. Nada foi mudado.")
    for key, value in empty_index().items():
        data.setdefault(key, value)
    # entries edited by hand: unusable ones are kept aside under "ignoradas" (never lost), numbers are checked
    ignored = data["ignoradas"] if isinstance(data.get("ignoradas"), list) else []
    for key in ("tracks", "lixeira"):
        good = []
        for t in data[key]:
            if _usable_entry(t):
                good.append(_clean_entry(t))
            else:
                ignored.append(t)
        data[key] = good
    if ignored:
        data["ignoradas"] = ignored
    return data


TRACK_NUMBERS = ("duration_s", "full_at_s", "voice_band_share", "integrated_lufs")


def _usable_entry(t) -> bool:
    return isinstance(t, dict) and all(isinstance(t.get(k), str) and t[k].strip() for k in ("id", "mood", "file"))


def _number(value) -> float | None:
    if isinstance(value, bool):
        return None
    try:
        value = float(value)
    except (TypeError, ValueError):
        return None
    return value if math.isfinite(value) else None


def _clean_entry(t: dict) -> dict:
    for key in TRACK_NUMBERS:
        if key in t:
            t[key] = _number(t[key])
    t["favorite"] = t.get("favorite") is True
    if not isinstance(t.get("title"), str) or not t["title"].strip():
        t["title"] = t["id"]
    return t


def library_file(lib: Path, rel) -> Path | None:
    """<lib>/<rel> for a file named in the index; None unless rel is a plain relative path inside the library
    (a hand-edited index must never make remover or escolher touch a file outside it)."""
    if not isinstance(rel, str) or not rel.strip() or "\x00" in rel:
        return None
    parts = rel.replace("\\", "/").split("/")
    if rel.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", rel) or any(p in ("", ".", "..") for p in parts):
        return None
    return lib.joinpath(*parts)


def _has_file(lib: Path, t: dict) -> bool:
    path = library_file(lib, t.get("file"))
    return path is not None and path.is_file()


def save_index(lib: Path, index: dict) -> None:
    try:
        save_json(lib / INDEX_NAME, index)
    except OSError as e:
        raise TrilhaError(f"não consegui gravar o índice da biblioteca em {lib} ({e.__class__.__name__}). "
                          "Confira o espaço e a permissão da pasta.", 1)


def all_moods(index: dict | None) -> list[dict]:
    out, seen = [], set()
    for m in preset_moods():
        out.append({"id": m["id"], "label": m.get("label") or m["id"], "when": m.get("when") or "",
                    "prompt": m.get("prompt"), "custom": False})
        seen.add(m["id"])
    for mid, info in sorted(((index or {}).get("moods") or {}).items()):
        if mid in seen or not MOOD_ID.match(str(mid)) or mid in RESERVED_MOODS:
            continue
        info = info if isinstance(info, dict) else {}
        text = {k: (info.get(k) if isinstance(info.get(k), str) else "") for k in ("label", "when", "prompt")}
        out.append({"id": mid, "label": text["label"].strip() or mid, "when": text["when"],
                    "prompt": text["prompt"] or None, "custom": True})
        seen.add(mid)
    for t in (index or {}).get("tracks") or []:
        mid = t.get("mood") if isinstance(t, dict) else None
        if isinstance(mid, str) and mid not in seen and MOOD_ID.match(mid) and mid not in RESERVED_MOODS:
            # a mood the "moods" list lost
            out.append({"id": mid, "label": mid, "when": "", "prompt": None, "custom": True})
            seen.add(mid)
    return out


def find_mood(index: dict | None, text) -> dict | None:
    key = slug(text)
    if not key:
        return None
    for m in all_moods(index):
        if key in (m["id"], slug(m["label"])):
            return m
    return None


def unknown_mood_message(text, index: dict | None) -> str:
    ids = ", ".join(m["id"] for m in all_moods(index))
    return f"o humor '{text}' não existe. Humores: {ids}. Lista completa: {skill_cmd('trilhas.py', 'humores')}"


def mood_tracks(lib: Path, index: dict, mood_id: str, existing_only: bool = True) -> list[dict]:
    tracks = [t for t in index.get("tracks", []) if t.get("mood") == mood_id]
    if existing_only:
        tracks = [t for t in tracks if _has_file(lib, t)]
    return sorted(tracks, key=lambda t: (not t.get("favorite"), str(t.get("added_at", "")), str(t["id"])))


def find_track(index: dict, track_id: str) -> dict | None:
    return next((t for t in index.get("tracks", []) if t.get("id") == track_id), None)


def next_id(lib: Path, index: dict, mood_id: str) -> str:
    used = [0]
    for t in list(index.get("tracks", [])) + list(index.get("lixeira", [])):
        m = re.fullmatch(re.escape(mood_id) + r"-(\d+)", str(t.get("id", "")))
        if m:
            used.append(int(m.group(1)))
    n = max(used) + 1
    while list((lib / mood_id).glob(f"{mood_id}-{n}.*")):
        n += 1
    return f"{mood_id}-{n}"


# ------------------------------------------------------------------ measuring

def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with open(str(path), "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def need_ffmpeg() -> None:
    for tool in ("ffmpeg", "ffprobe"):
        if not shutil.which(tool):
            raise TrilhaError(f"'{tool}' não encontrado. Mac: brew install ffmpeg · Windows: winget install Gyan.FFmpeg", 1)


def probe_audio(path: Path, name: str | None = None) -> dict:
    name = name or path.name
    try:
        info = ffprobe_json(path)
    except (RuntimeError, ValueError, OSError):
        raise TrilhaError(f"não consegui abrir {name} como áudio (arquivo corrompido ou de outro tipo).")
    audio = next((s for s in info.get("streams", []) if s.get("codec_type") == "audio"), None)
    if audio is None:
        raise TrilhaError(f"{name} não tem som.")
    try:
        seconds = float(audio.get("duration") or (info.get("format") or {}).get("duration") or 0.0)
    except (TypeError, ValueError):
        seconds = 0.0
    return {"duration": seconds, "codec": audio.get("codec_name") or "", "sample_fmt": audio.get("sample_fmt") or "",
            "sample_rate": int(audio.get("sample_rate") or 0), "channels": int(audio.get("channels") or 0)}


def integrated_lufs(path: Path) -> float | None:
    proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "info", "-i", str(path), "-vn", "-af",
                           "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"],
                          capture_output=True, text=True)
    m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr or "", re.S)
    try:
        value = float(json.loads(m.group(0))["input_i"]) if m else None
    except (ValueError, KeyError):
        return None
    return value if value is not None and math.isfinite(value) else None


def decode_mono(path: Path, sr: int = SR_MEASURE, name: str | None = None):
    import numpy as np

    proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-i", str(path), "-vn", "-ac", "1",
                           "-ar", str(sr), "-f", "f32le", "-"], capture_output=True)
    if proc.returncode != 0:
        raise TrilhaError(f"não consegui ler o som de {name or path.name}.")
    data = proc.stdout[: len(proc.stdout) // 4 * 4]
    return np.frombuffer(data, dtype="<f4").astype(np.float64)


def energy_profile(x, sr: int = SR_MEASURE) -> tuple[str, float, float | None]:
    """(note, full_at_s, dip_at_s) from 0.5 s RMS windows."""
    import numpy as np

    win = max(1, int(WINDOW_S * sr))
    n = len(x) // win
    if n == 0:
        return "cheia desde o início", 0.0, None
    power = (x[: n * win].reshape(n, win) ** 2).mean(axis=1)
    level = 10 * np.log10(power + 1e-12)
    ref = float(np.percentile(level, 90))
    full_idx = int(np.argmax(level >= ref - FULL_WITHIN_DB))
    full_at = full_idx * WINDOW_S
    note = "cheia desde o início" if full_at <= 1.0 else f"começa baixa e cresce aos {num(full_at)} s"
    dip_at = None
    need = int(round(DIP_MIN_S / WINDOW_S))
    last = n - int(round(TAIL_IGNORED_S / WINDOW_S))
    run = 0
    for i in range(full_idx + 1, max(full_idx + 1, last)):
        run = run + 1 if level[i] < ref - DIP_DB else 0
        if run >= need:
            dip_at = (i - need + 1) * WINDOW_S
            break
    if dip_at is not None:
        note += f"; cai aos {num(dip_at)} s"
    return note, round(full_at, 2), dip_at


def voice_band_share(x, sr: int = SR_MEASURE) -> float:
    import numpy as np

    frame = 4096
    n = len(x) // frame
    if n == 0:
        x = np.pad(x, (0, frame - len(x)))
        n = 1
    frames = x[: n * frame].reshape(n, frame) * np.hanning(frame)
    power = (np.abs(np.fft.rfft(frames, axis=1)) ** 2).sum(axis=0)
    freqs = np.fft.rfftfreq(frame, 1.0 / sr)
    total = power[(freqs >= FULL_BAND[0]) & (freqs <= FULL_BAND[1])].sum()
    voice = power[(freqs >= VOICE_BAND[0]) & (freqs <= VOICE_BAND[1])].sum()
    return round(float(voice / total), 3) if total > 0 else 0.0


def measure(path: Path, name: str | None = None) -> dict:
    """name: the file the person gave (path may be the temporary copy), for the messages."""
    info = probe_audio(path, name)
    x = decode_mono(path, name=name)
    if len(x) == 0 or not (abs(x).max() > 1e-6):
        raise TrilhaError(f"{name or path.name} está em silêncio.")
    note, full_at, _dip_at = energy_profile(x)
    seconds = info["duration"] or len(x) / SR_MEASURE
    lufs = integrated_lufs(path)
    return {"duration_s": round(seconds, 3), "integrated_lufs": round(lufs, 1) if lufs is not None else None,
            "energy": note, "full_at_s": full_at, "voice_band_share": voice_band_share(x)}


# ------------------------------------------------------------------ changing the library

def _convert(src: Path, dest: Path, sample_fmt: str) -> None:
    codec = "pcm_s16le" if sample_fmt.startswith(("s16", "u8")) else "pcm_s24le"
    proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-y", "-i", str(src), "-map", "0:a:0",
                           "-vn", "-ac", "2", "-ar", "48000", "-c:a", codec, "-f", "wav", str(dest)],
                          capture_output=True, text=True)
    if proc.returncode != 0:
        tail = " ".join((proc.stderr or "").strip().splitlines()[-2:])[:200]
        raise TrilhaError(f"não consegui converter {src.name} para WAV ({tail}).", 1)


def known_origin(src: Path, lib: Path) -> dict:
    """Source, prompt, license and generator facts the skill already wrote for a generated file: the note next to
    a file waiting in <lib>/_recebidas (gen_music --biblioteca) or its entry in <EDIT>/assets/credits.json."""
    try:
        if src.resolve().parent == (lib / INBOX_DIR).resolve():
            note = load_json(src.with_suffix(".json"))
            if isinstance(note, dict):
                facts = ("model", "instrumental", "generated_seconds")
                return {"source": note.get("source"), "prompt": note.get("prompt"), "license": note.get("license"),
                        "title": note.get("title"), "extra": {k: note[k] for k in facts if k in note},
                        "note_file": src.with_suffix(".json")}
        if src.parent.name == "assets":
            credits = load_json(src.parent / "credits.json")
            for c in credits if isinstance(credits, list) else []:
                if isinstance(c, dict) and c.get("file") == f"assets/{src.name}":
                    rules = "; ".join(str(c[k]) for k in ("license", "note") if isinstance(c.get(k), str) and c[k])
                    return {"source": c.get("source"), "prompt": c.get("prompt"), "license": rules or None,
                            "extra": {k: c[k] for k in ("model", "instrumental") if k in c}}
    except (OSError, ValueError):
        pass
    return {}


def add_track(src, mood_text, *, title: str | None = None, favorite: bool = False, source: str | None = None,
              prompt: str | None = None, license_note: str | None = None, new_mood: bool = False,
              when: str | None = None, lib: Path | None = None, move_source: bool = False,
              extra: dict | None = None) -> dict:
    """Copy (or convert) one file into the library and index it. Returns the new entry (+ "_new_mood")."""
    lib = check_location(lib)
    src = Path(src).expanduser()
    if not src.is_file():
        raise TrilhaError(f"arquivo não encontrado: {src}")
    ext = src.suffix.lower()
    if ext not in AUDIO_EXT + VIDEO_CONVERT:
        raise TrilhaError(f"formato não aceito ({ext or 'sem extensão'}): {src.name}. Use wav, mp3, m4a, aac, ogg, "
                          "opus, flac ou aiff (ou um vídeo mp4 ou mov, de onde eu tiro o som).")
    need_ffmpeg()
    info = probe_audio(src)
    if info["duration"] and info["duration"] < MIN_SECONDS:
        raise TrilhaError(f"{src.name} tem só {num(info['duration'], 2)} s. Uma trilha precisa de pelo menos "
                          f"{num(MIN_SECONDS)} s (para efeito curto, use sfx.events).")
    index = load_index(lib, required=False) or empty_index()
    mood = find_mood(index, mood_text)
    created = False
    if mood is None:
        mid = slug(mood_text)
        if not mid or not MOOD_ID.match(mid) or mid in RESERVED_MOODS:
            raise TrilhaError(f"nome de humor inválido: '{mood_text}'. Use palavras simples, ex.: --humor triste.")
        close = difflib.get_close_matches(mid, [m["id"] for m in all_moods(index)], n=1, cutoff=0.75)
        if close and not new_mood:
            raise TrilhaError(f"o humor '{mood_text}' não existe. Você quis dizer '{close[0]}'? Para criar um humor "
                              "novo com esse nome, repita com --novo-humor.")
        label = " ".join(str(mood_text).split())[:40]
        index["moods"][mid] = {"label": label, "when": " ".join(str(when or "").split())[:120],
                               "created_at": now_iso()}
        mood = {"id": mid, "label": label, "when": index["moods"][mid]["when"], "custom": True}
        created = True
    elif when and mood["custom"]:
        info = index["moods"].get(mood["id"])
        if not isinstance(info, dict):
            info = index["moods"][mood["id"]] = {"label": mood["label"], "created_at": now_iso()}
        info["when"] = " ".join(str(when).split())[:120]

    folder = lib / mood["id"]
    try:
        inside = src.resolve().relative_to(lib.resolve())
    except (ValueError, OSError):
        inside = None
    if inside is not None and inside.parts and inside.parts[0] == TRASH_DIR:
        raise TrilhaError(f"{src.name} está na lixeira da biblioteca. Para trazer de volta, use: "
                          f"{skill_cmd('trilhas.py', 'restaurar ID')}")
    src_sha = sha256(src)
    for t in index["tracks"]:
        if t.get("mood") == mood["id"] and src_sha in (t.get("sha256"), t.get("source_sha256")):
            raise TrilhaError(f"essa música já está no humor {mood['id']} como {t['id']} ('{t.get('title')}'). "
                              "Nada foi copiado.")
    for t in index["lixeira"]:
        if t.get("mood") == mood["id"] and src_sha in (t.get("sha256"), t.get("source_sha256")):
            back = skill_cmd("trilhas.py", "restaurar " + t["id"])
            raise TrilhaError(f"essa música está na lixeira do humor {mood['id']} como {t['id']} "
                              f"('{t.get('title')}'). Nada foi copiado. Para trazer de volta: {back}")
    origin = known_origin(src, lib)
    source = source or origin.get("source")
    title = title or (origin.get("title") if isinstance(origin.get("title"), str) else None)
    prompt = prompt or origin.get("prompt")
    license_note = license_note or origin.get("license")
    extra = {**(origin.get("extra") or {}), **(extra or {})}
    keep = ext in LOSSY_KEEP
    if origin.get("note_file") and keep:
        move_source = True              # a generated file waiting in _recebidas: its byte copy moves in

    track_id = next_id(lib, index, mood["id"])
    dest = folder / f"{track_id}{ext if keep else '.wav'}"
    tmp = folder / f".tmp-{dest.name}"
    new_folder = not folder.exists()
    stored = False
    try:
        folder.mkdir(parents=True, exist_ok=True)
        if keep:
            shutil.copyfile(str(src), str(tmp))
        else:
            _convert(src, tmp, info["sample_fmt"])
        facts = measure(tmp, src.name)
        stored_sha = src_sha if keep else sha256(tmp)
        os.replace(str(tmp), str(dest))
        stored = True
    except OSError as e:
        raise TrilhaError(f"não consegui copiar {src.name} para a biblioteca ({e.__class__.__name__}). Confira o "
                          f"espaço e a permissão de {folder}.", 1)
    finally:
        if tmp.exists():
            tmp.unlink()
        if not stored and new_folder:
            try:
                folder.rmdir()          # only an empty folder this call created
            except OSError:
                pass

    if favorite:
        for t in index["tracks"]:
            if t.get("mood") == mood["id"]:
                t["favorite"] = False
    clean_title = " ".join(str(title or "").split())[:80] or \
        " ".join(re.sub(r"[_\-]+", " ", src.stem).split())[:80] or track_id
    entry = {
        "id": track_id, "mood": mood["id"], "file": dest.relative_to(lib).as_posix(), "title": clean_title,
        **facts,
        "source": " ".join(str(source or SOURCE_USER).split())[:120],
        "license": " ".join(str(license_note or LICENSE_USER).split())[:300],
        "prompt": (" ".join(str(prompt).split())[:2000] if prompt else None),
        "added_at": now_iso(), "favorite": bool(favorite), "original_name": src.name,
        "format": "cópia do original" if keep else "WAV 48 kHz estéreo",
        "sha256": stored_sha, "source_sha256": src_sha,
    }
    for key, value in (extra or {}).items():
        entry.setdefault(key, value)
    index["tracks"].append(entry)
    try:
        save_index(lib, index)
    except TrilhaError:
        dest.unlink()
        raise
    if move_source:
        for leftover in (src, origin.get("note_file")):
            try:
                if leftover is not None:
                    leftover.unlink()
            except OSError:
                pass
    return {**entry, "_new_mood": created}


def set_favorite(track_id: str, on: bool = True, lib: Path | None = None) -> dict:
    lib = check_location(lib)
    index = load_index(lib)
    entry = find_track(index, track_id)
    if entry is None:
        raise TrilhaError(_no_track(index, track_id))
    for t in index["tracks"]:
        if t.get("mood") == entry["mood"]:
            t["favorite"] = False
    entry["favorite"] = bool(on)
    save_index(lib, index)
    return entry


def _no_track(index: dict, track_id: str) -> str:
    if any(t.get("id") == track_id for t in index.get("lixeira", [])):
        return (f"a trilha {track_id} está na lixeira. Para trazer de volta: "
                f"{skill_cmd('trilhas.py', f'restaurar {track_id}')}")
    return f"não achei a trilha '{track_id}' na biblioteca. Veja os ids com: {skill_cmd('trilhas.py', 'listar')}"


def _free_path(folder: Path, name: str) -> Path:
    path = folder / name
    n = 2
    while path.exists():
        path = folder / f"{Path(name).stem}_{n}{Path(name).suffix}"
        n += 1
    return path


def remove_track(track_id: str, lib: Path | None = None) -> tuple[dict, Path]:
    """Move a track to _lixeira/<mood>/ (never deletes) and keep its entry under "lixeira"."""
    lib = check_location(lib)
    index = load_index(lib)
    entry = find_track(index, track_id)
    if entry is None:
        raise TrilhaError(_no_track(index, track_id))
    src = library_file(lib, entry["file"])
    folder = lib / TRASH_DIR / (entry["mood"] if MOOD_ID.match(entry["mood"]) else "outros")
    folder.mkdir(parents=True, exist_ok=True)
    dest = _free_path(folder, Path(entry["file"].replace("\\", "/")).name)
    if src is not None and src.is_file():
        shutil.move(str(src), str(dest))
    index["tracks"] = [t for t in index["tracks"] if t is not entry]
    was_favorite = bool(entry.get("favorite"))
    entry.update({"file": dest.relative_to(lib).as_posix(), "favorite": False, "was_favorite": was_favorite,
                  "removed_at": now_iso()})
    index["lixeira"].append(entry)
    save_index(lib, index)
    return entry, dest


def restore_track(track_id: str, lib: Path | None = None) -> dict:
    lib = check_location(lib)
    index = load_index(lib)
    entry = next((t for t in index["lixeira"] if t.get("id") == track_id), None)
    if entry is None:
        raise TrilhaError(f"a trilha '{track_id}' não está na lixeira.")
    src = library_file(lib, entry["file"])
    if src is None or not src.is_file():
        raise TrilhaError(f"o arquivo da trilha {track_id} sumiu da lixeira ({lib / TRASH_DIR}).")
    if not MOOD_ID.match(entry["mood"]):
        raise TrilhaError(f"a trilha {track_id} tem um humor inválido no índice ('{entry['mood']}').")
    folder = lib / entry["mood"]
    folder.mkdir(parents=True, exist_ok=True)
    dest = _free_path(folder, src.name)
    shutil.move(str(src), str(dest))
    index["lixeira"] = [t for t in index["lixeira"] if t is not entry]
    for key in ("removed_at", "was_favorite"):
        entry.pop(key, None)
    entry["file"] = dest.relative_to(lib).as_posix()
    index["tracks"].append(entry)
    save_index(lib, index)
    return entry


# ------------------------------------------------------------------ choosing

def _score(t: dict, seconds: float | None) -> tuple:
    length = float(t.get("duration_s") or 0.0)
    voice = float(t.get("voice_band_share") if t.get("voice_band_share") is not None else 0.5)
    full_at = float(t.get("full_at_s") or 0.0)
    if seconds:
        short = max(0.0, seconds + FADE_ROOM_S - length) / seconds
        late = min(1.0, max(0.0, full_at - 1.0) / seconds)
        closeness = abs(length - (seconds + 2.0))
    else:
        short, late, closeness = 0.0, min(1.0, max(0.0, full_at - 1.0) / 30.0), 0.0
    return (round(3.0 * short + late + voice, 6), closeness, str(t["id"]))


def choose(lib: Path, index: dict, mood: dict, seconds: float | None = None, track_id: str | None = None,
           exclude: tuple = ()) -> tuple[dict, str]:
    """(track, why) for a mood: the asked track, else the favorite, else the best fit."""
    if track_id:
        entry = find_track(index, track_id)
        if entry is None:
            raise TrilhaError(_no_track(index, track_id))
        if not _has_file(lib, entry):
            raise TrilhaError(f"o arquivo da trilha {track_id} sumiu da biblioteca ({lib}).")
        other = f" (ela é do humor {entry['mood']})" if entry["mood"] != mood["id"] else ""
        return entry, f"foi a trilha pedida ({track_id}){other}"
    all_tracks = mood_tracks(lib, index, mood["id"])
    tracks = [t for t in all_tracks if t["id"] not in exclude]
    if not tracks:
        if all_tracks:
            raise TrilhaError(f"o humor '{mood['id']}' só tem a trilha que esta edição já usa "
                              f"({', '.join(exclude)}). {fill_hint(mood['id'])}.")
        missing = [t for t in index.get("tracks", []) if t.get("mood") == mood["id"]]
        gone = f" ({len(missing)} com o arquivo sumido)" if missing else ""
        raise TrilhaError(f"o humor '{mood['id']}' ({mood['label']}) ainda não tem trilha na biblioteca{gone} "
                          f"({lib}). {fill_hint(mood['id'])}.")
    favorite = next((t for t in tracks if t.get("favorite")), None)
    if favorite:
        return favorite, f"é a favorita do humor {mood['label']}"
    best = min(tracks, key=lambda t: _score(t, seconds))
    parts = []
    if len(tracks) == 1:
        head = f"é a única trilha do humor {mood['label']}"
    else:
        head = f"é a que encaixa melhor entre {len(tracks)} trilhas do humor {mood['label']} (nenhuma é favorita)"
    length = float(best.get("duration_s") or 0.0)
    if seconds:
        if length >= seconds + FADE_ROOM_S:
            parts.append(f"dura {dur(length)} e cobre os {dur(seconds)} do corte sem repetir")
        else:
            parts.append(f"dura {dur(length)}, menos que o corte ({dur(seconds)}), então vai repetir")
    share = best.get("voice_band_share")
    if share is not None:
        lowest = len(tracks) > 1 and all(share <= (t.get("voice_band_share") if t.get("voice_band_share") is not None
                                                   else 1.0) for t in tracks)
        parts.append(f"{round(share * 100)}% da energia na faixa da voz" + (" (a menor)" if lowest else ""))
    if best.get("energy"):
        parts.append(str(best["energy"]))
    return best, head + (": " + ", ".join(parts) if parts else "")


# ------------------------------------------------------------------ edit folder

def load_lock(edit: Path) -> tuple[dict, str | None]:
    """(lock, warning). A broken lock counts as empty (the choice is made again)."""
    path = edit / "assets" / LOCK_NAME
    empty = {"version": 1, "moods": {}, "copies": [], "hashes": {}}
    if not path.is_file():
        return empty, None
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        if not isinstance(data, dict) or not isinstance(data.get("moods", {}), dict):
            raise ValueError("formato")
    except (OSError, ValueError):
        return empty, f"assets/{LOCK_NAME} estava ilegível: a trilha foi escolhida de novo"
    data.setdefault("version", 1)
    data.setdefault("moods", {})
    if not isinstance(data.get("copies"), list):
        data["copies"] = []
    if not isinstance(data.get("hashes"), dict):
        data["hashes"] = {}
    return data, None


def copy_to_edit(lib: Path, entry: dict, edit: Path, mood_id: str, why: str) -> dict:
    """Copy the chosen library file to <EDIT>/assets/trilha_<mood>.<ext> and record it in the lock."""
    src = library_file(lib, entry.get("file"))
    if src is None or not src.is_file():
        raise TrilhaError(f"o arquivo da trilha {entry.get('id')} sumiu da biblioteca ({lib}).")
    ext = src.suffix.lower()
    assets = edit / "assets"
    assets.mkdir(parents=True, exist_ok=True)
    lock, _warn = load_lock(edit)
    digest = sha256(src)
    records = [r for r in lock["moods"].values() if isinstance(r, dict)]
    target = None
    for n in range(1, 100):
        name = f"trilha_{mood_id}{ext}" if n == 1 else f"trilha_{mood_id}_{n}{ext}"
        path = assets / name
        rel = f"assets/{name}"
        if not path.exists():
            target = path
            break
        if not path.is_file():
            continue
        current = sha256(path)
        # rewritten only when it still holds what this helper copied (a file the person put there stays)
        if current == digest or lock["hashes"].get(rel) == current or \
                any(r.get("file") == rel and r.get("sha256") == current for r in records):
            target = path
            break
    if target is None:
        raise TrilhaError(f"assets/ já tem arquivos demais com o nome trilha_{mood_id}. Nada foi copiado.", 1)
    rel = f"assets/{target.name}"
    if not (target.is_file() and sha256(target) == digest):
        tmp = assets / f".tmp-{target.name}"
        try:
            shutil.copyfile(str(src), str(tmp))
            os.replace(str(tmp), str(target))
        except OSError as e:
            raise TrilhaError(f"não consegui copiar a trilha para {assets} ({e.__class__.__name__}).", 1)
        finally:
            if tmp.exists():
                tmp.unlink()
    record = {"track_id": entry["id"], "mood": mood_id, "title": entry.get("title"), "file": rel, "sha256": digest,
              "library_file": entry["file"], "duration_s": entry.get("duration_s"),
              "source": entry.get("source"), "license": entry.get("license"), "why": why, "chosen_at": now_iso()}
    lock["moods"][mood_id] = record
    if rel not in lock["copies"]:
        lock["copies"].append(rel)
    lock["hashes"][rel] = digest
    save_json(assets / LOCK_NAME, lock)
    return record


def cut_seconds(edit: Path) -> float | None:
    try:
        value = float((load_json(edit / "cut_timeline.json") or {}).get("duration") or 0)
    except (OSError, ValueError, TypeError, AttributeError):
        return None
    return value if value > 0 and math.isfinite(value) else None


def _index_for(lib: Path, mood_text) -> dict:
    index = load_index(lib, required=False)
    if index is None:
        raise TrilhaError(missing_library_message(lib, slug(mood_text) or "triste"))
    return index


def pick_for_edit(edit: Path, mood_text, seconds: float | None = None, track_id: str | None = None,
                  change: bool = False, lib: Path | None = None) -> dict:
    """The `escolher` command: always choose again (or the asked track), copy and record."""
    lib = check_location(lib)
    index = _index_for(lib, mood_text)
    mood = find_mood(index, mood_text)
    if mood is None:
        raise TrilhaError(unknown_mood_message(mood_text, index))
    lock, _warn = load_lock(edit)
    exclude = ()
    if change and not track_id:
        current = (lock["moods"].get(mood["id"]) or {}).get("track_id")
        exclude = (current,) if current else ()
    entry, why = choose(lib, index, mood, seconds, track_id, exclude)
    if exclude:
        why = f"troquei a {exclude[0]}; {why}"
    record = copy_to_edit(lib, entry, edit, mood["id"], why)
    return {**record, "mood_label": mood["label"], "from": "biblioteca"}


def resolve_for_edit(edit: Path, mood_text, seconds: float | None = None, track_id: str | None = None,
                     lib: Path | None = None) -> dict:
    """compose_final's music.mood: the recorded choice when its file is intact, else choose and record."""
    mood_id = slug(mood_text)
    if not mood_id:
        raise TrilhaError("music.mood está vazio. Use um humor, ex.: \"mood\": \"triste\".")
    lock, warn = load_lock(edit)
    rec = lock["moods"].get(mood_id)
    if rec and (not track_id or track_id == rec.get("track_id")):
        path = edit / str(rec.get("file", ""))
        if path.is_file() and sha256(path) == rec.get("sha256"):
            out = {**rec, "from": "escolha anterior"}
            return out
    lib = check_location(lib)
    index = _index_for(lib, mood_text)
    mood = find_mood(index, mood_text)
    if mood is None:
        raise TrilhaError(unknown_mood_message(mood_text, index))
    entry, why = choose(lib, index, mood, seconds, track_id)
    record = copy_to_edit(lib, entry, edit, mood["id"], why)
    out = {**record, "mood_label": mood["label"], "from": "biblioteca"}
    if warn:
        out["warning"] = warn
    return out


def edit_music_files(edit: Path) -> list[str]:
    """Music files already in <EDIT>/assets that the person could use as they are (not the library copies)."""
    assets = edit / "assets"
    if not assets.is_dir():
        return []
    lock, _warn = load_lock(edit)
    ours = set(lock.get("copies") or []) | {str(r.get("file")) for r in lock["moods"].values() if isinstance(r, dict)}
    out = []
    for path in sorted(assets.iterdir()):
        rel = f"assets/{path.name}"
        if not path.is_file() or path.name.startswith((".", "sfx_")) or rel in ours:
            continue
        if path.suffix.lower() in AUDIO_EXT:
            out.append(rel)
    return out


# ------------------------------------------------------------------ read-only views

def moods_summary(lib: Path | None = None) -> dict:
    """What the preview shows: moods that have tracks. Never creates or changes anything."""
    try:
        lib = check_location(lib)
    except TrilhaError as e:
        return {"exists": False, "error": str(e), "moods": []}
    try:
        index = load_index(lib, required=False)
    except TrilhaError as e:
        return {"exists": True, "error": str(e), "moods": []}
    if index is None:
        return {"exists": False, "moods": []}
    moods = []
    for m in all_moods(index):
        tracks = mood_tracks(lib, index, m["id"])
        if not tracks:
            continue
        fav = next((t for t in tracks if t.get("favorite")), None)
        moods.append({"id": m["id"], "label": m["label"], "when": m["when"], "count": len(tracks),
                      "favorite_id": fav["id"] if fav else None, "favorite_title": fav.get("title") if fav else None,
                      "custom": m["custom"]})
    return {"exists": True, "moods": moods}


def _say(text: str = "") -> None:
    try:
        print(text, flush=True)
    except UnicodeError:
        sys.stdout.buffer.write((text + "\n").encode("utf-8", "replace"))


def track_line(t: dict, lib: Path) -> str:
    bits = [dur(t.get("duration_s") or 0)]
    if t.get("integrated_lufs") is not None:
        bits.append(f"{num(t['integrated_lufs'])} LUFS")
    if t.get("energy"):
        bits.append(str(t["energy"]))
    if t.get("voice_band_share") is not None:
        bits.append(f"voz {round(t['voice_band_share'] * 100)}%")
    bits.append(str(t.get("source") or SOURCE_USER))
    star = "★" if t.get("favorite") else " "
    gone = "" if _has_file(lib, t) else "  [ARQUIVO SUMIU]"
    return f"  {star} {t['id']}  \"{t.get('title')}\" · " + " · ".join(bits) + gone


def cmd_listar(args) -> int:
    lib = check_location()
    index = load_index(lib, required=False)
    if args.json:
        _say(json.dumps({"library": str(lib), "index": index}, ensure_ascii=False, indent=2))
        return 0
    if index is None:
        _say(f"A biblioteca de trilhas ainda está vazia ({lib}).")
        _say(fill_hint("triste") + ".")
        return 0
    only = None
    if args.humor:
        only = find_mood(index, args.humor)
        if only is None:
            raise TrilhaError(unknown_mood_message(args.humor, index))
    _say(f"Biblioteca de trilhas: {lib}")
    empty = []
    for m in all_moods(index):
        if only and m["id"] != only["id"]:
            continue
        tracks = mood_tracks(lib, index, m["id"], existing_only=False)
        if not tracks:
            empty.append(m["id"])
            continue
        when = f" · {m['when']}" if m["when"] else ""
        label = "trilha" if len(tracks) == 1 else "trilhas"
        _say(f"\n{m['label']} ({m['id']}){when} · {len(tracks)} {label}")
        for t in tracks:
            _say(track_line(t, lib))
    if empty:
        _say(f"\nSem trilha ainda: {', '.join(empty)}.")
        _say(fill_hint(empty[0]) + ".")
    trash = index.get("lixeira") or []
    if trash and not only:
        _say(f"\nNa lixeira ({lib / TRASH_DIR}): {', '.join(t['id'] for t in trash)}")
    return 0


def cmd_humores(args) -> int:
    lib = check_location()
    index = load_index(lib, required=False)
    rows = []
    for m in all_moods(index):
        tracks = mood_tracks(lib, index, m["id"]) if index else []
        fav = next((t for t in tracks if t.get("favorite")), None)
        rows.append({**m, "count": len(tracks), "favorite_id": fav["id"] if fav else None,
                     "favorite_title": fav.get("title") if fav else None})
    beats = load_presets().get("beats") or {}
    if args.json:
        _say(json.dumps({"library": str(lib), "moods": rows, "beats": beats}, ensure_ascii=False, indent=2))
        return 0
    _say(f"Humores da biblioteca ({lib}):")
    for r in rows:
        count = "vazio" if not r["count"] else f"{r['count']} {'trilha' if r['count'] == 1 else 'trilhas'}"
        fav = f" (favorita: \"{r['favorite_title']}\")" if r["favorite_title"] else ""
        tag = " [seu]" if r["custom"] else ""
        _say(f"  {r['id']:<12} {r['label']:<12} {r['when'] or '-':<45} {count}{fav}{tag}")
    if beats:
        labels = {m["id"]: m["label"].lower() for m in rows}
        tips = [f"{beat} {' ou '.join(labels.get(i, i) for i in ids)}" for beat, ids in beats.items()]
        _say("Sugestão pelo trecho do anúncio: " + " · ".join(tips))
    return 0


def cmd_adicionar(args) -> int:
    entry = add_track(args.arquivo, args.humor, title=args.titulo, favorite=args.favorita, source=args.fonte,
                      prompt=args.prompt, license_note=args.licenca, new_mood=args.novo_humor, when=args.quando)
    lib = library_dir()
    if entry.get("_new_mood"):
        _say(f"humor novo criado: {entry['mood']}")
    _say(f"ok: {entry['id']} \"{entry['title']}\" entrou no humor {entry['mood']}")
    _say(f"  arquivo: {lib / entry['file']} ({entry['format']})")
    lufs = f"{num(entry['integrated_lufs'])} LUFS" if entry.get("integrated_lufs") is not None else "volume não medido"
    _say(f"  {dur(entry['duration_s'])} · {lufs} · {entry['energy']} · "
         f"{round(entry['voice_band_share'] * 100)}% da energia na faixa da voz")
    _say(f"  origem: {entry['source']} · uso: {entry['license']}")
    _say(f"  favorita: {'sim' if entry['favorite'] else 'não'}")
    return 0


def cmd_favorita(args) -> int:
    entry = set_favorite(args.id, on=not args.tirar)
    if entry["favorite"]:
        _say(f"ok: {entry['id']} \"{entry.get('title')}\" é a favorita do humor {entry['mood']}")
    else:
        _say(f"ok: o humor {entry['mood']} ficou sem favorita (a escolha vai pelo encaixe no corte)")
    return 0


def cmd_remover(args) -> int:
    entry, dest = remove_track(args.id)
    track_id = entry["id"]
    _say(f"ok: {track_id} \"{entry.get('title')}\" foi para a lixeira ({dest}). Nada foi apagado.")
    _say("Para trazer de volta: " + skill_cmd("trilhas.py", f"restaurar {track_id}"))
    return 0


def cmd_restaurar(args) -> int:
    entry = restore_track(args.id)
    _say(f"ok: {entry['id']} \"{entry.get('title')}\" voltou para o humor {entry['mood']}")
    return 0


def cmd_escolher(args) -> int:
    edit = Path(args.edit_dir).expanduser().resolve()
    if not edit.is_dir():
        raise TrilhaError(f"pasta edit não encontrada: {edit}")
    seconds = args.duracao
    origin = "pedido em --duracao"
    if seconds is None:
        seconds = cut_seconds(edit)
        origin = "duração do corte" if seconds else "sem duração do corte"
    elif not (math.isfinite(seconds) and seconds > 0):
        raise TrilhaError("--duracao precisa ser um número maior que zero (ex.: 32)")
    rec = pick_for_edit(edit, args.humor, seconds, args.trilha, args.trocar)
    _say(f"trilha: {rec['track_id']} \"{rec.get('title')}\" (humor {rec['mood']})")
    _say(f"arquivo: {edit / rec['file']}")
    _say(f"por quê: {rec['why']}" + (f" ({origin}: {dur(seconds)})" if seconds else ""))
    _say(f"origem: {rec.get('source')} · uso: {rec.get('license')}")
    _say(f'no edit_data.json: "music": {{"enabled": true, "mood": "{rec["mood"]}", "volume_db": -15, '
         '"duck": true, "fade_out": 1.5}')
    return 0


def cmd_onde(_args) -> int:
    lib = check_location()
    state = "existe" if (lib / INDEX_NAME).is_file() else "ainda não existe (nasce com a primeira música)"
    _say(f"{lib} ({state})")
    return 0


def build_parser() -> argparse.ArgumentParser:
    ap = argparse.ArgumentParser(prog="trilhas.py", description="Biblioteca pessoal de trilhas por humor "
                                 "(fica fora da skill, em ~/.editor-video/trilhas).")
    sub = ap.add_subparsers(dest="cmd", metavar="comando")
    sub.required = True
    p = sub.add_parser("onde", help="mostra onde fica a biblioteca")
    p.set_defaults(fn=cmd_onde)
    p = sub.add_parser("humores", help="lista os humores, quando usar e quantas trilhas cada um tem")
    p.add_argument("--json", action="store_true")
    p.set_defaults(fn=cmd_humores)
    p = sub.add_parser("listar", help="lista as trilhas por humor, a favorita primeiro")
    p.add_argument("--humor", help="só este humor")
    p.add_argument("--json", action="store_true")
    p.set_defaults(fn=cmd_listar)
    p = sub.add_parser("adicionar", help="copia um arquivo de música para a biblioteca")
    p.add_argument("arquivo", help="arquivo de música (wav, mp3, m4a, aac, ogg, opus, flac, aiff) ou vídeo")
    p.add_argument("--humor", required=True, help="humor (ex.: triste). Nome novo cria um humor seu")
    p.add_argument("--titulo", help="nome para mostrar (padrão: o nome do arquivo)")
    p.add_argument("--favorita", action="store_true", help="vira a favorita do humor")
    p.add_argument("--fonte", help=f"de onde veio (padrão: {SOURCE_USER})")
    p.add_argument("--prompt", help="pedido usado para gerar, se foi feita com IA")
    p.add_argument("--licenca", help="nota de licença ou de uso")
    p.add_argument("--novo-humor", action="store_true", help="cria o humor mesmo com nome parecido com outro")
    p.add_argument("--quando", help="quando usar (só para humor seu)")
    p.set_defaults(fn=cmd_adicionar)
    p = sub.add_parser("favorita", help="marca a favorita do humor")
    p.add_argument("id")
    p.add_argument("--tirar", action="store_true", help="tira a marca de favorita")
    p.set_defaults(fn=cmd_favorita)
    p = sub.add_parser("remover", help="manda a trilha para a lixeira (não apaga)")
    p.add_argument("id")
    p.set_defaults(fn=cmd_remover)
    p = sub.add_parser("restaurar", help="traz uma trilha de volta da lixeira")
    p.add_argument("id")
    p.set_defaults(fn=cmd_restaurar)
    p = sub.add_parser("escolher", help="escolhe a trilha de um humor para uma edição e copia para assets/")
    p.add_argument("--humor", required=True)
    p.add_argument("--edit-dir", required=True, help="pasta edit da sessão")
    p.add_argument("--duracao", type=float, default=None, help="duração do corte em segundos (padrão: cut_timeline.json)")
    p.add_argument("--trilha", help="usa esta trilha (id) em vez de escolher")
    p.add_argument("--trocar", action="store_true", help="escolhe outra, diferente da que a edição usa agora")
    p.set_defaults(fn=cmd_escolher)
    return ap


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    try:
        return args.fn(args)
    except TrilhaError as e:
        print(f"erro: {e}", file=sys.stderr, flush=True)
        return e.code
    except KeyboardInterrupt:
        print("interrompido", file=sys.stderr)
        return 130


def utf8_console() -> None:
    for stream in (sys.stdout, sys.stderr):
        try:
            stream.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
        except (AttributeError, ValueError, OSError):
            pass


if __name__ == "__main__":
    utf8_console()
    sys.exit(main())
