// The PasspetPersona XPCOM component holds the state common to all browser
// windows and documents that is associated with a single Passpet persona.

// Shorthand for getting XPConnect interfaces, classes, objects, and services.
const XPI = Components.interfaces, XPC = Components.classes;
function XPC_(c) { return XPC[c.match(/^@/) ? c : '@mozilla.org/' + c]; }
function XPO(c, i) { return XPC_(c) ? XPC_(c).createInstance(i) : null; }
function XPS(c, i) { return XPC_(c) ? XPC_(c).getService(i) : null; }
function XQI(o, i) { return o.QueryInterface(i); }

const XPR = Components.results;
const CID = Components.ID('{b81226e5-ee8a-57e4-a31e-093e50509c0a}');
const NAME = 'Passpet Persona Object';
const CONTRACT = '@passpet.org/persona;1';

// The module registers and unregisters the Passpet component interface.
function NSGetModule(manager, filespec) {
    return new function() {
        this.registerSelf = function(manager, filespec, location, type) {
            manager = XQI(manager, XPI.nsIComponentRegistrar);
            manager.registerFactoryLocation(
                CID, NAME, CONTRACT, filespec, location, type);
        };

        this.unregisterSelf = function(manager, location, type) {
            manager = XQI(manager, XPI.nsIComponentRegistrar);
            manager.unregisterFactoryLocation(CID, location);
        };

        this.canUnload = function(manager) {
            return true;
        };

        this.getClassObject = function(manager, cid, iid) {
            if (!iid.equals(XPI.nsIFactory)) throw XPR.NS_ERROR_NOT_IMPLEMENTED;
            if (!cid.equals(CID)) throw XPR.NS_ERROR_NO_INTERFACE;
            return new function() {
                this.createInstance = function(outer, iid) {
                    if (outer != null) throw XPR.NS_ERROR_NO_AGGREGATION;
                    return XQI(new class(), iid);
                };
            };
        };
    };
};

// ------------------------------------------------------ string conversions

// Handy shorthand for converting numbers to characters.
const chr = String.fromCharCode;

// Convert a string of bytes to a base-62 string.
function strToBase62(bytes) {
    const pj = XPS('@passpet.org/java;1', XPI.IPasspetJava);
    const java = pj.wrappedJSObject.java;
    var value = new java.math.BigInteger(0);
    var maxvalue = new java.math.BigInteger(0);
    for (var i = 0; i < bytes.length; i++) {
        var byte = new java.math.BigInteger(bytes.charCodeAt(i));
        value = value.shiftLeft(8).add(byte);
        var maxbyte = new java.math.BigInteger(255);
        maxvalue = maxvalue.shiftLeft(8).add(maxbyte);
    }
    const base = new java.math.BigInteger(62);
    const digits = '0123456789abcdefghijklmnopqrstuvwxyz' +
                   'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    var result = '';
    while (maxvalue > 0) {
        result += digits.charAt(value.mod(base));
        value = value.divide(base);
        maxvalue = maxvalue.divide(base);
    }
    return result;
}

const crypto = new function() {
    this.encrypt = function(key, data) {
        const aes = XPO('@passpet.org/aes;1', XPI.IPasspetAES);
        aes.init(128, key);
        var nonce = '';
        for (var i = 0; i < 16; i++) {
            nonce += chr(Math.floor(Math.random()*256));
        }
        const gcm = XPO('@passpet.org/gcm;1', XPI.IPasspetGCM);
        gcm.initWithNonce(aes, nonce);
        return nonce + gcm.transform(data);
    };

    this.decrypt = function(key, data) {
        const aes = XPO('@passpet.org/aes;1', XPI.IPasspetAES);
        aes.init(128, key);
        var nonce = data.substring(0, 16);
        const gcm = XPO('@passpet.org/gcm;1', XPI.IPasspetGCM);
        gcm.initWithNonce(aes, nonce);
        return gcm.transform(data.substring(16));
    };

    this.getMAC = function(key, data) {
        const aes = XPO('@passpet.org/aes;1', XPI.IPasspetAES);
        aes.init(128, key);
        return aes.getMAC(data);
    };
}

// ---------------------------------------------------- continuation helpers

