import argparse
import os
import subprocess
import sys
import numpy as np
from pathlib import Path
from PIL import Image

current_path = sys.path.copy()
sys.path.append(str(Path(__file__).parent.parent))

sys.path = current_path


def is_windows() -> bool:
    return os.name == "nt"


def unswizzle(input_list, width, height, pixels_per_block_w=8, pixels_per_block_h=8):
    if width * height > len(input_list):
        raise Exception(
            f"There are not enough elements in input_list for the specified Width and Height!"
            f"\nExpected a length of {width * height}, but got a length of {len(input_list)}!"
        )

    block_x_count = width // pixels_per_block_w
    block_y_count = height // pixels_per_block_h
    if block_y_count < 1:
        block_y_count = 1  # Hack for small textures -- Not sure if this is correct.

    output_buffer = [None] * len(input_list)
    pixel_index = 0

    for y_block in range(block_y_count):
        for x_block in range(block_x_count):
            for y_pixel in range(pixels_per_block_h):
                for x_pixel in range(pixels_per_block_w):
                    output_buffer_index = (
                        (width * pixels_per_block_h * y_block)
                        + y_pixel * width
                        + x_block * pixels_per_block_w
                        + x_pixel
                    )
                    output_buffer[output_buffer_index] = input_list[pixel_index]
                    pixel_index += 1

    return output_buffer


def swizzle(input_list, width, height, pixels_per_block_w=8, pixels_per_block_h=8):
    if width * height > len(input_list):
        raise Exception(
            f"There are not enough elements in input_list for the specified Width and Height!\n"
            f"Width = {width} | Height = {height} | Width * Height = {width * height} | Input List Length = {len(input_list)}"
        )

    block_x_count = width // pixels_per_block_w
    block_y_count = height // pixels_per_block_h

    output_buffer = [None] * len(input_list)
    output_buffer_index = 0

    for y_block in range(block_y_count):
        for x_block in range(block_x_count):
            for y_pixel in range(pixels_per_block_h):
                for x_pixel in range(pixels_per_block_w):
                    pixel_index = (
                        (width * pixels_per_block_h * y_block)
                        + y_pixel * width
                        + x_block * pixels_per_block_w
                        + x_pixel
                    )
                    output_buffer[output_buffer_index] = input_list[pixel_index]
                    output_buffer_index += 1

    return output_buffer


def read_rgb5a3_colors(input_bytes, n):
    """
    Reads N unsigned 16-bit values from an input bytearray as big-endian RGB5A3 colors.

    Args:
    - input_bytes (bytearray): The input bytearray.
    - n (int): The number of 16-bit values to read.

    Returns:
    - List of tuples representing RGB5A3 colors.
    """
    if len(input_bytes) < n * 2:
        raise ValueError("Input bytearray does not contain enough data for N colors")

    colors = []
    for i in range(n):
        # Extract 16-bit value for each color, assuming big-endian order
        color_value = int.from_bytes(input_bytes[i * 2 : i * 2 + 2], "big")
        colors.append(color_value)

    return colors


def to_argb8(pixel):
    if (pixel & 0x8000) == 0x8000:
        # No Alpha Channel
        a = 0xFF

        # Separate RGB from bits
        r = (pixel & 0x7C00) >> 10
        g = (pixel & 0x03E0) >> 5
        b = pixel & 0x001F

        # Convert to RGB8 values
        r = (r << (8 - 5)) | (r >> (10 - 8))
        g = (g << (8 - 5)) | (g >> (10 - 8))
        b = (b << (8 - 5)) | (b >> (10 - 8))
    else:
        # An Alpha Channel Exists, 3 bits for Alpha Channel and 4 bits each for RGB
        a = (pixel & 0x7000) >> 12
        r = (pixel & 0x0F00) >> 8
        g = (pixel & 0x00F0) >> 4
        b = pixel & 0x000F

        a = (a << (8 - 3)) | (a << (8 - 6)) | (a >> (9 - 8))
        r = (r << (8 - 4)) | r
        g = (g << (8 - 4)) | g
        b = (b << (8 - 4)) | b

    # Ensure the values are byte-sized (0-255)
    a = a & 0xFF
    r = r & 0xFF
    g = g & 0xFF
    b = b & 0xFF

    return r, g, b, a


