#!/usr/bin/node

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

const fsp = require('fs').promises;
const { URL } = require('url');
const http = require('http');
const https = require('https');
const EventEmitter = require('events');

const ws = require('ws');

const TinyRaft = require('tinyraft');

const EtcTree = require('./etctree.js');
const AntiPersistence = require('./antipersistence.js');
const AntiCluster = require('./anticluster.js');
const AntiLocker = require('./antilocker.js');
const { runCallbacks, de64, b64, RequestError } = require('./common.js');

const VERSION = '1.2.2';

class AntiEtcd extends EventEmitter
{
    constructor(cfg)
    {
        super();
        cfg['merge_watches'] = !('merge_watches' in cfg) || is_true(cfg['merge_watches']);
        cfg['stale_read'] = !('stale_read' in cfg) || is_true(cfg['stale_read']);
        cfg['use_base64'] = !('use_base64' in cfg) || is_true(cfg['use_base64']);
        cfg['use_locks'] = cfg.cluster && (!('use_locks' in cfg) || is_true(cfg['use_locks']));
        cfg['lock_wait_timeout'] = Number(cfg['lock_wait_timeout']);
        if (!cfg['lock_wait_timeout'] || cfg['lock_wait_timeout'] < 1000)
            cfg['lock_wait_timeout'] = 30000;
        if (typeof cfg['logs'] == 'string')
        {
            const logs = {};
            for (const k of cfg['logs'].split(/,/))
                logs[k] = true;
            cfg['logs'] = logs;
        }
        else if (!cfg['logs'])
            cfg['logs'] = {};
        else if (!(cfg['logs'] instanceof Object))
            throw new Error('logs should be object or string with keywords (access,watch,cluster)');
        this.clients = {};
        this.client_id = 1;
        this.etctree = new EtcTree();
        this.persistence = null;
        this.cluster = null;
        this.locker = null;
        this.stored_term = 0;
        this.cfg = cfg;
        this.loading = false;
        this.stopped = false;
        this.inflight = 0;
        this.wait_inflight = [];
        this.api_watches = {};
    }

    async start()
    {
        if (this.cfg.data || this.cfg.cluster)
        {
            this.etctree.set_replicate_watcher(msg => this._persistAndReplicate(msg));
        }
        if (this.cfg.data)
        {
            this.persistence = new AntiPersistence(this);
            // Load data from disk
            await this.persistence.load();
        }
        if (this.cfg.cluster)
        {
            this.cluster = new AntiCluster(this);
        }
        if (this.cfg.use_locks)
        {
            this.locker = new AntiLocker(this.cfg);
            this.locker.set_resume_notifications(rev => this.etctree.resume_notifications(rev));
        }
        if (this.cfg.cert)
        {
            this.tls = {
                key: await fsp.readFile(this.cfg.key),
                cert: await fsp.readFile(this.cfg.cert),
            };
            if (this.cfg.ca)
            {
                this.tls.ca = await fsp.readFile(this.cfg.ca);
            }
            if (this.cfg.client_cert_auth)
            {
                this.tls.requestCert = true;
            }
            this.server = https.createServer(this.tls, (req, res) => this._handleRequest(req, res));
        }
        else
        {
            this.server = http.createServer((req, res) => this._handleRequest(req, res));
        }
        this.wss = new ws.Server({ server: this.server });
        // eslint-disable-next-line no-unused-vars
        this.wss.on('connection', (conn, req) => this._startWebsocket(conn, null));
        this.server.listen(this.cfg.port || 2379, this.cfg.ip || undefined);
    }

    async stop()
    {
        if (this.stopped)
        {
            return;
        }
        this.stopped = true;
        // Wait until all requests complete
        while (this.inflight > 0)
        {
            await new Promise(ok => this.wait_inflight.push(ok));
        }
        if (this.persistence)
        {
            await this.persistence.persist();
        }
    }

