"""
Binary processing utilities for reading and writing PSD file structures.

This module provides low-level utilities for reading and writing binary data
in the Adobe Photoshop PSD file format. All functions handle big-endian byte
order as specified by the PSD format.

These utilities are used throughout the ``psd_tools.psd`` package for parsing
and serializing PSD file structures.
"""

import array
import logging
import struct
import sys
from typing import IO, Any, Callable

logger = logging.getLogger(__name__)


def pack(fmt: str, *args: Any) -> bytes:
    fmt = str(">" + fmt)
    return struct.pack(fmt, *args)


def unpack(fmt: str, data: bytes) -> tuple[Any, ...]:
    fmt = str(">" + fmt)
    return struct.unpack(fmt, data)


def read_fmt(fmt: str, fp: IO[bytes]) -> tuple[Any, ...]:
    """
    Reads data from ``fp`` according to ``fmt``.
    """
    fmt = str(">" + fmt)
    fmt_size = struct.calcsize(fmt)
    data = fp.read(fmt_size)
    if len(data) != fmt_size:
        fp.seek(-len(data), 1)
        raise IOError(
            "Failed to read data section: read=%d, expected=%d. "
            "Likely the file is corrupted." % (len(data), fmt_size)
        )
    return struct.unpack(fmt, data)


def write_fmt(fp: IO[bytes], fmt: str, *args: Any) -> int:
    """
    Writes data to ``fp`` according to ``fmt``.
    """
    fmt = str(">" + fmt)
    fmt_size = struct.calcsize(fmt)
    written = write_bytes(fp, struct.pack(fmt, *args))
    if written != fmt_size:
        raise IOError(
            "Failed to write data section: written=%d, expected=%d."
            % (written, fmt_size)
        )
    return written


def write_bytes(fp: IO[bytes], data: bytes) -> int:
    """
    Write bytes to the file object and returns bytes written.

    :return: written byte size
    """
    pos = fp.tell()
    fp.write(data)
    written = fp.tell() - pos
    if written != len(data):
        raise IOError(
            "Failed to write data: written=%d, expected=%d." % (written, len(data))
        )
    return written


def read_length_block(fp: IO[bytes], fmt: str = "I", padding: int = 1) -> bytes:
    """
    Read a block of data with a length marker at the beginning.

    :param fp: file-like
    :param fmt: format of the length marker
    :return: bytes object
    """
    length = read_fmt(fmt, fp)[0]
    data = fp.read(length)
    if len(data) != length:
        raise IOError(
            "Failed to read data section: read=%d, expected=%d. "
            "Likely the file is corrupted." % (len(data), length)
        )
    read_padding(fp, length, padding)
    return data


def write_length_block(
    fp: IO[bytes],
    writer: Callable[..., int],
    fmt: str = "I",
    padding: int = 1,
    **kwargs: Any,
) -> int:
    """
    Writes a block of data with a length marker at the beginning.

    Example::

        with io.BytesIO() as fp:
            write_length_block(fp, lambda f: f.write(b'\x00\x00'))

    :param fp: file-like
    :param writer: function object that takes file-like object as an argument
    :param fmt: format of the length marker
    :param padding: divisor for padding not included in length marker
    :return: written byte size
    """
    length_position = reserve_position(fp, fmt)
    written = writer(fp, **kwargs)
    written += write_position(fp, length_position, written, fmt)
    written += write_padding(fp, written, padding)
    return written


def reserve_position(fp: IO[bytes], fmt: str = "I") -> int:
    """
    Reserves the current position for write.

    Use with `write_position`.

    :param fp: file-like object
    :param fmt: format of the reserved position
    :return: the position
    """
    position = fp.tell()
    fp.seek(struct.calcsize(str(">" + fmt)), 1)
    return position


def write_position(fp: IO[bytes], position: int, value: int, fmt: str = "I") -> int:
    """
    Writes a value to the specified position.

    :param fp: file-like object
    :param position: position of the value marker
    :param value: value to write
    :param fmt: format of the value
    :return: written byte size
    """
    current_position = fp.tell()
    fp.seek(position)
    written = write_bytes(fp, struct.pack(str(">" + fmt), value))
    fp.seek(current_position)
    return written


