"""Servico "leitor" -- OCR (Apple Vision) + analise por Qwen (LM Studio) no mac mini.

Corre via launchd, sem sudo, atras de firewall restrito. Nunca recebe
caminhos de ficheiro -- so bytes + um id opaco. Nao persiste nada: os
temporarios sao sempre apagados em `finally`.

Ver /Volumes/www/assistente/docs/06-CONTRATO-LEITOR-MINI.md e
07-DECISOES-FASE0.md para o contrato completo.
"""
from __future__ import annotations

import asyncio
import base64
import io
import json
import logging
import os
import re
import secrets
import shutil
import subprocess
import tempfile
import time
import zipfile
from pathlib import Path
from typing import Optional

import httpx
import jsonschema
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from PIL import Image

import prompts
from contratos import paginacao as paginacao_contrato

# --------------------------------------------------------------------------
# Configuracao
# --------------------------------------------------------------------------

LEITOR_HOME = Path(os.environ.get("LEITOR_HOME", str(Path.home() / "leitor")))
ENV_PATH = LEITOR_HOME / ".env"
SCHEMAS_DIR = Path(__file__).resolve().parent / "schemas"
OCR_BIN = Path.home() / "ocr" / "ocr"
LM_STUDIO_BASE = os.environ.get("LM_STUDIO_BASE", "http://localhost:1234")
LM_STUDIO_MODEL = os.environ.get("LM_STUDIO_MODEL", "google/gemma-4-26b-a4b-qat")
# Modelos que um pedido pode escolher (campo "modelo"); sem campo usa-se o
# LM_STUDIO_MODEL, como sempre. Serve para comparar modelos (Qwen vs Gemma 4)
# com os mesmos documentos, sem mudar o que a app usa por omissao.
MODELOS_PERMITIDOS = tuple(
    m.strip() for m in os.environ.get(
        "LM_MODELOS_PERMITIDOS", "qwen/qwen3.8-27b,google/gemma-4-26b-a4b-qat,gemma-4-26b-a4b-it-mlx"
    ).split(",") if m.strip()
)
VERSAO_SERVICO = "1.0.0"

TAMANHO_MAX_BYTES = 25 * 1024 * 1024  # 25 MB
PAGINAS_MAX_PDF = 30
TIMEOUT_PEDIDO_S = 180
QWEN_MAX_TOKENS = int(os.environ.get("QWEN_MAX_TOKENS", "1500"))

TAMANHO_MAX_DOCX_BYTES = 10 * 1024 * 1024  # 10 MB
TIMEOUT_PAGINAR_PDF_S = 180
# Subpasta propria dentro do contentor do Word (ver contratos/paginacao.py) -- e a unica
# pasta onde o Word abre ficheiros por AppleScript sem pedir autorizacao de cada vez;
# fora dela (ex. /private/tmp) da erro -1708.
WORD_MINI_TMP = Path.home() / "Library" / "Containers" / "com.microsoft.Word" / "Data" / "tmp" / "leitor-mini"

MIME_MAGIC = [
    (b"\xff\xd8\xff", "image/jpeg"),
    (b"\x89PNG\r\n\x1a\n", "image/png"),
    (b"%PDF-", "application/pdf"),
    (b"II*\x00", "image/tiff"),
    (b"MM\x00*", "image/tiff"),
]


def _carregar_ou_gerar_token() -> str:
    LEITOR_HOME.mkdir(parents=True, exist_ok=True)
    if ENV_PATH.exists():
        for linha in ENV_PATH.read_text().splitlines():
            linha = linha.strip()
            if linha.startswith("LEITOR_TOKEN="):
                valor = linha.split("=", 1)[1].strip()
                if valor:
                    return valor
    token = secrets.token_hex(32)
    conteudo = ""
    if ENV_PATH.exists():
        conteudo = ENV_PATH.read_text()
    conteudo = re.sub(r"^LEITOR_TOKEN=.*$", "", conteudo, flags=re.MULTILINE).strip()
    conteudo = (conteudo + f"\nLEITOR_TOKEN={token}\n").strip() + "\n"
    ENV_PATH.write_text(conteudo)
    os.chmod(ENV_PATH, 0o600)
    print(f"Token gerado e guardado em {ENV_PATH}")
    return token


