"""Sound effect library by category, outside the skill (like the music library in trilhas.py).

  macOS / Linux: ~/.editor-video/efeitos        Windows: %USERPROFILE%\\.editor-video\\efeitos
  (or the folder in EDITOR_VIDEO_EFEITOS)

The 24 built-in effects (assets/sfx) are one sound per name, so three pops in a row sound the same. The
library keeps many takes per category, each cut, cleaned and set to the kit level, with where it came from
and its license. In edit_data.json a sound is asked for by category ("transicao", "humor"...) and every event
gets a different take, or narrowed by the source name ("impacto:boom", "transicao:swipe", "humor:boing"), or by an
exact id ("humor-012"). compose_final resolves those names here.

Categories: transicao (whoosh, swish, passagem), impacto (batida, soco, explosão curta), revelacao (grave que
cresce, brilho mágico, clima antes de mostrar), interface (clique, toque, botão), tecnologia (IA, laser,
bipes, energia), sucesso (confirmação, subida), erro (negação, falha), humor (boing, pop de desenho, apito).

Commands:
    efeitos.py importar PASTA --origem kenney|sse|arquivos [--categoria C] [--licenca TEXTO] [--fonte URL]
        cuts every file in PASTA (long reels split at the pauses), levels it and files it by category
    efeitos.py listar [--categoria C]      what the library has, per category
    efeitos.py amostra --categoria C       one mp3 with every take of C and a list with the time of each one
    efeitos.py remover ID [ID ...]         moves takes to _lixeira (restaurar brings them back)
    efeitos.py restaurar ID [ID ...]
    efeitos.py onde                        the library folder
"""

from __future__ import annotations

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

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent))
import make_sfx  # noqa: E402

SR = make_sfx.SR
LIBRARY_ENV = "EDITOR_VIDEO_EFEITOS"
INDEX = "efeitos.json"
UP_DB = 20 * math.log10(make_sfx.SFX_CALIBRATION)     # what sfx.volume 1.0 adds in compose_final

# category -> (label, when to use, loudest 200 ms at sfx.volume 1.0 in LUFS, longest take in s)
CATEGORIES = {
    "transicao": ("Transição", "whoosh e swish: corte, troca de assunto, animação ou imagem entrando", -10.0, 2.5),
    "impacto": ("Impacto", "batida, soco, explosão curta: frase forte, número, palavra que bate na tela", -10.0, 3.0),
    "revelacao": ("Revelação", "grave que cresce, brilho mágico: antes ou no momento de mostrar algo", -12.0, 6.0),
    "interface": ("Interface", "clique, toque, botão, rolagem: item de lista, tela, celular", -14.0, 1.2),
    "tecnologia": ("Tecnologia", "IA, laser, bipes, energia, dados: sistema trabalhando, robô, app", -13.0, 3.0),
    "sucesso": ("Sucesso", "confirmação, subida, nível acima: deu certo, check, conquista", -13.0, 2.5),
    "erro": ("Erro", "negação, falha, buzina: o que não fazer, erro comum", -13.0, 2.0),
    "humor": ("Humor", "boing, pop de desenho, apito, soco de desenho: quebra de padrão, piada", -11.0, 3.0),
}
LONG_CAP = -20.0          # loudest 3 s window at sfx.volume 1.0 (the kit's ceiling for long sounds)
PEAK_MAX_DB = -1.0
MAX_PER_GROUP = 8         # takes kept per source name (ui-audio has 38 switches)

# (origin, regex on "folder/file") -> category or None (left out), first match wins
RULES = [
    ("kenney", r"footstep|spaceengine|thruster|enginecircular|slime|spacetrash|preview", None),
    ("kenney", r"confirmation|powerup|phaserup|highup|threetone(up)?|zapthreetoneup", "sucesso"),
    ("kenney", r"interface.*error|lowdown|zapthreetonedown|phaserdown|highdown", "erro"),
    ("kenney", r"explosion|impactmetal|impactpunch|impactplate|impactwood|impacttin|impactsoft|impactplank|"
               r"impactmining|impactglass|impactbell|impactgeneric", "impacto"),
    ("kenney", r"door(open|close)", "transicao"),
    ("kenney", r"laser|forcefield|computernoise|zap|phase|tone|pepsound|glitch|lowrandom|lowthree", "tecnologia"),
    ("kenney", r"scratch", "humor"),
    ("kenney", r".", "interface"),
    ("sse", r"swooshes|fireball", "transicao"),
    ("sse", r"rumble|magic", "revelacao"),
    ("sse", r"cartoon|whistles", "humor"),
    ("sse", r"explosions", "impacto"),
    ("sse", r".", "tecnologia"),
    ("sintetico", r"whoosh|swipe", "transicao"),
    ("sintetico", r"riser|swell", "revelacao"),
    ("sintetico", r"boom808", "humor"),
    ("sintetico", r"boom", "impacto"),
    ("sintetico", r"glitch", "tecnologia"),
]
LICENSES = {
    "kenney": ("CC0 1.0 (domínio público), Kenney (www.kenney.nl)", "https://kenney.nl/assets/category:Audio"),
    "sse": ("CC0 1.0 (domínio público), SSE Library / USC Cinema no Internet Archive",
            "https://archive.org/details/SSE_Library_SWOOSHES"),
    "sintetico": ("gerado pelo editor-video (helpers/efeitos_gerar.py), código próprio", "helpers/efeitos_gerar.py"),
}
AUDIO_EXT = {".wav", ".ogg", ".mp3", ".flac", ".aif", ".aiff", ".m4a"}