def rgb5a3_to_argb8(colors):
    """
    Converts an array of RGB5A3 colors to standard ARGB8 colors.

    Args:
    - colors (List[int]): List of integers representing RGB5A3 colors.

    Returns:
    - List of tuples representing ARGB8 colors.
    """
    argb8_colors = []
    for color in colors:
        argb8_colors.append((to_argb8(color)))

    return argb8_colors


def extract_texture_asset(byte_array, width, height, tex_count):
    texture_data_size = (width * height) // 2
    entry_size = 2 * 16 + texture_data_size * tex_count
    n_entries = len(byte_array) // entry_size
    entries = []

    for entry_index in range(n_entries):
        offset = entry_index * entry_size
        palette_bytes = byte_array[offset : offset + 2 * 16]
        palette_colors = read_rgb5a3_colors(palette_bytes, 16)

        textures = []
        for texture_index in range(tex_count):
            texture_offset = offset + 2 * 16 + (texture_index * texture_data_size)
            texture_bytes = byte_array[
                texture_offset : texture_offset + texture_data_size
            ]
            textures.append(texture_bytes)

        entries.append({"palette": palette_colors, "textures": textures})

    return entries


def generate_c_source_entries_with_all_textures(entries, width):
    """
    Generates a list of C source strings from extracted palette and textures data.
    Each string contains one palette and all textures formatted for a C source file,
    where each entry is represented as a separate string in the list.

    Args:
    - entries (List[Dict]): Extracted data containing 'palette' and 'textures' for each entry.

    Returns:
    - List of strings, each representing the C source code for one entry including one palette and all textures.
    """
    c_source_entries = []
    for i, entry in enumerate(entries):
        c_source = ""
        # Palette
        c_source += f"// clang-format off\nunsigned short floor{i:02d}_pal[] = {{\n    "
        for j, color in enumerate(entry["palette"]):
            c_source += f"0x{color:04X},"
            if (j + 1) % 8 == 0 and (j + 1) < 16:  # Newline after every 8 entries
                c_source += "\n    "
            else:
                c_source += " "
        c_source = (
            c_source.rstrip(", \n") + "\n};\n// clang-format on\n\n"
        )  # Remove trailing comma and add closing bracket

        # Textures
        c_source += f"// clang-format off\nunsigned char floor{i:02d}_tex[] = {{\n    // texture 0\n    "
        for texture_index, texture in enumerate(entry["textures"]):
            for k, pixel in enumerate(texture):
                c_source += f"0x{pixel:02X},"
                if (k + 1) % (width // 2) == 0:  # Newline after width/2 texture pixels
                    if (
                        k + 1 != len(texture)
                        or texture_index < len(entry["textures"]) - 1
                    ):
                        c_source += "\n    "
                else:
                    c_source += " "
            if texture_index < len(entry["textures"]) - 1:
                c_source += f"// texture {texture_index + 1}\n    "  # Add a newline after each texture except the last
        c_source = (
            c_source.rstrip(", \n") + "\n};\n// clang-format on\n"
        )  # Close texture array

        c_source_entries.append(c_source)

    return c_source_entries


def save_palette_as_hex(palette, directory_path):
    """
    Saves an RGB5A3 palette list to a text file with hex values.

    Args:
    - palette (List[int]): List of colors in RGB5A3 format.
    - directory_path (Path): The directory where 'palette.txt' will be saved.
    """
    # Ensure the directory exists
    directory_path.mkdir(parents=True, exist_ok=True)

    # Define the file path
    file_path = directory_path / "palette.txt"

    with file_path.open(mode="w") as file:
        for color in palette:
            # Write each color as a hexadecimal value
            file.write(f"{color:04X}\n")


def save_textures_as_png(base_path, entries, width, height, base_name):
    for i, entry in enumerate(entries):
        folder_name = base_path / f"{base_name}{i:02d}"
        folder_name.mkdir(
            parents=True, exist_ok=True
        )  # Create the directory, including any necessary parent directories

        # Save RGB5A3 palette to 'palette.txt'
        save_palette_as_hex(entry["palette"], folder_name)

        # Convert to RGBA8
        palette = rgb5a3_to_argb8(entry["palette"])

        for j, texture_data in enumerate(entry["textures"]):
            # Construct an image from the 4-bit indexed data
            img_data = bytearray(
                len(texture_data) * 2
            )  # Each byte in texture_data represents two pixels
            for idx, byte in enumerate(texture_data):
                img_data[idx * 2] = byte >> 4  # High nibble for the first pixel
                img_data[idx * 2 + 1] = byte & 0x0F  # Low nibble for the second pixel

            # Map the indexed pixels to RGBA using the palette
            rgba_img = [palette[pix] for pix in img_data]

            # Unswizzle the RGBA image data
            unswizzled_data = unswizzle(
                rgba_img, width, height
            )  # Assume unswizzle is defined elsewhere

            # Create the final image and save it
            img = Image.new("RGBA", (width, height))
            img.putdata(unswizzled_data)
            img_file_path = folder_name / f"texture{j:02d}.png"
            img.save(img_file_path)


def generate_includes(n, input_string):
    includes = ""
    for i in range(n):
        includes += f'#include "./{input_string}{i:02d}.c"\n'
    return includes


def extract_player_room_floor(byte_array: bytearray, out_dir: str):
    entries = extract_texture_asset(byte_array, 64, 64, 4)
    entry_sources = generate_c_source_entries_with_all_textures(entries, 64)
    Path(out_dir).mkdir(parents=True, exist_ok=True)  # make dirs
    for i in range(len(entries)):
        path = Path(out_dir) / ("floor%02d.c" % i)
        with path.open("w") as f:
            f.write(entry_sources[i])
    save_textures_as_png(Path(out_dir) / "tex", entries, 64, 64, "floor")

    # write aggregate file
    with (Path(out_dir) / "player_room_floor.c").open("w") as f:
        f.write(generate_includes(len(entries), "floor"))


def extract_player_room_wall(byte_array: bytearray, out_dir: str):
    entries = extract_texture_asset(byte_array, 64, 64, 2)
    entry_sources = generate_c_source_entries_with_all_textures(entries, 64)
    Path(out_dir).mkdir(parents=True, exist_ok=True)  # make dirs
    for i in range(len(entries)):
        path = Path(out_dir) / ("wall%02d.c" % i)
        with path.open("w") as f:
            f.write(entry_sources[i])
    save_textures_as_png(Path(out_dir) / "tex", entries, 64, 64, "wall")

    # write aggregate file
    with (Path(out_dir) / "player_room_wall.c").open("w") as f:
        f.write(generate_includes(len(entries), "wall"))


# packing


def rgba8_to_rgb5a3(r, g, b, a):
    if a >= 224:  # Treat as opaque
        return (1 << 15) | ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3)
    else:  # Use 3 bits for alpha
        return ((a >> 5) << 12) | ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)


