using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using static System.Buffers.Binary.BinaryPrimitives;

namespace PKHeX.Core;

/// <summary>
/// Generation 5 C-Gear Background Image
/// </summary>
public sealed class CGearBackground
{
    public const string Extension = "cgb";
    public const string Filter = $"C-Gear Background|*.{Extension}";
    public const int Width = 256; // px
    public const int Height = 192; // px
    private const int ColorCount = 0x10;
    private const int TileSize = 8;
    private const int TileCount = (Width / TileSize) * (Height / TileSize); // 0x300

    internal const int CountTilePool = 0xFF;
    private const int LengthTilePool = CountTilePool * Tile.SIZE_TILE; // 0x1FE0
    private const int CountColors = 0x10;
    private const int LengthColorData = CountColors * 2; // 0x20
    private const int OffsetTileMap = LengthTilePool + LengthColorData; // 0x2000
    private const int LengthTileMap = TileCount * 2; // 0x600

    public const int SIZE_CGB = OffsetTileMap + LengthTileMap; // 0x2600

    /* CGearBackground Documentation
    * CGearBackgrounds (.cgb) are tiled images.
    * Tiles are 8x8, and serve as a tileset for building the image.
    * The first 0x2000 bytes are the tile building region.
    * A tile to have two pixels defined in one byte of space.
    * A tile takes up 64 pixels, 32 bytes, 0x20 chunks.
    * The last tile is actually the colors used in the image (16bit).
    * Only 16 colors can be used for the entire image.
    * 255 tiles may be chosen from, as (0x2000-(0x20))/0x20 = 0xFF
    * The last 0x600 bytes are the tiles used.
    * 256/8 = 32, 192/8 = 24
    * 32 * 24 = 0x300
    * The tiles are chosen based on the 16bit index of the tile.
    * 0x300 * 2 = 0x600!
    *
    * CGearBackgrounds tilemap (when stored on BW) employs some obfuscation.
    * BW obfuscates by adding 0xA0A0.
    * The obfuscated number is then tweaked by adding 15*(i/17)
    * To reverse, use a similar reverse calculation
    * PSK files are basically raw game rips (obfuscated)
    * CGB files are un-obfuscated / B2W2.
    * Due to BW and B2W2 using different obfuscation adds, PSK files are incompatible between the versions.
    */

    public readonly int[] ColorPalette;
    public readonly Tile[] Tiles;
    public readonly TileMap Map;

    public CGearBackground(ReadOnlySpan<byte> data)
    {
        if (data.Length != SIZE_CGB)
            throw new ArgumentOutOfRangeException(nameof(data));

        var dataTiles = data[..LengthTilePool];
        var dataColors = data.Slice(LengthTilePool, LengthColorData);
        var dataArrange = data.Slice(OffsetTileMap, LengthTileMap);

        Tiles = ReadTiles(dataTiles);
        ColorPalette = ReadColorPalette(dataColors);
        Map = new TileMap(dataArrange);

        foreach (var tile in Tiles)
            tile.SetTile(ColorPalette);
    }

    private CGearBackground(int[] palette, Tile[] tilelist, TileMap tm)
    {
        Map = tm;
        ColorPalette = palette;
        Tiles = tilelist;
    }

    /// <summary>
    /// Writes the <see cref="CGearBackground"/> data to a binary form.
    /// </summary>
    /// <param name="data">Destination buffer to write the skin to</param>
    /// <param name="cgb">True if the destination game is <see cref="GameVersion.B2W2"/>, otherwise false for <see cref="GameVersion.BW"/>.</param>
    /// <returns>Serialized skin data for writing to the save file</returns>
    public void Write(Span<byte> data, bool cgb)
    {
        var dataTiles = data[..LengthTilePool];
        var dataColors = data.Slice(LengthTilePool, LengthColorData);
        var dataArrange = data.Slice(OffsetTileMap, LengthTileMap);

        WriteTiles(dataTiles, Tiles);
        WriteColorPalette(dataColors, ColorPalette);
        Map.Write(dataArrange, cgb);
    }