    async _persistAndReplicate(msg)
    {
        if (this.cfg.use_locks && msg.events && msg.events.length)
        {
            this.locker.set_txn_locks(msg.events, msg.header.revision);
        }
        let res = [];
        if (this.cluster)
        {
            // We have to guarantee that replication is processed sequentially
            // So we have to send messages without first awaiting for anything!
            res.push(this.cluster.replicateChange(msg));
        }
        if (this.persistence)
        {
            res.push(this.persistence.persistChange(msg));
        }
        if (res.length == 1)
        {
            await res[0];
        }
        else if (res.length > 0)
        {
            let done = 0;
            await new Promise((allOk, allNo) =>
            {
                res.map(promise => promise.then((/*r*/) =>
                {
                    if ((++done) == res.length)
                        allOk();
                }).catch(e =>
                {
                    console.error(e);
                    allNo(e);
                }));
            });
        }
        if (!this.cluster)
        {
            // Run deletion compaction without followers
            const mod_revision = this.etctree.mod_revision;
            if (mod_revision - this.etctree.compact_revision > (this.cfg.compact_revisions||1000)*2)
            {
                const revision = mod_revision - (this.cfg.compact_revisions||1000);
                this.etctree.compact(revision);
            }
        }
        if (this.cfg.use_locks && msg.events && msg.events.length &&
            (!this.cluster || this.cluster.raft.state == TinyRaft.LEADER))
        {
            // Leader unlocks txn itself, followers wait for feedback from the leader
            this.locker.unlock_txn(msg.header.revision);
            if (this.cluster)
            {
                this.cluster.unlockChange(msg.header.revision).catch(e =>
                {
                    if (!(e instanceof RequestError))
                        console.error(e);
                });
            }
        }
    }

    _handleRequest(req, res)
    {
        let data = [];
        req.on('data', (chunk) => data.push(chunk));
        req.on('end', async () =>
        {
            this.inflight++;
            data = Buffer.concat(data);
            let body = '';
            let code = 200;
            let ctype = 'text/plain; charset=utf-8';
            let reply;
            try
            {
                if (req.headers['content-type'] != 'application/json')
                {
                    throw new RequestError(400, 'content-type should be application/json');
                }
                body = data.toString();
                try
                {
                    data = data.length ? JSON.parse(data) : {};
                }
                catch (e)
                {
                    throw new RequestError(400, 'body should be valid JSON');
                }
                if (!(data instanceof Object) || data instanceof Array)
                {
                    throw new RequestError(400, 'body should be JSON object');
                }
                reply = await this._runHandler(req, data);
                reply = JSON.stringify(reply);
                ctype = 'application/json';
            }
            catch (e)
            {
                if (e instanceof RequestError)
                {
                    code = e.code;
                    reply = e.message+'\n';
                }
                else
                {
                    console.error(e);
                    code = 500;
                    reply = 'Internal error: '+e.message+'\n';
                }
            }
            try
            {
                // Access log
                if (this.cfg.logs.access)
                {
                    console.log(
                        new Date().toISOString()+
                        ' '+(req.headers['x-forwarded-for'] || (req.socket.remoteAddress + ':' + req.socket.remotePort))+
                        ' '+req.method+' '+req.url+' '+code+'\n  '+body.replace(/\n/g, '\\n')+
                        '\n  '+reply.replace(/\n/g, '\\n')
                    );
                }
                reply = Buffer.from(reply);
                res.writeHead(code, {
                    'Content-Type': ctype,
                    'Content-Length': reply.length,
                });
                res.write(reply);
                res.end();
            }
            catch (e)
            {
                console.error(e);
            }
            this.inflight--;
            if (!this.inflight)
            {
                runCallbacks(this, 'wait_inflight', []);
            }
        });
    }

    _startWebsocket(socket, reconnect)
    {
        const client_id = this.client_id++;
        this.clients[client_id] = {
            id: client_id,
            addr: socket._socket ? socket._socket.remoteAddress+':'+socket._socket.remotePort : '',
            socket,
            alive: true,
            watches: {},
            pinger: null,
        };
        socket.on('pong', () => this.clients[client_id].alive = true);
        const pinger = () =>
        {
            if (!this.clients[client_id])
            {
                return;
            }
            if (!this.clients[client_id].alive)
            {
                return socket.terminate();
            }
            this.clients[client_id].alive = false;
            socket.ping(() => {});
        };
        if (socket.readyState == ws.WebSocket.OPEN)
        {
            this.clients[client_id].pinger = setInterval(pinger, this.cfg.ws_keepalive_interval||30000);
        }
        else
        {
            socket.on('open', () =>
            {
                this.clients[client_id].pinger = setInterval(pinger, this.cfg.ws_keepalive_interval||30000);
            });
        }
        socket.on('message', (msg) =>
        {
            try
            {
                msg = JSON.parse(msg);
            }
            catch (e)
            {
                socket.send(JSON.stringify({ error: 'bad-json' }));
                return;
            }
            if (!msg)
            {
                socket.send(JSON.stringify({ error: 'empty-message' }));
            }
            else
            {
                this._handleMessage(client_id, msg, socket);
            }
        });
        const on_close = () =>
        {
            if (this.clients[client_id])
            {
                this._unsubscribeClient(client_id);
                if (this.clients[client_id].pinger)
                {
                    clearInterval(this.clients[client_id].pinger);
                    this.clients[client_id].pinger = null;
                }
                delete this.clients[client_id];
            }
            socket.terminate();
            if (reconnect)
            {
                reconnect();
            }
        };
        socket.on('error', e =>
        {
            console.error(e.syscall === 'connect' ? e.message : e);
            on_close();
        });
        socket.on('close', on_close);
        return client_id;
    }

