from __future__ import annotations

import logging
import math
from pathlib import Path
from time import monotonic

import cairo
import gi

gi.require_version("Gtk", "4.0")
gi.require_version("GdkPixbuf", "2.0")

from gi.repository import Gdk, GdkPixbuf, GLib, Gtk  # noqa: E402

logger = logging.getLogger(__name__)

from . import x11_overlay
from .config import APP_ICON_PATH, AppConfig, persist_overlay_position
from .state import TitleRuntime, TitleState, TitleTimerEngine

ROW_HEIGHT = 46
ROW_GAP = 8
ROW_WIDTH = 220
PADDING = 12
# Fills most of the row height (46px, minus ~2px inset the row border is
# drawn at on each side) while still leaving a small margin so it doesn't
# touch the row outline.
ICON_SIZE = 36

# Below this many remaining seconds, the cooldown border/bar/countdown ease
# from the title's own color towards this warning color, so the last
# moments of a cooldown read as urgent without needing a separate config
# knob - a generic progress-bar convention, not tied to any specific title.
_WARNING_COLOR = "#ff5252"
_WARNING_THRESHOLD_SECONDS = 5.0


def _lerp_rgb(a: tuple[float, float, float], b: tuple[float, float, float], t: float) -> tuple[float, float, float]:
    return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)


def _hex_to_rgba(color: str, alpha: float) -> tuple[float, float, float, float]:
    color = color.lstrip("#")
    r = int(color[0:2], 16) / 255
    g = int(color[2:4], 16) / 255
    b = int(color[4:6], 16) / 255
    return (r, g, b, alpha)


