# 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.utils.string_utils.py

"""String processing utilities, including before_space and
xml_{de,en}code_for_info"""

from typing import TYPE_CHECKING, TypeVar

from pdftl.utils.whatisit import whatis_guess

if TYPE_CHECKING:
    from collections.abc import Callable


def remove_ignored_nonprinting_chars(s):
    """Remove ignored non-printing characters from a string"""
    import itertools
    import re

    IGNORED_NONPRINTING_CHAR_RE = re.compile(
        "["
        + "".join(
            map(
                chr,
                itertools.chain(
                    range(0x00, 0x0A),
                    range(0x0B, 0x0D),
                    range(0x0E, 0x20),
                    range(0x80, 0xA0),
                ),
            )
        )
        + "]"
    )
    return IGNORED_NONPRINTING_CHAR_RE.sub("", s)


XML_LOOKUPS = {
    "&": "&amp;",
    "'": "&apos;",
    '"': "&quot;",
    "<": "&lt;",
    ">": "&gt;",
}


def xml_encode_for_info(x):
    """
    Ad-hoc imitation of pdftk's xml escaping
    """

    # pdftk escapes ASCII DEL = 0x7f
    return remove_ignored_nonprinting_chars(
        "".join(
            [
                (
                    XML_LOOKUPS[c]
                    if c in XML_LOOKUPS
                    else c if c.isascii() and c != chr(0x7F) else f"&#{ord(c)};"
                )
                for c in x
            ]
        )
    )


def _xml_decode_lookup_step(x):
    """
    If x starts with an encoded lookup string, decode it.

    Returns:
    x', y
    where x' is x with any leading encoded lookup removed,
    and y is the decoded version of that encoded lookup, or None
    """
    for k, v in XML_LOOKUPS.items():
        if x.startswith(v):
            return x[len(v) :], k
    return x, None


def xml_decode_for_info(x):
    """
    Reverse the encoding of xml_encode_for_info()
    """
    out = ""
    while len(x) > 0:
        x, y = _xml_decode_lookup_step(x)
        if y is not None:
            out += y
            continue

        if x.startswith("&#") and ";" in x:
            code, x = x[2:].split(";", 1)
            out += chr(int(code))
            continue

        out += x[0]
        x = x[1:]

    return out


T = TypeVar("T")


def recursive_decode(data: T, decoder: "Callable[[str], str]") -> T:
    """Recursively 'decode' using the given str to str decoder"""
    # Note: type ignore directives are due to mypy limitations
    if isinstance(data, str):
        return decoder(data)  # type: ignore[return-value]
    if isinstance(data, list):
        return [recursive_decode(i, decoder) for i in data]  # type: ignore[return-value]
    if isinstance(data, dict):
        return {k: recursive_decode(v, decoder) for k, v in data.items()}  # type: ignore[return-value]
    return data


def before_space(x):
    """return everything before the first space in x, if there is one,
    or x if not"""
    return x.partition(" ")[0]


def pdf_num_to_string(x):
    """Formats a PDF number as an int if possible, otherwise a float."""
    return f"{int(x) if x == round(x) else float(x):,}"


def pdf_rect_to_string(arr):
    """Normalizes and formats a PDF rectangle array."""
    if len(arr) != 4:
        return ""
    x0, y0, x1, y1 = arr
    rect = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)]
    return " ".join(map(pdf_num_to_string, rect))


def pdf_obj_to_string(x):
    """Convert a pdf object to a string, for dump_data_annots style output"""
    from pikepdf import Array, Name

    guess = whatis_guess(x)
    if guess == Name:
        return str(x)[1:]
    if guess in (Array, list):
        return " ".join([pdf_obj_to_string(y) for y in x])
    if guess in (int, str):
        return str(x)
    raise NotImplementedError(guess, x, type(x))


def split_escaped(text: str, delimiter: str) -> list[str]:
    r"""
    Splits a string by a delimiter, allowing the delimiter to be
    escaped with a backslash.

    Other backslashes should be left alone!

    Args:
        text: The string to split.
        delimiter: A single character to split on.

    Returns:
        A list of unescaped strings.

    Example:
        >>> split_escaped("a.b\\.c.d", ".")
        ['a', 'b.c', 'd']
        >>> split_escaped("a\\\\.b.c", ".")
        ['a\\\.b', 'c']
    """
    if len(delimiter) != 1:
        raise ValueError("Delimiter must be a single character")

    parts = []
    current_part = []
    is_escaped = False

    for char in text:
        if is_escaped:
            # The previous char was a backslash.
            if char != delimiter:
                current_part.append("\\")
            current_part.append(char)
            is_escaped = False
        elif char == "\\":
            # Start an escape. The backslash itself is *not* added
            # to the output. It is consumed.
            is_escaped = True
        elif char == delimiter:
            # We've hit a non-escaped delimiter.
            # Add the completed part and start a new one.
            parts.append("".join(current_part))
            current_part = []
        else:
            # A normal character.
            current_part.append(char)

    # After the loop, add the final part.
    parts.append("".join(current_part))
    return parts


def compact_json_string(json_string):
    """Use regex heuristics to compact a json string, by eliminating some linebreaks"""

    def compact_simple_array(match):
        # Extract the content of the array
        array_content = match.group(2)
        # Remove newlines and reduce whitespace to single spaces
        compacted_content = " ".join(array_content.strip().split())
        return f"{match.group(1)}{compacted_content}{match.group(3)}"

    ret = json_string
    import re

    for regex_str in [r"(\[)\s*([^\[\]]*?)\s*(\])", r"(\{)\s*([^\{\}]*?)\s*(\})"]:
        ret = re.sub(regex_str, compact_simple_array, ret)
    return ret


def sensible_decode(data: bytes) -> str:
    # 1. UTF-8 (Strict) - Standard for modern data
    # 2. CP1252 - Common legacy Windows encoding (covers most Western Euro)
    # 3. Latin-1 - Fallback that maps 1-to-1 bytes to chars (never fails)
    candidates = ["utf-8", "cp1252", "latin-1"]

    for enc in candidates:
        try:
            return data.decode(enc)
        except UnicodeDecodeError:
            continue

    # Should be unreachable because latin-1 never raises,
    # but good for safety in case latin-1 is removed from the list.
    return data.decode("utf-8", errors="replace")


def fix_mojibake(text: str) -> str:
    try:
        # 1. Smash it back to bytes, preserving the raw 'bad' bytes (0xE7, 0xF2)
        raw_bytes = text.encode("utf-8", "surrogateescape")
        # 2. Decode properly
        return sensible_decode(raw_bytes)
    except UnicodeError:
        return text


def split_string_respecting_quotes(text, delimiter=","):
    """
    Splits a string by a delimiter, respecting nested quotes (single and double).
    Preserves empty fields and original quotes, exactly like re.split behavior.

    DoS Safe: Runs in O(N) time.
    """
    result = []
    current = []
    quote_char = None

    for char in text:
        if quote_char:
            # We are inside a quote, just keep accumulating
            current.append(char)
            if char == quote_char:
                quote_char = None  # Closed the quote
        else:
            # We are not in a quote
            if char == delimiter:
                # Found a split point!
                result.append("".join(current))
                current = []
            else:
                current.append(char)
                # If this is a start of a quote, track it
                if char in ('"', "'"):
                    quote_char = char

    if quote_char:
        raise ValueError(f"Unbalanced quote in string: {text}")

    # Append whatever is left (even if it's an empty string)
    result.append("".join(current))

    return result
