import assert from "node:assert";

import type {SerializableMemoryObject} from "./serializable-memory-object";

/* Helper Types */
type StructMemberType = "uint8" | "uint16" | "uint32" | "uint8array" | "uint8array-reversed" | "struct";
type StructBuildOmitKeys = "member" | "method" | "padding" | "build" | "default";
type StructChild = {offset: number; struct: Struct};
export type BuiltStruct<T = Struct> = Omit<T, StructBuildOmitKeys>;
export type StructFactorySignature<T = Struct> = (data?: Buffer) => T;

export type StructMemoryAlignment = "unaligned" | "aligned";

/**
 * Struct provides a builder-like interface to create Buffer-based memory
 * structures for read/write interfacing with data structures from adapters.
 */
export class Struct implements SerializableMemoryObject {
    /**
     * Creates an empty struct. Further calls to `member()` and `method()` functions will form the structure.
     * Finally call to `build()` will type the resulting structure appropriately without internal functions.
     */
    public static new(): Struct {
        return new Struct();
    }

    // @ts-expect-error initialized in `build()`
    private buffer: Buffer;
    private defaultData: Buffer | undefined;
    private members: {key: string; offset: number; type: StructMemberType; length?: number}[] = [];
    private childStructs: {[key: string]: StructChild} = {};
    private length = 0;
    private paddingByte = 0x00;

    private constructor() {}

    /**
     * Returns raw contents of the structure as a sliced Buffer.
     * Mutations to the returned buffer will not be reflected within struct.
     */
    public serialize(alignment: StructMemoryAlignment = "unaligned", padLength = true, parentOffset = 0): Buffer {
        switch (alignment) {
            case "unaligned": {
                /* update child struct values and return as-is (unaligned) */
                for (const key of Object.keys(this.childStructs)) {
                    const child = this.childStructs[key];
                    this.buffer.set(child.struct.serialize(alignment), child.offset);
                }
                return Buffer.from(this.buffer);
            }
            case "aligned": {
                /* create 16-bit aligned buffer */
                const aligned = Buffer.alloc(this.getLength(alignment, padLength, parentOffset), this.paddingByte);
                let offset = 0;
                for (const member of this.members) {
                    switch (member.type) {
                        case "uint8":
                            aligned.set(this.buffer.slice(member.offset, member.offset + 1), offset);
                            offset += 1;
                            break;
                        case "uint16":
                            offset += offset % 2;
                            aligned.set(this.buffer.slice(member.offset, member.offset + 2), offset);
                            offset += 2;
                            break;
                        case "uint32":
                            offset += offset % 2;
                            aligned.set(this.buffer.slice(member.offset, member.offset + 4), offset);
                            offset += 4;
                            break;
                        case "uint8array":
                        case "uint8array-reversed":
                            assert(member.length !== undefined);
                            aligned.set(this.buffer.slice(member.offset, member.offset + member.length), offset);
                            offset += member.length;
                            break;
                        case "struct": {
                            const structData = this.childStructs[member.key].struct.serialize(alignment, false, offset);
                            aligned.set(structData, offset);
                            offset += structData.length;
                            break;
                        }
                    }
                }
                return aligned;
            }
        }
    }

    /**
     * Returns total length of the struct. Struct length is always fixed and configured
     * by calls to `member()` methods.
     */
    public getLength(alignment: StructMemoryAlignment = "unaligned", padLength = true, parentOffset = 0): number {
        switch (alignment) {
            case "unaligned": {
                /* return actual length */
                return this.length;
            }
            case "aligned": {
                /* compute aligned length and return */
                let length = this.members.reduce((offset, member) => {
                    switch (member.type) {
                        case "uint8":
                            offset += 1;
                            break;
                        case "uint16":
                            offset += ((parentOffset + offset) % 2) + 2;
                            break;
                        case "uint32":
                            offset += ((parentOffset + offset) % 2) + 4;
                            break;
                        case "uint8array":
                        case "uint8array-reversed":
                            assert(member.length !== undefined);
                            offset += member.length;
                            break;
                        case "struct":
                            offset += this.childStructs[member.key].struct.getLength(alignment, false);
                            break;
                    }
                    return offset;
                }, 0);
                if (padLength) {
                    length += length % 2;
                }
                return length;
            }
        }
    }

