"""
Главное окно приложения DSP Scanner.

Аудит v3 (реализация рекомендаций отчёта по отладке):
    1. Автопереключение на вкладку "Результаты" (`setCurrentIndex(0)`)
       при старте сканирования, чтобы пользователь сразу видел совпадения.
    2. Добавлена подробная диагностика в журнал при старте сканирования
       (пути, ключевые слова, выбранные типы, статус OCR).
    3. Компактный список обрабатываемых файлов (`processing_list`, макс. 50 шт.)
       размещён ВЫШЕ прогресс-бара, чтобы наглядно видеть процесс в реальном
       времени.
    4. Статус-метка (`progress_label`) сделана фиксированной по структуре:
       только `Обработано: X / Y`, что исключает скачки ширины.
    5. Добавлена кнопка "🔍 Проверить 1 файл…" для быстрой изоляции и
       диагностики проблем чтения конкретного документа минуя настройки
       поиска.
    6. Кнопка "Стоп" визуально меняет состояние на красное во время
       активного сканирования (через QSS `[active="true"]`).
    7. Если совпадений 0, всплывает информативное окно с возможными причинами
       и рекомендациями.
"""
from __future__ import annotations

import json
import logging
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import List, Optional

from PySide6.QtCore import Qt, QSortFilterProxyModel, QSettings, QUrl
from PySide6.QtGui import QAction, QDesktopServices, QIcon, QPixmap, QPainter, QClipboard
from PySide6.QtWidgets import (
    QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QTabWidget,
    QGroupBox, QListWidget, QPushButton, QLabel, QLineEdit, QTextEdit,
    QCheckBox, QComboBox, QSpinBox, QDoubleSpinBox, QProgressBar, QTableView,
    QAbstractItemView, QFileDialog, QMessageBox, QPlainTextEdit, QSplitter,
    QHeaderView, QStatusBar, QListWidgetItem, QMenu, QDialog, QDialogButtonBox,
    QApplication,
)

from app.config import (
    DocReadMethod, ScanSettings, SUPPORTED_EXTENSIONS, DEFAULT_COPY_DESTINATION,
    REPORTS_DIR, SCAN_CONFIG_FILE, ScanReport,
)
from app.gui.results_model import ResultsTableModel, HighlightDelegate
from app.gui.workers import ScanWorker, CopyWorker, SecureOpWorker
from app.gui.settings_dialog import SettingsDialog
from app.fileops.file_operations import CopyMapping, detect_removed_copies
from app.export.excel_export import export_to_excel
from app.export.text_exports import export_to_csv, export_to_markdown
from app.readers import extract_text
from app.readers.doc_reader import _release_word_all


def _make_app_icon() -> QIcon:
    """Создаёт иконку приложения из встроенного SVG (лупа на градиенте)."""
    svg = (
        '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
        '<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
        '<stop offset="0" stop-color="#4C8DFF"/><stop offset="1" stop-color="#7B4CFF"/>'
        '</linearGradient></defs>'
        '<rect width="64" height="64" rx="14" fill="url(#g)"/>'
        '<circle cx="28" cy="28" r="11" fill="none" stroke="white" stroke-width="4"/>'
        '<line x1="36" y1="36" x2="46" y2="46" stroke="white" stroke-width="4" stroke-linecap="round"/>'
        '</svg>'
    )
    from PySide6.QtSvg import QSvgRenderer
    pixmap = QPixmap(64, 64)
    pixmap.fill(Qt.transparent)
    renderer = QSvgRenderer(svg.encode())
    painter = QPainter(pixmap)
    renderer.render(painter)
    painter.end()
    return QIcon(pixmap)


class StatCard(QWidget):
    """Карточка статистики с фиксированными размерами."""

    def __init__(self, label: str, parent=None):
        super().__init__(parent)
        self.setObjectName("statCard")
        layout = QVBoxLayout(self)
        layout.setContentsMargins(12, 8, 12, 8)
        layout.setAlignment(Qt.AlignCenter)
        self.value_label = QLabel("0")
        self.value_label.setObjectName("cardValue")
        self.value_label.setAlignment(Qt.AlignCenter)
        caption = QLabel(label)
        caption.setObjectName("cardLabel")
        caption.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.value_label)
        layout.addWidget(caption)
        self.setFixedSize(110, 64)

    def set_value(self, value) -> None:
        self.value_label.setText(str(value))


