"""Optional soundtrack made in the buyer's OWN ElevenLabs account (Eleven Music, paid per use).

Each buyer uses their own key (ELEVENLABS_API_KEY in the environment or in `.env` in the skill root) and
their own plan: nothing generated ships with the product. Without the key the helper exits 2 and tells the
agent to ask the person to paste the key in the chat (the agent writes the .env line itself). The key is
never printed, logged or saved (see elevenlabs_api.redact).

The track is instrumental by default (a sung voice fights the speaker) and lasts the cut duration from
<edit>/cut_timeline.json rounded up plus 2 s, kept between 3 s and 300 s. Each variation is one request.
Files go to <edit>/assets/trilha_<n>.mp3 (or --out) and every file gets an entry in
<edit>/assets/credits.json. Prompts that name real artists, songs, soundtracks or brand sounds are refused
before any request. `--dry-run` prints the request and the estimated credits without touching the network.

Moods (helpers/trilhas_humores.json): `--humor triste` fills the prompt with the mood's ready English prompt;
words given as the prompt are appended to it. `--biblioteca` (needs --humor) also puts every generated file in
the person's personal soundtrack library (helpers/trilhas.py, outside the skill folder), with the source,
prompt, model and usage note recorded. With --biblioteca the edit folder is optional: without it the file
waits in <library>/_recebidas/ until it is indexed, and the length defaults to 60 s (with an edit folder:
the longer of 60 s and the cut + 2 s), so one track covers most ads without repeating.

Usage:
    uv run python helpers/gen_music.py "<prompt in English>" --edit-dir DIR [--seconds S]
        [--no-instrumental] [--out NAME.mp3] [--variations N] [--model ID] [--dry-run]
    uv run python helpers/gen_music.py [extra words] --humor ID [--biblioteca] [--titulo T] [--edit-dir DIR] ...
"""

from __future__ import annotations

import argparse
import math
import sys
from datetime import datetime
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import elevenlabs_api as el  # noqa: E402
from common import load_json, save_json  # noqa: E402

MODELS = ("music_v2_5", "music_v2", "music_v1")
DEFAULT_MODEL = MODELS[0]               # the "latest, most advanced" model in the ElevenLabs model list
MIN_SECONDS = 3.0                       # API: music_length_ms from 3000
MAX_SECONDS = 300.0                     # API accepts up to 600000 ms; the product page says 5 minutes
EXTRA_SECONDS = 2
LIBRARY_SECONDS = 60.0                  # a library track should cover most ads (20 to 45 s) without looping
MAX_VARIATIONS = 3
SOURCE = "ElevenLabs Music (conta do usuário)"
LIBRARY_NOTE = (f"{el.COMMERCIAL_NOTE}; gerada na conta da própria pessoa, só para os vídeos dela "
                "(não repassar, não vender, não juntar em pacote)")
EXAMPLE = "upbeat modern pop instrumental, 110-120 bpm, positive, no vocals, leaves room for voice"
TIMEOUT = 300
MUSIC_LINE = '"volume_db": -15, "duck": true, "fade_out": 1.5}'


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


def pick_seconds(edit: Path | None, asked: float | None, library: bool = False) -> tuple[float, str, list[str]]:
    """-> (seconds, where it came from, warnings)."""
    notes = []
    if asked is None:
        duration = cut_duration(edit) if edit else None
        if library:
            if duration is not None and math.ceil(duration - 1e-6) + EXTRA_SECONDS > LIBRARY_SECONDS:
                seconds = float(math.ceil(duration - 1e-6) + EXTRA_SECONDS)
                origin = f"corte de {duration:.2f} s arredondado para cima + {EXTRA_SECONDS} s"
            else:
                seconds = LIBRARY_SECONDS
                origin = f"padrão da biblioteca ({LIBRARY_SECONDS:g} s, cobre a maioria dos anúncios sem repetir)"
        elif duration is None:
            raise el.ElevenError("não achei a duração do corte (cut_timeline.json na pasta edit). Renderize o corte "
                                 "antes ou diga a duração com --seconds.", 2)
        else:
            seconds = float(math.ceil(duration - 1e-6) + EXTRA_SECONDS)
            origin = f"corte de {duration:.2f} s arredondado para cima + {EXTRA_SECONDS} s"
    else:
        if not math.isfinite(asked) or asked <= 0:
            raise el.ElevenError("--seconds precisa ser um número maior que zero (ex.: 32)", 2)
        seconds, origin = float(asked), "pedido em --seconds"
    if seconds < MIN_SECONDS:
        notes.append(f"a ElevenLabs faz trilha de {MIN_SECONDS:g} s no mínimo: ajustei {seconds:g} s "
                     f"para {MIN_SECONDS:g} s")
        seconds = MIN_SECONDS
    elif seconds > MAX_SECONDS:
        notes.append(f"trilha limitada a {MAX_SECONDS:g} s: ajustei {seconds:g} s para {MAX_SECONDS:g} s")
        seconds = MAX_SECONDS
    return round(seconds, 3), origin, notes