function Cont(id, proceed, abort, error) {
    var pfunc = (proceed == null) ? (function() { }) :
                (proceed.proceed == null) ? proceed : proceed.proceed;
    this.proceed = function() {
        var args = '';
        for (var i = 0; i < arguments.length; i++) args += ', ' + arguments[i];
        dump('Cont ' + id + ' proceed: ' + args.substring(2) + '\n');
        pfunc.apply(null, arguments);
    }
    var afunc = (abort == null) ? (function() { }) :
                (abort.abort == null) ? abort : abort.abort;
    if (error == null) {
        this.abort = function(e) {
            dump('Cont ' + id + ' abort: ' + e + '\n');
            afunc(e);
        }
    } else {
        this.abort = function(e) {
            dump('Cont ' + id + ' abort: ' + e + ' -> ' + error + '\n');
            afunc(error);
        }
    }
    this.cancel = function() {
        this.proceed = function() { };
        this.abort = function() { };
    };
}

const NULL_CONTINUATION = new Cont();
NULL_CONTINUATION.cancel();

// ------------------------------ implementation of the PasspetPersona class

const class = function() {
    const hasher = XPO('@passpet.org/iterated-hash;1', XPI.IPasspetHash);
    const folder = XPS('@passpet.org/folder;1', XPI.IPasspetFolder);
    const remote = XPS('@passpet.org/remote-storage;1', XPI.IPasspetRemote);
    const UPLOAD_DELAY = 15000; // upload at most once every UPLOAD_DELAY ms
    const DUTY_ON = 90; // hash for DUTY_ON ms each cycle
    const DUTY_OFF = 10; // wait for DUTY_OFF ms each cycle
    const MAC_LENGTH = crypto.getMAC('', '').length;
    const HASH_LENGTH = hasher.hash(1, '').length;
    const PASSWORD_MAX = 12;
    const PASSWORD_MIN = 6;
    const K2 = 1000;

    var initialized = false; // true means V is available
    var awake = false; // true means secret and petnames are available
    var online = false; // true means sending updates to server
    var lastUploadTime = 0; // keep track so we don't upload too often

    var pid, address, name, iconName, icon, width; // present if valid
    var index, V, file, remoteFile; // present if initialized
    var secret, W, petnames, usernames; // present if awake

    // ---------------------------------------------------------- attributes

    // Failure codes passed to cont.abort().
    const SERVER_NOT_FOUND = this.SERVER_NOT_FOUND = remote.SERVER_NOT_FOUND;
    const LOGIN_FAILED = this.LOGIN_FAILED = remote.LOGIN_FAILED;
    const WRITE_REFUSED = this.WRITE_REFUSED = remote.WRITE_REFUSED;
    const INVALID_REQUEST = this.INVALID_REQUEST = remote.INVALID_REQUEST;
    const SERVER_ERROR = this.SERVER_ERROR = remote.SERVER_ERROR;
    const CONNECTION_ERROR = this.CONNECTION_ERROR = remote.CONNECTION_ERROR;

    this.initialized getter = function() { return initialized; }
    this.awake getter = function() { return awake; }
    this.pid getter = function() { return pid; }
    this.address getter = function() { return address; }
    this.name getter = function() { return name; }
    this.iconName getter = function() { return iconName; }
    this.icon getter = function() { return icon; }
    this.width getter = function() { return width; }
    this.width setter = function(value) { width = value; }

    // ------------------------------------------------------------- methods

    this.create = function(pid0, address0, name0, iconName0, icon0, width0) {
        dump('persona create: ' + pid0 + '\n');
        pid = pid0;
        address = address0;
        name = name0;
        iconName = iconName0;
        icon = icon0;
        width = width0;
        initialized = false;
        awake = false;
        // We don't save (sync) the persona here, because we might be about
        // to call .setup(), which will save on success.
    };

    // This method is here mainly so we have the option of saving or not
    // saving an uninitialized persona right after it is created.
    this.save = function(immediate) {
        sync(false, immediate);
    }

    this.load = function(pid0) {
        dump('persona load: ' + pid0 + '\n');
        pid = pid0;
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        szr.deserialize(folder.read(pid + '.pp'));
        if (szr.length < 5) throw 'invalid persona file';
        address = szr.get('address');
        name = szr.fromUTF8(szr.get('name'));
        iconName = szr.fromUTF8(szr.get('iconName'));
        icon = szr.get('icon');
        width = szr.get('width') - 0;
        initialized = false;
        awake = false;
        if (szr.contains('index')) {
            index = szr.get('index') - 0;
            V = szr.fromHex(szr.get('V'));
            file = szr.fromHex(szr.get('file'));
            remoteFile = szr.contains('remoteFile') ?
                szr.fromHex(szr.get('remoteFile')) : file;
            initialized = true;
            dump('for pid ' + pid0 + ' loaded file ' +
                 (file ? file.length : file) + '\n');
        }
    };

    this.setup = function(secret0, k1, V0, cont) {
        dump('persona setup: ' + address + ' ' + secret0 + ' ' + k1 + '\n');
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        var secretu8 = szr.toUTF8(secret0);
        var W0 = hasher.hash(K2, secretu8 + '\0' + V0);
        remote.create(address, k1, W0, new Cont('setup1', function(result) {
            try {
                index = result - 0;
            } catch (error) {
                cont.abort(error);
            }
            V = V0;
            remoteFile = '';
            secret = secret0;
            W = W0;
            petnames = {};
            usernames = {};
            initialized = true;
            awake = true;
            online = true;
            // (For some reason sync(true, false) doesn't work here, so we
            // call encryptFile and updateRemote directly.)
            // Encrypt the empty file so we can verify the secret locally.
            file = encryptFile({}, {});
            save();
            updateRemote();
            cont.proceed(index);
        }, cont));
    };

    // Encrypt the contents of the given 'petnames' and 'usernames'
    // dictionaries and return the encrypted data.
    function encryptFile(petnames, usernames) {
        dump('persona encryptFile\n');
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        var iszr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        for (var siteid in petnames) {
            iszr.clear();
            iszr.set('petname', petnames[siteid]);
            iszr.set('username', usernames[siteid] || '');
            szr.set(siteid, iszr.serialize());
        }
        var plainText = szr.serialize();
        var W1 = W.substring(0, HASH_LENGTH/2);
        var W2 = W.substring(HASH_LENGTH/2);
        var cipherText = crypto.encrypt(W1, plainText);
        var msgAuthCode = crypto.getMAC(W2, plainText);
        return cipherText + msgAuthCode;
    }

    // Given a file and key, decrypt the contents and return an object
    // containing two dictionaries, 'petnames' and 'usernames'.
    function decryptFile(file0, W0) {
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        var iszr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        var W1 = W0.substring(0, HASH_LENGTH/2);
        var W2 = W0.substring(HASH_LENGTH/2);
        var dataLength = file0.length - MAC_LENGTH;
        var cipherText = file0.substring(0, dataLength);
        var msgAuthCode = file0.substring(dataLength);
        var plainText = crypto.decrypt(W1, cipherText);
        if (msgAuthCode == crypto.getMAC(W2, plainText)) {
            dump('msgAuthCode match\n');
            var result = {};
            result.petnames = {};
            result.usernames = {};
            szr.deserialize(plainText);
            for (var i = 0; i < szr.length; i++) {
                var siteid = szr.key(i);
                iszr.deserialize(szr.get(szr.key(i)));
                result.petnames[siteid] = iszr.fromUTF8(iszr.get('petname'));
                result.usernames[siteid] = iszr.fromUTF8(iszr.get('username'));
            }
            return result;
        }
        dump('msgAuthCode mismatch\n');
        return null;
    }

    function later(delay, action) {
        var timer = XPO('timer;1', XPI.nsITimer);
        var observer = new function() {
            this.observe = function() {
                action();
            };
        };
        timer.init(observer, delay, XPI.nsITimer.TYPE_ONE_SHOT);
    }

    function backgroundHash(iterations, data, callback) {
        var hasher = XPO('@passpet.org/iterated-hash;1', XPI.IPasspetHash);
        hasher.data = data;
        function hashMore() {
            hasher.run(iterations, DUTY_ON);
            if (hasher.iterations < iterations) {
                later(DUTY_OFF, hashMore);
            } else {
                callback(hasher.data);
            }
        };
        later(0, hashMore);
    }

    this.initialize = function(secret0, cont) {
        dump('persona initialize: ' + secret0 + '\n');
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        var secretu8 = szr.toUTF8(secret0);
        remote.list(address, new Cont('initialize1', function(result) {
            var items = result.split(' ');
            function readNext(i) {
                if (i >= items.length) {
                    return cont.abort(LOGIN_FAILED);
                }
                var pair = items[i].split(':');
                if (pair.length != 2) return readNext(i + 1);
                var index0 = pair[0] - 0;
                var k1 = pair[1] - 0;
                dump('trying to read ' + index0 + ':' + k1 + '\n');
                if (isNaN(index0) || isNaN(k1)) return readNext(i + 1);

                backgroundHash(k1, address + '\0' + secretu8, function(V0) {
                    backgroundHash(K2, secretu8 + '\0' + V0, function(W0) {
                        dump('V0: ' + szr.toHex(V0) + '\n');
                        dump('W0: ' + szr.toHex(W0) + '\n');
                        remote.read(address, index0, W0, new function() {
                            this.proceed = function(file0) {
                                dump('remote read ' + file0.length + '\n');
                                var result = decryptFile(file0, W0);
                                if (result != null) {
                                    index = index0;
                                    V = V0;
                                    file = file0;
                                    remoteFile = file0;
                                    secret = secret0;
                                    W = W0;
                                    petnames = result.petnames;
                                    usernames = result.usernames;
                                    initialized = true;
                                    awake = true;
                                    online = true;
                                    sync(false, true);
                                    cont.proceed('');
                                } else {
                                    cont.abort(SERVER_ERROR);
                                }
                            };
                            this.abort = function(error) {
                                if (error == LOGIN_FAILED) {
                                    dump('refused, try next\n');
                                    return readNext(i + 1);
                                } else {
                                    dump('other error ' + error + '\n');
                                    cont.abort(error);
                                }
                            };
                        });
                    });
                });
            }
            readNext(0);
        }, cont));
    };

    this.awaken = function(secret0, cont) {
        dump('persona awaken: ' + secret0 + '\n');
        if (!initialized) throw XPR.NS_ERROR_NOT_INITIALIZED;
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        var secretu8 = szr.toUTF8(secret0);
        backgroundHash(K2, secretu8 + '\0' + V, function(W0) {
            dump('V: ' + szr.toHex(V) + '\n');
            dump('W0: ' + szr.toHex(W0) + '\n');
            var result = decryptFile(file, W0);
            if (result != null) {
                secret = secret0;
                W = W0;
                petnames = result.petnames;
                usernames = result.usernames;
                awake = true;
            } else {
                cont.abort(LOGIN_FAILED);
                return;
            }
            remote.read(address, index, W0, new Cont('awaken1',
                function(file0) {
                    dump('remote read ' + file0.length + '\n');
                    if (file0 != remoteFile) {
                        dump('three-way merge needed!\n');
                        // TODO: merge delta(remoteFile -> file0) into file.
                        // (For now: assume local file is more recent.)
                        remoteFile = file0;
                        // Merging will change file, so re-encrypt file.
                        sync(true, false);
                    } else if (remoteFile != file) sync(false, false);
                    online = true;
                    cont.proceed('');
                },
                function(error) {
                    // We're already awake, just not online.
                    cont.proceed(error);
                }
            ));
        });
    };

    this.sleep = function() {
        dump('persona sleep\n');
        secret = '';
        W = '';
        awake = false;
        online = false;
        petnames = {};
        usernames = {};
    }

    // Save the current state of this persona to the local profile.
    function save() {
        dump('persona save\n');
        var szr = XPO('@passpet.org/serializer;1', XPI.IPasspetSerializer);
        szr.set('address', address);
        szr.set('name', szr.toUTF8(name));
        szr.set('iconName', szr.toUTF8(iconName));
        szr.set('icon', icon);
        szr.set('width', width + '');
        if (initialized) {
            szr.set('index', index + '');
            szr.set('V', szr.toHex(V));
            szr.set('file', szr.toHex(file));
            if (remoteFile != file) {
                szr.set('remoteFile', szr.toHex(remoteFile));
            }
        }
        folder.write(pid + '.pp', szr.serialize());
    };

    // Given the last known remote file and the current file, update
    // the remote copy to contain changes in the current file.
    function upload(oldFile, newFile, cont) {
        dump('persona upload\n');
        if (!initialized || !awake) throw XPR.NS_ERROR_NOT_INITIALIZED;
        var oldMAC = oldFile.substring(oldFile.length - MAC_LENGTH);
        remote.write(address, index, W, oldMAC, newFile, new Cont('upload1',
            function(result) {
                remoteFile = newFile;
                dump('remote wrote ' + newFile.length + '\n');
                cont.proceed('');
            },
            function(error) {
                if (error == this.WRITE_REFUSED) {
                    remote.read(address, index, W, new Cont('upload2',
                        function(file0) {
                            dump('remote read ' + file0.length + '\n');
                            if (file0 != remoteFile) {
                                dump('three-way merge needed!\n');
                                // TODO: merge delta(remoteFile -> file0) into file.
                                // (For now: assume local file is more recent.)
                                remoteFile = file0;
                                // Merging will change file, so re-encrypt file.
                                sync(true, false);
                            }
                        },
                        function(error) {
                            dump('upload retry failed: ' + error + '\n');
                            cont.abort(error);
                        }
                    ));
                } else {
                    dump('upload failed: ' + error + '\n');
                    cont.abort(error);
                }
            }
        ));
    }

    function updateRemote() {
        dump('updateRemote: remoteFile ' + remoteFile.length +
             ', file ' + file.length + '\n');
        if (remoteFile != file) {
            lastUploadTime = (new Date()).getTime();
            upload(remoteFile, file, new Cont('update1',
                function(result) {
                    // Record the updated remoteFile.
                    save();
                },
                function(error) {
                    // Uh oh. Try again? TODO
                    dump('upload failed, rescheduling\n');
                    later(UPLOAD_DELAY, updateRemote);
                }
            ));
        }
    }

    // Set 'encrypt' true if the petnames/usernames data has changed.
    // Set 'immediate' true to save immediately; otherwise schedule for later.
    function sync(encrypt, immediate) {
        dump('sync ' + immediate + '\n');
        if (encrypt) file = encryptFile(petnames, usernames);
        if (immediate) {
            save();
            if (initialized && awake && online && remoteFile != file) {
                // If we haven't uploaded in a while, do it right away;
                // otherwise schedule an upload for later.
                var now = (new Date()).getTime();
                var nextUpload = lastUploadTime + UPLOAD_DELAY;
                if (now >= nextUpload) {
                    dump('uploading now\n');
                    updateRemote();
                } else {
                    dump('uploading later ' + (nextUpload - now) + '\n');
                    later(nextUpload - now, updateRemote);
                }
            }
        } else {
            later(0, function() { sync(false, true); });
        }
    }

    this.findPetname = function(petname) {
        for (var siteid in petnames) {
            if (petnames[siteid] == petname) return siteid;
        }
    };

    this.getPetname = function(siteid) {
        var petname = (siteid in petnames) ? petnames[siteid] : '';
        return siteid in petnames ? petnames[siteid] : '';
    };

    this.setPetname = function(siteid, petname) {
        petnames[siteid] = petname;
        sync(true, false);
    };

    this.getUsername = function(siteid) {
        return siteid in usernames ? usernames[siteid] : '';
    };

    this.setUsername = function(siteid, username) {
        usernames[siteid] = username;
        sync(true, false);
    };

    function isAcceptablePassword(password) {
        var p = password.substring(0, PASSWORD_MIN);
        return p.match(/[0-9]/) && p.match(/[A-Z]/) && p.match(/[a-z]/);
    };

    this.getPassword = function(siteid) {
        dump('getPassword: ' + siteid + ' ');
        if (!(siteid in petnames)) return '';
        hasher.hash(K2, petnames[siteid] + '\0' + secret + '\0' + V);
        while (true) {
            var password = strToBase62(hasher.data);
            password = password.substr(0, PASSWORD_MAX);
            dump('-> ' + password + '\n');
            if (isAcceptablePassword(password)) return password;
            hasher.hash(1, hasher.data);
        }
    };

    this.removeSite = function(siteid) {
        delete petnames[siteid];
        delete usernames[siteid];
        sync(true, false);
    };

    // This method implements the nsISupports interface.
    this.QueryInterface = function(iid) {
        if (iid.equals(XPI.IPasspetPersona)) return this;
        if (iid.equals(XPI.nsISupports)) return this;
        throw XPR.NS_ERROR_NO_INTERFACE;
    };
};

