"""写真の日付修復ツール（app101.py / PythonLand）

Google データ エクスポート（Takeout）で書き出した写真・動画は、解凍した時点で
ファイルの更新日時がダウンロード日時になってしまう。Takeout に同梱される
JSON サイドカー（photoTakenTime）を読み、本来の撮影日時へ戻す tkinter 製ツール。

安全側の設計:
  - 既定はドライラン。「実際にファイルを書き換える」を ON にしないと 1 バイトも書かない
  - JSON が見つからない写真は推測せず「スキップ」として一覧に残す
  - JPEG の EXIF 書き込みは piexif.insert のみ（再エンコードしない＝画質を落とさない）
  - 1 ファイルの失敗で全体を止めない（ログに残して次へ進む）

任意ライブラリ（無くても動く）:
  pip install piexif          … JPEG の EXIF 撮影日時も書き戻す
  pip install win32-setctime  … Windows の「作成日時」も書き戻す

MIT License / PythonLand (https://pythonland.tech/)
"""

import csv
import importlib
import json
import os
import re
import tkinter as tk
from datetime import datetime, timezone
from tkinter import filedialog, messagebox, ttk

# ---------------------------------------------------------------------------
# 任意ライブラリの読み込み（入っていなければ None にして機能だけ落とす）
# ---------------------------------------------------------------------------


def load_optional(module_name):
    """任意ライブラリを読み込む。入っていなければ None を返す（落とさない）。"""
    try:
        return importlib.import_module(module_name)
    except Exception:
        return None


piexif = load_optional("piexif")
setctime_mod = load_optional("win32_setctime") if os.name == "nt" else None

# ---------------------------------------------------------------------------
# 定数
# ---------------------------------------------------------------------------

MEDIA_EXTS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".mp4", ".mov")
EXIF_EXTS = (".jpg", ".jpeg")
SUPPLEMENTAL = "supplemental-metadata"

# Google フォトの「編集済みコピー」の接尾辞は表示言語でローカライズされる。
# 編集済みコピーは自前の JSON を持たず、元ファイルの JSON を共有する。
EDITED_SUFFIXES = (
    "-edited", "-編集済み", "-编辑", "-편집됨", "-editado", "-modifié",
    "-bearbeitet", "-modificato", "-bewerkt", "-muokattu", "-edytowane",
    "-redigeret", "-отредактировано", "-відредаговано",
)

SEQ_RE = re.compile(r"\((\d+)\)$")

ACT_FIX = "日付を直す"
ACT_SAME = "変更なし（既に一致）"
ACT_NO_JSON = "スキップ（JSONなし）"
ACT_BAD_TIME = "スキップ（日時が不正）"
ACT_MISMATCH = "スキップ（名前が一致しない）"

# ---------------------------------------------------------------------------
# コア（1）ファイル名の正規化
# ---------------------------------------------------------------------------


def split_sequence(name):
    """末尾の "(3)" を切り離して (本体, 3) を返す。無ければ (name, None)。"""
    m = SEQ_RE.search(name)
    if not m:
        return name, None
    return name[:m.start()], int(m.group(1))


def strip_edited(stem):
    """編集済みコピーの接尾辞を外して (元の名前, 外したか) を返す。"""
    for suffix in EDITED_SUFFIXES:
        if stem.endswith(suffix):
            return stem[:-len(suffix)], True
    return stem, False


def normalize_json_name(filename):
    """JSON サイドカーのファイル名から (照合キー, 連番) を作る。

    Google は ".supplemental-metadata" を長さの都合で途中まで切り詰めるので、
    「supplemental-metadata の前方一致になっているセグメント」を丸ごと落とす。
    切り詰めの文字数ルールには依存しない。
    """
    if not filename.lower().endswith(".json"):
        return None, None
    body = filename[:-len(".json")]
    body, num = split_sequence(body)
    dot = body.rfind(".")
    if dot != -1:
        segment = body[dot + 1:]
        # ".." で終わる（接尾辞がドットだけ残った）ケースも segment == "" で拾える
        if SUPPLEMENTAL.startswith(segment) and "." in body[:dot]:
            body = body[:dot]
    return body, num


def media_lookup_keys(filename):
    """メディアファイル名から、JSON 索引を引く順番どおりの候補キーを返す。"""
    stem, ext = os.path.splitext(filename)
    stem, num = split_sequence(stem)
    original, edited = strip_edited(stem)
    keys = [(stem + ext, num), (stem + ext, None)]
    if edited:
        keys += [(original + ext, num), (original + ext, None)]
    return keys

