#!/usr/bin/env python3
"""Create a timestamped PowerPoint screenshot record from a video.

Requirements:
    pip install python-pptx pillow
    ffmpeg and ffprobe must be available on PATH.

Examples:
    python video_to_pptx.py recording.mp4
    python video_to_pptx.py recording.mp4 --interval 5 -o procedure.pptx
    python video_to_pptx.py recording.mp4 --start 30 --end 150 --per-slide 2
    python video_to_pptx.py recording.mp4 --keep-images
    python video_to_pptx.py recording.mp4 --mode motion --min-interval 4
    python video_to_pptx.py recording.mp4 --mode both --interval 15
"""

from __future__ import annotations

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

from PIL import Image, ImageChops, ImageStat
from pptx import Presentation
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor


SLIDE_W, SLIDE_H = Inches(13.333), Inches(7.5)  # 16:9
MARGIN_X = Inches(0.45)
HEADER_H = Inches(0.58)
FOOTER_H = Inches(0.25)


def require_command(name: str) -> None:
    if shutil.which(name) is None:
        raise RuntimeError(f"'{name}' が見つかりません。ffmpeg をインストールし、PATH を確認してください。")


def run(command: list[str]) -> str:
    result = subprocess.run(command, check=True, text=True, capture_output=True)
    return result.stdout


def video_duration(video: Path) -> float:
    text = run([
        "ffprobe", "-v", "error", "-show_entries", "format=duration",
        "-of", "json", str(video),
    ])
    return float(json.loads(text)["format"]["duration"])


def timestamp(seconds: float) -> str:
    total = max(0, round(seconds))
    return f"{total // 3600:02d}:{(total % 3600) // 60:02d}:{total % 60:02d}"


def extract_frame(video: Path, at: float, destination: Path, max_width: int) -> None:
    # -ss before -i provides fast, sufficiently accurate seeking for screen recordings.
    run([
        "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{at:.3f}",
        "-i", str(video), "-frames:v", "1", "-vf", f"scale={max_width}:-2",
        "-q:v", "2", str(destination),
    ])


def extract_analysis_frames(video: Path, destination: Path, start: float, end: float, sample_fps: float) -> list[Path]:
    """Extract low-resolution frames used only for motion analysis."""
    destination.mkdir(parents=True, exist_ok=True)
    run([
        "ffmpeg", "-hide_banner", "-loglevel", "error", "-y", "-ss", f"{start:.3f}",
        "-to", f"{end:.3f}", "-i", str(video), "-vf", f"fps={sample_fps},scale=360:-2",
        "-q:v", "6", str(destination / "analysis_%05d.jpg"),
    ])
    return sorted(destination.glob("analysis_*.jpg"))


def mean_frame_difference(before: Path, after: Path) -> float:
    """Mean absolute grayscale difference, expressed as percent of full scale."""
    with Image.open(before) as a, Image.open(after) as b:
        a = a.convert("L")
        b = b.convert("L")
        diff = ImageChops.difference(a, b)
        return ImageStat.Stat(diff).mean[0] * 100.0 / 255.0


def motion_times(video: Path, temp_dir: Path, start: float, end: float, args: argparse.Namespace) -> list[float]:
    """Return stable post-operation times detected from low-resolution frame changes."""
    paths = extract_analysis_frames(video, temp_dir / "analysis", start, end, args.sample_fps)
    if len(paths) < 2:
        return [start]

    selected = [start] if args.include_first else []
    last_selected = selected[-1] if selected else -float("inf")
    motion_seen = False
    stable_since: float | None = None
    for i in range(1, len(paths)):
        now = start + i / args.sample_fps
        score = mean_frame_difference(paths[i - 1], paths[i])
        if score >= args.motion_threshold:
            motion_seen = True
            stable_since = None
            continue
        if not motion_seen or score > args.stable_threshold:
            stable_since = None
            continue
        if stable_since is None:
            stable_since = now
            continue
        if now - stable_since >= args.settle_seconds and now - last_selected >= args.min_interval:
            selected.append(now)
            last_selected = now
            motion_seen = False
            stable_since = None
    return selected