    async _runHandler(req, data)
    {
        // v3/kv/txn
        // v3/kv/range
        // v3/kv/put
        // v3/kv/deleterange
        // v3/lease/grant
        // v3/lease/keepalive
        // v3/lease/revoke O_o
        // v3/kv/lease/revoke O_o
        const requestUrl = new URL(req.url, 'http://'+(req.headers.host || 'localhost'));
        if (requestUrl.searchParams.get('leaderonly'))
        {
            data.leaderonly = true;
        }
        if (requestUrl.searchParams.get('serializable'))
        {
            data.serializable = true;
        }
        if (requestUrl.searchParams.get('nowaitquorum'))
        {
            data.nowaitquorum = true;
        }
        try
        {
            if (requestUrl.pathname.substr(0, 4) == '/v3/')
            {
                const path = requestUrl.pathname.substr(4).replace(/\/+$/, '').replace(/\/+/g, '_');
                if (req.method != 'POST')
                {
                    throw new RequestError(405, 'Please use POST method');
                }
                return await this.api(path, data);
            }
            else if (requestUrl.pathname == '/dump')
            {
                return await this.api('dump', data);
            }
            else
            {
                throw new RequestError(404, '');
            }
        }
        catch (e)
        {
            if ((e instanceof RequestError) && e.code == 404)
            {
                throw new RequestError(404, 'Supported APIs: /v3/kv/txn, /v3/kv/range, /v3/kv/put, /v3/kv/deleterange, '+
                    '/v3/lease/grant, /v3/lease/revoke, /v3/kv/lease/revoke, /v3/lease/keepalive, /v3/maintenance/status');
            }
            else
            {
                throw e;
            }
        }
    }

    // public generic handler
    async api(path, data)
    {
        if (this.stopped)
        {
            throw new RequestError(502, 'Server is stopping');
        }
        if (this.cluster && path !== 'dump' && path != 'maintenance_status')
        {
            const res = await this.cluster.checkRaftState(path, data);
            if (res)
            {
                return res;
            }
        }
        const cb = this['_handle_'+path];
        if (cb)
        {
            const res = cb.call(this, data);
            if (res instanceof Promise)
            {
                return await res;
            }
            return res;
        }
        throw new RequestError(404, 'Unsupported API');
    }

    // public wrappers
    async txn(params)
    {
        return await this.api('kv_txn', params);
    }

    async range(params)
    {
        return await this.api('kv_range', params);
    }

    async put(params)
    {
        return await this.api('kv_put', params);
    }

    async deleterange(params)
    {
        return await this.api('kv_deleterange', params);
    }

    async lease_grant(params)
    {
        return await this.api('lease_grant', params);
    }

    async lease_revoke(params)
    {
        return await this.api('lease_revoke', params);
    }

    async lease_keepalive(params)
    {
        return await this.api('lease_keepalive', params);
    }

    // public watch API
    async create_watch(params, callback, stream_id)
    {
        params = this._encodeWatch(params);
        if (this.cfg.use_locks && params.start_revision)
        {
            // watch is also a read, so it also has to follow read lock rules
            const max_ts = Date.now() + this.cfg.lock_wait_timeout;
            let lock_revision = 0;
            while ((lock_revision = this.locker.check_locks(params.key, params.range_end)))
            {
                await this.locker.wait_commit(lock_revision, max_ts);
            }
        }
        const watch = this.etctree.api_create_watch({ ...params, watch_id: null }, (msg) => callback(this._encodeMsg(msg)), stream_id);
        if (!watch.created)
        {
            throw new RequestError(400, 'Requested watch revision is compacted', { compact_revision: watch.compact_revision });
        }
        const watch_id = params.watch_id || watch.watch_id;
        this.api_watches[watch_id] = watch.watch_id;
        if (watch.events)
        {
            callback(this._encodeMsg({ result: { ...watch, watch_id } }));
        }
        return watch_id;
    }

