#!/usr/bin/env python3
"""Motion graphics over a Kenerate clip (or a colour / gradient / image): kinetic title, subtitle, lower third,
CTA pill and an end logo sting. Frames are drawn with Pillow and encoded with ffmpeg.

Presets: bold-pop (words pop in on an accent sticker), minimal (fade + slide, thin rules), neon (glowing flicker).

Examples
  python motion.py --bg https://.../clip.mp4 --title "SUMMER DROP" --subtitle "New flavours, same kick" \
      --lower-third "Maya Lin|Head Roaster" --logo logo.png --cta "Shop now" --preset bold-pop --aspect 9:16
  python motion.py --bg "gradient:#0f172a,#7c3aed" --title "Launch day" --preset neon --duration 6 --aspect 16:9
"""
from __future__ import annotations

import argparse
import math
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import kmedia as K  # noqa: E402

PRESETS = {
    "bold-pop": {"accent": "#FFD60A", "text": "#111111", "sub": "#FFFFFF", "font": "bold"},
    "minimal": {"accent": "#FFFFFF", "text": "#FFFFFF", "sub": "#E5E5E5", "font": "bold"},
    "neon": {"accent": "#00F0FF", "text": "#FFFFFF", "sub": "#FF4FD8", "font": "bold"},
}


def clamp(x: float, a: float = 0.0, b: float = 1.0) -> float:
    return max(a, min(b, x))


def ease_out_cubic(t: float) -> float:
    t = clamp(t)
    return 1 - (1 - t) ** 3


def ease_out_back(t: float, s: float = 1.9) -> float:
    t = clamp(t) - 1
    return t * t * ((s + 1) * t + s) + 1


def ease_in_cubic(t: float) -> float:
    t = clamp(t)
    return t ** 3


def text_img(text: str, size: int, fill, kind: str = "bold", font=None, stroke: int = 0, stroke_fill=(0, 0, 0)):
    from PIL import Image, ImageDraw
    f = K.load_font(size, kind, font)
    d = ImageDraw.Draw(Image.new("RGBA", (4, 4)))
    b = d.textbbox((0, 0), text, font=f, stroke_width=stroke)
    pad = stroke + 2
    im = Image.new("RGBA", (b[2] - b[0] + pad * 2, b[3] - b[1] + pad * 2), (0, 0, 0, 0))
    ImageDraw.Draw(im).text((pad - b[0], pad - b[1]), text, font=f, fill=fill, stroke_width=stroke, stroke_fill=stroke_fill)
    return im


def glow(im, radius: int, color, strength: float = 1.6):
    from PIL import Image, ImageFilter
    pad = radius * 3
    base = Image.new("RGBA", (im.width + pad * 2, im.height + pad * 2), (0, 0, 0, 0))
    base.alpha_composite(im, (pad, pad))
    a = base.getchannel("A").filter(ImageFilter.GaussianBlur(radius)).point(lambda v: min(255, int(v * strength)))
    g = Image.new("RGBA", base.size, tuple(color) + (0,))
    g.putalpha(a)
    g.alpha_composite(base)
    return g


def with_opacity(im, o: float):
    if o >= 0.999:
        return im
    im = im.copy()
    im.putalpha(im.getchannel("A").point(lambda v: int(v * clamp(o))))
    return im


