# -*- coding: utf-8 -*-
"""02viz studio dock — charts, map diagrams and labels under one roof.

A shared Data card (layer / selected-only / external table) above three tabs:
**Charts** (engine/type/theme/fields/shaping → embedded viewer with a
chart→map selection bridge; spec assembly in ``_build_spec``), **Map
diagrams** (native on-canvas diagrams with optional normalisation) and
**Labels** (expression-driven, formatted, multi-line labels). A header
**Guide** button shows the offline user guide and **Suggest** asks the
offline assistant for the most suitable chart. Rendering goes through the engine
registry, so engines never touch Qt; the pure logic lives in ``core``.
"""
from __future__ import annotations

import json
import os
import tempfile
import time
from contextlib import suppress

from qgis.PyQt.QtCore import Qt, QTimer, QSettings, QSize
from qgis.PyQt.QtGui import QColor, QIcon, QPainter, QPixmap
from qgis.PyQt.QtWidgets import (
    QAbstractItemView,
    QApplication,
    QCheckBox,
    QColorDialog,
    QComboBox,
    QDockWidget,
    QDoubleSpinBox,
    QFileDialog,
    QFormLayout,
    QFrame,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QListWidget,
    QListWidgetItem,
    QListView,
    QMessageBox,
    QInputDialog,
    QPushButton,
    QPlainTextEdit,
    QScrollArea,
    QSpinBox,
    QTabWidget,
    QVBoxLayout,
    QWidget,
)
from qgis.core import QgsFieldProxyModel
from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox

from ..core import (
    assistant, datasource, diagrams, expressions, labels,
    overlays, presets, profile as profiler, requirements, stats, transform,
)
from ..core.export_utils import (
    atomic_output,
    atomic_write_text,
    ensure_extension,
    human_size,
)
from ..engines import engines
from ..engines.base import DEFAULT_THEME, PALETTES, THEMES, webkit_fallback_page
from ..engines.dashboard import build_dashboard
from ..engines.guide import build_guide_html
from .bridge import SelectionBridge
from .webview import attach_title_listener, create_chart_view, run_js, show_html_file

# above this many selected features, skip the cross-filter highlight (the
# injected id list would dwarf any visual benefit)
MAX_HIGHLIGHT_IDS = 20000

# sentinel entry in the Colors combo that opens the custom palette editor
CUSTOM_PALETTE_LABEL = "Custom…"

PRESET_SETTINGS_KEY = "zero2viz/chart_presets"

# 💡 Suggest: how many numeric fields it will ever pull into Python for one
# click, independent of core.assistant's O(fields^2) scan cap — a wide
# grid/zonal-stats layer's raw field*row pull is itself heavy enough to
# strain memory before any analysis even starts.
_SUGGEST_FIELD_CAP = 60
_SUGGEST_FULL_FIELD_CAP = 300  # after the user explicitly asks for a full scan

# The studio has its own light visual identity (light root, white cards). On
# QGIS 3 (Qt5) the host palette is light too, so unstyled child widgets looked
# fine by accident. On QGIS 4 (Qt6) the host palette is often dark: every
# colour we DON'T pin is then inherited from it, so the combo popups paint
# near-black and the field labels/checkboxes turn into light-on-white ghosts
# (the "black dropdown + unreadable label" bug). The remedy is to pin every text
# and input colour here, so the panel reads identically under any host theme.
_DOCK_QSS = """
QWidget#o2vizRoot { background: #fbfbfd; }
QWidget#o2vizTabPage { background: #ffffff; }
QLabel#o2vizTitle { font-size: 18px; font-weight: 700; color: #16323f; }
QLabel#o2vizTagline { color: #5b6b73; }
QFrame.o2vizCard {
    background: #ffffff;
    border: 1px solid #e3e7ec;
    border-radius: 10px;
}
QLabel.o2vizCardTitle { font-weight: 600; color: #16323f; }
QLabel#o2vizStatus { color: #5b6b73; padding: 2px; }
QTabWidget::pane { border: 1px solid #e3e7ec; border-radius: 8px; top: -1px;
                   background: #ffffff; }
/* font-weight is fixed at 600 for BOTH states on purpose: QTabBar sizes each
   tab once from its normal-state text metrics, so making only :selected bold
   widens that tab's text past the box it was already sized for — Qt does not
   re-lay-out the tab on a pseudo-state change, so the wider glyphs got
   clipped/overlapped ("kendi metin bloğuna sığmıyor"). Keeping the weight
   constant and varying only colour/background avoids the mismatch entirely. */
/* each tab sizes to its OWN text + this modest padding — a shared
   min-width (tried in 0.15.8) forced every tab to match the widest one
   ("Diagrams"), which read as oversized and inconsistent for the two
   shorter tabs ("Charts", "Labels"). Left padding still leads right: a
   real screenshot showed every tab's FIRST letter missing without it (the
   native Windows tab style's own left inset fights a custom QSS one); the
   extra left room, plus the leading space in each tab's label text (see
   StudioDockWidget._build_ui), gives that inset something other than a
   real letter to eat into. The elide-fallback set in _build_ui
   (ElideRight) is the safety net for the rare case this still isn't quite
   enough on an unusual font — a graceful "…" instead of a raw clip.
   0.15.11's shorter (3px) vertical padding didn't land well — reverted the
   height back to 0.15.10's 10px, and scaled the horizontal padding up by
   the requested ×1.2 from that same 0.15.10 baseline (16/20 → 19/24). */
QTabBar::tab { background: #eef1f4; color: #5b6b73; padding: 10px 19px 10px 24px;
               margin-right: 4px; font-weight: 600;
               border-top-left-radius: 7px; border-top-right-radius: 7px; }
/* REVERTED (0.15.9 → 0.15.10): a QSS border-top accent stripe on the
   selected tab covered part of the text with a white strip on a real
   Windows screenshot — this native "windowsvista" tab style has already
   shown once (0.15.6, the missing-first-letter bug) that it does not
   reliably honour a custom QSS box-model addition; adding border-top most
   likely grew the box by 3px without the native sizeFromContents
   calculation reserving matching space, pushing the text down into the
   zone the content pane below already owns. Back to a background/colour
   difference only — no border property at all — which is exactly what
   shipped from 0.15.0 through 0.15.8 without this complaint. Lesson: on
   THIS specific tab bar, treat any new border/padding/margin property as
   suspect until proven on a real screenshot, not just headless. */
QTabBar::tab:selected { background: #ffffff; color: #16323f; }
QTabBar::tab:hover:!selected { background: #e7ebee; color: #16323f; }

/* ── text + form labels (pinned dark so they never inherit a light palette
      text colour and vanish on the white cards) ── */
QLabel { color: #2c3e46; background: transparent; }
QCheckBox { color: #2c3e46; background: transparent; spacing: 6px; }
QCheckBox:disabled { color: #9aa6ae; }

/* ── inputs: white field, dark text, teal selection — independent of the host
      palette so the dropdown popup is never black again ── */
QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QListWidget {
    background: #ffffff;
    color: #16323f;
    border: 1px solid #cbd3da;
    border-radius: 6px;
    padding: 3px 6px;
    selection-background-color: #2a8f85;
    selection-color: #ffffff;
}
QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus {
    border: 1px solid #2a8f85;
}
QComboBox:disabled, QLineEdit:disabled, QSpinBox:disabled,
QDoubleSpinBox:disabled {
    background: #eef1f3; color: #9aa6ae; border-color: #dde2e6;
}
/* the drop-down list itself — the surface the user saw as solid black */
QComboBox QAbstractItemView {
    background: #ffffff;
    color: #16323f;
    border: 1px solid #cbd3da;
    selection-background-color: #2a8f85;
    selection-color: #ffffff;
    outline: 0;
}
QComboBox QAbstractItemView::item { min-height: 22px; padding: 2px 4px; }
QListWidget::item { color: #16323f; }
QListWidget::item:selected { background: #2a8f85; color: #ffffff; }

/* ── secondary buttons (Load table / Clear / ↗): the primary ones set their
      own teal style in code, which wins over this neutral default ── */
QPushButton {
    background: #eef1f4; color: #1f333d;
    border: 1px solid #cbd3da; border-radius: 6px; padding: 5px 10px;
}
QPushButton:hover { background: #e4e9ed; }
QPushButton:disabled { background: #f2f4f6; color: #9aa6ae; border-color: #e2e6ea; }

QToolTip { background: #16323f; color: #ffffff; border: 1px solid #16323f;
           padding: 4px 6px; }
"""
_RENDER_BTN_QSS = (
    "QPushButton { background-color: #2a8f85; border: 1px solid #237a72;"
    " color: #ffffff; font-weight: 600; padding: 7px 14px; border-radius: 8px; }"
    "QPushButton:hover { background-color: #319c91; }"
    "QPushButton:disabled { background-color: #a9c8c4; border-color: #a9c8c4; }"
)
# the single most-clicked action in the whole studio gets its own accent — a
# soft "yavru ağzı" (salmon) stroke around the same teal fill, so it reads as
# the primary action at a glance without changing its familiar colour
_RENDER_CHART_BTN_QSS = (
    "QPushButton { background-color: #2a8f85; border: 2px solid #f2a488;"
    " color: #ffffff; font-weight: 600; padding: 6px 14px; border-radius: 8px; }"
    "QPushButton:hover { background-color: #319c91; border-color: #f6bda8; }"
    "QPushButton:disabled { background-color: #a9c8c4; border-color: #e4c6bb; }"
)

CHART_TYPES = [
    ("bar", "Bar chart"),
    ("line", "Line chart"),
    ("area", "Area chart"),
    ("scatter", "Scatter plot"),
    ("bubble", "Bubble chart"),
    ("histogram", "Histogram"),
    ("pie", "Pie / donut"),
    ("box", "Box plot"),
    ("heatmap", "Heatmap (matrix)"),
    ("treemap", "Treemap"),
    ("sunburst", "Sunburst"),
    ("errorband", "Mean ± σ band"),
    ("errorbar", "Mean ± σ bars"),
    ("density", "Density (KDE)"),
    ("violin", "Violin plot"),
    ("radar", "Radar / spider"),
    ("pareto", "Pareto (80/20)"),
]

# which controls matter per chart type:
#   (y, group, value_size, aggregate, bins, topn_sort, stacked, trend)
_CONTROLS = {
    "bar":       (True,  True,  False, True,  False, True,  True,  False),
    "line":      (True,  True,  False, True,  False, True,  False, False),
    "area":      (True,  True,  False, True,  False, True,  True,  False),
    "scatter":   (True,  True,  False, False, False, False, False, True),
    "bubble":    (True,  True,  True,  False, False, False, False, True),
    "histogram": (False, False, False, False, True,  False, False, False),
    "pie":       (True,  False, False, True,  False, True,  False, False),
    "box":       (True,  True,  False, False, False, False, False, False),
    "heatmap":   (True,  False, True,  True,  False, False, False, False),
    "treemap":   (False, True,  True,  True,  False, False, False, False),
    "sunburst":  (False, True,  True,  True,  False, False, False, False),
    "errorband": (True,  True,  False, False, False, False, False, False),
    "errorbar":  (True,  True,  False, False, False, False, False, False),
    "density":   (False, True,  False, False, False, False, False, False),
    "violin":    (True,  True,  False, False, False, False, False, False),
    "radar":     (True,  True,  False, True,  False, False, False, False),
    "pareto":    (True,  False, False, True,  False, True,  False, False),
}


def _vector_filter():
    """Scoped-enum-safe vector filter for QgsMapLayerComboBox (Qt5/Qt6)."""
    from qgis.core import QgsMapLayerProxyModel

    try:
        return QgsMapLayerProxyModel.VectorLayer
    except AttributeError:  # QGIS 4 scoped enums
        return QgsMapLayerProxyModel.Filter.VectorLayer


def _numeric_field_filter():
    """Scoped-enum-safe numeric filter for QgsFieldComboBox (Qt5/Qt6)."""
    try:
        return QgsFieldProxyModel.Numeric
    except AttributeError:  # QGIS 4 scoped enums
        return QgsFieldProxyModel.Filter.Numeric