def read_padding(fp: IO[bytes], size: int, divisor: int = 2) -> bytes:
    """
    Read padding bytes for the given byte size.

    :param fp: file-like object
    :param divisor: divisor of the byte alignment
    :return: read byte size
    """
    remainder = size % divisor
    if remainder:
        return fp.read(divisor - remainder)
    return b""


def write_padding(fp: IO[bytes], size: int, divisor: int = 2) -> int:
    """
    Writes padding bytes given the currently written size.

    :param fp: file-like object
    :param divisor: divisor of the byte alignment
    :return: written byte size
    """
    remainder = size % divisor
    if remainder:
        return write_bytes(fp, struct.pack("%dx" % (divisor - remainder)))
    return 0


def is_readable(fp: IO[bytes], size: int = 1) -> bool:
    """
    Check if the file-like object is readable.

    :param fp: file-like object
    :param size: byte size
    :return: bool
    """
    read_size = len(fp.read(size))
    fp.seek(-read_size, 1)
    return read_size == size


def pad(number: int, divisor: int) -> int:
    if number % divisor:
        number = (number // divisor + 1) * divisor
    return number


def read_pascal_string(
    fp: IO[bytes], encoding: str = "macroman", padding: int = 2
) -> str:
    """
    Reads pascal string (length + bytes).

    :param fp: file-like object
    :param encoding: string encoding
    :param padding: padding size
    :return: str
    """
    start_pos = fp.tell()
    # read_length_block doesn't work for a byte.
    length = read_fmt("B", fp)[0]
    data = fp.read(length)
    assert len(data) == length, (len(data), length)
    read_padding(fp, fp.tell() - start_pos, padding)
    return data.decode(encoding)


def write_pascal_string(
    fp: IO[bytes], value: str, encoding: str = "macroman", padding: int = 2
) -> int:
    data = value.encode(encoding)
    written = write_fmt(fp, "B", len(data))
    written += write_bytes(fp, data)
    written += write_padding(fp, written, padding)
    return written


def read_unicode_string(fp: IO[bytes], padding: int = 1) -> str:
    num_chars = read_fmt("I", fp)[0]
    data = fp.read(num_chars * 2)
    read_padding(fp, struct.calcsize("I") + num_chars * 2, padding)
    return data.decode("utf-16-be")


def write_unicode_string(fp: IO[bytes], value: str, padding: int = 1) -> int:
    encoded = value.encode("utf-16-be")
    written = write_fmt(fp, "I", len(encoded) // 2)
    written += write_bytes(fp, encoded)
    written += write_padding(fp, written, padding)
    return written


def read_be_array(fmt: str, count: int, fp: IO[bytes]) -> array.array:
    """
    Reads an array from a file with big-endian data.
    """
    arr = array.array(str(fmt))
    arr.frombytes(fp.read(count * arr.itemsize))
    return fix_byteorder(arr)


def write_be_array(fp: IO[bytes], arr: array.array) -> int:
    """
    Writes an array to a file with big-endian data.
    """
    return write_bytes(fp, be_array_to_bytes(arr))


def fix_byteorder(arr: array.array) -> array.array:
    """
    Fixes the byte order of the array (assuming it was read
    from a Big Endian data).
    """
    if sys.byteorder == "little":
        arr.byteswap()
    return arr


def be_array_from_bytes(fmt: str, data: bytes) -> array.array:
    """
    Reads an array from bytestring with big-endian data.
    """
    arr = array.array(str(fmt), data)
    return fix_byteorder(arr)


def be_array_to_bytes(arr: array.array) -> bytes:
    """
    Writes an array to bytestring with big-endian data.
    """
    data = fix_byteorder(arr)
    if hasattr(arr, "tobytes"):
        return data.tobytes()
    else:
        return data.tostring()  # type: ignore[attr-defined]


def trimmed_repr(data: Any, trim_length: int = 16) -> str:
    if isinstance(data, bytes):
        if len(data) > trim_length:
            return repr(data[:trim_length] + b" ... =" + str(len(data)).encode("ascii"))
    return repr(data)


def decode_fixed_point_32bit(data: bytes) -> float:
    """
    Decodes ``data`` as an unsigned 4-byte fixed-point number.
    """
    lo, hi = unpack("2H", data)
    # XXX: shouldn't denominator be 2**16 ?
    return lo + hi / (2**16 - 1)