TOKEN = _carregar_ou_gerar_token()

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("leitor")

app = FastAPI(title="leitor-mini")

QWEN_LOCK = asyncio.Lock()
# Um trinco por modelo: o mesmo modelo nunca recebe dois pedidos em paralelo,
# mas modelos diferentes podem trabalhar ao mesmo tempo. O do modelo por
# omissao e o QWEN_LOCK de sempre (usado tambem pelo /v1/saude).
_TRINCOS_MODELO: dict[str, asyncio.Lock] = {}


def trinco_modelo(modelo: str) -> asyncio.Lock:
    if modelo == LM_STUDIO_MODEL:
        return QWEN_LOCK
    return _TRINCOS_MODELO.setdefault(modelo, asyncio.Lock())


def modelo_do_pedido(modelo: Optional[str]) -> str:
    if not modelo:
        return LM_STUDIO_MODEL
    if modelo not in MODELOS_PERMITIDOS:
        raise HTTPException(status_code=400, detail="modelo nao permitido")
    return modelo
WORD_LOCK = asyncio.Lock()  # lock proprio, separado do QWEN_LOCK: paginacao/exportacao nao concorre com o Qwen


class _WordIndisponivel(Exception):
    """Microsoft Word nao respondeu (ou nao foi possivel abri-lo) dentro do prazo."""


# --------------------------------------------------------------------------
# Auxiliares
# --------------------------------------------------------------------------


def detectar_mime(cabecalho: bytes) -> Optional[str]:
    for assinatura, mime in MIME_MAGIC:
        if cabecalho.startswith(assinatura):
            return mime
    # HEIC/HEIF: ftyp box com marca heic/heix/mif1/msf1 nos bytes 4-12
    if len(cabecalho) >= 12 and cabecalho[4:8] == b"ftyp":
        marca = cabecalho[8:12]
        if marca in (b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"):
            return "image/heic"
    return None


def verificar_token(authorization: Optional[str]) -> None:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="token em falta")
    apresentado = authorization[len("Bearer ") :].strip()
    if not secrets.compare_digest(apresentado, TOKEN):
        raise HTTPException(status_code=401, detail="token invalido")


async def ler_ficheiro_validado(ficheiro: UploadFile) -> tuple[bytes, str]:
    conteudo = await ficheiro.read()
    if len(conteudo) == 0:
        raise HTTPException(status_code=400, detail="ficheiro vazio")
    if len(conteudo) > TAMANHO_MAX_BYTES:
        raise HTTPException(status_code=413, detail="ficheiro excede o limite de 25 MB")
    mime = detectar_mime(conteudo[:16])
    if mime is None:
        raise HTTPException(status_code=422, detail="tipo de ficheiro nao suportado")
    return conteudo, mime


class DiretorioTemporario:
    """Diretorio 0700 apagado sempre, mesmo em erro."""

    def __enter__(self) -> Path:
        self._caminho = Path(tempfile.mkdtemp(prefix="leitor-"))
        os.chmod(self._caminho, 0o700)
        return self._caminho

    def __exit__(self, *exc) -> None:
        shutil.rmtree(self._caminho, ignore_errors=True)


