"""Document text extraction — turning a file into something the brain can read.

Before this module a binary reached the vault as opaque bytes: a PDF could be
stored, referenced from a note, and then never found again, because `search` and
`grep` read text and there was nothing to read. Extraction is what makes an
uploaded document actually *enrich* a brain rather than sit next to it.

Design rules:

- **Optional dependencies, imported lazily.** ``pypdf``/``python-docx``/
  ``python-pptx`` land in the ``documents`` extra. A brain without them keeps
  working and gets an error that names the exact install, rather than an
  ImportError traceback from three frames down.
- **Stdlib formats always work** — ``.eml``, ``.txt``, ``.md``, ``.csv``,
  ``.html``, ``.json``. Mail archives and note exports are the two things people
  most want to bulk-load, and neither needs a dependency.
- **Never guess silently.** An unsupported or unreadable file returns a refusal
  the caller can show a user, not an empty string that files as a blank note.
"""
import html.parser
import io
import json
import os

TEXT_SUFFIXES = {".txt", ".md", ".markdown", ".text", ".log", ".rst"}
# What each optional format needs, named in the error so the fix is one copy-paste.
_NEEDS = {
    ".pdf": ("pypdf", "pypdf"),
    ".docx": ("docx", "python-docx"),
    ".pptx": ("pptx", "python-pptx"),
}
SUPPORTED = (sorted(TEXT_SUFFIXES) +
             [".csv", ".tsv", ".json", ".html", ".htm", ".eml"] + sorted(_NEEDS))


class ExtractError(RuntimeError):
    """Extraction failed in a way worth showing the user verbatim."""


class UnsupportedFormat(ExtractError):
    pass


def extract_text(data, name="", *, max_chars=None):
    """``(text, meta)`` for one document.

    ``data`` is bytes (or a path). ``name`` supplies the suffix when ``data`` is
    bytes. ``meta`` carries what the format knew about itself — page/slide
    counts, mail headers — so a caller can put real provenance in frontmatter
    instead of just a filename.
    """
    if isinstance(data, (str, os.PathLike)):
        path = os.fspath(data)
        name = name or os.path.basename(path)
        try:
            with open(path, "rb") as fh:
                data = fh.read()
        except OSError as e:
            raise ExtractError(f"cannot read {name or path}: {e}")
    if not isinstance(data, (bytes, bytearray)):
        raise ExtractError("extract_text needs bytes or a path")

    suffix = os.path.splitext(name or "")[1].lower()
    handler = _HANDLERS.get(suffix)
    if handler is None:
        raise UnsupportedFormat(
            f"cannot extract text from {name or 'this file'!r} "
            f"({suffix or 'no extension'}). Supported: {', '.join(SUPPORTED)}")
    text, meta = handler(bytes(data), name)
    text = (text or "").strip()
    if not text:
        raise ExtractError(
            f"{name or 'the file'} produced no text — it may be a scanned image "
            "(no OCR in Alexandria) or an empty document. Nothing was filed.")
    meta = dict(meta or {})
    meta.setdefault("source_name", name)
    meta["chars"] = len(text)
    if max_chars and len(text) > max_chars:
        text = text[:max_chars]
        meta["truncated"] = True
    return text, meta


def _need(suffix):
    mod, dist = _NEEDS[suffix]
    try:
        return __import__(mod)
    except ImportError:
        raise ExtractError(
            f"reading {suffix} files needs the '{dist}' package, which ships in "
            f"Alexandria's optional 'documents' extra. Install it with: "
            f"pip install 'alexandria-vault[documents]'")


# ---- handlers -----------------------------------------------------------------------

def _plain(data, name):
    return data.decode("utf-8", errors="replace"), {"format": "text"}


def _csv(data, name):
    import csv
    raw = data.decode("utf-8", errors="replace")
    delim = "\t" if name.lower().endswith(".tsv") else ","
    rows = list(csv.reader(io.StringIO(raw), delimiter=delim))
    if not rows:
        return "", {"format": "csv"}
    # A markdown table is what the vault stores anyway, and it keeps the header
    # attached to its data — a model routing a bare CSV dump loses that.
    head, body = rows[0], rows[1:]
    out = ["| " + " | ".join(head) + " |",
           "|" + "|".join(["---"] * len(head)) + "|"]
    out += ["| " + " | ".join(c.replace("|", "\\|") for c in r) + " |" for r in body]
    return "\n".join(out), {"format": "csv", "rows": len(body), "columns": len(head)}


def _json(data, name):
    raw = data.decode("utf-8", errors="replace")
    try:
        obj = json.loads(raw)
    except ValueError as e:
        raise ExtractError(f"{name or 'file'} is not valid JSON: {e}")
    return json.dumps(obj, indent=2, ensure_ascii=False), {"format": "json"}


