"""Shared inbound-email body extraction + cleanup for the Alexandria inbox parsers.

principal-inbox, federation, and team-digests all pull the body out of a raw inbound
message and hand it to an LLM (or an envelope parser). Raw email is noise — quoted reply
chains, signatures, HTML-only bodies, odd charsets — so this gives all three ONE robust
extractor, so the parser sees signal:

  body_text(msg)     — the best plain-text body: the first text/plain part (charset-decoded),
                       else HTML converted to text, else the raw payload. Attachments skipped.
  strip_quotes(t)    — drop a trailing reply-quote chain ("On … wrote:" / a leading ">"), so a
                       reply-to-digest doesn't flood the parser with quoted history.
  strip_signature(t) — drop a trailing signature after the RFC 3676 "-- " delimiter.
  clean(t)           — strip_quotes then strip_signature.
  reply_text(msg)    — body_text + clean: the entry point for a human reply/forward (PARSE).
  capture_text(msg)  — body_text + de-signature only, KEEPING any quoted/forwarded chain: the
                       entry point for CAPTURING an emailed note verbatim (a note may BE a
                       forward, and that context is exactly what we want to file).
  attachments(msg)   — [(filename, bytes, content_type)] for every file part (PDF, deck,
                       image, doc), so an emailed document is persisted into the vault.

Pure stdlib; loaded via the daily-digest ``_load()`` shim (no package imports), so it must
stay import-free of the rest of the vault.
"""
from __future__ import annotations

import re
from email.header import decode_header, make_header
from html import unescape
from html.parser import HTMLParser


# ---- HTML → text -----------------------------------------------------------
class _Textractor(HTMLParser):
    _DROP = {"script", "style", "head", "title"}
    _BREAK = {"br", "p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote"}

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self._chunks: list = []
        self._skip = 0

    def handle_starttag(self, tag, attrs):
        if tag in self._DROP:
            self._skip += 1
        elif tag in self._BREAK:
            self._chunks.append("\n")

    def handle_endtag(self, tag):
        if tag in self._DROP and self._skip:
            self._skip -= 1
        elif tag in self._BREAK:
            self._chunks.append("\n")

    def handle_data(self, data):
        if not self._skip:
            self._chunks.append(data)

    def text(self) -> str:
        out = "".join(self._chunks)
        out = re.sub(r"[ \t]+\n", "\n", out)
        out = re.sub(r"\n{3,}", "\n\n", out)
        return out.strip()


def html_to_text(html: str) -> str:
    """Best-effort HTML → readable plain text (block tags → newlines; script/style dropped)."""
    parser = _Textractor()
    try:
        parser.feed(html)
        return parser.text()
    except Exception:
        # Never fail on malformed HTML — fall back to a crude tag strip.
        return unescape(re.sub(r"(?is)<[^>]+>", " ", html)).strip()


# ---- MIME body extraction --------------------------------------------------
def _decode(part) -> str:
    payload = part.get_payload(decode=True)
    if payload is None:
        raw = part.get_payload()
        return raw if isinstance(raw, str) else ""
    charset = part.get_content_charset() or "utf-8"
    try:
        return payload.decode(charset, "replace")
    except (LookupError, TypeError):
        return payload.decode("utf-8", "replace")


def _is_attachment(part) -> bool:
    return "attachment" in str(part.get("Content-Disposition") or "").lower()


def body_text(msg) -> str:
    """Best plain-text body of an ``email.message.Message``: the first ``text/plain`` part wins;
    else ``text/html`` converted to text; else the raw payload. Attachment parts are skipped."""
    plain = html = None
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_maintype() == "multipart" or _is_attachment(part):
                continue
            ctype = part.get_content_type()
            if ctype == "text/plain" and plain is None:
                plain = _decode(part)
            elif ctype == "text/html" and html is None:
                html = _decode(part)
    elif msg.get_content_type() == "text/html":
        html = _decode(msg)
    else:
        plain = _decode(msg)
    if plain and plain.strip():
        return plain
    if html and html.strip():
        return html_to_text(html)
    return (plain or "").strip()


# ---- reply cleanup ---------------------------------------------------------
_ON_WROTE = re.compile(r"^\s*On\b.*\bwrote:\s*$", re.I)   # Gmail / Apple Mail reply attribution
_QUOTED = re.compile(r"^\s*>")                             # a ">"-prefixed quoted line
_SIG = re.compile(r"^--\s?$")                             # RFC 3676 signature delimiter


def strip_quotes(text: str) -> str:
    """Drop a trailing reply-quote chain (top-posting assumed). Never returns empty — if the
    message is entirely quoted (e.g. a bare forward), the original text is kept."""
    lines = text.replace("\r\n", "\n").split("\n")
    cut = len(lines)
    for i, line in enumerate(lines):
        if _ON_WROTE.match(line) or _QUOTED.match(line):
            cut = i
            break
    kept = "\n".join(lines[:cut]).strip()
    return kept if kept else text.strip()


def strip_signature(text: str) -> str:
    """Drop a trailing signature after the standard '-- ' delimiter — conservative: only the
    RFC 3676 marker, never a heuristic guess."""
    lines = text.replace("\r\n", "\n").split("\n")
    for i, line in enumerate(lines):
        if _SIG.match(line):
            kept = "\n".join(lines[:i]).strip()
            return kept if kept else text.strip()
    return text.strip()


def clean(text: str) -> str:
    return strip_signature(strip_quotes(text))


def reply_text(msg) -> str:
    """Extract + clean the body of a human reply/forward: best part → de-quote → de-signature."""
    return clean(body_text(msg))


def capture_text(msg) -> str:
    """The full readable body for CAPTURE (vs ``reply_text`` for parsing) — LOSSLESS on
    purpose: the best-part body with attachments excluded, but NOTHING else removed. An
    emailed note may itself be a forward, so quoted/forwarded chains are kept, and we don't
    risk a heuristic signature-strip dropping real content. The caller scrubs the passphrase
    (hash-matched) before this text ever lands in the vault."""
    return body_text(msg)


# ---- attachments -----------------------------------------------------------
def _decode_filename(raw: str) -> str:
    """Decode an RFC 2047 encoded-word filename (=?utf-8?…?=) to plain text; a no-op on a
    plain ASCII name. Never raises — a bad header falls back to the raw string."""
    if not raw:
        return ""
    try:
        return str(make_header(decode_header(raw)))
    except Exception:
        return raw


def attachments(msg):
    """Return ``[(filename, content_bytes, content_type), …]`` for every attachment/file part
    of a message — anything with a filename or an explicit ``attachment`` disposition — so an
    emailed document (PDF, deck, spreadsheet, image, doc) can be persisted into the vault at
    capture time. The ``text/plain`` + ``text/html`` body parts and multipart containers are
    skipped; a part with no decodable payload is skipped. Order is preserved (walk order)."""
    out = []
    if not msg.is_multipart():
        return out
    for part in msg.walk():
        if part.get_content_maintype() == "multipart":
            continue
        fname = _decode_filename(part.get_filename() or "")
        if not fname and not _is_attachment(part):
            continue                                       # a body part, not a file
        if part.get_content_type() in ("text/plain", "text/html") and not fname:
            continue                                       # the body, even if oddly dispositioned
        payload = part.get_payload(decode=True)
        if payload is None:
            continue
        out.append((fname or f"attachment-{len(out) + 1}", payload, part.get_content_type()))
    return out
