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

const kTestDataTransferType = "x-moz-datatransfer-test";
const kTestDataTransferData = "Dragged Test Data";

// Common base of DragSourceChildContext and DragTargetChildContext
export class DragChildContextBase {
  // The name of the subtype of this object.
  subtypeName = "";

  // Map of counts of events (indexed by event type) that are expected before the next checkExpected
  // NB: A second expected array is maintained on the document object.  This is because the source and
  // target of the drag may be in the same document, in which case source-document events
  // will obviously appear on the target-document and vice-versa.
  expected = {};

  // Array of all of the relevantEvents received on dragElement.
  events = [];

  // (Document) event handler, which is the method with 'this' bound.
  eventHandler = null;
  documentEventHandler = null;

  // Array of all of the relevantEvents received on the document.
  // We expect this to be the same list as the list of events that
  // dragElement should receive, unless the other element involved
  // in the drag is in the same document.  @see expected.
  documentEvents = [];

  // The window
  dragWindow = null;

  // The element being dragged from or to.  Set as parameter to initialize.
  dragElement = null;

  // Position of element in client coords.
  clientPos = null;

  // Position of element in screen coords
  screenPos = null;

  // Was there a drag session before the test started?
  alreadyHadSession = false;

  // Array of monitored event types.  Set as parameter to initialize.
  relevantEvents = [];

  // Label added to diagnostic output to identify the specific drag.
  // Set as parameter to initialize.
  contextLabel = null;

  // Should an exception be thrown when an incorrect event is received.
  // Useful for debugging.  Set as parameter to initialize.
  throwOnExtraMessage = false;

  // Should events other than dragstart and drop have access to the
  // dataTransfer?  Set as parameter to initialize.
  expectProtectedDataTransferAccess = false;

  window = null;

  dragService = null;

  expect(aEvType) {
    this.expected[aEvType] += 1;
    this.dragWindow.document.expected[aEvType] += 1;
  }

  async checkExpected() {
    for (let ev of this.relevantEvents) {
      this.is(
        this.events[ev].length,
        this.expected[ev],
        `\telement received proper number of ${ev} events.`
      );
      this.is(
        this.documentEvents[ev].length,
        this.dragWindow.document.expected[ev],
        `\tdocument received proper number of ${ev} events.`
      );
    }
  }

  checkHasDrag(aShouldHaveDrag = true) {
    this.info(
      `${this.subtypeName} had pre-existing drag: ${this.alreadyHadSession}`
    );
    this.ok(
      !!this.dragService.getCurrentSession(this.dragWindow) ==
        aShouldHaveDrag || this.alreadyHadSession,
      `Has ${!aShouldHaveDrag ? "no " : ""}drag session`
    );
  }

  checkSessionHasAction() {
    this.checkHasDrag();
    if (this.alreadyHadSession) {
      return;
    }
    this.ok(
      this.dragService.getCurrentSession(this.dragWindow).dragAction !==
        Ci.nsIDragService.DRAGDROP_ACTION_NONE,
      "Drag session has valid action"
    );
  }

  // Adapted from EventUtils
  nodeIsFlattenedTreeDescendantOf(aPossibleDescendant, aPossibleAncestor) {
    do {
      if (aPossibleDescendant == aPossibleAncestor) {
        return true;
      }
      aPossibleDescendant = aPossibleDescendant.flattenedTreeParentNode;
    } while (aPossibleDescendant);
    return false;
  }

  // Adapted from EventUtils
  getInclusiveFlattenedTreeParentElement(aNode) {
    for (
      let inclusiveAncestor = aNode;
      inclusiveAncestor;
      inclusiveAncestor = this.getFlattenedTreeParentNode(inclusiveAncestor)
    ) {
      if (inclusiveAncestor.nodeType == Node.ELEMENT_NODE) {
        return inclusiveAncestor;
      }
    }
    return null;
  }

  constructor(aSubtypeName, aDragWindow, aParams) {
    this.subtypeName = aSubtypeName;
    this.dragWindow = aDragWindow;
    this.dragService = Cc["@mozilla.org/widget/dragservice;1"].getService(
      Ci.nsIDragService
    );

    Object.assign(this, aParams);

    this.info = msg => {
      aParams.info(`[${this.contextLabel}|${this.subtypeName}]| ${msg}`);
    };
    this.ok = (cond, msg) => {
      aParams.ok(cond, `[${this.contextLabel}|${this.subtypeName}]| ${msg}`);
    };
    this.is = (v1, v2, msg) => {
      aParams.is(v1, v2, `[${this.contextLabel}|${this.subtypeName}]| ${msg}`);
    };

    this.alreadyHadSession = !!this.dragService.getCurrentSession(
      this.dragWindow
    );

    this.initializeElementInfo(this.dragElementId);

    // Register for events on both the drag element AND the document so we can
    // detect that the right events were/were not sent at the right time.
    this.registerForRelevantEvents();
  }

  initializeElementInfo(aDragElementId) {
    this.dragElement = this.dragWindow.document.getElementById(aDragElementId);
    if (!this.dragElement) {
      for (let shadowRoot of this.dragWindow.document.getConnectedShadowRoots()) {
        if (shadowRoot.isUAWidget()) {
          continue;
        }

        this.dragElement = shadowRoot.getElementById(aDragElementId);
        if (this.dragElement) {
          break;
        }
      }
      this.ok(this.dragElement, "dragElement found in shadow DOM");
    } else {
      this.ok(this.dragElement, "dragElement found");
    }
    let rect = this.dragElement.getBoundingClientRect();
    let rectStr =
      `left: ${rect.left}, top: ${rect.top}, ` +
      `right: ${rect.right}, bottom: ${rect.bottom}`;
    this.info(`getBoundingClientRect(): ${rectStr}`);
    const scale = this.dragWindow.devicePixelRatio;
    this.clientPos = [
      this.dragElement.offsetLeft * scale,
      this.dragElement.offsetTop * scale,
    ];
    this.screenPos = [
      (this.dragWindow.mozInnerScreenX + this.dragElement.offsetLeft) * scale,
      (this.dragWindow.mozInnerScreenY + this.dragElement.offsetTop) * scale,
    ];
  }

