"""Parameter schema + parenthesis-aware scanners for hint parameters.

This module is the single source of truth for the *parameters* that may be
attached to chunk-strategy selectors inside a parser hint or a
``LIGHTRAG_PARSER`` rule, e.g. ``[-R(chunk_ts=800,chunk_ol=80)]`` or
``pdf:legacy-R(chunk_ts=800)``.

Design (see ``docs/FileProcessingPipeline.md``):

* Inside ``(...)`` a comma is **only** a parameter separator; a single
  parameter value never contains ``,`` ``(`` ``)`` or ``]``.
* Parameter names use a readable ``canonical`` form with optional short
  ``alias`` forms; everything is normalised to canonical before use, so the
  internal structures never carry an alias.
* Parameters are declared per *target* (a chunk selector char ``F``/``R``/``V``/
  ``P``) with a type and the set of selectors they apply to.

Chunk-parameter scope: the integer ``chunk_token_size`` (alias ``chunk_ts``)
and ``chunk_overlap_token_size`` (alias ``chunk_ol``), plus the boolean
``drop_references`` (alias ``drop_rf``, paragraph-semantic only), all flowing
through the existing per-document ``chunk_options`` channel.  Float/enum chunk
parameters are still rejected with a friendly error; adding them is a matter of
registering new :class:`ParamSpec` entries and an extra ``kind`` branch in
:func:`parse_chunk_params`.

This module is import-cheap and has no dependency on
:mod:`lightrag.parser.routing` so it can be reused without import cycles.
"""

from __future__ import annotations

import os
import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any

from lightrag.constants import (
    PARSER_ENGINE_DOCLING,
    PARSER_ENGINE_MINERU,
    PROCESS_OPTION_CHUNK_FIXED,
    PROCESS_OPTION_CHUNK_PARAGRAH,
    PROCESS_OPTION_CHUNK_RECURSIVE,
    PROCESS_OPTION_CHUNK_VECTOR,
)

# Characters a parameter value may never contain.  ``]`` can never appear
# anyway (``_PARSER_HINT_RE`` terminates the bracket on ``]``) but is rejected
# explicitly so the error is friendly rather than a silent truncation.
_VALUE_FORBIDDEN = frozenset(",()]")


# ---------------------------------------------------------------------------
# Parenthesis-aware scanners (shared by routing's rule/engine/options splits)
# ---------------------------------------------------------------------------


def split_top_level(text: str, separators: str) -> list[str]:
    """Split ``text`` on any char in ``separators`` at parenthesis depth 0.

    Characters inside ``(...)`` are protected, so a comma used to separate
    parameters never splits the surrounding rule / options string.  Always
    returns at least one element (the whole string when no separator is hit).
    """
    parts: list[str] = []
    depth = 0
    start = 0
    for i, ch in enumerate(text):
        if ch == "(":
            depth += 1
        elif ch == ")":
            if depth > 0:
                depth -= 1
        elif depth == 0 and ch in separators:
            parts.append(text[start:i])
            start = i + 1
    parts.append(text[start:])
    return parts


def take_paren_block(text: str, i: int) -> tuple[str | None, int]:
    """Read a balanced ``(...)`` block starting at ``text[i]``.

    Returns ``(inner, index_just_after_closing_paren)`` when ``text[i]`` opens
    a balanced block, otherwise ``(None, i)`` — used for both "no block here"
    (``text[i] != '('``) and "unterminated block".  Callers that need to flag
    an unterminated block compare the returned index / ``None`` accordingly.
    """
    if i >= len(text) or text[i] != "(":
        return None, i
    depth = 0
    for j in range(i, len(text)):
        if text[j] == "(":
            depth += 1
        elif text[j] == ")":
            depth -= 1
            if depth == 0:
                return text[i + 1 : j], j + 1
    return None, i  # unterminated


