/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/**
 * validateManifest() warns of the following errors:
 *  - No manifest specified in page
 *  - Manifest is not utf-8
 *  - Manifest mimetype not text/cache-manifest
 *  - Manifest does not begin with "CACHE MANIFEST"
 *  - Page modified since appcache last changed
 *  - Duplicate entries
 *  - Conflicting entries e.g. in both CACHE and NETWORK sections or in cache
 *    but blocked by FALLBACK namespace
 *  - Detect referenced files that are not available
 *  - Detect referenced files that have cache-control set to no-store
 *  - Wildcards used in a section other than NETWORK
 *  - Spaces in URI not replaced with %20
 *  - Completely invalid URIs
 *  - Too many dot dot slash operators
 *  - SETTINGS section is valid
 *  - Invalid section name
 *  - etc.
 */

"use strict";

const { classes: Cc, interfaces: Ci, utils: Cu } = Components;

let { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {});
let { Services }   = Cu.import("resource://gre/modules/Services.jsm", {});
let { Promise }    = Cu.import("resource://gre/modules/commonjs/sdk/core/promise.js", {});

this.EXPORTED_SYMBOLS = ["AppCacheUtils"];

function AppCacheUtils(documentOrUri) {
  this._parseManifest = this._parseManifest.bind(this);

  if (documentOrUri) {
    if (typeof documentOrUri == "string") {
      this.uri = documentOrUri;
    }
    if (/HTMLDocument/.test(documentOrUri.toString())) {
      this.doc = documentOrUri;
    }
  }
}

