# tools/ocr_backends/kraken.py - Kraken OCR (old books, archaic glyphs, historical documents).
from __future__ import annotations

import json
import math
import os
import shutil
import ssl
import subprocess
import time
import traceback
import urllib.request
import uuid
from pypdf import PdfReader, PdfWriter

_SSL_CTX = ssl.create_default_context()
try:
    import certifi
    _SSL_CTX.load_verify_locations(certifi.where())
except ImportError:
    pass
from collections import defaultdict
from threading import Thread, Lock

from langchain_core.tools import tool
from pdf2image import convert_from_path

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.live_view_visuals import emit_live_view_preview, image_to_preview_base64
from tools.ocr_dispatch import post_process_to_markdown, post_process_to_latex, _sanitize_ocr_output
from tools.file_ops import apply_ocr_correction
from tools.plugin_stream_controls import clear_stream_state, get_action_count
from tools.ocr_backends.tesseract import (
    extract_and_link_footnotes,
    _extract_footnote_blocks_at_line_start,
    _extract_footnote_bodies_from_text,
    _parse_split_pdf_result,
)
from tools.pdf_tools import split_pdf
from tools.mugi_brew_bootstrap import ensure_mugi_homebrew_core, get_mugi_homebrew_bin_dir, _run_brew
from tools.pdf2image_errors import message_for_pdf2image_error
from utils.path_safety import is_dangerous_path, is_plugins_dir_write_blocked

try:
    from persistence import KRAKEN_VENV_DIR, MUGI_DIR, MUGI_LOGS_DIR
except ImportError:
    _mugi = os.path.join(os.path.expanduser("~"), ".mugi")
    KRAKEN_VENV_DIR = os.path.join(_mugi, "kraken-venv")
    MUGI_DIR = _mugi
    MUGI_LOGS_DIR = os.path.join(_mugi, "logs")
try:
    from persistence import STANDALONE_PYTHON312_DIR
except ImportError:
    STANDALONE_PYTHON312_DIR = os.path.join(MUGI_DIR, "python-3.12")

# Venv path: app-managed under ~/.mugi/kraken-venv (see persistence.KRAKEN_VENV_DIR)
if os.name == "nt":
    KRAKEN_VENV_PYTHON = os.path.join(KRAKEN_VENV_DIR, "Scripts", "python.exe")
else:
    KRAKEN_VENV_PYTHON = os.path.join(KRAKEN_VENV_DIR, "bin", "python")
KRAKEN_VENV_ROOT = KRAKEN_VENV_DIR
KRAKEN_MODELS_DIR = os.path.expanduser("~/.local/share/kraken")
KRAKEN_HTRMOPO_DIR = os.path.expanduser("~/.local/share/htrmopo")
_KRAKEN_MACOS_APP_SUPPORT = os.path.expanduser("~/Library/Application Support")
DEFAULT_KRAKEN_MODEL_NAME = "catmus-print-fondue-large.mlmodel"
DEFAULT_KRAKEN_MODEL_PATH = os.path.join(KRAKEN_MODELS_DIR, DEFAULT_KRAKEN_MODEL_NAME)

_KRAKEN_MODEL_SEARCH_DIRS = [
    os.path.join(_KRAKEN_MACOS_APP_SUPPORT, "htrmopo"),
    os.path.join(_KRAKEN_MACOS_APP_SUPPORT, "kraken"),
    KRAKEN_MODELS_DIR,
    KRAKEN_HTRMOPO_DIR,
    os.path.join(KRAKEN_VENV_ROOT, ".local", "share", "kraken"),
    os.path.join(KRAKEN_VENV_ROOT, ".local", "share", "htrmopo"),
    os.path.join(KRAKEN_VENV_ROOT, "share", "kraken"),
    os.path.join(KRAKEN_VENV_ROOT, "share", "htrmopo"),
]

KRAKEN_AVAILABLE = False
_KRAKEN_ERROR = None
_KRAKEN_USE_VENV = False
_kraken_model_cache = {}
_kraken_model_lock = Lock()
_kraken_debug_logged = False
_venv_check_stderr = None

_KRAKEN_DEFAULT_TIMEOUT = 600
_KRAKEN_LARGE_FILE_MB = 30


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 _pdf_page_count(path: str) -> int:
    try:
        with open(path, "rb") as f:
            return len(PdfReader(f).pages)
    except Exception:
        return -1


def _fallback_split_pdf_equal(src_pdf: str, output_dir: str, num_parts: int) -> list[str]:
    with open(src_pdf, "rb") as f:
        reader = PdfReader(f)
        total_pages = len(reader.pages)
        if total_pages <= 0 or num_parts < 2:
            return [src_pdf]
        pages_per_part = total_pages // num_parts
        remainder = total_pages % num_parts
        start = 0
        out_paths: list[str] = []
        for idx in range(num_parts):
            end = start + pages_per_part + (1 if idx < remainder else 0)
            writer = PdfWriter()
            for page_num in range(start, end):
                writer.add_page(reader.pages[page_num])
            out_path = os.path.join(output_dir, f"staging_fallback_part{idx + 1}.pdf")
            with open(out_path, "wb") as out_f:
                writer.write(out_f)
            out_paths.append(out_path)
            start = end
        return out_paths

_KRAKEN_VENV_SCRIPT = r"""
import json, sys, traceback, time, os
from kraken import binarization, pageseg, rpred
from kraken.lib import models
from PIL import Image
try:
    data = json.load(sys.stdin)
    image_paths = data["images"]
    model_path = data["model_path"]
    model = models.load_any(model_path)
    page_texts = []
    for path in image_paths:
        im = Image.open(path).convert("L")
        bw = binarization.nlbin(im)
        seg = pageseg.segment(bw)
        if not seg or not getattr(seg, "lines", None):
            page_texts.append("")
            continue
        lines = []
        for rec in rpred.rpred(network=model, im=bw, bounds=seg):
            pred = getattr(rec, "prediction", None) or getattr(rec, "text", "")
            if pred:
                lines.append(pred)
        page_texts.append("\n".join(lines))
    print("\n\n".join(page_texts))
except Exception as e:
    traceback.print_exc(file=sys.stderr)
    print(str(e), file=sys.stderr)
    sys.exit(1)
"""

# Optional direct import
_kraken_binarization = None
_kraken_pageseg = None
_kraken_rpred = None
_kraken_models = None


def _kraken_find_mlmodel_in_dir(parent: str) -> str | None:
    if not os.path.isdir(parent):
        return None
    for name in sorted(os.listdir(parent)):
        if name.endswith(".mlmodel"):
            path = os.path.join(parent, name)
            if os.path.isfile(path):
                return path
        sub = os.path.join(parent, name)
        if os.path.isdir(sub):
            for subname in sorted(os.listdir(sub)):
                if subname.endswith(".mlmodel"):
                    path = os.path.join(sub, subname)
                    if os.path.isfile(path):
                        return path
    return None


