# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

# src/pdftl/operations/export_import_fonts.py

"""
Operations for exporting and importing fonts and their metadata from/to PDFs.

Registered Operations:
  - export_fonts
  - import_fonts

The actual "how" of these operations lives in pdftl/operations/helpers/:
  - font_ops_shared.py    shared helpers (hashing, name sanitizing, page resolution)
  - font_export_helpers.py   export-side logic (manifest building, sidecar writing)
  - font_import_helpers.py   import-side logic (stream writing, widths sync, etc.)

Pure fontTools-based binary manipulation (reading/patching/squashing metrics
directly inside a TTF/OTF file) lives in pdftl/fonts/font_binary_utils.py,
alongside the codebase's other stateless font-domain utilities.
"""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any

import pdftl.core.constants as c
from pdftl.core.core_types import OpResult
from pdftl.core.registry import register_operation
from pdftl.exceptions import InvalidArgumentError
from pdftl.operations.helpers.font_export_helpers import build_manifest
from pdftl.operations.helpers.font_import_helpers import (
    import_cid_to_gid_map,
    import_differences,
    import_encoding_cmap,
    import_single_font_binary,
    import_tounicode_and_get_mappings,
    import_widths,
)
from pdftl.operations.helpers.font_ops_shared import get_target_pages
from pdftl.utils.hooks import from_result_meta
from pdftl.utils.io_helpers import smart_open_maybe_dash
from pdftl.utils.keyval_parser import parse_keyval_list
from pdftl.utils.dependencies import ensure_dependencies

if TYPE_CHECKING:
    import pikepdf

logger = logging.getLogger(__name__)


# ============================================================================
# User Documentation & Examples
# ============================================================================

_EXPORT_FONTS_LONG_DESC = """
The `export_fonts` operation extracts embedded and referenced font assets from the
PDF, writes their binary programs (.ttf, .otf, .pfb) to a directory, and compiles
their wrapping metrics (/Widths, /W, /Differences) and /ToUnicode mappings into a
unified, structured JSON file.

This unifies character code mappings and layout metrics into a single sidecar file:
  `font_{obj_id}_{gen_id}_{name}.json`

This file contains character-by-character definitions of both layout metrics
(width) and semantic mappings (unicode) using a unified hexadecimal indexing schema.
It also carries a top-level `width_sync_mode` field (default `auto`) that
controls how `import_fonts` reconciles width edits back into the PDF — see
`import_fonts` help for the available modes.

Beyond widths and ToUnicode, the sidecar also carries, when applicable:
  - A `descriptor` block with the font's /FontDescriptor properties (FontName,
    Flags, FontBBox, Ascent/Descent, StemV, Panose, etc).
  - `differences` / `base_encoding`, mirroring a Simple font's /Encoding.
  - `encoding_cmap`, recording whether a Type0 font's /Encoding is
    /Identity-H or /Identity-V.
  - `cid_to_gid_map`, either `"Identity"` or a reference to a separate
    `font_{obj_id}_{gen_id}_{name}.cid2gid.json` sidecar for an explicit
    /CIDToGIDMap table.

Type 3 fonts have no font binary at all: their glyph procedures are extracted
instead into a `font_{obj_id}_{gen_id}_{name}.charprocs` text file (one block
per glyph), with any embedded inline bitmap images pulled out losslessly as
sibling TIFF files.

### Arguments

* `<directory>`: **(Required)** The directory where the extracted font files will be saved.
* `[output <manifest.json>]`: The file path to save the JSON manifest. If omitted, the
  manifest is automatically saved as `manifest.json` inside the specified directory.
  Specify `output -` to print the manifest directly to standard output.
* `[tounicode=<json|ps|all>]`: Which format to use for /ToUnicode mapping sidecars.
  - `json` (default): Write high-level unified character mapping JSON.
  - `ps`: Write raw CMap PostScript stream text.
  - `all`: Write both formats side-by-side.
* `[page_range]`: Optional page ranges to limit the extraction.
"""

_EXPORT_FONTS_EXAMPLES = [
    {
        "cmd": "in.pdf export_fonts my_fonts",
        "desc": (
            "Extract all fonts to 'my_fonts/' and save the manifest to 'my_fonts/manifest.json'."
        ),
    },
    {
        "cmd": "in.pdf export_fonts 1-5 edited_assets tounicode=all output manifest.json",
        "desc": (
            "Extract fonts from pages 1-5 into 'edited_assets/' and write "
            "manifest to 'manifest.json'."
        ),
    },
]

