from __future__ import annotations

from collections import OrderedDict
from pathlib import Path
from threading import Event
from typing import Any
import html

from PySide6.QtCore import QEvent, QSettings, QThread, Qt, QTimer, QSize, QSignalBlocker, QRectF
from PySide6.QtGui import QFont, QImageReader, QPalette, QPixmap, QAction, QKeySequence, QTextDocument
from PySide6.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QFileDialog,
    QHeaderView,
    QMainWindow,
    QMessageBox,
    QProgressDialog,
    QSizePolicy,
    QStyle,
    QTableWidgetItem,
    QLabel,
    QMenu,
    QCheckBox,
    QComboBox,
    QWidgetAction,
    QToolButton,
    QToolTip,
    QStyledItemDelegate,
    QStyleOption,
    QStyleOptionHeader,
    QStyleOptionViewItem,
    QSpacerItem,
)

from ..recommendations import (
    recommendation_bullets_for_record,
    DEFAULT_MESSAGE,
    DPI_LOW,
    DPI_BORDERLINE,
    SC_MODERATE,
    SC_HIGH,
    HC_MODERATE,
    HC_HIGH,
    CONTRAST_LOW,
    CONTRAST_BORDERLINE,
    CF_LOW,
    CF_LIMITED,
    CR_LOW,
    CR_LIMITED,
)
from .table_items import NumericItem
from .ui_main_window import Ui_MainWindow
from .utils import parse_float, tooltip_html
from .viewer import ImageViewerDialog
from .worker import AnalyzeWorker
from .help_dialog import HelpDialog
from .theme import (
    DARK_COLORS,
    THEME_OPTIONS,
    THEME_SYSTEM,
    ThemeColors,
    apply_application_theme,
    normalize_theme_preference,
)


MAX_PREVIEW_LONG_SIDE = 1600

DEFAULT_INPUT_DIR = Path.home()
DEFAULT_OUTPUT_DIR = Path.home() / "Documents" / "PDF_Image_Quality_Outputs"

TABLE_COLUMN_KEYS: list[str] = [
    "page",
    "image_id",
    "dpi_pair",
    "shadow_clip_pct",
    "highlight_clip_pct",
    "contrast_p1p99_pct",
    "colorfulness_hs",
    "color_richness_pct",
    "recommendations",
]

TABLE_HEADERS: list[str] = [
    "Page",
    "Image ID",
    "DPI",
    "Shadow clip [%]",
    "Highlight clip [%]",
    "Contrast P1–P99 [%]",
    "Colorfulness (HS)",
    "Color richness [%]",
    "Recommendations",
]

COLUMN_HELP: dict[str, str] = {
    "page": "Page number in the PDF.",
    "image_id": "Internal image identifier (p{page}_Im{occ}).",
    "dpi_pair": "Effective DPI from image pixels vs. its size on the page. Low DPI < 200, Borderline DPI 200–299 (aim for 300+).",
    "shadow_clip_pct": "Shadow clip = % of pixels at exact value 0 (pure black). Moderate Shadow Clip >= 0.5%, High Shadow Clip > 5%. Ideally below ~0.5%.",
    "highlight_clip_pct": "Highlight clip = % of pixels at exact value 255 (pure white). Moderate Highlight Clip >= 0.5%, High Highlight Clip > 5%. Ideally below ~0.5%.",
    "contrast_p1p99_pct": "Contrast (P1–P99) in %. Low Contrast < 5%, Borderline Contrast 5–10%. Higher is usually better for readability.",
    "colorfulness_hs": "Colorfulness (Hasler–Süsstrunk). Low Colorfulness < 15, Limited Colorfulness 15–33. (Not a 'quality' metric by itself; mainly for diagnostics.)",
    "color_richness_pct": "Color richness (entropy-based) in %. Low Color Richness < 20%, Limited Color Richness 20–33.3%.",
    "recommendations": "Detected issues / tags based on thresholds. Hover a cell to see full explanation.",
}

IMAGE_INFO_LABEL_HELP: dict[str, str] = {
    "Page": COLUMN_HELP.get("page", ""),
    "Image ID": COLUMN_HELP.get("image_id", ""),
    "DPI": COLUMN_HELP.get("dpi_pair", ""),
    "Pixels": "Pixel dimensions of the extracted image.",
    "BBox [cm]": "BBox = image placement rectangle on the page in centimeters.",
    "Page dimensions [cm]": "Page dimensions in centimeters.",
    "Shadow clip [%]": COLUMN_HELP.get("shadow_clip_pct", ""),
    "Highlight clip [%]": COLUMN_HELP.get("highlight_clip_pct", ""),
    "Contrast P1–P99 [%]": COLUMN_HELP.get("contrast_p1p99_pct", ""),
    "Colorfulness (HS)": COLUMN_HELP.get("colorfulness_hs", ""),
    "Color richness [%]": COLUMN_HELP.get("color_richness_pct", ""),
}

TAG_ORDER: list[str] = [
    DEFAULT_MESSAGE,
    "Borderline DPI",
    "Low DPI",
    "Moderate Shadow Clip",
    "High Shadow Clip",
    "Moderate Highlight Clip",
    "High Highlight Clip",
    "Borderline Contrast",
    "Low Contrast",
    "Limited Colorfulness",
    "Low Colorfulness",
    "Limited Color Richness",
    "Low Color Richness",
]

IMAGE_INFO_REFERENCE: dict[str, str] = {
    "DPI": f"<{DPI_LOW} low | {DPI_LOW}-{DPI_BORDERLINE - 1} borderline | >={DPI_BORDERLINE} recommended",
    "Shadow clip [%]": f"<{SC_MODERATE:.1f} recommended | {SC_MODERATE:.1f}-{SC_HIGH:.1f} moderate | >{SC_HIGH:.1f} high",
    "Highlight clip [%]": f"<{HC_MODERATE:.1f} recommended | {HC_MODERATE:.1f}-{HC_HIGH:.1f} moderate | >{HC_HIGH:.1f} high",
    "Contrast P1–P99 [%]": f"<{CONTRAST_LOW:.1f} low | {CONTRAST_LOW:.1f}-{CONTRAST_BORDERLINE:.1f} borderline | >={CONTRAST_BORDERLINE:.1f} recommended",
    "Colorfulness (HS)": f"<{CF_LOW:.1f} low | {CF_LOW:.1f}-{CF_LIMITED:.1f} limited | >{CF_LIMITED:.1f} recommended",
    "Color richness [%]": f"<{CR_LOW:.1f} low | {CR_LOW:.1f}-{CR_LIMITED:.1f} limited | >{CR_LIMITED:.1f} recommended",
}


