"""This module holds classes relating to read and write operations."""

from __future__ import annotations

import io
import mmap
import os

from abc import ABC, abstractmethod
from pathlib import Path
from typing import IO, Any

from pykotor.common.language import LocalizedString
from utility.common.stream import (
    ArrayHead as _ArrayHead,
    RawBinaryReader,
    RawBinaryWriter,
    RawBinaryWriterBytearray,
    RawBinaryWriterFile,
)

ArrayHead = _ArrayHead  # backwards compatibility


class BinaryReader(RawBinaryReader, ABC):
    """Provides easier reading of binary objects that abstracts uniformly to all different stream/data types."""

    def read_locstring(self) -> LocalizedString:
        """Reads the localized string data structure from the stream.

        The binary data structure that is read follows the structure found in the GFF format specification.

        Returns:
        -------
            A LocalizedString read from the stream.
        """
        locstring: LocalizedString = LocalizedString.from_invalid()
        self.skip(4)  # total number of bytes of the localized string
        locstring.stringref = self.read_uint32(max_neg1=True)
        string_count: int = self.read_uint32()
        for _ in range(string_count):
            string_id: int = self.read_uint32()
            language, gender = LocalizedString.substring_pair(string_id)
            length: int = self.read_uint32()
            string: str = self.read_string(length, encoding=language.get_encoding())
            locstring.set_data(language, gender, string)
        return locstring


class BinaryWriter(RawBinaryWriter, ABC):
    @abstractmethod
    def write_locstring(
        self,
        value: LocalizedString,
        *,
        big: bool = False,
    ): ...

    @classmethod
    def to_bytearray(
        cls,
        data: bytearray | None = None,
    ) -> BinaryWriterBytearray:
        """Returns a new BinaryWriter with a stream established to the specified bytes.

        Args:
        ----
            data: The bytes to write to.

        Returns:
        -------
            A new BinaryWriter instance.
        """
        if isinstance(data, (bytes, memoryview)):
            msg = "Must be bytearray, not bytes or memoryview."
            raise TypeError(msg)
        if data is None:
            data = bytearray()
        return BinaryWriterBytearray(data)

    @classmethod
    def to_file(
        cls,
        path: str | os.PathLike,
    ) -> BinaryWriterFile:
        return BinaryWriterFile(Path(path).open("wb"))


class BinaryWriterFile(BinaryWriter, RawBinaryWriterFile):
    def write_locstring(
        self,
        value: LocalizedString,
        *,
        big: bool = False,
    ):
        """Writes the specified localized string to the stream.

        The binary data structure that is read follows the structure found in the GFF format specification.

        Args:
        ----
            value: The localized string to be written.
            big: Write any integers as big endian.
        """
        bw: RawBinaryWriterBytearray = RawBinaryWriter.to_bytearray()
        bw.write_uint32(value.stringref, big=big, max_neg1=True)
        bw.write_uint32(len(value), big=big)

        for language, gender, substring in value:
            string_id: int = LocalizedString.substring_id(language, gender)
            bw.write_uint32(string_id, big=big)
            bw.write_string(substring, prefix_length=4, encoding=language.get_encoding())

        locstring_data: bytes = bw.data()
        self.write_uint32(len(locstring_data))
        self.write_bytes(locstring_data)


class BinaryWriterBytearray(BinaryWriter, RawBinaryWriterBytearray):
    def write_locstring(
        self,
        value: LocalizedString,
        *,
        big: bool = False,
    ):
        """Writes the specified localized string to the stream.

        The binary data structure that is read follows the structure found in the GFF format specification.

        Args:
        ----
            value: The localized string to be written.
            big: Write any integers as big endian.
        """
        bw: BinaryWriterBytearray = BinaryWriter.to_bytearray()
        bw.write_uint32(value.stringref, big=big, max_neg1=True)
        bw.write_uint32(len(value), big=big)

        for language, gender, substring in value:
            string_id: int = LocalizedString.substring_id(language, gender)
            bw.write_uint32(string_id, big=big)
            bw.write_string(substring, prefix_length=4, encoding=language.get_encoding(), errors="replace")

        locstring_data: bytes = bw.data()
        self.write_uint32(len(locstring_data))
        self.write_bytes(locstring_data)


