"""
Implementation of PDF object types and other generic functionality.
The internals were imported from PyPDF2, with modifications.

See :ref:`here <pypdf2-license>` for the original license
of the PyPDF2 project.
"""
import binascii
import codecs
import decimal
import logging
import os
import re
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from io import BytesIO
from typing import Any, Callable, Iterator, Optional, Tuple, Union

from . import filters
from .misc import (
    IndirectObjectExpected,
    PdfReadError,
    PdfStreamError,
    PdfWriteError,
    is_regular_character,
    read_non_whitespace,
    read_until_regex,
    skip_over_comment,
)

__all__ = [
    'Dereferenceable', 'Reference', 'TrailerReference',
    'PdfObject', 'IndirectObject', 'NullObject', 'BooleanObject', 'FloatObject',
    'NumberObject', 'ByteStringObject', 'TextStringObject', 'NameObject',
    'ArrayObject', 'DictionaryObject', 'StreamObject',
    'read_object', 'pdf_name', 'pdf_string', 'pdf_date'
]

OBJECT_PREFIXES = b'/<[tf(n%'
NUMBER_SIGNS = b'+-'
INDIRECT_PATTERN = re.compile(r"(\d+)\s+(\d+)\s+R[^a-zA-Z]".encode('ascii'))

logger = logging.getLogger(__name__)


def _deproxy_decrypt(obj):
    if isinstance(obj, DecryptedObjectProxy):
        return obj.decrypted
    else:
        return obj


class Dereferenceable:
    """Represents an opaque reference to a PDF object associated with
    a PDF Handler (see :class:`PdfHandler <.rw_common.PdfHandler>`).

    This can either be a reference to an object with an object ID
    (see :class:`.Reference`) or a reference to the trailer of a PDF document
    (see :class:`.TrailerReference`).
    """

    def get_object(self) -> 'PdfObject':
        """Retrieve the PDF object backing this dereferenceable.

        :return: A :class:`.PdfObject`.
        """
        raise NotImplementedError

    def get_pdf_handler(self):
        """Return the PDF handler associated with this dereferenceable.

        :return: a :class:`~.rw_common.PdfHandler`.
        """
        raise NotImplementedError


class TrailerReference(Dereferenceable):
    """A reference to the trailer of a PDF document.

    .. warning::
       Since the trailer does not have a well-defined object ID in files with
       "classical" cross-reference tables (as opposed to cross-reference
       streams), this is not a subclass of :class:`.Reference`.
    """

    def __init__(self, reader):
        """Create a reference to the trailer of a :class:`.reader.PdfFileReader`
        instance.

        :param reader: A PDF reader
        """
        self.reader = reader

    def get_object(self) -> 'PdfObject':
        return self.reader.trailer

    def get_pdf_handler(self):
        return self.reader


@dataclass(frozen=True)
class Reference(Dereferenceable):
    """A reference to an object with a certain ID and generation number, with
    a PDF handler attached to it.

    .. warning::
       Contrary to what one might expect, the generation number does *not*
       indicate the document revision in which the object was modified. In fact,
       nonzero generation numbers are exceedingly rare these days; in most
       real-world PDF files, objects are simply overridden without ever
       increasing the generation number.

       Except in very specific circumstances, dereferencing a
       :class:`.Reference` will return the most recent version of the object
       with the stated object ID and generation number.
    """

    idnum: int
    """
    The object's ID.
    """

    generation: int = 0
    """
    The object's generation number (usually `0`)
    """

    pdf: object = field(repr=False, hash=False, compare=False, default=None)
    """
    The PDF handler associated with this reference, an instance of
    :class:`~.rw_common.PdfHandler`.
    
    .. warning::
       This field is ignored when hashing or comparing :class:`.Reference`
       objects, so it is the API user's responsibility to not mix up
       references originating from unrelated PDF handlers.
    """

    def get_object(self) -> 'PdfObject':
        if self.pdf is None:
            return NullObject()
        from pyhanko.pdf_utils.rw_common import PdfHandler
        assert isinstance(self.pdf, PdfHandler)
        return self.pdf.get_object(self).get_object()

    def get_pdf_handler(self):
        return self.pdf


def read_object(stream, container_ref: 'Dereferenceable') -> 'PdfObject':
    """Read a PDF object from an input stream.

    .. note::
       The `container_ref` parameter tells the API which reference to register
       when the returned object is modified in an incremental update.
       See also here :ref:`here <container-ref-example>` for further
       information.

    :param stream:
        An input stream.
    :param container_ref:
        A reference to an object containing this one.

        *Note:* It is perfectly possible (and common) for `container_ref` to
        resolve to the return value of this function.
    :return:
        A :class:`.PdfObject`.
    """

    tok = stream.read(1)
    stream.seek(-1, os.SEEK_CUR)  # reset to start
    idx = OBJECT_PREFIXES.find(tok)
    if idx == 0:
        # name object
        result = NameObject.read_from_stream(stream)
    elif idx == 1:
        # hexadecimal string OR dictionary
        peek = stream.read(2)
        stream.seek(-2, os.SEEK_CUR)  # reset to start
        if peek == b'<<':
            result = DictionaryObject.read_from_stream(
                stream, container_ref
            )
        else:
            result = read_hex_string_from_stream(stream)
    elif idx == 2:
        # array object
        result = ArrayObject.read_from_stream(
            stream, container_ref
        )
    elif idx == 3 or idx == 4:
        # boolean object
        result = BooleanObject.read_from_stream(stream)
    elif idx == 5:
        # string object
        result = read_string_from_stream(stream)
    elif idx == 6:
        # null object
        result = NullObject.read_from_stream(stream)
    elif idx == 7:
        # comment
        while tok not in (b'\r', b'\n'):
            tok = stream.read(1)
        read_non_whitespace(stream)
        stream.seek(-1, os.SEEK_CUR)
        result = read_object(stream, container_ref)
    else:
        # number object OR indirect reference
        if tok in NUMBER_SIGNS:
            # number
            result = NumberObject.read_from_stream(stream)
        else:
            peek = stream.read(20)
            stream.seek(-len(peek), os.SEEK_CUR)  # reset to start
            if INDIRECT_PATTERN.match(peek) is not None:
                result = IndirectObject.read_from_stream(stream, container_ref)
            else:
                result = NumberObject.read_from_stream(stream)

    result.container_ref = container_ref
    return result


