import { LazyResizableTypedArray } from './LazyResizableTypedArray';
import { RgbaComponent } from './loader/rgba';
import { Writeable } from './Types';

/**
 * Type for precalculated bit masks. Used by {@link Uint32Bits}
 */
type Masks = number[] & { negated: number[] };

/**
 * Represents the bits that can be modified/read by {@link Uint32Bits} operations.
 */
export type BitIndex =
    | 0
    | 1
    | 2
    | 3
    | 4
    | 5
    | 6
    | 7
    | 8
    | 9
    | 10
    | 11
    | 12
    | 13
    | 14
    | 15
    | 16
    | 17
    | 18
    | 19
    | 20
    | 21
    | 22
    | 23
    | 24
    | 25
    | 26
    | 27
    | 28
    | 29
    | 30
    | 31;

/**
 * bit count in {@link Uint32Bytes}.
 */
const BIT_COUNT = 32;

/**
 * Provides methods to manipulate and read individual and bytes in the uint3.
 * of a typed array `Uint32Array`.
 */
export class Uint32Bytes {
    /**
     * Points to the current number to manipulate/read bytes in/from in the {@link Uint32Bits} instance passed in the `constructor`.
     * For internal use only.
     * @hidden
     * @internal
     */
    public _dataOffsetInBytes = 0;

    /**
     * constructor
     * @param _uint32 Instance to read/manipulate bytes in.
     */
    constructor(private _uint32: Uint32Bits) {}

    /**
     * Get the current uint32 number that this instance is currently working with.
     */
    get value(): number {
        return this._uint32.value;
    }

    /**
     * Replace the current uint32 number that this instance is currently working with.
     */
    set value(v: number) {
        this._uint32.value = v;
    }

    /**
     * Whether the number this instance is working with has changed
     * started working with it.
     */
    get isChanged(): boolean {
        return this._uint32.isChanged;
    }

    /**
     * Get the value at the byte offset specified by `offset`.
     * @param offset byte offset in the current uint32 number. 0 .. 3
     * @returns Byte value at `offset`
     */
    get(offset: number | RgbaComponent): number {
        return this._uint32.data.uint8[this._dataOffsetInBytes + offset];
    }

    /**
     * Set the value at the byte offset specified by `offset`.
     * @param offset byte offset in the current uint32 number. 0 .. 3
     * @param v byte value to set. Should be 0..255
     */
    set(offset: number | RgbaComponent, v: number): void {
        const prev = this.value;
        this._uint32.data.uint8[this._dataOffsetInBytes + offset] = v;
        this._uint32._onChanged(prev, this.value);
    }

    /**
     * Copies the bytes values of the current uint32 number into `dst`. If `dst`
     * cannot accomodate all the numbers only so many numbers are copied that can fit in `dst`.
     * @param dst Destination array to write numbers to
     * @param dstOffset offset `dst` to write numbers to
     * @returns `dst`.
     */
    copyTo(dst: Writeable<ArrayLike<number>>, dstOffset = 0): ArrayLike<number> {
        for (let srcOffset = 0; srcOffset < 4 && dstOffset < dst.length; ++srcOffset, ++dstOffset) {
            dst[dstOffset] = this._uint32.data.uint8[this._dataOffsetInBytes + srcOffset];
        }
        return dst;
    }

    /**
     * Copies the bytes in `src` into the current uint32 number starting at byte position 0 and ending at
     * 3. If `src` cannot provide 4 bytes of data only as much as possible with be copied.
     * @param src Src array to copy from
     * @param srcOffset Offset in `src` to copy from
     * @returns {@link Uint32Bytes} instance.
     */
    copyFrom(src: ArrayLike<number>, srcOffset = 0): Uint32Bytes {
        const prev = this.value;
        const uint8 = this._uint32.data.uint8;
        for (let dstOffset = 0; dstOffset < 4 && srcOffset < src.length; ++srcOffset, ++dstOffset) {
            uint8[this._dataOffsetInBytes + dstOffset] = src[srcOffset];
        }
        this._uint32._onChanged(prev, this.value);
        return this;
    }
}

/**
 * A handler function for events triggered when a 32-bit unsigned integer value changes.
 *
 * @param offset - The offset at which the change occurred.
 * @param current - The current value after the change.
 * @param prev - The previous value before the change.
 */
export interface Uint32BitsOnChangedEventHandler {
    (offset: number, current: number, prev: number): void;
}

/**
 * Provides methods to manipulate and read individual and bits in the numbers held by a
 * a typed array `Uint32Array`.
 */
