// Based on https://github.com/Nanonid/rison at e64af6c096fd30950ec32cfd48526ca6ee21649d (Jun 9, 2017)

import {assert, unwrap} from './assert.js';

import {isString} from '../lib/common-utils.js';

//////////////////////////////////////////////////
//
//  the stringifier is based on
//    http://json.org/json.js as of 2006-04-28 from json.org
//  the parser is based on
//    http://osteele.com/sources/openlaszlo/json
//

/*
 * we divide the uri-safe glyphs into three sets
 *   <rison> - used by rison                         ' ! : ( ) ,
 *   <reserved> - not common in strings, reserved    * @ $ & ; =
 *
 * we define <identifier> as anything that's not forbidden
 */

/**
 * punctuation characters that are legal inside ids.
 */
// this var isn't actually used
//rison.idchar_punctuation = "_-./~";

const not_idchar = " '!:(),*@$";

/**
 * characters that are illegal as the start of an id
 * this is so ids can't look like numbers.
 */
const not_idstart = '-0123456789';

const [id_ok, next_id] = (() => {
    const _idrx = '[^' + not_idstart + not_idchar + '][^' + not_idchar + ']*';
    return [
        new RegExp('^' + _idrx + '$'),
        // regexp to find the end of an id when parsing
        // g flag on the regexp is necessary for iterative regexp.exec()
        new RegExp(_idrx, 'g'),
    ];
})();

/**
 * this is like encodeURIComponent() but quotes fewer characters.
 *
 * @see rison.uri_ok
 *
 * encodeURIComponent passes   ~!*()-_.'
 * rison.quote also passes   ,:@$/
 *   and quotes " " as "+" instead of "%20"
 */
