#!/usr/bin/env node

// CLI for AntiEtcd
// (c) Vitaliy Filippov, 2024
// License: Mozilla Public License 2.0 or Vitastor Network Public License 1.1

const fs = require('fs');
const fsp = require('fs').promises;
const http = require('http');
const https = require('https');
const WebSocket = require('ws');

const help_text = `CLI for AntiEtcd
(c) Vitaliy Filippov, 2024
License: Mozilla Public License 2.0 or Vitastor Network Public License 1.1

Usage:

anticli.js [OPTIONS] put <key> [<value>]
anticli.js [OPTIONS] get <key> [-p|--prefix] [-v|--print-value-only] [-k|--keys-only] [--no-temp]
anticli.js [OPTIONS] del <key> [-p|--prefix]
anticli.js [OPTIONS] watch <key> [key...] [-p|--prefix] [--revision REV]
anticli.js [OPTIONS] load [--with-lease] < dump.json

Options:

[--endpoints|-e http://node1:2379,http://node2:2379,http://node3:2379]
[--cert cert.pem] [--key key.pem] [--ca ca.pem] [--timeout 1000] [--base64 1] [--json]
`;

class AntiEtcdCli
{
    static parse(args)
    {
        const cmd = [];
        const options = {};
        for (let i = 2; i < args.length; i++)
        {
            const arg = args[i].toLowerCase().replace(/^--(.+)$/, (m, m1) => '--'+m1.replace(/-/g, '_'));
            if (arg === '-h' || arg === '--help')
            {
                process.stderr.write(help_text);
                process.exit();
            }
            else if (arg == '-e' || arg == '--endpoints')
            {
                options['endpoints'] = args[++i].split(/\s*[,\s]+\s*/);
            }
            else if (arg == '-p' || arg == '--prefix')
            {
                options['prefix'] = true;
            }
            else if (arg == '-v' || arg == '--print_value_only')
            {
                options['print_value_only'] = true;
            }
            else if (arg == '-k' || arg == '--keys_only')
            {
                options['keys_only'] = true;
            }
            else if (arg == '--with_lease')
            {
                options['with_lease'] = true;
            }
            else if (arg == '--write_out' && args[i+1] == 'json')
            {
                i++;
                options['json'] = true;
            }
            else if (arg == '--json' || arg == '--write_out=json')
            {
                options['json'] = true;
            }
            else if (arg[0] == '-' && arg[1] !== '-')
            {
                process.stderr.write('Unknown option '+arg);
                process.exit(1);
            }
            else if (arg.substr(0, 2) == '--')
            {
                options[arg.substr(2)] = args[++i];
            }
            else
            {
                cmd.push(arg);
            }
        }
        if (!cmd.length || cmd[0] != 'get' && cmd[0] != 'put' && cmd[0] != 'del' && cmd[0] != 'load' && cmd[0] != 'watch')
        {
            process.stderr.write('Supported commands: get, put, del, load, watch. Use --help to see details\n');
            process.exit(1);
        }
        return [ cmd, options ];
    }

    async run(cmd, options)
    {
        this.options = options;
        if (!this.options.endpoints)
        {
            this.options.endpoints = [ 'http://localhost:2379' ];
        }
        this.options.base64 = (this.options.base64 == null) || is_true(this.options.base64);
        this.tls = {};
        if (this.options.cert && this.options.key)
        {
            this.tls.key = await fsp.readFile(this.options.key);
            this.tls.cert = await fsp.readFile(this.options.cert);
        }
        if (this.options.ca)
        {
            this.tls.ca = await fsp.readFile(this.options.ca);
        }
        if (cmd[0] == 'get')
        {
            await this.get(cmd.slice(1));
        }
        else if (cmd[0] == 'put')
        {
            await this.put(cmd[1], cmd.length > 2 ? cmd[2] : undefined);
        }
        else if (cmd[0] == 'del')
        {
            await this.del(cmd.slice(1));
        }
        else if (cmd[0] == 'load')
        {
            await this.load();
        }
        else if (cmd[0] == 'watch')
        {
            await this.watch(cmd.slice(1));
        }
        // wait until output is fully flushed
        await new Promise(ok => process.stdout.write('', ok));
        await new Promise(ok => process.stderr.write('', ok));
        process.exit(0);
    }