class PdfObject:
    """Superclass for all PDF objects."""

    container_ref: Dereferenceable = None
    """
    For objects read from a file, `container_ref` points to the unique
    addressable object containing this object.
    
    .. _container-ref-example:
    
    .. note::
        Consider the following object definition in a PDF file:
        
        .. code-block:: text
        
           4 0 obj
           << /Foo (Bar) >>
          
        This declares a dictionary with ID `4`, but the values ``/Foo`` and 
        ``(Bar)`` are also PDF objects (a name and a string, respectively).
        All of these will have `container_ref` given by a :class:`.Reference`
        with object ID `4` and generation number `0`.
    
    If an object is part of the trailer of a PDF file, `container_ref` will be
    a :class:`.TrailerReference`.
    For newly created objects (i.e. those not read from a file), `container_ref`
    is always ``None``.
    """

    # TODO simplify a number of modification routines using this new API
    def get_container_ref(self) -> Dereferenceable:
        """
        Return a reference to the closest parent object containing this object.
        Raises an error if no such reference can be found.
        """
        ref = self.container_ref
        if ref is None:  # pragma: nocover
            raise ValueError(
                'No container reference available. This object probably '
                'wasn\'t read from a file.'
            )
        return ref

    def get_object(self):
        """Resolves indirect references.

        :return: `self`, unless an instance of :class:`.IndirectObject`.
        """
        return self

    def write_to_stream(self, stream, handler=None,
                        container_ref: Reference = None):
        """
        Abstract method to render this object to an output stream.

        :param stream:
            An output stream.
        :param container_ref:
            Local encryption key.
        :param handler:
            Security handler
        """
        raise NotImplementedError


class NullObject(PdfObject):
    """PDF `null` object.

    All instances are treated as equal and falsy.
    """

    def write_to_stream(self, stream, handler=None, container_ref=None):
        stream.write(b"null")

    @staticmethod
    def read_from_stream(stream):
        nulltxt = stream.read(4)
        if nulltxt != b"null":
            raise PdfReadError("Could not read Null object")
        return NullObject()

    def __eq__(self, other):
        return self is other or isinstance(other, NullObject)

    def __hash__(self):
        return hash(None)

    def __bool__(self):
        return False


class BooleanObject(PdfObject):
    """PDF boolean value."""

    def __init__(self, value):
        self.value = value

    def write_to_stream(self, stream, handler=None, container_ref=None):
        if self.value:
            stream.write(b"true")
        else:
            stream.write(b"false")

    @staticmethod
    def read_from_stream(stream):
        word = stream.read(4)
        if word == b"true":
            return BooleanObject(True)
        elif word == b"fals":
            stream.read(1)
            return BooleanObject(False)
        else:
            raise PdfReadError('Could not read Boolean object')

    def __bool__(self):
        return bool(self.value)

    def __eq__(self, other):
        return isinstance(other, (BooleanObject, bool)) \
               and bool(self) == bool(other)

    def __str__(self):
        return str(bool(self))

    def __repr__(self):
        return str(self)


class ArrayObject(list, PdfObject):
    """PDF array object. This class extends from Python's list class,
    and supports its interface.

    .. warning::
        Contrary to the case of dictionary objects, PyPDF2 does not
        transparently dereference array entries when accessed using
        :meth:`__getitem__`.
        For usability & consistency reasons, I decided to depart from that
        and dereference automatically.
        This makes the behaviour of :class:`.ArrayObject` consistent with
        :class:`.DictionaryObject`.

        That said, some vestiges of the old PyPDF2 behaviour may linger in
        the codebase. I'll fix those as I get to them.
    """

    def __getitem__(self, index):
        return self.raw_get(index).get_object()

    def raw_get(self, index, decrypt=True):
        """
        Get a value from an array without dereferencing.
        In other words, if the value corresponding to the given key is of type
        :class:`.IndirectObject`, the indirect reference will not be resolved.

        :param index:
            Key to look up in the dictionary.
        :param decrypt:
            If ``False``, instances of :class:`.DecryptedObjectProxy` will
            be returned as-is. If ``True``, they will be decrypted.
            Default ``True``.
        :return:
            A :class:`.PdfObject`.
        """

        val = list.__getitem__(self, index)
        return _deproxy_decrypt(val) if decrypt else val

    def write_to_stream(self, stream, handler=None, container_ref=None):
        stream.write(b"[")
        for data in self:
            stream.write(b" ")
            data.write_to_stream(
                stream, handler=handler, container_ref=container_ref
            )
        stream.write(b" ]")

    @staticmethod
    def read_from_stream(stream, container_ref):
        arr = ArrayObject()
        tmp = stream.read(1)
        if tmp != b"[":
            raise PdfReadError("Could not read array")
        while True:
            # skip leading whitespace & check for array ending
            peekahead = read_non_whitespace(stream)
            if peekahead == b"]":
                break
            stream.seek(-1, os.SEEK_CUR)
            # read and append obj
            arr.append(read_object(stream, container_ref))
        return arr


class IndirectObject(PdfObject, Dereferenceable):
    """
    Thin wrapper around a :class:`.Reference`, implementing both the
    :class:`.Dereferenceable` and :class:`.PdfObject` interfaces.

    .. warning::
        For many purposes, this class is functionally interchangeable with
        :class:`.Reference`, with one important exception:
        :class:`.IndirectObject` instances pointing to the same reference
        but occurring at different locations in the file may have distinct
        `container_ref` values.
    """

    def __init__(self, idnum, generation, pdf):
        self.reference = Reference(idnum, generation, pdf)

    def get_object(self):
        """
        :return: The PDF object this reference points to.
        """
        obj = self.reference.get_object()
        # there are few legitimate use cases for indirect references
        #  pointing to indirect references, but the standard doesn't forbid
        #  them, so we have to support them.
        # TODO protect against reference loops?
        return obj.get_object() if isinstance(obj, IndirectObject) else obj

    def get_pdf_handler(self):
        return self.reference.get_pdf_handler()

    @property
    def idnum(self) -> int:
        """
        :return: the object ID of this reference.
        """
        return self.reference.idnum

    @property
    def generation(self):
        """
        :return: the generation number of this reference.
        """
        return self.reference.generation

    def __repr__(self):
        return "IndirectObject(%r, %r)" % (self.idnum, self.generation)

    # TODO I'm starting to think that making indirect objects hashable
    #  is a bad idea. Think about that for a bit, I might just be getting
    #  overly pedantic.
    def __hash__(self):
        return hash((self.idnum, self.generation))

    def __eq__(self, other):
        return (
            other is not None and
            isinstance(other, IndirectObject) and
            self.reference == other.reference
        )

    def __ne__(self, other):
        return not self.__eq__(other)

    def write_to_stream(self, stream, handler=None, container_ref=None):
        stream.write(b"%d %d R" % (self.idnum, self.generation))

    @staticmethod
    def read_from_stream(stream, container_ref: 'Dereferenceable'):
        idnum = b""
        while True:
            tok = stream.read(1)
            if not tok:
                # stream has truncated prematurely
                raise PdfStreamError("Stream has ended unexpectedly")
            if tok.isspace():
                break
            idnum += tok
        generation = b""
        while True:
            tok = stream.read(1)
            if not tok:
                # stream has truncated prematurely
                raise PdfStreamError("Stream has ended unexpectedly")
            if tok.isspace():
                if not generation:
                    continue
                break
            generation += tok
        r = read_non_whitespace(stream)
        if r != b"R":
            pos = hex(stream.tell())
            raise PdfReadError(
                "Error reading indirect object reference at byte %s" % pos
            )
        return IndirectObject(
            int(idnum), int(generation), container_ref.get_pdf_handler()
        )