def add_textbox(slide, text: str, left, top, width, height, size: int, color, bold=False, align=None):
    shape = slide.shapes.add_textbox(left, top, width, height)
    tf = shape.text_frame
    tf.clear()
    p = tf.paragraphs[0]
    p.text = text
    p.font.name = "Yu Gothic"
    p.font.size = Pt(size)
    p.font.bold = bold
    p.font.color.rgb = RGBColor(*color)
    if align is not None:
        p.alignment = align
    return shape


def add_image_fit(slide, image_path: Path, left, top, width, height) -> None:
    with Image.open(image_path) as im:
        ratio = im.width / im.height
    box_ratio = width / height
    if ratio >= box_ratio:
        image_w, image_h = width, width / ratio
        image_left, image_top = left, top + (height - image_h) / 2
    else:
        image_h, image_w = height, height * ratio
        image_left, image_top = left + (width - image_w) / 2, top
    slide.shapes.add_picture(str(image_path), image_left, image_top, width=image_w, height=image_h)


def make_slide(prs: Presentation, entries: list[tuple[float, Path]], page: int) -> None:
    slide = prs.slides.add_slide(prs.slide_layouts[6])
    slide.background.fill.solid()
    slide.background.fill.fore_color.rgb = RGBColor(248, 250, 252)
    start, end = entries[0][0], entries[-1][0]
    heading = f"Excel操作画面  {timestamp(start)}" if len(entries) == 1 else f"Excel操作画面  {timestamp(start)} – {timestamp(end)}"
    add_textbox(slide, heading, MARGIN_X, Inches(0.12), Inches(9.5), Inches(0.35), 24, (15, 23, 42), bold=True)
    add_textbox(slide, f"{page:02d}", Inches(12.3), Inches(0.16), Inches(0.55), Inches(0.28), 14, (100, 116, 139), align=PP_ALIGN.RIGHT)

    n = len(entries)
    cols = 1 if n == 1 else 2
    rows = math.ceil(n / cols)
    gap = Inches(0.18)
    area_top = HEADER_H + Inches(0.08)
    area_h = SLIDE_H - area_top - FOOTER_H - Inches(0.08)
    cell_w = (SLIDE_W - 2 * MARGIN_X - gap * (cols - 1)) / cols
    cell_h = (area_h - gap * (rows - 1)) / rows
    for i, (at, path) in enumerate(entries):
        col, row = i % cols, i // cols
        x, y = MARGIN_X + col * (cell_w + gap), area_top + row * (cell_h + gap)
        add_image_fit(slide, path, x, y, cell_w, cell_h - Inches(0.25))
        add_textbox(slide, timestamp(at), x, y + cell_h - Inches(0.22), cell_w, Inches(0.18), 12, (71, 85, 105), align=PP_ALIGN.CENTER)


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(description="MP4等の動画から定期的または操作後の安定画面のSSを作成し、PowerPointに貼り付けます。")
    p.add_argument("input", type=Path, help="入力動画（MP4など）")
    p.add_argument("-o", "--output", type=Path, help="出力PPTX（省略時: 入力名_SS.pptx）")
    p.add_argument("--mode", choices=("interval", "motion", "both"), default="interval", help="抽出方法（既定: interval）")
    p.add_argument("--interval", type=float, default=10.0, help="定期抽出の間隔（秒、既定: 10）")
    p.add_argument("--start", type=float, default=0.0, help="抽出開始時刻（秒、既定: 0）")
    p.add_argument("--end", type=float, help="抽出終了時刻（秒、省略時: 動画末尾）")
    p.add_argument("--per-slide", type=int, choices=(1, 2, 4), default=1, help="1スライド当たりの画像数（既定: 1）")
    p.add_argument("--image-width", type=int, default=1600, help="抽出画像の最大幅px（既定: 1600）")
    p.add_argument("--keep-images", action="store_true", help="抽出したJPEGを <出力名>_frames に残す")
    motion = p.add_argument_group("motion mode options")
    motion.add_argument("--sample-fps", type=float, default=2.0, help="動き判定のサンプリングfps（既定: 2）")
    motion.add_argument("--motion-threshold", type=float, default=0.45, help="操作開始とみなす平均画素差（%%、既定: 0.45）")
    motion.add_argument("--stable-threshold", type=float, default=0.12, help="静止とみなす平均画素差（%%、既定: 0.12）")
    motion.add_argument("--settle-seconds", type=float, default=0.8, help="操作後に待つ安定時間（秒、既定: 0.8）")
    motion.add_argument("--min-interval", type=float, default=3.0, help="motion SSの最小時間間隔（秒、既定: 3）")
    motion.add_argument("--include-first", action=argparse.BooleanOptionalAction, default=True, help="開始画面もmotion SSに含める（既定: true）")
    p.add_argument("--pause", action="store_true", help="Pause before terminate")
    return p.parse_args()


