const TinyRaft = require('./tinyraft.js');
const FakeTimer = require('./faketimer.js');

const fake = new FakeTimer();
TinyRaft.setTimeout = fake.setTimeout;
TinyRaft.clearTimeout = fake.clearTimeout;
TinyRaft.setInterval = fake.setInterval;
TinyRaft.clearInterval = fake.clearInterval;

function newNode(id, nodes, partitions, mod)
{
    partitions = partitions || {};
    let cfg = {
        nodes: [ 1, 2, 3, 4, 5 ],
        nodeId: id,
        heartbeatTimeout: 100,
        electionTimeout: 500,
        send: function(to, msg)
        {
            if (!partitions[n.nodeId+'-'+to] && !partitions[to+'-'+n.nodeId] && nodes[to])
            {
                console.log('['+fake.time+'] received from '+n.nodeId+' to '+to+': '+JSON.stringify(msg));
                fake.setImmediate(function() { nodes[to].onReceive(n.nodeId, msg); });
            }
            else
                console.log('['+fake.time+'] failed to send from '+n.nodeId+' to '+to+': '+JSON.stringify(msg));
        },
    };
    if (mod)
        mod(cfg);
    let n = new TinyRaft(cfg);
    n.on('change', (st) =>
    {
        console.log(
            '['+fake.time+'] node '+n.nodeId+': '+(st.state == TinyRaft.FOLLOWER ? 'following '+st.leader : st.state)+
            ', term '+st.term+(st.state == TinyRaft.LEADER ? ', followers: '+st.followers.join(', ') : '')
        );
    });
    nodes[id] = n;
}

function newNodes(count, partitions, mod)
{
    partitions = partitions || {};
    const nodes = {};
    for (let i = 1; i <= count; i++)
    {
        newNode(i, nodes, partitions, mod);
    }
    for (let i = 1; i <= count; i++)
    {
        nodes[i].start();
    }
    return nodes;
}

function checkQuorum(nodes, count)
{
    let leaders = 0;
    let out = 0;
    for (const i in nodes)
    {
        if (nodes[i].state == TinyRaft.LEADER)
        {
            leaders++;
            if (nodes[i].followers.length != count)
                throw new Error('leader '+i+' has '+nodes[i].followers.length+' followers, but should have '+count);
        }
        else if (nodes[i].state != TinyRaft.FOLLOWER)
        {
            out++;
            if (out > nodes.length-count)
                throw new Error('node '+i+' is not in quorum: state '+nodes[i].state);
        }
    }
    if (leaders != 1)
    {
        throw new Error('we have '+leaders+' leaders instead of exactly 1');
    }
    console.log('['+fake.time+'] OK: '+count+' nodes in quorum');
}

function checkNoQuorum(nodes)
{
    const nc = Object.values(nodes).filter(n => n.state != TinyRaft.CANDIDATE);
    if (nc.length > 0)
    {
        throw new Error('we have non-candidates ('+nc.map(n => n.nodeId).join(', ')+'), but we should not');
    }
    console.log('['+fake.time+'] OK: '+Object.keys(nodes).length+' candidates, no quorum');
}

async function testStartThenRemoveNode()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testStartThenRemoveNode');
    const nodes = newNodes(5);
    let leaderChanges = 0, prevLeader = null;
    nodes[2].on('change', (st) =>
    {
        if (st.leader && st.leader != prevLeader)
        {
            prevLeader = st.leader;
            leaderChanges++;
        }
    });
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    if (leaderChanges >= 3)
    {
        throw new Error('More than 3 leader changes in 2000ms: '+leaderChanges);
    }
    // Stop the leader
    let leader = nodes[1].leader;
    nodes[leader].onReceive = () => {};
    nodes[leader].stop();
    delete nodes[leader];
    // Check quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 4);
    // Stop the leader again
    leader = nodes[Object.keys(nodes)[0]].leader;
    nodes[leader].onReceive = () => {};
    nodes[leader].stop();
    delete nodes[leader];
    // Check quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 3);
    // Stop the leader again
    leader = nodes[Object.keys(nodes)[0]].leader;
    nodes[leader].onReceive = () => {};
    nodes[leader].stop();
    delete nodes[leader];
    // Check that no quorum exists
    fake.runFor(2000);
    checkNoQuorum(nodes);
    // Clean up
    for (const id in nodes)
    {
        nodes[id].stop();
    }
    console.log('testStartThenRemoveNode: OK');
}

async function testAddNode()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testAddNode');
    const nodes = newNodes(5, {}, cfg => cfg.initialTerm = 1000);
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    // Add node
    newNode(6, nodes);
    for (let i = 1; i <= 5; i++)
        nodes[i].setNodes([ 1, 2, 3, 4, 5, 6 ]);
    nodes[6].start();
    // Check quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 6);
    // Clean up
    for (const id in nodes)
    {
        nodes[id].stop();
    }
    console.log('testAddNode: OK');
}

async function testLeadershipExpiration()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testLeadershipExpiration');
    const partitions = {};
    const nodes = newNodes(5, partitions, cfg => cfg.leadershipTimeout = 1500);
    // Check that 5 nodes are in quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    // Break network on the leader
    let leader = nodes[1].leader;
    console.log("["+fake.time+"] --> stopping the leader's ("+leader+") network");
    for (let i = 1; i <= 5; i++)
    {
        partitions[i+'-'+leader] = true;
        partitions[leader+'-'+i] = true;
    }
    // Check that the leader loses leadership after 2 * leadershipTimeout
    fake.runFor(3000);
    if (nodes[leader].state != TinyRaft.CANDIDATE)
    {
        throw new Error("leadership expiration doesn't work");
    }
    // Clean up
    for (const id in nodes)
    {
        nodes[id].stop();
    }
    console.log('testLeadershipExpiration: OK');
}

