// Global console

(function (global) {
    const monitorMode = (GetConvar('monitormode', 'false') == 'true') && IsDuplicityVersion();
    const percent = '%'.charCodeAt(0);

    const stringSpecifier = 's'.charCodeAt(0);
    const decimalSpecifier = 'd'.charCodeAt(0);
    const floatSpecifier = 'f'.charCodeAt(0);
    const oSmallSpecifier = 'o'.charCodeAt(0);
    const oBigSpecifier = 'O'.charCodeAt(0);

    const typedArrays = [
        Uint8Array,
        Uint16Array,
        Uint32Array,
        Uint8ClampedArray,
        Int8Array,
        Int16Array,
        Int32Array,
        Float32Array,
        Float64Array
    ];

    class AssertionError extends Error {
        constructor(options) {
            if (typeof options !== 'object' || options === null) {
                throw new TypeError('ERR_INVALID_ARG_TYPE', 'options', 'object');
            }

            if (options.message) {
                super(options.message);
            } else {
                super(
                    `${format(options.actual).slice(0, 128)} ` +
                    `${options.operator} ${format(options.expected).slice(0, 128)}`
                );
            }

            this.generatedMessage = !options.message;
            this.name = 'AssertionError [ERR_ASSERTION]';
            this.code = 'ERR_ASSERTION';
            this.actual = options.actual;
            this.expected = options.expected;
            this.operator = options.operator;

            Error.captureStackTrace(this, options.stackStartFunction);
        }
    }

    function indentNewLines(str, indent) {
        return str.split('\n').map(s => indent + s).join('\n');
    }

    function formatMap(map) {
        const lines = ['Map {'];

        for (const [key, value] of map) {
            const keyFormatted = format(key);
            const valueFormatted = format(value);

            lines[lines.length] = indentNewLines(`${keyFormatted} => ${valueFormatted}`, '  ');
        }

        return lines.concat('}').join('\n');
    }

    function formatSet(set) {
        const lines = ['Set {'];

        for (const value of set) {
            lines[lines.length] = indentNewLines(format(value), '  ');
        }

        return lines.concat('}').join('\n');
    }

    function isTypedArray(array) {
        for (const type of typedArrays) {
            if (array instanceof type) {
                return true;
            }
        }

        return false;
    }

    function formatTypedArray(array) {
        return `${array.constructor.name}[ ${array.join(' ')} ]`;
    }

    function formatValue(arg) {
        switch (true) {
            case arg === null:
                return 'null';
                
            case arg === undefined:
                return 'undefined'

            case arg instanceof WeakMap:
                return 'WeakMap {}';

            case arg instanceof WeakSet:
                return 'WeakSet {}';

            case arg instanceof Map:
                return formatMap(arg);

            case arg instanceof Set:
                return formatSet(arg);

            case isTypedArray(arg):
                return formatTypedArray(arg);

            case Array.isArray(arg):
            case arg.toString() === "[object Object]":
                let cache = [];
                let out = JSON.stringify(arg, (key, value) => {
                    if (typeof value === 'object' && value !== null) {
                        if (cache.indexOf(value) !== -1) return;
                        cache.push(value);
                    }
                    return value;
                }, 2);
                cache = null;
                return out;

            case arg.toString() === "[object Error]" || arg instanceof Error:
                return arg.stack || Error.prototype.toString.call(arg);

            default:
                return arg.toString();
        }
    }

    function formatSpecifier(specifier, value) {
        switch (specifier) {
            case stringSpecifier:
                return '' + value;

            case decimalSpecifier:
                return parseInt(value, 10).toString();

            case floatSpecifier:
                return parseFloat(value).toString();

            case oSmallSpecifier:
            case oBigSpecifier:
                return formatValue(value);

            default:
                return false;
        }
    }

    /**
     * WHATWG Spec compliant formatter
     * 
     * @see https://console.spec.whatwg.org/#formatter
     * @param {*} message 
     * @param {*} args 
     */
    function format(message = undefined, ...args) {
        const totalArgs = args.length;

        if (totalArgs === 0) {
            return formatValue(message);
        }

        const result = [];
        let usedArgs = 0;

        if (typeof message !== "string") {
            result[result.length] = formatValue(message);
        } else {
            const messageLastIndex = message.length - 1;
            let formattedMessage = '';
            let i = 0;

            for (; i < messageLastIndex; i++) {
                const char = message.charAt(i);

                if (char.charCodeAt(0) === percent && totalArgs > usedArgs) {
                    const result = formatSpecifier(message.charCodeAt(i + 1), args[usedArgs]);

                    if (result !== false) {
                        formattedMessage += result;

                        i++;
                        usedArgs++;

                        continue;
                    }
                }

                formattedMessage += char;
            }

            if (i === messageLastIndex) {
                formattedMessage += message[messageLastIndex];
            }

            result[result.length] = formattedMessage;
        }        

        return result.concat(args.slice(usedArgs).map(formatValue)).join(' ');
    }

    class Console {
        constructor() {
            this._trace = global['Citizen'].trace || global['print'];

            this._timers = new Map();
            this._counters = new Map();

            // TODO: Improve the sync console output.
            this.log = (monitorMode) ? this.logSync.bind(this) : this.log.bind(this);
            this.info = this.info.bind(this);
            this.warn = this.warn.bind(this);
            this.time = this.time.bind(this);
            this.clear = this.clear.bind(this);
            this.group = this.group.bind(this);
            this.error = this.error.bind(this);
            this.trace = this.trace.bind(this);
            this.table = this.table.bind(this);
            this.dirxml = this.dirxml.bind(this);
            this.select = this.select.bind(this);
            this.assert = this.assert.bind(this);
            this.timeEnd = this.timeEnd.bind(this);
            this.groupEnd = this.groupEnd.bind(this);
            this.groupCollapsed = this.groupCollapsed.bind(this);
            this.msIsIndependentlyComposed = this.msIsIndependentlyComposed.bind(this);
        }

        table() {
            this.error('console.table is not implemented yet');
        }

        dirxml() {
            this.error('console.dirxml is not implemented yet');
        }

        clear() {
            this.error('console.clear is not implemented yet');
        }

        group() {
            this.error('console.group is not implemented yet');
        }

        groupCollapsed() {
            this.error('console.groupCollapsed is not implemented yet');
        }

        groupEnd() {
            this.error('console.groupEnd is not implemented yet');
        }

        msIsIndependentlyComposed() {
            this.error('console.msIsIndependentlyComposed is not implemented yet');
        }

        select() {
            this.error('console.select is not implemented yet');
        }

        log(message = undefined, ...optionalParams) {
            this._trace(format(message, ...optionalParams));
        }

        logSync(message = undefined, ...optionalParams) {
            process.stdout.write(format(message, ...optionalParams) + "\n");
        }

        debug(message = undefined, ...optionalParams) {
            this._trace('Debug: ' + format(message, ...optionalParams));
        }

        info(message = undefined, ...optionalParams) {
            this._trace('Info: ' + format(message, ...optionalParams));
        }

        warn(message = undefined, ...optionalParams) {
            this._trace('^3Warning: ' + format(message, ...optionalParams) + '^7');
        }

        error(message = undefined, ...optionalParams) {
            this._trace('^1Error: ' + format(message, ...optionalParams) + '^7');
        }

        trace(message = undefined, ...optionalParams) {
            const err = {
                name: 'Trace',
                message: format(message, ...optionalParams)
            };

            Error.captureStackTrace(err, this.trace);

            this._trace(err.stack);
        }

        dir(item, options) {
            this._trace('Dir: ', formatValue(item));
        }

        count(label) {
            if (label === undefined) {
                label = 'default'
            }
            const counter = this._counters.get(label) ? this._counters.get(label) + 1 : 1;

            this._counters.set(label, counter);

            this.log(`${label}: ${counter}`);
        }

        countReset(label) {
            if (label === undefined) {
                label = 'default'
            }
            if (this._counters.get(label) === undefined) {
                this.warn(`Counter "${label}" doesn't exist.`);
                return
            }

            this._counters.set(label, undefined);

            this.log(`${label}: 0`);
        }

        time(label) {
            this._timers.set(label, Citizen.getTickCount());
        }

        timeEnd(label) {
            if (!this._timers.has(label)) {
                this.warn(`No such label ${label} for console.timeEnd()`);
                return;
            }

            const duration = Citizen.getTickCount() - this._timers.get(label);

            this.log(`${label}: ${duration} ms`);

            this._timers.delete(label);
        }

        assert(expression, ...args) {
            if (!expression) {
                throw new AssertionError({
                    actual: expression,
                    expected: true,
                    message: format.apply(null, args),
                    operator: '==',
                    stackStartFunction: this.assert
                });
            }
        }
    }

    global.console = new Console();
})(this || globalThis);
