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

import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.sys.mjs",
  E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
  LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.sys.mjs",
});

XPCOMUtils.defineLazyGetter(lazy, "ReferrerInfo", () =>
  Components.Constructor(
    "@mozilla.org/referrer-info;1",
    "nsIReferrerInfo",
    "init"
  )
);

// Filter out request headers as per discussion in Bug #1567549
// CONNECTION: Used by Gecko to manage connections
// HOST: Relates to how gecko will ultimately interpret the resulting resource as that
//       determines the effective request URI
const BAD_HEADERS = ["connection", "host"];

// Headers use |\r\n| as separator so these characters cannot appear
// in the header name or value
const FORBIDDEN_HEADER_CHARACTERS = ["\n", "\r"];

// Keep in sync with GeckoSession.java
const HEADER_FILTER_CORS_SAFELISTED = 1;
// eslint-disable-next-line no-unused-vars
const HEADER_FILTER_UNRESTRICTED_UNSAFE = 2;

// Create default ReferrerInfo instance for the given referrer URI string.
const createReferrerInfo = aReferrer => {
  let referrerUri;
  try {
    referrerUri = Services.io.newURI(aReferrer);
  } catch (ignored) {}

  return new lazy.ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, referrerUri);
};

function convertFlags(aFlags) {
  let navFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
  if (!aFlags) {
    return navFlags;
  }
  // These need to match the values in GeckoSession.LOAD_FLAGS_*
  if (aFlags & (1 << 0)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
  }
  if (aFlags & (1 << 1)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY;
  }
  if (aFlags & (1 << 2)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
  }
  if (aFlags & (1 << 3)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_POPUPS;
  }
  if (aFlags & (1 << 4)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CLASSIFIER;
  }
  if (aFlags & (1 << 5)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FORCE_ALLOW_DATA_URI;
  }
  if (aFlags & (1 << 6)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY;
  }
  if (aFlags & (1 << 7)) {
    navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE;
  }
  return navFlags;
}

// Handles navigation requests between Gecko and a GeckoView.
// Handles GeckoView:GoBack and :GoForward requests dispatched by
// GeckoView.goBack and .goForward.
// Dispatches GeckoView:LocationChange to the GeckoView on location change when
// active.
// Implements nsIBrowserDOMWindow.
export class GeckoViewNavigation extends GeckoViewModule {
  onInitBrowser() {
    this.window.browserDOMWindow = this;

    debug`sessionContextId=${this.settings.sessionContextId}`;

    if (this.settings.sessionContextId !== null) {
      // Gecko may have issues with strings containing special characters,
      // so we restrict the string format to a specific pattern.
      if (!/^gvctx(-)?([a-f0-9]+)$/.test(this.settings.sessionContextId)) {
        throw new Error("sessionContextId has illegal format");
      }

      this.browser.setAttribute(
        "geckoViewSessionContextId",
        this.settings.sessionContextId
      );
    }

    // There may be a GeckoViewNavigation module in another window waiting for
    // us to create a browser so it can set openWindowInfo, so allow them to do
    // that now.
    Services.obs.notifyObservers(this.window, "geckoview-window-created");
  }

  onInit() {
    debug`onInit`;

    this.registerListener([
      "GeckoView:GoBack",
      "GeckoView:GoForward",
      "GeckoView:GotoHistoryIndex",
      "GeckoView:LoadUri",
      "GeckoView:Reload",
      "GeckoView:Stop",
      "GeckoView:PurgeHistory",
      "GeckoView:DotPrintFinish",
    ]);

    this._initialAboutBlank = true;
  }

  validateHeader(key, value, filter) {
    if (!key) {
      // Key cannot be empty
      return false;
    }

    for (const c of FORBIDDEN_HEADER_CHARACTERS) {
      if (key.includes(c) || value?.includes(c)) {
        return false;
      }
    }

    if (BAD_HEADERS.includes(key.toLowerCase().trim())) {
      return false;
    }

    if (
      filter == HEADER_FILTER_CORS_SAFELISTED &&
      !this.window.windowUtils.isCORSSafelistedRequestHeader(key, value)
    ) {
      return false;
    }

    return true;
  }