def place(canvas, im, cx: float, cy: float, scale: float = 1.0, opacity: float = 1.0, rot: float = 0.0) -> None:
    from PIL import Image
    if opacity <= 0.003 or scale <= 0.01:
        return
    if abs(scale - 1) > 0.002:
        im = im.resize((max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.BILINEAR)
    if abs(rot) > 0.05:
        im = im.rotate(rot, resample=Image.BICUBIC, expand=True)
    im = with_opacity(im, opacity)
    x, y = int(cx - im.width / 2), int(cy - im.height / 2)
    # clip to the canvas (alpha_composite refuses negative offsets)
    sx, sy = max(0, -x), max(0, -y)
    if sx >= im.width or sy >= im.height or x >= canvas.width or y >= canvas.height:
        return
    crop = im.crop((sx, sy, min(im.width, canvas.width - x), min(im.height, canvas.height - y)))
    canvas.alpha_composite(crop, (max(0, x), max(0, y)))


def flicker(t: float, start: float) -> float:
    """Neon tube switching on: a few deterministic blinks, then steady with a soft hum."""
    dt = t - start
    if dt < 0:
        return 0.0
    if dt < 0.6:
        pattern = [1, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 0]
        return 0.25 + 0.75 * pattern[int(dt / 0.05) % len(pattern)]
    return 0.92 + 0.08 * math.sin(dt * 9.0)


class Scene:
    def __init__(self, a, W: int, H: int, D: float):
        from PIL import Image, ImageDraw
        self.a, self.W, self.H, self.D = a, W, H, D
        p = PRESETS[a.preset]
        self.accent = K.hex_rgb(a.accent or p["accent"])
        self.fg = K.hex_rgb(p["text"]) if a.preset != "bold-pop" else (17, 17, 17)
        self.sub_col = K.hex_rgb(p["sub"])
        short = min(W, H)
        self.short = short
        self.vertical = H > W
        self.sting = bool(a.logo) and a.sting != "none"
        self.sting_len = min(1.8, D * 0.35) if self.sting else 0.0
        self.out_t = D - self.sting_len - (0.45 if self.sting else 0.5)
        # title: one line per word group, auto-fit to 84% width
        maxw = int(W * (0.84 if self.vertical else 0.7))
        title = a.title.upper() if a.preset != "minimal" else a.title
        f, lines, lh, _ = K.fit_text(title, maxw, int(H * 0.32), int(short * 0.16), 24, "bold", a.font, max_lines=3)
        size = getattr(f, "size", 64)
        self.title_size = size
        self.lines = lines
        self.line_h = lh
        if a.preset == "bold-pop":
            self.words = [[text_img(w, size, (17, 17, 17), font=a.font) for w in ln.split()] for ln in lines]
        elif a.preset == "neon":
            self.words = [[glow(text_img(w, size, (255, 255, 255), font=a.font, stroke=max(2, size // 40), stroke_fill=self.accent),
                                max(6, size // 7), self.accent) for w in ln.split()] for ln in lines]
        else:
            self.words = [[text_img(w, size, self.fg, font=a.font) for w in ln.split()] for ln in lines]
        self.space = int(size * 0.28)
        if a.preset == "neon":  # glow() pads every word by 3x its blur radius
            self.space -= 2 * 3 * max(6, size // 7)
        self.cy = H * (0.42 if self.vertical else 0.45)
        self.sub_img = None
        if a.subtitle:
            sf, sl, _, _ = K.fit_text(a.subtitle, maxw, int(short * 0.12), int(short * 0.055), 16, "regular", None, max_lines=2)
            ss = getattr(sf, "size", 28)
            col = self.sub_col
            im = text_img(" ".join(sl), ss, col, "regular") if len(sl) == 1 else text_img(sl[0], ss, col, "regular")
            if a.preset == "neon":
                im = glow(im, max(4, ss // 6), self.sub_col, 1.2)
            elif a.preset == "bold-pop":
                pad = int(ss * 0.45)
                box = Image.new("RGBA", (im.width + pad * 2, im.height + pad), (17, 17, 17, 235))
                box.alpha_composite(im, (pad, pad // 2))
                im = box
            self.sub_img = im
        self.lt = None
        if a.lower_third:
            name, _, role = a.lower_third.partition("|")
            self.lt = self._lower_third(name.strip(), role.strip())
        self.cta = None
        if a.cta:
            cs = int(short * 0.05)
            t = text_img(a.cta.upper() if a.preset != "minimal" else a.cta, cs, (17, 17, 17) if a.preset != "neon" else (255, 255, 255))
            pad = int(cs * 0.8)
            pill = Image.new("RGBA", (t.width + pad * 2, t.height + int(pad * 1.1)), (0, 0, 0, 0))
            d = ImageDraw.Draw(pill)
            if a.preset == "neon":
                d.rounded_rectangle((2, 2, pill.width - 3, pill.height - 3), radius=pill.height // 2, outline=self.accent, width=max(3, cs // 10))
            else:
                d.rounded_rectangle((0, 0, pill.width - 1, pill.height - 1), radius=pill.height // 2,
                                    fill=self.accent if a.preset == "bold-pop" else (255, 255, 255))
            pill.alpha_composite(t, (pad, int(pad * 0.55)))
            self.cta = glow(pill, max(4, cs // 4), self.accent, 1.0) if a.preset == "neon" else pill
        self.logo = None
        if a.logo:
            lg = K.open_image(Path(a.logo_path)).convert("RGBA")
            box = int(short * 0.36)
            lg.thumbnail((box, box), Image.LANCZOS)
            self.logo = lg
            self.tagline = text_img(a.tagline, int(short * 0.045), (255, 255, 255), "regular") if a.tagline else None
        # accent sticker / rule geometry
        self.title_w = max(sum(w.width for w in ln) + self.space * (len(ln) - 1) for ln in self.words)
        self.title_h = self.line_h * len(self.words)

    def _lower_third(self, name: str, role: str):
        from PIL import Image, ImageDraw
        a, s = self.a, self.short
        n = text_img(name, int(s * 0.05), (17, 17, 17) if a.preset == "bold-pop" else (255, 255, 255))
        r = text_img(role, int(s * 0.034), (40, 40, 40) if a.preset == "bold-pop" else (220, 220, 220), "regular") if role else None
        pad = int(s * 0.03)
        w = max(n.width, r.width if r else 0) + pad * 3
        h = n.height + (r.height + pad // 2 if r else 0) + pad * 2
        im = Image.new("RGBA", (w + pad, h), (0, 0, 0, 0))
        d = ImageDraw.Draw(im)
        if a.preset == "bold-pop":
            d.rectangle((0, 0, pad // 2, h), fill=self.accent)
            d.rectangle((pad // 2, 0, w, h), fill=(255, 255, 255, 245))
        elif a.preset == "neon":
            d.rounded_rectangle((2, 2, w - 2, h - 2), radius=pad, outline=self.accent, width=max(3, s // 300), fill=(5, 5, 20, 150))
        else:
            d.rectangle((0, 0, max(2, s // 400), h), fill=(255, 255, 255))
        im.alpha_composite(n, (pad * 2 if a.preset != "minimal" else pad, pad))
        if r:
            im.alpha_composite(r, (pad * 2 if a.preset != "minimal" else pad, pad + n.height + pad // 2))
        return glow(im, max(4, s // 120), self.accent, 1.0) if a.preset == "neon" else im

    # ── per-frame drawing ──
    def frame(self, t: float, base=None):
        from PIL import Image, ImageDraw
        W, H = self.W, self.H
        canvas = base.copy().convert("RGBA") if base is not None else Image.new("RGBA", (W, H), (0, 0, 0, 0))
        a = self.a
        t_in = 0.25
        exit_k = ease_in_cubic((t - self.out_t) / 0.45) if t > self.out_t else 0.0
        if a.preset == "minimal" and t > t_in:
            scrim = Image.new("RGBA", (W, H), (0, 0, 0, int(70 * ease_out_cubic((t - t_in) / 0.6) * (1 - exit_k))))
            canvas.alpha_composite(scrim)
        # title
        top = self.cy - self.title_h / 2
        if a.preset == "bold-pop":
            grow = ease_out_back((t - t_in) / 0.35, 1.4) * (1 - exit_k)
            if grow > 0.01:
                pw, ph = int((self.title_w + self.title_size * 0.8) * grow), int(self.title_h + self.title_size * 0.5)
                st = Image.new("RGBA", (max(pw, 1), ph), (0, 0, 0, 0))
                ImageDraw.Draw(st).rounded_rectangle((0, 0, st.width - 1, ph - 1), radius=int(self.title_size * 0.22), fill=self.accent)
                place(canvas, st, W / 2, self.cy, 1.0, 1.0, rot=2.5)
        k = 0
        for li, ln in enumerate(self.words):
            lw = sum(w.width for w in ln) + self.space * (len(ln) - 1)
            x = W / 2 - lw / 2
            y = top + li * self.line_h + self.line_h / 2
            for w in ln:
                st_t = t_in + 0.12 + k * 0.11
                cx = x + w.width / 2
                if a.preset == "bold-pop":
                    sc = ease_out_back((t - st_t) / 0.32) * (1 - exit_k)
                    place(canvas, w, cx, y, sc, 1.0 if sc > 0 else 0, rot=2.5)
                elif a.preset == "minimal":
                    p = ease_out_cubic((t - st_t) / 0.5)
                    place(canvas, w, cx, y + (1 - p) * self.title_size * 0.5 - exit_k * self.title_size * 0.4, 1.0, p * (1 - exit_k))
                else:  # neon
                    o = flicker(t, st_t - 0.1) * (1 - exit_k)
                    place(canvas, w, cx, y, 1.0 + 0.012 * math.sin(t * 3 + k), o)
                x += w.width + self.space
                k += 1
        title_bottom = top + self.title_h
        if a.preset == "minimal":
            p = ease_out_cubic((t - t_in - 0.3) / 0.6) * (1 - exit_k)
            if p > 0:
                rw = int(self.title_w * 0.5 * p)
                d = ImageDraw.Draw(canvas)
                yy = int(title_bottom + self.title_size * 0.25)
                d.rectangle((W // 2 - rw // 2, yy, W // 2 + rw // 2, yy + max(2, self.short // 360)), fill=self.accent + (255,))
        # subtitle
        below = title_bottom
        if self.sub_img is not None:
            st_t = t_in + 0.25 + k * 0.11
            p = ease_out_cubic((t - st_t) / 0.45) * (1 - exit_k)
            yy = title_bottom + self.title_size * (0.75 if a.preset != "minimal" else 0.9) + self.sub_img.height / 2
            below = yy + self.sub_img.height / 2
            place(canvas, self.sub_img, W / 2, yy + (1 - p) * 30, 1.0, p if a.preset != "neon" else p * flicker(t, st_t))
        # lower third
        if self.lt is not None:
            lt_in, lt_out = min(1.0, self.D * 0.2), self.out_t - 0.2
            if lt_in < t < lt_out + 0.5:
                p = ease_out_cubic((t - lt_in) / 0.45) - ease_in_cubic((t - lt_out) / 0.4)
                margin = self.short * 0.06
                x = -self.lt.width / 2 + p * (margin + self.lt.width)
                y = H * (0.8 if self.vertical else 0.82)
                if a.preset == "minimal":
                    place(canvas, self.lt, margin + self.lt.width / 2, y, 1.0, clamp(p))
                else:
                    place(canvas, self.lt, x, y, 1.0, 1.0)
        # CTA
        if self.cta is not None:
            c_in = min(self.D * 0.45, self.out_t - 1.0)
            p = ease_out_back((t - c_in) / 0.4) * (1 - exit_k)
            pulse = 1 + 0.03 * math.sin((t - c_in) * 6) if t > c_in + 0.4 else 1
            cy = max(H * (0.68 if self.vertical else 0.72), below + self.short * 0.04 + self.cta.height / 2)
            place(canvas, self.cta, W / 2, cy, p * pulse, 1.0 if p > 0 else 0)
        # logo sting
        if self.sting and self.logo is not None:
            s0 = self.D - self.sting_len
            if t >= s0 - 0.3:
                dim = ease_out_cubic((t - s0 + 0.3) / 0.4)
                canvas.alpha_composite(Image.new("RGBA", (W, H), (8, 8, 12, int(225 * dim))))
                p = (t - s0) / 0.55
                sc = ease_out_back(p, 1.6)
                if p > 0:
                    ring_p = clamp((t - s0) / 0.8)
                    if ring_p < 1:
                        r = int(self.logo.width * (0.4 + 0.9 * ease_out_cubic(ring_p)))
                        ring = Image.new("RGBA", (r * 2 + 8, r * 2 + 8), (0, 0, 0, 0))
                        ImageDraw.Draw(ring).ellipse((4, 4, r * 2 + 4, r * 2 + 4), outline=self.accent + (int(255 * (1 - ring_p)),),
                                                     width=max(3, self.short // 150))
                        place(canvas, ring, W / 2, H / 2, 1.0, 1.0)
                    place(canvas, self.logo, W / 2, H / 2 - (self.short * 0.03 if self.tagline else 0), sc, clamp(p * 2))
                    if self.tagline is not None:
                        q = ease_out_cubic((t - s0 - 0.35) / 0.4)
                        place(canvas, self.tagline, W / 2, H / 2 + self.logo.height / 2 + self.short * 0.03 + (1 - q) * 20, 1.0, q)
        return canvas


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--bg", required=True, help="video / image URL or file, '#RRGGBB', or 'gradient:#a,#b'")
    ap.add_argument("--title", required=True)
    ap.add_argument("--subtitle", default="")
    ap.add_argument("--lower-third", default="", help="'Name|Role'")
    ap.add_argument("--cta", default="", help="call-to-action pill, e.g. 'Shop now'")
    ap.add_argument("--logo", default="", help="logo PNG (transparent works best) for the end sting")
    ap.add_argument("--tagline", default="", help="line under the logo in the sting")
    ap.add_argument("--sting", choices=["end", "none"], default="end")
    ap.add_argument("--preset", choices=list(PRESETS), default="bold-pop")
    ap.add_argument("--accent", default="", help="accent colour #RRGGBB (default from preset)")
    ap.add_argument("--aspect", default="9:16")
    ap.add_argument("--res", type=int, default=1080)
    ap.add_argument("--fps", type=int, default=30)
    ap.add_argument("--duration", type=float, default=0, help="seconds (default: background clip length, else 6)")
    ap.add_argument("--music", default="", help="music URL / file (replaces the clip's sound)")
    ap.add_argument("--music-db", type=float, default=-6.0)
    K.add_font_args(ap, "TTF/OTF for the title")
    ap.add_argument("--out", default="kenerate-out/motion-design/motion.mp4")
    ap.add_argument("--preview", action="store_true", help="also save a PNG contact sheet of key frames")
    ap.add_argument("--keep-temp", action="store_true")
    a = K.parse_args(ap)

    st = K.Status(4, "motion-design")
    K.require(status=st)
    W, H = K.size_for(a.aspect, a.res)
    tmp = K.workdir(a.keep_temp)
    out = Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)

    st.step("Fetching inputs")
    base_img = K.parse_bg(a.bg, W, H)
    bg_video = None
    if base_img is None:
        p = K.fetch(a.bg, tmp, "bg", st)
        info = K.probe(p)
        if K.kind_of(str(p)) == "image" or info["duration"] <= 0.05:
            base_img = K.cover(K.open_image(p).convert("RGB"), W, H)
        else:
            bg_video = p
            if not a.duration:
                a.duration = min(info["duration"], 30.0)
    D = a.duration or 6.0
    if D < 2:
        K.fail("Duration must be at least 2 s.", "Pass --duration 4 or more for a readable animation.")
    a.logo_path = str(K.fetch(a.logo, tmp, "logo", st)) if a.logo else ""
    music = K.fetch(a.music, tmp, "music", st) if a.music else None

    st.step(f"Laying out '{a.preset}' ({W}x{H}, {D:.1f} s)")
    sc = Scene(a, W, H, D)
    st.info(f"title font {sc.title_size}px, {len(sc.lines)} line(s); sting {'on' if sc.sting else 'off'}")
    n = int(round(D * a.fps))

    st.step(f"Rendering {n} frames + encoding")
    if bg_video is not None:
        frames = (sc.frame(i / a.fps) for i in range(n))
        pipe = K.FramePipe(out, W, H, a.fps, n, background=["-stream_loop", "-1", "-i", str(bg_video)],
                           bg_filter=K.fit_filter(W, H, "cover"), audio=music, music_db=a.music_db, status=st)
    else:
        frames = (sc.frame(i / a.fps, base_img).convert("RGB") for i in range(n))
        pipe = K.FramePipe(out, W, H, a.fps, n, rgba=False, audio=music, music_db=a.music_db, status=st)
    pipe.run(frames)

    st.step("Checking output")
    info = K.file_info(out)
    sheet = None
    if a.preview:
        pts = [D * f for f in (0.08, 0.25, 0.5, 0.75, 0.97)]
        ims = [K.extract_frame(out, t, tmp / f"pv_{i}.jpg") for i, t in enumerate(pts)]
        sheet = K.contact_sheet(ims, out.with_name(out.stem + "_preview.jpg"), cols=5, cell=300,
                                labels=[f"{t:.1f}s" for t in pts])
    K.finish({"output": info, "preview": str(sheet) if sheet else None, "preset": a.preset, "frame": f"{W}x{H}",
              "seconds": D, "elapsed_s": st.elapsed()})


if __name__ == "__main__":
    K.main_guard(main)