class FloatObject(decimal.Decimal, PdfObject):
    """PDF Float object.

    Internally, these are treated as decimals (and therefore actually
    fixed-point objects, to be precise).
    """

    # noinspection PyArgumentList,PyTypeChecker
    def __new__(cls, value="0", context=None):
        try:
            return decimal.Decimal.__new__(cls, str(value), context)
        except (ValueError, decimal.DecimalException):
            return decimal.Decimal.__new__(cls, str(value))

    def __repr__(self):
        if self == self.to_integral():
            return str(self.quantize(decimal.Decimal(1)))
        else:
            return str(self)

    def as_numeric(self):
        """
        :return: a Python ``float`` value for this object.
        """
        return float(self)

    def write_to_stream(self, stream, handler=None, container_ref=None):
        stream.write(repr(self).encode('ascii'))


class NumberObject(int, PdfObject):
    """
    PDF number object. This is the PDF type for integer values.
    """
    NumberPattern = re.compile(b'[^+-.0-9]')
    ByteDot = b"."

    # noinspection PyArgumentList
    def __new__(cls, value):
        val = int(value)
        try:
            return int.__new__(cls, val)
        except OverflowError:
            return int.__new__(cls, 0)

    def as_numeric(self):
        """
        :return: a Python ``int`` value for this object.
        """
        return int(self)

    def write_to_stream(self, stream, handler=None, container_ref=None):
        stream.write(repr(self).encode('ascii'))

    @staticmethod
    def read_from_stream(stream):
        num = read_until_regex(stream, NumberObject.NumberPattern)
        if num.find(NumberObject.ByteDot) != -1:
            return FloatObject(num.decode('ascii'))
        else:
            return NumberObject(num.decode('ascii'))


# TODO: not sure I like this behaviour of PyPDF2. Review.

def pdf_string(string) -> Union['ByteStringObject', 'TextStringObject']:
    """
    Encode a string as a :class:`.TextStringObject` if possible,
    or a :class:`.ByteStringObject` otherwise.

    :param string:
        A Python string.
    """
    if isinstance(string, str):
        return TextStringObject(string)
    elif isinstance(string, (bytes, bytearray)):
        try:
            if string.startswith(codecs.BOM_UTF16_BE):
                retval = TextStringObject(string.decode("utf-16"))
                retval.autodetect_utf16 = True
                return retval
            else:
                # This is probably a big performance hit here, but we need to
                # convert string objects into the text/unicode-aware version if
                # possible... and the only way to check if that's possible is
                # to try.  Some strings are strings, some are just byte arrays.
                retval = TextStringObject(decode_pdfdocencoding(string))
                retval.autodetect_pdfdocencoding = True
                return retval
        except UnicodeDecodeError:
            return ByteStringObject(string)
    else:
        raise TypeError("pdf_string should have str or bytes arg")


HEX_DIGITS = b'0123456789abcdefABCDEF'


def read_hex_string_from_stream(stream) \
        -> Union['ByteStringObject', 'TextStringObject']:
    """
    Read a hex string from a stream into a PDF string object.

    :param stream:
        An input stream.
    """
    stream.read(1)

    odd = False

    def read_tokens():
        nonlocal odd
        while True:
            tok = read_non_whitespace(stream)
            if not tok:
                # stream has truncated prematurely
                raise PdfStreamError("Stream has ended unexpectedly")
            elif tok == b">":
                return
            elif tok not in HEX_DIGITS:
                raise PdfStreamError(
                    "Unexpected token in hex string: " + repr(tok)
                )
            yield tok
            odd = not odd
    result = binascii.unhexlify(
        b''.join(read_tokens()) + (b'0' if odd else b'')
    )
    return pdf_string(result)


def read_string_from_stream(stream) -> Union['ByteStringObject',
                                             'TextStringObject']:
    """
    Read a PDF string literal from a stream.

    This method should in principle always return a :class:`TextStringObject`
    instance if the underlying file is a valid PDF file.

    :param stream:
        An input stream.
    """

    stream.read(1)
    parens = 1
    txt = b""
    while True:
        tok = stream.read(1)
        if not tok:
            # stream has truncated prematurely
            raise PdfStreamError("Stream has ended unexpectedly")
        if tok == b"(":
            parens += 1
        elif tok == b")":
            parens -= 1
            if parens == 0:
                break
        elif tok == b"\\":
            tok = stream.read(1)
            if tok in b"() /%<>[]#_&$\\":
                pass  # simply use the second byte we read
            elif tok == b"n":
                tok = b"\n"
            elif tok == b"r":
                tok = b"\r"
            elif tok == b"t":
                tok = b"\t"
            elif tok == b"b":
                tok = b"\b"
            elif tok == b"f":
                tok = b"\f"
            elif tok.isdigit():
                # "The number ddd may consist of one, two, or three
                # octal digits; high-order overflow shall be ignored.
                # Three octal digits shall be used, with leading zeros
                # as needed, if the next character of the string is also
                # a digit." (PDF reference 7.3.4.2, p 16)
                for i in range(2):
                    ntok = stream.read(1)
                    if ntok.isdigit():
                        tok += ntok
                    else:
                        break
                octal = int(tok, base=8)
                # interpret as byte
                tok = bytes((octal,))
            elif tok in b"\n\r":
                # This case is  hit when a backslash followed by a line
                # break occurs.  If it's a multi-char EOL, consume the
                # second character:
                tok = stream.read(1)
                if tok not in b"\n\r":
                    stream.seek(-1, os.SEEK_CUR)
                # Then don't add anything to the actual string, since this
                # line break was escaped:
                tok = b''
            else:
                raise PdfReadError(r"Unexpected escaped string: %s" % tok)
        txt += tok
    return pdf_string(txt)


