"""Honest empty states — dashed frame, lattice ground, reason, action.

The web kit's ``.empty`` translated (00-brand §5): centered icon +
display-face title + muted reason + optional action button, on the
Federation-Lattice tile at the brand's opacity ceiling. The lattice
variant (light/dark) is picked from the widget's *palette* at paint
time, so the widget re-themes automatically with no manager wiring.

``bind_empty_state`` swaps a view/empty pair on model emptiness.
"""

from __future__ import annotations

from importlib import resources
from typing import TYPE_CHECKING

from PySide6.QtCore import Qt
from PySide6.QtGui import QPainter, QPixmap
from PySide6.QtWidgets import (
    QAbstractItemView,
    QHBoxLayout,
    QLabel,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

from mgf.brand_qt.fonts import display_font
from mgf.brand_qt.icons import icon
from mgf.brand_qt.tokens import TEXT_PT
from mgf.brand_qt.widgets.props import set_mgf

if TYPE_CHECKING:
    from collections.abc import Callable

    from PySide6.QtCore import QAbstractItemModel

_TILES: dict[str, QPixmap] = {}


def lattice_tile(variant: str) -> QPixmap:
    """The Federation-Lattice pattern tile, ``"light"`` or ``"dark"``.

    Public per PAPER-05: consumers painting the lattice on their own
    surfaces (hero panes, drop zones) re-shipped the PNGs rather than
    reach a private helper. Cached per variant.
    """
    if variant not in ("light", "dark"):
        raise ValueError(f"unknown lattice variant {variant!r}; use 'light' or 'dark'")
    tile = _TILES.get(variant)
    if tile is None:
        data = (
            resources.files("mgf.brand_qt")
            .joinpath(f"assets/pattern/pattern-lattice-fine-{variant}.png")
            .read_bytes()
        )
        tile = QPixmap()
        tile.loadFromData(data)
        _TILES[variant] = tile
    return tile


_lattice_tile = lattice_tile  # internal alias; paintEvent predates the promotion


class EmptyState(QWidget):
    """Centered icon + title + reason (+ optional action button)."""

    def __init__(
        self,
        icon_name: str,
        title: str,
        reason: str,
        *,
        action_text: str | None = None,
        on_action: Callable[[], None] | None = None,
        parent: QWidget | None = None,
    ) -> None:
        super().__init__(parent)
        # Subclassed QWidget: stylesheet borders need this attribute.
        self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
        set_mgf(self, Class="emptyState")
        outer = QVBoxLayout(self)
        outer.addStretch(1)

        # Labels are attributes (PAPER-08): honestly-changing copy must
        # not cost a widget rebuild — the reason is precisely the part
        # of an empty state that changes at runtime.
        self.icon_label = QLabel(self)
        self.icon_label.setPixmap(icon(icon_name, size=32).pixmap(32, 32))
        self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        outer.addWidget(self.icon_label)

        self.title_label = QLabel(title, self)
        self.title_label.setFont(display_font(TEXT_PT["lg"]))
        self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        outer.addWidget(self.title_label)

        self.reason_label = QLabel(reason, self)
        set_mgf(self.reason_label, Class="hint")
        self.reason_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.reason_label.setWordWrap(True)
        outer.addWidget(self.reason_label)

        self.action_button: QPushButton | None = None
        if action_text is not None:
            row = QHBoxLayout()
            row.addStretch(1)
            self.action_button = QPushButton(action_text, self)
            if on_action is not None:
                self.action_button.clicked.connect(on_action)
            row.addWidget(self.action_button)
            row.addStretch(1)
            outer.addLayout(row)

        outer.addStretch(1)

    def set_title(self, title: str) -> None:
        """Replace the title in place (PAPER-08)."""
        self.title_label.setText(title)

    def set_reason(self, reason: str) -> None:
        """Replace the reason in place (PAPER-08) — no rebuild, no rebind."""
        self.reason_label.setText(reason)

    def set_icon(self, icon_name: str) -> None:
        """Replace the icon in place (PAPER-08); validates the 42-icon set."""
        self.icon_label.setPixmap(icon(icon_name, size=32).pixmap(32, 32))

    def paintEvent(self, event: object) -> None:
        # Palette-derived theme pick: dark canvas → dark lattice tile.
        window = self.palette().window().color()
        variant = "dark" if window.lightness() < 128 else "light"
        painter = QPainter(self)
        painter.setOpacity(0.5)  # PNG tiles already carry brand opacity
        painter.drawTiledPixmap(self.rect(), _lattice_tile(variant))
        painter.end()
        super().paintEvent(event)  # type: ignore[arg-type]


class EmptyStateBinding:
    """The live handle a :func:`bind_empty_state` call returns (PAPER-01).

    Holds the model connections so a consumer tearing a pane down (or
    re-binding across a reconnect) can :meth:`unbind` instead of
    leaving dying models signalling dying widgets. ``unbind`` is
    idempotent.
    """

    _SIGNALS = (
        "modelReset",
        "rowsInserted",
        "rowsRemoved",
        "layoutChanged",
        "dataChanged",
    )

    def __init__(self, view: QAbstractItemView, empty: EmptyState) -> None:
        model = view.model()
        if model is None:
            raise ValueError("bind_empty_state requires the view to have a model")
        self._model: QAbstractItemModel | None = model
        self._view = view
        self._empty = empty

        def sync(*_args: object) -> None:
            # Read the CURRENT model — the paper's closure bug made the
            # binding track the bind-time model after a setModel swap.
            live = view.model()
            has_rows = live is not None and live.rowCount() > 0
            view.setVisible(has_rows)
            empty.setVisible(not has_rows)

        self._sync = sync
        for name in self._SIGNALS:
            getattr(model, name).connect(sync)
        sync()

    def unbind(self) -> None:
        """Disconnect every signal this binding made; safe to call twice."""
        model = self._model
        if model is None:
            return
        self._model = None
        for name in self._SIGNALS:
            try:
                getattr(model, name).disconnect(self._sync)
            except RuntimeError:
                # The model's C++ half may already be gone mid-teardown;
                # a dead connection is exactly what unbind wants.
                continue


def bind_empty_state(view: QAbstractItemView, empty: EmptyState) -> EmptyStateBinding:
    """Show ``empty`` instead of ``view`` while the view's model is empty.

    The pair must share a layout (insert ``empty`` next to ``view``
    before binding). Tracks reset/insert/remove/layout **and**
    ``dataChanged`` (PAPER-01). Returns an :class:`EmptyStateBinding`
    whose ``unbind()`` releases every connection; binding to a model
    installed later (``setModel`` after bind) is a re-bind, not a
    mutation of the old binding.
    """
    return EmptyStateBinding(view, empty)


__all__ = ["EmptyState", "EmptyStateBinding", "bind_empty_state", "lattice_tile"]