  // Bundle event handler.
  async onEvent(aEvent, aData, aCallback) {
    debug`onEvent: event=${aEvent}, data=${aData}`;

    switch (aEvent) {
      case "GeckoView:GoBack":
        this.browser.goBack(aData.userInteraction);
        break;
      case "GeckoView:GoForward":
        this.browser.goForward(aData.userInteraction);
        break;
      case "GeckoView:GotoHistoryIndex":
        this.browser.gotoIndex(aData.index);
        break;
      case "GeckoView:LoadUri":
        const {
          uri,
          referrerUri,
          referrerSessionId,
          flags,
          headers,
          headerFilter,
        } = aData;

        let navFlags = convertFlags(flags);
        // For performance reasons we don't call the LoadUriDelegate.loadUri
        // from Gecko, and instead we call it directly in the loadUri Java API.
        navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE;

        let triggeringPrincipal, referrerInfo, csp;
        if (referrerSessionId) {
          const referrerWindow = Services.ww.getWindowByName(referrerSessionId);
          triggeringPrincipal = referrerWindow.browser.contentPrincipal;
          csp = referrerWindow.browser.csp;

          const referrerPolicy = referrerWindow.browser.referrerInfo
            ? referrerWindow.browser.referrerInfo.referrerPolicy
            : Ci.nsIReferrerInfo.EMPTY;

          referrerInfo = new lazy.ReferrerInfo(
            referrerPolicy,
            true,
            referrerWindow.browser.documentURI
          );
        } else if (referrerUri) {
          referrerInfo = createReferrerInfo(referrerUri);
        } else {
          // External apps are treated like web pages, so they should not get
          // a privileged principal.
          const isExternal =
            navFlags & Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
          if (!isExternal) {
            // Always use the system principal as the triggering principal
            // for user-initiated (ie. no referrer session and not external)
            // loads. See discussion in bug 1573860.
            triggeringPrincipal =
              Services.scriptSecurityManager.getSystemPrincipal();
          }
        }

        if (!triggeringPrincipal) {
          triggeringPrincipal =
            Services.scriptSecurityManager.createNullPrincipal({});
        }

        let additionalHeaders = null;
        if (headers) {
          additionalHeaders = "";
          for (const [key, value] of Object.entries(headers)) {
            if (!this.validateHeader(key, value, headerFilter)) {
              console.error(`Ignoring invalid header '${key}'='${value}'.`);
              continue;
            }

            additionalHeaders += `${key}:${value ?? ""}\r\n`;
          }

          if (additionalHeaders != "") {
            additionalHeaders =
              lazy.E10SUtils.makeInputStream(additionalHeaders);
          } else {
            additionalHeaders = null;
          }
        }

        // For any navigation here, we should have an appropriate triggeringPrincipal:
        //
        // 1) If we have a referring session, triggeringPrincipal is the contentPrincipal from the
        //    referring document.
        // 2) For certain URI schemes listed above, we will have a codebase principal.
        // 3) In all other cases, we create a NullPrincipal.
        //
        // The navigation flags are driven by the app. We purposely do not propagate these from
        // the referring document, but expect that the app will in most cases.
        //
        // The referrerInfo is derived from the referring document, if present, by propagating any
        // referrer policy. If we only have the referrerUri from the app, we create a referrerInfo
        // with the specified URI and no policy set. If no referrerUri is present and we have no
        // referring session, the referrerInfo is null.
        //
        // csp is only present if we have a referring document, null otherwise.
        this.browser.fixupAndLoadURIString(uri, {
          flags: navFlags,
          referrerInfo,
          triggeringPrincipal,
          headers: additionalHeaders,
          csp,
        });
        break;
      case "GeckoView:Reload":
        // At the moment, GeckoView only supports one reload, which uses
        // nsIWebNavigation.LOAD_FLAGS_NONE flag, and the telemetry doesn't
        // do anything to differentiate reloads (i.e normal vs skip caches)
        // So whenever we add more reload methods, please make sure the
        // telemetry probe is adjusted
        this.browser.reloadWithFlags(convertFlags(aData.flags));
        break;
      case "GeckoView:Stop":
        this.browser.stop();
        break;
      case "GeckoView:PurgeHistory":
        this.browser.purgeSessionHistory();
        break;
      case "GeckoView:DotPrintFinish":
        var printActor = this.moduleManager.getActor("GeckoViewPrintDelegate");
        printActor.clearStaticClone();
        printActor.telemetryDotPrintPdfCompleted(aData);
        break;
    }
  }

