import html
import re
from dataclasses import dataclass, field
from html.parser import HTMLParser

from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.shared import Pt, RGBColor
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.platypus import Paragraph, Spacer


ALLOWED_TAGS = {"p", "div", "h1", "h2", "h3", "blockquote", "ul", "ol", "li",
                "b", "strong", "i", "em", "u", "s", "strike", "span", "font", "br"}
BLOCK_TAGS = {"p", "div", "h1", "h2", "h3", "blockquote", "li"}
ALLOWED_FONTS = {"Arial", "Calibri", "Georgia", "Times New Roman", "Verdana"}
ALLOWED_ALIGN = {"left", "center", "right", "justify"}
COLOR_RE = re.compile(r"^(?:#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))$")
FONT_BY_CASEFOLD = {font.casefold(): font for font in ALLOWED_FONTS}
SIZE_RE = re.compile(r"^(\d+(?:\.\d+)?)(px|pt)$", re.IGNORECASE)
BLOCK_SIZES = {"h1": 24.0, "h2": 18.6, "h3": 15.0}


def _font_name(value: str) -> str | None:
    for candidate in value.split(","):
        normalized = candidate.strip().strip("'\"")
        if normalized.casefold() in FONT_BY_CASEFOLD:
            return FONT_BY_CASEFOLD[normalized.casefold()]
    return None


def _font_size(value: str) -> tuple[str, float] | None:
    match = SIZE_RE.fullmatch(value.strip())
    if not match:
        return None
    amount = float(match.group(1))
    unit = match.group(2).lower()
    points = amount * .75 if unit == "px" else amount
    if not 6 <= points <= 54:
        return None
    rendered = str(int(amount)) if amount.is_integer() else f"{amount:.2f}".rstrip("0").rstrip(".")
    return f"{rendered}{unit}", round(points, 2)


def _safe_style(value: str) -> str:
    clean = []
    for declaration in value.split(";"):
        if ":" not in declaration:
            continue
        key, raw = (piece.strip() for piece in declaration.split(":", 1))
        lowered = key.lower()
        if lowered == "font-family":
            font = _font_name(raw)
            if font:
                clean.append(f"font-family:{font}")
        elif lowered == "font-size":
            size = _font_size(raw)
            if size:
                clean.append(f"font-size:{size[0]}")
        elif lowered == "font-weight" and raw.lower() in {"normal", "bold", "600", "700", "800"}:
            clean.append(f"font-weight:{raw.lower()}")
        elif lowered == "font-style" and raw.lower() in {"normal", "italic"}:
            clean.append(f"font-style:{raw.lower()}")
        elif lowered in {"text-decoration", "text-decoration-line"} and raw.lower() in {"none", "underline", "line-through"}:
            clean.append(f"text-decoration:{raw.lower()}")
        elif lowered == "text-align" and raw.lower() in ALLOWED_ALIGN:
            clean.append(f"text-align:{raw.lower()}")
        elif lowered == "color" and COLOR_RE.fullmatch(raw):
            clean.append(f"color:{raw}")
    return ";".join(clean)


