# tools/ocr_backends/tesseract.py - Complex OCR (Tesseract + OpenCV + PIL): tables, formatting, footnotes.
from __future__ import annotations

import json
import math
import os
import re
import time
from collections import defaultdict

from langchain_core.tools import tool
from pdf2image import convert_from_path
import numpy as np
from pypdf import PdfReader

from tools._logging import _debug_log, _emit_input_status
from tools.job_stream_schema import (
    close_job_stream,
    error_job_stream,
    make_capabilities,
    metric_def,
    open_job_stream,
    update_job_stream,
)
from tools.pdf2image_errors import message_for_pdf2image_error
from tools.live_view_visuals import emit_live_view_preview, image_to_preview_base64
from tools.plugin_stream_controls import get_action_count
from tools.ocr_dispatch import (
    _letter_ratio,
    post_process_to_markdown,
    post_process_to_latex,
    final_ocr_polish,
    _sanitize_ocr_output,
)
from tools.file_ops import apply_ocr_correction
from tools.pdf_tools import split_pdf
from utils.path_safety import is_dangerous_path, is_plugins_dir_write_blocked, normalize_likely_mugi_temp_upload_path

# Optional: Tesseract + OpenCV + PIL
_cv2 = None
_pytesseract = None
_PIL_Image = None
_COMPLEX_OCR_ERROR = None
try:
    import cv2 as _cv2
    import pytesseract as _pytesseract
    from PIL import Image as _PIL_Image
except ImportError as e:
    _cv2 = _pytesseract = _PIL_Image = None
    _COMPLEX_OCR_ERROR = (
        "Complex OCR (tables, formatting, footnotes) requires: pip install opencv-python pytesseract Pillow, "
        "and Tesseract installed on the system (e.g. brew install tesseract). "
        f"Import error: {e}"
    )

_OCR_COMPLEX_LARGE_FILE_MB = 30
_CORRECTION_GUARD_MIN_PAGES = 20
_CORRECTION_GUARD_MIN_CHARS = 20_000
_CORRECTION_GUARD_MIN_RATIO = 0.6
_CORRECTION_ARTIFACT_REPEAT_THRESHOLD = 4
_CORRECTION_ARTIFACT_PHRASES = (
    "the above text is a continuation of the original document provided by the user",
    "continuation of the original document provided by the user",
)


def _is_probably_pdf(path: str) -> bool:
    if (path or "").lower().endswith(".pdf"):
        return True
    try:
        with open(path, "rb") as f:
            return f.read(5) == b"%PDF-"
    except Exception:
        return False


def _parse_split_pdf_result(result: str) -> list[str]:
    """Extract part paths from split_pdf return text."""
    paths = []
    for line in result.strip().splitlines():
        line = line.strip()
        if re.match(r"^\d+\.\s+", line):
            rest = line.split(". ", 1)[1].strip()
            if " (" in rest:
                rest = rest.rsplit(" (", 1)[0].strip()
            if rest.endswith(".pdf"):
                paths.append(rest)
    return paths


def _get_pdf_page_count(file_path: str) -> int | None:
    """Best-effort page count lookup for PDFs; returns None on failure."""
    try:
        with open(file_path, "rb") as f:
            reader = PdfReader(f)
            count = len(reader.pages)
        if count > 0:
            return count
    except Exception:
        return None
    return None


def _load_single_pdf_page_image(file_path: str, page_num: int, dpi: int = 400):
    """Load exactly one page image (1-indexed) from a PDF."""
    imgs = convert_from_path(file_path, dpi=dpi, first_page=page_num, last_page=page_num)
    if not imgs:
        raise ValueError(f"No rendered image for page {page_num}")
    return imgs[0]


def _should_reject_corrected_text(original: str, corrected: str, total_pages: int) -> bool:
    """Guard against destructive OCR correction that collapses long documents."""
    if total_pages < _CORRECTION_GUARD_MIN_PAGES:
        return False
    if len(original or "") < _CORRECTION_GUARD_MIN_CHARS:
        return False
    orig_len = len((original or "").strip())
    corr_len = len((corrected or "").strip())
    if orig_len <= 0:
        return False
    return corr_len < int(orig_len * _CORRECTION_GUARD_MIN_RATIO)