def imagens_para_data_uris(conteudo: bytes, mime: str, limite_paginas: int = PAGINAS_MAX_PDF) -> list[str]:
    """Converte o ficheiro (imagem ou PDF) numa lista de data URIs PNG."""
    data_uris: list[str] = []
    if mime == "application/pdf":
        import pypdfium2 as pdfium

        pdf = pdfium.PdfDocument(conteudo)
        n_paginas = len(pdf)
        if n_paginas > PAGINAS_MAX_PDF:
            raise HTTPException(status_code=400, detail=f"PDF com {n_paginas} paginas excede o limite de {PAGINAS_MAX_PDF}")
        for i in range(min(n_paginas, limite_paginas)):
            pagina = pdf[i]
            bitmap = pagina.render(scale=2.0)
            pil_img = bitmap.to_pil()
            data_uris.append(_pil_para_data_uri(pil_img))
    else:
        pil_img = Image.open(io.BytesIO(conteudo))
        pil_img = pil_img.convert("RGB")
        data_uris.append(_pil_para_data_uri(pil_img))
    return data_uris


def _pil_para_data_uri(img: Image.Image) -> str:
    # Limitar a maior dimensao para nao sobrecarregar o pedido ao Qwen.
    max_lado = 2000
    if max(img.size) > max_lado:
        fator = max_lado / max(img.size)
        img = img.resize((int(img.size[0] * fator), int(img.size[1] * fator)))
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    b64 = base64.b64encode(buf.getvalue()).decode("ascii")
    return f"data:image/png;base64,{b64}"


def extrair_json(texto: str) -> dict:
    texto = texto.strip()
    # Remove blocos markdown ```json ... ``` se existirem.
    if texto.startswith("```"):
        texto = re.sub(r"^```[a-zA-Z]*\n?", "", texto)
        texto = re.sub(r"\n?```$", "", texto)
    inicio = texto.find("{")
    fim = texto.rfind("}")
    if inicio == -1 or fim == -1 or fim < inicio:
        raise ValueError("sem objeto JSON na resposta")
    return json.loads(texto[inicio : fim + 1])


async def chamar_qwen(mensagens: list[dict], reasoning_effort: str = "none", modelo: Optional[str] = None) -> str:
    payload = {
        "model": modelo or LM_STUDIO_MODEL,
        "messages": mensagens,
        "reasoning_effort": reasoning_effort,
        "temperature": 0.0,
        # As respostas sao JSON pequenos (schema fechado por tipo). Sem limite, o
        # modelo pode entrar em repeticao e gerar ate ao timeout, prendendo a fila
        # do Qwen para todos os pedidos seguintes (caso real em prod, 24/09/2026).
        "max_tokens": QWEN_MAX_TOKENS,
    }
    async with httpx.AsyncClient(timeout=TIMEOUT_PEDIDO_S) as client:
        resposta = await client.post(f"{LM_STUDIO_BASE}/v1/chat/completions", json=payload)
        resposta.raise_for_status()
        corpo = resposta.json()
        return corpo["choices"][0]["message"]["content"]


# --------------------------------------------------------------------------
# GET /v1/saude
# --------------------------------------------------------------------------


@app.get("/v1/saude")
async def saude(authorization: Optional[str] = Header(default=None)):
    verificar_token(authorization)
    inicio = time.monotonic()
    modelo_carregado = None
    lm_studio_ok = False
    try:
        async with httpx.AsyncClient(timeout=5) as client:
            resposta = await client.get(f"{LM_STUDIO_BASE}/api/v0/models")
            if resposta.status_code == 200:
                corpo = resposta.json()
                # O modelo reportado é o que o leitor USA (LM_STUDIO_MODEL) quando
                # está carregado; outros modelos carregados (ex. o Qwen de outra
                # sessão) não contam (28/09/2026: a app gravava "qwen" por engano).
                carregados = [m.get("id") for m in corpo.get("data", []) if m.get("state") == "loaded"]
                if LM_STUDIO_MODEL in carregados:
                    modelo_carregado = LM_STUDIO_MODEL
                elif carregados:
                    modelo_carregado = None
                    log.warning("saude: %s nao esta carregado (carregados: %s)", LM_STUDIO_MODEL, ",".join(carregados))
                lm_studio_ok = True
    except Exception as exc:  # nao critico
        log.warning("falha ao consultar LM Studio: %s", type(exc).__name__)

    ram = _info_ram()
    word_ativo, word_versao = await asyncio.to_thread(_word_ativo)

    resultado = {
        "ok": True,
        "versao": VERSAO_SERVICO,
        "lm_studio": "carregado" if modelo_carregado else ("acessivel" if lm_studio_ok else "indisponivel"),
        "modelo": modelo_carregado,
        "fila": 1 if QWEN_LOCK.locked() else 0,
        "ram": ram,
        "word": "disponivel" if word_ativo else "indisponivel",
        "word_versao": word_versao,
    }
    log.info("saude ok=%s tempo=%.3fs", resultado["ok"], time.monotonic() - inicio)
    return resultado


