"""VA mintsチェック メインウィンドウ（tkinter）。

要件定義書4.1「フォルダ選択・スキャン機能」・4.2「ファイル状態把握機能」
（4.6「墨消し完全性の検証」を含む）・4.3「出力ファイル名入力機能」・
4.5「適合化処理」・4.7「証拠番号処理」・4.8「基本レポート出力機能」及び出力
フォルダへの書き出し（第3章処理フロー⑥）を対象とする。

4.3の範囲は「ファイル名が証拠番号パターンに合致するかどうかの判定と、
4.7（証拠番号処理）の適用候補への反映」にとどめる。ファイル名の内容が
適切かどうかの判断（標目の付け方等）は利用者があらかじめ（エクスプローラー
等で）行っている前提とし、ツール側での編集・自動採番機能は持たない。ファイル名が
技術的にmintsの制約に反する場合（100文字超過・禁止文字）のみ、4.2の検出結果
としてB（要修正）に反映する。

書き出し対象はA（適合済み）・B（要修正）のうち記録対応形式のみ（C＝要変換は
4.4未実装のため対象外、D＝処理対象外も対象外）。ファイル名整形・メタデータ除去・
証拠番号スタンプ・枝番ファイル結合はいずれもファイルの内容・体裁を変更する処理のため、
CLAUDE.md「絶対に譲れない設計原則」3に従い、実行前に必ずユーザーの確認を得る。
その具体的なUI実装として、書き出し対象を1件（又は1枝番ファイル結合グループ）ずつ
処理する逐次レビュー画面（ui/write_review_dialog.py）を用いる。各回、対象の
プレビュー（原本に証拠番号が既に記載されていないかを目視確認できるように）と、
適用する処理を選ぶチェックボックス（ファイルの状態に応じた既定値つき、手動で
変更可）を表示し、確定した内容をその場で書き出す。枝番ファイル結合のみ複数ファイルに
またがる決定のため、対象一覧をグループ単位のチェックボックスでまとめて確認する
別画面（ui/checklist_dialog.py）を経る。
"""

from __future__ import annotations

import os
import queue
import sys
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

from mints_prep.config import Config, load_config
from mints_prep.models import Classification, FileRecord, ScanResult
from mints_prep.naming.evidence_number import match_evidence_number
from mints_prep.processors.batch_planner import (
    Batch,
    find_batch_conflicts,
    plan_batches,
    resolve_batch_dir,
)
from mints_prep.processors.evidence_merger import BranchGroup, find_branch_merge_groups
from mints_prep.processors.filename_sanitizer import needs_sanitization, sanitize_filename
from mints_prep.processors.output_writer import (
    PlannedCopy,
    copy_to_output,
    plan_output_filenames,
    select_eligible_records,
)
from mints_prep.processors.report_writer import (
    build_report_text,
    collect_check_warnings,
    collect_critical_warnings,
    format_evidence_sequence_warnings,
    write_report,
)
from mints_prep.scanner.walker import scan_folder
from mints_prep.ui.checklist_dialog import ChecklistItem, ask_checklist
from mints_prep.ui.formatting import (
    CLASSIFICATION_LABELS,
    describe_expected_processing,
    format_size_display,
)
from mints_prep.ui.review_logic import ReviewItem
from mints_prep.ui.theme import apply_theme
from mints_prep.ui.wrapping_table import ColumnSpec, WrappingTable
from mints_prep.ui.write_review_dialog import review_and_confirm

_CLASSIFICATION_TAGS = {
    Classification.A_COMPLIANT: "cls_a",
    Classification.B_NEEDS_FIX: "cls_b",
    Classification.C_NEEDS_CONVERSION: "cls_c",
    Classification.D_UNSUPPORTED: "cls_d",
}

_TAG_COLORS = {
    "cls_a": "#e6f4ea",
    "cls_b": "#fff4e5",
    "cls_c": "#e8f0fe",
    "cls_d": "#f1f1f1",
}

# 証拠番号スタンプ（4.7）の対象形式。音声・動画には「ページ」の概念がなく
# 焼き込み対象にならないため対象外とする。
_STAMPABLE_FORMATS = {"PDF", "JPEG", "PNG"}

