#!/usr/bin/env python3
"""Выгрузить смонтированную шкалу в Premiere Pro как настоящий таймлайн.

Скилл отдаёт готовый MP4. Иногда этого мало: надо доправить руками, переставить
план, поменять музыку. Открыть в Premiere отрендеренный файл — значит потерять
всю структуру и резать заново.

Формат выбран не любой. Premiere **не импортирует** `.fcpxml` — тот, что отдаёт
современный Final Cut. Он импортирует **Final Cut Pro 7 XML** (`xmeml version=5`),
формат старый, но живой и понимаемый ещё и DaVinci Resolve с Vegas. Поэтому
целимся туда, а не в fcpxml, который пришлось бы конвертировать сторонним
инструментом.

Что попадает в проект:

* **V1** — куски исходника с их точками входа и выхода из нарезки речи;
* **V2** — перебивки, каждая на своём месте;
* **A1** — звук исходника, связанный с картинкой;
* **маркеры** — на каждом блоке субтитров, на каждом событии графики и на каждом
  стыке. По ним видно, где что стоит, без открытия JSON.

Скоростные рампы в FCP7 XML выражаются `timemap`-фильтром и переносятся не всеми
приложениями одинаково. Поэтому клип с изменённой скоростью помечается маркером
с точным множителем: лучше честная пометка, чем молча потерянный ретайминг.
"""
from __future__ import annotations

import argparse
import json
import sys
import urllib.parse
import urllib.request
from pathlib import Path
from xml.etree import ElementTree as ET

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, utf8_stdout  # noqa: E402


def sub(parent: ET.Element, tag: str, text=None) -> ET.Element:
    node = ET.SubElement(parent, tag)
    if text is not None:
        node.text = str(text)
    return node


def rate(parent: ET.Element, fps: int) -> None:
    node = sub(parent, "rate")
    sub(node, "timebase", fps)
    # ntsc=FALSE означает целочисленную частоту. Для 29.97/59.94 сюда пришлось бы
    # писать TRUE и timebase 30 — приложение само поймёт дробность.
    sub(node, "ntsc", "FALSE" if float(fps).is_integer() else "TRUE")


def path_url(path: Path) -> str:
    """file:// URL, который Premiere откроет на Windows.

    Обратные слэши и кириллица в пути ломают импорт молча: клип встанет в
    таймлайн со статусом «media offline», и человек будет искать причину в
    Premiere, а не в экспорте.
    """
    return urllib.parse.urljoin("file:", urllib.request.pathname2url(str(path.resolve())))


def add_file(parent: ET.Element, file_id: str, path: Path, fps: int,
             frames: int, width: int, height: int, has_audio: bool,
             seen: set) -> ET.Element:
    node = sub(parent, "file")
    node.set("id", file_id)
    # Второй и последующий раз файл ссылается по id без тела — так требует
    # формат, и без этого Premiere заводит по копии медиа на каждый клип.
    if file_id in seen:
        return node
    seen.add(file_id)
    sub(node, "name", path.name)
    sub(node, "pathurl", path_url(path))
    rate(node, fps)
    sub(node, "duration", frames)
    media = sub(node, "media")
    video = sub(media, "video")
    sub(video, "duration", frames)
    characteristics = sub(video, "samplecharacteristics")
    sub(characteristics, "width", width)
    sub(characteristics, "height", height)
    if has_audio:
        audio = sub(media, "audio")
        sub(audio, "channelcount", 2)
    return node


def add_clip(track: ET.Element, index: int, name: str, path: Path,
             start: int, end: int, in_frame: int, out_frame: int,
             fps: int, width: int, height: int, source_frames: int,
             has_audio: bool, seen: set) -> ET.Element:
    clip = sub(track, "clipitem")
    clip.set("id", f"clip-{index}")
    sub(clip, "name", name)
    sub(clip, "enabled", "TRUE")
    sub(clip, "duration", source_frames)
    rate(clip, fps)
    sub(clip, "start", start)
    sub(clip, "end", end)
    sub(clip, "in", in_frame)
    sub(clip, "out", out_frame)
    add_file(clip, f"file-{path.name}", path, fps, source_frames, width, height,
             has_audio, seen)
    return clip