def _info_ram() -> dict:
    info: dict = {}
    try:
        saida = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=5).stdout
        tamanho_pagina = 4096
        m = re.search(r"page size of (\d+) bytes", saida)
        if m:
            tamanho_pagina = int(m.group(1))
        livres = re.search(r"Pages free:\s+(\d+)", saida)
        ativas = re.search(r"Pages active:\s+(\d+)", saida)
        inativas = re.search(r"Pages inactive:\s+(\d+)", saida)
        speculativas = re.search(r"Pages speculative:\s+(\d+)", saida)
        if livres:
            info["livre_mb"] = round(int(livres.group(1)) * tamanho_pagina / 1024 / 1024, 1)
        if ativas and inativas:
            usada = int(ativas.group(1)) + int(inativas.group(1))
            if speculativas:
                pass
            info["usada_mb"] = round(usada * tamanho_pagina / 1024 / 1024, 1)
    except Exception as exc:
        log.warning("falha ao ler vm_stat: %s", type(exc).__name__)
    return info


# --------------------------------------------------------------------------
# POST /v1/ocr
# --------------------------------------------------------------------------


@app.post("/v1/ocr")
async def ocr(
    ficheiro: UploadFile = File(...),
    id: str = Form(...),
    authorization: Optional[str] = Header(default=None),
):
    verificar_token(authorization)
    inicio = time.monotonic()
    conteudo, mime = await ler_ficheiro_validado(ficheiro)

    if mime == "application/pdf":
        try:
            import pypdfium2 as pdfium

            n_paginas = len(pdfium.PdfDocument(conteudo))
        except Exception:
            n_paginas = 0
        if n_paginas > PAGINAS_MAX_PDF:
            raise HTTPException(status_code=400, detail=f"PDF com {n_paginas} paginas excede o limite de {PAGINAS_MAX_PDF}")

    with DiretorioTemporario() as tmp:
        extensao = {
            "image/jpeg": ".jpg",
            "image/png": ".png",
            "image/heic": ".heic",
            "image/tiff": ".tiff",
            "application/pdf": ".pdf",
        }[mime]
        caminho = tmp / f"documento{extensao}"
        caminho.write_bytes(conteudo)
        os.chmod(caminho, 0o600)

        try:
            processo = subprocess.run(
                [str(OCR_BIN), str(caminho)],
                capture_output=True,
                timeout=TIMEOUT_PEDIDO_S,
            )
        except subprocess.TimeoutExpired:
            log.error("ocr id=%s timeout", id)
            raise HTTPException(status_code=500, detail="timeout no OCR")

        if processo.returncode != 0:
            log.error("ocr id=%s codigo=%s", id, processo.returncode)
            raise HTTPException(status_code=500, detail="falha no OCR")

        try:
            resultado = json.loads(processo.stdout.decode("utf-8"))
        except Exception:
            log.error("ocr id=%s json invalido do binario", id)
            raise HTTPException(status_code=500, detail="resposta invalida do OCR")

    tempo = time.monotonic() - inicio
    log.info("ocr id=%s mime=%s tempo=%.3fs", id, mime, tempo)

    resposta = {"id": id}
    if isinstance(resultado, list) and resultado:
        resposta.update(resultado[0])
    else:
        resposta["paginas"] = []
    return resposta


# --------------------------------------------------------------------------
# POST /v1/analisar-documento
# --------------------------------------------------------------------------


