"""Text widget: user-authored rich text (bold/italic/underline, per-run
text/highlight color), composed once in the dialog and rendered on
every panel refresh from the parsed run structure -- no live upstream to
fetch, same self-contained shape as static_image.py, just word-wrapped
text instead of an uploaded image. See app/text_content.py for how the
dialog's contenteditable HTML becomes models.TextWidgetConfig.content
(the sanitization boundary; this module never sees raw HTML).

Bold/italic use real vendored font weights (app/fonts/*.ttf, OFL-
licensed like the emoji fonts already there) across a small curated set
of families (FONT_FAMILIES) rather than every other widget's single
ImageFont.load_default() -- the one widget type where that distinction
is the whole point. Font family is a whole-widget setting like
font_size/align, not per-run -- only bold/italic/underline/color/bg
vary run-to-run (see app/text_content.py).

No button actions -- there's nothing to advance/back/check for a fixed
block of authored text."""

from __future__ import annotations

import re
from functools import lru_cache
from pathlib import Path

from PIL import Image, ImageDraw, ImageFont
from sqlalchemy.orm import Session

from ..image_pipeline import _quantize, draw_text, hex_to_rgb, logical_render_size
from ..models import Frame, TextWidgetConfig, Widget
from ..text_content import has_text
from ._shared import placeholder_image

ACTIONS: dict = {}
ACTION_LABELS: dict[str, str] = {}

MARGIN = 14
MIN_FONT_SIZE = 10
LINE_HEIGHT_FACTOR = 1.35
DEFAULT_FG = (0, 0, 0)
DEFAULT_BG = (255, 255, 255)

_FONT_DIR = Path(__file__).resolve().parent.parent / "fonts"

# A small curated set, not an open-ended picker -- each entry needs a
# real vendored Regular/Bold/Italic/BoldItalic file, so families that
# only ship as a variable font (Playfair Display, Lora, Merriweather,
# stock "Inter"/"Source Sans 3" from Google Fonts) were skipped in favor
# of static builds from their own upstream repos where one exists (see
# app/fonts/OFL-*.txt for each non-Noto family's own license/copyright --
# they're all OFL, same as the Noto fonts already vendored here, but
# each has a different copyright holder so gets its own license file
# rather than sharing app/fonts/OFL.txt).
DEFAULT_FONT_FAMILY = "sans"
FONT_FAMILIES: dict[str, str] = {
    "sans": "Sans-serif (Noto Sans)",
    "inter": "Inter",
    "source_sans": "Source Sans",
    "serif": "Serif (Noto Serif)",
    "elegant": "Elegant serif (Crimson Text)",
    "slab": "Slab serif (Arvo)",
    "mono": "Monospace (IBM Plex Mono)",
}
_FONT_FILES = {
    "sans": {
        (False, False): "NotoSans-Regular.ttf", (True, False): "NotoSans-Bold.ttf",
        (False, True): "NotoSans-Italic.ttf", (True, True): "NotoSans-BoldItalic.ttf",
    },
    "inter": {
        (False, False): "Inter-Regular.ttf", (True, False): "Inter-Bold.ttf",
        (False, True): "Inter-Italic.ttf", (True, True): "Inter-BoldItalic.ttf",
    },
    "source_sans": {
        (False, False): "SourceSans3-Regular.ttf", (True, False): "SourceSans3-Bold.ttf",
        (False, True): "SourceSans3-Italic.ttf", (True, True): "SourceSans3-BoldItalic.ttf",
    },
    "serif": {
        (False, False): "NotoSerif-Regular.ttf", (True, False): "NotoSerif-Bold.ttf",
        (False, True): "NotoSerif-Italic.ttf", (True, True): "NotoSerif-BoldItalic.ttf",
    },
    "elegant": {
        (False, False): "CrimsonText-Regular.ttf", (True, False): "CrimsonText-Bold.ttf",
        (False, True): "CrimsonText-Italic.ttf", (True, True): "CrimsonText-BoldItalic.ttf",
    },
    "slab": {
        (False, False): "Arvo-Regular.ttf", (True, False): "Arvo-Bold.ttf",
        (False, True): "Arvo-Italic.ttf", (True, True): "Arvo-BoldItalic.ttf",
    },
    "mono": {
        (False, False): "IBMPlexMono-Regular.ttf", (True, False): "IBMPlexMono-Bold.ttf",
        (False, True): "IBMPlexMono-Italic.ttf", (True, True): "IBMPlexMono-BoldItalic.ttf",
    },
}
_WORD_OR_SPACE = re.compile(r"\S+|\s+")