class TagsDelegate(QStyledItemDelegate):
    PADDING_X = 6

    def __init__(self, get_theme_colors, parent=None) -> None:
        super().__init__(parent)
        self._get_theme_colors = get_theme_colors

    def _create_document(self, text: str, font) -> QTextDocument:
        doc = QTextDocument()
        doc.setDefaultFont(font)
        doc.setDocumentMargin(2)

        if not text:
            return doc

        tags = [t.strip() for t in text.split(",") if t.strip()]
        html_tags: list[str] = []
        colors: ThemeColors = self._get_theme_colors()

        for t in tags:
            t_upper = t.upper()
            if "LOW DPI" in t_upper or "HIGH" in t_upper:
                color = colors.danger
            elif "BORDERLINE" in t_upper or "MODERATE" in t_upper or "LOW" in t_upper or "LIMITED" in t_upper:
                color = colors.warning
            elif t_upper == "ALL GOOD":
                color = colors.success
            else:
                color = colors.tag_default

            html_tags.append(f"<span style='color: {color}; font-weight: bold;'>{t}</span>")

        html_text = "&nbsp;&nbsp;&bull;&nbsp;&nbsp;".join(html_tags)
        doc.setHtml(f"<div style='white-space: nowrap;'>{html_text}</div>")
        return doc

    def paint(self, painter, option, index) -> None:
        opt = QStyleOptionViewItem(option)
        self.initStyleOption(opt, index)

        opt.features &= ~QStyleOptionViewItem.ViewItemFeature.HasDisplay
        opt.text = ""

        widget = option.widget
        if widget is not None:
            style = widget.style()
            style.drawControl(QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget)

        text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
        if not text:
            return

        doc = self._create_document(text, option.font)

        painter.save()

        painter.translate(option.rect.left() + self.PADDING_X, option.rect.top())

        y_offset = (option.rect.height() - doc.size().height()) / 2.0
        painter.translate(0, max(0.0, y_offset))

        draw_width = option.rect.width() - (2 * self.PADDING_X)
        doc.drawContents(painter, QRectF(0, 0, draw_width, option.rect.height()))

        painter.restore()

    def sizeHint(self, option, index) -> QSize:
        text = str(index.data(Qt.ItemDataRole.DisplayRole) or "")
        if not text:
            return super().sizeHint(option, index)

        doc = self._create_document(text, option.font)

        width = int(doc.idealWidth()) + (2 * self.PADDING_X)
        height = int(doc.size().height())
        return QSize(width, height)