@app.post("/v1/analisar-documento")
async def analisar_documento(
    ficheiro: UploadFile = File(...),
    id: str = Form(...),
    tipo: str = Form(...),
    modelo: Optional[str] = Form(None),
    authorization: Optional[str] = Header(default=None),
):
    verificar_token(authorization)
    modelo = modelo_do_pedido(modelo)
    if tipo not in prompts.TIPOS_VALIDOS:
        raise HTTPException(status_code=400, detail=f"tipo desconhecido: {tipo}")

    schema_path = SCHEMAS_DIR / f"{tipo}.json"
    if not schema_path.exists():
        raise HTTPException(status_code=500, detail="schema em falta no servico")
    schema = json.loads(schema_path.read_text())

    inicio = time.monotonic()
    conteudo, mime = await ler_ficheiro_validado(ficheiro)

    try:
        data_uris = imagens_para_data_uris(conteudo, mime)
    except HTTPException:
        raise
    except Exception as exc:
        log.error("analisar-documento id=%s falha ao converter imagem: %s", id, type(exc).__name__)
        raise HTTPException(status_code=422, detail="nao foi possivel processar o ficheiro")

    conteudo_mensagem = [{"type": "text", "text": prompts.prompt_para_tipo(tipo)}]
    for uri in data_uris:
        conteudo_mensagem.append({"type": "image_url", "image_url": {"url": uri}})

    mensagens = [{"role": "user", "content": conteudo_mensagem}]

    async with trinco_modelo(modelo):
        try:
            texto_resposta = await chamar_qwen(mensagens, modelo=modelo)
        except Exception as exc:
            log.error("analisar-documento id=%s tipo=%s modelo=%s falha: %s", id, tipo, modelo, type(exc).__name__)
            raise HTTPException(status_code=500, detail="falha ao consultar o modelo")

    bruto_valido = True
    dados: dict = {}
    try:
        dados = extrair_json(texto_resposta)
        # Campo nao encontrado: uns modelos omitem-no, outros (Gemma 4) devolvem
        # "" ou null. Retirar antes de validar, para o schema julgar so o que foi
        # lido e os dois modelos serem tratados da mesma forma (24/09/2026).
        if isinstance(dados, dict):
            dados = {k: v for k, v in dados.items() if v not in ("", None)}
        jsonschema.validate(dados, schema)
    except jsonschema.ValidationError:
        bruto_valido = False
    except Exception:
        bruto_valido = False
        dados = {}

    tempo = time.monotonic() - inicio
    log.info("analisar-documento id=%s tipo=%s modelo=%s valido=%s tempo=%.3fs", id, tipo, modelo, bruto_valido, tempo)

    return {
        "id": id,
        "tipo": tipo,
        "modelo": modelo,
        "tempo_s": round(tempo, 3),
        "dados": dados,
        "bruto_valido": bruto_valido,
    }


# --------------------------------------------------------------------------
# POST /v1/classificar-documento
# --------------------------------------------------------------------------

TIPOS_CLASSIFICACAO = ("cc_frente", "cc_verso", "cc_digital", "cartao_psp", "rc", "morada", "iban", "outro")