AppCacheUtils.prototype = {
  get cachePath() {
    return "";
  },

  validateManifest: function ACU_validateManifest() {
    let deferred = Promise.defer();
    this.errors = [];
    // Check for missing manifest.
    this._getManifestURI().then(manifestURI => {
      this.manifestURI = manifestURI;

      if (!this.manifestURI) {
        this._addError(0, "noManifest");
        deferred.resolve(this.errors);
      }

      this._getURIInfo(this.manifestURI).then(uriInfo => {
        this._parseManifest(uriInfo).then(() => {
          // Sort errors by line number.
          this.errors.sort(function(a, b) {
            return a.line - b.line;
          });
          deferred.resolve(this.errors);
        });
      });
    });

    return deferred.promise;
  },

  _parseManifest: function ACU__parseManifest(uriInfo) {
    let deferred = Promise.defer();
    let manifestName = uriInfo.name;
    let manifestLastModified = new Date(uriInfo.responseHeaders["Last-Modified"]);

    if (uriInfo.charset.toLowerCase() != "utf-8") {
      this._addError(0, "notUTF8", uriInfo.charset);
    }

    if (uriInfo.mimeType != "text/cache-manifest") {
      this._addError(0, "badMimeType", uriInfo.mimeType);
    }

    let parser = new ManifestParser(uriInfo.text, this.manifestURI);
    let parsed = parser.parse();

    if (parsed.errors.length > 0) {
      this.errors.push.apply(this.errors, parsed.errors);
    }

    // Check for duplicate entries.
    let dupes = {};
    for (let parsedUri of parsed.uris) {
      dupes[parsedUri.uri] = dupes[parsedUri.uri] || [];
      dupes[parsedUri.uri].push({
        line: parsedUri.line,
        section: parsedUri.section,
        original: parsedUri.original
      });
    }
    for (let [uri, value] of Iterator(dupes)) {
      if (value.length > 1) {
        this._addError(0, "duplicateURI", uri, JSON.stringify(value));
      }
    }

    // Loop through network entries making sure that fallback and cache don't
    // contain uris starting with the network uri.
    for (let neturi of parsed.uris) {
      if (neturi.section == "NETWORK") {
        for (let parsedUri of parsed.uris) {
          if (parsedUri.uri.startsWith(neturi.uri)) {
            this._addError(neturi.line, "networkBlocksURI", neturi.line,
                           neturi.original, parsedUri.line, parsedUri.original,
                           parsedUri.section);
          }
        }
      }
    }

    // Loop through fallback entries making sure that fallback and cache don't
    // contain uris starting with the network uri.
    for (let fb of parsed.fallbacks) {
      for (let parsedUri of parsed.uris) {
        if (parsedUri.uri.startsWith(fb.namespace)) {
          this._addError(fb.line, "fallbackBlocksURI", fb.line,
                         fb.original, parsedUri.line, parsedUri.original,
                         parsedUri.section);
        }
      }
    }

    // Check that all resources exist and that their cach-control headers are
    // not set to no-store.
    let current = -1;
    for (let i = 0, len = parsed.uris.length; i < len; i++) {
      let parsedUri = parsed.uris[i];
      this._getURIInfo(parsedUri.uri).then(uriInfo => {
        current++;

        if (uriInfo.success) {
          // Check that the resource was not modified after the manifest was last
          // modified. If it was then the manifest file should be refreshed.
          let resourceLastModified =
            new Date(uriInfo.responseHeaders["Last-Modified"]);

          if (manifestLastModified < resourceLastModified) {
            this._addError(parsedUri.line, "fileChangedButNotManifest",
                           uriInfo.name, manifestName, parsedUri.line);
          }

          // If cache-control: no-store the file will not be added to the
          // appCache.
          if (uriInfo.nocache) {
            this._addError(parsedUri.line, "cacheControlNoStore",
                           parsedUri.original, parsedUri.line);
          }
        } else {
          this._addError(parsedUri.line, "notAvailable",
                         parsedUri.original, parsedUri.line);
        }

        if (current == len - 1) {
          deferred.resolve();
        }
      });
    }

    return deferred.promise;
  },

  _getURIInfo: function ACU__getURIInfo(uri) {
    let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
                        .createInstance(Ci.nsIScriptableInputStream);
    let deferred = Promise.defer();
    let channelCharset = "";
    let buffer = "";
    let channel = Services.io.newChannel(uri, null, null);

    // Avoid the cache:
    channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
    channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;

    channel.asyncOpen({
      onStartRequest: function (request, context) {
        // This empty method is needed in order for onDataAvailable to be
        // called.
      },

      onDataAvailable: function (request, context, stream, offset, count) {
        request.QueryInterface(Ci.nsIHttpChannel);
        inputStream.init(stream);
        buffer = buffer.concat(inputStream.read(count));
      },

      onStopRequest: function onStartRequest(request, context, statusCode) {
        if (statusCode == 0) {
          request.QueryInterface(Ci.nsIHttpChannel);

          let result = {
            name: request.name,
            success: request.requestSucceeded,
            status: request.responseStatus + " - " + request.responseStatusText,
            charset: request.contentCharset || "utf-8",
            mimeType: request.contentType,
            contentLength: request.contentLength,
            nocache: request.isNoCacheResponse() || request.isNoStoreResponse(),
            prePath: request.URI.prePath + "/",
            text: buffer
          };

          result.requestHeaders = {};
          request.visitRequestHeaders(function(header, value) {
            result.requestHeaders[header] = value;
          });

          result.responseHeaders = {};
          request.visitResponseHeaders(function(header, value) {
            result.responseHeaders[header] = value;
          });

          deferred.resolve(result);
        } else {
          deferred.resolve({
            name: request.name,
            success: false
          });
        }
      }
    }, null);
    return deferred.promise;
  },

  listEntries: function ACU_show(searchTerm) {
    if (!Services.prefs.getBoolPref("browser.cache.disk.enable")) {
      throw new Error(l10n.GetStringFromName("cacheDisabled"));
    }

    let entries = [];

    Services.cache.visitEntries({
      visitDevice: function(deviceID, deviceInfo) {
        return true;
      },

      visitEntry: function(deviceID, entryInfo) {
        if (entryInfo.deviceID == "offline") {
          let entry = {};
          let lowerKey = entryInfo.key.toLowerCase();

          if (searchTerm && lowerKey.indexOf(searchTerm.toLowerCase()) == -1) {
            return true;
          }

          for (let [key, value] of Iterator(entryInfo)) {
            if (key == "QueryInterface") {
              continue;
            }
            if (key == "clientID") {
              entry.key = entryInfo.key;
            }
            if (key == "expirationTime" || key == "lastFetched" || key == "lastModified") {
              value = new Date(value * 1000);
            }
            entry[key] = value;
          }
          entries.push(entry);
        }
        return true;
      }
    });

    if (entries.length == 0) {
      throw new Error(l10n.GetStringFromName("noResults"));
    }
    return entries;
  },

  viewEntry: function ACU_viewEntry(key) {
    let uri;

    Services.cache.visitEntries({
      visitDevice: function(deviceID, deviceInfo) {
        return true;
      },

      visitEntry: function(deviceID, entryInfo) {
        if (entryInfo.deviceID == "offline" && entryInfo.key == key) {
          uri = "about:cache-entry?client=" + entryInfo.clientID +
                "&sb=1&key=" + entryInfo.key;
          return false;
        }
        return true;
      }
    });

    if (uri) {
      let wm = Cc["@mozilla.org/appshell/window-mediator;1"]
                 .getService(Ci.nsIWindowMediator);
      let win = wm.getMostRecentWindow("navigator:browser");
      win.gBrowser.selectedTab = win.gBrowser.addTab(uri);
    } else {
      return l10n.GetStringFromName("entryNotFound");
    }
  },

  clearAll: function ACU_clearAll() {
    Services.cache.evictEntries(Ci.nsICache.STORE_OFFLINE);
  },

  _getManifestURI: function ACU__getManifestURI() {
    let deferred = Promise.defer();

    let getURI = node => {
      let htmlNode = this.doc.querySelector("html[manifest]");
      if (htmlNode) {
        let pageUri = this.doc.location ? this.doc.location.href : this.uri;
        let origin = pageUri.substr(0, pageUri.lastIndexOf("/") + 1);
        return origin + htmlNode.getAttribute("manifest");
      }
    };

    if (this.doc) {
      let uri = getURI(this.doc);
      return Promise.resolve(uri);
    } else {
      this._getURIInfo(this.uri).then(uriInfo => {
        if (uriInfo.success) {
          let html = uriInfo.text;
          let parser = _DOMParser;
          this.doc = parser.parseFromString(html, "text/html");
          let uri = getURI(this.doc);
          deferred.resolve(uri);
        } else {
          this.errors.push({
            line: 0,
            msg: l10n.GetStringFromName("invalidURI")
          });
        }
      });
    }
    return deferred.promise;
  },

  _addError: function ACU__addError(line, l10nString, ...params) {
    let msg;

    if (params) {
      msg = l10n.formatStringFromName(l10nString, params, params.length);
    } else {
      msg = l10n.GetStringFromName(l10nString);
    }

    this.errors.push({
      line: line,
      msg: msg
    });
  },
};

