from binascii import Error as BinasciiError
from binascii import unhexlify
from collections.abc import Callable
from functools import partial
from hashlib import sha256
from io import BytesIO
from typing import Any, Union, cast

from ._codecs import adobe_glyphs, charset_encoding
from ._utils import logger_error, logger_warning
from .errors import LimitReachedError
from .generic import (
    ArrayObject,
    DecodedStreamObject,
    DictionaryObject,
    NullObject,
    StreamObject,
)
from .generic._data_structures import _FontFileCharacterMap

_predefined_cmap: dict[str, str] = {
    "/Identity-H": "utf-16-be",
    "/Identity-V": "utf-16-be",
    "/GB-EUC-H": "gbk",
    "/GB-EUC-V": "gbk",
    "/GBpc-EUC-H": "gb2312",
    "/GBpc-EUC-V": "gb2312",
    "/GBK-EUC-H": "gbk",
    "/GBK-EUC-V": "gbk",
    "/GBK2K-H": "gb18030",
    "/GBK2K-V": "gb18030",
    "/ETen-B5-H": "cp950",
    "/ETen-B5-V": "cp950",
    "/ETenms-B5-H": "cp950",
    "/ETenms-B5-V": "cp950",
    "/UniCNS-UTF16-H": "utf-16-be",
    "/UniCNS-UTF16-V": "utf-16-be",
    "/UniGB-UTF16-H": "gb18030",
    "/UniGB-UTF16-V": "gb18030",
    # Japanese CMaps (PDF Reference 1.7, Appendix H)
    "/90ms-RKSJ-H": "cp932",  # Shift-JIS (JIS X 0208-1990), horizontal
    "/90ms-RKSJ-V": "cp932",  # Shift-JIS (JIS X 0208-1990), vertical
    "/UniJIS-UTF16-H": "utf-16-be",  # Unicode UTF-16BE -> JIS, horizontal
    "/UniJIS-UTF16-V": "utf-16-be",  # Unicode UTF-16BE -> JIS, vertical
    # UCS2 in code
}


def get_encoding(
    ft: DictionaryObject
) -> tuple[Union[str, dict[int, str]], dict[Any, Any]]:
    encoding = _parse_encoding(ft)
    map_dict, int_entry = _parse_to_unicode(ft)

    # Apply rule from PDF ref 1.7 §5.9.1, 1st bullet:
    #   if cmap not empty encoding should be discarded
    #   (here transformed into identity for those characters)
    # If encoding is a string, it is expected to be an identity translation.
    if isinstance(encoding, dict):
        for x in int_entry:
            if x <= 255:
                encoding[x] = chr(x)

    return encoding, map_dict


def _parse_encoding(
    ft: DictionaryObject
) -> Union[str, dict[int, str]]:
    encoding: Union[str, list[str], dict[int, str]] = []
    # If ft["/Encoding"] exists, then use that for encoding. Otherwise, use StandardEncoding as a basis,
    # and add what the embedded font file says, if present. See Table 114, PDF Reference 1.7 / 2.0
    if "/Encoding" not in ft:
        if "/BaseFont" in ft and cast(str, ft["/BaseFont"]) in charset_encoding:
            # This will match Symbol and ZapfDingBats
            return dict(
                zip(range(256), charset_encoding[cast(str, ft["/BaseFont"])])
            )

        # Return StandardEncoding as fallback option. Note that a font's internal encoding can be used
        # to overwrite this, which we do for Type1 fonts in _derive_character_map_from_(cff_)type1_font_file.
        return dict(
            zip(range(256), charset_encoding["/StandardEncoding"])
        )

    enc: Union[str, DictionaryObject, NullObject] = cast(
        Union[str, DictionaryObject, NullObject], ft["/Encoding"].get_object()
    )
    if isinstance(enc, str):
        try:
            # already done : enc = NameObject.unnumber(enc.encode()).decode()
            # for #xx decoding
            if enc in charset_encoding:
                encoding = charset_encoding[enc].copy()
            elif enc in _predefined_cmap:
                encoding = _predefined_cmap[enc]
            elif "-UCS2-" in enc:
                encoding = "utf-16-be"
            else:
                raise Exception("not found")
        except Exception:
            encoding = (
                "utf-16-be" if ft.get("/Subtype", "") == "/Type0" else charset_encoding["/StandardEncoding"].copy()
            )
            logger_error(
                "Advanced encoding %(encoding)s not implemented yet, using %(new_encoding)s instead.",
                source=__name__,
                encoding=enc,
                new_encoding=encoding
            )
    elif isinstance(enc, DictionaryObject) and "/BaseEncoding" in enc:
        try:
            encoding = charset_encoding[cast(str, enc["/BaseEncoding"])].copy()
        except Exception:
            logger_error(
                "Advanced encoding %(encoding)s not implemented yet",
                source=__name__, encoding=encoding
            )
            encoding = charset_encoding["/StandardEncoding"].copy()
    else:
        encoding = charset_encoding["/StandardEncoding"].copy()
    if isinstance(enc, DictionaryObject) and "/Differences" in enc:
        x: int = 0
        o: Union[int, str]
        differences = enc["/Differences"].get_object()
        if not isinstance(differences, ArrayObject):
            logger_warning(
                "Font encoding differences are not an array: %(differences)s",
                source=__name__,
                differences=differences,
            )
            differences = ArrayObject()
        for o in differences:
            if isinstance(o, int):
                x = o
            else:  # isinstance(o, str):
                try:
                    if x < len(encoding):
                        encoding[x] = adobe_glyphs[o]  # type: ignore[index]
                except Exception:
                    encoding[x] = o  # type: ignore[index]
                x += 1
    if isinstance(encoding, list):
        encoding = dict(zip(range(256), encoding))
    return encoding