# ---------------------------------------------------------------------------
# Parameter schema registry
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class ParamSpec:
    """Declares one tunable hint parameter for a set of chunk selectors.

    ``kind`` is ``"int"`` or ``"bool"`` today; the field exists so future
    types (``float``/``enum``/``range_list``) slot in without a structural
    change.  ``targets`` is the set of chunk selector chars the parameter is
    valid for (e.g. overlap is invalid for ``V``, ``drop_references`` only
    applies to ``P``).
    """

    canonical: str
    aliases: frozenset[str]
    kind: str
    targets: frozenset[str]
    is_list: bool = False
    min_value: int | None = None
    names: frozenset[str] = field(init=False)

    def __post_init__(self) -> None:
        object.__setattr__(self, "names", frozenset({self.canonical}) | self.aliases)


_ALL_CHUNK_SELECTORS = frozenset(
    {
        PROCESS_OPTION_CHUNK_FIXED,
        PROCESS_OPTION_CHUNK_RECURSIVE,
        PROCESS_OPTION_CHUNK_VECTOR,
        PROCESS_OPTION_CHUNK_PARAGRAH,
    }
)

# Phase 1 registry — keep this list small; expand it (and only it) to grow
# hint-parameter coverage.
_CHUNK_PARAM_SPECS: tuple[ParamSpec, ...] = (
    ParamSpec(
        canonical="chunk_token_size",
        aliases=frozenset({"chunk_ts"}),
        kind="int",
        targets=_ALL_CHUNK_SELECTORS,
        min_value=1,
    ),
    ParamSpec(
        canonical="chunk_overlap_token_size",
        aliases=frozenset({"chunk_ol"}),
        kind="int",
        # Semantic-vector (V) chunking has no overlap concept.
        targets=frozenset(
            {
                PROCESS_OPTION_CHUNK_FIXED,
                PROCESS_OPTION_CHUNK_RECURSIVE,
                PROCESS_OPTION_CHUNK_PARAGRAH,
            }
        ),
        min_value=0,
    ),
    # Paragraph-semantic only: drop the trailing reference section before
    # chunking.  Detection-tuning knobs (tail window / heading prefixes) are
    # env-only and read live by the chunker, so only the switch is a hint param.
    ParamSpec(
        canonical="drop_references",
        aliases=frozenset({"drop_rf"}),
        kind="bool",
        targets=frozenset({PROCESS_OPTION_CHUNK_PARAGRAH}),
    ),
)

_CHUNK_PARAM_BY_NAME: dict[str, ParamSpec] = {}
for _spec in _CHUNK_PARAM_SPECS:
    for _name in _spec.names:
        _CHUNK_PARAM_BY_NAME[_name] = _spec


def supported_chunk_param_names() -> str:
    """Comma-joined canonical names, for friendly error messages."""
    return ", ".join(sorted(spec.canonical for spec in _CHUNK_PARAM_SPECS))


def _parse_int(value: str) -> int | None:
    try:
        return int(value, 10)
    except (TypeError, ValueError):
        return None


