# rename_photos.py のテスト（unittest）
# 作成: AI（Claude）／Alice in Sandbox のレシピ用サンプル
# 使い方: rename_photos.py と同じフォルダで  python -m unittest test_rename_photos -v
# 見本の写真は、テストのたびに一時フォルダへ単色の画像として作る（実在の写真・位置情報は使わない）
import contextlib
import csv
import hashlib
import io
import os
import tempfile
import unittest
from datetime import datetime
from pathlib import Path
from unittest import mock

from PIL import Image

import rename_photos as rp


def make_photo(folder, name, shot=None, mtime="2026-05-10 12:00:00", color=(200, 100, 100)):
    """単色の画像を作る。shot を渡すと EXIF の撮影日（DateTimeOriginal）を入れる"""
    path = Path(folder) / name
    img = Image.new("RGB", (64, 48), color)
    if path.suffix.lower() in (".jpg", ".jpeg"):
        exif = Image.Exif()
        if shot:
            exif.get_ifd(rp.EXIF_IFD)[rp.DATETIME_ORIGINAL] = shot
        img.save(path, "JPEG", exif=exif)
    else:
        img.save(path)
    t = datetime.strptime(mtime, "%Y-%m-%d %H:%M:%S").timestamp()
    os.utime(path, (t, t))
    return path


def fingerprints(folder):
    """{ファイル名: 中身のハッシュ}。名前を変えても中身が同じかを確かめる"""
    return {p.name: hashlib.sha256(p.read_bytes()).hexdigest()
            for p in Path(folder).iterdir() if p.is_file() and not p.name.endswith(".csv")}


def quiet(func, *args):
    with contextlib.redirect_stdout(io.StringIO()) as out:
        func(*args)
    return out.getvalue()