/**
 * We use our own custom parser because we need far more detailed information
 * than the system manifest parser provides.
 *
 * @param {String} manifestText
 *        The text content of the manifest file.
 * @param {String} manifestURI
 *        The URI of the manifest file. This is used in calculating the path of
 *        relative URIs.
 */
function ManifestParser(manifestText, manifestURI) {
  this.manifestText = manifestText;
  this.origin = manifestURI.substr(0, manifestURI.lastIndexOf("/") + 1)
                           .replace(" ", "%20");
}

ManifestParser.prototype = {
  parse: function OCIMP_parse() {
    let lines = this.manifestText.split(/\r?\n/);
    let fallbacks = this.fallbacks = [];
    let settings = this.settings = [];
    let errors = this.errors = [];
    let uris = this.uris = [];

    this.currSection = "CACHE";

    for (let i = 0; i < lines.length; i++) {
      let text = this.text = lines[i].replace(/^\s+|\s+$/g);
      this.currentLine = i + 1;

      if (i == 0 && text != "CACHE MANIFEST") {
        this._addError(1, "firstLineMustBeCacheManifest", 1);
      }

      // Ignore comments
      if (/^#/.test(text) || !text.length) {
        continue;
      }

      if (text == "CACHE MANIFEST") {
        if (this.currentLine != 1) {
          this._addError(this.currentLine, "cacheManifestOnlyFirstLine2",
                         this.currentLine);
        }
        continue;
      }

      if (this._maybeUpdateSectionName()) {
        continue;
      }

      switch (this.currSection) {
        case "CACHE":
        case "NETWORK":
          this.parseLine();
          break;
        case "FALLBACK":
          this.parseFallbackLine();
          break;
        case "SETTINGS":
          this.parseSettingsLine();
          break;
      }
    }

    return {
      uris: uris,
      fallbacks: fallbacks,
      settings: settings,
      errors: errors
    };
  },

  parseLine: function OCIMP_parseLine() {
    let text = this.text;

    if (text.indexOf("*") != -1) {
      if (this.currSection != "NETWORK" || text.length != 1) {
        this._addError(this.currentLine, "asteriskInWrongSection2",
                       this.currSection, this.currentLine);
        return;
      }
    }

    if (/\s/.test(text)) {
      this._addError(this.currentLine, "escapeSpaces", this.currentLine);
      text = text.replace(/\s/g, "%20")
    }

    if (text[0] == "/") {
      if (text.substr(0, 4) == "/../") {
        this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine);
      } else {
        this.uris.push(this._wrapURI(this.origin + text.substring(1)));
      }
    } else if (text.substr(0, 2) == "./") {
      this.uris.push(this._wrapURI(this.origin + text.substring(2)));
    } else if (text.substr(0, 4) == "http") {
      this.uris.push(this._wrapURI(text));
    } else {
      let origin = this.origin;
      let path = text;

      while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) {
        let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1;
        origin = origin.substr(0, trimIdx);
        path = path.substr(3);
      }

      if (path.substr(0, 3) == "../") {
        this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine);
        return;
      }

      if (/^https?:\/\//.test(path)) {
        this.uris.push(this._wrapURI(path));
        return;
      }
      this.uris.push(this._wrapURI(origin + path));
    }
  },

  parseFallbackLine: function OCIMP_parseFallbackLine() {
    let split = this.text.split(/\s+/);
    let origURI = this.text;

    if (split.length != 2) {
      this._addError(this.currentLine, "fallbackUseSpaces", this.currentLine);
      return;
    }

    let [ namespace, fallback ] = split;

    if (namespace.indexOf("*") != -1) {
      this._addError(this.currentLine, "fallbackAsterisk2", this.currentLine);
    }

    if (/\s/.test(namespace)) {
      this._addError(this.currentLine, "escapeSpaces", this.currentLine);
      namespace = namespace.replace(/\s/g, "%20")
    }

    if (namespace.substr(0, 4) == "/../") {
      this._addError(this.currentLine, "slashDotDotSlashBad", this.currentLine);
    }

    if (namespace.substr(0, 2) == "./") {
      namespace = this.origin + namespace.substring(2);
    }

    if (namespace.substr(0, 4) != "http") {
      let origin = this.origin;
      let path = namespace;

      while (path.substr(0, 3) == "../" && /^https?:\/\/.*?\/.*?\//.test(origin)) {
        let trimIdx = origin.substr(0, origin.length - 1).lastIndexOf("/") + 1;
        origin = origin.substr(0, trimIdx);
        path = path.substr(3);
      }

      if (path.substr(0, 3) == "../") {
        this._addError(this.currentLine, "tooManyDotDotSlashes", this.currentLine);
      }

      if (/^https?:\/\//.test(path)) {
        namespace = path;
      } else {
        if (path[0] == "/") {
          path = path.substring(1);
        }
        namespace = origin + path;
      }
    }

    this.text = fallback;
    this.parseLine();

    this.fallbacks.push({
      line: this.currentLine,
      original: origURI,
      namespace: namespace,
      fallback: fallback
    });
  },

  parseSettingsLine: function OCIMP_parseSettingsLine() {
    let text = this.text;

    if (this.settings.length == 1 || !/prefer-online|fast/.test(text)) {
      this._addError(this.currentLine, "settingsBadValue", this.currentLine);
      return;
    }

    switch (text) {
      case "prefer-online":
        this.settings.push(this._wrapURI(text));
        break;
      case "fast":
        this.settings.push(this._wrapURI(text));
        break;
    }
  },

  _wrapURI: function OCIMP__wrapURI(uri) {
    return {
      section: this.currSection,
      line: this.currentLine,
      uri: uri,
      original: this.text
    };
  },

  _addError: function OCIMP__addError(line, l10nString, ...params) {
    let msg;

    if (params) {
      msg = l10n.formatStringFromName(l10nString, params, params.length);
    } else {
      msg = l10n.GetStringFromName(l10nString);
    }

    this.errors.push({
      line: line,
      msg: msg
    });
  },

  _maybeUpdateSectionName: function OCIMP__maybeUpdateSectionName() {
    let text = this.text;

    if (text == text.toUpperCase() && text.charAt(text.length - 1) == ":") {
      text = text.substr(0, text.length - 1);

      switch (text) {
        case "CACHE":
        case "NETWORK":
        case "FALLBACK":
        case "SETTINGS":
          this.currSection = text;
          return true;
        default:
          this._addError(this.currentLine,
                         "invalidSectionName", text, this.currentLine);
          return false;
      }
    }
  },
};

XPCOMUtils.defineLazyGetter(this, "l10n", function() Services.strings
  .createBundle("chrome://browser/locale/devtools/appcacheutils.properties"));

XPCOMUtils.defineLazyGetter(this, "appcacheservice", function() {
  return Cc["@mozilla.org/network/application-cache-service;1"]
           .getService(Ci.nsIApplicationCacheService);

});

XPCOMUtils.defineLazyGetter(this, "_DOMParser", function() {
  return Cc["@mozilla.org/xmlextras/domparser;1"].createInstance(Ci.nsIDOMParser);
});
