/*
 Copyright 2010 Xmarks Inc.

 history.jsm: Implements history sync.

 */


/*
 To do:

 * Do AsyncIterate
 * Clear data from server on disablement

*/

var Cc = Components.classes;
var Ci = Components.interfaces;
var Cu = Components.utils;

// Make sure that we've got JSON additions to fundamental objects
var loader = Cc["@mozilla.org/moz/jssubscript-loader;1"].
    getService(Ci.mozIJSSubScriptLoader);
loader.loadSubScript("chrome://foxmarks/content/foxmarks-json.js", null);
loader.loadSubScript("chrome://foxmarks/content/foxmarks-utils.js", null);

/*
*/


var EXPORTED_SYMBOLS = ["Read", "Write", "Changed", "IsDirty"];

var clean = undefined;
var IDLE_SECONDS = 15 * 60;

function forEachAsync(obj, func, callback, self) {
    // Iterate asynchronously over elements of obj. For each, call
    // provided func(cb, value, key, obj). When func completes, it
    // must call callback with an object that contains a status member
    // indicating non-zero for failure.
    // When complete, calls callback, passing the last provided result.

    var it = Iterator(obj);
    if (self == undefined) self = this;

    var Next = function(result) {
        if (result === undefined) {
            result = { status: 0 };
        }
        if (result.status !== 0) {
            callback(result);
            return;
        }
        try {
            var [key, value] = it.next();
        } catch(e) {
            callback(result);
            return;
        }
        var args = [Next, value, key, obj];
        func.apply(self, args);
    };
    Next();
}

function Truncate(str, len) {
    return (!str || str.length <= len) ? str : 
        (str.substring(0, len - 1) + "\u2026");
}

function Changed() {
    // Called when something we're interested in (visits table)
    // has changed.
    clean = false;
}

function IsDirty() { return clean === false; }

var observer = {
    QueryInterface: function(aIID) {
        if (aIID.equals(Ci.nsISupports) ||
                aIID.equals(Ci.nsIInterfaceRequestor) ||
                aIID.equals(Ci.nsIObserver) ||
                aIID.equals(Ci.nsINavHistoryObserver)) {
            return this;
        }
        throw Components.results.NS_NOINTERFACE;
    },
    observe: function(s, t, d) { 
        init();
        if (t == 'idle' && Xmarks.gSettings.historySyncOnIdle) {
            this.sync(t);
        } else if (t == "sleep_notification" && 
                Xmarks.gSettings.historySyncOnSleep) {
            this.sync(t);
        } else if (t == "profile-after-change") {
            init();
        }
    },

    onVisit: function(uri, visitID, time, sessionID, referreringID, 
            transitionType, added) {
        if (!isSyncing && 
                transitionType != Ci.nsINavHistoryService.TRANSITION_EMBED) {
            Changed();
        }
    },

    onBeforeDeleteURI: function() {},
    onDeleteURI: function() {},
    onClearHistory: function() {},
    onBeginUpdateBatch: function() {},
    onEndUpdateBatch: function() {},
    onPageChanged: function() {},
    onPageExpired: function() {},
    onTitleChanged: function() {},

    sync: function(t) {
        if (!Xmarks.gSettings.isSyncEnabled('history') || !IsDirty()) {
            return;
        }
        Xmarks.Notify(
            { 
                status: 1, msg: 
                Xmarks.Bundle().GetStringFromName('dialog.status.working') 
            });
        Write(function(status) {
            if (!status) {
                Xmarks.Notify(
                    { 
                        status: 0, 
                        msg: Xmarks.Bundle().
                            GetStringFromName('msg.synccompleted') 
                    });
            } else {
                Xmarks.Notify(
                    { 
                        status: status,
                        msg: Xmarks.MapErrorMessage(status)
                    });
            }
        }, false);
    }
};

Cc["@mozilla.org/observer-service;1"].getService(Ci.nsIObserverService).
    addObserver(observer, "profile-after-change", false);

function getPlacesDbConn() {
    return Cc['@mozilla.org/browser/nav-history-service;1'].
      getService(Ci.nsPIPlacesDatabase).DBConnection;
}

var urlstmt, visitstmt, visitsFromUrl, hsvc;

