# Copyright (C) 2026 NPO NT
# SPDX-License-Identifier: AGPL-3.0-only

from __future__ import annotations

import hashlib
import os
import queue
import subprocess
import sys
import threading
import time
import tkinter as tk
from collections import Counter
from pathlib import Path
from tkinter import filedialog, messagebox, ttk

from PIL import Image, ImageTk

from . import __version__
from .analyzer import analyze_document
from .legal import legal_notice
from .loader import DocumentLoadError, load_document
from .models import Action, Box, Finding, Severity
from .ocr import TesseractOcr
from .policy import MIKA_NAVIGATION_CATEGORIES
from .progress import OperationCancelled, ProgressUpdate
from .workflow import ResidualFindingsForReview, ReleaseBlockedError, create_verified_copy


BACKGROUND = "#f5f1e8"
INK = "#14241c"
ACCENT = "#b8e65b"
RED = "#d44747"
AMBER = "#df9b2f"
MUTED = "#6b756f"
SELECTION = "#2563a6"
MIN_ZOOM_FACTOR = 0.5
MAX_ZOOM_FACTOR = 4.0
ZOOM_STEP = 1.25
OPEN_BUTTON_TEXT = "1. ВЫБРАТЬ PDF ИЛИ ФОТОГРАФИЮ"
CONFIRM_BUTTON_TEXT = "✓ ПОДТВЕРДИТЬ ЭТУ СТРАНИЦУ"
SAVE_BUTTON_TEXT = "3. СОЗДАТЬ И ПРОВЕРИТЬ КОПИЮ"
SAVE_RETRY_BUTTON_TEXT = "3. СОЗДАТЬ И ПОВТОРНО ПРОВЕРИТЬ КОПИЮ"
SUPPORTED_SOURCE_SUFFIXES = {".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".heic", ".heif"}


def _format_duration(seconds: float) -> str:
    rounded = max(0, int(seconds))
    minutes, remaining_seconds = divmod(rounded, 60)
    hours, minutes = divmod(minutes, 60)
    if hours:
        return f"{hours:d}:{minutes:02d}:{remaining_seconds:02d}"
    return f"{minutes:02d}:{remaining_seconds:02d}"


def _short_display_path(path: str | Path) -> str:
    candidate = Path(path)
    parts = candidate.parts
    if len(parts) <= 3:
        return str(candidate)
    return str(Path("…") / parts[-2] / parts[-1])


class ReviewApp:
    def __init__(self, root: tk.Tk) -> None:
        self.root = root
        self.root.title(f"MIKA De-ID Local {__version__} - только синтетические данные")
        self.root.geometry("1220x900")
        self.root.minsize(1000, 760)
        self.root.configure(bg=BACKGROUND)

        self.ocr = TesseractOcr()
        self.analysis = None
        self.page_index = 0
        self.confirmed_pages: set[int] = set()
        self.display_image: ImageTk.PhotoImage | None = None
        self.display_scale = 1.0
        self.display_offset = (0.0, 0.0)
        self.display_content_size = (1.0, 1.0)
        self.zoom_factor = 1.0
        self.drag_start: tuple[float, float] | None = None
        self.drag_item: int | None = None
        self.selected_finding_id: str | None = None
        self.manual_history: list[str] = []
        self.manual_sequence = 0
        self.refreshing_tree = False
        self.busy = False
        self.cancel_signal: threading.Event | None = None
        self.worker_events: queue.SimpleQueue[tuple[str, object]] = queue.SimpleQueue()
        self.operation_started_at: float | None = None
        self.operation_last_progress_at: float | None = None
        self.operation_progress = 0.0
        self.operation_completed_pages = 0
        self.operation_cancellable = False
        self.pending_output_path: str | None = None
        self.last_output_path: Path | None = None
        self.last_report_path: Path | None = None

        self._build_ui()
        self._update_controls()

    def _build_ui(self) -> None:
        header = tk.Frame(self.root, bg=INK, padx=24, pady=18)
        header.pack(fill="x")
        tk.Label(
            header,
            text="MIKA De-ID Local",
            font=("Helvetica Neue", 25, "bold"),
            fg="white",
            bg=INK,
        ).pack(side="left")
        tk.Label(
            header,
            text="LOCAL / SYNTHETIC DATA ONLY",
            font=("Helvetica Neue", 11, "bold"),
            fg=INK,
            bg=ACCENT,
            padx=12,
            pady=6,
        ).pack(side="right")

        notice = tk.Label(
            self.root,
            text=(
                "Программа не отправляет исходник в интернет. Она создаёт новую очищенную копию. "
                "Каждую страницу необходимо проверить вручную."
            ),
            font=("Helvetica Neue", 12),
            fg=INK,
            bg="#e9f2d6",
            padx=20,
            pady=10,
            anchor="w",
            justify="left",
        )
        notice.pack(fill="x")

        toolbar = tk.Frame(self.root, bg=BACKGROUND, padx=18, pady=12)
        toolbar.pack(fill="x")
        self.open_button = tk.Button(
            toolbar,
            text=OPEN_BUTTON_TEXT,
            command=self.open_document,
            bg="#caff55",
            fg=INK,
            activebackground=ACCENT,
            activeforeground=INK,
            relief="raised",
            borderwidth=2,
            highlightthickness=2,
            highlightbackground=ACCENT,
            highlightcolor=ACCENT,
            disabledforeground="#405047",
            padx=16,
            pady=10,
            font=("Helvetica Neue", 13, "bold"),
        )
        self.open_button.pack(side="left")
        self.status_var = tk.StringVar(value="Выберите синтетический тестовый документ")
        tk.Label(
            toolbar,
            textvariable=self.status_var,
            font=("Helvetica Neue", 11),
            fg=MUTED,
            bg=BACKGROUND,
            padx=14,
        ).pack(side="left")
        self.legal_button = tk.Button(
            toolbar,
            text="О программе и лицензии",
            command=self.show_legal_notice,
            bg=BACKGROUND,
            fg=INK,
            relief="flat",
            padx=10,
            pady=8,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.legal_button.pack(side="right")

        self.progress_frame = tk.Frame(self.root, bg="#e9f2d6", padx=18, pady=10)
        progress_top = tk.Frame(self.progress_frame, bg="#e9f2d6")
        progress_top.pack(fill="x")
        self.progress_detail_var = tk.StringVar(value="")
        tk.Label(
            progress_top,
            textvariable=self.progress_detail_var,
            bg="#e9f2d6",
            fg=INK,
            font=("Helvetica Neue", 11, "bold"),
            anchor="w",
        ).pack(side="left", fill="x", expand=True)
        self.progress_time_var = tk.StringVar(value="")
        tk.Label(
            progress_top,
            textvariable=self.progress_time_var,
            bg="#e9f2d6",
            fg=MUTED,
            font=("Helvetica Neue", 10),
            anchor="e",
        ).pack(side="right", padx=(12, 0))
        progress_bottom = tk.Frame(self.progress_frame, bg="#e9f2d6")
        progress_bottom.pack(fill="x", pady=(7, 0))
        self.progress_var = tk.DoubleVar(value=0.0)
        self.progress_bar = ttk.Progressbar(
            progress_bottom,
            variable=self.progress_var,
            maximum=100.0,
            mode="determinate",
        )
        self.progress_bar.pack(side="left", fill="x", expand=True)
        self.cancel_button = tk.Button(
            progress_bottom,
            text="Отменить",
            command=self.cancel_current_operation,
            bg="#ffffff",
            fg=INK,
            relief="flat",
            padx=14,
            pady=5,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.cancel_button.pack(side="right", padx=(12, 0))

        self.body = tk.PanedWindow(self.root, orient="horizontal", bg=BACKGROUND, sashwidth=6, bd=0)
        self.body.pack(fill="both", expand=True, padx=18, pady=(0, 12))

        canvas_frame = tk.Frame(self.body, bg="#d8d8d2")
        zoom_toolbar = tk.Frame(canvas_frame, bg="#eef0eb", padx=10, pady=7)
        zoom_toolbar.pack(fill="x")
        self.zoom_out_button = tk.Button(
            zoom_toolbar,
            text="− Отдалить",
            command=self.zoom_out,
            bg="white",
            fg=INK,
            relief="flat",
            padx=10,
            pady=5,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.zoom_out_button.pack(side="left")
        self.zoom_in_button = tk.Button(
            zoom_toolbar,
            text="+ Увеличить",
            command=self.zoom_in,
            bg="white",
            fg=INK,
            relief="flat",
            padx=10,
            pady=5,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.zoom_in_button.pack(side="left", padx=(6, 0))
        self.fit_button = tk.Button(
            zoom_toolbar,
            text="По размеру",
            command=self.zoom_fit,
            bg="white",
            fg=INK,
            relief="flat",
            padx=10,
            pady=5,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.fit_button.pack(side="left", padx=(6, 0))
        self.zoom_var = tk.StringVar(value="Масштаб: по размеру (100%)")
        tk.Label(
            zoom_toolbar,
            textvariable=self.zoom_var,
            bg="#eef0eb",
            fg=MUTED,
            font=("Helvetica Neue", 10),
        ).pack(side="left", padx=12)
        tk.Label(
            zoom_toolbar,
            text="Колесо / трекпад",
            bg="#eef0eb",
            fg=MUTED,
            font=("Helvetica Neue", 9),
        ).pack(side="right")

        canvas_area = tk.Frame(canvas_frame, bg="#d8d8d2")
        canvas_area.pack(fill="both", expand=True)
        canvas_area.rowconfigure(0, weight=1)
        canvas_area.columnconfigure(0, weight=1)
        self.canvas = tk.Canvas(canvas_area, bg="#d8d8d2", highlightthickness=0, cursor="crosshair")
        self.canvas.grid(row=0, column=0, sticky="nsew")
        self.canvas_x_scroll = ttk.Scrollbar(canvas_area, orient="horizontal", command=self.canvas.xview)
        self.canvas_x_scroll.grid(row=1, column=0, sticky="ew")
        self.canvas_y_scroll = ttk.Scrollbar(canvas_area, orient="vertical", command=self.canvas.yview)
        self.canvas_y_scroll.grid(row=0, column=1, sticky="ns")
        self.canvas.configure(
            xscrollcommand=self.canvas_x_scroll.set,
            yscrollcommand=self.canvas_y_scroll.set,
        )
        self.canvas.bind("<Configure>", lambda _event: self.draw_page())
        self.canvas.bind("<ButtonPress-1>", self.on_canvas_press)
        self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
        self.canvas.bind("<ButtonRelease-1>", self.on_canvas_release)
        self.canvas.bind("<MouseWheel>", self.on_canvas_mousewheel)
        self.canvas.bind("<Shift-MouseWheel>", self.on_canvas_shift_mousewheel)
        self.body.add(canvas_frame, stretch="always", minsize=700)

        side = tk.Frame(self.body, bg="white", padx=18, pady=16)
        self.body.add(side, minsize=310)
        self.page_var = tk.StringVar(value="Страница -")
        tk.Label(side, textvariable=self.page_var, bg="white", fg=INK, font=("Helvetica Neue", 17, "bold")).pack(anchor="w")
        tk.Label(
            side,
            text=(
                "Красное — будет удалено. Жёлтое — клиническая дата, сохраняется по умолчанию. "
                "Щёлкните область, чтобы выбрать её. Протяните мышью, чтобы добавить ручную маску."
            ),
            bg="white",
            fg=MUTED,
            font=("Helvetica Neue", 10),
            wraplength=280,
            justify="left",
        ).pack(anchor="w", pady=(6, 12))

        self.finding_tree = ttk.Treeview(side, columns=("state", "category"), show="headings", height=4)
        self.finding_tree.heading("state", text="Удалить")
        self.finding_tree.heading("category", text="Категория")
        self.finding_tree.column("state", width=65, anchor="center")
        self.finding_tree.column("category", width=205, anchor="w")
        self.finding_tree.pack(fill="x")
        self.finding_tree.bind("<Double-1>", self.toggle_tree_finding)
        self.finding_tree.bind("<<TreeviewSelect>>", self.select_tree_finding)

        self.selection_var = tk.StringVar(value="Область не выбрана")
        tk.Label(
            side,
            textvariable=self.selection_var,
            bg="white",
            fg=INK,
            font=("Helvetica Neue", 10, "bold"),
            wraplength=280,
            justify="left",
        ).pack(fill="x", pady=(10, 6))

        self.toggle_selected_button = tk.Button(
            side,
            text="Включить / выключить удаление",
            command=self.toggle_selected_finding,
            bg="#eef0eb",
            fg=INK,
            relief="flat",
            padx=8,
            pady=7,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.toggle_selected_button.pack(fill="x", pady=(0, 6))
        manual_actions = tk.Frame(side, bg="white")
        manual_actions.pack(fill="x")
        self.delete_manual_button = tk.Button(
            manual_actions,
            text="Удалить ручную область",
            command=self.delete_selected_manual,
            bg="#fff0ed",
            fg="#8b2f2f",
            relief="flat",
            padx=8,
            pady=7,
            font=("Helvetica Neue", 9, "bold"),
        )
        self.delete_manual_button.pack(side="left", fill="x", expand=True, padx=(0, 3))
        self.undo_manual_button = tk.Button(
            manual_actions,
            text="↶ Отменить последнее",
            command=self.undo_last_manual,
            bg="#eef0eb",
            fg=INK,
            relief="flat",
            padx=8,
            pady=7,
            font=("Helvetica Neue", 9, "bold"),
        )
        self.undo_manual_button.pack(side="left", fill="x", expand=True, padx=(3, 0))
        self.warning_var = tk.StringVar(value="")
        tk.Label(
            side,
            textvariable=self.warning_var,
            bg="white",
            fg="#9a4f1a",
            font=("Helvetica Neue", 10),
            wraplength=280,
            justify="left",
        ).pack(fill="x", pady=(7, 9))

        self.confirm_button = tk.Button(
            side,
            text=CONFIRM_BUTTON_TEXT,
            command=self.confirm_page,
            bg="#caff55",
            fg=INK,
            activebackground="#b8e65b",
            activeforeground=INK,
            relief="raised",
            borderwidth=2,
            highlightthickness=2,
            highlightbackground="#51731a",
            highlightcolor="#51731a",
            disabledforeground="#405047",
            padx=10,
            pady=10,
            font=("Helvetica Neue", 12, "bold"),
        )
        self.confirm_button.pack(fill="x", pady=(4, 8))

        nav = tk.Frame(side, bg="white")
        nav.pack(fill="x")
        self.prev_button = tk.Button(nav, text="← Назад", command=self.previous_page, relief="flat", pady=8)
        self.prev_button.pack(side="left", fill="x", expand=True, padx=(0, 4))
        self.next_button = tk.Button(nav, text="Вперёд →", command=self.next_page, relief="flat", pady=8)
        self.next_button.pack(side="left", fill="x", expand=True, padx=(4, 0))

        export_frame = tk.Frame(self.root, bg=INK, padx=12, pady=10)
        export_frame.pack(side="bottom", fill="x", padx=18, pady=(0, 14), before=self.body)
        self.output_path_var = tk.StringVar(
            value="Путь не выбран. Готовая копия сохраняется только после финальной проверки."
        )
        tk.Label(
            export_frame,
            textvariable=self.output_path_var,
            bg=INK,
            fg="white",
            font=("Helvetica Neue", 9, "bold"),
            wraplength=270,
            justify="left",
            anchor="w",
        ).pack(side="left", fill="x", expand=True, padx=(0, 10))
        self.reveal_output_button = tk.Button(
            export_frame,
            text="В ПАПКУ",
            command=self.reveal_last_output,
            bg="#eef0eb",
            fg=INK,
            relief="raised",
            borderwidth=2,
            disabledforeground="#405047",
            padx=8,
            pady=9,
            font=("Helvetica Neue", 9, "bold"),
        )
        self.reveal_output_button.pack(side="right", padx=(6, 0))
        self.open_output_button = tk.Button(
            export_frame,
            text="ОТКРЫТЬ КОПИЮ",
            command=self.open_last_output,
            bg="#caff55",
            fg=INK,
            relief="raised",
            borderwidth=2,
            disabledforeground="#405047",
            padx=10,
            pady=9,
            font=("Helvetica Neue", 9, "bold"),
        )
        self.open_output_button.pack(side="right", padx=(6, 0))
        self.save_button = tk.Button(
            export_frame,
            text=SAVE_BUTTON_TEXT,
            command=self.save_copy,
            bg="#caff55",
            fg=INK,
            activebackground=ACCENT,
            activeforeground=INK,
            relief="raised",
            borderwidth=2,
            highlightthickness=2,
            highlightbackground="white",
            highlightcolor="white",
            disabledforeground="#405047",
            padx=12,
            pady=9,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.save_button.pack(side="right", padx=(6, 0))
        self.choose_output_button = tk.Button(
            export_frame,
            text="2. ВЫБРАТЬ МЕСТО И ИМЯ…",
            command=self.choose_output_path,
            bg="#e8f1ff",
            fg="#173a64",
            activebackground="#d9eaff",
            activeforeground="#173a64",
            relief="raised",
            borderwidth=2,
            disabledforeground="#405047",
            padx=10,
            pady=9,
            font=("Helvetica Neue", 10, "bold"),
        )
        self.choose_output_button.pack(side="right", padx=(6, 0))

        for shortcut in ("<Command-z>", "<Control-z>"):
            self.root.bind(shortcut, self._undo_shortcut)
        for shortcut in ("<Delete>", "<BackSpace>"):
            self.root.bind(shortcut, self._delete_shortcut)
        for shortcut in ("<Command-plus>", "<Control-plus>", "<Command-equal>", "<Control-equal>"):
            self.root.bind(shortcut, self._zoom_in_shortcut)
        for shortcut in ("<Command-minus>", "<Control-minus>"):
            self.root.bind(shortcut, self._zoom_out_shortcut)
        for shortcut in ("<Command-0>", "<Control-0>"):
            self.root.bind(shortcut, self._zoom_fit_shortcut)

    def show_legal_notice(self) -> None:
        messagebox.showinfo("MIKA De-ID Local - лицензия и исходный код", legal_notice())

    def set_busy(self, busy: bool, message: str | None = None) -> None:
        self.busy = busy
        if message:
            self.status_var.set(message)
        if not busy:
            self.progress_bar.stop()
            self.progress_frame.pack_forget()
            self.cancel_signal = None
            self.operation_started_at = None
            self.operation_last_progress_at = None
            self.operation_cancellable = False
        self._update_controls()

    def _start_operation(self, message: str, *, determinate: bool, cancellable: bool) -> None:
        self.worker_events = queue.SimpleQueue()
        self.cancel_signal = threading.Event() if cancellable else None
        self.operation_started_at = time.monotonic()
        self.operation_last_progress_at = self.operation_started_at
        self.operation_progress = 0.0
        self.operation_completed_pages = 0
        self.operation_cancellable = cancellable
        self.progress_var.set(0.0)
        self.progress_detail_var.set(message)
        self.progress_time_var.set("Прошло 00:00 · оценка остатка появится после первой страницы")
        self.progress_bar.stop()
        self.progress_bar.configure(mode="determinate" if determinate else "indeterminate")
        if not determinate:
            self.progress_bar.start(12)
        self.cancel_button.configure(state=tk.NORMAL if cancellable else tk.DISABLED)
        self.progress_frame.pack(fill="x", padx=18, pady=(0, 10), before=self.body)
        self.set_busy(True, message)
        self.root.after(100, self._poll_worker_events)

    def cancel_current_operation(self) -> None:
        if not self.busy or not self.operation_cancellable or self.cancel_signal is None:
            return
        self.cancel_signal.set()
        self.cancel_button.configure(state=tk.DISABLED)
        self.status_var.set("Останавливаем локальный анализ...")
        self.progress_detail_var.set("Запрошена отмена. Завершается текущая локальная операция...")

    def _queue_progress(self, update: ProgressUpdate) -> None:
        self.worker_events.put(("progress", update))

    def _poll_worker_events(self) -> None:
        while True:
            try:
                event, payload = self.worker_events.get_nowait()
            except queue.Empty:
                break
            if event == "progress":
                self._apply_progress(payload)
            elif event == "analysis_ready":
                self._analysis_ready(payload)
            elif event == "analysis_failed":
                self._analysis_failed(payload)
            elif event == "analysis_cancelled":
                self._analysis_cancelled()
            elif event == "save_ready":
                result_path, report_path, verification = payload
                self._save_ready(result_path, report_path, verification)
            elif event == "save_failed":
                self._save_failed(payload)
            elif event == "save_residuals":
                self._save_residuals_detected(payload)
        if self.busy:
            self._update_elapsed_label()
            self.root.after(100, self._poll_worker_events)

    def _apply_progress(self, payload: object) -> None:
        if not isinstance(payload, ProgressUpdate):
            return
        update = payload
        self.operation_last_progress_at = time.monotonic()
        total = max(0, update.total)
        current = max(0, update.current)
        if update.stage == "opening":
            progress, detail = 1.0, "Проверяем выбранный файл"
        elif update.stage == "rendering":
            fraction = (current - 1) / total if total else 0.0
            progress = 3.0 + 17.0 * fraction
            detail = f"Готовим изображение страницы {current} из {total}"
        elif update.stage == "render_complete":
            fraction = current / total if total else 0.0
            progress = 3.0 + 17.0 * fraction
            detail = f"Подготовлено страниц: {current} из {total}"
        elif update.stage == "hashing":
            fraction = min(1.0, current / total) if total else 0.0
            progress = 20.0 + 5.0 * fraction
            detail = "Проверяем целостность исходного файла"
        elif update.stage == "capabilities":
            progress, detail = 25.0, "Проверяем локальные OCR-компоненты"
        elif update.stage == "ocr":
            fraction = (current - 1) / total if total else 0.0
            progress = 25.0 + 70.0 * fraction
            detail = f"Распознаём текст: страница {current} из {total}"
        elif update.stage == "barcodes":
            fraction = (current - 0.2) / total if total else 0.0
            progress = 25.0 + 70.0 * fraction
            detail = f"Ищем QR-коды и штрихкоды: страница {current} из {total}"
        elif update.stage == "page_complete":
            fraction = current / total if total else 0.0
            progress = 25.0 + 75.0 * fraction
            detail = f"Проанализировано страниц: {current} из {total}"
            self.operation_completed_pages = max(self.operation_completed_pages, current)
        elif update.stage == "complete":
            progress, detail = 100.0, "Локальный анализ завершён"
        else:
            return
        self.operation_progress = max(self.operation_progress, min(100.0, progress))
        self.progress_var.set(self.operation_progress)
        self.progress_detail_var.set(f"{detail} · {self.operation_progress:.0f}%")
        self.status_var.set(detail)

    def _update_elapsed_label(self) -> None:
        if self.operation_started_at is None:
            return
        now = time.monotonic()
        elapsed = max(0.0, now - self.operation_started_at)
        elapsed_text = _format_duration(elapsed)
        stalled_for = max(0.0, now - self.operation_last_progress_at) if self.operation_last_progress_at else 0.0
        if self.operation_cancellable and stalled_for >= 30.0:
            estimate = f"нет нового этапа {_format_duration(stalled_for)}; можно отменить"
        elif self.operation_completed_pages >= 1 and self.operation_progress < 100.0 and elapsed >= 3.0:
            remaining = elapsed * (100.0 - self.operation_progress) / self.operation_progress
            estimate = f"примерно осталось {_format_duration(remaining)}"
        elif self.operation_progress >= 100.0:
            estimate = "завершено"
        else:
            estimate = "оценка остатка появится после первой страницы"
        self.progress_time_var.set(f"Прошло {elapsed_text} · {estimate}")

    def open_document(self) -> None:
        if self.busy:
            return
        source = filedialog.askopenfilename(
            title="Выберите синтетический медицинский документ",
            filetypes=[
                ("PDF и изображения", "*.pdf *.png *.jpg *.jpeg *.tif *.tiff *.heic *.heif"),
            ],
        )
        if not source:
            return
        suffix = Path(source).suffix.lower()
        if suffix not in SUPPORTED_SOURCE_SUFFIXES:
            hint = (
                "Архив ZIP необходимо сначала распаковать, а затем выбрать один PDF или файл изображения."
                if suffix == ".zip"
                else "Выберите PDF, JPEG/JPG, PNG, TIFF или HEIC/HEIF."
            )
            messagebox.showerror("Формат не поддерживается", hint)
            return
        self.pending_output_path = None
        self.last_output_path = None
        self.last_report_path = None
        self.output_path_var.set(
            "Путь сохранения не выбран. Нажмите кнопку ниже, чтобы выбрать папку и имя файла."
        )
        self.save_button.configure(text=SAVE_BUTTON_TEXT)
        self._start_operation("Начинаем локальный анализ...", determinate=True, cancellable=True)

        def work() -> None:
            try:
                document = load_document(
                    source,
                    progress_callback=self._queue_progress,
                    cancel_signal=self.cancel_signal,
                )
                analysis = analyze_document(
                    document,
                    ocr=self.ocr,
                    progress_callback=self._queue_progress,
                    cancel_signal=self.cancel_signal,
                )
            except OperationCancelled:
                self.worker_events.put(("analysis_cancelled", None))
            except Exception as exc:
                self.worker_events.put(("analysis_failed", exc))
            else:
                self.worker_events.put(("analysis_ready", analysis))

        threading.Thread(target=work, daemon=True).start()

    def _analysis_failed(self, exc: Exception) -> None:
        self.set_busy(False, "Анализ не выполнен")
        detail = str(exc) if isinstance(exc, DocumentLoadError) else f"{type(exc).__name__}: {exc}"
        messagebox.showerror("Документ не обработан", detail)

    def _analysis_cancelled(self) -> None:
        self.set_busy(False, "Локальный анализ отменён. Можно выбрать другой документ.")

    def _analysis_ready(self, analysis) -> None:
        self.analysis = analysis
        self.page_index = 0
        self.confirmed_pages.clear()
        self.zoom_factor = 1.0
        self.selected_finding_id = None
        self.manual_history.clear()
        self.manual_sequence = 0
        self.pending_output_path = None
        self.last_output_path = None
        self.last_report_path = None
        self.output_path_var.set(
            "Путь сохранения не выбран. Нажмите кнопку ниже, чтобы выбрать папку и имя файла."
        )
        self.save_button.configure(text=SAVE_BUTTON_TEXT)
        self.set_busy(False, f"Найдено областей: {len(analysis.findings)}. Проверьте каждую страницу.")
        if not analysis.capabilities.complete:
            messagebox.showwarning(
                "Неполная локальная конфигурация",
                "Сохранение будет заблокировано:\n\n" + "\n".join(analysis.capabilities.blocking_issues),
            )
        self.draw_page(reset_view=True)
        self._update_controls()

    def _page_findings(self) -> list[Finding]:
        if self.analysis is None:
            return []
        return self.analysis.findings_for_page(self.page_index)

    def _viewport_center_page(self) -> tuple[float, float] | None:
        if self.analysis is None or self.display_scale <= 0:
            return None
        page = self.analysis.document.pages[self.page_index]
        canvas_width = max(1, self.canvas.winfo_width())
        canvas_height = max(1, self.canvas.winfo_height())
        center_x = self.canvas.canvasx(canvas_width / 2)
        center_y = self.canvas.canvasy(canvas_height / 2)
        offset_x, offset_y = self.display_offset
        page_x = (center_x - offset_x) / self.display_scale
        page_y = (center_y - offset_y) / self.display_scale
        return (
            min(max(page_x, 0.0), float(page.width)),
            min(max(page_y, 0.0), float(page.height)),
        )

    def _center_view_on_page_point(self, point: tuple[float, float]) -> None:
        canvas_width = max(1, self.canvas.winfo_width())
        canvas_height = max(1, self.canvas.winfo_height())
        offset_x, offset_y = self.display_offset
        target_x = offset_x + point[0] * self.display_scale
        target_y = offset_y + point[1] * self.display_scale
        content_width, content_height = self.display_content_size
        scrollable_x = max(0.0, content_width - canvas_width)
        scrollable_y = max(0.0, content_height - canvas_height)
        if scrollable_x:
            self.canvas.xview_moveto(min(1.0, max(0.0, (target_x - canvas_width / 2) / content_width)))
        else:
            self.canvas.xview_moveto(0.0)
        if scrollable_y:
            self.canvas.yview_moveto(min(1.0, max(0.0, (target_y - canvas_height / 2) / content_height)))
        else:
            self.canvas.yview_moveto(0.0)

    def draw_page(
        self,
        *,
        reset_view: bool = False,
        focus_point: tuple[float, float] | None = None,
    ) -> None:
        if focus_point is None and not reset_view:
            focus_point = self._viewport_center_page()
        self.canvas.delete("all")
        if self.analysis is None or not self.analysis.document.pages:
            self.canvas.configure(scrollregion=(0, 0, 1, 1))
            self.zoom_var.set("Масштаб: по размеру (100%)")
            return
        page = self.analysis.document.pages[self.page_index]
        canvas_width = max(1, self.canvas.winfo_width())
        canvas_height = max(1, self.canvas.winfo_height())
        fit_scale = min((canvas_width - 30) / page.width, (canvas_height - 30) / page.height, 1.0)
        scale = max(0.01, fit_scale * self.zoom_factor)
        width = max(1, int(page.width * scale))
        height = max(1, int(page.height * scale))
        preview = page.image.resize((width, height), Image.Resampling.LANCZOS)
        self.display_image = ImageTk.PhotoImage(preview)
        content_width = max(canvas_width, width + 30)
        content_height = max(canvas_height, height + 30)
        self.display_content_size = (float(content_width), float(content_height))
        offset_x = max(15.0, (content_width - width) / 2)
        offset_y = max(15.0, (content_height - height) / 2)
        self.display_scale = scale
        self.display_offset = (offset_x, offset_y)
        self.canvas.create_image(offset_x, offset_y, anchor="nw", image=self.display_image, tags=("page",))

        for finding in self._page_findings():
            box = finding.box.normalized()
            coords = (
                offset_x + box.x0 * scale,
                offset_y + box.y0 * scale,
                offset_x + box.x1 * scale,
                offset_y + box.y1 * scale,
            )
            color = RED if finding.action == Action.REDACT else AMBER
            if not finding.selected and finding.action == Action.REDACT:
                color = MUTED
            selected_here = finding.finding_id == self.selected_finding_id
            outline = SELECTION if selected_here else color
            line_width = 5 if selected_here else 3
            self.canvas.create_rectangle(
                *coords,
                outline=outline,
                width=line_width,
                dash=() if finding.selected else (5, 3),
                tags=("finding", finding.finding_id),
            )
            self.canvas.create_text(coords[0] + 4, coords[1] + 3, anchor="nw", text=finding.category, fill=color, font=("Helvetica Neue", 9, "bold"))
        self.canvas.configure(scrollregion=(0, 0, content_width, content_height))
        if reset_view:
            self.canvas.xview_moveto(0.0)
            self.canvas.yview_moveto(0.0)
        elif focus_point is not None:
            self._center_view_on_page_point(focus_point)
        if abs(self.zoom_factor - 1.0) < 0.01:
            self.zoom_var.set("Масштаб: по размеру (100%)")
        else:
            self.zoom_var.set(f"Масштаб: {round(self.zoom_factor * 100):d}% от размера окна")
        self._refresh_side_panel()

    def zoom_in(self) -> None:
        if self.analysis is None or self.busy:
            return
        next_factor = min(MAX_ZOOM_FACTOR, self.zoom_factor * ZOOM_STEP)
        if abs(next_factor - self.zoom_factor) < 0.001:
            return
        focus = self._viewport_center_page()
        self.zoom_factor = next_factor
        self.draw_page(focus_point=focus)
        self._update_controls()

    def zoom_out(self) -> None:
        if self.analysis is None or self.busy:
            return
        next_factor = max(MIN_ZOOM_FACTOR, self.zoom_factor / ZOOM_STEP)
        if abs(next_factor - self.zoom_factor) < 0.001:
            return
        focus = self._viewport_center_page()
        self.zoom_factor = next_factor
        self.draw_page(focus_point=focus)
        self._update_controls()

    def zoom_fit(self) -> None:
        if self.analysis is None or self.busy:
            return
        self.zoom_factor = 1.0
        self.draw_page(reset_view=True)
        self._update_controls()

    def on_canvas_mousewheel(self, event: tk.Event) -> str:
        if self.analysis is not None:
            direction = -1 if event.delta > 0 else 1
            self.canvas.yview_scroll(direction * 3, "units")
        return "break"

    def on_canvas_shift_mousewheel(self, event: tk.Event) -> str:
        if self.analysis is not None:
            direction = -1 if event.delta > 0 else 1
            self.canvas.xview_scroll(direction * 3, "units")
        return "break"

    def _refresh_side_panel(self) -> None:
        if self.analysis is None:
            return
        page_count = len(self.analysis.document.pages)
        confirmed = " ✓ подтверждена" if self.page_index in self.confirmed_pages else ""
        self.page_var.set(f"Страница {self.page_index + 1} из {page_count}{confirmed}")
        self.refreshing_tree = True
        for item in self.finding_tree.get_children():
            self.finding_tree.delete(item)
        for finding in self._page_findings():
            label = MIKA_NAVIGATION_CATEGORIES.get(finding.category, MIKA_NAVIGATION_CATEGORIES["manual"]).label_ru
            self.finding_tree.insert("", "end", iid=finding.finding_id, values=("да" if finding.selected else "нет", label))
        if self.selected_finding_id in self.finding_tree.get_children():
            self.finding_tree.selection_set(self.selected_finding_id)
            self.finding_tree.focus(self.selected_finding_id)
            self.finding_tree.see(self.selected_finding_id)
        self.refreshing_tree = False
        page_warnings = [
            warning for warning in self.analysis.warnings if warning.startswith(f"Страница {self.page_index + 1}:")
        ]
        if self.page_index in self.analysis.pages_requiring_manual_review:
            page_warnings.insert(0, "Повышенный риск: страницу необходимо проверить особенно внимательно.")
        self.warning_var.set("\n".join(dict.fromkeys(page_warnings)))
        self._update_selection_controls()

    def _finding_by_id(self, finding_id: str | None) -> Finding | None:
        if self.analysis is None or finding_id is None:
            return None
        return next((finding for finding in self.analysis.findings if finding.finding_id == finding_id), None)

    def _update_selection_controls(self) -> None:
        finding = self._finding_by_id(self.selected_finding_id)
        finding_on_page = finding is not None and finding.page_index == self.page_index
        controls_enabled = finding_on_page and not self.busy
        if not finding_on_page:
            self.selection_var.set("Область не выбрана")
        else:
            policy = MIKA_NAVIGATION_CATEGORIES.get(finding.category, MIKA_NAVIGATION_CATEGORIES["manual"])
            state = "будет удалена" if finding.selected else "не будет удалена"
            self.selection_var.set(f"Выбрано: {policy.label_ru} · {state}")
        self.toggle_selected_button.configure(
            state=tk.NORMAL if controls_enabled else tk.DISABLED,
            text=(
                "Не удалять выбранную область"
                if finding_on_page and finding.selected
                else "Удалять выбранную область"
            ),
        )
        can_delete = bool(
            controls_enabled
            and finding is not None
            and (finding.manually_added or finding.source == "manual")
        )
        self.delete_manual_button.configure(state=tk.NORMAL if can_delete else tk.DISABLED)
        has_manual_history = any(self._finding_by_id(finding_id) is not None for finding_id in self.manual_history)
        self.undo_manual_button.configure(
            state=tk.NORMAL if has_manual_history and not self.busy else tk.DISABLED
        )

    def _canvas_to_page(self, x: float, y: float) -> tuple[float, float] | None:
        if self.analysis is None or self.display_scale <= 0:
            return None
        page = self.analysis.document.pages[self.page_index]
        offset_x, offset_y = self.display_offset
        canvas_x = self.canvas.canvasx(x)
        canvas_y = self.canvas.canvasy(y)
        page_x = (canvas_x - offset_x) / self.display_scale
        page_y = (canvas_y - offset_y) / self.display_scale
        if not (0 <= page_x <= page.width and 0 <= page_y <= page.height):
            return None
        return page_x, page_y

    def on_canvas_press(self, event: tk.Event) -> None:
        point = self._canvas_to_page(event.x, event.y)
        if point is None:
            self.selected_finding_id = None
            self.draw_page()
            return
        self.drag_start = point
        canvas_x = self.canvas.canvasx(event.x)
        canvas_y = self.canvas.canvasy(event.y)
        self.drag_item = self.canvas.create_rectangle(
            canvas_x,
            canvas_y,
            canvas_x,
            canvas_y,
            outline=SELECTION,
            width=3,
            dash=(4, 3),
        )

    def on_canvas_drag(self, event: tk.Event) -> None:
        if self.drag_start is None or self.drag_item is None:
            return
        offset_x, offset_y = self.display_offset
        start_x = offset_x + self.drag_start[0] * self.display_scale
        start_y = offset_y + self.drag_start[1] * self.display_scale
        self.canvas.coords(
            self.drag_item,
            start_x,
            start_y,
            self.canvas.canvasx(event.x),
            self.canvas.canvasy(event.y),
        )

    def on_canvas_release(self, event: tk.Event) -> None:
        if self.analysis is None or self.drag_start is None:
            return
        end = self._canvas_to_page(event.x, event.y)
        if self.drag_item is not None:
            self.canvas.delete(self.drag_item)
        start = self.drag_start
        self.drag_start = None
        self.drag_item = None
        if end is None:
            return
        dx = abs(end[0] - start[0])
        dy = abs(end[1] - start[1])
        if dx < 6 and dy < 6:
            for finding in reversed(self._page_findings()):
                if finding.box.contains_point(*end):
                    self.selected_finding_id = finding.finding_id
                    self.draw_page()
                    self._update_controls()
                    return
            self.selected_finding_id = None
            self.draw_page()
            self._update_controls()
            return
        box = Box(start[0], start[1], end[0], end[1]).normalized()
        self.manual_sequence += 1
        material = (
            f"manual:{self.manual_sequence}:{self.page_index}:"
            f"{box.x0:.1f}:{box.y0:.1f}:{box.x1:.1f}:{box.y1:.1f}"
        )
        finding = Finding(
            finding_id=hashlib.sha256(material.encode("utf-8")).hexdigest()[:16],
            page_index=self.page_index,
            category="manual",
            box=box,
            action=Action.REDACT,
            severity=Severity.CRITICAL,
            confidence=100.0,
            source="manual",
            reason="Область добавлена пользователем",
            manually_added=True,
        )
        self.analysis.findings.append(finding)
        self.manual_history.append(finding.finding_id)
        self.selected_finding_id = finding.finding_id
        self.confirmed_pages.discard(self.page_index)
        self.status_var.set("Ручная область добавлена. Если это случайность, нажмите ⌘Z или «Отменить последнее».")
        self.draw_page()
        self._update_controls()

    def select_tree_finding(self, _event: tk.Event) -> None:
        if self.refreshing_tree:
            return
        selected = self.finding_tree.selection()
        next_finding_id = selected[0] if selected else None
        if next_finding_id == self.selected_finding_id:
            self._update_selection_controls()
            return
        self.selected_finding_id = next_finding_id
        self.draw_page()
        self._update_controls()

    def toggle_tree_finding(self, _event: tk.Event) -> None:
        selected = self.finding_tree.selection()
        if not selected:
            return
        self.selected_finding_id = selected[0]
        self.toggle_selected_finding()

    def toggle_selected_finding(self) -> None:
        finding = self._finding_by_id(self.selected_finding_id)
        if finding is None or finding.page_index != self.page_index or self.busy:
            return
        finding.selected = not finding.selected
        self.confirmed_pages.discard(finding.page_index)
        action = "будет удалена" if finding.selected else "исключена из удаления"
        self.status_var.set(f"Выбранная область {action}. Проверьте и подтвердите страницу повторно.")
        self.draw_page()
        self._update_controls()

    def delete_selected_manual(self) -> None:
        finding = self._finding_by_id(self.selected_finding_id)
        if (
            self.analysis is None
            or finding is None
            or self.busy
            or not (finding.manually_added or finding.source == "manual")
        ):
            return
        page_index = finding.page_index
        page_changed = self.page_index != page_index
        self.analysis.findings.remove(finding)
        self.selected_finding_id = None
        self.confirmed_pages.discard(page_index)
        self.page_index = page_index
        self.status_var.set("Ручная область удалена. Проверьте и подтвердите страницу повторно.")
        self.draw_page(reset_view=page_changed)
        self._update_controls()

    def undo_last_manual(self) -> None:
        if self.analysis is None or self.busy:
            return
        while self.manual_history:
            finding_id = self.manual_history.pop()
            finding = self._finding_by_id(finding_id)
            if finding is None:
                continue
            page_index = finding.page_index
            page_changed = self.page_index != page_index
            self.analysis.findings.remove(finding)
            self.selected_finding_id = None
            self.confirmed_pages.discard(page_index)
            self.page_index = page_index
            self.status_var.set("Последнее ручное выделение отменено.")
            self.draw_page(reset_view=page_changed)
            self._update_controls()
            return

    def _undo_shortcut(self, _event: tk.Event) -> str:
        self.undo_last_manual()
        return "break"

    def _delete_shortcut(self, _event: tk.Event) -> str:
        self.delete_selected_manual()
        return "break"

    def _zoom_in_shortcut(self, _event: tk.Event) -> str:
        self.zoom_in()
        return "break"

    def _zoom_out_shortcut(self, _event: tk.Event) -> str:
        self.zoom_out()
        return "break"

    def _zoom_fit_shortcut(self, _event: tk.Event) -> str:
        self.zoom_fit()
        return "break"

    def confirm_page(self) -> None:
        if self.analysis is None:
            return
        self.confirmed_pages.add(self.page_index)
        self.draw_page()
        self._update_controls()

    def previous_page(self) -> None:
        if self.analysis is not None and self.page_index > 0:
            self.page_index -= 1
            self.selected_finding_id = None
            self.draw_page(reset_view=True)
            self._update_controls()

    def next_page(self) -> None:
        if self.analysis is not None and self.page_index + 1 < len(self.analysis.document.pages):
            self.page_index += 1
            self.selected_finding_id = None
            self.draw_page(reset_view=True)
            self._update_controls()

    def choose_output_path(self) -> str | None:
        if self.analysis is None or self.busy:
            return None
        output = filedialog.asksaveasfilename(
            title="Выберите папку и имя новой очищенной копии",
            defaultextension=".pdf",
            initialfile="MIKA_cleaned_copy.pdf",
            confirmoverwrite=False,
            filetypes=[("PDF", "*.pdf")],
        )
        if not output:
            return None
        self.pending_output_path = output
        self.output_path_var.set(
            f"Выбрано: {_short_display_path(output)}\nСуществующий файл не будет перезаписан."
        )
        self.status_var.set("Путь очищенной копии выбран. Завершите проверку страниц и создайте копию.")
        self._update_controls()
        return output

    def _open_local_path(self, path: Path, *, reveal: bool = False) -> None:
        if not path.exists():
            messagebox.showerror("Файл не найден", "Очищенная копия больше не находится по сохранённому пути.")
            return
        try:
            if sys.platform == "darwin":
                command = ["/usr/bin/open", "-R", str(path)] if reveal else ["/usr/bin/open", str(path)]
                completed = subprocess.run(command, capture_output=True, text=True, timeout=10, check=False)
                if completed.returncode:
                    detail = completed.stderr.strip() or f"Код завершения: {completed.returncode}"
                    raise OSError(detail)
            elif os.name == "nt":
                if reveal:
                    subprocess.run(["explorer", "/select,", str(path)], check=False, timeout=10)
                else:
                    os.startfile(path)  # type: ignore[attr-defined]
            else:
                target = path.parent if reveal else path
                completed = subprocess.run(
                    ["xdg-open", str(target)],
                    capture_output=True,
                    text=True,
                    timeout=10,
                    check=False,
                )
                if completed.returncode:
                    detail = completed.stderr.strip() or f"Код завершения: {completed.returncode}"
                    raise OSError(detail)
        except (OSError, ValueError, subprocess.SubprocessError) as exc:
            self.status_var.set("Не удалось открыть локальный файл")
            messagebox.showerror("Не удалось открыть файл", f"{type(exc).__name__}: {exc}")
            return
        action = "Показываем копию в папке" if reveal else "Открываем копию в программе просмотра PDF"
        self.status_var.set(f"{action}: {_short_display_path(path)}")

    def _output_path_for_action(self, *, reveal: bool) -> Path | None:
        if self.busy:
            return None
        if self.last_output_path is not None and self.last_output_path.exists():
            return self.last_output_path
        if self.last_output_path is not None:
            self.last_output_path = None
            messagebox.showwarning(
                "Копия перемещена или удалена",
                "Последняя созданная копия не найдена. Выберите готовый PDF вручную.",
            )
        selected = filedialog.askopenfilename(
            title=(
                "Выберите готовую копию PDF, которую нужно показать в папке"
                if reveal
                else "Выберите готовую копию PDF, которую нужно открыть"
            ),
            filetypes=[("PDF", "*.pdf")],
        )
        if not selected:
            self.status_var.set("Открытие копии отменено")
            return None
        path = Path(selected)
        self.last_output_path = path
        self.output_path_var.set(
            f"Выбран PDF: {_short_display_path(path)}\nФайл не изменяется; он открыт только для проверки."
        )
        self._update_controls()
        return path

    def open_last_output(self) -> None:
        path = self._output_path_for_action(reveal=False)
        if path is not None:
            self._open_local_path(path)

    def reveal_last_output(self) -> None:
        path = self._output_path_for_action(reveal=True)
        if path is not None:
            self._open_local_path(path, reveal=True)

    def save_copy(self) -> None:
        if self.analysis is None or self.busy:
            return
        if len(self.confirmed_pages) != len(self.analysis.document.pages):
            messagebox.showwarning("Проверка не завершена", "Сначала подтвердите каждую страницу документа.")
            return
        output = self.pending_output_path
        if output is None:
            output = self.choose_output_path()
        if output is None:
            return
        self._start_operation(
            "Создание и независимая проверка новой копии...",
            determinate=False,
            cancellable=False,
        )

        def work() -> None:
            try:
                result_path, report_path, verification = create_verified_copy(
                    self.analysis,
                    output,
                    confirmed_pages=set(self.confirmed_pages),
                    ocr=self.ocr,
                )
            except ResidualFindingsForReview as exc:
                self.worker_events.put(("save_residuals", exc))
            except Exception as exc:
                self.worker_events.put(("save_failed", exc))
            else:
                self.worker_events.put(("save_ready", (result_path, report_path, verification)))

        threading.Thread(target=work, daemon=True).start()

    def _save_failed(self, exc: Exception) -> None:
        self.pending_output_path = None
        self.output_path_var.set("Копия не создана. Выберите новый путь и имя файла для следующей попытки.")
        self.save_button.configure(text=SAVE_BUTTON_TEXT)
        self.set_busy(False, "Копия не выпущена")
        detail = str(exc) if isinstance(exc, ReleaseBlockedError) else f"{type(exc).__name__}: {exc}"
        messagebox.showerror("Итоговая проверка не пройдена", detail)

    def _save_residuals_detected(self, payload: object) -> None:
        if not isinstance(payload, ResidualFindingsForReview) or self.analysis is None:
            self._save_failed(RuntimeError("Не удалось вернуть дополнительные области на проверку"))
            return

        affected_pages: set[int] = set()
        review_findings: list[Finding] = []
        for incoming in payload.findings:
            match = next(
                (
                    existing
                    for existing in self.analysis.findings
                    if existing.source == "verification"
                    and existing.page_index == incoming.page_index
                    and existing.category == incoming.category
                    and existing.box.overlap_ratio(incoming.box) >= 0.5
                ),
                None,
            )
            if match is None:
                self.analysis.findings.append(incoming)
                match = incoming
            else:
                match.box = match.box.union(incoming.box)
                match.selected = True
                match.confidence = max(match.confidence, incoming.confidence)
            affected_pages.add(match.page_index)
            review_findings.append(match)

        if not affected_pages:
            self._save_failed(RuntimeError("Финальная проверка не вернула координаты найденных областей"))
            return

        self.confirmed_pages.difference_update(affected_pages)
        self.analysis.pages_requiring_manual_review.update(affected_pages)
        for page_index in sorted(affected_pages):
            warning = (
                f"Страница {page_index + 1}: финальная проверка добавила область к удалению; "
                "проверьте её и подтвердите страницу повторно"
            )
            if warning not in self.analysis.warnings:
                self.analysis.warnings.append(warning)

        self.page_index = min(affected_pages)
        first_review_finding = min(
            review_findings,
            key=lambda finding: (finding.page_index, finding.box.y0, finding.box.x0),
        )
        self.selected_finding_id = first_review_finding.finding_id
        self.save_button.configure(text=SAVE_RETRY_BUTTON_TEXT)
        self.set_busy(
            False,
            f"Финальная проверка добавила областей: {len(review_findings)}. Проверьте отмеченные страницы.",
        )
        focus_box = first_review_finding.box.normalized()
        self.draw_page(
            focus_point=((focus_box.x0 + focus_box.x1) / 2, (focus_box.y0 + focus_box.y1) / 2)
        )
        self._update_controls()

        category_counts = Counter(finding.category for finding in review_findings)
        category_lines = []
        for category, count in sorted(category_counts.items()):
            policy = MIKA_NAVIGATION_CATEGORIES.get(category, MIKA_NAVIGATION_CATEGORIES["manual"])
            category_lines.append(f"• {policy.label_ru}: {count}")
        pages_text = ", ".join(str(index + 1) for index in sorted(affected_pages))
        messagebox.showwarning(
            "Найдены дополнительные области",
            (
                "Во время финальной проверки обнаружены дополнительные кандидаты на удаление.\n\n"
                + "\n".join(category_lines)
                + f"\n\nСтраницы для повторной проверки: {pages_text}.\n\n"
                "Области уже добавлены красным и выбраны к удалению, но итоговый файл пока не сохранён. "
                "Проверьте их визуально, повторно подтвердите отмеченные страницы и нажмите "
                "«Создать и повторно проверить копию»."
            ),
        )

    def _save_ready(self, result_path: Path, report_path: Path, verification) -> None:
        verification_added = [
            finding
            for finding in self.analysis.findings
            if finding.source == "verification" and finding.selected
        ] if self.analysis is not None else []
        category_counts = Counter(finding.category for finding in verification_added)
        additional_summary = ""
        if verification_added:
            labels = []
            for category, count in sorted(category_counts.items()):
                policy = MIKA_NAVIGATION_CATEGORIES.get(category, MIKA_NAVIGATION_CATEGORIES["manual"])
                labels.append(f"{policy.label_ru}: {count}")
            additional_summary = (
                "\n\nДополнительно выявлено при финальной проверке, подтверждено пользователем и удалено: "
                + "; ".join(labels)
                + "."
            )
        self.pending_output_path = None
        self.last_output_path = Path(result_path)
        self.last_report_path = Path(report_path)
        self.output_path_var.set(
            f"Готово: {_short_display_path(result_path)}\nКопию можно открыть для дополнительной проверки."
        )
        self.save_button.configure(text=SAVE_BUTTON_TEXT)
        self.set_busy(False, "Очищенная копия создана и проверена")
        messagebox.showinfo(
            "Готово",
            (
                f"Создана новая очищенная копия:\n{result_path}\n\n"
                f"Отчёт проверки:\n{report_path}\n\n"
                f"Страниц: {verification.page_count}. Исходный документ не изменён."
                f"{additional_summary}"
            ),
        )

    def _update_controls(self) -> None:
        has_document = self.analysis is not None
        state = tk.DISABLED if self.busy else tk.NORMAL
        self.open_button.configure(state=state)
        self.prev_button.configure(state=tk.NORMAL if has_document and not self.busy and self.page_index > 0 else tk.DISABLED)
        page_count = len(self.analysis.document.pages) if has_document else 0
        self.next_button.configure(
            state=tk.NORMAL if has_document and not self.busy and self.page_index + 1 < page_count else tk.DISABLED
        )
        self.confirm_button.configure(state=tk.NORMAL if has_document and not self.busy else tk.DISABLED)
        self.zoom_out_button.configure(
            state=(
                tk.NORMAL
                if has_document and not self.busy and self.zoom_factor > MIN_ZOOM_FACTOR + 0.001
                else tk.DISABLED
            )
        )
        self.zoom_in_button.configure(
            state=(
                tk.NORMAL
                if has_document and not self.busy and self.zoom_factor < MAX_ZOOM_FACTOR - 0.001
                else tk.DISABLED
            )
        )
        self.fit_button.configure(state=tk.NORMAL if has_document and not self.busy else tk.DISABLED)
        self.choose_output_button.configure(state=tk.NORMAL if has_document and not self.busy else tk.DISABLED)
        output_action_state = tk.DISABLED if self.busy else tk.NORMAL
        self.open_output_button.configure(state=output_action_state)
        self.reveal_output_button.configure(state=output_action_state)
        can_save = (
            has_document
            and not self.busy
            and len(self.confirmed_pages) == page_count
            and self.analysis.capabilities.complete
        )
        self.save_button.configure(state=tk.NORMAL if can_save else tk.DISABLED)
        self._update_selection_controls()


def main() -> None:
    root = tk.Tk()
    style = ttk.Style(root)
    try:
        style.theme_use("clam")
    except tk.TclError:
        pass
    app = ReviewApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