    async load()
    {
        const dump = JSON.parse(await new Promise((ok, no) => fs.readFile(0, { encoding: 'utf-8' }, (err, res) => err ? no(err) : ok(res))));
        if (!dump.responses && !dump.kvs)
        {
            console.error('dump should be /kv/txn or /kv/range response in json format');
            process.exit(1);
        }
        const success = [];
        for (const r of (dump.responses
            ? dump.responses.map(r => r.response_range).filter(r => r)
            : [ dump ]))
        {
            for (const kv of r.kvs)
            {
                if (kv.value == null)
                {
                    console.error('dump should contain values');
                    process.exit(1);
                }
                success.push({ request_put: { key: kv.key, value: kv.value, lease: this.options.with_lease ? kv.lease||undefined : undefined } });
            }
        }
        const res = await this.request('/v3/kv/txn', { success });
        if (this.options.json)
        {
            process.stdout.write(JSON.stringify(res));
            return;
        }
        if (res.succeeded)
        {
            process.stdout.write('OK, loaded '+success.length+' values\n');
        }
    }

    async get(keys)
    {
        if (this.options.prefix)
        {
            keys = keys.map(k => k.replace(/\/+$/, ''));
        }
        const txn = { success: keys.map(key => ({ request_range: this.options.prefix
            ? { key: this.b64(key+'/'), range_end: this.b64(key+'0') }
            : { key: this.b64(key) } })) };
        const res = await this.request('/v3/kv/txn', txn);
        if (this.options.notemp)
        {
            // Skip temporary values (values with lease)
            for (const r of res.responses||[])
            {
                if (r.response_range)
                {
                    r.response_range.kvs = r.response_range.kvs.filter(kv => !kv.lease);
                }
            }
        }
        if (this.options.json)
        {
            process.stdout.write(JSON.stringify(keys.length == 1 ? res.responses[0].response_range : res));
            return;
        }
        for (const r of res.responses||[])
        {
            if (r.response_range)
            {
                for (const kv of r.response_range.kvs)
                {
                    if (!this.options.print_value_only)
                    {
                        process.stdout.write(this.de64(kv.key)+'\n');
                    }
                    if (!this.options.keys_only)
                    {
                        process.stdout.write(this.de64(kv.value)+'\n');
                    }
                }
            }
        }
    }

    async put(key, value)
    {
        if (value === undefined)
        {
            value = await new Promise((ok, no) => fs.readFile(0, { encoding: 'utf-8' }, (err, res) => err ? no(err) : ok(res)));
        }
        const res = await this.request('/v3/kv/put', { key: this.b64(key), value: this.b64(value) });
        if (res.header)
        {
            process.stdout.write('OK\n');
        }
    }

    async del(keys)
    {
        if (this.options.prefix)
        {
            keys = keys.map(k => k.replace(/\/+$/, ''));
        }
        const txn = { success: keys.map(key => ({ request_delete_range: this.options.prefix
            ? { key: this.b64(key+'/'), range_end: this.b64(key+'0') }
            : { key: this.b64(key) } })) };
        const res = await this.request('/v3/kv/txn', txn);
        for (const r of res.responses||[])
        {
            if (r.response_delete_range)
            {
                process.stdout.write(r.response_delete_range.deleted+'\n');
            }
        }
    }

