#!/usr/bin/env python3
"""
fc_player.py — Malin FC expression player (the runtime).

Loads a video-harvested expression library + manifest and plays expressions on a
frameless always-on-top window driven by Jun's keyboard NUMBERS, with an idle
base loop + INTERRUPTIBLE crossfade transitions (A->B with no neutral detour).
Design: fc_runtime_spec.md. Library shape: fc_video_library_instructions.md.

Modes:
  fc_player.py --library DIR --gui                       # interactive window (run on the 5090)
  fc_player.py --library DIR --demo out.mp4 --script "2@0,4@2.5,3@4"   # headless render for review
  fc_player.py --make-placeholder DIR                    # synth a tiny test library from Malin stills

Library layout (Hermes produces this):
  DIR/manifest.json
  DIR/<emotion>/frame_0000.png ...
manifest.json:
  { "fps":24, "resolution":[W,H],
    "hotkeys": {"1":"neutral","2":"happy",...},
    "emotions": { "neutral": {"dir":"neutral","frames":N,"beats":{"idle":[0,N]}},
                  "happy":   {"dir":"happy","frames":M,
                              "beats":{"onset":[0,a],"peak":[a,b],"offset":[b,M]}} } }
"""
import argparse, json, os, random, sys
import numpy as np
import cv2

XF = 8          # crossfade length in frames (~0.33s @24fps) — the interrupt bridge
HOLD_LOOP = True  # loop the peak range while held


class Library:
    """Loads frames + manifest. Frames are BGR numpy arrays, all same resolution."""
    def __init__(self, root):
        self.root = root
        with open(os.path.join(root, "manifest.json")) as f:
            self.man = json.load(f)
        self.fps = self.man.get("fps", 24)
        self.hotkeys = self.man.get("hotkeys", {})
        self.emotions = self.man["emotions"]
        self._cache = {}

    def beats(self, emo):
        return self.emotions[emo]["beats"]

    def seg(self, emo, beat):
        """(start,end) frame range for a beat, or None."""
        return self.emotions[emo]["beats"].get(beat)

    def frame(self, emo, idx):
        key = (emo, idx)
        if key not in self._cache:
            d = os.path.join(self.root, self.emotions[emo]["dir"])
            p = os.path.join(d, f"frame_{idx:04d}.png")
            im = cv2.imread(p)
            if im is None:  # tolerate sparse placeholder libs — hold last good
                im = self._cache.get((emo, idx - 1))
            self._cache[key] = im
        return self._cache[key]