export function quote(x: string) {
    if (/^[-A-Za-z0-9~!*()_.',:@$/]*$/.test(x)) return x;

    return encodeURIComponent(x)
        .replace(/%2C/g, ',')
        .replace(/%3A/g, ':')
        .replace(/%40/g, '@')
        .replace(/%24/g, '$')
        .replace(/%2F/g, '/')
        .replace(/%20/g, '+');
}

//
//  based on json.js 2006-04-28 from json.org
//  license: http://www.json.org/license.html
//
//  hacked by nix for use in uris.
//
// url-ok but quoted in strings
const string_table = {
    "'": true,
    '!': true,
};

class Encoders {
    static array(x: JSONValue[]) {
        const a = ['!('];
        let b;
        let i;
        const l = x.length;
        let v;
        for (i = 0; i < l; i += 1) {
            v = enc(x[i]);
            if (typeof v == 'string') {
                if (b) {
                    a[a.length] = ',';
                }
                a[a.length] = v;
                b = true;
            }
        }
        a[a.length] = ')';
        return a.join('');
    }
    static boolean(x: boolean) {
        if (x) return '!t';
        return '!f';
    }
    static null() {
        return '!n';
    }
    static number(x: number) {
        if (!isFinite(x)) return '!n';
        // strip '+' out of exponent, '-' is ok though
        return String(x).replace(/\+/, '');
    }
    static object(x: Record<string, JSONValue> | null) {
        if (x) {
            // because typeof null === 'object'
            if (x instanceof Array) {
                return Encoders.array(x);
            }

            const a = ['('];
            let b = false;
            let i: string;
            let v: string | undefined;
            let k: string;
            let ki: number;
            const ks: string[] = [];
            for (const i in x) ks[ks.length] = i;
            ks.sort();
            for (ki = 0; ki < ks.length; ki++) {
                i = ks[ki];
                v = enc(x[i]);
                if (typeof v == 'string') {
                    if (b) {
                        a[a.length] = ',';
                    }
                    k = isNaN(parseInt(i)) ? Encoders.string(i) : Encoders.number(parseInt(i));
                    a.push(k, ':', v);
                    b = true;
                }
            }
            a[a.length] = ')';
            return a.join('');
        }
        return '!n';
    }
    static string(x: string) {
        if (x === '') return "''";

        if (id_ok.test(x)) return x;

        x = x.replace(/(['!])/g, function (a, b) {
            if (string_table[b]) return '!' + b;
            return b;
        });
        return "'" + x + "'";
    }
    static undefined() {
        // ignore undefined just like JSON
        return undefined;
    }
}

const encode_table: Record<string, (x: any) => string | undefined> = {
    array: Encoders.array,
    object: Encoders.object,
    boolean: Encoders.boolean,
    string: Encoders.string,
    number: Encoders.number,
    null: Encoders.null,
    undefined: Encoders.undefined,
};

function enc(v: JSONValue | (JSONValue & {toJSON?: () => string})) {
    if (v && typeof v === 'object' && 'toJSON' in v && typeof v.toJSON === 'function') v = v.toJSON();
    if (typeof v in encode_table) {
        return encode_table[typeof v](v);
    }
}

/**
 * rison-encode a javascript structure
 *
 *  implemementation based on Douglas Crockford's json.js:
 *    http://json.org/json.js as of 2006-04-28 from json.org
 *
 */
export function encode(v: JSONValue | (JSONValue & {toJSON?: () => string})) {
    return enc(v);
}

/**
 * rison-encode a javascript object without surrounding parens
 *
 */
export function encode_object(v: JSONValue) {
    if (typeof v != 'object' || v === null || v instanceof Array)
        throw new Error('rison.encode_object expects an object argument');
    const r = unwrap(encode_table[typeof v](v));
    return r.substring(1, r.length - 1);
}

/**
 * rison-encode a javascript array without surrounding parens
 *
 */
export function encode_array(v: JSONValue) {
    if (!(v instanceof Array)) throw new Error('rison.encode_array expects an array argument');
    const r = unwrap(encode_table[typeof v](v));
    return r.substring(2, r.length - 1);
}

/**
 * rison-encode and uri-encode a javascript structure
 *
 */
export function encode_uri(v: JSONValue) {
    return quote(unwrap(encode_table[typeof v](v)));
}

//
// based on openlaszlo-json and hacked by nix for use in uris.
//
// Author: Oliver Steele
// Copyright: Copyright 2006 Oliver Steele.  All rights reserved.
// Homepage: http://osteele.com/sources/openlaszlo/json
// License: MIT License.
// Version: 1.0

/**
 * parse a rison string into a javascript structure.
 *
 * this is the simplest decoder entry point.
 *
 *  based on Oliver Steele's OpenLaszlo-JSON
 *     http://osteele.com/sources/openlaszlo/json
 */
export function decode(r: string) {
    const p = new Parser();
    return p.parse(r);
}

/**
 * parse an o-rison string into a javascript structure.
 *
 * this simply adds parentheses around the string before parsing.
 */
export function decode_object(r: string) {
    return decode('(' + r + ')');
}

/**
 * parse an a-rison string into a javascript structure.
 *
 * this simply adds array markup around the string before parsing.
 */
export function decode_array(r: string) {
    return decode('!(' + r + ')');
}

// prettier-ignore
export type JSONValue =
    | string
    | number
    | boolean
    | null
    | undefined
    | {[x: string]: JSONValue}
    | Array<JSONValue>;

class Parser {
    /**
     * a string containing acceptable whitespace characters.
     * by default the rison decoder tolerates no whitespace.
     * to accept whitespace set rison.parser.WHITESPACE = " \t\n\r\f";
     */
    static WHITESPACE = '';

    static readonly bangs = {
        t: true,
        f: false,
        n: null,
        '(': Parser.parse_array,
    };

    string: string;
    index: number;
    readonly table: Record<string, () => JSONValue>;

    constructor() {
        this.string = '';
        this.index = -1;
        this.table = {
            '!': () => {
                const s = this.string;
                const c = s.charAt(this.index++);
                if (!c) return this.error('"!" at end of input');
                const x = Parser.bangs[c];
                if (typeof x == 'function') {
                    // eslint-disable-next-line no-useless-call
                    return x.call(null, this);
                } else if (typeof x === 'undefined') {
                    return this.error('unknown literal: "!' + c + '"');
                }
                return x;
            },
            '(': () => {
                const o: JSONValue = {};
                let c;
                let count = 0;
                while ((c = this.next()) !== ')') {
                    if (count) {
                        if (c !== ',') this.error("missing ','");
                    } else if (c === ',') {
                        this.error("extra ','");
                    } else --this.index;
                    const k = this.readValue();
                    if (typeof k == 'undefined') return undefined;
                    if (this.next() !== ':') this.error("missing ':'");
                    const v = this.readValue();
                    if (typeof v == 'undefined') return undefined;
                    assert(isString(k));
                    o[k] = v;
                    count++;
                }
                return o;
            },
            "'": () => {
                const s = this.string;
                let i = this.index;
                let start = i;
                const segments: string[] = [];
                let c;
                while ((c = s.charAt(i++)) !== "'") {
                    //if (i == s.length) return this.error('unmatched "\'"');
                    if (!c) this.error('unmatched "\'"');
                    if (c === '!') {
                        if (start < i - 1) segments.push(s.slice(start, i - 1));
                        c = s.charAt(i++);
                        if ("!'".includes(c)) {
                            segments.push(c);
                        } else {
                            this.error('invalid string escape: "!' + c + '"');
                        }
                        start = i;
                    }
                }
                if (start < i - 1) segments.push(s.slice(start, i - 1));
                this.index = i;
                return segments.length === 1 ? segments[0] : segments.join('');
            },
            // Also any digit.  The statement that follows this table
            // definition fills in the digits.
            '-': () => {
                let s = this.string;
                let i = this.index;
                const start = i - 1;
                let state = 'int';
                let permittedSigns = '-';
                const transitions = {
                    'int+.': 'frac',
                    'int+e': 'exp',
                    'frac+e': 'exp',
                };
                do {
                    const c = s.charAt(i++);
                    if (!c) break;
                    if ('0' <= c && c <= '9') continue;
                    if (permittedSigns.includes(c)) {
                        permittedSigns = '';
                        continue;
                    }
                    state = transitions[state + '+' + c.toLowerCase()];
                    if (state === 'exp') permittedSigns = '-';
                } while (state);
                this.index = --i;
                s = s.slice(start, i);
                if (s === '-') this.error('invalid number');
                return Number(s);
            },
        };
        // copy table['-'] to each of table[i] | i <- '0'..'9':
        for (let i = 0; i <= 9; i++) this.table[String(i)] = this.table['-'];
    }

    /**
     * parse a rison string into a javascript structure.
     */
    parse(str: string): JSONValue {
        this.string = str;
        this.index = 0;
        const value = this.readValue();
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        if (this.next()) this.error("unable to parse string as rison: '" + encode(str) + "'");
        return value;
    }

    error(message: string): never {
        throw new Error('rison parser error: ' + message);
    }

    readValue(): JSONValue {
        const c = this.next();
        const fn = c && this.table[c];

        if (fn) return fn.apply(this);

        // fell through table, parse as an id

        const s = this.string;
        const i = this.index - 1;

        // Regexp.lastIndex may not work right in IE before 5.5?
        // g flag on the regexp is also necessary
        next_id.lastIndex = i;
        const m = unwrap(next_id.exec(s));

        // console.log('matched id', i, r.lastIndex);

        if (m.length > 0) {
            const id = m[0];
            this.index = i + id.length;
            return id; // a string
        }

        if (c) this.error("invalid character: '" + c + "'");
        this.error('empty expression');
    }

    next(): string | undefined {
        let c: string;
        const s = this.string;
        let i = this.index;
        do {
            if (i === s.length) return undefined;
            c = s.charAt(i++);
        } while (Parser.WHITESPACE.includes(c));
        this.index = i;
        return c;
    }

    static parse_array(parser: Parser): JSONValue[] | undefined {
        const ar: JSONValue[] = [];
        let c;
        while ((c = parser.next()) !== ')') {
            if (!c) return parser.error("unmatched '!('");
            if (ar.length) {
                if (c !== ',') parser.error("missing ','");
            } else if (c === ',') {
                return parser.error("extra ','");
            } else --parser.index;
            const n = parser.readValue();
            if (n === undefined) return undefined;
            ar.push(n);
        }
        return ar;
    }
}
