/**
 * Get the reference key for the circular value
 *
 * @param keys the keys to build the reference key from
 * @param cutoff the maximum number of keys to include
 * @returns the reference key
 */
function getReferenceKey(keys: string[], cutoff: number) {
    return keys.slice(0, cutoff).join(".") || ".";
}

/**
 * Faster `Array.prototype.indexOf` implementation build for slicing / splicing
 *
 * @param array the array to match the value in
 * @param value the value to match
 * @returns the matching index, or -1
 */
function getCutoff(array: any[], value: any) {
    const { length } = array;

    for (let index = 0; index < length; ++index) {
        if (array[index] === value) {
            return index + 1;
        }
    }

    return 0;
}

type StandardReplacer = (key: string, value: any) => any;
type CircularReplacer = (key: string, value: any, referenceKey: string) => any;

/**
 * Create a replacer method that handles circular values
 *
 * @param [replacer] a custom replacer to use for non-circular values
 * @param [circularReplacer] a custom replacer to use for circular methods
 * @returns the value to stringify
 */
function createReplacer(
    replacer?: StandardReplacer | null | undefined,
    circularReplacer?: CircularReplacer | null | undefined,
): StandardReplacer {
    const hasReplacer = typeof replacer === "function";
    const hasCircularReplacer = typeof circularReplacer === "function";

    const cache: any[] = [];
    const keys: string[] = [];

    return function replace(this: any, key: string, value: any) {
        if (typeof value === "object") {
            if (cache.length) {
                const thisCutoff = getCutoff(cache, this);

                if (thisCutoff === 0) {
                    cache[cache.length] = this;
                } else {
                    cache.splice(thisCutoff);
                    keys.splice(thisCutoff);
                }

                keys[keys.length] = key;

                const valueCutoff = getCutoff(cache, value);

                if (valueCutoff !== 0) {
                    return hasCircularReplacer
                        ? circularReplacer.call(
                              this,
                              key,
                              value,
                              getReferenceKey(keys, valueCutoff),
                          )
                        : `[ref=${getReferenceKey(keys, valueCutoff)}]`;
                }
            } else {
                cache[0] = value;
                keys[0] = key;
            }
        }

        return hasReplacer ? replacer.call(this, key, value) : value;
    };
}

/**
 * Stringifier that handles circular values
 *
 * Forked from https://github.com/planttheidea/fast-stringify
 *
 * @param value to stringify
 * @param [replacer] a custom replacer function for handling standard values
 * @param [indent] the number of spaces to indent the output by
 * @param [circularReplacer] a custom replacer function for handling circular values
 * @returns the stringified output
 */
export function serialize(
    value: any,
    replacer?: StandardReplacer | null | undefined,
    indent?: number | null | undefined,
    circularReplacer?: CircularReplacer | null | undefined,
) {
    return JSON.stringify(
        value,
        createReplacer((key, value_) => {
            let value = value_;
            if (typeof value === "bigint") value = { __type: "bigint", value: value_.toString() };
            if (value instanceof Map)
                value = { __type: "Map", value: Array.from(value_.entries()) };
            return replacer?.(key, value) ?? value;
        }, circularReplacer),
        indent ?? undefined,
    );
}