def _parse_to_unicode(
    ft: DictionaryObject
) -> tuple[dict[Any, Any], list[int]]:
    from .generic._font import HAS_FONTTOOLS  # noqa: PLC0415

    # We store all character mappings in map_dict. In map_dict[-1] we store the byte length
    # of the character codes (or CIDs) encoded inside the ToUnicode stream.
    map_dict: dict[Any, Any] = {}

    # We provide the list of cmap keys in int_entry to correct encoding later on in get_encoding().
    int_entry: list[int] = []

    if "/ToUnicode" not in ft:
        if ft.get("/Subtype", "") == "/Type1":
            font_descriptor = ft.get("/FontDescriptor")
            if not font_descriptor:
                return map_dict, int_entry

            # We try to read encoding from an embedded font file, if we can. See Table 126 about embedded font
            # file organization in the PDF specification 1.7 for details.
            font_file_handlers = (
                # A normal Type1 font file, can be part of a Type1 or MMType1 font dictionary.
                (
                    "/FontFile",
                    lambda _: True,
                    _derive_character_map_from_type1_font_file
                ),
                # A CFF Type1 font file, as part of a Type1 or MMType1 font dictionary, when subtype is Type1C.
                (
                    "/FontFile3",
                    lambda stream: stream.get("/Subtype") == "/Type1C",
                    _derive_character_map_from_cff_type1_font_file,
                )
            )
            for font_file, condition, font_file_processor in font_file_handlers:
                if (
                    font_file in font_descriptor and
                    isinstance(font_file_dict := font_descriptor[font_file], StreamObject) and
                    condition(font_file_dict)
                ):
                    if font_file == "/FontFile3" and not HAS_FONTTOOLS:
                        logger_warning(
                            (
                                "fontTools is required to fully parse the encoding of a CFF Type1 font in font "
                                "dictionary %(ft)s, but is not installed. Consider installing fontTools if you "
                                "encounter encoding problems."
                            ),
                            source=__name__,
                            ft=ft,
                        )
                        return map_dict, int_entry

                    font_file_data = font_file_dict.get_data()
                    if not font_file_data:
                        return map_dict, int_entry

                    return _derive_character_map_from_font_file(
                        font_file_dict, font_file_data, font_file_processor, map_dict, int_entry
                    )

            return map_dict, int_entry

        return {}, []

    process_rg: bool = False
    process_char: bool = False
    multiline_rg: Union[
        tuple[int, int], None
    ] = None  # tuple = (current_char, remaining size) ; cf #1285 for example of file
    cm = prepare_cm(ft)
    for line in cm.split(b"\n"):
        process_rg, process_char, multiline_rg = process_cm_line(
            line.strip(b" \t"),
            process_rg,
            process_char,
            multiline_rg,
            map_dict,
            int_entry,
        )

    map_dict.pop(-1, None)  # Don't pass the -1 key, we only used it to temporarily store encoding length

    return map_dict, int_entry