class ResultsHeaderView(QHeaderView):
    def paintSection(self, painter, rect, logical_index: int) -> None:
        if not rect.isValid():
            return

        option = QStyleOptionHeader()
        self.initStyleOption(option)
        self.initStyleOptionForIndex(option, logical_index)
        option.rect = rect
        option.section = logical_index

        style = self.style()
        style.drawControl(QStyle.ControlElement.CE_HeaderSection, option, painter, self)

        text = self.model().headerData(
            logical_index,
            self.orientation(),
            Qt.ItemDataRole.DisplayRole,
        )
        text = "" if text is None else str(text)

        margin = style.pixelMetric(QStyle.PixelMetric.PM_HeaderMargin, option, self)
        mark = style.pixelMetric(QStyle.PixelMetric.PM_HeaderMarkSize, option, self)
        if margin <= 0:
            margin = 6
        if mark <= 0:
            mark = 12

        text_rect = rect.adjusted(margin, 0, -margin, 0)

        if self.isSortIndicatorShown() and self.sortIndicatorSection() == logical_index:
            arrow_rect = rect.adjusted(0, 0, -margin, 0)
            arrow_rect.setLeft(arrow_rect.right() - mark)
            arrow_rect.setTop(rect.center().y() - (mark // 2))
            arrow_rect.setSize(QSize(mark, mark))

            arrow_option = QStyleOptionHeader(option)
            arrow_option.rect = arrow_rect

            if self.sortIndicatorOrder() == Qt.SortOrder.AscendingOrder:
                arrow_option.state |= QStyle.StateFlag.State_UpArrow
                arrow_option.state &= ~QStyle.StateFlag.State_DownArrow
            else:
                arrow_option.state |= QStyle.StateFlag.State_DownArrow
                arrow_option.state &= ~QStyle.StateFlag.State_UpArrow

            style.drawPrimitive(
                QStyle.PrimitiveElement.PE_IndicatorHeaderArrow,
                arrow_option,
                painter,
                self,
            )
            text_rect.setRight(arrow_rect.left() - margin)

        text_alignment = int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
        style.drawItemText(
            painter,
            text_rect,
            text_alignment,
            option.palette,
            self.isEnabled(),
            text,
            QPalette.ColorRole.ButtonText,
        )


class MainWindow(QMainWindow, Ui_MainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.setupUi(self)

        self._help_dialog = None

        self._id_col = TABLE_COLUMN_KEYS.index("image_id")
        self._settings = QSettings("pdfimgq", "gui")
        self._theme_preference = THEME_SYSTEM
        self._applying_theme = False
        self._theme_colors = DARK_COLORS
        self._failures_by_file: dict[Path, str] = {}
        self._analysis_notes_by_file: dict[Path, str] = {}

        self._preview_cache: OrderedDict[str, QPixmap] = OrderedDict()
        self._preview_cache_max = 64

        self._last_sorted_section: int | None = None
        self._header_width_before_sort: dict[int, int] = {}
        self._in_programmatic_column_resize = False

        self._input_paths: list[Path] = []
        self._output_dir: Path | None = None

        self._rows_by_file: dict[Path, list[dict[str, Any]]] = {}

        self._analysis_has_run = False
        self._table_placeholder: QLabel | None = None

        self._selected_tags: set[str] = set()
        self._tag_menu: QMenu | None = None

        self._updating_preview = False
        self._last_run_canceled = False
        self._in_reco_width_update = False

        self._current_preview_pixmap: QPixmap | None = None
        self._current_preview_loaded_path: Path | None = None

        self._thread: QThread | None = None
        self._worker: AnalyzeWorker | None = None
        self._cancel_event: Event | None = None
        self._progress_dialog: QProgressDialog | None = None

        self._reco_col = TABLE_COLUMN_KEYS.index("recommendations")
        self._reco_content_width = 260

        self._reco_resize_timer = QTimer(self)
        self._reco_resize_timer.setSingleShot(True)
        self._reco_resize_timer.setInterval(0)
        self._reco_resize_timer.timeout.connect(self._update_recommendations_column_width)

        self._ui_state_save_timer = QTimer(self)
        self._ui_state_save_timer.setSingleShot(True)
        self._ui_state_save_timer.setInterval(250)
        self._ui_state_save_timer.timeout.connect(self._save_ui_state)

        self._setup_widgets()
        self.imagePreviewLabel.installEventFilter(self)
        self.resultsTableView.viewport().installEventFilter(self)
        self._connect_signals()
        app = QApplication.instance()
        if app is not None:
            app.styleHints().colorSchemeChanged.connect(self._on_system_color_scheme_changed)
        self._apply_defaults_on_start()


    def _setup_widgets(self) -> None:
        self.setWindowTitle("PDF Image Quality Checker")

        self._setup_theme_selector()
        self._setup_table()
        self._setup_image_info_table()
        self._setup_help()
        self._setup_tag_filter_ui()
        self._setup_table_placeholder()
        self._clear_detail()

        self.imagePreviewLabel.setMinimumSize(180, 120)
        self.imagePreviewLabel.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored)

        self.verticalLayout.setStretch(1, 3)
        self.verticalLayout.setStretch(2, 2)
        self.horizontalLayout_5.setStretch(0, 3)
        self.horizontalLayout_5.setStretch(1, 2)

        self.helpButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
        self.helpButton.setToolTip("Help (F1)")

        self.recommendationTextEdit.setLineWrapMode(self.recommendationTextEdit.LineWrapMode.WidgetWidth)
        self._apply_theme_styles()

    def _setup_theme_selector(self) -> None:
        self.themeLabel = QLabel("Theme:", self.topPanelWidget)
        self.themeComboBox = QComboBox(self.topPanelWidget)
        self.themeComboBox.setMinimumContentsLength(7)
        self.themeComboBox.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)

        for label, value in THEME_OPTIONS:
            self.themeComboBox.addItem(label, value)

        insert_at = self.horizontalLayout.indexOf(self.tagFilterButton)
        self.horizontalLayout.insertWidget(insert_at, self.themeLabel)
        self.horizontalLayout.insertItem(
            insert_at + 1,
            QSpacerItem(4, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum),
        )
        self.horizontalLayout.insertWidget(insert_at + 2, self.themeComboBox)
        self.horizontalLayout.insertItem(
            insert_at + 3,
            QSpacerItem(5, 20, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Minimum),
        )

    def _current_theme_colors(self) -> ThemeColors:
        return self._theme_colors

    def _apply_theme_styles(self) -> None:
        colors = self._theme_colors
        self.setStyleSheet(
            f"""
            QGroupBox {{
                font-weight: 600;
            }}

            QPlainTextEdit,
            QLabel#imagePreviewLabel,
            QTextBrowser,
            QLineEdit,
            QTableWidget#resultsTableView,
            QTableWidget#imageInfoTable {{
                border: 1px solid {colors.border};
                border-radius: 4px;
            }}

            QPlainTextEdit,
            QTextBrowser,
            QLineEdit,
            QTableWidget {{
                selection-background-color: {colors.selection};
                selection-color: {colors.selected_text};
            }}

            QTableWidget#resultsTableView {{
                gridline-color: {colors.table_grid};
                background-color: {colors.surface_bg};
            }}

            QTableWidget#imageInfoTable {{
                background-color: {colors.surface_bg};
                alternate-background-color: {colors.detail_alt_row};
            }}

            QTableWidget::item {{
                padding: 4px;
            }}

            QTableView::item:selected {{
                background-color: {colors.selection};
                color: {colors.selected_text};
            }}

            QTableView::item:selected:active {{
                background-color: {colors.selection_active};
                color: {colors.selected_text};
            }}

            QTableView::item:focus {{
                outline: none;
            }}

            QMenu::item:selected {{
                background: {colors.selection};
                color: {colors.selected_text};
            }}

            QToolTip {{
                background-color: {colors.tooltip_bg};
                color: {colors.tooltip_text};
                border: 1px solid {colors.tooltip_border};
                padding: 4px 6px;
            }}
            """
        )
        QToolTip.setPalette(self.palette())
        if self._table_placeholder is not None:
            self._table_placeholder.setStyleSheet(
                f"color: {colors.subtle_text}; padding: 16px;"
            )


    def _setup_table(self) -> None:
        table = self.resultsTableView
        header = ResultsHeaderView(Qt.Orientation.Horizontal, table)
        table.setHorizontalHeader(header)

        table.setColumnCount(len(TABLE_COLUMN_KEYS))
        table.setHorizontalHeaderLabels(TABLE_HEADERS)

        table.setShowGrid(True)
        table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        table.verticalHeader().setVisible(False)
        table.horizontalHeader().setVisible(False)

        table.setWordWrap(False)
        table.setTextElideMode(Qt.TextElideMode.ElideNone)

        header = table.horizontalHeader()
        header.setSectionsMovable(False)
        header.setSectionsClickable(True)
        header.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
        header.setStretchLastSection(False)
        header.setStyleSheet("")
        header.setTextElideMode(Qt.TextElideMode.ElideNone)
        header.setSortIndicatorShown(True)
        header.sortIndicatorChanged.connect(self._on_sort_indicator_changed)

        for c, key in enumerate(TABLE_COLUMN_KEYS):
            it = table.horizontalHeaderItem(c)
            if it is None:
                continue
            help_text = COLUMN_HELP.get(key, "")
            if help_text:
                it.setToolTip(tooltip_html(help_text, max_width_px=520))

        header.sectionResized.connect(self._on_header_section_resized)

        self._tags_delegate = TagsDelegate(self._current_theme_colors, self)
        table.setItemDelegateForColumn(self._reco_col, self._tags_delegate)


    def _setup_table_placeholder(self) -> None:
        lbl = QLabel(self.resultsTableView.viewport())
        lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
        lbl.setWordWrap(True)
        lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
        lbl.hide()
        self._table_placeholder = lbl
        self._apply_theme_styles()
        self._layout_table_placeholder()


    def _layout_table_placeholder(self) -> None:
        self._table_placeholder.setGeometry(self.resultsTableView.viewport().rect())
        self._table_placeholder.raise_()

        if not self._input_paths and self._table_placeholder.isVisible():
            w = self.resultsTableView.viewport().width()
            table_w = min(1106, max(400, int(w * 0.85)))
            self._table_placeholder.setText(self._get_welcome_html(table_w))


    def _show_table_empty_state(self) -> None:
        has_rows = self.resultsTableView.rowCount() > 0
        if has_rows and self._selected_tags:
            self._layout_table_placeholder()
            return
        
        self._table_placeholder.setVisible(not has_rows)
        
        if not has_rows:
            if not self._input_paths:
                pass
            elif not self._analysis_has_run:
                self._table_placeholder.setText("Ready.<br>Click <b>Analyze</b> to start.")
            else:
                self._table_placeholder.setText("No results to display.")

        self._layout_table_placeholder()


    def _connect_signals(self) -> None:
        self.selectInputButton.clicked.connect(self._on_select_input_clicked)
        self.selectOutputButton.clicked.connect(self._on_select_output_clicked)
        self.runButton.clicked.connect(self._on_run_clicked)
        self.helpButton.clicked.connect(self._show_help)
        self.themeComboBox.currentIndexChanged.connect(self._on_theme_changed)

        self.resultsTableView.itemSelectionChanged.connect(self._on_table_selection_changed)
        self.fileComboBox.currentIndexChanged.connect(self._on_file_changed)
        self.fileComboBox.currentIndexChanged.connect(self._schedule_save_ui_state)


    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        self._reco_resize_timer.start()

        if self._current_preview_pixmap is not None:
            self._apply_preview_scale()
        
        self._layout_table_placeholder()


    def closeEvent(self, event) -> None:
        if self._thread is not None and self._thread.isRunning():
            QMessageBox.information(
                self,
                "Analysis running",
                "Analysis is running.\nPlease cancel it first, then close the application.",
            )
            event.ignore()
            return

        try:
            self._save_ui_state()
        except Exception:
            pass

        try:
            self._save_settings()
        except Exception:
            pass

        super().closeEvent(event)


    def _apply_defaults_on_start(self) -> None:
        self._load_theme_preference()

        geo = self._settings.value("window_geometry")
        state = self._settings.value("window_state")
        if geo is not None:
            try:
                self.restoreGeometry(geo)
            except Exception:
                pass
        if state is not None:
            try:
                self.restoreState(state)
            except Exception:
                pass

        self._reco_resize_timer.start()

        out_path_str = self._settings.value("output_path", str(DEFAULT_OUTPUT_DIR))
        out_dir = Path(str(out_path_str)).resolve()
        self._output_dir = out_dir

        self.statusbar.showMessage(f"Output: {self._output_dir}", 5000)
        self._update_output_tooltip()

        self._analysis_has_run = False

        self.fileComboBox.clear()
        self.fileComboBox.setEnabled(False)
        self.fileComboBox.setPlaceholderText("Select PDFs")

        self._input_paths = []
        self._rows_by_file.clear()
        self._failures_by_file.clear()
        self._analysis_notes_by_file.clear()

        self.detailWidget.setVisible(False)
        self.runButton.setEnabled(False)

        self._fill_table_from_rows([])
        self._show_table_empty_state()

    
    def _setup_help(self) -> None:
        action = QAction(self)
        action.setShortcut(QKeySequence("F1"))
        action.triggered.connect(self._show_help)
        self.addAction(action)
        self.menuBar().hide()


    def _setup_tag_filter_ui(self) -> None:
        menu = QMenu(self.tagFilterButton)
        self.tagFilterButton.setMenu(menu)

        self._tag_menu = menu
        self.tagFilterButton.setEnabled(False)

        self._tag_checkboxes: dict[str, QCheckBox] = {}
        self._clear_selection_button: QToolButton | None = None

        self._rebuild_tag_filter_menu([])


    def _parse_tags(self, recommendations: str) -> set[str]:
        s = (recommendations or "").strip()
        if not s or s.casefold() == DEFAULT_MESSAGE.casefold():
            return {DEFAULT_MESSAGE}
        return {t.strip() for t in s.split(",") if t.strip()}


    def _on_tag_checkbox_toggled(self, tag: str, checked: bool) -> None:
        if checked:
            self._selected_tags.add(tag)
        else:
            self._selected_tags.discard(tag)

        if self._clear_selection_button is not None:
            self._clear_selection_button.setEnabled(bool(self._selected_tags))

        self._apply_tag_filter()


    def _rebuild_tag_filter_menu(self, rows: list[dict[str, Any]]) -> None:
        menu = self._tag_menu
        if menu is None:
            return

        tags: set[str] = set()
        for rec in rows:
            tags |= self._parse_tags(str(rec.get("recommendations") or ""))

        order_index = {t: i for i, t in enumerate(TAG_ORDER)}
        available = sorted(tags, key=lambda t: (order_index.get(t, 10_000), t.casefold()))

        self._selected_tags.intersection_update(available)

        menu.clear()
        self._tag_checkboxes.clear()

        clear_btn = QToolButton(menu)
        clear_btn.setText("Clear selection")
        clear_btn.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextOnly)
        clear_btn.setAutoRaise(True)
        clear_btn.setEnabled(bool(self._selected_tags))
        clear_btn.clicked.connect(self._clear_tag_filter)

        clear_wa = QWidgetAction(menu)
        clear_wa.setDefaultWidget(clear_btn)
        menu.addAction(clear_wa)

        if available:
            menu.addSeparator()

        self._clear_selection_button = clear_btn

        for tag in available:
            cb = QCheckBox(tag, menu)
            cb.setChecked(tag in self._selected_tags)
            cb.toggled.connect(lambda checked, t=tag: self._on_tag_checkbox_toggled(t, checked))

            self._tag_checkboxes[tag] = cb

            wa = QWidgetAction(menu)
            wa.setDefaultWidget(cb)
            menu.addAction(wa)

        self.tagFilterButton.setEnabled(bool(available))


    def _clear_tag_filter(self) -> None:
        if not self._selected_tags:
            if self._clear_selection_button is not None:
                self._clear_selection_button.setEnabled(False)
            return

        self._selected_tags.clear()

        for cb in self._tag_checkboxes.values():
            blocker = QSignalBlocker(cb)
            try:
                cb.setChecked(False)
            finally:
                del blocker

        self._apply_tag_filter()

        if self._clear_selection_button is not None:
            self._clear_selection_button.setEnabled(False)


    def _rec_for_table_row(self, row: int) -> dict[str, Any] | None:
        item = self.resultsTableView.item(row, self._id_col)
        if item is None:
            return None
        rec = item.data(Qt.ItemDataRole.UserRole)
        return rec if isinstance(rec, dict) else None


    def _get_welcome_html(self, width: int) -> str:
        colors = self._theme_colors
        return f"""
        <table align="center" width="{width}" cellpadding="0" cellspacing="0">
            <tr>
                <td align="center" style="padding-bottom: 48px;">
                    <p style='color: {colors.text}; font-weight: bold; margin: 0 0 24px 0; font-size: 30px;'>Welcome to PDF Image Quality Checker</p>
                    
                    <span style='color: {colors.muted_text}; font-size: 16px;'>
                        This tool helps you verify the technical quality of images embedded in your PDF documents, 
                        which is crucial for professional-looking theses and publications.
                    </span>
                </td>
            </tr>
            <tr>
                <td align="left" style="padding-bottom: 16px;">
                    <b style='color: {colors.text}; font-size: 16px;'>What can you check?</b>
                </td>
            </tr>
            <tr>
                <td align="left" style="padding-bottom: 36px;">
                    <table cellpadding="0" cellspacing="0" style='color: {colors.muted_text}; font-size: 16px;'>
                        <tr>
                            <td valign="top" style="padding-right: 10px; padding-bottom: 8px;">&bull;</td>
                            <td style="padding-bottom: 8px;"><b>Print Readiness:</b> Detects low DPI that could cause blurriness in print.</td>
                        </tr>
                        <tr>
                            <td valign="top" style="padding-right: 10px; padding-bottom: 8px;">&bull;</td>
                            <td style="padding-bottom: 8px;"><b>Tonal Quality:</b> Identifies loss of detail in shadows or highlights.</td>
                        </tr>
                        <tr>
                            <td valign="top" style="padding-right: 10px; padding-bottom: 8px;">&bull;</td>
                            <td style="padding-bottom: 8px;"><b>Visual Impact:</b> Measures contrast, colorfulness, and color richness.</td>
                        </tr>
                        <tr>
                            <td valign="top" style="padding-right: 10px;">&bull;</td>
                            <td><b>Data Extraction:</b> Automatically extracts and saves all images for manual review.</td>
                        </tr>
                    </table>
                </td>
            </tr>
            <tr>
                <td align="center">
                    <span style='color: {colors.subtle_text}; font-size: 16px;'>
                        To begin, click the <b style='color: {colors.muted_text};'>'Select PDFs'</b> button in the top panel.
                    </span>
                </td>
            </tr>
        </table>
        """
    

    def _apply_tag_filter(self) -> None:
        t = self.resultsTableView
        n = t.rowCount()
        if n == 0:
            return

        visible = 0

        for r in range(n):
            rec = self._rec_for_table_row(r)
            if rec is None:
                t.setRowHidden(r, True)
                continue

            if not self._selected_tags:
                show = True
            else:
                rec_tags = self._parse_tags(str(rec.get("recommendations") or ""))
                show = bool(rec_tags & self._selected_tags)

            t.setRowHidden(r, not show)
            if show:
                visible += 1

        if self._selected_tags and visible == 0:
            self._table_placeholder.setText("No rows match selected tags.")
            self._table_placeholder.show()
            self._layout_table_placeholder()
        else:
            self._table_placeholder.hide()

        rows = t.selectionModel().selectedRows()
        if rows and t.isRowHidden(rows[0].row()):
            t.clearSelection()
            self._clear_detail()
            self.detailWidget.setVisible(False)


    def _show_help(self) -> None:
        if self._help_dialog is None:
            dlg = HelpDialog(self._theme_colors, self)
            dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True)
            dlg.destroyed.connect(lambda *_: setattr(self, "_help_dialog", None))
            self._help_dialog = dlg
        else:
            dlg = self._help_dialog

        dlg.show()
        dlg.raise_()
        dlg.activateWindow()

    def _load_theme_preference(self) -> None:
        preference = normalize_theme_preference(self._settings.value("theme_preference", THEME_SYSTEM))
        self._theme_preference = preference
        combo_index = self.themeComboBox.findData(preference)
        if combo_index >= 0:
            blocker = QSignalBlocker(self.themeComboBox)
            try:
                self.themeComboBox.setCurrentIndex(combo_index)
            finally:
                del blocker
        self._apply_theme(preference, persist=False)

    def _on_theme_changed(self, index: int) -> None:
        preference = normalize_theme_preference(self.themeComboBox.itemData(index))
        self._apply_theme(preference)

    def _on_system_color_scheme_changed(self, _scheme: Qt.ColorScheme) -> None:
        if self._theme_preference == THEME_SYSTEM and not self._applying_theme:
            self._apply_theme(self._theme_preference, persist=False)

    def _apply_theme(self, preference: str, *, persist: bool = True) -> None:
        if self._applying_theme:
            return
        app = QApplication.instance()
        if app is None:
            return

        self._applying_theme = True
        try:
            context = apply_application_theme(app, preference)
            self._theme_preference = context.preference
            self._theme_colors = context.colors
            self._apply_theme_styles()

            if self._help_dialog is not None:
                self._help_dialog.apply_theme(self._theme_colors)

            self.resultsTableView.viewport().update()
            self.imageInfoTable.viewport().update()
            self.recommendationTextEdit.viewport().update()
            self._show_table_empty_state()

            if persist:
                self._settings.setValue("theme_preference", self._theme_preference)
        finally:
            self._applying_theme = False


    def _save_settings(self) -> None:
        self._settings.setValue("theme_preference", self._theme_preference)
        if self._output_dir is not None:
            self._settings.setValue("output_path", str(self._output_dir))

        if self._input_paths:
            self._settings.setValue("input_dir", str(self._input_paths[0].parent))

    
    def _update_output_tooltip(self) -> None:
        path = self._output_dir.resolve() if self._output_dir is not None else DEFAULT_OUTPUT_DIR.resolve()
        self.selectOutputButton.setToolTip(tooltip_html(f"Output folder:\n{path}", max_width_px=520))

    
    def _schedule_save_ui_state(self, *_: object) -> None:
        self._ui_state_save_timer.start()


    def _save_ui_state(self) -> None:
        self._settings.setValue("window_geometry", self.saveGeometry())
        self._settings.setValue("window_state", self.saveState())

        cur = self.fileComboBox.currentData()
        if isinstance(cur, Path):
            self._settings.setValue("last_selected_pdf", str(cur))


    def _duplicate_stems(self, paths: list[Path]) -> dict[str, list[Path]]:
        groups: dict[str, list[Path]] = {}
        for p in paths:
            groups.setdefault(p.stem.casefold(), []).append(p)
        return {k: v for k, v in groups.items() if len(v) > 1}


    def _set_input_files(self, paths: list[Path], *, show_errors: bool = True) -> None:
        cleaned = [p.resolve() for p in paths if p.exists() and p.suffix.lower() == ".pdf"]

        dups = self._duplicate_stems(cleaned)
        if dups:
            if show_errors:
                msg = QMessageBox(self)
                msg.setIcon(QMessageBox.Icon.Warning)
                msg.setWindowTitle("Duplicate PDF filenames")
                msg.setText(
                    "Selected PDFs contain duplicate filenames.\n"
                    "Outputs are based on the filename (without extension), so names must be unique.\n\n"
                    "Please rename the files and select them again."
                )

                details_lines: list[str] = []
                for stem, items in sorted(dups.items()):
                    details_lines.append(f"{stem}:")
                    for p in items:
                        details_lines.append(f"  - {p}")
                    details_lines.append("")

                msg.setDetailedText("\n".join(details_lines).strip())
                msg.exec()

            return

        self._input_paths = cleaned
        self._analysis_has_run = False

        self._rows_by_file.clear()
        self._failures_by_file.clear()
        self._analysis_notes_by_file.clear()

        self.fileComboBox.blockSignals(True)
        try:
            self.fileComboBox.clear()

            for p in self._input_paths:
                display = p.name
                self.fileComboBox.addItem(display, userData=p)

                idx = self.fileComboBox.count() - 1
                self.fileComboBox.setItemData(
                    idx,
                    tooltip_html(str(p), max_width_px=700),
                    Qt.ItemDataRole.ToolTipRole,
                )

            if self._input_paths:
                restored = self._restore_last_selected_pdf()
                if not restored:
                    self.fileComboBox.setCurrentIndex(0)
            else:
                self.fileComboBox.setCurrentIndex(-1)
        finally:
            self.fileComboBox.blockSignals(False)

        self.fileComboBox.setEnabled(bool(self._input_paths))
        self.runButton.setEnabled(bool(self._input_paths))

        self._fill_table_from_rows([])
        self._clear_detail()
        self.detailWidget.setVisible(False)
        self._show_table_empty_state()

        self.statusbar.showMessage(f"Loaded {len(self._input_paths)} PDF files.", 5000)
        self._save_settings()
        self._schedule_save_ui_state()
        

    
    def _restore_last_selected_pdf(self) -> bool:
        val = self._settings.value("last_selected_pdf")
        if not val:
            return False
        try:
            target = Path(str(val)).resolve()
        except Exception:
            return False

        for i in range(self.fileComboBox.count()):
            p = self.fileComboBox.itemData(i)
            if isinstance(p, Path) and p.resolve() == target:
                self.fileComboBox.setCurrentIndex(i)
                return True
        return False


    def _on_select_input_clicked(self) -> None:
        if self._input_paths:
            start_dir = str(self._input_paths[0].parent)
        else:
            s = self._settings.value("input_dir", "")
            start_dir = str(s) if isinstance(s, str) and s else (
                str(DEFAULT_INPUT_DIR.resolve()) if DEFAULT_INPUT_DIR.exists() else ""
            )

        paths, _ = QFileDialog.getOpenFileNames(
            self,
            "Select PDF files",
            start_dir,
            "PDF files (*.pdf)",
        )
        if paths:
            self._set_input_files([Path(p) for p in paths])


    def _on_select_output_clicked(self) -> None:
        if self._output_dir and self._output_dir.exists():
            start_dir = str(self._output_dir.resolve())
        else:
            start_dir = str(Path.home() / "Documents")

        directory = QFileDialog.getExistingDirectory(self, "Select output directory", start_dir)
        if not directory:
            return

        self._output_dir = Path(directory).resolve()
        
        self._output_dir.mkdir(parents=True, exist_ok=True)

        self._update_output_tooltip()
        self.statusbar.showMessage(f"Output: {self._output_dir}", 5000)
        self._save_settings()


    def _on_run_clicked(self) -> None:
        if not self._input_paths:
            return

        out_dir = self._output_dir
        if out_dir is None:
            return

        self._last_run_canceled = False
        self._set_running_ui(True)
        self._cancel_event = Event()

        self._progress_dialog = QProgressDialog("Starting…", "Cancel", 0, 100, self)
        self._progress_dialog.setAutoClose(False)
        self._progress_dialog.setAutoReset(False)
        self._progress_dialog.setWindowTitle("Analyzing")
        self._progress_dialog.setWindowModality(Qt.WindowModality.WindowModal)
        self._progress_dialog.setMinimumDuration(0)
        self._progress_dialog.canceled.connect(self._request_cancel)
        self._progress_dialog.setValue(0)
        self._progress_dialog.show()

        self._thread = QThread(self)
        self._worker = AnalyzeWorker(self._input_paths, None, out_dir, self._cancel_event)
        self._worker.moveToThread(self._thread)

        self._thread.started.connect(self._worker.run)
        self._worker.progress.connect(self._on_worker_progress)
        self._worker.finished.connect(self._on_worker_finished)
        self._worker.failed.connect(self._on_worker_failed)

        self._worker.finished.connect(self._thread.quit)
        self._worker.failed.connect(self._thread.quit)

        self._thread.finished.connect(self._cleanup_worker)
        self._thread.finished.connect(self._worker.deleteLater)
        self._thread.finished.connect(self._thread.deleteLater)

        self._thread.start()


    def _request_cancel(self) -> None:
        self._last_run_canceled = True
        if self._cancel_event is not None:
            self._cancel_event.set()
        if self._progress_dialog is not None:
            self._progress_dialog.setLabelText("Canceling...")
            self._progress_dialog.setCancelButton(None)


    def _set_running_ui(self, running: bool) -> None:
        has_input = bool(self._input_paths)

        self.runButton.setEnabled((not running) and has_input)
        self.selectInputButton.setEnabled(not running)
        self.selectOutputButton.setEnabled(not running)
        self.fileComboBox.setEnabled((not running) and has_input)


    def _on_worker_progress(self, percent: int, text: str) -> None:
        self.statusbar.showMessage(text)

        dlg = self._progress_dialog
        if dlg is None:
            return

        p = max(0, min(100, int(percent)))

        if self._last_run_canceled:
            try:
                dlg.setValue(p)
            except RuntimeError:
                pass
            return

        try:
            dlg.setLabelText(text)
            dlg.setValue(p)
        except RuntimeError:
            pass


    def _on_worker_finished(self, results_by_file: object, suspected_by_file: object, failures_by_file: object, analysis_notes_by_file: object) -> None:
        self._close_progress_dialog()
        self._set_running_ui(False)
        self._analysis_has_run = True
        self._rows_by_file = dict(results_by_file)  # type: ignore[arg-type]
        suspected_map: dict[Path, list[int]] = dict(suspected_by_file)  # type: ignore[arg-type]
        self._failures_by_file = dict(failures_by_file)  # type: ignore[arg-type]
        self._analysis_notes_by_file = dict(analysis_notes_by_file)  # type: ignore[arg-type]

        self._on_file_changed(self.fileComboBox.currentIndex())

        warning_lines: list[str] = []
        for pdf, pages in suspected_map.items():
            if pages:
                warning_lines.append(f"{pdf.name}: {', '.join(str(p) for p in pages)}")

        total = len(self._input_paths)
        attempted = len(self._rows_by_file) + len(self._failures_by_file)

        if self._last_run_canceled:
            msg = [f"Canceled. Processed {attempted}/{total} PDF(s).", f"Output: {self._output_dir}"]
        else:
            msg = [f"Done. Processed {total} PDF(s).", f"Output: {self._output_dir}"]

        if self._failures_by_file:
            msg.append("")
            msg.append(f"Failed PDFs: {len(self._failures_by_file)}")

            items = list(self._failures_by_file.items())
            max_show = 10
            for pdf, reason in items[:max_show]:
                msg.append(f"- {pdf.name}: {reason}")

            if len(items) > max_show:
                msg.append(f"... and {len(items) - max_show} more.")

        if warning_lines:
            msg.append("")
            msg.append("Suspected tiled images detected (heuristic):")
            msg.extend(warning_lines)

        if self._analysis_notes_by_file:
            msg.append("")
            msg.append("Incomplete results:")
            for pdf, note in self._analysis_notes_by_file.items():
                msg.append(f"- {pdf.name}: {note}")

        title = "Canceled" if self._last_run_canceled else "Finished"
        QMessageBox.information(self, title, "\n".join(msg))

        self._show_table_empty_state()

        status = "Canceled." if self._last_run_canceled else "Finished."
        self.statusbar.showMessage(status, 5000)

    def _on_worker_failed(self, message: str) -> None:
        self._close_progress_dialog()
        self._set_running_ui(False)
        self._analysis_has_run = True
        self.detailWidget.setVisible(False)
        self._show_table_empty_state()
        mb = QMessageBox(self)
        mb.setIcon(QMessageBox.Icon.Critical)
        mb.setWindowTitle("Failed")
        mb.setText("Analysis failed due to an unexpected error.")
        mb.setDetailedText(message)
        mb.exec()

        self.statusbar.showMessage("Failed.", 5000)


    def _cleanup_worker(self) -> None:
        self._close_progress_dialog()

        self._worker = None
        self._thread = None
        self._cancel_event = None

        self._set_running_ui(False)


    def _close_progress_dialog(self) -> None:
        dlg = self._progress_dialog
        if dlg is None:
            return

        try:
            dlg.canceled.disconnect(self._request_cancel)
        except (TypeError, RuntimeError):
            pass

        dlg.blockSignals(True)
        dlg.hide()
        dlg.deleteLater()
        self._progress_dialog = None


    def _on_file_changed(self, index: int) -> None:
        path = self.fileComboBox.itemData(index)
        if not isinstance(path, Path):
            self._clear_detail()
            self._fill_table_from_rows([])
            self._show_table_empty_state()
            return

        if path in self._failures_by_file:
            self._clear_detail()
            self._fill_table_from_rows([])
            if self._table_placeholder is not None:
                self._table_placeholder.setText(
                    "This PDF could not be processed:\n" + str(self._failures_by_file[path])
                )
                self._table_placeholder.show()
                self._layout_table_placeholder()
            return

        self._clear_detail()
        rows = self._rows_by_file.get(path, [])
        self._fill_table_from_rows(rows)
        note = self._analysis_notes_by_file.get(path)
        if note:
            self.statusbar.showMessage(note, 10000)
        self._show_table_empty_state()


    def _clear_detail(self) -> None:
        self.imagePreviewLabel.setPixmap(QPixmap())
        self.imagePreviewLabel.setText("")

        self._current_preview_pixmap = None
        self._current_preview_loaded_path = None

        self.imageInfoTable.setRowCount(0)
        self.imageInfoTable.clearContents()

        self.recommendationTextEdit.setPlainText("")


    def _load_preview_pixmap(self, image_path: Path, max_long_side: int = MAX_PREVIEW_LONG_SIDE) -> QPixmap | None:
        use_cache = max_long_side <= MAX_PREVIEW_LONG_SIDE
        if use_cache:
            key = f"{image_path.resolve()}|{max_long_side}"
            cached = self._preview_cache.get(key)
            if cached is not None:
                self._preview_cache.move_to_end(key)
                return cached

        reader = QImageReader(str(image_path))
        reader.setAutoTransform(True)

        size = reader.size()
        if size.isValid():
            w = int(size.width())
            h = int(size.height())
            long_side = max(w, h)
            if long_side > 0:
                scale = min(1.0, max_long_side / float(long_side))
                target = QSize(max(1, int(w * scale)), max(1, int(h * scale)))
                reader.setScaledSize(target)

        img = reader.read()
        if img.isNull():
            return None

        pix = QPixmap.fromImage(img)
        if pix.isNull():
            return None

        if use_cache:
            self._preview_cache[key] = pix
            self._preview_cache.move_to_end(key)
            while len(self._preview_cache) > self._preview_cache_max:
                self._preview_cache.popitem(last=False)

        return pix


    def _set_preview(self, image_path: Path | None) -> None:
        if self._updating_preview:
            return

        self._updating_preview = True
        try:
            label = self.imagePreviewLabel

            if image_path is None or not image_path.exists():
                self._current_preview_loaded_path = None
                self._current_preview_pixmap = None
                label.setPixmap(QPixmap())
                label.setText("(preview not available)")
                return

            if image_path != self._current_preview_loaded_path or self._current_preview_pixmap is None:
                pix = self._load_preview_pixmap(image_path)
                self._current_preview_loaded_path = image_path
                self._current_preview_pixmap = pix

            pix = self._current_preview_pixmap
            if pix is None or pix.isNull():
                label.setPixmap(QPixmap())
                label.setText("(failed to load preview)")
                return

            self._apply_preview_scale()

        finally:
            self._updating_preview = False

    
    def _apply_preview_scale(self) -> None:
        label = self.imagePreviewLabel
        pix = self._current_preview_pixmap
        if pix is None or pix.isNull():
            return

        target = label.contentsRect().size()
        if target.width() <= 0 or target.height() <= 0:
            return

        scaled = pix.scaled(
            target,
            Qt.AspectRatioMode.KeepAspectRatio,
            Qt.TransformationMode.SmoothTransformation,
        )
        label.setPixmap(scaled)
        label.setText("")


    def _format_dpi_table(self, value: Any) -> tuple[str, float | None]:
        if not value:
            return ("", None)
        try:
            dx, dy = value
        except Exception:
            v = parse_float(value)
            if v is None:
                return (str(value), None)
            return (f"{v:.1f}", v)

        fx = parse_float(dx)
        fy = parse_float(dy)

        nums = [v for v in (fx, fy) if v is not None]
        sort_val = min(nums) if nums else None

        if fx is not None and fy is not None:
            if abs(fx - fy) < 0.5:
                return (f"{fx:.1f}", sort_val)
            return (f"{fx:.1f}×{fy:.1f}", sort_val)
        if fx is not None:
            return (f"{fx:.1f}", sort_val)
        if fy is not None:
            return (f"{fy:.1f}", sort_val)
        return ("", None)


    def _fill_table_from_rows(self, rows: list[dict[str, Any]]) -> None:
        table = self.resultsTableView

        def set_tip(item: QTableWidgetItem, text: str, *, max_width_px: int = 360) -> None:
            t = (text or "").strip()
            if t:
                item.setToolTip(tooltip_html(t, max_width_px=max_width_px))

        numeric_keys: set[str] = {
            "page",
            "shadow_clip_pct",
            "highlight_clip_pct",
            "contrast_p1p99_pct",
            "colorfulness_hs",
            "color_richness_pct",
        }

        table.setUpdatesEnabled(False)
        table.blockSignals(True)
        table.setSortingEnabled(False)
        header = table.horizontalHeader()
        header.setVisible(bool(rows))
        header.blockSignals(True)
        try:
            table.clearContents()
            table.setRowCount(len(rows))

            for r, rec in enumerate(rows):
                for c, key in enumerate(TABLE_COLUMN_KEYS):
                    if key == "recommendations":
                        text = str(rec.get(key, "") or "")
                        item = QTableWidgetItem(text)
                        tip = str(rec.get("recommendations_long") or text or "")
                        set_tip(item, tip, max_width_px=520)
                        table.setItem(r, c, item)
                        continue

                    if key == "image_id":
                        text = str(rec.get(key, "") or "")
                        item = QTableWidgetItem(text)
                        item.setData(Qt.ItemDataRole.UserRole, rec)
                        set_tip(item, text, max_width_px=360)
                        table.setItem(r, c, item)
                        continue

                    if key == "dpi_pair":
                        txt, sort_val = self._format_dpi_table(rec.get(key))
                        item = NumericItem(txt, sort_val)
                        set_tip(item, txt, max_width_px=360)
                        table.setItem(r, c, item)
                        continue

                    if key in numeric_keys:
                        raw = rec.get(key)
                        num = parse_float(raw)
                        if num is None:
                            txt = "" if raw is None else str(raw).strip()
                            item = NumericItem(txt, None)
                        else:
                            txt = str(int(num)) if key == "page" else f"{num:.2f}"
                            item = NumericItem(txt, num)
                        set_tip(item, item.text(), max_width_px=360)
                        table.setItem(r, c, item)
                        continue

                    text = str(rec.get(key, "") or "")
                    item = QTableWidgetItem(text)
                    set_tip(item, text, max_width_px=360)
                    table.setItem(r, c, item)

            table.resizeColumnsToContents()
            self._recompute_recommendations_content_width()
            self._reco_resize_timer.start()
            table.clearSelection()

        finally:
            header.blockSignals(False)
            table.blockSignals(False)
            table.setSortingEnabled(True)
            table.setUpdatesEnabled(True)
        
        self._rebuild_tag_filter_menu(rows)
        self._apply_tag_filter()



    def _setup_image_info_row(self, row: int, label: str, value: str, reference: str = "") -> None:
        t = self.imageInfoTable
        bold = QFont()
        bold.setBold(True)

        it0 = QTableWidgetItem(label)
        it0.setFont(bold)

        it1 = QTableWidgetItem(value)
        it2 = QTableWidgetItem(reference)

        if label.strip():
            help_text = IMAGE_INFO_LABEL_HELP.get(label, label)
            it0.setToolTip(tooltip_html(help_text, max_width_px=520))

        if value.strip():
            it1.setToolTip(tooltip_html(value, max_width_px=520))

        if reference.strip():
            it2.setToolTip(tooltip_html(reference, max_width_px=520))

        t.setItem(row, 0, it0)
        t.setItem(row, 1, it1)
        t.setItem(row, 2, it2)


    def _fill_image_info_table(self, rec: dict[str, Any]) -> None:
        t = self.imageInfoTable
        t.setRowCount(0)
        t.clearContents()

        def add(label: str, value: str, reference: str = "") -> None:
            r = t.rowCount()
            t.insertRow(r)
            self._setup_image_info_row(r, label, value, reference)

        page = rec.get("page", "")
        image_id = rec.get("image_id", "")
        dpi_pair = rec.get("dpi_pair", (None, None))
        pixels = rec.get("pixels")
        bbox_pt = rec.get("bbox_pt")
        page_dims = rec.get("page_dims")

        add("Page", str(page))
        add("Image ID", str(image_id))

        dpi_txt, _ = self._format_dpi_table(dpi_pair)
        if dpi_txt:
            add("DPI", dpi_txt, IMAGE_INFO_REFERENCE.get("DPI", ""))

        if pixels is not None:
            try:
                w_px, h_px = pixels
                add("Pixels", f"{w_px}×{h_px}")
            except Exception:
                add("Pixels", str(pixels))

        if bbox_pt is not None:
            try:
                x0_pt, y0_pt, x1_pt, y1_pt = bbox_pt
                cm_per_pt = 2.54 / 72.0
                x0_cm = float(x0_pt) * cm_per_pt
                y0_cm = float(y0_pt) * cm_per_pt
                x1_cm = float(x1_pt) * cm_per_pt
                y1_cm = float(y1_pt) * cm_per_pt
                add("BBox [cm]", f"({x0_cm:.2f}, {y0_cm:.2f}) – ({x1_cm:.2f}, {y1_cm:.2f})")
            except Exception:
                add("BBox", str(bbox_pt))

        if page_dims:
            add("Page dimensions [cm]", str(page_dims))

        def add_float(label: str, key: str) -> None:
            v = rec.get(key)
            n = parse_float(v)
            if n is None:
                return
            add(label, f"{n:.2f}", IMAGE_INFO_REFERENCE.get(label, ""))

        add_float("Shadow clip [%]", "shadow_clip_pct")
        add_float("Highlight clip [%]", "highlight_clip_pct")
        add_float("Contrast P1–P99 [%]", "contrast_p1p99_pct")
        add_float("Colorfulness (HS)", "colorfulness_hs")
        add_float("Color richness [%]", "color_richness_pct")

        QTimer.singleShot(0, self._update_image_info_table_min_width)


    def _format_recommendations_for_gui(self, summary: str) -> str:
        tags_str = (summary or "").strip()
        if not tags_str:
            tags_str = DEFAULT_MESSAGE

        bullets = recommendation_bullets_for_record(tags_str)

        if not bullets:
            return ""

        lines: list[str] = []
        for tag, explanation in bullets:
            safe_explanation = html.escape(explanation)
            
            lines.append(f"<b>{tag}:</b><br>{safe_explanation}")

        return "<br><br>".join(lines)


    def _on_table_selection_changed(self) -> None:
        table = self.resultsTableView
        selected_rows = table.selectionModel().selectedRows()
        if not selected_rows:
            self._clear_detail()
            return

        row_idx = selected_rows[0].row()
        item = table.item(row_idx, self._id_col)
        rec = item.data(Qt.ItemDataRole.UserRole)

        self.detailWidget.setVisible(True)

        self.detailWidget.setUpdatesEnabled(False)
        try:
            self._fill_image_info_table(rec)

            saved = rec.get("saved")
            img_path = Path(saved) if isinstance(saved, str) and saved else None
            self._set_preview(img_path)

            summary = rec.get("recommendations") or ""
            
            html_content = self._format_recommendations_for_gui(summary)
            self.recommendationTextEdit.clear()
            self.recommendationTextEdit.appendHtml(html_content)
            
        finally:
            self.detailWidget.setUpdatesEnabled(True)


    def _recompute_recommendations_content_width(self) -> None:
        table = self.resultsTableView
        reco_col = self._reco_col
        if table.columnCount() == 0:
            return
        table.resizeColumnToContents(reco_col)
        self._reco_content_width = max(table.columnWidth(reco_col), 260)


    def _update_recommendations_column_width(self) -> None:
        if self._in_reco_width_update:
            return
        self._in_reco_width_update = True
        try:
            table = self.resultsTableView
            reco_col = self._reco_col
            if table.columnCount() == 0:
                return

            other_width = 0
            for c in range(table.columnCount()):
                if c != reco_col:
                    other_width += table.columnWidth(c)

            viewport_width = table.viewport().width()

            desired = max(self._reco_content_width, viewport_width - other_width)
            desired = max(desired, 260)

            self._in_programmatic_column_resize = True
            try:
                table.setColumnWidth(reco_col, desired)
            finally:
                self._in_programmatic_column_resize = False

        finally:
            self._in_reco_width_update = False


    def _ensure_header_section_fits(self, logical_index: int, *, include_sort_indicator: bool) -> None:
        table = self.resultsTableView
        header = table.horizontalHeader()

        it = table.horizontalHeaderItem(logical_index)
        text = it.text() if it is not None else ""
        text = str(text or "")

        fm = header.fontMetrics()
        text_w = fm.horizontalAdvance(text)

        style = header.style()
        mark = style.pixelMetric(QStyle.PixelMetric.PM_HeaderMarkSize, None, header)
        margin = style.pixelMetric(QStyle.PixelMetric.PM_HeaderMargin, None, header)

        if mark <= 0:
            mark = 18
        if margin <= 0:
            margin = 6

        extra = 2 * margin
        if include_sort_indicator:
            extra += mark + margin

        required = text_w + extra
        if table.columnWidth(logical_index) < required:
            self._in_programmatic_column_resize = True
            try:
                table.setColumnWidth(logical_index, required)
            finally:
                self._in_programmatic_column_resize = False


    def _on_sort_indicator_changed(self, logical_index: int, _: Qt.SortOrder) -> None:
        table = self.resultsTableView
        prev = self._last_sorted_section

        if prev is not None and prev != logical_index:
            old_width = self._header_width_before_sort.pop(prev, None)
            if old_width is not None:
                self._in_programmatic_column_resize = True
                try:
                    table.setColumnWidth(prev, old_width)
                finally:
                    self._in_programmatic_column_resize = False

                self._ensure_header_section_fits(prev, include_sort_indicator=False)

        if logical_index >= 0:
            if logical_index not in self._header_width_before_sort:
                self._header_width_before_sort[logical_index] = table.columnWidth(logical_index)

            self._ensure_header_section_fits(logical_index, include_sort_indicator=True)
            self._last_sorted_section = logical_index
        else:
            self._last_sorted_section = None


    def _setup_image_info_table(self) -> None:
        t = self.imageInfoTable

        t.setColumnCount(3)
        t.horizontalHeader().setVisible(False)
        t.verticalHeader().setVisible(False)

        t.setFocusPolicy(Qt.FocusPolicy.NoFocus)
        t.setAlternatingRowColors(True)
        t.setShowGrid(False)
        t.setWordWrap(False)

        t.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        t.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)

        header = t.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
        header.setStretchLastSection(True)


    def _update_image_info_table_min_width(self) -> None:
        t = self.imageInfoTable
        header = t.horizontalHeader()

        old0 = header.sectionResizeMode(0)
        old1 = header.sectionResizeMode(1)
        old2 = header.sectionResizeMode(2)

        header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)

        t.resizeColumnsToContents()

        reference_width = min(t.columnWidth(2), 320)
        width = t.frameWidth() * 2 + t.columnWidth(0) + t.columnWidth(1) + reference_width

        if t.verticalScrollBar().isVisible():
            width += t.verticalScrollBar().sizeHint().width()

        header.setSectionResizeMode(0, old0)
        header.setSectionResizeMode(1, old1)
        header.setSectionResizeMode(2, old2)
        header.setStretchLastSection(True)

        t.setMinimumWidth(width)


    def _open_fullscreen_preview(self) -> None:
        path = self._current_preview_loaded_path
        if path is None or not path.exists():
            return

        pix = self._load_preview_pixmap(path, max_long_side=6000)
        if pix is None or pix.isNull():
            return

        dlg = ImageViewerDialog(pix, path.name, self)
        dlg.showMaximized()
        dlg.exec()


    def eventFilter(self, obj, event) -> bool:
        if obj is self.imagePreviewLabel:
            if event.type() == QEvent.Type.MouseButtonDblClick:
                self._open_fullscreen_preview()
                return True

            if event.type() == QEvent.Type.Resize:
                if self._current_preview_pixmap is not None:
                    QTimer.singleShot(0, self._apply_preview_scale)
                return False

        if obj is self.resultsTableView.viewport() and event.type() == QEvent.Type.Resize:
            self._reco_resize_timer.start()
            self._layout_table_placeholder()
            return False

        return super().eventFilter(obj, event)
 
    
    def _on_header_section_resized(self, logical_index: int, _: int, new_size: int) -> None:
        if self._in_programmatic_column_resize:
            return

        if logical_index in self._header_width_before_sort:
            self._header_width_before_sort[logical_index] = int(new_size)