class SampleFolder(unittest.TestCase):
    """記事の見本と同じ7枚＋写真ではないファイル1つ"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.dir = Path(self.tmp.name)
        make_photo(self.dir, "IMG_0001.jpg", "2026:05:03 09:12:00", color=(230, 120, 120))
        make_photo(self.dir, "IMG_0002.jpg", "2026:05:03 09:12:00", color=(120, 200, 120))
        make_photo(self.dir, "IMG_0003.jpg", "2026:05:03 14:30:10", color=(120, 120, 230))
        make_photo(self.dir, "IMG_0004.JPG", "2026:05:04 08:05:00", color=(230, 200, 90))
        make_photo(self.dir, "DSC_0100.jpg", None, "2026-05-02 18:40:00", color=(90, 200, 200))
        make_photo(self.dir, "2026-05-03_001.jpg", "2026:05:06 12:00:00", color=(200, 90, 200))
        make_photo(self.dir, "scan_note.png", None, "2026-05-04 21:15:00", color=(160, 160, 160))
        (self.dir / "memo.txt").write_text("写真ではないファイル", encoding="utf-8")
        self.before = fingerprints(self.dir)

    def tearDown(self):
        self.tmp.cleanup()

    def plan_map(self):
        return {r["old"]: r["new"] for r in rp.make_plan(self.dir)}

    # --- 撮影日の読み取り ---
    def test_exif_date_is_used(self):
        self.assertEqual(rp.shot_date(self.dir / "IMG_0003.jpg"),
                         (datetime(2026, 5, 3, 14, 30, 10), "EXIF"))

    def test_no_exif_uses_mtime(self):
        self.assertEqual(rp.shot_date(self.dir / "DSC_0100.jpg"),
                         (datetime(2026, 5, 2, 18, 40, 0), "更新日時"))
        self.assertEqual(rp.shot_date(self.dir / "scan_note.png")[1], "更新日時")

    def test_broken_exif_date_uses_mtime(self):
        make_photo(self.dir, "zero.jpg", "0000:00:00 00:00:00", "2026-05-05 07:00:00")
        self.assertEqual(rp.shot_date(self.dir / "zero.jpg"),
                         (datetime(2026, 5, 5, 7, 0, 0), "更新日時"))

    def test_not_an_image_uses_mtime(self):
        fake = self.dir / "broken.jpg"
        fake.write_bytes(b"not a picture")
        self.assertEqual(rp.shot_date(fake)[1], "更新日時")

    # --- 一覧（試し運転） ---
    def test_plan(self):
        self.assertEqual(self.plan_map(), {
            "DSC_0100.jpg": "2026-05-02_001.jpg",
            "IMG_0001.jpg": "2026-05-03_001.jpg",
            "IMG_0002.jpg": "2026-05-03_002.jpg",  # 同じ日時は元の名前の順
            "IMG_0003.jpg": "2026-05-03_003.jpg",
            "IMG_0004.JPG": "2026-05-04_001.jpg",  # 拡張子は小文字にそろえる
            "scan_note.png": "2026-05-04_002.png",
            "2026-05-03_001.jpg": "2026-05-06_001.jpg",
        })

    def test_dry_run_changes_nothing(self):
        out = quiet(rp.main, [str(self.dir)])
        self.assertIn("試し運転です（7件", out)
        self.assertEqual(fingerprints(self.dir), self.before)
        self.assertFalse((self.dir / rp.LOG_NAME).exists())

    # --- 実行 ---
    def test_run_with_name_collision(self):
        expected = self.plan_map()
        quiet(rp.main, [str(self.dir), "--run"])
        after = fingerprints(self.dir)
        # 中身はそのまま、名前だけが表のとおりに変わっている（ぶつかった名前も含めて）
        for old, new in expected.items():
            self.assertEqual(after[new], self.before[old])
        self.assertEqual(after["memo.txt"], self.before["memo.txt"])
        self.assertEqual(len(after), len(self.before))
        self.assertFalse(any(n.startswith(rp.TEMP) for n in after))

    def test_log_is_written(self):
        quiet(rp.main, [str(self.dir), "--run"])
        with open(self.dir / rp.LOG_NAME, encoding="utf-8-sig", newline="") as f:
            rows = list(csv.DictReader(f))
        self.assertEqual(len(rows), 7)
        self.assertEqual(rows[0], {"old": "DSC_0100.jpg", "new": "2026-05-02_001.jpg",
                                   "date": "2026-05-02 18:40:00", "source": "更新日時"})

    def test_second_run_is_refused(self):
        quiet(rp.main, [str(self.dir), "--run"])
        log_before = (self.dir / rp.LOG_NAME).read_bytes()
        with self.assertRaises(SystemExit):
            quiet(rp.main, [str(self.dir), "--run"])
        self.assertEqual((self.dir / rp.LOG_NAME).read_bytes(), log_before)

    # --- 元に戻す ---
    def test_undo_restores_everything(self):
        quiet(rp.main, [str(self.dir), "--run"])
        quiet(rp.main, [str(self.dir), "--undo"])
        self.assertEqual(fingerprints(self.dir), self.before)
        self.assertFalse((self.dir / rp.LOG_NAME).exists())
        self.assertTrue((self.dir / "rename_log_undone.csv").exists())

    def test_run_undo_twice(self):
        for _ in range(2):
            quiet(rp.main, [str(self.dir), "--run"])
            quiet(rp.main, [str(self.dir), "--undo"])
        self.assertEqual(fingerprints(self.dir), self.before)
        self.assertTrue((self.dir / "rename_log_undone_2.csv").exists())

    def crash_at(self, n, args):
        """n 回目の名前の付け替えで止まる（電源が切れた、などの代わり）"""
        real_rename = Path.rename
        calls = {"n": 0}

        def flaky(self_path, target):
            calls["n"] += 1
            if calls["n"] == n:
                raise OSError("途中で止まった（テスト用）")
            return real_rename(self_path, target)

        with mock.patch.object(Path, "rename", flaky):
            with self.assertRaises(OSError):
                quiet(rp.main, [str(self.dir)] + args)

    def test_undo_after_run_crash(self):
        """--run が途中（1〜14回目の付け替えのどこか）で止まっても、--undo で全部戻る"""
        for n in range(1, 15):  # 7枚 × 2段階 = 14回
            with self.subTest(止まった回=n):
                self.tearDown()
                self.setUp()
                self.crash_at(n, ["--run"])
                quiet(rp.main, [str(self.dir), "--undo"])
                self.assertEqual(fingerprints(self.dir), self.before)
                self.assertFalse(any(x.name.startswith(rp.TEMP) for x in self.dir.iterdir()))

    def test_undo_after_undo_crash(self):
        """--undo が途中で止まっても、もう一度 --undo を打てば全部戻る"""
        for n in range(1, 16):  # 14回の付け替え＋対応表の名前変え
            with self.subTest(止まった回=n):
                self.tearDown()
                self.setUp()
                quiet(rp.main, [str(self.dir), "--run"])
                self.crash_at(n, ["--undo"])
                quiet(rp.main, [str(self.dir), "--undo"])
                self.assertEqual(fingerprints(self.dir), self.before)
                self.assertTrue((self.dir / "rename_log_undone.csv").exists())

    def test_undo_without_log(self):
        with self.assertRaises(SystemExit):
            quiet(rp.main, [str(self.dir), "--undo"])
        self.assertEqual(fingerprints(self.dir), self.before)


if __name__ == "__main__":
    unittest.main()
