"""Parses a contenteditable div's serialized innerHTML (widget_dialog_
text.js's POSTed content_html) into a plain, storage-safe run structure
-- list of paragraphs, each a list of {"text", "bold", "italic",
"underline", "color", "bg"} runs -- for the text widget (see
models.TextWidgetConfig, app/widgets/text.py).

This is the sanitization boundary the checklist's stored-XSS note
(CLAUDE.md, another linked user could have set this) is about: raw HTML
never round-trips back into any browser DOM. Only text content and a
small fixed set of style flags survive parsing; every tag, attribute,
and CSS property not explicitly recognized below is simply discarded --
there's no allowlist-of-tags-to-keep-as-HTML step where something could
slip through unescaped, because nothing is ever re-emitted as HTML at
all. The dialog reconstructs its editor from this same run structure via
safe DOM calls (createElement/textContent), never innerHTML."""

from __future__ import annotations

import re
from html.parser import HTMLParser

# Generous ceilings, not exact UX limits -- just stop a direct API call
# (bypassing the dialog's own textarea-ish size) from storing something
# pathologically large. MAX_INPUT_CHARS bounds parse work; MAX_TOTAL_CHARS
# bounds what's actually kept (a widget's on-panel region is a few
# hundred pixels -- there is no legible use for more than a few thousand
# characters of body text there).
MAX_INPUT_CHARS = 200_000
MAX_TOTAL_CHARS = 4_000

_BASE_STYLE = {"bold": False, "italic": False, "underline": False, "color": None, "bg": None}
_BLOCK_TAGS = {"div", "p", "li"}
_VOID_TAGS = {"br"}

_HEX6 = re.compile(r"^#([0-9a-fA-F]{6})$")
_HEX3 = re.compile(r"^#([0-9a-fA-F]{3})$")
_RGB = re.compile(r"^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$")
_STYLE_PROP = re.compile(r"([a-zA-Z-]+)\s*:\s*([^;]+)")
_BOLD_WEIGHTS = {"bold", "bolder", "600", "700", "800", "900"}


def _normalize_color(value: str) -> str | None:
    """"#1a2b3c" / "#abc" / "rgb(26, 43, 60)" -> "#1a2b3c". Anything else
    (a CSS named color, "transparent", garbage) -> None, i.e. dropped --
    this is the one place an arbitrary style-attribute string could try
    to smuggle something through, so it's a strict allowlist match, not
    a best-effort parse."""
    value = value.strip()
    m = _HEX6.match(value)
    if m:
        return "#" + m.group(1).lower()
    m = _HEX3.match(value)
    if m:
        return "#" + "".join(c * 2 for c in m.group(1)).lower()
    m = _RGB.match(value)
    if m:
        r, g, b = (max(0, min(255, int(x))) for x in m.groups())
        return f"#{r:02x}{g:02x}{b:02x}"
    return None


class _RichTextParser(HTMLParser):
    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.paragraphs: list[list[dict]] = [[]]
        self._style_stack: list[dict] = [_BASE_STYLE]
        self._at_line_start = True

    def _break(self, tag: str) -> None:
        # Coalesces contenteditable's per-line block wrapping (Chrome
        # wraps every line in its own <div> even without a deliberate
        # blank line) down to one paragraph break per actual line gap,
        # while still letting an explicit <br> when already at a fresh
        # line start (Chrome's "<div><br></div>" idiom for a blank line,
        # or a genuine double Shift+Enter) add a real blank paragraph.
        if not self._at_line_start:
            self.paragraphs.append([])
            self._at_line_start = True
        elif tag == "br":
            self.paragraphs.append([])

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        if tag in _BLOCK_TAGS or tag in _VOID_TAGS:
            self._break(tag)
        if tag in _VOID_TAGS:
            return
        style = dict(self._style_stack[-1])
        attrs_dict = {k: v for k, v in attrs if v is not None}
        if tag in ("b", "strong"):
            style["bold"] = True
        elif tag in ("i", "em"):
            style["italic"] = True
        elif tag == "u":
            style["underline"] = True
        elif tag == "font":
            color = _normalize_color(attrs_dict.get("color", ""))
            if color:
                style["color"] = color
        elif tag == "span":
            for prop, val in _STYLE_PROP.findall(attrs_dict.get("style", "")):
                prop = prop.strip().lower()
                val = val.strip()
                if prop == "color":
                    color = _normalize_color(val)
                    if color:
                        style["color"] = color
                elif prop == "background-color":
                    color = _normalize_color(val)
                    if color:
                        style["bg"] = color
                elif prop == "font-weight" and val.lower() in _BOLD_WEIGHTS:
                    style["bold"] = True
                elif prop == "font-style" and val.lower() == "italic":
                    style["italic"] = True
                elif prop == "text-decoration" and "underline" in val.lower():
                    style["underline"] = True
        # Pushed for every non-void tag, including ones with no
        # recognized style effect (script/a/img/...) -- keeps push/pop
        # balanced against handle_endtag regardless of tag, without
        # needing to track which tags actually pushed something.
        self._style_stack.append(style)

    def handle_endtag(self, tag: str) -> None:
        if tag in _VOID_TAGS:
            return
        if len(self._style_stack) > 1:
            self._style_stack.pop()

    def handle_data(self, data: str) -> None:
        if not data:
            return
        style = self._style_stack[-1]
        self.paragraphs[-1].append({"text": data, **style})
        if data.strip():
            self._at_line_start = False


def parse_rich_text(html: str) -> list[list[dict]]:
    """The sanitization entry point -- see module docstring. Always
    returns a valid (possibly all-empty) paragraphs structure, never
    raises for malformed markup (html.parser tolerates unclosed/
    mismatched tags; handle_endtag's length guard tolerates an
    over-popped stack)."""
    parser = _RichTextParser()
    parser.feed(html[:MAX_INPUT_CHARS])
    parser.close()
    paragraphs = parser.paragraphs

    total = 0
    truncated: list[list[dict]] = []
    for para in paragraphs:
        new_para: list[dict] = []
        for run in para:
            remaining = MAX_TOTAL_CHARS - total
            if remaining <= 0:
                break
            text = run["text"][:remaining]
            total += len(text)
            new_para.append({**run, "text": text})
        truncated.append(new_para)
        if total >= MAX_TOTAL_CHARS:
            break
    return truncated


def has_text(paragraphs: list[list[dict]] | None) -> bool:
    if not paragraphs:
        return False
    return any(run["text"].strip() for para in paragraphs for run in para)