    private static Tile[] ReadTiles(ReadOnlySpan<byte> data)
    {
        var result = new Tile[data.Length / Tile.SIZE_TILE];
        for (int i = 0; i < result.Length; i++)
        {
            var span = data.Slice(i * Tile.SIZE_TILE, Tile.SIZE_TILE);
            result[i] = new Tile(span);
        }
        return result;
    }

    private static void WriteTiles(Span<byte> data, ReadOnlySpan<Tile> tiles)
    {
        for (int i = 0; i < tiles.Length; i++)
        {
            var tile = tiles[i];
            var span = data.Slice(i * Tile.SIZE_TILE, Tile.SIZE_TILE);
            tile.Write(span);
        }
    }

    private static int[] ReadColorPalette(ReadOnlySpan<byte> data)
    {
        var result = new int[data.Length / 2];
        for (int i = 0; i < result.Length; i++)
            result[i] = Color15Bit.GetRGB555_16(ReadUInt16LittleEndian(data[(i * 2)..]));
        return result;
    }

    private static void WriteColorPalette(Span<byte> data, ReadOnlySpan<int> colors)
    {
        for (int i = 0; i < colors.Length; i++)
        {
            var value = Color15Bit.GetRGB555(colors[i]);
            WriteUInt16LittleEndian(data[(i * 2)..], value);
        }
    }

    /// <summary>
    /// Creates a new C-Gear Background object from an input image data byte array, with 32 bits per pixel.
    /// </summary>
    /// <param name="data">Image data</param>
    /// <returns>new C-Gear Background object</returns>
    public static CGearBackground GetBackground(ReadOnlySpan<byte> data)
    {
        const int bpp = 4;
        if (Width * Height * bpp != data.Length)
            throw new ArgumentException("Invalid image data size.");

        var colors = GetColorData(data);
        var palette = colors.Distinct().ToArray();
        if (palette.Length > ColorCount)
            throw new ArgumentException($"Too many unique colors. Expected <= 16, got {palette.Length}");

        var tiles = GetTiles(colors, palette);
        GetTileList(tiles, out List<Tile> tilelist, out TileMap tm);
        if (tilelist.Count >= CountTilePool)
            throw new ArgumentException($"Too many unique tiles. Expected < 256, received {tilelist.Count}.");

        // Finished!
        return new CGearBackground(palette, tilelist.ToArray(), tm);
    }

    private static int[] GetColorData(ReadOnlySpan<byte> data)
    {
        var pixels = MemoryMarshal.Cast<byte, int>(data);
        int[] colors = new int[pixels.Length];
        for (int i = 0; i < pixels.Length; i++)
        {
            var pixel = pixels[i];
            if (!BitConverter.IsLittleEndian)
                pixel = ReverseEndianness(pixel);
            colors[i] = Color15Bit.GetRGB555_32(pixel);
        }
        return colors;
    }

    private static Tile[] GetTiles(ReadOnlySpan<int> colors, ReadOnlySpan<int> palette)
    {
        var tiles = new Tile[TileCount];
        for (int i = 0; i < tiles.Length; i++)
            tiles[i] = GetTile(colors, palette, i);
        return tiles;
    }

    private static Tile GetTile(ReadOnlySpan<int> colors, ReadOnlySpan<int> palette, int tileIndex)
    {
        int x = (tileIndex * 8) % Width;
        int y = 8 * ((tileIndex * 8) / Width);

        var t = new Tile();
        var choices = t.ColorChoices;
        for (uint ix = 0; ix < 8; ix++)
        {
            for (uint iy = 0; iy < 8; iy++)
            {
                int index = ((int) (y + iy) * Width) + (int) (x + ix);
                var c = colors[index];

                choices[(ix % 8) + (iy * 8)] = (byte)palette.IndexOf(c);
            }
        }

        t.SetTile(palette);
        return t;
    }

    private static void GetTileList(ReadOnlySpan<Tile> tiles, out List<Tile> tilelist, out TileMap tm)
    {
        tilelist = new List<Tile> { tiles[0] };
        tm = new TileMap(LengthTileMap);

        // start at 1 as the 0th tile is always non-duplicate
        for (int i = 1; i < tm.TileChoices.Length; i++)
            FindPossibleRotatedTile(tiles[i], tilelist, tm, i);
    }