class ByteStringObject(bytes, PdfObject):
    """ PDF bytestring class.
    """

    original_bytes = property(lambda self: self)
    """
    For compatibility with :attr:`.TextStringObject.original_bytes`
    """

    def write_to_stream(self, stream, handler=None, container_ref=None):
        bytearr = self
        if handler is not None and container_ref is not None:
            cf = handler.get_string_filter()
            local_key = cf.derive_object_key(
                container_ref.idnum, container_ref.generation
            )
            bytearr = cf.encrypt(local_key, bytearr)
        stream.write(b"<")
        stream.write(binascii.hexlify(bytearr))
        stream.write(b">")


class TextStringObject(str, PdfObject):
    """
    PDF text string object.
    """

    autodetect_pdfdocencoding = False
    """
    If ``True``, this string was determined to be encoded in PDFDoc encoding.
    """

    autodetect_utf16 = False
    """
    If ``True``, this string was determined to be encoded in UTF16-BE encoding.
    """

    @property
    def original_bytes(self):
        """
        Retrieve the original bytes of the string as specified in the
        source file.

        This may be necessary if this string was misidentified as a text string.
        """

        # We're a text string object, but the library is trying to get our raw
        # bytes.  This can happen if we auto-detected this string as text, but
        # we were wrong.  It's pretty common.  Return the original bytes that
        # would have been used to create this object, based upon the autodetect
        # method.
        if self.autodetect_utf16:
            return codecs.BOM_UTF16_BE + self.encode("utf-16be")
        elif self.autodetect_pdfdocencoding:
            return encode_pdfdocencoding(self)
        else:
            raise Exception("no information about original bytes")

    def write_to_stream(self, stream, handler=None, container_ref=None):
        # Try to write the string out as a PDFDocEncoding encoded string.  It's
        # nicer to look at in the PDF file.  Sadly, we take a performance hit
        # here for trying...
        bytearr: bytes
        try:
            bytearr = encode_pdfdocencoding(self)
        except UnicodeEncodeError:
            bytearr = codecs.BOM_UTF16_BE + self.encode("utf-16be")

        cf = None
        if handler is not None and container_ref is not None:
            cf_name = handler.crypt_filter_config.string_filter_name
            # apply default processing if the filter is the identity filter
            cf = None if cf_name == '/Identity' else handler.get_string_filter()

        if cf is not None:
            local_key = cf.derive_object_key(
                container_ref.idnum, container_ref.generation
            )
            bytearr = cf.encrypt(local_key, bytearr)
            obj = ByteStringObject(bytearr)
            obj.write_to_stream(stream)
        else:
            stream.write(b"(")
            for c in bytearr:
                c_ = bytes([c])
                if not c_.isalnum() and c != 0x20:
                    stream.write(b"\\%03o" % c)
                else:
                    stream.write(c_)
            stream.write(b")")


def _as_hex_digit(ascii_char):
    if 0x30 <= ascii_char <= 0x39:
        return ascii_char - 0x30
    elif 0x41 <= ascii_char <= 0x46:
        return ascii_char - 0x37
    elif 0x61 <= ascii_char <= 0x66:
        return ascii_char - 0x57
    else:
        raise PdfReadError(
            "Numeric escape in PDF name must use hexadecimal digits"
        )


def _decode_name(name_bytes: bytes) -> 'NameObject':
    """
    Decode the bytes that make up a name object (minus the initial /), expanding
    all escapes along the way.
    """
    result = BytesIO()
    result.write(b'/')
    name_iter = iter(name_bytes)
    try:
        while True:
            cur_byte = next(name_iter)
            if cur_byte == 0x23:  # '#' is the 2-digit escape prefix
                # escape sequence: grab next two bytes
                try:
                    digit1 = next(name_iter)
                    digit2 = next(name_iter)
                except StopIteration:
                    raise PdfReadError(
                        f"Unterminated escape in PDF name /{repr(name_bytes)}"
                    )

                cur_byte = _as_hex_digit(digit1) * 16 + _as_hex_digit(digit2)
            elif not (0x21 <= cur_byte <= 0x7e) or \
                    not is_regular_character(cur_byte):
                raise PdfReadError(
                    f"Byte (0x{hex(cur_byte)}) must be escaped in a PDF name"
                )
            result.write(
                bytes((cur_byte,))
            )
    except StopIteration:
        pass
    name_bytes = result.getvalue()
    # NOTE: we assume UTF-8, but the PDF spec actually doesn't prescribe
    #  a character encoding for names, they're just byte sequences.
    #  This doesn't matter in 99.99% of cases (since names are not supposed
    #  to contain renderable text, and are typically 7-bit ASCII anyhow),
    #  but it's not 100% correct. I don't see a way to fix this without causing
    #  massive non-obvious API breakage (since NameObject inherits from 'str' as
    #  in PyPDF2), i.e. the correctness benefit is vastly outweighed by the
    #  risks (for now)
    encodings_to_try = ('utf8', 'latin1')
    # latin1 should never trigger decoding errors, since Python's implementation
    # maps even unassigned values to corresponding unicode codepoints
    name_str = None
    for enc in encodings_to_try:
        try:
            name_str = name_bytes.decode(enc)
            break
        except ValueError:
            pass
    assert name_str is not None
    return NameObject(name_str)


class NameObject(str, PdfObject):
    """
    PDF name object. These are valid Python strings, but names and strings
    are treated differently in the PDF specification, so proper care is
    required.
    """

    DELIMITER_PATTERN = re.compile(r"\s+|[\(\)<>\[\]{}/%]".encode('ascii'))

    def write_to_stream(self, stream, handler=None, container_ref=None):
        byte_iter = iter(self.encode('utf8'))
        if not next(byte_iter) == 0x2f:
            raise PdfWriteError(
                f"Could not serialise name object {repr(self)}, "
                f"must start with /"
            )
        stream.write(b'/')
        for cur_byte in byte_iter:
            if cur_byte == 0x23 or not (0x21 <= cur_byte <= 0x7e) or \
                    not is_regular_character(cur_byte):
                stream.write('#{:X}'.format(cur_byte).encode('ascii'))
            else:
                # no convenient syntax for writing a single byte...
                as_bytes = bytes((cur_byte,))
                stream.write(as_bytes)

    @staticmethod
    def read_from_stream(stream):
        name_start = stream.read(1)
        if name_start != b'/':
            raise PdfReadError("Name object should start with /")
        name_bytes = read_until_regex(
            stream, NameObject.DELIMITER_PATTERN, ignore_eof=True
        )
        return _decode_name(name_bytes)


