## Copyright (C) 2026  Solaar Contributors https://pwr-solaar.github.io/Solaar/
##
## This program is free software; you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation; either version 2 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License along
## with this program; if not, write to the Free Software Foundation, Inc.,
## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

"""Cairo-rendered keyboard canvas. Renders a BoundLayout as colored rectangles
and dispatches paint events through a configurable Tool to the editor.
"""

from __future__ import annotations

import logging

from enum import Enum
from typing import Callable

import gi

gi.require_version("Gtk", "3.0")
from gi.repository import Gdk  # NOQA: E402
from gi.repository import GObject  # NOQA: E402
from gi.repository import Gtk  # NOQA: E402

from .layout import BoundCell  # NOQA: E402
from .layout import BoundLayout  # NOQA: E402
from .layout import Cell  # NOQA: E402
from .tools import TOOLS  # NOQA: E402
from .tools import ToolContext  # NOQA: E402

logger = logging.getLogger(__name__)


class GtkSignal(Enum):
    DRAW = "draw"
    BUTTON_PRESS_EVENT = "button-press-event"
    BUTTON_RELEASE_EVENT = "button-release-event"
    MOTION_NOTIFY_EVENT = "motion-notify-event"
    LEAVE_NOTIFY_EVENT = "leave-notify-event"


CELL_PX = 36
GUTTER_PX = 4
STRIP_GAP_PX = 16
PADDING_PX = 8


