import {
    normalizeReplacer,
    normalizeSpace,
    replaceValue
} from './utils.js';

const hasOwn = typeof Object.hasOwn === 'function'
    ? Object.hasOwn
    : (object, key) => Object.hasOwnProperty.call(object, key);

// https://tc39.es/ecma262/#table-json-single-character-escapes
const escapableCharCodeSubstitution = { // JSON Single Character Escape Sequences
    0x08: '\\b',
    0x09: '\\t',
    0x0a: '\\n',
    0x0c: '\\f',
    0x0d: '\\r',
    0x22: '\\\"',
    0x5c: '\\\\'
};

const charLength2048 = Uint8Array.from({ length: 2048 }, (_, code) => {
    if (hasOwn(escapableCharCodeSubstitution, code)) {
        return 2; // \X
    }

    if (code < 0x20) {
        return 6; // \uXXXX
    }

    return code < 128 ? 1 : 2; // UTF8 bytes
});

function isLeadingSurrogate(code) {
    return code >= 0xD800 && code <= 0xDBFF;
}

function isTrailingSurrogate(code) {
    return code >= 0xDC00 && code <= 0xDFFF;
}

function stringLength(str) {
    // Fast path to compute length when a string contains only characters encoded as single bytes
    if (!/[^\x20\x21\x23-\x5B\x5D-\x7F]/.test(str)) {
        return str.length + 2;
    }

    let len = 0;
    let prevLeadingSurrogate = false;

    for (let i = 0; i < str.length; i++) {
        const code = str.charCodeAt(i);

        if (code < 2048) {
            len += charLength2048[code];
        } else if (isLeadingSurrogate(code)) {
            len += 6; // \uXXXX since no pair with trailing surrogate yet
            prevLeadingSurrogate = true;
            continue;
        } else if (isTrailingSurrogate(code)) {
            len = prevLeadingSurrogate
                ? len - 2  // surrogate pair (4 bytes), since we calculate prev leading surrogate as 6 bytes, substruct 2 bytes
                : len + 6; // \uXXXX
        } else {
            len += 3; // code >= 2048 is 3 bytes length for UTF8
        }

        prevLeadingSurrogate = false;
    }

    return len + 2; // +2 for quotes
}

// avoid producing a string from a number
function intLength(num) {
    let len = 0;

    if (num < 0) {
        len = 1;
        num = -num;
    }

    if (num >= 1e9) {
        len += 9;
        num = (num - num % 1e9) / 1e9;
    }

    if (num >= 1e4) {
        if (num >= 1e6) {
            return len + (num >= 1e8
                ? 9
                : num >= 1e7 ? 8 : 7
            );
        }
        return len + (num >= 1e5 ? 6 : 5);
    }

    return len + (num >= 1e2
        ? num >= 1e3 ? 4 : 3
        : num >= 10 ? 2 : 1
    );
};

function primitiveLength(value) {
    switch (typeof value) {
        case 'string':
            return stringLength(value);

        case 'number':
            return Number.isFinite(value)
                ? Number.isInteger(value)
                    ? intLength(value)
                    : String(value).length
                : 4 /* null */;

        case 'boolean':
            return value ? 4 /* true */ : 5 /* false */;

        case 'undefined':
        case 'object':
            return 4; /* null */

        default:
            return 0;
    }
}

function spaceLength(space) {
    space = normalizeSpace(space);
    return typeof space === 'string' ? space.length : 0;
}

export function stringifyInfo(value, optionsOrReplacer, space) {
    if (optionsOrReplacer === null || Array.isArray(optionsOrReplacer) || typeof optionsOrReplacer !== 'object') {
        optionsOrReplacer = {
            replacer: optionsOrReplacer,
            space
        };
    }

    const continueOnCircular = Boolean(optionsOrReplacer.continueOnCircular);
    let replacer = normalizeReplacer(optionsOrReplacer.replacer);
    let getKeys = Object.keys;

    if (Array.isArray(replacer)) {
        const allowlist = replacer;

        getKeys = () => allowlist;
        replacer = null;
    }

    space = spaceLength(space);

    const keysLength = new Map();
    const visited = new Map();
    const stack = [];
    const circular = new Set();
    const root = { '': value };
    let stop = false;
    let bytes = 0;
    let objects = 0;

    walk(root, '', value);

    // when value is undefined or replaced for undefined
    if (bytes === 0) {
        bytes += 9; // FIXME: that's the length of undefined, should we normalize behaviour to convert it to null?
    }

    return {
        bytes: isNaN(bytes) ? Infinity : bytes,
        circular: [...circular]
    };

    function walk(holder, key, value) {
        if (stop) {
            return;
        }

        value = replaceValue(holder, key, value, replacer);

        if (value === null || typeof value !== 'object') {
            // primitive
            if (value !== undefined || Array.isArray(holder)) {
                bytes += primitiveLength(value);
            }
        } else {
            // check for circular references
            if (stack.includes(value)) {
                circular.add(value);
                bytes += 4; // treat as null

                if (!continueOnCircular) {
                    stop = true;
                }

                return;
            }

            // Using 'visited' allows avoiding hang-ups in cases of highly interconnected object graphs;
            // for example, a list of git commits with references to parents can lead to N^2 complexity for traversal,
            // and N when 'visited' is used
            if (visited.has(value)) {
                bytes += visited.get(value);

                return;
            }

            objects++;

            const prevObjects = objects;
            const valueBytes = bytes;
            let valueLength = 0;

            stack.push(value);

            if (Array.isArray(value)) {
                // array
                valueLength = value.length;

                for (let i = 0; i < valueLength; i++) {
                    walk(value, i, value[i]);
                }
            } else {
                // object
                let prevLength = bytes;

                for (const key of getKeys(value)) {
                    walk(value, key, value[key]);

                    if (prevLength !== bytes) {
                        let keyLen = keysLength.get(key);

                        if (keyLen === undefined) {
                            keysLength.set(key, keyLen = stringLength(key) + (space > 0 ? 2 : 1)); // "key":
                        }

                        // value is printed
                        bytes += keyLen;
                        valueLength++;
                        prevLength = bytes;
                    }
                }
            }

            bytes += valueLength === 0
                ? 2 // {} or []
                : 1 + valueLength; // {} or [] + commas

            if (space > 0 && valueLength > 0) {
                bytes +=
                    (1 + stack.length * space) * valueLength + // for each key-value: \n{space}
                    1 + (stack.length - 1) * space; // for }
            }

            stack.pop();

            // add to 'visited' only objects that contain nested objects
            if (prevObjects !== objects) {
                visited.set(value, bytes - valueBytes);
            }
        }
    }
};
