// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-

/* 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/. */

/*
 * To keep the global namespace safe, don't define global variables and
 * functions in this file.
 *
 * This file silently depends on contentAreaUtils.js for
 * getDefaultFileName, getNormalizedLeafName and getDefaultExtension
 */

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "ViewSourceBrowser",
  "resource://gre/modules/ViewSourceBrowser.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Deprecated",
  "resource://gre/modules/Deprecated.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
  "resource://gre/modules/PrivateBrowsingUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Services",
  "resource://gre/modules/Services.jsm");

var gViewSourceUtils = {

  mnsIWebBrowserPersist: Components.interfaces.nsIWebBrowserPersist,
  mnsIWebProgress: Components.interfaces.nsIWebProgress,
  mnsIWebPageDescriptor: Components.interfaces.nsIWebPageDescriptor,

  /**
   * Opens the view source window.
   *
   * @param aArgsOrURL (required)
   *        This is either an Object containing parameters, or a string
   *        URL for the page we want to view the source of. In the latter
   *        case we will be paying attention to the other parameters, as
   *        we will be supporting the old API for this method.
   *        If aArgsOrURL is an Object, the other parameters will be ignored.
   *        aArgsOrURL as an Object can include the following properties:
   *
   *        URL (required):
   *          A string URL for the page we'd like to view the source of.
   *        browser (optional):
   *          The browser containing the document that we would like to view the
   *          source of. This is required if outerWindowID is passed.
   *        outerWindowID (optional):
   *          The outerWindowID of the content window containing the document that
   *          we want to view the source of. Pass this if you want to attempt to
   *          load the document source out of the network cache.
   *        lineNumber (optional):
   *          The line number to focus on once the source is loaded.
   *
   * @param aPageDescriptor (deprecated, optional)
   *        Accepted for compatibility reasons, but is otherwise ignored.
   * @param aDocument (deprecated, optional)
   *        The content document we would like to view the source of. This
   *        function will throw if aDocument is a CPOW.
   * @param aLineNumber (deprecated, optional)
   *        The line number to focus on once the source is loaded.
   */
  viewSource(aArgsOrURL, aPageDescriptor, aDocument, aLineNumber) {
    if (Services.prefs.getBoolPref("view_source.editor.external")) {
      this.openInExternalEditor(aArgsOrURL, aPageDescriptor, aDocument, aLineNumber);
    } else {
      this._openInInternalViewer(aArgsOrURL, aPageDescriptor, aDocument, aLineNumber);
    }
  },

  /**
   * Displays view source in the provided <browser>.  This allows for non-window
   * display methods, such as a tab from Firefox.
   *
   * @param aArgs
   *        An object with the following properties:
   *
   *        URL (required):
   *          A string URL for the page we'd like to view the source of.
   *        viewSourceBrowser (required):
   *          The browser to display the view source in.
   *        browser (optional):
   *          The browser containing the document that we would like to view the
   *          source of. This is required if outerWindowID is passed.
   *        outerWindowID (optional):
   *          The outerWindowID of the content window containing the document that
   *          we want to view the source of. Pass this if you want to attempt to
   *          load the document source out of the network cache.
   *        lineNumber (optional):
   *          The line number to focus on once the source is loaded.
   */
  viewSourceInBrowser(aArgs) {
    Services.telemetry
            .getHistogramById("VIEW_SOURCE_IN_BROWSER_OPENED_BOOLEAN")
            .add(true);
    let viewSourceBrowser = new ViewSourceBrowser(aArgs.viewSourceBrowser);
    viewSourceBrowser.loadViewSource(aArgs);
  },

  /**
   * Displays view source for a selection from some document in the provided
   * <browser>.  This allows for non-window display methods, such as a tab from
   * Firefox.
   *
   * @param aViewSourceInBrowser
   *        The browser containing the page to view the source of.
   * @param aTarget
   *        Set to the target node for MathML. Null for other types of elements.
   * @param aGetBrowserFn
   *        If set, a function that will return a browser to open the source in.
   *        If null, or this function returns null, opens the source in a new window.
   */
  viewPartialSourceInBrowser(aViewSourceInBrowser, aTarget, aGetBrowserFn) {
    let mm = aViewSourceInBrowser.messageManager;
    mm.addMessageListener("ViewSource:GetSelectionDone", function gotSelection(message) {
      mm.removeMessageListener("ViewSource:GetSelectionDone", gotSelection);

      if (!message.data)
        return;

      let browserToOpenIn = aGetBrowserFn ? aGetBrowserFn() : null;
      if (browserToOpenIn) {
        let viewSourceBrowser = new ViewSourceBrowser(browserToOpenIn);
        viewSourceBrowser.loadViewSourceFromSelection(message.data.uri, message.data.drawSelection,
                                                      message.data.baseURI);
      } else {
        window.openDialog("chrome://global/content/viewPartialSource.xul",
                          "_blank", "all,dialog=no",
                          {
                            URI: message.data.uri,
                            drawSelection: message.data.drawSelection,
                            baseURI: message.data.baseURI,
                            partial: true,
                          });
      }
    });

    mm.sendAsyncMessage("ViewSource:GetSelection", { }, { target: aTarget });
  },

  // Opens the interval view source viewer
  _openInInternalViewer(aArgsOrURL, aPageDescriptor, aDocument, aLineNumber) {
    // try to open a view-source window while inheriting the charset (if any)
    var charset = null;
    var isForcedCharset = false;
    if (aDocument) {
      if (Components.utils.isCrossProcessWrapper(aDocument)) {
        throw new Error("View Source cannot accept a CPOW as a document.");
      }

      charset = "charset=" + aDocument.characterSet;
      try {
        isForcedCharset =
          aDocument.defaultView
                   .QueryInterface(Components.interfaces.nsIInterfaceRequestor)
                   .getInterface(Components.interfaces.nsIDOMWindowUtils)
                   .docCharsetIsForced;
      } catch (ex) {
      }
    }
    Services.telemetry
            .getHistogramById("VIEW_SOURCE_IN_WINDOW_OPENED_BOOLEAN")
            .add(true);
    openDialog("chrome://global/content/viewSource.xul",
               "_blank",
               "all,dialog=no",
               aArgsOrURL, charset, aPageDescriptor, aLineNumber, isForcedCharset);
  },

  buildEditorArgs(aPath, aLineNumber) {
    // Determine the command line arguments to pass to the editor.
    // We currently support a %LINE% placeholder which is set to the passed
    // line number (or to 0 if there's none)
    var editorArgs = [];
    var args = Services.prefs.getCharPref("view_source.editor.args");
    if (args) {
      args = args.replace("%LINE%", aLineNumber || "0");
      // add the arguments to the array (keeping quoted strings intact)
      const argumentRE = /"([^"]+)"|(\S+)/g;
      while (argumentRE.test(args))
        editorArgs.push(RegExp.$1 || RegExp.$2);
    }
    editorArgs.push(aPath);
    return editorArgs;
  },

  /**
   * Opens an external editor with the view source content.
   *
   * @param aArgsOrURL (required)
   *        This is either an Object containing parameters, or a string
   *        URL for the page we want to view the source of. In the latter
   *        case we will be paying attention to the other parameters, as
   *        we will be supporting the old API for this method.
   *        If aArgsOrURL is an Object, the other parameters will be ignored.
   *        aArgsOrURL as an Object can include the following properties:
   *
   *        URL (required):
   *          A string URL for the page we'd like to view the source of.
   *        browser (optional):
   *          The browser containing the document that we would like to view the
   *          source of. This is required if outerWindowID is passed.
   *        outerWindowID (optional):
   *          The outerWindowID of the content window containing the document that
   *          we want to view the source of. Pass this if you want to attempt to
   *          load the document source out of the network cache.
   *        lineNumber (optional):
   *          The line number to focus on once the source is loaded.
   *
   * @param aPageDescriptor (deprecated, optional)
   *        Accepted for compatibility reasons, but is otherwise ignored.
   * @param aDocument (deprecated, optional)
   *        The content document we would like to view the source of. This
   *        function will throw if aDocument is a CPOW.
   * @param aLineNumber (deprecated, optional)
   *        The line number to focus on once the source is loaded.
   * @param aCallBack
   *        A function accepting two arguments:
   *          * result (true = success)
   *          * data object
   *        The function defaults to opening an internal viewer if external
   *        viewing fails.
   */
  openInExternalEditor(aArgsOrURL, aPageDescriptor, aDocument,
                                 aLineNumber, aCallBack) {
    let data;
    if (typeof aArgsOrURL == "string") {
      Deprecated.warning("The arguments you're passing to " +
                         "openInExternalEditor are using an out-of-date API.",
                         "https://developer.mozilla.org/en-US/Add-ons/" +
                         "Code_snippets/View_Source_for_XUL_Applications");
      if (Components.utils.isCrossProcessWrapper(aDocument)) {
        throw new Error("View Source cannot accept a CPOW as a document.");
      }
      data = {
        url: aArgsOrURL,
        pageDescriptor: aPageDescriptor,
        doc: aDocument,
        lineNumber: aLineNumber,
        isPrivate: false,
      };
      if (aDocument) {
          data.isPrivate =
            PrivateBrowsingUtils.isWindowPrivate(aDocument.defaultView);
      }
    } else {
      let { URL, browser, lineNumber } = aArgsOrURL;
      data = {
        url: URL,
        lineNumber,
        isPrivate: false,
      };
      if (browser) {
        data.doc = {
          characterSet: browser.characterSet,
          contentType: browser.documentContentType,
          title: browser.contentTitle,
        };
        data.isPrivate = PrivateBrowsingUtils.isBrowserPrivate(browser);
      }
    }

    try {
      var editor = this.getExternalViewSourceEditor();
      if (!editor) {
        this.handleCallBack(aCallBack, false, data);
        return;
      }

      // make a uri
      var charset = data.doc ? data.doc.characterSet : null;
      var uri = Services.io.newURI(data.url, charset);
      data.uri = uri;

      var path;
      var contentType = data.doc ? data.doc.contentType : null;
      if (uri.scheme == "file") {
        // it's a local file; we can open it directly
        path = uri.QueryInterface(Components.interfaces.nsIFileURL).file.path;

        var editorArgs = this.buildEditorArgs(path, data.lineNumber);
        editor.runw(false, editorArgs, editorArgs.length);
        this.handleCallBack(aCallBack, true, data);
      } else {
        // set up the progress listener with what we know so far
        this.viewSourceProgressListener.contentLoaded = false;
        this.viewSourceProgressListener.editor = editor;
        this.viewSourceProgressListener.callBack = aCallBack;
        this.viewSourceProgressListener.data = data;
        if (!data.pageDescriptor) {
          // without a page descriptor, loadPage has no chance of working. download the file.
          var file = this.getTemporaryFile(uri, data.doc, contentType);
          this.viewSourceProgressListener.file = file;

          var webBrowserPersist = Components
                                  .classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
                                  .createInstance(this.mnsIWebBrowserPersist);
          // the default setting is to not decode. we need to decode.
          webBrowserPersist.persistFlags = this.mnsIWebBrowserPersist.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
          webBrowserPersist.progressListener = this.viewSourceProgressListener;
          let referrerPolicy = Components.interfaces.nsIHttpChannel.REFERRER_POLICY_NO_REFERRER;
          webBrowserPersist.savePrivacyAwareURI(uri, null, null, referrerPolicy, null, null, file, data.isPrivate);

          let helperService = Components.classes["@mozilla.org/uriloader/external-helper-app-service;1"]
                                        .getService(Components.interfaces.nsPIExternalAppLauncher);
          if (data.isPrivate) {
            // register the file to be deleted when possible
            helperService.deleteTemporaryPrivateFileWhenPossible(file);
          } else {
            // register the file to be deleted on app exit
            helperService.deleteTemporaryFileOnExit(file);
          }
        } else {
          // we'll use nsIWebPageDescriptor to get the source because it may
          // not have to refetch the file from the server
          // XXXbz this is so broken...  This code doesn't set up this docshell
          // at all correctly; if somehow the view-source stuff managed to
          // execute script we'd be in big trouble here, I suspect.
          var webShell = Components.classes["@mozilla.org/docshell;1"].createInstance();
          webShell.QueryInterface(Components.interfaces.nsIBaseWindow).create();
          this.viewSourceProgressListener.webShell = webShell;
          var progress = webShell.QueryInterface(this.mnsIWebProgress);
          progress.addProgressListener(this.viewSourceProgressListener,
                                       this.mnsIWebProgress.NOTIFY_STATE_DOCUMENT);
          var pageLoader = webShell.QueryInterface(this.mnsIWebPageDescriptor);
          pageLoader.loadPage(data.pageDescriptor, this.mnsIWebPageDescriptor.DISPLAY_AS_SOURCE);
        }
      }
    } catch (ex) {
      // we failed loading it with the external editor.
      Components.utils.reportError(ex);
      this.handleCallBack(aCallBack, false, data);
    }
  },

  // Default callback - opens the internal viewer if the external editor failed
  internalViewerFallback(result, data) {
    if (!result) {
      this._openInInternalViewer(data.url, data.pageDescriptor, data.doc, data.lineNumber);
    }
  },

  // Calls the callback, keeping in mind undefined or null values.
  handleCallBack(aCallBack, result, data) {
    Services.telemetry
            .getHistogramById("VIEW_SOURCE_EXTERNAL_RESULT_BOOLEAN")
            .add(result);
    // if callback is undefined, default to the internal viewer
    if (aCallBack === undefined) {
      this.internalViewerFallback(result, data);
    } else if (aCallBack) {
      aCallBack(result, data);
    }
  },

  // Returns nsIProcess of the external view source editor or null
  getExternalViewSourceEditor() {
    try {
      let viewSourceAppPath =
        Services.prefs.getComplexValue("view_source.editor.path",
                                       Components.interfaces.nsIFile);
      let editor = Components.classes["@mozilla.org/process/util;1"]
                             .createInstance(Components.interfaces.nsIProcess);
      editor.init(viewSourceAppPath);

      return editor;
    } catch (ex) {
      Components.utils.reportError(ex);
    }

    return null;
  },

  viewSourceProgressListener: {

    mnsIWebProgressListener: Components.interfaces.nsIWebProgressListener,

    QueryInterface(aIID) {
     if (aIID.equals(this.mnsIWebProgressListener) ||
         aIID.equals(Components.interfaces.nsISupportsWeakReference) ||
         aIID.equals(Components.interfaces.nsISupports))
       return this;
     throw Components.results.NS_NOINTERFACE;
    },

    destroy() {
      if (this.webShell) {
        this.webShell.QueryInterface(Components.interfaces.nsIBaseWindow).destroy();
      }
      this.webShell = null;
      this.editor = null;
      this.callBack = null;
      this.data = null;
      this.file = null;
    },

    // This listener is used both for tracking the progress of an HTML parse
    // in one case and for tracking the progress of nsIWebBrowserPersist in
    // another case.
    onStateChange(aProgress, aRequest, aFlag, aStatus) {
      // once it's done loading...
      if ((aFlag & this.mnsIWebProgressListener.STATE_STOP) && aStatus == 0) {
        if (!this.webShell) {
          // We aren't waiting for the parser. Instead, we are waiting for
          // an nsIWebBrowserPersist.
          this.onContentLoaded();
          return 0;
        }
        var webNavigation = this.webShell.QueryInterface(Components.interfaces.nsIWebNavigation);
        if (webNavigation.document.readyState == "complete") {
          // This branch is probably never taken. Including it for completeness.
          this.onContentLoaded();
        } else {
          webNavigation.document.addEventListener("DOMContentLoaded",
                                                  this.onContentLoaded.bind(this));
        }
      }
      return 0;
    },

    onContentLoaded() {
      // The progress listener may call this multiple times, so be sure we only
      // run once.
      if (this.contentLoaded) {
        return;
      }
      try {
        if (!this.file) {
          // it's not saved to file yet, it's in the webshell

          // get a temporary filename using the attributes from the data object that
          // openInExternalEditor gave us
          this.file = gViewSourceUtils.getTemporaryFile(this.data.uri, this.data.doc,
                                                        this.data.doc.contentType);

          // we have to convert from the source charset.
          var webNavigation = this.webShell.QueryInterface(Components.interfaces.nsIWebNavigation);
          var foStream = Components.classes["@mozilla.org/network/file-output-stream;1"]
                                   .createInstance(Components.interfaces.nsIFileOutputStream);
          foStream.init(this.file, 0x02 | 0x08 | 0x20, -1, 0); // write | create | truncate
          var coStream = Components.classes["@mozilla.org/intl/converter-output-stream;1"]
                                   .createInstance(Components.interfaces.nsIConverterOutputStream);
          coStream.init(foStream, this.data.doc.characterSet);

          // write the source to the file
          coStream.writeString(webNavigation.document.body.textContent);

          // clean up
          coStream.close();
          foStream.close();

          let helperService = Components.classes["@mozilla.org/uriloader/external-helper-app-service;1"]
                              .getService(Components.interfaces.nsPIExternalAppLauncher);
          if (this.data.isPrivate) {
            // register the file to be deleted when possible
            helperService.deleteTemporaryPrivateFileWhenPossible(this.file);
          } else {
            // register the file to be deleted on app exit
            helperService.deleteTemporaryFileOnExit(this.file);
          }
        }

        var editorArgs = gViewSourceUtils.buildEditorArgs(this.file.path,
                                                          this.data.lineNumber);
        this.editor.runw(false, editorArgs, editorArgs.length);

        this.contentLoaded = true;
        gViewSourceUtils.handleCallBack(this.callBack, true, this.data);
      } catch (ex) {
        // we failed loading it with the external editor.
        Components.utils.reportError(ex);
        gViewSourceUtils.handleCallBack(this.callBack, false, this.data);
      } finally {
        this.destroy();
      }
    },

    webShell: null,
    editor: null,
    callBack: null,
    data: null,
    file: null
  },

  // returns an nsIFile for the passed document in the system temp directory
  getTemporaryFile(aURI, aDocument, aContentType) {
    // include contentAreaUtils.js in our own context when we first need it
    if (!this._caUtils) {
      this._caUtils = {};
      Services.scriptloader.loadSubScript("chrome://global/content/contentAreaUtils.js", this._caUtils);
    }

    var tempFile = Services.dirsvc.get("TmpD", Components.interfaces.nsIFile);
    var fileName = this._caUtils.getDefaultFileName(null, aURI, aDocument, aContentType);
    var extension = this._caUtils.getDefaultExtension(fileName, aURI, aContentType);
    var leafName = this._caUtils.getNormalizedLeafName(fileName, extension);
    tempFile.append(leafName);
    return tempFile;
  }
};