@app.post("/v1/classificar-documento")
async def classificar_documento(
    ficheiro: UploadFile = File(...),
    id: str = Form(...),
    modelo: Optional[str] = Form(None),
    authorization: Optional[str] = Header(default=None),
):
    """O modelo olha para a imagem e diz o tipo do documento (so a 1.a pagina).
    Devolve {"tipo": um de TIPOS_CLASSIFICACAO}; resposta fora da lista = "outro"
    com valido=false. Nunca regista conteudo do documento."""
    verificar_token(authorization)
    modelo = modelo_do_pedido(modelo)
    inicio = time.monotonic()
    conteudo, mime = await ler_ficheiro_validado(ficheiro)
    try:
        data_uris = imagens_para_data_uris(conteudo, mime)[:1]
    except HTTPException:
        raise
    except Exception as exc:
        log.error("classificar id=%s falha ao converter imagem: %s", id, type(exc).__name__)
        raise HTTPException(status_code=422, detail="nao foi possivel processar o ficheiro")

    conteudo_mensagem = [{"type": "text", "text": prompts.PROMPT_CLASSIFICAR}]
    for uri in data_uris:
        conteudo_mensagem.append({"type": "image_url", "image_url": {"url": uri}})
    async with trinco_modelo(modelo):
        try:
            texto = await chamar_qwen([{"role": "user", "content": conteudo_mensagem}], modelo=modelo)
        except Exception as exc:
            log.error("classificar id=%s modelo=%s falha: %s", id, modelo, type(exc).__name__)
            raise HTTPException(status_code=500, detail="falha ao consultar o modelo")

    tipo, valido = "outro", False
    try:
        dados = extrair_json(texto)
        if dados.get("tipo") in TIPOS_CLASSIFICACAO:
            tipo, valido = dados["tipo"], True
    except Exception:
        pass
    tempo = time.monotonic() - inicio
    log.info("classificar id=%s modelo=%s tipo=%s valido=%s tempo=%.3fs", id, modelo, tipo, valido, tempo)
    return {"id": id, "modelo": modelo, "tipo": tipo, "valido": valido, "tempo_s": round(tempo, 3)}


# --------------------------------------------------------------------------
# POST /v1/comparar
# --------------------------------------------------------------------------


@app.post("/v1/comparar")
async def comparar(payload: dict, authorization: Optional[str] = Header(default=None)):
    verificar_token(authorization)
    campo = payload.get("campo")
    valor_a = payload.get("valor_a")
    valor_b = payload.get("valor_b")
    if not campo or valor_a is None or valor_b is None:
        raise HTTPException(status_code=400, detail="campo, valor_a e valor_b sao obrigatorios")

    inicio = time.monotonic()
    mensagens = [{"role": "user", "content": prompts.prompt_comparacao(campo, str(valor_a), str(valor_b))}]

    async with QWEN_LOCK:
        try:
            texto_resposta = await chamar_qwen(mensagens)
        except Exception as exc:
            log.error("comparar campo=%s falha no Qwen: %s", campo, type(exc).__name__)
            raise HTTPException(status_code=500, detail="falha ao consultar o modelo")

    try:
        dados = extrair_json(texto_resposta)
        equivalente = bool(dados.get("equivalente"))
        motivo = str(dados.get("motivo", ""))
    except Exception:
        log.error("comparar campo=%s resposta invalida do modelo", campo)
        raise HTTPException(status_code=500, detail="resposta invalida do modelo")

    tempo = time.monotonic() - inicio
    log.info("comparar campo=%s tempo=%.3fs", campo, tempo)

    return {"equivalente": equivalente, "motivo": motivo}


# --------------------------------------------------------------------------
# POST /v1/contrato/paginar-pdf
# --------------------------------------------------------------------------


def _e_docx_valido(conteudo: bytes) -> bool:
    """.docx e um zip com word/document.xml -- verificado por conteudo, nao por extensao."""
    if not conteudo.startswith(b"PK\x03\x04"):
        return False
    try:
        with zipfile.ZipFile(io.BytesIO(conteudo)) as z:
            return "word/document.xml" in z.namelist()
    except Exception:
        return False


def _word_ativo(timeout: float = 5) -> tuple[bool, Optional[str]]:
    """Pergunta ao Word (por AppleScript) se esta a responder. Devolve (ativo, versao)."""
    try:
        r = subprocess.run(
            ["osascript", "-e", 'tell application "Microsoft Word" to get name'],
            capture_output=True,
            timeout=timeout,
        )
        if r.returncode != 0:
            return False, None
    except Exception:
        return False, None
    versao = None
    try:
        rv = subprocess.run(
            ["osascript", "-e", 'tell application "Microsoft Word" to get version'],
            capture_output=True,
            timeout=timeout,
        )
        if rv.returncode == 0:
            versao = rv.stdout.decode("utf-8", "replace").strip() or None
    except Exception:
        pass
    return True, versao