  // Checks that the event was expected and, if so, adds the event to
  // the list of events of that type.
  eventHandlerFn(aEv) {
    this.events[aEv.type].push(aEv);
    this.info(`Element received ${aEv.type}`);

    // In order to properly test the dataTransfer, we need to try to access the
    // dataTransfer under the principal of the web page.  Otherwise, we will run
    // under the system principal and dataTransfer access will always be given.
    let sandbox = this.dragWindow.SpecialPowers.unwrap(
      Cu.Sandbox(this.dragWindow.document.nodePrincipal)
    );

    sandbox.is = this.is;
    sandbox.kTestDataTransferType = kTestDataTransferType;
    sandbox.kTestDataTransferData = kTestDataTransferData;
    sandbox.aEv = aEv;

    let getFromDataTransfer = Cu.evalInSandbox(
      "(" +
        function () {
          return aEv.dataTransfer.getData(kTestDataTransferType);
        } +
        ")",
      sandbox
    );
    let setInDataTransfer = Cu.evalInSandbox(
      "(" +
        function () {
          return aEv.dataTransfer.setData(
            kTestDataTransferType,
            kTestDataTransferData
          );
        } +
        ")",
      sandbox
    );
    let clearDataTransfer = Cu.evalInSandbox(
      "(" +
        function () {
          return aEv.dataTransfer.setData(kTestDataTransferType, "");
        } +
        ")",
      sandbox
    );

    try {
      if (aEv.type == "dragstart") {
        // Add some additional data to the DataTransfer so we can look for it
        // as we get later events.
        this.is(
          getFromDataTransfer(),
          "",
          `[${aEv.type}]| DataTransfer didn't have kTestDataTransferType`
        );
        setInDataTransfer();
        this.is(
          getFromDataTransfer(),
          kTestDataTransferData,
          `[${aEv.type}]| Successfully added kTestDataTransferType to DataTransfer`
        );
      } else if (aEv.type == "drop") {
        this.is(
          getFromDataTransfer(),
          kTestDataTransferData,
          `[${aEv.type}]| Successfully read from DataTransfer`
        );
        try {
          clearDataTransfer();
          this.ok(false, "Writing to DataTransfer throws an exception");
        } catch (ex) {
          this.ok(true, "Got exception: " + ex);
        }
        this.is(
          getFromDataTransfer(),
          kTestDataTransferData,
          `[${aEv.type}]| Properly failed to write to DataTransfer`
        );
      } else if (
        aEv.type == "dragenter" ||
        aEv.type == "dragover" ||
        aEv.type == "dragleave" ||
        aEv.type == "dragend"
      ) {
        this.is(
          getFromDataTransfer(),
          this.expectProtectedDataTransferAccess ? kTestDataTransferData : "",
          `[${aEv.type}]| ${
            this.expectProtectedDataTransferAccess
              ? "Successfully"
              : "Unsuccessfully"
          } read from DataTransfer`
        );
      }
    } catch (ex) {
      this.ok(false, "Handler did not throw an uncaught exception: " + ex);
    }

    if (
      this.throwOnExtraMessage &&
      this.events[aEv.type].length > this.expected[aEv.type]
    ) {
      throw new Error(
        `[${this.contextLabel}|${this.subtypeName}] Received unexpected ${
          aEv.type
        } | received ${this.events[aEv.type].length} > expected ${
          this.expected[aEv.type]
        } | event: ${aEv}`
      );
    }
  }

  documentEventHandlerFn(aEv) {
    this.documentEvents[aEv.type].push(aEv);
    this.info(`Document received ${aEv.type}`);
    if (
      this.throwOnExtraMessage &&
      this.documentEvents[aEv.type].length >
        this.dragWindow.document.expected[aEv.type]
    ) {
      throw new Error(
        `[${this.contextLabel}|${
          this.subtypeName
        }] Document received unexpected ${aEv.type} | received ${
          this.documentEvents[aEv.type].length
        } > expected ${
          this.dragWindow.document.expected[aEv.type]
        } | event: ${aEv}`
      );
    }
  }

  registerForRelevantEvents() {
    this.eventHandler = this.eventHandlerFn.bind(this);
    this.documentEventHandler = this.documentEventHandlerFn.bind(this);
    if (!this.dragWindow.document.expected) {
      this.dragWindow.document.expected = [];
    }
    for (let ev of this.relevantEvents) {
      this.events[ev] = [];
      this.documentEvents[ev] = [];
      this.expected[ev] = 0;
      // See the comment on the declaration of this.expected for the reason
      // why we define the expected array for document events on the document
      // itself.
      this.dragWindow.document.expected[ev] = 0;
      this.dragElement.addEventListener(ev, this.eventHandler);
      this.dragWindow.document.addEventListener(ev, this.documentEventHandler);
    }
  }

  getElementPositions() {
    this.info(`clientpos: ${this.clientPos}, screenPos: ${this.screenPos}`);
    return { clientPos: this.clientPos, screenPos: this.screenPos };
  }

  async cleanup() {
    for (let ev of this.relevantEvents) {
      this.dragElement.removeEventListener(ev, this.eventHandler);
      this.dragWindow.document.removeEventListener(
        ev,
        this.documentEventHandler
      );
    }
  }
}