def parse_chunk_params(
    text: str, *, selector: str, label: str
) -> tuple[dict[str, Any], list[str]]:
    """Parse one chunk-strategy parameter block into a canonical dict.

    ``text`` is the raw text inside ``(...)`` (parameter separators only —
    no surrounding parens).  ``selector`` is the chunk char the block is
    attached to (``F``/``R``/``V``/``P``).  Returns ``(canonical_dict,
    errors)``; ``errors`` is empty iff the block is fully valid.  Aliases are
    normalised to their canonical name in the returned dict.

    A boolean parameter may be written bare as a flag — ``P(drop_rf)`` is
    shorthand for ``P(drop_rf=true)``.  Non-boolean parameters still require
    the explicit ``key=value`` form.
    """
    result: dict[str, Any] = {}
    errors: list[str] = []
    seen: set[str] = set()

    for raw in split_top_level(text, ","):
        segment = raw.strip()
        if not segment:
            errors.append(f"{label}: empty parameter")
            continue
        if "=" in segment:
            key, _, value = segment.partition("=")
            key = key.strip()
            value = value.strip()
            flag_form = False
        else:
            # Bare flag form, e.g. ``drop_rf``.  Only valid for boolean
            # parameters, where it is shorthand for ``drop_rf=true``.
            key = segment
            value = ""
            flag_form = True

        spec = _CHUNK_PARAM_BY_NAME.get(key)
        if spec is None:
            errors.append(
                f"{label}: unknown parameter {key!r}; supported parameters: "
                f"{supported_chunk_param_names()}"
            )
            continue
        if selector not in spec.targets:
            errors.append(
                f"{label}: parameter {spec.canonical!r} is not supported for "
                f"chunk strategy {selector!r}"
            )
            continue
        if flag_form:
            if spec.kind != "bool":
                errors.append(
                    f"{label}: parameter {spec.canonical!r} must be written as "
                    "'key=value'; only boolean flags may be written bare"
                )
                continue
            value = "true"  # bare boolean flag means True
        if any(ch in _VALUE_FORBIDDEN for ch in value):
            errors.append(
                f"{label}: value for {spec.canonical!r} may not contain any of "
                "',' '(' ')' ']'"
            )
            continue
        if spec.canonical in seen and not spec.is_list:
            errors.append(f"{label}: parameter {spec.canonical!r} may not be repeated")
            continue
        seen.add(spec.canonical)

        if spec.kind == "int":
            parsed = _parse_int(value)
            if parsed is None:
                errors.append(
                    f"{label}: value for {spec.canonical!r} must be an integer, "
                    f"got {value!r}"
                )
                continue
            if spec.min_value is not None and parsed < spec.min_value:
                errors.append(
                    f"{label}: value for {spec.canonical!r} must be >= "
                    f"{spec.min_value}, got {parsed}"
                )
                continue
            result[spec.canonical] = parsed
        elif spec.kind == "bool":
            parsed_bool = _parse_bool(value)
            if parsed_bool is None:
                errors.append(
                    f"{label}: value for {spec.canonical!r} must be a boolean "
                    f"(true/false), got {value!r}"
                )
                continue
            result[spec.canonical] = parsed_bool
        else:  # pragma: no cover - only int/bool kinds registered today
            errors.append(
                f"{label}: parameter {spec.canonical!r} has unsupported type "
                f"{spec.kind!r}"
            )

    # Cross-field invariant: reject an explicit overlap >= size pair here so
    # every caller (rule startup validation AND filename-hint validation)
    # rejects it uniformly, instead of only failing later at enqueue.
    overlap_error = chunk_param_overlap_error(result)
    if overlap_error is not None:
        errors.append(f"{label}: {overlap_error}")

    return result, errors


def chunk_param_overlap_error(params: Mapping[str, Any]) -> str | None:
    """Return an error string when a block sets an invalid overlap/size pair.

    Only checks the cross-field invariant when **both** ``chunk_token_size`` and
    ``chunk_overlap_token_size`` are explicitly present in ``params`` (a parsed
    canonical dict).  When only one is given the effective value depends on
    env / ``addon_params`` and cannot be evaluated here — that case is left to
    the upload-time ``_validate_effective_chunk_overlap`` on the resolved
    snapshot.  Returns ``None`` when the pair is valid or not fully specified.
    """
    size = params.get("chunk_token_size")
    overlap = params.get("chunk_overlap_token_size")
    if size is not None and overlap is not None and overlap >= size:
        return (
            f"chunk_overlap_token_size ({overlap}) must be < chunk_token_size ({size})"
        )
    return None


# ---------------------------------------------------------------------------
# Engine parameters (Phase 2) — per-file params attached to the engine token,
# e.g. ``mineru(page_range=1-3,language=en)`` / ``docling(force_ocr=true)``.
# Keyed by engine name (unlike chunk params, which are keyed by F/R/V/P).
# ---------------------------------------------------------------------------