def _window_icon_path() -> Path:
    """ウィンドウアイコン（assets/VA_mintsCheck.ico）のパスを解決する。

    PyInstallerのonefile実行時は展開先（sys._MEIPASS）へ同梱されたものを、
    ソースから直接実行する開発時はリポジトリ直下のassets/を参照する
    （config.pyの_default_config_pathと同じ切替方針）。
    """
    if getattr(sys, "frozen", False):
        base = Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
    else:
        base = Path(__file__).resolve().parents[3]
    return base / "assets" / "VA_mintsCheck.ico"


# 結果一覧の列構成。Tkのインスタンス化を伴わずに単体テストできるよう、
# モジュールレベルの定数として切り出す。
RESULT_TABLE_COLUMNS = [
    ColumnSpec("filename", "ファイル名", 220),
    ColumnSpec("classification", "分類", 90),
    ColumnSpec("format", "検出形式", 80),
    ColumnSpec("size", "サイズ", 80),
    ColumnSpec("processing", "検出事項", 400),
    ColumnSpec("notes", "備考", 350),
]


class MainWindow(tk.Tk):
    def __init__(self, config: Config | None = None) -> None:
        super().__init__()
        self.config_obj = config or load_config()
        # 画面全体のテーマ（フラットデザイン・Microsoft系ダイアログの質感）。
        # ウィジェット生成前に一度だけ適用する（詳細はui/theme.py参照）。
        apply_theme(self)
        # ウィンドウタイトルに、ツール名に続けてサブタイトル（このツールが
        # 何であるか）を「ー」区切りで併記する（画面内のサブタイトル行は
        # 廃止し、タイトルバーに集約）。
        self.title("VA mintsチェック ー mints（民事裁判書類電子提出システム）「記録」提出前チェック・準備ツール")
        # タイトルバー・タスクバーのアイコン（実行ファイル自体のアイコンは
        # mints_prep.specのEXE(icon=...)で別途埋め込まれる）。アイコンが
        # 見つからない・読めない場合は既定のままにする（起動を妨げない）。
        icon_path = _window_icon_path()
        if icon_path.exists():
            try:
                self.iconbitmap(str(icon_path))
            except tk.TclError:
                pass
        self.geometry("1300x700")
        self.minsize(900, 500)

        self._input_dir_var = tk.StringVar(value="")
        self._output_dir_var = tk.StringVar(value="")
        self._scan_result: ScanResult | None = None
        self._queue: "queue.Queue[object]" = queue.Queue()

        self._build_widgets()

    # フォルダパスはテキストボックス（手動入力可）と連動させるため、常に
    # StringVarから読み取る（ボタンでの選択と手動入力の両方を単一の入力源にする）。
    @property
    def _input_dir(self) -> Path | None:
        text = self._input_dir_var.get().strip()
        return Path(text) if text else None

    @property
    def _output_dir(self) -> Path | None:
        text = self._output_dir_var.get().strip()
        return Path(text) if text else None

    # ------------------------------------------------------------------ UI構築
    def _build_widgets(self) -> None:
        top = ttk.Frame(self, padding=8)
        top.pack(side=tk.TOP, fill=tk.X)

        # フォルダパス欄は、これまで画面全幅まで伸びて見づらかったため、
        # 文字数指定の固定幅（画面全幅の半分程度）にとどめる。
        _FOLDER_ENTRY_WIDTH = 60

        ttk.Label(top, text="入力フォルダ：").grid(row=0, column=0, sticky="w", pady=2)
        ttk.Entry(top, textvariable=self._input_dir_var, width=_FOLDER_ENTRY_WIDTH).grid(
            row=0, column=1, sticky="w", padx=(4, 4), pady=2
        )
        # フォルダ選択ボタンは、フォルダ参照ダイアログを開く操作であることが
        # 隣接するテキスト欄から自明なため、Windows標準の「参照」ダイアログの
        # 慣行に倣い「...」の省略表記にとどめる。
        ttk.Button(top, text="...", width=3, command=self._choose_input_dir).grid(
            row=0, column=2, padx=(0, 4)
        )
        # ボタン名は「スキャン」を避ける（実物のスキャナー〔ScanSnap等〕による
        # 読み取り操作と紛らわしいため）。要件定義書自体はこの処理を「スキャン」と
        # 呼ぶが、これはツール内部の機能名であり、画面表示は「フォルダの中身を
        # ツールに取り込む」動作として利用者に自然な「読み込み」に統一する。
        self._scan_button = ttk.Button(
            top, text="ファイルを読み込み", command=self._start_scan, style="Emphasis.TButton"
        )
        self._scan_button.grid(row=0, column=3, padx=(4, 0))

        ttk.Label(
            top, text="※ サブフォルダ内のファイルは対象外です", foreground="#666666"
        ).grid(row=1, column=0, columnspan=4, sticky="w", pady=(0, 6))

        ttk.Label(top, text="").grid(row=2, column=0, columnspan=4)

        ttk.Label(top, text="出力フォルダ：").grid(row=3, column=0, sticky="w", pady=2)
        ttk.Entry(top, textvariable=self._output_dir_var, width=_FOLDER_ENTRY_WIDTH).grid(
            row=3, column=1, sticky="w", padx=(4, 4), pady=2
        )
        ttk.Button(top, text="...", width=3, command=self._choose_output_dir).grid(
            row=3, column=2, padx=(0, 4)
        )
        self._write_button = ttk.Button(
            top, text="ファイルを書き出し", command=self._start_output_write, style="Emphasis.TButton"
        )
        self._write_button.grid(row=3, column=3, padx=(4, 0))

        ttk.Label(
            top,
            text="※ 書き出し対象はA（適合済み）・B（要修正）のみ（C＝要変換／D＝対象外は対象外）です",
            foreground="#666666",
        ).grid(row=4, column=0, columnspan=4, sticky="w", pady=(0, 6))

        # ttk.Treeviewはセル内テキストの折り返し表示に対応していない（列幅を
        # 超える文章は単純に切り詰められる）ため、Frame/Labelベースの自作表
        # （WrappingTable）を使う。列は固定幅とする（ウィンドウ幅に応じて
        # 伸縮する列を作ると、伸縮後の実際の幅とLabelのwraplengthがずれて
        # 余白ばかり広がる見た目になるため）。
        self._table = WrappingTable(
            self, RESULT_TABLE_COLUMNS, tag_colors=_TAG_COLORS, with_checkbox=True
        )
        self._table.pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=8, pady=8)

        # ステータス行：画面の最下部に、見出しなしで配置する（「状況：」の
        # 見出し付き表示は見た目がしっくりこないとのフィードバックにより撤回）。
        # フラットデザイン方針のため立体的な枠線（sunken）は使わず、上側に
        # 細い区切り線（Separator）を1本引くだけの最下部バーとする。
        # side=BOTTOMは先にpackした要素ほど画面の下端に近づくため、
        # ステータス行→区切り線→サマリ行の順にpackする。
        self._status_label = ttk.Label(self, text="", anchor="w", padding=(8, 4))
        self._status_label.pack(side=tk.BOTTOM, fill=tk.X)
        ttk.Separator(self, orient="horizontal").pack(side=tk.BOTTOM, fill=tk.X)

        self._summary_label = ttk.Label(self, text="", padding=8, anchor="w", justify="left")
        self._summary_label.pack(side=tk.BOTTOM, fill=tk.X)

    # ------------------------------------------------------------------ フォルダ選択
    def _choose_input_dir(self) -> None:
        selected = filedialog.askdirectory(title="入力フォルダを選択", mustexist=True)
        if selected:
            self._input_dir_var.set(selected)

    def _choose_output_dir(self) -> None:
        selected = filedialog.askdirectory(title="出力フォルダを選択", mustexist=True)
        if not selected:
            return
        candidate = Path(selected)
        if self._input_dir is not None and candidate == self._input_dir:
            # 要件定義書4.1「出力フォルダ選択」：入力フォルダと同一指定も許容するが、
            # 上書き確認を必須とする。
            proceed = messagebox.askyesno(
                "確認",
                "出力フォルダが入力フォルダと同一です。\n"
                "処理結果の書き出し時に元ファイルを上書きする可能性があります。続行しますか？",
            )
            if not proceed:
                return
        self._output_dir_var.set(selected)

    # ------------------------------------------------------------------ 読み込み実行
    def _start_scan(self) -> None:
        input_dir = self._input_dir
        if input_dir is None:
            messagebox.showerror("エラー", "入力フォルダを選択してください。")
            return
        if not input_dir.is_dir():
            messagebox.showerror("エラー", f"入力フォルダが見つかりません：{input_dir}")
            return

        self._scan_button.config(state=tk.DISABLED)
        self._status_label.config(text="読み込み中...")
        self._table.clear()

        # input_dirはメインスレッドで確定させ、ワーカースレッドには値として
        # 渡す（フォルダパスがテキストボックス〔StringVar〕連動になったため、
        # 別スレッドから直接Tkの変数を読み取らないようにする）。
        thread = threading.Thread(target=self._run_scan_worker, args=(input_dir,), daemon=True)
        thread.start()
        self.after(100, self._poll_scan_queue)

    def _run_scan_worker(self, input_dir: Path) -> None:
        try:
            # サブフォルダの扱いを画面から選べるようにすると、意図せず対象外の
            # 書類（他事件のファイル等）まで読み込んでしまう懸念があるため、
            # 現状は常にトップレベルのみを対象とする（サブフォルダは対象外）。
            result = scan_folder(input_dir, self.config_obj, include_subfolders=False)
            self._queue.put(result)
        except Exception as exc:  # noqa: BLE001 - UIスレッドへエラーを引き渡す
            self._queue.put(exc)

    def _poll_scan_queue(self) -> None:
        try:
            item = self._queue.get_nowait()
        except queue.Empty:
            self.after(100, self._poll_scan_queue)
            return

        self._scan_button.config(state=tk.NORMAL)
        if isinstance(item, Exception):
            self._status_label.config(text="読み込みに失敗しました。")
            messagebox.showerror("エラー", f"読み込み中にエラーが発生しました：\n{item}")
            return

        self._scan_result = item
        self._populate_table(item)

    # ------------------------------------------------------------------ 結果表示
    def _populate_table(self, result: ScanResult) -> None:
        counts = {c: 0 for c in Classification}
        for record in result.records:
            counts[record.classification] += 1
            evidence_match = match_evidence_number(
                record.path.name, self.config_obj.evidence_number
            )
            # 対象選択チェックボックス（4.1節）：書き出し対象になり得るのは
            # A・B分類のみ（C・Dはselect_eligible_recordsで常に除外される）
            # のため、C・Dの行はチェックOFF・操作不可（グレーアウト）で表示
            # し、選択の余地がないことを視覚的に示す。
            selectable = record.classification in (
                Classification.A_COMPLIANT,
                Classification.B_NEEDS_FIX,
            )
            self._table.add_row(
                (
                    record.path.name,
                    CLASSIFICATION_LABELS[record.classification],
                    record.detected_format,
                    format_size_display(record.size_bytes),
                    describe_expected_processing(record, evidence_match),
                    "; ".join(record.notes),
                ),
                tag=_CLASSIFICATION_TAGS[record.classification],
                checked=selectable,
                checkable=selectable,
            )

        total = len(result.records)
        summary = (
            f"合計 {total} 件　|　"
            f"A：適合済み {counts[Classification.A_COMPLIANT]} 件　"
            f"B：要修正 {counts[Classification.B_NEEDS_FIX]} 件　"
            f"C：要変換 {counts[Classification.C_NEEDS_CONVERSION]} 件　"
            f"D：対象外 {counts[Classification.D_UNSUPPORTED]} 件"
        )
        self._summary_label.config(text=summary)
        self._status_label.config(text="読み込み完了")

    def _build_plan_and_batches(
        self, records: list[FileRecord], merge_groups: list[BranchGroup]
    ) -> tuple[list[PlannedCopy], list[Batch]]:
        """既定推奨（自動検出結果をすべて適用したと仮定）の書き出し計画と
        バッチ割当てを構築する。

        merge_groupsに含まれるメンバーは結合対象として除外し、残りを個別に
        扱う。上書き警告を最優先で表示するための事前計画（結合候補すべてを
        仮に結合したもの）と、枝番結合確認後の本番計画（確定した結合グループ
        のみ）の両方をこの関数で組み立てる（重複を避けるための共通化）。
        """
        merged_member_paths = {m.record.path for group in merge_groups for m in group.members}
        remaining = [r for r in records if r.path not in merged_member_paths]

        sanitized_names: dict[Path, str] = {
            r.path: sanitize_filename(r.path.name, self.config_obj.filename)
            for r in remaining
            if needs_sanitization(r.filename_finding)
        }
        strip_metadata_paths: set[Path] = {
            r.path
            for r in remaining
            if r.metadata_finding is not None and r.metadata_finding.has_metadata
        }
        stamp_labels: dict[Path, str] = {}
        for r in remaining:
            if r.detected_format not in _STAMPABLE_FORMATS:
                continue
            if r.pdf_finding is not None and r.pdf_finding.has_password:
                # パスワード付きPDFは復号キーがないと内容を読めず、スタンプ
                # 処理自体が実行できない（先にパスワード解除が必要）。
                continue
            match = match_evidence_number(r.path.name, self.config_obj.evidence_number)
            if match is not None:
                stamp_labels[r.path] = match.evidence_number_label

        planned = plan_output_filenames(
            remaining,
            sanitized_names,
            strip_metadata_paths,
            stamp_labels=stamp_labels,
            merge_groups=merge_groups,
            stamp_merge_sources=True,
            filename_config=self.config_obj.filename,
        )

        # 要件定義書4.5「200MB分割」：1回のアップロード合計上限（200MB）を
        # 超える場合に備え、提出回ごとのバッチへ仕分ける。ファイル自体を
        # 変更しない処理のため確認UIの対象外。バッチの割当ては、後続の逐次
        # レビューでの個別選択（整形の有無等）とは独立に決定してよい
        # （割当ての基準は証拠番号のまとまりと合計サイズであり、ファイル名の
        # 整形有無では変わらないため）。
        batches = plan_batches(planned, self.config_obj.upload, self.config_obj.evidence_number)
        return planned, batches

    # ------------------------------------------------------------------ 出力フォルダへの書き出し
    def _start_output_write(self) -> None:
        if self._scan_result is None:
            messagebox.showerror("エラー", "先にフォルダを読み込んでください。")
            return
        output_dir = self._output_dir
        if output_dir is None:
            messagebox.showerror("エラー", "出力フォルダを選択してください。")
            return
        if not output_dir.is_dir():
            messagebox.showerror("エラー", f"出力フォルダが見つかりません：{output_dir}")
            return

        all_eligible = select_eligible_records(self._scan_result.records)
        if not all_eligible:
            messagebox.showinfo("書き出し", "書き出し対象のファイル（A・B分類）がありません。")
            return

        # スキャン結果一覧の対象選択チェックボックス（4.1節）で対象から外された
        # ファイルは、この回の書き出しから除外する。外部アプリで一部のファイル
        # だけを修正して再処理したい場合に、フォルダ全体を毎回やり直さずに
        # 済むようにするための機能。行の並び順（get_checked_states）は
        # _populate_tableでのadd_row呼び出し順＝self._scan_result.recordsの
        # 順序と一致する前提。
        checked_states = self._table.get_checked_states()
        checked_paths = {
            record.path
            for record, checked in zip(self._scan_result.records, checked_states)
            if checked
        }
        eligible = [r for r in all_eligible if r.path in checked_paths]
        if not eligible:
            messagebox.showinfo(
                "書き出し",
                "書き出し対象として選択されているファイルがありません。"
                "一覧左端のチェックボックスをご確認ください。",
            )
            return

        # 要件定義書4.7「枝番ファイル結合」の候補検出（判定のみ、確認はまだ
        # 行わない）。上書き警告を最優先で表示するための事前計画づくりに使う。
        merge_groups = find_branch_merge_groups(eligible, self.config_obj.evidence_number)

        # 出力フォルダに既に同名ファイルがある場合の上書き警告を、他の確認・
        # 警告より先に最優先で表示する。ユーザーが以降の枝番結合確認・
        # ファイル別逐次レビューに時間をかける前に、続行するかどうかを
        # 早期に判断できるようにするため。結合候補は（実際の確認前だが）
        # すべて結合されるものと仮定した既定計画で判定する。
        preview_planned, preview_batches = self._build_plan_and_batches(eligible, merge_groups)
        conflicts = find_batch_conflicts(preview_batches, output_dir)
        if conflicts:
            conflict_list = "\n".join(f"・{name}" for name in conflicts[:20])
            if len(conflicts) > 20:
                conflict_list += f"\n...ほか{len(conflicts) - 20}件"
            proceed = messagebox.askyesno(
                "上書きの確認",
                f"出力フォルダには同名のファイルが既に{len(conflicts)}件あります。"
                "続行すると、これらのファイルは上書きされます。\n\n"
                f"{conflict_list}\n\n続行しますか？",
            )
            if not proceed:
                return

        # 要件定義書4.5「単体ファイル200MB超過の検出・警告」：合計サイズの
        # 200MB分割では解決できない、圧縮・分割（チェック専念方針により
        # 実行機能は廃止）なしには原理的に提出不可能な重大警告のため、
        # 書き出し実行の直前に必ず明示する（検出・警告にとどめ、処理は
        # ブロックしない）。
        oversized = [r for r in eligible if r.exceeds_single_file_limit]
        if oversized:
            oversized_list = "\n".join(
                f"・{r.path.name}（{r.size_mb:.1f}MB）" for r in oversized[:20]
            )
            if len(oversized) > 20:
                oversized_list += f"\n...ほか{len(oversized) - 20}件"
            messagebox.showwarning(
                "単体で200MBを超えるファイルがあります",
                f"以下の{len(oversized)}件は単体で200MBを超えており、圧縮又は分割を"
                "行わない限りmintsへ提出できません。\n\n"
                f"{oversized_list}\n\n"
                "このツールは検出・警告のみを行い、圧縮・分割の実行機能はまだ"
                "提供していません。書き出し自体は続行しますが、これらのファイルは"
                "別途対処が必要です。",
            )

        # 要件定義書4.7「枝番ファイル結合」：複数ファイルにまたがる決定のため、以降の
        # 1件単位の逐次レビュー画面にはなじまず、候補一覧をグループ単位の
        # チェックボックスでまとめて提示する（CLAUDE.md「絶対に譲れない設計
        # 原則」3「チェックボックスで選択」）。結合対象となったファイルは、
        # 以降の個別処理（ファイル名整形・単独スタンプ等）の対象から除外する。
        confirmed_merge_groups: list[BranchGroup] = []
        if merge_groups:
            checklist_items = [
                ChecklistItem(
                    key=str(i),
                    before_text="、".join(m.record.path.name for m in group.members),
                    after_text=f"→ {group.output_filename} へ結合（{len(group.members)}件）",
                    default_checked=True,
                )
                for i, group in enumerate(merge_groups)
            ]
            choices = ask_checklist(
                self,
                "枝番ファイル結合の確認",
                "以下のファイル群は、同一の証拠番号（枝番違い）と判定されました。"
                "準備の手引の規定により、枝番のある証拠は1つのファイルにまとめて"
                "提出することが想定されています。結合するグループのチェックを"
                "入れたまま実行してください（外すと、そのグループは結合せず"
                "個別のまま書き出します）。",
                checklist_items,
            )
            if choices:
                confirmed_merge_groups = [
                    group for i, group in enumerate(merge_groups) if choices.get(str(i), False)
                ]

        # 要件定義書4.5「200MB分割」：確定した結合グループを反映し、実際に
        # 書き出す計画・バッチ割当てを構築する。
        planned, batches = self._build_plan_and_batches(eligible, confirmed_merge_groups)

        self._write_button.config(state=tk.DISABLED)
        self._status_label.config(text="書き出し中...")
        self.update_idletasks()

        # 要件定義書4.5・4.7「ファイル別・逐次レビュー」：バッチ内の並び順の
        # とおり、1件（又は1枝番ファイル結合グループ）ずつプレビュー付きの確認画面を
        # 表示し、確定した内容を即座に書き出す。モーダルダイアログと1件ずつの
        # 書き込みが交互に発生するため、バックグラウンドスレッドは使わず
        # メインスレッドで同期的に処理する。
        actual_planned: list[PlannedCopy] = []
        report_batches: dict[int, list[PlannedCopy]] = {}
        seen_names: dict[str, int] = {}
        total_items = sum(len(batch.items) for batch in batches)
        processed = 0
        aborted = False

        for batch in batches:
            batch_dir = resolve_batch_dir(batches, batch, output_dir)
            batch_dir.mkdir(parents=True, exist_ok=True)
            for prelim_item in batch.items:
                processed += 1
                preview_record = (
                    prelim_item.merge_group.representative.record
                    if prelim_item.merge_group is not None
                    else prelim_item.record
                )
                review_item = ReviewItem(
                    preliminary=prelim_item, preview_record=preview_record, batch_dir=batch_dir
                )
                final_item = review_and_confirm(
                    self,
                    review_item,
                    processed,
                    total_items,
                    self.config_obj.filename,
                    self.config_obj.evidence_stamp,
                )
                if final_item is None:
                    aborted = True
                    break

                final_name = self._dedupe_output_filename(final_item.output_filename, seen_names)
                if final_name != final_item.output_filename:
                    final_item = PlannedCopy(
                        record=final_item.record,
                        output_filename=final_name,
                        strip_metadata=final_item.strip_metadata,
                        stamp_label=final_item.stamp_label,
                        merge_group=final_item.merge_group,
                        merge_stamp=final_item.merge_stamp,
                        stamp_margin_top_mm=final_item.stamp_margin_top_mm,
                        stamp_margin_right_mm=final_item.stamp_margin_right_mm,
                    )

                self._status_label.config(text=f"書き出し中：{processed} / {total_items} 件目...")
                self.update_idletasks()
                copy_to_output([final_item], batch_dir, self.config_obj.evidence_stamp)

                actual_planned.append(final_item)
                report_batches.setdefault(batch.index, []).append(final_item)
            if aborted:
                break

        # 要件定義書4.8「基本レポート出力機能」：処理結果・警告一覧・バッチ構成・
        # 未対応形式一覧を出力フォルダ直下にテキストで生成する。集計・整形の
        # みの読み取り専用処理のため確認UIの対象外（自動生成）。中止された
        # 場合も、実際に処理済みの範囲でレポートを生成する。
        report_batches_list = [
            Batch(index=idx, items=tuple(items)) for idx, items in sorted(report_batches.items())
        ]
        report_text = build_report_text(
            input_dir=self._input_dir,
            output_dir=output_dir,
            all_records=list(self._scan_result.records),
            planned=actual_planned,
            batches=report_batches_list,
            evidence_number_config=self.config_obj.evidence_number,
        )
        report_path = write_report(report_text, output_dir)

        self._write_button.config(state=tk.NORMAL)
        total_written = len(actual_planned)
        batch_count = len(report_batches_list)

        # 要件定義書4.8の最重要警告（単体200MB超過・墨消し完全性の残存検出・
        # 個人情報パターンの検出）、検出のみの要修正事項（パスワード・操作
        # 制限・用紙寸法・向き不一致）、及び号証の連番・欠番チェック（4.7節）
        # の結果は、レポートファイルを開かない限り気づけないままでは見落とし
        # の危険があるため、書き出し完了時（中止時を含む）に画面上でも警告する。
        self._show_completion_warnings(
            critical_lines=collect_critical_warnings(self._scan_result.records),
            check_lines=collect_check_warnings(self._scan_result.records),
            sequence_lines=format_evidence_sequence_warnings(
                (item.output_filename for item in actual_planned), self.config_obj.evidence_number
            ),
            report_path=report_path,
        )

        if aborted:
            self._status_label.config(
                text=f"書き出しを中止しました（{total_written} / {total_items} 件処理）"
            )
            messagebox.showwarning(
                "書き出しを中止しました",
                f"{total_written} 件を書き出した時点で中止しました"
                f"（残り {total_items - total_written} 件は未処理のまま入力フォルダに"
                "残っています）。\n\n"
                f"処理内容の詳細は「{report_path.name}」をご確認ください。",
            )
            self._offer_open_output_folder(output_dir)
            return

        self._status_label.config(text=f"書き出し完了（{total_written} 件・{batch_count} 回分）")

        if batch_count >= 2:
            # 要件定義書4.5「通知の必須化」：分割が発生する場合、確認（実行可否
            # の選択）は不要だが、処理完了時の通知は省略してはならない。
            batch_summary = "\n".join(
                f"・{b.folder_name}：{len(b.items)} 件" for b in report_batches_list
            )
            messagebox.showwarning(
                "複数回に分けて提出する必要があります",
                f"合計サイズが200MBを超えるため、{batch_count}回に分けて提出する"
                f"必要があります。「提出用_01」から順にご提出ください。"
                f"\n\n{batch_summary}\n\n"
                f"出力先：{self._output_dir}\n"
                "各「提出用_NN」フォルダの中身を、提出回ごとに手作業でmintsに"
                "アップロードしてください。\n\n"
                f"処理内容の詳細は「{report_path.name}」をご確認ください。",
            )
        else:
            messagebox.showinfo(
                "書き出し完了",
                f"{total_written} 件のファイルを書き出しました。\n出力先：{self._output_dir}\n\n"
                f"処理内容の詳細は「{report_path.name}」をご確認ください。",
            )

        self._offer_open_output_folder(output_dir)

    def _show_completion_warnings(
        self,
        *,
        critical_lines: list[str],
        check_lines: list[str],
        sequence_lines: list[str],
        report_path: Path,
    ) -> None:
        """書き出し完了時（中止時を含む）に、検出事項（最重要警告＋検出のみ
        の要修正事項）と号証の連番・欠番チェックの結果を画面上に表示する。

        いずれもレポートファイル（write_report）・スキャン結果一覧には既に
        記載されているが、それらを見ないと気づけないままでは見落としの危険が
        あるため、書き出しが終わった直後にダイアログでも明示する。該当が
        なければ何も表示しない。

        最重要警告（単体200MB超過・墨消し・個人情報）と検出のみの要修正事項
        （パスワード・操作制限・用紙寸法・向き不一致）は、いずれも「そのまま
        提出すべきではない」という同じ行動を促すものであり、この画面では
        区分せず同列の1つの一覧として表示する（重要度の高いものが先頭に
        来る並び順は維持）。レポート側は要件定義書4.8の指定どおり最重要警告を
        冒頭に優先表示する構造を維持しており、区分はレポートでのみ行う。
        """
        issue_lines = critical_lines + check_lines
        if not issue_lines and not sequence_lines:
            return

        def _capped(lines: list[str]) -> str:
            shown = "\n".join(lines[:20])
            if len(lines) > 20:
                shown += f"\n...ほか{len(lines) - 20}件"
            return shown

        sections: list[str] = []
        if issue_lines:
            sections.append(
                "【検出事項（そのまま提出すべきではありません。他のアプリで"
                "対処のうえ、再度読み込んで再検証してください）】\n" + _capped(issue_lines)
            )
        if sequence_lines:
            sections.append("【号証の連番・欠番チェック】\n" + _capped(sequence_lines))

        body = "\n\n".join(sections)
        body += f"\n\n詳細は「{report_path.name}」をご確認ください。"
        messagebox.showwarning("確認が必要な検出結果があります", body)

    def _offer_open_output_folder(self, output_dir: Path) -> None:
        """書き出し完了時、出力フォルダをエクスプローラーで開くかどうかを
        ユーザーに選ばせる。結果をすぐ確認できるようにする、ファイルを
        変更しない操作のためCLAUDE.mdの確認UI原則（設計原則3）の対象外だが、
        任意選択である以上、無条件で開くのではなくここで確認する。
        """
        if messagebox.askyesno(
            "出力フォルダを開く", "出力フォルダをエクスプローラーで開きますか？"
        ):
            os.startfile(output_dir)  # noqa: S606 - ユーザーが選択した既存のローカルフォルダのみを開く

    @staticmethod
    def _dedupe_output_filename(name: str, seen: dict[str, int]) -> str:
        """出力ファイル名の重複を防ぐ。

        逐次レビューでのチェックボックス選択により、事前計画時に想定していた
        名前と実際の最終ファイル名が変わり得るため（例：ファイル名整形の
        チェックを外し、元の〔重複し得る〕名前に戻す等）、書き出しのたびに
        実際の最終ファイル名を基準に重複チェックする
        （output_writer._resolve_name_collisionsと同じ命名規則）。
        """
        if name not in seen:
            seen[name] = 1
            return name
        seen[name] += 1
        stem = Path(name).stem
        suffix = Path(name).suffix
        candidate = f"{stem}_{seen[name]}{suffix}"
        while candidate in seen:
            seen[name] += 1
            candidate = f"{stem}_{seen[name]}{suffix}"
        seen[candidate] = 1
        return candidate


def main() -> None:
    app = MainWindow()
    app.mainloop()


if __name__ == "__main__":
    main()