class StudioDockWidget(QDockWidget):
    def __init__(self, iface, icon_dir: str, parent=None):
        super().__init__("02viz - Geospatial Visualization Studio", parent)
        self.iface = iface
        self.icon_dir = icon_dir
        self.setAllowedAreas(
            Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea
        )
        self._view_kind: str | None = None
        self._view = None
        self._view_slot: QVBoxLayout | None = None
        self._last_html: str | None = None
        self._last_path: str | None = None
        self._last_extraction_info = None
        self._tmp_dir: str | None = None
        self._watched_layer = None
        self._suppress_refresh_until = 0.0
        self._custom_palette: list[str] | None = None
        self._custom_spec_enabled = False
        self._prev_palette_index = 0
        self._preset_library: dict[str, dict] = {}
        self.bridge = SelectionBridge()
        self.bridge.on_selected = self._on_chart_drove_selection
        self._build_ui()

    # ───────────────────────── UI ─────────────────────────

    def _card(self, title: str) -> tuple[QFrame, QVBoxLayout]:
        card = QFrame()
        card.setProperty("class", "o2vizCard")
        lay = QVBoxLayout(card)
        lay.setContentsMargins(12, 10, 12, 10)
        head = QLabel(title)
        head.setProperty("class", "o2vizCardTitle")
        lay.addWidget(head)
        return card, lay

    def _field_combo(self) -> QgsFieldComboBox:
        combo = QgsFieldComboBox()
        combo.setAllowEmptyFieldName(True)
        return combo

    @staticmethod
    def _dot_icon(color: str) -> QIcon:
        """A small filled circle, used as the active tab's indicator.

        Deliberately an ICON, not a border/padding QSS property: this tab
        bar's native "windowsvista" style has twice (0.15.6, 0.15.9) shown
        it does not reliably honour a custom QSS box-model addition, but
        tab icons are a first-class QTabBar feature Qt paints in its own
        dedicated, native-theme-compatible slot — it can't collide with the
        box-model math a border does.
        """
        size = 8
        pixmap = QPixmap(size, size)
        pixmap.fill(Qt.GlobalColor.transparent)
        painter = QPainter(pixmap)
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
        painter.setBrush(QColor(color))
        painter.setPen(Qt.PenStyle.NoPen)
        painter.drawEllipse(0, 0, size, size)
        painter.end()
        return QIcon(pixmap)

    def _sync_tab_dot(self, *_args) -> None:
        """Show a small teal dot on the active tab only — the "nice effect"
        for which tab is open, without touching border/padding (see
        _dot_icon)."""
        if not hasattr(self, "tabs"):
            return
        current = self.tabs.currentIndex()
        for i in range(self.tabs.count()):
            self.tabs.setTabIcon(i, self._tab_dot_icon if i == current else QIcon())

    def _build_ui(self) -> None:
        container = QWidget()
        container.setObjectName("o2vizRoot")
        container.setStyleSheet(_DOCK_QSS)
        root = QVBoxLayout(container)
        root.setContentsMargins(10, 10, 10, 10)
        root.setSpacing(8)

        head_row = QHBoxLayout()
        head_text = QVBoxLayout()
        head_text.setSpacing(0)
        title = QLabel("02viz")
        title.setObjectName("o2vizTitle")
        head_text.addWidget(title)
        tagline = QLabel("Zero2Visual · from zero to elegant visuals")
        tagline.setObjectName("o2vizTagline")
        head_text.addWidget(tagline)
        head_row.addLayout(head_text)
        head_row.addStretch(1)
        self.guide_btn = QPushButton("❔ Guide")
        self.guide_btn.setToolTip(
            "Open the full 02viz user guide — what every part does and how to "
            "get a great result (with suggestions for the current layer)")
        self.guide_btn.clicked.connect(self._open_guide)
        head_row.addWidget(self.guide_btn, 0, Qt.AlignmentFlag.AlignTop)
        root.addLayout(head_row)

        # the layer + scope are shared by all three surfaces (charts, on-canvas
        # diagrams, labels), so the Data card lives above the tabs
        root.addWidget(self._build_data_card())

        self.tabs = QTabWidget()
        # Each tab is sized to its own (short) text — do NOT force expanding
        # or a minimum width here: a forced minimum cannot shrink on a
        # narrow dock, and with expanding on, Qt held the whole bar at that
        # forced size regardless of the dock's actual width (verified: bar
        # width stayed fixed from 140px to 360px), so the excess silently
        # ran off the edge — a hard mid-glyph clip, worse than the original
        # bug. Left at Qt's default: `usesScrollButtons` is already on for
        # this style, so if the 3 short tabs genuinely don't fit, small
        # arrows let you reach the rest instead of any tab ever being
        # rendered smaller than its own text. ElideRight is still set as a
        # last-resort fallback so any edge case degrades to "…", never a
        # raw pixel clip.
        #
        # A real screenshot on Windows QGIS showed every tab missing its
        # FIRST letter ("Charts"→"harts", "Diagrams"→"iagram", "Labels"→
        # "abels") — a known Qt/Windows quirk: the native "windowsvista"
        # style paints QTabBar tabs through the real Windows theme engine
        # (UxTheme), which reserves its own internal left inset that a
        # custom QSS stylesheet doesn't fully override, so the leading
        # glyph is painted under the tab's rounded left edge. (Tried
        # forcing this tab bar onto the Fusion style, which draws purely
        # from QSS geometry and would have sidestepped the native theme
        # entirely — reverted: it segfaulted on dock teardown in real
        # QGIS and the applied style didn't even verify as Fusion, likely
        # from mixing an explicit setStyle() with the container's own
        # QSS-driven QStyleSheetStyle proxy. Not worth the crash risk.)
        # Two purely-QSS/string fixes instead, layered for safety since
        # this can't be reproduced headless (no native Windows theme
        # engine under offscreen — verified against the screenshot, not a
        # pixel test): extra LEFT padding beyond the right, and a leading
        # space in each label, so whatever gets eaten from the left is the
        # padding/space, not a real letter, regardless of the exact cause.
        tabbar = self.tabs.tabBar()
        with suppress(Exception):
            tabbar.setElideMode(Qt.TextElideMode.ElideRight)
        # "Map diagrams" ran tight against real desktop font metrics on a
        # narrow dock — "Diagrams" alone reads just as clearly next to
        # "Charts" and "Labels" (the tab's own card title inside still says
        # "Map diagrams — on canvas" in full)
        for page, label in ((self._build_charts_tab(), " Charts"),
                            (self._build_diagrams_tab(), " Diagrams"),
                            (self._build_labels_tab(), " Labels")):
            # pin a light page background so a dark host *stylesheet* (e.g. a
            # Night-Mapping UI theme that sets QWidget{background:dark}) can't
            # bleed through the chart area or the gaps around the cards
            page.setObjectName("o2vizTabPage")
            self.tabs.addTab(page, label)
        # a small teal dot marks the active tab (see _dot_icon/_sync_tab_dot)
        self.tabs.setIconSize(QSize(8, 8))
        self._tab_dot_icon = self._dot_icon("#2a8f85")
        self.tabs.currentChanged.connect(self._sync_tab_dot)
        self._sync_tab_dot()
        root.addWidget(self.tabs, 1)

        self.status = QLabel("Ready")
        self.status.setObjectName("o2vizStatus")
        root.addWidget(self.status)

        # The dock can be far shorter than the panel's natural content height
        # (many form rows across three tabs) — without this, QGIS simply
        # squashes everything and the bottom rows become unreachable. Wrap
        # the whole panel in a scroll area: widgetResizable=True still lets
        # the tab area stretch to fill a tall dock (root's stretch factor
        # below is honoured whenever there's spare height), but a short dock
        # scrolls instead of clipping.
        scroll = QScrollArea()
        scroll.setObjectName("o2vizScroll")
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.Shape.NoFrame)
        # AsNeeded, not AlwaysOff: widgetResizable=True does not shrink a
        # child below its own minimum-size-hint, so if anything inside ever
        # needs more width than a narrow dock offers (verified for the tab
        # bar; possible for other rows too), disabling this would make that
        # excess silently unreachable rather than a click-and-drag away.
        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
        scroll.setStyleSheet("QScrollArea#o2vizScroll { background: #fbfbfd; border: none; }")
        scroll.setWidget(container)
        self.setWidget(scroll)
        self._on_layer_changed(self.layer_combo.currentLayer())
        self._sync_engine_types()
        self._sync_controls()
        self._sync_swatches()

    # ── shared Data card ──

    def _build_data_card(self) -> QFrame:
        data_card, data_lay = self._card("Data")
        self.layer_combo = QgsMapLayerComboBox()
        self.layer_combo.setFilters(_vector_filter())
        self.layer_combo.layerChanged.connect(self._on_layer_changed)
        data_lay.addWidget(self.layer_combo)
        row = QHBoxLayout()
        self.selected_only = QCheckBox("Only selected features")
        row.addWidget(self.selected_only)
        self.auto_refresh = QCheckBox("Live: redraw on selection")
        self.auto_refresh.setToolTip(
            "Re-render the chart whenever the layer selection changes")
        row.addWidget(self.auto_refresh)
        data_lay.addLayout(row)
        ext_btn = QPushButton("Load external table…")
        ext_btn.setToolTip("Open a CSV/XLSX/ODS table — it joins the layer list above")
        ext_btn.clicked.connect(self._load_external)
        data_lay.addWidget(ext_btn)
        return data_card

    # ── Charts tab: the visualization studio ──

    def _build_charts_tab(self) -> QWidget:
        tab = QWidget()
        outer = QVBoxLayout(tab)
        outer.setContentsMargins(0, 8, 0, 0)
        outer.setSpacing(8)

        chart_card, chart_lay = self._card("Chart")
        form = QFormLayout()
        form.setHorizontalSpacing(8)
        form.setVerticalSpacing(6)

        # Engine first: the engine decides which chart types are drawable, so
        # the user picks the renderer, then chooses from its supported types.
        self.engine_combo = QComboBox()
        for eng in engines():
            label = eng.label if eng.available() else f"{eng.label}  (install…)"
            self.engine_combo.addItem(label, eng.id)
        self.engine_combo.setToolTip(
            "Vendored JS engines work offline with no setup; advanced engines "
            "(matplotlib/seaborn) install on demand for publication-grade static charts")
        form.addRow("Engine", self.engine_combo)

        self.type_combo = QComboBox()
        for key, label in CHART_TYPES:
            self.type_combo.addItem(label, key)
        self.type_combo.currentIndexChanged.connect(self._sync_controls)
        form.addRow("Type", self.type_combo)

        # wire the engine→type filter only now that type_combo exists
        # (engine_combo was populated above without a connected slot, so no
        # premature fire while type_combo was still missing)
        self.engine_combo.currentIndexChanged.connect(self._sync_engine_types)

        self.theme_combo = QComboBox()
        for name in THEMES:
            self.theme_combo.addItem(name)
        self.theme_combo.setCurrentText(DEFAULT_THEME)
        self.theme_combo.currentIndexChanged.connect(self._sync_swatches)
        form.addRow("Theme", self.theme_combo)

        self.palette_combo = QComboBox()
        for name in PALETTES:
            self.palette_combo.addItem(name)
        self.palette_combo.addItem(CUSTOM_PALETTE_LABEL)
        self.palette_combo.setToolTip(
            "Series colours — overrides the theme palette in every engine. "
            "Pick 'Custom…' or click a swatch to define your own.")
        # connect after populating so the addItem loop doesn't fire the handler
        self.palette_combo.currentIndexChanged.connect(self._on_palette_changed)
        form.addRow("Colors", self.palette_combo)

        # inline, embedded swatch editor — click a swatch to recolour, +/− to
        # resize the palette; any edit switches to a custom palette. No modal.
        self._swatch_host = QWidget()
        self._swatch_row = QHBoxLayout(self._swatch_host)
        self._swatch_row.setContentsMargins(0, 0, 0, 0)
        self._swatch_row.setSpacing(4)
        form.addRow("", self._swatch_host)

        self.title_edit = QLineEdit()
        self.title_edit.setPlaceholderText("(layer name)")
        self.title_edit.setToolTip("Override the chart title; empty uses the layer name")
        form.addRow("Title", self.title_edit)

        self.custom_spec_check = QCheckBox("Custom Vega-Lite spec")
        self.custom_spec_check.setToolTip("Edit the generated Vega-Lite JSON before rendering")
        self.custom_spec_check.toggled.connect(self._sync_custom_spec)
        form.addRow("Vega-Lite", self.custom_spec_check)
        self.custom_spec_edit = QPlainTextEdit()
        self.custom_spec_edit.setFixedHeight(150)
        self.custom_spec_edit.setPlaceholderText("Paste or edit a Vega-Lite JSON object")
        self.custom_spec_edit.setVisible(False)
        form.addRow("Spec JSON", self.custom_spec_edit)

        self.x_combo = self._field_combo()
        form.addRow("X / Category", self.x_combo)
        self.y_combo = self._field_combo()
        form.addRow("Y", self.y_combo)
        self.group_combo = self._field_combo()
        self.group_combo.setToolTip("Split into one colored series per value (or sub-group for treemap/sunburst)")
        form.addRow("Group / Color", self.group_combo)
        self.value_combo = self._field_combo()
        self.value_combo.setToolTip("Bubble size, heatmap cell value or treemap weight")
        form.addRow("Value / Size", self.value_combo)

        self.animate_combo = self._field_combo()
        self.animate_combo.setToolTip(
            "Play the chart through this field's values — a bar-chart race, "
            "Gapminder bubbles, composition over the years. ECharts / Plotly; "
            "bar · line · area · scatter · bubble · pie.")
        form.addRow("Animate by ▶", self.animate_combo)
        self.speed_combo = QComboBox()
        for _lbl, _ms in (("Slow", 1600), ("Medium", 900), ("Fast", 450)):
            self.speed_combo.addItem(_lbl, _ms)
        self.speed_combo.setCurrentIndex(1)
        self.speed_combo.setToolTip("Animation speed (time spent on each frame)")
        form.addRow("Play speed", self.speed_combo)

        self.agg_combo = QComboBox()
        self.agg_combo.addItems(list(stats.AGGREGATIONS))
        form.addRow("Aggregate", self.agg_combo)

        self.bins_spin = QSpinBox()
        self.bins_spin.setRange(2, 200)
        self.bins_spin.setValue(20)
        form.addRow("Bins", self.bins_spin)

        self.topn_spin = QSpinBox()
        self.topn_spin.setRange(0, 200)
        self.topn_spin.setValue(0)
        self.topn_spin.setSpecialValueText("all")
        self.topn_spin.setToolTip("Keep the N largest categories, collapse the rest into 'Other'")
        form.addRow("Top N", self.topn_spin)

        self.sort_combo = QComboBox()
        self.sort_combo.addItem("natural order", "natural")
        self.sort_combo.addItem("value ↓", "value_desc")
        self.sort_combo.addItem("value ↑", "value_asc")
        form.addRow("Sort", self.sort_combo)

        flags = QHBoxLayout()
        self.stacked_check = QCheckBox("Stacked")
        flags.addWidget(self.stacked_check)
        self.trend_check = QCheckBox("Trend line")
        flags.addWidget(self.trend_check)
        self.link_check = QCheckBox("Click selects on map")
        self.link_check.setChecked(True)
        flags.addWidget(self.link_check)

        # reference & statistics overlays (bar/line/area/scatter/bubble)
        refs = QHBoxLayout()
        refs.addWidget(QLabel("Reference:"))
        self.ov_mean_check = QCheckBox("Mean")
        self.ov_median_check = QCheckBox("Median")
        self.ov_sigma_check = QCheckBox("±1σ")
        self.ov_iqr_check = QCheckBox("IQR")
        self.ov_mean_check.setToolTip("Horizontal line at the mean of the plotted values")
        self.ov_median_check.setToolTip("Horizontal line at the median")
        self.ov_sigma_check.setToolTip("Shaded band one standard deviation either side of the mean")
        self.ov_iqr_check.setToolTip("Shaded band across the inter-quartile range (Q1–Q3)")
        for _c in (self.ov_mean_check, self.ov_median_check,
                   self.ov_sigma_check, self.ov_iqr_check):
            refs.addWidget(_c)
        self.ov_target_edit = QLineEdit()
        self.ov_target_edit.setPlaceholderText("Target…")
        self.ov_target_edit.setFixedWidth(84)
        self.ov_target_edit.setToolTip(
            "Draw a reference line at this value (leave blank for none)")
        refs.addWidget(self.ov_target_edit)
        refs.addStretch(1)

        chart_lay.addLayout(form)
        chart_lay.addLayout(flags)
        chart_lay.addLayout(refs)
        outer.addWidget(chart_card)

        preset_row = QHBoxLayout()
        preset_row.addWidget(QLabel("Preset"))
        self.preset_combo = QComboBox()
        self.preset_combo.setToolTip(
            "Recall a saved chart setup, including fields, styling and overlays")
        preset_row.addWidget(self.preset_combo, 1)
        self.preset_load_btn = QPushButton("Load")
        self.preset_load_btn.clicked.connect(self._load_selected_preset)
        preset_row.addWidget(self.preset_load_btn)
        self.preset_save_btn = QPushButton("Save preset")
        self.preset_save_btn.clicked.connect(self._save_preset)
        preset_row.addWidget(self.preset_save_btn)
        self.preset_delete_btn = QPushButton("Delete")
        self.preset_delete_btn.clicked.connect(self._delete_preset)
        preset_row.addWidget(self.preset_delete_btn)
        outer.addLayout(preset_row)
        self.preset_combo.currentIndexChanged.connect(self._sync_preset_buttons)
        self._read_presets()

        btn_row = QHBoxLayout()
        self.render_btn = QPushButton("Render chart")
        self.render_btn.setStyleSheet(_RENDER_CHART_BTN_QSS)
        self.render_btn.clicked.connect(self._render)
        btn_row.addWidget(self.render_btn, 2)
        self.suggest_btn = QPushButton("💡 Suggest")
        self.suggest_btn.setToolTip(
            "Let 02viz pick the most insightful chart for this layer's fields, "
            "configure the controls and render it")
        self.suggest_btn.clicked.connect(self._suggest_chart)
        btn_row.addWidget(self.suggest_btn, 1)
        self.explore_btn = QPushButton("✨ Explore layer")
        self.explore_btn.setToolTip(
            "One click: profile every field and build a full interactive dashboard")
        self.explore_btn.setStyleSheet(
            "QPushButton { background-color: #16323f; border: 1px solid #16323f;"
            " color: #ffffff; font-weight: 600; padding: 7px 12px; border-radius: 8px; }"
            "QPushButton:hover { background-color: #1f4254; }")
        self.explore_btn.clicked.connect(self._explore)
        btn_row.addWidget(self.explore_btn, 2)
        self.tile_picker = QListWidget()
        self.tile_picker.setObjectName("o2vizTilePicker")
        self.tile_picker.setFlow(QListView.Flow.LeftToRight)
        self.tile_picker.setWrapping(True)
        self.tile_picker.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.tile_picker.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
        self.tile_picker.setMinimumHeight(58)
        self.tile_picker.setMaximumHeight(64)
        for _label, _section in (("KPI row", "kpis"), ("Field table", "fields"), ("Count bars", "count_bars"), ("Histograms", "histograms"), ("Correlation matrix", "corr_matrix"), ("Scatter + trend", "scatter_trend"), ("Normalised box plot", "normalised_box"), ("Insights", "insights")):
            _item = QListWidgetItem(_label)
            _item.setData(Qt.ItemDataRole.UserRole, _section)
            _item.setFlags(_item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            _item.setCheckState(Qt.CheckState.Checked)
            self.tile_picker.addItem(_item)
        outer.addWidget(self.tile_picker)
        self.export_btn = QPushButton("Export HTML…")
        self.export_btn.setEnabled(False)
        self.export_btn.clicked.connect(self._export_html)
        btn_row.addWidget(self.export_btn, 1)
        self.export_svg_btn = QPushButton("Export SVG")
        self.export_svg_btn.setEnabled(False)
        self.export_svg_btn.clicked.connect(self._export_svg)
        btn_row.addWidget(self.export_svg_btn, 1)
        self.export_png_btn = QPushButton("Export PNG")
        self.export_png_btn.setEnabled(False)
        self.export_png_btn.clicked.connect(self._export_png)
        btn_row.addWidget(self.export_png_btn, 1)
        self.export_pdf_btn = QPushButton("Export PDF")
        self.export_pdf_btn.setEnabled(False)
        self.export_pdf_btn.clicked.connect(self._export_pdf)
        btn_row.addWidget(self.export_pdf_btn, 1)
        self.browser_btn = QPushButton("↗")
        self.browser_btn.setToolTip(
            "Open the current chart in your system web browser "
            "(use this if the embedded panel stays blank)")
        self.browser_btn.setEnabled(False)
        self.browser_btn.setFixedWidth(34)
        self.browser_btn.clicked.connect(self._open_in_browser)
        btn_row.addWidget(self.browser_btn)
        outer.addLayout(btn_row)

        self._view_slot = QVBoxLayout()
        self._view_placeholder = QLabel(
            "Pick a layer, choose a chart type and hit Render."
        )
        self._view_placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self._view_placeholder.setWordWrap(True)
        self._view_placeholder.setStyleSheet("color: #8d99ae; padding: 22px;")
        self._view_slot.addWidget(self._view_placeholder)
        outer.addLayout(self._view_slot, 1)
        return tab

    # ── Map diagrams tab: native QGIS diagrams on every feature ──

    def _build_diagrams_tab(self) -> QWidget:
        tab = QWidget()
        outer = QVBoxLayout(tab)
        outer.setContentsMargins(0, 8, 0, 0)
        outer.setSpacing(8)
        card, c = self._card("Map diagrams — on canvas")
        diag_intro = QLabel(
            "Draw a diagram on every feature, on the map canvas, "
            "coloured with the studio palette.")
        diag_intro.setWordWrap(True)
        c.addWidget(diag_intro)
        trow = QHBoxLayout()
        trow.addWidget(QLabel("Type"))
        self.diag_type_combo = QComboBox()
        for key in diagrams.DIAGRAM_TYPES:
            self.diag_type_combo.addItem(diagrams.DIAGRAM_LABELS[key], key)
        trow.addWidget(self.diag_type_combo, 1)
        c.addLayout(trow)
        c.addWidget(QLabel("Fields (numeric — slices / bars):"))
        self.diag_field_list = QListWidget()
        self.diag_field_list.setSelectionMode(
            QAbstractItemView.SelectionMode.NoSelection)
        self.diag_field_list.setMaximumHeight(150)
        c.addWidget(self.diag_field_list)
        srow = QHBoxLayout()
        srow.addWidget(QLabel("Size (mm)"))
        self.diag_size_spin = QDoubleSpinBox()
        self.diag_size_spin.setRange(3.0, 60.0)
        self.diag_size_spin.setValue(14.0)
        srow.addWidget(self.diag_size_spin)
        srow.addWidget(QLabel("Opacity"))
        self.diag_opacity_spin = QSpinBox()
        self.diag_opacity_spin.setRange(10, 100)
        self.diag_opacity_spin.setValue(100)
        self.diag_opacity_spin.setSuffix("%")
        srow.addWidget(self.diag_opacity_spin)
        srow.addStretch(1)
        c.addLayout(srow)

        szrow = QHBoxLayout()
        szrow.addWidget(QLabel("Size by"))
        self.diag_size_field_combo = self._field_combo()
        self.diag_size_field_combo.setFilters(_numeric_field_filter())
        self.diag_size_field_combo.setToolTip(
            "Proportional symbols: scale each feature's diagram between a small "
            "and a full-size diagram by this field's value, so a population or "
            "magnitude field reads at a glance instead of every feature looking "
            "equally big. Leave empty for a fixed size.")
        szrow.addWidget(self.diag_size_field_combo, 1)
        c.addLayout(szrow)

        orow = QHBoxLayout()
        self.diag_avoid_overlap_check = QCheckBox("Avoid overlaps (hide crowded diagrams)")
        self.diag_avoid_overlap_check.setToolTip(
            "Let QGIS drop diagrams that would collide instead of always "
            "drawing every one of them regardless of space.")
        orow.addWidget(self.diag_avoid_overlap_check)
        orow.addStretch(1)
        c.addLayout(orow)

        nrow = QHBoxLayout()
        nrow.addWidget(QLabel("Normalize"))
        self.diag_norm_combo = QComboBox()
        for key in expressions.NORMALIZE_MODES:
            self.diag_norm_combo.addItem(expressions.NORMALIZE_LABELS[key], key)
        self.diag_norm_combo.setToolTip(
            "Make differently-scaled fields comparable on every feature. "
            "Raw values let a big-number field swamp the rest; Min–max (0–1) is "
            "the safe default, Z-score standardises, Log compresses heavy tails.")
        self.diag_norm_combo.currentIndexChanged.connect(self._sync_diag_hint)
        self.diag_type_combo.currentIndexChanged.connect(self._sync_diag_hint)
        nrow.addWidget(self.diag_norm_combo, 1)
        c.addLayout(nrow)
        self.diag_hint = QLabel("")
        self.diag_hint.setWordWrap(True)
        self.diag_hint.setStyleSheet("color: #6b7a82; font-size: 11px;")
        c.addWidget(self.diag_hint)

        brow = QHBoxLayout()
        diag_apply = QPushButton("Apply to layer")
        diag_apply.setStyleSheet(_RENDER_BTN_QSS)
        diag_apply.clicked.connect(self._apply_diagram)
        brow.addWidget(diag_apply)
        diag_clear = QPushButton("Clear")
        diag_clear.setToolTip("Remove diagrams from this layer")
        diag_clear.clicked.connect(self._clear_diagram)
        brow.addWidget(diag_clear)
        brow.addStretch(1)
        c.addLayout(brow)
        outer.addWidget(card)
        outer.addStretch(1)
        return tab

    # ── Labels tab: format, round & multi-line feature labels ──

    def _build_labels_tab(self) -> QWidget:
        tab = QWidget()
        outer = QVBoxLayout(tab)
        outer.setContentsMargins(0, 8, 0, 0)
        outer.setSpacing(8)
        card, c = self._card("Labels — format, round & multi-line")
        label_intro = QLabel(
            "Turn fields into well-placed labels — round numbers, stack a second "
            "line, add units, or write your own expression.")
        label_intro.setWordWrap(True)
        c.addWidget(label_intro)
        form = QFormLayout()
        form.setHorizontalSpacing(8)
        form.setVerticalSpacing(6)

        self.label_field_combo = self._field_combo()
        self.label_field_combo.fieldChanged.connect(self._sync_label_preview)
        form.addRow("Label field", self.label_field_combo)

        self.label_field2_combo = self._field_combo()
        self.label_field2_combo.setToolTip(
            "Optional — shown on a second line below the first")
        self.label_field2_combo.fieldChanged.connect(self._sync_label_preview)
        form.addRow("Second line", self.label_field2_combo)

        self.label_decimals_spin = QSpinBox()
        self.label_decimals_spin.setRange(-1, 6)
        self.label_decimals_spin.setValue(-1)
        self.label_decimals_spin.setSpecialValueText("off")
        self.label_decimals_spin.setToolTip(
            "Round numeric labels to this many decimals (off = leave as is)")
        self.label_decimals_spin.valueChanged.connect(self._sync_label_preview)
        form.addRow("Decimals", self.label_decimals_spin)

        self.label_thousands_check = QCheckBox("Thousands separator (1,234)")
        self.label_thousands_check.toggled.connect(self._sync_label_preview)
        form.addRow("", self.label_thousands_check)

        prow = QHBoxLayout()
        self.label_prefix_edit = QLineEdit()
        self.label_prefix_edit.setPlaceholderText("prefix")
        self.label_prefix_edit.setMaximumWidth(86)
        self.label_prefix_edit.textChanged.connect(self._sync_label_preview)
        prow.addWidget(self.label_prefix_edit)
        prow.addWidget(QLabel("…value…"))
        self.label_suffix_edit = QLineEdit()
        self.label_suffix_edit.setPlaceholderText("suffix")
        self.label_suffix_edit.setMaximumWidth(86)
        self.label_suffix_edit.textChanged.connect(self._sync_label_preview)
        prow.addWidget(self.label_suffix_edit)
        prow.addStretch(1)
        form.addRow("Prefix / Suffix", prow)

        self.label_case_combo = QComboBox()
        for key in expressions.LABEL_CASES:
            self.label_case_combo.addItem(expressions.LABEL_CASE_LABELS[key], key)
        self.label_case_combo.currentIndexChanged.connect(self._sync_label_preview)
        form.addRow("Case", self.label_case_combo)

        self.label_wrap_spin = QSpinBox()
        self.label_wrap_spin.setRange(0, 80)
        self.label_wrap_spin.setValue(0)
        self.label_wrap_spin.setSpecialValueText("off")
        self.label_wrap_spin.setToolTip(
            "Wrap long labels at this many characters (off = no wrap)")
        self.label_wrap_spin.valueChanged.connect(self._sync_label_preview)
        form.addRow("Wrap at", self.label_wrap_spin)

        self.label_preset_combo = QComboBox()
        for key, lbl in labels.PRESETS:
            self.label_preset_combo.addItem(lbl, key)
        form.addRow("Style", self.label_preset_combo)

        self.label_size_spin = QDoubleSpinBox()
        self.label_size_spin.setRange(5.0, 40.0)
        self.label_size_spin.setValue(9.0)
        form.addRow("Size (pt)", self.label_size_spin)

        self.label_expr_edit = QLineEdit()
        self.label_expr_edit.setPlaceholderText(
            "advanced QGIS expression — overrides the controls above")
        self.label_expr_edit.setToolTip(
            "Type any QGIS expression, e.g. "
            "concat(\"name\", char(10), round(\"pop\", 0)). "
            "When filled it overrides the formatting controls above.")
        self.label_expr_edit.textChanged.connect(self._sync_label_preview)
        form.addRow("Expression", self.label_expr_edit)
        c.addLayout(form)

        self.label_preview = QLabel("")
        self.label_preview.setWordWrap(True)
        self.label_preview.setTextInteractionFlags(
            Qt.TextInteractionFlag.TextSelectableByMouse)
        self.label_preview.setToolTip("The exact label expression being applied"
                                      " — and a sample from the first feature")
        self.label_preview.setStyleSheet(
            "color:#207168; background:#f0f4f6; border:1px solid #e0e7ea;"
            " border-radius:6px; padding:5px 8px;"
            " font-family:Consolas,monospace; font-size:11px;")
        c.addWidget(self.label_preview)

        brow = QHBoxLayout()
        lbl_apply = QPushButton("Apply to layer")
        lbl_apply.setStyleSheet(_RENDER_BTN_QSS)
        lbl_apply.clicked.connect(self._apply_labels)
        brow.addWidget(lbl_apply)
        lbl_clear = QPushButton("Clear")
        lbl_clear.setToolTip("Remove labels from this layer")
        lbl_clear.clicked.connect(self._clear_labels)
        brow.addWidget(lbl_clear)
        brow.addStretch(1)
        c.addLayout(brow)
        outer.addWidget(card)
        outer.addStretch(1)
        return tab

    # ───────────────────────── wiring ─────────────────────────

    def _on_layer_changed(self, layer) -> None:
        for combo in (self.x_combo, self.y_combo, self.group_combo,
                      self.value_combo, self.animate_combo,
                      self.label_field_combo, self.label_field2_combo,
                      self.diag_size_field_combo):
            combo.setLayer(layer)
        self._refresh_diagram_fields(layer)
        self._sync_diag_hint()
        self._sync_label_preview()
        self._watch_layer(layer)

    def _watch_layer(self, layer) -> None:
        if self._watched_layer is not None:
            try:
                self._watched_layer.selectionChanged.disconnect(self._on_selection_changed)
            except (RuntimeError, TypeError):
                pass
        self._watched_layer = layer
        if layer is not None:
            layer.selectionChanged.connect(self._on_selection_changed)

    def _on_selection_changed(self, *_args) -> None:
        if self.auto_refresh.isChecked() and time.time() >= self._suppress_refresh_until:
            QTimer.singleShot(120, self._render)
            return
        # no re-render — cross-filter instead: dim chart items whose
        # features are not in the new canvas selection
        QTimer.singleShot(60, self._push_highlight)

    def _on_page_loaded(self, ok: bool) -> None:
        if ok:
            # nudge the chart to re-measure now that the view is laid out —
            # the engine bodies also self-fit, this covers the first paint
            run_js(self._view_kind, self._view,
                   "window.dispatchEvent(new Event('resize'));")
            self._push_highlight()

    def _push_highlight(self) -> None:
        if self._view is None or not self.link_check.isChecked():
            return
        ids: list[int] = []
        if self._watched_layer is not None:
            try:
                ids = list(self._watched_layer.selectedFeatureIds())
            except RuntimeError:  # layer C++ object deleted
                ids = []
        if len(ids) > MAX_HIGHLIGHT_IDS:
            ids = []
        run_js(self._view_kind, self._view,
               "window.__o2vizHighlight && window.__o2vizHighlight(%s);"
               % (json.dumps(ids) if ids else "null"))

    def _on_chart_drove_selection(self) -> None:
        # a chart click is about to change the selection — don't re-render
        # the very chart the user is interacting with
        self._suppress_refresh_until = time.time() + 1.0

    # Reusable chart presets

    @staticmethod
    def _set_combo_data(combo: QComboBox, value: object) -> bool:
        index = combo.findData(value)
        if index < 0:
            return False
        combo.setCurrentIndex(index)
        return True

    def _chart_preset(self) -> dict:
        """Capture the reusable parts of the current chart configuration."""
        return {
            "engine": self.engine_combo.currentData(),
            "type": self.type_combo.currentData(),
            "theme": self.theme_combo.currentText(),
            "palette": self.palette_combo.currentText(),
            "custom_palette": (
                list(self._custom_palette) if self._custom_palette else []),
            "title": self.title_edit.text(),
            "fields": {
                "x": self.x_combo.currentField(),
                "y": self.y_combo.currentField(),
                "group": self.group_combo.currentField(),
                "value": self.value_combo.currentField(),
                "animate": self.animate_combo.currentField(),
            },
            "speed": int(self.speed_combo.currentData()),
            "aggregation": self.agg_combo.currentText(),
            "bins": self.bins_spin.value(),
            "top_n": self.topn_spin.value(),
            "sort": self.sort_combo.currentData(),
            "selected_only": self.selected_only.isChecked(),
            "stacked": self.stacked_check.isChecked(),
            "trend": self.trend_check.isChecked(),
            "link": self.link_check.isChecked(),
            "mean": self.ov_mean_check.isChecked(),
            "median": self.ov_median_check.isChecked(),
            "sigma": self.ov_sigma_check.isChecked(),
            "iqr": self.ov_iqr_check.isChecked(),
            "target": self.ov_target_edit.text(),
            "custom_spec_enabled": self.custom_spec_check.isChecked(),
            "custom_spec": self.custom_spec_edit.toPlainText(),
        }

    def _read_presets(self) -> None:
        raw = QSettings().value(PRESET_SETTINGS_KEY, "")
        self._preset_library = presets.decode_library(raw)
        self._refresh_preset_combo()

    def _write_presets(self) -> None:
        QSettings().setValue(
            PRESET_SETTINGS_KEY, presets.encode_library(self._preset_library))

    def _refresh_preset_combo(self, selected: str = "") -> None:
        self.preset_combo.blockSignals(True)
        self.preset_combo.clear()
        if self._preset_library:
            for name in sorted(self._preset_library, key=str.casefold):
                self.preset_combo.addItem(name, name)
            index = self.preset_combo.findData(selected)
            if index >= 0:
                self.preset_combo.setCurrentIndex(index)
        else:
            self.preset_combo.addItem("No saved presets", None)
        self.preset_combo.blockSignals(False)
        self._sync_preset_buttons()

    def _sync_preset_buttons(self, *_args) -> None:
        available = self.preset_combo.currentData() in self._preset_library
        self.preset_load_btn.setEnabled(available)
        self.preset_delete_btn.setEnabled(available)

    def _save_preset(self) -> None:
        suggested = self.preset_combo.currentData() or (
            self.title_edit.text().strip()
            or self.type_combo.currentText().replace(" chart", ""))
        name, ok = QInputDialog.getText(
            self, "Save chart preset", "Preset name:", text=str(suggested))
        name = name.strip()
        if not ok or not name:
            return
        if (name not in self._preset_library
                and len(self._preset_library) >= presets.MAX_PRESETS):
            self.set_status(
                f"Preset limit reached ({presets.MAX_PRESETS}); delete one first")
            return
        if name in self._preset_library:
            answer = QMessageBox.question(
                self, "Replace chart preset",
                f'A preset named "{name}" already exists. Replace it?',
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No)
            if answer != QMessageBox.StandardButton.Yes:
                return
        clean = presets.sanitize_preset(self._chart_preset())
        if clean is None:
            self.set_status("Could not save the current chart setup")
            return
        self._preset_library[name] = clean
        self._write_presets()
        self._refresh_preset_combo(name)
        self.set_status(f'Saved chart preset "{name}"')

    def _delete_preset(self) -> None:
        name = self.preset_combo.currentData()
        if name not in self._preset_library:
            return
        answer = QMessageBox.question(
            self, "Delete chart preset", f'Delete the preset "{name}"?',
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No)
        if answer != QMessageBox.StandardButton.Yes:
            return
        del self._preset_library[name]
        self._write_presets()
        self._refresh_preset_combo()
        self.set_status(f'Deleted chart preset "{name}"')

    def _load_selected_preset(self) -> None:
        name = self.preset_combo.currentData()
        preset = self._preset_library.get(name)
        if preset is None:
            return
        if not self._set_combo_data(self.engine_combo, preset.get("engine")):
            self.set_status(f'Preset "{name}" uses an unavailable engine')
            return
        self._sync_engine_types()
        if not self._set_combo_data(self.type_combo, preset.get("type")):
            self.set_status(f'Preset "{name}" uses an unavailable chart type')
            return
        self._sync_controls()

        theme = self.theme_combo.findText(preset.get("theme", ""))
        if theme >= 0:
            self.theme_combo.setCurrentIndex(theme)
        custom_colors = preset.get("custom_palette", [])
        if preset.get("palette") == CUSTOM_PALETTE_LABEL and custom_colors:
            self._enter_custom(custom_colors)
        else:
            palette = self.palette_combo.findText(preset.get("palette", ""))
            if palette >= 0:
                self.palette_combo.setCurrentIndex(palette)

        self.title_edit.setText(preset.get("title", ""))
        layer = self.layer_combo.currentLayer()
        available = ({field.name() for field in layer.fields()}
                     if layer is not None else set())
        missing: list[str] = []
        fields = preset.get("fields", {})
        for key, combo in (
                ("x", self.x_combo), ("y", self.y_combo),
                ("group", self.group_combo), ("value", self.value_combo),
                ("animate", self.animate_combo)):
            field = fields.get(key, "")
            if field and field not in available:
                missing.append(field)
                field = ""
            combo.setField(field)

        self._set_combo_data(self.speed_combo, preset.get("speed"))
        agg = self.agg_combo.findText(preset.get("aggregation", ""))
        if agg >= 0:
            self.agg_combo.setCurrentIndex(agg)
        self.bins_spin.setValue(preset.get("bins", self.bins_spin.value()))
        self.topn_spin.setValue(preset.get("top_n", self.topn_spin.value()))
        self._set_combo_data(self.sort_combo, preset.get("sort"))
        for key, widget in (
                ("selected_only", self.selected_only),
                ("stacked", self.stacked_check), ("trend", self.trend_check),
                ("link", self.link_check), ("mean", self.ov_mean_check),
                ("median", self.ov_median_check), ("sigma", self.ov_sigma_check),
                ("iqr", self.ov_iqr_check)):
            if key in preset:
                widget.setChecked(preset[key])
        self.ov_target_edit.setText(preset.get("target", ""))
        self.custom_spec_edit.setPlainText(preset.get("custom_spec", ""))
        self.custom_spec_check.setChecked(
            bool(preset.get("custom_spec_enabled"))
            and self.engine_combo.currentData() == "vegalite")
        self._sync_custom_spec()
        suffix = (f" Â· missing fields cleared: {', '.join(sorted(set(missing)))}"
                  if missing else "")
        self.set_status(f'Loaded chart preset "{name}"{suffix}')

    def _sync_engine_types(self, *_args) -> None:
        """Grey out chart types the selected engine cannot draw
        (e.g. Vega-Lite has no treemap/sunburst grammar)."""
        eng = engines()[max(0, self.engine_combo.currentIndex())]
        model = self.type_combo.model()
        for i in range(self.type_combo.count()):
            model.item(i).setEnabled(self.type_combo.itemData(i) in eng.supports)
        if self.type_combo.currentData() not in eng.supports:
            for i in range(self.type_combo.count()):
                if self.type_combo.itemData(i) in eng.supports:
                    self.type_combo.setCurrentIndex(i)
                    break
        self._sync_animate()
        self._sync_custom_spec()

    def _sync_custom_spec(self, *_args) -> None:
        if hasattr(self, "custom_spec_check"):
            on = self.engine_combo.currentData() == "vegalite"
            self.custom_spec_check.setEnabled(on)
            self.custom_spec_edit.setVisible(on and self.custom_spec_check.isChecked())

    def _sync_controls(self) -> None:
        kind = self.type_combo.currentData()
        y, group, value, agg, bins, topn_sort, stacked, trend = _CONTROLS[kind]
        self.y_combo.setEnabled(y)
        self.group_combo.setEnabled(group)
        self.value_combo.setEnabled(value)
        self.agg_combo.setEnabled(agg)
        self.bins_spin.setEnabled(bins)
        self.topn_spin.setEnabled(topn_sort)
        self.sort_combo.setEnabled(topn_sort)
        self.stacked_check.setEnabled(stacked)
        self.trend_check.setEnabled(trend)
        ov_ok = kind in overlays.OVERLAY_TYPES
        for w in (self.ov_mean_check, self.ov_median_check, self.ov_sigma_check,
                  self.ov_iqr_check, self.ov_target_edit):
            w.setEnabled(ov_ok)
        self._sync_animate()

    def _sync_animate(self, *_args) -> None:
        """The play axis only lights up when the chosen engine can animate the
        chosen chart type (ECharts / Plotly · bar·line·area·scatter·bubble·pie)."""
        if not hasattr(self, "animate_combo"):
            return
        eng = engines()[max(0, self.engine_combo.currentIndex())]
        on = self.type_combo.currentData() in eng.animates
        self.animate_combo.setEnabled(on)
        self.speed_combo.setEnabled(on)

    # ── Map diagrams tab handlers ──

    def _refresh_diagram_fields(self, layer) -> None:
        if not hasattr(self, "diag_field_list"):
            return
        self.diag_field_list.clear()
        if layer is None:
            return
        for name in diagrams.numeric_field_names(layer):
            item = QListWidgetItem(name)
            item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
            item.setCheckState(Qt.CheckState.Unchecked)
            self.diag_field_list.addItem(item)

    def _checked_diagram_fields(self) -> list[str]:
        return [self.diag_field_list.item(i).text()
                for i in range(self.diag_field_list.count())
                if self.diag_field_list.item(i).checkState() == Qt.CheckState.Checked]

    def _apply_diagram(self) -> None:
        layer = self.layer_combo.currentLayer()
        if layer is None:
            self.set_status("Pick a layer first")
            return
        fields = self._checked_diagram_fields()
        if not fields:
            self.set_status("Tick at least one numeric field for the diagram")
            return
        kind = self.diag_type_combo.currentData()
        normalize = self.diag_norm_combo.currentData()
        size_field = self.diag_size_field_combo.currentField()
        stats_fields = list(fields)
        if size_field and size_field not in stats_fields:
            stats_fields.append(size_field)
        field_stats: dict = {}
        size_range = None
        if normalize != "none" or size_field:
            try:
                cols = datasource.columns(layer, stats_fields,
                                          self.selected_only.isChecked())
            except ValueError as exc:
                self.set_status(str(exc))
                return
            for name in fields:
                st = stats.field_numeric_stats(cols[name])
                if st:
                    field_stats[name] = st
            if size_field:
                st = stats.field_numeric_stats(cols[size_field])
                if st:
                    size_range = (st["min"], st["max"])
        ok = diagrams.apply_diagram(
            layer, kind=kind, fields=fields,
            colors=list(self._current_theme()["palette"]),
            size_mm=self.diag_size_spin.value(),
            opacity=self.diag_opacity_spin.value() / 100.0,
            normalize=normalize, stats=field_stats,
            size_field=size_field if size_range else "", size_range=size_range,
            avoid_overlap=self.diag_avoid_overlap_check.isChecked())
        if not ok:
            self.set_status("Could not apply the diagram (no geometry?)")
            return
        self._refresh_canvas(layer)
        norm_txt = ("" if normalize == "none"
                    else ", " + expressions.NORMALIZE_LABELS[normalize].lower())
        size_txt = f", sized by {size_field}" if size_field and size_range else ""
        self.set_status(
            f"Applied {diagrams.DIAGRAM_LABELS[kind].lower()} diagram "
            f"({len(fields)} field{'s' if len(fields) != 1 else ''}{norm_txt}{size_txt}) "
            f"on the canvas")

    def _sync_diag_hint(self, *_args) -> None:
        if not hasattr(self, "diag_hint"):
            return
        kind = self.diag_type_combo.currentData()
        mode = self.diag_norm_combo.currentData()
        msg = {
            "none": "Raw values — fine when fields share a scale; otherwise a "
                    "big-number field dominates the others.",
            "minmax": "Each field scaled to 0–1 — the safe choice for comparing "
                      "pies and bars across fields.",
            "zscore": "Standardised to mean 0 — great for bars; values can be "
                      "negative.",
            "log": "Log-compressed — tames heavy tails or a few dominant features.",
        }.get(mode, "")
        if mode == "zscore" and kind in ("pie", "stacked"):
            msg = ("⚠ Z-score can be negative, which a pie / stacked bar can't "
                   "draw — use Min–max or Log for this diagram type.")
        self.diag_hint.setText(msg)

    def _clear_diagram(self) -> None:
        layer = self.layer_combo.currentLayer()
        if layer is None:
            return
        diagrams.clear_diagram(layer)
        self._refresh_canvas(layer)
        self.set_status("Diagrams removed from this layer")

    # ── Labels tab handlers ──

    def _build_label_expression(self) -> str:
        """The label expression in effect: the advanced box if filled, else
        assembled from the format controls (pure — see core.expressions)."""
        layer = self.layer_combo.currentLayer()
        numeric = (set(diagrams.numeric_field_names(layer))
                   if layer is not None else None)
        return expressions.label_expression(
            [self.label_field_combo.currentField(),
             self.label_field2_combo.currentField()],
            decimals=self.label_decimals_spin.value(),
            thousands=self.label_thousands_check.isChecked(),
            prefix=self.label_prefix_edit.text(),
            suffix=self.label_suffix_edit.text(),
            case=self.label_case_combo.currentData(),
            wrap=self.label_wrap_spin.value(),
            multiline=True, numeric_fields=numeric,
            custom=self.label_expr_edit.text())

    def _sample_label(self, expr: str) -> str:
        """Guarded evaluation of ``expr`` on the first feature, for the
        live preview. Returns '' (or a short parser note) on any problem."""
        layer = self.layer_combo.currentLayer()
        if layer is None or not expr:
            return ""
        try:
            from qgis.core import (
                QgsExpression, QgsExpressionContext, QgsExpressionContextUtils,
            )
            parsed = QgsExpression(expr)
            if parsed.hasParserError():
                return "⚠ " + parsed.parserErrorString().strip()
            feat = next(layer.getFeatures(), None)
            if feat is None:
                return ""
            ctx = QgsExpressionContext()
            ctx.appendScopes(
                QgsExpressionContextUtils.globalProjectLayerScopes(layer))
            ctx.setFeature(feat)
            val = parsed.evaluate(ctx)
            if parsed.hasEvalError():
                return ""
            text = "" if val is None else str(val)
            return (text[:70] + "…") if len(text) > 70 else text
        except Exception:
            return ""

    def _sync_label_preview(self, *_args) -> None:
        if not hasattr(self, "label_preview"):
            return
        # advanced box overrides → grey out the format controls so it's clear
        custom = bool(self.label_expr_edit.text().strip())
        for w in (self.label_field_combo, self.label_field2_combo,
                  self.label_decimals_spin, self.label_thousands_check,
                  self.label_prefix_edit, self.label_suffix_edit,
                  self.label_case_combo, self.label_wrap_spin):
            w.setEnabled(not custom)
        expr = self._build_label_expression()
        sample = self._sample_label(expr)
        self.label_preview.setText(expr + (f"\n→  {sample}" if sample else ""))

    def _apply_labels(self) -> None:
        layer = self.layer_combo.currentLayer()
        if layer is None:
            self.set_status("Pick a layer first")
            return
        expr = self._build_label_expression()
        if expr in ("", "''"):
            self.set_status("Pick a field to label, or type an expression")
            return
        with suppress(Exception):  # don't let a typo silently produce no labels
            from qgis.core import QgsExpression
            parsed = QgsExpression(expr)
            if parsed.hasParserError():
                self.set_status("Expression error: "
                                + parsed.parserErrorString().strip())
                return
        ok = labels.apply_labels(
            layer, expression=expr, preset=self.label_preset_combo.currentData(),
            color=self._current_theme()["text"], size=self.label_size_spin.value())
        if not ok:
            self.set_status("Could not apply labels")
            return
        self._refresh_canvas(layer)
        self.set_status("Labelled features · "
                        + (expr[:70] + ("…" if len(expr) > 70 else "")))

    def _clear_labels(self) -> None:
        layer = self.layer_combo.currentLayer()
        if layer is None:
            return
        labels.clear_labels(layer)
        self._refresh_canvas(layer)
        self.set_status("Labels removed from this layer")

    def _refresh_canvas(self, layer) -> None:
        with suppress(Exception):
            self.iface.layerTreeView().refreshLayerSymbology(layer.id())
        with suppress(Exception):
            self.iface.mapCanvas().refreshAllLayers()

    def _last_io_dir(self) -> str:
        """Return the last-used load/export folder, falling back to the user home."""
        value = QSettings().value("zero2viz/last_io_dir", "")
        return value if isinstance(value, str) and os.path.isdir(value) else os.path.expanduser("~")

    def _remember_io_dir(self, path: str) -> None:
        folder = os.path.dirname(path)
        if folder and os.path.isdir(folder):
            QSettings().setValue("zero2viz/last_io_dir", folder)

    def _load_external(self) -> None:
        path, _ = QFileDialog.getOpenFileName(
            self, "Load external table", self._last_io_dir(),
            "Tables (*.csv *.xlsx *.ods *.gpkg *.dbf);;All files (*.*)")
        if not path:
            return
        self._remember_io_dir(path)
        layer = datasource.load_table(path)
        if layer is None:
            self.set_status(f"Could not read: {os.path.basename(path)}")
            return
        self.layer_combo.setLayer(layer)
        self.set_status(f"Loaded table: {layer.name()} ({layer.featureCount()} rows)")

    # ───────────────────────── spec assembly ─────────────────────────

    def _build_spec(self) -> dict | None:
        layer = self.layer_combo.currentLayer()
        if layer is None:
            self.set_status("Pick a layer first")
            return None
        kind = self.type_combo.currentData()
        eng = engines()[max(0, self.engine_combo.currentIndex())]
        x = self.x_combo.currentField()
        y = self.y_combo.currentField()
        group = self.group_combo.currentField() if self.group_combo.isEnabled() else ""
        value = self.value_combo.currentField() if self.value_combo.isEnabled() else ""
        animate = (self.animate_combo.currentField()
                   if self.animate_combo.isEnabled() else "")
        agg = self.agg_combo.currentText()
        sel = self.selected_only.isChecked()

        fields = [f for f in {x, y, group, value, animate} if f]
        if not fields:
            self.set_status("Pick at least an X field")
            return None
        try:
            cols, fids, extraction = datasource.columns_with_ids_info(
                layer, fields, sel)
            self._last_extraction_info = extraction
        except ValueError as exc:
            self.set_status(str(exc))
            return None
        if not fids:
            self.set_status("No rows (empty selection?)")
            return None

        title = self.title_edit.text().strip() or layer.name()
        spec = {"type": kind, "title": title, "x_label": x, "y_label": y,
                "stacked": self.stacked_check.isChecked(),
                "theme": self._current_theme()}

        builder = getattr(self, f"_spec_{kind}", None)
        data = builder(spec, cols, fids, x=x, y=y, group=group, value=value, agg=agg)
        if data is None:
            return None
        spec["data"] = data
        if self.custom_spec_check.isChecked() and eng.id == "vegalite":
            try:
                spec["custom_vl"] = json.loads(self.custom_spec_edit.toPlainText())
            except json.JSONDecodeError as exc:
                self.set_status(f"Custom spec JSON error at line {exc.lineno}: {exc.msg}")
                return None
        if animate and kind in eng.animates:
            frames = self._build_frames(
                spec, cols, fids, kind=kind, x=x, y=y, group=group,
                value=value, agg=agg, frame_field=animate)
            if frames is None:
                return None  # _build_frames sets the status
            spec["frames"] = frames
        else:
            # reference lines/bands apply to the static chart (a play axis tells
            # its own story, so overlays are skipped while animating)
            ov_opts = self._overlay_options(kind)
            if ov_opts:
                ovs = overlays.build_overlays(spec, ov_opts, spec["theme"])
                if ovs:
                    spec["overlays"] = ovs
        return spec

    def _overlay_options(self, kind: str) -> dict | None:
        """Read the reference-line controls into options for ``core.overlays``,
        or None when the type has no value axis or nothing is requested."""
        if kind not in overlays.OVERLAY_TYPES:
            return None
        opts = {"mean": self.ov_mean_check.isChecked(),
                "median": self.ov_median_check.isChecked(),
                "sigma": self.ov_sigma_check.isChecked(),
                "iqr": self.ov_iqr_check.isChecked()}
        text = self.ov_target_edit.text().strip()
        target = stats.to_float(text) if text else None
        if not (any(opts.values()) or target is not None):
            return None
        if target is not None:
            opts["target"] = target
        return opts

    # — per-type builders: return the spec["data"] dict, or None + status —

    def _agg_or(self, agg: str, fallback: str) -> str:
        return fallback if agg == "none" else agg

    def _cat_series(self, spec, cols, fids, *, x, y, group, agg):
        if not x:
            self.set_status("This chart needs an X / Category field")
            return None
        if agg == "none":
            if not y:
                self.set_status("Pick a Y field, or an aggregation like 'count'")
                return None
            cats = [transform._key(v) for v in cols[x]]
            vals = [stats.to_float(v) or 0.0 for v in cols[y]]
            ids = [[fid] for fid in fids]
            series = [{"name": y, "values": vals, "ids": ids}]
        elif group:
            spec["y_label"] = f"{agg}({y})" if agg != "count" else "count"
            cats, series = transform.pivot_rows(
                cols[x], cols[group], cols.get(y, []), fids, agg)
        else:
            if agg != "count" and not y:
                self.set_status(f"Aggregation '{agg}' needs a Y field")
                return None
            spec["y_label"] = f"{agg}({y})" if agg != "count" else "count"
            cats, vals, ids = transform.group_rows(cols[x], cols.get(y, []), fids, agg)
            series = [{"name": spec["y_label"], "values": vals, "ids": ids}]
        if len(series) == 1:
            cats, vals, ids = transform.sort_cats(
                cats, series[0]["values"], series[0]["ids"],
                self.sort_combo.currentData())
            cats, vals, ids = transform.topn_collapse(
                cats, vals, ids, self.topn_spin.value())
            series = [dict(series[0], values=vals, ids=ids)]
        return {"categories": cats, "series": series}

    def _spec_bar(self, spec, cols, fids, *, x, y, group, value, agg):
        return self._cat_series(spec, cols, fids, x=x, y=y, group=group, agg=agg)

    _spec_line = _spec_bar
    _spec_area = _spec_bar

    def _points(self, spec, cols, fids, *, x, y, group, size_field):
        if not x or not y:
            self.set_status("This chart needs numeric X and Y fields")
            return None
        sizes = (transform.bubble_sizes(cols[size_field])
                 if size_field else [None] * len(fids))
        buckets: dict[str, list] = {}
        order: list[str] = []
        all_points = []
        for i in range(len(fids)):
            xf, yf = stats.to_float(cols[x][i]), stats.to_float(cols[y][i])
            if xf is None or yf is None:
                continue
            point = [xf, yf, sizes[i], fids[i]]
            all_points.append(point)
            key = transform._key(cols[group][i]) if group else ""
            if key not in buckets:
                buckets[key] = []
                order.append(key)
            buckets[key].append(point)
        if not all_points:
            self.set_status("No numeric X/Y pairs found")
            return None
        series = [{"name": k or spec["y_label"], "points": buckets[k]} for k in order]
        trend = (transform.linreg_endpoints(all_points)
                 if self.trend_check.isChecked() else None)
        return {"series": series, "trend": trend}

    def _spec_scatter(self, spec, cols, fids, *, x, y, group, value, agg):
        return self._points(spec, cols, fids, x=x, y=y, group=group, size_field=None)

    def _spec_bubble(self, spec, cols, fids, *, x, y, group, value, agg):
        if not value:
            self.set_status("Bubble chart needs a Value / Size field")
            return None
        return self._points(spec, cols, fids, x=x, y=y, group=group, size_field=value)

    def _spec_histogram(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x:
            self.set_status("Histogram needs an X field (numeric)")
            return None
        labels, counts = stats.histogram(cols[x], self.bins_spin.value())
        if not labels:
            self.set_status(f"No numeric values in '{x}'")
            return None
        spec["y_label"] = "count"
        return {"categories": labels, "values": counts}

    def _spec_pie(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x:
            self.set_status("Pie needs an X / Category field")
            return None
        how = self._agg_or(agg, "count")
        if how != "count" and not y:
            self.set_status(f"Aggregation '{how}' needs a Y field")
            return None
        cats, vals, ids = transform.group_rows(cols[x], cols.get(y, []), fids, how)
        cats, vals, ids = transform.sort_cats(cats, vals, ids, self.sort_combo.currentData())
        cats, vals, ids = transform.topn_collapse(cats, vals, ids, self.topn_spin.value())
        return {"categories": cats, "values": vals, "ids": ids}

    def _group_buckets(self, source: list | None, values: list, fallback: str):
        """Bucket ``values`` by ``source`` keys (first-appearance order);
        a single bucket named ``fallback`` when there is no grouping field."""
        if source is None:
            return [fallback], {fallback: values}
        buckets: dict[str, list] = {}
        order: list[str] = []
        for i, raw in enumerate(source):
            key = transform._key(raw)
            if key not in buckets:
                buckets[key] = []
                order.append(key)
            buckets[key].append(values[i])
        return order, buckets

    def _spec_box(self, spec, cols, fids, *, x, y, group, value, agg):
        if not y:
            self.set_status("Box plot needs a Y field (numeric)")
            return None
        source = cols[group] if group else (cols[x] if x else None)
        order, buckets = self._group_buckets(source, cols[y], y)
        groups, box = [], []
        for key in order:
            st = stats.boxplot_stats(buckets[key])
            if st is not None:
                groups.append(key)
                box.append(st)
        if not box:
            self.set_status(f"No numeric values in '{y}'")
            return None
        return {"groups": groups, "stats": box}

    def _spec_heatmap(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x or not y:
            self.set_status("Heatmap needs X and Y category fields")
            return None
        how = self._agg_or(agg, "count")
        if how != "count" and not value:
            self.set_status(f"Aggregation '{how}' needs a Value field")
            return None
        x_cats, y_cats, cells, vmin, vmax = transform.heatmap_rows(
            cols[x], cols[y], cols.get(value, []), how)
        return {"x_cats": x_cats, "y_cats": y_cats, "cells": cells,
                "vmin": vmin, "vmax": vmax}

    def _spec_treemap(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x:
            self.set_status("This chart needs an X field as the main grouping")
            return None
        how = self._agg_or(agg, "count")
        if how != "count" and not value:
            self.set_status(f"Aggregation '{how}' needs a Value field")
            return None
        nodes = transform.tree_rows(cols[x], cols.get(group, []),
                                    cols.get(value, []), how, fids)
        return {"nodes": nodes}

    _spec_sunburst = _spec_treemap

    def _spec_errorband(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x or not y:
            self.set_status("Mean ± σ needs an X category and a numeric Y field")
            return None
        cats, series = transform.band_rows(cols[x], cols[y],
                                           cols[group] if group else None, fids)
        if not series:
            self.set_status(f"No numeric values in '{y}'")
            return None
        spec["y_label"] = f"mean({y}) ± 1σ"
        return {"categories": cats, "series": series}

    _spec_errorbar = _spec_errorband

    def _spec_density(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x:
            self.set_status("Density needs a numeric X field")
            return None
        order, buckets = self._group_buckets(cols[group] if group else None,
                                             cols[x], x)
        series = []
        for key in order:
            pts = stats.kde_points(buckets[key])
            if pts:
                series.append({"name": key, "points": pts})
        if not series:
            self.set_status(f"Need at least 2 distinct numeric values in '{x}'")
            return None
        spec["y_label"] = "density"
        return {"series": series}

    def _spec_violin(self, spec, cols, fids, *, x, y, group, value, agg):
        if not y:
            self.set_status("Violin needs a numeric Y field")
            return None
        source = cols[group] if group else (cols[x] if x else None)
        order, buckets = self._group_buckets(source, cols[y], y)
        groups, polygons, medians = transform.violin_rows(order, buckets)
        if not groups:
            self.set_status(
                f"Violin needs ≥ 2 distinct numeric '{y}' values per group")
            return None
        return {"groups": groups, "polygons": polygons, "medians": medians}

    def _spec_radar(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x:
            self.set_status("Radar needs an X / Category field for the axes")
            return None
        how = self._agg_or(agg, "mean")
        if how != "count" and not y:
            self.set_status(f"Aggregation '{how}' needs a Y field")
            return None
        spec["y_label"] = f"{how}({y})" if how != "count" else "count"
        if group:
            axes, raw = transform.pivot_rows(cols[x], cols[group],
                                             cols.get(y, []), fids, how)
            series = [{"name": s["name"], "values": s["values"]} for s in raw]
        else:
            axes, vals, _ids = transform.group_rows(cols[x], cols.get(y, []),
                                                    fids, how)
            series = [{"name": spec["y_label"], "values": vals}]
        if len(axes) < 3:
            self.set_status("Radar needs at least 3 categories on X")
            return None
        return {"axes": axes, "series": series,
                "maxes": transform.radar_axis_maxes(series)}

    def _spec_pareto(self, spec, cols, fids, *, x, y, group, value, agg):
        if not x:
            self.set_status("Pareto needs an X / Category field")
            return None
        how = self._agg_or(agg, "count")
        if how != "count" and not y:
            self.set_status(f"Aggregation '{how}' needs a Y field")
            return None
        spec["y_label"] = f"{how}({y})" if how != "count" else "count"
        cats, vals, ids = transform.group_rows(cols[x], cols.get(y, []), fids, how)
        cats, vals, ids = transform.topn_collapse(cats, vals, ids,
                                                  self.topn_spin.value())
        cats, vals, cum, ids = transform.pareto_rows(cats, vals, ids)
        return {"categories": cats, "values": vals, "cum": cum, "ids": ids}

    # ───────────────────────── animation (play axis) ─────────────────────────

    def _build_frames(self, spec, cols, fids, *, kind, x, y, group, value, agg,
                      frame_field):
        """Assemble the ``frames`` block for an animated chart. Validates the
        field roles, then delegates the slicing/alignment to the pure
        :func:`transform.build_frames` (so it stays testable headless)."""
        if kind in ("bar", "line", "area", "pie"):
            how = self._agg_or(agg, "count")
            if how != "count" and not y:
                self.set_status(f"Aggregation '{how}' needs a Y field")
                return None
            spec["y_label"] = f"{how}({y})" if how != "count" else "count"
            single = spec["y_label"]
        elif kind in ("scatter", "bubble"):
            if not x or not y:
                self.set_status("This chart needs numeric X and Y fields")
                return None
            if kind == "bubble" and not value:
                self.set_status("Bubble chart needs a Value / Size field")
                return None
            how, single = "count", (spec.get("y_label") or "points")
        else:
            return None

        built = transform.build_frames(
            kind, cols, fids, cols.get(frame_field, []),
            x=x, y=y, group=group, value=value, how=how, single_name=single)
        if built is None:
            self.set_status("Animate-by needs ≥ 2 frames with chartable data")
            return None
        labels, datas, union, bounds = built
        spec["data"] = datas[0]
        return {"field": frame_field, "labels": labels, "datas": datas,
                "categories": union, "bounds": bounds,
                "interval": self.speed_combo.currentData() or 900}

    # ───────────────────────── render / explore / export ─────────────────────────

    # ───────────────────────── colours (embedded swatch editor) ─────────────────────────

    def _resolve_palette(self) -> list[str]:
        """The colour list in effect: custom swatches, a chosen built-in
        palette, or the active theme's palette."""
        name = self.palette_combo.currentText()
        if name == CUSTOM_PALETTE_LABEL and self._custom_palette:
            return list(self._custom_palette)
        pal = PALETTES.get(name)
        if pal:
            return list(pal)
        theme = THEMES.get(self.theme_combo.currentText(), THEMES[DEFAULT_THEME])
        return list(theme["palette"])

    def _on_palette_changed(self, idx: int) -> None:
        # picking 'Custom…' with nothing defined yet seeds from what was showing
        if (self.palette_combo.itemText(idx) == CUSTOM_PALETTE_LABEL
                and not self._custom_palette):
            prev = self.palette_combo.itemText(self._prev_palette_index)
            seed = PALETTES.get(prev) or THEMES.get(
                self.theme_combo.currentText(), THEMES[DEFAULT_THEME])["palette"]
            self._custom_palette = list(seed)
        self._prev_palette_index = self.palette_combo.currentIndex()
        self._sync_swatches()

    def _enter_custom(self, colors: list[str]) -> None:
        """Adopt ``colors`` as the custom palette and switch the combo to it."""
        self._custom_palette = list(colors)
        idx = self.palette_combo.findText(CUSTOM_PALETTE_LABEL)
        self.palette_combo.blockSignals(True)
        self.palette_combo.setCurrentIndex(idx)
        self.palette_combo.blockSignals(False)
        self._prev_palette_index = idx
        self._sync_swatches()

    def _sync_swatches(self, *_args) -> None:
        if not hasattr(self, "_swatch_row"):
            return
        while self._swatch_row.count():
            item = self._swatch_row.takeAt(0)
            w = item.widget()
            if w is not None:
                w.deleteLater()
        palette = self._resolve_palette()
        for i, color in enumerate(palette):
            sw = QPushButton()
            sw.setFixedSize(20, 20)
            sw.setCursor(Qt.CursorShape.PointingHandCursor)
            sw.setToolTip(f"{color} — click to edit")
            sw.setStyleSheet(
                f"QPushButton {{ background: {color}; border: 1px solid #00000033;"
                f" border-radius: 4px; }}"
                f"QPushButton:hover {{ border: 2px solid #16323f; }}")
            sw.clicked.connect(lambda _=False, idx=i: self._edit_swatch(idx))
            self._swatch_row.addWidget(sw)
        # padding:0 so the glyph fits the 20×20 square (the generic QPushButton
        # rule's padding would otherwise clip a single +/− character)
        mini_qss = (
            "QPushButton { background: #eef1f4; color: #1f333d;"
            " border: 1px solid #cbd3da; border-radius: 4px; padding: 0;"
            " font-weight: 700; }"
            "QPushButton:hover { background: #e4e9ed; }"
            "QPushButton:disabled { color: #b9c2c9; }")
        add = QPushButton("+")
        add.setFixedSize(20, 20)
        add.setStyleSheet(mini_qss)
        add.setToolTip("Add a colour")
        add.clicked.connect(self._add_swatch)
        self._swatch_row.addWidget(add)
        rem = QPushButton("−")
        rem.setFixedSize(20, 20)
        rem.setStyleSheet(mini_qss)
        rem.setToolTip("Remove the last colour")
        rem.setEnabled(len(palette) > 1)
        rem.clicked.connect(self._remove_swatch)
        self._swatch_row.addWidget(rem)
        self._swatch_row.addStretch(1)

    def _edit_swatch(self, i: int) -> None:
        base = self._resolve_palette()
        if i >= len(base):
            return
        chosen = QColorDialog.getColor(QColor(base[i]), self, "Pick a colour")
        if chosen.isValid():
            base[i] = chosen.name()
            self._enter_custom(base)
            self.set_status("Custom palette updated")

    def _add_swatch(self) -> None:
        base = self._resolve_palette()
        if len(base) >= 16:
            return
        chosen = QColorDialog.getColor(QColor("#2a8f85"), self, "Add a colour")
        if chosen.isValid():
            base.append(chosen.name())
            self._enter_custom(base)

    def _remove_swatch(self) -> None:
        base = self._resolve_palette()
        if len(base) > 1:
            base.pop()
            self._enter_custom(base)

    def _current_theme(self) -> dict:
        theme = THEMES.get(self.theme_combo.currentText(), THEMES[DEFAULT_THEME])
        return dict(theme, palette=self._resolve_palette())

    def _ensure_view(self) -> None:
        """Create the embedded web view once, recording which backend the QGIS
        build ships (webengine / webkit / browser). Called before a render so
        the dock knows the backend in time to adapt the engine choice."""
        if self._view is None and self._view_kind is None:
            self._view_kind, self._view = create_chart_view(self)
            if self._view is not None:
                attach_title_listener(self._view_kind, self._view, self.bridge.handle_title)
                # a fresh page should immediately reflect the current canvas
                # selection (same signal on QWebView and QWebEngineView)
                self._view.loadFinished.connect(self._on_page_loaded)
                self._view_placeholder.hide()
                self._view.setMinimumHeight(300)
                self._view_slot.addWidget(self._view, 1)

    def _show_html(self, html: str, status: str, embed_html: str | None = None) -> None:
        """Show a chart. ``html`` is the real chart — always what Export and the
        ↗ browser button use. ``embed_html``, when given, is loaded into the
        panel instead (e.g. the legacy-WebKit explainer for an engine the
        embedded view can't run), without losing the real chart."""
        if self._tmp_dir is None:
            self._tmp_dir = tempfile.mkdtemp(prefix="o2viz_")
        stamp = int(time.time() * 1000)
        path = os.path.join(self._tmp_dir, f"chart_{stamp}.html")
        with open(path, "w", encoding="utf-8") as f:
            f.write(html)
        self._last_html = html
        self._last_path = path

        self._ensure_view()
        if embed_html is not None:
            load_path = os.path.join(self._tmp_dir, f"embed_{stamp}.html")
            with open(load_path, "w", encoding="utf-8") as f:
                f.write(embed_html)
        else:
            load_path = path
        self.bridge.bind(self.layer_combo.currentLayer()
                         if self.link_check.isChecked() else None)
        embedded = show_html_file(self._view_kind, self._view, load_path)
        self.export_btn.setEnabled(True)
        can_capture = bool(embedded and self._view is not None)
        self.export_svg_btn.setEnabled(can_capture)
        self.export_png_btn.setEnabled(can_capture)
        self.export_pdf_btn.setEnabled(can_capture)
        self.browser_btn.setEnabled(True)
        if embedded:
            where = f" · {self._view_kind}"
        else:
            where = " (no embedded web view in this QGIS build — opened in your browser)"
        self.set_status(status + where)

    def _open_in_browser(self) -> None:
        """Escape hatch: show the last chart in the system browser. Always
        works, even when the embedded QWebEngine view paints blank."""
        if not self._last_path or not os.path.exists(self._last_path):
            if not self._last_html:
                return
            self._show_html(self._last_html, "Reopened")  # rebuilds the temp file
        import webbrowser
        webbrowser.open("file:///" + self._last_path.replace("\\", "/"))
        self.set_status("Opened the current chart in your browser")

    def _render(self) -> None:
        engine = engines()[max(0, self.engine_combo.currentIndex())]
        if not self._ensure_engine_available(engine):
            return
        try:
            spec = self._build_spec()
            if spec is None:
                return
            if spec["type"] not in engine.supports:
                self.set_status(f"{engine.label} cannot draw {spec['type']} charts")
                return
            html = (engine.build_custom_html(spec, spec["custom_vl"])
                    if spec.get("custom_vl") and hasattr(engine, "build_custom_html")
                    else engine.build_html(spec))
        except Exception as exc:
            self.set_status(f"Chart failed: {exc}")
            return
        self._ensure_view()
        if self._view_kind == "webkit" and not engine.webkit_ok:
            # legacy QtWebKit (no QtWebEngine in this QGIS) can't run Plotly's /
            # Vega's modern JS — they parse-fail and the panel stays blank. Show
            # an explainer there, but keep the real chart for export / ↗ browser.
            self._show_html(
                html,
                f"{engine.label}: open in your browser ↗ — the embedded WebKit "
                f"view can't run it (try the ECharts engine for an embedded chart)",
                embed_html=webkit_fallback_page(engine.label, spec.get("type", "")))
        else:
            scope = (f" · {self._last_extraction_info.status_note}"
                     if self._last_extraction_info is not None else "")
            self._show_html(
                html, f"Rendered {spec['type']} · {engine.label}{scope}")

    # ───────────────────────── advanced engine availability ─────────────────────────

    def _refresh_engine_labels(self) -> None:
        for i in range(self.engine_combo.count()):
            eng = engines()[i]
            label = eng.label if eng.available() else f"{eng.label}  (install…)"
            self.engine_combo.setItemText(i, label)

    def _ensure_engine_available(self, engine) -> bool:
        """If an optional engine's libraries are missing, tell the user exactly
        how to install them themselves. The studio never runs pip (it must not
        shell out to a package manager); it only shows the copy-pasteable
        command. Returns True when the engine is ready to render."""
        if engine.available():
            return True
        pkgs = requirements.ENGINE_PIP_REQUIREMENTS.get(engine.id, [])
        missing = requirements.missing_packages(pkgs)
        if not missing:
            return True
        cmd = requirements.install_command_str(missing)
        box = QMessageBox(self)
        box.setIcon(QMessageBox.Icon.Information)
        box.setWindowTitle("Advanced engine needs a library")
        box.setTextFormat(Qt.TextFormat.RichText)
        box.setText(
            f"<b>{engine.label}</b> needs: {', '.join(missing)}.<br><br>"
            "Install it into the QGIS Python yourself, then reopen the dock — "
            "run this in the <b>OSGeo4W Shell</b> (or the QGIS Python console):"
            f"<br><br><code>{cmd}</code>")
        copy_btn = box.addButton("Copy command", QMessageBox.ButtonRole.ActionRole)
        box.addButton(QMessageBox.StandardButton.Close)
        box.exec()
        if box.clickedButton() is copy_btn:
            with suppress(Exception):
                QApplication.clipboard().setText(cmd)
            self.set_status(f"Copied the install command for {', '.join(missing)}")
        else:
            self.set_status(f"{engine.label} needs {', '.join(missing)}")
        return False

    def _explore(self) -> None:
        layer = self.layer_combo.currentLayer()
        if layer is None:
            self.set_status("Pick a layer first")
            return
        try:
            sections = {self.tile_picker.item(i).data(Qt.ItemDataRole.UserRole)
                         for i in range(self.tile_picker.count())
                         if self.tile_picker.item(i).checkState() == Qt.CheckState.Checked}
            prof = profiler.build_profile(layer, self.selected_only.isChecked(),
                                          self._current_theme(), sections=sections)
            if not prof["tiles"]:
                self.set_status("Nothing chartable found in this layer's fields")
                return
            html = build_dashboard(prof, self._current_theme())
        except Exception as exc:
            self.set_status(f"Explore failed: {exc}")
            return
        self._show_html(html, f"Explored {layer.name()}: {len(prof['tiles'])} charts, "
                              f"{len(prof['insights'])} insights")

    # ───────────────────────── smart assistant ─────────────────────────

    def _suggest_chart(self) -> None:
        """Offline assistant: pick the most suitable chart for this layer, configure the
        controls and render it."""
        layer = self.layer_combo.currentLayer()
        if layer is None:
            self.set_status("Pick a layer first")
            return
        # Cheap layer metadata only — no feature iteration yet — so the
        # decision below (and the field cap it drives) happens BEFORE any
        # data is pulled. Reported crash: even with the O(fields^2)
        # correlation scan bounded (core/assistant.py), pulling EVERY field
        # of a wide grid/zonal-stats layer into Python for up to 100k rows
        # is itself heavy enough to strain memory — the scan cap alone
        # wasn't the whole story.
        all_fields = layer.fields()
        numeric_names = [f.name() for f in all_fields if f.isNumeric()]
        other_names = [f.name() for f in all_fields if not f.isNumeric()]
        row_estimate = (len(layer.selectedFeatureIds())
                        if self.selected_only.isChecked() else layer.featureCount())
        full = False
        if assistant.would_sample(len(numeric_names), row_estimate):
            box = QMessageBox(self)
            box.setIcon(QMessageBox.Icon.Question)
            box.setWindowTitle("Suggest — large layer")
            box.setText(f"This layer has {len(numeric_names)} numeric "
                       f"field(s) and about {row_estimate:,} feature(s).")
            box.setInformativeText(
                "To stay responsive, Suggest normally checks a representative "
                "sample of the fields and rows for the strongest relationship "
                "— reliable for the recommendation, and instant. Scanning "
                "every field against every row on a layer this size can take "
                "a long time and use a lot of memory.")
            sample_btn = box.addButton("Use a sample (recommended)",
                                       QMessageBox.ButtonRole.AcceptRole)
            full_btn = box.addButton("Scan the full dataset",
                                     QMessageBox.ButtonRole.DestructiveRole)
            box.setDefaultButton(sample_btn)
            box.exec()
            full = box.clickedButton() is full_btn
        # Bound the raw pull itself regardless of the choice above — "full
        # scan" means "don't sub-sample the rows/fields we DID pull", not
        # "pull literally every field of a 1000-column layer"; that upper
        # bound (300) is generous enough to never matter on a real layer.
        field_cap = _SUGGEST_FULL_FIELD_CAP if full else _SUGGEST_FIELD_CAP
        names = numeric_names[:field_cap] + other_names[:20]
        try:
            cols, fids = datasource.columns_with_ids(
                layer, names, self.selected_only.isChecked())
        except Exception as exc:
            self.set_status(f"Suggest failed: {exc}")
            return
        sug = assistant.suggest_chart(cols, fids, full=full)
        if not sug:
            self.set_status("No clear chart suggestion — try ✨ Explore for an overview")
            return
        self._apply_chart_suggestion(sug)
        note = (" (based on a sample of the fields/rows — Suggest again and "
               "choose “Scan the full dataset” for an exhaustive search)"
               if sug.get("sampled") else "")
        self.set_status("💡 " + sug["why"] + note)
        self._render()

    def _apply_chart_suggestion(self, sug: dict) -> None:
        kind = sug["type"]
        eng = engines()[max(0, self.engine_combo.currentIndex())]
        if kind not in eng.supports:  # switch to an engine that can draw it
            for i, e in enumerate(engines()):
                if kind in e.supports:
                    self.engine_combo.setCurrentIndex(i)
                    break
        for i in range(self.type_combo.count()):
            if self.type_combo.itemData(i) == kind:
                self.type_combo.setCurrentIndex(i)
                break
        self._sync_controls()
        self.x_combo.setField(sug.get("x") or "")
        self.y_combo.setField(sug.get("y") or "")
        self.group_combo.setField(sug.get("group") or "")
        self.value_combo.setField(sug.get("value") or "")
        agg_i = self.agg_combo.findText(sug.get("agg") or "none")
        if agg_i >= 0:
            self.agg_combo.setCurrentIndex(agg_i)
        self.trend_check.setChecked(bool(sug.get("trend")))

    def _open_guide(self) -> None:
        """Show the full user guide, with suggestions for the active layer."""
        layer = self.layer_combo.currentLayer()
        layer_name = layer.name() if layer is not None else ""
        suggestions = None
        if layer is not None:
            try:
                names = [f.name() for f in layer.fields()]
                cols, fids = datasource.columns_with_ids(
                    layer, names, self.selected_only.isChecked())
                suggestions = assistant.suggestions(
                    cols, has_geometry=diagrams.layer_has_geometry(layer), fids=fids)
            except Exception:
                suggestions = None
        html = build_guide_html(suggestions=suggestions, layer_name=layer_name)
        self.tabs.setCurrentIndex(0)  # the embedded view lives on the Charts tab
        self._show_html(html, "Opened the 02viz user guide")

    def _export_html(self) -> None:
        if not self._last_html:
            return
        path, _ = QFileDialog.getSaveFileName(
            self, "Export chart as HTML", os.path.join(self._last_io_dir(), "02viz_chart.html"), "HTML (*.html)")
        if not path:
            return
        path = ensure_extension(path, ".html")
        self._remember_io_dir(path)
        try:
            size = atomic_write_text(path, self._last_html)
            self.set_status(
                f"Exported HTML: {os.path.basename(path)} · {human_size(size)}")
        except Exception as exc:
            self.set_status(f"HTML export failed: {exc}")

    def _export_svg(self) -> None:
        self._export_rendered_dialog("SVG", ".svg", "SVG (*.svg)")

    def _export_png(self) -> None:
        self._export_rendered_dialog("PNG", ".png", "PNG (*.png)")

    def _export_pdf(self) -> None:
        self._export_rendered_dialog("PDF", ".pdf", "PDF (*.pdf)")

    def _export_rendered_dialog(self, output_format: str, extension: str,
                                file_filter: str) -> None:
        if not self._view:
            self.set_status(f"{output_format} export needs a rendered view")
            return
        path, _ = QFileDialog.getSaveFileName(
            self,
            f"Export chart as {output_format}",
            os.path.join(self._last_io_dir(), f"02viz_chart{extension}"),
            file_filter,
        )
        if not path:
            return
        path = ensure_extension(path, extension)
        self._remember_io_dir(path)
        try:
            size = self._export_view_to(path, output_format)
            self.set_status(
                f"Exported {output_format}: {os.path.basename(path)} · "
                f"{human_size(size)}")
        except Exception as exc:
            self.set_status(f"{output_format} export failed: {exc}")

    def _export_view_to(self, path: str, output_format: str) -> int:
        """Render the current embedded chart to an atomic raster/vector file.

        Kept separate from the file dialog so it can be exercised against a
        real Qt widget on both supported QGIS runtimes.
        """
        if self._view is None:
            raise ValueError("No rendered chart view is available.")
        fmt = output_format.upper()
        if fmt not in {"PNG", "SVG", "PDF"}:
            raise ValueError(f"Unsupported rendered export format: {fmt}")
        if self._view.width() <= 0 or self._view.height() <= 0:
            raise ValueError("The rendered chart view has no visible size.")

        with atomic_output(path) as temporary_path:
            if fmt == "PNG":
                pixmap = self._view.grab()
                if pixmap.isNull() or not pixmap.save(temporary_path, "PNG"):
                    raise ValueError("Qt could not capture the chart as PNG.")
            elif fmt == "SVG":
                from qgis.PyQt.QtSvg import QSvgGenerator

                generator = QSvgGenerator()
                generator.setFileName(temporary_path)
                generator.setSize(self._view.size())
                generator.setViewBox(self._view.rect())
                generator.setTitle("02viz chart")
                painter = QPainter()
                if not painter.begin(generator):
                    raise ValueError("Qt could not initialize the SVG writer.")
                try:
                    self._view.render(painter)
                finally:
                    painter.end()
                # QSvgGenerator owns the QFile handle until its wrapper is
                # destroyed. Release it before atomic_output renames the
                # staging file (required on Windows/QGIS 4).
                del generator
            else:
                from qgis.PyQt.QtPrintSupport import QPrinter

                printer = QPrinter(QPrinter.PrinterMode.HighResolution)
                printer.setOutputFormat(QPrinter.OutputFormat.PdfFormat)
                printer.setOutputFileName(temporary_path)
                printer.setDocName("02viz chart")
                painter = QPainter()
                if not painter.begin(printer):
                    raise ValueError("Qt could not initialize the PDF writer.")
                try:
                    viewport = painter.viewport()
                    fitted = self._view.size()
                    fitted.scale(
                        viewport.size(), Qt.AspectRatioMode.KeepAspectRatio)
                    painter.setViewport(
                        viewport.x(), viewport.y(), fitted.width(), fitted.height())
                    painter.setWindow(self._view.rect())
                    self._view.render(painter)
                finally:
                    painter.end()
                # QPrinter likewise keeps the PDF handle open for its
                # lifetime; release it before publishing the staging file.
                del printer
        return os.path.getsize(os.path.abspath(path))

    def set_status(self, text: str) -> None:
        self.status.setText(text)