export class Uint32Bits {
    // Precalculate all bit masks for each bit for performance.
    // We require a lot of bitwise operations such as number & 1 << bits
    // by precalculating 1 << bits we avoid that cost.
    private static readonly _uint32BitMasks = (() => {
        const ret: Masks = [] as any;
        ret.negated = [];

        // Yes we do need 0..32 not just 0..31 or this.mask(31, 1) wont work.
        for (let bit = 0; bit <= BIT_COUNT; ++bit) {
            const m = Math.pow(2, bit);
            ret.push(m);
            ret.negated.push(~m >>> 0); // Bit shift with unsigned >>> operator to ensure we have a unsigned number.
        }
        return ret;
    })();

    private static readonly _pow2 = Array.from({ length: 64 }, (_, i) => Math.pow(2, i));

    private _dataOffset = 0;
    private _bytes = new Uint32Bytes(this);
    private _isChanged = false;

    /**
     * constructor
     * @param data uint32 numbers to read/manipulate
     * @param _onValueChanged Callback which is invoked when a {@link value} has changed.
     */
    constructor(
        public readonly data: Pick<LazyResizableTypedArray<number, Uint32Array>, 'buffer' | 'uint8'>,
        private readonly _onValueChanged: Uint32BitsOnChangedEventHandler = () => {}
    ) {
        // Why _onChanged instead of just an Observable? Because this event is called every time a value in 'data' changes
        // which can be A LOT. Calling Observeable.notifyObservers for every change comes with overhead
        // which adds up quickly when we have millions of items.
        this._bytes = new Uint32Bytes(this);
    }

    /**
     * Get a {@link Uint32Bytes} instance that can be used to manipulate the number that the bytes in {@link data}.`buffer` represents.
     * The number is specified by setting {@link dataOffset}. Calling mutating methods in the {@link Uint32Bytes} will trigger the {@link _onValueChanged}
     * callback.
     * and {@link isChanged} will also be changed accordingly.
     */
    public get bytes(): Uint32Bytes {
        this._bytes._dataOffsetInBytes = this._dataOffset * this.data.buffer.BYTES_PER_ELEMENT;
        return this._bytes;
    }

    /**
     * The `uint32` number that the {@link Uint32Bits} instance is currently working with (specified by {@link dataOffset}).
     */
    get value(): number {
        return this.data.buffer[this.dataOffset];
    }

    /**
     * Replace the `uint32` number in {@link data} that {@link dataOffset} points to.
     * Will trigger {@link _onValueChanged} callback and {@link isChanged} will also be changed accordingly.
     */
    set value(v: number) {
        const prev = this.data.buffer[this.dataOffset];
        this.data.buffer[this.dataOffset] = v;
        this._onChanged(prev, v);
    }

    /**
     * Gets the offset of the number in {@link data} the {@link Uint32Bits} instance is working with
     */
    get dataOffset(): number {
        return this._dataOffset;
    }

    /**
     * Sets the offset of the number in {@link data} the {@link Uint32Bits} instance is working with,
     * Also resets the {@link isChanged} property to false.
     */
    set dataOffset(o: number) {
        this._dataOffset = o;
        this._isChanged = false;
    }

    /**
     * Whether the number in {@link data} that {@link dataOffset} currently points to has changed since {@link dataOffset}
     * was last called. Can only be changed by calling methods and properties on this {@link Uint32Bits} instance.
     */
    get isChanged(): boolean {
        return this._isChanged;
    }

    /**
     * Check if a bit is on or off in {@link data} [{@link dataOffset}].
     * @param bit The bit to check-
     * @returns `true` if bit is on. Otherwise `false`.
     */
    has(bit: BitIndex): boolean {
        return Uint32Bits.has(this.value, bit);
    }

    /**
     * Set a bit to on/off in {@link data}[{@link dataOffset}].
     * @param bit Bit to set on/off.
     * @param truthy `truthy` sets the bit 1. `falsy` sets it to 0.
     * @returns The current {@link Uint32Bits} instance.
     */
    set(bit: BitIndex, truthy: unknown): Uint32Bits {
        this.value = Uint32Bits.set(this.value, bit, truthy);
        return this;
    }

    /**
     * Sets a bit to 1 in {@link data} [{@link dataOffset}].
     * @param bit Bit to set to on/off
     * @returns The current {@link Uint32Bits} instance.
     */
    on(bit: BitIndex): Uint32Bits {
        this.value = Uint32Bits.on(this.value, bit);
        return this;
    }

    /**
     * Sets a bit to 0 in {@link data} [{@link dataOffset}].
     * @param bit Bit to set to on/off
     * @returns The current {@link Uint32Bits} instance.
     */
    off(bit: BitIndex): Uint32Bits {
        this.value = Uint32Bits.off(this.value, bit);
        return this;
    }

    /**
     * Store a number at bits defined by `bit` and `bit + size`. Only the first size bits of `val`
     * will be used.
     * @param bit bit to start storing `val` at.
     * @param size number of bits to store `val` at.
     * @param val Value to store
     * @returns The {@link Uint32Bits} instance. Useful for chaining.
     */
    setNumber(bit: BitIndex, size: number, val: number): Uint32Bits {
        this.value = Uint32Bits.setNumber(this.value, bit, size, val);
        return this;
    }