@lru_cache(maxsize=256)
def _font(family: str, bold: bool, italic: bool, size: int) -> ImageFont.FreeTypeFont:
    files = _FONT_FILES.get(family) or _FONT_FILES[DEFAULT_FONT_FAMILY]
    return ImageFont.truetype(str(_FONT_DIR / files[(bold, italic)]), size)


def _paragraph_word_groups(paragraph: list[dict]) -> list[list[dict]]:
    """One paragraph's styled runs -> word groups: each group is a list
    of same-word sub-tokens that must stay glued together on one line
    (no whitespace between them in the source) -- otherwise bolding part
    of a word (e.g. "wor**ld**") would introduce a spurious space at the
    style boundary once wrapped. Whitespace runs become the implicit gap
    between groups (collapsed to a single space, however many source
    characters it was)."""
    groups: list[list[dict]] = []
    current: list[dict] = []
    for run in paragraph:
        for piece in _WORD_OR_SPACE.findall(run["text"]):
            if piece.isspace():
                if current:
                    groups.append(current)
                    current = []
            else:
                current.append({**run, "text": piece})
    if current:
        groups.append(current)
    return groups


def _group_width(draw: ImageDraw.ImageDraw, group: list[dict], family: str, size: int) -> float:
    return sum(draw.textlength(tok["text"], font=_font(family, tok["bold"], tok["italic"], size))
              for tok in group)


def _wrap_paragraph(draw: ImageDraw.ImageDraw, groups: list[list[dict]], family: str, size: int,
                    max_width: int, space_width: float) -> list[list[list[dict]]]:
    """Greedy word wrap -> list of lines, each a list of word groups.
    An empty `groups` (a blank authored line) still produces one empty
    line, to preserve the blank line's vertical space."""
    lines: list[list[list[dict]]] = []
    current: list[list[dict]] = []
    current_w = 0.0
    for group in groups:
        gw = _group_width(draw, group, family, size)
        add_w = gw + (space_width if current else 0)
        if current and current_w + add_w > max_width:
            lines.append(current)
            current = [group]
            current_w = gw
        else:
            current.append(group)
            current_w += add_w
    if current or not groups:
        lines.append(current)
    return lines


def _fit(draw: ImageDraw.ImageDraw, paragraphs: list[list[dict]], family: str, start_size: int,
        max_width: int, max_height: int) -> tuple[int, list[list[list[dict]]]]:
    """Shrinks font size (down to MIN_FONT_SIZE) until the wrapped
    content's total height fits max_height, or gives up at the floor --
    a too-small widget box just clips rather than raising. Returns the
    chosen size and the flat list of lines (each a list of word groups)
    across every paragraph, in order."""
    size = max(MIN_FONT_SIZE, start_size)
    lines: list[list[list[dict]]] = []
    while True:
        space_width = draw.textlength(" ", font=_font(family, False, False, size))
        lines = []
        for paragraph in paragraphs:
            lines.extend(_wrap_paragraph(draw, _paragraph_word_groups(paragraph), family, size,
                                         max_width, space_width))
        line_h = round(size * LINE_HEIGHT_FACTOR)
        total_h = len(lines) * line_h
        if total_h <= max_height or size <= MIN_FONT_SIZE:
            return size, lines
        size = max(MIN_FONT_SIZE, size - 2)


