#!/usr/bin/env python3
"""Batch-convert a tree of text PDFs to markdown for whole-file LLM reading.

Walks a source directory (recursing into topic subfolders), converts each PDF to
markdown with pymupdf4llm, and writes a mirrored .md tree under the output dir.

Design notes:
- Idempotent: a PDF is skipped when its .md already exists and is newer.
- No-text-layer PDFs (scans) yield ~no extractable text. Those are detected
  cheaply *before* the expensive markdown pass, logged to needs-ocr.txt, and
  left unwritten (no empty markdown) so they can go through an OCR tool later.
- Output is optimized for a model reading the entire file: a small provenance
  header, then page-separated markdown that keeps headings / lists / tables.
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import pymupdf  # bundled with pymupdf4llm
import pymupdf4llm

# A scan (image-only PDF) extracts almost nothing. Real text PDFs comfortably
# exceed this; the threshold only has to separate "basically empty" from "has a
# text layer", so it is deliberately low to avoid false OCR flags.
MIN_CHARS_PER_PAGE = 50


def extractable_chars(pdf_path: Path) -> tuple[int, int]:
    """Return (total_text_chars, page_count) using a cheap text extraction."""
    with pymupdf.open(pdf_path) as doc:
        pages = doc.page_count
        total = sum(len(page.get_text("text")) for page in doc)
    return total, pages


def has_text_layer(total_chars: int, pages: int) -> bool:
    if pages == 0:
        return False
    return (total_chars / pages) >= MIN_CHARS_PER_PAGE


def md_is_current(pdf_path: Path, md_path: Path) -> bool:
    """True when md exists and is at least as new as the source PDF."""
    return md_path.exists() and md_path.stat().st_mtime >= pdf_path.stat().st_mtime


def plain_text_markdown(pdf_path: Path) -> str:
    """Fallback: raw per-page text with the same '-----' page separators.

    Loses heading/list/table structure but never loses the text itself."""
    parts = []
    with pymupdf.open(pdf_path) as doc:
        for page in doc:
            parts.append(page.get_text("text").strip())
    return "\n\n-----\n\n".join(parts)


def convert_one(pdf_path: Path, md_path: Path, raw_chars: int) -> str:
    """Write markdown for one PDF. Returns the method used: 'pymupdf4llm' or 'plain'.

    pymupdf4llm gives structured markdown ("-----" page separators preserve page
    boundaries for citation). On some PDFs its layout pass emits almost nothing
    even though a text layer exists; when its output is implausibly small versus
    the raw extractable text, fall back to plain text extraction.
    """
    md_path.parent.mkdir(parents=True, exist_ok=True)
    body = pymupdf4llm.to_markdown(str(pdf_path), write_images=False, show_progress=False)
    method = "pymupdf4llm"
    # Generous floor: only trips when pymupdf4llm essentially gave up.
    if len(body.strip()) < 0.2 * raw_chars:
        body = plain_text_markdown(pdf_path)
        method = "plain"
    header = f"# {pdf_path.stem}\n\n> Source: `{pdf_path.name}`\n\n---\n\n"
    md_path.write_text(header + body, encoding="utf-8")
    return method


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    parser.add_argument("--src", type=Path, default=Path("pdfs"), help="source dir of PDFs (default: pdfs)")
    parser.add_argument("--out", type=Path, default=Path("md"), help="output dir for markdown (default: md)")
    parser.add_argument(
        "--ocr-list", type=Path, default=Path("needs-ocr.txt"),
        help="file listing PDFs with no usable text layer (default: needs-ocr.txt)",
    )
    parser.add_argument("--force", action="store_true", help="reconvert even if md is current")
    args = parser.parse_args(argv)

    if not args.src.is_dir():
        print(f"error: source dir not found: {args.src}", file=sys.stderr)
        return 2

    pdfs = sorted(args.src.rglob("*.pdf"))
    if not pdfs:
        print(f"No PDFs found under {args.src}")
        return 0

    converted, skipped, failed, plain = 0, 0, 0, 0
    needs_ocr: list[Path] = []

    for pdf in pdfs:
        rel = pdf.relative_to(args.src).with_suffix(".md")
        md_path = args.out / rel

        if not args.force and md_is_current(pdf, md_path):
            print(f"skip   {rel}  (up to date)")
            skipped += 1
            continue

        try:
            total, pages = extractable_chars(pdf)
        except Exception as exc:  # unreadable / corrupt PDF
            print(f"FAIL   {rel}  ({type(exc).__name__}: {exc})")
            failed += 1
            continue

        if not has_text_layer(total, pages):
            print(f"ocr?   {rel}  ({total} chars / {pages} pages -> no text layer)")
            needs_ocr.append(pdf)
            continue

        try:
            method = convert_one(pdf, md_path, total)
        except Exception as exc:
            print(f"FAIL   {rel}  ({type(exc).__name__}: {exc})")
            failed += 1
            continue

        tag = "  [plain-text fallback]" if method == "plain" else ""
        if method == "plain":
            plain += 1
        print(f"conv   {rel}  ({pages} pages){tag}")
        converted += 1

    # Rewritten fresh each run: flagged PDFs are re-checked every time since they
    # never produce markdown to skip on.
    if needs_ocr:
        args.ocr_list.write_text("\n".join(str(p) for p in needs_ocr) + "\n", encoding="utf-8")
    elif args.ocr_list.exists():
        args.ocr_list.unlink()

    print("\n" + "=" * 48)
    print(f"converted:       {converted}" + (f"  ({plain} via plain-text fallback)" if plain else ""))
    print(f"skipped:         {skipped}")
    print(f"flagged for OCR: {len(needs_ocr)}")
    if failed:
        print(f"failed:          {failed}")
    if needs_ocr:
        print(f"\nSee {args.ocr_list} -> run these through marker/docling (OCR).")
    return 1 if failed else 0


if __name__ == "__main__":
    raise SystemExit(main())
