"""Cache validation for ``*.mineru_raw/`` bundles.

Validation policy (settled in design discussion; see
``LightRAGSidecarFormat-zh.md`` related notes):

1. ``_manifest.json`` exists, parses, ``version=1.0`` ∧ ``engine=mineru``.
2. **Source size fast-path**: ``source_file.stat().st_size`` matches manifest;
   mismatch → miss without hashing.
3. **Source content_hash**: full sha256 of the current source file matches
   manifest. The size+hash pair is computed by a single-read helper so the
   stored manifest is internally self-consistent.
4. **API mode**: if the manifest recorded ``api_mode`` and it differs from
   current ``MINERU_API_MODE``, miss.
5. **Parser options**: the manifest must record an ``options_signature`` that
   matches the current effective MinerU request options. Missing signatures
   from older manifests are treated as stale.
6. **Engine version**: if ``MINERU_ENGINE_VERSION`` is set and the manifest
   recorded a non-empty one, they must match.
7. **Endpoint signature**: if the active MinerU endpoint is set and the
   manifest recorded a non-empty one, they must match.
8. **Critical file**: ``content_list.json`` must exist with matching size
   **and** sha256 — sha256 here is the final tie-breaker against silent
   corruption affecting the file the adapter depends on.
9. **Other files**: size-only verification (cheap; covers most corruption
   modes for image / middle.json / layout.pdf).

Any failed step ⇒ cache miss; the caller wipes the directory contents
(preserving the directory itself) and re-runs the download.
"""

from __future__ import annotations

import hashlib
import json
import os
from collections.abc import Mapping
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

from lightrag.constants import MINERU_RAW_DIR_SUFFIX, PARSED_DIR_SUFFIX
from lightrag.parser.external.mineru.manifest import load_manifest
from lightrag.utils import logger

DEFAULT_MINERU_API_MODE = "local"
DEFAULT_MINERU_OFFICIAL_ENDPOINT = "https://mineru.net"
DEFAULT_MINERU_MODEL_VERSION = "vlm"
DEFAULT_MINERU_LANGUAGE = "ch"
DEFAULT_MINERU_LOCAL_BACKEND = "hybrid-auto-engine"
DEFAULT_MINERU_LOCAL_PARSE_METHOD = "auto"
DEFAULT_MINERU_LOCAL_IMAGE_ANALYSIS = False
DEFAULT_MINERU_LOCAL_START_PAGE_ID = 0
DEFAULT_MINERU_LOCAL_END_PAGE_ID = 99999
DEFAULT_MINERU_ENABLE_TABLE = True
DEFAULT_MINERU_ENABLE_FORMULA = True
DEFAULT_MINERU_IS_OCR = False


def raw_dir_for_parsed_dir(parsed_dir: Path) -> Path:
    """Sibling raw dir for a given ``*.parsed`` dir.

    ``foo.parsed/`` → ``foo.mineru_raw/``. Used both at download time and at
    cache check time so the layout is canonical.
    """
    stem = parsed_dir.name
    if stem.endswith(PARSED_DIR_SUFFIX):
        stem = stem[: -len(PARSED_DIR_SUFFIX)]
    return parsed_dir.parent / f"{stem}{MINERU_RAW_DIR_SUFFIX}"


def clear_dir_contents(directory: Path) -> None:
    """Delete everything inside ``directory`` but keep ``directory`` itself."""
    if not directory.exists():
        return
    for entry in directory.iterdir():
        try:
            if entry.is_dir() and not entry.is_symlink():
                _rmtree_safe(entry)
            else:
                entry.unlink()
        except OSError:
            # Best-effort cleanup; subsequent download will overwrite.
            continue


def _rmtree_safe(directory: Path) -> None:
    import shutil

    shutil.rmtree(directory, ignore_errors=True)


def compute_size_and_hash(path: Path) -> tuple[int, str]:
    """Single-read computation of ``(size_bytes, "sha256:<hex>")``.

    Manifest writes use this so the recorded size and hash are guaranteed to
    describe the same byte stream; using two ``open()`` calls would risk a
    TOCTOU mismatch if the file changed in between.
    """
    h = hashlib.sha256()
    size = 0
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
            size += len(chunk)
    return size, f"sha256:{h.hexdigest()}"


def _current_api_mode() -> str:
    mode = _normalize_api_mode(os.getenv("MINERU_API_MODE", DEFAULT_MINERU_API_MODE))
    return mode


def _normalize_api_mode(mode: str) -> str:
    mode = str(mode or "").strip().lower()
    return mode if mode in {"official", "local"} else DEFAULT_MINERU_API_MODE


def _env_bool(name: str, default: bool) -> bool:
    raw = os.getenv(name, "").strip().lower()
    if raw in {"1", "true", "yes", "on"}:
        return True
    if raw in {"0", "false", "no", "off"}:
        return False
    return default


def _env_int(name: str, default: int) -> int:
    raw = os.getenv(name, "").strip()
    if not raw:
        return default
    try:
        return int(raw)
    except ValueError:
        logger.warning(
            "[mineru_raw] %s=%r is not an integer; using %s", name, raw, default
        )
        return default