def _garantir_word_aberto(limite_s: float = 30) -> tuple[bool, Optional[str]]:
    """Confirma que o Word responde; se nao, tenta abri-lo e espera ate `limite_s`."""
    ativo, versao = _word_ativo()
    if ativo:
        return True, versao
    try:
        subprocess.run(["open", "-a", "Microsoft Word"], capture_output=True, timeout=10)
    except Exception:
        return False, None
    fim = time.monotonic() + limite_s
    while time.monotonic() < fim:
        time.sleep(2)
        ativo, versao = _word_ativo()
        if ativo:
            return True, versao
    return False, None


# Cores que o CT (DocxEngine, modo revisao) usa para marcar os valores
# preenchidos no PDF de revisao. O amarelo ([FALTA]) nunca e retirado.
_REALCE_REVISAO_RE = re.compile(rb'<w:highlight w:val="(?:green|cyan|lightGray)"/>')


def _retirar_realce_revisao(docx_path: Path) -> None:
    """Reescreve o .docx sem os realces de revisao (so word/document.xml muda)."""
    with zipfile.ZipFile(docx_path) as zin:
        entradas = [(i, zin.read(i.filename)) for i in zin.infolist()]
    tmp = docx_path.with_suffix(".sem-realce.docx")
    with zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout:
        for info, dados in entradas:
            if info.filename == "word/document.xml":
                dados = _REALCE_REVISAO_RE.sub(b"", dados)
            zout.writestr(info, dados)
    os.chmod(tmp, 0o600)
    tmp.replace(docx_path)


def _paginar_pdf_sync(conteudo: bytes, id_: str, revisao: bool = False) -> dict:
    """Corre em thread (bloqueante: subprocess/osascript). Nunca regista conteudo do documento."""
    ativo, versao = _garantir_word_aberto()
    if not ativo:
        raise _WordIndisponivel(
            "Microsoft Word indisponivel: nao respondeu e a tentativa de o abrir falhou ou excedeu o prazo"
        )

    WORD_MINI_TMP.mkdir(parents=True, exist_ok=True)
    os.chmod(WORD_MINI_TMP, 0o700)
    nome = f"contrato-{secrets.token_hex(8)}.docx"
    docx_tmp = WORD_MINI_TMP / nome
    pdf_tmp = WORD_MINI_TMP / (nome[:-5] + ".pdf")
    pdf_rev_tmp = WORD_MINI_TMP / (nome[:-5] + "-revisao.pdf")
    try:
        docx_tmp.write_bytes(conteudo)
        os.chmod(docx_tmp, 0o600)

        log_pag, resumo, orfaos = paginacao_contrato.arrumar_com_word(str(docx_tmp))

        n_paginas = None
        m = re.search(r"paginas:\s*(\d+)", resumo)
        if m:
            n_paginas = int(m.group(1))

        pdf_rev_b64 = None
        if revisao:
            # 1.o o PDF de revisao (com as cores), depois o final sem elas. O
            # realce nao mexe no layout: as duas exportacoes paginam igual.
            erro_rev = paginacao_contrato.exportar_pdf(str(docx_tmp), str(pdf_rev_tmp))
            if erro_rev:
                raise RuntimeError(f"exportar_pdf (revisao): {erro_rev}")
            pdf_rev_b64 = base64.b64encode(pdf_rev_tmp.read_bytes()).decode("ascii")
            _retirar_realce_revisao(docx_tmp)

        erro_pdf = paginacao_contrato.exportar_pdf(str(docx_tmp), str(pdf_tmp))
        if erro_pdf:
            raise RuntimeError(f"exportar_pdf: {erro_pdf}")

        docx_b64 = base64.b64encode(docx_tmp.read_bytes()).decode("ascii")
        pdf_b64 = base64.b64encode(pdf_tmp.read_bytes()).decode("ascii")

        correcoes = list(log_pag) + [f"AVISO titulo orfao: {o}" for o in orfaos]
        resposta = {
            "docx_base64": docx_b64,
            "pdf_base64": pdf_b64,
            "paginas": n_paginas,
            "correcoes": correcoes,
            "word_versao": versao,
        }
        if pdf_rev_b64 is not None:
            resposta["pdf_revisao_base64"] = pdf_rev_b64
        return resposta
    finally:
        for p in (docx_tmp, pdf_tmp, pdf_rev_tmp, docx_tmp.with_suffix(".sem-realce.docx")):
            try:
                p.unlink()
            except OSError:
                pass
        # ficheiros de recuperacao que o Word por vezes deixa para tras (~$nome.docx)
        for extra in WORD_MINI_TMP.glob(f"~$*{nome[len('contrato-'):-5]}*"):
            try:
                extra.unlink()
            except OSError:
                pass


