/*
// add the private key and public key address system.
// store the private key permanently.
*/

'use strict';
var CryptoJS = require("crypto-js");
var express = require("express");
var bodyParser = require("body-parser");
var WebSocket = require("ws");
var fs = require("fs");
var ecdsa = require("elliptic");

var ec = new ecdsa.ec("secp256k1");

// set environment variable
var http_port = process.env.HTTP_PORT || 3001;                              // > $env:HTTP_PORT=3003
var p2p_port = process.env.P2P_PORT || 6001;                                // > $env:P2P_PORT=6003
var initialPeers = process.env.PEERS ? process.env.PEERS.split(',') : [];   // > $env:PEERS = "ws://127.0.0.1:6001, ws://127.0.0.1:6002"
var privateKeyLocation = process.env.PRIVATE_KEY || ('wallet/' + p2p_port.toString() + '/private_key');  // private key location

// get private key
function getPrivateFromWallet() {
    var buffer = fs.readFileSync(privateKeyLocation, 'utf8');
    return buffer.toString();
}

// get public key
function getPublicFromWallet() {
    var privateKey = getPrivateFromWallet();
    var key = ec.keyFromPrivate(privateKey, 'hex');
    return key.getPublic().encode('hex');
}

// generate private key
function generatePrivateKey() {
    var keyPair = ec.genKeyPair();
    var privateKey = keyPair.getPrivate();
    return privateKey.toString(16);
}

// get or generate private key
function initWallet() {
    // do not override existing private keys
    if (fs.existsSync(privateKeyLocation)) { return; }

    var newPrivateKey = generatePrivateKey();

    // mkdir, if necessary
    if (!fs.existsSync('wallet/')) { fs.mkdirSync('wallet/'); }
    if (!fs.existsSync('wallet/' + p2p_port.toString())) { fs.mkdirSync('wallet/' + p2p_port.toString()); }

    // write file
    fs.writeFileSync(privateKeyLocation, newPrivateKey);

    console.log('new wallet with private key created to : %s', privateKeyLocation);
}

// delete private key
function deleteWallet() {
    if (fs.existsSync(privateKeyLocation)) {
        fs.unlinkSync(privateKeyLocation);
    }
}

// Vote structure
class Vote {
    constructor(sender, receiver, amount) {
        this.sender = sender;
        this.receiver = receiver;
        this.amount = amount;
    }
}

// Transaction
class Transaction {
    constructor(vote, timestamp, id, signature) {
        this.vote = vote;
        this.timestamp = timestamp;
        this.id = id;
        this.signature = signature;
    }
}

// get transaction id
function getTransactionId(vote, timestamp) {
    return CryptoJS.SHA256(vote.sender + vote.receiver + vote.amount + timestamp).toString();
}

// get signature
function signTransaction(dataToSign) {
    var privateKey = getPrivateFromWallet();    
    var key = ec.keyFromPrivate(privateKey, 'hex');
    var signature = toHexString(key.sign(dataToSign).toDER());
    return signature.toString();
}

// get new block with vote
function generateNextBlockWithTx(sender, receiver, amount) {
    var newVote = new Vote(sender, receiver, amount);
    var nextTimestamp = new Date().getTime() / 1000;
    var TxId = getTransactionId(newVote, nextTimestamp);
    var newSignature = signTransaction(TxId);
    var newTx = new Transaction(newVote, nextTimestamp, TxId, newSignature);

    var previousBlock = getLatestBlock();
    var nextIndex = previousBlock.index + 1;    
    var nextHash = calculateHash(nextIndex, previousBlock.hash, nextTimestamp, newTx);
    return new Block(nextIndex, previousBlock.hash, nextTimestamp, newTx, nextHash);
};

// minimum block structure
class Block {
    constructor(index, previousHash, timestamp, data, hash) {
        this.index = index;
        this.previousHash = previousHash.toString();
        this.timestamp = timestamp;
        this.data = data;
        this.hash = hash.toString();
    }
}