# ---------------------------------------------------------------------------
# コア（2）JSON の索引づくりと突合
# ---------------------------------------------------------------------------


def build_json_index(root):
    """フォルダ以下の JSON を先に全部読み、正規化キーの索引にする。"""
    index = {}
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in filenames:
            if not name.lower().endswith(".json"):
                continue
            key, num = normalize_json_name(name)
            if not key:
                continue
            index.setdefault((key.casefold(), num), os.path.join(dirpath, name))
    return index


def find_json(index, filename):
    """索引からこのメディアに対応する JSON のパスを探す。見つからなければ None。"""
    for key, num in media_lookup_keys(filename):
        hit = index.get((key.casefold(), num))
        if hit:
            return hit
    # 元ファイル名まで切り詰められた場合の保険。最も長く一致したキーを採用する
    target = media_lookup_keys(filename)[0][0].casefold()
    _, num = split_sequence(os.path.splitext(filename)[0])
    best_key, best_path = "", None
    for (key, key_num), path in index.items():
        if key_num not in (num, None):
            continue
        if len(key) >= 8 and target.startswith(key) and len(key) > len(best_key):
            best_key, best_path = key, path
    return best_path


def read_sidecar(json_path):
    """JSON から (撮影日時のUTC秒, 使ったフィールド名, JSON内の元ファイル名) を読む。"""
    with open(json_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, dict):
        return None, None, None
    title = data.get("title") if isinstance(data.get("title"), str) else None
    for field in ("photoTakenTime", "creationTime"):
        node = data.get(field)
        if not isinstance(node, dict):
            continue
        raw = node.get("timestamp")
        if raw is None:
            continue
        try:
            return int(str(raw).strip()), field, title
        except ValueError:
            continue
    return None, None, title


def verify_title(filename, title):
    """JSON の title とメディアのファイル名を突き合わせて検算する。

    戻り値: "ok"（一致）/ "near"（編集済み・切り詰めの範囲で一致）/
            "none"（title が無い）/ "mismatch"（別の写真の JSON の疑い）
    """
    if not title:
        return "none"
    name = filename.casefold()
    title_cf = title.casefold()
    if name == title_cf:
        return "ok"
    stem, ext = os.path.splitext(filename)
    stem, _num = split_sequence(stem)
    original, _edited = strip_edited(stem)
    if (original + ext).casefold() == title_cf:
        return "ok"
    t_stem = os.path.splitext(title)[0].casefold()
    if t_stem and (original.casefold().startswith(t_stem)
                   or t_stem.startswith(original.casefold())):
        return "near"
    return "mismatch"

# ---------------------------------------------------------------------------
# コア（3）日時の変換
# ---------------------------------------------------------------------------


def to_local(timestamp):
    """UTC 秒を、このパソコンのタイムゾーンの日時に直す。扱えなければ None。"""
    if timestamp is None or timestamp <= 0:
        return None
    try:
        return datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone()
    except (OverflowError, OSError, ValueError):
        # Windows では 1970 年より前（負の値）で OSError [Errno 22] になる
        return None


def exif_datetime(local_dt):
    """EXIF の日時書式 "YYYY:MM:DD HH:MM:SS" に整える。"""
    return local_dt.strftime("%Y:%m:%d %H:%M:%S")


def exif_offset(local_dt):
    """EXIF の OffsetTimeOriginal 用に "+09:00" 形式の時差を作る。"""
    text = local_dt.strftime("%z")
    return text[:3] + ":" + text[3:] if len(text) == 5 else None

# ---------------------------------------------------------------------------
# コア（4）計画を立てる
# ---------------------------------------------------------------------------


class PlanItem:
    """1 ファイル分の「今どうなっていて、どう直すか」。"""

    def __init__(self, path, root):
        self.path = path
        self.name = os.path.basename(path)
        self.rel = os.path.relpath(path, root)
        self.json_path = None
        self.timestamp = None
        self.source = ""
        self.verify = ""
        self.local = None
        self.action = ACT_NO_JSON
        self.note = ""

    @property
    def json_name(self):
        return os.path.basename(self.json_path) if self.json_path else "—"

    @property
    def current_text(self):
        try:
            return datetime.fromtimestamp(os.path.getmtime(self.path)).strftime(
                "%Y-%m-%d %H:%M:%S")
        except (OSError, OverflowError, ValueError):
            return "読めません"

    @property
    def target_text(self):
        return self.local.strftime("%Y-%m-%d %H:%M:%S") if self.local else "—"

    @property
    def writes_exif(self):
        return (piexif is not None
                and os.path.splitext(self.name)[1].lower() in EXIF_EXTS)


