#!/usr/bin/env python3
"""
PrimoCode Studio — suíte de criação operada por IA.

    python3 server.py            # http://localhost:7777
    python3 server.py 8080

Só biblioteca padrão do Python. Nada para instalar.
"""

import base64
import hashlib
import functools
import json
import mimetypes
import os
import re
import sys
import threading
import time
import unicodedata
import uuid
import wave
from datetime import datetime, timezone
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, unquote, urlencode
import urllib.request

RAIZ = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, RAIZ)
try:
    import voz as voz_lib  # noqa: E402  (precisa da RAIZ no path antes)
except Exception as _e:    # instalação parcial, ou voz.py de uma versão velha
    # Narração é acabamento; o Studio inteiro não pode morrer por causa dela.
    # Sem o módulo, a corrente começa direto no Google e tudo mais funciona.
    sys.stderr.write("[voz] módulo indisponível (%s); narração vai só pelo Google\n" % _e)

    class _SemVoz:
        @staticmethod
        def pronta(*a, **k):
            return False

        @staticmethod
        def sintetizar(*a, **k):
            return None

        @staticmethod
        def instalar(*a, **k):
            return {"pronta": False, "erro": "módulo de voz indisponível"}

        @staticmethod
        def estado():
            return {"pronta": False, "vozes": [], "erro": "módulo de voz indisponível"}

        @staticmethod
        def apagar():
            return {"ok": True}

        @staticmethod
        def bloqueado():
            return "módulo de voz indisponível"

    voz_lib = _SemVoz()

try:
    import elevenlabs as elevenlabs_lib  # noqa: E402
except Exception as _e:
    sys.stderr.write("[voz] elevenlabs indisponível (%s)\n" % _e)

    class _SemEleven:
        @staticmethod
        def configurado():
            return False

        @staticmethod
        def sintetizar(*a, **k):
            return None, None

        @staticmethod
        def estado():
            return {"ativo": False, "vozes": [], "erro": "módulo indisponível"}

        @staticmethod
        def gravar_chave(*a, **k):
            return {"ok": False, "erro": "módulo indisponível"}

    elevenlabs_lib = _SemEleven()

try:
    import edge as edge_lib  # noqa: E402
except Exception as _e:
    sys.stderr.write("[voz] edge indisponível (%s)\n" % _e)

    class _SemEdge:
        @staticmethod
        def disponivel():
            return False

        @staticmethod
        def sintetizar(*a, **k):
            return None, None

        @staticmethod
        def instalar():
            return None

        @staticmethod
        def estado():
            return {"motor": "edge", "disponivel": False, "vozes": [],
                    "erro": "módulo indisponível"}

    edge_lib = _SemEdge()

WEB = os.path.join(RAIZ, "web")
ARQUIVOS = os.path.join(RAIZ, "arquivos")
FERRAMENTAS = os.path.join(RAIZ, "ferramentas")
MIDIA = os.path.join(RAIZ, "midia")
PORTA = int(sys.argv[1]) if len(sys.argv) > 1 else 7777

def _assinatura():
    """SHA-1 do código do Studio, lido NA SUBIDA. Se o npm trocar o código no
    disco depois, o processo no ar continua respondendo a assinatura antiga —
    e é exatamente essa diferença que o CLI usa para saber que precisa
    reiniciar o Studio em vez de servir a versão velha para sempre.

    Entra todo módulo do Studio, não só este arquivo: uma correção que mexa
    apenas na voz também precisa derrubar o processo velho."""
    h = hashlib.sha1()
    try:
        for nome in ("server.py", "voz.py"):
            caminho = os.path.join(RAIZ, nome)
            if os.path.exists(caminho):
                with open(caminho, "rb") as fh:
                    h.update(fh.read())
        return h.hexdigest()[:12]
    except Exception:
        return "?"

ASSINATURA = _assinatura()

# 60 MB por arquivo: cobre faixa longa em mp3 e foto de câmera sem susto
LIMITE_MIDIA = 60 * 1024 * 1024
# Vídeo do usuário é outra escala: um celular grava 100 MB por minuto. O teto
# de 60 MB recusava quase toda gravação real — e "edite meu vídeo" morria na
# primeira ordem. Só vale para o caminho bruto, que não passa por base64.
LIMITE_VIDEO = 2 * 1024 * 1024 * 1024

os.makedirs(ARQUIVOS, exist_ok=True)
os.makedirs(MIDIA, exist_ok=True)


# ------------------------------------------------------------- ferramentas --

FERRAMENTAS_INFO = [
    {
        "slug": "prisma",
        "nome": "Prisma",
        "papel": "Apresentações",
        "equivale": "PowerPoint, Keynote, Canva Apresentações",
        "modelo": "cena",
        "resumo": "Decks com layouts automáticos, temas, animação de entrada, notas do apresentador e export em PDF.",
        "cor": "#FF5A36",
    },
    {
        "slug": "tela",
        "nome": "Tela",
        "papel": "Design gráfico",
        "equivale": "Photoshop, Figma, Canva",
        "modelo": "cena",
        "resumo": "Artes em canvas livre com camadas, formas, texto, imagens, gradientes e sombras. Post, capa, banner, thumbnail.",
        "cor": "#8B5CF6",
    },
    {
        "slug": "prosa",
        "nome": "Prosa",
        "papel": "Documentos",
        "equivale": "Word, Google Docs, Notion",
        "modelo": "documento",
        "resumo": "Textos longos com hierarquia, citações, listas, tabelas, capa e sumário. Export em HTML e Markdown.",
        "cor": "#2E9E6B",
    },
    {
        "slug": "grade",
        "nome": "Grade",
        "papel": "Planilhas",
        "equivale": "Excel, Google Sheets",
        "modelo": "planilha",
        "resumo": "Tabelas com fórmulas, formatação condicional, totais e gráficos. Export em CSV.",
        "cor": "#1F8FE0",
    },
    {
        "slug": "corte",
        "nome": "Corte",
        "papel": "Vídeo",
        "equivale": "CapCut, Premiere, After Effects",
        "modelo": "cena",
        "resumo": "Montagem por cenas com duração, transições e animação. Reproduz no navegador e grava .webm.",
        "cor": "#E0397A",
    },
    {
        "slug": "traco",
        "nome": "Traço",
        "papel": "Diagramas",
        "equivale": "Miro, Whimsical, Lucidchart",
        "modelo": "cena",
        "resumo": "Fluxos, mapas mentais, arquiteturas e organogramas com nós e conectores que se ligam sozinhos.",
        "cor": "#D9A21B",
    },
]

POR_SLUG = {f["slug"]: f for f in FERRAMENTAS_INFO}


# -------------------------------------------------------------- utilidades --

def _normaliza(texto, tamanho):
    """NFKD → ASCII → não-alfanumérico vira hífen → minúsculo → corta.
    Compartilhada por slugify() e nome_seguro(), que faziam a mesma conta
    com um corte de tamanho diferente cada uma."""
    texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
    texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
    return (texto or "arquivo")[:tamanho]


def slugify(texto):
    return _normaliza(texto, 40)


def caminho(arq_id):
    if not re.fullmatch(r"[a-z0-9\-]{1,72}", arq_id or ""):
        return None
    return os.path.join(ARQUIVOS, arq_id + ".json")


def ler(arq_id):
    c = caminho(arq_id)
    if not c or not os.path.exists(c):
        return None
    with open(c, encoding="utf-8") as fh:
        return json.load(fh)