    async cancel_watch(watch_id)
    {
        const mapped_id = this.api_watches[watch_id];
        if (!mapped_id)
        {
            throw new RequestError(400, 'Watch not found');
        }
        this.etctree.api_cancel_watch(mapped_id);
        delete this.api_watches[watch_id];
    }

    // internal handlers
    async _handle_kv_txn(data)
    {
        let decoded = data;
        if (this.cfg.use_base64)
        {
            decoded = {};
            if (data.compare)
            {
                decoded.compare = [];
                for (const item of data.compare)
                    decoded.compare.push({ ...item, key: de64(item.key) });
            }
            for (const key of [ 'success', 'failure' ])
            {
                if (!data[key])
                    continue;
                const actions = [];
                for (const item of data[key])
                {
                    const copy = {};
                    let r;
                    if ((r = (item.request_range || item.requestRange)))
                        copy.request_range = { ...r, key: de64(r.key), range_end: de64(r.range_end) };
                    else if ((r = (item.request_delete_range || item.requestDeleteRange)))
                        copy.request_delete_range = { ...r, key: de64(r.key), range_end: de64(r.range_end) };
                    else if ((r = (item.request_put || item.requestPut)))
                        copy.request_put = { ...r, key: de64(r.key), value: de64(r.value) };
                    actions.push(copy);
                }
                decoded[key] = actions;
            }
        }
        if (this.cfg.use_locks)
        {
            const max_ts = Date.now() + this.cfg.lock_wait_timeout;
            let lock_revision = 0;
            while ((lock_revision = this.locker.check_txn_locks(decoded)))
            {
                await this.locker.wait_commit(lock_revision, max_ts);
                const res = await this.cluster.checkRaftState('kv_txn', data);
                if (res)
                {
                    return res;
                }
            }
        }
        const result = await this.etctree.api_txn(decoded);
        if (this.cfg.use_base64)
        {
            for (const item of result.responses||[])
            {
                if (item.response_range)
                {
                    for (const kv of item.response_range.kvs)
                    {
                        if (kv.key != null)
                            kv.key = b64(kv.key);
                        if (kv.value != null)
                            kv.value = b64(kv.value);
                    }
                }
            }
        }
        return result;
    }

    async _handle_kv_range(data)
    {
        const r = await this._handle_kv_txn({ success: [ { request_range: data } ] });
        return { header: r.header, ...r.responses[0].response_range };
    }

    async _handle_kv_put(data)
    {
        const r = await this._handle_kv_txn({ success: [ { request_put: data } ] });
        return { header: r.header, ...r.responses[0].response_put };
    }

    async _handle_kv_deleterange(data)
    {
        const r = await this._handle_kv_txn({ success: [ { request_delete_range: data } ] });
        return { header: r.header, ...r.responses[0].response_delete_range };
    }

    _handle_lease_grant(data)
    {
        return this.etctree.api_grant_lease(data);
    }

    _handle_lease_revoke(data)
    {
        return this.etctree.api_revoke_lease(data);
    }

    _handle_kv_lease_revoke(data)
    {
        return this.etctree.api_revoke_lease(data);
    }

    _handle_lease_keepalive(data)
    {
        return this.etctree.api_keepalive_lease(data);
    }

    _handle_maintenance_status(/*data*/)
    {
        const raft = this.cluster && this.cluster.raft;
        return {
            header: {
                member_id: this.cfg.node_id || undefined,
                revision: this.etctree.mod_revision,
                compact_revision: this.etctree.compact_revision || 0,
                raft_term: raft && raft.term || undefined,
            },
            version: 'antietcd '+AntiEtcd.VERSION,
            cluster: this.cfg.cluster || undefined,
            leader: raft && raft.leader || undefined,
            followers: raft && raft.followers || undefined,
            raftTerm: raft && raft.term || undefined,
            raftState: raft && raft.state || undefined,
            dbSize: process.memoryUsage().heapUsed,
        };
    }

    // eslint-disable-next-line no-unused-vars
    _handle_dump(data)
    {
        return { ...this.etctree.dump(), term: this.stored_term };
    }