def main() -> None:
    args = parse_args()
    require_command("ffmpeg")
    require_command("ffprobe")
    video = args.input.resolve()
    if not video.is_file():
        raise FileNotFoundError(f"動画が見つかりません: {video}")
    if args.interval <= 0 or args.sample_fps <= 0:
        raise ValueError("--interval と --sample-fps は正の値にしてください。")
    duration = video_duration(video)
    start, end = args.start, duration if args.end is None else min(args.end, duration)
    if start < 0 or start >= end:
        raise ValueError(f"範囲が不正です: start={start}, end={end:.3f}")
    output = args.output or video.with_name(f"{video.stem}_SS.pptx")
    output = output.resolve()
    temp_dir = Path(tempfile.mkdtemp(prefix="video_to_pptx_"))
    try:
        interval_times: list[float] = []
        if args.mode in ("interval", "both"):
            t = start
            while t < end - 0.05:
                interval_times.append(t)
                t += args.interval
        detected_times = motion_times(video, temp_dir, start, end, args) if args.mode in ("motion", "both") else []
        # Merge interval and motion candidates; 0.1 s removes duplicates without erasing nearby distinct steps.
        times = sorted({round(t, 1) for t in interval_times + detected_times})
        if not times:
            times = [start]
        print(f"抽出モード: {args.mode}  候補SS: {len(times)} 枚")
        frames = []
        for i, t in enumerate(times, 1):
            frame = temp_dir / f"frame_{i:03d}_{timestamp(t).replace(':', '-')}.jpg"
            extract_frame(video, t, frame, args.image_width)
            frames.append((t, frame))
            print(f"[{i}/{len(times)}] {timestamp(t)}")
        prs = Presentation()
        prs.slide_width, prs.slide_height = SLIDE_W, SLIDE_H
        for page, offset in enumerate(range(0, len(frames), args.per_slide), 1):
            make_slide(prs, frames[offset:offset + args.per_slide], page)
        output.parent.mkdir(parents=True, exist_ok=True)
        prs.save(output)
        print(f"作成完了: {output}")
        print(f"抽出枚数: {len(frames)}, スライド数: {len(prs.slides)}")
        if args.keep_images:
            frame_dir = output.with_name(f"{output.stem}_frames")
            frame_dir.mkdir(exist_ok=True)
            for _, frame in frames:
                shutil.copy2(frame, frame_dir / frame.name)
            print(f"抽出画像: {frame_dir}")
    finally:
        shutil.rmtree(temp_dir, ignore_errors=True)

    if args.pause:
        input("\nPress ENTER to terminate>>\n")
        

if __name__ == "__main__":
    try:
        main()
    except (RuntimeError, ValueError, FileNotFoundError, subprocess.CalledProcessError) as exc:
        print(f"エラー: {exc}", file=sys.stderr)
        sys.exit(1)
