"""Theme engine — Fusion base style, QPalette, generated QSS, OS watching.

:func:`apply_theme` is the single entry point the GUI calls once after
constructing :class:`QApplication`; it returns the long-lived
:class:`ThemeManager` that windows can subscribe to
(:attr:`ThemeManager.theme_changed`) and that the View → Theme menu
drives via :meth:`ThemeManager.set_mode`.

Modes are ``"dark"`` / ``"light"`` / ``"system"`` (system follows the
OS colour scheme live). The kit stores nothing itself — persistence is
the consumer's job
via the ``on_mode_changed`` callback (GUI-08: the GUI wires it to
``AppConfig`` + ``save_config``, never raw ``QSettings``).
"""

from __future__ import annotations

import logging
from importlib import resources
from typing import TYPE_CHECKING

from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtGui import QColor, QIcon, QPalette, QPixmap

from mgf.brand_qt.fonts import default_font, load_fonts
from mgf.brand_qt.qss import build_qss
from mgf.brand_qt.tokens import DARK, LIGHT, RAW, Mode, SemanticColors

if TYPE_CHECKING:
    from collections.abc import Callable

    from PySide6.QtWidgets import QApplication

LOG = logging.getLogger(__name__)


def to_qcolor(value: str) -> QColor:
    """Parse a token value — ``#rrggbb`` or ``rgba(r,g,b,a)`` — into a QColor.

    The public token→QColor parser (PAPER-05): every consumer painting
    with a semantic token (delegates, custom widgets) needs this exact
    parse, and two private copies had already grown before promotion.
    """
    if value.startswith("rgba(") and value.endswith(")"):
        parts = value[5:-1].split(",")
        red, green, blue = (int(p) for p in parts[:3])
        return QColor(red, green, blue, round(float(parts[3]) * 255))
    return QColor(value)


_qcolor = to_qcolor  # internal alias; the palette builder predates the promotion


def _build_palette(sem: SemanticColors) -> QPalette:
    """Map semantic tokens onto a full QPalette (03-target-design §5).

    Selection (``Highlight``) uses the link colour in both themes per
    the D14 selection discipline — a gold highlight would spend the
    one-gold-per-view budget on every text selection.
    """
    palette = QPalette()
    role_map = {
        QPalette.ColorRole.Window: sem.bg_canvas,
        QPalette.ColorRole.WindowText: sem.text_primary,
        QPalette.ColorRole.Base: sem.bg_surface,
        QPalette.ColorRole.AlternateBase: sem.bg_muted,
        QPalette.ColorRole.Text: sem.text_primary,
        QPalette.ColorRole.Button: sem.bg_surface,
        QPalette.ColorRole.ButtonText: sem.text_primary,
        QPalette.ColorRole.Highlight: sem.link,
        QPalette.ColorRole.HighlightedText: sem.text_inverse,
        QPalette.ColorRole.Link: sem.link,
        QPalette.ColorRole.LinkVisited: sem.link_hover,
        QPalette.ColorRole.PlaceholderText: sem.text_secondary,
        QPalette.ColorRole.ToolTipBase: sem.bg_surface,
        QPalette.ColorRole.ToolTipText: sem.text_primary,
    }
    for role, value in role_map.items():
        palette.setColor(role, _qcolor(value))
    disabled = _qcolor(sem.text_primary)
    disabled.setAlphaF(0.4)
    for role in (
        QPalette.ColorRole.WindowText,
        QPalette.ColorRole.Text,
        QPalette.ColorRole.ButtonText,
    ):
        palette.setColor(QPalette.ColorGroup.Disabled, role, disabled)
    return palette