  waitAndSetupWindow(aSessionId, aOpenWindowInfo, aName) {
    if (!aSessionId) {
      return Promise.resolve(null);
    }

    return new Promise(resolve => {
      const handler = {
        observe(aSubject, aTopic, aData) {
          if (
            aTopic === "geckoview-window-created" &&
            aSubject.name === aSessionId
          ) {
            // This value will be read by nsFrameLoader while it is being initialized.
            aSubject.browser.openWindowInfo = aOpenWindowInfo;

            // Gecko will use this attribute to set the name of the opened window.
            if (aName) {
              aSubject.browser.setAttribute("name", aName);
            }

            if (
              !aOpenWindowInfo.isRemote &&
              aSubject.browser.hasAttribute("remote")
            ) {
              // We cannot start in remote mode when we have an opener.
              aSubject.browser.setAttribute("remote", "false");
              aSubject.browser.removeAttribute("remoteType");
            }
            Services.obs.removeObserver(handler, "geckoview-window-created");
            resolve(aSubject);
          }
        },
      };

      // This event is emitted from createBrowser() in geckoview.js
      Services.obs.addObserver(handler, "geckoview-window-created");
    });
  }

  handleNewSession(aUri, aOpenWindowInfo, aWhere, aFlags, aName) {
    debug`handleNewSession: uri=${aUri && aUri.spec}
                             where=${aWhere} flags=${aFlags}`;

    if (!this.enabled) {
      return null;
    }

    const newSessionId = Services.uuid
      .generateUUID()
      .toString()
      .slice(1, -1)
      .replace(/-/g, "");

    const message = {
      type: "GeckoView:OnNewSession",
      uri: aUri ? aUri.displaySpec : "",
      newSessionId,
    };

    // The window might be already open by the time we get the response from
    // the Java layer, so we need to start waiting before sending the message.
    const setupPromise = this.waitAndSetupWindow(
      newSessionId,
      aOpenWindowInfo,
      aName
    );

    let browser = undefined;
    this.eventDispatcher
      .sendRequestForResult(message)
      .then(didOpenSession => {
        if (!didOpenSession) {
          return Promise.reject();
        }
        return setupPromise;
      })
      .then(
        window => {
          browser = window.browser;
        },
        () => {
          browser = null;
        }
      );

    // Wait indefinitely for app to respond with a browser or null
    Services.tm.spinEventLoopUntil(
      "GeckoViewNavigation.jsm:handleNewSession",
      () => this.window.closed || browser !== undefined
    );
    return browser || null;
  }

  // nsIBrowserDOMWindow.
  createContentWindow(
    aUri,
    aOpenWindowInfo,
    aWhere,
    aFlags,
    aTriggeringPrincipal,
    aCsp
  ) {
    debug`createContentWindow: uri=${aUri && aUri.spec}
                                where=${aWhere} flags=${aFlags}`;

    if (
      lazy.LoadURIDelegate.load(
        this.window,
        this.eventDispatcher,
        aUri,
        aWhere,
        aFlags,
        aTriggeringPrincipal
      )
    ) {
      // The app has handled the load, abort open-window handling.
      Components.returnCode = Cr.NS_ERROR_ABORT;
      return null;
    }

    const browser = this.handleNewSession(
      aUri,
      aOpenWindowInfo,
      aWhere,
      aFlags,
      null
    );
    if (!browser) {
      Components.returnCode = Cr.NS_ERROR_ABORT;
      return null;
    }

    return browser.browsingContext;
  }

  // nsIBrowserDOMWindow.
  createContentWindowInFrame(aUri, aParams, aWhere, aFlags, aName) {
    debug`createContentWindowInFrame: uri=${aUri && aUri.spec}
                                       where=${aWhere} flags=${aFlags}
                                       name=${aName}`;

    if (aWhere === Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
      return this.window.moduleManager.onPrintWindow(aParams);
    }

    if (
      lazy.LoadURIDelegate.load(
        this.window,
        this.eventDispatcher,
        aUri,
        aWhere,
        aFlags,
        aParams.triggeringPrincipal
      )
    ) {
      // The app has handled the load, abort open-window handling.
      Components.returnCode = Cr.NS_ERROR_ABORT;
      return null;
    }

    const browser = this.handleNewSession(
      aUri,
      aParams.openWindowInfo,
      aWhere,
      aFlags,
      aName
    );
    if (!browser) {
      Components.returnCode = Cr.NS_ERROR_ABORT;
      return null;
    }

    return browser;
  }

  handleOpenUri({
    uri,
    openWindowInfo,
    where,
    flags,
    triggeringPrincipal,
    csp,
    referrerInfo = null,
    name = null,
  }) {
    debug`handleOpenUri: uri=${uri && uri.spec}
                          where=${where} flags=${flags}`;

    if (
      lazy.LoadURIDelegate.load(
        this.window,
        this.eventDispatcher,
        uri,
        where,
        flags,
        triggeringPrincipal
      )
    ) {
      return null;
    }

    let browser = this.browser;

    if (
      where === Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW ||
      where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB
    ) {
      browser = this.handleNewSession(uri, openWindowInfo, where, flags, name);
    }

    if (!browser) {
      // Should we throw?
      return null;
    }

    // 3) We have a new session and a browser element, load the requested URI.
    browser.loadURI(uri, {
      triggeringPrincipal,
      csp,
      referrerInfo,
    });
    return browser;
  }

