"""HTML cleanup — web clipper noise removal and readable text extraction.

Two responsibilities, both previously duplicated across the codebase:

1. :func:`strip_web_clipper_noise` — line-level cleanup of markdown that
   was produced by a web clipper (Obsidian Clipper, Readability, etc.).
   Removes iframe embeds, HTML comment banners, and leading blank lines
   so downstream LLM processing sees the article body first, not ad
   nav/cookie banners. Previously lived as ``_strip_web_noise`` in
   ``kb/service.py``.

2. :func:`extract_readable_text` — raw HTML → plain text extraction using
   the stdlib ``HTMLParser``. Drops script, style, nav, header, footer,
   aside, and other non-content subtrees. Previously lived as
   ``_TextExtractor`` + ``_html_to_text`` in ``integrations/browser.py``.

Both functions are pure and take/return strings, so they are trivial to
chain and test. Neither performs network I/O.
"""

from __future__ import annotations

import re
from html.parser import HTMLParser

# ── Web clipper noise stripping ──────────────────────────────────────────────

# CDN-hosted article images are kept (they are usually real content).
_CDN_IMAGE = re.compile(r"^!\[.*\]\(https?://static\.")


def strip_web_clipper_noise(content: str) -> str:
    """Strip common web-clipper noise from already-clipped markdown.

    Obsidian Clipper and similar tools often capture ad banners, nav
    elements, cookie notices, and sidebar content before the actual
    article body. This removes those artifacts so downstream processors
    (LLM summarizers, entity extractors) see the real content first.

    Rules:
    - Drop leading blank lines before any real content.
    - Drop iframe embeds (ads, trackers, interactive widgets).
    - Drop single-line HTML comment blocks.
    - Preserve CDN-hosted article images (``![alt](https://static…)``).
    - Preserve everything else verbatim.

    Idempotent.
    """
    if not content:
        return ""
    lines = content.split("\n")
    cleaned: list[str] = []
    for line in lines:
        stripped = line.strip()
        # Skip empty lines at the very beginning of the document.
        if not cleaned and not stripped:
            continue
        # Drop iframe embeds.
        if stripped.startswith("<iframe") or stripped.startswith("</iframe"):
            continue
        # Preserve CDN-hosted article images.
        if _CDN_IMAGE.match(stripped):
            cleaned.append(line)
            continue
        # Drop single-line HTML comment banners.
        if stripped.startswith("<!--") and stripped.endswith("-->"):
            continue
        cleaned.append(line)
    return "\n".join(cleaned)


# ── Raw HTML → plain text extraction ─────────────────────────────────────────

# Tags whose entire subtree should be dropped (no text extracted).
_STRIP_SUBTREE_TAGS = frozenset({  # arch:deterministic — HTML semantics (script/style/nav/etc. are universal structural noise regardless of content); not a content-policy decision
    "script", "style", "nav", "header", "footer", "aside", "noscript",
    "svg", "iframe", "form", "button",
})

# Tags that should trigger a newline when opened (paragraph-like boundaries).
_NEWLINE_ON_OPEN_TAGS = frozenset({  # arch:deterministic — HTML block-level elements that render as paragraph boundaries; W3C semantic fact, not a user preference
    "br", "p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
})

# Whitespace normalization:
#   - collapse runs of non-newline whitespace to a single space
#   - collapse 3+ consecutive newlines to 2 (preserve paragraph gaps)
_NON_NEWLINE_WS = re.compile(r"[^\S\n]+")
_MANY_NEWLINES = re.compile(r"\n{3,}")


class _HTMLTextExtractor(HTMLParser):
    """Minimal HTML → text converter that drops noise subtrees."""

    def __init__(self) -> None:
        super().__init__()
        self._pieces: list[str] = []
        self._skip_depth = 0

    def handle_starttag(self, tag, attrs):  # type: ignore[override]
        if tag in _STRIP_SUBTREE_TAGS:
            self._skip_depth += 1
        elif tag in _NEWLINE_ON_OPEN_TAGS:
            if self._skip_depth == 0:
                self._pieces.append("\n")

    def handle_endtag(self, tag):  # type: ignore[override]
        if tag in _STRIP_SUBTREE_TAGS:
            self._skip_depth = max(0, self._skip_depth - 1)

    def handle_data(self, data):  # type: ignore[override]
        if self._skip_depth == 0:
            self._pieces.append(data)

    def get_text(self) -> str:
        raw = "".join(self._pieces)
        raw = _NON_NEWLINE_WS.sub(" ", raw)
        raw = _MANY_NEWLINES.sub("\n\n", raw)
        return raw.strip()


def extract_readable_text(html: str) -> str:
    """Convert raw HTML to readable plain text.

    Drops entire subtrees of noise elements (script, style, nav, header,
    footer, aside, svg, iframe, form, button, noscript). Inserts
    newlines on paragraph-like tags so block structure is preserved.
    Collapses whitespace runs without losing paragraph gaps.

    Returns an empty string if *html* is empty. Never raises — malformed
    HTML is handled gracefully by the stdlib parser.
    """
    if not html:
        return ""
    parser = _HTMLTextExtractor()
    try:
        parser.feed(html)
    except Exception:
        # Stdlib HTMLParser is very forgiving but guard against
        # pathological input (e.g., huge unclosed tags) just in case.
        pass
    return parser.get_text()