def prepare_cm(ft: DictionaryObject) -> bytes:
    tu = ft["/ToUnicode"]
    cm: bytes
    if isinstance(tu, StreamObject):
        cm = cast(DecodedStreamObject, ft["/ToUnicode"]).get_data()
    else:  # if (tu is None) or cast(str, tu).startswith("/Identity"):
        # the full range 0000-FFFF will be processed
        cm = b"beginbfrange\n<0000> <0001> <0000>\nendbfrange"
    if isinstance(cm, str):
        cm = cm.encode()
    # we need to prepare cm before due to missing return line in pdf printed
    # to pdf from word
    cm = (
        cm.strip()
        .replace(b"beginbfchar", b"\nbeginbfchar\n")
        .replace(b"endbfchar", b"\nendbfchar\n")
        .replace(b"beginbfrange", b"\nbeginbfrange\n")
        .replace(b"endbfrange", b"\nendbfrange\n")
        .replace(b"<<", b"\n{\n")  # text between << and >> not used but
        .replace(b">>", b"\n}\n")  # some solution to find it back
    )
    ll = cm.split(b"<")
    for i in range(len(ll)):
        j = ll[i].find(b">")
        if j >= 0:
            if j == 0:
                # string is empty: stash a placeholder here (see below)
                # see https://github.com/py-pdf/pypdf/issues/1111
                content = b"."
            else:
                content = ll[i][:j].replace(b" ", b"")
            ll[i] = content + b" " + ll[i][j + 1 :]
    cm = (
        (b" ".join(ll))
        .replace(b"[", b" [ ")
        .replace(b"]", b" ]\n ")
        .replace(b"\r", b"\n")
    )
    return cm


def process_cm_line(
    line: bytes,
    process_rg: bool,
    process_char: bool,
    multiline_rg: Union[tuple[int, int], None],
    map_dict: dict[Any, Any],
    int_entry: list[int],
) -> tuple[bool, bool, Union[tuple[int, int], None]]:
    if line == b"" or line[0] == 37:  # 37 = %
        return process_rg, process_char, multiline_rg
    line = line.replace(b"\t", b" ")
    if b"beginbfrange" in line:
        process_rg = True
    elif b"endbfrange" in line:
        process_rg = False
    elif b"beginbfchar" in line:
        process_char = True
    elif b"endbfchar" in line:
        process_char = False
    elif process_rg:
        try:
            multiline_rg = parse_bfrange(line, map_dict, int_entry, multiline_rg)
        except (ValueError, IndexError) as error:
            logger_warning("Skipping broken line %(line)r: %(error)s", source=__name__, line=line, error=error)
    elif process_char:
        try:
            parse_bfchar(line, map_dict, int_entry)
        except ValueError as error:
            logger_warning("Skipping broken line %(line)r: %(error)s", source=__name__, line=line, error=error)
    return process_rg, process_char, multiline_rg


# Usual values should be up to 65_536.
MAPPING_DICTIONARY_SIZE_LIMIT = 100_000

# Typical /ToUnicode CMaps use 1-4 byte source codes.
# This is intentionally generous.
# The actual limit is doubled, as each byte is represented by two hex characters.
MAX_CMAP_CODE_BYTES = 8
MAX_CMAP_STRING_BYTES = 512
MAX_CMAP_CODE_BYTES_LIMIT = MAX_CMAP_CODE_BYTES * 2
MAX_CMAP_STRING_BYTES_LIMIT = MAX_CMAP_STRING_BYTES * 2


def _check_mapping_size(size: int) -> None:
    if size > MAPPING_DICTIONARY_SIZE_LIMIT:
        raise LimitReachedError(f"Maximum /ToUnicode size limit reached: {size} > {MAPPING_DICTIONARY_SIZE_LIMIT}.")


def _check_token_length(token: bytes, limit: int) -> None:
    token_length = len(token)
    if token_length > limit:
        description = {
            MAX_CMAP_CODE_BYTES_LIMIT: "code",
            MAX_CMAP_STRING_BYTES_LIMIT: "string",
        }.get(limit, "token")

        raise LimitReachedError(
            f"Maximum /ToUnicode {description} length exceeded: {token_length} > {limit}."
        )


