#!/usr/bin/env python3
"""Passe visual ÚNICO por câmera (1 decode): face (YuNet) → closeness + centragem, emoção (enet_b0_8
ONNX) na maior face, e luma média (exposição). Alimenta as versões I_face/K_emotion/L_expo.
Cacheia facecache/{name}.json {fps, frames:[{t, nf, mff, cx, luma, joy, sad}]}.

  ./venv/bin/python analyze_visual.py NAME       # um source
  ./venv/bin/python analyze_visual.py --cam cam2
  ./venv/bin/python analyze_visual.py --all
"""
import json, subprocess, sys, os
import numpy as np, cv2

FFMPEG="/opt/homebrew/bin/ffmpeg"
W,FPS=640,4.0
EMO=["Anger","Contempt","Disgust","Fear","Happiness","Neutral","Sadness","Surprise"]
_yunet=[None]; _emo=[None]
# PORT VideoTask: modelos vivem em scripts/models/ (relativo ao script, não ao cwd — o cwd é o
# workdir dinâmico do bridge Swift, sem os modelos).
_MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
def yunet(w,h):
    d=cv2.FaceDetectorYN.create(os.path.join(_MODELS_DIR,"yunet.onnx"),"",(w,h),score_threshold=0.6,nms_threshold=0.3,top_k=50)
    return d
def emo_sess():
    if _emo[0] is None:
        import onnxruntime as ort
        import os as _os
        _EMO_MODEL = _os.path.join(_os.path.dirname(__file__), "models", "enet_b0_8_best_vgaf.onnx")
        _emo[0]=ort.InferenceSession(_EMO_MODEL,
                                     providers=["CPUExecutionProvider"])
    return _emo[0]

def emotion(face_bgr):
    img=cv2.resize(face_bgr,(224,224))[:,:,::-1].astype(np.float32)/255.0
    img=(img-[0.485,0.456,0.406])/[0.229,0.224,0.225]
    x=np.transpose(img,(2,0,1))[None].astype(np.float32)
    s=emo_sess(); out=s.run(None,{s.get_inputs()[0].name:x})[0][0]
    e=np.exp(out-out.max()); p=e/e.sum()
    return float(p[4]), float(p[6])   # Happiness, Sadness

FFPROBE=FFMPEG.replace("ffmpeg","ffprobe")
class DecodeError(Exception): pass

def _duration_s(path):
    pr=subprocess.run([FFPROBE,"-v","error","-select_streams","v:0","-show_entries",
                       "format=duration","-of","json",path],capture_output=True,text=True)
    try: return float(json.loads(pr.stdout)["format"]["duration"])
    except Exception: return None

def _nframes_expected(path,fps=FPS):
    d=_duration_s(path)
    try: return int(d*fps) if d is not None else None
    except Exception: return None

def sample_fps_for(path):
    """Clips longos (master 20–80 min) a 4 fps estouram o budget de cut — baixa densidade."""
    d = _duration_s(path) or 0
    if d > 1800:   # >30 min
        return 1.0
    if d > 600:    # >10 min
        return 2.0
    return FPS

