#!/usr/bin/env python3
"""
概要:
    MP4等の動画から定期的または操作後の安定画面のスクリーンショットを作成し、PowerPointに貼り付けます。
詳細説明:
    ffmpegおよびffprobeがシステムのPATHに存在し、Pillowとpython-pptxがインストールされている必要があります。
"""

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:
    """
    概要:
        コマンドがシステムに存在するか確認します。
    引数:
        :param name: 確認するコマンド名
        :type name: str
    戻り値:
        :returns: なし
        :rtype: None
    例外:
        :raises RuntimeError: コマンドが見つからない場合
    """
    if shutil.which(name) is None:
        raise RuntimeError(f"'{name}' が見つかりません。ffmpeg をインストールし、PATH を確認してください。")


def run(command: list[str]) -> str:
    """
    概要:
        外部コマンドを実行し標準出力を取得します。
    引数:
        :param command: 実行するコマンドのリスト
        :type command: list[str]
    戻り値:
        :returns: コマンドの標準出力文字列
        :rtype: str
    """
    result = subprocess.run(command, check=True, text=True, capture_output=True)
    return result.stdout


def video_duration(video: Path) -> float:
    """
    概要:
        動画の長さを秒単位で取得します。
    引数:
        :param video: 動画ファイルのパス
        :type video: Path
    戻り値:
        :returns: 動画の長さ
        :rtype: 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:
    """
    概要:
        秒数を HH:MM:SS 形式の文字列に変換します。
    引数:
        :param seconds: 秒数
        :type seconds: float
    戻り値:
        :returns: フォーマットされた時間文字列
        :rtype: 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:
    """
    概要:
        動画から指定時刻のフレームを抽出し画像として保存します。
    引数:
        :param video: 動画ファイルのパス
        :type video: Path
        :param at: 抽出する時刻
        :type at: float
        :param destination: 保存先ファイルのパス
        :type destination: Path
        :param max_width: 画像の最大幅
        :type max_width: int
    戻り値:
        :returns: なし
        :rtype: 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]:
    """
    概要:
        動き解析用に使用する低解像度フレームを抽出します。
    引数:
        :param video: 動画ファイルのパス
        :type video: Path
        :param destination: 保存先ディレクトリのパス
        :type destination: Path
        :param start: 抽出開始時刻
        :type start: float
        :param end: 抽出終了時刻
        :type end: float
        :param sample_fps: 抽出のサンプリングfps
        :type sample_fps: float
    戻り値:
        :returns: 抽出された画像パスのリスト
        :rtype: list[Path]
    """
    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:
    """
    概要:
        2つの画像間の平均絶対グレースケール差分をフルスケールのパーセントとして計算します。
    引数:
        :param before: 比較元画像ファイルのパス
        :type before: Path
        :param after: 比較先画像ファイルのパス
        :type after: Path
    戻り値:
        :returns: 差分のパーセンテージ
        :rtype: float
    """
    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]:
    """
    概要:
        低解像度フレームの変化から操作後の安定画面の時刻リストを検出します。
    引数:
        :param video: 動画ファイルのパス
        :type video: Path
        :param temp_dir: 作業用ディレクトリのパス
        :type temp_dir: Path
        :param start: 検出開始時刻
        :type start: float
        :param end: 検出終了時刻
        :type end: float
        :param args: コマンドライン引数を格納したNamespace
        :type args: argparse.Namespace
    戻り値:
        :returns: 検出された時刻のリスト
        :rtype: list[float]
    """
    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):
    """
    概要:
        スライドにテキストボックスを追加します。
    引数:
        :param slide: テキストボックスを追加するスライドオブジェクト
        :type slide: pptx.slide.Slide
        :param text: 表示するテキスト
        :type text: str
        :param left: 左端の位置
        :type left: int または float
        :param top: 上端の位置
        :type top: int または float
        :param width: 幅
        :type width: int または float
        :param height: 高さ
        :type height: int または float
        :param size: フォントサイズ
        :type size: int
        :param color: RGBカラーのタプル
        :type color: tuple
        :param bold: 太字にするかどうか
        :type bold: bool
        :param align: テキストの配置
        :type align: PP_ALIGN
    戻り値:
        :returns: 作成されたテキストボックスのシェイプオブジェクト
        :rtype: pptx.shapes.autoshape.Shape
    """
    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:
    """
    概要:
        指定された領域に収まるように画像をスライドに追加します。
    引数:
        :param slide: 画像を追加するスライドオブジェクト
        :type slide: pptx.slide.Slide
        :param image_path: 追加する画像ファイルのパス
        :type image_path: Path
        :param left: 領域の左端位置
        :type left: int または float
        :param top: 領域の上端位置
        :type top: int または float
        :param width: 領域の幅
        :type width: int または float
        :param height: 領域の高さ
        :type height: int または float
    戻り値:
        :returns: なし
        :rtype: 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:
    """
    概要:
        抽出した画像と時刻を配置したスライドを作成します。
    引数:
        :param prs: プレゼンテーションオブジェクト
        :type prs: Presentation
        :param entries: 時刻と画像パスのタプルのリスト
        :type entries: list
        :param page: スライド番号
        :type page: int
    戻り値:
        :returns: なし
        :rtype: 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:
    """
    概要:
        コマンドライン引数をパースします。
    引数:
        なし
    戻り値:
        :returns: パース結果を格納したNamespaceオブジェクト
        :rtype: 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:
    """
    概要:
        動画からスクリーンショットを抽出してPowerPoint資料を作成するメイン処理です。
    引数:
        なし
    戻り値:
        :returns: なし
        :rtype: 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)