function init() {
    Xmarks = {};
    Cu.import("resource://xmarks/network.jsm", Xmarks); // import Request
    Cu.import("resource://xmarks/settings.jsm", Xmarks); // import gSettings et al
    var os = Cc["@mozilla.org/observer-service;1"].
        getService(Ci.nsIObserverService);
    if (Xmarks.gSettings.historySyncOnSleep) {
        os.addObserver(observer, "sleep_notification", false);
    }

    if (Xmarks.gSettings.historySyncOnIdle) {
        var is = Cc["@mozilla.org/widget/idleservice;1"].
            getService(Ci.nsIIdleService);
        is.addIdleObserver(observer, IDLE_SECONDS);
    }

    hsvc = Cc["@mozilla.org/browser/nav-history-service;1"].
        getService(Ci.nsINavHistoryService);

    hsvc.addObserver(observer, false);

    var db = getPlacesDbConn();

    var urlsql = 
        "SELECT id, url, title, frecency FROM moz_places_temp " +
        "UNION ALL " +
        "SELECT id, url, title, frecency FROM moz_places " +
        "ORDER BY frecency DESC LIMIT :limit";
    urlstmt = db.createStatement(urlsql);
    var visitsql = "SELECT visit_type type, visit_date date " +
        "FROM moz_historyvisits_temp " +
        "WHERE place_id = :id " +
        "UNION ALL " +
        "SELECT visit_type type, visit_date date " +
        "FROM moz_historyvisits " +
        "WHERE place_id = :id " +
        "ORDER BY visit_date DESC LIMIT 10";
    visitstmt = db.createStatement(visitsql);
    visitsFromUrl = 
        db.createStatement(
                "SELECT visit_type type, visit_date date " +
                "FROM moz_historyvisits_temp " +
                "WHERE place_id = IFNULL(" +
                "(SELECT id from moz_places_temp WHERE url = :id), " +
                "(SELECT id from moz_places WHERE url = :id)) " +
                "UNION ALL " +
                "SELECT visit_type type, visit_date date " +
                "FROM moz_historyvisits_temp " +
                "WHERE place_id = IFNULL(" +
                "(SELECT id from moz_places_temp WHERE url = :id), " +
                "(SELECT id from moz_places WHERE url = :id)) " +
                "ORDER BY visit_date DESC LIMIT 10");
    init = function() {};
}

const MAX_TITLE_LEN = 80;

function getFrecentUrls(callback) {
    var urls = [];
    urlstmt.params.limit = Xmarks.gSettings.historyUrlLimit;
    urlstmt.executeAsync({
        handleResult: function(result) {
            var row;
            while ((row = result.getNextRow())) {
                urls.push({
                    id: row.getResultByName("id"),
                    u: row.getResultByName("url"),
                    t: Truncate(row.getResultByName("title"), MAX_TITLE_LEN)
                });
            }
        },
        handleError: function(error) {
            Xmarks.LogWrite("Error fetching history urls: " + error.toSource());
            callback({ status: 3 });
        },
        handleCompletion: function(reason) {
            if (reason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
                callback({ status: 0, urls: urls });
            } else {
                Xmarks.LogWrite("Aborted fetch of history urls: " +
                        reason.toSource());
                callback({ status: 3 });
            }
        }
    });
}


// We represent time as seconds since 1/1/2010.
const EPOCH = Math.round(new Date("January 1, 2010 UTC").getTime() / 1000);

function VisitsToString(visits) {
    var a = [];
    while (visits.length) {
        v = visits.pop();
        a.push(String(v.type)[0] + (Math.round(v.date / 1000000 - EPOCH).
                toString(36)));
    }
    return a.join(",");
}

function StringToVisits(str, epoch) {
    str = str.split(",");
    visits = [];
    if (epoch == undefined) epoch = EPOCH;

    while (str.length) {
        s = str.pop();
        visits.push({
            type: parseInt(s[0], 36),
            date: (parseInt(s.substring(1), 36) + epoch) * 1000000
        });
    }
    return visits;
}

function NewURI(str) {
    var uri = null;
    try {
        uri = Cc["@mozilla.org/network/io-service;1"].
            getService(Ci.nsIIOService).
            newURI(str, "UTF-8", null);
    } catch(e) {}
    if (!uri) {
        Xmarks.LogWrite("Failed to produce uri for " + str);
    }
    return uri;
}

function getVisits(id, localOnly, callback) {
    // id may be either a moz_places id or a url.
    var visits = [];

    var stmt = (typeof(id) == 'number') ? visitstmt : visitsFromUrl;
    stmt.params.id = id;
    stmt.executeAsync( {
        handleResult: function(result) {
            var row;
            while ((row = result.getNextRow())) {
                if (localOnly && row.getResultByName("date") % 1000000 === 0)
                    continue;
                visits.push({
                    date: row.getResultByName("date"),
                    type: row.getResultByName("type")
                });
            }
        },
        handleError: function(error) {
            Xmarks.LogWrite("Error fetching history visit for " + id +
                    ": " + error.toSource());
            callback({ status: 3 });
        },
        handleCompletion: function(reason) {
            if (reason == Ci.mozIStorageStatementCallback.REASON_FINISHED) {
                callback({ status: 0, visits: visits });
            } else {
                Xmarks.LogWrite("Aborted fetch of history urls: " +
                        reason.toSource());
                callback({ status: 3 });
            }
        }
    });
}

