#!/usr/bin/env python3
"""Собрать фотомозаику — из фотографий или из кадров самого исходника.

Приём, который в промо Palmier читается как «много фоток»: десятки снимков
влетают в кадр сеткой, накладываются, схлопываются в один. Он делает две вещи
сразу — показывает объём материала и даёт всплеск движения там, где по речи
нужен акцент.

Ключевая мысль: **фотографии не обязаны быть фотографиями**. Если снимков нет,
их даёт сам ролик. Кадры, вырезанные из исходника на границах смысловых блоков,
работают лучше стоковых картинок, потому что зритель их уже видел — мозаика
читается как «вот всё, о чём мы говорили», а не как случайный набор.

Скрипт готовит две вещи:

* **папку кадров** — вырезанных из видео по временам, которые имеют смысл:
  начало каждого блока субтитров, точки склейки, моменты акцентов;
* **план для HyperFrames** — раскладку сетки, порядок влёта и тайминги, готовый
  к рендеру композицией `assets/hyperframes/mosaic`.

Раскладка считается, а не берётся из шаблона: под 12 снимков нужна сетка 3×4, под
40 — 5×8, и размер ячейки обязан делить канвас нацело, иначе по краям остаются
полосы в один-два пикселя, которые на градиенте видно.
"""
from __future__ import annotations

import argparse
import json
import math
import subprocess
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from media_probe import probe  # noqa: E402
from skill_config import canvas as resolve_canvas, emit, require_binary, utf8_stdout  # noqa: E402


