/* 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 { GeckoViewActorChild } from "resource://gre/modules/GeckoViewActorChild.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  ManifestObtainer: "resource://gre/modules/ManifestObtainer.sys.mjs",
  SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs",
  SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs",
});

const MAX_TEXT_LENGTH = 4096;

export class ContentDelegateChild extends GeckoViewActorChild {
  notifyParentOfViewportFit() {
    if (this.triggerViewportFitChange) {
      this.contentWindow.cancelIdleCallback(this.triggerViewportFitChange);
    }
    this.triggerViewportFitChange = this.contentWindow.requestIdleCallback(
      () => {
        this.triggerViewportFitChange = null;
        const viewportFit = this.contentWindow.windowUtils.getViewportFitInfo();
        if (this.lastViewportFit === viewportFit) {
          return;
        }
        this.lastViewportFit = viewportFit;
        this.sendAsyncMessage("GeckoView:DOMMetaViewportFit", viewportFit);
      }
    );
  }

  #getSelection(aElement, aEditFlags) {
    if (aEditFlags & lazy.SpellCheckHelper.TEXTINPUT) {
      return aElement?.editor?.selection;
    }

    return this.contentWindow.getSelection();
  }

  #getSelectionBoundingRect(aFocusedElement, aEditFlags) {
    const selection = this.#getSelection(aFocusedElement, aEditFlags);
    if (!selection || selection.isCollapsed || selection.rangeCount != 1) {
      return null;
    }
    const range = selection.getRangeAt(0);
    return range.getBoundingClientRect();
  }

  /**
   * Show action menu if contextmenu is by mouse and we have selected text
   */
  #showActionMenu(aEvent) {
    if (aEvent.pointerType !== "mouse") {
      return false;
    }

    const win = this.contentWindow;
    const selectionInfo = lazy.SelectionUtils.getSelectionDetails(win);
    if (!selectionInfo.text.length) {
      // Don't show action menu by contextmenu event if no selection
      return false;
    }

    // The selection isn't collapsed and has a text.  We try to show action menu
    const focusedElement =
      Services.focus.focusedElement || aEvent.composedTarget;

    const editFlags = lazy.SpellCheckHelper.isEditable(focusedElement, win);
    const selectionEditable = !!(
      editFlags &
      (lazy.SpellCheckHelper.EDITABLE | lazy.SpellCheckHelper.CONTENTEDITABLE)
    );
    const boundingClientRect = this.#getSelectionBoundingRect(
      focusedElement,
      editFlags
    );

    const caretEvent = new CaretStateChangedEvent("mozcaretstatechanged", {
      bubbles: true,
      collapsed: selectionInfo.docSelectionIsCollapsed,
      boundingClientRect,
      reason: "taponcaret",
      caretVisible: true,
      selectionVisible: true,
      selectionEditable,
      selectedTextContent: selectionInfo.text,
    });

    win.dispatchEvent(caretEvent);

    // If selection is changed, or focus is changed, dismiss action menu
    const eventTarget = (() => {
      if (editFlags & lazy.SpellCheckHelper.TEXTINPUT) {
        return focusedElement;
      }
      return this.contentWindow.document;
    })();

    function dismissHandler() {
      const dismissEvent = new CaretStateChangedEvent("mozcaretstatechanged", {
        bubbles: true,
        reason: "visibilitychange",
      });
      win.dispatchEvent(dismissEvent);

      eventTarget.removeEventListener("selectionchange", dismissHandler);
      win.removeEventListener("focusin", dismissHandler);
      win.removeEventListener("focusout", dismissHandler);
      win.removeEventListener("blur", dismissHandler);
    }

    eventTarget.addEventListener("selectionchange", dismissHandler);
    win.addEventListener("focusin", dismissHandler);
    win.addEventListener("focusout", dismissHandler);
    win.addEventListener("blur", dismissHandler);

    return true;
  }

  // eslint-disable-next-line complexity
  handleEvent(aEvent) {
    debug`handleEvent: ${aEvent.type}`;

    switch (aEvent.type) {
      case "contextmenu": {
        if (aEvent.defaultPrevented) {
          return;
        }

        if (this.#showActionMenu(aEvent)) {
          aEvent.preventDefault();
          return;
        }

        function nearestParentAttribute(aNode, aAttribute) {
          while (
            aNode &&
            aNode.hasAttribute &&
            !aNode.hasAttribute(aAttribute)
          ) {
            aNode = aNode.parentNode;
          }
          return aNode && aNode.getAttribute && aNode.getAttribute(aAttribute);
        }

        function createAbsoluteUri(aBaseUri, aUri) {
          if (!aUri || !aBaseUri || !aBaseUri.displaySpec) {
            return null;
          }
          return Services.io.newURI(aUri, null, aBaseUri).displaySpec;
        }

        const node = aEvent.composedTarget;
        const baseUri = node.ownerDocument.baseURIObject;
        const uri = createAbsoluteUri(
          baseUri,
          nearestParentAttribute(node, "href")
        );
        const title = nearestParentAttribute(node, "title");
        const alt = nearestParentAttribute(node, "alt");
        const elementType = ChromeUtils.getClassName(node);
        const isImage = elementType === "HTMLImageElement";
        const isMedia =
          elementType === "HTMLVideoElement" ||
          elementType === "HTMLAudioElement";
        let elementSrc = (isImage || isMedia) && (node.currentSrc || node.src);
        if (elementSrc) {
          const isBlob = elementSrc.startsWith("blob:");
          if (isBlob && !URL.isBoundToBlob(elementSrc)) {
            elementSrc = null;
          }
        }

        if (uri || isImage || isMedia) {
          const msg = {
            // We don't have full zoom on Android, so using CSS coordinates
            // here is fine, since the CSS coordinate spaces match between the
            // child and parent processes.
            //
            // TODO(m_kato):
            // title, alt and textContent should consider surrogate pair and grapheme cluster?
            screenX: aEvent.screenX,
            screenY: aEvent.screenY,
            baseUri: (baseUri && baseUri.displaySpec) || null,
            uri,
            title: (title && title.substring(0, MAX_TEXT_LENGTH)) || null,
            alt: (alt && alt.substring(0, MAX_TEXT_LENGTH)) || null,
            elementType,
            elementSrc: elementSrc || null,
            textContent:
              (node.textContent &&
                node.textContent.substring(0, MAX_TEXT_LENGTH)) ||
              null,
            linkText:
              (node.innerText &&
                node.innerText.substring(0, MAX_TEXT_LENGTH)) ||
              null,
          };

          this.sendAsyncMessage("GeckoView:ContextMenu", msg);
          aEvent.preventDefault();
        }
        break;
      }
      case "MozDOMFullscreen:Request": {
        this.sendAsyncMessage("GeckoView:DOMFullscreenRequest");
        break;
      }
      case "MozDOMFullscreen:Entered":
      case "MozDOMFullscreen:Exited":
        // Content may change fullscreen state by itself, and we should ensure
        // that the parent always exits fullscreen when content has left
        // full screen mode.
        if (this.contentWindow?.document.fullscreenElement) {
          break;
        }
      // fall-through
      case "MozDOMFullscreen:Exit":
        this.sendAsyncMessage("GeckoView:DOMFullscreenExit");
        break;
      case "DOMMetaViewportFitChanged":
        if (aEvent.originalTarget.ownerGlobal == this.contentWindow) {
          this.notifyParentOfViewportFit();
        }
        break;
      case "DOMContentLoaded": {
        if (aEvent.originalTarget.ownerGlobal == this.contentWindow) {
          // If loaded content doesn't have viewport-fit, parent still
          // uses old value of previous content.
          this.notifyParentOfViewportFit();
        }
        if (this.contentWindow !== this.contentWindow?.top) {
          // Only check WebApp manifest on the top level window.
          return;
        }
        this.contentWindow.requestIdleCallback(async () => {
          const manifest = await lazy.ManifestObtainer.contentObtainManifest(
            this.contentWindow
          );
          if (manifest) {
            this.sendAsyncMessage("GeckoView:WebAppManifest", manifest);
          }
        });
        break;
      }
      case "MozFirstContentfulPaint": {
        this.sendAsyncMessage("GeckoView:FirstContentfulPaint");
        break;
      }
      case "MozPaintStatusReset": {
        this.sendAsyncMessage("GeckoView:PaintStatusReset");
        break;
      }
    }
  }
}

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