_BOOL_TRUE = frozenset({"1", "true", "yes", "on", "t", "y"})
_BOOL_FALSE = frozenset({"0", "false", "no", "off", "f", "n"})
# A single page-range segment: ``N`` or ``N-M`` (validated for positivity /
# ordering separately).
_PAGE_SEGMENT_RE = re.compile(r"^\d+(?:-\d+)?$")


@dataclass(frozen=True)
class EngineParamSpec:
    """Declares one tunable engine parameter (Phase 2).

    Separate from the chunk :class:`ParamSpec` (which requires a ``targets``
    set of F/R/V/P selectors that is meaningless for engines).  ``kind`` is one
    of ``"str"`` / ``"enum"`` / ``"bool"``.  ``is_list`` marks a repeated-key
    parameter (``page_range``) whose canonical value is a comma-joined string.
    ``enum_values`` constrains an ``"enum"`` parameter.
    """

    canonical: str
    aliases: frozenset[str]
    kind: str
    is_list: bool = False
    enum_values: frozenset[str] | None = None
    names: frozenset[str] = field(init=False)

    def __post_init__(self) -> None:
        object.__setattr__(self, "names", frozenset({self.canonical}) | self.aliases)


# Phase 2 registry — keyed by engine name; expand by adding specs (and, for a
# new engine, a new dict entry).  An engine absent here accepts NO parameters.
_ENGINE_PARAM_SPECS: dict[str, tuple[EngineParamSpec, ...]] = {
    PARSER_ENGINE_MINERU: (
        EngineParamSpec(
            canonical="page_range",
            aliases=frozenset({"pr"}),
            kind="str",
            is_list=True,
        ),
        EngineParamSpec(canonical="language", aliases=frozenset(), kind="str"),
        EngineParamSpec(
            canonical="local_parse_method",
            aliases=frozenset({"local_pm"}),
            kind="enum",
            enum_values=frozenset({"auto", "txt", "ocr"}),
        ),
    ),
    PARSER_ENGINE_DOCLING: (
        EngineParamSpec(canonical="force_ocr", aliases=frozenset({"ocr"}), kind="bool"),
    ),
}

_ENGINE_PARAM_BY_NAME: dict[str, dict[str, EngineParamSpec]] = {
    engine: {name: spec for spec in specs for name in spec.names}
    for engine, specs in _ENGINE_PARAM_SPECS.items()
}


def engine_params_supported(engine: str) -> bool:
    """Whether ``engine`` declares any tunable hint parameters."""
    return engine in _ENGINE_PARAM_SPECS


def supported_engine_param_names(engine: str) -> str:
    """Comma-joined canonical engine-param names, for error messages."""
    specs = _ENGINE_PARAM_SPECS.get(engine, ())
    return ", ".join(sorted(spec.canonical for spec in specs))


def _parse_bool(value: str) -> bool | None:
    low = value.strip().lower()
    if low in _BOOL_TRUE:
        return True
    if low in _BOOL_FALSE:
        return False
    return None


def _mineru_api_mode_is_local() -> bool:
    """True when MinerU runs in local mode (the default when unset).

    Read here (rather than importing ``mineru.cache``) to keep this module a
    dependency-free leaf.  Mirrors ``cache._normalize_api_mode``: anything that
    is not ``official`` is treated as local.
    """
    return (os.getenv("MINERU_API_MODE", "") or "").strip().lower() != "official"


def _validate_page_range_segments(parts: list[str]) -> list[str]:
    """Validate page-range segment shape + the MinerU local single-segment rule.

    ``official`` mode forwards a multi-segment list verbatim; ``local`` accepts
    only a single page / range (mirrors ``cache.local_page_bounds``).  The
    download-time ``local_page_bounds`` remains the final backstop.
    """
    errors: list[str] = []
    for seg in parts:
        if not _PAGE_SEGMENT_RE.match(seg):
            errors.append(
                f"page_range segment {seg!r} must be a page 'N' or range 'N-M'"
            )
            continue
        if "-" in seg:
            left, _, right = seg.partition("-")
            if int(left) < 1 or int(right) < 1:
                errors.append(f"page_range segment {seg!r} must use positive pages")
            elif int(right) < int(left):
                errors.append(f"page_range segment {seg!r} must have end >= start")
        elif int(seg) < 1:
            errors.append(f"page_range segment {seg!r} must be a positive page")
    if not errors and len(parts) > 1 and _mineru_api_mode_is_local():
        errors.append(
            "page_range with MINERU_API_MODE=local supports only a single page "
            "or range such as '1-10'; use MINERU_API_MODE=official for a "
            "multi-segment list"
        )
    return errors


