"""Partial implementation of the deprecated audioop module.

Only supports:
  - widths 1, 2, and 4
  - signed samples
  - bias, tomono, tostereo, lin2lin, ratecv
"""

import math
import struct
from typing import Final, List, Literal, Optional, Tuple, Union

BufferType = Union[bytes, bytearray]
Endian = Literal["big", "little"]
State = Tuple[int, Tuple[Tuple[int, ...], ...]]

# width = (_, 1, 2, _, 4)
_MAX_VALS: Final = [0, 0x7F, 0x7FFF, 0, 0x7FFFFFFF]
_MIN_VALS: Final = [0, -0x80, -0x8000, 0, -0x80000000]
_SIGNED_FORMATS: Final = ["", "b", "h", "", "i"]
_UNSIGNED_FORMATS: Final = ["", "B", "H", "", "I"]
_ENDIAN_FORMAT_PREFIX: Final = {"big": ">", "little": "<"}


def check_size(size: int) -> None:
    if size not in (1, 2, 4):
        raise ValueError(f"Size should be 1, 2, 4. Got {size}")


def check_parameters(fragment_length: int, size: int) -> None:
    check_size(size)
    if (fragment_length % size) != 0:
        raise ValueError(
            "Not a whole number of frames: "
            f"fragment_length={fragment_length}, size={size}"
        )


def _get_struct_format(width: int, endian: Endian) -> str:
    struct_format = _SIGNED_FORMATS[width]
    if width == 1:
        return struct_format

    try:
        return _ENDIAN_FORMAT_PREFIX[endian] + struct_format
    except KeyError as err:
        raise ValueError(f"Endian should be 'big' or 'little'. Got {endian}") from err


def fbound(val: float, min_val: float, max_val: float) -> int:
    if val > max_val:
        val = max_val
    elif val < (min_val + 1):
        val = min_val

    val = math.floor(val)

    return int(val)