def add_marker(parent: ET.Element, frame: int, name: str, comment: str = "") -> None:
    marker = sub(parent, "marker")
    sub(marker, "comment", comment)
    sub(marker, "name", name)
    sub(marker, "in", frame)
    sub(marker, "out", -1)


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("timeline", help="timeline_full.json")
    parser.add_argument("output", help="Куда писать .xml")
    parser.add_argument("--recut", help="recut.json — точки входа/выхода и скорости")
    parser.add_argument("--cold-open", help="cold_open.json — иначе V1 начнётся "
                                            "с кадра сдвига, а не с нуля")
    parser.add_argument("--broll-plan", help="broll_plan.json — вставки на V2")
    parser.add_argument("--caption-map", help="caption_map.json — маркеры по субтитрам")
    parser.add_argument("--retention-plan", help="retention_plan.json — маркеры по графике")
    parser.add_argument("--name", default="AI Pro Video Production")
    parser.add_argument("--canvas", default="reels")
    parser.add_argument("--markers", default="cuts,captions,graphics",
                        help="Что помечать: cuts, captions, graphics, speed")
    args = parser.parse_args()

    board = resolve_canvas(args.canvas)
    fps, width, height = board.fps, board.width, board.height
    timeline = json.loads(Path(args.timeline).read_text(encoding="utf-8"))
    wanted = {token.strip() for token in args.markers.split(",")}

    duration = float(timeline.get("expected_duration") or 0.0)
    total_frames = int(round(duration * fps))

    root = ET.Element("xmeml", {"version": "5"})
    sequence = sub(root, "sequence")
    sequence.set("id", "sequence-1")
    sub(sequence, "name", args.name)
    sub(sequence, "duration", total_frames)
    rate(sequence, fps)
    media = sub(sequence, "media")

    video = sub(media, "video")
    fmt = sub(video, "format")
    characteristics = sub(fmt, "samplecharacteristics")
    rate(characteristics, fps)
    sub(characteristics, "width", width)
    sub(characteristics, "height", height)
    sub(characteristics, "pixelaspectratio", "square")

    seen: set = set()
    v1 = sub(video, "track")
    clips_written = 0
    speed_notes: list[dict] = []

    # Холодный старт живёт отдельно от списка клипов: он вырезан из исходника по
    # своим точкам и приклеен спереди, а `clips` описывает уже сдвинутый корпус.
    # Без него V1 начинается не с нуля, а с кадра сдвига — то есть первые секунды
    # в Premiere окажутся пустыми, и человек решит, что экспорт сломан.
    cold_written = 0
    cold = timeline.get("cold_open") or {}
    if cold.get("length") and args.cold_open and Path(args.cold_open).is_file():
        hook = json.loads(Path(args.cold_open).read_text(encoding="utf-8")).get("cold_open") or {}
        source = Path(timeline.get("source") or "")
        if source.is_file() and hook.get("source_in") is not None:
            info = probe(source)
            source_frames = int(round(float(info.get("duration") or 0.0) * fps))
            length = int(round(float(cold["length"]) * fps))
            in_frame = int(round(float(hook["source_in"]) * fps))
            add_clip(v1, 900, "холодный старт", source, 0, length,
                     in_frame, in_frame + length, fps, width, height,
                     source_frames, bool(info.get("has_audio")), seen)
            cold_written = 1
            clips_written += 1

    clips = timeline.get("clips") or []
    for index, clip in enumerate(clips):
        source = Path(clip.get("file") or timeline.get("source") or "")
        if not source.is_file():
            continue
        info = probe(source)
        source_frames = int(round(float(info.get("duration") or 0.0) * fps))
        start = int(round(float(clip["global_start"]) * fps))
        end = int(round(float(clip["global_end"]) * fps))
        in_frame = int(round(float(clip.get("source_in", 0.0)) * fps))
        out_frame = in_frame + (end - start)
        add_clip(v1, index, f"{source.stem} #{index + 1}", source,
                 start, end, in_frame, out_frame, fps, width, height,
                 source_frames, bool(info.get("has_audio")), seen)
        clips_written += 1
        speed = float(clip.get("speed", 1.0))
        if abs(speed - 1.0) > 0.001:
            speed_notes.append({"frame": start, "speed": round(speed, 4)})

    # V2 — перебивки. Отдельная дорожка, потому что они лежат ПОВЕРХ базы и не
    # должны сдвигать её ни на кадр.
    broll_written = 0
    if args.broll_plan and Path(args.broll_plan).is_file():
        plan = json.loads(Path(args.broll_plan).read_text(encoding="utf-8"))
        v2 = sub(video, "track")
        for index, item in enumerate(plan.get("broll", [])):
            path = Path(item["path"])
            if not path.is_file():
                continue
            info = probe(path)
            source_frames = int(round(float(info.get("duration") or 0.0) * fps))
            start = int(round(float(item["start"]) * fps))
            length = int(round(float(item.get("duration", 2.4)) * fps))
            in_frame = int(round(float(item.get("source_in", 0.0)) * fps))
            add_clip(v2, 1000 + index, path.stem, path, start, start + length,
                     in_frame, in_frame + length, fps, width, height,
                     source_frames, bool(info.get("has_audio")), seen)
            broll_written += 1

    audio = sub(media, "audio")
    a1 = sub(audio, "track")
    for index, clip in enumerate(clips):
        source = Path(clip.get("file") or timeline.get("source") or "")
        if not source.is_file():
            continue
        info = probe(source)
        if not info.get("has_audio"):
            continue
        source_frames = int(round(float(info.get("duration") or 0.0) * fps))
        start = int(round(float(clip["global_start"]) * fps))
        end = int(round(float(clip["global_end"]) * fps))
        in_frame = int(round(float(clip.get("source_in", 0.0)) * fps))
        clip_node = add_clip(a1, 2000 + index, f"{source.stem} звук #{index + 1}",
                             source, start, end, in_frame, in_frame + (end - start),
                             fps, width, height, source_frames, True, seen)
        # Связка картинки со звуком: без неё в Premiere они разъедутся при первом
        # же перетаскивании, и вся точность нарезки пропадёт.
        link_video = sub(clip_node, "link")
        sub(link_video, "linkclipref", f"clip-{index}")
        link_audio = sub(clip_node, "link")
        sub(link_audio, "linkclipref", f"clip-{2000 + index}")

    markers = 0
    if "cuts" in wanted:
        for index, clip in enumerate(clips[1:], start=1):
            add_marker(sequence, int(round(float(clip["global_start"]) * fps)),
                       f"склейка {index}", "точка нарезки речи")
            markers += 1
    if "speed" in wanted or speed_notes:
        for note in speed_notes:
            add_marker(sequence, note["frame"], f"скорость ×{note['speed']}",
                       "Ретайминг из нарезки речи. FCP7 XML переносит timemap "
                       "не везде одинаково — проверь клип после импорта.")
            markers += 1
    if "captions" in wanted and args.caption_map and Path(args.caption_map).is_file():
        caption_map = json.loads(Path(args.caption_map).read_text(encoding="utf-8"))
        for block in caption_map.get("blocks", []):
            add_marker(sequence, int(round(float(block["start"]) * fps)),
                       (block.get("text") or "")[:40], "блок субтитров")
            markers += 1
    if "graphics" in wanted and args.retention_plan and Path(args.retention_plan).is_file():
        plan = json.loads(Path(args.retention_plan).read_text(encoding="utf-8"))
        for event in plan.get("events", []):
            add_marker(sequence, int(round(float(event["at"]) * fps)),
                       event.get("kind", "событие"), event.get("why", ""))
            markers += 1

    ET.indent(root, space="  ")
    body = ET.tostring(root, encoding="unicode")
    out = Path(args.output).resolve()
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(
        '<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE xmeml>\n' + body + "\n",
        encoding="utf-8")

    emit({
        "status": "ok",
        "output": str(out),
        "format": "Final Cut Pro 7 XML (xmeml v5)",
        "why_this_format": ("Premiere не импортирует .fcpxml современного Final Cut. "
                            "Он импортирует FCP7 XML, его же понимают DaVinci Resolve "
                            "и Vegas."),
        "sequence": {"name": args.name, "fps": fps, "size": f"{width}x{height}",
                     "frames": total_frames, "duration": round(duration, 3)},
        "tracks": {"V1_база": clips_written, "V2_перебивки": broll_written,
                   "A1_звук": clips_written - cold_written},
        "cold_open_included": bool(cold_written),
        "markers": markers,
        "speed_ramps": len(speed_notes),
        "how_to_open": "Premiere Pro: File > Import, выбрать этот .xml.",
        "caveats": [
            "Субтитры не переносятся: они прожжены в картинку. Рядом лежит .srt — "
            "импортируй его отдельно, если нужен редактируемый текст.",
            "Скоростные рампы помечены маркерами. FCP7 XML переносит timemap "
            "не всеми приложениями одинаково — проверь помеченные клипы.",
            "Грейд, эффекты на стыках и графика в XML не переносятся: это ffmpeg-"
            "фильтры и слой поверх, а не свойства клипа.",
        ],
    })


if __name__ == "__main__":
    main()