class KeyboardCanvas(Gtk.DrawingArea):
    __gsignals__ = {
        # Emitted on stroke release. delta is dict[zone_id, color].
        "paint": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
    }

    def __init__(self) -> None:
        super().__init__()
        self._bound: BoundLayout | None = None
        self._colors: dict[int, int] = {}  # zone_id -> packed RGB or -1 (unset)
        self._active_color: int = 0xFF0000
        self._gradient_colors_source: Callable[[], tuple[int, int]] | None = None
        self._zone_base_color: int | None = None
        self._tool_name: str = "single"
        self._press_cell: BoundCell | None = None
        self._motion_cell: BoundCell | None = None
        self._brush_path: list[int] = []
        self._dragging: bool = False
        self.set_can_focus(True)
        self.add_events(
            Gdk.EventMask.BUTTON_PRESS_MASK
            | Gdk.EventMask.BUTTON_RELEASE_MASK
            | Gdk.EventMask.POINTER_MOTION_MASK
            | Gdk.EventMask.LEAVE_NOTIFY_MASK
        )
        self.connect(GtkSignal.DRAW.value, self._on_draw)
        self.connect(GtkSignal.BUTTON_PRESS_EVENT.value, self._on_press)
        self.connect(GtkSignal.BUTTON_RELEASE_EVENT.value, self._on_release)
        self.connect(GtkSignal.MOTION_NOTIFY_EVENT.value, self._on_motion)
        self.connect(GtkSignal.LEAVE_NOTIFY_EVENT.value, self._on_leave)

    # ---- public API ----

    def set_layout(self, bound: BoundLayout) -> None:
        self._bound = bound
        self._update_size()
        self.queue_draw()

    def set_colors(self, colors: dict[int, int]) -> None:
        self._colors = dict(colors)
        self.queue_draw()

    def update_colors(self, deltas: dict[int, int]) -> None:
        self._colors.update(deltas)
        self.queue_draw()

    def set_active_color(self, color: int) -> None:
        self._active_color = int(color)

    def set_gradient_colors_source(self, source: Callable[[], tuple[int, int]] | None) -> None:
        self._gradient_colors_source = source

    def set_zone_base_color(self, color: int | None) -> None:
        self._zone_base_color = None if color is None else int(color)
        self.queue_draw()

    def set_tool(self, name: str) -> None:
        if name in TOOLS:
            self._tool_name = name

    # ---- size / hit-test ----

    def _matrix_size(self) -> tuple[int, int]:
        if not self._bound:
            return 0, 0
        max_col = 0
        max_row = 0
        for bc in self._bound.matrix:
            c = bc.cell
            max_col = max(max_col, c.col + int(round(c.width)))
            max_row = max(max_row, c.row + int(round(c.height)))
        return max_row, max_col

    def _strip_size(self) -> int:
        if not self._bound:
            return 0
        return len(self._bound.strip)

    def _update_size(self) -> None:
        rows, cols = self._matrix_size()
        strip_n = self._strip_size()
        w = PADDING_PX * 2 + cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
        matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
        strip_h = (CELL_PX + STRIP_GAP_PX) if strip_n else 0
        h = PADDING_PX * 2 + matrix_h + strip_h
        # widen if strip is wider than matrix
        if strip_n:
            sw = PADDING_PX * 2 + strip_n * CELL_PX + max(0, strip_n - 1) * GUTTER_PX
            w = max(w, sw)
        self.set_size_request(w, h)

    def _cell_rect(self, bc: BoundCell) -> tuple[float, float, float, float]:
        c = bc.cell
        if self._bound is not None and bc in self._bound.strip:
            # strip cells: laid out in a flat row beneath the matrix
            rows, _cols = self._matrix_size()
            matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
            strip_idx = self._bound.strip.index(bc)
            x = PADDING_PX + strip_idx * (CELL_PX + GUTTER_PX)
            y = PADDING_PX + matrix_h + STRIP_GAP_PX
            return (x, y, CELL_PX, CELL_PX)
        x = PADDING_PX + c.col * (CELL_PX + GUTTER_PX)
        y = PADDING_PX + c.row * (CELL_PX + GUTTER_PX)
        w = c.width * CELL_PX + max(0.0, c.width - 1.0) * GUTTER_PX
        h = c.height * CELL_PX + max(0.0, c.height - 1.0) * GUTTER_PX
        return (x, y, w, h)

    def _cell_at(self, x: float, y: float) -> BoundCell | None:
        if not self._bound:
            return None
        for bc in list(self._bound.matrix) + list(self._bound.strip):
            cx, cy, cw, ch = self._cell_rect(bc)
            if cx <= x < cx + cw and cy <= y < cy + ch:
                return bc
        # Phantom anchor for gaps in the matrix grid — gives rect/gradient
        # drags a valid endpoint where no real cell exists.
        rows, cols = self._matrix_size()
        matrix_w = cols * CELL_PX + max(0, cols - 1) * GUTTER_PX
        matrix_h = rows * CELL_PX + max(0, rows - 1) * GUTTER_PX
        if PADDING_PX <= x < PADDING_PX + matrix_w and PADDING_PX <= y < PADDING_PX + matrix_h:
            col = int((x - PADDING_PX) // (CELL_PX + GUTTER_PX))
            row = int((y - PADDING_PX) // (CELL_PX + GUTTER_PX))
            if 0 <= col < cols and 0 <= row < rows:
                return BoundCell(cell=Cell(zone_id=-1, row=row, col=col), bound=False)
        return None

    # ---- draw ----

    def _on_draw(self, _widget, cr) -> bool:
        if not self._bound:
            return False
        for bc in self._bound.matrix:
            self._draw_cell(cr, bc)
        for bc in self._bound.strip:
            self._draw_cell(cr, bc)
        if self._dragging and self._press_cell and self._motion_cell:
            tool = TOOLS.get(self._tool_name)
            if tool is not None:
                if tool.overlay_shape == "rect":
                    self._draw_rect_overlay(cr, self._press_cell, self._motion_cell)
                elif tool.overlay_shape == "line":
                    self._draw_line_overlay(cr, self._press_cell, self._motion_cell)
        return False

    def _draw_cell(self, cr, bc: BoundCell) -> None:
        x, y, w, h = self._cell_rect(bc)
        color = self._colors.get(bc.cell.zone_id, -1)
        # background
        if not bc.bound:
            cr.set_source_rgba(0.18, 0.18, 0.20, 1.0)
        elif color is None or color < 0:
            self._fill_checker(cr, x, y, w, h)
            cr.set_source_rgba(0, 0, 0, 0)  # no overlay fill
        else:
            r = ((color >> 16) & 0xFF) / 255.0
            g = ((color >> 8) & 0xFF) / 255.0
            b = (color & 0xFF) / 255.0
            cr.set_source_rgba(r, g, b, 1.0)
        if bc.bound and (color is not None and color >= 0):
            self._round_rect(cr, x, y, w, h, 4)
            cr.fill_preserve()
        elif not bc.bound:
            self._round_rect(cr, x, y, w, h, 4)
            cr.fill_preserve()
        else:
            self._round_rect(cr, x, y, w, h, 4)
        # border
        cr.set_source_rgba(0, 0, 0, 0.55)
        cr.set_line_width(1.0)
        cr.stroke()
        # label
        label = bc.cell.label or str(bc.cell.zone_id)
        cr.set_source_rgba(*self._label_color(color, bc.bound))
        cr.select_font_face("Sans")
        cr.set_font_size(11.0 if len(label) <= 3 else 9.0)
        try:
            extents = cr.text_extents(label)
            tx = x + (w - extents.width) / 2 - extents.x_bearing
            ty = y + (h + extents.height) / 2 - extents.y_bearing - extents.height
            cr.move_to(tx, ty)
            cr.show_text(label)
        except Exception as e:
            logger.debug("text rendering failed for %r: %s", label, e)

    def _fill_checker(self, cr, x, y, w, h) -> None:
        # Diagonal hash for "no change" cells. The background is the zone
        # base color (what these cells actually display on the keyboard).
        # Stripes alternate darker / lighter than base in equal measure so
        # the cell's perceived average stays at the base color, instead of
        # being uniformly biased toward black or white as a single-overlay
        # stripe would.
        cr.save()
        self._round_rect(cr, x, y, w, h, 4)
        cr.clip()
        base = self._zone_base_color
        if base is not None and base >= 0:
            r = ((base >> 16) & 0xFF) / 255.0
            g = ((base >> 8) & 0xFF) / 255.0
            b = (base & 0xFF) / 255.0
            cr.set_source_rgba(r, g, b, 1.0)
            cr.rectangle(x, y, w, h)
            cr.fill()
            # Per-channel ±offset. When a channel is too close to 0 or 1 to
            # fit the full offset, halve the offset on the constrained side
            # (per spec: average drifts at the limits, but stays centered on
            # base elsewhere).
            offset = 0.22

            def _shift(v: float) -> tuple[float, float]:
                down_off = offset if (v - offset) >= 0.0 else offset / 2.0
                up_off = offset if (v + offset) <= 1.0 else offset / 2.0
                return max(0.0, v - down_off), min(1.0, v + up_off)

            rd, ru = _shift(r)
            gd, gu = _shift(g)
            bd, bu = _shift(b)
            # Interleave darker and lighter stripes (period = step per set,
            # other-color set offset by step/2). Equal coverage of the two
            # colors keeps the perceived average at base.
            cr.set_line_width(1.5)
            step = 6
            half = step // 2
            d_max = int(w + h)
            cr.set_source_rgba(rd, gd, bd, 1.0)
            d = -int(h)
            while d <= d_max:
                cr.move_to(x + d, y + h)
                cr.line_to(x + d + h, y)
                cr.stroke()
                d += step
            cr.set_source_rgba(ru, gu, bu, 1.0)
            d = -int(h) + half
            while d <= d_max:
                cr.move_to(x + d, y + h)
                cr.line_to(x + d + h, y)
                cr.stroke()
                d += step
        else:
            # No zone base color known — fall back to a neutral dark bg with
            # medium-gray stripes; "average = base" doesn't apply since there
            # is no expected color to preserve.
            cr.set_source_rgba(0.30, 0.30, 0.32, 1.0)
            cr.rectangle(x, y, w, h)
            cr.fill()
            cr.set_source_rgba(0.55, 0.55, 0.60, 1.0)
            cr.set_line_width(1.5)
            step = 5
            d_max = int(w + h)
            d = -int(h)
            while d <= d_max:
                cr.move_to(x + d, y + h)
                cr.line_to(x + d + h, y)
                cr.stroke()
                d += step
        cr.restore()

    def _round_rect(self, cr, x, y, w, h, r) -> None:
        cr.new_sub_path()
        cr.arc(x + w - r, y + r, r, -1.5708, 0)
        cr.arc(x + w - r, y + h - r, r, 0, 1.5708)
        cr.arc(x + r, y + h - r, r, 1.5708, 3.1416)
        cr.arc(x + r, y + r, r, 3.1416, 4.7124)
        cr.close_path()

    def _label_color(self, color: int, bound: bool) -> tuple[float, float, float, float]:
        if not bound:
            return (0.50, 0.50, 0.52, 1.0)
        if color is None or color < 0:
            return (0.85, 0.85, 0.88, 1.0)
        # luminance heuristic
        r = ((color >> 16) & 0xFF) / 255.0
        g = ((color >> 8) & 0xFF) / 255.0
        b = (color & 0xFF) / 255.0
        lum = 0.299 * r + 0.587 * g + 0.114 * b
        return (0, 0, 0, 1.0) if lum > 0.55 else (1, 1, 1, 1.0)

    def _draw_rect_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
        ax, ay, aw, ah = self._cell_rect(a)
        bx, by, bw, bh = self._cell_rect(b)
        x0 = min(ax, bx) - 2
        y0 = min(ay, by) - 2
        x1 = max(ax + aw, bx + bw) + 2
        y1 = max(ay + ah, by + bh) + 2
        cr.set_source_rgba(0.30, 0.65, 1.0, 0.85)
        cr.set_line_width(1.5)
        cr.set_dash([4.0, 3.0])
        cr.rectangle(x0, y0, x1 - x0, y1 - y0)
        cr.stroke()
        cr.set_dash([])

    def _draw_line_overlay(self, cr, a: BoundCell, b: BoundCell) -> None:
        ax, ay, aw, ah = self._cell_rect(a)
        bx, by, bw, bh = self._cell_rect(b)
        ax_c, ay_c = ax + aw / 2, ay + ah / 2
        bx_c, by_c = bx + bw / 2, by + bh / 2
        cr.set_source_rgba(0.30, 0.65, 1.0, 0.95)
        cr.set_line_width(2.0)
        cr.set_dash([5.0, 3.0])
        cr.move_to(ax_c, ay_c)
        cr.line_to(bx_c, by_c)
        cr.stroke()
        cr.set_dash([])
        # endpoint dots — solid so the anchors read clearly
        for cx, cy in ((ax_c, ay_c), (bx_c, by_c)):
            cr.arc(cx, cy, 4.0, 0, 6.283)
            cr.fill()

    # ---- input ----

    def _on_press(self, _w, event: Gdk.EventButton) -> bool:
        if event.button != 1:
            return False
        bc = self._cell_at(event.x, event.y)
        if bc is None:
            return False
        tool = TOOLS.get(self._tool_name)
        # Endpoint tools (rect/gradient) anchor on cell centers regardless of
        # bind state; brush/bucket need a real key to paint/flood.
        if not (tool and tool.overlay_shape) and not bc.bound:
            return False
        self._press_cell = bc
        self._motion_cell = bc
        self._dragging = True
        self._brush_path = [bc.cell.zone_id]
        if tool is not None and tool.is_brush:
            self.update_colors({bc.cell.zone_id: self._active_color})
        else:
            self.queue_draw()
        return True

    def _on_motion(self, _w, event: Gdk.EventMotion) -> bool:
        if not self._dragging:
            return False
        bc = self._cell_at(event.x, event.y)
        if bc is None:
            return False
        tool = TOOLS.get(self._tool_name)
        if not (tool and tool.overlay_shape) and not bc.bound:
            return False
        if bc is self._motion_cell:
            return False
        self._motion_cell = bc
        if tool is not None and tool.is_brush:
            if bc.cell.zone_id not in self._brush_path:
                self._brush_path.append(bc.cell.zone_id)
                self.update_colors({bc.cell.zone_id: self._active_color})
        else:
            self.queue_draw()
        return True

    def _on_release(self, _w, event: Gdk.EventButton) -> bool:
        if event.button != 1 or not self._dragging:
            return False
        self._dragging = False
        if self._press_cell is None:
            return False
        if self._bound:
            bound_zones = {bc.cell.zone_id: bc for bc in list(self._bound.matrix) + list(self._bound.strip)}
            strip_zones = frozenset(bc.cell.zone_id for bc in self._bound.strip)
        else:
            bound_zones = {}
            strip_zones = frozenset()
        if self._tool_name == "gradient" and self._gradient_colors_source is not None:
            grad_active, grad_previous = self._gradient_colors_source()
            ctx = ToolContext(
                active_color=int(grad_active),
                last_color=int(grad_previous),
                cells_by_zone=bound_zones,
                strip_zones=strip_zones,
                current_colors=dict(self._colors),
            )
        else:
            ctx = ToolContext(
                active_color=self._active_color,
                last_color=self._active_color,
                cells_by_zone=bound_zones,
                strip_zones=strip_zones,
                current_colors=dict(self._colors),
            )
        tool = TOOLS.get(self._tool_name)
        delta: dict[int, int] = {}
        if tool is not None:
            delta = tool.compute(self._press_cell, self._motion_cell, list(self._brush_path), ctx)
        self._press_cell = None
        self._motion_cell = None
        self._brush_path = []
        self.queue_draw()
        if delta:
            self.update_colors(delta)
            self.emit("paint", delta)
        return True

    def _on_leave(self, _w, _event) -> bool:
        # don't cancel drags on leave; let the user re-enter
        return False