class ExpressionPlayer:
    """Frame-driven state machine. Call trigger(emo) on a keypress; next_frame()
    each tick returns the composited BGR frame. Interrupt-safe from any state."""
    def __init__(self, lib, neutral="neutral", auto_return_after=None):
        self.lib = lib
        self.neutral = neutral
        self.auto_return = auto_return_after  # frames to hold a peak before auto-offset (None=hold until key)
        self.cur = neutral
        self.mode = "idle"      # idle | xfade | onset | hold | offset
        self.idx = self._beat_start(neutral, "idle", 0)
        self.last = self.lib.frame(neutral, self.idx)
        self._xf_from = None
        self._xf_to = None
        self._xf_t = 0
        self._hold_count = 0
        self._blink_q = []                               # idle-blink: queued frame indices
        self._blink_timer = random.randint(40, 90)       # frames until next blink

    def _beat_start(self, emo, beat, default):
        s = self.lib.seg(emo, beat)
        return s[0] if s else default

    def trigger(self, emo):
        """Interrupt whatever's playing and crossfade from the CURRENT frame into
        the target. From an ACTIVE expression, blend straight into the target's
        PEAK (skip its neutral onset) so it reads expression->expression, not via
        neutral. From neutral/idle, blend into the onset and play the full arc."""
        if emo not in self.lib.emotions:
            return
        active = (self.cur != self.neutral and self.mode in ("onset", "peak"))
        if active and self.lib.seg(emo, "peak"):
            self._xf_to = (emo, self._beat_start(emo, "peak", 0))
            self._after_xf = "peak"
        elif self.lib.seg(emo, "onset"):
            self._xf_to = (emo, self._beat_start(emo, "onset", 0))
            self._after_xf = "onset"
        else:
            self._xf_to = (emo, 0)
            self._after_xf = "idle"
        self._xf_from = self.last.copy()
        self._xf_t = 0
        self.mode = "xfade"

    def _ease(self, t):
        return 4 * t**3 if t < 0.5 else 1 - pow(-2 * t + 2, 3) / 2

    def next_frame(self):
        m = self.mode
        if m == "xfade":
            t = self._ease((self._xf_t + 1) / XF)
            to_frame = self.lib.frame(*self._xf_to)
            out = cv2.addWeighted(self._xf_from, 1 - t, to_frame, t, 0)
            self._xf_t += 1
            if self._xf_t >= XF:
                self.cur, self.idx = self._xf_to
                self.mode = self._after_xf
            self.last = out
            return out
        if m == "onset":
            seg = self.lib.seg(self.cur, "onset")
            self.idx += 1
            if seg and self.idx >= seg[1]:
                self.idx = self._beat_start(self.cur, "peak", self.idx)
                self.mode = "peak"
        elif m == "peak":                            # play peak ONCE (no loop), then settle out
            peak = self.lib.seg(self.cur, "peak")
            self.idx += 1
            if peak and self.idx >= peak[1]:
                off = self.lib.seg(self.cur, "offset")
                if off:
                    self.idx = off[0]; self.mode = "offset"
                else:
                    self.cur, self.idx, self.mode = self.neutral, 0, "idle"
        elif m == "offset":                          # play the return-to-neutral, then idle
            off = self.lib.seg(self.cur, "offset")
            self.idx += 1
            if off and self.idx >= off[1]:
                self.cur, self.idx, self.mode = self.neutral, 0, "idle"
        elif m == "idle":
            # neutral folder = [0 open, 1 half-lid, 2 closed]; blink periodically so she's not a frozen stare
            nf = self.lib.emotions[self.neutral].get("frames", 1)
            if nf >= 3:
                if self._blink_q:
                    self.idx = self._blink_q.pop(0)
                else:
                    self._blink_timer -= 1
                    if self._blink_timer <= 0:
                        self._blink_q = [1, 2, 1, 0]           # half -> closed -> half -> open
                        self._blink_timer = random.randint(48, 90)
                        self.idx = self._blink_q.pop(0)
                    else:
                        self.idx = 0
            else:
                self.idx = 0                                  # single-frame neutral -> hold
        self.last = self.lib.frame(self.cur, self.idx)
        return self.last


# ---------- headless demo (validate logic + preview for Jun) ----------
def run_demo(lib, out_path, script, seconds):
    """script: list of (time_sec, emotion). Render a frame stream to mp4."""
    fps = lib.fps
    pl = ExpressionPlayer(lib)
    total = int(seconds * fps)
    events = {int(t * fps): emo for t, emo in script}
    h, w = lib.frame(pl.neutral, pl.idx).shape[:2]
    tmp = out_path + ".frames"
    os.makedirs(tmp, exist_ok=True)
    for f in os.listdir(tmp):
        os.remove(os.path.join(tmp, f))
    for n in range(total):
        if n in events:
            pl.trigger(events[n])
        fr = pl.next_frame()
        cv2.imwrite(os.path.join(tmp, f"f{n:04d}.png"), fr)
    import subprocess
    subprocess.run(["ffmpeg", "-y", "-framerate", str(fps), "-i",
                    os.path.join(tmp, "f%04d.png"), "-c:v", "libx264",
                    "-pix_fmt", "yuv420p", "-crf", "18", out_path],
                   check=True, capture_output=True)
    print("wrote", out_path, f"({total} frames @ {fps}fps)")


# ---------- interactive GUI (Hermes runs this on the 5090) ----------
def run_gui(lib):
    import tkinter as tk
    from PIL import Image, ImageTk
    pl = ExpressionPlayer(lib)
    root = tk.Tk()
    root.overrideredirect(True)
    root.attributes("-topmost", True)
    lbl = tk.Label(root, bd=0)
    lbl.pack()
    delay = int(1000 / lib.fps)

    def tick():
        fr = pl.next_frame()
        rgb = cv2.cvtColor(fr, cv2.COLOR_BGR2RGB)
        img = ImageTk.PhotoImage(Image.fromarray(rgb))
        lbl.img = img
        lbl.config(image=img)
        root.after(delay, tick)

    def onkey(e):
        if e.keysym in ("q", "Escape"):
            root.destroy(); return
        emo = lib.hotkeys.get(e.char)
        if emo:
            pl.trigger(emo)
    root.bind("<Key>", onkey)
    # drag to move
    st = {}
    lbl.bind("<Button-1>", lambda e: st.update(x=e.x, y=e.y))
    lbl.bind("<B1-Motion>", lambda e: root.geometry(f"+{root.winfo_x()+e.x-st['x']}+{root.winfo_y()+e.y-st['y']}"))
    tick()
    root.mainloop()