    /**
     * Returns the number stored at a `bit` up to the bit specified by `bit` + `size`.
     * @param bit bit
     * @param size number of bits starting at `bit`.
     * @returns The number stored at `[bit, bit + size]`.
     */
    getNumber(bit: BitIndex, size: number): number {
        return Uint32Bits.getNumber(this.value, bit, size);
    }

    /**
     * Converts {@link data}[{@link dataOffset}] to a binary string
     * @returns Binary string representation (32 positions.)
     */
    toBinaryString(): string {
        return Uint32Bits.toBinaryString(this.value);
    }

    /**
     * converts `n` to a binary string
     * @returns Binary string representation (32 positions.)
     * @param n Number to convert to a binary string.
     * @returns Binary string representation (32 positions.)
     */
    static toBinaryString(n: number): string {
        return n.toString(2).padStart(32, '0');
    }

    /**
     * Creates a mask with bits specified by `bit` to `bit + size` set to 1. All other bits are 0
     * @param bit bit
     * @param size number of bits to set
     * @returns mask
     */
    static mask(bit: BitIndex, size: number): number {
        // bit shift to the right with pow to avoid signed/unsigned issue when
        // bit + size >= 31 (bit shifts are 32 bit signed in JS).
        return (Uint32Bits._uint32BitMasks[size] - 1) * Uint32Bits._pow2[bit];
    }

    /**
     * Returns the number stored at a specified bit up to the bit specified by `bit + size`.
     * @param value The value to extract the number from.
     * @param bit The starting bit position.
     * @param size The number of bits to extract.
     * @returns The number stored at [bit, bit + size].
     */
    static getNumber(value: number, bit: BitIndex, size: number): number {
        return (value & Uint32Bits.mask(bit, size)) >>> bit;
    }

    /**
     * Stores a number at bits defined by `bit` and `bit + size`. Only the first size bits of `val` will be used.
     * @param value The original value.
     * @param bit The starting bit position.
     * @param size The number of bits to store.
     * @param val The value to store.
     * @returns The modified value with the number stored at the specified bits.
     */
    static setNumber(value: number, bit: BitIndex, size: number, val: number): number {
        // Calculate number of consecutive bits that needs to be 1 (size). Then position
        // them at correct place in uint32 number. Finally negate so we get a mask where
        // the bits we want to set are zero. This we will use to zero out the corresponding
        // bits in this.data[this.dataOffset] so we can the OR in the value we want there
        const mask = Uint32Bits.mask(bit, size);
        // left shift value so it is stored starting at bit and then use mask to mask way any bits in val
        // that go outside of bit..bit + size.
        val = (val * Uint32Bits._pow2[bit]) & mask;
        return (value & ~mask) | val;
    }

    /**
     * Checks if a bit is on or off in the given value.
     * @param value The value to check.
     * @param bit The bit to check.
     * @returns `true` if the bit is on, otherwise `false`.
     */
    static has(value: number, bit: BitIndex): boolean {
        // Notice shift by >>> 0 to fix sign issue when manipulating bit 31.
        return (value & Uint32Bits._uint32BitMasks[bit]) >>> 0 > 0;
    }

    /**
     * Sets a bit to on/off in the given value.
     * @param value The original value.
     * @param bit The bit to set on/off.
     * @param truthy `truthy` sets the bit to 1, `falsy` sets it to 0.
     * @returns The modified value with the bit set.
     */
    static set(value: number, bit: BitIndex, truthy: unknown): number {
        if (truthy) {
            return Uint32Bits.on(value, bit);
        }
        return Uint32Bits.off(value, bit);
    }

    /**
     * Sets a bit to 1 in the given value.
     * @param value The original value.
     * @param bit The bit to set to 1.
     * @returns The modified value with the bit set to 1.
     */
    static on(value: number, bit: BitIndex): number {
        return value | Uint32Bits._uint32BitMasks[bit];
    }

    /**
     * Sets a bit to 0 in the given value.
     * @param value The original value.
     * @param bit The bit to set to 0.
     * @returns The modified value with the bit set to 0.
     */
    static off(value: number, bit: BitIndex): number {
        return value & Uint32Bits._uint32BitMasks.negated[bit];
    }

    /**
     * @hidden
     * @internal
     */
    public _onChanged(prev: number, current: number): void {
        const isCurrentChanged = prev !== current; // Wether value at current dataoffset has changed
        this._isChanged ||= isCurrentChanged; // Wether the buffer itself has changed at any dataoffset.
        if (isCurrentChanged) {
            this._onValueChanged(this.dataOffset, current, prev);
        }
    }
}