def _normalise_key(key):
    if not isinstance(key, NameObject):
        if isinstance(key, str):
            return NameObject(key)
        else:
            raise ValueError("key must be PdfName")
    return key


class DictionaryObject(dict, PdfObject):
    """
    A PDF dictionary object.

    Keys in a PDF dictionary are PDF names, and values are PDF objects.

    When accessing a key using the standard :meth:`__getitem__` syntax,
    :class:`.IndirectObject` references will be resolved.
    """

    def __init__(self, dict_data=None):
        if dict_data is not None:
            super().__init__(
                {_normalise_key(k): v for k, v in dict_data.items()}
            )
        else:
            super().__init__()

    def raw_get(self, key, decrypt=True):
        """
        Get a value from a dictionary without dereferencing.
        In other words, if the value corresponding to the given key is of type
        :class:`.IndirectObject`, the indirect reference will not be resolved.

        :param key:
            Key to look up in the dictionary.
        :param decrypt:
            If ``False``, instances of :class:`.DecryptedObjectProxy` will
            be returned as-is. If ``True``, they will be decrypted.
            Default ``True``.
        :return:
            A :class:`.PdfObject`.
        """
        val = dict.__getitem__(self, key)
        return _deproxy_decrypt(val) if decrypt else val

    def __setitem__(self, key, value):
        key = _normalise_key(key)
        if not isinstance(value, PdfObject):
            raise ValueError("value must be PdfObject")
        if self.container_ref is not None:
            value.container_ref = self.container_ref
        return dict.__setitem__(self, key, value)

    def setdefault(self, key, value=None):
        key = _normalise_key(key)
        if not isinstance(value, PdfObject):
            raise ValueError("value must be PdfObject")
        if self.container_ref is not None:
            value.container_ref = self.container_ref
        return dict.setdefault(self, key, value)

    def __getitem__(self, key):
        return dict.__getitem__(self, key).get_object()

    def get_and_apply(self, key, function: Callable[[PdfObject], Any], *,
                      raw=False, default=None):
        try:
            value = self.raw_get(key) if raw else self[key]
        except KeyError:
            return default
        return function(value)

    def get_value_as_reference(self, key, optional=False) -> Reference:
        def as_ref(obj):
            if isinstance(obj, IndirectObject):
                return obj.reference
            raise IndirectObjectExpected

        value = self.get_and_apply(key, as_ref, raw=True)
        if value is None and not optional:
            raise KeyError
        return value

    def write_to_stream(self, stream, handler=None, container_ref=None):
        stream.write(b"<<\n")
        for key, value in list(self.items()):
            key.write_to_stream(stream, handler, container_ref)
            stream.write(b" ")
            value.write_to_stream(stream, handler, container_ref)
            stream.write(b"\n")
        stream.write(b">>")

    @staticmethod
    def read_from_stream(stream, container_ref: 'Dereferenceable'):
        tmp = stream.read(2)
        if tmp != b"<<":
            raise PdfReadError(
                "Dictionary read error at byte %s: "
                "stream must begin with '<<'" % hex(stream.tell())
            )
        data = {}
        handler = container_ref.get_pdf_handler()
        while True:
            tok = read_non_whitespace(stream)
            if tok == b'\x00':
                continue
            elif tok == b'%':
                stream.seek(-1, os.SEEK_CUR)
                skip_over_comment(stream)
                continue
            if not tok:
                # stream has truncated prematurely
                raise PdfStreamError("Stream has ended unexpectedly")

            if tok == b">":
                stream.read(1)
                break
            stream.seek(-1, os.SEEK_CUR)
            key = read_object(stream, container_ref)
            read_non_whitespace(stream)
            stream.seek(-1, os.SEEK_CUR)
            value = read_object(stream, container_ref)
            if key not in data:
                data[key] = value
            else:
                err = (
                    "Multiple definitions in dictionary at byte "
                    "%s for key %s" % (hex(stream.tell()), key)
                )
                if handler.strict:
                    raise PdfReadError(err)
                else:
                    logger.warning(err)

        pos = stream.tell()
        s = read_non_whitespace(stream, allow_eof=True)
        stream_data = None
        if s == b's' and stream.read(5) == b'tream':
            eol = stream.read(1)
            # odd PDF file output has spaces after 'stream' keyword
            # but before EOL. patch provided by Danial Sandler
            while eol == b' ':
                eol = stream.read(1)
            assert eol in (b"\n", b"\r")
            if eol == b"\r":
                # read \n after
                if stream.read(1) != b'\n':
                    stream.seek(-1, os.SEEK_CUR)
            # this is a stream object, not a dictionary
            length = data[pdf_name("/Length")]
            if isinstance(length, IndirectObject):
                t = stream.tell()
                length = handler.get_object(length)
                stream.seek(t)
            stream_data = stream.read(length)
            e = read_non_whitespace(stream)
            ndstream = stream.read(8)
            if (e + ndstream) != b"endstream":
                # (sigh) - the odd PDF file has a length that is too long, so
                # we need to read backwards to find the "endstream" ending.
                # ReportLab (unknown version) generates files with this bug,
                # and Python users into PDF files tend to be our audience.
                # we need to do this to correct the streamdata and chop off
                # an extra character.
                pos = stream.tell()
                stream.seek(-10, os.SEEK_CUR)
                end = stream.read(9)
                if end == b"endstream":
                    # we found it by looking back one character further.
                    stream_data = stream_data[:-1]
                else:
                    stream.seek(pos)
                    raise PdfReadError(
                        "Unable to find 'endstream' marker after "
                        "stream at byte %s." % hex(stream.tell())
                    )
        else:
            stream.seek(pos)
        if stream_data is not None:
            # pass in everything as encoded data, the StreamObject class
            # will take care of decoding as necessary
            return StreamObject(data, encoded_data=stream_data)
        else:
            return DictionaryObject(data)


