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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
  LayoutUtils: "resource://gre/modules/LayoutUtils.sys.mjs",
  LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs",
});

export class GeckoViewAutoFillChild extends GeckoViewActorChild {
  constructor() {
    super();

    this._autofillElements = undefined;
    this._autofillInfos = undefined;
  }

  // eslint-disable-next-line complexity
  handleEvent(aEvent) {
    debug`handleEvent: ${aEvent.type}`;
    switch (aEvent.type) {
      case "DOMFormHasPassword": {
        this.addElement(
          lazy.FormLikeFactory.createFromForm(aEvent.composedTarget)
        );
        break;
      }
      case "DOMInputPasswordAdded": {
        const input = aEvent.composedTarget;
        if (!input.form) {
          this.addElement(lazy.FormLikeFactory.createFromField(input));
        }
        break;
      }
      case "focusin": {
        const element = aEvent.composedTarget;
        if (!this.contentWindow.HTMLInputElement.isInstance(element)) {
          break;
        }
        GeckoViewUtils.waitForPanZoomState(this.contentWindow).finally(() => {
          if (Cu.isDeadWrapper(element)) {
            // Focus element is removed or document is navigated to new page.
            return;
          }
          const focusedElement =
            Services.focus.focusedElement ||
            element.ownerDocument?.activeElement;
          if (element == focusedElement) {
            this.onFocus(focusedElement);
          }
        });
        break;
      }
      case "focusout": {
        if (
          this.contentWindow.HTMLInputElement.isInstance(aEvent.composedTarget)
        ) {
          this.onFocus(null);
        }
        break;
      }
      case "pagehide": {
        if (aEvent.target === this.document) {
          this.clearElements(this.browsingContext);
        }
        break;
      }
      case "pageshow": {
        if (aEvent.target === this.document) {
          this.scanDocument(this.document);
        }
        break;
      }
      case "PasswordManager:ShowDoorhanger": {
        const { form: formLike } = aEvent.detail;
        this.commitAutofill(formLike);
        break;
      }
    }
  }

  /**
   * Process an auto-fillable form and send the relevant details of the form
   * to Java. Multiple calls within a short time period for the same form are
   * coalesced, so that, e.g., if multiple inputs are added to a form in
   * succession, we will only perform one processing pass. Note that for inputs
   * without forms, FormLikeFactory treats the document as the "form", but
   * there is no difference in how we process them.
   *
   * @param aFormLike A FormLike object produced by FormLikeFactory.
   */
  async addElement(aFormLike) {
    debug`Adding auto-fill ${aFormLike.rootElement.tagName}`;

    const window = aFormLike.rootElement.ownerGlobal;
    // Get password field to get better form data via LoginManagerChild.
    let passwordField;
    for (const field of aFormLike.elements) {
      if (
        ChromeUtils.getClassName(field) === "HTMLInputElement" &&
        field.type == "password"
      ) {
        passwordField = field;
        break;
      }
    }

    const loginManagerChild = lazy.LoginManagerChild.forWindow(window);
    const docState = loginManagerChild.stateForDocument(
      passwordField.ownerDocument
    );
    const [usernameField] = docState.getUserNameAndPasswordFields(
      passwordField || aFormLike.elements[0]
    );

    const focusedElement = aFormLike.rootElement.ownerDocument.activeElement;
    let sendFocusEvent = aFormLike.rootElement === focusedElement;

    const rootInfo = this._getInfo(
      aFormLike.rootElement,
      null,
      undefined,
      null
    );

    rootInfo.rootUuid = rootInfo.uuid;
    rootInfo.children = aFormLike.elements
      .filter(
        element =>
          element.type != "hidden" &&
          (!usernameField ||
            element.type != "text" ||
            element == usernameField ||
            (element.getAutocompleteInfo() &&
              element.getAutocompleteInfo().fieldName == "email"))
      )
      .map(element => {
        sendFocusEvent |= element === focusedElement;
        return this._getInfo(
          element,
          rootInfo.uuid,
          rootInfo.uuid,
          usernameField
        );
      });

    try {
      // We don't await here so that we can send a focus event immediately
      // after this as the app might not know which element is focused.
      const responsePromise = this.sendQuery("Add", {
        node: rootInfo,
      });

      if (sendFocusEvent) {
        // We might have missed sending a focus event for the active element.
        this.onFocus(aFormLike.ownerDocument.activeElement);
      }

      const responses = await responsePromise;
      // `responses` is an object with global IDs as keys.
      debug`Performing auto-fill ${Object.keys(responses)}`;

      const AUTOFILL_STATE = "autofill";

      for (const uuid in responses) {
        const entry =
          this._autofillElements && this._autofillElements.get(uuid);
        const element = entry && entry.get();
        const value = responses[uuid] || "";

        if (
          window.HTMLInputElement.isInstance(element) &&
          !element.disabled &&
          element.parentElement
        ) {
          element.setUserInput(value);
          if (element.value === value) {
            // Add highlighting for autofilled fields.
            element.autofillState = AUTOFILL_STATE;

            // Remove highlighting when the field is changed.
            element.addEventListener(
              "input",
              _ => (element.autofillState = ""),
              { mozSystemGroup: true, once: true }
            );
          }
        } else if (element) {
          warn`Don't know how to auto-fill ${element.tagName}`;
        }
      }
    } catch (error) {
      warn`Cannot perform autofill ${error}`;
    }
  }

