from __future__ import annotations

import faulthandler
import json
import os
import queue
import sys
import threading
import time
import traceback
import webbrowser
from pathlib import Path

from PySide6.QtCore import Qt, QThreadPool, QTimer, Signal, QObject, Slot
from PySide6.QtGui import QColor, QFont, QPainter, QPixmap
from PySide6.QtWidgets import (
    QApplication, QCheckBox, QComboBox, QFileDialog, QFormLayout,
    QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
    QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton,
    QScrollArea, QSizePolicy, QSpinBox, QSplitter, QTabWidget, QTableWidget,
    QTableWidgetItem, QTextBrowser, QVBoxLayout, QWidget, QInputDialog,
)

from armory_images import enrich_inventory_images
from awakening_theme import (
    GOLD_BRIGHT, GREEN, MUTED, WARNING, Panel, SectionTitle, apply_theme,
    asset_path, resource_dir,
)
from awakening_widgets import (
    CheckableComboBox, ImageTriptych, ItemCardOverlay, Worker,
    inventory_details, stats_text, stats_html,
)
from catalog_export import export_catalog_xlsx
from crafting import all_direct_crafts
from database import Database
from display_filters import (
    ALL, craft_filter_values, craft_matches, inventory_filter_values, item_matches,
)
from excel_export import export_inventory
from grammar import base_grammar, inflect_adjective, legendary_word
from image_repository import ImageRepository
from inventory_parser import parse_armory_html
from item_catalog import CatalogIdentity, ItemCatalog, VariantSelection
from models import Identity, InventoryItem, Plan, QUALITY_NAMES, level_label, level_parts
from official_stats import OfficialItemClient
from planner import Planner
from rules import RuleBook

APP_NAME = "BW Crafting Tool by Arg"
VERSION = "0.15.2-beta"


def app_storage_dir() -> Path:
    root = Path(os.environ.get("APPDATA") or (Path.home() / ".local" / "share"))
    path = root / "BW Crafting Planner"
    path.mkdir(parents=True, exist_ok=True)
    return path


_CRASH_LOG_HANDLE = None
_CRASH_LOG_LOCK = threading.Lock()


def enable_crash_logging() -> None:
    """Keep a useful traceback even in a windowed PyInstaller build."""
    global _CRASH_LOG_HANDLE
    try:
        log_path = app_storage_dir() / "crash.log"
        _CRASH_LOG_HANDLE = log_path.open("a", encoding="utf-8", buffering=1)
        _CRASH_LOG_HANDLE.write(
            f"\n--- start {time.strftime('%Y-%m-%d %H:%M:%S')} | {APP_NAME} {VERSION} ---\n"
        )
        faulthandler.enable(file=_CRASH_LOG_HANDLE, all_threads=True)

        def log_unhandled(exc_type, exc_value, exc_traceback):
            traceback.print_exception(
                exc_type, exc_value, exc_traceback, file=_CRASH_LOG_HANDLE
            )
            _CRASH_LOG_HANDLE.flush()
            sys.__excepthook__(exc_type, exc_value, exc_traceback)

        sys.excepthook = log_unhandled
    except Exception:
        # Logging must never stop the application from launching.
        _CRASH_LOG_HANDLE = None


def crash_note(message: str) -> None:
    """Append a small breadcrumb that survives a later native Qt crash."""
    if _CRASH_LOG_HANDLE is None:
        return
    try:
        with _CRASH_LOG_LOCK:
            _CRASH_LOG_HANDLE.write(
                f"[{time.strftime('%H:%M:%S')}] "
                f"thread={threading.current_thread().name}/{threading.get_ident()} "
                f"{message}\n"
            )
            _CRASH_LOG_HANDLE.flush()
    except Exception:
        pass


class BackgroundWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.setObjectName("centralRoot")
        self._background = QPixmap(str(asset_path("awakening_background.jpg")))

    def paintEvent(self, event):
        painter = QPainter(self)
        if not self._background.isNull():
            scaled = self._background.scaled(self.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
            x = (scaled.width() - self.width()) // 2
            y = (scaled.height() - self.height()) // 2
            painter.drawPixmap(0, 0, scaled, x, y, self.width(), self.height())
        painter.fillRect(self.rect(), QColor(0, 0, 0, 78))
        super().paintEvent(event)


class PlannerProgressBridge(QObject):
    event = Signal(object)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle(APP_NAME)
        self.resize(1540, 930)
        self.setMinimumSize(1180, 760)

        self.rules = RuleBook(resource_dir() / "data")
        self.db = Database(app_storage_dir() / "bw_planner.db")
        self.planner = Planner(self.rules)
        self.item_catalog = ItemCatalog(self.rules)
        self.images = ImageRepository(app_storage_dir() / "images")
        self.thread_pool = QThreadPool.globalInstance()
        # Keep QRunnable wrappers and their signal objects alive until every
        # queued GUI callback has been delivered.  Without this, PySide may
        # destroy a worker on the pool thread while its result is still waiting
        # in the main event queue, which can end in a native access violation.
        self._active_workers: set[Worker] = set()

        self.current_profile_id: int | None = None
        self.profile_ids: list[int] = []
        self.current_items: list[InventoryItem] = []
        self.current_direct = []
        self.filtered_inventory: list[InventoryItem] = []
        self.filtered_direct = []
        self.current_plans: list[Plan] = []
        self.current_plan: Plan | None = None
        self._live_plan_signatures: set[tuple] = set()
        self.encyclopedia_visible_rows: list[CatalogIdentity] = []
        self.encyclopedia_row_map: list[CatalogIdentity] = []
        self.fetch_running = False
        self.planner_running = False
        self._planner_cancel_requested = False
        self._planner_batch_elapsed = 0.0
        self._planner_batch_timed_out = False
        self._planner_batch_cancelled = False
        self._planner_batch_nodes = 0
        self._planner_target_count = 0
        self._image_prefetch_running = False
        self._item_fetch_serial = 0
        # One official BloodWars request at a time. New clicks are deliberately
        # NOT queued; the user can click again after the active request ends.
        self._official_fetch_context = ""
        self._official_fetch_cache_key = ""
        self._official_fetch_token = ""
        self._planner_allow_missing = False
        self._planner_search_all_depths = True

        self.root = BackgroundWidget()
        self.setCentralWidget(self.root)
        self.root_layout = QVBoxLayout(self.root)
        self.root_layout.setContentsMargins(12, 10, 12, 12)
        self.root_layout.setSpacing(8)
        self._build_top_bar()
        self._build_tabs()
        self.item_overlay = ItemCardOverlay(self.images, self.root)
        self.refresh_profiles()

    # ---------- generic UI helpers ----------
    def _start_worker(self, worker: Worker) -> None:
        """Start a retained worker and release it only from the GUI thread."""
        self._active_workers.add(worker)
        worker.signals.release.connect(
            self._release_finished_worker,
            Qt.ConnectionType.QueuedConnection,
        )
        self.thread_pool.start(worker)

    @Slot(object)
    def _release_finished_worker(self, worker: Worker) -> None:
        self._active_workers.discard(worker)

    def status(self, text: str, *, error: bool = False) -> None:
        self.status_label.setText(text)
        self.status_label.setStyleSheet(f"color:{'#ff9b75' if error else '#d9c7a7'};padding:2px 8px;")

    @staticmethod
    def _combo(combo: QComboBox, values: list[str], selected: str | None = None) -> None:
        current = selected if selected is not None else combo.currentText()
        combo.blockSignals(True)
        combo.clear()
        combo.addItems(values)
        if current in values:
            combo.setCurrentText(current)
        elif values:
            combo.setCurrentIndex(0)
        combo.blockSignals(False)

    @staticmethod
    def _check_combo(combo: CheckableComboBox, values: list[str], selected=None) -> None:
        combo.set_values([value for value in values if value != ALL], selected=selected)

    @staticmethod
    def _table_setup(table: QTableWidget, headers: list[str], widths: list[int] | None = None) -> None:
        table.setColumnCount(len(headers))
        table.setHorizontalHeaderLabels(headers)
        table.setAlternatingRowColors(True)
        table.setSelectionBehavior(QTableWidget.SelectRows)
        table.setSelectionMode(QTableWidget.SingleSelection)
        table.setEditTriggers(QTableWidget.NoEditTriggers)
        table.setSortingEnabled(True)
        table.verticalHeader().setVisible(False)
        table.horizontalHeader().setStretchLastSection(False)
        if widths:
            for index, width in enumerate(widths):
                table.setColumnWidth(index, width)
        table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive)

    @staticmethod
    def _item(text, *, color: str | None = None, bold: bool = False, data=None) -> QTableWidgetItem:
        item = QTableWidgetItem(str(text if text is not None else ""))
        if color:
            item.setForeground(QColor(color))
        if bold:
            font = item.font()
            font.setBold(True)
            item.setFont(font)
        if data is not None:
            item.setData(Qt.UserRole, data)
        return item

    def resizeEvent(self, event):
        super().resizeEvent(event)
        QTimer.singleShot(0, self._position_item_overlay)

    def _position_item_overlay(self):
        if not hasattr(self, "item_overlay"):
            return
        width = max(720, min(920, self.root.width() - 70))
        height = max(410, min(520, self.root.height() - 90))
        self.item_overlay.resize(width, height)
        self.item_overlay.move(
            max(10, (self.root.width() - width) // 2),
            max(10, (self.root.height() - height) // 2),
        )
        if self.item_overlay.isVisible():
            self.item_overlay.raise_()

    @staticmethod
    def _catalog_identity(identity: Identity) -> CatalogIdentity:
        return CatalogIdentity(identity.category, identity.prefix, identity.base, identity.suffix)

    def _variant_for_identity(self, identity: Identity, level: int, player_level: int = 80) -> VariantSelection:
        rank, upgrade = level_parts(level)
        return VariantSelection(
            self._catalog_identity(identity),
            quality=QUALITY_NAMES[rank],
            upgrade=upgrade,
            legendary=identity.legendary,
            player_level=player_level,
        )

    def _official_paths_from_stats(self, row: dict) -> tuple[str, str, str]:
        main = self.images.cached_path_for_url(row.get("main_image_url") or "", "main")
        prefix = self.images.cached_path_for_url(row.get("prefix_image_url") or "", "prefix")
        suffix = self.images.cached_path_for_url(row.get("suffix_image_url") or "", "suffix")
        return str(main or ""), str(prefix or ""), str(suffix or "")

    def _complete_stats_image_urls(self, stats, selection: VariantSelection) -> None:
        """Fill missing tester image URLs from the bundled official registry.

        The live tester currently exposes prefix/suffix graphics through nested
        links. The parser handles that layout, but this registry fallback also
        repairs responses or old cache entries where one URL is absent.
        """
        ident = selection.identity

        def missing_or_broken(url: str) -> bool:
            return not url or "/test_items.php/gfx/" in url

        if missing_or_broken(stats.main_image_url):
            stats.main_image_url = self.images.remote_url("base", ident.category, ident.base)
        if ident.prefix and missing_or_broken(stats.prefix_image_url):
            prefix_key = self.rules.prefix_semantic_key(ident.prefix)
            stats.prefix_image_url = self.images.remote_url("prefix", ident.category, prefix_key)
        if ident.suffix and missing_or_broken(stats.suffix_image_url):
            stats.suffix_image_url = self.images.remote_url("suffix", ident.category, ident.suffix)

    def _cached_stats_need_image_repair(self, selection: VariantSelection, row: dict) -> bool:
        """Return True when cached stats exist but expected image files do not."""
        expected = [("main", row.get("main_image_url") or "")]
        if selection.identity.prefix:
            expected.append(("prefix", row.get("prefix_image_url") or ""))
        if selection.identity.suffix:
            expected.append(("suffix", row.get("suffix_image_url") or ""))
        return any(
            not url
            or "/test_items.php/gfx/" in url
            or not self.images.cached_path_for_url(url, kind)
            for kind, url in expected
        )

    def show_item_card(
        self,
        identity: Identity,
        level: int,
        details: list[tuple[str, str]],
        *,
        title: str | None = None,
        main_image: str = "",
        prefix_image: str = "",
        suffix_image: str = "",
        player_level: int = 80,
    ) -> None:
        """Open an in-window card and transparently fill official data in the background."""
        self._item_fetch_serial += 1
        token = f"item-{self._item_fetch_serial}-{time.time_ns()}"
        selection = self._variant_for_identity(identity, level, player_level)
        cached_row = self.db.get_encyclopedia_stats(selection.cache_key())
        cached = dict(cached_row) if cached_row else None
        needs_fetch = not cached
        if cached:
            official_main, official_prefix, official_suffix = self._official_paths_from_stats(cached)
            main_image = official_main or main_image
            prefix_image = official_prefix or prefix_image
            suffix_image = official_suffix or suffix_image
            needs_fetch = self._cached_stats_need_image_repair(selection, cached)
        display_title = title or self.rules.identity_display(identity)
        busy = bool(self.fetch_running and needs_fetch)
        if busy and self._official_fetch_cache_key == selection.cache_key():
            # Keep the active worker token so its queued result can still update
            # the card if the same item is clicked again during the fetch.
            token = self._official_fetch_token or token
            loading = (
                "Pobieranie tego przedmiotu już trwa. Nie uruchomiono drugiego zapytania."
            )
        elif busy:
            loading = (
                "Trwa pobieranie innego przedmiotu. Ten przedmiot nie został dodany do kolejki. "
                "Kliknij go ponownie po zakończeniu bieżącego pobierania."
            )
        else:
            if cached and needs_fetch:
                loading = "Statystyki są w cache. Pobieranie brakujących grafik prefixu/suffixu…"
            else:
                loading = "" if cached else "Pobieranie oficjalnych statystyk i grafik…"

        self.item_overlay.show_item(
            token=token,
            title=display_title,
            details=details,
            main_image=main_image,
            prefix_image=prefix_image,
            suffix_image=suffix_image,
            category=identity.category,
            base=self.rules.display(identity.base),
            prefix_name=self.rules.prefix_display(identity.prefix, identity.base) if identity.prefix else "",
            suffix_name=self.rules.display(identity.suffix) if identity.suffix else "",
            base_key=identity.base,
            prefix_key=self.rules.prefix_semantic_key(identity.prefix) if identity.prefix else "",
            suffix_key=identity.suffix,
            legendary=identity.legendary,
            stats=cached,
            loading=loading,
        )
        self._position_item_overlay()
        if needs_fetch and not busy:
            self._fetch_item_card_stats(selection, token)

    def _fetch_item_card_stats(self, selection: VariantSelection, token: str) -> None:
        if not self._begin_official_fetch("karta przedmiotu", selection.cache_key()):
            self.item_overlay.update_error(
                token,
                "Trwa już inne pobieranie. Nie dodano tego zapytania do kolejki; kliknij ponownie później.",
            )
            return
        self._official_fetch_token = token
        crash_note(f"item fetch scheduled token={token} key={selection.cache_key()}")

        def task(progress):
            # A fresh client/session belongs only to this worker. Sharing a
            # requests.Session between simultaneous workers is intentionally avoided.
            crash_note(f"item fetch worker started token={token}")
            client = OfficialItemClient(self.rules)
            stats = client.fetch(selection)
            self._complete_stats_image_urls(stats, selection)
            crash_note(
                f"item stats parsed token={token} images="
                f"{bool(stats.main_image_url)}/{bool(stats.prefix_image_url)}/{bool(stats.suffix_image_url)}"
            )
            paths = client.download_images(stats, self.images.official_dir)
            crash_note(f"item images downloaded token={token}")
            return token, stats, paths

        worker = Worker(task)
        worker.signals.result.connect(
            self._item_card_fetch_done,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.error.connect(
            self._item_card_fetch_failed,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.finished.connect(
            self._finish_official_fetch,
            Qt.ConnectionType.QueuedConnection,
        )
        self._start_worker(worker)

    @Slot(object)
    def _item_card_fetch_done(self, payload) -> None:
        token, stats, paths = payload
        crash_note(f"item GUI result entered token={token}")
        try:
            self.db.upsert_encyclopedia_stats(stats)
        except Exception as exc:  # noqa: BLE001
            self.item_overlay.update_error(token, str(exc))
            return
        data = stats.to_dict()
        self.item_overlay.update_official(
            token,
            data,
            main=paths.get("main", ""),
            prefix=paths.get("prefix", ""),
            suffix=paths.get("suffix", ""),
            status="Oficjalne statystyki i grafiki zapisano w lokalnym cache.",
        )
        self.status("Pobieranie statystyk przedmiotu zakończone.")
        crash_note(f"item GUI result completed token={token}")

    @Slot(object)
    def _item_card_fetch_failed(self, error: Exception) -> None:
        token = self._official_fetch_token
        crash_note(f"item GUI error token={token}: {error}")
        if token:
            self.item_overlay.update_error(token, str(error))
        self.status(f"Błąd pobierania statystyk: {error}", error=True)

    def _build_top_bar(self):
        top = QFrame()
        top.setObjectName("topBar")
        layout = QHBoxLayout(top)
        layout.setContentsMargins(10, 6, 10, 6)
        logo = QLabel()
        pix = QPixmap(str(asset_path("BW_logo.png")))
        if not pix.isNull():
            logo.setPixmap(pix.scaledToHeight(37, Qt.SmoothTransformation))
        layout.addWidget(logo)
        title = QLabel("CRAFTING TOOL BY ARG")
        title.setStyleSheet("font-family:Georgia;font-size:17px;font-weight:700;color:#f3d17c;")
        layout.addWidget(title)
        layout.addSpacing(18)
        self.import_button = QPushButton("Importuj HTML zbrojowni")
        self.import_button.clicked.connect(self.import_html)
        layout.addWidget(self.import_button)
        self.export_button = QPushButton("Eksportuj bieżący Excel")
        self.export_button.clicked.connect(self.export_excel)
        layout.addWidget(self.export_button)
        layout.addSpacing(18)
        layout.addWidget(QLabel("Profil:"))
        self.profile_combo = QComboBox()
        self.profile_combo.setMinimumWidth(275)
        self.profile_combo.currentIndexChanged.connect(self.on_profile_change)
        layout.addWidget(self.profile_combo)
        layout.addStretch(1)
        self.status_label = QLabel("Gotowe")
        self.status_label.setObjectName("statusLabel")
        layout.addWidget(self.status_label)
        self.root_layout.addWidget(top)

    def _build_tabs(self):
        self.tabs = QTabWidget()
        self.root_layout.addWidget(self.tabs, 1)
        self.tabs.addTab(self._build_inventory_tab(), "Ekwipunek")
        self.tabs.addTab(self._build_craft_tab(), "Możliwe teraz")
        self.tabs.addTab(self._build_planner_tab(), "Planer celu")
        self.tabs.addTab(self._build_encyclopedia_tab(), "Encyklopedia")
        self.tabs.addTab(self._build_history_tab(), "Historia importów")
        self.tabs.addTab(self._build_saved_tab(), "Zapisane plany")

    # ---------- inventory ----------
    def _build_inventory_tab(self) -> QWidget:
        root = QWidget()
        layout = QVBoxLayout(root)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.addWidget(SectionTitle("ZBROJOWNIA — LOKALNY EKWIPUNEK"))
        self.inventory_stats = QLabel("Brak aktywnego profilu")
        self.inventory_stats.setStyleSheet("color:#f1d58b;font-weight:700;")
        layout.addWidget(self.inventory_stats)
        filters = QGroupBox("Filtry wyświetlania")
        grid = QGridLayout(filters)
        self.inv_search = QLineEdit()
        self.inv_search.setPlaceholderText("Szukaj po nazwie, półce lub ID…")
        self.inv_filters = {
            key: CheckableComboBox()
            for key in ("category", "prefix", "base", "suffix", "legendary", "quality", "upgrade", "shelf")
        }
        labels = [
            ("Szukaj", self.inv_search), ("Kategoria", self.inv_filters["category"]),
            ("Prefix", self.inv_filters["prefix"]), ("Baza", self.inv_filters["base"]),
            ("Suffix", self.inv_filters["suffix"]), ("Legendarny", self.inv_filters["legendary"]),
            ("Jakość", self.inv_filters["quality"]), ("+", self.inv_filters["upgrade"]),
            ("Półka", self.inv_filters["shelf"]),
        ]
        for col, (label, widget) in enumerate(labels):
            grid.addWidget(QLabel(label), 0, col)
            grid.addWidget(widget, 1, col)
            if isinstance(widget, CheckableComboBox):
                widget.selectionChanged.connect(self.refresh_inventory_table)
        self.inv_search.textChanged.connect(self.refresh_inventory_table)
        clear = QPushButton("Wyczyść")
        clear.clicked.connect(self.clear_inventory_filters)
        grid.addWidget(clear, 1, len(labels))
        grid.setColumnStretch(0, 1)
        layout.addWidget(filters)
        self.inventory_table = QTableWidget()
        self._table_setup(
            self.inventory_table,
            ["Nazwa", "Kategoria", "Legendarny", "Jakość", "+", "Prefix", "Baza", "Suffix", "Półka", "Item ID", "Uwagi"],
            [325, 135, 85, 90, 45, 120, 145, 135, 155, 80, 230],
        )
        self.inventory_table.cellClicked.connect(self.open_inventory_item)
        layout.addWidget(self.inventory_table, 1)
        return root

    def clear_inventory_filters(self):
        self.inv_search.clear()
        for combo in self.inv_filters.values():
            combo.select_all()
        self.refresh_inventory_table()

    def refresh_inventory_filter_options(self):
        values = inventory_filter_values(self.current_items, self.rules)
        for key, combo in self.inv_filters.items():
            self._check_combo(combo, values[key])

    def refresh_inventory_table(self):
        if not hasattr(self, "inventory_table"):
            return
        selections = {key: combo.selected_values() for key, combo in self.inv_filters.items()}
        self.filtered_inventory = [
            item for item in self.current_items
            if item_matches(item, self.rules, search=self.inv_search.text(), **selections)
        ]
        table = self.inventory_table
        table.setSortingEnabled(False)
        table.setRowCount(len(self.filtered_inventory))
        for row, item in enumerate(self.filtered_inventory):
            ident = item.identity
            values = [
                item.full_name, self.rules.category_label(ident.category), "TAK" if ident.legendary else "NIE",
                item.quality, f"+{item.upgrade}" if item.upgrade else "",
                self.rules.prefix_filter_label(ident.prefix) if ident.prefix else "Brak",
                self.rules.display(ident.base), self.rules.display(ident.suffix) if ident.suffix else "Brak",
                item.shelf, item.item_id, item.parser_notes,
            ]
            for col, value in enumerate(values):
                cell = self._item(value, bold=(col == 0), data=row if col == 0 else None)
                if ident.legendary:
                    cell.setBackground(QColor("#4c3913"))
                    if col in (0, 2):
                        cell.setForeground(QColor("#ffd75e"))
                if item.parser_notes:
                    cell.setBackground(QColor("#4d1718"))
                table.setItem(row, col, cell)
        table.setSortingEnabled(True)
        active = sum(1 for v in selections.values() if v) + (1 if self.inv_search.text().strip() else 0)
        self.inventory_stats.setText(
            f"Przedmioty: {len(self.current_items):,} | widoczne: {len(self.filtered_inventory):,} | aktywne filtry: {active}"
        )

    def open_inventory_item(self, row: int, _column: int):
        cell = self.inventory_table.item(row, 0)
        index = cell.data(Qt.UserRole) if cell else row
        if not isinstance(index, int) or not (0 <= index < len(self.filtered_inventory)):
            return
        item = self.filtered_inventory[index]
        self.show_item_card(
            item.identity, item.level, inventory_details(item, self.rules),
            title=item.full_name,
            main_image=item.main_image, prefix_image=item.prefix_image, suffix_image=item.suffix_image,
        )

    # ---------- direct crafts ----------
    def _build_craft_tab(self) -> QWidget:
        root = QWidget()
        layout = QVBoxLayout(root)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.addWidget(SectionTitle("MOŻLIWE POŁĄCZENIA Z AKTUALNEGO EKWIPUNKU"))
        top = QHBoxLayout()
        recalc = QPushButton("Przelicz połączenia")
        recalc.clicked.connect(self.recalculate_direct)
        top.addWidget(recalc)
        self.direct_stats = QLabel("Pary: 0")
        self.direct_stats.setStyleSheet("color:#f1d58b;font-weight:700;")
        top.addWidget(self.direct_stats)
        top.addStretch(1)
        layout.addLayout(top)
        filters = QGroupBox("Filtry wyniku")
        grid = QGridLayout(filters)
        self.direct_search = QLineEdit()
        self.direct_filters = {
            key: CheckableComboBox()
            for key in ("category", "prefix", "base", "suffix", "legendary", "quality", "upgrade")
        }
        defs = [
            ("Szukaj", self.direct_search), ("Kategoria", self.direct_filters["category"]),
            ("Prefix", self.direct_filters["prefix"]), ("Baza", self.direct_filters["base"]),
            ("Suffix", self.direct_filters["suffix"]), ("Legendarny", self.direct_filters["legendary"]),
            ("Jakość", self.direct_filters["quality"]), ("+", self.direct_filters["upgrade"]),
        ]
        for col, (label, widget) in enumerate(defs):
            grid.addWidget(QLabel(label), 0, col)
            grid.addWidget(widget, 1, col)
            if isinstance(widget, CheckableComboBox):
                widget.selectionChanged.connect(self.refresh_direct_table)
        self.direct_search.textChanged.connect(self.refresh_direct_table)
        clear = QPushButton("Wyczyść")
        clear.clicked.connect(self.clear_direct_filters)
        grid.addWidget(clear, 1, len(defs))
        grid.setColumnStretch(0, 1)
        layout.addWidget(filters)
        self.direct_table = QTableWidget()
        self._table_setup(
            self.direct_table,
            ["Wynik", "Kategoria", "Prefix", "Baza", "Suffix", "Poziom", "Legendarny", "Składnik 1", "Składnik 2"],
            [300, 125, 115, 135, 125, 95, 80, 310, 310],
        )
        self.direct_table.cellClicked.connect(self.open_direct_item)
        layout.addWidget(self.direct_table, 1)
        return root

    def recalculate_direct(self):
        self.status("Przeliczanie wszystkich fizycznych par…")
        self.current_direct = all_direct_crafts(self.current_items, self.rules)
        self.refresh_direct_filter_options()
        self.refresh_direct_table()
        self.status(f"Gotowe — {len(self.current_direct):,} możliwych par.")

    def refresh_direct_filter_options(self):
        values = craft_filter_values(self.current_direct, self.rules)
        for key, combo in self.direct_filters.items():
            self._check_combo(combo, values[key])

    def clear_direct_filters(self):
        self.direct_search.clear()
        for combo in self.direct_filters.values():
            combo.select_all()
        self.refresh_direct_table()

    def refresh_direct_table(self):
        if not hasattr(self, "direct_table"):
            return
        selections = {key: combo.selected_values() for key, combo in self.direct_filters.items()}
        self.filtered_direct = [
            option for option in self.current_direct
            if craft_matches(option, self.rules, search=self.direct_search.text(), **selections)
        ]
        table = self.direct_table
        table.setSortingEnabled(False)
        table.setRowCount(len(self.filtered_direct))
        for row, option in enumerate(self.filtered_direct):
            ident = option.result
            rank, upgrade = level_parts(option.result_level)
            values = [
                self.rules.identity_display(ident), self.rules.category_label(ident.category),
                self.rules.prefix_filter_label(ident.prefix) if ident.prefix else "Brak",
                self.rules.display(ident.base), self.rules.display(ident.suffix) if ident.suffix else "Brak",
                level_label(option.result_level), "TAK" if ident.legendary else "NIE",
                option.left.full_name, option.right.full_name,
            ]
            for col, value in enumerate(values):
                cell = self._item(value, bold=(col == 0), data=row if col == 0 else None)
                if ident.legendary:
                    cell.setBackground(QColor("#4c3913"))
                    if col in (0, 6):
                        cell.setForeground(QColor("#ffd75e"))
                table.setItem(row, col, cell)
        table.setSortingEnabled(True)
        active = sum(1 for v in selections.values() if v) + (1 if self.direct_search.text().strip() else 0)
        self.direct_stats.setText(
            f"Pary: {len(self.current_direct):,} | widoczne: {len(self.filtered_direct):,} | aktywne filtry: {active}"
        )

    def open_direct_item(self, row: int, _column: int):
        cell = self.direct_table.item(row, 0)
        index = cell.data(Qt.UserRole) if cell else row
        if not isinstance(index, int) or not (0 <= index < len(self.filtered_direct)):
            return
        option = self.filtered_direct[index]
        ident = option.result
        details = [
            ("Kategoria", self.rules.category_label(ident.category)),
            ("Poziom wyniku", level_label(option.result_level)),
            ("Legendarny", "TAK" if ident.legendary else "NIE"),
            ("Prefix", self.rules.prefix_filter_label(ident.prefix) if ident.prefix else "Brak"),
            ("Baza", self.rules.display(ident.base)),
            ("Suffix", self.rules.display(ident.suffix) if ident.suffix else "Brak"),
            ("Składnik 1", option.left.full_name),
            ("Składnik 2", option.right.full_name),
        ]
        if option.notes:
            details.append(("Uwagi", "; ".join(option.notes)))
        self.show_item_card(ident, option.result_level, details)

    # ---------- planner ----------
    def _build_planner_tab(self) -> QWidget:
        root = QWidget()
        layout = QVBoxLayout(root)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.addWidget(SectionTitle("PLANER WIELOETAPOWEGO ŁĄCZENIA"))

        controls = QGroupBox("Cel")
        grid = QGridLayout(controls)
        self.target_category = CheckableComboBox(allow_all=False)
        self.target_prefix = CheckableComboBox(allow_all=False)
        self.target_base = CheckableComboBox(allow_all=False)
        self.target_suffix = CheckableComboBox(allow_all=False)
        self.target_legendary = QCheckBox("Legendarny")
        self.target_quality = QComboBox()
        self.target_quality.addItems(["Dowolna"] + QUALITY_NAMES)
        self.target_upgrade = QSpinBox()
        self.target_upgrade.setRange(0, 5)
        self.target_depth = QSpinBox()
        self.target_depth.setRange(1, 5)
        self.target_depth.setValue(4)
        self.target_search_all_depths = QCheckBox("Szukaj do końca")
        self.target_search_all_depths.setChecked(True)
        self.target_search_all_depths.setToolTip(
            "Gdy zaznaczone, planer nie kończy po pierwszej skutecznej głębokości. "
            "Przeszukuje wszystkie warstwy aż do wartości ustawionej obok."
        )
        self.target_timeout = QComboBox()
        self.target_timeout.setEditable(True)
        self.target_timeout.addItems(["1", "5", "10", "30", "60", "Bez limitu"])
        self.target_timeout.setCurrentText("30")
        widgets = [
            ("Kategoria", self.target_category), ("Prefix", self.target_prefix),
            ("Baza", self.target_base), ("Suffix", self.target_suffix),
            ("", self.target_legendary), ("Min. jakość", self.target_quality),
            ("Min. +", self.target_upgrade), ("Głębokość końcowa", self.target_depth),
            ("Tryb głębokości", self.target_search_all_depths),
            ("Limit czasu (min)", self.target_timeout),
        ]
        for col, (label, widget) in enumerate(widgets):
            if label:
                grid.addWidget(QLabel(label), 0, col)
            grid.addWidget(widget, 1, col)
        self.find_owned_button = QPushButton("Znajdź z ekwipunku")
        self.find_owned_button.clicked.connect(lambda: self.run_planner(False))
        self.find_missing_button = QPushButton("Plan z brakami")
        self.find_missing_button.setObjectName("goldButton")
        self.find_missing_button.clicked.connect(lambda: self.run_planner(True))
        self.cancel_planner_button = QPushButton("Anuluj")
        self.cancel_planner_button.setEnabled(False)
        self.cancel_planner_button.clicked.connect(self.cancel_planner)
        grid.addWidget(self.find_owned_button, 1, len(widgets))
        grid.addWidget(self.find_missing_button, 1, len(widgets) + 1)
        grid.addWidget(self.cancel_planner_button, 1, len(widgets) + 2)
        layout.addWidget(controls)

        self.target_category.set_values([self.rules.category_label(c) for c in self.rules.categories()])
        self.target_category.selectionChanged.connect(self.refresh_target_options)
        self.refresh_target_options()

        splitter = QSplitter(Qt.Horizontal)
        left = Panel()
        left_layout = QVBoxLayout(left)
        left_layout.addWidget(SectionTitle("KOLEJNOŚĆ ŁĄCZENIA / BRAKI"))
        self.plan_summary = QLabel("Wybierz cel i uruchom wyszukiwanie.")
        self.plan_summary.setWordWrap(True)
        self.plan_summary.setStyleSheet("color:#eed9a0;font-weight:700;")
        left_layout.addWidget(self.plan_summary)
        self.plan_steps = QTableWidget()
        self._table_setup(self.plan_steps, ["Krok", "Składnik 1", "Składnik 2", "Wynik", "Poziom"], [55, 235, 235, 235, 95])
        self.plan_steps.setSortingEnabled(False)
        self.plan_steps.cellClicked.connect(self.open_plan_component)
        left_layout.addWidget(self.plan_steps, 1)
        save = QPushButton("Zapisz wybrany plan")
        save.clicked.connect(self.save_selected_plan)
        left_layout.addWidget(save)
        splitter.addWidget(left)

        right = Panel()
        right_layout = QVBoxLayout(right)
        right_layout.addWidget(SectionTitle("PLANY ZNALEZIONE NA ŻYWO"))
        self.live_plan_hint = QLabel(
            "Plany pojawiają się tutaj na żywo. Pełny tryb głębokości zachowuje także warianty wieloetapowe."
        )
        self.live_plan_hint.setWordWrap(True)
        self.live_plan_hint.setStyleSheet("color:#bda98a;")
        right_layout.addWidget(self.live_plan_hint)
        self.plan_table = QTableWidget()
        self._table_setup(
            self.plan_table,
            ["#", "Cel", "Warstw", "Kroków", "Braków", "Wynik", "Ocena"],
            [45, 250, 70, 70, 70, 115, 85],
        )
        self.plan_table.cellClicked.connect(self.select_plan)
        right_layout.addWidget(self.plan_table, 1)
        splitter.addWidget(right)
        splitter.setSizes([860, 470])
        layout.addWidget(splitter, 3)

        console_panel = Panel()
        console_layout = QVBoxLayout(console_panel)
        console_layout.addWidget(SectionTitle("PODGLĄD OPERACJI W CZASIE RZECZYWISTYM"))
        self.planner_console = QPlainTextEdit()
        self.planner_console.setReadOnly(True)
        self.planner_console.setMaximumBlockCount(2500)
        self.planner_console.setFont(QFont("Consolas", 9))
        console_layout.addWidget(self.planner_console)
        bottom = QHBoxLayout()
        self.planner_progress = QProgressBar()
        self.planner_progress.setRange(0, 100)
        self.planner_progress.setValue(0)
        self.planner_progress_label = QLabel("Gotowe")
        bottom.addWidget(self.planner_progress, 1)
        bottom.addWidget(self.planner_progress_label)
        console_layout.addLayout(bottom)
        layout.addWidget(console_panel, 2)
        return root

    def refresh_target_options(self):
        if not hasattr(self, "target_category"):
            return
        labels = self.target_category.selected_values()
        if not labels:
            labels = [self.rules.category_label(self.rules.categories()[0])]
            self.target_category.set_selected_values(labels)
        categories = [
            category for category in self.rules.categories()
            if self.rules.category_label(category) in labels
        ]
        prefixes = ["Brak"] + sorted(
            {self.rules.prefix_filter_label(prefix) for category in categories for prefix in self.rules.prefixes(category)},
            key=str.casefold,
        )
        bases = sorted(
            {self.rules.display(base) for category in categories for base in self.rules.bases(category)},
            key=str.casefold,
        )
        suffixes = ["Brak"] + sorted(
            {self.rules.display(suffix) for category in categories for suffix in self.rules.suffixes(category)},
            key=str.casefold,
        )
        self.target_prefix.set_values(prefixes)
        self.target_base.set_values(bases)
        self.target_suffix.set_values(suffixes)

    def selected_targets(self) -> tuple[list[Identity], int]:
        category_labels = self.target_category.selected_values()
        prefix_labels = self.target_prefix.selected_values()
        base_labels = self.target_base.selected_values()
        suffix_labels = self.target_suffix.selected_values()
        if not all((category_labels, prefix_labels, base_labels, suffix_labels)):
            return [], 0

        if self.target_quality.currentText() == "Dowolna":
            minimum = 0
        else:
            minimum = QUALITY_NAMES.index(self.target_quality.currentText()) * 6 + self.target_upgrade.value()

        targets: list[Identity] = []
        seen: set[str] = set()
        for category in self.rules.categories():
            if self.rules.category_label(category) not in category_labels:
                continue
            base_map = {self.rules.display(base): base for base in self.rules.bases(category)}
            suffix_map = {self.rules.display(suffix): suffix for suffix in self.rules.suffixes(category)}
            for prefix_label in prefix_labels:
                prefix = "" if prefix_label == "Brak" else self.rules.prefix_key_from_filter_label(category, prefix_label)
                if prefix_label != "Brak" and not prefix:
                    continue
                for base_label in base_labels:
                    base = base_map.get(base_label, "")
                    if not base:
                        continue
                    for suffix_label in suffix_labels:
                        suffix = "" if suffix_label == "Brak" else suffix_map.get(suffix_label, "")
                        if suffix_label != "Brak" and not suffix:
                            continue
                        identity = Identity(category, base, prefix, suffix, self.target_legendary.isChecked())
                        if identity.key() not in seen:
                            seen.add(identity.key())
                            targets.append(identity)
        return targets, minimum

    def planner_timeout_value(self) -> float | None:
        """Read the user-entered time limit in minutes and return seconds."""
        text = self.target_timeout.currentText().strip().casefold()
        if text in {"", "0", "bez limitu", "none", "off"}:
            return None
        try:
            minutes = float(text.replace(",", "."))
            return max(1.0, minutes * 60.0)
        except ValueError:
            self.target_timeout.setCurrentText("30")
            return 30.0 * 60.0

    def run_planner(self, allow_missing: bool):
        if self.planner_running:
            return
        targets, minimum = self.selected_targets()
        if not targets:
            QMessageBox.information(self, "Brak celu", "Zaznacz co najmniej jedną kategorię, bazę, prefix i suffix.")
            return
        if len(targets) > 64:
            QMessageBox.warning(
                self,
                "Za dużo celów",
                f"Wybrane filtry tworzą {len(targets)} celów. Zawęź wybór do maksymalnie 64 wariantów.",
            )
            return

        self.current_plans = []
        self.current_plan = None
        self._live_plan_signatures.clear()
        self.plan_table.setRowCount(0)
        self.plan_steps.setRowCount(0)
        search_all_depths = self.target_search_all_depths.isChecked()
        depth = self.target_depth.value()
        self.live_plan_hint.setText(
            f"Przeszukiwane cele: {len(targets)}. "
            + (
                f"Planer sprawdzi wszystkie warstwy 1–{depth}."
                if search_all_depths
                else "Planer zatrzyma się po pierwszej skutecznej głębokości."
            )
        )
        self.planner_console.clear()
        self.planner_progress.setRange(0, 100)
        self.planner_progress.setValue(0)
        self.planner_progress_label.setText("Start…")
        self.planner_running = True
        self._planner_cancel_requested = False
        self._planner_batch_elapsed = 0.0
        self._planner_batch_timed_out = False
        self._planner_batch_cancelled = False
        self._planner_batch_nodes = 0
        self._planner_target_count = len(targets)
        self.find_owned_button.setEnabled(False)
        self.find_missing_button.setEnabled(False)
        self.cancel_planner_button.setEnabled(True)
        timeout = self.planner_timeout_value()
        if timeout is None:
            answer = QMessageBox.warning(
                self,
                "Planer bez limitu",
                "Tryb bez limitu może działać bardzo długo i mocno obciążyć komputer. "
                "Jest zalecany tylko w lokalnej wersji narzędzia, nie na publicznej stronie.\n\n"
                "Możesz przerwać wyszukiwanie przyciskiem Anuluj. Kontynuować?",
                QMessageBox.Yes | QMessageBox.No,
                QMessageBox.No,
            )
            if answer != QMessageBox.Yes:
                self.planner_running = False
                return
        inventory_snapshot = list(self.current_items)
        self._planner_search_all_depths = search_all_depths

        def task(progress):
            batch_started = time.monotonic()
            compiled: list[Plan] = []
            for target_index, target in enumerate(targets, 1):
                if self._planner_cancel_requested:
                    self._planner_batch_cancelled = True
                    break
                elapsed = time.monotonic() - batch_started
                remaining = None if timeout is None else timeout - elapsed
                if remaining is not None and remaining <= 0:
                    self._planner_batch_timed_out = True
                    break
                target_name = self.rules.identity_display(target)

                def forward(event, *, _index=target_index, _name=target_name):
                    payload = dict(event)
                    payload["elapsed"] = time.monotonic() - batch_started
                    payload["target_index"] = _index
                    payload["target_total"] = len(targets)
                    payload["target_name"] = _name
                    message = str(payload.get("message") or "")
                    if message:
                        payload["message"] = f"[cel {_index}/{len(targets)}: {_name}] {message}"
                    progress(payload)

                plans = self.planner.find_plans(
                    target,
                    inventory_snapshot,
                    max_depth=depth,
                    top_k=25,
                    timeout_seconds=remaining,
                    allow_missing=allow_missing,
                    minimum_level=minimum,
                    progress_callback=forward,
                    stop_at_first_success=not search_all_depths,
                )
                compiled.extend(plans)
                self._planner_batch_nodes += int(self.planner.last_stats.get("nodes", 0))
                if self.planner.last_timed_out:
                    self._planner_batch_timed_out = True
                    break
                if self.planner.last_cancelled or self._planner_cancel_requested:
                    self._planner_batch_cancelled = True
                    break
            self._planner_batch_elapsed = time.monotonic() - batch_started
            # Targets are part of the plan signature, so this safely keeps the
            # best alternative plans across all selected target variants.
            best: dict[tuple, Plan] = {}
            for plan in compiled:
                signature = self._plan_signature(plan)
                old = best.get(signature)
                if old is None or self._plan_sort_key(plan) < self._plan_sort_key(old):
                    best[signature] = plan
            return sorted(best.values(), key=self._plan_sort_key)[:100]

        worker = Worker(task)
        self._planner_allow_missing = allow_missing
        worker.signals.progress.connect(
            self.handle_planner_event,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.result.connect(
            self._planner_worker_done,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.error.connect(
            self.planner_failed,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.finished.connect(
            self.planner_finished,
            Qt.ConnectionType.QueuedConnection,
        )
        self._start_worker(worker)

    @Slot(object)
    def _planner_worker_done(self, plans) -> None:
        self.planner_done(plans, self._planner_allow_missing)

    @staticmethod
    def _plan_signature(plan: Plan) -> tuple:
        return (
            plan.target.key(),
            plan.result_level,
            tuple(plan.consumed_item_ids),
            tuple(sorted(identity.key() for identity in plan.missing)),
            tuple(
                (step.left.key(), step.right.key(), step.result.key(), step.result_level)
                for step in plan.steps
            ),
        )

    @staticmethod
    def _plan_sort_key(plan: Plan) -> tuple:
        # A complete inventory plan is more useful than a shorter plan requiring
        # missing items.  Within the same missing count, prefer fewer layers and
        # then fewer total operations.
        return (
            plan.missing_count,
            plan.craft_depth,
            plan.step_count,
            len(plan.consumed_item_ids),
            -plan.result_level,
        )

    def _refresh_live_plan_table(self) -> None:
        selected_signature = self._plan_signature(self.current_plan) if self.current_plan else None
        self.current_plans.sort(key=self._plan_sort_key)
        self.plan_table.setSortingEnabled(False)
        self.plan_table.setRowCount(len(self.current_plans))
        selected_row = -1
        for row, plan in enumerate(self.current_plans):
            score = (
                plan.missing_count * 10000
                + plan.craft_depth * 1000
                + plan.step_count * 100
                + len(plan.consumed_item_ids)
            )
            values = [
                row + 1,
                self.rules.identity_display(plan.target),
                plan.craft_depth,
                plan.step_count,
                plan.missing_count,
                level_label(plan.result_level),
                score,
            ]
            for col, value in enumerate(values):
                cell = self._item(value, data=row if col == 0 else None)
                if plan.missing_count == 0:
                    cell.setBackground(QColor("#163a18"))
                    cell.setForeground(QColor("#b6ff95"))
                self.plan_table.setItem(row, col, cell)
            if selected_signature and self._plan_signature(plan) == selected_signature:
                selected_row = row
        self.plan_table.setSortingEnabled(True)
        if selected_row >= 0:
            self.plan_table.selectRow(selected_row)
        elif self.current_plans and self.current_plan is None:
            self.plan_table.selectRow(0)
            self.select_plan(0, 0)

    def _add_live_plan(self, plan: Plan) -> None:
        signature = self._plan_signature(plan)
        if signature in self._live_plan_signatures:
            return
        self._live_plan_signatures.add(signature)
        self.current_plans.append(plan)
        # Keep the live panel practical while leaving enough room for plans from
        # deeper layers.  The old limit of 50 meant that 50 shallow plans could
        # permanently hide every later depth-2/3 inventory route.
        self.current_plans.sort(key=self._plan_sort_key)
        live_limit = 250
        if len(self.current_plans) > live_limit:
            removed = self.current_plans[live_limit:]
            self.current_plans = self.current_plans[:live_limit]
            for old in removed:
                self._live_plan_signatures.discard(self._plan_signature(old))
        self._refresh_live_plan_table()
        depths = sorted({candidate.craft_depth for candidate in self.current_plans})
        depth_text = ", ".join(str(value) for value in depths) if depths else "0"
        self.live_plan_hint.setText(
            f"Znaleziono na żywo: {len(self.current_plans)} | "
            f"warstwy: {depth_text} | najlepszy plan: {self.current_plans[0].step_count} kroków, "
            f"braków: {self.current_plans[0].missing_count}"
        )

    @Slot(object)
    def handle_planner_event(self, event: dict):
        if event.get("kind") == "plan" and isinstance(event.get("plan"), Plan):
            self._add_live_plan(event["plan"])
        message = str(event.get("message") or "")
        elapsed = float(event.get("elapsed") or 0)
        if message:
            self.planner_console.appendPlainText(f"[{elapsed:7.2f}s] {message}")
            bar = self.planner_console.verticalScrollBar()
            bar.setValue(bar.maximum())
        mode = event.get("mode")
        if mode == "indeterminate":
            self.planner_progress.setRange(0, 0)
        else:
            self.planner_progress.setRange(0, 100)
            if event.get("percent") is not None:
                self.planner_progress.setValue(max(0, min(100, int(event["percent"]))))
        self.planner_progress_label.setText(
            f"{elapsed:.1f}s | cel: {event.get('target_index', 1)}/{event.get('target_total', self._planner_target_count or 1)} | "
            f"węzły: {self._planner_batch_nodes + int(event.get('nodes', self.planner.last_stats.get('nodes', 0))):,} | "
            f"plany: {len(self.current_plans)}"
        )

    def planner_done(self, plans: list[Plan], allow_missing: bool):
        for plan in plans:
            self._add_live_plan(plan)
        if self.current_plans:
            if self.current_plan is None:
                self.plan_table.selectRow(0)
                self.select_plan(0, 0)
            if self._planner_batch_timed_out:
                status = (
                    f"Limit czasu osiągnięty po {self._planner_batch_elapsed:.1f}s; "
                    f"zachowano {len(self.current_plans)} znalezionych planów."
                )
            elif self._planner_batch_cancelled:
                status = f"Wyszukiwanie anulowane; zachowano {len(self.current_plans)} znalezionych planów."
            else:
                if self._planner_search_all_depths:
                    status = (
                        f"Znaleziono {len(self.current_plans)} planów po pełnym przeszukaniu wybranej "
                        f"głębokości w {self._planner_batch_elapsed:.1f}s"
                    )
                else:
                    status = (
                        f"Znaleziono {len(self.current_plans)} planów przy pierwszej skutecznej "
                        f"głębokości w {self._planner_batch_elapsed:.1f}s"
                    )
            self.planner_progress_label.setText(status)
            self.planner_console.appendPlainText(status)
        else:
            if self._planner_batch_timed_out:
                text = "Wyszukiwanie osiągnęło limit czasu i nie skompilowało jeszcze żadnego planu."
            elif self._planner_batch_cancelled:
                text = "Wyszukiwanie anulowano."
            elif allow_missing:
                text = "Nie znaleziono rozkładu celu do podanej głębokości."
            else:
                text = "Nie ma pełnej ścieżki z aktualnego ekwipunku. Spróbuj „Plan z brakami”."
            self.plan_summary.setText(text)
            self.planner_console.appendPlainText(text)

    @Slot(object)
    def planner_failed(self, error: Exception):
        self.plan_summary.setText(f"Błąd planera: {error}")
        self.planner_console.appendPlainText(getattr(error, "_worker_traceback", str(error)))
        QMessageBox.critical(self, "Błąd planera", str(error))

    @Slot()
    def planner_finished(self):
        self.planner_running = False
        self.find_owned_button.setEnabled(True)
        self.find_missing_button.setEnabled(True)
        self.cancel_planner_button.setEnabled(False)
        if self.planner_progress.maximum() == 0:
            self.planner_progress.setRange(0, 100)
        if self.current_plans:
            self.planner_progress.setValue(100)

    def cancel_planner(self):
        self._planner_cancel_requested = True
        self.planner.request_cancel()
        self.planner_console.appendPlainText("[anulowanie] Wysłano żądanie zatrzymania…")
        self.cancel_planner_button.setEnabled(False)

    def select_plan(self, row: int, _column: int):
        cell = self.plan_table.item(row, 0)
        index = cell.data(Qt.UserRole) if cell else row
        if not isinstance(index, int) or not (0 <= index < len(self.current_plans)):
            return
        plan = self.current_plans[index]
        self.current_plan = plan
        target_name = self.rules.identity_display(plan.target)
        self.plan_summary.setText(
            f"CEL: {target_name} | wynik: {level_label(plan.result_level)} | "
            f"warstwy: {plan.craft_depth} | kroki: {plan.step_count} | brakujące: {plan.missing_count}"
        )
        rows = []
        for number, step in enumerate(plan.steps, 1):
            rows.append((
                str(number),
                self.rules.identity_display(step.left),
                self.rules.identity_display(step.right),
                self.rules.identity_display(step.result),
                level_label(step.result_level),
            ))
        for missing in plan.missing:
            rows.append(("BRAK", self.rules.identity_display(missing), "", "", "min. Zwykły +1"))
        self.plan_steps.setRowCount(len(rows))
        for r, values in enumerate(rows):
            for c, value in enumerate(values):
                item = self._item(value, data=(index, r, c) if c in (1, 2, 3) else None)
                if values[0] == "BRAK":
                    item.setBackground(QColor("#4d1718"))
                    item.setForeground(QColor("#ffad8e"))
                self.plan_steps.setItem(r, c, item)

    def open_plan_component(self, row: int, column: int):
        if not self.current_plan or column not in (1, 2, 3):
            return
        if row < len(self.current_plan.steps):
            step = self.current_plan.steps[row]
            identity = {1: step.left, 2: step.right, 3: step.result}[column]
            level = step.result_level if column == 3 else 1
            title = self.rules.identity_display(identity)
            details = [
                ("Kategoria", self.rules.category_label(identity.category)),
                ("Legendarny", "TAK" if identity.legendary else "NIE"),
                ("Poziom", level_label(level)),
                ("Rola", "Wynik kroku" if column == 3 else "Składnik"),
            ]
        else:
            missing_index = row - len(self.current_plan.steps)
            if not (0 <= missing_index < len(self.current_plan.missing)):
                return
            identity = self.current_plan.missing[missing_index]
            level = 1
            title = self.rules.identity_display(identity)
            details = [
                ("Kategoria", self.rules.category_label(identity.category)),
                ("Status", "Brakujący przedmiot"),
                ("Minimalny poziom", "Zwykły +1"),
            ]
        self.show_item_card(identity, level, details, title=title)

    def save_selected_plan(self):
        if not self.current_profile_id or not self.current_plan:
            QMessageBox.information(self, "Brak planu", "Wybierz najpierw plan.")
            return
        name, ok = QInputDialog.getText(self, "Zapisz plan", "Nazwa planu:")
        if ok and name.strip():
            self.db.save_plan(self.current_profile_id, name.strip(), self.current_plan)
            self.refresh_saved()
            self.status("Plan zapisany.")

    # ---------- encyclopedia ----------
    def _build_encyclopedia_tab(self) -> QWidget:
        root = QWidget()
        layout = QVBoxLayout(root)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.addWidget(SectionTitle("ENCYKLOPEDIA PRZEDMIOTÓW MORIA"))

        filters = QGroupBox("Wybór wariantu")
        grid = QGridLayout(filters)
        self.enc_search = QLineEdit()
        self.enc_search.setPlaceholderText("Szukaj nazwy…")
        self.enc_category = CheckableComboBox()
        self.enc_prefix = CheckableComboBox()
        self.enc_base = CheckableComboBox()
        self.enc_suffix = CheckableComboBox()
        self.enc_quality = QComboBox()
        self.enc_quality.addItems(QUALITY_NAMES)
        self.enc_upgrade = QSpinBox()
        self.enc_upgrade.setRange(0, 5)
        self.enc_legendary = QCheckBox("Legendarny")
        self.enc_player_level = QSpinBox()
        self.enc_player_level.setRange(1, 999)
        self.enc_player_level.setValue(80)
        widgets = [
            ("Szukaj", self.enc_search), ("Kategoria", self.enc_category),
            ("Prefix", self.enc_prefix), ("Baza", self.enc_base),
            ("Suffix", self.enc_suffix), ("Jakość", self.enc_quality),
            ("+", self.enc_upgrade), ("", self.enc_legendary),
            ("Poziom gracza", self.enc_player_level),
        ]
        for col, (label, widget) in enumerate(widgets):
            if label:
                grid.addWidget(QLabel(label), 0, col)
            grid.addWidget(widget, 1, col)
        clear = QPushButton("Wyczyść filtry")
        clear.clicked.connect(self.clear_encyclopedia_filters)
        grid.addWidget(clear, 1, len(widgets))
        grid.setColumnStretch(0, 1)
        layout.addWidget(filters)

        self.enc_category.set_values([self.rules.category_label(c) for c in self.rules.categories()])
        self.enc_category.selectionChanged.connect(self.refresh_encyclopedia_options)
        for widget in (self.enc_prefix, self.enc_base, self.enc_suffix):
            widget.selectionChanged.connect(self.refresh_encyclopedia_rows)
        self.enc_quality.currentIndexChanged.connect(self.refresh_encyclopedia_rows)
        self.enc_upgrade.valueChanged.connect(self.refresh_encyclopedia_rows)
        self.enc_legendary.toggled.connect(self.refresh_encyclopedia_rows)
        self.enc_search.textChanged.connect(self.refresh_encyclopedia_rows)
        self.enc_player_level.valueChanged.connect(lambda _value: self.show_selected_encyclopedia_item())

        stats_row = QHBoxLayout()
        self.enc_stats_label = QLabel()
        self.enc_stats_label.setStyleSheet("color:#f1d58b;font-weight:700;")
        stats_row.addWidget(self.enc_stats_label)
        stats_row.addStretch(1)
        export_csv = QPushButton("Eksport CSV")
        export_csv.clicked.connect(self.export_encyclopedia_csv)
        export_sqlite = QPushButton("Eksport SQLite")
        export_sqlite.clicked.connect(self.export_encyclopedia_sqlite)
        export_xlsx = QPushButton("Eksport XLSX")
        export_xlsx.clicked.connect(self.export_encyclopedia_xlsx)
        stats_row.addWidget(export_csv)
        stats_row.addWidget(export_sqlite)
        stats_row.addWidget(export_xlsx)
        layout.addLayout(stats_row)

        splitter = QSplitter(Qt.Horizontal)
        left = Panel()
        left_layout = QVBoxLayout(left)
        self.enc_table = QTableWidget()
        self._table_setup(
            self.enc_table,
            ["Wybrany wariant", "Kategoria", "Prefix", "Baza", "Suffix", "Cache"],
            [350, 135, 120, 145, 135, 60],
        )
        self.enc_table.cellClicked.connect(lambda *_: self.show_selected_encyclopedia_item())
        left_layout.addWidget(self.enc_table)
        splitter.addWidget(left)

        self.enc_detail_panel = Panel()
        detail_layout = QVBoxLayout(self.enc_detail_panel)
        detail_layout.addWidget(SectionTitle("OFICJALNE STATYSTYKI I GRAFIKI"))
        self.enc_visual = ImageTriptych(self.images)
        detail_layout.addWidget(self.enc_visual, alignment=Qt.AlignHCenter)
        self.enc_title = QLabel("Wybierz przedmiot")
        self.enc_title.setObjectName("itemTitle")
        self.enc_title.setWordWrap(True)
        self.enc_title.setAlignment(Qt.AlignCenter)
        detail_layout.addWidget(self.enc_title)
        self.enc_subtitle = QLabel()
        self.enc_subtitle.setWordWrap(True)
        self.enc_subtitle.setAlignment(Qt.AlignCenter)
        self.enc_subtitle.setStyleSheet(f"color:{MUTED};")
        detail_layout.addWidget(self.enc_subtitle)
        self.enc_detail = QTextBrowser()
        self.enc_detail.setOpenExternalLinks(False)
        self.enc_detail.setFont(QFont("Georgia", 10))
        self.enc_detail.setStyleSheet("background:#050505;border:1px solid #71452d;")
        detail_layout.addWidget(self.enc_detail, 1)
        self.enc_online_status = QLabel("Wybierz przedmiot z lewej strony.")
        self.enc_online_status.setWordWrap(True)
        self.enc_online_status.setStyleSheet(f"color:{MUTED};")
        detail_layout.addWidget(self.enc_online_status)
        button_row = QHBoxLayout()
        self.enc_fetch_button = QPushButton("Pobierz / odśwież")
        self.enc_fetch_button.setObjectName("goldButton")
        self.enc_fetch_button.clicked.connect(self.fetch_selected_encyclopedia_stats)
        self.enc_open_button = QPushButton("Otwórz oficjalną stronę")
        self.enc_open_button.clicked.connect(self.open_selected_official_url)
        button_row.addWidget(self.enc_fetch_button)
        button_row.addWidget(self.enc_open_button)
        detail_layout.addLayout(button_row)
        safe_fetch_note = QLabel(
            "Tryb bezpieczny: statystyki są pobierane pojedynczo. "
            "Kolejne kliknięcia nie tworzą kolejki."
        )
        safe_fetch_note.setWordWrap(True)
        safe_fetch_note.setStyleSheet(f"color:{MUTED};font-style:italic;")
        detail_layout.addWidget(safe_fetch_note)
        splitter.addWidget(self.enc_detail_panel)
        splitter.setSizes([920, 470])
        layout.addWidget(splitter, 1)

        self.refresh_encyclopedia_options()
        return root

    def encyclopedia_category_keys(self) -> list[str]:
        labels = set(self.enc_category.selected_values())
        if not labels:
            return list(self.rules.categories())
        return [category for category in self.rules.categories() if self.rules.category_label(category) in labels]

    def quality_word(self, quality: str, base: str) -> str:
        if quality == "Zwykły":
            return ""
        masculine = {"Dobry": "dobry", "Doskonały": "doskonały", "Epicki": "epicki"}.get(quality, quality.lower())
        return inflect_adjective(masculine, base_grammar(base)).capitalize()

    def variant_name(self, ident: CatalogIdentity) -> str:
        parts = []
        if self.enc_legendary.isChecked():
            parts.append(legendary_word(ident.base))
        quality = self.quality_word(self.enc_quality.currentText(), ident.base)
        if quality:
            parts.append(quality)
        if ident.prefix:
            parts.append(self.rules.prefix_display(ident.prefix, ident.base))
        parts.append(self.rules.display(ident.base))
        if ident.suffix:
            parts.append(self.rules.display(ident.suffix))
        if self.enc_upgrade.value():
            parts.append(f"(+{self.enc_upgrade.value()})")
        return " ".join(parts)

    def refresh_encyclopedia_options(self):
        if not hasattr(self, "enc_prefix"):
            return
        categories = self.encyclopedia_category_keys()
        prefix_labels = {"Brak"}
        base_labels = set()
        suffix_labels = {"Brak"}
        for category in categories:
            prefix_labels.update(self.rules.prefix_filter_label(prefix) for prefix in self.rules.prefixes(category))
            base_labels.update(self.rules.display(base) for base in self.rules.bases(category))
            suffix_labels.update(self.rules.display(suffix) for suffix in self.rules.suffixes(category))
        self.enc_prefix.set_values(sorted(prefix_labels, key=str.casefold))
        self.enc_base.set_values(sorted(base_labels, key=str.casefold))
        self.enc_suffix.set_values(sorted(suffix_labels, key=str.casefold))
        self.refresh_encyclopedia_rows()

    def clear_encyclopedia_filters(self):
        self.enc_search.clear()
        self.enc_category.select_all()
        self.enc_prefix.select_all()
        self.enc_base.select_all()
        self.enc_suffix.select_all()
        self.enc_quality.setCurrentText("Zwykły")
        self.enc_upgrade.setValue(0)
        self.enc_legendary.setChecked(False)
        self.refresh_encyclopedia_options()

    def selected_variant(self) -> VariantSelection | None:
        row = self.enc_table.currentRow()
        if row < 0:
            return None
        cell = self.enc_table.item(row, 0)
        index = cell.data(Qt.UserRole) if cell else row
        if not isinstance(index, int) or not (0 <= index < len(self.encyclopedia_row_map)):
            return None
        ident = self.encyclopedia_row_map[index]
        return VariantSelection(
            ident,
            quality=self.enc_quality.currentText(),
            upgrade=self.enc_upgrade.value(),
            legendary=self.enc_legendary.isChecked(),
            player_level=self.enc_player_level.value(),
        )

    def refresh_encyclopedia_rows(self):
        if not hasattr(self, "enc_table"):
            return
        category_labels = set(self.enc_category.selected_values())
        prefix_labels = set(self.enc_prefix.selected_values())
        base_labels = set(self.enc_base.selected_values())
        suffix_labels = set(self.enc_suffix.selected_values())
        query = self.enc_search.text().casefold().strip()
        rows: list[CatalogIdentity] = []
        for ident in self.item_catalog.identities():
            category_label = self.rules.category_label(ident.category)
            prefix_label = self.rules.prefix_filter_label(ident.prefix) if ident.prefix else "Brak"
            base_label = self.rules.display(ident.base)
            suffix_label = self.rules.display(ident.suffix) if ident.suffix else "Brak"
            if category_labels and category_label not in category_labels:
                continue
            if prefix_labels and prefix_label not in prefix_labels:
                continue
            if base_labels and base_label not in base_labels:
                continue
            if suffix_labels and suffix_label not in suffix_labels:
                continue
            if query:
                display = self.rules.identity_display(
                    Identity(ident.category, ident.base, ident.prefix, ident.suffix, False)
                ).casefold()
                if query not in display:
                    continue
            rows.append(ident)
        self.encyclopedia_visible_rows = rows
        self.encyclopedia_row_map = rows[:4000]
        table = self.enc_table
        table.setSortingEnabled(False)
        table.setRowCount(len(self.encyclopedia_row_map))
        for row, ident in enumerate(self.encyclopedia_row_map):
            variant = VariantSelection(
                ident, self.enc_quality.currentText(), self.enc_upgrade.value(),
                self.enc_legendary.isChecked(), self.enc_player_level.value(),
            )
            cached = "TAK" if self.db.get_encyclopedia_stats(variant.cache_key()) else ""
            values = [
                self.variant_name(ident), self.rules.category_label(ident.category),
                self.rules.prefix_filter_label(ident.prefix) if ident.prefix else "Brak",
                self.rules.display(ident.base), self.rules.display(ident.suffix) if ident.suffix else "Brak", cached,
            ]
            for col, value in enumerate(values):
                cell = self._item(value, bold=(col == 0), data=row if col == 0 else None)
                if self.enc_legendary.isChecked():
                    cell.setBackground(QColor("#4c3913"))
                    if col == 0:
                        cell.setForeground(QColor("#ffd75e"))
                table.setItem(row, col, cell)
        table.setSortingEnabled(True)
        extra = "; pokazano 4 000" if len(rows) > 4000 else ""
        self.enc_stats_label.setText(
            f"Tożsamości: {self.item_catalog.identity_count():,} | warianty: {self.item_catalog.variant_count():,} | "
            f"pasuje: {len(rows):,}{extra} | cache: {self.db.encyclopedia_cache_count():,}"
        )
        if self.encyclopedia_row_map:
            table.selectRow(0)
            self.show_selected_encyclopedia_item()
        else:
            self.enc_title.setText("Brak wyników")
            self.enc_detail.clear()
        if not self.fetch_running:
            self.enc_fetch_button.setEnabled(bool(self.encyclopedia_row_map))

    def _official_image_paths(self, row: dict) -> tuple[str, str, str]:
        main = self.images.cached_path_for_url(row.get("main_image_url") or "", "main")
        prefix = self.images.cached_path_for_url(row.get("prefix_image_url") or "", "prefix")
        suffix = self.images.cached_path_for_url(row.get("suffix_image_url") or "", "suffix")
        return str(main or ""), str(prefix or ""), str(suffix or "")

    def show_selected_encyclopedia_item(self):
        selection = self.selected_variant()
        if not selection:
            if not self.fetch_running:
                self.enc_fetch_button.setEnabled(False)
            return
        if not self.fetch_running:
            self.enc_fetch_button.setEnabled(True)
        ident = selection.identity
        name = self.variant_name(ident)
        self.enc_title.setObjectName("legendaryTitle" if selection.legendary else "itemTitle")
        self.enc_title.style().unpolish(self.enc_title)
        self.enc_title.style().polish(self.enc_title)
        self.enc_title.setText(name)
        self.enc_subtitle.setText(
            f"{self.rules.category_label(ident.category)} • {selection.quality}"
            f"{f' +{selection.upgrade}' if selection.upgrade else ''} • poziom gracza {selection.player_level}"
        )
        row = self.db.get_encyclopedia_stats(selection.cache_key())
        if row:
            data = dict(row)
            main, prefix, suffix = self._official_image_paths(data)
            self.enc_visual.set_images(
                main=main, prefix=prefix, suffix=suffix,
                category=ident.category, base=self.rules.display(ident.base),
                prefix_name=self.rules.prefix_display(ident.prefix, ident.base) if ident.prefix else "",
                suffix_name=self.rules.display(ident.suffix) if ident.suffix else "",
                base_key=ident.base,
                prefix_key=self.rules.prefix_semantic_key(ident.prefix) if ident.prefix else "",
                suffix_key=ident.suffix,
                quality=selection.quality, legendary=selection.legendary,
            )
            self.enc_detail.setHtml(stats_html(data))
            self.enc_online_status.setText(f"Lokalny cache • pobrano {data.get('fetched_at','')}")
        else:
            self.enc_visual.set_images(
                category=ident.category, base=self.rules.display(ident.base),
                prefix_name=self.rules.prefix_display(ident.prefix, ident.base) if ident.prefix else "",
                suffix_name=self.rules.display(ident.suffix) if ident.suffix else "",
                base_key=ident.base,
                prefix_key=self.rules.prefix_semantic_key(ident.prefix) if ident.prefix else "",
                suffix_key=ident.suffix,
                quality=selection.quality, legendary=selection.legendary,
            )
            self.enc_detail.setHtml(
                "<div style='text-align:center;color:#d8c7aa'>"
                f"<b>Prefix:</b> {self.rules.prefix_filter_label(ident.prefix) if ident.prefix else 'Brak'}<br><br>"
                f"<b>Baza:</b> {self.rules.display(ident.base)}<br><br>"
                f"<b>Suffix:</b> {self.rules.display(ident.suffix) if ident.suffix else 'Brak'}<br><br>"
                "Statystyki nie są jeszcze zapisane. Kliknij „Pobierz / odśwież”.</div>"
            )
            self.enc_online_status.setText("Brak w lokalnym cache.")

    def set_fetch_running(self, running: bool):
        self.fetch_running = running
        if hasattr(self, "enc_fetch_button"):
            self.enc_fetch_button.setEnabled(not running)
        if hasattr(self, "enc_open_button"):
            self.enc_open_button.setEnabled(not running)

    def _begin_official_fetch(self, context: str, cache_key: str = "") -> bool:
        """Claim the single official-network slot without creating a queue."""
        if self.fetch_running:
            return False
        self._official_fetch_context = context
        self._official_fetch_cache_key = cache_key
        self.set_fetch_running(True)
        self.status(f"Pobieranie: {context}…")
        return True

    @Slot()
    def _finish_official_fetch(self) -> None:
        self._official_fetch_context = ""
        self._official_fetch_cache_key = ""
        self._official_fetch_token = ""
        self.set_fetch_running(False)

    def fetch_selected_encyclopedia_stats(self):
        selection = self.selected_variant()
        if not selection:
            return
        if not self._begin_official_fetch("Encyklopedia", selection.cache_key()):
            self.enc_online_status.setText(
                "Trwa już inne pobieranie. Nie dodano zapytania do kolejki; spróbuj ponownie po zakończeniu."
            )
            return
        self.enc_online_status.setText("Łączenie z oficjalnym testerem BloodWars…")

        def task(progress):
            client = OfficialItemClient(self.rules)
            stats = client.fetch(selection)
            self._complete_stats_image_urls(stats, selection)
            paths = client.download_images(stats, self.images.official_dir)
            return stats, paths

        worker = Worker(task)
        worker.signals.result.connect(
            self.encyclopedia_fetch_done,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.error.connect(
            self.encyclopedia_fetch_failed,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.finished.connect(
            self._finish_official_fetch,
            Qt.ConnectionType.QueuedConnection,
        )
        self._start_worker(worker)

    @Slot(object)
    def encyclopedia_fetch_done(self, payload):
        stats, _paths = payload
        try:
            self.db.upsert_encyclopedia_stats(stats)
        except Exception as exc:  # noqa: BLE001
            self.encyclopedia_fetch_failed(exc)
            return
        self.enc_online_status.setText("Pobrano statystyki i grafiki; zapisano w lokalnym cache.")
        wanted = (stats.category, stats.prefix, stats.base, stats.suffix)
        self.refresh_encyclopedia_rows()
        for row, ident in enumerate(self.encyclopedia_row_map):
            if (ident.category, ident.prefix, ident.base, ident.suffix) == wanted:
                self.enc_table.selectRow(row)
                break
        self.show_selected_encyclopedia_item()

    @Slot(object)
    def encyclopedia_fetch_failed(self, error: Exception):
        self.set_fetch_running(False)
        self.enc_online_status.setText(f"Błąd pobierania: {error}")
        QMessageBox.critical(self, "Nie udało się pobrać statystyk", str(error))

    def fetch_visible_encyclopedia_stats(self):
        """Legacy entry point kept for compatibility; bulk network fetch is disabled."""
        QMessageBox.information(
            self,
            "Pobieranie seryjne wyłączone",
            "Dla stabilności program pobiera teraz tylko jeden kliknięty przedmiot naraz. "
            "Zapytania nie są kolejkowane.",
        )

    def _batch_fetch_finished(self):
        # Kept for compatibility with older saved code paths.
        self._finish_official_fetch()
        self.refresh_encyclopedia_rows()

    def open_selected_official_url(self):
        selection = self.selected_variant()
        if not selection:
            return
        row = self.db.get_encyclopedia_stats(selection.cache_key())
        if row and row["url"]:
            webbrowser.open(row["url"])
            return
        if not self._begin_official_fetch("adres oficjalnej strony", selection.cache_key()):
            self.enc_online_status.setText(
                "Trwa już inne pobieranie. Nie dodano otwarcia strony do kolejki."
            )
            return

        def task(progress):
            client = OfficialItemClient(self.rules)
            return client.build_url(selection)

        worker = Worker(task)
        worker.signals.result.connect(
            self._open_official_url_done,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.error.connect(
            self.encyclopedia_fetch_failed,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.finished.connect(
            self._finish_official_fetch,
            Qt.ConnectionType.QueuedConnection,
        )
        self._start_worker(worker)

    @Slot(object)
    def _open_official_url_done(self, url: str) -> None:
        webbrowser.open(str(url))

    def export_encyclopedia_sqlite(self):
        filename, _ = QFileDialog.getSaveFileName(self, "Eksport katalogu SQLite", "BW_Moria_Items.sqlite", "SQLite (*.sqlite)")
        if filename:
            self.item_catalog.export_sqlite(Path(filename))
            QMessageBox.information(self, "Gotowe", f"Zapisano katalog:\n{filename}")

    def export_encyclopedia_csv(self):
        filename, _ = QFileDialog.getSaveFileName(self, "Eksport katalogu CSV", "BW_Moria_Item_Identities.csv", "CSV (*.csv)")
        if filename:
            self.item_catalog.export_csv(Path(filename))
            QMessageBox.information(self, "Gotowe", f"Zapisano katalog:\n{filename}")

    def export_encyclopedia_xlsx(self):
        filename, _ = QFileDialog.getSaveFileName(self, "Eksport katalogu XLSX", "BW_Moria_Item_Catalog.xlsx", "Excel (*.xlsx)")
        if filename:
            cached = [dict(row) for row in self.db.encyclopedia_stats_rows()]
            export_catalog_xlsx(self.item_catalog, self.rules, Path(filename), cached)
            QMessageBox.information(self, "Gotowe", f"Zapisano katalog i {len(cached)} rekordów statystyk:\n{filename}")

    # ---------- history ----------
    def _build_history_tab(self) -> QWidget:
        root = QWidget()
        layout = QVBoxLayout(root)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.addWidget(SectionTitle("HISTORIA IMPORTÓW"))
        self.history_table = QTableWidget()
        self._table_setup(
            self.history_table,
            ["ID", "Data", "Plik", "Zadeklarowano", "Wczytano", "Ostrzeżenia"],
            [65, 180, 480, 115, 100, 100],
        )
        layout.addWidget(self.history_table, 1)
        restore = QPushButton("Przywróć zaznaczony import")
        restore.clicked.connect(self.restore_import)
        layout.addWidget(restore, alignment=Qt.AlignRight)
        return root

    def refresh_history(self):
        if not hasattr(self, "history_table"):
            return
        rows = self.db.imports(self.current_profile_id) if self.current_profile_id else []
        self.history_table.setRowCount(len(rows))
        for row_index, row in enumerate(rows):
            values = [
                row["id"], row["imported_at"], row["source_file"],
                row["declared_count"] if row["declared_count"] is not None else "",
                row["parsed_count"], row["warning_count"],
            ]
            for col, value in enumerate(values):
                self.history_table.setItem(row_index, col, self._item(value, data=int(row["id"]) if col == 0 else None))

    def restore_import(self):
        row = self.history_table.currentRow()
        if row < 0 or not self.current_profile_id:
            return
        item = self.history_table.item(row, 0)
        import_id = item.data(Qt.UserRole) if item else None
        if import_id:
            self.db.set_active_import(self.current_profile_id, int(import_id))
            self.load_profile()
            self.status(f"Przywrócono import #{import_id}.")

    # ---------- saved plans ----------
    def _build_saved_tab(self) -> QWidget:
        root = QWidget()
        layout = QVBoxLayout(root)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.addWidget(SectionTitle("ZAPISANE PLANY"))
        splitter = QSplitter(Qt.Horizontal)
        left = Panel()
        ll = QVBoxLayout(left)
        self.saved_table = QTableWidget()
        self._table_setup(self.saved_table, ["ID", "Nazwa", "Utworzono", "Cel"], [60, 260, 190, 330])
        self.saved_table.cellClicked.connect(self.show_saved_plan)
        ll.addWidget(self.saved_table)
        delete = QPushButton("Usuń zaznaczony")
        delete.clicked.connect(self.delete_saved_plan)
        ll.addWidget(delete, alignment=Qt.AlignRight)
        splitter.addWidget(left)
        right = Panel()
        rl = QVBoxLayout(right)
        self.saved_detail = QPlainTextEdit()
        self.saved_detail.setReadOnly(True)
        self.saved_detail.setFont(QFont("Consolas", 9))
        rl.addWidget(self.saved_detail)
        splitter.addWidget(right)
        splitter.setSizes([760, 600])
        layout.addWidget(splitter)
        return root

    def refresh_saved(self):
        if not hasattr(self, "saved_table"):
            return
        rows = self.db.saved_plans(self.current_profile_id) if self.current_profile_id else []
        self.saved_rows = rows
        self.saved_table.setRowCount(len(rows))
        for row_index, row in enumerate(rows):
            try:
                target = json.loads(row["target_json"])
                identity = Identity(
                    target.get("category", ""), target.get("base", ""), target.get("prefix", ""),
                    target.get("suffix", ""), bool(target.get("legendary")),
                )
                target_name = self.rules.identity_display(identity)
            except Exception:
                target_name = "(nieznany cel)"
            values = [row["id"], row["name"], row["created_at"], target_name]
            for col, value in enumerate(values):
                self.saved_table.setItem(row_index, col, self._item(value, data=row_index if col == 0 else None))

    def show_saved_plan(self, row: int, _column: int):
        cell = self.saved_table.item(row, 0)
        index = cell.data(Qt.UserRole) if cell else row
        if not hasattr(self, "saved_rows") or not isinstance(index, int) or not (0 <= index < len(self.saved_rows)):
            return
        data = self.saved_rows[index]
        try:
            plan = json.loads(data["plan_json"])
            text = json.dumps(plan, ensure_ascii=False, indent=2)
        except Exception:
            text = data["plan_json"]
        self.saved_detail.setPlainText(text)

    def delete_saved_plan(self):
        row = self.saved_table.currentRow()
        if row < 0 or not hasattr(self, "saved_rows") or row >= len(self.saved_rows):
            return
        cell = self.saved_table.item(row, 0)
        index = cell.data(Qt.UserRole) if cell else row
        if not isinstance(index, int) or not (0 <= index < len(self.saved_rows)):
            return
        plan_id = int(self.saved_rows[index]["id"])
        if QMessageBox.question(self, "Usuń plan", "Usunąć zaznaczony plan?") == QMessageBox.Yes:
            self.db.delete_saved_plan(plan_id)
            self.refresh_saved()
            self.saved_detail.clear()

    # ---------- profiles / import / export ----------
    def refresh_profiles(self, select_id: int | None = None):
        rows = self.db.profiles()
        current_id = select_id if select_id is not None else self.current_profile_id
        self.profile_combo.blockSignals(True)
        self.profile_combo.clear()
        self.profile_ids = []
        for row in rows:
            label = f"{row['name']} — {row['server'] or '?'} ({row['realm'] or 'nieznana kraina'})"
            self.profile_combo.addItem(label)
            self.profile_ids.append(int(row["id"]))
        self.profile_combo.blockSignals(False)
        if self.profile_ids:
            index = self.profile_ids.index(current_id) if current_id in self.profile_ids else 0
            self.profile_combo.setCurrentIndex(index)
            self.current_profile_id = self.profile_ids[index]
            self.load_profile()
        else:
            self.current_profile_id = None
            self.current_items = []
            self.refresh_inventory_filter_options()
            self.refresh_inventory_table()

    def on_profile_change(self, index: int):
        if 0 <= index < len(self.profile_ids):
            self.current_profile_id = self.profile_ids[index]
            self.load_profile()

    def load_profile(self):
        if not self.current_profile_id:
            return
        self.current_items = self.db.active_items(self.current_profile_id)
        self.current_direct = []
        self.current_plans = []
        self.current_plan = None
        self.refresh_inventory_filter_options()
        self.refresh_inventory_table()
        self.refresh_direct_filter_options()
        self.refresh_direct_table()
        self.refresh_history()
        self.refresh_saved()
        self.status(f"Wczytano profil: {len(self.current_items):,} przedmiotów.")

    def import_html(self):
        filename, _ = QFileDialog.getOpenFileName(
            self,
            "Wybierz zapisany HTML zbrojowni",
            "",
            "HTML (*.html *.htm);;Wszystkie pliki (*.*)",
        )
        if not filename:
            return
        path = Path(filename)
        self.import_button.setEnabled(False)
        self.status("Analizuję HTML i kopiuję grafiki przedmiotów…")

        def task(progress):
            items, metadata = parse_armory_html(path, self.rules)
            enrich_inventory_images(items, path, app_storage_dir() / "images" / "imports")
            image_ready = sum(
                1 for item in items
                if item.main_image or item.prefix_image or item.suffix_image
            )
            return items, metadata, image_ready

        worker = Worker(task)
        worker.signals.result.connect(
            self.import_done,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.error.connect(
            self.import_failed,
            Qt.ConnectionType.QueuedConnection,
        )
        worker.signals.finished.connect(
            self._import_worker_finished,
            Qt.ConnectionType.QueuedConnection,
        )
        self._start_worker(worker)

    @Slot()
    def _import_worker_finished(self) -> None:
        self.import_button.setEnabled(True)

    @Slot(object)
    def import_done(self, payload):
        items, metadata, image_ready = payload
        if not items:
            QMessageBox.warning(self, "Brak przedmiotów", "Nie znaleziono przedmiotów w tym HTML-u.")
            self.status("Brak przedmiotów", error=True)
            return
        if metadata.get("realm") and metadata.get("realm") != "Moria":
            answer = QMessageBox.question(
                self,
                "Inna kraina",
                "Wykryto krainę inną niż Moria. Baza połączeń dotyczy wyłącznie Morii.\n\n"
                "Zaimportować sam ekwipunek mimo to?",
            )
            if answer != QMessageBox.Yes:
                self.status("Anulowano")
                return
        profile_id = self.db.upsert_profile(metadata["character"], metadata.get("server", ""), metadata.get("realm", ""))
        self.db.import_inventory(profile_id, items, metadata)
        self.refresh_profiles(profile_id)
        warning_count = len(metadata.get("warnings") or [])
        declared = metadata.get("declared_count")
        mismatch = declared is not None and declared != len(items)
        message = f"Zaimportowano {len(items)} przedmiotów."
        if warning_count:
            message += f"\nOstrzeżenia parsera: {warning_count}"
        if mismatch:
            message += f"\nStrona deklaruje: {declared}."
        QMessageBox.information(self, "Import zakończony", message)
        self.status(
            f"Zaimportowano {len(items)} przedmiotów; lokalne grafiki przygotowane dla {image_ready}. "
            "Brakujące statystyki i grafiki zostaną pobrane dopiero po kliknięciu przedmiotu."
        )

    def start_inventory_asset_prefetch(self, items: list[InventoryItem]) -> None:
        """Bulk inventory prefetch is intentionally disabled.

        Official stats/images are fetched only after a user clicks an item, and
        only when no other official request is active. Nothing is queued here.
        """
        return

    @Slot(object)
    def import_failed(self, error: Exception):
        QMessageBox.critical(self, "Błąd importu", f"Nie udało się przeanalizować pliku:\n{error}")
        self.status("Błąd importu", error=True)

    def export_excel(self):
        if not self.current_items:
            QMessageBox.information(self, "Brak danych", "Najpierw zaimportuj ekwipunek.")
            return
        filename, _ = QFileDialog.getSaveFileName(self, "Eksport Excel", "BloodWars_Ekwipunek.xlsx", "Excel (*.xlsx)")
        if not filename:
            return
        try:
            export_inventory(Path(filename), self.current_items, self.rules)
            QMessageBox.information(self, "Gotowe", "Plik Excel został zapisany.")
        except Exception as exc:
            QMessageBox.critical(self, "Błąd eksportu", str(exc))

    def closeEvent(self, event):
        if self.planner_running:
            self.planner.request_cancel()
        self.db.close()
        event.accept()


def main() -> None:
    enable_crash_logging()
    app = QApplication(sys.argv)
    apply_theme(app)
    app.setApplicationName(APP_NAME)
    app.setApplicationVersion(VERSION)
    window = MainWindow()
    window.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