class StreamObject(DictionaryObject):
    """PDF stream object.

    Essentially, a PDF stream is a dictionary object with a binary blob of
    data attached. This data can be encoded by various filters (not all of which
    are currently supported, see :mod:`.filters`).

    A stream object can be initialised with encoded or decoded data.
    The former is used by :class:`.reader.PdfFileReader` to provide on-demand
    decoding, with :class:`.writer.BasePdfFileWriter` and its subclasses working
    the other way around.

    Note that the :class:`.StreamObject` class manages some of its dictionary
    keys by itself. This is partly the case for the various ``/Filter``
    and ``/DecodeParms`` entries, but also for the ``/Length`` entry.
    The latter will be overwritten as necessary.
    """

    def __init__(self, dict_data=None, stream_data=None, encoded_data=None,
                 handler=None):
        """Initialise a stream with dictionary data and stream data
        (either encoded or decoded). If both `stream_data` and `encoded_data`
        are provided, the caller is responsible for making sure that both are
        compatible given the currently relevant filter configuration.

        :param dict_data:
            The dictionary data for this stream object.
        :param stream_data:
            The (unencoded) stream data.
        :param encoded_data:
            The encoded stream data.
        :param handler:
            A reference to the currently active
            :class:`.pyhanko.pdf_utils.crypt.SecurityHandler`.
            This is only necessary if the stream requires crypt filters.
        """
        super().__init__(dict_data)
        self._data = stream_data
        self._encoded_data = encoded_data
        self._handler = handler

    @property
    def _has_crypt_filter(self) -> bool:
        return '/Crypt' in (name for name, _ in self._filters())

    def add_crypt_filter(self, name=NameObject('/Identity'),
                         params=None, handler=None):
        if handler is not None:
            self._handler = handler

        if name not in self._handler.crypt_filter_config:
            raise PdfStreamError(
                f"The crypt filter {name} is not known to the security handler."
            )
        params = params or DictionaryObject()
        params['/Type'] = pdf_name('/CryptFilterDecodeParms')
        params['/Name'] = name
        self.apply_filter(
            pdf_name('/Crypt'), params=params, allow_duplicates=True
        )

    def _filters(self) -> Iterator[Tuple[str, Optional[dict]]]:
        try:
            filter_arr = self[pdf_name('/Filter')]
        except KeyError:
            return

        if isinstance(filter_arr, NameObject):
            # we have a single filter instance
            filter_arr = (filter_arr,)
        elif not isinstance(filter_arr, ArrayObject):
            raise TypeError(
                '/Filter should be a name object or an array of names.'
            )

        try:
            decode_params = self[pdf_name('/DecodeParms')]
            if isinstance(decode_params, DictionaryObject):
                # one instance
                decode_params = (decode_params,)
            if isinstance(decode_params, (ArrayObject, tuple)):
                lendiff = len(filter_arr) - len(decode_params)
                # this should be zero, but let's be lenient
                if lendiff > 0:
                    decode_params += [NullObject()] * lendiff
        except KeyError:
            decode_params = [NullObject()] * len(filter_arr)

        # make sure to deal with resolving decrypted object proxies by
        # calling get_object()
        yield from zip(
            filter_arr, (param_set.get_object() for param_set in decode_params)
        )

    def _stream_decoders(self):
        for filter_type, params in self._filters():
            try:
                if params is None or isinstance(params, NullObject):
                    params = {}
                if filter_type == '/Crypt':
                    # crypt filters get special treatment
                    # if we're dealing with the identity filter, just move on
                    if params.get('/Name', '/Identity') == '/Identity':
                        continue
                    # if it's another one, we need a reference to the security
                    # handler
                    sh = self._handler
                    if sh is None:
                        raise PdfStreamError(
                            "PDF streams require a security handler to use "
                            "explicit /Crypt filters."
                        )
                    decoder = filters.CryptFilterDecoder(sh)
                else:
                    decoder = filters.get_generic_decoder(filter_type)
                yield decoder, params
            except KeyError:
                raise NotImplementedError(
                    "Filters of type %s are not supported." % filter_type
                )

    def strip_filters(self):
        """
        Ensure the stream is decoded, and remove any filters.
        """

        self._data = self._encoded_data = self.data
        self.pop(pdf_name('/Filter'), None)
        self.pop(pdf_name('/DecodeParms'), None)

    @property
    def data(self) -> bytes:
        """
        Return the decoded stream data as bytes.
        If the stream hasn't been decoded yet, it will be decoded on-the-fly.

        :raises .misc.PdfStreamError:
            If the stream could not be decoded.
        """
        if self._data is None:
            data = self._encoded_data
            if data is None:
                raise PdfStreamError("No data available.")
            for filter_cls, decode_params in self._stream_decoders():
                data = filter_cls.decode(data, decode_params)
            if isinstance(data, memoryview):
                data = data.tobytes()
            self._data = data
        return self._data

    @property
    def encoded_data(self) -> bytes:
        """
        Return the encoded stream data as bytes.
        If the stream hasn't been encoded yet, it will be encoded on-the-fly.

        :raises .misc.PdfStreamError:
            If the stream could not be encoded.
        """
        if self._encoded_data is None:
            data = self._data
            if data is None:
                raise PdfStreamError("No data available.")
            decoders = tuple(self._stream_decoders())
            for filter_cls, decode_params in reversed(decoders):
                data = filter_cls.encode(data, decode_params)
            self._encoded_data = data
        return self._encoded_data

    def apply_filter(self, filter_name, params=None,
                     allow_duplicates: Optional[bool] = True):
        """
        Apply a new filter to this stream. This filter will be prepended
        to any existing filters.
        This means that is is placed *last* in the encoding order, but *first*
        in the decoding order.

        *Note:* Calling this method on an encoded stream will first cause the
        stream to be decoded using the filters already present.
        The cached value for the encoded stream data will be cleared.

        :param filter_name:
            Name of the filter
            (see :const:`~pyhanko.pdf_utils.filters.DECODERS`)
        :param params:
            Parameters to the filter (will be written to ``/DecodeParms`` if
            not ``None``)
        :param allow_duplicates:
            If ``None``, silently ignore duplicate filters.
            If ``False``, raise ValueError when attempting to add a duplicate
            filter. If ``True`` (default), duplicate filters are allowed.
        """
        # If the stream already contains (encoded) data, we have to reencode it
        # later on, which requires a decoding operation.
        data = self._data
        if data is None and self._encoded_data is not None:
            data = self.data

        # ... and list all current filters with their parameters.
        cur_filters = list(self._filters())
        # normalise the input parameters
        if not isinstance(filter_name, NameObject):
            filter_name = pdf_name(filter_name)
        if params is not None and not isinstance(params, DictionaryObject):
            params = DictionaryObject(params)
        if not cur_filters:
            # only one filter, so don't write arrays
            self[pdf_name('/Filter')] = filter_name
            if params:
                self[pdf_name('/DecodeParms')] = params
        else:
            # split cur_filters back into two pieces
            filter_names, param_sets = zip(*cur_filters)
            if not allow_duplicates and filter_name in filter_names:
                if allow_duplicates is False:
                    raise ValueError(
                        f'Filter {filter_name} has already been applied to '
                        f'this stream.'
                    )
                else:
                    # Silently ignore
                    return

            # prepend the new filter (order is important!)
            self[pdf_name('/Filter')] = ArrayObject(
                (filter_name,) + filter_names
            )

            if params or any(param_sets):
                def _params():
                    yield params or NullObject()
                    for param_set in param_sets:
                        yield param_set or NullObject()
                self[pdf_name('/DecodeParms')] = ArrayObject(_params())
        self._encoded_data = None
        self._data = data

    def compress(self):
        """
        Convenience method to add a ``/FlateDecode`` filter with default
        settings, if one is not already present.

        *Note:* compression is not actually applied until the stream is written.
        """
        self.apply_filter(pdf_name('/FlateDecode'), allow_duplicates=None)

    @property
    def is_embedded_file_stream(self):
        try:
            return self.raw_get('/Type') == '/EmbeddedFile'
        except KeyError:
            return False

    def write_to_stream(self, stream, handler=None, container_ref=None):
        data = self.encoded_data
        if handler is not None and container_ref is not None and \
                not self._has_crypt_filter:
            cf = handler.get_stream_filter()
            local_key = cf.derive_object_key(
                container_ref.idnum, container_ref.generation
            )
            data = cf.encrypt(local_key, data)
        self[NameObject("/Length")] = NumberObject(len(data))
        # write the dictionary
        super().write_to_stream(stream, handler, container_ref)
        del self["/Length"]
        stream.write(b"\nstream\n")
        stream.write(data)
        stream.write(b"\nendstream")