function FetchHistory(callback) {
   init();
   getFrecentUrls(function(response) {
        if (response.status !== 0) {
            callback(response);
            return;
        }
        forEachAsync(response.urls, function(cb, entry, index, obj) {
            getVisits(entry.id, true, function(result) {
                if (result.status !== 0) {
                    cb(result);
                    return;
                }
                if (!result.visits || !result.visits.length) {
                    // No visits for this url.
                    // Delete it later.
                    // response.urls.splice(index, 1);
                } else {
                    delete entry.id;
                    entry.v = VisitsToString(result.visits);
                }
                cb({ status: 0, urls: obj });
            });
        }, function(result) {
            if (result.status !== 0) {
                callback(result);
                return;
            } 
            callback({ status: 0, 
                    urls: result.urls.filter(function(v) v.v != undefined) });
            return;
        });
    });
}


function UpdateHistory() {
}

var isSyncing = false;
function Write(callback, manual) {
    init();
    if (clean || Xmarks.gSettings.privateBrowsing || isSyncing) {
        if (callback) callback(0);
        return;
    }

    isSyncing = true;

    // Gather current history state and write it to the server.
    FetchHistory(function(result) {
        if (result.status !== 0) {
            callback(result.status);
            return;
        }
        var req = new Xmarks.Request("POST", 
            { 
                host: Xmarks.gSettings.cloudHost, 
                path: "/cloud/history/write" 
            },
            { 
                mymid: Xmarks.gSettings.machineId, 
                data: {
                    clientName: Xmarks.gSettings.clientName, 
                    browserName: "firefox",
                    visits: result.urls,
                    epoch: EPOCH
                }
            },
            {
                noauthdialog: !manual
            } );
        req.Start(function(resp) {
            isSyncing = false;
            if (resp.status === 0) {
                clean = true;
            }
            if (result.urls.length && resp.len && resp.maxlen) {
                Xmarks.gSettings.historyUrlLimit = 
                    Math.floor(resp.maxlen / (resp.len / result.urls.length));
            }
            if (callback) {
                callback(resp.status);
            }
        });
    });
}

function ProcessVisits(entry, epoch, callback) {
    // For this entry, add each associated visit that doesn't already
    // exist in history.
    getVisits(entry.u, false, function(result) {
        if (result.status !== 0) {
            callback(result);
            return;
        }
        var visits = StringToVisits(entry.v, epoch);
        var uri = NewURI(entry.u);
        var added = false;
        forEach(visits, function(v) {
            if (result.visits.every(function(r) { 
                    return Math.round(r.date / 1000000) != v.date / 1000000;
                })) {
                var redir = (v.type == Ci.TRANSITION_REDIRECT_PERMANENT ||
                    v.type == Ci.TRANSITION_REDIRECT_TEMPORARY);
                hsvc.addVisit(uri, v.date, null, v.type, redir, 0);
                if (entry.t && !hsvc.getPageTitle(uri))
                    hsvc.setPageTitle(uri, entry.t);
                added = true;
            }
        });
        if (entry.t && added) {
            hsvc.setPageTitle(uri, entry.t);
        }
        callback({status: 0});
    });
}

function ProcessClientData(mid, client, callback) {
    forEachAsync(client.visits, function(cb, visit) {
        ProcessVisits(visit, client.epoch, cb);
    }, callback);
}

function ProcessClients(mids, callback) {
    forEachAsync(mids, function(cb, data, mid) {
        ProcessClientData(mid, data, cb);
    }, callback);
}
function Read(callback) {
    init();
    isSyncing = true;
    var req = new Xmarks.Request("POST", 
        { 
            host: Xmarks.gSettings.cloudHost, 
            path: "/cloud/history/read" 
        },
        {   
            mymid: Xmarks.gSettings.machineId,
            timestamp: Xmarks.gSettings.historyTimestamp,
            excludeme: true
        }  );
    req.Start(function(resp) {
        if (resp.status || !resp.mids) {
            isSyncing = false;
            callback(resp.status || 500);
            return;
        }
        ProcessClients(resp.mids, function(result) {
            isSyncing = false;
            callback(result.status);
            if (result.status == 0 && resp.timestamp != undefined) {
                Xmarks.gSettings.historyTimestamp = resp.timestamp;
            }
        });
    });
}

function Delete(callback) {
    var req = new Xmarks.Request("POST", 
        { 
            host: Xmarks.gSettings.cloudHost, 
            path: "/cloud/history/delete" 
        },
        { mymid: Xmarks.gSettings.machineId }  );
    req.Start(function(resp) {
        Xmarks.LogWrite("History data deleted with status " + resp.status);
        callback(resp.status);
    });
}