def collect_media(root):
    """対象フォルダ以下の対応拡張子のファイルを集める。"""
    found = []
    for dirpath, _dirnames, filenames in os.walk(root):
        for name in filenames:
            if os.path.splitext(name)[1].lower() in MEDIA_EXTS:
                found.append(os.path.join(dirpath, name))
    found.sort()
    return found


def build_plan(root, progress=None):
    """フォルダをスキャンして PlanItem の一覧を返す（ファイルは一切書き換えない）。"""
    index = build_json_index(root)
    media = collect_media(root)
    items = []
    for i, path in enumerate(media):
        item = PlanItem(path, root)
        item.json_path = find_json(index, item.name)
        if item.json_path:
            try:
                item.timestamp, item.source, title = read_sidecar(item.json_path)
            except (OSError, ValueError, UnicodeDecodeError) as exc:
                item.action = ACT_NO_JSON
                item.note = "JSON を読めません: {}".format(exc)
                items.append(item)
                continue
            item.verify = verify_title(item.name, title)
            item.local = to_local(item.timestamp)
            if item.verify == "mismatch":
                item.action = ACT_MISMATCH
                item.note = "JSON の title が '{}' で別の写真の可能性".format(title)
            elif item.local is None:
                item.action = ACT_BAD_TIME
                item.note = "timestamp={} はこの環境で扱えません".format(item.timestamp)
            elif is_already_correct(item):
                item.action = ACT_SAME
            else:
                item.action = ACT_FIX
                if item.source == "creationTime":
                    item.note = "photoTakenTime が無いため creationTime を使用"
        if progress:
            progress(i + 1, len(media))
        items.append(item)
    return items


def is_already_correct(item):
    """更新日時も（対象なら）EXIF も既に正しいか。"""
    try:
        if int(os.path.getmtime(item.path)) != int(item.timestamp):
            return False
    except OSError:
        return False
    if item.writes_exif:
        return read_exif_original(item.path) == exif_datetime(item.local)
    return True

# ---------------------------------------------------------------------------
# コア（5）書き込み
# ---------------------------------------------------------------------------


def read_exif_original(path):
    """JPEG の EXIF 撮影日時を読む。無ければ None。"""
    if piexif is None:
        return None
    try:
        exif = piexif.load(path)
    except Exception:
        return None
    raw = exif.get("Exif", {}).get(piexif.ExifIFD.DateTimeOriginal)
    if isinstance(raw, bytes):
        return raw.decode("ascii", "ignore")
    return raw


def write_exif(path, local_dt):
    """piexif.insert で JPEG の日時タグだけ差し替える（再エンコードしない）。"""
    exif = piexif.load(path)
    stamp = exif_datetime(local_dt).encode("ascii")
    exif.setdefault("Exif", {})[piexif.ExifIFD.DateTimeOriginal] = stamp
    exif["Exif"][piexif.ExifIFD.DateTimeDigitized] = stamp
    exif.setdefault("0th", {})[piexif.ImageIFD.DateTime] = stamp
    offset = exif_offset(local_dt)
    if offset:
        code = offset.encode("ascii")
        exif["Exif"][piexif.ExifIFD.OffsetTimeOriginal] = code
        exif["Exif"][piexif.ExifIFD.OffsetTimeDigitized] = code
    exif.pop("thumbnail", None)
    exif.pop("1st", None)
    piexif.insert(piexif.dump(exif), path)


def apply_item(item, dry_run=True):
    """1 ファイルを直す。dry_run のときは何も書かずに予定だけ返す。"""
    done = []
    if item.action != ACT_FIX:
        return item.action
    if dry_run:
        return "（ドライラン）" + ACT_FIX
    if item.writes_exif:
        try:
            write_exif(item.path, item.local)
            done.append("EXIF")
        except Exception as exc:
            done.append("EXIF失敗({})".format(type(exc).__name__))
    os.utime(item.path, (item.timestamp, item.timestamp))
    done.append("更新日時")
    if setctime_mod is not None:
        try:
            setctime_mod.setctime(item.path, float(item.timestamp))
            done.append("作成日時")
        except Exception as exc:
            done.append("作成日時失敗({})".format(type(exc).__name__))
    return "書き換え: " + "・".join(done)