  _getInfo(aElement, aParent, aRoot, aUsernameField) {
    if (!this._autofillInfos) {
      this._autofillInfos = new WeakMap();
      this._autofillElements = new Map();
    }

    let info = this._autofillInfos.get(aElement);
    if (info) {
      return info;
    }

    const window = aElement.ownerGlobal;
    const bounds = aElement.getBoundingClientRect();
    const isInputElement = window.HTMLInputElement.isInstance(aElement);

    info = {
      isInputElement,
      uuid: Services.uuid.generateUUID().toString().slice(1, -1), // discard the surrounding curly braces
      parentUuid: aParent,
      rootUuid: aRoot,
      tag: aElement.tagName,
      type: isInputElement ? aElement.type : null,
      value: isInputElement ? aElement.value : null,
      editable:
        isInputElement &&
        [
          "color",
          "date",
          "datetime-local",
          "email",
          "month",
          "number",
          "password",
          "range",
          "search",
          "tel",
          "text",
          "time",
          "url",
          "week",
        ].includes(aElement.type),
      disabled: isInputElement ? aElement.disabled : null,
      attributes: Object.assign(
        {},
        ...Array.from(aElement.attributes)
          .filter(attr => attr.localName !== "value")
          .map(attr => ({ [attr.localName]: attr.value }))
      ),
      origin: aElement.ownerDocument.location.origin,
      autofillhint: "",
      bounds: {
        left: bounds.left,
        top: bounds.top,
        right: bounds.right,
        bottom: bounds.bottom,
      },
    };

    if (aElement === aUsernameField) {
      info.autofillhint = "username"; // AUTOFILL.HINT.USERNAME
    } else if (isInputElement) {
      // Using autocomplete attribute if it is email.
      const autocompleteInfo = aElement.getAutocompleteInfo();
      if (autocompleteInfo) {
        const autocompleteAttr = autocompleteInfo.fieldName;
        if (autocompleteAttr == "email") {
          info.type = "email";
        }
      }
    }

    this._autofillInfos.set(aElement, info);
    this._autofillElements.set(info.uuid, Cu.getWeakReference(aElement));
    return info;
  }

  _updateInfoValues(aElements) {
    if (!this._autofillInfos) {
      return [];
    }

    const updated = [];
    for (const element of aElements) {
      const info = this._autofillInfos.get(element);

      if (!info?.isInputElement || info.value === element.value) {
        continue;
      }
      debug`Updating value ${info.value} to ${element.value}`;

      info.value = element.value;
      this._autofillInfos.set(element, info);
      updated.push(info);
    }
    return updated;
  }

  /**
   * Called when an auto-fillable field is focused or blurred.
   *
   * @param aTarget Focused element, or null if an element has lost focus.
   */
  onFocus(aTarget) {
    debug`Auto-fill focus on ${aTarget && aTarget.tagName}`;

    const info = aTarget && this._autofillInfos?.get(aTarget);
    if (info) {
      const bounds = aTarget.getBoundingClientRect();
      const screenRect = lazy.LayoutUtils.rectToScreenRect(
        aTarget.ownerGlobal,
        bounds
      );
      info.screenRect = {
        left: screenRect.left,
        top: screenRect.top,
        right: screenRect.right,
        bottom: screenRect.bottom,
      };
    }

    if (!aTarget || info) {
      this.sendAsyncMessage("Focus", {
        node: info,
      });
    }
  }

  commitAutofill(aFormLike) {
    if (!aFormLike) {
      throw new Error("null-form on autofill commit");
    }

    debug`Committing auto-fill for ${aFormLike.rootElement.tagName}`;

    const updatedNodeInfos = this._updateInfoValues([
      aFormLike.rootElement,
      ...aFormLike.elements,
    ]);

    for (const updatedInfo of updatedNodeInfos) {
      debug`Updating node ${updatedInfo}`;
      this.sendAsyncMessage("Update", {
        node: updatedInfo,
      });
    }

    const info = this._getInfo(aFormLike.rootElement);
    if (info) {
      debug`Committing node ${info}`;
      this.sendAsyncMessage("Commit", {
        node: info,
      });
    }
  }

  /**
   * Clear all tracked auto-fill forms and notify Java.
   */
  clearElements(browsingContext) {
    this._autofillInfos = undefined;
    this._autofillElements = undefined;

    if (browsingContext === browsingContext.top) {
      this.sendAsyncMessage("Clear");
    }
  }

  /**
   * Scan for auto-fillable forms and add them if necessary. Called when a page
   * is navigated to through history, in which case we don't get our typical
   * "input added" notifications.
   *
   * @param aDoc Document to scan.
   */
  scanDocument(aDoc) {
    // Add forms first; only check forms with password inputs.
    const inputs = aDoc.querySelectorAll("input[type=password]");
    let inputAdded = false;
    for (let i = 0; i < inputs.length; i++) {
      if (inputs[i].form) {
        // Let addElement coalesce multiple calls for the same form.
        this.addElement(lazy.FormLikeFactory.createFromForm(inputs[i].form));
      } else if (!inputAdded) {
        // Treat inputs without forms as one unit, and process them only once.
        inputAdded = true;
        this.addElement(lazy.FormLikeFactory.createFromField(inputs[i]));
      }
    }
  }
}

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