# 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/fill_form.py

"""Operation to fill in interactive forms (AcroForm) in a PDF file."""

import logging
from collections.abc import Callable
from typing import TYPE_CHECKING

import pdftl.core.constants as c
from pdftl.core.core_types import OpResult
from pdftl.core.registry import register_operation
from pdftl.exceptions import UserCommandLineError
from pdftl.utils.fdf import wrap_fdf_data_in_pdf_bytes
from pdftl.utils.io_helpers import smart_open
from pdftl.utils.type_helpers import as_iterable
from pdftl.utils.user_input import filename_completer

if TYPE_CHECKING:
    from pikepdf import Pdf

logger = logging.getLogger(__name__)

_FILL_FORM_LONG_DESC = """

The `fill_form` operation is used to fill in a form in a PDF.
The `<form_data>` can be the path to a file in FDF or XFDF format,
or `-`, or `PROMPT`.

This operation supports text boxes, checkboxes, list-boxes,
radio buttons (including multi-page synchronized groups),
and choice fields with single or multiple options selected.

"""

_FILL_FORM_EXAMPLES = [
    {
        "cmd": "in.pdf fill_form data.fdf output out.pdf",
        "desc": "Complete a form in in.pdf using data from data.fdf",
        "test_example": False,
    }
]


@register_operation(
    "fill_form",
    tags=["in_place", "forms"],
    type="single input operation",
    desc="Fill a PDF form",
    long_desc=_FILL_FORM_LONG_DESC,
    usage="<input> fill_form <form_data> output <file> [<option>...]",
    examples=_FILL_FORM_EXAMPLES,
    args=([c.INPUT_PDF, c.OPERATION_ARGS, c.GET_INPUT], {}),
)
def fill_form(pdf: "Pdf", args: list[str], get_input: Callable = filename_completer) -> OpResult:
    """Fill in a form, treating the first argument as a filename for data."""
    if not args:
        args = ["PROMPT"]

    data_file = args[0]
    while not data_file or data_file == "PROMPT":
        # codeql[py/call/wrong-named-argument]
        data_file = get_input(
            "Enter a filename with FDF/XFDF input data: ", completer=filename_completer
        )

    try:
        with smart_open(data_file, "rb") as f:
            _fill_form_from_data(pdf, f.read())
    except OSError as exc:
        raise UserCommandLineError(exc) from exc

    return OpResult(success=True, pdf=pdf)


def _fill_form_from_data(pdf, data):
    """Fill in a form, using given raw data string."""
    from pikepdf.exceptions import PdfError
    from pikepdf.form import Form

    form = Form(pdf)

    try:
        _fill_form_from_fdf_data(form, data)
    except (PdfError, AttributeError, ValueError) as fdf_exc:
        try:
            logger.debug(
                "Got %s while trying to read data as FDF: %s", type(fdf_exc).__name__, fdf_exc
            )
            _fill_form_from_xfdf_data(form, data)
        except (PdfError, AttributeError, ValueError, NotImplementedError) as xfdf_exc:
            raise UserCommandLineError(
                "Errors encountered while processing FDF/XFDF data.\n"
                f"[FDF] {type(fdf_exc).__name__}: {fdf_exc}\n"
                f"[XFDF] {type(xfdf_exc).__name__}: {xfdf_exc}"
            ) from xfdf_exc


def _fill_form_from_fdf_data(form, data):
    """Fill in a form, using given FDF data bytes."""
    import pikepdf

    with pikepdf.open(wrap_fdf_data_in_pdf_bytes(data)) as wrapper_pdf:
        fdf_fields = wrapper_pdf.Root.FDF.Fields
        if not isinstance(fdf_fields, pikepdf.Array):
            raise UserCommandLineError(f"FDF fields is not an array: {type(fdf_fields)}")
        for fdf_field in as_iterable(fdf_fields):
            _fill_form_field_from_fdf_field(form, fdf_field)


def _fill_form_field_from_fdf_field(form, fdf_field, ancestors=None):
    """Fill in a form field, using given FDF field."""
    logger.debug("title=%s", getattr(fdf_field, "T", None))
    if ancestors is None:
        ancestors = []
    if hasattr(fdf_field, "V"):
        _fill_form_value_from_fdf_field(form, fdf_field, ancestors)
    if hasattr(fdf_field, "Kids"):
        logger.debug("title=%s has kids", getattr(fdf_field, "T", None))
        _process_fdf_field_kids(form, fdf_field, ancestors)


def _process_fdf_field_kids(form, fdf_field, ancestors):
    """Process kids of an FDF field recursively."""
    kid_ancestors = ancestors.copy()
    if hasattr(fdf_field, "T"):
        kid_ancestors.append(str(fdf_field.T))
    for fdf_field_kid in fdf_field.Kids:
        _fill_form_field_from_fdf_field(form, fdf_field_kid, kid_ancestors)


