import { BlockDataParser, BlockStates, ChunkRootTag, ChunkSectionTag, Palette } from "..";
import { BinaryParser, BitParser, findChildTagAtPath, findCompoundListChildren, NBTActions, nbtTagReducer, TagData, TagType } from "../..";
import { paletteNameList, paletteBlockList, paletteAsList } from "../block";
import { Coordinate3D } from "../types";

export function mod(n: number, m: number) {
    if (n < 0) return ((n % m) + m) % m;
    return n % m;
}

export function chunkCoordinateFromIndex(index: number): Coordinate3D {
    return [
        index % 16,
        Math.floor(index / 256),
        Math.floor(index / 16) % 16
    ];
}

export function indexFromChunkCoordinate(coordinate: Coordinate3D): number {
    const [ x, y, z ] = coordinate;
    return (y * 16 + z) * 16 + x;
}

export function biomeCoordinateFromIndex(index: number): Coordinate3D {
    return [
        index % 4,
        Math.floor(index / 16),
        Math.floor(index / 4) % 4
    ];
}

export function indexFromBiomeCoordinate(coordinate: Coordinate3D): number {
    const [ x, y, z ] = coordinate;
    return (y * 4 + z) * 4 + x;
}

export function sectionBlockStates(section: TagData[]): BlockStates | undefined {
    const b = section.find(x => x.name === "BlockStates") || (section.find(x => x.name === "block_states")?.data as TagData[]).find(x => x.name === "data");
    return b as BlockStates | undefined;
}

export function sectionPalette(section: TagData[]): Palette | undefined {
    const p = section.find(x => x.name === "Palette" || x.name === "palette") || (section.find(x => x.name === "block_states")?.data as TagData[]).find(x => x.name === "palette");
    return p as Palette | undefined;
}

export function blockParserFromSection(section: TagData[]): BlockDataParser | undefined {
    const blockstates = sectionBlockStates(section);
    const palette = sectionPalette(section);
    if (!blockstates || !palette) return;
    return new BlockDataParser(blockstates, palette);
}

/**
 * Class for parsing, mutating, and writing chunk-format data to and from NBT-format blobs. This class handles
 * NBT-related parsing and writing only; chunk compression logic is handled by the AnvilParser class.
 * @member AIR hash of the string "minecraft:air()"
 * @member BLOCKS_PER_CHUNK total number of blocks in a 16x16x16 chunk section
 * @member root the compound NBT tag containing this chunk's data.
 */
export class Chunk {

    static readonly AIR = -968583441; // hash of "minecraft:air()"
    static readonly BLOCKS_PER_CHUNK = 4096; // 16 * 16 * 16
    
    private blockStates: Map<number, number[][][]> = new Map();
    private palettes: Map<number, Palette | undefined> = new Map();
    private blockStatesDirty: Map<number, boolean> = new Map();
    root: TagData;

    /**
     * Checks if the given NBT tag is a valid chunk section tag (containing a list of chunk sections).
     * @param tag the tag to check.
     * @returns true if the tag is a valid section tag; false otherwise.
     */
    static isValidChunkSectionTag(tag?: TagData): tag is ChunkSectionTag {
        const t = tag as ChunkSectionTag;
        return t && t.type === TagType.LIST && t.name.toLowerCase() === "sections" && t.data && t.data.subType === TagType.COMPOUND
            && t.data.data && t.data.data.length !== undefined;
    }

    /**
     * Checks if the given NBT tag is a valid chunk root tag.
     * @param tag the tag to check.
     * @returns true if the tag is a valid chunk root tag; false otherwise.
     */
    static isValidChunkRootTag(tag?: TagData): tag is ChunkRootTag {
        const t = tag as ChunkRootTag;
        return t && t.type === TagType.COMPOUND && t.name === "" && t.data && t.data.length !== undefined;
    }

    /**
     * Creates a zero-filled 2D array of integers for storing blocks in an x-coordinate column of a chunk or section.
     * @param y the total number of y-coordinates to represent.
     * @param z the total number of z-coordinates to represnt.
     * @returns a zero-filled 2D array.
     */
    static emptyX(y: number = 16, z: number = 16): number[][] {
        const r: number[][] = [];
        for (let i = 0; i < y; ++i) {
            const c = [];
            for (let j = 0; j < z; ++j) c.push(0);
            r.push(c);
        }
        return r;
    }

    /**
     * Constructs a new chunk object from a given NBT tag.
     * @param root the NBT tag containing the chunk's data; must pass Chunk.isValidChunkRootTag.
     */
    constructor(root: TagData) {
        this.root = root;
    }