class _Sanitizer(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.output: list[str] = []
        self.stack: list[str] = []
        self.suppressed = 0

    def handle_starttag(self, tag, attrs):
        tag = tag.lower()
        if tag in {"script", "style", "iframe", "object", "svg"}:
            self.suppressed += 1
            return
        if self.suppressed or tag not in ALLOWED_TAGS:
            return
        safe = []
        source = dict(attrs)
        style = _safe_style(source.get("style", ""))
        legacy_align = source.get("align", "").strip().lower()
        if tag in BLOCK_TAGS and legacy_align in ALLOWED_ALIGN:
            style_map = _style_map(style)
            if "text-align" not in style_map:
                style = ";".join(value for value in (style, f"text-align:{legacy_align}") if value)
        if style:
            safe.append(("style", style))
        if tag == "font":
            face = _font_name(source.get("face", ""))
            if face:
                safe.append(("face", face))
            size = source.get("size", "")
            if size in {"1", "2", "3", "4", "5", "6", "7"}:
                safe.append(("size", size))
            color = source.get("color", "")
            if COLOR_RE.fullmatch(color):
                safe.append(("color", color))
        if tag == "br":
            self.output.append("<br>")
            return
        rendered = "".join(f' {key}="{html.escape(value, quote=True)}"' for key, value in safe)
        self.output.append(f"<{tag}{rendered}>")
        self.stack.append(tag)

    def handle_endtag(self, tag):
        tag = tag.lower()
        if tag in {"script", "style", "iframe", "object", "svg"} and self.suppressed:
            self.suppressed -= 1
            return
        if self.suppressed or tag not in ALLOWED_TAGS or tag == "br" or tag not in self.stack:
            return
        while self.stack:
            current = self.stack.pop()
            self.output.append(f"</{current}>")
            if current == tag:
                break

    def handle_data(self, data):
        if not self.suppressed:
            self.output.append(html.escape(data))

    def finish(self) -> str:
        while self.stack:
            self.output.append(f"</{self.stack.pop()}>")
        return "".join(self.output).strip()


def sanitize_template_html(value: str) -> str:
    source = (value or "").strip()
    if not re.search(r"</?[a-zA-Z][^>]*>", source):
        return "".join(f"<p>{html.escape(line) or '<br>'}</p>" for line in source.splitlines())
    # Algunos navegadores todavía generan la etiqueta histórica <center> al
    # ejecutar comandos de alineación sobre contenteditable. La normalizamos
    # antes de sanear para conservar la intención sin admitir HTML inseguro.
    source = re.sub(r"<\s*center(?:\s[^>]*)?>", '<div style="text-align:center">', source,
                    flags=re.IGNORECASE)
    source = re.sub(r"<\s*/\s*center\s*>", "</div>", source, flags=re.IGNORECASE)
    parser = _Sanitizer()
    parser.feed(source)
    parser.close()
    return parser.finish()


def template_text_content(value: str) -> str:
    return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", sanitize_template_html(value)))).strip()


@dataclass
class RichRun:
    text: str
    bold: bool = False
    italic: bool = False
    underline: bool = False
    strike: bool = False
    font: str | None = None
    size: float | None = None
    color: str | None = None


@dataclass
class RichBlock:
    kind: str = "p"
    align: str = "left"
    list_kind: str | None = None
    runs: list[RichRun] = field(default_factory=list)


def _style_map(raw: str) -> dict[str, str]:
    return {key.strip().lower(): value.strip() for item in raw.split(";") if ":" in item
            for key, value in [item.split(":", 1)]}


class _BlockParser(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.blocks: list[RichBlock] = []
        self.current: RichBlock | None = None
        self.inline: list[dict] = [{}]
        self.lists: list[str] = []

    def _block(self, tag="p", attrs=None):
        if self.current and self.current.runs:
            self.blocks.append(self.current)
        source = dict(attrs or [])
        style = _style_map(source.get("style", ""))
        legacy_align = source.get("align", "").strip().lower()
        alignment = style.get("text-align") or (legacy_align if legacy_align in ALLOWED_ALIGN else "left")
        self.current = RichBlock(tag, alignment,
                                 self.lists[-1] if tag == "li" and self.lists else None)

    def handle_starttag(self, tag, attrs):
        tag = tag.lower()
        if tag in {"ul", "ol"}:
            self.lists.append(tag)
        if tag in BLOCK_TAGS:
            self._block(tag, attrs)
        if tag == "br":
            self.handle_data("\n")
            return
        state = dict(self.inline[-1])
        style = _style_map(dict(attrs).get("style", ""))
        if tag in {"b", "strong"} or style.get("font-weight") in {"bold", "600", "700", "800"}: state["bold"] = True
        if tag in {"i", "em"} or style.get("font-style") == "italic": state["italic"] = True
        if tag == "u" or style.get("text-decoration") == "underline": state["underline"] = True
        if tag in {"s", "strike"} or style.get("text-decoration") == "line-through": state["strike"] = True
        font = _font_name(dict(attrs).get("face") or style.get("font-family", ""))
        if font: state["font"] = font
        parsed_size = _font_size(style.get("font-size", ""))
        if parsed_size: state["size"] = parsed_size[1]
        html_size = dict(attrs).get("size", "")
        if html_size.isdigit(): state["size"] = {1:8,2:10,3:12,4:14,5:18,6:24,7:36}.get(int(html_size), 12)
        color = dict(attrs).get("color") or style.get("color")
        if color and COLOR_RE.fullmatch(color): state["color"] = color
        self.inline.append(state)

    def handle_endtag(self, tag):
        tag = tag.lower()
        if tag in BLOCK_TAGS and self.current is not None:
            self.blocks.append(self.current); self.current = None
        if tag in {"ul", "ol"} and self.lists:
            self.lists.pop()
        if len(self.inline) > 1:
            self.inline.pop()

    def handle_data(self, data):
        if not data:
            return
        if self.current is None:
            self.current = RichBlock()
        self.current.runs.append(RichRun(data, **self.inline[-1]))

    def finish(self):
        if self.current and self.current.runs:
            self.blocks.append(self.current)
        return self.blocks or [RichBlock(runs=[RichRun("")])]


def parse_rich_blocks(value: str) -> list[RichBlock]:
    parser = _BlockParser()
    parser.feed(sanitize_template_html(value))
    parser.close()
    return parser.finish()


def append_docx_html(document, value: str) -> None:
    alignments = {"left": WD_ALIGN_PARAGRAPH.LEFT, "center": WD_ALIGN_PARAGRAPH.CENTER,
                  "right": WD_ALIGN_PARAGRAPH.RIGHT, "justify": WD_ALIGN_PARAGRAPH.JUSTIFY}
    for block in parse_rich_blocks(value):
        prefix = "• " if block.list_kind == "ul" else "1. " if block.list_kind == "ol" else ""
        paragraph = document.add_paragraph()
        if block.kind == "h1": paragraph.style = document.styles["Heading 1"]
        elif block.kind == "h2": paragraph.style = document.styles["Heading 2"]
        elif block.kind == "h3": paragraph.style = document.styles["Heading 3"]
        paragraph.alignment = alignments.get(block.align, WD_ALIGN_PARAGRAPH.LEFT)
        if prefix:
            paragraph.add_run(prefix)
        for item in block.runs:
            run = paragraph.add_run(item.text)
            run.bold, run.italic, run.underline = item.bold, item.italic, item.underline
            run.font.strike = item.strike
            run.font.name = item.font or "Arial"
            run.font.size = Pt(item.size or BLOCK_SIZES.get(block.kind, 9.0))
            if item.color and item.color.startswith("#"):
                value_hex = item.color.lstrip("#")
                if len(value_hex) == 3: value_hex = "".join(char * 2 for char in value_hex)
                run.font.color.rgb = RGBColor.from_string(value_hex.upper())


def reportlab_story(value: str):
    base = getSampleStyleSheet()["BodyText"]
    story = []
    aligns = {"left": TA_LEFT, "center": TA_CENTER, "right": TA_RIGHT, "justify": TA_JUSTIFY}
    for index, block in enumerate(parse_rich_blocks(value)):
        size = BLOCK_SIZES.get(block.kind, 9.0)
        style = ParagraphStyle(f"DocumentaRich{index}", parent=base, fontSize=size,
                               leading=size * 1.35, alignment=aligns.get(block.align, TA_LEFT),
                               spaceAfter=5)
        parts = []
        if block.list_kind: parts.append("• " if block.list_kind == "ul" else "1. ")
        for item in block.runs:
            text = html.escape(item.text).replace("\n", "<br/>")
            if item.bold: text = f"<b>{text}</b>"
            if item.italic: text = f"<i>{text}</i>"
            if item.underline: text = f"<u>{text}</u>"
            font_attrs = []
            if item.font:
                pdf_font = "Times-Roman" if item.font in {"Georgia", "Times New Roman"} else "Helvetica"
                font_attrs.append(f'name="{pdf_font}"')
            if item.size: font_attrs.append(f'size="{item.size}"')
            if item.color and item.color.startswith("#"): font_attrs.append(f'color="{item.color}"')
            if font_attrs: text = f"<font {' '.join(font_attrs)}>{text}</font>"
            parts.append(text)
        story.append(Paragraph("".join(parts) or " ", style))
        story.append(Spacer(1, 2))
    return story