def _current_endpoint_signature() -> str:
    mode = _current_api_mode()
    if mode == "official":
        return (
            os.getenv("MINERU_OFFICIAL_ENDPOINT", DEFAULT_MINERU_OFFICIAL_ENDPOINT)
            .strip()
            .rstrip("/")
        )
    if mode == "local":
        return os.getenv("MINERU_LOCAL_ENDPOINT", "").strip().rstrip("/")
    return ""


def local_page_bounds(page_ranges: str) -> tuple[int, int]:
    raw = page_ranges.strip()
    if not raw:
        return DEFAULT_MINERU_LOCAL_START_PAGE_ID, DEFAULT_MINERU_LOCAL_END_PAGE_ID
    if "," in raw:
        raise ValueError(
            "MINERU_PAGE_RANGES with MINERU_API_MODE=local supports only a "
            "single page or simple range such as '1-10'"
        )
    if raw.isdigit():
        page = max(int(raw), 1)
        return page - 1, page - 1
    if "-" in raw:
        left, _, right = raw.partition("-")
        if left.isdigit() and right.isdigit():
            start = max(int(left), 1)
            end = max(int(right), start)
            return start - 1, end - 1
    raise ValueError(
        "MINERU_PAGE_RANGES with MINERU_API_MODE=local must be a single "
        "positive page number or simple range such as '1-10'"
    )


@dataclass(frozen=True)
class MinerUParserOptions:
    """Effective MinerU parser options used both for live requests and the
    cache signature.

    Constructed once via :meth:`from_env` so the client and the cache
    validator agree on every defaulting / normalization rule.
    """

    api_mode: str
    model_version: str
    language: str
    enable_table: bool
    enable_formula: bool
    is_ocr: bool
    page_ranges: str
    local_backend: str
    local_parse_method: str
    local_image_analysis: bool
    local_start_page_id: int
    local_end_page_id: int

    @classmethod
    def from_env(
        cls,
        *,
        api_mode: str | None = None,
        overrides: "Mapping[str, Any] | None" = None,
    ) -> "MinerUParserOptions":
        """Build the effective options from env, with optional per-file overrides.

        ``overrides`` carries decoded per-file engine params (``page_range`` →
        ``page_ranges``, ``language``, ``local_parse_method``).  They feed both
        the live request and the cache signature, so an overridden document gets
        its own bundle.
        """
        overrides = overrides or {}
        mode = (
            _normalize_api_mode(api_mode)
            if api_mode is not None
            else _current_api_mode()
        )
        page_ranges = str(
            overrides.get("page_range", os.getenv("MINERU_PAGE_RANGES", ""))
        ).strip()
        language = (
            str(
                overrides.get(
                    "language", os.getenv("MINERU_LANGUAGE", DEFAULT_MINERU_LANGUAGE)
                )
            ).strip()
            or DEFAULT_MINERU_LANGUAGE
        )
        local_parse_method = (
            str(
                overrides.get(
                    "local_parse_method",
                    os.getenv(
                        "MINERU_LOCAL_PARSE_METHOD", DEFAULT_MINERU_LOCAL_PARSE_METHOD
                    ),
                )
            ).strip()
            or DEFAULT_MINERU_LOCAL_PARSE_METHOD
        )
        local_start = _env_int(
            "MINERU_LOCAL_START_PAGE_ID", DEFAULT_MINERU_LOCAL_START_PAGE_ID
        )
        local_end = _env_int(
            "MINERU_LOCAL_END_PAGE_ID", DEFAULT_MINERU_LOCAL_END_PAGE_ID
        )
        if mode == "local" and page_ranges:
            local_start, local_end = local_page_bounds(page_ranges)
        return cls(
            api_mode=mode,
            model_version=(
                os.getenv("MINERU_MODEL_VERSION", DEFAULT_MINERU_MODEL_VERSION).strip()
                or DEFAULT_MINERU_MODEL_VERSION
            ),
            language=language,
            enable_table=_env_bool("MINERU_ENABLE_TABLE", DEFAULT_MINERU_ENABLE_TABLE),
            enable_formula=_env_bool(
                "MINERU_ENABLE_FORMULA", DEFAULT_MINERU_ENABLE_FORMULA
            ),
            is_ocr=_env_bool("MINERU_IS_OCR", DEFAULT_MINERU_IS_OCR),
            page_ranges=page_ranges,
            local_backend=(
                os.getenv("MINERU_LOCAL_BACKEND", DEFAULT_MINERU_LOCAL_BACKEND).strip()
                or DEFAULT_MINERU_LOCAL_BACKEND
            ),
            local_parse_method=local_parse_method,
            local_image_analysis=_env_bool(
                "MINERU_LOCAL_IMAGE_ANALYSIS", DEFAULT_MINERU_LOCAL_IMAGE_ANALYSIS
            ),
            local_start_page_id=local_start,
            local_end_page_id=local_end,
        )

    def signature(self) -> str:
        return mineru_options_signature(**asdict(self))