class MainWindow(QMainWindow):
    def __init__(self, logger: logging.Logger, qt_log_handler, parent=None):
        super().__init__(parent)
        self.logger = logger
        self.qt_log_handler = qt_log_handler
        self.settings_store = QSettings("DSPScanner", "DSPScanner")

        self.setWindowTitle("DSP Scanner — поиск текста в документах")
        self.resize(1400, 880)
        self.setMinimumSize(1100, 700)

        icon = _make_app_icon()
        self.setWindowIcon(icon)
        QApplication.setWindowIcon(icon)

        self.results_model = ResultsTableModel(self)
        self.proxy_model = QSortFilterProxyModel(self)
        self.proxy_model.setSourceModel(self.results_model)
        self.proxy_model.setFilterKeyColumn(-1)
        self.proxy_model.setFilterCaseSensitivity(Qt.CaseInsensitive)
        self.proxy_model.setDynamicSortFilter(False)

        self.scan_worker: Optional[ScanWorker] = None
        self.copy_mappings: List[CopyMapping] = []
        self.last_report: Optional[ScanReport] = None

        self._build_menu()
        self._build_ui()
        self._connect_log_handler()
        self._restore_settings()

    # ------------------------------------------------------------------ #
    # Меню
    # ------------------------------------------------------------------ #
    def _build_menu(self) -> None:
        menubar = self.menuBar()

        # Файл
        file_menu = menubar.addMenu("&Файл")

        save_cfg_action = QAction("Сохранить конфигурацию поиска…", self)
        save_cfg_action.setShortcut("Ctrl+S")
        save_cfg_action.triggered.connect(self._save_scan_config)
        file_menu.addAction(save_cfg_action)

        load_cfg_action = QAction("Загрузить конфигурацию поиска…", self)
        load_cfg_action.setShortcut("Ctrl+O")
        load_cfg_action.triggered.connect(self._load_scan_config)
        file_menu.addAction(load_cfg_action)

        file_menu.addSeparator()

        test_action = QAction("Проверить 1 файл (быстрая диагностика)…", self)
        test_action.setShortcut("Ctrl+T")
        test_action.triggered.connect(self._test_single_file)
        file_menu.addAction(test_action)

        file_menu.addSeparator()

        exit_action = QAction("Выход", self)
        exit_action.setShortcut("Ctrl+Q")
        exit_action.triggered.connect(self.close)
        file_menu.addAction(exit_action)

        # Настройки
        settings_menu = menubar.addMenu("&Настройки")
        params_action = QAction("Параметры…", self)
        params_action.triggered.connect(self._open_settings)
        settings_menu.addAction(params_action)

        # Справка
        help_menu = menubar.addMenu("&Справка")
        about_action = QAction("О программе", self)
        about_action.triggered.connect(self._show_about)
        help_menu.addAction(about_action)

    # ------------------------------------------------------------------ #
    # UI
    # ------------------------------------------------------------------ #
    def _build_ui(self) -> None:
        central = QWidget()
        central.setObjectName("centralWidget")
        root_layout = QVBoxLayout(central)
        root_layout.setContentsMargins(10, 10, 10, 6)
        root_layout.setSpacing(8)

        # --- Блок мониторинга: список файлов + прогресс-бар ---
        monitor_box = QWidget()
        monitor_layout = QVBoxLayout(monitor_box)
        monitor_layout.setContentsMargins(0, 0, 0, 0)
        monitor_layout.setSpacing(6)

        # Список обрабатываемых файлов (макс 50 шт.)
        self.processing_list = QListWidget()
        self.processing_list.setMaximumHeight(68)  # 3 строки
        self.processing_list.setSelectionMode(QAbstractItemView.NoSelection)
        self.processing_list.setFocusPolicy(Qt.NoFocus)
        monitor_layout.addWidget(self.processing_list)

        # Прогресс-бар и Стоп
        progress_row = QHBoxLayout()
        progress_row.setSpacing(8)
        self.progress_bar = QProgressBar()
        self.progress_bar.setValue(0)
        self.progress_bar.setTextVisible(True)
        self.progress_bar.setFormat("%v / %m  (%p%)")
        self.progress_bar.setMinimumWidth(400)
        progress_row.addWidget(self.progress_bar, 1)

        self.progress_label = QLabel("Обработано: 0 / 0")
        self.progress_label.setFixedWidth(200)
        self.progress_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
        progress_row.addWidget(self.progress_label)

        self.stop_btn = QPushButton("■ Стоп")
        self.stop_btn.setObjectName("stopButton")
        self.stop_btn.setProperty("active", False)
        self.stop_btn.setEnabled(False)
        self.stop_btn.clicked.connect(self._cancel_scan)
        self.stop_btn.setFixedWidth(100)
        progress_row.addWidget(self.stop_btn)
        monitor_layout.addLayout(progress_row)

        root_layout.addWidget(monitor_box)

        # --- Сплиттер: слева настройки, справа вкладки ---
        splitter = QSplitter(Qt.Horizontal)
        splitter.setHandleWidth(4)
        splitter.addWidget(self._build_left_panel())
        splitter.addWidget(self._build_right_panel())
        splitter.setStretchFactor(0, 0)
        splitter.setStretchFactor(1, 1)
        splitter.setSizes([440, 960])
        root_layout.addWidget(splitter, 1)

        self.setCentralWidget(central)

        # --- Статус-бар ---
        self.status_bar = QStatusBar()
        self.setStatusBar(self.status_bar)
        self.status_bar.showMessage("Готово")

    # -- Левая панель (настройки + статистика + кнопки) ------------------- #
    def _build_left_panel(self) -> QWidget:
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(8)

        # Пути
        paths_box = QGroupBox("Пути для поиска")
        paths_layout = QVBoxLayout(paths_box)
        self.paths_list = QListWidget()
        self.paths_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.paths_list.setMaximumHeight(110)
        paths_layout.addWidget(self.paths_list)
        btn_row = QHBoxLayout()
        add_folder_btn = QPushButton("+ Папка")
        add_file_btn = QPushButton("+ Файл")
        remove_btn = QPushButton("Удалить")
        add_folder_btn.clicked.connect(self._add_folder)
        add_file_btn.clicked.connect(self._add_file)
        remove_btn.clicked.connect(self._remove_selected_paths)
        for b in (add_folder_btn, add_file_btn, remove_btn):
            b.setFixedHeight(28)
        btn_row.addWidget(add_folder_btn)
        btn_row.addWidget(add_file_btn)
        btn_row.addWidget(remove_btn)
        paths_layout.addLayout(btn_row)
        layout.addWidget(paths_box)

        # Слова
        words_box = QGroupBox("Ключевые слова / фразы (по одному в строке или через запятую)")
        words_layout = QVBoxLayout(words_box)
        self.words_edit = QTextEdit()
        self.words_edit.setPlaceholderText("например:\nдоговор\nконфиденциально\nоплата")
        self.words_edit.setMaximumHeight(110)
        words_layout.addWidget(self.words_edit)
        layout.addWidget(words_box)

        # Параметры
        options_box = QGroupBox("Параметры")
        options_layout = QGridLayout(options_box)

        self.type_checks = {}
        types_row = QHBoxLayout()
        for ext in SUPPORTED_EXTENSIONS:
            cb = QCheckBox(ext.lstrip("."))
            cb.setChecked(True)
            self.type_checks[ext] = cb
            types_row.addWidget(cb)
        options_layout.addWidget(QLabel("Типы:"), 0, 0)
        options_layout.addLayout(types_row, 0, 1)

        options_layout.addWidget(QLabel("Метод .doc:"), 1, 0)
        self.doc_method_combo = QComboBox()
        for method in DocReadMethod:
            self.doc_method_combo.addItem(method.label, method)
        options_layout.addWidget(self.doc_method_combo, 1, 1)

        self.case_sensitive_check = QCheckBox("Учитывать регистр")
        self.whole_word_check = QCheckBox("Целые слова")
        options_layout.addWidget(self.case_sensitive_check, 2, 0)
        options_layout.addWidget(self.whole_word_check, 2, 1)

        self.ocr_check = QCheckBox("OCR для PDF (Tesseract)")
        self.ocr_check.setChecked(True)  # Включён по умолчанию
        options_layout.addWidget(self.ocr_check, 3, 0, 1, 2)

        options_layout.addWidget(QLabel("Макс. размер (МБ):"), 4, 0)
        self.max_size_spin = QDoubleSpinBox()
        self.max_size_spin.setRange(1, 5000)
        self.max_size_spin.setValue(200)
        options_layout.addWidget(self.max_size_spin, 4, 1)

        options_layout.addWidget(QLabel("Символов контекста:"), 5, 0)
        self.context_spin = QSpinBox()
        self.context_spin.setRange(20, 1000)
        self.context_spin.setValue(150)
        options_layout.addWidget(self.context_spin, 5, 1)

        layout.addWidget(options_box)

        # Кнопки запуска и быстрой диагностики
        actions_row = QHBoxLayout()
        actions_row.setSpacing(8)

        self.start_btn = QPushButton("▶ Начать поиск")
        self.start_btn.setObjectName("primaryButton")
        self.start_btn.clicked.connect(self._start_scan)
        self.start_btn.setMinimumHeight(38)

        self.test_file_btn = QPushButton("🔍 Проверить 1 файл…")
        self.test_file_btn.setToolTip("Быстрая проверка извлечения текста из одного конкретного документа")
        self.test_file_btn.clicked.connect(self._test_single_file)
        self.test_file_btn.setMinimumHeight(38)

        actions_row.addWidget(self.start_btn, 5)
        actions_row.addWidget(self.test_file_btn, 4)
        layout.addLayout(actions_row)

        # Статистика
        stats_box = QGroupBox("Статистика последнего сканирования")
        stats_layout = QGridLayout(stats_box)
        self.stat_cards = {}
        stat_names = [
            ("processed", "Обработано"), ("found", "Совпадений"),
            ("docx_count", "DOCX"), ("doc_count", "DOC"),
            ("txt_count", "TXT"), ("pdf_count", "PDF"),
            ("errors", "Ошибок"), ("locked", "Заблокир."),
        ]
        for i, (key, label) in enumerate(stat_names):
            card = StatCard(label)
            self.stat_cards[key] = card
            stats_layout.addWidget(card, i // 4, i % 4)
        layout.addWidget(stats_box)

        layout.addStretch(1)
        return widget

    # -- Правая панель (вкладки) ------------------------------------------ #
    def _build_right_panel(self) -> QWidget:
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(0, 0, 0, 0)

        self.tabs = QTabWidget()
        self.tabs.addTab(self._build_results_tab(), "Результаты")
        self.tabs.addTab(self._build_files_tab(), "Файлы")
        self.tabs.addTab(self._build_log_tab(), "Журнал")
        layout.addWidget(self.tabs)
        return widget

    # -- Вкладка: Результаты ----------------------------------------------- #
    def _build_results_tab(self) -> QWidget:
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(8, 8, 8, 8)

        filter_row = QHBoxLayout()
        self.filter_edit = QLineEdit()
        self.filter_edit.setPlaceholderText("Фильтр по любому столбцу…")
        self.filter_edit.textChanged.connect(self.proxy_model.setFilterFixedString)
        filter_row.addWidget(self.filter_edit)
        layout.addLayout(filter_row)

        self.results_table = QTableView()
        self.results_table.setModel(self.proxy_model)
        self.results_table.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.results_table.setSortingEnabled(True)
        self.results_table.setAlternatingRowColors(True)
        self.results_table.verticalHeader().setVisible(False)
        self.results_table.verticalHeader().setDefaultSectionSize(24)
        self.results_table.setWordWrap(False)

        header = self.results_table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.Interactive)
        header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
        header.setSectionResizeMode(5, QHeaderView.Stretch)
        self.results_table.setColumnWidth(2, 480)

        self.results_table.setItemDelegateForColumn(2, HighlightDelegate())

        self.results_table.doubleClicked.connect(self._open_selected_result)
        self.results_table.setContextMenuPolicy(Qt.CustomContextMenu)
        self.results_table.customContextMenuRequested.connect(self._show_results_context_menu)
        layout.addWidget(self.results_table, 1)

        export_row = QHBoxLayout()
        export_excel_btn = QPushButton("Экспорт в Excel")
        export_csv_btn = QPushButton("Экспорт в CSV")
        export_md_btn = QPushButton("Экспорт в Markdown")
        open_folder_btn = QPushButton("Открыть папку файла")
        export_excel_btn.clicked.connect(self._export_excel)
        export_csv_btn.clicked.connect(self._export_csv)
        export_md_btn.clicked.connect(self._export_markdown)
        open_folder_btn.clicked.connect(self._open_selected_folder)
        for b in (export_excel_btn, export_csv_btn, export_md_btn, open_folder_btn):
            b.setFixedHeight(30)
            export_row.addWidget(b)
        export_row.addStretch(1)
        layout.addLayout(export_row)
        return widget

    # -- Вкладка: Файлы --------------------------------------------------- #
    def _build_files_tab(self) -> QWidget:
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(8, 8, 8, 8)

        copy_box = QGroupBox("Копирование найденных файлов")
        copy_layout = QHBoxLayout(copy_box)
        self.copy_dest_edit = QLineEdit(str(DEFAULT_COPY_DESTINATION))
        browse_btn = QPushButton("Обзор…")
        browse_btn.clicked.connect(self._browse_copy_dest)
        copy_btn = QPushButton("Скопировать найденные файлы")
        copy_btn.setObjectName("primaryButton")
        copy_btn.clicked.connect(self._copy_found_files)
        copy_layout.addWidget(QLabel("Папка:"))
        copy_layout.addWidget(self.copy_dest_edit, 1)
        copy_layout.addWidget(browse_btn)
        copy_layout.addWidget(copy_btn)
        layout.addWidget(copy_box)

        info_label = QLabel(
            "Подсказка: после копирования удалите из папки назначения файлы, чьи ОРИГИНАЛЫ "
            "нужно безопасно удалить или переместить, затем нажмите «Проверить удалённые копии»."
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet("color: #9AA3B2; padding: 6px;")
        layout.addWidget(info_label)

        check_row = QHBoxLayout()
        check_btn = QPushButton("Проверить удалённые копии")
        check_btn.clicked.connect(self._check_removed_copies)
        open_dest_btn = QPushButton("Открыть папку назначения")
        open_dest_btn.clicked.connect(lambda: self._open_path(self.copy_dest_edit.text()))
        check_row.addWidget(check_btn)
        check_row.addWidget(open_dest_btn)
        check_row.addStretch(1)
        layout.addLayout(check_row)

        layout.addWidget(QLabel("Оригиналы, чьи копии были удалены из папки назначения:"))
        self.pending_removal_list = QListWidget()
        self.pending_removal_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        layout.addWidget(self.pending_removal_list, 1)

        secure_row = QHBoxLayout()
        secure_delete_btn = QPushButton("Безопасно удалить оригиналы")
        secure_delete_btn.setObjectName("dangerButton")
        secure_delete_btn.clicked.connect(self._secure_delete_selected)
        secure_move_btn = QPushButton("Безопасно переместить оригиналы…")
        secure_move_btn.clicked.connect(self._secure_move_selected)
        secure_row.addWidget(secure_delete_btn)
        secure_row.addWidget(secure_move_btn)
        secure_row.addStretch(1)
        layout.addLayout(secure_row)
        return widget

    # -- Вкладка: Журнал --------------------------------------------------- #
    def _build_log_tab(self) -> QWidget:
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setContentsMargins(8, 8, 8, 8)
        self.log_view = QPlainTextEdit()
        self.log_view.setReadOnly(True)
        self.log_view.setMaximumBlockCount(5000)
        layout.addWidget(self.log_view, 1)

        btn_row = QHBoxLayout()
        clear_btn = QPushButton("Очистить журнал")
        clear_btn.clicked.connect(self.log_view.clear)
        btn_row.addWidget(clear_btn)
        btn_row.addStretch(1)
        layout.addLayout(btn_row)
        return widget

    # ------------------------------------------------------------------ #
    # Логирование
    # ------------------------------------------------------------------ #
    def _connect_log_handler(self) -> None:
        self.qt_log_handler.new_record.connect(self._append_log)

    def _append_log(self, level: str, message: str) -> None:
        color = {"ERROR": "#E5484D", "CRITICAL": "#E5484D", "WARNING": "#F2B84B"}.get(level, "#9AA3B2")
        self.log_view.appendHtml(f'<span style="color:{color}">{message}</span>')

    # ------------------------------------------------------------------ #
    # Быстрая проверка одного документа (диагностика)
    # ------------------------------------------------------------------ #
    def _collect_settings_silent(self) -> Optional[ScanSettings]:
        """Собирает настройки без вывода всплывающих окон об ошибках."""
        paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
        raw_words = self.words_edit.toPlainText()
        words = [w.strip() for chunk in raw_words.splitlines() for w in chunk.split(",") if w.strip()]
        file_types = {ext for ext, cb in self.type_checks.items() if cb.isChecked()}
        return ScanSettings(
            paths=paths, words=words, file_types=file_types,
            doc_read_method=self._doc_method_from_data(self.doc_method_combo.currentData()),
            case_sensitive=self.case_sensitive_check.isChecked(),
            whole_word=self.whole_word_check.isChecked(),
            context_chars=self.context_spin.value(),
            max_file_size_mb=self.max_size_spin.value(),
            use_ocr_for_pdf=self.ocr_check.isChecked(),
        )

    def _test_single_file(self) -> None:
        path, _ = QFileDialog.getOpenFileName(
            self, "Выберите файл для проверки чтения и OCR",
            filter="Документы (*.docx *.doc *.txt *.pdf);;Все файлы (*.*)"
        )
        if not path:
            return

        file_path = Path(path)
        settings = self._collect_settings_silent() or ScanSettings()
        settings.doc_read_method = self.doc_method_combo.currentData()
        settings.use_ocr_for_pdf = self.ocr_check.isChecked()

        start = time.monotonic()
        outcome = extract_text(file_path, file_path.suffix, settings)
        elapsed = time.monotonic() - start

        try:
            _release_word_all()
        except Exception:
            pass

        dlg = QDialog(self)
        dlg.setWindowTitle(f"Диагностика чтения: {file_path.name}")
        dlg.resize(750, 550)
        dlg_layout = QVBoxLayout(dlg)

        status_text = (
            f"<b>Полный путь:</b> {file_path}<br>"
            f"<b>Использованный ридер / метод:</b> {outcome.method_used or 'не определён'}<br>"
            f"<b>Время извлечения:</b> {elapsed:.2f} с<br>"
        )

        if outcome.locked:
            status_text += "<span style='color:#E5484D'><b>Статус:</b> ФАЙЛ ЗАБЛОКИРОВАН другим процессом!</span>"
        elif outcome.error:
            status_text += f"<span style='color:#E5484D'><b>Критическая ошибка:</b> {outcome.error}</span>"
        elif outcome.warning:
            status_text += f"<span style='color:#F2B84B'><b>Предупреждение / OCR:</b> {outcome.warning}</span><br><b>Статус:</b> Прочитан с предупреждениями"
        else:
            status_text += "<span style='color:#3DD68C'><b>Статус:</b> Успешно прочитан</span>"

        info_label = QLabel(status_text)
        info_label.setStyleSheet("padding: 6px; background-color: #1D2129; border-radius: 8px;")
        info_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
        dlg_layout.addWidget(info_label)

        dlg_layout.addWidget(QLabel("<b>Извлечённый текст (первые 4000 символов):</b>"))
        text_preview = QPlainTextEdit()
        text_preview.setReadOnly(True)
        text_preview.setStyleSheet("font-family: Consolas, monospace; font-size: 12px;")
        text_preview.setPlainText(outcome.text[:4000] if outcome.text else "<Текст отсутствует или пуст>")
        dlg_layout.addWidget(text_preview, 1)

        btns = QDialogButtonBox(QDialogButtonBox.Ok)
        btns.accepted.connect(dlg.accept)
        dlg_layout.addWidget(btns)

        dlg.exec()

    # ------------------------------------------------------------------ #
    # Управление путями
    # ------------------------------------------------------------------ #
    def _add_folder(self) -> None:
        folder = QFileDialog.getExistingDirectory(self, "Выберите папку для поиска")
        if folder:
            self.paths_list.addItem(folder)

    def _add_file(self) -> None:
        files, _ = QFileDialog.getOpenFileNames(
            self, "Выберите файлы",
            filter="Документы (*.docx *.doc *.txt *.pdf);;Все файлы (*.*)"
        )
        for f in files:
            self.paths_list.addItem(f)

    def _remove_selected_paths(self) -> None:
        for item in self.paths_list.selectedItems():
            self.paths_list.takeItem(self.paths_list.row(item))

    # ------------------------------------------------------------------ #
    # Сканирование
    # ------------------------------------------------------------------ #
    def _collect_settings(self) -> Optional[ScanSettings]:
        paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
        raw_words = self.words_edit.toPlainText()
        words = [w.strip() for chunk in raw_words.splitlines() for w in chunk.split(",")]
        words = [w for w in words if w]

        file_types = {ext for ext, cb in self.type_checks.items() if cb.isChecked()}

        settings = ScanSettings(
            paths=paths,
            words=words,
            file_types=file_types,
            doc_read_method=self._doc_method_from_data(self.doc_method_combo.currentData()),
            case_sensitive=self.case_sensitive_check.isChecked(),
            whole_word=self.whole_word_check.isChecked(),
            context_chars=self.context_spin.value(),
            max_file_size_mb=self.max_size_spin.value(),
            max_workers=0,  # "авто" — берётся из AppSettings в _start_scan
            use_ocr_for_pdf=self.ocr_check.isChecked(),
        )
        errors = settings.validate()
        if errors:
            QMessageBox.warning(self, "Проверка настроек", "\n".join(errors))
            return None
        return settings

    @staticmethod
    def _doc_method_from_data(data) -> DocReadMethod:
        """Восстанавливает DocReadMethod из данных QComboBox.

        PySide6 сериализует str-подкласс enum в обычную строку, поэтому
        currentData()/itemData() могут вернуть 'str' вместо enum. Приводим
        к DocReadMethod по строковому значению; неизвестное/пустое -> AUTO.
        """
        if isinstance(data, DocReadMethod):
            return data
        if isinstance(data, str):
            try:
                return DocReadMethod(data)
            except ValueError:
                return DocReadMethod.AUTO
        return DocReadMethod.AUTO

    def _start_scan(self) -> None:
        settings = self._collect_settings()
        if settings is None:
            return

        # Применяем max_workers из AppSettings
        from app.settings_store import load_settings
        app_s = load_settings()
        if settings.max_workers <= 0:
            settings.max_workers = app_s.max_workers

        self._save_settings()
        self.results_model.clear()
        self.processing_list.clear()

        self.progress_bar.setMaximum(0)  # "бесконечный" режим до первого обновления
        self.progress_bar.setValue(0)
        self.progress_label.setText("Обработано: 0 / 0")

        self.start_btn.setEnabled(False)
        self.stop_btn.setEnabled(True)
        self.stop_btn.setProperty("active", True)
        self.stop_btn.style().polish(self.stop_btn)

        # Автопереключение на "Результаты" (рекомендация отчёта)
        self.tabs.setCurrentIndex(0)

        # Подробная запись в лог (рекомендация отчёта)
        log_msg = (
            f"Старт сканирования | пути={settings.paths} | "
            f"слова={settings.words} | типы={settings.file_types} | "
            f"метод_doc={settings.doc_read_method.value} | OCR={settings.use_ocr_for_pdf} | "
            f"потоков={settings.max_workers}"
        )
        self.logger.info(log_msg)

        # Гарантированно отключаем предыдущие соединения (если старый
        # ScanWorker ещё не доставил финальные сигналы в очереди Qt)
        try:
            self.scan_worker.progress.disconnect()
        except (TypeError, RuntimeError, AttributeError):
            pass
        try:
            self.scan_worker.result_found.disconnect()
        except (TypeError, RuntimeError, AttributeError):
            pass
        try:
            self.scan_worker.log_message.disconnect()
        except (TypeError, RuntimeError, AttributeError):
            pass
        try:
            self.scan_worker.finished_ok.disconnect()
        except (TypeError, RuntimeError, AttributeError):
            pass

        self.scan_worker = ScanWorker(settings)
        self.scan_worker.progress.connect(self._on_progress)
        self.scan_worker.result_found.connect(self.results_model.add_result)
        # Используем именованную ссылку на slot, чтобы можно было отключиться
        self.scan_worker.log_message.connect(self._on_log_message)
        self.scan_worker.finished_ok.connect(self._on_scan_finished)
        self.scan_worker.start()

    def _cancel_scan(self) -> None:
        if self.scan_worker is not None:
            self.scan_worker.cancel()
            self.stop_btn.setEnabled(False)
            self.stop_btn.setProperty("active", False)
            self.stop_btn.style().polish(self.stop_btn)
            self.logger.warning("Сканирование прервано пользователем")

    def _on_progress(self, current: int, total: int, message: str) -> None:
        if total > 0:
            self.progress_bar.setMaximum(total)
            self.progress_bar.setValue(current)
        self.progress_label.setText(f"Обработано: {current} / {total}")

        if message:
            self.processing_list.addItem(message)
            self.processing_list.scrollToBottom()
            # Ограничиваем список 50 элементами, чтобы не нагружать виджет
            while self.processing_list.count() > 50:
                self.processing_list.takeItem(0)

    def _on_log_message(self, level: str, msg: str) -> None:
        """Именованный обработчик для логов сканирования (чтобы можно было
        отключить сигнал при повторном запуске)."""
        self.logger.log(getattr(logging, level, logging.INFO), msg)

    def _on_scan_finished(self, report: ScanReport) -> None:
        self.last_report = report
        self.start_btn.setEnabled(True)
        self.stop_btn.setEnabled(False)
        self.stop_btn.setProperty("active", False)
        self.stop_btn.style().polish(self.stop_btn)

        status = "отменено" if report.cancelled else "завершено"
        if report.stats.processed > 0:
            self.progress_bar.setMaximum(report.stats.processed)
            self.progress_bar.setValue(report.stats.processed)
        self.progress_label.setText(f"Обработано: {report.stats.processed} / {report.stats.processed}")

        self.status_bar.showMessage(
            f"Сканирование {status}: {len(report.results)} совпадений за {report.elapsed_seconds:.1f} с"
        )

        stats = report.stats
        for key, card in self.stat_cards.items():
            card.set_value(getattr(stats, key))

        self.logger.info(
            "Сканирование завершено (%s): обработано=%d, найдено=%d, ошибок=%d, время=%.1fс",
            status, stats.processed, len(report.results), stats.errors, report.elapsed_seconds,
        )

        # Если совпадений 0 — выводим всплывающее окно с анализом причин (рекомендация отчёта)
        if len(report.results) == 0 and not report.cancelled:
            QMessageBox.information(
                self, "Совпадений не найдено",
                f"Сканирование успешно завершено за {report.elapsed_seconds:.1f} с, но искомые слова не обнаружены.\n\n"
                "Рекомендации по поиску:\n"
                "1. Убедитесь, что ключевые слова написаны без опечаток.\n"
                "2. Если включена опция «Учитывать регистр» или «Только целые слова», попробуйте отключить их.\n"
                "3. Проверьте, что в параметрах выбраны нужные типы файлов.\n"
                "4. Для отсканированных PDF без текстового слоя убедитесь, что Tesseract OCR настроен корректно.\n\n"
                "Для быстрой проверки извлечения текста из конкретного подозрительного файла "
                "воспользуйтесь кнопкой «🔍 Проверить 1 файл…»."
            )

    # ------------------------------------------------------------------ #
    # Действия на вкладке "Результаты"
    # ------------------------------------------------------------------ #
    def _current_result(self):
        indexes = self.results_table.selectionModel().selectedRows()
        if not indexes:
            return None
        source_index = self.proxy_model.mapToSource(indexes[0])
        return self.results_model.result_at(source_index.row())

    def _open_selected_result(self) -> None:
        result = self._current_result()
        if result:
            self._open_path(result.full_path)

    def _open_selected_folder(self) -> None:
        result = self._current_result()
        if result:
            self._open_path(str(Path(result.full_path).parent))

    def _show_results_context_menu(self, position) -> None:
        result = self._current_result()
        menu = QMenu(self)
        open_file_action = menu.addAction("Открыть файл")
        open_folder_action = menu.addAction("Открыть папку файла")
        menu.addSeparator()
        copy_path_action = menu.addAction("Копировать полный путь")
        copy_context_action = menu.addAction("Копировать контекст")
        menu.addSeparator()
        select_all_action = menu.addAction("Выделить все совпадения в этом файле")

        if result is None:
            for a in (open_file_action, open_folder_action, copy_path_action,
                      copy_context_action, select_all_action):
                a.setEnabled(False)

        action = menu.exec(self.results_table.viewport().mapToGlobal(position))
        if action is None:
            return

        if action == open_file_action and result:
            self._open_path(result.full_path)
        elif action == open_folder_action and result:
            self._open_path(str(Path(result.full_path).parent))
        elif action == copy_path_action and result:
            QApplication.clipboard().setText(result.full_path, QClipboard.Clipboard)
            self.status_bar.showMessage("Путь скопирован в буфер обмена")
        elif action == copy_context_action and result:
            QApplication.clipboard().setText(result.context, QClipboard.Clipboard)
            self.status_bar.showMessage("Контекст скопирован в буфер обмена")
        elif action == select_all_action and result:
            for row in range(self.results_model.rowCount()):
                r = self.results_model.result_at(row)
                if r.full_path == result.full_path:
                    idx = self.results_model.index(row, 0)
                    proxy_idx = self.proxy_model.mapFromSource(idx)
                    self.results_table.selectRow(proxy_idx.row())

    @staticmethod
    def _open_path(path: str) -> None:
        try:
            if sys.platform.startswith("win"):
                os.startfile(path)  # type: ignore[attr-defined]
            elif sys.platform == "darwin":
                subprocess.run(["open", path])
            else:
                subprocess.run(["xdg-open", path])
        except Exception:
            QDesktopServices.openUrl(QUrl.fromLocalFile(path))

    def _search_info(self) -> dict:
        paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
        raw_words = self.words_edit.toPlainText()
        words = [w.strip() for chunk in raw_words.splitlines() for w in chunk.split(",") if w.strip()]
        return {
            "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
            "paths": paths,
            "words": words,
            "doc_method": self.doc_method_combo.currentText(),
        }

    def _export_excel(self) -> None:
        if not self.last_report:
            QMessageBox.information(self, "Нет данных", "Сначала выполните сканирование.")
            return
        REPORTS_DIR.mkdir(parents=True, exist_ok=True)
        default_name = str(REPORTS_DIR / f"search_results_{datetime.now():%Y%m%d_%H%M%S}.xlsx")
        path, _ = QFileDialog.getSaveFileName(self, "Сохранить Excel отчёт", default_name, "Excel (*.xlsx)")
        if not path:
            return
        ok = export_to_excel(
            self.last_report.results, self.last_report.stats,
            self.last_report.errors, self._search_info(), path,
        )
        if ok:
            QMessageBox.information(self, "Готово", f"Отчёт сохранён:\n{path}")
            self._open_path(path)
        else:
            QMessageBox.warning(self, "Ошибка", "Не удалось создать Excel-файл (проверьте openpyxl).")

    def _export_csv(self) -> None:
        if not self.last_report:
            QMessageBox.information(self, "Нет данных", "Сначала выполните сканирование.")
            return
        REPORTS_DIR.mkdir(parents=True, exist_ok=True)
        default_name = str(REPORTS_DIR / f"search_results_{datetime.now():%Y%m%d_%H%M%S}.csv")
        path, _ = QFileDialog.getSaveFileName(self, "Сохранить CSV", default_name, "CSV (*.csv)")
        if not path:
            return
        ok = export_to_csv(self.last_report.results, path, self.last_report.errors)
        if ok:
            QMessageBox.information(self, "Готово", f"CSV сохранён:\n{path}")
            self._open_path(path)
        else:
            QMessageBox.warning(self, "Ошибка", "Не удалось создать CSV-файл.")

    def _export_markdown(self) -> None:
        if not self.last_report:
            QMessageBox.information(self, "Нет данных", "Сначала выполните сканирование.")
            return
        REPORTS_DIR.mkdir(parents=True, exist_ok=True)
        default_name = str(REPORTS_DIR / f"search_results_{datetime.now():%Y%m%d_%H%M%S}.md")
        path, _ = QFileDialog.getSaveFileName(self, "Сохранить Markdown", default_name, "Markdown (*.md)")
        if not path:
            return
        ok = export_to_markdown(self.last_report.results, self._search_info(), self.last_report.errors, path)
        if ok:
            QMessageBox.information(self, "Готово", f"Markdown сохранён:\n{path}")
            self._open_path(path)
        else:
            QMessageBox.warning(self, "Ошибка", "Не удалось создать Markdown-файл.")

    # ------------------------------------------------------------------ #
    # Действия на вкладке "Файлы"
    # ------------------------------------------------------------------ #
    def _browse_copy_dest(self) -> None:
        folder = QFileDialog.getExistingDirectory(self, "Папка назначения для копирования")
        if folder:
            self.copy_dest_edit.setText(folder)

    def _copy_found_files(self) -> None:
        unique_paths = self.results_model.unique_paths()
        if not unique_paths:
            QMessageBox.information(self, "Нет файлов", "Сначала выполните сканирование с найденными результатами.")
            return
        destination = self.copy_dest_edit.text().strip()
        if not destination:
            QMessageBox.warning(self, "Не указан путь", "Укажите папку назначения.")
            return

        self.copy_worker = CopyWorker(unique_paths, destination)
        self.copy_worker.finished_ok.connect(self._on_copy_finished)
        self.copy_worker.failed.connect(lambda msg: QMessageBox.critical(self, "Ошибка копирования", msg))
        self.status_bar.showMessage("Копирование файлов…")
        self.copy_worker.start()

    def _on_copy_finished(self, mappings: List[CopyMapping]) -> None:
        self.copy_mappings = mappings
        self.status_bar.showMessage(f"Скопировано файлов: {len(mappings)}")
        QMessageBox.information(
            self, "Копирование завершено",
            f"Скопировано {len(mappings)} уникальных файлов в:\n{self.copy_dest_edit.text()}",
        )
        self._open_path(self.copy_dest_edit.text())

    def _check_removed_copies(self) -> None:
        if not self.copy_mappings:
            QMessageBox.information(self, "Нет данных", "Сначала скопируйте найденные файлы.")
            return
        removed = detect_removed_copies(self.copy_dest_edit.text(), self.copy_mappings)
        self.pending_removal_list.clear()
        for path in removed:
            self.pending_removal_list.addItem(QListWidgetItem(path))
        if not removed:
            QMessageBox.information(self, "Нет изменений", "Вы не удалили ни одной копии из папки назначения.")

    def _selected_pending_paths(self) -> List[str]:
        items = self.pending_removal_list.selectedItems() or [
            self.pending_removal_list.item(i) for i in range(self.pending_removal_list.count())
        ]
        return [i.text() for i in items]

    def _secure_delete_selected(self) -> None:
        paths = self._selected_pending_paths()
        if not paths:
            QMessageBox.information(self, "Нет файлов", "Список пуст. Сначала выполните проверку.")
            return
        confirm = QMessageBox.question(
            self, "Подтверждение",
            f"Безопасно (с многопроходной перезаписью) удалить {len(paths)} оригинальных файлов?\n"
            "Это действие необратимо!",
        )
        if confirm != QMessageBox.Yes:
            return
        from app.settings_store import load_settings
        passes = load_settings().secure_passes
        self.secure_worker = SecureOpWorker("delete", paths, passes=passes)
        self.secure_worker.finished_ok.connect(
            lambda n: QMessageBox.information(self, "Готово", f"Безопасно удалено файлов: {n}"))
        self.secure_worker.failed.connect(lambda msg: QMessageBox.critical(self, "Ошибка", msg))
        self.secure_worker.start()

    def _secure_move_selected(self) -> None:
        paths = self._selected_pending_paths()
        if not paths:
            QMessageBox.information(self, "Нет файлов", "Список пуст. Сначала выполните проверку.")
            return
        destination = QFileDialog.getExistingDirectory(self, "Папка назначения для перемещения")
        if not destination:
            return
        confirm = QMessageBox.question(
            self, "Подтверждение",
            f"Безопасно переместить {len(paths)} оригинальных файлов в:\n{destination}?",
        )
        if confirm != QMessageBox.Yes:
            return
        from app.settings_store import load_settings
        passes = load_settings().secure_passes
        self.secure_worker = SecureOpWorker("move", paths, destination, passes=passes)
        self.secure_worker.finished_ok.connect(
            lambda n: QMessageBox.information(self, "Готово", f"Безопасно перемещено файлов: {n}"))
        self.secure_worker.failed.connect(lambda msg: QMessageBox.critical(self, "Ошибка", msg))
        self.secure_worker.start()

    # ------------------------------------------------------------------ #
    # Диалог настроек и о программе
    # ------------------------------------------------------------------ #
    def _open_settings(self) -> None:
        dialog = SettingsDialog(self)
        if dialog.exec():
            self.status_bar.showMessage("Настройки сохранены")

    def _show_about(self) -> None:
        QMessageBox.about(
            self, "О программе",
            "<h2>DSP Scanner</h2>"
            "<p>Инструмент поиска ключевых слов в документах DOCX, DOC, TXT и PDF.</p>"
            "<p>Python-версия с модульной архитектурой и графическим интерфейсом PySide6/Qt6.</p>"
            "<p>Возможности:</p>"
            "<ul>"
            "<li>Точное чтение DOCX (python-docx + парсер вложений altChunk MHTML)</li>"
            "<li>Чтение легаси DOC через LibreOffice / MS Word COM / эвристику OLE2</li>"
            "<li>Чтение PDF через PyMuPDF с мощным многоязычным OCR (Tesseract)</li>"
            "<li>Быстрая диагностика подозрительных документов в один клик</li>"
            "<li>Экспорт в Excel / CSV / Markdown</li>"
            "<li>Копирование и безопасное (многопроходное) удаление файлов</li>"
            "</ul>",
        )

    # ------------------------------------------------------------------ #
    # Сохранение/загрузка конфигурации сканирования
    # ------------------------------------------------------------------ #
    def _save_scan_config(self) -> None:
        path, _ = QFileDialog.getSaveFileName(
            self, "Сохранить конфигурацию поиска", str(SCAN_CONFIG_FILE), "JSON (*.json)"
        )
        if not path:
            return
        config = {
            "paths": [self.paths_list.item(i).text() for i in range(self.paths_list.count())],
            "words": self.words_edit.toPlainText(),
            "file_types": [ext for ext, cb in self.type_checks.items() if cb.isChecked()],
            "doc_method": self._doc_method_from_data(self.doc_method_combo.currentData()).value,
            "case_sensitive": self.case_sensitive_check.isChecked(),
            "whole_word": self.whole_word_check.isChecked(),
            "use_ocr": self.ocr_check.isChecked(),
            "max_size_mb": self.max_size_spin.value(),
            "context_chars": self.context_spin.value(),
            "copy_dest": self.copy_dest_edit.text(),
        }
        try:
            Path(path).write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8")
            self.status_bar.showMessage(f"Конфигурация сохранена: {path}")
        except OSError as exc:
            QMessageBox.warning(self, "Ошибка", f"Не удалось сохранить конфигурацию:\n{exc}")

    def _load_scan_config(self) -> None:
        path, _ = QFileDialog.getOpenFileName(self, "Загрузить конфигурацию поиска", "", "JSON (*.json)")
        if not path:
            return
        try:
            config = json.loads(Path(path).read_text(encoding="utf-8"))
        except (OSError, json.JSONDecodeError) as exc:
            QMessageBox.warning(self, "Ошибка", f"Не удалось загрузить конфигурацию:\n{exc}")
            return

        self.paths_list.clear()
        for p in config.get("paths", []):
            self.paths_list.addItem(p)
        self.words_edit.setPlainText(config.get("words", ""))
        for ext, cb in self.type_checks.items():
            cb.setChecked(ext in config.get("file_types", SUPPORTED_EXTENSIONS))
        for i in range(self.doc_method_combo.count()):
            if self._doc_method_from_data(self.doc_method_combo.itemData(i)).value == config.get("doc_method"):
                self.doc_method_combo.setCurrentIndex(i)
                break
        self.case_sensitive_check.setChecked(config.get("case_sensitive", False))
        self.whole_word_check.setChecked(config.get("whole_word", False))
        self.ocr_check.setChecked(config.get("use_ocr", False))
        self.max_size_spin.setValue(config.get("max_size_mb", 200))
        self.context_spin.setValue(config.get("context_chars", 150))
        if config.get("copy_dest"):
            self.copy_dest_edit.setText(config["copy_dest"])
        self.status_bar.showMessage(f"Конфигурация загружена: {path}")

    # ------------------------------------------------------------------ #
    # Сохранение/восстановление базовых настроек
    # ------------------------------------------------------------------ #
    def _save_settings(self) -> None:
        paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
        self.settings_store.setValue("paths", paths)
        self.settings_store.setValue("words", self.words_edit.toPlainText())
        self.settings_store.setValue("copy_dest", self.copy_dest_edit.text())

    def _restore_settings(self) -> None:
        paths = self.settings_store.value("paths", [])
        if isinstance(paths, str):
            paths = [paths]
        for p in paths or []:
            self.paths_list.addItem(p)
        words = self.settings_store.value("words", "")
        if words:
            self.words_edit.setPlainText(words)
        copy_dest = self.settings_store.value("copy_dest", "")
        if copy_dest:
            self.copy_dest_edit.setText(copy_dest)

    def closeEvent(self, event) -> None:  # noqa: N802
        self._save_settings()
        if self.scan_worker is not None and self.scan_worker.isRunning():
            self.scan_worker.cancel()
            self.scan_worker.wait(2000)
        super().closeEvent(event)