def _coerce_engine_value(
    spec: EngineParamSpec, value: str, *, label: str
) -> tuple[Any, str | None]:
    """Validate + coerce a single engine-param value to its canonical type.

    Returns ``(coerced, error)``; ``error`` is ``None`` when valid.  Shared by
    the text path (:func:`parse_engine_params`) and the resolved-dict path
    (:func:`normalize_engine_params`) so both apply identical rules.  For the
    list-type ``page_range`` the ``value`` is the already-joined comma string.
    """
    # ``local_parse_method`` only feeds the local MinerU request + signature;
    # the official API neither sends it nor folds it into the cache key, so
    # accepting it under official mode would persist a directive that silently
    # does nothing. Reject it here (mirrors the page_range mode rule).
    if spec.canonical == "local_parse_method" and not _mineru_api_mode_is_local():
        return None, (
            f"{label}: 'local_parse_method' only applies to "
            "MINERU_API_MODE=local (the default); the official API ignores it"
        )
    if spec.kind == "bool":
        parsed = _parse_bool(value)
        if parsed is None:
            return None, (
                f"{label}: value for {spec.canonical!r} must be a boolean "
                f"(true/false), got {value!r}"
            )
        return parsed, None
    if spec.kind == "enum":
        if spec.enum_values is not None and value not in spec.enum_values:
            allowed = ", ".join(sorted(spec.enum_values))
            return None, (
                f"{label}: value for {spec.canonical!r} must be one of "
                f"{allowed}, got {value!r}"
            )
        return value, None
    # str (incl. the list-type page_range, whose value is a comma-joined string)
    if not value:
        return None, f"{label}: value for {spec.canonical!r} must be non-empty"
    if spec.is_list and spec.canonical == "page_range":
        segments = [p.strip() for p in value.split(",")]
        seg_errors = _validate_page_range_segments(segments)
        if seg_errors:
            return None, f"{label}: " + "; ".join(seg_errors)
        return ",".join(segments), None
    return value, None


def parse_engine_params(
    text: str, *, engine: str, label: str
) -> tuple[dict[str, Any], list[str]]:
    """Parse one engine parameter block into a canonical, coerced dict.

    ``text`` is the raw text inside ``(...)`` for an engine token; ``engine`` is
    the (bare) engine the block is attached to.  Returns ``(canonical_dict,
    errors)``; aliases are normalised to canonical and values are coerced to
    their declared type (so ``force_ocr`` is a real ``bool``).  A list-type
    ``page_range`` collects repeated keys and joins them with ``,``.
    """
    by_name = _ENGINE_PARAM_BY_NAME.get(engine)
    if by_name is None:
        if text.strip():
            return {}, [f"{label}: parser engine {engine!r} does not accept parameters"]
        return {}, []

    result: dict[str, Any] = {}
    list_values: dict[str, list[str]] = {}
    errors: list[str] = []
    seen: set[str] = set()

    for raw in split_top_level(text, ","):
        segment = raw.strip()
        if not segment:
            errors.append(f"{label}: empty parameter")
            continue
        if "=" not in segment:
            if _PAGE_SEGMENT_RE.match(segment) and (
                "page_range" in by_name or "pr" in by_name
            ):
                errors.append(
                    f"{label}: page lists must repeat the key, e.g. "
                    "'page_range=1-3,page_range=5' (a comma only separates "
                    "parameters)"
                )
            else:
                errors.append(
                    f"{label}: parameter {segment!r} must be written as "
                    "'key=value' (flag parameters are not supported yet)"
                )
            continue
        key, _, value = segment.partition("=")
        key = key.strip()
        value = value.strip()

        spec = by_name.get(key)
        if spec is None:
            errors.append(
                f"{label}: unknown parameter {key!r} for engine {engine!r}; "
                f"supported parameters: {supported_engine_param_names(engine)}"
            )
            continue
        if any(ch in _VALUE_FORBIDDEN for ch in value):
            errors.append(
                f"{label}: value for {spec.canonical!r} may not contain any of "
                "',' '(' ')' ']'"
            )
            continue
        if spec.is_list:
            list_values.setdefault(spec.canonical, []).append(value)
            continue
        if spec.canonical in seen:
            errors.append(f"{label}: parameter {spec.canonical!r} may not be repeated")
            continue
        seen.add(spec.canonical)
        coerced, error = _coerce_engine_value(spec, value, label=label)
        if error is not None:
            errors.append(error)
            continue
        result[spec.canonical] = coerced

    # Join repeated-key list params, then coerce/validate the joined value.
    for canonical, values in list_values.items():
        spec = by_name[canonical]
        coerced, error = _coerce_engine_value(spec, ",".join(values), label=label)
        if error is not None:
            errors.append(error)
            continue
        result[canonical] = coerced

    return result, errors