@app.post("/v1/contrato/paginar-pdf")
async def contrato_paginar_pdf(
    ficheiro: UploadFile = File(...),
    id: str = Form(...),
    revisao: bool = Form(False),
    authorization: Optional[str] = Header(default=None),
):
    verificar_token(authorization)
    inicio = time.monotonic()

    conteudo = await ficheiro.read()
    if len(conteudo) == 0:
        raise HTTPException(status_code=400, detail="ficheiro vazio")
    if len(conteudo) > TAMANHO_MAX_DOCX_BYTES:
        raise HTTPException(status_code=413, detail="ficheiro excede o limite de 10 MB")
    if not _e_docx_valido(conteudo):
        raise HTTPException(status_code=422, detail="ficheiro nao e um .docx valido")

    # O trinco so e libertado quando a thread do Word termina de facto: no
    # timeout, o wait_for so desiste de esperar (a thread continua a mexer no
    # Word) e um pedido seguinte nao pode entrar em paralelo (revisao do
    # Codex, 24/09/2026). O shield impede que o cancelamento marque a tarefa
    # como terminada antes de a thread acabar.
    await WORD_LOCK.acquire()
    tarefa = None
    try:
        tarefa = asyncio.ensure_future(asyncio.to_thread(_paginar_pdf_sync, conteudo, id, revisao))
        try:
            resultado = await asyncio.wait_for(asyncio.shield(tarefa), timeout=TIMEOUT_PAGINAR_PDF_S)
        except asyncio.TimeoutError:
            log.error("paginar-pdf id=%s timeout", id)
            raise HTTPException(status_code=504, detail="timeout na paginacao/exportacao (Word)")
        except _WordIndisponivel as exc:
            log.error("paginar-pdf id=%s word indisponivel", id)
            raise HTTPException(status_code=503, detail=str(exc))
        except HTTPException:
            raise
        except Exception as exc:
            log.error("paginar-pdf id=%s falha: %s", id, type(exc).__name__)
            raise HTTPException(status_code=500, detail="falha na paginacao/exportacao")
    finally:
        # Timeout ou pedido cancelado (cliente desligou) com a thread ainda a
        # correr: o trinco fica preso ate ela acabar.
        if tarefa is not None and not tarefa.done():
            def _libertar_quando_acabar(f):
                if not f.cancelled():
                    f.exception()  # consome a excecao, se houver (evita aviso)
                WORD_LOCK.release()
                log.info("paginar-pdf id=%s: thread do Word terminou depois do fim do pedido; trinco libertado", id)

            tarefa.add_done_callback(_libertar_quando_acabar)
        else:
            WORD_LOCK.release()

    tempo = time.monotonic() - inicio
    resultado["id"] = id
    resultado["tempo_s"] = round(tempo, 3)
    log.info("paginar-pdf id=%s paginas=%s tempo=%.3fs", id, resultado.get("paginas"), tempo)
    return resultado


@app.exception_handler(Exception)
async def excecao_generica(request, exc):
    log.error("erro interno: %s", type(exc).__name__)
    return JSONResponse(status_code=500, content={"detail": "erro interno"})