def frames(path,w=W,fps=FPS,hw=True):
    import json as J
    pr=subprocess.run([FFPROBE,"-v","error","-select_streams","v:0",
                       "-show_entries","stream=width,height","-of","json",path],capture_output=True,text=True)
    st=J.loads(pr.stdout)["streams"][0]; h=int(round(w*st["height"]/st["width"]))//2*2
    cmd=[FFMPEG,"-v","error"]+(["-hwaccel","videotoolbox"] if hw else [])+["-i",path,
         "-vf",f"scale={w}:{h},fps={fps}","-pix_fmt","bgr24","-f","rawvideo","-"]
    p=subprocess.Popen(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
    sz=w*h*3; n=0
    while True:
        b=p.stdout.read(sz)
        if len(b)<sz: break
        n+=1; yield np.frombuffer(b,np.uint8).reshape(h,w,3)
    p.stdout.close(); err=(p.stderr.read() or b"").decode(errors="replace"); p.wait()
    # Só rejeita decode CURTO/vazio. VideoToolbox em batch (dezenas de 4K) cospe
    # "Error submitting packet" / "Decoding error" no stderr MESMO com 100% dos frames —
    # tratar isso como falha forçava re-decode software e DOBRAVA o tempo (timeout 3h no cut).
    exp=_nframes_expected(path,fps)
    hard_fail = (
        n == 0
        or "No frame decoded" in err
        or "hardware accelerator failed" in err
        or (exp is not None and n < exp * 0.9)
        or (p.returncode != 0 and (exp is None or n < exp * 0.95))
    )
    if hard_fail:
        raise DecodeError(f"decodou {n}/{exp if exp else '?'} frames rc={p.returncode} hw={hw}"
                          + (f" [{err.strip().splitlines()[-1][:80]}]" if err.strip() else ""))

def analyze(path):
    # Batch de facecache: software é o default estável. VT em loop longo só atrasa
    # (stderr ruidoso + re-decode). HW opt-in: VIDEOTASK_FACE_HW=1.
    use_hw = os.environ.get("VIDEOTASK_FACE_HW", "").strip().lower() in ("1", "true", "yes")
    if use_hw:
        try:
            return _analyze_once(path, hw=True)
        except DecodeError as e:
            print(f"  ! decode hwaccel {os.path.basename(path)}: {e} — refazendo em software", flush=True)
            return _analyze_once(path, hw=False)
    return _analyze_once(path, hw=False)

def _analyze_once(path,hw=True):
    det=None; out=[]; i=0
    fps = sample_fps_for(path)
    for fr in frames(path, hw=hw, fps=fps):
        h,w=fr.shape[:2]
        if det is None: det=yunet(w,h)
        luma=float(cv2.cvtColor(fr,cv2.COLOR_BGR2GRAY).mean())
        _,faces=det.detect(fr)
        nf=0 if faces is None else len(faces)
        mff=0.0; cx=0.5; cy=0.5; joy=None; sad=None; sharp=None
        if faces is not None and len(faces):
            big=max(faces,key=lambda f:f[2]*f[3])
            x,y,fw,fh=[int(v) for v in big[:4]]
            mff=float(fh)/h; cx=float(x+fw/2)/w; cy=float(y+fh/2)/h
            if mff>0.08:
                crop=fr[max(0,y):y+fh,max(0,x):x+fw]
                if crop.size:
                    joy,sad=emotion(crop)
                    # nitidez NA REGIÃO DO ROSTO (var-Laplaciano) — subject-blur, não fundo
                    g=cv2.cvtColor(crop,cv2.COLOR_BGR2GRAY)
                    sharp=float(cv2.Laplacian(g,cv2.CV_64F).var())
        out.append({"t":round(i/fps,2),"nf":nf,"mff":round(mff,3),"cx":round(cx,3),"cy":round(cy,3),
                    "luma":round(luma,1),"joy":round(joy,3) if joy is not None else None,
                    "sad":round(sad,3) if sad is not None else None,
                    "sharp":round(sharp,1) if sharp is not None else None})
        i+=1
    return {"fps":fps,"frames":out}

def sources(cam=None,name=None):
    import vlib as V
    M=json.load(open("media_paths.json")); P=json.load(open("placements.json"))["clips"]
    if name: return {name:M[name]} if name in M else {}
    r={}
    for c in P:
        # v0.4: footage_key(name, cam=) + resolve — contrato name=f"{cam}_{stem}"
        n = V.footage_key(c["name"], cam=c.get("cam"))
        path = V.resolve_media_path(c["name"], M, cam=c.get("cam"))
        if (cam is None or c["cam"] == cam) and path and n:
            r[n] = path
    return r

if __name__=="__main__":
    os.makedirs("facecache",exist_ok=True)
    if sys.argv[1]=="--cam": srcs=sources(cam=sys.argv[2])
    elif sys.argv[1]=="--all": srcs=sources()
    else: srcs=sources(name=sys.argv[1])
    rev="--rev" in sys.argv
    items=list(srcs.items())
    # Curtos primeiro: em timeout do orquestrador, maximiza n de caches úteis; re-run retoma.
    def _dur(p):
        try:
            return float(json.loads(subprocess.run(
                [FFPROBE,"-v","error","-show_entries","format=duration","-of","json",p],
                capture_output=True,text=True).stdout)["format"]["duration"])
        except Exception:
            return 1e9
    items.sort(key=lambda kv: _dur(kv[1]), reverse=bool(rev))
    failed=[]
    n_cached=n_done=0
    print(f"analyze_visual: {len(items)} sources | FACE_HW={os.environ.get('VIDEOTASK_FACE_HW','0')} "
          f"| curtos-primeiro={not rev}", flush=True)
    for name,path in items:
        out=f"facecache/{name}.json"
        if os.path.exists(out):
            print(f"  cached: {name}",flush=True); n_cached+=1; continue
        if not os.path.exists(path): print(f"  SKIP {name}",flush=True); continue
        try: r=analyze(path)
        except DecodeError as e:
            print(f"  ✗ DECODE FALHOU {name}: {e}",flush=True); failed.append(name); continue
        except Exception as e: print(f"  ERROR {name}: {e}",flush=True); failed.append(name); continue
        tmp=f"{out}.{os.getpid()}.tmp"; json.dump(r,open(tmp,"w")); os.replace(tmp,out)
        n_done+=1
        fr=r["frames"]; mff=np.array([x["mff"] for x in fr]); nf=np.array([x["nf"] for x in fr])
        print(f"  {name:40} n={len(fr):>5} faces%={100*np.mean(nf>0):.0f} closeness_p90={np.percentile(mff,90):.2f}",flush=True)
    print(f"\nfacecache: novos={n_done} cached={n_cached} failed={len(failed)} total_src={len(items)}",flush=True)
    # Só aborta se NADA útil (0 caches) — parcial permite o cut seguir; re-run preenche o resto.
    n_ok = len([1 for n,_ in items if os.path.exists(f"facecache/{n}.json")])
    if n_ok == 0 and items:
        print(f"\n✗ PERCEPÇÃO facial: 0/{len(items)} caches — decode total falhou: {failed}",flush=True)
        sys.exit(1)
    if failed:
        print(f"  ⚠️  {len(failed)} fonte(s) sem face cache (cut segue com cobertura parcial): {failed[:8]}",
              flush=True)