def normalize_engine_params(
    engine: str, params: Mapping[str, Any]
) -> tuple[dict[str, Any], list[str]]:
    """Normalise + validate an already-resolved engine-param dict.

    Used by the pipeline layer where direct (SDK/API) callers bypass routing's
    text parsing.  Returns a **coerced** dict (e.g. ``force_ocr`` becomes a real
    ``bool``, ``page_range`` a validated comma-joined string) so what gets
    persisted is exactly what the engine override seam consumes.  Accepts a
    ``page_range`` value as either a list or a comma-joined string.
    """
    if not params:
        return {}, []
    by_name = _ENGINE_PARAM_BY_NAME.get(engine)
    if by_name is None:
        return {}, [f"parser engine {engine!r} does not accept parameters"]

    result: dict[str, Any] = {}
    errors: list[str] = []
    for key, value in params.items():
        spec = by_name.get(str(key))
        if spec is None:
            errors.append(
                f"unknown parameter {key!r} for engine {engine!r}; supported "
                f"parameters: {supported_engine_param_names(engine)}"
            )
            continue
        if spec.is_list and isinstance(value, (list, tuple)):
            value = ",".join(str(v).strip() for v in value)
        else:
            value = str(value).strip()
        coerced, error = _coerce_engine_value(
            spec, value, label=f"engine parameter {spec.canonical!r}"
        )
        if error is not None:
            errors.append(error)
            continue
        result[spec.canonical] = coerced
    return result, errors


def render_engine_params(
    engine: str, params: Mapping[str, Any]
) -> tuple[str, list[str]]:
    """Render a resolved engine-param dict to the canonical inner text.

    Returns ``(inner_text, errors)`` where ``inner_text`` is the
    ``key=value,...`` string that goes inside the ``parse_engine`` parens (e.g.
    ``page_range=1-3,page_range=5,language=en``).  Normalises first (so the
    output always round-trips through :func:`parse_engine_params`); a list-type
    value is emitted as **repeated keys**, a bool as ``true``/``false``.  Keys
    are sorted for deterministic output.
    """
    normalized, errors = normalize_engine_params(engine, params)
    if errors:
        return "", errors
    by_name = _ENGINE_PARAM_BY_NAME.get(engine, {})
    parts: list[str] = []
    for canonical in sorted(normalized):
        spec = by_name[canonical]
        value = normalized[canonical]
        if spec.is_list:
            parts.extend(f"{canonical}={seg}" for seg in str(value).split(","))
        elif spec.kind == "bool":
            parts.append(f"{canonical}={'true' if value else 'false'}")
        else:
            parts.append(f"{canonical}={value}")
    return ",".join(parts), []
