/* 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";

export class GeckoViewContent extends GeckoViewModule {
  onInit() {
    this.registerListener([
      "GeckoViewContent:ExitFullScreen",
      "GeckoView:ClearMatches",
      "GeckoView:DisplayMatches",
      "GeckoView:FindInPage",
      "GeckoView:HasCookieBannerRuleForBrowsingContextTree",
      "GeckoView:RestoreState",
      "GeckoView:ContainsFormData",
      "GeckoView:ScrollBy",
      "GeckoView:ScrollTo",
      "GeckoView:SetActive",
      "GeckoView:SetFocused",
      "GeckoView:SetPriorityHint",
      "GeckoView:UpdateInitData",
      "GeckoView:ZoomToInput",
      "GeckoView:IsPdfJs",
    ]);
  }

  onEnable() {
    this.window.addEventListener(
      "MozDOMFullscreen:Entered",
      this,
      /* capture */ true,
      /* untrusted */ false
    );
    this.window.addEventListener(
      "MozDOMFullscreen:Exited",
      this,
      /* capture */ true,
      /* untrusted */ false
    );
    this.window.addEventListener(
      "framefocusrequested",
      this,
      /* capture */ true,
      /* untrusted */ false
    );

    this.window.addEventListener("DOMWindowClose", this);
    this.window.addEventListener("pagetitlechanged", this);
    this.window.addEventListener("pageinfo", this);

    this.window.addEventListener("cookiebannerdetected", this);
    this.window.addEventListener("cookiebannerhandled", this);

    Services.obs.addObserver(this, "oop-frameloader-crashed");
    Services.obs.addObserver(this, "ipc:content-shutdown");
  }

  onDisable() {
    this.window.removeEventListener(
      "MozDOMFullscreen:Entered",
      this,
      /* capture */ true
    );
    this.window.removeEventListener(
      "MozDOMFullscreen:Exited",
      this,
      /* capture */ true
    );
    this.window.removeEventListener(
      "framefocusrequested",
      this,
      /* capture */ true
    );

    this.window.removeEventListener("DOMWindowClose", this);
    this.window.removeEventListener("pagetitlechanged", this);
    this.window.removeEventListener("pageinfo", this);

    this.window.removeEventListener("cookiebannerdetected", this);
    this.window.removeEventListener("cookiebannerhandled", this);

    Services.obs.removeObserver(this, "oop-frameloader-crashed");
    Services.obs.removeObserver(this, "ipc:content-shutdown");
  }

  get actor() {
    return this.getActor("GeckoViewContent");
  }

  get isPdfJs() {
    return (
      this.browser.contentPrincipal.spec === "resource://pdf.js/web/viewer.html"
    );
  }

  // Goes up the browsingContext chain and sends the message every time
  // we cross the process boundary so that every process in the chain is
  // notified.
  sendToAllChildren(aEvent, aData) {
    let { browsingContext } = this.actor;

    while (browsingContext) {
      if (!browsingContext.currentWindowGlobal) {
        break;
      }

      const currentPid = browsingContext.currentWindowGlobal.osPid;
      const parentPid = browsingContext.parent?.currentWindowGlobal.osPid;

      if (currentPid != parentPid) {
        const actor =
          browsingContext.currentWindowGlobal.getActor("GeckoViewContent");
        actor.sendAsyncMessage(aEvent, aData);
      }

      browsingContext = browsingContext.parent;
    }
  }

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

    switch (aEvent) {
      case "GeckoViewContent:ExitFullScreen":
        this.browser.ownerDocument.exitFullscreen();
        break;
      case "GeckoView:ClearMatches": {
        if (!this.isPdfJs) {
          this._clearMatches();
        }
        break;
      }
      case "GeckoView:DisplayMatches": {
        if (!this.isPdfJs) {
          this._displayMatches(aData);
        }
        break;
      }
      case "GeckoView:FindInPage": {
        if (!this.isPdfJs) {
          this._findInPage(aData, aCallback);
        }
        break;
      }
      case "GeckoView:ZoomToInput":
        this.sendToAllChildren(aEvent, aData);
        break;
      case "GeckoView:ScrollBy":
        // Unclear if that actually works with oop iframes?
        this.sendToAllChildren(aEvent, aData);
        break;
      case "GeckoView:ScrollTo":
        // Unclear if that actually works with oop iframes?
        this.sendToAllChildren(aEvent, aData);
        break;
      case "GeckoView:UpdateInitData":
        this.sendToAllChildren(aEvent, aData);
        break;
      case "GeckoView:SetActive":
        this.browser.docShellIsActive = !!aData.active;
        break;
      case "GeckoView:SetFocused":
        if (aData.focused) {
          this.browser.focus();
          this.browser.setAttribute("primary", "true");
        } else {
          this.browser.removeAttribute("primary");
          this.browser.blur();
        }
        break;
      case "GeckoView:SetPriorityHint":
        if (this.browser.isRemoteBrowser) {
          const remoteTab = this.browser.frameLoader?.remoteTab;
          if (remoteTab) {
            remoteTab.priorityHint = aData.priorityHint;
          }
        }
        break;
      case "GeckoView:RestoreState":
        this.actor.restoreState(aData);
        break;
      case "GeckoView:ContainsFormData":
        this._containsFormData(aCallback);
        break;
      case "GeckoView:IsPdfJs":
        aCallback.onSuccess(this.isPdfJs);
        break;
      case "GeckoView:HasCookieBannerRuleForBrowsingContextTree":
        this._hasCookieBannerRuleForBrowsingContextTree(aCallback);
        break;
    }
  }

  // DOM event handler
  handleEvent(aEvent) {
    debug`handleEvent: ${aEvent.type}`;

    switch (aEvent.type) {
      case "framefocusrequested":
        if (this.browser != aEvent.target) {
          return;
        }
        if (this.browser.hasAttribute("primary")) {
          return;
        }
        this.eventDispatcher.sendRequest({
          type: "GeckoView:FocusRequest",
        });
        aEvent.preventDefault();
        break;
      case "MozDOMFullscreen:Entered":
        if (this.browser == aEvent.target) {
          // Remote browser; dispatch to content process.
          this.sendToAllChildren("GeckoView:DOMFullscreenEntered");
        }
        break;
      case "MozDOMFullscreen:Exited":
        this.sendToAllChildren("GeckoView:DOMFullscreenExited");
        break;
      case "pagetitlechanged":
        this.eventDispatcher.sendRequest({
          type: "GeckoView:PageTitleChanged",
          title: this.browser.contentTitle,
        });
        break;
      case "DOMWindowClose":
        // We need this because we want to allow the app
        // to close the window itself. If we don't preventDefault()
        // here Gecko will close it immediately.
        aEvent.preventDefault();

        this.eventDispatcher.sendRequest({
          type: "GeckoView:DOMWindowClose",
        });
        break;
      case "pageinfo":
        if (aEvent.detail.previewImageURL) {
          this.eventDispatcher.sendRequest({
            type: "GeckoView:PreviewImage",
            previewImageUrl: aEvent.detail.previewImageURL,
          });
        }
        break;
      case "cookiebannerdetected":
        this.eventDispatcher.sendRequest({
          type: "GeckoView:CookieBannerEvent:Detected",
        });
        break;
      case "cookiebannerhandled":
        this.eventDispatcher.sendRequest({
          type: "GeckoView:CookieBannerEvent:Handled",
        });
        break;
    }
  }

  // nsIObserver event handler
  observe(aSubject, aTopic, aData) {
    debug`observe: ${aTopic}`;
    this._contentCrashed = false;
    const browser = aSubject.ownerElement;

    switch (aTopic) {
      case "oop-frameloader-crashed": {
        if (!browser || browser != this.browser) {
          return;
        }
        this.window.setTimeout(() => {
          if (this._contentCrashed) {
            this.eventDispatcher.sendRequest({
              type: "GeckoView:ContentCrash",
            });
          } else {
            this.eventDispatcher.sendRequest({
              type: "GeckoView:ContentKill",
            });
          }
        }, 250);
        break;
      }
      case "ipc:content-shutdown": {
        aSubject.QueryInterface(Ci.nsIPropertyBag2);
        if (aSubject.get("dumpID")) {
          if (
            browser &&
            aSubject.get("childID") != browser.frameLoader.childID
          ) {
            return;
          }
          this._contentCrashed = true;
        }
        break;
      }
    }
  }

  async _containsFormData(aCallback) {
    aCallback.onSuccess(await this.actor.containsFormData());
  }

  async _hasCookieBannerRuleForBrowsingContextTree(aCallback) {
    const { browsingContext } = this.actor;
    aCallback.onSuccess(
      Services.cookieBanners.hasRuleForBrowsingContextTree(browsingContext)
    );
  }

  _findInPage(aData, aCallback) {
    debug`findInPage: data=${aData} callback=${aCallback && "non-null"}`;

    let finder;
    try {
      finder = this.browser.finder;
    } catch (e) {
      if (aCallback) {
        aCallback.onError(`No finder: ${e}`);
      }
      return;
    }

    if (this._finderListener) {
      finder.removeResultListener(this._finderListener);
    }

    this._finderListener = {
      response: {
        found: false,
        wrapped: false,
        current: 0,
        total: -1,
        searchString: aData.searchString || finder.searchString,
        linkURL: null,
        clientRect: null,
        flags: {
          backwards: !!aData.backwards,
          linksOnly: !!aData.linksOnly,
          matchCase: !!aData.matchCase,
          wholeWord: !!aData.wholeWord,
        },
      },

      onFindResult(aOptions) {
        if (!aCallback || aOptions.searchString !== aData.searchString) {
          // Result from a previous search.
          return;
        }

        Object.assign(this.response, {
          found: aOptions.result !== Ci.nsITypeAheadFind.FIND_NOTFOUND,
          wrapped: aOptions.result !== Ci.nsITypeAheadFind.FIND_FOUND,
          linkURL: aOptions.linkURL,
          clientRect: aOptions.rect && {
            left: aOptions.rect.left,
            top: aOptions.rect.top,
            right: aOptions.rect.right,
            bottom: aOptions.rect.bottom,
          },
          flags: {
            backwards: aOptions.findBackwards,
            linksOnly: aOptions.linksOnly,
            matchCase: this.response.flags.matchCase,
            wholeWord: this.response.flags.wholeWord,
          },
        });

        if (!this.response.found) {
          this.response.current = 0;
          this.response.total = 0;
        }

        // Only send response if we have a count.
        if (!this.response.found || this.response.current !== 0) {
          debug`onFindResult: ${this.response}`;
          aCallback.onSuccess(this.response);
          aCallback = undefined;
        }
      },

      onMatchesCountResult(aResult) {
        if (!aCallback || finder.searchString !== aData.searchString) {
          // Result from a previous search.
          return;
        }

        Object.assign(this.response, {
          current: aResult.current,
          total: aResult.total,
        });

        // Only send response if we have a result. `found` and `wrapped` are
        // both false only when we haven't received a result yet.
        if (this.response.found || this.response.wrapped) {
          debug`onMatchesCountResult: ${this.response}`;
          aCallback.onSuccess(this.response);
          aCallback = undefined;
        }
      },

      onCurrentSelection() {},

      onHighlightFinished() {},
    };

    finder.caseSensitive = !!aData.matchCase;
    finder.entireWord = !!aData.wholeWord;
    finder.matchDiacritics = !!aData.matchDiacritics;
    finder.addResultListener(this._finderListener);

    const drawOutline =
      this._matchDisplayOptions && !!this._matchDisplayOptions.drawOutline;

    if (!aData.searchString || aData.searchString === finder.searchString) {
      // Search again.
      aData.searchString = finder.searchString;
      finder.findAgain(
        aData.searchString,
        !!aData.backwards,
        !!aData.linksOnly,
        drawOutline
      );
    } else {
      finder.fastFind(aData.searchString, !!aData.linksOnly, drawOutline);
    }
  }

  _clearMatches() {
    debug`clearMatches`;

    let finder;
    try {
      finder = this.browser.finder;
    } catch (e) {
      return;
    }

    finder.removeSelection();
    finder.highlight(false);

    if (this._finderListener) {
      finder.removeResultListener(this._finderListener);
      this._finderListener = null;
    }
  }

  _displayMatches(aData) {
    debug`displayMatches: data=${aData}`;

    let finder;
    try {
      finder = this.browser.finder;
    } catch (e) {
      return;
    }

    this._matchDisplayOptions = aData;
    finder.onModalHighlightChange(!!aData.dimPage);
    finder.onHighlightAllChange(!!aData.highlightAll);

    if (!aData.highlightAll && !aData.dimPage) {
      finder.highlight(false);
      return;
    }

    if (!this._finderListener || !finder.searchString) {
      return;
    }
    const linksOnly = this._finderListener.response.linksOnly;
    finder.highlight(true, finder.searchString, linksOnly, !!aData.drawOutline);
  }
}

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