def __parse_bfrange__decode(map_dict: dict[Any, Any], code: int) -> str:
    # `map_dict[-1]` is the number of bytes each source code occupies. Building
    # the bytes directly with `int.to_bytes` avoids the hex round-trip of
    # `unhexlify(b"%%0%dX" % (map_dict[-1] * 2) % code)` (format to hex, parse
    # the hex back to bytes), which is measurably cheaper for large maps.
    return code.to_bytes(map_dict[-1], "big").decode(
        "charmap" if map_dict[-1] == 1 else "utf-16-be",
        "surrogatepass",
    )


def parse_bfrange(
    line: bytes,
    map_dict: dict[Any, Any],
    int_entry: list[int],
    multiline_rg: Union[tuple[int, int], None],
) -> Union[tuple[int, int], None]:
    lst = line.split()
    closure_found = False
    entry_count = len(int_entry)
    _check_mapping_size(entry_count)
    decode_utf16 = partial(bytes.decode, encoding="utf-16-be", errors="surrogatepass")
    if multiline_rg is not None:
        a = multiline_rg[0]  # a, b not in the current line
        b = multiline_rg[1]
        for sq in lst:
            if sq == b"]":
                closure_found = True
                break
            _check_token_length(sq, limit=MAX_CMAP_STRING_BYTES_LIMIT)
            entry_count += 1
            _check_mapping_size(entry_count)
            map_dict[
                __parse_bfrange__decode(map_dict=map_dict, code=a)
            ] = decode_utf16(unhexlify(sq))
            int_entry.append(a)
            a += 1
    else:
        _check_token_length(lst[0], limit=MAX_CMAP_CODE_BYTES_LIMIT)
        _check_token_length(lst[1], limit=MAX_CMAP_CODE_BYTES_LIMIT)
        a = int(lst[0], 16)
        b = int(lst[1], 16)
        nbi = max(len(lst[0]), len(lst[1]))
        map_dict[-1] = (nbi + 1) // 2
        if lst[2] == b"[":
            for sq in lst[3:]:
                if sq == b"]":
                    closure_found = True
                    break
                _check_token_length(sq, limit=MAX_CMAP_STRING_BYTES_LIMIT)
                entry_count += 1
                _check_mapping_size(entry_count)
                map_dict[
                    __parse_bfrange__decode(map_dict=map_dict, code=a)
                ] = decode_utf16(unhexlify(sq))
                int_entry.append(a)
                a += 1
        else:  # case without list
            _check_token_length(lst[2], limit=MAX_CMAP_STRING_BYTES_LIMIT)
            c = int(lst[2], 16)
            fmt2 = b"%%0%dX" % max(4, len(lst[2]))
            closure_found = True
            range_size = max(0, b - a + 1)
            _check_mapping_size(entry_count + range_size)  # This can be checked beforehand.
            while a <= b:
                destination = unhexlify(fmt2 % c)
                _check_token_length(destination, limit=MAX_CMAP_CODE_BYTES_LIMIT)
                map_dict[
                    __parse_bfrange__decode(map_dict=map_dict, code=a)
                ] = decode_utf16(destination)
                int_entry.append(a)
                a += 1
                c += 1
    return None if closure_found else (a, b)


def parse_bfchar(line: bytes, map_dict: dict[Any, Any], int_entry: list[int]) -> None:
    lst = [x for x in line.split(b" ") if x]
    lst_length = len(lst)
    if lst_length == 0:
        logger_warning("Skipping broken line %(line)r: Line is empty.", source=__name__, line=line)
        return
    if lst_length % 2:
        logger_warning("Ignoring final token of odd-length line %(line)r.", source=__name__, line=line)

    new_count = lst_length // 2
    _check_mapping_size(len(int_entry) + new_count)  # This can be checked beforehand.
    map_dict[-1] = len(lst[0]) // 2

    while len(lst) > 1:
        source = lst[0]
        destination = lst[1]

        _check_token_length(source, limit=MAX_CMAP_CODE_BYTES_LIMIT)

        map_to = ""
        # placeholder (see above) means empty string
        if destination != b".":
            _check_token_length(destination, limit=MAX_CMAP_STRING_BYTES_LIMIT)
            try:
                map_to = unhexlify(destination).decode(
                    "charmap" if len(destination) < 4 else "utf-16-be", "surrogatepass"
                )  # join is here as some cases where the code was split
            except BinasciiError as exception:
                logger_warning(
                    "Got invalid hex string: %(exception)s (%(destination)r)",
                    source=__name__,
                    exception=exception,
                    destination=destination,
                )
        map_dict[
            unhexlify(source).decode(
                "charmap" if map_dict[-1] == 1 else "utf-16-be", "surrogatepass"
            )
        ] = map_to
        int_entry.append(int(source, 16))
        lst = lst[2:]


