"""Document reader -- parse documents into clean markdown.

Supports PDF, DOCX, XLSX, CSV, TXT, Markdown, and LaTeX formats.
Single entry point ``parse_document()`` dispatches by MIME type.
All sync parsers run via ``asyncio.to_thread()`` for non-blocking I/O.

Optional dependencies:
- ``pdf``: PyMuPDF (fitz) for PDF parsing
- ``docx``: python-docx for DOCX parsing
- ``xlsx``: openpyxl for XLSX parsing
- ``ocr``: pytesseract + pdf2image for scanned PDF OCR
"""

from __future__ import annotations

import asyncio
import base64
import csv
import io
import mimetypes
import re
from dataclasses import dataclass, field
from typing import Any

from langchain_core.tools import BaseTool, tool
from pydantic import BaseModel, Field

from threetears.agent.tools.base_tool import MCPToolDefinition, TearsTool, ToolResult
from threetears.observe import get_logger, traced

__all__ = [
    "DocumentResult",
    "DocumentSection",
    "OcrConfig",
    "ParseDocumentInput",
    "ParseDocumentTool",
    "create_parse_document_tool",
    "detect_mime_from_filename",
    "parse_document",
    "render_pdf_pages_to_images",
]

log = get_logger(__name__)


# -- Data types ---------------------------------------------------------------


@dataclass
class OcrConfig:
    """Configuration for OCR fallback on scanned PDF pages."""

    enabled: bool = False
    language: str = "eng"
    #: Tesseract Page Segmentation Mode. Default 4 ("assume a single column of
    #: text of variable sizes") -- live-researched and tested (scrape-task-05,
    #: 2026-07-15): PSM 3 (Tesseract's own upstream default, "fully automatic
    #: page segmentation") can misclassify a narrow, sparse column (e.g. a
    #: numeric "Number of Employees" column beside a "Job Title" column) as
    #: non-text and drop it entirely -- a documented PSM-3 failure mode, not
    #: ordinary misrecognition. PSM 4 is the commonly-recommended mode for
    #: exactly this "OCR column/table data" shape and is this module's own
    #: default for that reason, but it explicitly assumes single-column text --
    #: a real risk for a genuinely multi-column document (a form, a
    #: side-by-side layout) parsed by some OTHER caller of this shared,
    #: general-purpose tool. Override per call (e.g. ``psm=3`` or ``psm=6``)
    #: rather than living with one target's own evidence as an unconditional
    #: global default.
    psm: int = 4


@dataclass
class DocumentSection:
    """A section within a parsed document."""

    heading: str | None
    content: str
    page_number: int | None
    level: int  # heading level 1-6, 0 for body


@dataclass
class DocumentResult:
    """Result of parsing a document into markdown."""

    text: str  # Full document as clean markdown
    title: str | None
    page_count: int | None
    word_count: int
    was_ocr: bool
    metadata: dict[str, Any] = field(default_factory=dict)
    sections: list[DocumentSection] = field(default_factory=list)


# -- MIME type dispatch -------------------------------------------------------

_MIME_PARSERS: dict[str, str] = {
    "application/pdf": "pdf",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
    "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
    "text/csv": "csv",
    "text/plain": "txt",
    "text/markdown": "markdown",
    "application/x-tex": "latex",
    "application/x-latex": "latex",
}


def detect_mime_from_filename(filename: str) -> str | None:
    """Detect MIME type from filename extension."""
    ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
    ext_map = {
        "pdf": "application/pdf",
        "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
        "csv": "text/csv",
        "txt": "text/plain",
        "md": "text/markdown",
        "tex": "application/x-tex",
        "latex": "application/x-latex",
    }
    return ext_map.get(ext) or mimetypes.guess_type(filename)[0]