async function testRestart()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testRestart');
    const nodes = newNodes(5, {}, cfg => cfg.initialTerm = 1000);
    let leaderChanges = 0, prevLeader = null;
    nodes[2].on('change', (st) =>
    {
        const leader = st.state == TinyRaft.CANDIDATE ? null : st.leader;
        if (leader != prevLeader)
        {
            prevLeader = leader;
            leaderChanges++;
        }
    });
    // Check that 5 nodes are in quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    if (leaderChanges >= 3)
    {
        throw new Error("leaderChanges = "+leaderChanges+" (expected < 3)");
    }
    // Stop a follower
    let restarted = 1 + (prevLeader % 5);
    if (restarted == 2)
    {
        restarted = 1 + (prevLeader + 1) % 5;
    }
    console.log("["+fake.time+"] --> stopping a follower (node "+restarted+")");
    nodes[restarted].stop();
    delete nodes[restarted];
    // Wait 2000ms
    fake.runFor(2000);
    // Restart a follower
    console.log("["+fake.time+"] --> restarting a follower (node "+restarted+")");
    leaderChanges = 0;
    newNode(restarted, nodes, {}, null);
    nodes[restarted].start();
    // Check quorum and the fact that the leader didn't change after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    if (leaderChanges > 0)
    {
        throw new Error("leader changed after restart of a follower");
    }
    // Clean up
    for (const id in nodes)
    {
        nodes[id].stop();
    }
    console.log('testRestart: OK');
}

async function testChangeNodes()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testChangeNodes');
    console.log('['+fake.time+'] --> starting nodes 1-5');
    const nodes = newNodes(5, {}, cfg => cfg.initialTerm = 1000);
    // Check that 5 nodes are in quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    // Stop node 4
    console.log('['+fake.time+'] --> stopping node 4');
    nodes[4].stop();
    delete nodes[4];
    // Wait 1000ms
    fake.runFor(1000);
    // Change nodes from 1 2 3 4 5 to 1 2 3 5 6
    console.log('['+fake.time+'] --> starting node 6');
    newNode(6, nodes);
    nodes[6].start();
    nodes[1].setNodes([ 1, 2, 3, 5, 6 ]);
    nodes[2].setNodes([ 1, 2, 3, 5, 6 ]);
    nodes[3].setNodes([ 1, 2, 3, 5, 6 ]);
    nodes[5].setNodes([ 1, 2, 3, 5, 6 ]);
    nodes[6].setNodes([ 1, 2, 3, 5, 6 ]);
    // Check that 5 nodes are in quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    // Clean up
    for (const id in nodes)
    {
        if (nodes[id])
            nodes[id].stop();
    }
    console.log('testChangeNodes: OK');
}

async function testLeaderPriority()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testLeaderPriority');
    console.log('['+fake.time+'] --> starting nodes 1-5');
    const nodes = newNodes(5, {}, cfg => cfg.leaderPriority = cfg.nodeId+1);
    // Check that 5 nodes are in quorum after 2000ms
    fake.runFor(2000);
    checkQuorum(nodes, 5);
    if (nodes[1].leader != 5)
    {
        throw new Error('leader is not 5');
    }
    // Stop node 5
    console.log('['+fake.time+'] --> stopping node 5');
    nodes[5].stop();
    delete nodes[5];
    // Wait 2000ms and check that the leader is now 4
    fake.runFor(2000);
    checkQuorum(nodes, 4);
    if (nodes[1].leader != 4)
    {
        throw new Error('leader is not 4');
    }
    // Stop node 4
    console.log('['+fake.time+'] --> stopping node 4');
    nodes[4].stop();
    delete nodes[4];
    // Wait 2000ms and check that the leader is now 3
    fake.runFor(2000);
    checkQuorum(nodes, 3);
    if (nodes[1].leader != 3)
    {
        throw new Error('leader is not 3');
    }
    // Clean up
    for (const id in nodes)
    {
        if (nodes[id])
            nodes[id].stop();
    }
    console.log('testLeaderPriority: OK');
}

async function testPartition1_3()
{
    console.log('--------------------------------------------------------------------------------');
    console.log('testPartition1_3');
    const partitions = { '1-3': true, '3-1': true };
    const nodes = newNodes(3, partitions, cfg => cfg.nodes = [ 1, 2, 3 ]);
    // Check that 2 or 3 nodes are in quorum after 5000ms
    // This situation should be fixed by "prevote protocol", but it breaks other things
    fake.runFor(5000);
    if (nodes[2].leader == 2)
        checkQuorum(nodes, 3);
    else
        checkQuorum(nodes, 2);
    // Clean up
    for (const id in nodes)
    {
        nodes[id].stop();
    }
    console.log('testPartition1_3: OK');
}

async function run()
{
    await testStartThenRemoveNode();
    await testAddNode();
    await testLeadershipExpiration();
    await testRestart();
    await testChangeNodes();
    await testLeaderPriority();
    await testPartition1_3();
    process.exit(0);
}

run().catch(err => { console.error(err); process.exit(1); });