    /**
     * Checks if the given block coordinate is contained within this chunk.
     * @param c the coordinate to check.
     * @returns true if this coordinate is contained within the chunk; false otherwise.
     */
    containsCoordinate(c: Coordinate3D): boolean {
        if (c[1] < -64 || c[1] > 256) return false;
        const cc = this.getCoordinates();
        if (!cc) return false;
        return c[0] >= cc[0] && c[0] < cc[0] + 16 && c[2] >= cc[1] && c[2] < cc[1] + 16;
    }

    /**
     * Encodes this chunk's location (in chunk coordinates) for use as a key.
     * @returns a string representation of this chunk's coordinates which may be used as a key.
     */
    coordinateKey(): string | undefined {
        const coordinates = this.getChunkCoordinates();
        if (!coordinates) return;
        return `${coordinates[0]},${coordinates[1]}`;
    }

    /**
     * Fetches this chunk's location in chunk coordinates.
     * @returns the chunk's coordinates.
     */
    getChunkCoordinates(): [ number, number ] | undefined {
        if (!Chunk.isValidChunkRootTag(this.root)) return;
        const x = (findChildTagAtPath("Level/xPos", this.root) || findChildTagAtPath("xPos", this.root))?.data;
        const z = (findChildTagAtPath("Level/zPos", this.root) || findChildTagAtPath("zPos", this.root))?.data;
        if (x !== undefined && z !== undefined) return [ x as number, z as number ];
    }

    /**
     * Fetches the starting location of this chunk in block coordinates.
     * @returns the chunk's coordinates.
     */
    getCoordinates(): [ number, number ] | undefined {
        const c = this.getChunkCoordinates();
        if (!c) return;
        return [ c[0] * 16, c[1] * 16 ];
    }

    /**
     * Fetches the NBT tag containing the list of chunk sections within this chunk.
     * @returns the section NBT tag.
     */
    sections(): ChunkSectionTag | undefined {
        if (!Chunk.isValidChunkRootTag(this.root)) return;
        const sectionTag = findChildTagAtPath("Level/Sections", this.root) || findChildTagAtPath("sections", this.root);
        if (!Chunk.isValidChunkSectionTag(sectionTag)) return;
        return sectionTag;
    }

    /**
     * Fetches a list of section NBT tags within this chunk, sorted by ascending Y coordinate.
     * @returns a list of section NBT tags.
     */
    sortedSections(): TagData[][] | undefined {
        const s = this.sections();
        if (s === undefined) return;
        const yTags = findCompoundListChildren(s!, x => x.name === "Y")?.map( (v, i) => ({ v, i }) ).filter(x => x.v);
        return yTags?.sort( (a, b) => (a.v!.data as number) - (b.v!.data as number) ).map( x => s!.data.data[x.i] );
    }

    /**
     * Fetches a list of 3D coordinates where blocks of the given type occur within this chunk.
     * @param name the name of the block to fetch.
     * @returns a list of 3D coordinate block locations.
     */
    findBlocksByName(name: string): Coordinate3D[] {
        this.chunkData();
        const sections = this.sortedSections();
        if (sections === undefined) return [];
        return sections.flatMap( section => {
            let yy = (section.find(x => x.name === "Y")?.data || 0) as number * 16;
            if (yy >= 4032) yy -= 4096;
            const [ xx, zz ] = this.getCoordinates() || [ 0, 0 ];
            const blockData = sectionBlockStates(section);
            const palette = sectionPalette(section);
            if (blockData === undefined || palette === undefined) return [];
            return (new BlockDataParser(blockData as BlockStates, palette as Palette))
                .findBlocksByName(name)
                .map(chunkCoordinateFromIndex)
                .map(x => [ x[0] + xx, x[1] + yy, x[2] + zz ] as Coordinate3D);
        });
    }

    /**
     * Fetches the block at the provided coordinates.
     * @param coordinates the 3D coordinates at which to fetch the block.
     * @returns the name and property key-value pairs for the block.
     */
    getBlock(coordinates: Coordinate3D): { name: string, properties: { [key: string]: string } } {
        const yIndex = coordinates[1] >= 0 ? Math.floor(coordinates[1] / 16) : Math.floor((coordinates[1] + 4096) / 16);
        const [ s, palette ] = this.sectionBlockStateTensor(yIndex);
        const i = s[mod(coordinates[0], 16)][(coordinates[1] + 64) % 16][mod(coordinates[2], 16)];
        return (palette ? paletteAsList(palette) : [])[i];
    }

    getSectionContainingCoordinate(coordinates: Coordinate3D): TagData[] | undefined {
        const s = this.sortedSections();
        const yIndex = coordinates[1] >= 0 ? Math.floor(coordinates[1] / 16) : Math.floor((coordinates[1] + 4096) / 16);
        if (!s) return;
        return s.find(x => x.find(xx => xx.name === "Y")?.data === yIndex) as TagData[];
    }

