# src/pdftl/operations/update_bookmarks.py

"""Update PDF Table of Contents from a YAML or JSON file."""

import json
import logging

import pdftl.core.constants as c
from pdftl.core.core_types import OpResult
from pdftl.core.registry import register_operation
from pdftl.exceptions import MissingArgumentError, OperationError
from pdftl.info.toc import build_toc_tree
from pdftl.utils.dependencies import ensure_dependencies
from pdftl.utils.io_helpers import smart_open

logger = logging.getLogger(__name__)

_UPDATE_TOC_LONG_DESC = """Replaces the document's Table of Contents
(bookmarks) entirely using a specified YAML or JSON file, or YAML data
on stdin if `bookmarks_file` is set to "`-`".

This operation consumes the structured format generated by the
`dump_bookmarks` command. It applies validation to prevent broken
PDFs: it validates page boundaries, catches typos in property names,
and ensures the hierarchical tree structure is sound.

### Important Rules & Behaviors
* **Full Replacement:** This command does not merge or append bookmarks. It completely
  overwrites the existing Table of Contents with the contents of your file.
* **Page Indexing:** Target `page` numbers must be 1-indexed (e.g., `page: 1`
  points to the very first physical page of the document).
* **Boundary Checking:** If a bookmark points to a page number that exceeds
  the total number of pages in the PDF, the operation will fail safely to
  prevent document corruption.
* **Strict Validation:** Unrecognized properties (such as a typoed `pagee` instead
  of `page`) will trigger an error rather than being silently ignored. This ensures
  your routing logic always behaves as expected.
* **Precedence:** `dump_bookmarks` output may contain a `dest` field alongside resolved
  `page` and `view` fields. This means that the bookmark uses a named destination, and
  for convenience, the dump includes the resolved page number and view parameters.
  For `update_bookmarks`, the `dest` field, if present, always takes precedence.
  If both are present, a warning is issued on first encounter.
  * To update `page` and `view`, delete the `dest` field.
  * To avoid the warning and keep the `dest` field only, delete `page` and `view`.
    Alternatively, use the `no_resolve` keyword when using `dump_bookmarks` to
    avoid this issue.

### Clearing Bookmarks
If you want to completely remove the Table of Contents from a PDF, you
can provide a file containing just an empty list.
* **YAML file content:** `[]`
* **JSON file content:** `{ "bookmarks": [] }`

### Dependency note
Loading YAML files requires the `pyyaml` library. If it is not installed,
you can install it via `pip install pdftl[yaml-bookmarks]`, or simply use
`.json` files natively.
"""

_UPDATE_TOC_EXAMPLES = [
    {
        "desc": "Apply a YAML bookmarks file",
        "cmd": "in.pdf update_bookmarks bookmarks.yaml output modified.pdf",
        "test_setup": {"copy_assets": {"bookmarks.yaml": "bookmarks.yaml"}},
    },
    {
        "desc": "Apply a JSON bookmarks file",
        "cmd": "in.pdf update_bookmarks bookmarks.json output modified.pdf",
        "test_setup": {"copy_assets": {"bookmarks.json": "bookmarks.json"}},
    },
]


@register_operation(
    "update_bookmarks",
    tags=["info", "metadata", "bookmarks"],
    type="single input operation",
    desc="Replace PDF bookmarks from a YAML or JSON file",
    long_desc=_UPDATE_TOC_LONG_DESC,
    examples=_UPDATE_TOC_EXAMPLES,
    usage="<input> update_bookmarks <bookmarks_file> output <filename>",
    args=(
        [c.INPUT_PDF, c.OPERATION_ARGS],
        {c.OPTIONS: "options"},
    ),
)
def update_toc(pdf, op_args, options=None) -> OpResult:
    toc_items = (options or {}).get("bookmarks")
    if toc_items is None:
        file_path, is_yaml, is_json = _get_file_traits(op_args)
        yaml_module = _try_import_yaml(is_yaml)

        with smart_open(file_path, mode="r") as f:
            content = f.read()

        toc_items, parsers_tried, errors = _parse_content(content, is_json, is_yaml, yaml_module)

        if toc_items is None:
            _handle_parse_failure(content, file_path, yaml_module, errors, parsers_tried)

    _validate_toc_structure(toc_items)
    logger.debug("Got %s items for the new toc tree", len(toc_items))
    _validate_node_schema(toc_items)
    _enforce_precedence_and_warn(toc_items, {"emitted": False})
    _apply_toc_tree(pdf, toc_items)

    return OpResult(success=True, pdf=pdf)


def _enforce_precedence_and_warn(nodes: list[dict], warning_state: dict):
    """Recursively strips derived keys if 'dest' is present, warning the user once."""
    for node in nodes:
        has_dest = "dest" in node
        has_derived = "page" in node or "view" in node

        if has_dest and has_derived:
            logger.debug(
                f"Precedence enforced for node '{node.get('title')}': "
                "'dest' overrides 'page'/'view'."
            )
            if not warning_state["emitted"]:
                logger.warning(
                    "Bookmarks contain both 'dest' and 'page'/'view'. "
                    "The 'dest' key takes precedence. Derived keys will be ignored. "
                    "(Further occurrences suppressed; see debug logs)"
                )
                warning_state["emitted"] = True

            # Strip derived keys so the backend doesn't try to apply both!
            node.pop("page", None)
            node.pop("view", None)

        if "children" in node and isinstance(node["children"], list):
            _enforce_precedence_and_warn(node["children"], warning_state)