def tomono(
    fragment: BufferType, width: int, lfactor: float, rfactor: float
) -> BufferType:
    fragment_length = len(fragment)
    check_parameters(fragment_length, width)

    max_val = _MAX_VALS[width]
    min_val = _MIN_VALS[width]
    struct_format = _SIGNED_FORMATS[width]
    result = bytearray(fragment_length // 2)

    for i in range(0, fragment_length, width * 2):
        val_left = struct.unpack_from(struct_format, fragment, i)[0]
        val_right = struct.unpack_from(struct_format, fragment, i + width)[0]
        val_mono = (val_left * lfactor) + (val_right * rfactor)
        sample_mono = fbound(val_mono, min_val, max_val)
        struct.pack_into(struct_format, result, i // 2, sample_mono)

    return result


def tostereo(
    fragment: BufferType, width: int, lfactor: float, rfactor: float
) -> BufferType:
    fragment_length = len(fragment)
    check_parameters(fragment_length, width)

    max_val = _MAX_VALS[width]
    min_val = _MIN_VALS[width]
    struct_format = _SIGNED_FORMATS[width]
    result = bytearray(fragment_length * 2)

    for i in range(0, fragment_length, width):
        val_mono = struct.unpack_from(struct_format, fragment, i)[0]
        sample_left = fbound(val_mono * lfactor, min_val, max_val)
        sample_right = fbound(val_mono * rfactor, min_val, max_val)
        struct.pack_into(struct_format, result, i * 2, sample_left)
        struct.pack_into(struct_format, result, (i * 2) + width, sample_right)

    return result


def _get_sample32(fragment: BufferType, width: int, index: int, endian: Endian) -> int:
    """Extract a sample and convert it to 32-bit representation."""
    if width == 1:
        return struct.unpack_from("b", fragment, index)[0] << 24

    if width == 2:
        return (
            struct.unpack_from(_get_struct_format(width, endian), fragment, index)[0]
            << 16
        )

    if width == 4:
        return struct.unpack_from(_get_struct_format(width, endian), fragment, index)[0]

    raise ValueError(f"Invalid width: {width}")


def _set_sample32(
    fragment: bytearray, width: int, index: int, sample: int, endian: Endian
) -> None:
    """Set a sample from 32-bit representation."""
    if width == 1:
        struct.pack_into("b", fragment, index, sample >> 24)
    elif width == 2:
        struct.pack_into(
            _get_struct_format(width, endian), fragment, index, sample >> 16
        )
    elif width == 4:
        struct.pack_into(_get_struct_format(width, endian), fragment, index, sample)
    else:
        raise ValueError(f"Invalid width: {width}")


def bias(
    fragment: BufferType, width: int, bias: int, endian: Endian = "little"
) -> BufferType:
    fragment_length = len(fragment)
    check_parameters(fragment_length, width)

    result = bytearray(fragment_length)
    mask = (1 << (8 * width)) - 1

    for i in range(0, fragment_length, width):
        # Add bias with wrap-around on overflow, matching audioop.bias.
        sample = int.from_bytes(fragment[i : i + width], endian)
        sample = (sample + bias) & mask
        result[i : i + width] = sample.to_bytes(width, endian)

    return result


def lin2lin(
    fragment: BufferType, width: int, new_width: int, endian: Endian = "little"
) -> BufferType:
    if width == new_width:
        return fragment

    fragment_length = len(fragment)
    check_parameters(fragment_length, width)
    check_size(new_width)

    result = bytearray(int((fragment_length / width) * new_width))

    j = 0
    for i in range(0, fragment_length, width):
        sample = _get_sample32(fragment, width, i, endian)
        _set_sample32(result, new_width, j, sample, endian)
        j += new_width

    return result


def ratecv(
    fragment: BufferType,
    width: int,
    nchannels: int,
    inrate: int,
    outrate: int,
    state: Optional[State],
    weightA: int = 1,
    weightB: int = 0,
) -> Tuple[bytearray, Optional[State]]:
    fragment_length = len(fragment)
    check_size(width)
    if nchannels < 1:
        raise ValueError(f"Number of channels should be >= 1, got {nchannels}")
    bytes_per_frame = width * nchannels
    if (weightA < 1) or (weightB) < 0:
        raise ValueError(
            "weightA should be >= 1, weightB should be >= 0, "
            f"got weightA={weightA}, weightB={weightB}"
        )

    if (fragment_length % bytes_per_frame) != 0:
        raise ValueError("Not a whole number of frames")

    if (inrate <= 0) or (outrate <= 0):
        raise ValueError("Sampling rate not > 0")

    d = math.gcd(inrate, outrate)
    inrate //= d
    outrate //= d

    d = math.gcd(weightA, weightB)
    weightA //= d
    weightB //= d

    prev_i: List[int] = [0] * nchannels
    cur_i: List[int] = [0] * nchannels

    if state is None:
        d = -outrate
        # prev_i and cur_i are already zeroed
    else:
        d, samps = state
        if len(samps) != nchannels:
            raise ValueError("Illegal state argument")

        for chan_index, channel in enumerate(samps):
            prev_i[chan_index], cur_i[chan_index] = channel

    input_frames = fragment_length // bytes_per_frame
    output_frames = int(math.ceil(input_frames * (outrate / inrate)))

    # Approximate version used in C code to avoid overflow:
    # q = 1 + ((input_frames - 1) // inrate)
    # output_frames = q * outrate * bytes_per_frame

    result = bytearray(output_frames * bytes_per_frame)
    struct_format = _SIGNED_FORMATS[width]

    input_index = 0
    output_index = 0
    while True:
        while d < 0:
            if input_frames == 0:
                samps = tuple(
                    (prev_i[chan], cur_i[chan]) for chan in range(0, nchannels)
                )

                # NOTE: It's critical that result is clipped here
                return result[:output_index], (d, samps)

            for chan in range(0, nchannels):
                prev_i[chan] = cur_i[chan]
                cur_i[chan] = struct.unpack_from(struct_format, fragment, input_index)[
                    0
                ]
                input_index += width
                cur_i[chan] = ((weightA * cur_i[chan]) + (weightB * prev_i[chan])) // (
                    weightA + weightB
                )

            input_frames -= 1
            d += outrate
        while d >= 0:
            for chan in range(0, nchannels):
                sample = int(
                    (
                        (float(prev_i[chan]) * float(d))
                        + (float(cur_i[chan]) * (float(outrate) - float(d)))
                    )
                    / float(outrate)
                )
                struct.pack_into(struct_format, result, output_index, sample)
                output_index += width
            d -= inrate

    return result, None