def output_names(edit: Path, out: str | None, count: int) -> list[Path]:
    assets = edit / "assets"
    if out:
        stem = el.output_stem(out)
        names = [f"{stem}.mp3"] if count == 1 else [f"{stem}_{i}.mp3" for i in range(1, count + 1)]
    else:
        first = el.next_number(assets, "trilha_")
        names = [f"trilha_{first + i}.mp3" for i in range(count)]
    return el.free_paths(assets, names)


def inbox_names(lib: Path, mood_id: str, count: int) -> list[Path]:
    """Where generated files wait (inside the library) until they are indexed."""
    import trilhas

    inbox = lib / trilhas.INBOX_DIR
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    names = [f"{mood_id}_{stamp}.mp3"] if count == 1 else [f"{mood_id}_{stamp}_{i}.mp3" for i in range(1, count + 1)]
    return el.free_paths(inbox, names)


def resolve_mood(text: str, library: bool):
    """(mood dict, library folder) for --humor; the library is only read.

    With --biblioteca the library must be usable before anything is paid: a bad location, an unreadable index
    or a missing ffmpeg stop here. Without it, an unreadable index only hides the person's own moods."""
    import trilhas

    try:
        lib = trilhas.check_location()
        try:
            index = trilhas.load_index(lib, required=False)
        except trilhas.TrilhaError:
            if library:
                raise
            index = None
        if library:
            trilhas.need_ffmpeg()
        mood = trilhas.find_mood(index, text)
    except trilhas.TrilhaError as e:
        raise el.ElevenError(str(e), e.code) from None
    if mood is None:
        raise el.ElevenError(trilhas.unknown_mood_message(text, index), 2)
    return mood, lib