class ThemeManager(QObject):
    """Owns the current theme mode and re-skins the application on change.

    Emits :attr:`theme_changed` with the resolved theme (``"light"`` or
    ``"dark"``) after every application — initial, user switch, and OS
    scheme change while in ``"system"`` mode.
    """

    theme_changed = Signal(str)

    def __init__(
        self,
        app: QApplication,
        mode: Mode,
        on_mode_changed: Callable[[Mode], None] | None = None,
        parent: QObject | None = None,
    ) -> None:
        super().__init__(parent)
        self._app = app
        self._mode: Mode = mode
        self._on_mode_changed = on_mode_changed
        self._extra_qss = ""
        hints = app.styleHints()
        # Qt >= 6.5; guarded so older runtimes just lose live OS tracking.
        if hasattr(hints, "colorSchemeChanged"):
            hints.colorSchemeChanged.connect(self._on_os_scheme_changed)
        self._apply()

    @property
    def mode(self) -> Mode:
        """The configured mode (``dark`` / ``light`` / ``system``)."""
        return self._mode

    def resolved(self) -> str:
        """The effective theme: ``"light"`` or ``"dark"``."""
        if self._mode != "system":
            return self._mode
        hints = self._app.styleHints()
        if hasattr(hints, "colorScheme") and hints.colorScheme() == Qt.ColorScheme.Dark:
            return "dark"
        return "light"

    def set_mode(self, mode: Mode) -> None:
        """Switch mode, notify the persistence callback, re-skin the app."""
        if mode == self._mode:
            return
        self._mode = mode
        if self._on_mode_changed is not None:
            self._on_mode_changed(mode)
        self._apply()

    def set_extra_qss(self, qss: str) -> None:
        """Append consumer QSS after the generated stylesheet and re-apply.

        The manager owns the application stylesheet and rewrites it
        wholesale on every theme flip; before this hook a consumer's
        additions were silently dropped on the first ``set_mode``
        (PAPER-05). The extra block survives every re-apply. Pass
        ``""`` to remove a previously set block.
        """
        if qss == self._extra_qss:
            return
        self._extra_qss = qss
        self._apply()

    def _on_os_scheme_changed(self, *_args: object) -> None:
        if self._mode == "system":
            self._apply()

    def _apply(self) -> None:
        resolved = self.resolved()
        sem = DARK if resolved == "dark" else LIGHT
        self._app.setPalette(_build_palette(sem))
        qss = build_qss(sem, chrome_ink=RAW.ink, chrome_text=RAW.mist)
        if self._extra_qss:
            qss = f"{qss}\n{self._extra_qss}"
        self._app.setStyleSheet(qss)
        LOG.info("theme applied", extra={"theme": resolved, "mode": self._mode})
        self.theme_changed.emit(resolved)


#: The live manager per QApplication. apply_theme() is get-or-create:
#: a second construction path used to run app.setStyle() twice on a
#: live widget tree, and setStyle x 2 + setStyleSheet x 2 SIGSEGVs
#: once a QWizard exists — the re-polish walks buttons through the
#: destroyed QStyle (PAPER-11, bisection table in FEEDBACK.md).
#:
#: A STRONG reference, deliberately (0.1.3). 0.1.2 held a weakref, so a
#: caller that dropped the returned manager — every test suite, any
#: consumer that only wanted the side effect — let it be collected, the
#: registry forgot it, and the next apply_theme walked the full
#: construction path again: the exact crash the release existed to
#: foreclose, found by figrecovery's suite within the hour. The kit owns
#: this lifetime; one process-scoped manager is the design, not a leak.
#: A NEW QApplication (test isolation) replaces the entry via the
#: identity check below.
_ACTIVE: ThemeManager | None = None


def apply_theme(
    app: QApplication,
    mode: Mode,
    on_mode_changed: Callable[[Mode], None] | None = None,
) -> ThemeManager:
    """Brand the application: Fusion style, fonts, window icon, theme.

    Call from the GUI entry point right after the :class:`QApplication`
    is constructed. Returns the live :class:`ThemeManager`.

    Idempotent per application (PAPER-11): calling it again on the same
    ``app`` returns the existing manager — updated to ``mode`` and the
    new ``on_mode_changed`` — instead of constructing a second one. The
    one-time surfaces (Fusion style, fonts, window icon) are applied
    exactly once; re-theming always goes through the safe
    :meth:`ThemeManager.set_mode` path.
    """
    global _ACTIVE
    existing = _ACTIVE
    if existing is not None and existing._app is app:
        LOG.info(
            "apply_theme reused the live ThemeManager",
            extra={"mode": mode},
        )
        existing._on_mode_changed = on_mode_changed
        if mode != existing._mode:
            existing.set_mode(mode)
        else:
            # An explicit apply_theme call guarantees applied state even
            # when the mode is unchanged (set_mode alone would no-op).
            existing._apply()
        return existing

    app.setStyle("Fusion")
    families = load_fonts()
    LOG.info("bundled fonts loaded", extra={"families": sorted(set(families))})
    app.setFont(default_font())
    try:
        icon_resource = resources.files("mgf.brand_qt").joinpath(
            "assets/app/magogi-512.png",
        )
        with resources.as_file(icon_resource) as icon_path:
            # Load eagerly via QPixmap: QIcon defers file reads, and the
            # wheel-extracted temp path may vanish after this block.
            pixmap = QPixmap(str(icon_path))
        if pixmap.isNull():
            LOG.warning("app icon pixmap failed to load")
        else:
            app.setWindowIcon(QIcon(pixmap))
    except OSError:
        LOG.warning("cannot load bundled app icon", exc_info=True)
    manager = ThemeManager(app, mode, on_mode_changed)
    _ACTIVE = manager
    return manager


__all__ = ["Mode", "ThemeManager", "apply_theme", "to_qcolor"]