def find_closest_color(color, palette):
    min_dist, index = float("inf"), -1
    for i, p in enumerate(palette):
        # Directly compare the integer values
        dist = (color - p) ** 2
        if dist < min_dist:
            min_dist, index = dist, i
    return index


def load_palette_from_hex(directory_path):
    """
    Loads an RGB5A3 palette list from a text file containing hex values.

    Args:
    - directory_path (Path): The directory where 'palette.txt' is located.

    Returns:
    - List[int]: List of colors in RGB5A3 format.
    """
    # Define the file path
    file_path = directory_path / "palette.txt"

    palette = []
    with file_path.open(mode="r") as file:
        for line in file:
            # Convert each hexadecimal string back to an integer
            color = int(line.strip(), 16)
            palette.append(color)

    return palette


def process_png_image(image_path, palette):
    img = Image.open(image_path).convert("RGBA")
    pixels = np.array(img)

    # Create a texture map with indices
    texture_map = []
    for row in pixels:
        row_indices = []
        for r, g, b, a in row:
            rgb5a3 = rgba8_to_rgb5a3(r, g, b, a)
            closest_index = find_closest_color(rgb5a3, palette)
            row_indices.append(closest_index)
        texture_map.extend(row_indices)

    # Swizzle texture
    swizzled_texture = swizzle(texture_map, img.width, img.height)

    # Pack texture into 4bpp texels
    packed_texture_map = [0] * (len(swizzled_texture) // 2)
    for i, p in enumerate(swizzled_texture):
        if (i % 2) == 0:
            packed_texture_map[i // 2] = (p & 0xF) << 4
        else:
            packed_texture_map[i // 2] |= p & 0xF

    return packed_texture_map


def pack_player_room_floor(main_path: Path):
    objects = []
    for dir in (main_path / "tex").iterdir():
        # load palette file
        palette = load_palette_from_hex(dir)

        # load texture00.png-texture03.png
        textures = []
        for i in range(0, 4):
            textures.append(process_png_image(dir / f"texture{i:02d}.png", palette))

        obj = {
            "palette": palette,
            "textures": textures,
            "idx": int(dir.name.replace("floor", "")),
        }
        objects.append(obj)

    # sort list
    objects.sort(key=lambda x: x["idx"])

    # process texture data into C source
    c_source = generate_c_source_entries_with_all_textures(objects, 64)

    # output C source to files
    for i in range(len(objects)):
        with (main_path / f"floor{i:02d}.c").open("w") as f:
            f.write(c_source[i])

    # build elf file and dump .data section
    os.chdir(str(Path(__file__).parent.parent))

    out_elf = main_path / f"{main_path.name}.o"

    if not is_windows():
        subprocess.run(
            [
                "wibo",
                "./build/compilers/1.3.2/mwcceppc.exe",
                f"-I{str(main_path)}",
                "-c",
                main_path / f"{main_path.name}.c",
                "-o",
                out_elf,
            ]
        )
    else:
        subprocess.run(
            [
                "./build/compilers/1.3.2/mwcceppc.exe",
                f"-I{str(main_path)}",
                "-c",
                main_path / f"{main_path.name}.c",
                "-o",
                out_elf,
            ]
        )

    out_obj = main_path / f"{main_path.name}.bin"
    subprocess.run(
        ["powerpc-eabi-objcopy", "--dump-section", f".data={out_obj}", out_elf]
    )

    # restore current dir
    os.chdir(str(Path(__file__).parent))


def pack_player_room_wall(main_path: Path):
    objects = []
    for dir in (main_path / "tex").iterdir():
        # load palette file
        palette = load_palette_from_hex(dir)

        # load texture00.png-texture03.png
        textures = []
        for i in range(0, 2):
            textures.append(process_png_image(dir / f"texture{i:02d}.png", palette))

        obj = {
            "palette": palette,
            "textures": textures,
            "idx": int(dir.name.replace("wall", "")),
        }
        objects.append(obj)

    # sort list
    objects.sort(key=lambda x: x["idx"])

    # process texture data into C source
    c_source = generate_c_source_entries_with_all_textures(objects, 64)

    # output C source to files
    for i in range(len(objects)):
        with (main_path / f"wall{i:02d}.c").open("w") as f:
            f.write(c_source[i])

    # build elf file and dump .data section
    os.chdir(str(Path(__file__).parent.parent))

    out_elf = main_path / f"{main_path.name}.o"

    if not is_windows():
        subprocess.run(
            [
                "wibo",
                "./build/compilers/1.3.2/mwcceppc.exe",
                f"-I{str(main_path)}",
                "-c",
                main_path / f"{main_path.name}.c",
                "-o",
                out_elf,
            ]
        )
    else:
        subprocess.run(
            [
                "./build/compilers/1.3.2/mwcceppc.exe",
                f"-I{str(main_path)}",
                "-c",
                main_path / f"{main_path.name}.c",
                "-o",
                out_elf,
            ]
        )

    out_obj = main_path / f"{main_path.name}.bin"
    subprocess.run(
        ["powerpc-eabi-objcopy", "--dump-section", f".data={out_obj}", out_elf]
    )


def unpack():
    with open("src/data/bin2/data/player_room_floor.bin", "rb") as f:
        extract_player_room_floor(
            bytearray(f.read()), "src/data/item/player_room_floor"
        )

    with open("src/data/bin2/data/player_room_wall.bin", "rb") as f:
        extract_player_room_wall(bytearray(f.read()), "src/data/item/player_room_wall")


def pack():
    cwd = os.getcwd()

    pack_player_room_floor(Path("src/data/item/player_room_floor"))
    os.chdir(cwd)

    pack_player_room_wall(Path("src/data/item/player_room_wall"))
    os.chdir(cwd)


def main():
    parser = argparse.ArgumentParser(
        description="Pack or dump Animal Crossing player_room_[floor][wall].bin files."
    )
    parser.add_argument("-m", help="The mode to run. Valid arguments are un[pack].")

    args = parser.parse_args()

    if args.m.lower() == "pack":
        pack()
    elif args.m.lower() == "unpack":
        unpack()
    else:
        raise Exception("Invalid mode! Please use -m un[pack]")


if __name__ == "__main__":
    main()