    /**
     * Returns structure contents in JS object format.
     */
    public toJSON() {
        return this.members.reduce((a, c) => {
            // biome-ignore lint/suspicious/noExplicitAny: API
            a[c.key] = (this as any)[c.key];
            return a;
            // biome-ignore lint/suspicious/noExplicitAny: API
        }, {} as any);
    }

    /**
     * Adds a numeric member of `uint8`, `uint16` or `uint32` type.
     * Internal representation is always little endian.
     *
     * *This method is stripped from type on struct `build()`.*
     *
     * @param type Underlying data type (uint8, uint16 or uint32).
     * @param name Name of the struct member.
     */
    public member<T extends number, N extends string, R extends this & Record<N, T>>(type: "uint8" | "uint16" | "uint32", name: N): R;

    /**
     * Adds an uint8 array (byte array) as a struct member.
     *
     * *This method is stripped from type on struct `build()`.*
     *
     * @param type Underlying data type. Must be `uint8array`.
     * @param name Name of the struct member.
     * @param length Length of the byte array.
     */
    public member<T extends Buffer, N extends string, R extends this & Record<N, T>>(
        type: "uint8array" | "uint8array-reversed",
        name: N,
        length: number,
    ): R;

    /**
     * Adds another struct type as a struct member. Struct factory is provided
     * as a child struct definition source.
     *
     * *This method is stripped from type on struct `build()`.*
     *
     * @param type Underlying data type. Must be `struct`.
     * @param name Name of the struct member.
     * @param structFactory Factory providing the wanted child struct.
     */
    public member<T extends BuiltStruct, N extends string, R extends this & Record<N, T>>(
        type: "struct",
        name: N,
        structFactory: StructFactorySignature<T>,
    ): R;

    public member<T extends number | BuiltStruct, N extends string, R extends this & Record<N, T>>(
        type: StructMemberType,
        name: N,
        lengthOrStructFactory?: number | StructFactorySignature<T>,
    ): R {
        const offset = this.length;
        const structFactory = type === "struct" ? (lengthOrStructFactory as StructFactorySignature<T>) : undefined;
        const length = structFactory ? (structFactory() as unknown as Struct).length : (lengthOrStructFactory as number);

        switch (type) {
            case "uint8": {
                Object.defineProperty(this, name, {
                    enumerable: true,
                    get: () => this.buffer.readUInt8(offset),
                    set: (value: number) => this.buffer.writeUInt8(value, offset),
                });
                this.length += 1;
                break;
            }
            case "uint16": {
                Object.defineProperty(this, name, {
                    enumerable: true,
                    get: () => this.buffer.readUInt16LE(offset),
                    set: (value: number) => this.buffer.writeUInt16LE(value, offset),
                });
                this.length += 2;
                break;
            }
            case "uint32": {
                Object.defineProperty(this, name, {
                    enumerable: true,
                    get: () => this.buffer.readUInt32LE(offset),
                    set: (value: number) => this.buffer.writeUInt32LE(value, offset),
                });
                this.length += 4;
                break;
            }
            case "uint8array":
            case "uint8array-reversed": {
                /* v8 ignore start */
                if (!length) {
                    throw new Error("Struct builder requires length for `uint8array` and `uint8array-reversed` type");
                }
                /* v8 ignore stop */
                Object.defineProperty(this, name, {
                    enumerable: true,
                    get: () =>
                        type === "uint8array-reversed"
                            ? Buffer.from(this.buffer.slice(offset, offset + length)).reverse()
                            : Buffer.from(this.buffer.slice(offset, offset + length)),
                    set: (value: Buffer) => {
                        if (value.length !== length) {
                            throw new Error(`Invalid length for member ${name} (expected=${length}, got=${value.length})`);
                        }
                        if (type === "uint8array-reversed") {
                            value = Buffer.from(value).reverse();
                        }
                        for (let i = 0; i < length; i++) {
                            this.buffer[offset + i] = value[i];
                        }
                    },
                });
                this.length += length;
                break;
            }
            case "struct": {
                assert(structFactory);
                this.childStructs[name] = {offset, struct: structFactory() as unknown as Struct};
                Object.defineProperty(this, name, {
                    enumerable: true,
                    get: () => this.childStructs[name].struct,
                });
                this.length += length;
            }
        }
        this.members.push({key: name, offset, type, length});
        return this as unknown as R;
    }