    private static void FindPossibleRotatedTile(Tile t, IList<Tile> tilelist, TileMap tm, int tileIndex)
    {
        // Test all tiles currently in the list
        for (byte i = 0; i < tilelist.Count; i++)
        {
            var rotVal = t.GetRotationValue(tilelist[i].ColorChoices);
            if (rotVal == Tile.ROTATION_BAD)
                continue;
            tm.TileChoices[tileIndex] = i;
            tm.Rotations[tileIndex] = rotVal;
            return;
        }

        // No tile found, add to list
        tilelist.Add(t);
        tm.TileChoices[tileIndex] = (byte)(tilelist.Count - 1);
        tm.Rotations[tileIndex] = 0;
    }

    public byte[] GetImageData()
    {
        byte[] data = new byte[4 * Width * Height];
        WriteImageData(data);
        return data;
    }

    private void WriteImageData(Span<byte> data)
    {
        for (int i = 0; i < Map.TileChoices.Length; i++)
        {
            int x = (i * 8) % Width;
            int y = 8 * ((i * 8) / Width);
            var choice = Map.TileChoices[i] % (Tiles.Length + 1);
            var tile = Tiles[choice];
            var tileData = tile.Rotate(Map.Rotations[i]);
            for (int iy = 0; iy < 8; iy++)
            {
                const int size = 4 * 8;
                int src = iy * size;
                int dest = (((y + iy) * Width) + x) * 4;
                tileData.Slice(src, size).CopyTo(data.Slice(dest, size));
            }
        }
    }
}

public sealed class Tile
{
    internal const int SIZE_TILE = 0x20;
    private const int TileWidth = 8;
    private const int TileHeight = 8;
    internal readonly byte[] ColorChoices = new byte[TileWidth * TileHeight];

    // Keep track of known rotations for this tile.
    private byte[] PixelData = Array.Empty<byte>();
    private byte[]? PixelDataX;
    private byte[]? PixelDataY;

    internal Tile() { }

    internal Tile(ReadOnlySpan<byte> data) : this()
    {
        if (data.Length != SIZE_TILE)
            throw new ArgumentException(null, nameof(data));

        // Unpack the nibbles into the color choice array.
        for (int i = 0; i < data.Length; i++)
        {
            var value = data[i];
            var ofs = i * 2;
            ColorChoices[ofs + 0] = (byte)(value & 0xF);
            ColorChoices[ofs + 1] = (byte)(value >> 4);
        }
    }

    internal void SetTile(ReadOnlySpan<int> palette) => PixelData = GetTileData(palette, ColorChoices);

    private static byte[] GetTileData(ReadOnlySpan<int> Palette, ReadOnlySpan<byte> choices)
    {
        byte[] data = new byte[choices.Length * 4];
        SetTileData(data, Palette, choices);
        return data;
    }

    private static void SetTileData(Span<byte> result, ReadOnlySpan<int> palette, ReadOnlySpan<byte> colorChoices)
    {
        for (int i = 0; i < colorChoices.Length; i++)
        {
            var choice = colorChoices[i];
            var value = palette[choice];
            var span = result.Slice(4 * i, 4);
            WriteInt32LittleEndian(span, value);
        }
    }

    public void Write(Span<byte> data) => Write(data, ColorChoices);

    private static void Write(Span<byte> data, ReadOnlySpan<byte> colorChoices)
    {
        for (int i = 0; i < data.Length; i++)
        {
            var span = colorChoices.Slice(i * 2, 2);
            data[i] = (byte)((span[0] & 0xF) | ((span[1] & 0xF) << 4));
        }
    }

    public ReadOnlySpan<byte> Rotate(int rotFlip)
    {
        if (rotFlip == 0)
            return PixelData;
        if ((rotFlip & 4) > 0)
            return PixelDataX ??= FlipX(PixelData, TileWidth);
        if ((rotFlip & 8) > 0)
            return PixelDataY ??= FlipY(PixelData, TileHeight);
        return PixelData;
    }

    private static byte[] FlipX(ReadOnlySpan<byte> data, int width, int bpp = 4)
    {
        byte[] result = new byte[data.Length];
        Result(data, result, width, bpp);
        return result;
    }