    _handleMessage(client_id, msg, socket)
    {
        const client = this.clients[client_id];
        if (this.cfg.logs.access)
        {
            console.log(new Date().toISOString()+' '+client.addr+' '+(client.raft_node_id || '-')+' -> '+JSON.stringify(msg));
        }
        if (msg.create_request)
        {
            const create_request = this._encodeWatch(msg.create_request);
            if (!create_request.watch_id || !client.watches[create_request.watch_id])
            {
                client.send_cb = client.send_cb || (msg => socket.send(JSON.stringify(this._encodeMsg(msg, client))));
                if (this.cfg.use_locks && create_request.start_revision)
                {
                    this._lock_and_create_watch(client, create_request);
                }
                else
                {
                    this._create_client_watch(client, create_request);
                }
            }
        }
        else if (msg.cancel_request)
        {
            const mapped_id = client.watches[msg.cancel_request.watch_id];
            if (mapped_id)
            {
                this.etctree.api_cancel_watch(mapped_id);
                delete client.watches[msg.cancel_request.watch_id];
                socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision }, watch_id: msg.cancel_request.watch_id, canceled: true } }));
            }
        }
        else if (msg.progress_request)
        {
            socket.send(JSON.stringify({ result: { header: { revision: this.etctree.mod_revision } } }));
        }
        else
        {
            if (!this.cluster)
            {
                return;
            }
            this.cluster.handleWsMsg(client, msg);
        }
    }

    async _lock_and_create_watch(client, create_request)
    {
        // watch is also a read, so it also has to follow read lock rules
        try
        {
            const max_ts = Date.now() + this.cfg.lock_wait_timeout;
            let lock_revision = 0;
            while ((lock_revision = this.locker.check_locks(create_request.key, create_request.range_end)))
            {
                await this.locker.wait_commit(lock_revision, max_ts);
            }
            this._create_client_watch(client, create_request);
        }
        catch (e)
        {
            if (!(e instanceof RequestError))
            {
                console.error(e);
            }
            client.send_cb({ result: {
                header: { revision: this.etctree.mod_revision },
                watch_id: create_request.watch_id,
                canceled: true,
                cancel_reason: e.message,
            } });
        }
    }

    _create_client_watch(client, create_request)
    {
        const watch = this.etctree.api_create_watch(
            { ...create_request, watch_id: null }, client.send_cb, (this.cfg.merge_watches ? 'C'+client.id : null)
        );
        if (!watch.created)
        {
            client.send_cb({ result: { header: { revision: this.etctree.mod_revision }, watch_id: create_request.watch_id, ...watch } });
        }
        else
        {
            create_request.watch_id = create_request.watch_id || watch.watch_id;
            client.watches[create_request.watch_id] = watch.watch_id;
            client.send_cb({ result: {
                header: { revision: this.etctree.mod_revision },
                watch_id: create_request.watch_id,
                created: true,
                events: watch.events,
            }});
        }
    }

    _encodeWatch(create_request)
    {
        if (this.cfg.use_base64)
        {
            const req = { ...create_request };
            if (req.key != null)
                req.key = de64(req.key);
            if (req.range_end != null)
                req.range_end = de64(req.range_end);
            return req;
        }
        return create_request;
    }

    _encodeMsg(msg, client)
    {
        if (this.cfg.logs.watch && client)
        {
            console.log(new Date().toISOString()+' '+client.addr+' '+(client.raft_node_id || '-')+' <- '+JSON.stringify(msg));
        }
        if (this.cfg.use_base64 && msg.result && msg.result.events)
        {
            return { ...msg, result: { ...msg.result, events: msg.result.events.map(ev => ({
                ...ev,
                kv: !ev.kv ? ev.kv : {
                    ...ev.kv,
                    key: b64(ev.kv.key),
                    value: b64(ev.kv.value),
                },
            })) } };
        }
        return msg;
    }

    _unsubscribeClient(client_id)
    {
        if (!this.clients[client_id])
        {
            return;
        }
        for (const watch_id in this.clients[client_id].watches)
        {
            const mapped_id = this.clients[client_id].watches[watch_id];
            this.etctree.api_cancel_watch(mapped_id);
        }
    }
}

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

AntiEtcd.RequestError = RequestError;

AntiEtcd.VERSION = VERSION;

module.exports = AntiEtcd;