def _kraken_venv_discover_model() -> str | None:
    script = r"""
import os, sys
bases = []
if sys.platform == "darwin":
    bases.append(os.path.expanduser("~/Library/Application Support"))
bases.append(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"))
if getattr(sys, "prefix", None):
    bases.append(sys.prefix)
for base in bases:
    for sub in ["htrmopo", "kraken"]:
        d = os.path.join(base, sub) if base != getattr(sys, "prefix", None) else os.path.join(base, "share", sub)
        if os.path.isdir(d):
            for root, _dirs, files in os.walk(d):
                for f in files:
                    if f.endswith(".mlmodel"):
                        print(os.path.join(root, f))
                        sys.exit(0)
sys.exit(1)
"""
    try:
        r = subprocess.run(
            [KRAKEN_VENV_PYTHON, "-c", script],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=15,
            env=_clean_env_for_subprocess(),
        )
        if r.returncode == 0 and (r.stdout or "").strip():
            return (r.stdout or "").strip().splitlines()[0].strip()
    except Exception as e:
        if _debug_log:
            _debug_log(f"[Kraken OCR] venv discover exception: {e}", "DEBUG")
    return None


def _kraken_resolve_model_path(requested: str | None) -> str | None:
    if (requested or "").strip():
        p = os.path.abspath(os.path.expanduser((requested or "").strip()))
        if os.path.isfile(p):
            return p
    # User preference from Advanced prefs (Settings → Advanced → Kraken OCR).
    try:
        from utils.app_prefs_store import load_app_prefs_cached
        prefs = load_app_prefs_cached()
        preferred = (prefs.get("kraken_recognition_model_path") or "").strip()
        if preferred:
            p = os.path.abspath(os.path.expanduser(preferred))
            if os.path.isfile(p):
                return p
    except Exception:
        pass
    if os.path.isfile(DEFAULT_KRAKEN_MODEL_PATH):
        return DEFAULT_KRAKEN_MODEL_PATH
    for dir_path in _KRAKEN_MODEL_SEARCH_DIRS:
        found = _kraken_find_mlmodel_in_dir(dir_path)
        if found:
            return found
    if _KRAKEN_USE_VENV:
        return _kraken_venv_discover_model()
    return None