def _fill_form_value_from_fdf_field(form, fdf_field, ancestors):
    """Fill in a form value from an FDF field."""
    import pikepdf
    from pikepdf.form import RadioButtonGroup

    fully_qualified_fdf_name = fully_qualified_name(fdf_field, ancestors)
    logger.debug(fully_qualified_fdf_name)
    field = next((x for x in form if x.fully_qualified_name == fully_qualified_fdf_name), None)

    if field is not None:
        value = fdf_field.V
        logger.debug("Got a hit")

        # Keep FDF specific logic for RadioButtons with 'Opt' lists
        if isinstance(field, RadioButtonGroup) and hasattr(field.obj, "Opt"):
            idx = next(x for x, y in enumerate(field.obj.Opt) if value == y)
            field.value = pikepdf.Name("/" + str(idx))
        else:
            # Use shared helper for everything else
            _set_form_field_value(field, value)


def _tagname(element_with_tag):
    return element_with_tag.tag.split("}", 1)[-1]


def _recurse_xfdf(element, xfdf_data, parent_name=""):
    for child in element:
        if _tagname(child) == "field":
            name = child.get("name")
            if name:
                _recurse_into_named_xfdf_field(child, name, xfdf_data, parent_name)


def _recurse_into_named_xfdf_field(field, name, xfdf_data, parent_name):
    full_name = f"{parent_name}.{name}" if parent_name else name

    for subchild in field:
        if _tagname(subchild) == "value":
            xfdf_data[full_name] = subchild.text or ""
            break

    _recurse_xfdf(field, xfdf_data, full_name)


def _fill_form_from_xfdf_data(form, data):
    """Fill in a form, using given XFDF data."""
    import defusedxml.ElementTree as ET

    try:
        root = ET.fromstring(data)
    except ET.ParseError as e:
        raise ValueError(f"Invalid XFDF XML: {e}") from e

    # 1. Parse XFDF into a flat dictionary
    xfdf_data = {}
    fields_element = None

    for child in root:
        if child.tag.split("}", 1)[-1] == "fields":
            fields_element = child
            break
    if fields_element is not None:
        _recurse_xfdf(fields_element, xfdf_data)

    # 2. Fill fields
    for field in form:
        fq_name = field.fully_qualified_name

        if fq_name in xfdf_data:
            value = xfdf_data[fq_name]
            logger.debug("Filling %s = %s", fq_name, value)
            _set_form_field_value(field, value)


def _set_form_field_value(field, value):
    import pikepdf

    # ISO 32000-2 §12.7.5.5 Guard: Never write primitive string values onto digital signature
    # fields
    if field.obj.get("/FT") == pikepdf.Name("/Sig"):
        logger.warning(
            "Skipping digital signature field '%s'. Custom signature filling "
            "is not supported directly.",
            field.fully_qualified_name,
        )
        return

    # ISO 32000-2 §12.7.5.4 Choice Multi-Select Array support
    # We assign directly to field.obj.V to bypass pikepdf.ChoiceField's limited array support
    if isinstance(value, (list, pikepdf.Array)):
        field.obj.V = pikepdf.Array([pikepdf.String(str(v)) for v in value])
        return

    if field.is_text:
        field.value = str(value)

    elif field.is_checkbox:
        _set_checkbox_value(field, value)

    elif field.is_radio_button:
        _set_radio_button_value(field, value)

    else:
        # Standard fallback (includes choice fields with single selections)
        field.value = str(value)


def _set_checkbox_value(field, value):
    from pikepdf import Name

    # Map XFDF "Yes"/"On" string to boolean
    # Common valid "true" values in XFDF: "Yes", "On", "true", "1"
    is_checked = str(value).lower() in ("yes", "on", "true", "1")

    try:
        # Try the high-level pikepdf wrapper first (safe, verifies keys)
        field.checked = is_checked
    except AttributeError:
        # FALLBACK: The PDF is missing the /AP (Appearance) dictionary.
        # We update the Value (/V) and Appearance State (/AS) manually.
        val = Name("/Yes") if is_checked else Name("/Off")
        field.obj.V = val
        field.obj.AS = val


def _set_radio_button_value(field, value):
    from pikepdf import Name

    # Logic for radio buttons
    val_str = str(value)
    if not val_str.startswith("/"):
        val_str = "/" + val_str

    # Workaround for pikepdf/qpdf crash on RadioGroups without /Kids
    if "/Kids" not in field.obj:
        if val_str.lower() == "/off":
            _clear_radio(field)
        else:
            field.obj.V = Name(val_str)
    else:
        try:
            field.value = Name(val_str)
        except ValueError as e:
            # Catch "Off" values and map them to manual deletion
            if "uncheck a radio button" in str(e) and val_str.lower() == "/off":
                _clear_radio(field)
            else:
                raise e


def _clear_radio(field):
    if "/V" in field.obj:
        del field.obj["/V"]
    if "/AS" in field.obj:
        del field.obj["/AS"]


def fully_qualified_name(x, ancestors):
    """Return the fully qualified name starting from FDF object root."""
    return ".".join(map(str, [*ancestors, x.T]))