def _glyph_name_to_unicode(glyph_name: str) -> Union[str, None]:
    try:
        return adobe_glyphs[glyph_name]
    except KeyError:
        if not glyph_name.startswith("/uni"):
            return None
        try:
            return chr(int(glyph_name[4:], 16))
        except ValueError:  # pragma: no cover
            return None


def _derive_character_map_from_font_file(
    font_file: StreamObject,
    font_data: bytes,
    font_file_processor: Callable[[bytes, dict[Any, Any], list[int]], tuple[dict[Any, Any], list[int]]],
    map_dict: dict[Any, Any],
    int_entry: list[int],
) -> tuple[dict[Any, Any], list[int]]:
    """
    Derive the character map from an embedded font program, caching the result on its stream.

    The character map depends only on the decoded font data, and the same font program is reached from every
    font dictionary, resource name and page referencing its stream. Parsing a CFF font program with fontTools is
    expensive, so each stream is parsed once and reused until its data changes, which a digest of the data
    detects (a writer may replace the font program). See #4156.
    """
    digest = sha256(font_data).digest()
    cached = font_file._font_file_character_map
    if cached is None or cached.digest != digest:
        cached_map_dict, cached_int_entry = font_file_processor(font_data, {}, [])
        cached = _FontFileCharacterMap(digest, cached_map_dict, cached_int_entry)
        font_file._font_file_character_map = cached
    map_dict.update(cached.map_dict)
    int_entry.extend(cached.int_entry)
    return map_dict, int_entry


def _derive_character_map_from_cff_type1_font_file(
    font_data: bytes,
    map_dict: dict[Any, Any],
    int_entry: list[int],
) -> tuple[dict[Any, Any], list[int]]:
    try:
        from fontTools.cffLib import CFFFontSet  # noqa: PLC0415
        cff_set = CFFFontSet()
        cff_set.decompile(BytesIO(font_data), None)
        cff_font = cff_set.topDictIndex[0]
        cff_encoding = cff_font.Encoding
        # Encoding can fall back to literal strings "StandardEncoding" or "ExpertEncoding", which we do not parse.
        if isinstance(cff_encoding, str):
            return map_dict, int_entry
        for i in range(min(len(cff_encoding), 256)):
            glyph_name = cff_encoding[i]
            if not glyph_name or glyph_name == ".notdef":
                continue
            if unipoint := _glyph_name_to_unicode(f"/{glyph_name}"):
                map_dict[chr(i)] = unipoint
                int_entry.append(i)
        return map_dict, int_entry

    except Exception:
        return map_dict, int_entry


def _derive_character_map_from_type1_font_file(
    font_data: bytes,
    map_dict: dict[Any, Any],
    int_entry: list[int],
) -> tuple[dict[Any, Any], list[int]]:
    txt = font_data.split(b"eexec\n")[0]  # Only the clear part
    encoding_part = txt.split(b"/Encoding")
    if len(encoding_part) < 2:
        return map_dict, int_entry
    txt = encoding_part[1]  # To get the encoding part
    lines = txt.replace(b"\r", b"\n").split(b"\n")
    for li in lines:
        if li.startswith(b"dup"):
            words = [_w for _w in li.split(b" ") if _w != b""]
            if len(words) < 3 or (len(words) > 3 and words[3] != b"put"):
                continue
            try:
                i = int(words[1])
            except ValueError:
                continue

            unipoint = _glyph_name_to_unicode(words[2].decode())
            if unipoint:
                map_dict[chr(i)] = unipoint
                int_entry.append(i)

    return map_dict, int_entry