def encode_pdfdocencoding(unicode_string):
    def _build():
        for c in unicode_string:
            try:
                yield _pdfDocEncoding_rev[c]
            except KeyError:
                raise UnicodeEncodeError(
                    "pdfdocencoding", c, -1, -1,
                    "does not exist in translation table"
                )
    return bytes(_build())


def decode_pdfdocencoding(byte_array):
    def _build():
        for b in byte_array:
            c = _pdfDocEncoding[b]
            if c == '\u0000':
                raise UnicodeDecodeError(
                    "pdfdocencoding", bytes((b,)), -1, -1,
                    "does not exist in translation table"
                )
            yield c
    return ''.join(_build())


_pdfDocEncoding = (
 '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000',
 '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000',
 '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000', '\u0000',
 '\u02d8', '\u02c7', '\u02c6', '\u02d9', '\u02dd', '\u02db', '\u02da', '\u02dc',
 '\u0020', '\u0021', '\u0022', '\u0023', '\u0024', '\u0025', '\u0026', '\u0027',
 '\u0028', '\u0029', '\u002a', '\u002b', '\u002c', '\u002d', '\u002e', '\u002f',
 '\u0030', '\u0031', '\u0032', '\u0033', '\u0034', '\u0035', '\u0036', '\u0037',
 '\u0038', '\u0039', '\u003a', '\u003b', '\u003c', '\u003d', '\u003e', '\u003f',
 '\u0040', '\u0041', '\u0042', '\u0043', '\u0044', '\u0045', '\u0046', '\u0047',
 '\u0048', '\u0049', '\u004a', '\u004b', '\u004c', '\u004d', '\u004e', '\u004f',
 '\u0050', '\u0051', '\u0052', '\u0053', '\u0054', '\u0055', '\u0056', '\u0057',
 '\u0058', '\u0059', '\u005a', '\u005b', '\u005c', '\u005d', '\u005e', '\u005f',
 '\u0060', '\u0061', '\u0062', '\u0063', '\u0064', '\u0065', '\u0066', '\u0067',
 '\u0068', '\u0069', '\u006a', '\u006b', '\u006c', '\u006d', '\u006e', '\u006f',
 '\u0070', '\u0071', '\u0072', '\u0073', '\u0074', '\u0075', '\u0076', '\u0077',
 '\u0078', '\u0079', '\u007a', '\u007b', '\u007c', '\u007d', '\u007e', '\u0000',
 '\u2022', '\u2020', '\u2021', '\u2026', '\u2014', '\u2013', '\u0192', '\u2044',
 '\u2039', '\u203a', '\u2212', '\u2030', '\u201e', '\u201c', '\u201d', '\u2018',
 '\u2019', '\u201a', '\u2122', '\ufb01', '\ufb02', '\u0141', '\u0152', '\u0160',
 '\u0178', '\u017d', '\u0131', '\u0142', '\u0153', '\u0161', '\u017e', '\u0000',
 '\u20ac', '\u00a1', '\u00a2', '\u00a3', '\u00a4', '\u00a5', '\u00a6', '\u00a7',
 '\u00a8', '\u00a9', '\u00aa', '\u00ab', '\u00ac', '\u0000', '\u00ae', '\u00af',
 '\u00b0', '\u00b1', '\u00b2', '\u00b3', '\u00b4', '\u00b5', '\u00b6', '\u00b7',
 '\u00b8', '\u00b9', '\u00ba', '\u00bb', '\u00bc', '\u00bd', '\u00be', '\u00bf',
 '\u00c0', '\u00c1', '\u00c2', '\u00c3', '\u00c4', '\u00c5', '\u00c6', '\u00c7',
 '\u00c8', '\u00c9', '\u00ca', '\u00cb', '\u00cc', '\u00cd', '\u00ce', '\u00cf',
 '\u00d0', '\u00d1', '\u00d2', '\u00d3', '\u00d4', '\u00d5', '\u00d6', '\u00d7',
 '\u00d8', '\u00d9', '\u00da', '\u00db', '\u00dc', '\u00dd', '\u00de', '\u00df',
 '\u00e0', '\u00e1', '\u00e2', '\u00e3', '\u00e4', '\u00e5', '\u00e6', '\u00e7',
 '\u00e8', '\u00e9', '\u00ea', '\u00eb', '\u00ec', '\u00ed', '\u00ee', '\u00ef',
 '\u00f0', '\u00f1', '\u00f2', '\u00f3', '\u00f4', '\u00f5', '\u00f6', '\u00f7',
 '\u00f8', '\u00f9', '\u00fa', '\u00fb', '\u00fc', '\u00fd', '\u00fe', '\u00ff'
)

assert len(_pdfDocEncoding) == 256

_pdfDocEncoding_rev = {char: ix for ix, char in enumerate(_pdfDocEncoding)}

pdf_name = NameObject
PROXYABLE = (TextStringObject, ByteStringObject, DictionaryObject, ArrayObject)


def proxy_encrypted_obj(encrypted_obj, handler):
    if isinstance(encrypted_obj, PROXYABLE):
        return DecryptedObjectProxy(encrypted_obj, handler)
    else:
        return encrypted_obj