# ---------- placeholder library (test the player before Hermes's real one) ----------
def make_placeholder(dirpath):
    """Synthesize a tiny library from the Malin stills so the player logic can be
    tested + demoed. NOT real assets — proves load/crossfade/hotkeys/sequencing."""
    look = "/Users/feral/dev/feral-cc-bots/calypso/specs/malin-look"
    poc = "/Users/feral/dev/feral-cc-bots/calypso/specs/malin-harness/fc_warp_poc"
    anchor = cv2.imread(os.path.join(look, "malin_anchor.jpg"))
    H, W = anchor.shape[:2]
    def load(p, fallback=anchor):
        im = cv2.imread(p)
        return cv2.resize(im, (W, H)) if im is not None else fallback
    kA = load(os.path.join(poc, "key_a.png"))
    kB = load(os.path.join(poc, "key_b.png"))
    kC = load(os.path.join(poc, "smile_key_aligned.png"))
    # build emotion frame sequences (placeholder: happy real-ish; others tinted to test SWITCHING)
    def tint(im, b, g, r):
        t = im.astype(np.float32) * np.array([b, g, r]); return np.clip(t, 0, 255).astype(np.uint8)
    seqs = {
        "neutral": [anchor],
        "happy":   [anchor, kA, kB, kC, kC, kC, kB, kA, anchor],          # onset0-3 peak3-6 offset6-9
        "flirty":  [anchor, kB, kC, kC, kC, kB, anchor],                  # different shape, real-ish
        "surprise":[anchor, tint(anchor,1.05,1.05,1.05), tint(kB,1.1,1.1,1.15), tint(kB,1.1,1.1,1.15), tint(anchor,1.05,1.05,1.05), anchor],
        "thoughtful":[anchor, tint(anchor,1.0,0.97,0.93), tint(anchor,0.97,0.95,0.9), tint(anchor,0.97,0.95,0.9), anchor],
    }
    beats = {
        "neutral": {"idle": [0, 1]},
        "happy":   {"onset": [0, 3], "peak": [3, 6], "offset": [6, 9]},
        "flirty":  {"onset": [0, 2], "peak": [2, 5], "offset": [5, 7]},
        "surprise":{"onset": [0, 2], "peak": [2, 4], "offset": [4, 6]},
        "thoughtful":{"onset": [0, 2], "peak": [2, 4], "offset": [4, 5]},
    }
    os.makedirs(dirpath, exist_ok=True)
    emotions = {}
    for emo, frames in seqs.items():
        d = os.path.join(dirpath, emo)
        os.makedirs(d, exist_ok=True)
        for i, fr in enumerate(frames):
            cv2.imwrite(os.path.join(d, f"frame_{i:04d}.png"), fr)
        emotions[emo] = {"dir": emo, "frames": len(frames), "beats": beats[emo]}
    manifest = {
        "fps": 24, "resolution": [W, H],
        "hotkeys": {"1": "neutral", "2": "happy", "3": "flirty", "4": "surprise", "5": "thoughtful"},
        "emotions": emotions,
    }
    with open(os.path.join(dirpath, "manifest.json"), "w") as f:
        json.dump(manifest, f, indent=2)
    print("placeholder library at", dirpath, "->", list(emotions))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--library")
    ap.add_argument("--gui", action="store_true")
    ap.add_argument("--demo")
    ap.add_argument("--script", default="")
    ap.add_argument("--seconds", type=float, default=8.0)
    ap.add_argument("--make-placeholder")
    a = ap.parse_args()
    if a.make_placeholder:
        make_placeholder(a.make_placeholder); return
    lib = Library(a.library)
    if a.gui:
        run_gui(lib)
    elif a.demo:
        script = []
        for tok in filter(None, a.script.split(",")):
            emo, t = tok.split("@"); script.append((float(t), emo))
        run_demo(lib, a.demo, script, a.seconds)
    else:
        print("specify --gui or --demo")


if __name__ == "__main__":
    main()