def gravar(arq):
    arq["atualizado"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
    destino = caminho(arq["id"])
    # Escrita atômica: o servidor é multi-thread (autosave do editor a cada
    # 600 ms + a thread de narração gravando o mesmo doc). Escrever direto
    # podia deixar um JSON pela metade se duas gravações se cruzassem ou o
    # processo caísse no meio. Com tmp + replace, ou o arquivo antigo está
    # inteiro, ou o novo está inteiro — nunca metade.
    tmp = destino + ".tmp"
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(arq, fh, ensure_ascii=False, indent=2)
    os.replace(tmp, destino)
    return arq


def _duracao_wav(dados):
    """Segundos de um WAV em bytes; None se não der para medir."""
    try:
        import io
        with wave.open(io.BytesIO(dados), "rb") as w:
            fr = w.getframerate()
            return (w.getnframes() / fr) if fr else None
    except Exception:
        return None


# ------------------------------------------------------------------ versões --
#
# Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
# oito cenas, com narração e trilha, pediu "aplique a identidade visual" e o
# agente mandou um PUT com doc={"tema":"..."}. O handler trocava o documento
# INTEIRO pelo que chegou — as oito cenas, a narração e a trilha viraram um
# documento de uma chave só. Não havia como voltar.
#
# Daí saíram duas defesas. `mesclar` faz o pedido parcial ser parcial de
# verdade, que é o conserto. Este histórico é o que sobra quando o conserto
# não basta: guarda o estado anterior a cada gravação, para que sobrescrever
# nunca mais seja definitivo. Nada aqui apaga o arquivo vivo; o pior caso é
# ocupar disco, e vinte versões de um documento de texto não dão um megabyte.

VERSOES = os.path.join(ARQUIVOS, ".versoes")
MAX_VERSOES = 20


_seq_versao = 0


def guardar_versao(arq):
    """Copia o estado atual para .versoes/<id>/<carimbo>.json antes de trocar.

    O carimbo é ordenável como texto, e por isso precisa de milissegundo: o
    editor do navegador salva a cada 600 ms, e duas versões do mesmo segundo
    desempatadas por sorteio fariam "desfaz a última mudança" desfazer a
    penúltima. O contador no fim resolve o empate que sobra."""
    global _seq_versao
    try:
        pasta = os.path.join(VERSOES, arq["id"])
        os.makedirs(pasta, exist_ok=True)
        _seq_versao = (_seq_versao + 1) % 10000
        agora = datetime.now(timezone.utc)
        carimbo = "%s%03d-%04d" % (agora.strftime("%Y%m%dT%H%M%S"),
                                   agora.microsecond // 1000, _seq_versao)
        with open(os.path.join(pasta, carimbo + ".json"), "w", encoding="utf-8") as fh:
            json.dump(arq, fh, ensure_ascii=False)
        antigas = sorted(n for n in os.listdir(pasta) if n.endswith(".json"))
        for velha in antigas[:-MAX_VERSOES]:
            os.remove(os.path.join(pasta, velha))
        return carimbo
    except Exception:
        # Histórico é rede de proteção, não requisito: se o disco recusar, a
        # gravação principal segue. Falhar aqui seria trocar perder o passado
        # por perder o presente.
        return None


def versoes(arq_id):
    """As versões guardadas, da mais nova para a mais velha."""
    if not caminho(arq_id):
        return []
    pasta = os.path.join(VERSOES, arq_id)
    if not os.path.isdir(pasta):
        return []
    saida = []
    for nome in sorted(os.listdir(pasta), reverse=True):
        if not nome.endswith(".json"):
            continue
        try:
            with open(os.path.join(pasta, nome), encoding="utf-8") as fh:
                a = json.load(fh)
        except Exception:
            continue
        doc = a.get("doc") or {}
        saida.append({
            "versao": nome[:-5],
            "titulo": a.get("titulo", ""),
            "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
            "atualizado": a.get("atualizado", ""),
        })
    return saida


def ler_versao(arq_id, versao):
    if not caminho(arq_id) or not re.fullmatch(r"[0-9A-Za-z\-]{1,40}", versao or ""):
        return None
    c = os.path.join(VERSOES, arq_id, versao + ".json")
    if not os.path.exists(c):
        return None
    with open(c, encoding="utf-8") as fh:
        return json.load(fh)


def _tem_narracao(doc):
    if not isinstance(doc, dict):
        return False
    if isinstance(doc.get("narracao"), str) and doc["narracao"].strip():
        return True
    for c in doc.get("cenas") or []:
        if isinstance(c, dict) and isinstance(c.get("narracao"), str) and c["narracao"].strip():
            return True
    return False


def preparar_voz(doc):
    """Documento com narração chegou: começa a baixar a voz agora.

    O download é de ~90 MB e leva algum tempo. Fazê-lo quando o usuário
    aperta play seria tarde — ele ouviria a voz robótica do Google. Fazê-lo
    aqui, no instante em que o roteiro é gravado, dá ao download a mesma
    janela que o agente gasta escrevendo o resto: quando alguém toca o vídeo,
    a voz boa já está na máquina. Se não der tempo, a corrente cobre.

    Com o ElevenLabs configurado, não há nada para baixar: a síntese é
    imediata e os 90 MB do Piper ficam para o dia em que faltarem chave ou
    internet."""
    if _tem_narracao(doc) and not elevenlabs_lib.configurado():
        # O Edge (pip, segundos) e o Piper (90 MB, minutos) começam JUNTOS.
        # Antes só o Piper era preparado aqui, e o Edge — a melhor voz grátis —
        # só era instalado quando a primeira cena já estava pedindo áudio: a
        # narração caía no Google enquanto o pip rodava, e o dono ouviu a
        # voz de robô no vídeo inteiro.
        try:
            edge_lib.instalar()
        except Exception:
            pass
        if not voz_lib.pronta():
            try:
                voz_lib.instalar()
            except Exception:
                pass


# ------------------------------------------------------- narração em arquivo --
#
# O usuário testou e disse: "as vozes tá a mesma merda". Estava certo, e a
# causa era tempo, não motor. A narração era sintetizada NA HORA DE TOCAR:
# quem apertava play antes de o Piper terminar de baixar ouvia o motor de
# reserva — a voz robótica de sempre. Como o download leva minutos e o vídeo
# fica pronto em segundos, era o que acontecia SEMPRE na primeira vez.
#
# Agora a narração vira ARQUIVO assim que o roteiro é gravado. O trabalho
# roda no fundo, espera o motor bom ficar pronto (esperar é de graça: ninguém
# está olhando) e grava um .wav por cena. O player já preferia
# `cena.narracaoAudio` quando existe — passa a existir sempre.
#
# De quebra, conserta duas coisas que ninguém tinha ligado: o vídeo gravado
# em MP4 carrega a voz boa, e tocar de novo não re-sintetiza nada.

_narrando = set()
_tranca_narracao = threading.Lock()


def _assinatura_voz(texto, voz):
    corpo = "%s|%s|%s|%s" % (texto, voz.get("voz") or "", voz.get("grave") or "",
                             voz.get("velocidade") or "")
    return hashlib.sha1(corpo.encode("utf-8")).hexdigest()[:10]


def _google_tts(texto, idioma="pt-BR"):
    """Google Translate TTS em MP3, no nível do módulo, para o pré-render
    também ter o mesmo piso do endpoint: uma voz que responde sempre. Robótica,
    mas garante que NENHUMA cena fique sem áudio. Devolve b"" se falhar."""
    try:
        pedacos = []
        for t in _picar(texto, 190):
            url = "https://translate.google.com/translate_tts?" + urlencode({
                "ie": "UTF-8", "q": t, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,
            })
            req = urllib.request.Request(url, headers={
                "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                              "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
                "Referer": "https://translate.google.com/",
            })
            with urllib.request.urlopen(req, timeout=20) as r:
                pedacos.append(r.read())
        return b"".join(pedacos)
    except Exception as e:
        sys.stderr.write("[voz] Google TTS falhou: %s\n" % e)
        return b""


def _gravar_narracao(arq_id):
    """Sintetiza a narração de cada cena e guarda em midia/."""
    try:
        # Com ElevenLabs (chave) ou Edge (grátis, neural, sem download) já há
        # voz de gente na hora — não se espera o Piper. Só quando NENHUM dos
        # dois responde vale aguardar o Piper baixar, porque a alternativa é
        # o Google, que soa a robô no vídeo que a pessoa vai apresentar.
        if not elevenlabs_lib.configurado() and not edge_lib.disponivel():
            edge_lib.instalar()          # tenta trazer o Edge em paralelo
            for _ in range(120):
                if voz_lib.pronta() or edge_lib.disponivel():
                    break
                # Máquina onde o Piper nunca vai rodar (binário de outra
                # arquitetura): esperar 10 minutos aqui era esperar por nada.
                if voz_lib.bloqueado():
                    sys.stderr.write("[voz] Piper bloqueado: %s\n" % voz_lib.bloqueado())
                    break
                voz_lib.instalar()
                time.sleep(5)
            # Nem Piper nem Edge subiram: ainda assim seguimos — o Google, lá
            # embaixo na síntese, garante que nenhuma cena fique sem áudio.

        a = ler(arq_id)
        if not a:
            return
        doc = a.get("doc") or {}
        voz = doc.get("voz") if isinstance(doc.get("voz"), dict) else {}
        mudou = False

        for i, c in enumerate(doc.get("cenas") or []):
            if not isinstance(c, dict):
                continue
            texto = c.get("narracao")
            if not isinstance(texto, str) or not texto.strip():
                continue
            assinatura = _assinatura_voz(texto, voz)
            # Já gravada e com o mesmo texto e a mesma voz: nada a fazer.
            if c.get("narracaoVoz") == assinatura and c.get("narracaoAudio"):
                continue
            # Gravação do próprio usuário nunca é sobrescrita — a voz dele
            # vale mais que qualquer síntese.
            if c.get("narracaoAudio") and not c.get("narracaoVoz"):
                continue
            audio, ext, dur = None, ".wav", None
            motor_usado = None
            # A CORRENTE, na ordem de qualidade. Cada motor cai no seguinte
            # quando não devolve áudio — e o Google, no fim, garante que a
            # cena nunca fique muda. É o que faz o pré-render nunca parar.

            # 1) ElevenLabs: a voz que o usuário aprovou. MP3 CBR — a duração
            #    do módulo já vem medida.
            if elevenlabs_lib.configurado():
                audio, dur = elevenlabs_lib.sintetizar(texto, voz)
                if audio:
                    ext, motor_usado = ".mp3", "elevenlabs"

            # 2) Edge TTS: neural, grátis, sem chave. MP3, duração medida pelo
            #    VTT que o próprio edge escreve. Ninguém está olhando este
            #    laço: se o pip ainda roda, espera por ele (até 2 min) — é de
            #    graça, e é o que separa voz de gente de voz de robô.
            if not audio:
                if not edge_lib.disponivel():
                    edge_lib.instalar(esperar=120)
                if edge_lib.disponivel():
                    dados, edur = edge_lib.sintetizar(texto, voz)
                    if dados:
                        audio, dur, ext, motor_usado = dados, edur, ".mp3", "edge"

            # 3) Piper: neural e offline, quando baixado e compatível. WAV.
            if not audio:
                try:
                    audio = voz_lib.sintetizar(
                        texto, voz.get("voz"),
                        grave=bool(voz.get("grave")),
                        velocidade=float(voz.get("velocidade") or 1))
                except Exception as e:
                    sys.stderr.write("[voz] Piper cena %d falhou: %s\n" % (i + 1, e))
                    audio = None
                if audio:
                    dur, ext, motor_usado = _duracao_wav(audio), ".wav", "piper"

            # 4) Google: última reserva. Robótica, mas responde. MP3 — a
            #    duração sai do tamanho (CBR de 24 kbps ≈ 3000 bytes/s).
            #    Vai MARCADA na cena (`narracaoMotor`): o agente lê e avisa a
            #    pessoa, em vez de ela descobrir ouvindo.
            if not audio:
                dados = _google_tts(texto)
                if dados:
                    audio, ext, motor_usado = dados, ".mp3", "google"
                    dur = round(len(dados) / 3000.0, 2)
                    sys.stderr.write("[voz] RESERVA na cena %d: Google (edge-tts: %s)\n"
                                     % (i + 1, edge_lib.ultimo_erro() or "não instalado"))

            if not audio:
                continue
            nome = "narr-%s-%d-%s%s" % (arq_id[:28], i + 1, assinatura, ext)
            with open(os.path.join(MIDIA, nome), "wb") as fh:
                fh.write(audio)
            c["narracaoAudio"] = "/midia/" + nome
            c["narracaoVoz"] = assinatura
            c["narracaoMotor"] = motor_usado
            # A duração medida vai junto: com ela o player sabe quanto a cena
            # REALMENTE dura (a fala inteira + respiro) e a linha do tempo
            # vira determinística — sem ela, o relógio congela na beirada da
            # cena esperando a voz, e a duração do vídeo muda a cada
            # reprodução.
            if dur:
                c["narracaoDur"] = round(dur, 2)
            mudou = True

        if mudou:
            # Relê antes de gravar: o agente pode ter mexido no documento
            # enquanto a síntese rodava, e o trabalho dele vale mais.
            atual = ler(arq_id)
            if atual:
                cenas = (atual.get("doc") or {}).get("cenas") or []
                for i, c in enumerate(doc.get("cenas") or []):
                    if i < len(cenas) and c.get("narracaoAudio") \
                            and cenas[i].get("narracao") == c.get("narracao"):
                        cenas[i]["narracaoAudio"] = c["narracaoAudio"]
                        cenas[i]["narracaoVoz"] = c["narracaoVoz"]
                        if c.get("narracaoMotor"):
                            cenas[i]["narracaoMotor"] = c["narracaoMotor"]
                        if c.get("narracaoDur"):
                            cenas[i]["narracaoDur"] = c["narracaoDur"]
                gravar(atual)
    except Exception as e:
        sys.stderr.write("[voz] narração em arquivo falhou: %s\n" % e)
    finally:
        with _tranca_narracao:
            _narrando.discard(arq_id)


def narrar_em_arquivo(arq_id, doc):
    if not _tem_narracao(doc):
        return
    with _tranca_narracao:
        if arq_id in _narrando:
            return
        _narrando.add(arq_id)
    threading.Thread(target=_gravar_narracao, args=(arq_id,), daemon=True).start()


def mesclar(antigo, novo):
    """O documento novo por cima do antigo, chave a chave.

    Quem só quer trocar o tema manda {"tema":"papel"} — e é a coisa natural a
    mandar, porque o pedido era esse. Trocar o documento inteiro por isso
    apagava cenas, narração e trilha. Mesclando, o que não foi citado fica
    onde estava e o que foi citado vale: mandar cenas novas ainda troca as
    cenas, que é o ajuste de verdade."""
    if not isinstance(antigo, dict) or not isinstance(novo, dict):
        return novo
    saida = dict(antigo)
    saida.update(novo)
    return saida


def listar():
    saida = []
    for nome in os.listdir(ARQUIVOS):
        if not nome.endswith(".json"):
            continue
        try:
            with open(os.path.join(ARQUIVOS, nome), encoding="utf-8") as fh:
                a = json.load(fh)
            doc = a.get("doc") or {}
            saida.append({
                "id": a["id"],
                "titulo": a.get("titulo", "Sem título"),
                "ferramenta": a.get("ferramenta", "prisma"),
                "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
                "atualizado": a.get("atualizado", ""),
            })
        except Exception:
            pass
    return sorted(saida, key=lambda a: a["atualizado"], reverse=True)


# ------------------------------------------------------------------ mídia --

TIPOS_OK = {
    ".png": "imagem", ".jpg": "imagem", ".jpeg": "imagem", ".gif": "imagem",
    ".webp": "imagem", ".svg": "imagem", ".avif": "imagem",
    ".mp3": "audio", ".m4a": "audio", ".wav": "audio", ".ogg": "audio",
    ".aac": "audio", ".flac": "audio", ".opus": "audio", ".weba": "audio",
    ".mp4": "video", ".webm": "video", ".mov": "video",
}

# .weba é WebM só com áudio — é o que sai da gravação de voz pelo microfone
mimetypes.add_type("audio/webm", ".weba")
mimetypes.add_type("audio/mp4", ".m4a")


def nome_seguro(nome):
    base, ext = os.path.splitext(nome or "arquivo")
    return _normaliza(base, 48), ext.lower()


def listar_midia():
    fora = []
    for nome in sorted(os.listdir(MIDIA)):
        ext = os.path.splitext(nome)[1].lower()
        if ext not in TIPOS_OK:
            continue
        caminho_arq = os.path.join(MIDIA, nome)
        fora.append({
            "nome": nome,
            "url": "/midia/" + nome,
            "tipo": TIPOS_OK[ext],
            "bytes": os.path.getsize(caminho_arq),
        })
    return sorted(fora, key=lambda m: m["nome"])


def _nome_livre(base, ext):
    nome = base + ext
    if os.path.exists(os.path.join(MIDIA, nome)):
        nome = base + "-" + uuid.uuid4().hex[:4] + ext
    return nome


def guardar_midia_bruta(nome_pedido, fluxo, tamanho):
    """Grava o arquivo lendo o corpo direto do socket, sem passar por base64.

    Um vídeo gravado no celular tem centenas de MB. Em base64 ele cresce um
    terço, vira uma linha só de JSON e precisa caber INTEIRO na memória três
    vezes — no texto, no decodificado e no que o json guarda. O estúdio
    engasgava antes de gravar um byte, e o dono só via "falhou".

    Aqui o corpo desce em pedaços de 1 MB direto para o disco: o consumo não
    depende do tamanho do arquivo.
    """
    base, ext = nome_seguro(nome_pedido)
    if ext not in TIPOS_OK:
        return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400
    if tamanho <= 0:
        return {"erro": "corpo vazio"}, 400
    limite = LIMITE_VIDEO if TIPOS_OK[ext] == "video" else LIMITE_MIDIA
    if tamanho > limite:
        return {"erro": "arquivo acima de %d MB" % (limite // 1048576)}, 413

    nome = _nome_livre(base, ext)
    destino = os.path.join(MIDIA, nome)
    escrito = 0
    try:
        with open(destino, "wb") as fh:
            while escrito < tamanho:
                pedaco = fluxo.read(min(1048576, tamanho - escrito))
                if not pedaco:
                    break
                fh.write(pedaco)
                escrito += len(pedaco)
    except Exception as erro:
        try: os.remove(destino)
        except OSError: pass
        return {"erro": "não consegui gravar: %s" % erro}, 500

    if escrito != tamanho:
        # Meio arquivo é pior que nenhum: o <video> abre, mostra o começo e
        # corta no meio da frase, e nada denuncia de onde veio.
        try: os.remove(destino)
        except OSError: pass
        return {"erro": "a transferência parou em %d de %d bytes" % (escrito, tamanho)}, 400

    return {"nome": nome, "url": "/midia/" + nome,
            "tipo": TIPOS_OK[ext], "bytes": escrito}, 201


def guardar_midia(dados):
    """Recebe {nome, dados:"data:...;base64,..."} e grava em midia/."""
    bruto = dados.get("dados") or ""
    m = re.match(r"^data:([^;]*);base64,(.*)$", bruto, re.S)
    if not m:
        return {"erro": "envie 'dados' como data URI base64"}, 400

    base, ext = nome_seguro(dados.get("nome"))
    if ext not in TIPOS_OK:
        adivinhado = mimetypes.guess_extension(m.group(1) or "") or ""
        ext = adivinhado.lower()
        if ext not in TIPOS_OK:
            return {"erro": "formato não aceito", "aceitos": sorted(TIPOS_OK)}, 400

    try:
        conteudo = base64.b64decode(m.group(2), validate=True)
    except Exception:
        return {"erro": "base64 inválido"}, 400

    limite = LIMITE_VIDEO if TIPOS_OK[ext] == "video" else LIMITE_MIDIA
    if len(conteudo) > limite:
        return {"erro": "arquivo acima de %d MB" % (limite // 1048576)}, 413

    nome = _nome_livre(base, ext)
    with open(os.path.join(MIDIA, nome), "wb") as fh:
        fh.write(conteudo)

    return {"nome": nome, "url": "/midia/" + nome,
            "tipo": TIPOS_OK[ext], "bytes": len(conteudo)}, 201


# ------------------------------------------------------------ análise de áudio --
#
# "Ele vai cortar as pausas demoradas no vídeo."
#
# Para cortar as pausas é preciso SABER onde elas estão, e ninguém aqui sabe
# ler o áudio de um .mp4: o PrimoCode não tem dependência nenhuma, e o Python
# da máquina do usuário lê WAV e mais nada. ffmpeg resolveria — e seria mais
# uma instalação de 80 MB para quem só quer editar um vídeo.
#
# Quem sabe decodificar MP4, MOV e webm já está aberto na mesa: o navegador.
# `decodeAudioData` faz isso desde sempre, em qualquer sistema, sem instalar
# coisa alguma. Então a conta roda lá, e o servidor só segura o pedido e a
# resposta enquanto isso — é um quadro de recados entre o agente e a aba.
#
# Fica na memória de propósito: é resultado de trabalho, não trabalho. Se o
# estúdio reiniciar, pedir de novo custa segundos.
ANALISES = {}
LIMITE_ANALISES = 40


def criar_analise(dados):
    arquivo = (dados or {}).get("arquivo") or ""
    if not arquivo.startswith("/midia/"):
        return {"erro": 'passe "arquivo" como a URL que /api/midia devolveu (ex.: "/midia/x.mp4")'}, 400
    nome = unquote(arquivo[len("/midia/"):])
    if nome != os.path.basename(nome) or not os.path.exists(os.path.join(MIDIA, nome)):
        return {"erro": "não achei %s em midia/" % arquivo}, 404

    # Podar antes de inserir: sem isto uma sessão longa acumula análises que
    # ninguém mais vai ler.
    if len(ANALISES) >= LIMITE_ANALISES:
        for velho in sorted(ANALISES, key=lambda k: ANALISES[k]["criado"])[:len(ANALISES) - LIMITE_ANALISES + 1]:
            ANALISES.pop(velho, None)

    # `manter` e `repetida` viajam SEM valor padrão aqui, e isso é de propósito:
    # quem decide quanto de pausa fica é a página (ela conhece o tamanho de cada
    # uma), e um padrão escrito também deste lado seria um segundo dono da mesma
    # escolha — os dois divergem na primeira mudança e o que vale passa a ser o
    # que ninguém lembra de olhar.
    pedido = dados or {}
    ident = uuid.uuid4().hex[:10]
    ANALISES[ident] = {
        "id": ident, "arquivo": arquivo, "estado": "pedido",
        "silencio": float(pedido.get("silencio") or 0.45),
        "pausa": float(pedido.get("pausa") or 0.6),
        "manter": None if pedido.get("manter") is None else float(pedido["manter"]),
        "repetida": pedido.get("repetida") is not False,
        "criado": time.time(), "resultado": None,
    }
    return {"id": ident, "pagina": "/analise#" + ident, "estado": "pedido"}, 201


def gravar_analise(ident, dados):
    tarefa = ANALISES.get(ident)
    if not tarefa:
        return {"erro": "análise não encontrada"}, 404
    if (dados or {}).get("erro"):
        tarefa["estado"] = "erro"
        tarefa["resultado"] = {"erro": str(dados["erro"])[:300]}
    else:
        tarefa["estado"] = "pronto"
        # ESTE DICIONÁRIO É UMA PENEIRA, e campo que não está aqui morre em
        # silêncio. Foi assim que a lista de falas repetidas sumiu no dia em que
        # a página passou a devolvê-la: o vídeo saía com o tropeço já cortado e
        # nada na tela dizia o que tinha sido tirado. Campo novo na página entra
        # AQUI junto, ou não existe.
        tarefa["resultado"] = {
            "duracao": dados.get("duracao"),
            "trechos": dados.get("trechos") or [],
            "pausas": dados.get("pausas") or [],
            "repetidos": dados.get("repetidos") or [],
            "cortado": dados.get("cortado"),
        }
    return {"ok": True, "estado": tarefa["estado"]}, 200


# ------------------------------------------------------- revisão de monotonia --
#
# "Ele não alterna entre os temas, usa um tema só pra todos os vídeos, e o
# vídeo sempre tem a mesma estrutura. Fica tudo genérico."
#
# O estúdio tem doze temas, onze fundos, oito transições e seis trilhas. O
# agente usa uma fatia estreita e repete — porque a spec tem UM exemplo
# completo e modelo ancora em exemplo. Conselho escrito na spec ajuda pouco
# quando ela nem sempre é relida.
#
# Então quem revisa é o próprio estúdio, na hora de gravar. Isto NÃO recusa
# nada: um vídeo monótono continua sendo um vídeo, e recusar conteúdo válido
# seria pior que o problema. O que ele faz é devolver o que dá para melhorar,
# em texto que o agente lê como resultado da tool — do mesmo jeito que
# validar_doc ensina o formato quando o tipo está errado.
def revisar_monotonia(doc, ferramenta):
    if ferramenta not in ("corte", "prisma") or not isinstance(doc, dict):
        return []
    cenas = [c for c in (doc.get("cenas") or []) if isinstance(c, dict)]
    if len(cenas) < 3:
        return []                       # peça curta demais para julgar

    avisos = []

    fundos = [c.get("fundo") for c in cenas]
    distintos = {f if isinstance(f, str) else "?" for f in fundos if f}
    if len(distintos) <= 1:
        avisos.append(
            "todas as %d cenas usam o mesmo fundo%s. São onze: malha, brilho, grade, "
            "pontos, suave, vinheta, aurora, nevoa, ondas, raios, xadrez. Fundo igual "
            "em tudo é o que faz o vídeo parecer template."
            % (len(cenas), " (" + list(distintos)[0] + ")" if distintos else " — nenhum"))

    trs = [c.get("transicao") for c in cenas[1:] if c.get("transicao")]
    if len(trs) >= 2 and len(set(trs)) == 1:
        avisos.append(
            'toda transição é "%s". São oito: corte, fade, deslizar, subir, zoom, '
            "limpar, persiana, flash. Tudo igual parece slide, não vídeo." % trs[0])

    if ferramenta == "corte" and not doc.get("trilha"):
        avisos.append(
            "o vídeo está sem trilha. Oito estilos gerados existem (pulso, ambiente, "
            "lofi, epico, alegre, tenso, suave, foco) e nenhum custa arquivo nem licença. "
            "Se alguém FALA no vídeo, use suave ou foco: as duas foram feitas para ficar "
            "embaixo de uma voz. Escolha "
            "pelo assunto.")

    duracoes = [c.get("duracao") for c in cenas if isinstance(c.get("duracao"), (int, float))]
    if len(duracoes) >= 4 and len(set(duracoes)) == 1:
        avisos.append(
            "todas as cenas duram %s s. Ritmo igual do começo ao fim cansa: gancho "
            "curto, argumento mais longo, fecho curto." % duracoes[0])

    anims = []
    for c in cenas:
        for e in (c.get("elementos") or []):
            if isinstance(e, dict) and e.get("anim"):
                anims.append(e["anim"])
    if len(anims) >= 4 and len(set(anims)) == 1:
        avisos.append(
            'toda animação de entrada é "%s". Varie: subir, surgir, esquerda, direita, '
            "zoom, desfoque, cair, girar, saltar, revelar, cortina, elastico." % anims[0])

    # ── O QUE FALTA, E NÃO SÓ O QUE SE REPETE ───────────────────────────
    #
    #   "Falta de efeitos visuais: não adiciona efeitos, não insere ícones ou
    #    elementos visuais de apoio no vídeo final."
    #
    # Os avisos acima cobram VARIEDADE — eles só disparam quando alguma coisa
    # foi usada e usada sempre igual. Um vídeo que não usa NADA passava por
    # todos eles calado, e era esse o vídeo do relato: texto sobre fundo, do
    # primeiro segundo ao último. O que falta não aparece contando repetição;
    # aparece contando ausência.
    todos = [e for c in cenas for e in (c.get("elementos") or []) if isinstance(e, dict)]
    tipos = {e.get("tipo") for e in todos}

    if not doc.get("acabamento") and not any(c.get("acabamento") for c in cenas):
        avisos.append(
            "nenhuma cena tem acabamento. É a textura por cima do quadro inteiro "
            "(grao, vinheta, scanlines, luz, brilho, moldura) e é o que tira a cara "
            'de template: "acabamento": "grao" no documento vale para o vídeo todo.')

    apoio = {"icone", "selo", "forma", "grafico", "progresso", "dispositivo",
             "conversa", "passos", "opcoes", "numeros", "avatar", "citacao"}
    if not (tipos & apoio):
        avisos.append(
            "o vídeo é só texto e imagem: nenhum elemento de apoio em %d cenas. "
            'Um "icone" ao lado do argumento (raio, alvo, foguete, escudo, relogio, '
            "lampada, cadeado, crescimento, pessoas, dinheiro, globo, fogo… ou um "
            'DESENHADO por você, com "caminho": path SVG numa caixa 24x24), um '
            '"selo" de estado, um "numeros" com "contar": true — é isto que faz a '
            "tela parecer produzida em vez de um slide com legenda." % len(cenas))

    movimento = any(e.get("chaves") or e.get("movimento") for e in todos)
    if not movimento and not anims:
        avisos.append(
            "nada se mexe: nenhum elemento tem anim, chaves ou movimento. Imagem "
            'parada em vídeo denuncia preguiça — toda "imagem" leva "movimento" '
            "(aproximar, afastar, esquerda, direita, cima), e uma ou duas peças por "
            "vídeo levam chaves de animação.")

    # ── ANÚNCIO DE MARCA SEM PRODUTO E SEM ASSINATURA ───────────────────
    #
    #   "Ainda não tá bom, tá genérico. Quero nesse nível." — o dono, sobre um
    #   comercial de fintech mandado como referência.
    #
    # O que separava a peça daqui daquela não era tema nem cor: era não ter o
    # PRODUTO na tela e não assinar no fim. Quem declarou uma `marca` está
    # fazendo peça de marca, e aí estas duas ausências são o defeito inteiro.
    if doc.get("marca") and len(cenas) >= 4:
        if not (tipos & {"cartao", "app", "vitrine", "dispositivo", "imagem", "clipe"}):
            avisos.append(
                "esta peça tem marca declarada e nenhuma cena mostra o PRODUTO — são %d cenas "
                "de texto sobre fundo. Um anúncio de cartão mostra o cartão (`cartao`), o app "
                "funcionando dentro do celular (`dispositivo` + `app`) e a linha de produtos "
                "(`vitrine`). É essa ausência que se lê como genérico." % len(cenas))
        if "assinatura" not in tipos:
            avisos.append(
                'o anúncio acaba sem assinar. A última cena de uma peça de marca é '
                '{"tipo":"assinatura","nome":"…","sobre":"marca"} — o logo sozinho, grande, '
                "sobre a cor da casa. Sem ela a peça termina numa frase e ninguém fica "
                "sabendo quem falou.")

    # Só a voz com tela montada: existe e quase nunca é lembrado, porque é o
    # contrário do `mudo` e o contrário não estava no vocabulário até agora.
    clipes = [e for e in todos if e.get("tipo") == "clipe"]
    if clipes and len(clipes) >= 3 and not any(
            e.get("apenas") == "som" or e.get("som") is True for e in clipes):
        avisos.append(
            "todos os %d clipes mostram a gravação. Se o que vale é o que ele DIZ, "
            '"apenas": "som" no clipe mantém a voz e libera o quadro para a tela que '
            "você montar por cima — é o vídeo dele virando narração da sua peça. "
            "Vale para a parte em que ele explica, não para a em que ele aparece."
            % len(clipes))

    return avisos


def criar(dados):
    # O cliente é um agente de IA falando JSON cru — um tipo errado aqui
    # (titulo como lista, por exemplo) não pode virar AttributeError sem
    # resposta HTTP nenhuma; melhor um 400 com o motivo.
    ferramenta_bruta = dados.get("ferramenta")
    if ferramenta_bruta is not None and not isinstance(ferramenta_bruta, str):
        raise ValueError("ferramenta precisa ser texto")
    ferramenta = (ferramenta_bruta or "prisma").lower()
    if ferramenta not in POR_SLUG:
        return None

    titulo_bruto = dados.get("titulo")
    if titulo_bruto is not None and not isinstance(titulo_bruto, str):
        raise ValueError("titulo precisa ser texto")
    titulo = (titulo_bruto or "Sem título").strip()

    doc = dados.get("doc")
    if doc is not None and not isinstance(doc, dict):
        raise ValueError("doc precisa ser um objeto")

    # ── O doc torto não pode virar arquivo ───────────────────────────────
    # Quem cria é um agente de IA, e quando ele não viu a spec ele INVENTA a
    # estrutura. Antes o arquivo era gravado assim mesmo: o player não achava
    # cena nenhuma e o "vídeo" saía uma tela preta — o usuário viu exatamente
    # isso. Recusar com o formato certo na mensagem transforma o erro mudo em
    # correção na rodada seguinte.
    if doc:
        problema = validar_doc(POR_SLUG[ferramenta]["modelo"], doc)
        if problema:
            raise ValueError(problema)

    preparar_voz(doc)
    arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
    salvo = gravar({
        "id": arq_id,
        "ferramenta": ferramenta,
        "titulo": titulo,
        "doc": doc or exemplo(ferramenta),
    })
    narrar_em_arquivo(arq_id, salvo.get("doc") or {})
    return salvo


# Frases que só existem nos EXEMPLOS das specs. Documento que chega com uma
# delas é o agente copiando o exemplo em vez de escrever o conteúdo do pedido
# — um usuário pediu um vídeo da empresa dele e recebeu cenas sobre "bandeira
# tarifária", o assunto fictício do exemplo do corte.
# Só frases INCONFUNDÍVEIS entram aqui: "energia solar no brasil" seria um
# pedido legítimo possível, e bloquear pedido legítimo é pior que deixar um
# plágio passar. Estas seis não existem fora do exemplo.
FRASES_DE_EXEMPLO = [
    "bandeira tarifária em 12 meses", "bandeira tarifaria em 12 meses",
    "não foi o seu chuveiro", "nao foi o seu chuveiro",
    "saturadas no nordeste", "usina gerando sem escoar",
    "segue para entender a próxima", "segue para entender a proxima",
]


def _texto_do_doc(doc):
    """Todo texto visível do documento, achatado, para inspeção."""
    pedacos = []

    def anda(x):
        if isinstance(x, dict):
            for v in x.values():
                anda(v)
        elif isinstance(x, list):
            for v in x:
                anda(v)
        elif isinstance(x, str):
            pedacos.append(x)

    anda(doc)
    return " ".join(pedacos).lower()


# Os temas que o render.css de fato define. Fora desta lista não existe cor
# nenhuma — foi o que aconteceu com "noite", que passou como se fosse tema.
TEMAS_VALIDOS = {"claro", "meia-noite", "papel", "neon", "brasa", "floresta",
                 "retro", "oceano", "doce", "mono", "ambar", "grafite"}


def _tema_invalido(tema, onde=""):
    if tema is None:
        return None
    if isinstance(tema, str) and tema in TEMAS_VALIDOS:
        return None
    return ('tema %r%s não existe — a peça sairia sem cor nenhuma. Os temas são: %s. '
            'Para escuro use "meia-noite".'
            % (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))


def _clipe_invalido(e, i, j):
    """Um `clipe` é um TRECHO da gravação do usuário: arquivo, começo e fim.

    Os três erros abaixo saem calados. `src` faltando ou apontando para o
    disco da pessoa dá <video> que nunca carrega — cena preta, sem aviso.
    `ate` antes de `de` dá trecho de duração negativa, que vira cena de zero
    segundo. E `de`/`ate` em texto ("00:12") faz a conta de duração virar
    concatenação, que foi como já saiu um vídeo de 00:00 daqui.
    """
    onde = "elemento %d da cena %d" % (j + 1, i + 1)
    src = e.get("src")
    if not isinstance(src, str) or not src.strip():
        return ('%s é "clipe" mas está sem "src". O caminho é a URL que /api/midia devolveu '
                '(ex.: "/midia/gravacao.mp4"). Envie o arquivo primeiro; caminho do disco '
                '("/Users/...", "C:\\\\...") o navegador não abre.' % onde)
    if re.match(r"^([a-zA-Z]:[\\/]|/(Users|home|mnt|var|tmp)/|file:)", src.strip()):
        return ('%s aponta para o disco (%r). O navegador não lê caminho de arquivo: envie o vídeo '
                'para /api/midia e use a URL "/midia/..." que ele devolve.' % (onde, src[:60]))
    for campo in ("de", "ate"):
        v = e.get(campo)
        if v is None:
            continue
        if isinstance(v, bool) or not isinstance(v, (int, float)):
            return ('%s tem "%s": %r, que não é número. São SEGUNDOS do arquivo original: '
                    '"de": 12.5, "ate": 19.' % (onde, campo, v))
        if v < 0:
            return '%s tem "%s": %r — segundo negativo não existe.' % (onde, campo, v)
    # "apenas": "som" entrega a VOZ da gravação com a tela montada por cima.
    # Junto com "mudo": true ele não entrega nada: sem imagem porque foi
    # escondido, sem som porque foi silenciado. A cena sairia com a duração do
    # trecho e o conteúdo de um silêncio — que é exatamente o tipo de defeito
    # que só aparece quando alguém assiste o vídeo pronto.
    if (e.get("apenas") == "som" or e.get("som") is True) and e.get("mudo"):
        return ('%s tem "apenas": "som" com "mudo": true. Um cancela o outro: o clipe some da '
                'tela E fica sem áudio, e a cena vira um silêncio do tamanho do trecho. Para a '
                'voz dele com a sua tela por cima, tire o "mudo".' % onde)
    de, ate = e.get("de"), e.get("ate")
    if isinstance(de, (int, float)) and isinstance(ate, (int, float)) and ate <= de:
        return ('%s tem "ate": %r antes ou igual a "de": %r. O trecho ficaria com duração zero ou '
                'negativa e a cena sairia vazia.' % (onde, ate, de))
    return None


def validar_doc(modelo, doc):
    """None quando o doc serve; senão, a frase que ENSINA o formato certo.

    A mensagem repete a estrutura mínima de propósito: ela volta para o agente
    como resultado da tool, e é a única chance de ele se corrigir sem o
    usuário ver um vídeo preto."""
    texto = _texto_do_doc(doc)
    for frase in FRASES_DE_EXEMPLO:
        if frase in texto:
            return ('o documento contém "%s" — isso é texto do EXEMPLO da spec, não do pedido '
                    'do usuário. O exemplo ensina o FORMATO; o conteúdo é seu: reescreva cada '
                    'cena com os fatos da tarefa (a pesquisa que você fez) e crie de novo.' % frase)

    # Os tipos que o render.js de fato desenha. Elemento de tipo inventado
    # ("titulo", "subtitulo", "video"...) passava na validação e caía no
    # default do renderizador: texto cru de 16px num canvas de 1920 — ou nada,
    # quando o texto estava noutro campo. Era o "vídeo com música mas tudo
    # preto": documento válido na forma, invisível na tela.
# O kit de anúncio (cartao, app, vitrine, assinatura) entra na lista abaixo.
#
# A LISTA É LIDA POR UM TESTE (`vocabulario.test.js`), que a recorta com uma
# expressão regular e pega as palavras — então comentário DENTRO dela vira
# "tipo que o render precisa desenhar", e o teste passa a cobrar `produto`,
# `linha` e `fim`. Texto sobre os tipos mora aqui fora.
    TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
                     "citacao", "forma", "imagem", "icone", "grafico", "no", "conector",
                     "objeto3d", "clipe",
                     "cartao", "app", "vitrine", "assinatura",
                     "selo", "progresso", "avatar", "dispositivo", "conversa",
                     "passos", "opcoes"}
    # Os sete últimos são o vocabulário do vídeo de referência. O comentário
    # mora aqui fora porque test/vocabulario.test.js lê esta lista com
    # expressão regular, comparando com a do render.js — palavra de comentário
    # dentro das chaves entrava na lista como se fosse um tipo válido.

    # Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
    # não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
    # sem identidade nenhuma. Recusar aqui devolve a lista certa ao agente.
    problema = _tema_invalido(doc.get("tema"))
    if problema:
        return problema

    if modelo == "cena":
        cenas = doc.get("cenas")
        if not isinstance(cenas, list) or not cenas:
            return ('doc sem "cenas". O formato é: {"tema":"...", "cenas":[{"layout":"centro",'
                    '"duracao":3,"elementos":[{"tipo":"texto","texto":"..."}]}]}. '
                    'Leia studio_spec da ferramenta e refaça.')
        for i, c in enumerate(cenas):
            if not isinstance(c, dict):
                return "cena %d não é um objeto" % (i + 1)
            els = c.get("elementos")
            if not isinstance(els, list) or not els:
                return ('cena %d sem "elementos". Cada cena precisa de elementos:[...] — '
                        'ex.: {"tipo":"texto","texto":"..."}. Leia studio_spec e refaça.' % (i + 1))
            problema = _tema_invalido(c.get("tema"), " (cena %d)" % (i + 1))
            if problema:
                return problema
            dur = c.get("duracao")
            if dur is not None and (isinstance(dur, bool) or not isinstance(dur, (int, float))):
                return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
                        '"duracao": 3' % (i + 1, dur))
            # Duração zero passava: era número, afinal. E um vídeo cujas cenas
            # somam zero é o "vídeo de 0 segundos" que o usuário viu mais de
            # uma vez — arquivo válido, conteúdo inalcançável.
            if isinstance(dur, (int, float)) and not isinstance(dur, bool) and dur <= 0:
                return ('cena %d tem "duracao": %r. Zero (ou negativo) faz o vídeo inteiro durar '
                        'nada — foi assim que saiu um vídeo de 0 segundos. Use pelo menos 1.'
                        % (i + 1, dur))
            for j, e in enumerate(els):
                if not isinstance(e, dict) or not e.get("tipo"):
                    return 'elemento %d da cena %d sem "tipo" (texto, imagem, lista, numeros, forma...)' % (j + 1, i + 1)
                if e.get("tipo") not in TIPOS_VALIDOS:
                    return ('elemento %d da cena %d tem tipo "%s", que NÃO existe. Os tipos válidos são: %s. '
                            'Para título use {"tipo":"texto","papel":"titulo","texto":"..."}.'
                            % (j + 1, i + 1, e.get("tipo"), ", ".join(sorted(TIPOS_VALIDOS))))
                if e.get("tipo") == "texto" and not (e.get("texto") or "").strip():
                    return ('elemento %d da cena %d é "texto" mas o campo "texto" está vazio — o conteúdo '
                            'vai SEMPRE no campo "texto".' % (j + 1, i + 1))
                # Clipe: o trecho da gravação do usuário. Erra-se em silêncio de
                # três formas, e as três dão vídeo preto ou corte que não corta.
                if e.get("tipo") == "clipe":
                    problema = _clipe_invalido(e, i, j)
                    if problema:
                        return problema
    elif modelo == "documento":
        blocos = doc.get("blocos")
        if not isinstance(blocos, list) or not blocos:
            return ('doc sem "blocos". O formato é: {"tema":"papel","blocos":[{"tipo":"titulo",'
                    '"texto":"..."},{"tipo":"paragrafo","texto":"..."}]}. Leia studio_spec e refaça.')
    elif modelo == "planilha":
        abas = doc.get("abas")
        if not isinstance(abas, list) or not abas:
            return ('doc sem "abas". O formato é: {"abas":[{"nome":"...","colunas":[...],'
                    '"linhas":[[...]]}]}. Leia studio_spec e refaça.')
    return None


def exemplo(ferramenta):
    """Documento inicial de cada ferramenta — o mínimo que já fica bonito."""
    if ferramenta == "prosa":
        return {
            "tema": "papel",
            "blocos": [
                {"tipo": "titulo", "texto": "Título do documento"},
                {"tipo": "paragrafo", "texto": "Comece a escrever por aqui."},
            ],
        }
    if ferramenta == "grade":
        return {
            "tema": "claro",
            "abas": [{
                "nome": "Planilha 1",
                "colunas": ["Item", "Valor"],
                "linhas": [["Exemplo", 100]],
            }],
        }
    if ferramenta == "tela":
        return {
            "tema": "meia-noite", "largura": 1080, "altura": 1080,
            "cenas": [{"layout": "livre", "fundo": "malha", "elementos": [
                {"tipo": "texto", "x": 100, "y": 440, "l": 880, "a": 200,
                 "texto": "Sua arte", "tamanho": 120, "peso": 700, "alinhar": "centro"},
            ]}],
        }
    if ferramenta == "corte":
        return {
            "tema": "meia-noite", "largura": 1920, "altura": 1080,
            "cenas": [
                {"layout": "capa", "duracao": 3, "fundo": "malha",
                 "elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Primeira cena"}]},
                {"layout": "capa", "duracao": 3, "fundo": "brilho",
                 "elementos": [{"tipo": "texto", "papel": "titulo", "texto": "Segunda cena"}]},
            ],
        }
    if ferramenta == "traco":
        return {
            "tema": "meia-noite", "largura": 1920, "altura": 1080,
            "cenas": [{"layout": "livre", "elementos": [
                {"id": "a", "tipo": "no", "x": 300, "y": 460, "l": 340, "a": 160, "texto": "Início"},
                {"id": "b", "tipo": "no", "x": 1100, "y": 460, "l": 340, "a": 160, "texto": "Fim"},
                {"tipo": "conector", "de": "a", "para": "b"},
            ]}],
        }
    return {
        "tema": "meia-noite", "largura": 1920, "altura": 1080,
        "cenas": [
            {"layout": "capa", "fundo": "malha", "elementos": [
                {"tipo": "texto", "papel": "chapeu", "texto": "PrimoCode Studio"},
                {"tipo": "texto", "papel": "titulo", "texto": "Título da apresentação"},
                {"tipo": "texto", "papel": "apoio", "texto": "Uma frase que explica do que se trata."},
            ]},
            {"layout": "titulo", "elementos": [
                {"tipo": "texto", "papel": "titulo", "texto": "Uma ideia por cena"},
                {"tipo": "lista", "itens": ["Primeiro ponto", "Segundo ponto", "Terceiro ponto"]},
            ]},
        ],
    }


# ------------------------------------------------------------------ manual --

def manual():
    """Junta ferramentas/*.md — a pasta é a fonte da verdade para a IA."""
    ordem = ["00-protocolo", "01-modelos"] + [f["slug"] for f in FERRAMENTAS_INFO]
    partes = ["PRIMOCODE STUDIO — MANUAL PARA AGENTES",
              "Servidor: http://localhost:%d" % PORTA, ""]
    for nome in ordem:
        p = os.path.join(FERRAMENTAS, nome + ".md")
        if os.path.exists(p):
            with open(p, encoding="utf-8") as fh:
                partes.append(fh.read().rstrip())
            partes.append("\n" + "=" * 70 + "\n")
    return "\n".join(partes)


# ----------------------------------------------------------------- handler --

def seguro(metodo):
    """Qualquer exceção não prevista na rota vira 500 em JSON, não conexão
    fechada sem resposta nenhuma — era o comportamento padrão do
    BaseHTTPRequestHandler para exceção não tratada num do_*, e o cliente (o
    agente de IA) não tem como interpretar uma conexão que só morre."""
    @functools.wraps(metodo)
    def envolto(self, *a, **kw):
        try:
            return metodo(self, *a, **kw)
        except Exception as e:
            try:
                self.js({"erro": "erro interno: %s" % e}, 500)
            except Exception:
                pass
    return envolto


def _picar(texto, limite):
    """Corta o texto em pedaços que o TTS aceita, sempre numa fronteira de
    palavra — cortar no meio da palavra faz a voz gaguejar na emenda."""
    palavras, atual, saida = texto.split(), "", []
    for w in palavras:
        if len(atual) + len(w) + 1 > limite and atual:
            saida.append(atual)
            atual = w
        else:
            atual = (atual + " " + w).strip()
    if atual:
        saida.append(atual)
    return saida or [texto[:limite]]


class Handler(SimpleHTTPRequestHandler):
    server_version = "PrimoCode/1.0"

    def __init__(self, *a, **kw):
        super().__init__(*a, directory=WEB, **kw)

    def log_message(self, fmt, *args):
        if "/api/" in (self.path or ""):
            sys.stderr.write("  %s %s\n" % (self.command, self.path))

    def end_headers(self):
        self.send_header("Cache-Control", "no-store, max-age=0")
        super().end_headers()

    # -------------------------------------------------------------- saídas --

    def js(self, obj, status=200):
        corpo = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(corpo)))
        self.end_headers()
        self.wfile.write(corpo)

    def txt(self, texto, ctype="text/plain; charset=utf-8", status=200, baixar=None):
        corpo = texto.encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(corpo)))
        if baixar:
            self.send_header("Content-Disposition", 'attachment; filename="%s"' % baixar)
        self.end_headers()
        self.wfile.write(corpo)

    def pagina(self, nome):
        with open(os.path.join(WEB, nome), encoding="utf-8") as fh:
            self.txt(fh.read(), "text/html; charset=utf-8")

    def corpo(self):
        n = int(self.headers.get("Content-Length") or 0)
        if not n:
            return {}
        try:
            return json.loads(self.rfile.read(n).decode("utf-8"))
        except Exception:
            return {}

    # Achado de segurança: POST/PUT/DELETE sem checagem de Origin é CSRF — uma
    # página de terceiro (aberta no MESMO navegador, sem relação nenhuma com o
    # Studio) consegue disparar um fetch() com Content-Type: text/plain (que
    # não exige preflight de CORS) contra http://127.0.0.1:7777 e o pedido
    # chega igual a um legítimo. Bloqueia quando o Origin existe e não é o
    # próprio servidor — sem Origin nenhum (cliente não-navegador, como o CLI
    # do PrimoCode via http nativo do Node) continua liberado, já que isso
    # nunca é o vetor de um navegador sendo usado contra o usuário.
    def origem_ok(self):
        origem = self.headers.get("Origin")
        if not origem:
            return True
        esperados = {
            "http://127.0.0.1:%d" % PORTA,
            "http://localhost:%d" % PORTA,
        }
        return origem in esperados

    # -------------------------------------------------------------- rotas --

    @seguro
    def do_GET(self):
        p = urlparse(self.path).path

        if p == "/":
            return self.pagina("home.html")
        if re.fullmatch(r"/t/[^/]+/[^/]+", p):
            return self.pagina("tool.html")
        if re.fullmatch(r"/v/[^/]+", p):
            return self.pagina("view.html")
        if p == "/analise":
            return self.pagina("analise.html")

        if p == "/api/manual":
            return self.txt(manual())
        if p == "/api/ferramentas":
            return self.js(FERRAMENTAS_INFO)

        m = re.fullmatch(r"/api/ferramentas/([a-z]+)", p)
        if m:
            info = POR_SLUG.get(m.group(1))
            if not info:
                return self.js({"erro": "ferramenta desconhecida"}, 404)
            doc = os.path.join(FERRAMENTAS, m.group(1) + ".md")
            texto = ""
            if os.path.exists(doc):
                with open(doc, encoding="utf-8") as fh:
                    texto = fh.read()
            return self.txt(texto or json.dumps(info, ensure_ascii=False, indent=2))

        if p == "/api/versao":
            return self.js({"assinatura": ASSINATURA})

        m = re.fullmatch(r"/api/analise/([a-z0-9]+)", p)
        if m:
            tarefa = ANALISES.get(m.group(1))
            if not tarefa:
                return self.js({"erro": "análise não encontrada"}, 404)
            return self.js(tarefa)

        if p == "/api/voz":
            return self.voz(urlparse(self.path).query)
        if p == "/api/voz/status":
            return self.voz_status()

        if p == "/api/midia":
            return self.js(listar_midia())
        if p.startswith("/midia/"):
            return self.servir_midia(unquote(p[len("/midia/"):]))

        if p == "/api/arquivos":
            return self.js(listar())

        m = re.fullmatch(r"/api/arquivos/([^/]+)", p)
        if m:
            a = ler(m.group(1))
            return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)

        m = re.fullmatch(r"/api/arquivos/([^/]+)/versoes", p)
        if m:
            if not ler(m.group(1)):
                return self.js({"erro": "não encontrado"}, 404)
            return self.js({"versoes": versoes(m.group(1))})

        m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
        if m:
            a = ler(m.group(1))
            if not a:
                return self.js({"erro": "não encontrado"}, 404)
            return self.exportar(a)

        return super().do_GET()

    # ── Voz ────────────────────────────────────────────────────────────
    # A narração inteira é decidida AQUI, no Python, e não no navegador.
    #
    # Antes o navegador tentava a voz neural do Edge por WebSocket e, quando
    # ela não subia, caía no speechSynthesis — o TTS da própria máquina, que
    # soa a computador. A voz do Edge parou de subir (a Microsoft responde
    # 403 na síntese), então na prática TODO vídeo saía com voz de robô. Era
    # o que o usuário estava ouvindo.
    #
    # A corrente agora é esta, e o navegador só consome o resultado:
    #   1. PIPER    neural, roda na máquina, offline, ilimitado, com escolha
    #               de voz — inclusive graves de verdade (voz.py mede e diz
    #               a frequência de cada uma). É o bom.
    #   2. VOICEBOX se o usuário tiver o app: voz clonável.
    #   3. GOOGLE   uma voz só, robótica, mas responde na hora sem instalar
    #               nada — é o que cobre enquanto o Piper baixa.
    #
    # O endpoint do Google recusa texto comprido, então a frase é cortada em
    # pedaços de até 190 caracteres, sempre numa fronteira de palavra, e os
    # MP3 são emendados. MP3 aceita concatenação de quadros: é feio, mas toca.
    def voz(self, query):
        from urllib.parse import parse_qs
        q = parse_qs(query or "")
        texto = (q.get("texto", [""])[0] or "").strip()
        idioma = (q.get("idioma", ["pt-BR"])[0] or "pt-BR")[:8]
        if not texto:
            return self.js({"erro": "sem texto"}, 400)
        if len(texto) > 4000:
            texto = texto[:4000]

        voz_pedida = (q.get("voz", [""])[0] or "").strip()
        instrucao = (q.get("instrucao", [""])[0] or "").strip()
        motor = (q.get("motor", [""])[0] or "").strip()
        grave = (q.get("grave", [""])[0] or "").lower() in ("1", "true", "sim")
        try:
            velocidade = float(q.get("velocidade", ["1"])[0])
        except ValueError:
            velocidade = 1.0

        tipo = "audio/mpeg"
        audio = b""

        # 0) ElevenLabs: a voz que o usuário aprovou nos vídeos. Só entra na
        #    corrente quando há chave configurada — e continua caindo para os
        #    motores locais se falhar, porque narração nunca fica muda.
        if motor in ("", "elevenlabs") and elevenlabs_lib.configurado():
            dados, _ = elevenlabs_lib.sintetizar(texto, {"voz": voz_pedida})
            if dados:
                audio = dados
                tipo = "audio/mpeg"
            elif motor == "elevenlabs":
                return self.js({"erro": "ElevenLabs não devolveu áudio — chave válida? cota?"}, 502)

        # 0.5) Edge TTS: neural, grátis, sem chave. Entra logo depois do
        #      ElevenLabs porque é a melhor voz gratuita da corrente — vozes
        #      da Microsoft, muito acima do Google. Precisa de internet.
        if not audio and motor in ("", "edge"):
            # Pacote ainda não instalado? ESPERA o pip (até 60 s) em vez de
            # cair para o Google. Quem pediu voz tem internet — é o que o Edge
            # precisa — e meio minuto é menos ruim que a voz de robô no vídeo.
            if not edge_lib.disponivel():
                edge_lib.instalar(esperar=60)
            dados, _ = edge_lib.sintetizar(texto, {"voz": voz_pedida,
                                                   "grave": grave, "velocidade": velocidade})
            if dados:
                audio = dados
                tipo = "audio/mpeg"
            elif motor == "edge":
                return self.js({"erro": "Edge TTS não devolveu áudio — sem internet ou pacote ausente?"}, 502)

        # 1) Piper: o motor local. Se ainda não baixou, começa a baixar agora
        #    e segue para o próximo — quem pediu narração hoje ouve o Google,
        #    e a partir da próxima já é voz de verdade.
        if not audio and motor not in ("google", "voicebox", "elevenlabs", "edge"):
            try:
                if voz_lib.pronta(voz_pedida or None):
                    audio = voz_lib.sintetizar(texto, voz_pedida or None,
                                               grave=grave, velocidade=velocidade) or b""
                    if audio:
                        tipo = "audio/wav"
                else:
                    voz_lib.instalar(voz_pedida or None)
            except Exception as e:
                sys.stderr.write("[voz] Piper falhou (%s); seguindo a corrente\n" % e)
                audio = b""

        # 2) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
        #    sem depender de nada na internet.
        if not audio and motor != "google" and self._voicebox_vivo():
            try:
                audio, tipo = self._voz_voicebox(texto, voz_pedida, instrucao)
            except Exception as e:
                sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
                audio = b""
        # 3) Google: uma voz só, mas responde sempre. É a EXCEÇÃO, e vai
        #    marcada: o cabeçalho diz qual motor falou, para o player e o
        #    agente saberem que a voz saiu pela reserva — em vez de a pessoa
        #    descobrir ouvindo.
        motor_usado = "elevenlabs" if (audio and tipo == "audio/mpeg" and elevenlabs_lib.configurado()) else \
            ("edge" if audio and tipo == "audio/mpeg" else ("piper" if audio and tipo == "audio/wav" else "voicebox" if audio else "google"))
        if not audio:
            tipo = "audio/mpeg"
            try:
                audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
            except Exception as e:                  # rede, bloqueio, mudança do serviço
                return self.js({"erro": "voz indisponível: %s" % e}, 502)
            sys.stderr.write("[voz] RESERVA: a narração saiu pelo Google (edge-tts: %s)\n"
                             % (edge_lib.ultimo_erro() or "não instalado"))
        if not audio:
            return self.js({"erro": "voz não devolveu áudio"}, 502)

        self.send_response(200)
        self.send_header("X-Voz-Motor", motor_usado)
        self.send_header("Content-Type", tipo)
        self.send_header("Content-Length", str(len(audio)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(audio)

    # ── Voicebox ───────────────────────────────────────────────────────
    # https://voicebox.sh — app local, código aberto, de graça e sem limite,
    # com clonagem de voz. É a melhor voz que existe para este uso e não
    # depende de serviço nenhum na internet: roda na máquina do usuário.
    #
    # A ponte é FEITA AQUI, e não no navegador, por causa do CORS: o Voicebox
    # só libera as portas dele (5173 e 17493), e o Studio serve em 7777 — do
    # navegador o pedido morreria antes de sair.
    #
    # Não está instalado? A rota cai para o Google, e ninguém fica sem voz.
    VOICEBOX = "http://127.0.0.1:17493"

    def voz_status(self):
        """O que está disponível para narrar, para a interface não oferecer o
        que não existe — e para ela poder mostrar o download em vez de fingir
        que já está tudo pronto."""
        vivo = self._voicebox_vivo()
        vozes = []
        if vivo:
            try:
                with urllib.request.urlopen(self.VOICEBOX + "/profiles", timeout=6) as r:
                    vozes = [{"id": v.get("id"), "nome": v.get("name"), "idioma": v.get("language")}
                             for v in json.loads(r.read().decode("utf-8"))]
            except Exception:
                vozes = []
        return self.js({
            "elevenlabs": elevenlabs_lib.estado(),
            "edge": edge_lib.estado(),
            "piper": voz_lib.estado(),
            "voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
            "google": {"ativo": True},
        })

    def _voicebox_vivo(self):
        try:
            with urllib.request.urlopen(self.VOICEBOX + "/health", timeout=2):
                return True
        except Exception:
            return False

    def _voicebox_perfil(self, pedido):
        """A voz a usar: a que o usuário pediu pelo nome, ou a primeira que
        houver. Sem perfil não há geração — o Voicebox exige um."""
        req = urllib.request.Request(self.VOICEBOX + "/profiles")
        with urllib.request.urlopen(req, timeout=8) as r:
            perfis = json.loads(r.read().decode("utf-8"))
        if not perfis:
            raise RuntimeError("Voicebox está no ar mas sem nenhuma voz criada")
        if pedido:
            alvo = pedido.strip().lower()
            for p in perfis:
                if alvo in (p.get("name") or "").lower() or alvo == p.get("id"):
                    return p
        # Sem escolha explícita, prefere uma voz em português.
        for p in perfis:
            if (p.get("language") or "").startswith("pt"):
                return p
        return perfis[0]

    def _voz_voicebox(self, texto, voz, instrucao):
        perfil = self._voicebox_perfil(voz)
        corpo = json.dumps({
            "profile_id": perfil["id"],
            "text": texto[:50000],
            "language": "pt",
            # A instrução de entrega é o que dá voz de cinema em vez de voz de
            # leitura. O Voicebox aceita em linguagem natural, até 500 chars.
            "instruct": (instrucao or "Narração institucional: voz grave, ritmo pausado, tom seguro.")[:500],
            "max_chunk_chars": 800,
            "crossfade_ms": 50,
            "normalize": True,
        }).encode("utf-8")
        req = urllib.request.Request(self.VOICEBOX + "/generate", data=corpo,
                                     headers={"Content-Type": "application/json"})
        with urllib.request.urlopen(req, timeout=30) as r:
            g = json.loads(r.read().decode("utf-8"))
        gid = g.get("id")
        if not gid:
            raise RuntimeError("Voicebox não devolveu id de geração")

        # /generate volta na hora com status "generating" e sintetiza em
        # segundo plano — então é preciso esperar de verdade.
        prazo = time.time() + 180
        estado = g.get("status")
        while estado in (None, "generating", "pending", "processing"):
            if time.time() > prazo:
                raise RuntimeError("Voicebox demorou demais")
            time.sleep(1.0)
            with urllib.request.urlopen(self.VOICEBOX + "/generate/%s/status" % gid, timeout=8) as r:
                estado = (json.loads(r.read().decode("utf-8")) or {}).get("status")
        if estado != "completed":
            raise RuntimeError("Voicebox falhou (%s)" % estado)

        with urllib.request.urlopen(self.VOICEBOX + "/audio/%s" % gid, timeout=30) as r:
            return r.read(), (r.headers.get("Content-Type") or "audio/wav")

    def _trecho_de_voz(self, texto, idioma):
        url = "https://translate.google.com/translate_tts?" + urlencode({
            "ie": "UTF-8", "q": texto, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,
        })
        req = urllib.request.Request(url, headers={
            # Sem cara de navegador o serviço devolve 403.
            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
                          "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36",
            "Referer": "https://translate.google.com/",
        })
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.read()

    def servir_midia(self, nome):
        """Serve com suporte a Range — sem isso o áudio não busca no <audio>."""
        if "/" in nome or ".." in nome:
            return self.js({"erro": "caminho inválido"}, 400)
        caminho_arq = os.path.join(MIDIA, nome)
        if not os.path.isfile(caminho_arq):
            return self.js({"erro": "não encontrado"}, 404)

        tamanho = os.path.getsize(caminho_arq)
        ctype = mimetypes.guess_type(caminho_arq)[0] or "application/octet-stream"
        faixa = self.headers.get("Range")
        inicio, fim = 0, tamanho - 1
        status = 200

        if faixa:
            m = re.match(r"bytes=(\d*)-(\d*)", faixa)
            if m:
                if not m.group(1) and m.group(2):
                    # "bytes=-500" é sufixo HTTP válido: os ÚLTIMOS 500 bytes,
                    # não os primeiros 500 — sem este caso à parte, group(2)
                    # virava fim absoluto e o pedido servia o pedaço errado.
                    inicio = max(0, tamanho - int(m.group(2)))
                    fim = tamanho - 1
                else:
                    if m.group(1):
                        inicio = int(m.group(1))
                    if m.group(2):
                        fim = min(int(m.group(2)), tamanho - 1)
                if inicio > fim or inicio >= tamanho:
                    self.send_response(416)
                    self.send_header("Content-Range", "bytes */%d" % tamanho)
                    self.end_headers()
                    return
                status = 206

        self.send_response(status)
        self.send_header("Content-Type", ctype)
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("Content-Length", str(fim - inicio + 1))
        if status == 206:
            self.send_header("Content-Range", "bytes %d-%d/%d" % (inicio, fim, tamanho))
        self.end_headers()

        with open(caminho_arq, "rb") as fh:
            fh.seek(inicio)
            restante = fim - inicio + 1
            while restante > 0:
                pedaco = fh.read(min(262144, restante))
                if not pedaco:
                    break
                self.wfile.write(pedaco)
                restante -= len(pedaco)

    @seguro
    def do_POST(self):
        if not self.origem_ok():
            return self.js({"erro": "origem não permitida"}, 403)
        rota = urlparse(self.path).path
        if rota == "/api/midia":
            corpo, status = guardar_midia(self.corpo())
            return self.js(corpo, status)

        if rota == "/api/analise":
            corpo, status = criar_analise(self.corpo())
            return self.js(corpo, status)

        if rota == "/api/voz/instalar":
            pedido = self.corpo()
            if pedido.get("apagar"):
                return self.js(voz_lib.apagar())
            return self.js(voz_lib.instalar(pedido.get("voz"),
                                            esperar=min(60, int(pedido.get("esperar") or 0))))

        if rota == "/api/voz/elevenlabs":
            # Grava (ou apaga, com chave vazia) a chave do ElevenLabs. A chave
            # nunca volta em resposta nenhuma — só o "configurado" volta.
            return self.js(elevenlabs_lib.gravar_chave(self.corpo().get("chave")))

        if rota == "/api/sair":
            # Só o CLI chama, e só quando o código no disco mudou (update do
            # npm). Sem isto o processo antigo ficava no ar servindo o bug que
            # o update tinha acabado de corrigir. Responde antes de morrer,
            # para quem pediu não ver erro de conexão.
            self.js({"ok": True, "tchau": ASSINATURA})
            import threading
            threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
            return

        m = re.fullmatch(r"/api/arquivos/([^/]+)/restaurar", rota)
        if m:
            a = ler(m.group(1))
            if not a:
                return self.js({"erro": "não encontrado"}, 404)
            pedido = self.corpo()
            disponiveis = versoes(m.group(1))
            # Sem versão dita, a anterior — que é o que "desfaz" quer dizer
            # quando alguém acabou de estragar o arquivo.
            alvo = pedido.get("versao") or (disponiveis[0]["versao"] if disponiveis else None)
            velha = ler_versao(m.group(1), alvo) if alvo else None
            if not velha:
                return self.js({"erro": "versão não encontrada",
                                "versoes": disponiveis}, 404)
            # Restaurar também guarda versão: desfazer o desfazer tem de valer.
            guardar_versao(a)
            a["titulo"] = velha.get("titulo", a.get("titulo"))
            a["doc"] = velha.get("doc") or a.get("doc")
            gravar(a)
            doc = a.get("doc") or {}
            return self.js({
                "ok": True, "id": a["id"], "restaurado": alvo,
                "titulo": a.get("titulo"),
                "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
            })

        if rota == "/api/arquivos":
            try:
                a = criar(self.corpo())
            except ValueError as e:
                return self.js({"erro": str(e)}, 400)
            if not a:
                return self.js({"erro": "ferramenta inválida",
                                "validas": list(POR_SLUG)}, 400)
            base = "http://localhost:%d" % PORTA
            corpo = {
                "id": a["id"],
                "ferramenta": a["ferramenta"],
                "titulo": a["titulo"],
                "editar": "%s/t/%s/%s" % (base, a["ferramenta"], a["id"]),
                "ver": "%s/v/%s" % (base, a["id"]),
            }
            revisao = revisar_monotonia(a.get("doc") or {}, a.get("ferramenta"))
            if revisao:
                corpo["podeMelhorar"] = revisao
            return self.js(corpo, 201)
        return self.js({"erro": "não encontrado"}, 404)

    @seguro
    def do_PUT(self):
        if not self.origem_ok():
            return self.js({"erro": "origem não permitida"}, 403)

        # Envio bruto: PUT /api/midia/<nome> com o arquivo no corpo, sem JSON
        # e sem base64. É por onde passa a gravação do usuário, que é grande
        # demais para virar texto.
        m = re.fullmatch(r"/api/midia/([^/]+)", urlparse(self.path).path)
        if m:
            corpo, status = guardar_midia_bruta(
                unquote(m.group(1)), self.rfile,
                int(self.headers.get("Content-Length") or 0))
            return self.js(corpo, status)

        m = re.fullmatch(r"/api/analise/([a-z0-9]+)", urlparse(self.path).path)
        if m:
            corpo, status = gravar_analise(m.group(1), self.corpo())
            return self.js(corpo, status)

        m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
        if not m:
            return self.js({"erro": "não encontrado"}, 404)
        a = ler(m.group(1))
        if not a:
            return self.js({"erro": "não encontrado"}, 404)
        dados = self.corpo()
        # Mesma validação de criar(): sem isso, um PUT com titulo/doc do tipo
        # errado grava sujeira que só quebra depois, no consumo (export etc).
        if "titulo" in dados and dados["titulo"] is not None and not isinstance(dados["titulo"], str):
            return self.js({"erro": "titulo precisa ser texto"}, 400)
        if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
            return self.js({"erro": "doc precisa ser um objeto"}, 400)

        antes = a.get("doc") or {}
        novo = a.get("doc")
        if "doc" in dados and dados["doc"] is not None:
            # Mesclar em vez de trocar. Era aqui que o trabalho sumia: um doc
            # parcial substituía o inteiro. Quem quiser mesmo jogar fora o que
            # existe manda "substituir": true e assume.
            novo = dados["doc"] if dados.get("substituir") else mesclar(antes, dados["doc"])

            # O agente precisa da validação: é a única forma de ele saber que
            # errou o formato. O editor do navegador, não — ali há uma pessoa
            # digitando, e uma cena momentaneamente vazia no meio da edição não
            # pode fazer o salvamento falhar calado.
            if self.headers.get("X-Primo-Agente"):
                problema = validar_doc(
                    POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
                if problema:
                    # As frases de validar_doc foram escritas para a CRIAÇÃO:
                    # elas terminam em "crie de novo" e "refaça". Devolver isso
                    # numa ATUALIZAÇÃO manda o agente fazer exatamente a coisa
                    # errada — e foi o que aconteceu: ele recebia o erro, jogava
                    # fora o vídeo pronto e recomeçava do zero, às vezes saindo
                    # pior. Aqui a instrução é a oposta, e vem por último para
                    # ser a última coisa que ele lê.
                    problema = re.sub(r"(crie de novo|e refaça)\.?", "", problema).strip()
                    return self.js({
                        "erro": problema
                        + ' Isto é uma ATUALIZAÇÃO: NÃO crie outro arquivo e NÃO recomece do zero. '
                          'Corrija só o que está errado acima e chame studio_atualizar de novo '
                          'com o MESMO id "%s". O arquivo continua intacto — nada foi perdido.' % a["id"],
                        "id": a["id"],
                    }, 400)

        preparar_voz(novo)
        versao = guardar_versao(a)
        if "titulo" in dados:
            a["titulo"] = dados["titulo"]
        if "doc" in dados:
            a["doc"] = novo
        salvo = gravar(a)
        narrar_em_arquivo(a["id"], salvo.get("doc") or {})
        if versao:
            # O agente vê isto no resultado da tool: se ele estragou algo, o
            # caminho de volta está na resposta, não numa doc que ele não leu.
            salvo = dict(salvo, versao_anterior=versao)
        return self.js(salvo)

    @seguro
    def do_DELETE(self):
        if not self.origem_ok():
            return self.js({"erro": "origem não permitida"}, 403)
        rota = urlparse(self.path).path
        m = re.fullmatch(r"/api/midia/([^/]+)", rota)
        if m:
            nome = unquote(m.group(1))
            alvo = os.path.join(MIDIA, nome)
            if "/" not in nome and ".." not in nome and os.path.isfile(alvo):
                os.remove(alvo)
                return self.js({"ok": True})
            return self.js({"erro": "não encontrado"}, 404)

        m = re.fullmatch(r"/api/arquivos/([^/]+)", rota)
        c = caminho(m.group(1)) if m else None
        if c and os.path.exists(c):
            os.remove(c)
            return self.js({"ok": True})
        return self.js({"erro": "não encontrado"}, 404)

    # ------------------------------------------------------------- export --

    def exportar(self, a):
        ferramenta = a.get("ferramenta")
        doc = a.get("doc") or {}

        if ferramenta == "grade":
            linhas = []
            for aba in doc.get("abas", []):
                linhas.append(",".join(csv_celula(c) for c in aba.get("colunas", [])))
                for lin in calcular_aba(aba):
                    linhas.append(",".join(csv_celula(c) for c in lin))
                linhas.append("")
            return self.txt("\n".join(linhas), "text/csv; charset=utf-8",
                            baixar=a["id"] + ".csv")

        if ferramenta == "prosa":
            return self.txt(prosa_markdown(doc), "text/markdown; charset=utf-8",
                            baixar=a["id"] + ".md")

        return self.txt(html_autonomo(a), "text/html; charset=utf-8",
                        baixar=a["id"] + ".html")


def csv_celula(valor):
    if isinstance(valor, float):
        valor = round(valor, 2)
        if valor == int(valor):
            valor = int(valor)
    s = "" if valor is None else str(valor)
    return '"%s"' % s.replace('"', '""') if re.search(r'[",\n\r]', s) else s


# ------------------------------------------------- fórmulas (espelha o JS) --

def _mediana(v):
    if not v:
        return 0
    o = sorted(v)
    m = len(o) // 2
    return o[m] if len(o) % 2 else (o[m - 1] + o[m]) / 2


_FUNCOES = {
    "SOMA": sum,
    "MEDIA": lambda v: sum(v) / len(v) if v else 0,
    "MIN": lambda v: min(v) if v else 0,
    "MAX": lambda v: max(v) if v else 0,
    "CONT": len,
    "ARRED": lambda v: round(v[0], int(v[1])) if len(v) > 1 else round(v[0] if v else 0),
    "ABS": lambda v: abs(v[0] if v else 0),
    "RAIZ": lambda v: abs(v[0] if v else 0) ** 0.5,
    "POT": lambda v: (v[0] if v else 0) ** (v[1] if len(v) > 1 else 2),
    "TETO": lambda v: -(-(v[0] if v else 0) // 1),
    "PISO": lambda v: (v[0] if v else 0) // 1,
    "MEDIANA": _mediana,
    "MAIOR": lambda v: sorted(v[:-1], reverse=True)[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
    "MENOR": lambda v: sorted(v[:-1])[max(0, int(v[-1]) - 1)] if len(v) > 1 else 0,
    "SE": lambda v: (v[1] if len(v) > 1 else 1) if v and v[0] else (v[2] if len(v) > 2 else 0),
}


def _criterio(texto):
    m = re.match(r'^(<=|>=|<>|!=|<|>|=)?\s*(-?[\d.,]+)$', str(texto).strip().strip('"\''))
    if not m:
        return lambda x: False
    op, alvo = m.group(1) or "=", float(m.group(2).replace(",", "."))
    return {
        ">": lambda x: x > alvo, "<": lambda x: x < alvo,
        ">=": lambda x: x >= alvo, "<=": lambda x: x <= alvo,
        "<>": lambda x: x != alvo, "!=": lambda x: x != alvo,
    }.get(op, lambda x: x == alvo)


def _col(letras):
    n = 0
    for c in letras:
        n = n * 26 + (ord(c) - 64)
    return n - 1


def _num(v):
    if isinstance(v, (int, float)):
        return v
    if v in (None, ""):
        return 0
    try:
        return float(re.sub(r"[R$\s%.]", "", str(v)).replace(",", "."))
    except ValueError:
        return 0


def calcular_aba(aba):
    """Resolve as fórmulas da aba e devolve a matriz de valores."""
    linhas = [list(l) for l in aba.get("linhas", [])]

    def celula(c, l, prof):
        # l/c negativos (referência tipo "A0", que vira índice -1) não podem
        # passar: índice negativo do Python "funciona" contando do fim da
        # lista, e a última linha da planilha aparecia como resposta de uma
        # referência inválida em vez de virar 0.
        if prof > 24 or l < 0 or c < 0 or l >= len(linhas) or c >= len(linhas[l]):
            return 0
        bruto = linhas[l][c]
        if isinstance(bruto, str) and bruto.startswith("="):
            return avaliar(bruto[1:], prof + 1)
        return bruto

    def faixa(ref, prof):
        m = re.fullmatch(r"([A-Z]+)(\d+):([A-Z]+)(\d+)", ref)
        if not m:
            return []
        c1, l1, c2, l2 = _col(m.group(1)), int(m.group(2)) - 1, _col(m.group(3)), int(m.group(4)) - 1
        return [_num(celula(c, l, prof))
                for l in range(min(l1, l2), max(l1, l2) + 1)
                for c in range(min(c1, c2), max(c1, c2) + 1)]

    def ref_simples(ref, prof):
        m = re.fullmatch(r"([A-Z]+)(\d+)", ref)
        return celula(_col(m.group(1)), int(m.group(2)) - 1, prof) if m else 0

    def avaliar(expr, prof):
        def fn(m):
            nome = m.group(1)
            partes = [p.strip() for p in m.group(2).split(";")]

            if nome in ("SOMASE", "CONTSE"):
                base = faixa(partes[0], prof) if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", partes[0]) else []
                passou = [x for x in base if _criterio(partes[1] if len(partes) > 1 else "")(x)]
                return repr(sum(passou) if nome == "SOMASE" else len(passou))

            f = _FUNCOES.get(nome)
            if not f:
                return "0"
            vals = []
            for parte in partes:
                if re.fullmatch(r"[A-Z]+\d+:[A-Z]+\d+", parte):
                    vals += faixa(parte, prof)
                elif parte:
                    # qualquer outro argumento é uma expressão: resolve antes
                    vals.append(_num(avaliar(parte, prof)))
            try:
                # MAIOR/MENOR estouram IndexError com k maior que o intervalo;
                # POT estoura OverflowError com base/expoente grandes demais.
                # Uma fórmula ruim vira "#ERRO" NESSA célula, não derruba a
                # exportação inteira (mesma regra do eval() logo abaixo).
                return repr(f(vals))
            except Exception:
                return "#ERRO"

        # repete até esgotar: resolve de dentro para fora em fórmula aninhada
        e, voltas = expr, 0
        while re.search(r"[A-Z]+\(", e) and voltas < 12:
            e = re.sub(r"([A-Z]+)\(([^()]*)\)", fn, e)
            voltas += 1
        e = re.sub(r"\b[A-Z]+\d+\b", lambda m: repr(_num(ref_simples(m.group(0), prof))), e)
        # comparações são permitidas para o SE funcionar
        if not re.fullmatch(r"[-+*/().\d\s,<>=!]*", e):
            return "#ERRO"
        try:
            pronto = e.replace(",", ".").replace("<>", "!=")
            pronto = re.sub(r"([^<>!=])=(?!=)", r"\1==", pronto) or "0"
            r = eval(pronto, {"__builtins__": {}}, {})
            if isinstance(r, bool):
                return 1 if r else 0
            return round(r, 6) if isinstance(r, float) else r
        except Exception:
            return "#ERRO"

    return [[celula(c, l, 0) for c in range(len(linhas[l]))] for l in range(len(linhas))]


def prosa_markdown(doc):
    fora = []
    capa = doc.get("capa")
    if capa:
        if capa.get("chapeu"):
            fora += ["*%s*" % capa["chapeu"], ""]
        if capa.get("titulo"):
            fora += ["# " + capa["titulo"], ""]
        if capa.get("apoio"):
            fora += [capa["apoio"], ""]
        assinatura = " · ".join(x for x in (capa.get("autor"), capa.get("data")) if x)
        if assinatura:
            fora += ["_%s_" % assinatura, ""]
        fora += ["---", ""]

    for b in doc.get("blocos", []):
        t, texto = b.get("tipo"), b.get("texto", "")
        if t == "titulo":
            fora.append("# " + texto)
        elif t == "secao":
            fora.append("## " + texto)
        elif t == "subsecao":
            fora.append("### " + texto)
        elif t == "chapeu":
            fora.append("*" + texto + "*")
        elif t == "apoio":
            fora.append("_" + texto + "_")
        elif t == "destaque":
            fora.append("> **%s**  " % b.get("titulo", "Nota"))
            fora.append("> " + texto)
        elif t == "sumario":
            fora.append("## " + (texto or "Sumário"))
            fora += ["%d. %s" % (i + 1, s.get("texto", ""))
                     for i, s in enumerate([x for x in doc.get("blocos", []) if x.get("tipo") == "secao"])]
        elif t == "citacao":
            fora.append("> " + texto)
            if b.get("autor"):
                fora += [">", "> — " + b["autor"]]
        elif t == "lista":
            fora += ["- " + i for i in b.get("itens", [])]
        elif t == "numerada":
            fora += ["%d. %s" % (i + 1, v) for i, v in enumerate(b.get("itens", []))]
        elif t == "codigo":
            fora.append("```\n" + texto + "\n```")
        elif t == "divisor":
            fora.append("---")
        elif t == "tabela":
            linhas = b.get("linhas", [])
            if linhas:
                fora.append("| " + " | ".join(map(str, linhas[0])) + " |")
                fora.append("|" + "---|" * len(linhas[0]))
                for l in linhas[1:]:
                    fora.append("| " + " | ".join(map(str, l)) + " |")
        else:
            fora.append(texto)
        fora.append("")
    return "\n".join(fora)


def embutir_midia(texto, limite=24 * 1024 * 1024):
    """Troca /midia/x por data URI para o export abrir sem servidor nenhum."""
    def troca(m):
        nome = unquote(m.group(1))
        alvo = os.path.join(MIDIA, nome)
        if "/" in nome or ".." in nome or not os.path.isfile(alvo):
            return m.group(0)
        if os.path.getsize(alvo) > limite:
            return m.group(0)                       # grande demais: mantém o link
        tipo = mimetypes.guess_type(alvo)[0] or "application/octet-stream"
        with open(alvo, "rb") as fh:
            return "data:%s;base64,%s" % (tipo, base64.b64encode(fh.read()).decode())

    return re.sub(r"/midia/([A-Za-z0-9._%\-]+)", troca, texto)


def html_autonomo(a):
    """Arquivo único: CSS, renderizadores, áudio, mídia e documento embutidos."""
    def leia(*partes):
        with open(os.path.join(WEB, *partes), encoding="utf-8") as fh:
            return fh.read()

    js = "\n".join(leia("js", n) for n in
                   ("icones.js", "audio.js", "sfx.js", "render.js", "render-prosa.js", "render-grade.js"))

    dados = json.dumps(a, ensure_ascii=False).replace("</script", "<\\/script")

    return """<!doctype html>
<html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>%(titulo)s</title>
<style>%(css)s</style></head>
<body class="modo-ver">
<div id="palco" class="palco"></div>
<script id="dados" type="application/json">%(dados)s</script>
<script>%(js)s</script>
<script>%(player)s</script>
</body></html>
""" % {
        "titulo": (a.get("titulo") or "PrimoCode").replace("<", "&lt;"),
        "css": leia("core.css") + leia("render.css"),
        "dados": embutir_midia(dados),
        "js": js,
        "player": leia("js", "player.js"),
    }


# -------------------------------------------------------------------- main --

def _abrir_log():
    """Diagnóstico que sobrevive ao `stdio: 'ignore'` do CLI.

    O CLI sobe este servidor descartando stdout/stderr — e era exatamente
    para lá que iam os erros de voz e o traceback de porta ocupada. Ninguém
    via nada: o Studio "não subia" em silêncio. Espelhar stdout/stderr num
    arquivo ao lado do código dá onde olhar. Rotação simples: passou de
    2 MB, o log atual vira `.old` e começa outro.
    """
    try:
        destino = os.path.join(RAIZ, "studio.log")
        if os.path.exists(destino) and os.path.getsize(destino) > 2 * 1024 * 1024:
            os.replace(destino, destino + ".old")
        fh = open(destino, "a", encoding="utf-8", buffering=1)

        class _Tee:
            def __init__(self, original):
                self.original = original
            def write(self, s):
                try:
                    self.original.write(s)
                except Exception:
                    pass
                try:
                    fh.write(s)
                except Exception:
                    pass
            def flush(self):
                for alvo in (self.original, fh):
                    try:
                        alvo.flush()
                    except Exception:
                        pass

        sys.stdout = _Tee(sys.stdout)
        sys.stderr = _Tee(sys.stderr)
        print("[studio] %s — iniciando na porta %d" %
              (datetime.now(timezone.utc).isoformat(timespec="seconds"), PORTA))
    except Exception:
        pass          # sem log ainda é melhor que sem servidor


if __name__ == "__main__":
    _abrir_log()
    url = "http://localhost:%d" % PORTA
    print("""
  ██  PrimoCode Studio
  ────────────────────────────────────────────────────────
  Estúdio          %s
  Manual da IA     %s/api/manual
  Ferramentas      %s
  ────────────────────────────────────────────────────────
  Ctrl+C para parar
""" % (url, url, ", ".join(f["nome"] for f in FERRAMENTAS_INFO)))

    try:
        ThreadingHTTPServer(("127.0.0.1", PORTA), Handler).serve_forever()
    except KeyboardInterrupt:
        print("\n  Encerrado.\n")
    except OSError as e:
        # Porta ocupada saía como traceback — e com stdio descartado pelo
        # CLI, saía para lugar nenhum. Mensagem clara + código de saída
        # próprio: quem supervisiona sabe o que houve sem ler stack.
        if getattr(e, "errno", None) in (48, 98, 10048):   # mac, linux, windows
            print("\n  A porta %d já está em uso — outro Studio no ar?\n"
                  "  Feche-o (POST /api/sair) ou use PRIMOCODE_STUDIO_PORT.\n" % PORTA)
            sys.exit(2)
        raise