class OverlayWindow(Gtk.Window):
    def __init__(self, config: AppConfig, config_path: Path):
        super().__init__()
        self._config = config
        self._engine = TitleTimerEngine(config, on_change=self._request_redraw)
        self._config_path = config_path
        self._phase = 0.0
        # Loaded lazily and cached per path - _on_draw fires ~10x/sec, and a
        # failed/missing icon path would otherwise retry the disk read (and
        # log a warning) on every single frame.
        self._icon_cache: dict[str, GdkPixbuf.Pixbuf | None] = {}

        self.set_decorated(False)
        self.add_css_class("title-timer-overlay")

        display = Gdk.Display.get_default()
        provider = Gtk.CssProvider()
        provider.load_from_string(
            "window.title-timer-overlay { background: transparent; }"
        )
        Gtk.StyleContext.add_provider_for_display(
            display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

        n_titles = max(1, len(config.titles))
        self._width = int(ROW_WIDTH * config.overlay.scale)
        self._height = int((ROW_HEIGHT + ROW_GAP) * n_titles * config.overlay.scale)
        self.set_default_size(self._width, self._height)

        self._drawing_area = Gtk.DrawingArea()
        self._drawing_area.set_content_width(self._width)
        self._drawing_area.set_content_height(self._height)
        self._drawing_area.set_draw_func(self._on_draw)
        self.set_child(self._drawing_area)

        self._xid: int | None = None
        self._x: int | None = None
        self._y: int | None = None
        self._drag_start_x: int | None = None
        self._drag_start_y: int | None = None
        # Dedicated connection reused across drag-related X11 calls instead
        # of opening a new socket per call.
        self._drag_display = x11_overlay.connect()

        self._drag_gesture = Gtk.GestureDrag()
        self._drag_gesture.set_button(Gdk.BUTTON_PRIMARY)
        self._drag_gesture.connect("drag-begin", self._on_drag_begin)
        self._drag_gesture.connect("drag-update", self._on_drag_update)
        self._drag_gesture.connect("drag-end", self._on_drag_end)
        self._drawing_area.add_controller(self._drag_gesture)

        self.connect("realize", self._on_realize)
        self.connect("map", self._on_map)

        GLib.timeout_add(100, self._on_tick)
        GLib.timeout_add(1000, self._on_reraise_tick)

    def _on_realize(self, _widget) -> None:
        # Hotkeys are read directly from /dev/input, and the game underneath
        # must keep input focus - so this must never grab keyboard/pointer
        # input. GNOME/Mutter doesn't implement wlr-layer-shell, so instead
        # of a layer surface this applies EWMH always-on-top + click-through
        # hints over XWayland (works on GNOME, KDE, and wlroots compositors).
        self._xid, self._x, self._y = x11_overlay.make_overlay(
            self, self._config.overlay, self._width, self._height
        )

    def _on_map(self, _widget) -> None:
        if self._xid is not None:
            x11_overlay.restack_above(self._drag_display, self._xid)
            # Must run on "map" - see settings_window.py's _on_map for why
            # (GTK4 sets/clears _NET_WM_ICON itself around map time). This
            # window is skip_taskbar so it's normally not user-visible
            # anywhere an icon would show, but set for consistency/in case
            # some WM's alt-tab ignores that hint.
            x11_overlay.set_wm_icon(self._drag_display, self._xid, str(APP_ICON_PATH))

    def _request_redraw(self) -> None:
        self._drawing_area.queue_draw()

    def _on_tick(self) -> bool:
        self._phase += 0.1
        self._engine.tick()
        self._drawing_area.queue_draw()
        return GLib.SOURCE_CONTINUE

    def _on_reraise_tick(self) -> bool:
        # Defensive - restack_above() should already hold thanks to
        # override-redirect (see x11_overlay.make_overlay), but this costs
        # nothing and guards against anything else on the stack (the game,
        # a notification) restacking itself above us in between.
        if self._xid is not None:
            x11_overlay.restack_above(self._drag_display, self._xid)
        return GLib.SOURCE_CONTINUE

    def handle_hotkey(self, held: frozenset[str], pressed_key: str) -> None:
        self._engine.handle_hotkey(held, pressed_key)

    def apply_config(self, new_config: AppConfig, config_path: Path) -> None:
        """Pick up a config saved from the settings window without
        restarting - rebuilds the timer engine (so hotkeys/dogma-mode take
        effect immediately) and, if the title count or scale changed,
        resizes the window in place.

        `config_path` is tracked separately because it can change too - the
        settings window may have switched to a different character profile,
        whose file a subsequent Ctrl+Alt-drag must persist into instead of
        the previously active profile's.

        Any title mid-cooldown resets to idle, since a fresh engine has no
        memory of the old one - an acceptable tradeoff for "edit settings
        without restarting the whole app" rather than trying to migrate
        live timer state across a config change.
        """
        self._config = new_config
        self._config_path = config_path
        self._icon_cache.clear()

        n_titles = max(1, len(new_config.titles))
        new_width = int(ROW_WIDTH * new_config.overlay.scale)
        new_height = int((ROW_HEIGHT + ROW_GAP) * n_titles * new_config.overlay.scale)
        if (new_width, new_height) != (self._width, self._height):
            self._width, self._height = new_width, new_height
            self._drawing_area.set_content_width(self._width)
            self._drawing_area.set_content_height(self._height)
            if self._xid is not None:
                x11_overlay.resize_window(self._drag_display, self._xid, self._width, self._height)

        self._engine = TitleTimerEngine(new_config, on_change=self._request_redraw)
        self._request_redraw()

    # -- Ctrl+Alt+drag repositioning --
    #
    # The overlay is normally click-through (empty XShape input region) so
    # the game underneath keeps receiving clicks. Several earlier approaches
    # didn't pan out:
    #   1. Querying the pointer's absolute position via X11 to hit-test a
    #      click-through window - unreliable under XWayland, query_pointer()
    #      returned a frozen position that never updated (XWayland seems to
    #      only track the core pointer while it's over a surface actually
    #      receiving pointer events, which a click-through window never is).
    #   2. Handing the drag off to the WM via _NET_WM_MOVERESIZE (the same
    #      protocol path normal titlebar dragging uses) - worked fine on
    #      Mutter/KDE, but the window is now override-redirect (see
    #      x11_overlay.make_overlay, needed for reliable always-on-top on
    #      COSMIC), and an override-redirect window isn't managed by any WM
    #      at all - nothing to hand the move off to anymore.
    #   3. Plain Alt (no Ctrl) as the modifier - collided with GNOME/
    #      Mutter's own built-in "Alt+drag moves any window" binding on
    #      systems still at its upstream default (observed: Bazzite/GNOME);
    #      Mutter's exclusive passive grab on that modifier+button ate the
    #      click before this app ever saw it, so nothing happened at all.
    #
    # So: ModifierWatcher (drag.py) reports Ctrl+Alt up/down from raw evdev
    # - a combo that can't collide with that GNOME setting regardless of its
    # value, since the setting only ever matches a single exact modifier.
    # While the combo is held, the input region is temporarily set to the
    # full window rect (on_alt_change, called from ModifierWatcher's
    # background thread) so a click actually reaches GTK; a Gtk.GestureDrag
    # then tracks the motion itself and repositions the window via
    # x11_overlay.move_window() on every update - client-side tracking was
    # laggy on a WM-managed window (see move_window()'s docstring) but isn't
    # anymore now that the WM has no smoothing/animation to apply, since it
    # doesn't manage this window at all.

    def on_alt_change(self, held: bool) -> None:
        if self._xid is None:
            return
        x11_overlay.set_click_through(
            self._drag_display, self._xid, not held, self._width, self._height
        )

    def _on_drag_begin(self, _gesture, _start_x: float, _start_y: float) -> None:
        self._drag_start_x = self._x
        self._drag_start_y = self._y

    def _on_drag_update(self, _gesture, offset_x: float, offset_y: float) -> None:
        if self._xid is None or self._drag_start_x is None or self._drag_start_y is None:
            return
        self._x = self._drag_start_x + round(offset_x)
        self._y = self._drag_start_y + round(offset_y)
        x11_overlay.move_window(self._drag_display, self._xid, self._x, self._y)

    def _on_drag_end(self, _gesture, offset_x: float, offset_y: float) -> None:
        self._on_drag_update(_gesture, offset_x, offset_y)
        self._drag_start_x = None
        self._drag_start_y = None
        if self._x is not None and self._y is not None:
            persist_overlay_position(self._config_path, self._x, self._y)

    def _on_draw(self, area, cr, width, height) -> None:
        now = monotonic()
        cr.set_line_width(3)
        for i, rt in enumerate(self._engine.snapshot()):
            y = i * (ROW_HEIGHT + ROW_GAP)
            self._draw_row(cr, rt, y, width, now)

    def _draw_row(self, cr, rt: TitleRuntime, y: float, width: float, now: float) -> None:
        color = rt.config.color
        radius = 8
        cooldown_rgb: tuple[float, float, float] | None = None
        standby_rgb: tuple[float, float, float] | None = None

        if rt.state is TitleState.STANDBY:
            standby_remaining = rt.standby_remaining_seconds(now)
            pulse = (math.sin(self._phase * 3) + 1) / 2  # 0..1
            base_rgb = _hex_to_rgba(color, 1.0)[:3]

            if standby_remaining is not None:
                # A timeout is configured - ease towards the warning color
                # as the window to press step 2 runs out, same convention
                # as the cooldown bar below.
                urgency = max(0.0, min(1.0, 1.0 - standby_remaining / _WARNING_THRESHOLD_SECONDS))
                warn_rgb = _hex_to_rgba(_WARNING_COLOR, 1.0)[:3]
                r, g, b = standby_rgb = _lerp_rgb(base_rgb, warn_rgb, urgency)
            else:
                r, g, b = base_rgb

            self._outlined_stroke(
                cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius, (r, g, b, 0.5 + 0.5 * pulse)
            )

            if standby_remaining is not None:
                timeout = rt.config.standby_timeout_seconds or 1.0
                bar_x, bar_y, bar_h = PADDING, y + ROW_HEIGHT - 12, 5
                bar_track_w = width - 2 * PADDING
                cr.set_source_rgba(1, 1, 1, 0.08)
                self._rounded_rect(cr, bar_x, bar_y, bar_track_w, bar_h, bar_h / 2)
                cr.fill()
                bar_fill_w = max(0.0, bar_track_w * (standby_remaining / timeout))
                if bar_fill_w > 0:
                    cr.set_source_rgba(r, g, b, 0.95)
                    self._rounded_rect(cr, bar_x, bar_y, bar_fill_w, bar_h, bar_h / 2)
                    cr.fill()
        elif rt.state is TitleState.COOLDOWN:
            remaining = rt.remaining_seconds(now)
            urgency = max(0.0, min(1.0, 1.0 - remaining / _WARNING_THRESHOLD_SECONDS))
            base_rgb = _hex_to_rgba(color, 1.0)[:3]
            warn_rgb = _hex_to_rgba(_WARNING_COLOR, 1.0)[:3]
            r, g, b = cooldown_rgb = _lerp_rgb(base_rgb, warn_rgb, urgency)

            # Soft color wash behind the whole row, so an active cooldown
            # reads as a filled "card" rather than just an outline.
            cr.set_source_rgba(r, g, b, 0.12)
            self._rounded_rect(cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius)
            cr.fill()

            # Border gently pulses once urgent, calm/steady otherwise.
            urgent_pulse = (math.sin(self._phase * 5) + 1) / 2 if urgency > 0 else 0.0
            self._outlined_stroke(
                cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius,
                (r, g, b, 0.85 + 0.15 * urgent_pulse * urgency), line_width=2.5,
            )

            # Full-width progress bar: dim track + bright fill, rounded caps.
            bar_x, bar_y, bar_h = PADDING, y + ROW_HEIGHT - 12, 5
            bar_track_w = width - 2 * PADDING
            cr.set_source_rgba(1, 1, 1, 0.08)
            self._rounded_rect(cr, bar_x, bar_y, bar_track_w, bar_h, bar_h / 2)
            cr.fill()

            fraction = rt.remaining_fraction(now)
            bar_fill_w = max(0.0, bar_track_w * fraction)
            if bar_fill_w > 0:
                cr.set_source_rgba(r, g, b, 0.95)
                self._rounded_rect(cr, bar_x, bar_y, bar_fill_w, bar_h, bar_h / 2)
                cr.fill()
        else:
            self._outlined_stroke(cr, 2, y + 2, width - 4, ROW_HEIGHT - 4, radius, (1, 1, 1, 0.55))

        text_x = PADDING
        icon = self._get_icon(rt.config.icon) if rt.config.icon else None
        if icon is not None:
            icon_y = y + (ROW_HEIGHT - ICON_SIZE) / 2
            Gdk.cairo_set_source_pixbuf(cr, icon, PADDING, icon_y)
            cr.paint()
            text_x = PADDING + ICON_SIZE + 8

        cr.set_source_rgba(1, 1, 1, 0.9)
        cr.select_font_face("sans-serif", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
        cr.set_font_size(14)
        cr.move_to(text_x, y + 20)
        cr.show_text(rt.config.name)

        countdown_rgb = cooldown_rgb if rt.state is TitleState.COOLDOWN else standby_rgb
        countdown_seconds = (
            rt.remaining_seconds(now) if rt.state is TitleState.COOLDOWN
            else rt.standby_remaining_seconds(now)
        )
        if countdown_rgb is not None and countdown_seconds is not None:
            text = f"{countdown_seconds:.1f}s"
            cr.select_font_face("monospace", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
            cr.set_font_size(15)
            text_width = cr.text_extents(text).width
            cr.set_source_rgba(*countdown_rgb, 1.0)
            cr.move_to(width - PADDING - text_width, y + 20)
            cr.show_text(text)

    def _outlined_stroke(
        self, cr, x, y, w, h, r, rgba: tuple[float, float, float, float], line_width: float = 3.0
    ) -> None:
        """Stroke a rounded rect with a dark contrast outline underneath the
        actual color - a plain single-color border reads fine against one
        background but disappears against another (a bright border blends
        into a light game background, a dark one blends into a dark one).
        The dark underlay keeps an edge visible either way, same idea as a
        subtitle's black outline around white text.
        """
        cr.set_line_width(line_width + 1.6)
        cr.set_source_rgba(0, 0, 0, 0.55)
        self._rounded_rect(cr, x, y, w, h, r)
        cr.stroke()

        cr.set_line_width(line_width)
        cr.set_source_rgba(*rgba)
        self._rounded_rect(cr, x, y, w, h, r)
        cr.stroke()

    def _get_icon(self, path: str) -> GdkPixbuf.Pixbuf | None:
        if path not in self._icon_cache:
            try:
                self._icon_cache[path] = GdkPixbuf.Pixbuf.new_from_file_at_scale(
                    path, ICON_SIZE, ICON_SIZE, True
                )
            except GLib.Error as exc:
                logger.warning("Could not load icon %r: %s", path, exc)
                self._icon_cache[path] = None
        return self._icon_cache[path]

    @staticmethod
    def _rounded_rect(cr, x, y, w, h, r) -> None:
        if w <= 0 or h <= 0:
            cr.new_path()
            return
        cr.new_sub_path()
        cr.arc(x + w - r, y + r, r, -math.pi / 2, 0)
        cr.arc(x + w - r, y + h - r, r, 0, math.pi / 2)
        cr.arc(x + r, y + h - r, r, math.pi / 2, math.pi)
        cr.arc(x + r, y + r, r, math.pi, 3 * math.pi / 2)
        cr.close_path()
