import { OPERATION } from "../encoding/spec.js";
import { Metadata } from "../Metadata.js";
import { Schema } from "../Schema.js";
import type { IRef, Ref } from "../encoder/ChangeTree.js";
import type { Decoder } from "./Decoder.js";
import { Iterator, decode } from "../encoding/decode.js";
import { $childType, $deleteByIndex, $getByIndex, $refId } from "../types/symbols.js";

import type { ArraySchema } from "../types/custom/ArraySchema.js";

import { getType } from "../types/registry.js";
import { Collection } from "../types/HelperTypes.js";

export interface DataChange<T = any, F = string> {
    ref: IRef,
    refId: number,
    op: OPERATION,
    field: F;
    dynamicIndex?: number | string;
    value: T;
    previousValue: T;
}

export const DEFINITION_MISMATCH = -1;

export type DecodeOperation<T extends Schema = any> = (
    decoder: Decoder<T>,
    bytes: Uint8Array,
    it: Iterator,
    ref: IRef,
    allChanges: DataChange[],
) => number | void;

export function decodeValue<T extends Ref>(
    decoder: Decoder,
    operation: OPERATION,
    ref: T,
    index: number,
    type: any,
    bytes: Uint8Array,
    it: Iterator,
    allChanges: DataChange[],
) {
    const $root = decoder.root;
    const previousValue = (ref as any)[$getByIndex](index) as T;

    let value: any;

    if ((operation & OPERATION.DELETE) === OPERATION.DELETE)
    {
        // Flag `refId` for garbage collection.
        const previousRefId = previousValue?.[$refId];
        if (previousRefId !== undefined) { $root.removeRef(previousRefId); }

        //
        // Delete operations
        //
        if (operation !== OPERATION.DELETE_AND_ADD) {
            (ref as any)[$deleteByIndex](index);
        }

        value = undefined;
    }

    if (operation === OPERATION.DELETE) {
        //
        // Don't do anything
        //

    } else if (Schema.is(type)) {
        const refId = decode.number(bytes, it);
        value = $root.refs.get(refId);

        if ((operation & OPERATION.ADD) === OPERATION.ADD) {
            const childType = decoder.getInstanceType(bytes, it, type);
            if (!value) {
                value = decoder.createInstanceOfType(childType);
            }

            $root.addRef(
                refId,
                value,
                (
                    value !== previousValue || // increment ref count if value has changed
                    (operation === OPERATION.DELETE_AND_ADD && value === previousValue) // increment ref count if the same instance is being added again
                )
            );
        }

    } else if (typeof(type) === "string") {
        //
        // primitive value (number, string, boolean, etc)
        //
        value = (decode as any)[type](bytes, it);

    } else {
        const typeDef = getType(Object.keys(type)[0]);
        const refId = decode.number(bytes, it);

        const valueRef: Ref = ($root.refs.has(refId))
            ? previousValue || $root.refs.get(refId)
            : new typeDef.constructor();

        value = valueRef.clone(true);
        value[$childType] = Object.values(type)[0]; // cache childType for ArraySchema and MapSchema

        if (previousValue) {
            let previousRefId = previousValue[$refId];

            if (previousRefId !== undefined && refId !== previousRefId) {
                // Collection field replaced by a different instance.
                //
                // Don't decrement children here: GC (`garbageCollectDeletedRefs`)
                // removes them once the previous collection's refId hits zero.
                // Doing it here too would double-decrement a *shared* child and
                // drop it while still referenced ("refId not found").
                if ((operation & OPERATION.DELETE) !== OPERATION.DELETE) {
                    // Replacement not tagged DELETE (e.g. pending ADD not upgraded
                    // to DELETE_AND_ADD), so the previous refId wasn't decremented
                    // above. Release it here, else it never gets GC'd (leak).
                    $root.removeRef(previousRefId);
                }

                // enqueue onRemove callbacks for the previous collection's children.
                const entries: IterableIterator<[any, any]> = (previousValue as any).entries();
                let iter: IteratorResult<[any, any]>;
                while ((iter = entries.next()) && !iter.done) {
                    const [key, value] = iter.value;

                    if (typeof(value) === "object") {
                        previousRefId = value[$refId];
                    }

                    allChanges.push({
                        ref: previousValue,
                        refId: previousRefId,
                        op: OPERATION.DELETE,
                        field: key,
                        value: undefined,
                        previousValue: value,
                    });
                }

            }
        }

        $root.addRef(refId, value, (
            valueRef !== previousValue ||
            (operation === OPERATION.DELETE_AND_ADD && valueRef === previousValue)
        ));
    }

    return { value, previousValue };
}

export const decodeSchemaOperation: DecodeOperation = function <T extends Schema>(
    decoder: Decoder<any>,
    bytes: Uint8Array,
    it: Iterator,
    ref: T,
    allChanges: DataChange[],
) {
    const first_byte = bytes[it.offset++];
    const metadata: Metadata = (ref.constructor as typeof Schema)[Symbol.metadata];

    // "compressed" index + operation
    const operation = (first_byte >> 6) << 6
    const index = first_byte % (operation || 255);

    // skip early if field is not defined
    const field = metadata[index];
    if (field === undefined) {
        console.warn("@colyseus/schema: field not defined at", { index, ref: ref.constructor.name, metadata });
        return DEFINITION_MISMATCH;
    }

    const { value, previousValue } = decodeValue(
        decoder,
        operation,
        ref,
        index,
        field.type,
        bytes,
        it,
        allChanges,
    );

    if (value !== null && value !== undefined) {
        ref[field.name as keyof T] = value;
    }

    // add change
    if (previousValue !== value) {
        allChanges.push({
            ref,
            refId: decoder.currentRefId,
            op: operation,
            field: field.name,
            value,
            previousValue,
        });
    }
}

