#!/usr/bin/env python3
"""Cache-manifest genérico pros estágios caros do pipeline (sync_ingest.py = audalign, ~15-20min;
scene_judge.py = VLM por clip). Chave de invalidação = hash SHA256 do PRÓPRIO .py que gera o dado
+ assinatura (mtime+size) do(s) input(s) relevante(s) — mesmo padrão que já existia isolado em
sync_ingest.spine_recognizer() pro .fp.json da espinha (ver comentário lá: "invalidação por
CONTEÚDO... Fingerprint de 2 dias atrás sendo usado pra espinha de HOJE = matches sutilmente
ERRADOS"). Generalizado aqui pra qualquer estágio, com dependência em CADEIA (estágio B declara
o estágio A como dependência; se A invalidar, B invalida junto mesmo que o script de B não tenha
mudado — o INPUT de B mudou).

Manifest fica em <workdir>/.cache_manifest.json, formato:
    {"<estagio>": {"script_hash": "...", "input_sig": {...}, "completed_at": "..."}}

Uso típico (dentro de um script de estágio):

    import cache_manifest as cm
    sig = cm.input_signature([decupagem_path, spine_wav_path])
    if cm.is_fresh(workdir, "sync_ingest", __file__, sig, extra_deps=["scene_judge"]) \\
            and os.path.exists(out_json):
        print("[cache] sync_ingest: script+input inalterados, pulando fingerprint")
        return
    ... trabalho pesado ...
    cm.mark_done(workdir, "sync_ingest", __file__, sig)

`extra_deps`: nomes de OUTROS estágios que, se tiverem rodado DEPOIS do último `mark_done` deste
estágio (completed_at mais recente), forçam invalidação em cascata — ex: scene_judge depende do
output do sync_ingest; se sync_ingest rodou de novo, scene_judge tem que rodar de novo mesmo que
scene_judge.py não tenha mudado.
"""
import os
import hashlib
import json
import time

MANIFEST_NAME = ".cache_manifest.json"


def script_hash(script_path):
    """SHA256 (16 hex chars, suficiente pra detectar qualquer edição) do arquivo .py."""
    return hashlib.sha256(open(script_path, "rb").read()).hexdigest()[:16]


def _file_sig(path):
    try:
        st = os.stat(path)
        return {"path": path, "mtime": st.st_mtime, "size": st.st_size}
    except OSError:
        return {"path": path, "mtime": None, "size": None}


def input_signature(paths):
    """Assinatura mtime+size de 1+ arquivos de input (mesmo estilo já usado em
    sync_ingest.spine_recognizer pro .fp.json da espinha). Ordem estável (lista, não set) —
    o caller decide a ordem; mesma lista de paths sempre produz a mesma assinatura."""
    if isinstance(paths, str):
        paths = [paths]
    return {"files": [_file_sig(p) for p in paths]}


def _file_content_sig(path):
    try:
        return {"path": path, "sha256": hashlib.sha256(open(path, "rb").read()).hexdigest()}
    except OSError:
        return {"path": path, "sha256": None}


def content_signature(paths):
    """Assinatura por CONTEÚDO (SHA256) de 1+ arquivos de input — usar pra intermediários
    REESCRITOS toda rodada pelo próprio pipeline (ex: UNI.json/spike_candidates.json que o
    unified.py pass0 regrava byte-idêntico quando nada mudou, mas com mtime novo).
    `input_signature` (mtime+size) NUNCA acertaria cache nesses arquivos — mtime muda sempre,
    mesmo sem mudança de conteúdo. Mesmo formato de retorno que `input_signature` (dict
    comparável por igualdade exata em `is_fresh`), só troca mtime+size por sha256 do conteúdo.
    NÃO usar em mídia-fonte grande (vídeo 4K) — hashear seria caro à toa; ali `input_signature`
    já é suficiente porque a mídia original é imutável (Regra #0)."""
    if isinstance(paths, str):
        paths = [paths]
    return {"files": [_file_content_sig(p) for p in paths]}


def _manifest_path(workdir):
    return os.path.join(workdir, MANIFEST_NAME)


def load_manifest(workdir):
    p = _manifest_path(workdir)
    if not os.path.exists(p):
        return {}
    try:
        return json.load(open(p))
    except Exception:
        return {}


def save_manifest(workdir, manifest):
    p = _manifest_path(workdir)
    tmp = p + ".tmp"
    json.dump(manifest, open(tmp, "w"), indent=1, ensure_ascii=False)
    os.replace(tmp, p)


def is_fresh(workdir, stage, script_path, input_sig, extra_deps=None):
    """True se o estágio pode ser PULADO: script + input batem com a última execução registrada,
    E nenhuma dependência (extra_deps) rodou DEPOIS do último mark_done deste estágio."""
    manifest = load_manifest(workdir)
    entry = manifest.get(stage)
    if not entry:
        return False
    if entry.get("script_hash") != script_hash(script_path):
        return False
    if entry.get("input_sig") != input_sig:
        return False
    if extra_deps:
        my_completed = entry.get("completed_at") or ""
        for dep in extra_deps:
            dep_entry = manifest.get(dep)
            if dep_entry and (dep_entry.get("completed_at") or "") > my_completed:
                return False  # dependência rodou depois de mim -> meu output pode estar obsoleto
    return True


def mark_done(workdir, stage, script_path, input_sig):
    manifest = load_manifest(workdir)
    manifest[stage] = {
        "script_hash": script_hash(script_path),
        "input_sig": input_sig,
        "completed_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
    }
    save_manifest(workdir, manifest)


def invalidate(workdir, stage):
    """Remove um estágio do manifest (força re-run na próxima checagem) — usado quando o próprio
    script muda de forma que exige reset de estado acumulado (ex: scene_judge.py mudou -> reseta
    scene_judge.json inteiro, não só invalida o gate de skip)."""
    manifest = load_manifest(workdir)
    if stage in manifest:
        del manifest[stage]
        save_manifest(workdir, manifest)