class _Stripper(html.parser.HTMLParser):
    _SKIP = {"script", "style", "head", "meta", "link"}

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.parts, self._skip, self.title = [], 0, ""
        self._in_title = False

    def handle_starttag(self, tag, attrs):
        if tag in self._SKIP:
            self._skip += 1
        self._in_title = tag == "title"
        if tag in ("p", "br", "div", "li", "tr", "h1", "h2", "h3", "h4"):
            self.parts.append("\n")

    def handle_endtag(self, tag):
        if tag in self._SKIP and self._skip:
            self._skip -= 1
        self._in_title = False

    def handle_data(self, d):
        if self._in_title:
            self.title = (self.title + d).strip()
        if not self._skip:
            self.parts.append(d)


def _html(data, name):
    p = _Stripper()
    p.feed(data.decode("utf-8", errors="replace"))
    lines = [ln.strip() for ln in "".join(p.parts).splitlines()]
    return "\n".join(ln for ln in lines if ln), {"format": "html", "title": p.title}


def _eml(data, name):
    """A .eml is what a mail export is made of, and it needs no dependency —
    which makes 'load my mail archive' a stdlib operation."""
    import email
    from email import policy
    msg = email.message_from_bytes(data, policy=policy.default)
    meta = {"format": "eml",
            "subject": str(msg.get("subject", "") or ""),
            "from": str(msg.get("from", "") or ""),
            "to": str(msg.get("to", "") or ""),
            "date": str(msg.get("date", "") or "")}
    body = ""
    try:
        part = msg.get_body(preferencelist=("plain", "html"))
        if part is not None:
            payload = part.get_content()
            body = (payload if part.get_content_subtype() == "plain"
                    else _html(payload.encode("utf-8"), name)[0])
    except (LookupError, ValueError, TypeError):
        body = ""
    if not body:                                  # non-multipart / odd encodings
        raw = msg.get_payload(decode=True)
        body = raw.decode("utf-8", errors="replace") if raw else ""
    header = "\n".join(f"{k}: {meta[k]}" for k in ("subject", "from", "to", "date")
                       if meta[k])
    names = [p.get_filename() for p in msg.walk() if p.get_filename()]
    if names:
        meta["attachments"] = names
    return (header + "\n\n" + body if header else body), meta


def _pdf(data, name):
    pypdf = _need(".pdf")
    try:
        reader = pypdf.PdfReader(io.BytesIO(data))
        if getattr(reader, "is_encrypted", False):
            try:
                reader.decrypt("")           # many PDFs are "encrypted" with no password
            except Exception:
                raise ExtractError(f"{name or 'the PDF'} is password-protected")
        pages = [(p.extract_text() or "") for p in reader.pages]
    except ExtractError:
        raise
    except Exception as e:                   # pypdf raises a wide variety
        raise ExtractError(f"could not read {name or 'the PDF'}: {e}")
    return "\n\n".join(t.strip() for t in pages if t.strip()), {
        "format": "pdf", "pages": len(pages)}


def _docx(data, name):
    docx = _need(".docx")
    try:
        doc = docx.Document(io.BytesIO(data))
    except Exception as e:
        raise ExtractError(f"could not read {name or 'the .docx'}: {e}")
    out = [p.text for p in doc.paragraphs]
    for t in doc.tables:                     # tables carry the facts in most decks/specs
        for row in t.rows:
            cells = [c.text.strip() for c in row.cells]
            if any(cells):
                out.append("| " + " | ".join(cells) + " |")
    return "\n".join(x for x in out if x.strip()), {"format": "docx"}


def _pptx(data, name):
    pptx = _need(".pptx")
    try:
        deck = pptx.Presentation(io.BytesIO(data))
    except Exception as e:
        raise ExtractError(f"could not read {name or 'the .pptx'}: {e}")
    out, n = [], 0
    for i, slide in enumerate(deck.slides, 1):
        n = i
        out.append(f"## Slide {i}")
        for shape in slide.shapes:
            if getattr(shape, "has_text_frame", False) and shape.text_frame.text.strip():
                out.append(shape.text_frame.text.strip())
    return "\n\n".join(out), {"format": "pptx", "slides": n}


_HANDLERS = {s: _plain for s in TEXT_SUFFIXES}
_HANDLERS.update({".csv": _csv, ".tsv": _csv, ".json": _json,
                  ".html": _html, ".htm": _html, ".eml": _eml,
                  ".pdf": _pdf, ".docx": _docx, ".pptx": _pptx})