export const decodeKeyValueOperation: DecodeOperation = function (
    decoder: Decoder<any>,
    bytes: Uint8Array,
    it: Iterator,
    ref: Ref,
    allChanges: DataChange[]
) {
    // "uncompressed" index + operation (array/map items)
    const operation = bytes[it.offset++];

    if (operation === OPERATION.CLEAR) {
        //
        // When decoding:
        // - enqueue items for DELETE callback.
        // - flag child items for garbage collection.
        //
        decoder.removeChildRefs(ref as unknown as Collection, allChanges);

        (ref as any).clear();
        return;
    }

    const index = decode.number(bytes, it);
    const type = (ref as any)[$childType];

    let dynamicIndex: number | string;

    if ((operation & OPERATION.ADD) === OPERATION.ADD) { // ADD or DELETE_AND_ADD
        if (typeof((ref as any)['set']) === "function") {
            dynamicIndex = decode.string(bytes, it); // MapSchema
            (ref as any)['setIndex'](index, dynamicIndex);
        } else {
            dynamicIndex = index; // ArraySchema
        }
    } else {
        // get dynamic index from "ref"
        dynamicIndex = (ref as any)['getIndex'](index);
    }

    const { value, previousValue } = decodeValue(
        decoder,
        operation,
        ref,
        index,
        type,
        bytes,
        it,
        allChanges,
    );

    if (value !== null && value !== undefined) {
        if (typeof((ref as any)['set']) === "function") {
            // MapSchema
            (ref as any)['$items'].set(dynamicIndex as string, value);

        } else if (typeof((ref as any)['$setAt']) === "function") {
            // ArraySchema
            (ref as any)['$setAt'](index, value, operation);

        } else if (typeof((ref as any)['add']) === "function") {
            // CollectionSchema && SetSchema
            const index = (ref as any).add(value);

            if (typeof(index) === "number") {
                (ref as any)['setIndex'](index, index);
            }
        }
    }

    // add change
    if (previousValue !== value) {
        allChanges.push({
            ref,
            refId: decoder.currentRefId,
            op: operation,
            field: "", // FIXME: remove this
            dynamicIndex,
            value,
            previousValue,
        });
    }
}

export const decodeArray: DecodeOperation = function (
    decoder: Decoder<any>,
    bytes: Uint8Array,
    it: Iterator,
    ref: ArraySchema,
    allChanges: DataChange[]
) {
    // "uncompressed" index + operation (array/map items)
    let operation = bytes[it.offset++];
    let index: number;

    if (operation === OPERATION.CLEAR) {
        //
        // When decoding:
        // - enqueue items for DELETE callback.
        // - flag child items for garbage collection.
        //
        decoder.removeChildRefs(ref as unknown as Collection, allChanges);
        (ref as ArraySchema).clear();
        return;

    } else if (operation === OPERATION.REVERSE) {
        (ref as ArraySchema).reverse();
        return;

    } else if (operation === OPERATION.DELETE_BY_REFID) {
        // TODO: refactor here, try to follow same flow as below
        const refId = decode.number(bytes, it);
        const previousValue = decoder.root.refs.get(refId);
        index = ref.findIndex((value) => value === previousValue);
        ref[$deleteByIndex](index);
        allChanges.push({
            ref,
            refId: decoder.currentRefId,
            op: OPERATION.DELETE,
            field: "", // FIXME: remove this
            dynamicIndex: index,
            value: undefined,
            previousValue,
        });

        return;

    } else if (operation === OPERATION.ADD_BY_REFID) {
        const refId = decode.number(bytes, it);
        const itemByRefId = decoder.root.refs.get(refId);

        // if item already exists, use existing index
        if (itemByRefId) {
            index = ref.findIndex((value) => value === itemByRefId);
        }

        // fallback to use last index
        if (index === -1 || index === undefined) {
            index = ref.length;
        }

    } else {
        index = decode.number(bytes, it);
    }

    const type = ref[$childType];

    let dynamicIndex: number | string = index;

    const { value, previousValue } = decodeValue(
        decoder,
        operation,
        ref,
        index,
        type,
        bytes,
        it,
        allChanges,
    );

    if (
        value !== null && value !== undefined &&
        value !== previousValue // avoid setting same value twice (if index === 0 it will result in a "unshift" for ArraySchema)
    ) {
        // ArraySchema
        (ref as ArraySchema)['$setAt'](index, value, operation);
    }

    // add change
    if (previousValue !== value) {
        allChanges.push({
            ref,
            refId: decoder.currentRefId,
            op: operation,
            field: "", // FIXME: remove this
            dynamicIndex,
            value,
            previousValue,
        });
    }
}