def to_library(lib: Path, path: Path, mood: dict, title: str, prompt: str, seconds: float, args,
               from_inbox: bool) -> dict:
    import trilhas

    extra = {"model": args.model, "instrumental": bool(args.instrumental), "generated_seconds": seconds}
    try:
        return trilhas.add_track(path, mood["id"], title=title, source=SOURCE, prompt=prompt,
                                 license_note=LIBRARY_NOTE, lib=lib, move_source=from_inbox, extra=extra)
    except Exception as e:  # noqa: BLE001  (a paid file: keep it and its note whatever went wrong)
        if from_inbox:
            # trilhas.py adicionar reads this note back (source, prompt, title, usage rule, model)
            try:
                save_json(path.with_suffix(".json"), {"source": SOURCE, "prompt": prompt, "mood": mood["id"],
                                                      "title": title, "license": LIBRARY_NOTE, **extra})
            except OSError:
                pass
        why = e if isinstance(e, trilhas.TrilhaError) else f"{e.__class__.__name__}: {e}"
        retry = trilhas.skill_cmd("trilhas.py", f"adicionar '{path}' --humor {mood['id']}")
        raise el.ElevenError(f"a trilha foi gerada e salva em {path}, mas não entrou na biblioteca ({why}). Para "
                             f"pôr na biblioteca sem gerar de novo (a origem e a regra de uso vão junto): {retry}",
                             1) from None


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Gera uma trilha com IA na conta ElevenLabs da própria pessoa "
                                             "(opcional, paga). O arquivo vai para assets/ da pasta edit e, com "
                                             "--biblioteca, para a biblioteca de trilhas por humor.")
    ap.add_argument("prompt", nargs="?", default=None,
                    help="descrição da música, de preferência em inglês (com --humor, palavras a mais no pedido pronto)")
    ap.add_argument("--edit-dir", default=None, help="pasta edit da sessão (obrigatória sem --biblioteca)")
    ap.add_argument("--humor", help="usa o pedido pronto de um humor (animada, inspiradora, triste, tensa, calma, "
                                    "epica, divertida, luxo)")
    ap.add_argument("--biblioteca", action="store_true",
                    help="guarda cada trilha gerada na biblioteca pessoal, no humor de --humor")
    ap.add_argument("--titulo", help="nome da trilha na biblioteca (padrão: '<Humor> IA <data>')")
    ap.add_argument("--seconds", type=float, default=None,
                    help=f"duração em segundos (padrão: o corte arredondado para cima + {EXTRA_SECONDS} s; com "
                         f"--biblioteca, pelo menos {LIBRARY_SECONDS:g} s; de {MIN_SECONDS:g} a {MAX_SECONDS:g})")
    ap.add_argument("--instrumental", action=argparse.BooleanOptionalAction, default=True,
                    help="só instrumental, sem voz cantada (padrão: ligado; --no-instrumental desliga)")
    ap.add_argument("--out", help="nome do arquivo em assets/ (padrão: trilha_<n>.mp3)")
    ap.add_argument("--variations", type=int, default=1,
                    help=f"quantas versões gerar, uma requisição cada (padrão 1, no máximo {MAX_VARIATIONS})")
    ap.add_argument("--model", default=DEFAULT_MODEL, choices=MODELS,
                    help=f"modelo de música (padrão {DEFAULT_MODEL})")
    ap.add_argument("--dry-run", action="store_true",
                    help="só mostra o pedido e o custo estimado, sem gerar e sem internet")
    args = ap.parse_args(argv)

    key = None
    saved: list[str] = []

    def say(*parts: str, flush: bool = False) -> None:
        print(el.redact(" ".join(parts), key), flush=flush)

    try:
        extra_words = (args.prompt or "").strip()
        mood = lib = None
        if args.humor is not None:
            if not args.humor.strip():
                raise el.ElevenError("--humor está vazio (ex.: --humor triste)", 2)
            mood, lib = resolve_mood(args.humor, args.biblioteca and not args.dry_run)
            if not mood.get("prompt") and not extra_words:
                raise el.ElevenError(f"o humor '{mood['id']}' é seu e não tem pedido pronto. Escreva o pedido em "
                                     f"inglês junto (ex.: '{EXAMPLE}').", 2)
            prompt = ", ".join(p for p in (mood.get("prompt") or "", extra_words) if p)
        else:
            prompt = extra_words
        if not prompt:
            raise el.ElevenError("o pedido está vazio", 2)
        if args.biblioteca and mood is None:
            raise el.ElevenError("--biblioteca precisa de --humor (ex.: --humor triste --biblioteca): a trilha entra "
                                 "na biblioteca dentro de um humor.", 2)
        if not args.edit_dir and not args.biblioteca:
            raise el.ElevenError("falta --edit-dir (a pasta edit do vídeo). Para gerar só para a biblioteca, use "
                                 "--humor e --biblioteca.", 2)
        if args.out and not args.edit_dir:
            raise el.ElevenError("--out só vale com --edit-dir (na biblioteca o nome é dado pelo humor).", 2)
        el.check_prompt(prompt, EXAMPLE)
        if not 1 <= args.variations <= MAX_VARIATIONS:
            raise el.ElevenError(f"--variations vai de 1 a {MAX_VARIATIONS} (cada uma é uma cobrança)", 2)
        edit = el.edit_folder(args.edit_dir) if args.edit_dir else None
        seconds, origin, notes = pick_seconds(edit, args.seconds, library=args.biblioteca)
        key = el.api_key()
        el.check_no_key(prompt, key)
        if args.titulo is not None:
            el.check_no_key(args.titulo, key)
        if not key and not args.dry_run:
            raise el.ElevenError(el.missing_key_message(
                "Gerar trilha", "com um arquivo de música que a pessoa tem direito de usar, ou sem trilha"), 2)
        paths = output_names(edit, args.out, args.variations) if edit else \
            inbox_names(lib, mood["id"], args.variations)
        payload = {"prompt": prompt, "music_length_ms": int(round(seconds * 1000)),
                   "force_instrumental": bool(args.instrumental), "model_id": args.model}
        for note in notes:
            say(f"aviso: {note}")
        if el.looks_portuguese(extra_words if mood else prompt):
            say("aviso: o pedido parece estar em português. A ElevenLabs entende melhor em inglês "
                f"(ex.: '{EXAMPLE}').")
        if not args.instrumental:
            say("aviso: trilha com voz cantada briga com a fala do vídeo. Use só se a pessoa pediu.")
        base_title = " ".join((args.titulo or "").split())[:70] or \
            f"{mood['label'] if mood else 'Trilha'} IA {datetime.now().strftime('%d/%m/%Y')}"

        each = el.credits_estimate("music", seconds)
        if args.dry_run:
            say(f"trilha: {seconds:g} s ({origin}), {'instrumental' if args.instrumental else 'pode ter voz'}, "
                f"modelo {args.model}")
            if mood:
                how = "pedido pronto + as suas palavras" if extra_words else "pedido pronto"
                say(f"humor: {mood['id']} ({mood['label']}), {how}")
            if edit:
                say("arquivos: " + ", ".join(f"assets/{p.name}" for p in paths))
            if args.biblioteca:
                say(f"biblioteca: {lib} (cada versão entra no humor {mood['id']} com o nome '{base_title}'; a "
                    "biblioteca fica fora da skill e é só sua)")
            say(f"custo estimado: cerca de {each} créditos por versão, {each * len(paths)} no total "
                f"(a ElevenLabs cobra cerca de {el.MUSIC_CREDITS_PER_MINUTE} créditos por minuto de música; "
                "o valor certo aparece no painel dela)")
            say(el.PAID_PLAN_LINE)
            if not key:
                say(f"chave: ausente. Antes de gerar, peça a {el.KEY_NAME} "
                    "(veja a mensagem que sai sem --dry-run).")
            say("pedido (não enviado):")
            say(el.dry_run_request(el.MUSIC_URL, payload, key))
            return 0

        entries = []
        for i, path in enumerate(paths, 1):
            say(f"gerando trilha {i}/{len(paths)} ({seconds:g} s) na conta ElevenLabs da pessoa...", flush=True)
            data = el.post_audio(el.MUSIC_URL, payload, key or "", "Music", TIMEOUT)
            title = base_title if len(paths) == 1 else f"{base_title} v{i}"
            if edit:
                rel = f"assets/{path.name}"
                el.store(edit, path, data, el.new_credit(rel, SOURCE, prompt, seconds,
                                                         instrumental=bool(args.instrumental), model=args.model))
                saved.append(rel)
                say(f"ok: {rel}  {seconds:g} s  {'instrumental' if args.instrumental else 'com voz'}  "
                    f"{len(data) // 1024} KB", flush=True)
            else:
                try:
                    el.save_audio(path, data)
                except OSError as e:
                    raise el.ElevenError(f"a ElevenLabs gerou o áudio, mas não consegui salvar em {path} "
                                         f"({e.__class__.__name__}). Confira o espaço e a permissão da pasta da "
                                         "biblioteca antes de gerar de novo (a geração já foi cobrada).") from None
                saved.append(str(path))
            if args.biblioteca:
                entry = to_library(lib, path, mood, title, prompt, seconds, args, from_inbox=edit is None)
                entries.append(entry)
                if not edit:
                    saved[-1] = f"{entry['id']} na biblioteca"
                say(f"na biblioteca: {entry['id']} \"{entry['title']}\" (humor {entry['mood']}) · "
                    f"{entry['energy']} · {round(entry['voice_band_share'] * 100)}% da energia na faixa da voz",
                    flush=True)
        if edit:
            say(f"créditos anotados em assets/credits.json ({el.COMMERCIAL_NOTE})")
        if entries:
            say(f"biblioteca: {lib} (origem, pedido e regra de uso anotados no índice)")
        if edit:
            say(f'no edit_data.json: "music": {{"enabled": true, "file": "{saved[0]}", {MUSIC_LINE}')
        else:
            say(f'no edit_data.json: "music": {{"enabled": true, "mood": "{mood["id"]}", {MUSIC_LINE}')
        return 0
    except Exception as e:  # noqa: BLE001  (never a traceback: its text could carry the key)
        if not isinstance(e, el.ElevenError):
            e = el.unexpected(e)
        if saved:
            e = el.ElevenError(f"{e} Já ficaram salvas: {', '.join(saved)}.", e.code)
        el.print_error(e, key)
        return e.code


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