# ------------------------------------------------------------------ library

def library_dir() -> Path:
    raw = (os.environ.get(LIBRARY_ENV) or "").strip()
    return Path(raw).expanduser() if raw else Path.home() / ".editor-video" / "efeitos"


def load_index(lib: Path | None = None) -> dict:
    lib = lib or library_dir()
    try:
        data = json.loads((lib / INDEX).read_text(encoding="utf-8"))
        return data if isinstance(data, dict) and isinstance(data.get("efeitos"), dict) else {"efeitos": {}}
    except (OSError, ValueError):
        return {"efeitos": {}}


def save_index(lib: Path, index: dict) -> None:
    lib.mkdir(parents=True, exist_ok=True)
    tmp = lib / (INDEX + ".tmp")
    tmp.write_text(json.dumps(index, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
    tmp.replace(lib / INDEX)


def slug(text: str) -> str:
    t = unicodedata.normalize("NFD", str(text).lower())
    t = "".join(c for c in t if unicodedata.category(c) != "Mn")
    return re.sub(r"[^a-z0-9]+", "-", t).strip("-")


def category_of(name: str) -> str | None:
    s = slug(name)
    for cid, (label, _, _, _) in CATEGORIES.items():
        if s in (cid, slug(label)):
            return cid
    return None


def takes(category: str, lib: Path | None = None, index: dict | None = None) -> list[tuple[str, dict]]:
    lib = lib or library_dir()
    index = index or load_index(lib)
    return sorted((k, v) for k, v in index["efeitos"].items()
                  if v.get("categoria") == category and (lib / v["arquivo"]).is_file())


def resolve(names, lib: Path | None = None, seed: str = "") -> dict:
    """{name: {"path", "anchor_s", "id", "takes": [...]}} for names that are a library category or take id.

    A category gets every take in a fixed shuffled order (seeded by `seed`, the edit folder): the caller hands
    them out one per event, so a category used 5 times plays 5 different takes."""
    lib = lib or library_dir()
    index = load_index(lib)
    out = {}
    for name in names:
        base, _, term = str(name).partition(":")        # "impacto:boom" = impacto takes whose name has "boom"
        cat = category_of(base)
        if cat:
            ts = takes(cat, lib, index)
            if term:
                ts = [kv for kv in ts if slug(term).replace("-", "") in slug(kv[1].get("nome", "")).replace("-", "")]
            if not ts:
                continue
            order = sorted(ts, key=lambda kv: hashlib.sha1(f"{seed}|{kv[0]}".encode()).hexdigest())
            out[name] = {"id": cat, "takes": [{"path": lib / v["arquivo"], "anchor_s": float(v.get("anchor_s", 0)),
                                               "id": k} for k, v in order]}
            continue
        v = index["efeitos"].get(slug(name))
        if v and (lib / v["arquivo"]).is_file():
            t = {"path": lib / v["arquivo"], "anchor_s": float(v.get("anchor_s", 0)), "id": slug(name)}
            out[name] = {"id": slug(name), "takes": [t]}
    return out


# ------------------------------------------------------------------ cutting and leveling

def decode(path: Path) -> np.ndarray:
    """(2, n) float at 48 kHz."""
    raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "2", "-ar", str(SR), "-f", "f32le", "-"],
                         capture_output=True, check=True).stdout
    x = np.frombuffer(raw, dtype="<f4").astype(np.float64)
    return x.reshape(-1, 2).T