def mineru_options_signature(
    *,
    api_mode: str,
    model_version: str = DEFAULT_MINERU_MODEL_VERSION,
    language: str = DEFAULT_MINERU_LANGUAGE,
    enable_table: bool = DEFAULT_MINERU_ENABLE_TABLE,
    enable_formula: bool = DEFAULT_MINERU_ENABLE_FORMULA,
    is_ocr: bool = DEFAULT_MINERU_IS_OCR,
    page_ranges: str = "",
    local_backend: str = DEFAULT_MINERU_LOCAL_BACKEND,
    local_parse_method: str = DEFAULT_MINERU_LOCAL_PARSE_METHOD,
    local_image_analysis: bool = DEFAULT_MINERU_LOCAL_IMAGE_ANALYSIS,
    local_start_page_id: int = DEFAULT_MINERU_LOCAL_START_PAGE_ID,
    local_end_page_id: int = DEFAULT_MINERU_LOCAL_END_PAGE_ID,
) -> str:
    mode = _normalize_api_mode(api_mode)
    payload: dict[str, Any] = {
        "signature_version": 1,
        "api_mode": mode,
        "language": str(language or "").strip() or DEFAULT_MINERU_LANGUAGE,
        "enable_table": bool(enable_table),
        "enable_formula": bool(enable_formula),
    }
    if mode == "official":
        payload.update(
            {
                "model_version": str(model_version or "").strip()
                or DEFAULT_MINERU_MODEL_VERSION,
                "is_ocr": bool(is_ocr),
                "page_ranges": str(page_ranges or "").strip(),
            }
        )
    else:
        payload.update(
            {
                "local_backend": str(local_backend or "").strip()
                or DEFAULT_MINERU_LOCAL_BACKEND,
                "local_parse_method": str(local_parse_method or "").strip()
                or DEFAULT_MINERU_LOCAL_PARSE_METHOD,
                "local_image_analysis": bool(local_image_analysis),
                "local_start_page_id": int(local_start_page_id),
                "local_end_page_id": int(local_end_page_id),
            }
        )

    raw = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return "sha256:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()


def current_mineru_options_signature(
    overrides: "Mapping[str, Any] | None" = None,
) -> str:
    return MinerUParserOptions.from_env(overrides=overrides).signature()


def is_bundle_valid(
    raw_dir: Path,
    source_file: Path,
    *,
    overrides: "Mapping[str, Any] | None" = None,
) -> bool:
    """Return True iff the bundle is intact and matches the current source.

    See module docstring for the full policy. Returns False on any of:
    missing manifest, malformed manifest, schema version mismatch, source
    size/hash mismatch, parser options mismatch, engine/endpoint env mismatch,
    critical file missing or corrupted, or any non-critical file size mismatch.
    """
    if not raw_dir.is_dir():
        return False

    manifest = load_manifest(raw_dir)
    if manifest is None:
        return False

    # 1. Source size fast-path
    try:
        cur_size = source_file.stat().st_size
    except OSError:
        return False
    if cur_size != int(manifest.source_size_bytes):
        return False

    # 2. Source content_hash
    _, cur_hash = compute_size_and_hash(source_file)
    if cur_hash != manifest.source_content_hash:
        return False

    # 3. API mode (only when manifest had one; old manifests remain compatible)
    cur_api_mode = _current_api_mode()
    if manifest.api_mode and cur_api_mode != manifest.api_mode:
        return False

    # 4. Parser options. Old manifests did not record this and must miss so
    # changes such as MINERU_LOCAL_BACKEND cannot silently reuse stale output.
    if not manifest.options_signature:
        return False
    if current_mineru_options_signature(overrides) != manifest.options_signature:
        return False

    # 5. Engine version (only when current env exposes one AND manifest had one)
    cur_engine_version = os.getenv("MINERU_ENGINE_VERSION", "").strip()
    if (
        cur_engine_version
        and manifest.engine_version
        and cur_engine_version != manifest.engine_version
    ):
        return False

    # 6. Endpoint signature
    cur_endpoint = _current_endpoint_signature()
    if (
        cur_endpoint
        and manifest.endpoint_signature
        and cur_endpoint != manifest.endpoint_signature
    ):
        return False

    # 7. Critical file: size + sha256
    crit = manifest.critical_file
    crit_path = raw_dir / crit.path
    try:
        if crit_path.stat().st_size != int(crit.size):
            return False
    except OSError:
        return False
    if crit.sha256:
        _, crit_actual = compute_size_and_hash(crit_path)
        if crit_actual != crit.sha256:
            return False

    # 8. Other files: size only
    for entry in manifest.files:
        ep = raw_dir / entry.path
        try:
            if ep.stat().st_size != int(entry.size):
                return False
        except OSError:
            return False

    return True


__all__ = [
    "MINERU_RAW_DIR_SUFFIX",
    "MinerUParserOptions",
    "clear_dir_contents",
    "compute_size_and_hash",
    "current_mineru_options_signature",
    "is_bundle_valid",
    "local_page_bounds",
    "mineru_options_signature",
    "raw_dir_for_parsed_dir",
]