    /**
     * Adds a custom method to the struct.
     *
     * *This method is stripped from type on struct `build()`.*
     *
     * @param name Name of the method to be appended.
     * @param _returnType Return type (eg. `Buffer.prototype`).
     * @param body Function implementation. Takes struct as a first and single input parameter.
     */
    public method<T, N extends string, R extends this & Record<N, () => T>>(name: N, _returnType: T, body: (struct: R) => T): R {
        Object.defineProperty(this, name, {
            enumerable: true,
            configurable: false,
            writable: false,
            // @ts-expect-error ignore because we are using `this`
            value: () => body.bind(this)(this),
        });
        return this as unknown as R;
    }

    /**
     * Sets default data to initialize empty struct with.
     *
     * @param data Data to initialize empty struct with.
     */
    public default(data: Buffer): this {
        /* v8 ignore start */
        if (data.length !== this.length) {
            throw new Error("Default value needs to have the length of unaligned structure.");
        }
        /* v8 ignore stop */
        this.defaultData = Buffer.from(data);
        return this;
    }

    /**
     * Sets byte to use for padding.
     *
     * @param padding Byte to use for padding
     */
    public padding(padding = 0x00): this {
        this.paddingByte = padding;
        return this;
    }

    /**
     * Creates the struct and optionally fills it with data. If data is provided, the length
     * of the provided buffer needs to match the structure length.
     *
     * *This method is stripped from type on struct `build()`.*
     */
    public build(data?: Buffer): BuiltStruct<this> {
        if (data) {
            if (data.length === this.getLength("unaligned")) {
                this.buffer = Buffer.from(data);
                for (const key of Object.keys(this.childStructs)) {
                    const child = this.childStructs[key];
                    child.struct.build(this.buffer.slice(child.offset, child.offset + child.struct.length));
                }
            } else if (data.length === this.getLength("aligned")) {
                this.buffer = Buffer.alloc(this.length, this.paddingByte);
                let offset = 0;
                for (const member of this.members) {
                    switch (member.type) {
                        case "uint8":
                            this.buffer.set(data.slice(offset, offset + 1), member.offset);
                            offset += 1;
                            break;
                        case "uint16":
                            offset += offset % 2;
                            this.buffer.set(data.slice(offset, offset + 2), member.offset);
                            offset += 2;
                            break;
                        case "uint32":
                            offset += offset % 2;
                            this.buffer.set(data.slice(offset, offset + 4), member.offset);
                            offset += 4;
                            break;
                        case "uint8array":
                        case "uint8array-reversed":
                            assert(member.length !== undefined);
                            this.buffer.set(data.slice(offset, offset + member.length), member.offset);
                            offset += member.length;
                            break;
                        case "struct": {
                            const child = this.childStructs[member.key];
                            child.struct.build(data.slice(offset, offset + child.struct.length));
                            this.buffer.set(child.struct.serialize(), member.offset);
                            offset += child.struct.length;
                            break;
                        }
                    }
                }
            } else {
                const expectedLengths = `${this.getLength("unaligned")}/${this.getLength("aligned")}`;
                throw new Error(`Struct length mismatch (expected=${expectedLengths}, got=${data.length})`);
            }
        } else {
            this.buffer = this.defaultData ? Buffer.from(this.defaultData) : Buffer.alloc(this.length);
        }
        return this;
    }
}