    /**
     * Places a block at the provided coordinates.
     * @param coordinates the 3D coordinates at which to set the block.
     * @param name the name of the block to set.
     * @param properties property key-value pairs for the block.
     */
    setBlock(coordinates: Coordinate3D, name: string, properties: { [key: string]: string }) {
        const yIndex = coordinates[1] >= 0 ? Math.floor(coordinates[1] / 16) : Math.floor((coordinates[1] + 4096) / 16);
        const fullName = `${name}(${Object.keys(properties).map(k => `${k}:${properties[k]}`).sort((a, b) => a.localeCompare(b)).join(",")})`;
        const [ s, palette ] = this.sectionBlockStateTensor(yIndex);
        const nameOrder = palette ? paletteBlockList(palette) : [];
        const i = nameOrder.findIndex(x => x === fullName);
        const index = i !== -1 ? i : nameOrder.length;
        s[mod(coordinates[0], 16)][(coordinates[1] + 64) % 16][mod(coordinates[2], 16)] = index;
        if (i === -1) this.palettes.set(yIndex, nbtTagReducer(palette || { type: TagType.LIST, name: "palette", data: { subType: TagType.COMPOUND, data: [] } }, {
            type: NBTActions.NBT_ADD_COMPOUND_LIST_ITEM,
            path: "",
            index,
            tags: [{
                type: TagType.STRING,
                name: "Name",
                data: name
            }, ...(Object.keys(properties).length === 0 ? [] : [{
                type: TagType.COMPOUND,
                name: "Properties",
                data: Object.keys(properties).map(k => ({
                    type: TagType.STRING,
                    name: k,
                    data: properties[k]
                }))
            }])] as TagData[]
        }) as Palette);
    }

    /**
     * Obtains a set of unique block names contained within the chunk.
     * @returns a set of block name strings.
     */
    uniqueBlockNames(): Set<string> {
        return new Set(
            this.sortedSections()?.flatMap(x => paletteNameList(x.find(xx => xx.name === "Palette" || xx.name === "palette") as Palette)) || []
        );
    }

    /**
     * Fetches a 2D matrix of world heights for this chunk.
     * @param name the world height metric to use; defaults to WORLD_SURFACE.
     * @returns a matrix of world heights; x-coordinate is the outer coordinate and z-coordinate is the inner coordinate.
     */
    worldHeights(name: string = "WORLD_SURFACE") : number[][] | undefined {

        /* check if the tag is valid */
        if (!Chunk.isValidChunkRootTag(this.root)) return;
        
        /* extract the height map tag */
        const r: number[][] = Chunk.emptyX(16);
        const map = findChildTagAtPath(`Level/Heightmaps/${name}`, this.root) || findChildTagAtPath(`Heightmaps/${name}`, this.root);
        if (map === undefined || map.type !== TagType.LONG_ARRAY || !map.data) return;
    
        /* initialize bitwise parser for the height map tag */
        const d = new BinaryParser(map.data as ArrayBuffer);
        const b = new BigUint64Array(d.remainingLength() / 8);
        for (let i = 0; i < b.length; ++i) b[i] = d.getUInt64LE();
        const p = new BitParser(b.buffer);
    
        /* loop the tag extracting height values */
        for (let i = 0; i < 259; ++i) {
            const ii = i + 6 - 2 * (i % 7);
            const x = ii % 16;
            const z = Math.floor(ii / 16);
            if (i % 7 === 0) p.getBits(1);
            const cc = p.getBits(9);
            if (x < 16 && z < 16) r[x][z] = cc;
        }
        return r;
    
    }

    /**
     * Extracts a 3D tensor containing block states for the chunk section at the given y coordinates and the palette NBT tag.
     * @param yIndex the y index of the coordinate (in chunk coordinates).
     * @returns a 3D tensor of block state indexes (corresponding to indexes in the block state palette for the section) and the palette NBT tag.
     */
    sectionBlockStateTensor(yIndex: number): [ number[][][], Palette | undefined ] {
        this.blockStatesDirty.set(yIndex, true);
        if (this.blockStates.get(yIndex) && this.palettes.get(yIndex)) return [ this.blockStates.get(yIndex)!, this.palettes.get(yIndex)! ];
        const r: number[][][] = [];
        for (let x = 0; x < 16; ++x) r.push(Chunk.emptyX());
        const sections = this.sortedSections();
        if (!sections) return [ r, undefined ];
        const section = sections.find(x => x.find(xx => xx.name === "Y")?.data === yIndex) as TagData[];
        const blockData = section && sectionBlockStates(section);
        const palette = section && sectionPalette(section);
        this.blockStates.set(yIndex, r);
        this.palettes.set(yIndex, palette as Palette);
        if (blockData === undefined) return [ r, palette as Palette ];
        const b = new BlockDataParser(blockData as BlockStates, palette as Palette);
        b.getRawBlocks().forEach( (v, i) => {
            const [ x, y, z ] = chunkCoordinateFromIndex(i);
            r[x][y][z] = v || 0;
        });
        return [ r, palette as Palette ];
    }