def env_db(x: np.ndarray, hop: int = SR // 100) -> np.ndarray:
    m = x.mean(axis=0)
    n = len(m) // hop
    if n == 0:
        return np.full(1, -120.0)
    f = m[: n * hop].reshape(n, hop)
    return 10 * np.log10((f * f).mean(axis=1) + 1e-12)


def pieces(x: np.ndarray, max_len: float, split: bool) -> list[np.ndarray]:
    """The sounds in x: one trimmed piece, or (split) every sound between pauses of 0.25 s or more."""
    hop = SR // 100
    e = env_db(x, hop)
    top = float(np.percentile(e, 99))
    thr = max(float(np.percentile(e, 5)) + 18.0, top - 45.0)
    loud = e >= thr
    spans, k = [], 0
    gap = 25
    while k < len(loud):
        if not loud[k]:
            k += 1
            continue
        a, quiet = k, 0
        while k < len(loud) and (quiet < gap or not split):
            quiet = 0 if loud[k] else quiet + 1
            k += 1
            if not split and k >= len(loud):
                break
        b = k - quiet if split else int(np.nonzero(loud)[0][-1]) + 1
        spans.append((a, b))
        if not split:
            break
    out = []
    for a, b in spans:
        if (b - a) * hop < 0.06 * SR:
            continue
        s0 = max(0, a * hop - int(0.02 * SR))
        s1 = min(x.shape[1], b * hop + int(0.15 * SR))
        s1 = min(s1, s0 + int(max_len * SR))
        piece = x[:, s0:s1].copy()
        fade = min(int(0.03 * SR), piece.shape[1] // 4)
        if fade > 0:
            piece[:, -fade:] *= np.linspace(1, 0, fade)
            piece[:, : int(0.003 * SR)] *= np.linspace(0, 1, int(0.003 * SR))
        out.append(piece)
    return out


def level(piece: np.ndarray, target_200: float) -> tuple[np.ndarray, dict] | None:
    """Gain so the loudest 200 ms sits at target_200 at sfx.volume 1.0, the 3 s window never above LONG_CAP,
    the peak never above PEAK_MAX_DB. None for a piece that is almost silent."""
    m = make_sfx.measure(piece)
    if m["loudness_200ms_lufs"] < -60:
        return None
    gain = (target_200 - UP_DB) - m["loudness_200ms_lufs"]
    gain = min(gain, (LONG_CAP - UP_DB) - m["short_term_max_lufs"], PEAK_MAX_DB - m["peak_dbfs"])
    out = piece * (10 ** (gain / 20))
    return out, make_sfx.measure(out)


def write_wav(path: Path, x: np.ndarray) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    make_sfx.write_wav(path, x)


def classify(origin: str, rel: str) -> str | None:
    key = slug(rel).replace("-", "")
    for org, rx, cat in RULES:
        if org == origin and re.search(rx, key, re.I):
            return cat
    return None


def group_name(rel: str) -> str:
    stem = Path(rel).stem
    stem = re.sub(r"_CH?S_USC$", "", stem)
    return re.sub(r"[_\-]?\d+$", "", stem) or stem


def importar(folder: Path, origin: str, categoria: str | None, licenca: str | None, fonte: str | None,
             lib: Path, dry: bool = False) -> dict:
    index = load_index(lib)
    have = {v.get("origem_arquivo"): k for k, v in index["efeitos"].items()}
    counts: dict[str, int] = {}
    groups: dict[str, int] = {}
    files = sorted(p for p in folder.rglob("*") if p.suffix.lower() in AUDIO_EXT and "_lixeira" not in p.parts)
    lic, src = LICENSES.get(origin, (licenca or "", fonte or ""))
    if licenca:
        lic = licenca
    if fonte:
        src = fonte
    skipped = 0
    for p in files:
        rel = str(p.relative_to(folder))
        cat = categoria or classify(origin, rel)
        if not cat:
            skipped += 1
            continue
        label, _, target, max_len = CATEGORIES[cat]
        g = f"{cat}|{group_name(rel)}"
        try:
            x = decode(p)
        except subprocess.CalledProcessError:
            print(f"não abriu: {rel}")
            continue
        long_reel = x.shape[1] > 6 * SR
        for j, piece in enumerate(pieces(x, max_len, split=long_reel)):
            if groups.get(g, 0) >= MAX_PER_GROUP * (3 if long_reel else 1):
                break
            tag = f"{rel}#{j}"
            if tag in have:
                continue
            got = level(piece, target)
            if got is None:
                continue
            y, m = got
            n = 1 + max([int(k.rsplit("-", 1)[1]) for k in index["efeitos"] if k.startswith(cat + "-")] or [0])
            eid = f"{cat}-{n:03d}"
            if not dry:
                write_wav(lib / cat / f"{eid}.wav", y)
            index["efeitos"][eid] = {
                "categoria": cat, "arquivo": f"{cat}/{eid}.wav", "nome": group_name(rel),
                "anchor_s": m["anchor_s"], "duracao_s": m["duration_s"],
                "loudness_200ms_lufs": m["loudness_200ms_lufs"], "short_term_max_lufs": m["short_term_max_lufs"],
                "origem": origin, "origem_arquivo": tag, "licenca": lic, "fonte": src}
            have[tag] = eid
            groups[g] = groups.get(g, 0) + 1
            counts[cat] = counts.get(cat, 0) + 1
    if not dry:
        save_index(lib, index)
    return {"novos": counts, "fora": skipped}


# ------------------------------------------------------------------ commands

def cmd_listar(lib: Path, categoria: str | None) -> None:
    index = load_index(lib)
    for cid, (label, when, _, _) in CATEGORIES.items():
        if categoria and cid != category_of(categoria):
            continue
        ts = takes(cid, lib, index)
        print(f"{cid:<11} {len(ts):>4} sons · {label}: {when}")
        if categoria:
            for k, v in ts:
                print(f"   {k}  {v['duracao_s']:.2f} s  {v['nome']}  ({v['origem']})")


def cmd_amostra(lib: Path, categoria: str, out: Path | None) -> Path:
    cat = category_of(categoria)
    if not cat:
        raise SystemExit(f"categoria '{categoria}' não existe ({', '.join(CATEGORIES)})")
    ts = takes(cat, lib)
    if not ts:
        raise SystemExit(f"a categoria {cat} está vazia")
    gap = np.zeros((2, int(0.45 * SR)))
    parts, lines, t = [], [], 0.0
    for k, v in ts:
        x = make_sfx.read_audio(lib / v["arquivo"])
        x = x if x.shape[0] == 2 else np.vstack([x[0], x[0]])
        lines.append(f"{int(t // 60):02d}:{t % 60:05.2f}  {k}  {v['nome']}")
        parts += [x, gap]
        t += x.shape[1] / SR + gap.shape[1] / SR
    out = out or lib / "_amostras" / f"amostra_{cat}.mp3"
    out.parent.mkdir(parents=True, exist_ok=True)
    wav = out.with_suffix(".wav")
    write_wav(wav, np.hstack(parts))
    subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(wav), "-b:a", "192k", str(out)], check=True)
    wav.unlink()
    out.with_suffix(".txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
    print(f"{len(ts)} sons em {out} (tempo de cada um em {out.with_suffix('.txt').name})")
    return out


def cmd_mover(lib: Path, ids: list[str], back: bool) -> None:
    index = load_index(lib)
    trash = lib / "_lixeira"
    for eid in ids:
        v = index["efeitos"].get(slug(eid))
        if not v:
            print(f"{eid}: não existe")
            continue
        src, dst = lib / v["arquivo"], trash / v["arquivo"]
        if back:
            src, dst = dst, src
        if src.is_file():
            dst.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(src), str(dst))
        print(f"{eid}: {'de volta' if back else 'na lixeira'}")


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    sub = ap.add_subparsers(dest="cmd", required=True)
    im = sub.add_parser("importar")
    im.add_argument("pasta", type=Path)
    im.add_argument("--origem", default="arquivos")
    im.add_argument("--categoria", choices=list(CATEGORIES))
    im.add_argument("--licenca")
    im.add_argument("--fonte")
    im.add_argument("--dry-run", action="store_true")
    ls = sub.add_parser("listar")
    ls.add_argument("--categoria")
    am = sub.add_parser("amostra")
    am.add_argument("--categoria", required=True)
    am.add_argument("-o", type=Path)
    for nm in ("remover", "restaurar"):
        sub.add_parser(nm).add_argument("ids", nargs="+")
    sub.add_parser("onde")
    a = ap.parse_args()
    lib = library_dir()
    if a.cmd == "importar":
        if a.origem == "arquivos" and not a.categoria:
            raise SystemExit("arquivos seus: diga a --categoria")
        if a.origem not in LICENSES and not a.licenca:
            raise SystemExit("diga a --licenca (só entra som com direito de uso comercial)")
        r = importar(a.pasta, a.origem, a.categoria, a.licenca, a.fonte, lib, a.dry_run)
        print("novos: " + (", ".join(f"{k} {v}" for k, v in sorted(r["novos"].items())) or "nenhum") +
              f" · fora (não servem): {r['fora']} arquivos")
    elif a.cmd == "listar":
        cmd_listar(lib, a.categoria)
    elif a.cmd == "amostra":
        cmd_amostra(lib, a.categoria, a.o)
    elif a.cmd in ("remover", "restaurar"):
        cmd_mover(lib, a.ids, a.cmd == "restaurar")
    elif a.cmd == "onde":
        print(lib)
    return 0


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