class DecryptedObjectProxy(PdfObject):

    def __init__(self, raw_object: PdfObject, handler):
        self.raw_object = raw_object
        self._decrypted = None
        self.handler = handler

    @property
    def decrypted(self):
        decrypted = self._decrypted
        if decrypted is not None:
            return decrypted

        from .crypt import SecurityHandler

        obj = self.raw_object
        handler: SecurityHandler = self.handler
        container_ref = obj.container_ref
        if not isinstance(container_ref, Reference):
            raise ValueError(
                "Proxyable objects must have a container ref pointing to a "
                f"numbered object, not '{container_ref}'."
            )  # pragma: nocover
        if isinstance(obj, ByteStringObject) or \
                isinstance(obj, TextStringObject):
            cf = handler.get_string_filter()
            local_key = cf.derive_object_key(
                container_ref.idnum, container_ref.generation
            )
            decrypted = pdf_string(cf.decrypt(local_key, obj.original_bytes))
        elif isinstance(obj, DictionaryObject):
            decrypted_entries = {
                dictkey: proxy_encrypted_obj(value, handler)
                for dictkey, value in obj.items()
            }
            if isinstance(obj, StreamObject):
                # can't deal with crypt filters here
                if obj._has_crypt_filter:
                    # in this case, dealing with encryption is delegated
                    # to the stream decoding process, so just pretend the data
                    # is decrypted.
                    # We pass a reference to the security handler below,
                    # which is sufficient to take care of /Crypt filters
                    # in the stream.
                    decrypted_data = obj.encoded_data
                else:
                    if obj.is_embedded_file_stream:
                        cf = handler.get_embedded_file_filter()
                    else:
                        cf = handler.get_stream_filter()
                    local_key = cf.derive_object_key(
                        container_ref.idnum, container_ref.generation
                    )
                    decrypted_data = cf.decrypt(local_key, obj.encoded_data)

                decrypted = StreamObject(
                    decrypted_entries, encoded_data=decrypted_data,
                    handler=handler
                )
            else:
                decrypted = DictionaryObject(decrypted_entries)
        elif isinstance(obj, ArrayObject):
            decrypted_map = map(
                lambda v: proxy_encrypted_obj(v, handler), obj
            )
            decrypted = ArrayObject(decrypted_map)
        else:  # pragma: nocover
            raise TypeError(f'Object of type {type(obj)} is not proxyable.')
        decrypted.container_ref = obj.container_ref
        self._decrypted = decrypted
        return decrypted

    def write_to_stream(self, stream, handler=None, container_ref=None):
        # maybe the encryption key for this object changed (due to it being
        # included as part of a larger object or somesuch, without proper
        # dereferencing), so to avoid unexpected shenanigans, let's start from
        # scratch.
        self.decrypted.write_to_stream(stream, handler, container_ref)

    def get_object(self):
        return self.decrypted.get_object()

    @property
    def container_ref(self):
        return self.raw_object.container_ref

    def __eq__(self, other):
        # NOTE: this will fail if the dictionary contains "un-decryptable"
        #  descendants! The diff_analysis module is aware of this restriction,
        #  but you probably shouldn't use this __eq__ method to compare
        #  arbitrary objects in a PDF file.
        return isinstance(other, DecryptedObjectProxy) \
               and other.decrypted == self.decrypted


ASN_DT_FORMAT = "D:%Y%m%d%H%M%S"


def pdf_date(dt: datetime) -> TextStringObject:
    """
    Convert a datetime object into a PDF string.
    This function supports both timezone-aware and naive datetime objects.

    :param dt:
        The datetime object to convert.
    :return:
        A :class:`TextStringObject` representing the datetime passed in.
    """

    base_dt = dt.strftime(ASN_DT_FORMAT)
    utc_offset_string = ''
    if dt.tzinfo is not None:
        # compute UTC offset string
        tz_seconds = dt.utcoffset().total_seconds()
        if not tz_seconds:
            utc_offset_string = 'Z'
        else:
            sign = '+'
            if tz_seconds < 0:
                sign = '-'
                tz_seconds = abs(tz_seconds)
            hrs, tz_seconds = divmod(tz_seconds, 3600)
            mins = tz_seconds // 60
            # XXX the apostrophe after the minute part of the offset is NOT
            #  what's in the spec, but Adobe Reader DC refuses to validate
            #  signatures with a date string that doesn't contain it.
            #  No idea why.
            utc_offset_string = sign + ("%02d'%02d'" % (hrs, mins))

    return TextStringObject(base_dt + utc_offset_string)


# The year field is the only mandatory one
MIN_DATE_REGEX = re.compile(r'^D:(\d{4})')
TWO_DIGIT_START = re.compile(r'^(\d\d)')
UTC_OFFSET = re.compile(r"(\d\d)(?:'(\d\d))?'?")


def parse_pdf_date(date_str: str) -> datetime:
    m = MIN_DATE_REGEX.match(date_str)
    if not m:
        raise PdfReadError(f"{date_str} does not appear to be a date string.")
    year = int(m.group(1))

    # now, there are a number of 2-digit groups (anywhere from 0 to 5)
    date_remaining = date_str[6:]
    lower_order = [1, 1, 0, 0, 0]

    for ix in range(5):
        m = TWO_DIGIT_START.match(date_remaining)
        if not m:
            break
        lower_order[ix] = int(m.group(1))
        date_remaining = date_remaining[2:]

    # TODO range checks
    month, day, hour, minute, second = lower_order

    # finally, parse the timezone
    tz_info = None
    if date_remaining:
        sgn = date_remaining[0]
        if sgn == 'Z' and len(date_remaining) == 1:
            tz_offset = timedelta(0)
        elif sgn in ('+', '-'):
            tz_spec = date_remaining[1:]
            tz_match = UTC_OFFSET.fullmatch(tz_spec)
            if not tz_match:
                raise PdfReadError(
                    f"Improper timezone specification in {date_str}: {tz_spec}"
                )
            tz_hours = int(tz_match.group(1))
            tz_minutes = int(tz_match.group(2) or 0)
            tz_offset = timedelta(hours=tz_hours, minutes=tz_minutes)
            if sgn == '-':
                tz_offset = -tz_offset
        else:
            raise PdfReadError(
                f"Improper trailing characters in {date_str}."
            )
        tz_info = timezone(tz_offset)

    try:
        return datetime(
            year=year, month=month, day=day, hour=hour, minute=minute,
            second=second, microsecond=0, tzinfo=tz_info
        )
    except ValueError as e:
        raise PdfReadError("Improper date value", e)