    private static byte[] FlipY(ReadOnlySpan<byte> data, int height, int bpp = 4)
    {
        byte[] result = new byte[data.Length];
        FlipY(data, result, height, bpp);
        return result;
    }

    private static void Result(ReadOnlySpan<byte> data, Span<byte> result, int width, int bpp)
    {
        int pixels = data.Length / bpp;
        for (int i = 0; i < pixels; i++)
        {
            int x = i % width;
            int y = i / width;

            x = width - x - 1; // flip x
            int dest = ((y * width) + x) * bpp;

            var o = i * bpp;
            result[dest + 0] = data[o + 0];
            result[dest + 1] = data[o + 1];
            result[dest + 2] = data[o + 2];
            result[dest + 3] = data[o + 3];
        }
    }

    private static void FlipY(ReadOnlySpan<byte> data, Span<byte> result, int height, int bpp)
    {
        int pixels = data.Length / bpp;
        int width = pixels / height;
        for (int i = 0; i < pixels; i++)
        {
            int x = i % width;
            int y = i / width;

            y = height - y - 1; // flip x
            int dest = ((y * width) + x) * bpp;

            var o = i * bpp;
            result[dest + 0] = data[o + 0];
            result[dest + 1] = data[o + 1];
            result[dest + 2] = data[o + 2];
            result[dest + 3] = data[o + 3];
        }
    }

    internal const byte ROTATION_BAD = byte.MaxValue;

    internal byte GetRotationValue(ReadOnlySpan<byte> tileColors)
    {
        // Check all rotation types
        if (tileColors.SequenceEqual(ColorChoices))
            return 0;

        if (IsMirrorX(tileColors))
            return 4;
        if (IsMirrorY(tileColors))
            return 8;
        if (IsMirrorXY(tileColors))
            return 12;

        return ROTATION_BAD;
    }

    private bool IsMirrorX(ReadOnlySpan<byte> tileColors)
    {
        const int pixels = TileWidth * TileHeight;
        for (int i = 0; i < pixels; i++)
        {
            var index = (7 - (i & 7)) + (8 * (i / 8));
            if (ColorChoices[index] != tileColors[i])
                return false;
        }

        return true;
    }

    private bool IsMirrorY(ReadOnlySpan<byte> tileColors)
    {
        const int pixels = TileWidth * TileHeight;
        for (int i = 0; i < pixels; i++)
        {
            var index = (8 * (1 + (i / 8))) + (i & 7);
            if (ColorChoices[^index] != tileColors[i])
                return false;
        }

        return true;
    }

    private bool IsMirrorXY(ReadOnlySpan<byte> tileColors)
    {
        const int pixels = TileWidth * TileHeight;
        for (int i = 0; i < pixels; i++)
        {
            if (ColorChoices[^i] != tileColors[i])
                return false;
        }

        return true;
    }
}

public sealed class TileMap
{
    public readonly byte[] TileChoices;
    public readonly byte[] Rotations;

    public TileMap(int length)
    {
        TileChoices = new byte[length / 2];
        Rotations = new byte[length / 2];
    }

    internal TileMap(ReadOnlySpan<byte> data) : this(data.Length) => LoadData(data, TileChoices, Rotations);

    private static void LoadData(ReadOnlySpan<byte> data, Span<byte> tiles, Span<byte> rotations)
    {
        bool isCGB = IsCGB(data);
        if (!isCGB)
            LoadDataPSK(data, tiles, rotations);
        else
            LoadDataCGB(data, tiles, rotations);
    }

    private static void LoadDataCGB(ReadOnlySpan<byte> data, Span<byte> tiles, Span<byte> rotations)
    {
        for (int i = 0; i < data.Length; i += 2)
        {
            var span = data.Slice(i, 2);
            var tile = span[0];
            var rot = span[1];

            tiles[i / 2] = tile;
            rotations[i / 2] = rot;
        }
    }

    private static void LoadDataPSK(ReadOnlySpan<byte> data, Span<byte> tiles, Span<byte> rotations)
    {
        for (int i = 0; i < data.Length; i += 2)
        {
            var span = data.Slice(i, 2);
            var value = ReadUInt16LittleEndian(span);
            var (tile, rot) = DecomposeValuePSK(value);
            tiles[i / 2] = tile;
            rotations[i / 2] = rot;
        }
    }