def grid_for(count: int, width: int, height: int) -> tuple[int, int, int, int]:
    """Сетка под N снимков и размер ячейки, делящий канвас нацело.

    Пропорция сетки подгоняется под пропорцию кадра: на вертикальном канвасе
    5 столбцов на 8 рядов выглядит как сетка, а 8 на 5 — как обрезанная лента.
    """
    aspect = height / width
    columns = max(1, int(round(math.sqrt(count / aspect))))
    rows = max(1, math.ceil(count / columns))
    while columns * rows < count:
        columns += 1
    # Ячейка кратна двум: нечётный размер ломает yuv420p при рендере.
    cell_w = max(2, (width // columns) & ~1)
    cell_h = max(2, (height // rows) & ~1)
    return columns, rows, cell_w, cell_h


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--video", help="Исходник, из которого резать кадры")
    parser.add_argument("--photos", help="Папка с готовыми снимками (вместо кадров)")
    parser.add_argument("--timeline", help="timeline.json — откуда брать осмысленные моменты")
    parser.add_argument("--caption-map", help="caption_map.json — по блокам субтитров")
    parser.add_argument("--out", required=True, help="Папка для кадров и плана")
    parser.add_argument("--count", type=int, default=24)
    parser.add_argument("--at", type=float, help="Куда в ролике поставить мозаику, с")
    parser.add_argument("--duration", type=float, default=2.6)
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--style", default="assemble",
                        choices=["assemble", "cascade", "shuffle"],
                        help="assemble — сетка собирается и схлопывается в один кадр; "
                             "cascade — снимки сыплются волной; "
                             "shuffle — быстрая перетасовка на месте")
    args = parser.parse_args()

    require_binary("ffmpeg")
    board = resolve_canvas(args.canvas)
    out = Path(args.out).resolve()
    frames_dir = out / "frames"
    frames_dir.mkdir(parents=True, exist_ok=True)

    sources: list[dict] = []

    if args.photos:
        folder = Path(args.photos)
        for path in sorted(folder.rglob("*")):
            if path.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp"):
                sources.append({"path": str(path), "from": "photo"})
        sources = sources[:args.count]

    if not sources and args.video:
        video = Path(args.video).resolve()
        if not video.is_file():
            raise SystemExit(f"Нет файла: {video}")
        info = probe(video)
        duration = float((info.get("video") or {}).get("duration")
                         or info.get("duration") or 0.0)

        # Моменты, которые что-то значат, а не равномерная сетка по времени.
        moments: list[tuple[float, str]] = []
        if args.caption_map and Path(args.caption_map).is_file():
            blocks = json.loads(Path(args.caption_map).read_text(encoding="utf-8")).get("blocks", [])
            moments += [(float(b["start"]) + 0.15, (b.get("text") or "")[:30]) for b in blocks]
        if args.timeline and Path(args.timeline).is_file():
            data = json.loads(Path(args.timeline).read_text(encoding="utf-8"))
            moments += [(float(c["global_start"]) + 0.2, "склейка")
                        for c in (data.get("clips") or [])[1:]]
        if not moments:
            moments = [(duration * (i + 0.5) / args.count, "равномерно")
                       for i in range(args.count)]

        # Прореживание до нужного числа с равномерным шагом по списку: взять
        # первые N подряд значило бы собрать мозаику из одного начала ролика.
        moments.sort(key=lambda item: item[0])
        moments = [m for m in moments if 0.2 < m[0] < duration - 0.2]
        if len(moments) > args.count:
            step = len(moments) / args.count
            moments = [moments[int(i * step)] for i in range(args.count)]

        for index, (at, label) in enumerate(moments):
            target = frames_dir / f"m{index:03d}.jpg"
            done = subprocess.run(
                ["ffmpeg", "-y", "-v", "error", "-nostdin", "-ss", f"{at:.3f}",
                 "-i", str(video), "-frames:v", "1",
                 # Тот же режим, что в контрольных кадрах: mjpeg отказывается
                 # кодировать limited-range YUV готового мастера.
                 "-vf", "scale=out_range=pc,format=yuvj420p", "-q:v", "3", str(target)],
                capture_output=True, text=True, errors="replace")
            if done.returncode or not target.is_file():
                continue
            sources.append({"path": str(target), "from": "frame",
                            "at": round(at, 3), "label": label})

    if not sources:
        raise SystemExit("Нечего складывать: не заданы ни --photos, ни --video")

    columns, rows, cell_w, cell_h = grid_for(len(sources), board.width, board.height)

    # Порядок появления. Для каждого стиля он свой и это и есть весь эффект.
    order = list(range(len(sources)))
    if args.style == "cascade":
        # Волной по диагонали: ячейка появляется тем позже, чем дальше она от
        # верхнего левого угла.
        order.sort(key=lambda i: (i % columns) + (i // columns))
    elif args.style == "shuffle":
        # Детерминированная перетасовка: один и тот же план на каждом прогоне,
        # иначе повторный рендер не совпадёт с уже собранным монтажом.
        seed = 20260801
        keyed = []
        for i in order:
            seed = (seed * 1664525 + 1013904223) % 4294967296
            keyed.append((seed, i))
        order = [i for _s, i in sorted(keyed)]

    step = max(0.012, (args.duration * 0.55) / max(1, len(sources)))
    tiles = []
    for slot, index in enumerate(order):
        column, row = index % columns, index // columns
        tiles.append({
            "index": index,
            "src": sources[index]["path"],
            "x": column * cell_w,
            "y": row * cell_h,
            "w": cell_w,
            "h": cell_h,
            "at": round(slot * step, 4),
            "from": sources[index].get("from"),
            "label": sources[index].get("label"),
        })

    plan = {
        "version": "1.0",
        "style": args.style,
        "canvas": {"width": board.width, "height": board.height, "fps": board.fps},
        "grid": {"columns": columns, "rows": rows, "cell": [cell_w, cell_h],
                 "covers": f"{columns * cell_w}x{rows * cell_h} из "
                           f"{board.width}x{board.height}"},
        "count": len(tiles),
        "place_at": args.at,
        "duration": args.duration,
        "tile_step_seconds": round(step, 4),
        "tiles": tiles,
        "note": ("Кадры вырезаны из самого ролика: зритель их уже видел, поэтому "
                 "мозаика читается как «вот всё, о чём мы говорили», а не как "
                 "случайный набор картинок."),
        "next": [
            "python scripts/render_photo_mosaic.py PLAN --out mosaic.mov",
            "Затем наложить как элемент: render_element_overlay.py",
        ],
    }
    plan_path = out / "mosaic_plan.json"
    plan_path.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")

    emit({
        "status": "ok",
        "frames_dir": str(frames_dir),
        "plan": str(plan_path),
        "count": len(tiles),
        "grid": plan["grid"],
        "style": args.style,
        "sample": tiles[:4],
    })


if __name__ == "__main__":
    main()