_IMPORT_FONTS_LONG_DESC = """
The `import_fonts` operation reads a JSON manifest (generated by `export_fonts`) and
overwrites the corresponding internal PDF font structures with the edited assets from a
directory.

It automatically handles:
  - Binary Font stream injection if MD5 hashes differ (safe skip on unmodified files).
  - Character metrics updates based on the 'width_sync_mode' parameter.
  - Re-compilation of sidecar ToUnicode JSON maps back to compliant PostScript CMaps.
  - /FontDescriptor property edits from the sidecar's `descriptor` block.
  - /CIDToGIDMap restoration, from either `"Identity"` or an explicit
    `.cid2gid.json` sidecar.
  - Type 3 glyph procedure reconstruction (including inline images) from a
    `.charprocs` file.

If both `.json` and `.ps` files exist for a given font's /ToUnicode map, an error is
raised to prevent ambiguity. The user must delete or rename one of them first.

`encoding_cmap` edits are limited: the only supported change is switching a
Type0 font's /Encoding between /Identity-H and /Identity-V. Any other value
written into `encoding_cmap` is rejected with a warning, and /Encoding is left
untouched — there is no general mechanism here for re-encoding into an
arbitrary CMap.

### Width Sync Modes

Each font's unified JSON sidecar (`font_{obj_id}_{gen_id}_{name}.json`) has a
top-level `width_sync_mode` field, defaulting to `auto` on export. This controls
how `import_fonts` reconciles the PDF's /Widths (or /W for CID fonts) with any
edits:

  - `auto` (default): Reads the true metrics out of the edited font binary (if
    its MD5 changed since export) and writes those into the PDF. If the binary
    is unchanged or unreadable, falls back to `manual` behavior using any
    `width.pdf` values present in the sidecar mappings.
  - `manual`: Writes the `width.pdf` values from the sidecar mappings directly
    into the PDF, ignoring the font binary.
  - `patch_font_metrics`: Dynamically patches the font binary's horizontal metrics table
    in-memory to match the sidecar's `width.pdf` values, and writes the patched stream
    directly into the PDF (leaving workspace files untouched).
  - `squash_font_vectors`: Dynamically rescales the font binary's glyph outlines in-memory
    to visually fit the sidecar's `width.pdf` values, and writes the squashed stream
    directly into the PDF (leaving workspace files untouched).
  - `preserve`: Leaves the PDF's existing /Widths untouched entirely.

`patch_font_metrics` and `squash_font_vectors` require the embedded font file to
be present in the directory; without it, no width sync occurs for that font.

For both of these modes, the in-memory binary edit is only ever best-effort:
if it can't be applied (an unresolvable CID-to-GID mapping, no character
codes in the font actually matched, or a malformed/unreadable font program),
`import_fonts` still writes the sidecar's `width.pdf` values into /Widths or
/W directly, so a requested width edit is never silently dropped even when
the font program itself couldn't be patched.

### Format-Specific Limitations

When employing `squash_font_vectors`, be advised that this mode relies on direct,
affine coordinate scaling which is only structurally supported for TrueType outline
representations (`glyf` tables). For OpenType-CFF (CFF/CFF2) and classic Adobe Type 1
fonts which do not possess a standard TrueType `glyf` table, the operation degrades
silently to `patch_font_metrics` mode—meaning the font binary's internal character
metrics will be updated correctly in-memory to prevent layout mismatches, but the
visual glyph outline vectors will remain unscaled.
"""

_IMPORT_FONTS_EXAMPLES = [
    {
        "cmd": "in.pdf import_fonts my_fonts output out.pdf",
        "desc": "Import edited fonts from 'my_fonts/' and save.",
        "test_setup": {
            "mkdirs": ["my_fonts"],
            "create_files": {"my_fonts/manifest.json": '{ "fonts": [] }'},
        },
    },
]


# ============================================================================
# export_fonts
# ============================================================================


def export_fonts_cli_hook(result: OpResult, stage, _pipeline):
    """Writes the manifest data to stdout or a file in JSON."""
    if not result.success:
        return

    output_file = from_result_meta(result, c.META_OUTPUT_FILE)
    from pdftl.utils.string_utils import compact_json_string

    with smart_open_maybe_dash(output_file) as file:
        json_string = compact_json_string(json.dumps(result.data, indent=2), fold_dicts=False)
        file.write(json_string + "\n")


@register_operation(
    "export_fonts",
    tags=["info", "metadata", "fonts", "export"],
    type="single input operation",
    desc="Export fonts and a JSON manifest for external editing",
    long_desc=_EXPORT_FONTS_LONG_DESC,
    examples=_EXPORT_FONTS_EXAMPLES,
    cli_hook=export_fonts_cli_hook,
    usage="<input> export_fonts [<spec>...] <dir> [output <output>]",
    args=([c.INPUT_PDF, c.OPERATION_ARGS], {"output_file": c.OUTPUT}),
    skip_pipeline_save=True,
)
def export_fonts(pdf, specs, output_file=None) -> OpResult:
    """Export fonts with unified sidecar mappings and metrics."""
    raw_tokens: list[str] = []
    parse_keyval_list(
        specs or [],
        bare_tokens=raw_tokens,
        allowed_keys=["tounicode"],
        context="export_fonts",
    )

    ensure_dependencies(
        feature_name="export_fonts",
        dependencies={"fontTools": "fonttools"},
        extra_tag="export-fonts",
    )

    tounicode_mode = "json"
    for arg in specs or []:
        if arg.startswith("tounicode="):
            tounicode_mode = arg.split("=")[1].strip().lower()

    if tounicode_mode not in ("json", "ps", "all"):
        raise InvalidArgumentError(
            f"Invalid tounicode mode: '{tounicode_mode}'. Choose from: json, ps, all"
        )

    # Filter out tounicode kwarg to isolate directory tokens
    clean_tokens = [t for t in raw_tokens if not t.startswith("tounicode=")]
    if not clean_tokens:
        raise InvalidArgumentError("Missing required directory argument.")

    out_dir_str = clean_tokens[-1]
    page_specs = clean_tokens[:-1]

    out_dir = Path(out_dir_str)
    out_dir.mkdir(parents=True, exist_ok=True)

    target_pages = get_target_pages(pdf, page_specs)
    manifest = build_manifest(pdf, target_pages, out_dir, tounicode_mode)

    resolved_output = output_file if output_file is not None else str(out_dir / "manifest.json")

    return OpResult(
        success=True,
        data=manifest,
        meta={c.META_OUTPUT_FILE: resolved_output},
    )


