from __future__ import annotations

import json
import os
import queue
import sys
import threading
import time
import webbrowser
from pathlib import Path
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog, ttk

from crafting import all_direct_crafts
from display_filters import (
    ALL, craft_filter_values, craft_matches, inventory_filter_values, item_matches,
)
from database import Database
from excel_export import export_inventory
from inventory_parser import parse_armory_html
from models import Identity, level_label, quality_level
from planner import Planner
from rules import RuleBook
from item_catalog import ItemCatalog, CatalogIdentity, VariantSelection
from official_stats import OfficialItemClient
from catalog_export import export_catalog_xlsx
from grammar import base_grammar, inflect_adjective, legendary_word

APP_NAME = "BW Crafting Planner"
VERSION = "0.10-beta"


def resource_dir() -> Path:
    return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))


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


class Application(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(f"{APP_NAME} {VERSION}")
        self.geometry("1480x860")
        self.minsize(1100, 700)
        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.official_client = OfficialItemClient(self.rules)
        self.encyclopedia_row_map: dict[str, CatalogIdentity] = {}
        self.encyclopedia_visible_rows: list[CatalogIdentity] = []
        self.encyclopedia_fetch_running = False
        self.profile_map: dict[str, int] = {}
        self.current_profile_id: int | None = None
        self.current_items = []
        self.current_plans = []
        self.current_direct = []
        self.planner_running = False
        self.planner_event_queue: queue.Queue[dict] = queue.Queue()
        self.planner_poll_job: str | None = None
        self.planner_console_last_activity = 0.0
        self.planner_progress_mode = "determinate"
        self._configure_style()
        self._build_ui()
        labels = [self.rules.category_label(cat) for cat in self.rules.categories()]
        self.cat_combo["values"] = labels
        if labels:
            self.target_category.set(labels[0])
            self.refresh_target_options()
        self.refresh_profiles()
        self.protocol("WM_DELETE_WINDOW", self.on_close)

    def _configure_style(self):
        style = ttk.Style(self)
        try:
            style.theme_use("clam")
        except tk.TclError:
            pass
        style.configure("Treeview", rowheight=25, font=("Segoe UI", 9))
        style.configure("Treeview.Heading", font=("Segoe UI", 9, "bold"))
        style.configure("TNotebook.Tab", padding=(12, 7))

    def _build_ui(self):
        toolbar = ttk.Frame(self, padding=8)
        toolbar.pack(fill="x")
        ttk.Button(toolbar, text="Importuj HTML zbrojowni", command=self.import_html).pack(side="left")
        ttk.Button(toolbar, text="Eksportuj bieżący Excel", command=self.export_excel).pack(side="left", padx=(8, 0))
        ttk.Label(toolbar, text="Profil:").pack(side="left", padx=(25, 5))
        self.profile_var = tk.StringVar()
        self.profile_combo = ttk.Combobox(toolbar, textvariable=self.profile_var, state="readonly", width=34)
        self.profile_combo.pack(side="left")
        self.profile_combo.bind("<<ComboboxSelected>>", self.on_profile_change)
        self.status_var = tk.StringVar(value="Gotowe")
        ttk.Label(toolbar, textvariable=self.status_var).pack(side="right")

        self.notebook = ttk.Notebook(self)
        self.notebook.pack(fill="both", expand=True, padx=8, pady=(0, 8))
        self._build_inventory_tab()
        self._build_craft_tab()
        self._build_planner_tab()
        self._build_encyclopedia_tab()
        self._build_history_tab()
        self._build_saved_tab()

    def _build_inventory_tab(self):
        frame = ttk.Frame(self.notebook, padding=8)
        self.notebook.add(frame, text="Ekwipunek")

        top = ttk.Frame(frame)
        top.pack(fill="x", pady=(0, 6))
        self.inventory_stats = tk.StringVar(value="Brak aktywnego profilu")
        ttk.Label(top, textvariable=self.inventory_stats, font=("Segoe UI", 10, "bold")).pack(side="left")

        filters = ttk.LabelFrame(frame, text="Filtry wyświetlania", padding=(8, 6))
        filters.pack(fill="x", pady=(0, 8))
        self.inventory_search = tk.StringVar()
        self.inv_filter_category = tk.StringVar(value=ALL)
        self.inv_filter_prefix = tk.StringVar(value=ALL)
        self.inv_filter_base = tk.StringVar(value=ALL)
        self.inv_filter_suffix = tk.StringVar(value=ALL)
        self.inv_filter_legendary = tk.StringVar(value=ALL)
        self.inv_filter_quality = tk.StringVar(value=ALL)
        self.inv_filter_upgrade = tk.StringVar(value=ALL)
        self.inv_filter_shelf = tk.StringVar(value=ALL)

        filter_defs = [
            ("Szukaj", self.inventory_search, "entry", 23),
            ("Kategoria", self.inv_filter_category, "combo", 22),
            ("Prefix", self.inv_filter_prefix, "combo", 16),
            ("Baza", self.inv_filter_base, "combo", 18),
            ("Suffix", self.inv_filter_suffix, "combo", 16),
            ("Legendarny", self.inv_filter_legendary, "combo", 17),
            ("Jakość", self.inv_filter_quality, "combo", 12),
            ("Ulepszenie", self.inv_filter_upgrade, "combo", 11),
            ("Półka", self.inv_filter_shelf, "combo", 14),
        ]
        self.inventory_filter_combos = {}
        for column, (label, variable, kind, width) in enumerate(filter_defs):
            ttk.Label(filters, text=label).grid(row=0, column=column, sticky="w", padx=(0, 5))
            if kind == "entry":
                widget = ttk.Entry(filters, textvariable=variable, width=width)
                widget.bind("<KeyRelease>", lambda _e: self.refresh_inventory_tree())
            else:
                widget = ttk.Combobox(filters, textvariable=variable, state="readonly", width=width)
                widget.bind("<<ComboboxSelected>>", lambda _e: self.refresh_inventory_tree())
                self.inventory_filter_combos[label.lower()] = widget
            widget.grid(row=1, column=column, sticky="ew", padx=(0, 5))
        ttk.Button(filters, text="Wyczyść", command=self.clear_inventory_filters).grid(row=1, column=len(filter_defs), padx=(6, 0))
        filters.columnconfigure(0, weight=1)

        cols = ("name", "category", "legendary", "quality", "upgrade", "prefix", "base", "suffix", "shelf", "id", "notes")
        self.inventory_tree = ttk.Treeview(frame, columns=cols, show="headings")
        headings = {
            "name": "Nazwa", "category": "Kategoria", "legendary": "Legendarny", "quality": "Jakość",
            "upgrade": "+", "prefix": "Prefix", "base": "Baza", "suffix": "Suffix", "shelf": "Półka",
            "id": "Item ID", "notes": "Uwagi parsera",
        }
        widths = {"name": 330, "category": 150, "legendary": 85, "quality": 90, "upgrade": 45, "prefix": 120, "base": 145, "suffix": 135, "shelf": 145, "id": 75, "notes": 240}
        for col in cols:
            self.inventory_tree.heading(col, text=headings[col], command=lambda c=col: self.sort_tree(self.inventory_tree, c, False))
            self.inventory_tree.column(col, width=widths[col], anchor="w")
        self.inventory_tree.tag_configure("legendary", background="#FFF1B8")
        self.inventory_tree.tag_configure("warning", background="#FFE4E6")
        y = ttk.Scrollbar(frame, orient="vertical", command=self.inventory_tree.yview)
        x = ttk.Scrollbar(frame, orient="horizontal", command=self.inventory_tree.xview)
        self.inventory_tree.configure(yscrollcommand=y.set, xscrollcommand=x.set)
        self.inventory_tree.pack(fill="both", expand=True, side="left")
        y.pack(fill="y", side="right")
        x.pack(fill="x", side="bottom")

    def _build_craft_tab(self):
        frame = ttk.Frame(self.notebook, padding=8)
        self.notebook.add(frame, text="Możliwe teraz")
        top = ttk.Frame(frame)
        top.pack(fill="x", pady=(0, 6))
        ttk.Button(top, text="Przelicz połączenia", command=self.refresh_direct_crafts).pack(side="left")
        self.direct_stats = tk.StringVar(value="")
        ttk.Label(top, textvariable=self.direct_stats).pack(side="left", padx=12)

        filters = ttk.LabelFrame(frame, text="Filtry wyniku", padding=(8, 6))
        filters.pack(fill="x", pady=(0, 8))
        self.direct_search = tk.StringVar()
        self.direct_filter_category = tk.StringVar(value=ALL)
        self.direct_filter_prefix = tk.StringVar(value=ALL)
        self.direct_filter_base = tk.StringVar(value=ALL)
        self.direct_filter_suffix = tk.StringVar(value=ALL)
        self.direct_filter_legendary = tk.StringVar(value=ALL)
        self.direct_filter_quality = tk.StringVar(value=ALL)
        self.direct_filter_upgrade = tk.StringVar(value=ALL)

        filter_defs = [
            ("Szukaj", self.direct_search, "entry", 25),
            ("Kategoria wyniku", self.direct_filter_category, "combo", 22),
            ("Prefix wyniku", self.direct_filter_prefix, "combo", 16),
            ("Baza wyniku", self.direct_filter_base, "combo", 18),
            ("Suffix wyniku", self.direct_filter_suffix, "combo", 16),
            ("Legendarny", self.direct_filter_legendary, "combo", 17),
            ("Jakość wyniku", self.direct_filter_quality, "combo", 13),
            ("Ulepszenie", self.direct_filter_upgrade, "combo", 11),
        ]
        self.direct_filter_combos = {}
        for column, (label, variable, kind, width) in enumerate(filter_defs):
            ttk.Label(filters, text=label).grid(row=0, column=column, sticky="w", padx=(0, 5))
            if kind == "entry":
                widget = ttk.Entry(filters, textvariable=variable, width=width)
                widget.bind("<KeyRelease>", lambda _e: self.refresh_direct_tree())
            else:
                widget = ttk.Combobox(filters, textvariable=variable, state="readonly", width=width)
                widget.bind("<<ComboboxSelected>>", lambda _e: self.refresh_direct_tree())
                self.direct_filter_combos[label.lower()] = widget
            widget.grid(row=1, column=column, sticky="ew", padx=(0, 5))
        ttk.Button(filters, text="Wyczyść", command=self.clear_direct_filters).grid(row=1, column=len(filter_defs), padx=(6, 0))
        filters.columnconfigure(0, weight=1)

        cols = ("result", "category", "prefix", "base", "suffix", "level", "legendary", "left", "right")
        self.direct_tree = ttk.Treeview(frame, columns=cols, show="headings")
        for col, label, width in [
            ("result", "Wynik", 300), ("category", "Kategoria", 145), ("prefix", "Prefix", 110),
            ("base", "Baza", 125), ("suffix", "Suffix", 120), ("level", "Poziom wyniku", 115),
            ("legendary", "Legendarny", 85), ("left", "Składnik 1", 315), ("right", "Składnik 2", 315),
        ]:
            self.direct_tree.heading(col, text=label, command=lambda c=col: self.sort_tree(self.direct_tree, c, False))
            self.direct_tree.column(col, width=width, anchor="w")
        self.direct_tree.tag_configure("legendary", background="#FFF1B8")
        y = ttk.Scrollbar(frame, orient="vertical", command=self.direct_tree.yview)
        x = ttk.Scrollbar(frame, orient="horizontal", command=self.direct_tree.xview)
        self.direct_tree.configure(yscrollcommand=y.set, xscrollcommand=x.set)
        self.direct_tree.pack(fill="both", expand=True, side="left")
        y.pack(fill="y", side="right")
        x.pack(fill="x", side="bottom")

    def _build_planner_tab(self):
        frame = ttk.Frame(self.notebook, padding=8)
        self.notebook.add(frame, text="Planer celu")
        controls = ttk.LabelFrame(frame, text="Cel", padding=10)
        controls.pack(fill="x")
        self.target_category = tk.StringVar()
        self.target_legendary = tk.BooleanVar(value=False)
        self.target_prefix = tk.StringVar(value="Brak")
        self.target_base = tk.StringVar()
        self.target_suffix = tk.StringVar(value="Brak")
        self.target_quality = tk.StringVar(value="Dowolna")
        self.target_upgrade = tk.StringVar(value="0")
        self.max_depth = tk.IntVar(value=5)
        self.planner_timeout = tk.StringVar(value="30")

        ttk.Label(controls, text="Kategoria").grid(row=0, column=0, sticky="w")
        self.cat_combo = ttk.Combobox(controls, textvariable=self.target_category, state="readonly", width=20)
        self.cat_combo.grid(row=1, column=0, padx=(0, 8), sticky="ew")
        self.cat_combo.bind("<<ComboboxSelected>>", lambda _e: self.refresh_target_options())
        ttk.Checkbutton(controls, text="Legendarny", variable=self.target_legendary).grid(row=1, column=1, padx=8)
        ttk.Label(controls, text="Prefix").grid(row=0, column=2, sticky="w")
        self.prefix_combo = ttk.Combobox(controls, textvariable=self.target_prefix, state="readonly", width=20)
        self.prefix_combo.grid(row=1, column=2, padx=8)
        ttk.Label(controls, text="Baza").grid(row=0, column=3, sticky="w")
        self.base_combo = ttk.Combobox(controls, textvariable=self.target_base, state="readonly", width=22)
        self.base_combo.grid(row=1, column=3, padx=8)
        ttk.Label(controls, text="Suffix").grid(row=0, column=4, sticky="w")
        self.suffix_combo = ttk.Combobox(controls, textvariable=self.target_suffix, state="readonly", width=22)
        self.suffix_combo.grid(row=1, column=4, padx=8)
        ttk.Label(controls, text="Min. jakość").grid(row=0, column=5, sticky="w")
        ttk.Combobox(controls, textvariable=self.target_quality, state="readonly", values=["Dowolna", "Zwykły", "Dobry", "Doskonały", "Epicki"], width=12).grid(row=1, column=5, padx=8)
        ttk.Label(controls, text="Min. +").grid(row=0, column=6, sticky="w")
        ttk.Combobox(controls, textvariable=self.target_upgrade, state="readonly", values=[str(x) for x in range(6)], width=6).grid(row=1, column=6, padx=8)
        ttk.Label(controls, text="Maks. głębokość").grid(row=0, column=7, sticky="w")
        self.depth_spinbox = ttk.Spinbox(controls, from_=1, to=5, textvariable=self.max_depth, width=6)
        self.depth_spinbox.grid(row=1, column=7, padx=8)
        ttk.Label(controls, text="Limit czasu (s)").grid(row=0, column=8, sticky="w")
        self.timeout_combo = ttk.Combobox(
            controls,
            textvariable=self.planner_timeout,
            state="normal",
            values=["Bez limitu", "30", "60", "120", "300", "600", "1800"],
            width=12,
        )
        self.timeout_combo.grid(row=1, column=8, padx=8)
        self.find_inventory_button = ttk.Button(
            controls,
            text="Znajdź z ekwipunku",
            command=lambda: self.run_planner(False),
        )
        self.find_inventory_button.grid(row=1, column=9, padx=(16, 4))
        self.plan_missing_button = ttk.Button(
            controls,
            text="Plan z brakami",
            command=lambda: self.run_planner(True),
        )
        self.plan_missing_button.grid(row=1, column=10, padx=4)
        self.cancel_planner_button = ttk.Button(
            controls,
            text="Anuluj",
            command=self.cancel_planner,
            state="disabled",
        )
        self.cancel_planner_button.grid(row=1, column=11, padx=(8, 0))

        # Bottom status/progress area remains visible even when the console pane is resized.
        progress_frame = ttk.Frame(frame)
        progress_frame.pack(fill="x", side="bottom", pady=(6, 0))
        self.planner_progress_text = tk.StringVar(value="Gotowe")
        ttk.Label(progress_frame, textvariable=self.planner_progress_text, width=70).pack(side="left", padx=(0, 8))
        self.planner_progress = ttk.Progressbar(progress_frame, mode="determinate", maximum=100)
        self.planner_progress.pack(side="left", fill="x", expand=True)
        self.planner_elapsed_text = tk.StringVar(value="")
        ttk.Label(progress_frame, textvariable=self.planner_elapsed_text, width=16, anchor="e").pack(side="right", padx=(8, 0))

        main = ttk.Panedwindow(frame, orient="vertical")
        main.pack(fill="both", expand=True, pady=(8, 0))

        body = ttk.Panedwindow(main, orient="horizontal")
        left = ttk.Frame(body)
        right = ttk.Frame(body)
        body.add(left, weight=1)
        body.add(right, weight=3)
        main.add(body, weight=4)

        self.plan_list = tk.Listbox(left, font=("Segoe UI", 10), exportselection=False)
        self.plan_list.pack(fill="both", expand=True)
        self.plan_list.bind("<<ListboxSelect>>", lambda _e: self.show_selected_plan())
        ttk.Button(left, text="Zapisz wybrany plan", command=self.save_selected_plan).pack(fill="x", pady=(6, 0))
        self.plan_text = tk.Text(right, wrap="word", font=("Consolas", 10), padx=10, pady=10)
        self.plan_text.pack(fill="both", expand=True)

        console_frame = ttk.LabelFrame(main, text="Podgląd wyszukiwania na żywo", padding=(6, 4))
        console_toolbar = ttk.Frame(console_frame)
        console_toolbar.pack(fill="x", pady=(0, 4))
        ttk.Label(
            console_toolbar,
            text="Pokazuje etapy, liczniki i próbkowane kombinacje. Logowanie jest ograniczone, aby nie spowalniać planera.",
        ).pack(side="left")
        ttk.Button(console_toolbar, text="Wyczyść log", command=self.clear_planner_console).pack(side="right")
        console_body = ttk.Frame(console_frame)
        console_body.pack(fill="both", expand=True)
        self.planner_console = tk.Text(
            console_body,
            wrap="none",
            height=9,
            font=("Consolas", 9),
            background="#111318",
            foreground="#D8DEE9",
            insertbackground="#FFFFFF",
            padx=8,
            pady=6,
            state="disabled",
        )
        console_y = ttk.Scrollbar(console_body, orient="vertical", command=self.planner_console.yview)
        console_x = ttk.Scrollbar(console_body, orient="horizontal", command=self.planner_console.xview)
        self.planner_console.configure(yscrollcommand=console_y.set, xscrollcommand=console_x.set)
        self.planner_console.grid(row=0, column=0, sticky="nsew")
        console_y.grid(row=0, column=1, sticky="ns")
        console_x.grid(row=1, column=0, sticky="ew")
        console_body.rowconfigure(0, weight=1)
        console_body.columnconfigure(0, weight=1)
        main.add(console_frame, weight=1)

    def _build_encyclopedia_tab(self):
        frame = ttk.Frame(self.notebook, padding=8)
        self.notebook.add(frame, text="Encyklopedia")

        self.enc_search = tk.StringVar()
        self.enc_category = tk.StringVar(value="Wszystkie")
        self.enc_prefix = tk.StringVar(value="Wszystkie")
        self.enc_base = tk.StringVar(value="Wszystkie")
        self.enc_suffix = tk.StringVar(value="Wszystkie")
        self.enc_quality = tk.StringVar(value="Zwykły")
        self.enc_upgrade = tk.StringVar(value="0")
        self.enc_legendary = tk.BooleanVar(value=False)
        self.enc_player_level = tk.IntVar(value=80)
        self.enc_batch_limit = tk.StringVar(value="25")

        controls = ttk.LabelFrame(frame, text="Katalog wszystkich przedmiotów Moria", padding=(8, 6))
        controls.pack(fill="x", pady=(0, 8))
        defs = [
            ("Szukaj", self.enc_search, "entry", 22),
            ("Kategoria", self.enc_category, "combo", 20),
            ("Prefix", self.enc_prefix, "combo", 18),
            ("Baza", self.enc_base, "combo", 20),
            ("Suffix", self.enc_suffix, "combo", 18),
            ("Jakość", self.enc_quality, "quality", 12),
            ("+", self.enc_upgrade, "upgrade", 5),
        ]
        self.enc_widgets = {}
        for col, (label, var, kind, width) in enumerate(defs):
            ttk.Label(controls, text=label).grid(row=0, column=col, sticky="w", padx=(0, 5))
            if kind == "entry":
                widget = ttk.Entry(controls, textvariable=var, width=width)
                widget.bind("<KeyRelease>", lambda _e: self.refresh_encyclopedia_rows())
            else:
                values = []
                if kind == "quality":
                    values = ["Zwykły", "Dobry", "Doskonały", "Epicki"]
                elif kind == "upgrade":
                    values = [str(i) for i in range(6)]
                widget = ttk.Combobox(controls, textvariable=var, state="readonly", width=width, values=values)
                if label == "Kategoria":
                    widget.bind("<<ComboboxSelected>>", lambda _e: self.refresh_encyclopedia_options())
                else:
                    widget.bind("<<ComboboxSelected>>", lambda _e: self.refresh_encyclopedia_rows())
                self.enc_widgets[label] = widget
            widget.grid(row=1, column=col, sticky="ew", padx=(0, 5))
        ttk.Checkbutton(controls, text="Legendarny", variable=self.enc_legendary, command=self.refresh_encyclopedia_rows).grid(row=1, column=7, padx=8)
        ttk.Label(controls, text="Poziom gracza").grid(row=0, column=8, sticky="w")
        ttk.Spinbox(controls, from_=1, to=200, textvariable=self.enc_player_level, width=7).grid(row=1, column=8, padx=(0, 8))
        ttk.Button(controls, text="Wyczyść filtry", command=self.clear_encyclopedia_filters).grid(row=1, column=9, padx=(4, 0))
        controls.columnconfigure(0, weight=1)

        toolbar = ttk.Frame(frame)
        toolbar.pack(fill="x", pady=(0, 8))
        self.enc_stats_text = tk.StringVar(value="")
        ttk.Label(toolbar, textvariable=self.enc_stats_text, font=("Segoe UI", 9, "bold")).pack(side="left")
        ttk.Button(toolbar, text="Eksport katalogu XLSX", command=self.export_encyclopedia_xlsx).pack(side="right", padx=(6, 0))
        ttk.Button(toolbar, text="Eksport bazy SQLite", command=self.export_encyclopedia_sqlite).pack(side="right", padx=(6, 0))
        ttk.Button(toolbar, text="Eksport katalogu CSV", command=self.export_encyclopedia_csv).pack(side="right", padx=(6, 0))

        body = ttk.Panedwindow(frame, orient="horizontal")
        body.pack(fill="both", expand=True)
        left = ttk.Frame(body)
        right = ttk.Frame(body, padding=(8, 0, 0, 0))
        body.add(left, weight=3)
        body.add(right, weight=2)

        cols = ("name", "category", "prefix", "base", "suffix", "cached")
        self.enc_tree = ttk.Treeview(left, columns=cols, show="headings", selectmode="browse")
        for col, label, width in [
            ("name", "Wybrany wariant", 360), ("category", "Kategoria", 145),
            ("prefix", "Prefix", 125), ("base", "Baza", 145),
            ("suffix", "Suffix", 145), ("cached", "Statystyki", 85),
        ]:
            self.enc_tree.heading(col, text=label, command=lambda c=col: self.sort_tree(self.enc_tree, c, False))
            self.enc_tree.column(col, width=width, anchor="w")
        self.enc_tree.tag_configure("legendary", background="#FFF1B8")
        self.enc_tree.bind("<<TreeviewSelect>>", lambda _e: self.show_selected_encyclopedia_item())
        self.enc_tree.bind("<Double-1>", lambda _e: self.fetch_selected_encyclopedia_stats())
        ey = ttk.Scrollbar(left, orient="vertical", command=self.enc_tree.yview)
        ex = ttk.Scrollbar(left, orient="horizontal", command=self.enc_tree.xview)
        self.enc_tree.configure(yscrollcommand=ey.set, xscrollcommand=ex.set)
        self.enc_tree.grid(row=0, column=0, sticky="nsew")
        ey.grid(row=0, column=1, sticky="ns")
        ex.grid(row=1, column=0, sticky="ew")
        left.rowconfigure(0, weight=1)
        left.columnconfigure(0, weight=1)

        actions = ttk.LabelFrame(right, text="Oficjalne statystyki", padding=8)
        actions.pack(fill="x")
        self.enc_fetch_button = ttk.Button(actions, text="Pobierz / odśwież wybrany", command=self.fetch_selected_encyclopedia_stats)
        self.enc_fetch_button.pack(fill="x")
        ttk.Button(actions, text="Otwórz oficjalną stronę", command=self.open_selected_official_url).pack(fill="x", pady=(5, 0))
        batch = ttk.Frame(actions)
        batch.pack(fill="x", pady=(8, 0))
        ttk.Label(batch, text="Pobierz widoczne:").pack(side="left")
        ttk.Combobox(batch, textvariable=self.enc_batch_limit, values=["10", "25", "50", "100"], state="readonly", width=5).pack(side="left", padx=5)
        self.enc_batch_button = ttk.Button(batch, text="Start", command=self.fetch_visible_encyclopedia_stats)
        self.enc_batch_button.pack(side="left")
        self.enc_online_status = tk.StringVar(value="Statystyki są pobierane na żądanie i zapisywane lokalnie.")
        ttk.Label(actions, textvariable=self.enc_online_status, wraplength=440).pack(fill="x", pady=(8, 0))

        self.enc_detail = tk.Text(right, wrap="word", font=("Consolas", 9), padx=8, pady=8)
        self.enc_detail.pack(fill="both", expand=True, pady=(8, 0))

        category_labels = ["Wszystkie"] + [self.rules.category_label(c) for c in self.rules.categories()]
        self.enc_widgets["Kategoria"]["values"] = category_labels
        self.refresh_encyclopedia_options()

    def _build_history_tab(self):
        frame = ttk.Frame(self.notebook, padding=8)
        self.notebook.add(frame, text="Historia importów")
        ttk.Button(frame, text="Ustaw zaznaczony import jako aktywny", command=self.restore_import).pack(anchor="w", pady=(0, 6))
        cols = ("date", "file", "parsed", "declared", "warnings")
        self.history_tree = ttk.Treeview(frame, columns=cols, show="headings")
        for col, label, width in [
            ("date", "Data", 180), ("file", "Plik", 520), ("parsed", "Wczytano", 90),
            ("declared", "Deklarowano", 100), ("warnings", "Ostrzeżenia", 100),
        ]:
            self.history_tree.heading(col, text=label)
            self.history_tree.column(col, width=width, anchor="w")
        self.history_tree.pack(fill="both", expand=True)

    def _build_saved_tab(self):
        frame = ttk.Frame(self.notebook, padding=8)
        self.notebook.add(frame, text="Zapisane plany")
        top = ttk.Frame(frame)
        top.pack(fill="x", pady=(0, 6))
        ttk.Button(top, text="Usuń zaznaczony", command=self.delete_saved_plan).pack(side="left")
        self.saved_tree = ttk.Treeview(frame, columns=("name", "date", "target"), show="headings")
        self.saved_tree.heading("name", text="Nazwa")
        self.saved_tree.heading("date", text="Data")
        self.saved_tree.heading("target", text="Cel")
        self.saved_tree.column("name", width=240)
        self.saved_tree.column("date", width=180)
        self.saved_tree.column("target", width=600)
        self.saved_tree.pack(fill="both", expand=True)

    def refresh_profiles(self, select_id: int | None = None):
        rows = self.db.profiles()
        self.profile_map = {}
        labels = []
        for row in rows:
            label = f"{row['name']} — {row['server'] or '?'} ({row['realm'] or 'nieznana kraina'})"
            labels.append(label)
            self.profile_map[label] = int(row["id"])
        self.profile_combo["values"] = labels
        if labels:
            if select_id:
                label = next((x for x in labels if self.profile_map[x] == select_id), labels[0])
            else:
                label = self.profile_var.get() if self.profile_var.get() in labels else labels[0]
            self.profile_var.set(label)
            self.current_profile_id = self.profile_map[label]
            self.load_profile()
        else:
            self.current_profile_id = None
            self.current_items = []
            self.refresh_inventory_tree()

    def on_profile_change(self, _event=None):
        self.current_profile_id = self.profile_map.get(self.profile_var.get())
        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.planner_running = False
        self.current_plans = []
        self.refresh_inventory_filter_options()
        self.refresh_direct_filter_options()
        self.refresh_inventory_tree()
        self.refresh_history()
        self.refresh_saved()
        self.refresh_direct_tree()

    def import_html(self):
        filename = filedialog.askopenfilename(title="Wybierz zapisany HTML zbrojowni", filetypes=[("HTML", "*.html;*.htm"), ("Wszystkie", "*.*")])
        if not filename:
            return
        self.status_var.set("Analizuję HTML…")
        self.update_idletasks()
        try:
            items, metadata = parse_armory_html(Path(filename), self.rules)
        except Exception as exc:
            messagebox.showerror("Błąd importu", f"Nie udało się przeanalizować pliku:\n{exc}")
            self.status_var.set("Błąd importu")
            return
        if not items:
            messagebox.showwarning("Brak przedmiotów", "Nie znaleziono przedmiotów w tym HTML-u.")
            self.status_var.set("Brak przedmiotów")
            return
        if metadata.get("realm") and metadata.get("realm") != "Moria":
            if not messagebox.askyesno("Inna kraina", "Wykryto krainę inną niż Moria. Baza połączeń w tej aplikacji dotyczy wyłącznie Morii.\n\nZaimportować sam ekwipunek mimo to?"):
                self.status_var.set("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_text = f"\nOstrzeżenia parsera: {len(metadata['warnings'])}" if metadata.get("warnings") else ""
        mismatch = ""
        if metadata.get("declared_count") is not None and metadata["declared_count"] != len(items):
            mismatch = f"\nUWAGA: strona deklaruje {metadata['declared_count']} przedmiotów."
        messagebox.showinfo("Import zakończony", f"Zaimportowano {len(items)} przedmiotów.{warning_text}{mismatch}")
        self.status_var.set(f"Zaimportowano: {len(items)}")

    def export_excel(self):
        if not self.current_items:
            messagebox.showinfo("Brak danych", "Najpierw zaimportuj ekwipunek.")
            return
        filename = filedialog.asksaveasfilename(defaultextension=".xlsx", filetypes=[("Excel", "*.xlsx")], initialfile="BloodWars_Ekwipunek.xlsx")
        if not filename:
            return
        try:
            export_inventory(Path(filename), self.current_items, self.rules)
            messagebox.showinfo("Gotowe", "Plik Excel został zapisany.")
        except Exception as exc:
            messagebox.showerror("Błąd eksportu", str(exc))

    @staticmethod
    def _update_filter_combo(combo: ttk.Combobox, variable: tk.StringVar, values: list[str]):
        current = variable.get()
        combo["values"] = values
        variable.set(current if current in values else ALL)

    def refresh_inventory_filter_options(self):
        if not hasattr(self, "inventory_filter_combos"):
            return
        values = inventory_filter_values(self.current_items, self.rules)
        mapping = {
            "kategoria": (self.inv_filter_category, values["category"]),
            "prefix": (self.inv_filter_prefix, values["prefix"]),
            "baza": (self.inv_filter_base, values["base"]),
            "suffix": (self.inv_filter_suffix, values["suffix"]),
            "legendarny": (self.inv_filter_legendary, values["legendary"]),
            "jakość": (self.inv_filter_quality, values["quality"]),
            "ulepszenie": (self.inv_filter_upgrade, values["upgrade"]),
            "półka": (self.inv_filter_shelf, values["shelf"]),
        }
        for key, (variable, options) in mapping.items():
            self._update_filter_combo(self.inventory_filter_combos[key], variable, options)

    def clear_inventory_filters(self):
        self.inventory_search.set("")
        for variable in (
            self.inv_filter_category, self.inv_filter_prefix, self.inv_filter_base,
            self.inv_filter_suffix, self.inv_filter_legendary, self.inv_filter_quality,
            self.inv_filter_upgrade, self.inv_filter_shelf,
        ):
            variable.set(ALL)
        self.refresh_inventory_tree()

    def _active_inventory_filter_count(self) -> int:
        values = [
            self.inv_filter_category.get(), self.inv_filter_prefix.get(), self.inv_filter_base.get(),
            self.inv_filter_suffix.get(), self.inv_filter_legendary.get(), self.inv_filter_quality.get(),
            self.inv_filter_upgrade.get(), self.inv_filter_shelf.get(),
        ]
        return sum(value != ALL for value in values) + bool(self.inventory_search.get().strip())

    def refresh_direct_filter_options(self):
        if not hasattr(self, "direct_filter_combos"):
            return
        values = craft_filter_values(self.current_direct, self.rules)
        mapping = {
            "kategoria wyniku": (self.direct_filter_category, values["category"]),
            "prefix wyniku": (self.direct_filter_prefix, values["prefix"]),
            "baza wyniku": (self.direct_filter_base, values["base"]),
            "suffix wyniku": (self.direct_filter_suffix, values["suffix"]),
            "legendarny": (self.direct_filter_legendary, values["legendary"]),
            "jakość wyniku": (self.direct_filter_quality, values["quality"]),
            "ulepszenie": (self.direct_filter_upgrade, values["upgrade"]),
        }
        for key, (variable, options) in mapping.items():
            self._update_filter_combo(self.direct_filter_combos[key], variable, options)

    def clear_direct_filters(self):
        self.direct_search.set("")
        for variable in (
            self.direct_filter_category, self.direct_filter_prefix, self.direct_filter_base,
            self.direct_filter_suffix, self.direct_filter_legendary,
            self.direct_filter_quality, self.direct_filter_upgrade,
        ):
            variable.set(ALL)
        self.refresh_direct_tree()

    def _active_direct_filter_count(self) -> int:
        values = [
            self.direct_filter_category.get(), self.direct_filter_prefix.get(), self.direct_filter_base.get(),
            self.direct_filter_suffix.get(), self.direct_filter_legendary.get(),
            self.direct_filter_quality.get(), self.direct_filter_upgrade.get(),
        ]
        return sum(value != ALL for value in values) + bool(self.direct_search.get().strip())

    def refresh_inventory_tree(self):
        if not hasattr(self, "inventory_tree"):
            return
        self.inventory_tree.delete(*self.inventory_tree.get_children())
        visible = 0
        warnings = 0
        legends = 0
        for item in self.current_items:
            if not item_matches(
                item,
                self.rules,
                search=self.inventory_search.get(),
                category=self.inv_filter_category.get(),
                prefix=self.inv_filter_prefix.get(),
                base=self.inv_filter_base.get(),
                suffix=self.inv_filter_suffix.get(),
                legendary=self.inv_filter_legendary.get(),
                quality=self.inv_filter_quality.get(),
                upgrade=self.inv_filter_upgrade.get(),
                shelf=self.inv_filter_shelf.get(),
            ):
                continue
            ident = item.identity
            values = (
                item.full_name, self.rules.category_label(ident.category), "TAK" if ident.legendary else "NIE",
                item.quality, f"+{item.upgrade}",
                self.rules.prefix_display(ident.prefix, ident.base) if ident.prefix else "",
                self.rules.display(ident.base), self.rules.display(ident.suffix) if ident.suffix else "",
                item.shelf, item.item_id, item.parser_notes,
            )
            tag = "warning" if item.parser_notes else ("legendary" if ident.legendary else "")
            self.inventory_tree.insert("", "end", values=values, tags=(tag,) if tag else ())
            visible += 1
            warnings += bool(item.parser_notes)
            legends += ident.legendary
        active = self._active_inventory_filter_count()
        active_text = f" | aktywne filtry: {active}" if active else ""
        self.inventory_stats.set(
            f"Przedmioty: {len(self.current_items)} | widoczne: {visible} | "
            f"widoczne legendarne: {legends} | widoczne uwagi: {warnings}{active_text}"
        )

    def refresh_direct_crafts(self):
        if not self.current_items:
            messagebox.showinfo("Brak danych", "Najpierw zaimportuj ekwipunek.")
            return
        self.status_var.set("Przeliczam bezpośrednie połączenia…")
        self.update_idletasks()
        self.current_direct = all_direct_crafts(self.current_items, self.rules)
        self.refresh_direct_filter_options()
        self.refresh_direct_tree()
        self.status_var.set("Gotowe")

    def refresh_direct_tree(self):
        if not hasattr(self, "direct_tree"):
            return
        self.direct_tree.delete(*self.direct_tree.get_children())
        visible = 0
        for option in self.current_direct:
            if not craft_matches(
                option,
                self.rules,
                search=self.direct_search.get(),
                category=self.direct_filter_category.get(),
                prefix=self.direct_filter_prefix.get(),
                base=self.direct_filter_base.get(),
                suffix=self.direct_filter_suffix.get(),
                legendary=self.direct_filter_legendary.get(),
                quality=self.direct_filter_quality.get(),
                upgrade=self.direct_filter_upgrade.get(),
            ):
                continue
            ident = option.result
            values = (
                self.rules.identity_display(ident),
                self.rules.category_label(ident.category),
                self.rules.prefix_display(ident.prefix, ident.base) if ident.prefix else "",
                self.rules.display(ident.base),
                self.rules.display(ident.suffix) if ident.suffix else "",
                level_label(option.result_level),
                "TAK" if ident.legendary else "NIE",
                option.left.full_name,
                option.right.full_name,
            )
            self.direct_tree.insert("", "end", values=values, tags=("legendary",) if ident.legendary else ())
            visible += 1
        if hasattr(self, "direct_stats"):
            active = self._active_direct_filter_count()
            active_text = f" | aktywne filtry: {active}" if active else ""
            self.direct_stats.set(f"Pary: {len(self.current_direct)} | widoczne: {visible}{active_text}")

    def refresh_target_options(self):
        label_to_category = {self.rules.category_label(cat): cat for cat in self.rules.categories()}
        category = label_to_category.get(self.target_category.get())
        if not category:
            return
        self._target_category_key = category
        prefixes = ["Brak"] + sorted({self.rules.prefix_filter_label(x) for x in self.rules.prefixes(category)}, key=str.casefold)
        bases = [self.rules.display(x) for x in self.rules.bases(category)]
        suffixes = ["Brak"] + [self.rules.display(x) for x in self.rules.suffixes(category)]
        self.prefix_combo["values"] = prefixes
        self.base_combo["values"] = bases
        self.suffix_combo["values"] = suffixes
        self.target_prefix.set(prefixes[0])
        if bases:
            self.target_base.set(bases[0])
        self.target_suffix.set(suffixes[0])

    def _selected_target(self) -> tuple[Identity, int]:
        category = getattr(self, "_target_category_key", None)
        if not category:
            raise ValueError("Wybierz kategorię.")
        def key_from_display(value: str, kind: str) -> str:
            if value == "Brak":
                return ""
            if kind == "prefix":
                return self.rules.prefix_key_from_filter_label(category, value)
            candidates = {self.rules.display(x): x for x in (self.rules.suffixes(category) if kind == "suffix" else self.rules.bases(category))}
            return candidates.get(value, "")
        identity = Identity(
            category,
            key_from_display(self.target_base.get(), "base"),
            key_from_display(self.target_prefix.get(), "prefix"),
            key_from_display(self.target_suffix.get(), "suffix"),
            self.target_legendary.get(),
        )
        minimum = 0 if self.target_quality.get() == "Dowolna" else quality_level(self.target_quality.get(), int(self.target_upgrade.get()))
        return identity, minimum

    def clear_planner_console(self) -> None:
        if not hasattr(self, "planner_console"):
            return
        self.planner_console.configure(state="normal")
        self.planner_console.delete("1.0", "end")
        self.planner_console.configure(state="disabled")
        self.planner_console_last_activity = 0.0

    def _append_planner_log(self, message: str, elapsed: float | None = None) -> None:
        if not hasattr(self, "planner_console") or not message:
            return
        prefix = f"[+{elapsed:7.1f}s] " if elapsed is not None else ""
        self.planner_console.configure(state="normal")
        self.planner_console.insert("end", prefix + message.rstrip() + "\n")
        # Keep the live console useful without letting a very long unlimited search
        # consume unbounded GUI memory.
        try:
            line_count = int(self.planner_console.index("end-1c").split(".")[0])
            if line_count > 1800:
                self.planner_console.delete("1.0", "301.0")
                self.planner_console.insert("1.0", "[… starsze wpisy logu usunięto …]\n")
        except (ValueError, tk.TclError):
            pass
        self.planner_console.see("end")
        self.planner_console.configure(state="disabled")

    def _reset_planner_event_queue(self) -> None:
        while True:
            try:
                self.planner_event_queue.get_nowait()
            except queue.Empty:
                break

    def _enqueue_planner_event(self, event: dict) -> None:
        self.planner_event_queue.put(dict(event))

    def _set_planner_progress_mode(self, mode: str) -> None:
        if mode not in {"determinate", "indeterminate"}:
            return
        if self.planner_progress_mode == mode:
            return
        self.planner_progress.stop()
        self.planner_progress.configure(mode=mode)
        self.planner_progress_mode = mode
        if mode == "indeterminate" and self.planner_running:
            self.planner_progress.start(12)

    def _handle_planner_event(self, event: dict) -> None:
        kind = str(event.get("kind", "activity"))
        message = str(event.get("message", "")).strip()
        elapsed = float(event.get("elapsed", 0.0) or 0.0)
        mode = str(event.get("mode", ""))
        if mode in {"determinate", "indeterminate"}:
            self._set_planner_progress_mode(mode)
        if self.planner_progress_mode == "determinate" and "percent" in event:
            try:
                self.planner_progress["value"] = max(0.0, min(100.0, float(event["percent"])))
            except (TypeError, ValueError):
                pass
        if message:
            self.planner_progress_text.set(message)
        self.planner_elapsed_text.set(f"{elapsed:.1f} s")

        if kind in {"start", "phase", "log", "trace", "complete"}:
            self._append_planner_log(message, elapsed)
        elif kind == "activity" and elapsed - self.planner_console_last_activity >= 0.75:
            self.planner_console_last_activity = elapsed
            self._append_planner_log(message, elapsed)

    def _drain_planner_events(self, limit: int | None = None) -> int:
        processed = 0
        while limit is None or processed < limit:
            try:
                event = self.planner_event_queue.get_nowait()
            except queue.Empty:
                break
            self._handle_planner_event(event)
            processed += 1
        return processed

    def _poll_planner_events(self) -> None:
        self._drain_planner_events(limit=250)
        if self.planner_running or not self.planner_event_queue.empty():
            self.planner_poll_job = self.after(90, self._poll_planner_events)
        else:
            self.planner_poll_job = None

    def _planner_timeout_value(self) -> float | None:
        raw = str(self.planner_timeout.get()).strip()
        normalized = raw.casefold().replace("∞", "bez limitu")
        if normalized in {"", "0", "brak", "bez limitu", "bezlimitu", "none", "unlimited"}:
            self.planner_timeout.set("Bez limitu")
            return None
        try:
            value = float(raw.replace(",", "."))
        except ValueError as exc:
            raise ValueError("Wpisz liczbę sekund albo wybierz „Bez limitu”.") from exc
        if value <= 0:
            self.planner_timeout.set("Bez limitu")
            return None
        # Keep a clean display for whole-second values, but allow decimals for testing.
        self.planner_timeout.set(str(int(value)) if value.is_integer() else str(value))
        return value

    def _set_planner_running(self, running: bool) -> None:
        self.planner_running = running
        state = "disabled" if running else "normal"
        self.find_inventory_button.configure(state=state)
        self.plan_missing_button.configure(state=state)
        self.cancel_planner_button.configure(state="normal" if running else "disabled")
        if not running:
            self.planner_progress.stop()
            if self.planner_progress_mode == "indeterminate":
                self.planner_progress.configure(mode="determinate")
                self.planner_progress_mode = "determinate"

    def cancel_planner(self) -> None:
        if not self.planner_running:
            return
        self.planner.request_cancel()
        self.status_var.set("Anulowanie wyszukiwania…")
        self.planner_progress_text.set("Anulowanie wyszukiwania…")
        self._append_planner_log("Wysłano żądanie anulowania.")
        self.cancel_planner_button.configure(state="disabled")

    def run_planner(self, allow_missing: bool):
        if self.planner_running:
            return
        if not self.current_items:
            messagebox.showinfo("Brak danych", "Najpierw zaimportuj ekwipunek.")
            return
        try:
            target, minimum = self._selected_target()
        except ValueError as exc:
            messagebox.showwarning("Brak celu", str(exc))
            return
        try:
            depth = max(1, min(5, int(self.max_depth.get())))
        except (TypeError, ValueError):
            depth = 5
        self.max_depth.set(depth)
        try:
            timeout_seconds = self._planner_timeout_value()
        except ValueError as exc:
            messagebox.showwarning("Nieprawidłowy limit czasu", str(exc))
            return

        timeout_label = "bez limitu czasu" if timeout_seconds is None else f"limit {timeout_seconds:g} s"
        self.status_var.set(f"Szukam planów — {timeout_label}…")
        self.plan_list.delete(0, "end")
        self.plan_text.delete("1.0", "end")
        self.plan_text.insert(
            "end",
            "Trwa wyszukiwanie. Szczegółowe etapy i próbkowane kombinacje są widoczne w konsoli poniżej. "
            "Możesz nacisnąć „Anuluj”. Maksymalna głębokość planowania wynosi 5.",
        )
        self.clear_planner_console()
        self._reset_planner_event_queue()
        self.planner_progress.stop()
        self.planner_progress.configure(mode="determinate", value=0)
        self.planner_progress_mode = "determinate"
        self.planner_progress_text.set("Przygotowanie wyszukiwania…")
        self.planner_elapsed_text.set("0.0 s")
        self._set_planner_running(True)
        if self.planner_poll_job is None:
            self.planner_poll_job = self.after(50, self._poll_planner_events)

        def worker():
            try:
                plans = self.planner.find_plans(
                    target,
                    self.current_items,
                    max_depth=depth,
                    top_k=8,
                    timeout_seconds=timeout_seconds,
                    allow_missing=allow_missing,
                    minimum_level=minimum,
                    progress_callback=self._enqueue_planner_event,
                )
            except Exception as exc:  # keep worker failures visible instead of silently dying
                self.after(0, lambda err=exc: self._planner_failed(err))
                return
            self.after(0, lambda: self._planner_done(plans, allow_missing))

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

    def _planner_failed(self, error: Exception) -> None:
        self._drain_planner_events()
        self._set_planner_running(False)
        self.plan_list.delete(0, "end")
        self.plan_text.delete("1.0", "end")
        self.plan_text.insert("end", f"Błąd wyszukiwania: {error}")
        self._append_planner_log(f"BŁĄD: {error}")
        self.planner_progress_text.set("Błąd wyszukiwania")
        self.status_var.set("Błąd")

    def _planner_done(self, plans, allow_missing):
        self._drain_planner_events()
        self._set_planner_running(False)
        self.current_plans = plans
        self.plan_list.delete(0, "end")
        self.plan_text.delete("1.0", "end")
        if not plans:
            elapsed = self.planner.last_elapsed
            if self.planner.last_status == "timeout":
                self.plan_text.insert(
                    "end",
                    f"Wyszukiwanie zatrzymano po {elapsed:.1f} s (limit: {self.planner.last_timeout_seconds:.0f} s).\n\n"
                    "Zwiększ limit czasu albo zmniejsz głębokość. Maksymalna obsługiwana głębokość wynosi 5; "
                    "każdy kolejny poziom wielokrotnie zwiększa drzewo wyszukiwania."
                )
            elif self.planner.last_status == "cancelled":
                self.plan_text.insert("end", "Wyszukiwanie zostało anulowane.")
            elif allow_missing:
                self.plan_text.insert(
                    "end",
                    f"Nie znaleziono planu w granicy {self.planner.last_depth} kroków na najdłuższej gałęzi. "
                    "Sprawdź konsolę wyszukiwania: pokaże, które etapy i warianty zostały przeanalizowane."
                )
            else:
                self.plan_text.insert(
                    "end",
                    f"Przeszukano możliwości w {elapsed:.1f} s i nie znaleziono planu wyłącznie z obecnego ekwipunku. "
                    "Użyj „Plan z brakami”, aby zobaczyć potrzebne składniki."
                )
        else:
            for idx, plan in enumerate(plans, 1):
                self.plan_list.insert("end", f"{idx}. braków: {plan.missing_count}, kroków: {plan.step_count}, wynik: {level_label(plan.result_level)}")
            self.plan_list.selection_set(0)
            self.show_selected_plan()
        self.planner_progress["value"] = 100
        if self.planner.last_status == "cancelled":
            self.planner_progress_text.set("Anulowano")
            self.status_var.set("Anulowano")
        elif self.planner.last_status == "timeout":
            self.planner_progress_text.set("Osiągnięto limit czasu")
            self.status_var.set("Limit czasu")
        elif plans:
            self.planner_progress_text.set(f"Gotowe — znaleziono {len(plans)} planów")
            self.status_var.set("Gotowe")
        else:
            self.planner_progress_text.set("Gotowe — brak planu w zadanych granicach")
            self.status_var.set("Gotowe")

    def show_selected_plan(self):
        selection = self.plan_list.curselection()
        if not selection or not self.current_plans:
            return
        plan = self.current_plans[selection[0]]
        lines = [
            f"CEL: {self.rules.identity_display(plan.target)}",
            f"Przewidywany poziom: {level_label(plan.result_level)}",
            f"Kroki: {plan.step_count} | Brakujące przedmioty: {plan.missing_count}",
            "",
        ]
        if plan.missing:
            lines.append("BRAKUJE:")
            counts = {}
            for identity in plan.missing:
                counts[identity.key()] = counts.get(identity.key(), [identity, 0])
                counts[identity.key()][1] += 1
            for identity, count in (value for value in counts.values()):
                lines.append(f"  • {count}× {self.rules.identity_display(identity)} (minimum Zwykły +1)")
            lines.append("")
        if not plan.steps:
            lines.append("Cel jest już w ekwipunku.")
        else:
            lines.append("KOLEJNOŚĆ ŁĄCZENIA:")
            for idx, step in enumerate(plan.steps, 1):
                lines.append(f"{idx}. {self.rules.identity_display(step.left)}")
                lines.append(f"   + {self.rules.identity_display(step.right)}")
                lines.append(f"   = {self.rules.identity_display(step.result)} [{level_label(step.result_level)}]")
        if plan.notes:
            lines.extend(["", "UWAGI:"] + [f"  • {note}" for note in plan.notes])
        self.plan_text.delete("1.0", "end")
        self.plan_text.insert("end", "\n".join(lines))

    def save_selected_plan(self):
        if not self.current_profile_id or not self.current_plans:
            return
        selection = self.plan_list.curselection()
        if not selection:
            return
        plan = self.current_plans[selection[0]]
        name = simpledialog.askstring("Zapisz plan", "Nazwa planu:", initialvalue=self.rules.identity_display(plan.target))
        if not name:
            return
        self.db.save_plan(self.current_profile_id, name, plan)
        self.refresh_saved()

    def _encyclopedia_category_key(self) -> str:
        label = self.enc_category.get()
        if label == "Wszystkie":
            return ""
        for category in self.rules.categories():
            if self.rules.category_label(category) == label:
                return category
        return ""

    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.get():
            parts.append(legendary_word(ident.base))
        quality = self._quality_word(self.enc_quality.get(), 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))
        try:
            upgrade = int(self.enc_upgrade.get())
        except ValueError:
            upgrade = 0
        if upgrade:
            parts.append(f"(+{upgrade})")
        return " ".join(parts)

    def refresh_encyclopedia_options(self):
        category = self._encyclopedia_category_key()
        categories = [category] if category else self.rules.categories()
        prefix_labels = {"Brak"}
        base_labels = {}
        suffix_labels = {"Brak": ""}
        self.enc_prefix_map = {"Brak": "__EMPTY__"}
        self.enc_base_map = {}
        self.enc_suffix_map = {"Brak": "__EMPTY__"}
        for cat in categories:
            for prefix in self.rules.prefixes(cat):
                label = self.rules.prefix_filter_label(prefix)
                prefix_labels.add(label)
                self.enc_prefix_map.setdefault(label, self.rules.prefix_semantic_key(prefix))
            for base in self.rules.bases(cat):
                label = self.rules.display(base)
                base_labels[label] = base
                self.enc_base_map[label] = base
            for suffix in self.rules.suffixes(cat):
                label = self.rules.display(suffix)
                suffix_labels[label] = suffix
                self.enc_suffix_map[label] = suffix
        self.enc_widgets["Prefix"]["values"] = ["Wszystkie"] + sorted(prefix_labels, key=str.casefold)
        self.enc_widgets["Baza"]["values"] = ["Wszystkie"] + sorted(base_labels, key=str.casefold)
        self.enc_widgets["Suffix"]["values"] = ["Wszystkie"] + sorted(suffix_labels, key=str.casefold)
        for variable, key in ((self.enc_prefix, "Prefix"), (self.enc_base, "Baza"), (self.enc_suffix, "Suffix")):
            if variable.get() not in self.enc_widgets[key]["values"]:
                variable.set("Wszystkie")
        self.refresh_encyclopedia_rows()

    def clear_encyclopedia_filters(self):
        self.enc_search.set("")
        self.enc_category.set("Wszystkie")
        self.enc_prefix.set("Wszystkie")
        self.enc_base.set("Wszystkie")
        self.enc_suffix.set("Wszystkie")
        self.enc_quality.set("Zwykły")
        self.enc_upgrade.set("0")
        self.enc_legendary.set(False)
        self.refresh_encyclopedia_options()

    def _encyclopedia_player_level_value(self) -> int:
        try:
            value = max(1, min(999, int(self.enc_player_level.get())))
        except (TypeError, ValueError, tk.TclError):
            value = 80
            self.enc_player_level.set(value)
        return value

    def _selected_variant(self) -> VariantSelection | None:
        selected = self.enc_tree.selection()
        if not selected:
            return None
        ident = self.encyclopedia_row_map.get(selected[0])
        if not ident:
            return None
        player_level = self._encyclopedia_player_level_value()
        return VariantSelection(
            ident,
            quality=self.enc_quality.get(),
            upgrade=int(self.enc_upgrade.get()),
            legendary=bool(self.enc_legendary.get()),
            player_level=player_level,
        )

    def refresh_encyclopedia_rows(self):
        if not hasattr(self, "enc_tree"):
            return
        category = self._encyclopedia_category_key()
        prefix_label = self.enc_prefix.get()
        prefix = "" if prefix_label in {"", "Wszystkie"} else self.enc_prefix_map.get(prefix_label, "")
        base = "" if self.enc_base.get() in {"", "Wszystkie"} else self.enc_base_map.get(self.enc_base.get(), "")
        suffix = "" if self.enc_suffix.get() in {"", "Wszystkie"} else self.enc_suffix_map.get(self.enc_suffix.get(), "")
        rows = self.item_catalog.filter(
            category=category,
            prefix_semantic=prefix,
            base=base,
            suffix=suffix,
            query=self.enc_search.get(),
        )
        self.encyclopedia_visible_rows = rows
        self.enc_tree.delete(*self.enc_tree.get_children())
        self.encyclopedia_row_map = {}
        display_limit = 4000
        for idx, ident in enumerate(rows[:display_limit]):
            iid = f"enc-{idx}"
            self.encyclopedia_row_map[iid] = ident
            variant = VariantSelection(
                ident, self.enc_quality.get(), int(self.enc_upgrade.get()), bool(self.enc_legendary.get()),
                self._encyclopedia_player_level_value(),
            )
            cached = "TAK" if self.db.get_encyclopedia_stats(variant.cache_key()) else ""
            tags = ("legendary",) if self.enc_legendary.get() else ()
            self.enc_tree.insert("", "end", iid=iid, tags=tags, 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,
            ))
        suffix_text = f"; pokazano pierwsze {display_limit}" if len(rows) > display_limit else ""
        self.enc_stats_text.set(
            f"Tożsamości: {self.item_catalog.identity_count():,} | Pełne warianty: {self.item_catalog.variant_count():,} | "
            f"Pasuje: {len(rows):,}{suffix_text} | Cache statystyk: {self.db.encyclopedia_cache_count():,}"
        )
        self.enc_detail.delete("1.0", "end")
        self.enc_detail.insert("end", "Wybierz przedmiot. Dwuklik pobiera oficjalne statystyki i zapisuje je w lokalnym cache.")

    def show_selected_encyclopedia_item(self):
        selection = self._selected_variant()
        if not selection:
            return
        row = self.db.get_encyclopedia_stats(selection.cache_key())
        if row:
            self._display_encyclopedia_stats(dict(row), cached=True)
        else:
            ident = selection.identity
            lines = [
                self._variant_name(ident), "",
                f"Kategoria: {self.rules.category_label(ident.category)}",
                f"Prefix: {self.rules.prefix_filter_label(ident.prefix) if ident.prefix else 'Brak'}",
                f"Baza: {self.rules.display(ident.base)}",
                f"Suffix: {self.rules.display(ident.suffix) if ident.suffix else 'Brak'}",
                f"Jakość: {selection.quality} +{selection.upgrade}",
                f"Legendarny: {'TAK' if selection.legendary else 'NIE'}",
                f"Poziom gracza testera: {selection.player_level}", "",
                "Statystyki nie są jeszcze zapisane. Kliknij „Pobierz / odśwież wybrany”.",
            ]
            self.enc_detail.delete("1.0", "end")
            self.enc_detail.insert("end", "\n".join(lines))

    def _display_encyclopedia_stats(self, row: dict, cached: bool = False):
        lines = [
            row.get("item_name") or "(brak nazwy)",
            row.get("item_category") or self.rules.category_label(row.get("category", "")),
            "",
        ]
        for label, key in [
            ("Obrażenia", "damage"), ("Obrona", "defense"), ("Cechy", "features"),
            ("Wymagania", "requirements"), ("Cena", "price"),
            ("Cena sprzedaży", "sell_price"), ("Mana", "mana"), ("Nanity", "nanites"),
            ("Strefa — przedmiot", "zone_item"), ("Strefa — prefix", "zone_prefix"),
            ("Strefa — suffix", "zone_suffix"), ("Strefa — razem", "zone_total"),
            ("Rzadkość — przedmiot", "rarity_item"), ("Rzadkość — prefix", "rarity_prefix"),
            ("Rzadkość — suffix", "rarity_suffix"), ("Rzadkość — razem", "rarity_total"),
        ]:
            value = row.get(key)
            if value not in (None, ""):
                lines.append(f"{label}: {value}")
        lines.extend(["", f"Źródło: {row.get('url','')}", f"Pobrano: {row.get('fetched_at','')}"])
        if cached:
            lines.append("Status: lokalny cache")
        self.enc_detail.delete("1.0", "end")
        self.enc_detail.insert("end", "\n".join(lines))

    def _set_encyclopedia_fetch_running(self, running: bool):
        self.encyclopedia_fetch_running = running
        state = "disabled" if running else "normal"
        self.enc_fetch_button.configure(state=state)
        self.enc_batch_button.configure(state=state)

    def fetch_selected_encyclopedia_stats(self):
        if self.encyclopedia_fetch_running:
            return
        selection = self._selected_variant()
        if not selection:
            messagebox.showinfo("Brak wyboru", "Wybierz przedmiot z listy.")
            return
        self._set_encyclopedia_fetch_running(True)
        self.enc_online_status.set("Łączenie z oficjalnym testerem BloodWars…")

        def worker():
            try:
                stats = self.official_client.fetch(selection)
            except Exception as exc:
                self.after(0, lambda e=exc: self._encyclopedia_fetch_failed(e))
                return
            self.after(0, lambda s=stats: self._encyclopedia_fetch_done(s))
        threading.Thread(target=worker, daemon=True).start()

    def _encyclopedia_fetch_failed(self, error: Exception):
        self._set_encyclopedia_fetch_running(False)
        self.enc_online_status.set(f"Błąd pobierania: {error}")
        messagebox.showerror("Nie udało się pobrać statystyk", str(error))

    def _encyclopedia_fetch_done(self, stats):
        self.db.upsert_encyclopedia_stats(stats)
        self._set_encyclopedia_fetch_running(False)
        self.enc_online_status.set("Pobrano i zapisano statystyki w lokalnym cache.")
        self.refresh_encyclopedia_rows()
        self._display_encyclopedia_stats(stats.to_dict())

    def fetch_visible_encyclopedia_stats(self):
        if self.encyclopedia_fetch_running:
            return
        try:
            limit = max(1, min(100, int(self.enc_batch_limit.get())))
        except ValueError:
            limit = 25
        rows = self.encyclopedia_visible_rows[:limit]
        if not rows:
            return
        if not messagebox.askyesno(
            "Pobieranie seryjne",
            f"Pobrać maksymalnie {len(rows)} widocznych wariantów?\n\n"
            "Program robi przerwę między zapytaniami i pomija pozycje już zapisane w cache.",
        ):
            return
        quality, upgrade, legendary = self.enc_quality.get(), int(self.enc_upgrade.get()), bool(self.enc_legendary.get())
        player_level = self._encyclopedia_player_level_value()
        self._set_encyclopedia_fetch_running(True)

        def worker():
            done = 0
            errors = 0
            local_db = Database(self.db.path)
            try:
                for ident in rows:
                    selection = VariantSelection(ident, quality, upgrade, legendary, player_level)
                    if local_db.get_encyclopedia_stats(selection.cache_key()):
                        done += 1
                        self.after(0, lambda d=done: self.enc_online_status.set(f"Cache / pobrano: {d}/{len(rows)}"))
                        continue
                    try:
                        stats = self.official_client.fetch(selection)
                        local_db.upsert_encyclopedia_stats(stats)
                    except Exception:
                        errors += 1
                    done += 1
                    self.after(0, lambda d=done, e=errors: self.enc_online_status.set(f"Pobrano: {d}/{len(rows)} | błędy: {e}"))
                    time.sleep(1.0)
            finally:
                local_db.close()
            self.after(0, lambda: self._encyclopedia_batch_done(done, errors))
        threading.Thread(target=worker, daemon=True).start()

    def _encyclopedia_batch_done(self, done: int, errors: int):
        self._set_encyclopedia_fetch_running(False)
        self.enc_online_status.set(f"Zakończono serię: {done} pozycji, błędy: {errors}.")
        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
        self.enc_online_status.set("Ustalanie adresu oficjalnego testera…")
        def worker():
            try:
                url = self.official_client.build_url(selection)
            except Exception as exc:
                self.after(0, lambda e=exc: self._encyclopedia_fetch_failed(e))
                return
            self.after(0, lambda: webbrowser.open(url))
        threading.Thread(target=worker, daemon=True).start()

    def export_encyclopedia_sqlite(self):
        path = filedialog.asksaveasfilename(
            title="Eksport katalogu SQLite",
            defaultextension=".sqlite",
            filetypes=[("SQLite database", "*.sqlite"), ("Wszystkie pliki", "*.*")],
            initialfile="BW_Moria_Items.sqlite",
        )
        if not path:
            return
        self.item_catalog.export_sqlite(Path(path))
        messagebox.showinfo("Gotowe", f"Zapisano pełny katalog i widok {self.item_catalog.variant_count():,} wariantów:\n{path}")

    def export_encyclopedia_csv(self):
        path = filedialog.asksaveasfilename(
            title="Eksport katalogu CSV",
            defaultextension=".csv",
            filetypes=[("CSV", "*.csv"), ("Wszystkie pliki", "*.*")],
            initialfile="BW_Moria_Item_Identities.csv",
        )
        if not path:
            return
        self.item_catalog.export_csv(Path(path))
        messagebox.showinfo("Gotowe", f"Zapisano {self.item_catalog.identity_count():,} tożsamości:\n{path}")

    def export_encyclopedia_xlsx(self):
        path = filedialog.asksaveasfilename(
            title="Eksport katalogu XLSX",
            defaultextension=".xlsx",
            filetypes=[("Excel", "*.xlsx"), ("Wszystkie pliki", "*.*")],
            initialfile="BW_Moria_Item_Catalog.xlsx",
        )
        if not path:
            return
        cached = [dict(row) for row in self.db.encyclopedia_stats_rows()]
        export_catalog_xlsx(self.item_catalog, self.rules, Path(path), cached)
        messagebox.showinfo(
            "Gotowe",
            f"Zapisano {self.item_catalog.identity_count():,} tożsamości oraz {len(cached):,} zapisanych rekordów statystyk:\n{path}",
        )

    def refresh_history(self):
        self.history_tree.delete(*self.history_tree.get_children())
        if not self.current_profile_id:
            return
        profile = self.db.get_profile(self.current_profile_id)
        active = profile["active_import_id"] if profile else None
        for row in self.db.imports(self.current_profile_id):
            filename = Path(row["source_file"]).name
            label = "★ " + filename if row["id"] == active else filename
            self.history_tree.insert("", "end", iid=str(row["id"]), values=(row["imported_at"], label, row["parsed_count"], row["declared_count"] or "", row["warning_count"]))

    def restore_import(self):
        selected = self.history_tree.selection()
        if not selected or not self.current_profile_id:
            return
        self.db.set_active_import(self.current_profile_id, int(selected[0]))
        self.load_profile()

    def refresh_saved(self):
        self.saved_tree.delete(*self.saved_tree.get_children())
        if not self.current_profile_id:
            return
        for row in self.db.saved_plans(self.current_profile_id):
            target = json.loads(row["target_json"])
            ident = Identity(**target)
            self.saved_tree.insert("", "end", iid=str(row["id"]), values=(row["name"], row["created_at"], self.rules.identity_display(ident)))

    def delete_saved_plan(self):
        selected = self.saved_tree.selection()
        if not selected:
            return
        if messagebox.askyesno("Usuń plan", "Usunąć zaznaczony plan?"):
            self.db.delete_saved_plan(int(selected[0]))
            self.refresh_saved()

    @staticmethod
    def sort_tree(tree: ttk.Treeview, column: str, reverse: bool):
        data = [(tree.set(item, column), item) for item in tree.get_children("")]
        data.sort(key=lambda x: x[0].casefold(), reverse=reverse)
        for index, (_value, item) in enumerate(data):
            tree.move(item, "", index)
        tree.heading(column, command=lambda: Application.sort_tree(tree, column, not reverse))

    def on_close(self):
        self.db.close()
        self.destroy()


def main():
    app = Application()
    app.mainloop()


if __name__ == "__main__":
    main()