def _draw_line(img: Image.Image, draw: ImageDraw.ImageDraw, line: list[list[dict]], y: int,
              family: str, size: int, line_h: int, max_width: int, align: str, space_width: float) -> None:
    line_width = sum(_group_width(draw, g, family, size) for g in line) + space_width * max(0, len(line) - 1)
    if align == "center":
        x = MARGIN + max(0, (max_width - line_width) / 2)
    elif align == "right":
        x = MARGIN + max(0, max_width - line_width)
    else:
        x = MARGIN
    underline_h = max(1, size // 16)
    for gi, group in enumerate(line):
        for tok in group:
            font = _font(family, tok["bold"], tok["italic"], size)
            w = draw.textlength(tok["text"], font=font)
            if tok["bg"]:
                bg_rgb = hex_to_rgb(tok["bg"])
                if bg_rgb:
                    draw.rectangle([x, y, x + w, y + line_h], fill=bg_rgb)
            fill = hex_to_rgb(tok["color"]) if tok["color"] else None
            draw_text(img, (round(x), y), tok["text"], font, fill or DEFAULT_FG)
            if tok["underline"]:
                underline_y = y + font.size + 1
                draw.rectangle([x, underline_y, x + w, underline_y + underline_h], fill=fill or DEFAULT_FG)
            x += w
        if gi < len(line) - 1:
            x += space_width


def _render_text(cfg: TextWidgetConfig, target_w: int, target_h: int) -> Image.Image:
    bg = hex_to_rgb(cfg.background_color) or DEFAULT_BG
    img = Image.new("RGB", (target_w, target_h), bg)
    draw = ImageDraw.Draw(img)

    family = cfg.font_family if cfg.font_family in FONT_FAMILIES else DEFAULT_FONT_FAMILY
    max_width = max(10, target_w - 2 * MARGIN)
    max_height = max(10, target_h - 2 * MARGIN)
    size, lines = _fit(draw, cfg.content or [], family, cfg.font_size, max_width, max_height)
    line_h = round(size * LINE_HEIGHT_FACTOR)
    space_width = draw.textlength(" ", font=_font(family, False, False, size))

    total_h = len(lines) * line_h
    y = MARGIN + max(0, (max_height - total_h) // 2)
    align = cfg.align if cfg.align in ("left", "center", "right") else "left"
    for line in lines:
        if y + line_h > target_h:
            break  # ran out of room even at the smallest size -- clip remaining lines rather than overflow
        _draw_line(img, draw, line, y, family, size, line_h, max_width, align, space_width)
        y += line_h
    return img


def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: int,
          is_normal_wake: bool = True) -> Image.Image:
    """is_normal_wake is unused -- see app/widgets/whiteboard.py's
    identical note; every widget type's render() shares one call
    signature regardless of which ones actually care."""
    cfg = db.get(TextWidgetConfig, widget.id)
    if cfg is None or not has_text(cfg.content):
        return placeholder_image(target_w, target_h, ["Text widget", "not configured yet"])
    return _render_text(cfg, target_w, target_h)


def render_preview_png(cfg: TextWidgetConfig, orientation: str, palette_rgb: list | None) -> bytes:
    """A normal browser-viewable PNG at full logical panel size --
    mirrors calendar_render.render_tasks_preview_png's relationship to
    render_tasks (the dialog's own preview endpoint always renders at
    the frame's full size, not the widget's actual grid box, same
    convention every other widget type's preview endpoint follows)."""
    import io

    target_w, target_h = logical_render_size(orientation)
    img = _render_text(cfg, target_w, target_h)
    quantized = _quantize(img, palette_rgb, dither_strength=1.0)
    buf = io.BytesIO()
    quantized.convert("RGB").save(buf, format="PNG")
    return buf.getvalue()