# ============================================================================
# import_fonts
# ============================================================================


def _process_single_font_import(
    pdf: pikepdf.Pdf, font_entry: dict, out_dir: Path, pikepdf_mod: Any
) -> bool:
    """Helper orchestrator to process a single font entry from the manifest."""
    obj_id = font_entry["obj_id"]
    gen_id = font_entry["gen_id"]

    try:
        font_obj = pdf.get_object((obj_id, gen_id))
    except (pikepdf_mod.PdfError, KeyError):
        font_obj = None

    if font_obj is None:
        logger.warning("Font object (%d:%d) defined in manifest not found in PDF.", obj_id, gen_id)
        return False

    binary_updated = import_single_font_binary(font_obj, font_entry, out_dir)
    mappings = import_tounicode_and_get_mappings(font_obj, font_entry, out_dir, pikepdf_mod, pdf)
    import_differences(font_obj, font_entry, pikepdf_mod)
    encoding_cmap_updated = import_encoding_cmap(font_obj, font_entry, pikepdf_mod)
    cid_to_gid_updated = import_cid_to_gid_map(font_obj, font_entry, out_dir, pikepdf_mod, pdf)

    # Update structural font descriptor properties
    from pdftl.operations.helpers.font_import_helpers import import_descriptor

    descriptor_updated = import_descriptor(font_obj, font_entry, out_dir, pikepdf_mod)

    if font_entry.get("subtype") == "Type3":
        from pdftl.operations.helpers.font_import_helpers import import_type3_font

        import_type3_font(font_obj, font_entry, out_dir)
        binary_updated = True

    inmemory_updated = import_widths(font_obj, font_entry, out_dir, mappings, pikepdf_mod)

    return (
        binary_updated
        or inmemory_updated
        or cid_to_gid_updated
        or descriptor_updated
        or encoding_cmap_updated
    )


@register_operation(
    "import_fonts",
    tags=["in_place", "fonts", "import", "replace"],
    type="single input operation",
    desc="Import edited fonts from a directory using a JSON manifest",
    long_desc=_IMPORT_FONTS_LONG_DESC,
    usage="<input> import_fonts <dir> [manifest=<manifest.json>] output <out.pdf>",
    examples=_IMPORT_FONTS_EXAMPLES,
    args=([c.INPUT_PDF, c.OPERATION_ARGS], {}),
)
def import_fonts(pdf: pikepdf.Pdf, specs: list[str]) -> OpResult:
    import pikepdf

    raw_tokens: list[str] = []
    kv = parse_keyval_list(
        specs or [],
        bare_tokens=raw_tokens,
        allowed_keys=["manifest"],
        context="import_fonts",
    )

    ensure_dependencies(
        feature_name="export_fonts",
        dependencies={"fontTools": "fonttools"},
        extra_tag="import-fonts",
    )

    if not raw_tokens:
        raise InvalidArgumentError("Missing required directory argument.")

    out_dir_str = raw_tokens[0]
    out_dir = Path(out_dir_str)
    if not out_dir.is_dir():
        raise InvalidArgumentError(f"Target directory does not exist: {out_dir}")

    manifest_str = kv.get("manifest")
    manifest_path = out_dir / "manifest.json" if not manifest_str else Path(manifest_str)

    if not manifest_path.is_file():
        raise InvalidArgumentError(f"Manifest file not found: {manifest_path}")

    try:
        with open(manifest_path, encoding="utf-8") as f:
            manifest = json.load(f)
    except json.JSONDecodeError as exc:
        raise InvalidArgumentError(f"Invalid JSON manifest: {exc}") from exc

    fonts = manifest.get("fonts", {})
    if not fonts:
        logger.info("Manifest contains no font records. Skipping import.")
        return OpResult(success=True, pdf=pdf)

    updated_count = sum(
        1
        for _, font_entry in fonts.items()
        if _process_single_font_import(pdf, font_entry, out_dir, pikepdf)
    )

    logger.info("Successfully processed font updates. %d binary stream(s) updated.", updated_count)
    return OpResult(success=True, pdf=pdf)
