#!/usr/bin/env python3
"""Vidéo YouTube 16:9 — motion type Wise : fonds pleins, typo énorme, wipes.

Usage :
    .venv-scripts/bin/python scripts/generate_maintenance_prestashop_video.py
    .venv-scripts/bin/python scripts/generate_maintenance_prestashop_video.py --reuse-audio
"""

from __future__ import annotations

import asyncio
import json
import math
import subprocess
import sys
from functools import lru_cache
from pathlib import Path

import edge_tts
from PIL import Image, ImageDraw

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

from generate_maintenance_prestashop_visuals import load_font  # noqa: E402

OUT = ROOT / "assets/video-maintenance-prestashop-2026"
W, H, FPS = 1920, 1080, 30
VOICE = "fr-FR-RemyMultilingualNeural"
RATE = "-2%"
ARTICLE = "https://www.arnaud-merigeau.fr/contrat-maintenance-prestashop-2026/"

CREAM = (255, 243, 228)
PEACH = (255, 214, 186)
ORANGE = (233, 128, 48)
NAVY = (16, 32, 28)
INK = (22, 32, 28)
WHITE = (255, 255, 255)
RED = (196, 48, 42)
MINT = (214, 232, 214)


def clamp01(t: float) -> float:
    return 0.0 if t < 0 else 1.0 if t > 1 else t


def ease_out_expo(t: float) -> float:
    t = clamp01(t)
    return 1.0 if t == 1 else 1 - 2 ** (-10 * t)


def ease_out_back(t: float) -> float:
    t = clamp01(t)
    return 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2


def ease_in_out(t: float) -> float:
    t = clamp01(t)
    return 4 * t**3 if t < 0.5 else 1 - ((-2 * t + 2) ** 3) / 2


def lerp(a: float, b: float, t: float) -> float:
    return a + (b - a) * t


@lru_cache(maxsize=48)
def font(size: int, *, bold: bool = False, black: bool = False):
    return load_font(size, bold=bold, black=black)


def field(color: tuple[int, int, int]) -> Image.Image:
    return Image.new("RGB", (W, H), color)


def fit_type(lines: list[str], max_w: int = 1680, start: int = 200) -> ImageDraw.ImageFont:
    for size in range(start, 36, -8):
        f = font(size, black=True)
        tmp = ImageDraw.Draw(Image.new("RGB", (1, 1)))
        if max(tmp.textlength(line, font=f) for line in lines) <= max_w:
            return f
    return font(40, black=True)