def _has_correction_artifacts(text: str) -> bool:
    """Detect common LLM artifact patterns in OCR-correction output."""
    if not isinstance(text, str):
        return False
    lowered = text.lower()
    for phrase in _CORRECTION_ARTIFACT_PHRASES:
        if lowered.count(phrase) >= _CORRECTION_ARTIFACT_REPEAT_THRESHOLD:
            return True
    # Repeated identical footnote boilerplate lines are a strong artifact signal.
    foot_lines = [ln.strip() for ln in text.splitlines() if ln.strip().startswith("[^")]
    if foot_lines:
        dupes = {}
        for ln in foot_lines:
            dupes[ln] = dupes.get(ln, 0) + 1
        if max(dupes.values()) >= _CORRECTION_ARTIFACT_REPEAT_THRESHOLD:
            return True
    return False


def fix_hyphenation(text: str) -> str:
    """Rejoin words broken by end-of-line hyphenation only (broad, safe)."""
    if not text or not text.strip():
        return text
    text = re.sub(r"(\w+)-\s*\n\s*(\w+)", r"\1\2", text)
    text = re.sub(r"\s*-\s*\n\s*", "", text)
    return text


def cleanup_ocr_text(text: str) -> str:
    """Lightweight cleanup: spacing, common OCR noise (| ~ • ®), strip _/° at line edges."""
    if not text or not text.strip():
        return text
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\s*[|~•®]\s*", " ", text)
    text = re.sub(r"(^|\s)\|\s*", r"\1", text)
    text = re.sub(r"\s*\|\s*(?=\s|\n|$)", " ", text)
    text = re.sub(r"\boo\b", "", text)
    text = re.sub(r" \. ", ". ", text)
    text = re.sub(r"\.([A-Z])", r". \1", text)
    text = re.sub(r"\s+_\s+", " ", text)
    text = re.sub(r"\s+°\s+", " ", text)
    text = re.sub(r" +\n", "\n", text)
    text = re.sub(r"\n +", "\n", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    text = re.sub(r" {2,}", " ", text)
    lines = [re.sub(r"^[\s_°]+|[\s_°]+$", "", ln) for ln in text.split("\n")]
    return "\n".join(lines).strip()


_HEADER_FOOTER_NOISE_LINE = re.compile(
    r"^\s*[|~•®\s]+\s*$|^\s*oo\s*$|^\s*[-*·]\s*$",
    re.IGNORECASE,
)
_PAGE_NUM_AT_END = re.compile(r".*\d+°\s*(:\s*°)?\s*$")
_MOSTLY_DIGITS_SYMBOLS = re.compile(r"^[\d\s°.:\-–—*·•®|~]+$", re.IGNORECASE)


def _drop_noise_lines(text: str) -> str:
    """Remove header/footer noise: symbols-only, page numbers, low-letter lines, short junk."""
    if not text or not text.strip():
        return text
    lines = text.split("\n")
    kept = []
    for line in lines:
        s = line.strip()
        if not s:
            kept.append(line)
            continue
        if _HEADER_FOOTER_NOISE_LINE.match(s) or s in ("|", "~", "•", "®", "oo", "*", "·"):
            continue
        if _PAGE_NUM_AT_END.match(s):
            continue
        if _MOSTLY_DIGITS_SYMBOLS.match(s) and len(s) >= 2:
            continue
        if len(s) <= 2 and not s.isalpha():
            continue
        if len(s) >= 5 and _letter_ratio(s) < 0.25:
            continue
        kept.append(line)
    return "\n".join(kept)


def remove_header_footer_heuristic(lines: list[str]) -> list[str]:
    """Drop lines that look like headers, footers, page numbers, or noise."""
    if not lines:
        return lines
    page_num_re = re.compile(r"^\s*[-]?\s*\d+\s*$|^\s*page\s+\d+\s*$", re.I)
    repeated = set()
    seen = []
    for line in lines:
        s = line.strip()
        if not s:
            seen.append(line)
            continue
        if page_num_re.match(s):
            continue
        if _HEADER_FOOTER_NOISE_LINE.match(s) or s in ("|", "~", "•", "®", "oo", "*", "·"):
            continue
        if _MOSTLY_DIGITS_SYMBOLS.match(s) and len(s) >= 2:
            continue
        if len(s) >= 5 and _letter_ratio(s) < 0.25:
            continue
        if len(s) < 50 and s in repeated:
            continue
        if len(s) < 50:
            repeated.add(s)
        seen.append(line)
    result = []
    for i, line in enumerate(seen):
        s = line.strip()
        if not s:
            result.append(line)
            continue
        if i == 0 and len(s) < 40 and len(seen) > 2:
            rest = " ".join(seen[1:4])
            if s in rest:
                continue
        result.append(line)
    return result


def detect_and_convert_tables(text: str) -> str:
    """Heuristic table detector → Markdown tables."""
    lines = text.split("\n")
    result = []
    i = 0
    while i < len(lines):
        line = lines[i].strip()
        if "|" in line and line.count("|") >= 3:
            table_lines = [line]
            i += 1
            while i < len(lines) and ("|" in lines[i] or lines[i].strip() == ""):
                table_lines.append(lines[i].strip())
                i += 1
            result.append(convert_lines_to_md_table(table_lines))
            continue
        result.append(lines[i])
        i += 1
    return "\n".join(result)


def convert_lines_to_md_table(lines: list[str]) -> str:
    """Convert lines that look like a table into proper Markdown table."""
    if not lines:
        return ""
    cleaned = [line.replace(" | ", "|").strip() for line in lines if line.strip()]
    if len(cleaned) < 2:
        return "\n".join(lines)
    return "\n".join(cleaned) + "\n"


_SUPerscript_TO_DIGIT = {"¹": "1", "²": "2", "³": "3", "⁴": "4", "⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9", "⁰": "0"}

_FOOTNOTE_SYMBOLS = ("*", "†", "‡", "§", "¶")
_LINE_START_FOOTNOTE_RE = re.compile(r"^\s*([*†‡§¶])\s+(.*)$")


def _extract_footnote_blocks_at_line_start(text: str, footnotes: dict) -> tuple[str, dict]:
    """Extract footnote blocks that start with a symbol at beginning of a line (e.g. '† This does not...').
    These are typical at the bottom of a page. Removes the block from text and adds content to footnotes[sym]."""
    lines = text.split("\n")
    result: list[str] = []
    i = 0
    while i < len(lines):
        line = lines[i]
        m = _LINE_START_FOOTNOTE_RE.match(line)
        if m:
            sym = m.group(1)
            body_parts = [m.group(2).strip()]
            i += 1
            while i < len(lines):
                next_line = lines[i]
                if not next_line.strip():
                    break
                if _LINE_START_FOOTNOTE_RE.match(next_line):
                    break
                body_parts.append(next_line.strip())
                i += 1
            body = " ".join(body_parts)
            body = re.sub(r"\s+", " ", body)
            body = re.sub(r"\s*-\s*$", "", body)  # trailing hyphen from line break
            body = re.sub(r"(\w)-\s+(\w)", r"\1\2", body)  # rejoin hyphenated words (e.g. com- pany)
            if len(body) > 10:
                if sym not in footnotes:
                    footnotes[sym] = []
                footnotes[sym].append(body)
            continue
        result.append(line)
        i += 1
    return "\n".join(result), footnotes


def extract_and_link_footnotes(text: str, footnotes: dict) -> tuple[str, dict]:
    """Normalize footnote references to [^key] format."""
    text = re.sub(r"(\.|\?|\!)\s*(\d)\b", r"\1[^\2]", text)
    for sym in ("*", "†", "‡", "§", "¶"):
        text = text.replace(f"[{sym}]", f"[^{sym}]")
    text = re.sub(r"(\w)\s*([\*†‡§¶])\b", r"\1[^\2]", text)
    def replacer(match):
        g1, g2, g3 = match.group(1), match.group(2), match.group(3)
        key = g1 or g2 or g3
        if key is None:
            return match.group(0)
        if key in _SUPerscript_TO_DIGIT:
            key = _SUPerscript_TO_DIGIT[key]
        return f"[^{key}]"
    text = re.sub(r'(?:\[(\d+)\]|\^(\d+)|([¹²³⁴⁵⁶⁷⁸⁹⁰]))', replacer, text)
    return text, footnotes


def _extract_footnote_bodies_from_text(text: str, footnotes: dict) -> tuple[str, dict]:
    """Find [^key] in text and move the following sentence/paragraph into footnotes[key]; remove from body."""
    def repl(match):
        key = match.group(1)
        body = match.group(2).strip()
        body = re.sub(r"^[\s=+'\-.:;]+", "", body)
        body = re.sub(r"\s+", " ", body).strip()
        if not body or len(body) <= 10:
            return f"[^{key}]"
        if key not in footnotes or not footnotes[key]:
            if key == "*" and (" + " in body or "Prager" in body or "\u2019 + " in body):
                for sep in (" + ", "\u2019 + ", "' + "):
                    if sep in body:
                        first, _, rest = body.partition(sep)
                        first = first.strip()
                        rest = re.sub(r"^[\s=+'\-.:;&]+", "", rest).strip()
                        if first and len(first) > 15:
                            footnotes["*"] = [first]
                        if rest and len(rest) > 15:
                            footnotes["†"] = [rest]
                        return f"[^{key}]"
                if "Prager" in body:
                    idx = body.find("Prager")
                    first = body[:idx].strip()
                    rest = body[idx:].strip()
                    if first and len(first) > 15:
                        footnotes["*"] = [first]
                    if rest and len(rest) > 15:
                        footnotes["†"] = [rest]
                    return f"[^{key}]"
            footnotes[key] = [body]
        return f"[^{key}]"
    text = re.sub(
        r"\[\^([^\]]+)\]\s*(.*?)(?=\[\^|\n\n|$)",
        repl,
        text,
        flags=re.DOTALL,
    )
    return text, footnotes


@tool
def ocr_complex_document(
    file_path: str,
    output_format: str = "markdown",
    enhance_for_tables: bool = True,
    detect_formatting: bool = True,
    detect_footnotes: bool = True,
) -> str:
    """High-quality OCR for complex documents (tables, graphs, bold/italic, footnotes).
    Preferred over ocr_scanned_pdf when the document has tables, bold/italic, footnotes, charts.
    Returns clean Markdown (default) or LaTeX."""
    stream_id = f"ocr_tesseract:{int(time.time() * 1000)}"
    open_job_stream(
        plugin_id="ocr_job",
        plugin_name="OCR Job",
        title="Complex OCR (Tesseract)",
        job_type="ocr_complex_document",
        stream_id=stream_id,
        controls=[
            {"id": "cancel", "type": "button", "label": "Cancel OCR", "action_id": "cancel_job", "style": "destructive"}
        ],
        capabilities=make_capabilities(cancel=True, progress=True, metrics=True, phases=True, batch_items=True, artifacts=True),
        metric_schema=[
            metric_def("pages_total", "Pages Total", format="number"),
            metric_def("pages_done", "Pages Done", format="number"),
            metric_def("chars_total", "Characters", format="number"),
        ],
        metadata={"file_path": file_path, "output_format": output_format},
    )
    file_path = normalize_likely_mugi_temp_upload_path((file_path or "").strip(' "\n,'))
    if _COMPLEX_OCR_ERROR:
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="init", message=_COMPLEX_OCR_ERROR)
        return _COMPLEX_OCR_ERROR
    if _cv2 is None or _pytesseract is None or _PIL_Image is None:
        msg = "Complex OCR is not available (opencv-python, pytesseract, Pillow required)."
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="init", message=msg)
        return msg
    abs_path = os.path.abspath(os.path.expanduser(file_path))
    if not os.path.exists(abs_path) or not os.path.isfile(abs_path):
        msg = f"File not found: {abs_path}"
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="init", message=msg)
        return msg
    if is_dangerous_path(abs_path):
        msg = "That path is not allowed for reading."
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="init", message=msg)
        return msg
    if not _is_probably_pdf(abs_path):
        msg = "This tool is PDF-only. For PNG/JPG/WebP images, use ocr_image_text(file_path=...)."
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="init", message=msg)
        return msg

    _debug_log(f"[ocr_complex_document] Starting on {abs_path}")
    _emit_input_status("Converting PDF to images (high DPI)...")

    page_count = _get_pdf_page_count(abs_path)
    use_pagewise_render = isinstance(page_count, int) and page_count > 1
    images = []
    if not use_pagewise_render:
        try:
            images = convert_from_path(abs_path, dpi=400)
            _debug_log(f"[ocr_complex_document] Converted {len(images)} pages")
            page_count = len(images)
        except Exception as e:
            _debug_log(f"[ERROR ocr_complex_document] pdf2image failed: {e}", "ERROR")
            msg = message_for_pdf2image_error(e)
            error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="convert_pdf", message=msg)
            return msg
    else:
        _debug_log(f"[ocr_complex_document] Using page-by-page rendering for {page_count} pages")

    full_pages = []
    footnotes = defaultdict(list)

    try:
        total_pages = page_count or 0
        if total_pages <= 0:
            return "Complex OCR failed: unable to determine page count."
        total_chars = 0
        update_job_stream(
            plugin_id="ocr_job",
            plugin_name="OCR Job",
            stream_id=stream_id,
            phase="ocr_pages",
            status="running",
            progress_pct=0,
            message=f"Processing {total_pages} pages...",
            metrics={"pages_total": float(total_pages), "pages_done": 0.0, "chars_total": 0.0},
        )
        for page_num in range(1, total_pages + 1):
            if get_action_count(stream_id, "cancel_job") > 0:
                close_job_stream(
                    plugin_id="ocr_job",
                    plugin_name="OCR Job",
                    stream_id=stream_id,
                    status="stopped",
                    message=f"Complex OCR cancelled at page {page_num}/{total_pages}.",
                    metrics={"pages_total": float(total_pages), "pages_done": float(page_num - 1), "chars_total": float(total_chars)},
                )
                return "Complex OCR cancelled by user."
            if use_pagewise_render:
                try:
                    pil_img = _load_single_pdf_page_image(abs_path, page_num, dpi=400)
                except Exception as e:
                    _debug_log(f"[ERROR ocr_complex_document] page render failed ({page_num}/{total_pages}): {e}", "ERROR")
                    msg = message_for_pdf2image_error(e)
                    error_job_stream(
                        plugin_id="ocr_job",
                        plugin_name="OCR Job",
                        stream_id=stream_id,
                        phase="convert_pdf",
                        message=f"{msg} (page {page_num}/{total_pages})",
                    )
                    return f"Complex OCR failed while rendering page {page_num}/{total_pages}: {msg}"
            else:
                pil_img = images[page_num - 1]
            _emit_input_status(f"OCR page {page_num}/{total_pages}...")
            img = np.array(pil_img)
            gray = _cv2.cvtColor(img, _cv2.COLOR_RGB2GRAY)
            if enhance_for_tables:
                gray = _cv2.GaussianBlur(gray, (3, 3), 0)
                _, gray = _cv2.threshold(gray, 0, 255, _cv2.THRESH_BINARY + _cv2.THRESH_OTSU)
                kernel = np.array([[-1, -1, -1], [-1, 9, -1], [-1, -1, -1]])
                gray = _cv2.filter2D(gray, -1, kernel)
            pil_processed = _PIL_Image.fromarray(gray)
            custom_config = r'--psm 6 --oem 3 -c preserve_interword_spaces=1'
            page_text = _pytesseract.image_to_string(pil_processed, config=custom_config)
            full_pages.append(page_text)
            total_chars += len(page_text or "")
            update_job_stream(
                plugin_id="ocr_job",
                plugin_name="OCR Job",
                stream_id=stream_id,
                phase="ocr_pages",
                status="running",
                progress_pct=float(100 * page_num / total_pages),
                message=f"OCR page {page_num}/{total_pages}",
                metrics={"pages_total": float(total_pages), "pages_done": float(page_num), "chars_total": float(total_chars)},
            )
            try:
                b64 = image_to_preview_base64(pil_img, max_width=320)
                emit_live_view_preview(
                    f"OCR page {page_num}/{total_pages}",
                    tool_name="ocr_complex_document",
                    progress_pct=int(100 * page_num / total_pages),
                    preview_image_base64=b64,
                    preview_text=page_text[:300] if page_text else None,
                )
            except Exception:
                pass

        raw_text = "\n\n".join(full_pages)
        raw_text = fix_hyphenation(raw_text)
        raw_text = cleanup_ocr_text(raw_text)
        raw_text = _drop_noise_lines(raw_text)
        raw_text = re.sub(r"\[\s*\[\^([^\]]+)\]\s*\]", r"[^\1]", raw_text)
        if enhance_for_tables:
            raw_text = detect_and_convert_tables(raw_text)
        if detect_footnotes:
            raw_text, footnotes = _extract_footnote_blocks_at_line_start(raw_text, footnotes)
            raw_text, footnotes = extract_and_link_footnotes(raw_text, footnotes)
            raw_text, footnotes = _extract_footnote_bodies_from_text(raw_text, footnotes)
        if output_format.lower() == "latex":
            final = post_process_to_latex(raw_text, footnotes)
        else:
            final = post_process_to_markdown(raw_text, footnotes)
        final = final_ocr_polish(final)
        corrected = apply_ocr_correction(final)
        if _has_correction_artifacts(corrected):
            _debug_log(
                "[ocr_complex_document] OCR correction output contained repeated artifact text; keeping uncorrected OCR output.",
                "WARN",
            )
        elif _should_reject_corrected_text(final, corrected, total_pages):
            _debug_log(
                (
                    "[ocr_complex_document] OCR correction output looked destructively short; "
                    f"keeping uncorrected text (before={len(final)}, after={len(corrected)}, pages={total_pages})."
                ),
                "WARN",
            )
        else:
            final = corrected
        _emit_input_status("OCR complete")
        _debug_log(f"[ocr_complex_document] Finished — {len(final)} characters")
        close_job_stream(
            plugin_id="ocr_job",
            plugin_name="OCR Job",
            stream_id=stream_id,
            status="completed",
            message=f"Complex OCR complete ({total_pages} pages).",
            metrics={"pages_total": float(total_pages), "pages_done": float(total_pages), "chars_total": float(len(final))},
        )
        return final
    except (re.error, ValueError, TypeError, KeyError, IndexError) as e:
        _debug_log(f"[ERROR ocr_complex_document] Processing failed: {e}", "ERROR")
        msg = f"Complex OCR failed during processing: {e}. Try ocr_scanned_pdf for plain text."
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="postprocess", message=msg)
        return msg
    except Exception as e:
        _debug_log(f"[ERROR ocr_complex_document] Unexpected error: {e}", "ERROR")
        msg = f"Complex OCR failed: {e}. Try ocr_scanned_pdf for plain text."
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="ocr_complex_document", message=msg)
        return msg