def apply_plan(items, dry_run=True, log=None, progress=None):
    """計画をまとめて実行する。1 件の失敗で全体を止めない。"""
    summary = {"fixed": 0, "skipped": 0, "failed": 0}
    targets = [it for it in items if it.action == ACT_FIX]
    for i, item in enumerate(targets):
        try:
            result = apply_item(item, dry_run)
            summary["fixed"] += 1
        except Exception as exc:
            result = "失敗: {}: {}".format(type(exc).__name__, exc)
            summary["failed"] += 1
        if log:
            log("{} … {}".format(item.rel, result))
        if progress:
            progress(i + 1, len(targets))
    summary["skipped"] = len(items) - len(targets)
    return summary


def save_log_csv(items, path):
    """一覧をそのまま CSV に保存する（Excel で開ける UTF-8 BOM 付き）。"""
    with open(path, "w", encoding="utf-8-sig", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["ファイル", "対応するJSON", "現在の更新日時",
                         "修正後の日時", "予定", "メモ"])
        for item in items:
            writer.writerow([item.rel, item.json_name, item.current_text,
                             item.target_text, item.action, item.note])

# ---------------------------------------------------------------------------
# GUI
# ---------------------------------------------------------------------------


class App101:
    """写真の日付修復ツール"""

    COLUMNS = ("ファイル", "対応するJSON", "現在の更新日時", "修正後の日時", "予定")
    WIDTHS = (250, 250, 150, 150, 170)

    def __init__(self, root):
        self.root = root
        self.root.title("写真の日付修復ツール")
        self.root.geometry("1000x680")
        self.folder = tk.StringVar(value="")
        self.do_write = tk.BooleanVar(value=False)
        self.status = tk.StringVar(value="フォルダを選んで「まず試す」を押してください。")
        self.items = []
        self._busy = False
        self._build_ui()
        self._show_environment()

    def _build_ui(self):
        top = tk.Frame(self.root, padx=10, pady=8)
        top.pack(fill="x")
        tk.Label(top, text="Takeout を解凍したフォルダ").pack(anchor="w")
        row = tk.Frame(top)
        row.pack(fill="x", pady=4)
        ttk.Entry(row, textvariable=self.folder).pack(side="left", fill="x",
                                                      expand=True)
        ttk.Button(row, text="フォルダを選ぶ", command=self._choose).pack(
            side="left", padx=6)
        ttk.Button(row, text="まず試す（変更しない）", command=self._scan).pack(
            side="left")

        opt = tk.Frame(self.root, padx=10)
        opt.pack(fill="x")
        ttk.Checkbutton(opt, text="実際にファイルを書き換える（チェックを外している間は何も変更しません）",
                        variable=self.do_write).pack(side="left")
        ttk.Button(opt, text="日付を直す", command=self._run).pack(side="right")
        ttk.Button(opt, text="ログをCSV保存", command=self._save_log).pack(
            side="right", padx=6)

        self.tree = ttk.Treeview(self.root, columns=self.COLUMNS, show="headings",
                                 height=14)
        for col, width in zip(self.COLUMNS, self.WIDTHS):
            self.tree.heading(col, text=col)
            self.tree.column(col, width=width, anchor="w")
        self.tree.tag_configure("fix", background="#e8f5e9")
        self.tree.tag_configure("skip", background="#ffebee")
        self.tree.tag_configure("same", background="#f5f5f5")
        bar = ttk.Scrollbar(self.root, orient="vertical", command=self.tree.yview)
        self.tree.configure(yscrollcommand=bar.set)
        self.tree.pack(side="top", fill="both", expand=True, padx=10, pady=8)
        self.tree.bind("<Double-1>", self._show_detail)

        self.progress = ttk.Progressbar(self.root, mode="determinate")
        self.progress.pack(fill="x", padx=10)
        tk.Label(self.root, textvariable=self.status, anchor="w").pack(
            fill="x", padx=10, pady=4)
        self.log = tk.Text(self.root, height=7, wrap="none")
        self.log.pack(fill="both", padx=10, pady=(0, 10))

    def _show_environment(self):
        self._log("写真の日付修復ツール — 先に Takeout の zip を残したままにしてください。")
        self._log("JPEG の EXIF 書き込み: {}".format(
            "有効（piexif）" if piexif else "無効（piexif が未導入のためファイル日時のみ直します）"))
        self._log("Windows の作成日時: {}".format(
            "有効（win32-setctime）" if setctime_mod else "無効（更新日時のみ直します）"))

    def _log(self, text):
        self.log.insert("end", text + "\n")
        self.log.see("end")

    def _choose(self):
        path = filedialog.askdirectory(title="Takeout を解凍したフォルダを選んでください")
        if path:
            self.folder.set(path)

    def _folder_ok(self):
        path = self.folder.get().strip()
        if not path or not os.path.isdir(path):
            messagebox.showwarning("フォルダが未選択です",
                                   "Takeout を解凍したフォルダを選んでください。")
            return None
        return path

    def _set_progress(self, done, total):
        self.progress["maximum"] = max(total, 1)
        self.progress["value"] = done
        self.root.update_idletasks()

    def _scan(self):
        path = self._folder_ok()
        if not path or self._busy:
            return
        self._busy = True
        self.status.set("スキャン中…")
        try:
            self.items = build_plan(path, progress=self._set_progress)
        except OSError as exc:
            messagebox.showerror("スキャンできません", str(exc))
            self._busy = False
            return
        self._fill_tree()
        self._busy = False

    def _fill_tree(self):
        self.tree.delete(*self.tree.get_children())
        counts = {ACT_FIX: 0, ACT_SAME: 0, ACT_NO_JSON: 0,
                  ACT_BAD_TIME: 0, ACT_MISMATCH: 0}
        for item in self.items:
            counts[item.action] = counts.get(item.action, 0) + 1
            tag = ("fix" if item.action == ACT_FIX
                   else "same" if item.action == ACT_SAME else "skip")
            self.tree.insert("", "end", tags=(tag,),
                             values=(item.rel, item.json_name, item.current_text,
                                     item.target_text, item.action))
        self.status.set(
            "対象 {} 件／直す {} 件／変更なし {} 件／JSONなし {} 件／日時が不正 {} 件／名前が一致しない {} 件".format(
                len(self.items), counts[ACT_FIX], counts[ACT_SAME],
                counts[ACT_NO_JSON], counts[ACT_BAD_TIME], counts[ACT_MISMATCH]))
        self._log(self.status.get())

    def _show_detail(self, event):
        selected = self.tree.focus()
        if not selected:
            return
        row = self.tree.index(selected)
        if 0 <= row < len(self.items):
            item = self.items[row]
            messagebox.showinfo(item.name, item.note or "特記事項はありません。")

    def _run(self):
        if not self.items:
            messagebox.showinfo("先にスキャンしてください",
                                "「まず試す（変更しない）」で内容を確認してから実行してください。")
            return
        if self._busy:
            return
        dry_run = not self.do_write.get()
        targets = sum(1 for it in self.items if it.action == ACT_FIX)
        if not dry_run and not messagebox.askyesno(
                "確認", "{} 件のファイルを書き換えます。Takeout の zip は残っていますか？".format(targets)):
            return
        self._busy = True
        self.status.set("実行中…")
        summary = apply_plan(self.items, dry_run=dry_run, log=self._log,
                             progress=self._set_progress)
        self.status.set("{}: 処理 {} 件／スキップ {} 件／失敗 {} 件".format(
            "ドライラン" if dry_run else "書き換え完了",
            summary["fixed"], summary["skipped"], summary["failed"]))
        self._log(self.status.get())
        if not dry_run:
            self.items = build_plan(self.folder.get(), progress=self._set_progress)
            self._fill_tree()
        self._busy = False

    def _save_log(self):
        if not self.items:
            messagebox.showinfo("保存するものがありません", "先にスキャンしてください。")
            return
        path = filedialog.asksaveasfilename(
            defaultextension=".csv", initialfile="photo_date_log.csv",
            filetypes=[("CSVファイル", "*.csv")])
        if not path:
            return
        try:
            save_log_csv(self.items, path)
        except OSError as exc:
            messagebox.showerror("保存できません", str(exc))
            return
        self._log("ログを保存しました: " + path)


if __name__ == "__main__":
    root = tk.Tk()
    app = App101(root)
    root.mainloop()