def draw_lines(img: Image.Image, lines: list[str], color, enter: float, *, y: int | None = None) -> None:
    if enter <= 0.02:
        return
    d = ImageDraw.Draw(img)
    fnt = fit_type(lines)
    gap = int(fnt.size * 1.05)
    total_h = gap * len(lines)
    cy = (H - total_h) // 2 if y is None else y
    slide = int((1 - ease_out_expo(enter)) * 70)
    scale_nudge = 1 + (1 - ease_out_back(enter)) * 0.12
    for i, line in enumerate(lines):
        yy = cy + i * gap + slide
        # faux scale via légère opacité + offset, le fond est plat donc OK
        d.text((W // 2, yy), line, fill=color, font=fnt, anchor="mm")
    _ = scale_nudge


def wipe_over(base: Image.Image, incoming: Image.Image, p: float, *, side: str = "right") -> Image.Image:
    p = ease_out_expo(p)
    out = base.copy()
    if side == "right":
        x = int(lerp(W, 0, p))
        crop = incoming.crop((x, 0, W, H))
        out.paste(crop, (x, 0))
    elif side == "left":
        w = int(W * p)
        out.paste(incoming.crop((0, 0, w, H)), (0, 0))
    elif side == "up":
        y = int(lerp(H, 0, p))
        crop = incoming.crop((0, y, W, H))
        out.paste(crop, (0, y))
    else:
        r = int(math.hypot(W, H) * p)
        mask = Image.new("L", (W, H), 0)
        ImageDraw.Draw(mask).ellipse((W // 2 - r, H // 2 - r, W // 2 + r, H // 2 + r), fill=255)
        out.paste(incoming, (0, 0), mask)
    return out


def pill(img: Image.Image, box, fill, enter: float) -> None:
    if enter <= 0:
        return
    x0, y0, x1, y1 = box
    dx = int((1 - ease_out_back(enter)) * 80)
    d = ImageDraw.Draw(img)
    d.rounded_rectangle((x0 + dx, y0, x1 + dx, y1), radius=(y1 - y0) // 2, fill=fill)


def circle_icon(img: Image.Image, cx: int, cy: int, r: int, fill, glyph: str, gcolor, enter: float) -> None:
    if enter <= 0:
        return
    s = lerp(0.15, 1.0, ease_out_back(enter))
    rr = max(8, int(r * s))
    d = ImageDraw.Draw(img)
    d.ellipse((cx - rr, cy - rr, cx + rr, cy + rr), fill=fill)
    d.text((cx, cy), glyph, fill=gcolor, font=font(int(rr * 0.7), black=True), anchor="mm")


def play(t: float, beats: list[dict]) -> Image.Image:
    """beats: {at, hold, bg, lines, ink, wipe, extra}"""
    img = field(beats[0]["bg"])
    for i, beat in enumerate(beats):
        start = beat["at"]
        nxt = beats[i + 1]["at"] if i + 1 < len(beats) else 10_000
        wipe_d = beat.get("wipe", 0.28)
        if t < start:
            continue
        shot = field(beat["bg"])
        local = t - start
        enter = clamp01(local / 0.32)
        if beat.get("lines"):
            draw_lines(shot, beat["lines"], beat.get("ink", INK), enter)
        if extra := beat.get("extra"):
            extra(shot, local, enter)
        if t < start + wipe_d and i > 0:
            img = wipe_over(img, shot, local / wipe_d, side=beat.get("side", "right"))
        else:
            img = shot
        if t < nxt:
            return img
    return img


# --------------------------------------------------------------------------- scènes
def extra_stamp(img, local, enter):
    circle_icon(img, W // 2, 780, 90, RED, "×", WHITE, enter)


def extra_clock(img, local, enter):
    sweep = ease_in_out(clamp01((local - 0.2) / 6.5))
    total = lerp(10 * 60, 18 * 60 + 40, sweep)
    hours, minutes = total / 60, total % 60
    cx, cy, r = W // 2, 520, 210
    d = ImageDraw.Draw(img)
    d.ellipse((cx - r, cy - r, cx + r, cy + r), outline=ORANGE, width=10)
    for i in range(12):
        ang = math.radians(i * 30 - 90)
        d.line(
            [
                (cx + int((r - 26) * math.cos(ang)), cy + int((r - 26) * math.sin(ang))),
                (cx + int((r - 6) * math.cos(ang)), cy + int((r - 6) * math.sin(ang))),
            ],
            fill=ORANGE,
            width=5,
        )
    h_ang = math.radians((hours % 12) * 30 - 90)
    m_ang = math.radians(minutes * 6 - 90)
    d.line([(cx, cy), (cx + int(r * 0.5 * math.cos(h_ang)), cy + int(r * 0.5 * math.sin(h_ang)))], fill=INK, width=14)
    d.line([(cx, cy), (cx + int(r * 0.74 * math.cos(m_ang)), cy + int(r * 0.74 * math.sin(m_ang)))], fill=ORANGE, width=8)
    d.ellipse((cx - 10, cy - 10, cx + 10, cy + 10), fill=ORANGE)


def extra_pills_clauses(img, local, enter):
    labels = ["Périmètre", "SLA en heures", "Backup hors site", "Restauration"]
    y = 240
    for i, lab in enumerate(labels):
        a = clamp01((local - 0.15 - i * 0.16) / 0.28)
        pill(img, (360, y, 1560, y + 110), ORANGE if i < 3 else RED, a)
        if a > 0.4:
            ImageDraw.Draw(img).text((W // 2, y + 55), lab, fill=WHITE if i < 3 else WHITE, font=font(36, black=True), anchor="mm")
        y += 150


def extra_prices(img, local, enter):
    cols = [("VEILLE", "50-120"), ("STANDARD", "120-300"), ("AVANCÉ", "300-600"), ("CRITIQUE", "600+")]
    cw = 380
    x0 = (W - 4 * cw - 90) // 2
    for i, (name, price) in enumerate(cols):
        a = clamp01((local - i * 0.12) / 0.3)
        x = x0 + i * (cw + 30)
        y = 360 + int((1 - ease_out_back(a)) * 90)
        d = ImageDraw.Draw(img)
        d.rounded_rectangle((x, y, x + cw, y + 280), radius=40, fill=ORANGE if i != 3 else RED)
        if a > 0.35:
            d.text((x + cw // 2, y + 90), name, fill=WHITE, font=font(26, bold=True), anchor="mm")
            d.text((x + cw // 2, y + 170), price, fill=WHITE, font=font(40, black=True), anchor="mm")


def extra_url(img, local, enter):
    a = ease_out_back(enter)
    bw, bh = 1320, 150
    bx, by = (W - bw) // 2, 620
    d = ImageDraw.Draw(img)
    d.rounded_rectangle((bx, by, bx + bw, by + bh), radius=75, fill=INK)
    url = "arnaud-merigeau.fr/contrat-maintenance-prestashop-2026"
    n = max(1, int(len(url) * ease_out_expo(clamp01((local - 0.25) / 0.9))))
    d.text((W // 2, by + 75), url[:n], fill=CREAM, font=font(28, bold=True), anchor="mm")
    _ = a


def frame_hook(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": CREAM, "lines": ["Ton contrat."], "ink": INK},
            {"at": 2.1, "bg": ORANGE, "lines": ["SAUVEGARDE", "QUOTIDIENNE"], "ink": INK, "side": "right"},
            {"at": 6.4, "bg": NAVY, "lines": ["Elle tourne."], "ink": CREAM, "side": "up"},
            {"at": 10.2, "bg": CREAM, "lines": ["Restaurée", "quand ?"], "ink": ORANGE, "side": "iris"},
        ],
    )


def frame_quote(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": NAVY, "lines": ["JAMAIS", "TESTÉE"], "ink": ORANGE, "extra": extra_stamp},
            {"at": 3.7, "bg": CREAM, "lines": ["pas une", "garantie"], "ink": INK, "side": "left"},
            {"at": 8.2, "bg": ORANGE, "lines": ["C'est une", "hypothèse."], "ink": INK, "side": "right"},
        ],
    )


def frame_friday(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": CREAM, "lines": ["Mardi 10 h"], "ink": INK},
            {"at": 3.6, "bg": CREAM, "lines": [], "extra": extra_clock, "side": "up"},
            {"at": 10.2, "bg": ORANGE, "lines": ["18 H 40"], "ink": INK, "side": "iris"},
        ],
    )


def frame_cost(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": NAVY, "lines": ["822 €"], "ink": ORANGE},
            {"at": 4.6, "bg": ORANGE, "lines": ["3 JOURS"], "ink": INK, "side": "right"},
            {"at": 9.6, "bg": CREAM, "lines": ["= 1 AN", "DE CONTRAT"], "ink": INK, "side": "up"},
        ],
    )


def frame_cve(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": NAVY, "lines": ["9,3 / 10"], "ink": CREAM},
            {"at": 5.2, "bg": CREAM, "lines": ["XSS STOCKÉ"], "ink": RED, "side": "left"},
            {"at": 9.8, "bg": ORANGE, "lines": ["Qui pose", "le patch ?"], "ink": INK, "side": "iris"},
        ],
    )


def frame_contract(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": CREAM, "lines": ["14 CLAUSES"], "ink": INK},
            {"at": 4.2, "bg": NAVY, "lines": [], "extra": extra_pills_clauses, "side": "right"},
            {"at": 10.0, "bg": ORANGE, "lines": ["pas « on s'occupe", "de tout »"], "ink": INK, "side": "up"},
        ],
    )


def frame_prices(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": NAVY, "lines": [], "extra": extra_prices},
            {"at": 5.6, "bg": CREAM, "lines": ["50 à 600 €"], "ink": INK, "side": "left"},
            {"at": 11.6, "bg": ORANGE, "lines": ["En dessous", "de 50 € ?"], "ink": INK, "side": "iris"},
        ],
    )


def frame_cta(t: float, dur: float) -> Image.Image:
    return play(
        t,
        [
            {"at": 0.0, "bg": NAVY, "lines": ["LE GUIDE"], "ink": ORANGE},
            {"at": 3.6, "bg": CREAM, "lines": ["12 QUESTIONS"], "ink": INK, "side": "right"},
            {"at": 7.4, "bg": ORANGE, "lines": ["Lis-le."], "ink": INK, "extra": extra_url, "side": "up"},
        ],
    )


SCENES = [
    {
        "id": "01-hook",
        "voice": (
            "Ton contrat de maintenance PrestaShop. Il est écrit : sauvegarde quotidienne."
            "<break time='420ms'/>"
            "La question, c'est pas si elle tourne. C'est : la dernière fois qu'on l'a restaurée "
            "pour de vrai… c'était quand ?"
        ),
        "caption": "Ton contrat dit « sauvegarde quotidienne ». Elle a été restaurée quand ?",
        "frame": frame_hook,
    },
    {
        "id": "02-hypothese",
        "voice": (
            "Une sauvegarde jamais testée, c'est pas une garantie."
            "<break time='380ms'/>"
            "C'est une hypothèse."
            "<break time='420ms'/>"
            "Je relis des contrats toute l'année. Cette ligne-là, elle manque presque toujours."
        ),
        "caption": "Une sauvegarde jamais testée n'est pas une garantie. C'est une hypothèse.",
        "frame": frame_quote,
    },
    {
        "id": "03-vendredi",
        "voice": (
            "Et une boutique, elle tombe jamais le mardi à dix heures. Jamais."
            "<break time='320ms'/>"
            "Elle tombe le vendredi. Dix-huit heures quarante. En pleine opé. "
            "Quand le module de paiement refuse toutes les cartes."
        ),
        "caption": "Elle tombe le vendredi à 18 h 40, en pleine opération commerciale.",
        "frame": frame_friday,
    },
    {
        "id": "04-cout",
        "voice": (
            "À trois cent mille euros de chiffre d'affaires, un jour d'arrêt, "
            "c'est huit cent vingt-deux euros par terre."
            "<break time='280ms'/>"
            "Trois jours : tu viens de payer un an de contrat."
            "<break time='320ms'/>"
            "À un million, la journée vaut deux mille sept cent quarante euros."
        ),
        "caption": "300 k€ de CA : 822 € perdus par jour. 3 jours = un an de contrat.",
        "frame": frame_cost,
    },
    {
        "id": "05-cve",
        "voice": (
            "Sur la seule branche 8.2, PrestaShop a corrigé un XSS stocké, "
            "noté neuf virgule trois sur dix."
            "<break time='320ms'/>"
            "Les correctifs existent. La question, c'est qui les applique chez toi. Et sous quel délai."
        ),
        "caption": "Branche 8.2 : XSS stocké noté 9,3/10. Qui applique le patch chez toi ?",
        "frame": frame_cve,
    },
    {
        "id": "06-contrat",
        "voice": (
            "Un contrat sérieux, c'est pas une ligne « on s'occupe de tout »."
            "<break time='280ms'/>"
            "C'est quatorze clauses. Un S.L.A. en heures. Des sauvegardes hors serveur. "
            "Et un test de restauration, tous les trimestres."
        ),
        "caption": "14 clauses. Un SLA en heures. Un test de restauration chaque trimestre.",
        "frame": frame_contract,
    },
    {
        "id": "07-prix",
        "voice": (
            "Les tarifs du marché, en deux mille vingt-six : cinquante à cent vingt euros pour une veille. "
            "Cent vingt à trois cents en standard. Trois cents à six cents si tu veux un vrai S.L.A."
            "<break time='340ms'/>"
            "En dessous de cinquante, tu paies quelqu'un pour te dire que tout va bien."
        ),
        "caption": "Marché 2026 : 50-120 € en veille, 120-300 € en standard, 300-600 € avec SLA.",
        "frame": frame_prices,
    },
    {
        "id": "08-cta",
        "voice": (
            "J'ai publié le guide. Quatorze lignes, neuf clauses, douze questions à envoyer à ton prestataire."
            "<break time='280ms'/>"
            "Le lien est sous la vidéo."
        ),
        "caption": "Guide complet : arnaud-merigeau.fr/contrat-maintenance-prestashop-2026",
        "frame": frame_cta,
    },
]


def run(cmd: list[str]) -> None:
    subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)


def ffprobe_duration(path: Path) -> float:
    out = subprocess.check_output(
        [
            "ffprobe",
            "-v",
            "error",
            "-show_entries",
            "format=duration",
            "-of",
            "default=noprint_wrappers=1:nokey=1",
            str(path),
        ],
        text=True,
    )
    return float(out.strip())


async def _tts(text: str, dest: Path) -> None:
    dest.parent.mkdir(parents=True, exist_ok=True)
    speak = text if text.lstrip().startswith("<speak") else f"<speak>{text}</speak>"
    await edge_tts.Communicate(speak, VOICE, rate=RATE).save(str(dest))


def tts(text: str, dest: Path) -> None:
    asyncio.run(_tts(text, dest))


def render_clip(frame_fn, audio: Path, dest: Path, duration: float) -> None:
    n = max(int(round(duration * FPS)), FPS)
    cmd = [
        "ffmpeg",
        "-y",
        "-f",
        "rawvideo",
        "-pix_fmt",
        "rgb24",
        "-s",
        f"{W}x{H}",
        "-r",
        str(FPS),
        "-i",
        "-",
        "-i",
        str(audio),
        "-vf",
        "format=yuv420p",
        "-af",
        "apad",
        "-t",
        f"{duration:.3f}",
        "-c:v",
        "libx264",
        "-preset",
        "veryfast",
        "-crf",
        "18",
        "-c:a",
        "aac",
        "-b:a",
        "192k",
        "-movflags",
        "+faststart",
        str(dest),
    ]
    proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    assert proc.stdin is not None
    for i in range(n):
        proc.stdin.write(frame_fn(i / FPS, duration).convert("RGB").tobytes())
    proc.stdin.close()
    err = proc.communicate()[1]
    if proc.returncode != 0:
        raise RuntimeError(err[-2000:].decode("utf-8", "replace"))


def write_youtube(path: Path, total: float, scenes: list[dict]) -> None:
    mins, secs = int(total // 60), int(total % 60)
    labels = {
        "01-hook": "La sauvegarde jamais testée",
        "02-hypothese": "Une hypothèse, pas une garantie",
        "03-vendredi": "Vendredi 18 h 40",
        "04-cout": "Ce que coûte une journée d'arrêt",
        "05-cve": "Les failles 8.2 de 2026",
        "06-contrat": "Les 14 clauses d'un contrat sérieux",
        "07-prix": "Les tarifs du marché",
        "08-cta": "Le guide et les 12 questions",
    }
    t = 0.0
    chapters = []
    for scene in scenes:
        chapters.append(f"{int(t // 60):02d}:{int(t % 60):02d} {labels[scene['id']]}")
        t += scene["duration"]
    path.write_text(
        f"""Titre YouTube
Ce que ton contrat de maintenance PrestaShop ne dit pas (2026)

Description
Ton contrat dit « sauvegarde quotidienne ». Elle a été restaurée quand, pour de vrai ?

Dans cette vidéo, je reprends le guide que je viens de publier : ce qu'un contrat de maintenance PrestaShop doit contenir ligne par ligne, ce que ça coûte vraiment en 2026, et les clauses qui te laissent tout seul le vendredi à 18 h 40.

Lire l'article :
{ARTICLE}

Je suis Arnaud Mérigeau, freelance PrestaShop et WordPress, basé à Bordeaux. Je passe l'essentiel de mon temps sur des boutiques que je n'ai pas construites.

Me contacter :
https://www.arnaud-merigeau.fr/contact/

Chapitres
{chr(10).join(chapters)}

Durée montée : {mins}:{secs:02d}

Tags
PrestaShop, maintenance PrestaShop, contrat de maintenance, TMA, e-commerce, sauvegarde, SLA, sécurité PrestaShop
""",
        encoding="utf-8",
    )


def write_srt(scenes: list[dict], path: Path) -> None:
    def ts(t: float) -> str:
        h, m = int(t // 3600), int((t % 3600) // 60)
        return f"{h:02d}:{m:02d}:{t % 60:06.3f}".replace(".", ",")

    t = 0.0
    blocks = []
    for i, scene in enumerate(scenes, 1):
        blocks.append(f"{i}\n{ts(t)} --> {ts(t + scene['duration'] - 0.12)}\n{scene['caption']}\n")
        t += scene["duration"]
    path.write_text("\n".join(blocks), encoding="utf-8")


def main() -> int:
    audio_dir = OUT / "audio"
    clips = OUT / "clips"
    audio_dir.mkdir(parents=True, exist_ok=True)
    clips.mkdir(parents=True, exist_ok=True)
    reuse = "--reuse-audio" in sys.argv
    timed: list[dict] = []
    for scene in SCENES:
        mp3 = audio_dir / f"{scene['id']}.mp3"
        clip = clips / f"{scene['id']}.mp4"
        if not (reuse and mp3.exists()):
            print(f"  voix  {scene['id']}")
            tts(scene["voice"], mp3)
        dur = ffprobe_duration(mp3) + 0.45
        print(f"  motion {scene['id']} ({dur:.1f}s)")
        render_clip(scene["frame"], mp3, clip, dur)
        timed.append({**scene, "duration": ffprobe_duration(clip), "clip": clip})

    listing = OUT / "concat.txt"
    listing.write_text("".join(f"file '{c['clip']}'\n" for c in timed), encoding="utf-8")
    video = OUT / "contrat-maintenance-prestashop-2026.mp4"
    run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(listing), "-c", "copy", "-movflags", "+faststart", str(video)])
    total = ffprobe_duration(video)
    write_srt(timed, OUT / "sous-titres.srt")
    write_youtube(OUT / "youtube.txt", total, timed)
    (OUT / "scenes.json").write_text(
        json.dumps([{"id": s["id"], "duration": round(s["duration"], 2), "caption": s["caption"]} for s in timed], ensure_ascii=False, indent=2)
        + "\n",
        encoding="utf-8",
    )
    print(f"OK {video} ({total:.1f}s, {video.stat().st_size / 1_000_000:.1f} Mo)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