def _validate_node_routing(title, node):
    """Validates routing collisions and view dependencies."""
    if "uri" in node and ("page" in node or "dest" in node):
        raise OperationError(
            f"Validation Error in '{title}': Cannot contain both a web link ('uri') "
            "and an internal document target ('page'/'dest')."
        )
    if "view" in node and "page" not in node and "dest" not in node:
        raise OperationError(
            f"Validation Error in '{title}': A 'view' array requires a target 'page'."
        )


def _validate_node_color(title, node):
    """Validates the color field if present."""
    if "color" not in node:
        return
    color = node["color"]
    if (
        not isinstance(color, list)
        or len(color) != 3
        or not all(isinstance(c, (int, float)) for c in color)
    ):
        raise OperationError(
            f"Validation Error in '{title}': 'color' must be a list of 3 numbers."
        )


def _validate_node_types(title, node):
    """Validates field types for page, children, and view."""
    if "page" in node and not isinstance(node["page"], int):
        raise OperationError(f"Validation Error in '{title}': 'page' must be an integer.")
    if "children" in node and not isinstance(node["children"], list):
        raise OperationError(f"Validation Error in '{title}': 'children' must be a list.")
    if "view" in node and not isinstance(node["view"], list):
        raise OperationError(f"Validation Error in '{title}': 'view' must be a list.")


def _validate_node_schema(nodes):
    """Recursively validates bookmark schema, types, and routing logic."""
    for node in nodes:
        if not isinstance(node, dict):
            raise OperationError(f"Expected a dictionary for bookmark, got: {type(node).__name__}")
        if "title" not in node:
            raise OperationError(f"Missing required 'title' field in node: {node}")
        if not isinstance(node["title"], str):
            raise OperationError(f"Bookmark 'title' must be a string, got: {node['title']}")

        title = node["title"]
        _validate_node_types(title, node)
        _validate_node_color(title, node)
        _validate_node_routing(title, node)

        if node.get("children"):
            _validate_node_schema(node["children"])


def _get_file_traits(op_args):
    """Validate operational arguments and discover file type intentions."""
    if not op_args:
        raise MissingArgumentError("update_bookmarks requires a <bookmarks_file> argument")

    file_path = op_args[0]
    if file_path == "-":
        file_path = None

    is_yaml = file_path is not None and file_path.lower().endswith((".yaml", ".yml"))
    is_json = file_path is not None and file_path.lower().endswith(".json")
    return file_path, is_yaml, is_json


def _try_import_yaml(is_yaml_ext: bool):
    """Attempt parsing library extraction, ensuring explicit path rules are hit."""
    try:
        import yaml

        return yaml
    except ImportError:
        logger.debug("No YAML support")
        if is_yaml_ext:
            _ensure_yaml_or_raise()
        return None


def _ensure_yaml_or_raise():
    ensure_dependencies(
        feature_name="YAML bookmarks loading",
        dependencies={"yaml": "pyyaml"},
        extra_tag="yaml",
    )


def _try_parse_json(content: str, is_json_ext: bool):
    """Isolated handling of standard JSON library conversions."""
    try:
        return json.loads(content), None
    except json.JSONDecodeError as exc:
        if is_json_ext:
            raise OperationError(f"Failed to parse JSON file: {exc}") from exc
        return None, exc


def _try_parse_yaml(content: str, is_yaml_ext: bool, yaml_module):
    """Isolated handling of third-party YAML library conversions."""
    try:
        return yaml_module.safe_load(content), None
    except yaml_module.YAMLError as exc:
        if is_yaml_ext:
            raise OperationError(f"Failed to parse YAML file: {exc}") from exc
        return None, exc


def _parse_content(content: str, is_json: bool, is_yaml: bool, yaml_module):
    """Determine runtime parser order sequences and catch execution exceptions."""
    toc_items = None
    parsers_tried = []
    errors = {}

    if not is_yaml:
        parsers_tried.append("json")
        toc_items, json_exc = _try_parse_json(content, is_json)
        if json_exc:
            errors["json"] = json_exc

    if toc_items is None and yaml_module is not None:
        parsers_tried.append("yaml")
        toc_items, yaml_exc = _try_parse_yaml(content, is_yaml, yaml_module)
        if yaml_exc:
            errors["yaml"] = yaml_exc

    return toc_items, parsers_tried, errors


def _handle_parse_failure(content: str, file_path, yaml_module, errors, parsers_tried):
    """Process contextual edge cases for stream failures and invalid strings."""
    if file_path is None and yaml_module is None:
        if content.strip().startswith(("[", "{")):
            json_err = errors.get("json", "Unknown JSON error")
            raise OperationError(f"Invalid JSON data provided on stdin: {json_err}")

        _ensure_yaml_or_raise()

    error_msgs = ", ".join([f"{k}: {v}" for k, v in errors.items()])
    raise OperationError(
        f"Could not parse bookmarks input. Parsers tried: {parsers_tried}. Details: {error_msgs}"
    )


def _validate_toc_structure(toc_items):
    """Ensure the underlying structure matches our required target types."""
    if not isinstance(toc_items, list):
        raise OperationError(
            "Invalid bookmarks format: Root element must be a list of items."
            " To delete all bookmarks, use []."
        )


def _apply_toc_tree(pdf, toc_items):
    """Final structural interface to apply the TOC items onto our PDF asset."""
    try:
        build_toc_tree(pdf, toc_items)
    except ValueError as exc:
        raise OperationError(exc) from exc