  // nsIBrowserDOMWindow.
  openURI(aUri, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
    const browser = this.handleOpenUri({
      uri: aUri,
      openWindowInfo: aOpenWindowInfo,
      where: aWhere,
      flags: aFlags,
      triggeringPrincipal: aTriggeringPrincipal,
      csp: aCsp,
    });
    return browser && browser.browsingContext;
  }

  // nsIBrowserDOMWindow.
  openURIInFrame(aUri, aParams, aWhere, aFlags, aName) {
    const browser = this.handleOpenUri({
      uri: aUri,
      openWindowInfo: aParams.openWindowInfo,
      where: aWhere,
      flags: aFlags,
      triggeringPrincipal: aParams.triggeringPrincipal,
      csp: aParams.csp,
      referrerInfo: aParams.referrerInfo,
      name: aName,
    });
    return browser;
  }

  // nsIBrowserDOMWindow.
  canClose() {
    debug`canClose`;
    return true;
  }

  onEnable() {
    debug`onEnable`;

    const flags = Ci.nsIWebProgress.NOTIFY_LOCATION;
    this.progressFilter = Cc[
      "@mozilla.org/appshell/component/browser-status-filter;1"
    ].createInstance(Ci.nsIWebProgress);
    this.progressFilter.addProgressListener(this, flags);
    this.browser.addProgressListener(this.progressFilter, flags);
  }

  onDisable() {
    debug`onDisable`;

    if (!this.progressFilter) {
      return;
    }
    this.progressFilter.removeProgressListener(this);
    this.browser.removeProgressListener(this.progressFilter);
  }

  serializePermission({ type, capability, principal }) {
    const { URI, originAttributes, privateBrowsingId } = principal;
    return {
      uri: Services.io.createExposableURI(URI).displaySpec,
      principal: lazy.E10SUtils.serializePrincipal(principal),
      perm: type,
      value: capability,
      contextId: originAttributes.geckoViewSessionContextId,
      privateMode: privateBrowsingId != 0,
    };
  }

  // WebProgress event handler.
  onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
    debug`onLocationChange`;

    let fixedURI = aLocationURI;

    try {
      fixedURI = Services.io.createExposableURI(aLocationURI);
    } catch (ex) {}

    // We manually fire the initial about:blank messages to make sure that we
    // consistently send them so there's nothing to do here.
    const ignore = this._initialAboutBlank && fixedURI.spec === "about:blank";
    this._initialAboutBlank = false;

    if (ignore) {
      return;
    }

    const { contentPrincipal } = this.browser;
    let permissions;
    if (
      contentPrincipal &&
      lazy.GeckoViewUtils.isSupportedPermissionsPrincipal(contentPrincipal)
    ) {
      let rawPerms = [];
      try {
        rawPerms = Services.perms.getAllForPrincipal(contentPrincipal);
      } catch (ex) {
        warn`Could not get permissions for principal. ${ex}`;
      }
      permissions = rawPerms.map(this.serializePermission);

      // The only way for apps to set permissions is to get hold of an existing
      // permission and change its value.
      // Tracking protection exception permissions are only present when
      // explicitly added by the app, so if one is not present, we need to send
      // a DENY_ACTION tracking protection permission so that apps can use it
      // to add tracking protection exceptions.
      const trackingProtectionPermission =
        contentPrincipal.privateBrowsingId == 0
          ? "trackingprotection"
          : "trackingprotection-pb";
      if (
        contentPrincipal.isContentPrincipal &&
        rawPerms.findIndex(p => p.type == trackingProtectionPermission) == -1
      ) {
        permissions.push(
          this.serializePermission({
            type: trackingProtectionPermission,
            capability: Ci.nsIPermissionManager.DENY_ACTION,
            principal: contentPrincipal,
          })
        );
      }
    }

    const message = {
      type: "GeckoView:LocationChange",
      uri: fixedURI.displaySpec,
      canGoBack: this.browser.canGoBack,
      canGoForward: this.browser.canGoForward,
      isTopLevel: aWebProgress.isTopLevel,
      permissions,
    };

    this.eventDispatcher.sendRequest(message);
  }
}

const { debug, warn } = GeckoViewNavigation.initLogging("GeckoViewNavigation");