def _python_for_venv() -> str | None:
    """Return a Python 3.12 executable that has the venv module (for creating the Kraken venv).
    Kraken requires Python 3.12. Checks common macOS locations (app may have minimal PATH).

    IMPORTANT: Mugi-owned Python paths are checked FIRST so we never fall through to
    /usr/bin/python3 (the macOS stub that triggers the Xcode CLT install dialog)."""
    import sys
    import shutil
    version_check = "import sys; sys.exit(0 if sys.version_info[:2] == (3, 12) else 1)"
    venv_check = "import venv"
    seen = set()
    candidates = []

    # Mugi-owned Python — checked first so we NEVER touch /usr/bin/python3 (macOS stub).
    # These are guaranteed to exist after the main backend bootstrap completes.
    mugi_dir = os.path.expanduser("~/.mugi")
    mugi_owned = [
        # Standalone Python bundled/extracted by PythonBackend.swift bootstrap
        os.path.join(mugi_dir, "python-3.12", "bin", "python3"),
        os.path.join(mugi_dir, "python-3.12", "bin", "python3.12"),
        # Main backend venv Python (also 3.12 and has venv)
        os.path.join(mugi_dir, "venv", "bin", "python3"),
        # Mugi Homebrew python@3.12
        os.path.join(mugi_dir, "homebrew", "bin", "python3.12"),
        os.path.join(mugi_dir, "homebrew", "opt", "python@3.12", "bin", "python3.12"),
    ]
    for path in mugi_owned:
        if path and path not in seen and os.path.isfile(path):
            seen.add(path)
            candidates.append(path)

    # Only fall through to system/Homebrew paths if no Mugi-owned Python was found.
    # Avoids the macOS /usr/bin/python3 stub that pops up the Xcode CLT dialog.
    search_paths = [
        "/usr/local/bin/python3.12",
        "/opt/homebrew/bin/python3.12",
        "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
        "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12",
        os.path.expanduser("~/Library/Python/3.12/bin/python3"),
        os.path.expanduser("~/Library/Python/3.12/bin/python3.12"),
        # /usr/bin/python3.12 (not the .12-less stub) is safe — only /usr/bin/python3 triggers CLT
        "/usr/bin/python3.12",
    ]
    for path in search_paths:
        if path and path not in seen and os.path.isfile(path):
            seen.add(path)
            candidates.append(path)
    # PATH-based lookup — only python3.12 (versioned), never bare python3 which resolves
    # to the macOS CLT stub at /usr/bin/python3 and triggers the Xcode install dialog.
    for path in (
        shutil.which("python3.12"),
    ):
        if path and path not in seen and os.path.isfile(path):
            seen.add(path)
            candidates.append(path)
    # With explicit PATH so GUI app finds python3.12 (e.g. Homebrew, python.org installer)
    rich_path = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:" + os.environ.get("PATH", "")
    try:
        r = subprocess.run(
            ["which", "python3.12"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=5,
            env={**os.environ, "PATH": rich_path},
        )
        if r.returncode == 0 and r.stdout:
            path = r.stdout.strip().splitlines()[0].strip()
            if path and path not in seen and os.path.isfile(path):
                seen.add(path)
                candidates.append(path)
    except Exception:
        pass
    # Only use sys.executable if it is 3.12 (e.g. when not frozen)
    if sys.executable and os.path.isfile(sys.executable) and sys.executable not in seen:
        try:
            r = subprocess.run(
                [sys.executable, "-c", version_check],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=5,
            )
            if r.returncode == 0:
                candidates.append(sys.executable)
        except Exception:
            pass
    # Use clean env for subprocess so bundled app's PYTHONHOME/PYTHONPATH don't break the candidate interpreter.
    _strip = ("PYTHONHOME", "PYTHONPATH", "PYTHONEXECUTABLE")
    clean_env = {k: v for k, v in os.environ.items() if k not in _strip}
    clean_env.setdefault("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
    for exe in candidates:
        if not exe or not os.path.isfile(exe):
            continue
        exe_dir = os.path.dirname(exe)
        if exe_dir and exe_dir not in clean_env.get("PATH", ""):
            clean_env["PATH"] = exe_dir + os.pathsep + clean_env.get("PATH", "")
        try:
            r = subprocess.run(
                [exe, "-c", version_check],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=5,
                env=clean_env,
            )
            if r.returncode != 0:
                continue
            r = subprocess.run(
                [exe, "-c", venv_check],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=5,
                env=clean_env,
            )
            if r.returncode == 0:
                return exe
        except Exception as e:
            continue
    return None


def _clean_env_for_subprocess(extra_path_prefix: str = "") -> dict:
    """Env with PYTHONHOME/PYTHONPATH stripped so child uses its own interpreter (for bundled app)."""
    _strip = ("PYTHONHOME", "PYTHONPATH", "PYTHONEXECUTABLE")
    env = {k: v for k, v in os.environ.items() if k not in _strip}
    env.setdefault("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
    if extra_path_prefix:
        env["PATH"] = extra_path_prefix + os.pathsep + env.get("PATH", "")
    return env


def _brew_path() -> str | None:
    """Return path to Homebrew's brew executable, or None if not found.
    GUI apps often have minimal PATH or restricted filesystem view; we try direct paths, then which in a subprocess."""
    import shutil
    for path in ("/opt/homebrew/bin/brew", "/usr/local/bin/brew"):
        if path and os.path.isfile(path):
            return path
    rich_path = "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:" + os.environ.get("PATH", "")
    found = shutil.which("brew", path=rich_path)
    if found:
        return found
    # GUI apps may not see /opt/homebrew; run which in a subprocess with explicit PATH
    try:
        r = subprocess.run(
            ["which", "brew"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=5,
            env={**os.environ, "PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"},
        )
        out = (r.stdout or "").strip()
        path = out.splitlines()[0].strip() if out else None
        if r.returncode == 0 and path and os.path.isfile(path):
            return path
    except Exception:
        pass
    return None


def _ensure_poppler() -> tuple[bool, str]:
    """Ensure poppler is available (for pdf2image PDF→images). Uses Mugi Homebrew (~/.mugi/homebrew). Returns (True, message) or (False, error)."""
    mugi_bin = get_mugi_homebrew_bin_dir()
    _path = mugi_bin + os.pathsep + "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:" + os.environ.get("PATH", "")
    if shutil.which("pdftoppm", path=_path):
        return True, ""
    ok, msg = ensure_mugi_homebrew_core()
    if not ok:
        return False, f"Poppler is required for PDF OCR. Mugi Homebrew could not be set up: {msg}"
    ok, msg = _run_brew(["install", "poppler"], timeout=300)
    if not ok:
        return False, f"Poppler install via Mugi Homebrew failed: {msg[:500]}"
    return True, "Poppler installed."


def _install_python312_via_brew() -> tuple[bool, str | None]:
    """Install Python 3.12 via Homebrew (versioned; does not change system default Python).
    Returns (True, None) on success, (False, error_message) when brew is missing or install fails."""
    brew = _brew_path()
    if not brew:
        return False, (
            "Homebrew was not found. The app uses Homebrew to install Python 3.12 for Kraken "
            "(a versioned install that does not change your default Python). "
            "If Homebrew is already installed, try running the app from Terminal so it can see your PATH, "
            "or install Homebrew from https://brew.sh and try again."
        )
    env = {**os.environ, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin:" + os.environ.get("PATH", "")}
    try:
        r = subprocess.run(
            [brew, "install", "python@3.12"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=300,
            env=env,
        )
        if r.returncode != 0:
            err = (r.stderr or r.stdout or "").strip()
            return False, f"Homebrew install of python@3.12 failed: {err[:800]}"
        return True, None
    except subprocess.TimeoutExpired:
        return False, "Homebrew install of python@3.12 timed out (5 min)."
    except FileNotFoundError:
        return False, "Homebrew not found."
    except Exception as e:
        return False, str(e)


# Fallback release tags (astral-sh/python-build-standalone) known to include Python 3.12 when "latest" does not.
_STANDALONE_PY312_FALLBACK_TAGS = ("20250103", "20241020", "20240224")

def _install_standalone_python312() -> str | None:
    """Download and extract Python 3.12 from astral-sh/python-build-standalone into ~/.mugi/python-3.12.
    Returns path to bin/python3 if successful, None on failure. Uses install_only.tar.gz (no .zst).
    Tries latest release first, then fallback tags if no 3.12 asset for this platform."""
    import platform
    import shutil
    import sys
    import tarfile
    import tempfile
    import urllib.request

    install_dir = os.path.abspath(os.path.expanduser(STANDALONE_PYTHON312_DIR))
    python_exe = os.path.join(install_dir, "bin", "python3")
    if os.path.isfile(python_exe):
        try:
            r = subprocess.run(
                [python_exe, "-c", "import sys; import venv; sys.exit(0 if sys.version_info[:2] == (3, 12) else 1)"],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=10,
            )
            if r.returncode == 0:
                return python_exe
        except Exception:
            pass
    machine = platform.machine().lower()
    if sys.platform == "darwin":
        if machine in ("arm64", "aarch64"):
            arch = "aarch64-apple-darwin"
        elif machine == "x86_64":
            arch = "x86_64-apple-darwin"
        else:
            return None
    else:
        return None
    want = f"-{arch}-"
    has_zstd = bool(shutil.which("zstd"))

    def pick_asset(assets: list) -> tuple[dict | None, bool]:
        """Return (asset, is_zst). Prefer full .tar.zst when zstd available (has full stdlib e.g. compileall, venv)."""
        if has_zstd:
            for a in assets:
                name = (a.get("name") or "")
                if "3.12" not in name or want not in name:
                    continue
                if "full" in name and name.endswith(".tar.zst"):
                    return a, True
        for a in assets:
            name = (a.get("name") or "")
            if "3.12" not in name or want not in name or not name.endswith("install_only.tar.gz"):
                continue
            return a, False
        return None, False

    release_urls = ["https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest"]
    for tag in _STANDALONE_PY312_FALLBACK_TAGS:
        release_urls.append(f"https://api.github.com/repos/astral-sh/python-build-standalone/releases/tags/{tag}")

    chosen = None
    for api_url in release_urls:
        try:
            req = urllib.request.Request(
                api_url,
                headers={"Accept": "application/vnd.github.v3+json"},
            )
            with urllib.request.urlopen(req, timeout=15, context=_SSL_CTX) as resp:
                release = json.loads(resp.read().decode("utf-8", errors="replace"))
        except Exception as e:
            if _debug_log:
                _debug_log(f"[Kraken] standalone Python: GitHub API {api_url!r} failed: {e}", "DEBUG")
            continue
        assets = release.get("assets") or []
        tag = release.get("tag_name") or ""
        chosen, use_zst = pick_asset(assets)
        if chosen:
            if _debug_log and "latest" not in api_url:
                _debug_log(f"[Kraken] standalone Python: using fallback tag {release.get('tag_name', '')}", "DEBUG")
            break
    if not chosen:
        if _debug_log:
            _debug_log("[Kraken] standalone Python: no 3.12 install_only.tar.gz for this platform in latest or fallback tags", "DEBUG")
        return None
    url = chosen.get("browser_download_url")
    if not url:
        return None
    try:
        parent = os.path.dirname(install_dir)
        if parent:
            os.makedirs(parent, exist_ok=True)
    except OSError as e:
        return None
    suffix = ".tar.zst" if use_zst else ".tar.gz"
    fd, archive_path = tempfile.mkstemp(suffix=suffix)
    try:
        os.close(fd)
        with urllib.request.urlopen(url, timeout=180, context=_SSL_CTX) as resp:
            with open(archive_path, "wb") as f:
                while True:
                    chunk = resp.read(65536)
                    if not chunk:
                        break
                    f.write(chunk)
        extract_dir = tempfile.mkdtemp(prefix="pbs-")
        try:
            if use_zst:
                tfd, tar_path = tempfile.mkstemp(suffix=".tar")
                os.close(tfd)
                try:
                    subprocess.run(
                        ["zstd", "-d", "-f", "-o", tar_path, archive_path],
                        check=True,
                        capture_output=True,
                        timeout=120,
                    )
                    with tarfile.open(tar_path, "r:") as tf:
                        tf.extractall(extract_dir)
                finally:
                    try:
                        os.unlink(tar_path)
                    except OSError:
                        pass
            else:
                with tarfile.open(archive_path, "r:gz") as tf:
                    tf.extractall(extract_dir)
            inner = os.path.join(extract_dir, "python", "install")
            if os.path.isdir(inner):
                if os.path.isdir(install_dir):
                    shutil.rmtree(install_dir)
                shutil.copytree(inner, install_dir)
            else:
                maybe_bin = os.path.join(extract_dir, "bin", "python3")
                if os.path.isfile(maybe_bin):
                    if os.path.isdir(install_dir):
                        shutil.rmtree(install_dir)
                    os.makedirs(install_dir, exist_ok=True)
                    for name in os.listdir(extract_dir):
                        shutil.move(os.path.join(extract_dir, name), os.path.join(install_dir, name))
                else:
                    top = os.listdir(extract_dir)
                    if len(top) == 1 and os.path.isdir(os.path.join(extract_dir, top[0])):
                        single = os.path.join(extract_dir, top[0])
                        if os.path.isdir(install_dir):
                            shutil.rmtree(install_dir)
                        shutil.copytree(single, install_dir)
                    else:
                        if os.path.isdir(install_dir):
                            shutil.rmtree(install_dir)
                        os.makedirs(install_dir, exist_ok=True)
                        for name in top:
                            shutil.move(os.path.join(extract_dir, name), os.path.join(install_dir, name))
        finally:
            try:
                shutil.rmtree(extract_dir)
            except OSError:
                pass
    except Exception as e:
        raise
    finally:
        try:
            os.unlink(archive_path)
        except OSError:
            pass
    alt = os.path.join(install_dir, "bin", "python")
    if not os.path.isfile(python_exe):
        if os.path.isfile(alt):
            python_exe = alt
        else:
            return None
    try:
        r = subprocess.run(
            [python_exe, "-c", "import sys; sys.exit(0 if sys.version_info[:2] == (3, 12) else 1)"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=10,
        )
        if r.returncode != 0:
            return None
        # Standalone build may omit the venv stdlib module; ensure_kraken_venv will use virtualenv fallback.
        return python_exe
    except Exception as e:
        return None


def remove_kraken_venv() -> tuple[bool, str]:
    """Remove the app-managed Kraken venv (~/.mugi/kraken-venv) if it exists.
    Returns (True, message) on success or when nothing was installed, (False, error_message) on failure.
    On success, resets KRAKEN_AVAILABLE and _KRAKEN_USE_VENV so the tool no longer uses the venv."""
    import shutil
    global KRAKEN_AVAILABLE, _KRAKEN_ERROR, _KRAKEN_USE_VENV
    try:
        target = os.path.realpath(KRAKEN_VENV_DIR)
        mugi_root = os.path.realpath(MUGI_DIR)
        if not target.startswith(mugi_root + os.sep) and target != mugi_root:
            return False, "Kraken venv path is outside Mugi directory; removal refused."
        if not os.path.isdir(target):
            _KRAKEN_USE_VENV = False
            KRAKEN_AVAILABLE = False
            _KRAKEN_ERROR = None
            return True, "No Kraken OCR environment was installed."
        shutil.rmtree(target)
        _KRAKEN_USE_VENV = False
        KRAKEN_AVAILABLE = False
        _KRAKEN_ERROR = None
        return True, "Kraken OCR environment has been removed. You can reinstall it from Settings → Advanced."
    except OSError as e:
        return False, f"Could not remove Kraken venv: {e}"
    except Exception as e:
        return False, str(e)


# Default recognition model: Greek–English (AjaxMultiCommentary/OCR-kraken-models).
_KRAKEN_DEFAULT_MODEL_NAME = "greek-english_porson_sophoclesplaysa05campgoog.mlmodel"
_KRAKEN_DEFAULT_MODEL_URL = (
    "https://raw.githubusercontent.com/AjaxMultiCommentary/OCR-kraken-models/main/"
    "kraken-models/greek-english_porson_sophoclesplaysa05campgoog/greek-english_porson_sophoclesplaysa05campgoog.mlmodel"
)


def _install_default_kraken_model() -> str:
    """Download the default Kraken model into the venv's share dir. Returns extra message (or ''); does not raise."""
    model_dir = os.path.join(KRAKEN_VENV_ROOT, ".local", "share", "kraken")
    model_path = os.path.join(model_dir, _KRAKEN_DEFAULT_MODEL_NAME)
    if os.path.isfile(model_path):
        return ""
    try:
        os.makedirs(model_dir, exist_ok=True)
    except OSError as e:
        return f"Model directory could not be created: {e}"
    try:
        req = urllib.request.Request(
            _KRAKEN_DEFAULT_MODEL_URL,
            headers={"User-Agent": "Mugi-Kraken-Installer/1.0"},
        )
        with urllib.request.urlopen(req, timeout=300, context=_SSL_CTX) as resp:
            chunk_size = 1 << 20
            with open(model_path, "wb") as f:
                while True:
                    chunk = resp.read(chunk_size)
                    if not chunk:
                        break
                    f.write(chunk)
        return "Default model (Greek–English) installed."
    except Exception as e:
        try:
            if os.path.isfile(model_path):
                os.remove(model_path)
        except OSError:
            pass
        return f"Default model download failed: {e}. You can add a model manually to ~/.local/share/kraken or the venv share dir."


def ensure_kraken_venv() -> tuple[bool, str]:
    """Create the app-managed Kraken venv (~/.mugi/kraken-venv) and install kraken + Pillow if missing.
    Returns (True, message) on success, (False, error_message) on failure.
    On success, sets KRAKEN_AVAILABLE and _KRAKEN_USE_VENV so the tool works without restart."""
    import sys
    global KRAKEN_AVAILABLE, _KRAKEN_ERROR, _KRAKEN_USE_VENV
    return _ensure_kraken_venv_impl()

def _ensure_kraken_venv_impl() -> tuple[bool, str]:
    import sys
    global KRAKEN_AVAILABLE, _KRAKEN_ERROR, _KRAKEN_USE_VENV
    parent = os.path.dirname(KRAKEN_VENV_DIR)
    if parent:
        try:
            os.makedirs(parent, exist_ok=True)
        except OSError as e:
            return False, f"Cannot create directory for Kraken venv: {e}"
    if os.path.isfile(KRAKEN_VENV_PYTHON):
        r = subprocess.run(
            [KRAKEN_VENV_PYTHON, "-c", "import kraken"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=15,
        )
        if r.returncode == 0:
            KRAKEN_AVAILABLE = True
            _KRAKEN_ERROR = None
            _KRAKEN_USE_VENV = True
            model_msg = _install_default_kraken_model()
            poppler_ok, poppler_msg = _ensure_poppler()
            parts = ["Kraken OCR environment is already installed.", model_msg]
            if poppler_ok and poppler_msg:
                parts.append(poppler_msg)
            elif not poppler_ok:
                parts.append("Poppler could not be installed; PDF OCR may fail. Install manually: brew install poppler.")
            return True, " ".join(p for p in parts if p).strip()
        # venv exists but kraken import failed; reinstall
    venv_python = _python_for_venv()
    if not venv_python:
        ok, err = _install_python312_via_brew()
        if not ok:
            return False, (err or "").strip()
        venv_python = _python_for_venv()
    if not venv_python and sys.platform != "darwin":
        venv_python = _install_standalone_python312()
    if not venv_python:
        return False, (
            "Python 3.12 is required to create the Kraken venv. No Python 3.12 was found. "
            "On macOS: install Homebrew (https://brew.sh) then click Install again, or install Python 3.12 from python.org. "
            "If you previously used a standalone install, remove ~/.mugi/python-3.12 so the app will use Homebrew."
        )
    venv_env = _clean_env_for_subprocess(os.path.dirname(venv_python))
    venv_bin_env = _clean_env_for_subprocess()
    try:
        r = subprocess.run(
            [venv_python, "-m", "venv", KRAKEN_VENV_DIR],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=120,
            env=venv_env,
        )
        err_text = (r.stderr or "") + (r.stdout or "")
        if r.returncode != 0 and ("No module named 'venv'" in err_text or "No module named venv" in err_text):
            # Standalone Python (e.g. python-build-standalone) may omit venv and pip; bootstrap pip then use virtualenv.
            try:
                subprocess.run(
                    [venv_python, "-m", "ensurepip", "--upgrade"],
                    check=True,
                    capture_output=True,
                    text=True,
                    encoding="utf-8",
                    errors="replace",
                    timeout=60,
                    env=venv_env,
                )
            except subprocess.CalledProcessError:
                pass
            # Standalone may have no pip; try get-pip.py if pip is missing.
            pip_ok = subprocess.run(
                [venv_python, "-m", "pip", "--version"],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=10,
                env=venv_env,
            ).returncode == 0
            if not pip_ok:
                import tempfile
                import urllib.request
                try:
                    with urllib.request.urlopen("https://bootstrap.pypa.io/get-pip.py", timeout=30, context=_SSL_CTX) as resp:
                        get_pip = resp.read().decode("utf-8", errors="replace")
                    fd, get_pip_path = tempfile.mkstemp(suffix=".py")
                    try:
                        os.write(fd, get_pip.encode())
                        os.close(fd)
                        r_getpip = subprocess.run(
                            [venv_python, get_pip_path],
                            capture_output=True,
                            text=True,
                            encoding="utf-8",
                            errors="replace",
                            timeout=120,
                            env=venv_env,
                        )
                        if r_getpip.returncode != 0:
                            out = (r_getpip.stdout or "").strip()
                            err = (r_getpip.stderr or "").strip()
                            msg = f"Could not bootstrap pip for standalone Python. get-pip.py failed (exit {r_getpip.returncode}): {err or out or 'no output'}"
                            combined = (err + out).lower()
                            if "no module named" in combined or "modulenotfounderror" in combined or "compileall" in combined or "sysconfig" in err or "sysconfig" in out:
                                msg += " The auto-installed Python is incomplete for pip. Install Python 3.12 from python.org or Homebrew (brew install python@3.12), then try again. You can remove ~/.mugi/python-3.12 so the app will use your system Python."
                            return False, msg
                    finally:
                        try:
                            os.unlink(get_pip_path)
                        except OSError:
                            pass
                except subprocess.TimeoutExpired as e:
                    return False, "Could not bootstrap pip: get-pip.py timed out."
                except OSError as e:
                    return False, f"Could not bootstrap pip for standalone Python: {e}"
                except Exception as e:
                    return False, f"Could not bootstrap pip for standalone Python: {e}"
            subprocess.run(
                [venv_python, "-m", "pip", "install", "virtualenv"],
                check=True,
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=120,
                env=venv_env,
            )
            r = subprocess.run(
                [venv_python, "-m", "virtualenv", KRAKEN_VENV_DIR],
                capture_output=True,
                text=True,
                encoding="utf-8",
                errors="replace",
                timeout=120,
                env=venv_env,
            )
        if r.returncode != 0:
            err = (r.stderr or r.stdout or str(r)).strip()
            return False, f"Failed to create venv: {err}"
    except subprocess.CalledProcessError as e:
        err = (e.stderr or e.stdout or str(e)).strip()
        return False, f"Failed to create venv: {err}"
    except FileNotFoundError:
        return False, "python -m venv not available."
    if not os.path.isfile(KRAKEN_VENV_PYTHON):
        return False, f"Venv created but {KRAKEN_VENV_PYTHON!r} not found."
    try:
        subprocess.run(
            [KRAKEN_VENV_PYTHON, "-m", "pip", "install", "--upgrade", "pip", "kraken", "Pillow"],
            check=True,
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=600,
            env=venv_bin_env,
        )
    except subprocess.CalledProcessError as e:
        err = (e.stderr or e.stdout or str(e)).strip()
        return False, f"pip install kraken failed: {err[:1500]}"
    except subprocess.TimeoutExpired:
        return False, "pip install kraken timed out (10 min)."
    r = subprocess.run(
        [KRAKEN_VENV_PYTHON, "-c", "import kraken"],
        capture_output=True,
        text=True,
        encoding="utf-8",
        errors="replace",
        timeout=15,
        env=venv_bin_env,
    )
    if r.returncode != 0:
        err = (r.stderr or r.stdout or "Unknown error").strip()
        return False, f"Kraken installed but import failed: {err[:1000]}"
    # Download default recognition model (Greek–English) so Kraken is usable without manual setup.
    model_msg = _install_default_kraken_model()
    poppler_ok, poppler_msg = _ensure_poppler()
    KRAKEN_AVAILABLE = True
    _KRAKEN_ERROR = None
    _KRAKEN_USE_VENV = True
    parts = ["Kraken OCR environment is ready. You can use Kraken OCR now.", model_msg]
    if poppler_ok and poppler_msg:
        parts.append(poppler_msg)
    elif not poppler_ok:
        parts.append("Poppler could not be installed; PDF OCR may fail. Install manually: brew install poppler.")
    return True, " ".join(p for p in parts if p).strip()


def _kraken_load_model(model_path: str | None):
    global _kraken_model_cache
    path = (model_path or "").strip()
    if not path:
        raise RuntimeError("Kraken requires an explicit model path.")
    with _kraken_model_lock:
        if path in _kraken_model_cache:
            return _kraken_model_cache[path]
        if not KRAKEN_AVAILABLE:
            raise RuntimeError(_KRAKEN_ERROR or "Kraken not available")
        try:
            model = _kraken_models.load_any(path)
            _kraken_model_cache[path] = model
            return model
        except Exception as e:
            raise RuntimeError(f"Failed to load Kraken model at {path}: {e}")


def _run_kraken_ocr_impl(
    file_path: str,
    output_format: str,
    model_path: str | None,
    timeout_seconds: int,
) -> str:
    abs_path = os.path.abspath(os.path.expanduser(file_path.strip(' "\n,')))
    if not os.path.exists(abs_path) or not os.path.isfile(abs_path):
        return f"File not found or not a file: {abs_path}"
    effective_model_path = _kraken_resolve_model_path(model_path)
    if not effective_model_path:
        return (
            f"Kraken: no recognition model found. Looked at {DEFAULT_KRAKEN_MODEL_PATH!r}, "
            "any *.mlmodel under KRAKEN_MODELS_DIR and venv share dirs. Run in the venv: kraken get 10.5281/zenodo.10592716"
        )
    lower = (output_format or "markdown").strip().lower()
    fmt = "latex" if lower == "latex" else "markdown"
    emit_live_view_preview(
        f"Kraken OCR: Using model: {effective_model_path}",
        tool_name="ocr_with_kraken_api",
        progress_pct=0,
    )
    _emit_input_status("Kraken OCR: Converting PDF to images…")
    emit_live_view_preview("Kraken OCR: Converting PDF to images…", tool_name="ocr_with_kraken_api", progress_pct=0)
    try:
        images = convert_from_path(abs_path, dpi=300)
    except Exception as e:
        return message_for_pdf2image_error(e)
    if not images:
        return "No pages could be converted from the PDF."
    total_pages = len(images)
    _emit_input_status(f"Kraken OCR: Converted {total_pages} page(s).")
    emit_live_view_preview(f"Kraken OCR: Converted {total_pages} page(s).", tool_name="ocr_with_kraken_api", progress_pct=5)

    if _KRAKEN_USE_VENV:
        import tempfile
        tmpdir = tempfile.mkdtemp(prefix="kraken_ocr_")
        image_paths = []
        try:
            for i, pil_im in enumerate(images, 1):
                path = os.path.join(tmpdir, f"page_{i}.png")
                pil_im.save(path)
                image_paths.append(path)
                try:
                    b64 = image_to_preview_base64(pil_im, max_width=320)
                    emit_live_view_preview(
                        f"Kraken OCR page {i}/{total_pages} (preparing)",
                        tool_name="ocr_with_kraken_api",
                        progress_pct=5 + int(35 * i / total_pages),
                        preview_image_base64=b64,
                    )
                except Exception:
                    pass
            payload = json.dumps(
                {
                    "images": image_paths,
                    "model_path": effective_model_path,
                }
            )
            _emit_input_status("Kraken OCR: Recognizing text…")
            emit_live_view_preview("Kraken OCR: Recognizing text…", tool_name="ocr_with_kraken_api", progress_pct=40)
            _KRAKEN_VENV_POLL_INTERVAL = 12
            deadline = (time.monotonic() + max(1, timeout_seconds)) if timeout_seconds > 0 else None
            stderr_path = os.path.join(tmpdir, "_stderr.log")
            stdout_path = os.path.join(tmpdir, "_stdout.txt")
            stderr_fh = open(stderr_path, "w", encoding="utf-8", errors="replace")
            stdout_fh = open(stdout_path, "w", encoding="utf-8", errors="replace")
            proc = subprocess.Popen(
                [KRAKEN_VENV_PYTHON, "-c", _KRAKEN_VENV_SCRIPT],
                stdin=subprocess.PIPE,
                stdout=stdout_fh,
                stderr=stderr_fh,
                text=True,
                encoding="utf-8",
                errors="replace",
                cwd=tmpdir,
                env=_clean_env_for_subprocess(),
            )
            try:
                proc.stdin.write(payload)
                proc.stdin.close()
            except OSError:
                pass
            last_emit = 0.0
            _STALL_TIMEOUT = 600 if timeout_seconds > 0 else None
            _last_stderr_size = 0
            _last_stderr_change = time.monotonic()
            while True:
                ret = proc.poll()
                if ret is not None:
                    break
                if deadline is not None and time.monotonic() > deadline:
                    try:
                        proc.kill()
                        proc.wait(timeout=5)
                    except Exception:
                        pass
                    stderr_fh.close()
                    try:
                        stdout_fh.close()
                    except Exception:
                        pass
                    return f"Kraken OCR timed out after {timeout_seconds} seconds. Try a smaller PDF or increase timeout_seconds."
                now = time.monotonic()
                try:
                    _cur_stderr_size = os.path.getsize(stderr_path)
                except OSError:
                    _cur_stderr_size = _last_stderr_size
                if _cur_stderr_size != _last_stderr_size:
                    _last_stderr_size = _cur_stderr_size
                    _last_stderr_change = now
                elif _STALL_TIMEOUT is not None and now - _last_stderr_change > _STALL_TIMEOUT:
                    try:
                        proc.kill()
                        proc.wait(timeout=5)
                    except Exception:
                        pass
                    stderr_fh.close()
                    try:
                        stdout_fh.close()
                    except Exception:
                        pass
                    return (
                        f"Kraken OCR appears stalled (no activity for {_STALL_TIMEOUT}s). "
                        "The subprocess was terminated. Please try again — if this persists, "
                        "the Kraken OCR environment may need to be reinstalled via Settings → Advanced."
                    )
                if now - last_emit >= _KRAKEN_VENV_POLL_INTERVAL:
                    last_emit = now
                    emit_live_view_preview(
                        "Kraken OCR: Recognizing text… (still running)",
                        tool_name="ocr_with_kraken_api",
                        progress_indeterminate=True,
                    )
                time.sleep(2)
            stderr_fh.close()
            try:
                stdout_fh.close()
            except Exception:
                pass
            try:
                with open(stdout_path, "r", encoding="utf-8", errors="replace") as _of:
                    stdout = _of.read()
            except Exception:
                stdout = ""
            try:
                with open(stderr_path, "r", encoding="utf-8", errors="replace") as _sf:
                    stderr = _sf.read()
            except Exception:
                stderr = ""
            if proc.returncode != 0:
                err = (stderr or stdout or "Unknown error").strip()
                hint = " Ensure the model exists or run 'kraken get 10.5281/zenodo.10592716' in the venv." if err else ""
                return f"Kraken OCR (venv) failed: {err}{hint}"
            raw_text = (stdout or "").strip()
        finally:
            try:
                for p in image_paths:
                    try:
                        os.unlink(p)
                    except OSError:
                        pass
                os.rmdir(tmpdir)
            except Exception:
                pass
    else:
        model = _kraken_load_model(effective_model_path)
        page_texts = []
        total_pages = len(images)
        for page_num, pil_im in enumerate(images, 1):
            _emit_input_status(f"Kraken OCR page {page_num}/{total_pages}…")
            try:
                bw = _kraken_binarization.nlbin(pil_im)
                seg = _kraken_pageseg.segment(bw)
                if not seg or not getattr(seg, "lines", None):
                    page_texts.append("")
                    continue
                lines = []
                pred_it = _kraken_rpred.rpred(network=model, im=bw, bounds=seg)
                for rec in pred_it:
                    pred = getattr(rec, "prediction", None) or getattr(rec, "text", "")
                    if pred:
                        lines.append(pred)
                page_text = "\n".join(lines)
                page_texts.append(page_text)
                try:
                    b64 = image_to_preview_base64(pil_im, max_width=320)
                    emit_live_view_preview(
                        f"Kraken OCR page {page_num}/{total_pages}",
                        tool_name="ocr_with_kraken_api",
                        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
            except Exception as e:
                page_texts.append(f"[Page {page_num} error: {e}]")
                _debug_log(f"[ocr_with_kraken_api] Page {page_num} error: {e}", "WARN")
        raw_text = "\n\n".join(page_texts)

    footnotes = defaultdict(list)
    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 fmt == "latex":
        return post_process_to_latex(raw_text, footnotes)
    return post_process_to_markdown(raw_text, footnotes)


def _recheck_venv_available() -> None:
    """If venv exists but KRAKEN_AVAILABLE is False (e.g. backend restarted after user installed),
    re-run the venv check so the agent sees Kraken as available without requiring a full restart."""
    global KRAKEN_AVAILABLE, _KRAKEN_ERROR, _KRAKEN_USE_VENV
    if KRAKEN_AVAILABLE or not os.path.isfile(KRAKEN_VENV_PYTHON):
        return
    try:
        r = subprocess.run(
            [KRAKEN_VENV_PYTHON, "-c", "import kraken"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=10,
            env=_clean_env_for_subprocess(),
        )
        if r.returncode == 0:
            KRAKEN_AVAILABLE = True
            _KRAKEN_ERROR = None
            _KRAKEN_USE_VENV = True
    except Exception:
        pass


# Prefer venv; else try direct import
if os.path.isfile(KRAKEN_VENV_PYTHON):
    try:
        r = subprocess.run(
            [KRAKEN_VENV_PYTHON, "-c", "import kraken"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=10,
            env=_clean_env_for_subprocess(),
        )
        if r.returncode == 0:
            KRAKEN_AVAILABLE = True
            _KRAKEN_ERROR = None
            _KRAKEN_USE_VENV = True
        else:
            _venv_check_stderr = r.stderr or r.stdout or ""
    except Exception as e:
        _venv_check_stderr = str(e)
if not KRAKEN_AVAILABLE:
    try:
        from kraken import binarization as _kraken_binarization
        from kraken import pageseg as _kraken_pageseg
        from kraken import rpred as _kraken_rpred
        from kraken.lib import models as _kraken_models
        KRAKEN_AVAILABLE = True
        _KRAKEN_ERROR = None
    except ImportError as e:
        KRAKEN_AVAILABLE = False
        _KRAKEN_ERROR = f"Kraken OCR is not available. Install via Settings → Advanced → Install Kraken OCR environment (not Homebrew). Import error: {e}"


@tool
def ocr_with_kraken_api(
    file_path: str,
    output_format: str = "markdown",
    model_path: str | None = None,
    timeout_seconds: int = _KRAKEN_DEFAULT_TIMEOUT,
) -> str:
    """High-performance OCR using Kraken. Best for old books, archaic glyphs, historical documents.
    Pipeline: PDF → images → binarize → segment → recognize. Use explicit model path or run 'kraken get 10.5281/zenodo.10592716' in the venv."""
    stream_id = f"ocr_kraken:{int(time.time() * 1000)}"
    open_job_stream(
        plugin_id="ocr_job",
        plugin_name="OCR Job",
        title="Kraken OCR",
        job_type="ocr_with_kraken_api",
        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},
    )
    _recheck_venv_available()
    if not KRAKEN_AVAILABLE:
        msg = _KRAKEN_ERROR or "Kraken OCR is not available. Install via Settings → Advanced → Install Kraken OCR environment (not Homebrew)."
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="init", message=msg)
        return msg

    global _kraken_debug_logged
    if not _kraken_debug_logged:
        _kraken_debug_logged = True
        resolved = _kraken_resolve_model_path(None)
        _debug_log(
            f"[Kraken OCR init] KRAKEN_AVAILABLE={KRAKEN_AVAILABLE}, _KRAKEN_USE_VENV={_KRAKEN_USE_VENV}, "
            f"resolved_model={resolved!r}",
            "INFO",
        )

    # Stage ALL files to a safe path before any read (security: no exceptions).
    file_path = (file_path or "").strip(' "\n,')
    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 or not a file: {abs_path}"
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="validate", 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="validate", message=msg)
        return msg
    _workspace = os.path.expanduser("~/.mugi/workspace")
    _staging_dir = os.path.join(_workspace, "kraken_staging")
    os.makedirs(_staging_dir, exist_ok=True)
    _staging_pdf = os.path.join(_staging_dir, f"staging_{uuid.uuid4().hex}.pdf")
    try:
        shutil.copy2(abs_path, _staging_pdf)
    except Exception as e:
        msg = f"Could not stage file to safe location: {e}"
        error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="stage", message=msg)
        return msg

    result_holder = [None]
    exc_holder = [None]
    def run():
        try:
            result_holder[0] = _run_kraken_ocr_impl(_staging_pdf, output_format, model_path, 0)
        except Exception as e:
            exc_holder[0] = e
    try:
        update_job_stream(
            plugin_id="ocr_job",
            plugin_name="OCR Job",
            stream_id=stream_id,
            phase="ocr",
            status="running",
            progress_pct=5,
            message="Running Kraken OCR...",
        )
        thread = Thread(target=run, daemon=True)
        thread.start()
        while thread.is_alive():
            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="Kraken OCR cancelled by user.",
                )
                return "Kraken OCR cancelled by user."
            thread.join(timeout=0.25)
        if exc_holder[0] is not None:
            msg = f"Kraken OCR failed: {exc_holder[0]}"
            error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="ocr", message=msg)
            return msg
        text = result_holder[0] or ""
        lower_text = text.lower()
        if text.startswith("Kraken") and (
            "failed" in lower_text
            or "not available" in lower_text
            or "timed out" in lower_text
            or "no recognition model" in lower_text
            or "appears stalled" in lower_text
            or "subprocess was terminated" in lower_text
        ):
            error_job_stream(plugin_id="ocr_job", plugin_name="OCR Job", stream_id=stream_id, phase="ocr", message=text[:400])
            return text
        if text:
            text = apply_ocr_correction(text)
        close_job_stream(
            plugin_id="ocr_job",
            plugin_name="OCR Job",
            stream_id=stream_id,
            status="completed",
            message="Kraken OCR complete.",
            metrics={"chars_total": float(len(text or ""))},
        )
        return text
    finally:
        if _staging_pdf and os.path.isfile(_staging_pdf):
            try:
                os.unlink(_staging_pdf)
            except Exception:
                pass
        clear_stream_state(stream_id)


@tool
def ocr_with_kraken_api_to_file(
    pdf_path: str,
    output_file_path: str,
    output_format: str = "markdown",
    model_path: str | None = None,
    timeout_seconds: int = _KRAKEN_DEFAULT_TIMEOUT,
) -> str:
    """Run Kraken OCR on a PDF and write the result directly to a file."""
    _recheck_venv_available()
    if not KRAKEN_AVAILABLE:
        return _KRAKEN_ERROR or "Kraken OCR is not available. Install via Settings → Advanced → Install Kraken OCR environment (not Homebrew)."

    pdf_path = pdf_path.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 not _is_probably_pdf(pdf_abs):
        return "This tool is PDF-only. For PNG/JPG/WebP images, use ocr_image_text(file_path=...)."
    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="overwrite")
    if _um_err:
        return _um_err

    # Stage ALL files to a safe path before any read (security: no exceptions for size).
    _workspace = os.path.expanduser("~/.mugi/workspace")
    _staging_dir = os.path.join(_workspace, "kraken_staging")
    os.makedirs(_staging_dir, exist_ok=True)
    _staging_pdf = os.path.join(_staging_dir, f"staging_{uuid.uuid4().hex}.pdf")
    try:
        shutil.copy2(pdf_abs, _staging_pdf)
    except Exception as e:
        return f"Could not stage PDF to safe location: {e}"

    file_size_mb = os.path.getsize(_staging_pdf) / (1024 * 1024)
    part_paths = []
    if file_size_mb >= _KRAKEN_LARGE_FILE_MB:
        num_parts = max(2, math.ceil(file_size_mb / _KRAKEN_LARGE_FILE_MB))
        _debug_log(f"[ocr_with_kraken_api_to_file] PDF {file_size_mb:.1f} MB >= {_KRAKEN_LARGE_FILE_MB} MB; splitting into {num_parts} parts", "INFO")
        _emit_input_status("Splitting PDF for Kraken OCR…")
        emit_live_view_preview("Splitting PDF for Kraken OCR…", tool_name="ocr_with_kraken_api_to_file", progress_pct=0)
        try:
            split_result = split_pdf.invoke({
                "input_json": json.dumps({
                    "file_path": _staging_pdf,
                    "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:
            try:
                if os.path.isfile(_staging_pdf):
                    os.unlink(_staging_pdf)
            except Exception:
                pass
            return f"Split failed: {e}"
        if not part_paths:
            try:
                if os.path.isfile(_staging_pdf):
                    os.unlink(_staging_pdf)
            except Exception:
                pass
            return f"Split did not return part paths. Result: {str(split_result)[:500]}"
    else:
        part_paths = [_staging_pdf]

    source_page_count = _pdf_page_count(_staging_pdf)
    parsed_part_page_counts = [_pdf_page_count(p) for p in part_paths] if part_paths else []

    if (
        source_page_count > 0
        and len(part_paths) > 1
        and all(c == source_page_count for c in parsed_part_page_counts if c > 0)
    ):
        try:
            fallback_parts = _fallback_split_pdf_equal(_staging_pdf, _staging_dir, len(part_paths))
            fallback_counts = [_pdf_page_count(p) for p in fallback_parts]
            if fallback_parts and all(c > 0 for c in fallback_counts):
                part_paths = fallback_parts
        except Exception:
            pass

    separator = "\n\n---\n\n"
    all_parts = []
    try:
        for i, part_path in enumerate(part_paths):
            _emit_input_status(f"Kraken OCR part {i + 1}/{len(part_paths)}…")
            emit_live_view_preview(f"Kraken OCR part {i + 1}/{len(part_paths)}…", tool_name="ocr_with_kraken_api_to_file", progress_pct=int(80 * (i + 1) / len(part_paths)))
            result_holder = [None]
            exc_holder = [None]
            def run(_path=part_path):
                try:
                    result_holder[0] = _run_kraken_ocr_impl(_path, output_format or "markdown", model_path, 0)
                except Exception as e:
                    exc_holder[0] = e
            thread = Thread(target=run, daemon=True)
            thread.start()
            thread.join()
            if exc_holder[0] is not None:
                return f"Kraken OCR failed on part {i + 1}: {exc_holder[0]}"
            part_text = _sanitize_ocr_output(result_holder[0] or "")
            lower_part_text = part_text.lower()
            if part_text.startswith("Kraken") and (
                "failed" in lower_part_text
                or "not available" in lower_part_text
                or "timed out" in lower_part_text
                or "no recognition model" in lower_part_text
                or "appears stalled" in lower_part_text
                or "subprocess was terminated" in lower_part_text
            ):
                return part_text
            all_parts.append(part_text)
    finally:
        if _staging_pdf and os.path.isfile(_staging_pdf):
            try:
                os.unlink(_staging_pdf)
            except Exception:
                pass
        for _p in part_paths:
            if os.path.isfile(_p):
                try:
                    os.unlink(_p)
                except Exception:
                    pass
        if _staging_dir and os.path.isdir(_staging_dir):
            try:
                if not os.listdir(_staging_dir):
                    os.rmdir(_staging_dir)
            except Exception:
                pass
    text = separator.join(all_parts)
    text = _sanitize_ocr_output(text)
    text = apply_ocr_correction(text)
    try:
        with open(out_abs, "w", encoding="utf-8") as f:
            f.write(text)
            f.flush()
            try:
                os.fsync(f.fileno())
            except (OSError, AttributeError):
                pass
    except OSError as e:
        return f"Kraken OCR succeeded but could not finalize write to {out_abs}: {e}"
    if not os.path.isfile(out_abs):
        return f"Kraken OCR completed but output file was not created at {out_abs}."
    size_bytes = os.path.getsize(out_abs)
    return f"Done. Kraken OCR saved to:\n{out_abs}\n(File size: {size_bytes:,} bytes). Tell the user their document is at that path."