// WARNING!! if you modify any of the following data,
// you might need to obtain a new hash(SHA256) value
function getGenesisBlock() {
    return new Block(0, "", 1535165503, "Genesis block", "a12eab42aa059b74b1ee08310a88b56e64c3d90cf803445250dc2f209833d6d2");
};

// WARNING!! the current implementation is stored in local volatile memory.
// you may need a database to store the data permanently.
var blockchain = [getGenesisBlock()];

// get new block
// blockData can be anything; transactions, strings, values, etc.
function generateNextBlock(blockData) {
    var previousBlock = getLatestBlock();
    var nextIndex = previousBlock.index + 1;
    var nextTimestamp = new Date().getTime() / 1000;
    var nextHash = calculateHash(nextIndex, previousBlock.hash, nextTimestamp, blockData);
    return new Block(nextIndex, previousBlock.hash, nextTimestamp, blockData, nextHash);
};

// get hash
function calculateHashForBlock(block) {
    return calculateHash(block.index, block.previousHash, block.timestamp, block.data);
};

function calculateHash(index, previousHash, timestamp, data) {
    return CryptoJS.SHA256(index + previousHash + timestamp + data).toString();
};

// add new block
// need validation test
function addBlock(newBlock) {
    if (isValidNewBlock(newBlock, getLatestBlock())) {
        blockchain.push(newBlock);
    }
};

// validation test of new block
function isValidNewBlock(newBlock, previousBlock) {
    if (previousBlock.index + 1 !== newBlock.index) {
        console.log('invalid index');
        return false;
    } else if (previousBlock.hash !== newBlock.previousHash) {
        console.log('invalid previoushash');
        return false;
    } else if (calculateHashForBlock(newBlock) !== newBlock.hash) {
        console.log(typeof (newBlock.hash) + ' ' + typeof calculateHashForBlock(newBlock));
        console.log('invalid hash: ' + calculateHashForBlock(newBlock) + ' ' + newBlock.hash);
        return false;
    }
    return true;
};

// WARNING!! you can modify the following implementaion according to your own consensus design.
// current consensus: the longest chain rule.
function replaceChain(newBlocks) {
    if (isValidChain(newBlocks) && newBlocks.length > blockchain.length) {
        console.log('Received blockchain is valid. Replacing current blockchain with received blockchain');
        blockchain = newBlocks;
        broadcast(responseLatestMsg());
    } else {
        console.log('Received blockchain invalid');
    }
};

// validation test of blockchain
function isValidChain(blockchainToValidate) {
    if (JSON.stringify(blockchainToValidate[0]) !== JSON.stringify(getGenesisBlock())) {
        return false;
    }
    var tempBlocks = [blockchainToValidate[0]];
    for (var i = 1; i < blockchainToValidate.length; i++) {
        if (isValidNewBlock(blockchainToValidate[i], tempBlocks[i - 1])) {
            tempBlocks.push(blockchainToValidate[i]);
        } else {
            return false;
        }
    }
    return true;
};

// get latest block
function getLatestBlock() { return blockchain[blockchain.length - 1] };

var sockets = [];
var MessageType = {
    QUERY_LATEST: 0,
    QUERY_ALL: 1,
    RESPONSE_BLOCKCHAIN: 2
};

// REST API
function initHttpServer() {
    var app = express();
    app.use(bodyParser.json());

    app.get('/blocks', function (req, res) {
        res.send(JSON.stringify(blockchain));
    });
    app.post('/mineBlock', function (req, res) {
        var newBlock = generateNextBlock(req.body.data || "");
        addBlock(newBlock);
        broadcast(responseLatestMsg());
        console.log('block added: ' + JSON.stringify(newBlock));
        res.send();
    });
    app.get('/peers', function (req, res) {
        res.send(sockets.map(s => s._socket.remoteAddress + ':' + s._socket.remotePort));
    });
    app.post('/addPeer', function (req, res) {
        connectToPeers([req.body.peer]);
        res.send();
    });
    app.post('/stop', function (req, res) {
        res.send({ 'msg': 'stopping server' });
        process.exit();
    });
    app.get('/address', function (req, res) {
        var address = getPublicFromWallet().toString();
        res.send({ 'address': address });
    });
    app.post('/deleteWallet', function (req, res) {
        deleteWallet();
        res.send();
    });
    app.post('/voting', function (req, res) {
        var sender = getPublicFromWallet();
        var newBlock = generateNextBlockWithTx(sender, req.body.receiver || "", req.body.amount || 0);
        addBlock(newBlock);
        broadcast(responseLatestMsg());
        console.log('block added: ' + JSON.stringify(newBlock));
        res.send();
    });
    app.listen(http_port, function () { console.log('Listening http on port: ' + http_port) });
};