@tool
def ocr_complex_document_to_file(
    pdf_path: str,
    output_file_path: str,
    append: bool = False,
    output_format: str = "markdown",
) -> str:
    """Complex OCR (tables, bold/italic, footnotes) and write the result directly to a file.
    For PDFs 30 MB or larger: splits internally, OCRs each part, writes/appends to one file."""
    if _COMPLEX_OCR_ERROR:
        return _COMPLEX_OCR_ERROR
    if _cv2 is None or _pytesseract is None or _PIL_Image is None:
        return "Complex OCR is not available (opencv-python, pytesseract, Pillow required)."

    pdf_path = normalize_likely_mugi_temp_upload_path((pdf_path or "").strip(' "\n,'))
    output_file_path = output_file_path.strip(' "\n,')
    pdf_abs = os.path.abspath(os.path.expanduser(pdf_path))
    out_abs = os.path.abspath(os.path.expanduser(output_file_path))
    if not os.path.exists(pdf_abs) or not os.path.isfile(pdf_abs):
        return f"PDF not found or not a file: {pdf_abs}"
    if is_dangerous_path(out_abs):
        return f"Output path is in a protected location: {out_abs}"
    if is_plugins_dir_write_blocked(out_abs):
        return "Blocked: cannot write to the plugins directory. Use create_plugin (evolution mode) to add plugins."
    from utils.user_model import enforce_low_level_user_model_write_guard

    _um_err = enforce_low_level_user_model_write_guard(
        out_abs, mode="append" if append else "overwrite"
    )
    if _um_err:
        return _um_err

    file_size_mb = os.path.getsize(pdf_abs) / (1024 * 1024)
    part_paths = []
    if file_size_mb >= _OCR_COMPLEX_LARGE_FILE_MB:
        num_parts = max(2, math.ceil(file_size_mb / _OCR_COMPLEX_LARGE_FILE_MB))
        _debug_log(f"[ocr_complex_document_to_file] PDF {file_size_mb:.1f} MB >= {_OCR_COMPLEX_LARGE_FILE_MB} MB; splitting into {num_parts} parts", "INFO")
        try:
            split_result = split_pdf.invoke({
                "input_json": json.dumps({
                    "file_path": pdf_abs,
                    "split_method": "equal",
                    "num_parts": num_parts,
                }),
            })
            if isinstance(split_result, dict):
                split_result = split_result.get("output", str(split_result))
            part_paths = _parse_split_pdf_result(str(split_result))
        except Exception as e:
            return f"Split failed: {e}. Split result may be an error message."
        if not part_paths:
            return f"Split did not return part paths. Result: {str(split_result)[:500]}"
    else:
        part_paths = [pdf_abs]

    parent_dir = os.path.dirname(out_abs) or "."
    os.makedirs(parent_dir, exist_ok=True)
    separator = "\n\n---\n\n"
    total_chars = 0
    for i, part_path in enumerate(part_paths):
        _emit_input_status(f"Complex OCR part {i + 1}/{len(part_paths)}…")
        fmt = (output_format or "markdown").strip().lower()
        if fmt not in ("markdown", "latex"):
            fmt = "markdown"
        text = ocr_complex_document.invoke({
            "file_path": part_path,
            "output_format": fmt,
            "enhance_for_tables": True,
            "detect_formatting": True,
            "detect_footnotes": True,
        })
        text = _sanitize_ocr_output(text)
        if not text or not isinstance(text, str):
            return f"OCR part {i + 1} returned no text."
        if (text.startswith("Failed") or text.startswith("Complex OCR is not available") or text.startswith("Complex OCR failed")):
            return f"OCR part {i + 1} failed: {text[:400]}"
        mode = "w" if (i == 0 and not append) else "a"
        with open(out_abs, mode, encoding="utf-8") as f:
            if i > 0 or append:
                f.write(separator)
            f.write(text)
        total_chars += len(text)
        _debug_log(f"[ocr_complex_document_to_file] Part {i + 1}/{len(part_paths)}: {len(text)} chars written", "INFO")

    size_bytes = os.path.getsize(out_abs)
    return (
        f"Done. Complex OCR: {len(part_paths)} part(s), {total_chars:,} characters, saved to:\n{out_abs}\n"
        f"(File size: {size_bytes:,} bytes). Tell the user their cleaned document is at that path."
    )