    private static bool IsCGB(ReadOnlySpan<byte> data)
    {
        // check odd bytes for anything not rotation flag
        for (int i = 0; i < data.Length; i += 2)
        {
            if ((data[i + 1] & ~0b1100) != 0)
                return false;
        }
        return true;
    }

    public void Write(Span<byte> data, bool cgb) => Write(data, TileChoices, Rotations, cgb);

    private static void Write(Span<byte> data, ReadOnlySpan<byte> choices, ReadOnlySpan<byte> rotations, bool cgb)
    {
        if (choices.Length != rotations.Length)
            throw new ArgumentException($"length of {nameof(TileChoices)} and {nameof(Rotations)} must be equal");
        if (data.Length != choices.Length + rotations.Length)
            throw new ArgumentException($"data length must be twice the length of the {nameof(TileMap)}");

        if (!cgb)
            WriteDataPSK(data, choices, rotations);
        else
            WriteDataCGB(data, choices, rotations);
    }

    private static void WriteDataCGB(Span<byte> data, ReadOnlySpan<byte> choices, ReadOnlySpan<byte> rotations)
    {
        for (int i = 0; i < data.Length; i += 2)
        {
            var span = data.Slice(i, 2);
            span[0] = choices[i / 2];
            span[1] = rotations[i / 2];
        }
    }

    private static void WriteDataPSK(Span<byte> data, ReadOnlySpan<byte> choices, ReadOnlySpan<byte> rotations)
    {
        for (int i = 0; i < data.Length; i += 2)
        {
            var span = data.Slice(i, 2);
            var tile = choices[i / 2];
            var rot = rotations[i / 2];
            int val = GetPSKValue(tile, rot);
            WriteUInt16LittleEndian(span, (ushort)val);
        }
    }

    public static (byte Tile, byte Rotation) DecomposeValuePSK(ushort val)
    {
        ushort value;
        var trunc = (val & 0x3FF);
        if (trunc is < 0xA0 or > 0x280)
            value = (ushort)((val & 0x5C00) | 0xFF);
        else
            value = (ushort)(((val % 0x20) + (17 * ((trunc - 0xA0) / 0x20))) | (val & 0x5C00));

        byte tile = (byte)value;
        byte rot = (byte)(value >> 8);
        if (tile == CGearBackground.CountTilePool) // out of range?
            tile = 0;
        return (tile, rot);
    }

    public static ushort GetPSKValue(byte tile, byte rot)
    {
        if (tile == CGearBackground.CountTilePool) // out of range?
            tile = 0;

        var result = tile + (15 * (tile / 17)) + 0xA0A0 + rot;
        return (ushort)result;
    }
}

public static class Color15Bit
{
    public static int GetRGB555_32(int val) => unchecked((int)0xFF_000000) | val; // Force opaque

    public static int GetRGB555_16(ushort val)
    {
        int R = (val >> 0) & 0x1F;
        int G = (val >> 5) & 0x1F;
        int B = (val >> 10) & 0x1F;

        R = Convert5To8[R];
        G = Convert5To8[G];
        B = Convert5To8[B];

        return (0xFF << 24) | (R << 16) | (G << 8) | B;
    }

    public static ushort GetRGB555(int v)
    {
        var R = (byte)(v >> 16);
        var G = (byte)(v >> 8);
        var B = (byte)(v >> 0);

        int val = 0;
        val |= Convert8to5(R) << 0;
        val |= Convert8to5(G) << 5;
        val |= Convert8to5(B) << 10;
        return (ushort)val;
    }

    private static byte Convert8to5(int colorval)
    {
        byte i = 0;
        while (colorval > Convert5To8[i])
            i++;
        return i;
    }

    private static ReadOnlySpan<byte> Convert5To8 => new byte[] // 0x20 entries
    {
        0x00,0x08,0x10,0x18,0x20,0x29,0x31,0x39,
        0x41,0x4A,0x52,0x5A,0x62,0x6A,0x73,0x7B,
        0x83,0x8B,0x94,0x9C,0xA4,0xAC,0xB4,0xBD,
        0xC5,0xCD,0xD5,0xDE,0xE6,0xEE,0xF6,0xFF,
    };
}