    async watch(keys)
    {
        const prefix = this.options.prefix || !keys.length;
        keys = keys.length ? keys : [''];
        for (const url of this.options.endpoints)
        {
            const ws = new WebSocket(url.replace(/^http/, 'ws').replace(/\/+$/, '')+'/watch', this.tls||{});
            let closed = false;
            let started = false;
            let close_cb = null;
            const on_error = (e) =>
            {
                console.log(e);
                closed = true;
                if (close_cb)
                    close_cb();
            };
            ws.on('error', on_error);
            ws.on('message', (msg) =>
            {
                try
                {
                    msg = JSON.parse(msg);
                }
                catch (e)
                {
                    console.error(JSON.stringify('Bad JSON received from websocket: '+msg));
                    return;
                }
                if (this.options.base64 && msg.result && msg.result.events)
                {
                    for (const ev of msg.result.events)
                    {
                        if (ev.kv)
                        {
                            ev.kv.key = this.de64(ev.kv.key);
                            ev.kv.value = this.de64(ev.kv.value);
                        }
                    }
                }
                console.log(JSON.stringify(msg));
            });
            ws.on('open', () =>
            {
                started = true;
                for (let i = 0; i < keys.length; i++)
                {
                    const k = keys[i];
                    ws.send(JSON.stringify({
                        create_request: {
                            key: this.b64(prefix ? k+'/' : k),
                            range_end: prefix ? this.b64(k+'0') : undefined,
                            watch_id: i+1,
                            progress_notify: true,
                            start_revision: this.options.revision || undefined,
                        },
                    }));
                }
            });
            if (closed)
                continue;
            else
            {
                await new Promise(ok => close_cb = ok);
                if (!started)
                    return;
            }
        }
    }

    async request(path, body)
    {
        for (const url of this.options.endpoints)
        {
            const cur_url = url.replace(/\/+$/, '')+path;
            const res = await POST(cur_url, this.tls||{}, body, this.options.timeout||1000);
            if (res.json)
            {
                if (res.json.error)
                {
                    process.stderr.write(cur_url+': '+res.json.error+'\n');
                    process.exit(1);
                }
                return res.json;
            }
            if (res.body)
            {
                process.stderr.write(cur_url+': '+res.body+'\n');
            }
            if (res.error)
            {
                process.stderr.write(cur_url+': '+res.error+'\n');
                if (!res.response || !res.response.statusCode)
                {
                    // This URL is unavailable
                    continue;
                }
            }
            break;
        }
        process.exit(1);
    }

    b64(str)
    {
        return this.options.base64 ? Buffer.from(str).toString('base64') : str;
    }

    de64(str)
    {
        return this.options.base64 ? Buffer.from(str, 'base64').toString() : str;
    }
}

function POST(url, options, body, timeout)
{
    return new Promise(ok =>
    {
        const body_text = Buffer.from(JSON.stringify(body));
        let timer_id = timeout > 0 ? setTimeout(() =>
        {
            if (req)
                req.abort();
            req = null;
            ok({ error: 'timeout' });
        }, timeout) : null;
        let req = (url.substr(0, 8).toLowerCase() == 'https://' ? https : http).request(url, { method: 'POST', headers: {
            'Content-Type': 'application/json',
            'Content-Length': body_text.length,
        }, timeout, ...options }, (res) =>
        {
            if (!req)
            {
                return;
            }
            clearTimeout(timer_id);
            let res_body = '';
            res.setEncoding('utf8');
            res.on('error', (error) => ok({ error }));
            res.on('data', chunk => { res_body += chunk; });
            res.on('end', () =>
            {
                if (res.statusCode != 200 || !/application\/json/i.exec(res.headers['content-type']))
                {
                    ok({ response: res, body: res_body, code: res.statusCode });
                    return;
                }
                try
                {
                    res_body = JSON.parse(res_body);
                    ok({ response: res, json: res_body });
                }
                catch (e)
                {
                    ok({ response: res, error: e, body: res_body });
                }
            });
        });
        req.on('error', (error) => ok({ error }));
        req.on('close', () => ok({ error: new Error('Connection closed prematurely') }));
        req.write(body_text);
        req.end();
    });
}

function is_true(s)
{
    return s === true || s === 1 || s === '1' || s === 'yes' || s === 'true' || s === 'on';
}

new AntiEtcdCli().run(...AntiEtcdCli.parse(process.argv)).catch(console.error);