function initP2PServer() {
    var server = new WebSocket.Server({ port: p2p_port });
    server.on('connection', function (ws) { initConnection(ws) });
    console.log('listening websocket p2p port on: ' + p2p_port);

};

function initConnection(ws) {
    sockets.push(ws);
    initMessageHandler(ws);
    initErrorHandler(ws);
    write(ws, queryChainLengthMsg());
};

function initMessageHandler(ws) {
    ws.on('message', function (data) {
        var message = JSON.parse(data);
        console.log('Received message' + JSON.stringify(message));
        switch (message.type) {
            case MessageType.QUERY_LATEST:
                write(ws, responseLatestMsg());
                break;
            case MessageType.QUERY_ALL:
                write(ws, responseChainMsg());
                break;
            case MessageType.RESPONSE_BLOCKCHAIN:
                handleBlockchainResponse(message);
                break;
        }
    });
};

function initErrorHandler(ws) {
    var closeConnection = function (ws) {
        console.log('connection failed to peer: ' + ws.url);
        sockets.splice(sockets.indexOf(ws), 1);
    };
    ws.on('close', function () { closeConnection(ws) });
    ws.on('error', function () { closeConnection(ws) });
};

function connectToPeers(newPeers) {
    newPeers.forEach(function (peer) {
        var ws = new WebSocket(peer);
        ws.on('open', function () { initConnection(ws) });
        ws.on('error', function () {
            console.log('connection failed')
        });
    });
};

function handleBlockchainResponse(message) {
    var receivedBlocks = JSON.parse(message.data).sort(function (b1, b2) { (b1.index - b2.index) });
    var latestBlockReceived = receivedBlocks[receivedBlocks.length - 1];
    var latestBlockHeld = getLatestBlock();
    if (latestBlockReceived.index > latestBlockHeld.index) {
        console.log('blockchain possibly behind. We got: ' + latestBlockHeld.index + ' Peer got: ' + latestBlockReceived.index);
        if (latestBlockHeld.hash === latestBlockReceived.previousHash) {
            console.log("We can append the received block to our chain");
            blockchain.push(latestBlockReceived);
            broadcast(responseLatestMsg());
        } else if (receivedBlocks.length === 1) {
            console.log("We have to query the chain from our peer");
            broadcast(queryAllMsg());
        } else {
            console.log("Received blockchain is longer than current blockchain");
            replaceChain(receivedBlocks);
        }
    } else {
        console.log('received blockchain is not longer than current blockchain. Do nothing');
    }
};

function queryChainLengthMsg() { return ({ 'type': MessageType.QUERY_LATEST }) };
function queryAllMsg() { return ({ 'type': MessageType.QUERY_ALL }) };
function responseChainMsg() {
    return ({
        'type': MessageType.RESPONSE_BLOCKCHAIN, 'data': JSON.stringify(blockchain)
    })
};
function responseLatestMsg() {
    return ({
        'type': MessageType.RESPONSE_BLOCKCHAIN,
        'data': JSON.stringify([getLatestBlock()])
    })
};

function write(ws, message) { ws.send(JSON.stringify(message)) };
function broadcast(message) { sockets.forEach(socket => write(socket, message)) };

// utils
function toHexString(byteArray) {
    return Array.from(byteArray, (byte) => {
        return ('0' + (byte & 0xFF).toString(16)).slice(-2);
    }).join('');
}

// main
connectToPeers(initialPeers);
initHttpServer();
initP2PServer();
initWallet();