    /**
     * Extracts a 3D tensor of block states for the chunk.
     * @returns a 3D tensor of block states for the chunk, each corresponding to an index in the corresponding chunk section palette.
     */
    blockStateTensor(): number[][][] {
        const r: number[][][] = [];
        for (let x = 0; x < 16; ++x) r.push(Chunk.emptyX());
        const sections = this.sortedSections() || [];
        sections.forEach( (section, y) => {
            const yy = y * 16;
            const blockData = sectionBlockStates(section);
            const palette = sectionPalette(section);
            if (blockData === undefined || palette === undefined) return [];
            const b = new BlockDataParser(blockData as BlockStates, palette as Palette);
            b.getBlockTypeIDs().forEach( (v, i) => {
                const [ x, y, z ] = chunkCoordinateFromIndex(i);
                r[x][y + yy + 64][z] = v || 0;
            });
        });
        return r;
    }

    /**
     * Flushes unsaved changes to this chunk's NBT tag and then returns the tag. This method should be used instead of
     * directly accessing the object's root tag property to ensure any unsaved block changes are reflected.
     * @returns NBT tag containing this chunk's data.
     */
    chunkData(): TagData {

        /* get a list of sections in this chunk */
        const sections = this.sections()?.data.data;
        if (!sections) return this.root;

        /* loop through chunks needing updates */
        [ ...this.blockStatesDirty.keys() ].filter(k => this.blockStatesDirty.get(k)).forEach( yy => {

            /* create a flat list of blocks in this section */
            const blocks: number[] = [];
            for (let y = 0; y < 16; ++y)
                for (let z = 0; z < 16; ++z)
                    for (let x = 0; x < 16; ++x)
                        blocks.push(this.blockStates.get(yy)![x][y][z]);

            /* write out updated blocks and palette for this section */
            const [ r, palette ] = BlockDataParser.writeBlockStates(blocks, this.palettes.get(yy)!);
            const index = sections.findIndex(x => x.find(xx => xx.name === "Y")?.data === yy);
            this.root = (palette?.data.data.length || 0) > 1 ? nbtTagReducer(this.root, {
                type: NBTActions.NBT_ADD_TAG,
                overwrite: true,
                path: `sections/${index}/block_states`,
                tag: {
                    type: TagType.LONG_ARRAY,
                    name: 'data',
                    data: r
                }
            }) : ( findChildTagAtPath(`sections/${index}/block_states/data`, this.root) ? nbtTagReducer(this.root, {
                type: NBTActions.NBT_DELETE_TAG,
                recursive: true,
                path: `sections/${index}/block_states/data`
            }) : this.root ); // updates block state data or deletes it if the palette is a single item
            this.palettes.set(yy, palette);
            this.blockStates.delete(yy);
            this.root = nbtTagReducer(this.root, {
                type: NBTActions.NBT_ADD_TAG,
                overwrite: true,
                path: `sections/${index}/block_states`,
                tag: (this.palettes.get(yy) || findChildTagAtPath(`sections/${index}/block_states/palette`, this.root))!
            }); // updates palette
            if (findChildTagAtPath(`sections/${index}/SkyLight`, this.root) !== undefined)
                this.root = nbtTagReducer(this.root, {
                    type: NBTActions.NBT_DELETE_TAG,
                    path: `sections/${index}/SkyLight`
                });
            if (findChildTagAtPath(`sections/${index}/BlockLight`, this.root) !== undefined)
                this.root = nbtTagReducer(this.root, {
                    type: NBTActions.NBT_DELETE_TAG,
                    path: `sections/${index}/BlockLight`
                });
            this.blockStatesDirty.set(yy, false);

        });
        if (findChildTagAtPath("Heightmaps", this.root) !== undefined)
            this.root = nbtTagReducer(this.root, {
                path: "Heightmaps",
                recursive: true,
                type: NBTActions.NBT_DELETE_TAG
            });
        this.root = nbtTagReducer(this.root, {
            type: NBTActions.NBT_EDIT_TAG,
            path: "Status",
            tag: { name: "Status", type: TagType.STRING, data: "features" }
        });
        return this.root;

    }

};