if __name__ == "__main__":
    import random
    import time

    # Constants
    TEST_FILE = "test_file.bin"
    NUM_OPERATIONS = 10000
    NUM_INSTANTIATIONS = 10
    FILE_SIZE = 500 * 1024 * 1024  # 500MB
    FILE_DATA: bytes | None = None

    # Function to perform the I/O operations
    def test_io_performance(  # noqa: C901, PLR0912, PLR0915
        stream_class: type,
        mode: str = "rb",
    ) -> tuple[float, float, float]:
        print(f"Testing {stream_class.__name__}, mode={mode}")
        assert FILE_DATA is not None
        instantiation_times: list[float] = []
        operation_times: list[float] = []
        stream: BinaryReader | io.BytesIO | io.FileIO | io.BufferedReader | io.BufferedRandom | None = None
        raw_raw_stream: io.RawIOBase | io.BufferedIOBase | mmap.mmap | None = None
        raw_stream: IO[Any] | mmap.mmap | None = None

        for i in range(NUM_INSTANTIATIONS):
            try:
                instantiation_start_time: float = time.time()
                if stream_class is BinaryReader:
                    if mode == "file":
                        stream = BinaryReader.from_file(TEST_FILE)
                    elif mode == "bytes":
                        instantiation_start_time = time.time()
                        stream = BinaryReader.from_bytes(FILE_DATA)
                    elif mode == "mmap":
                        raw_raw_stream = open(TEST_FILE, "rb")  # noqa: PTH123, SIM115
                        raw_stream = mmap.mmap(raw_raw_stream.fileno(), os.stat(TEST_FILE).st_size, access=mmap.ACCESS_READ)  # noqa: PTH116
                        instantiation_start_time = time.time()
                        stream = BinaryReader(raw_stream)
                    elif mode == "stream(io.BufferedReader)":
                        raw_raw_stream = open(TEST_FILE, "rb")  # noqa: PTH123, SIM115
                        raw_stream = io.BufferedReader(raw_raw_stream)
                        instantiation_start_time = time.time()
                        stream = BinaryReader.from_stream(raw_stream)
                    elif mode == "stream(io.BufferedRandom)":
                        raw_raw_stream = open(TEST_FILE, "r+b")  # noqa: PTH123, SIM115
                        assert isinstance(raw_raw_stream, io.RawIOBase), "raw_raw_stream must be a RawIOBase"
                        raw_stream = io.BufferedRandom(raw_raw_stream)
                        instantiation_start_time = time.time()
                        stream = BinaryReader.from_stream(raw_stream)
                    elif mode == "stream(io.BytesIO)":
                        # Special handling for BytesIO
                        raw_stream = io.BytesIO(FILE_DATA)
                        instantiation_start_time = time.time()
                        stream = BinaryReader.from_stream(raw_stream)
                    elif mode == "stream(io.FileIO)":
                        raw_stream = io.FileIO(TEST_FILE, "rb")
                        instantiation_start_time = time.time()
                        stream = BinaryReader.from_stream(raw_stream)
                    elif mode == "stream(raw)":
                        raw_stream = open(TEST_FILE, "rb")  # noqa: PTH123, SIM115
                        instantiation_start_time = time.time()
                        stream = BinaryReader.from_stream(raw_stream)
                    else:
                        raise ValueError(f"cannot test mode: {mode}")
                elif stream_class is io.BytesIO:
                    # Special handling for BytesIO
                    raw_stream = io.BytesIO(FILE_DATA)
                    instantiation_start_time = time.time()
                    stream = BinaryReader.from_stream(raw_stream)
                else:
                    raw_stream = open(TEST_FILE, mode)  # noqa: PTH123, SIM115
                    if stream_class is io.BufferedReader:
                        assert isinstance(raw_stream, io.RawIOBase), "raw_stream must be a RawIOBase"
                        stream = io.BufferedReader(raw_stream)
                    elif stream_class is io.BufferedRandom:
                        assert isinstance(raw_stream, io.RawIOBase), "raw_stream must be a RawIOBase"
                        stream = io.BufferedRandom(raw_stream)
                    else:
                        stream = stream_class(TEST_FILE, mode)  # pyright: ignore[reportArgumentType, reportCallIssue]
                instantiation_end_time: float = time.time()
                instantiation_times.append(instantiation_end_time - instantiation_start_time)
            finally:
                if i != NUM_INSTANTIATIONS - 1:
                    assert stream is not None, "stream is None"
                    stream.close()

        try:
            operation_start_time: float = time.time()
            for _ in range(NUM_OPERATIONS):
                seek_position: int = random.randint(0, os.path.getsize(TEST_FILE) - 1)  # noqa: S311, PTH202
                assert stream is not None, "stream is None"
                stream.seek(seek_position)
                stream.read(1)
            operation_end_time: float = time.time()
            operation_times.append(operation_end_time - operation_start_time)
        finally:
            assert stream is not None, "stream is None"
            stream.close()

        total_instantiation_time: float = sum(instantiation_times)
        total_operation_time: float = sum(operation_times)
        total_time: float = total_instantiation_time + total_operation_time

        return total_instantiation_time, total_operation_time, total_time

    # Main function to run the tests
    def main():
        global FILE_DATA  # noqa: PLW0603

        results: list[list[int | float]] = []
        # Test using different stream types
        stream_types: list[tuple[type, str]] = [
            (io.FileIO, "rb"),  # Raw access
            (io.BytesIO, "rb"),  # In-memory stream (needs special handling)
            (io.BufferedReader, "rb"),  # Buffered reading
            (io.BufferedRandom, "r+b"),  # Buffered random access
            (BinaryReader, "file"),
            (BinaryReader, "mmap"),
            (BinaryReader, "stream(io.BufferedReader)"),
            (BinaryReader, "stream(io.BufferedRandom)"),
            (BinaryReader, "stream(io.BytesIO)"),
            (BinaryReader, "stream(io.FileIO)"),
            (BinaryReader, "stream(raw)"),
        ]

        # Ensure the test file exists and is the correct size
        if not os.path.exists(TEST_FILE) or os.path.getsize(TEST_FILE) != FILE_SIZE:  # noqa: PTH110, PTH202
            FILE_DATA = os.urandom(FILE_SIZE)
            print("Creating test file...")
            with open(TEST_FILE, "wb") as f:  # noqa: PTH123
                f.write(FILE_DATA)
        if FILE_DATA is None:
            with open(TEST_FILE, "rb") as f:  # noqa: PTH123
                FILE_DATA = f.read()

        # Run the tests
        for stream_class, mode in stream_types:
            print(f"Testing {stream_class.__name__}, mode={mode}")
            total_instantiation_time, total_operation_time, total_time = test_io_performance(stream_class, mode)
            results.append(
                [
                    f"{stream_class.__name__}({mode})",  # pyright: ignore[reportArgumentType]
                    total_instantiation_time,
                    total_operation_time,
                    total_time,
                ]
            )

        # Sort by total performance (fastest first)
        results.sort(key=lambda x: x[3])
        fastest_total_time: float = results[0][3]
        fastest_instantiation_time: float = min(results, key=lambda x: x[1])[1]
        fastest_operation_time: float = min(results, key=lambda x: x[2])[2]

        for result in results:
            for i, elem in enumerate(result):
                if elem != 0:
                    continue
                result[i] = 0.0001

        # Print the results with additional statistics
        print("------------------------------------------------------\n\nInstantiation Statistics (sorted by fastest to slowest):\n")
        for result in results:
            speed_percent: float = (fastest_instantiation_time / result[1]) * 100
            print(f"{result[0]}: {result[1]:.4f} seconds ({speed_percent:.2f}% of fastest, {result[1] / NUM_INSTANTIATIONS:.2f}s per)")

        print("------------------------------------------------------\n\nOperation Statistics (sorted by fastest to slowest):")
        for result in results:
            speed_percent = (fastest_operation_time / result[2]) * 100
            print(f"{result[0]}: {result[2]:.4f} seconds ({speed_percent:.2f}% of fastest, {result[2] / NUM_INSTANTIATIONS:.2f}s per)")

        print("------------------------------------------------------\n\nCombined Statistics (sorted by fastest to slowest):")
        for result in results:
            speed_percent = (fastest_total_time / result[3]) * 100
            print(f"{result[0]}: {result[3]:.4f} seconds (Inst: {result[1]:.4f}s, Ops: {result[2]:.4f}s) ({speed_percent:.2f}% of fastest)")
        print("------------------------------------------------------\n")

        # Calculate average times
        avg_instantiation_time: float = sum(r[1] for r in results) / len(results)
        avg_operation_time: float = sum(r[2] for r in results) / len(results)
        avg_total_time: float = sum(r[3] for r in results) / len(results)

        print(f"Average Instantiation Time: {avg_instantiation_time:.4f} seconds")
        print(f"Average Operation Time: {avg_operation_time:.4f} seconds")
        print(f"Average Total Time: {avg_total_time:.4f} seconds")
        print(f"Fastest Total Time: {fastest_total_time:.4f} seconds")
        print(f"Slowest Total Time: {results[-1][3]:.4f} seconds")
        print(f"Speed Ratio (Slowest/Fastest): {results[-1][3] / fastest_total_time:.2f}\n")

    main()


def get_aurora_scale(obj) -> float:
    """If the scale is uniform, i.e, x=y=z, we will return
    the value. Else we'll return 1.
    """
    scale = obj.scale
    if scale[0] == scale[1] == scale[2]:
        return scale[0]

    return 1.0


def get_aurora_rot_from_object(obj) -> list[float]:
    q = obj.rotation_quaternion
    return [q.axis[0], q.axis[1], q.axis[2], q.angle]