@traced()
async def parse_document(
    data: bytes,
    mime_type: str,
    filename: str | None = None,
    ocr_config: OcrConfig | None = None,
    *,
    merge_wrapped_table_rows: bool = False,
) -> DocumentResult:
    """Parse document bytes into markdown.

    Dispatches to format-specific parsers based on MIME type.
    Falls back to filename extension detection if MIME type is unknown.

    :param merge_wrapped_table_rows: for PDFs only, opt in to
        :func:`_merge_wrapped_table_rows`'s continuation-row stitching -- see
        :func:`_extract_pdf_tables`'s own *merge_wrapped_rows* docstring for why
        this defaults off. No-op for every other document format.
    :ptype merge_wrapped_table_rows: bool
    """
    parser_key = _MIME_PARSERS.get(mime_type)

    # Fallback to filename extension
    if parser_key is None and filename:
        detected = detect_mime_from_filename(filename)
        if detected:
            parser_key = _MIME_PARSERS.get(detected)

    if parser_key is None:
        return DocumentResult(
            text=f"[Unsupported document type: {mime_type}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )

    parsers = {
        "pdf": _parse_pdf,
        "docx": _parse_docx,
        "xlsx": _parse_xlsx,
        "csv": _parse_csv,
        "txt": _parse_txt,
        "markdown": _parse_markdown,
        "latex": _parse_latex,
    }

    parser_fn = parsers[parser_key]
    ocr = ocr_config or OcrConfig()
    if parser_key == "pdf":
        return await asyncio.to_thread(_parse_pdf, data, filename, ocr, merge_wrapped_table_rows)
    return await asyncio.to_thread(parser_fn, data, filename, ocr)


# -- PDF parser ---------------------------------------------------------------


def _parse_pdf(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
    merge_wrapped_table_rows: bool = False,
) -> DocumentResult:
    """Parse PDF using PyMuPDF with OCR fallback for scanned pages."""
    try:
        import fitz  # PyMuPDF

        doc = fitz.open(stream=data, filetype="pdf")
        try:
            sections: list[DocumentSection] = []
            full_parts: list[str] = []
            was_ocr = False
            title = doc.metadata.get("title") or filename

            metadata = {}
            for key in ("author", "subject", "keywords", "creator", "producer"):
                val = doc.metadata.get(key)
                if val:
                    metadata[key] = val

            for page_num in range(len(doc)):
                page = doc[page_num]
                text = page.get_text("text")

                # OCR fallback for pages with very little text
                if len(text.strip()) < 50 and ocr.enabled:
                    ocr_text = _ocr_page(data, page_num, ocr.language, ocr.psm)
                    if ocr_text:
                        text = ocr_text
                        was_ocr = True

                # Try to extract tables
                tables_md = _extract_pdf_tables(page, merge_wrapped_rows=merge_wrapped_table_rows)

                # Font-size heuristic for headings
                page_sections = _extract_pdf_headings(page, text, page_num + 1)
                if page_sections:
                    sections.extend(page_sections)
                else:
                    sections.append(
                        DocumentSection(
                            heading=None,
                            content=text.strip(),
                            page_number=page_num + 1,
                            level=0,
                        )
                    )

                page_text = text.strip()
                if tables_md:
                    page_text += "\n\n" + tables_md
                if page_text:
                    full_parts.append(page_text)

            page_count = len(doc)
        finally:
            doc.close()
        full_text = "\n\n---\n\n".join(full_parts)
        word_count = len(full_text.split())

        return DocumentResult(
            text=full_text,
            title=title if title else None,
            page_count=page_count,
            word_count=word_count,
            was_ocr=was_ocr,
            metadata=metadata,
            sections=sections,
        )

    except Exception as exc:
        log.error(
            "PDF parsing failed",
            extra={"extra_data": {"error": str(exc)}},
        )
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


def _merge_wrapped_table_rows(rows: list[list[Any]]) -> list[list[Any]]:
    """Stitch a PyMuPDF ``find_tables()`` continuation row back onto its parent record.

    Live-found (scrape-task-07 follow-up, Mississippi's real quarterly WARN Act PDF):
    ``find_tables()`` gets a table's column boundaries genuinely right, but when a
    long-text cell (a NAICS code description, a free-text "Reason/Comments" column)
    word-wraps across multiple lines *within one logical row* in the source PDF,
    ``table.extract()`` emits each wrapped line as its OWN separate row -- one real
    record ends up split across up to 7 physical rows, only the first of which
    carries the record's other column values (Date/Company/Type/Count); every
    following row has those columns empty and only a fragment of the wrapped
    column's text.

    A row is treated as a continuation of the row immediately above it when its
    OWN first column (*rows[i][0]*, e.g. "Date of Notice") is empty/whitespace.
    **Not safe as a blanket default** -- an independent review correctly flagged
    that a table whose first column is LEGITIMATELY blank on some genuinely
    separate record (a grouped layout: one date/company shown once, several real
    distinct line-items below it) would get silently fused into its neighbor,
    trading visible fragmentation for invisible corruption, worse than the bug
    this fixes. Confirmed only against Mississippi's own real table -- callers
    opt in explicitly (see :func:`_extract_pdf_tables`'s own *merge_wrapped_rows*
    parameter) rather than this becoming an unconditional change for every
    document-backed target sharing this general-purpose tool (same "don't make a
    one-target finding a global default" lesson as this same module's own
    ``OcrConfig.psm`` and ``force_images``, scrape-task-05/07).

    Continuation cells are appended (space-joined, mirroring ``threetears.scrape.
    drivers.document._merge_broken_pipe_rows``'s own word-wrap rejoin) onto the
    parent row's SAME column position; a continuation row's own first column
    (always empty by definition) contributes nothing. A continuation row wider
    than its parent has its excess cells logged and dropped, not silently lost
    with no trace -- a malformed-input edge case, not one seen in the real data
    this was verified against.

    Deliberately conservative: the header row (*rows[0]*) and the first data row
    (*rows[1]*, which has no prior row to merge into even if its own first column
    happens to be empty) are never merged into anything.

    :param rows: ``table.extract()``'s raw row list, header first
    :ptype rows: list[list[Any]]
    :return: the same rows with any continuation rows merged into their parent,
        header and structure otherwise unchanged; never mutates *rows* or any
        row within it
    :rtype: list[list[Any]]
    """
    if len(rows) <= 2:  # header + at most one data row -- nothing to merge
        return rows
    merged = [list(rows[0]), list(rows[1])]  # copy every row -- never touch the caller's own lists
    for row in rows[2:]:
        is_continuation = not (row and str(row[0] or "").strip())
        if is_continuation:
            parent = merged[-1]
            for i, cell in enumerate(row):
                if i == 0 or not cell:
                    continue
                if i >= len(parent):
                    log.debug("PDF table row-merge: dropping out-of-bounds continuation cell %r at index %d", cell, i)
                    continue
                parent_cell = str(parent[i]) if parent[i] else ""
                fragment = str(cell).strip()
                parent[i] = f"{parent_cell} {fragment}".strip() if parent_cell else fragment
        else:
            merged.append(list(row))
    return merged


def _extract_pdf_tables(page: Any, *, merge_wrapped_rows: bool = False) -> str:
    """Extract tables from a PDF page as markdown.

    :param merge_wrapped_rows: opt in to :func:`_merge_wrapped_table_rows`'s
        continuation-row stitching -- off by default, since that heuristic is
        confirmed safe only for the one real table it was built against (see its
        own docstring) and this function is shared by every document-backed
        target, not scoped to any one of them.
    :ptype merge_wrapped_rows: bool
    """
    try:
        tables = page.find_tables()
        if not tables or not tables.tables:
            return ""

        parts = []
        for table in tables.tables:
            rows = table.extract()
            if not rows:
                continue
            if merge_wrapped_rows:
                rows = _merge_wrapped_table_rows(rows)
            # Build markdown table
            header = rows[0]
            header_line = "| " + " | ".join(str(c) if c else "" for c in header) + " |"
            sep_line = "| " + " | ".join("---" for _ in header) + " |"
            body_lines = []
            for row in rows[1:]:
                cells = [str(c) if c else "" for c in row]
                body_lines.append("| " + " | ".join(cells) + " |")
            parts.append("\n".join([header_line, sep_line] + body_lines))

        return "\n\n".join(parts)
    except Exception as exc:  # noqa: BLE001 -- prawduct:allow prawduct/broad-except -- honest-empty
        # contract: a table-extraction failure must never fail the whole document
        # parse; the plain (tableless) text this feeds into is still usable on its
        # own, same posture as this module's other PDF-page-rendering fallbacks.
        log.debug("PDF table extraction failed: %s", exc)
        return ""


def _extract_pdf_headings(
    page: Any,
    text: str,
    page_number: int,
) -> list[DocumentSection]:
    """Use font-size heuristic to identify headings on a PDF page."""
    try:
        blocks = page.get_text("dict")["blocks"]
        if not blocks:
            return []

        font_sizes: list[float] = []
        for block in blocks:
            if block.get("type") == 0:
                for line in block.get("lines", []):
                    for span in line.get("spans", []):
                        font_sizes.append(span.get("size", 12))

        if not font_sizes:
            return []

        median_size = sorted(font_sizes)[len(font_sizes) // 2]

        sections: list[DocumentSection] = []
        current_heading: str | None = None
        current_level = 0
        current_text: list[str] = []

        for line in text.split("\n"):
            line_text = line.strip()
            if not line_text:
                continue

            is_heading = False
            for block in blocks:
                if block.get("type") == 0:
                    for bline in block.get("lines", []):
                        for span in bline.get("spans", []):
                            if span.get("text", "").strip() == line_text and span.get("size", 12) > median_size * 1.2:
                                is_heading = True

            if is_heading:
                if current_text or current_heading:
                    sections.append(
                        DocumentSection(
                            heading=current_heading,
                            content="\n".join(current_text).strip(),
                            page_number=page_number,
                            level=current_level,
                        )
                    )
                    current_text = []

                if any(
                    span.get("text", "").strip() == line_text and span.get("size", 12) > median_size * 1.5
                    for block in blocks
                    if block.get("type") == 0
                    for bline in block.get("lines", [])
                    for span in bline.get("spans", [])
                ):
                    current_level = 1
                else:
                    current_level = 3
                current_heading = line_text
            else:
                current_text.append(line_text)

        # Flush remaining
        if current_text or current_heading:
            sections.append(
                DocumentSection(
                    heading=current_heading,
                    content="\n".join(current_text).strip(),
                    page_number=page_number,
                    level=current_level,
                )
            )

        return sections

    except Exception:
        return []


def _ocr_page(pdf_data: bytes, page_num: int, language: str, psm: int) -> str | None:
    """OCR a single PDF page using pytesseract + pdf2image.

    :param psm: Tesseract Page Segmentation Mode -- see :attr:`OcrConfig.psm`'s
        own docstring for why this is caller-configurable, not a hardcoded
        module constant (one target's own evidence for PSM 4 shouldn't become
        an unconditional default for every consumer of this shared tool).
    :ptype psm: int
    """
    try:
        from pdf2image import convert_from_bytes
        import pytesseract

        images = convert_from_bytes(
            pdf_data,
            first_page=page_num + 1,
            last_page=page_num + 1,
            dpi=300,
        )
        if not images:
            return None

        text = pytesseract.image_to_string(images[0], lang=language, config=f"--psm {psm}")
        return text.strip() if text.strip() else None

    except Exception as exc:
        log.warning(
            "OCR failed for page",
            extra={"extra_data": {"page": page_num, "error": str(exc)}},
        )
        return None


#: Default page cap for :func:`render_pdf_pages_to_images` -- a vision-capable model
#: call bills/latency-scales per image, and a real WARN Act letter's substantive
#: content (the letter body + any table) live in the first few pages; later pages are
#: typically signatures/boilerplate/cc lists. Not a hard technical limit, a cost bound
#: -- revisit if a real target's own data lives deeper than this.
_DEFAULT_MAX_VISION_PAGES = 3


def render_pdf_pages_to_images(
    pdf_data: bytes, *, dpi: int = 150, max_pages: int = _DEFAULT_MAX_VISION_PAGES
) -> list[bytes]:
    """Render a PDF's first *max_pages* pages to PNG image bytes, one per page.

    Shared by :mod:`threetears.scrape.drivers.document`'s vision-extraction path
    (scrape-task-06) -- reuses the exact ``pdf2image`` import already established
    by :func:`_ocr_page`, rather than a second package taking on the same optional
    ``ocr`` extra dependency.

    :param pdf_data: raw PDF bytes
    :ptype pdf_data: bytes
    :param dpi: render resolution -- 150 balances legibility against image payload
        size for a vision model call (300, ``_ocr_page``'s own DPI, is Tesseract's
        own sharper-is-better preference; a vision model reasons over the whole
        image holistically and doesn't need that much, live-verified sufficient)
    :ptype dpi: int
    :param max_pages: cap on how many leading pages to render
    :ptype max_pages: int
    :return: one PNG-encoded image per rendered page, in page order; empty list on
        any failure (never raises -- mirrors ``_ocr_page``'s own honest-empty contract)
    :rtype: list[bytes]
    """
    try:
        import io

        from pdf2image import convert_from_bytes

        images = convert_from_bytes(pdf_data, dpi=dpi, first_page=1, last_page=max_pages)
    except Exception as exc:  # noqa: BLE001 -- prawduct:allow prawduct/broad-except -- honest-empty contract, mirrors _ocr_page's own: a rendering failure must never crash the caller, only degrade to "no images"
        log.warning("PDF page image rendering failed", extra={"extra_data": {"error": str(exc)}})
        return []

    encoded: list[bytes] = []
    for image in images:
        buf = io.BytesIO()
        image.save(buf, format="PNG")
        encoded.append(buf.getvalue())
    return encoded


# -- DOCX parser --------------------------------------------------------------


def _parse_docx(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
) -> DocumentResult:
    """Parse DOCX using python-docx."""
    try:
        import io

        from docx import Document

        doc = Document(io.BytesIO(data))
        sections: list[DocumentSection] = []
        full_parts: list[str] = []
        title = None

        # Extract core properties
        metadata: dict[str, Any] = {}
        try:
            props = doc.core_properties
            if props.title:
                title = props.title
            for attr in ("author", "subject", "keywords", "category"):
                val = getattr(props, attr, None)
                if val:
                    metadata[attr] = val
        except Exception:
            pass

        current_heading: str | None = None
        current_level = 0
        current_text: list[str] = []

        for para in doc.paragraphs:
            style_name = (para.style.name or "").lower() if para.style else ""

            # Detect headings
            if style_name.startswith("heading"):
                # Flush current section
                if current_text or current_heading:
                    sections.append(
                        DocumentSection(
                            heading=current_heading,
                            content="\n".join(current_text).strip(),
                            page_number=None,
                            level=current_level,
                        )
                    )
                    full_parts.extend(current_text)
                    current_text = []

                # Parse heading level
                try:
                    level = int(style_name.replace("heading", "").strip())
                except ValueError:
                    level = 1

                current_heading = para.text.strip()
                current_level = level
                md_heading = "#" * level + " " + current_heading
                full_parts.append(md_heading)
            else:
                # Process inline formatting
                line = _docx_para_to_markdown(para)
                if line:
                    current_text.append(line)

        # Flush remaining
        if current_text or current_heading:
            sections.append(
                DocumentSection(
                    heading=current_heading,
                    content="\n".join(current_text).strip(),
                    page_number=None,
                    level=current_level,
                )
            )
            full_parts.extend(current_text)

        # Extract tables
        for table in doc.tables:
            table_md = _docx_table_to_markdown(table)
            if table_md:
                full_parts.append(table_md)

        full_text = "\n\n".join(p for p in full_parts if p.strip())
        word_count = len(full_text.split())

        return DocumentResult(
            text=full_text,
            title=title or filename,
            page_count=None,
            word_count=word_count,
            was_ocr=False,
            metadata=metadata,
            sections=sections,
        )

    except Exception as exc:
        log.error(
            "DOCX parsing failed",
            extra={"extra_data": {"error": str(exc)}},
        )
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


def _docx_para_to_markdown(para: Any) -> str:
    """Convert a DOCX paragraph to markdown with inline formatting."""
    parts = []
    for run in para.runs:
        text = run.text
        if not text:
            continue
        if run.bold and run.italic:
            text = f"***{text}***"
        elif run.bold:
            text = f"**{text}**"
        elif run.italic:
            text = f"*{text}*"
        parts.append(text)
    return "".join(parts)


def _docx_table_to_markdown(table: Any) -> str:
    """Convert a DOCX table to markdown."""
    rows = []
    for row in table.rows:
        cells = [cell.text.strip() for cell in row.cells]
        rows.append("| " + " | ".join(cells) + " |")

    if len(rows) < 1:
        return ""

    # Add separator after header
    header_cells = len(table.rows[0].cells)
    sep = "| " + " | ".join("---" for _ in range(header_cells)) + " |"
    return rows[0] + "\n" + sep + "\n" + "\n".join(rows[1:])


# -- XLSX parser --------------------------------------------------------------


def _parse_xlsx(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
) -> DocumentResult:
    """Parse XLSX using openpyxl."""
    try:
        import io

        from openpyxl import load_workbook

        wb = load_workbook(io.BytesIO(data), data_only=True, read_only=True)
        try:
            sections: list[DocumentSection] = []
            full_parts: list[str] = []

            metadata: dict[str, Any] = {"sheet_count": len(wb.sheetnames)}

            for sheet_name in wb.sheetnames:
                ws = wb[sheet_name]
                heading = f"## {sheet_name}"
                full_parts.append(heading)

                # Read all rows
                all_rows: list[list[str]] = []
                for row in ws.iter_rows(values_only=True):
                    cells = [str(c) if c is not None else "" for c in row]
                    # Skip completely empty rows
                    if any(c.strip() for c in cells):
                        all_rows.append(cells)

                if not all_rows:
                    sections.append(
                        DocumentSection(
                            heading=sheet_name,
                            content="(empty sheet)",
                            page_number=None,
                            level=2,
                        )
                    )
                    continue

                # Build markdown table
                header = all_rows[0]
                header_line = "| " + " | ".join(header) + " |"
                sep_line = "| " + " | ".join("---" for _ in header) + " |"
                body_lines = []
                for row in all_rows[1:]:
                    # Pad or trim to header width
                    padded = row + [""] * (len(header) - len(row))
                    body_lines.append("| " + " | ".join(padded[: len(header)]) + " |")

                table_md = "\n".join([header_line, sep_line] + body_lines)
                full_parts.append(table_md)

                sections.append(
                    DocumentSection(
                        heading=sheet_name,
                        content=table_md,
                        page_number=None,
                        level=2,
                    )
                )
        finally:
            wb.close()
        full_text = "\n\n".join(full_parts)
        word_count = len(full_text.split())

        return DocumentResult(
            text=full_text,
            title=filename,
            page_count=None,
            word_count=word_count,
            was_ocr=False,
            metadata=metadata,
            sections=sections,
        )

    except Exception as exc:
        log.error(
            "XLSX parsing failed",
            extra={"extra_data": {"error": str(exc)}},
        )
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


# -- CSV parser ---------------------------------------------------------------


def _parse_csv(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
) -> DocumentResult:
    """Parse CSV into a single markdown table using the stdlib ``csv`` module.

    Already tabular -- unlike XLSX/DOCX, there's no source-format table
    structure to walk, just rows to read (``csv.reader`` handles RFC 4180
    quoting -- embedded commas/newlines inside quoted fields -- correctly,
    unlike a naive ``line.split(",")``). Mirrors ``_parse_xlsx``'s single-
    table shape: first non-empty row is the header, every row after is
    padded/trimmed to the header's width so a ragged CSV still produces a
    well-formed table.
    """
    try:
        try:
            text = data.decode("utf-8")
        except UnicodeDecodeError:
            text = data.decode("latin-1")

        all_rows: list[list[str]] = []
        for row in csv.reader(io.StringIO(text)):
            if any(cell.strip() for cell in row):
                all_rows.append(row)

        if not all_rows:
            return DocumentResult(
                text="(empty file)",
                title=filename,
                page_count=None,
                word_count=0,
                was_ocr=False,
            )

        header = all_rows[0]
        header_line = "| " + " | ".join(header) + " |"
        sep_line = "| " + " | ".join("---" for _ in header) + " |"
        body_lines = []
        for row in all_rows[1:]:
            padded = row + [""] * (len(header) - len(row))
            body_lines.append("| " + " | ".join(padded[: len(header)]) + " |")

        full_text = "\n".join([header_line, sep_line] + body_lines)
        word_count = len(full_text.split())

        sections = [
            DocumentSection(
                heading=None,
                content=full_text,
                page_number=None,
                level=0,
            )
        ]

        return DocumentResult(
            text=full_text,
            title=filename,
            page_count=None,
            word_count=word_count,
            was_ocr=False,
            sections=sections,
        )

    except Exception as exc:
        log.error(
            "CSV parsing failed",
            extra={"extra_data": {"error": str(exc)}},
        )
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


# -- TXT parser ---------------------------------------------------------------


def _parse_txt(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
) -> DocumentResult:
    """Parse plain text with encoding fallback."""
    try:
        try:
            text = data.decode("utf-8")
        except UnicodeDecodeError:
            text = data.decode("latin-1")

        word_count = len(text.split())

        sections = [
            DocumentSection(
                heading=None,
                content=text,
                page_number=None,
                level=0,
            )
        ]

        return DocumentResult(
            text=text,
            title=filename,
            page_count=None,
            word_count=word_count,
            was_ocr=False,
            sections=sections,
        )

    except Exception as exc:
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


# -- Markdown parser ----------------------------------------------------------


def _parse_markdown(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
) -> DocumentResult:
    """Parse markdown -- direct passthrough since it's already markdown."""
    try:
        try:
            text = data.decode("utf-8")
        except UnicodeDecodeError:
            text = data.decode("latin-1")

        word_count = len(text.split())

        # Extract sections from markdown headings
        sections: list[DocumentSection] = []
        current_heading: str | None = None
        current_level = 0
        current_text: list[str] = []

        for line in text.split("\n"):
            heading_match = re.match(r"^(#{1,6})\s+(.+)$", line)
            if heading_match:
                if current_text or current_heading:
                    sections.append(
                        DocumentSection(
                            heading=current_heading,
                            content="\n".join(current_text).strip(),
                            page_number=None,
                            level=current_level,
                        )
                    )
                    current_text = []

                current_level = len(heading_match.group(1))
                current_heading = heading_match.group(2).strip()
            else:
                current_text.append(line)

        if current_text or current_heading:
            sections.append(
                DocumentSection(
                    heading=current_heading,
                    content="\n".join(current_text).strip(),
                    page_number=None,
                    level=current_level,
                )
            )

        # Try to extract title from first heading
        title = filename
        if sections and sections[0].heading:
            title = sections[0].heading

        return DocumentResult(
            text=text,
            title=title,
            page_count=None,
            word_count=word_count,
            was_ocr=False,
            sections=sections,
        )

    except Exception as exc:
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


# -- LaTeX parser -------------------------------------------------------------


def _parse_latex(
    data: bytes,
    filename: str | None = None,
    ocr: OcrConfig = OcrConfig(),
) -> DocumentResult:
    """Parse LaTeX into markdown using regex transformations."""
    try:
        try:
            text = data.decode("utf-8")
        except UnicodeDecodeError:
            text = data.decode("latin-1")

        # Extract title before stripping preamble
        title_match = re.search(r"\\title\{([^}]+)\}", text)
        title = title_match.group(1) if title_match else filename

        # Strip preamble
        doc_start = re.search(r"\\begin\{document\}", text)
        doc_end = re.search(r"\\end\{document\}", text)
        if doc_start:
            text = text[doc_start.end() :]
        if doc_end:
            text = text[: doc_end.start()]

        # Remove common preamble commands
        text = re.sub(r"\\(usepackage|documentclass|author|date|maketitle)\{[^}]*\}", "", text)
        text = re.sub(r"\\(usepackage|documentclass)\[[^\]]*\]\{[^}]*\}", "", text)

        # Section headings
        text = re.sub(r"\\section\*?\{([^}]+)\}", r"# \1", text)
        text = re.sub(r"\\subsection\*?\{([^}]+)\}", r"## \1", text)
        text = re.sub(r"\\subsubsection\*?\{([^}]+)\}", r"### \1", text)
        text = re.sub(r"\\paragraph\*?\{([^}]+)\}", r"#### \1", text)

        # Inline formatting
        text = re.sub(r"\\textbf\{([^}]+)\}", r"**\1**", text)
        text = re.sub(r"\\textit\{([^}]+)\}", r"*\1*", text)
        text = re.sub(r"\\emph\{([^}]+)\}", r"*\1*", text)
        text = re.sub(r"\\texttt\{([^}]+)\}", r"`\1`", text)
        text = re.sub(r"\\underline\{([^}]+)\}", r"\1", text)

        # Code blocks
        text = re.sub(
            r"\\begin\{verbatim\}(.*?)\\end\{verbatim\}",
            r"```\n\1\n```",
            text,
            flags=re.DOTALL,
        )
        text = re.sub(
            r"\\begin\{lstlisting\}(.*?)\\end\{lstlisting\}",
            r"```\n\1\n```",
            text,
            flags=re.DOTALL,
        )

        # Lists
        text = re.sub(r"\\begin\{(itemize|enumerate)\}", "", text)
        text = re.sub(r"\\end\{(itemize|enumerate)\}", "", text)
        text = re.sub(r"\\item\s*", "- ", text)

        # Remove remaining LaTeX commands (simple ones)
        text = re.sub(r"\\[a-zA-Z]+\{([^}]*)\}", r"\1", text)

        # Clean up
        text = re.sub(r"\n{3,}", "\n\n", text)
        text = text.strip()

        word_count = len(text.split())

        # Extract sections
        sections: list[DocumentSection] = []
        current_heading: str | None = None
        current_level = 0
        current_text: list[str] = []

        for line in text.split("\n"):
            heading_match = re.match(r"^(#{1,6})\s+(.+)$", line)
            if heading_match:
                if current_text or current_heading:
                    sections.append(
                        DocumentSection(
                            heading=current_heading,
                            content="\n".join(current_text).strip(),
                            page_number=None,
                            level=current_level,
                        )
                    )
                    current_text = []
                current_level = len(heading_match.group(1))
                current_heading = heading_match.group(2).strip()
            else:
                current_text.append(line)

        if current_text or current_heading:
            sections.append(
                DocumentSection(
                    heading=current_heading,
                    content="\n".join(current_text).strip(),
                    page_number=None,
                    level=current_level,
                )
            )

        return DocumentResult(
            text=text,
            title=title,
            page_count=None,
            word_count=word_count,
            was_ocr=False,
            sections=sections,
        )

    except Exception as exc:
        return DocumentResult(
            text=f"[Parsing failed: {exc}]",
            title=filename,
            page_count=None,
            word_count=0,
            was_ocr=False,
        )


# -- parse_document tool factory ----------------------------------------------


class ParseDocumentInput(BaseModel):
    """Input schema for the parse_document tool."""

    content_base64: str = Field(description="Base64-encoded file content")
    filename: str = Field(
        description="Original filename with extension, used for format detection",
    )


_PARSE_DOCUMENT_MAX_CHARS = 15_000
_PARSE_DOCUMENT_MAX_BYTES = 50 * 1024 * 1024  # 50 MB


def _tool_error(tool_name: str, action: str, error: str) -> str:
    return f"[TOOL ERROR] {tool_name}: {action} failed — {error}"


def create_parse_document_tool(
    config: dict[str, Any],
    description: str,
    ocr_config: OcrConfig | None = None,
) -> BaseTool:
    """Create a LangChain parse_document tool.

    Decodes base64 content, detects MIME type from filename, parses the
    document, and returns clean markdown text (truncated to 15K chars).
    """
    ocr = ocr_config or OcrConfig()

    @tool("parse_document", args_schema=ParseDocumentInput)
    async def parse_document_tool(content_base64: str, filename: str) -> str:
        """Parse binary document content into clean markdown text."""
        try:
            data = base64.b64decode(content_base64)
        except Exception as exc:
            return _tool_error("parse_document", "decode", f"Invalid base64: {exc}")

        if len(data) > _PARSE_DOCUMENT_MAX_BYTES:
            return _tool_error(
                "parse_document",
                "decode",
                f"Decoded content exceeds maximum size ({len(data)} > {_PARSE_DOCUMENT_MAX_BYTES} bytes)",
            )

        mime_type = detect_mime_from_filename(filename)
        if mime_type is None:
            return _tool_error(
                "parse_document",
                "detect format",
                f"Cannot determine format from filename '{filename}'. Supported: .pdf, .docx, .xlsx, .csv, .txt, .md, .tex",
            )

        try:
            result = await parse_document(data, mime_type, filename, ocr_config=ocr)
        except Exception as exc:
            return _tool_error("parse_document", "parse", str(exc))

        text = result.text
        if len(text) > _PARSE_DOCUMENT_MAX_CHARS:
            text = text[:_PARSE_DOCUMENT_MAX_CHARS] + "\n\n[Content truncated]"

        # Build response with metadata
        parts = []
        if result.title:
            parts.append(f"**Title:** {result.title}")
        if result.page_count:
            parts.append(f"**Pages:** {result.page_count}")
        parts.append(f"**Words:** {result.word_count}")
        if result.was_ocr:
            parts.append("**Note:** OCR was used for some pages")
        parts.append("")
        parts.append(text)

        return "\n".join(parts)

    parse_document_tool.description = description
    return parse_document_tool


class ParseDocumentTool(TearsTool):
    """TearsTool wrapper for document parsing into clean markdown.

    parses binary document content (PDF, DOCX, XLSX, CSV, TXT, Markdown,
    LaTeX) into markdown text. accepts base64-encoded content and
    detects format from filename extension. optionally supports OCR
    for scanned PDF pages.
    """

    _INPUT_SCHEMA: dict[str, Any] = {
        "type": "object",
        "properties": {
            "content_base64": {
                "type": "string",
                "description": "base64-encoded file content",
            },
            "filename": {
                "type": "string",
                "description": "original filename with extension, used for format detection",
            },
        },
        "required": ["content_base64", "filename"],
    }

    def __init__(self, ocr_config: OcrConfig | None = None) -> None:
        """initialize parse document tool with optional OCR configuration.

        :param ocr_config: OCR configuration for scanned PDF fallback
        :ptype ocr_config: OcrConfig | None
        """
        self._ocr_config = ocr_config

    async def execute(self, **kwargs: Any) -> ToolResult:
        """parse document content into markdown.

        :param kwargs: must include 'content_base64' and 'filename' keys
        :ptype kwargs: Any
        :return: result containing parsed markdown or error
        :rtype: ToolResult
        """
        content_base64 = kwargs.get("content_base64", "")
        filename = kwargs.get("filename", "")
        lc_tool = create_parse_document_tool(
            config={},
            description="parse document",
            ocr_config=self._ocr_config,
        )
        content = await lc_tool.ainvoke(
            {"content_base64": content_base64, "filename": filename},
        )
        success = not content.startswith("[TOOL ERROR]")
        result = ToolResult(
            success=success,
            content=content,
            error=content if not success else None,
        )
        return result

    def mcp_schema(self) -> MCPToolDefinition:
        """return MCP-compatible tool definition for parse document.

        :return: tool definition with name, version, description, input schema
        :rtype: MCPToolDefinition
        """
        result = MCPToolDefinition(
            name=self.mcp_name(),
            version=self.mcp_version(),
            description="parse binary document content into clean markdown text",
            input_schema=self._INPUT_SCHEMA,
        )
        return result

    def mcp_name(self) -> str:
        """return namespaced tool name.

        :return: namespaced tool name
        :rtype: str
        """
        return "threetears.parse_document"

    def mcp_version(self) -> str:
        """return tool version.

        :return: version string
        :rtype: str
        """
        return "1.0"
