/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(
  SpecialPowers.Ci.nsIGfxInfo
);

async function waitForBlockedPopups(numberOfPopups, { doc }) {
  let toolbarDoc = doc || document;
  let menupopup = toolbarDoc.getElementById("blockedPopupOptions");
  await BrowserTestUtils.waitForCondition(() => {
    let popups = menupopup.querySelectorAll("[popupReportIndex]");
    return popups.length == numberOfPopups;
  }, `Waiting for ${numberOfPopups} popups`);
}

/*
 * Tests that a sequence of size changes ultimately results in the latest
 * requested size. The test also fails when an unexpected window size is
 * observed in a resize event.
 *
 * aPropertyDeltas    List of objects where keys describe the name of a window
 *                    property and the values the difference to its initial
 *                    value.
 *
 * aInstant           Issue changes without additional waiting in between.
 *
 * A brief example of the resutling code that is effectively run for the
 * following list of deltas:
 * [{outerWidth: 5, outerHeight: 10}, {outerWidth: 10}]
 *
 * let initialWidth = win.outerWidth;
 * let initialHeight = win.outerHeight;
 *
 * if (aInstant) {
 *   win.outerWidth = initialWidth + 5;
 *   win.outerHeight = initialHeight + 10;
 *
 *   win.outerWidth = initialWidth + 10;
 * } else {
 *   win.requestAnimationFrame(() => {
 *     win.outerWidth = initialWidth + 5;
 *     win.outerHeight = initialHeight + 10;
 *
 *     win.requestAnimationFrame(() => {
 *        win.outerWidth = initialWidth + 10;
 *     });
 *   });
 * }
 */
async function testPropertyDeltas(
  aPropertyDeltas,
  aInstant,
  aPropInfo,
  aMsg,
  aWaitForCompletion
) {
  let msg = `[${aMsg}]`;

  let win = this.content.popup || this.content.wrappedJSObject;

  // Property names and mapping from ResizeMoveTest
  let {
    sizeProps,
    positionProps /* can be empty/incomplete as workaround on Linux */,
    readonlyProps,
    crossBoundsMapping,
  } = aPropInfo;

  let stringifyState = state => {
    let stateMsg = sizeProps
      .concat(positionProps)
      .filter(prop => state[prop] !== undefined)
      .map(prop => `${prop}: ${state[prop]}`)
      .join(", ");
    return `{ ${stateMsg} }`;
  };

  let initialState = {};
  let finalState = {};

  info("Initializing all values to current state.");
  for (let prop of sizeProps.concat(positionProps)) {
    let value = win[prop];
    initialState[prop] = value;
    finalState[prop] = value;
  }

  // List of potential states during resize events. The current state is also
  // considered valid, as the resize event might still be outstanding.
  let validResizeStates = [initialState];

  let updateFinalState = (aProp, aDelta) => {
    if (
      readonlyProps.includes(aProp) ||
      !sizeProps.concat(positionProps).includes(aProp)
    ) {
      throw new Error(`Unexpected property "${aProp}".`);
    }

    // Update both properties of the same axis.
    let otherProp = crossBoundsMapping[aProp];
    finalState[aProp] = initialState[aProp] + aDelta;
    finalState[otherProp] = initialState[otherProp] + aDelta;

    // Mark size as valid in resize event.
    if (sizeProps.includes(aProp)) {
      let state = {};
      sizeProps.forEach(p => (state[p] = finalState[p]));
      validResizeStates.push(state);
    }
  };

  info("Adding resize event listener.");
  let resizeCount = 0;
  let resizeListener = evt => {
    resizeCount++;

    let currentSizeState = {};
    sizeProps.forEach(p => (currentSizeState[p] = win[p]));

    info(
      `${msg} ${resizeCount}. resize event: ${stringifyState(currentSizeState)}`
    );
    let matchingIndex = validResizeStates.findIndex(state =>
      sizeProps.every(p => state[p] == currentSizeState[p])
    );
    if (matchingIndex < 0) {
      info(`${msg} Size state should have been one of:`);
      for (let state of validResizeStates) {
        info(stringifyState(state));
      }
    }

    if (win.gBrowser && evt.target != win) {
      // Without e10s we receive content resize events in chrome windows.
      todo(false, `${msg} Resize event target is our window.`);
      return;
    }

    ok(
      matchingIndex >= 0,
      `${msg} Valid intermediate state. Current: ` +
        stringifyState(currentSizeState)
    );

    // No longer allow current and preceding states.
    validResizeStates.splice(0, matchingIndex + 1);
  };
  win.addEventListener("resize", resizeListener);

  info("Starting property changes.");
  await new Promise(resolve => {
    let index = 0;
    let next = async () => {
      let pre = `${msg} [${index + 1}/${aPropertyDeltas.length}]`;

      let deltaObj = aPropertyDeltas[index];
      for (let prop in deltaObj) {
        updateFinalState(prop, deltaObj[prop]);

        let targetValue = initialState[prop] + deltaObj[prop];
        info(`${pre} Setting ${prop} to ${targetValue}.`);
        if (sizeProps.includes(prop)) {
          win.resizeTo(finalState.outerWidth, finalState.outerHeight);
        } else {
          win.moveTo(finalState.screenX, finalState.screenY);
        }
        if (aWaitForCompletion) {
          await ContentTaskUtils.waitForCondition(
            () => win[prop] == targetValue,
            `${msg} Waiting for ${prop} to be ${targetValue}.`
          );
        }
      }

      index++;
      if (index < aPropertyDeltas.length) {
        scheduleNext();
      } else {
        resolve();
      }
    };

    let scheduleNext = () => {
      if (aInstant) {
        next();
      } else {
        info(`${msg} Requesting animation frame.`);
        win.requestAnimationFrame(next);
      }
    };
    scheduleNext();
  });

  try {
    info(`${msg} Waiting for window to match the final state.`);
    await ContentTaskUtils.waitForCondition(
      () => sizeProps.concat(positionProps).every(p => win[p] == finalState[p]),
      "Waiting for final state."
    );
  } catch (e) {}

  info(`${msg} Checking final state.`);
  info(`${msg} Exepected: ${stringifyState(finalState)}`);
  info(`${msg} Actual:    ${stringifyState(win)}`);
  for (let prop of sizeProps.concat(positionProps)) {
    is(win[prop], finalState[prop], `${msg} Expected final value for ${prop}`);
  }

  win.removeEventListener("resize", resizeListener);
}

function roundedCenter(aDimension, aOrigin) {
  let center = aOrigin + Math.floor(aDimension / 2);
  return center - (center % 100);
}

class ResizeMoveTest {
  static WindowWidth = 200;
  static WindowHeight = 200;
  static WindowLeft = roundedCenter(screen.availWidth - 200, screen.left);
  static WindowTop = roundedCenter(screen.availHeight - 200, screen.top);

  static PropInfo = {
    sizeProps: ["outerWidth", "outerHeight", "innerWidth", "innerHeight"],
    positionProps: [
      "screenX",
      "screenY",
      /* readonly */ "mozInnerScreenX",
      /* readonly */ "mozInnerScreenY",
    ],
    readonlyProps: ["mozInnerScreenX", "mozInnerScreenY"],
    crossAxisMapping: {
      outerWidth: "outerHeight",
      outerHeight: "outerWidth",
      innerWidth: "innerHeight",
      innerHeight: "innerWidth",
      screenX: "screenY",
      screenY: "screenX",
      mozInnerScreenX: "mozInnerScreenY",
      mozInnerScreenY: "mozInnerScreenX",
    },
    crossBoundsMapping: {
      outerWidth: "innerWidth",
      outerHeight: "innerHeight",
      innerWidth: "outerWidth",
      innerHeight: "outerHeight",
      screenX: "mozInnerScreenX",
      screenY: "mozInnerScreenY",
      mozInnerScreenX: "screenX",
      mozInnerScreenY: "screenY",
    },
  };

  constructor(
    aPropertyDeltas,
    aInstant = false,
    aMsg = "ResizeMoveTest",
    aWaitForCompletion = false
  ) {
    this.propertyDeltas = aPropertyDeltas;
    this.instant = aInstant;
    this.msg = aMsg;
    this.waitForCompletion = aWaitForCompletion;

    // Allows to ignore positions while testing.
    this.ignorePositions = false;
    // Allows to ignore only mozInnerScreenX/Y properties while testing.
    this.ignoreMozInnerScreen = false;
    // Allows to skip checking the restored position after testing.
    this.ignoreRestoredPosition = false;

    if (AppConstants.platform == "linux" && !SpecialPowers.isHeadless) {
      // We can occasionally start the test while nsWindow reports a wrong
      // client offset (gdk origin and root_origin are out of sync). This
      // results in false expectations for the final mozInnerScreenX/Y values.
      this.ignoreMozInnerScreen = !ResizeMoveTest.hasCleanUpTask;

      let { positionProps } = ResizeMoveTest.PropInfo;
      let resizeOnlyTest = aPropertyDeltas.every(deltaObj =>
        positionProps.every(prop => deltaObj[prop] === undefined)
      );

      let isWayland = gfxInfo.windowProtocol == "wayland";
      if (resizeOnlyTest && isWayland) {
        // On Wayland we can't move the window in general. The window also
        // doesn't necessarily open our specified position.
        this.ignoreRestoredPosition = true;
        // We can catch bad screenX/Y at the start of the first test in a
        // window.
        this.ignorePositions = !ResizeMoveTest.hasCleanUpTask;
      }
    }

    if (!ResizeMoveTest.hasCleanUpTask) {
      ResizeMoveTest.hasCleanUpTask = true;
      registerCleanupFunction(ResizeMoveTest.Cleanup);
    }

    add_task(async () => {
      let tab = await ResizeMoveTest.GetOrCreateTab();
      let browsingContext =
        await ResizeMoveTest.GetOrCreatePopupBrowsingContext();
      if (!browsingContext) {
        return;
      }

      info("=== Running in content. ===");
      await this.run(browsingContext, `${this.msg} (content)`);
      await this.restorePopupState(browsingContext);

      info("=== Running in chrome. ===");
      let popupChrome = browsingContext.topChromeWindow;
      await this.run(popupChrome.browsingContext, `${this.msg} (chrome)`);
      await this.restorePopupState(browsingContext);

      info("=== Running in opener. ===");
      await this.run(tab.linkedBrowser, `${this.msg} (opener)`);
      await this.restorePopupState(browsingContext);
    });
  }

  async run(aBrowsingContext, aMsg) {
    let testType = this.instant ? "instant" : "fanned out";
    let msg = `${aMsg} (${testType})`;

    let propInfo = {};
    for (let k in ResizeMoveTest.PropInfo) {
      propInfo[k] = ResizeMoveTest.PropInfo[k];
    }
    if (this.ignoreMozInnerScreen) {
      todo(false, `[${aMsg}] Shouldn't ignore mozInnerScreenX/Y.`);
      propInfo.positionProps = propInfo.positionProps.filter(
        prop => !["mozInnerScreenX", "mozInnerScreenY"].includes(prop)
      );
    }
    if (this.ignorePositions) {
      todo(false, `[${aMsg}] Shouldn't ignore position.`);
      propInfo.positionProps = [];
    }

    info(`${msg}: ` + JSON.stringify(this.propertyDeltas));
    await SpecialPowers.spawn(
      aBrowsingContext,
      [
        this.propertyDeltas,
        this.instant,
        propInfo,
        msg,
        this.waitForCompletion,
      ],
      testPropertyDeltas
    );
  }

  async restorePopupState(aBrowsingContext) {
    info("Restore popup state.");

    let { deltaWidth, deltaHeight } = await SpecialPowers.spawn(
      aBrowsingContext,
      [],
      () => {
        return {
          deltaWidth: this.content.outerWidth - this.content.innerWidth,
          deltaHeight: this.content.outerHeight - this.content.innerHeight,
        };
      }
    );

    let chromeWindow = aBrowsingContext.topChromeWindow;
    let {
      WindowLeft: left,
      WindowTop: top,
      WindowWidth: width,
      WindowHeight: height,
    } = ResizeMoveTest;

    chromeWindow.resizeTo(width + deltaWidth, height + deltaHeight);
    chromeWindow.moveTo(left, top);

    await SpecialPowers.spawn(
      aBrowsingContext,
      [left, top, width, height, this.ignoreRestoredPosition],
      async (aLeft, aTop, aWidth, aHeight, aIgnorePosition) => {
        let win = this.content.wrappedJSObject;

        info("Waiting for restored size.");
        await ContentTaskUtils.waitForCondition(
          () => win.innerWidth == aWidth && win.innerHeight === aHeight,
          "Waiting for restored size."
        );
        is(win.innerWidth, aWidth, "Restored width.");
        is(win.innerHeight, aHeight, "Restored height.");

        if (!aIgnorePosition) {
          info("Waiting for restored position.");
          await ContentTaskUtils.waitForCondition(
            () => win.screenX == aLeft && win.screenY === aTop,
            "Waiting for restored position."
          );
          is(win.screenX, aLeft, "Restored screenX.");
          is(win.screenY, aTop, "Restored screenY.");
        } else {
          todo(false, "Shouldn't ignore restored position.");
        }
      }
    );
  }

  static async GetOrCreateTab() {
    if (ResizeMoveTest.tab) {
      return ResizeMoveTest.tab;
    }

    info("Opening tab.");
    ResizeMoveTest.tab = await BrowserTestUtils.openNewForegroundTab(
      window.gBrowser,
      "https://example.net/browser/browser/base/content/test/popups/popup_blocker_a.html"
    );
    return ResizeMoveTest.tab;
  }

  static async GetOrCreatePopupBrowsingContext() {
    if (ResizeMoveTest.popupBrowsingContext) {
      if (!ResizeMoveTest.popupBrowsingContext.isActive) {
        return undefined;
      }
      return ResizeMoveTest.popupBrowsingContext;
    }

    let tab = await ResizeMoveTest.GetOrCreateTab();
    info("Opening popup.");
    ResizeMoveTest.popupBrowsingContext = await SpecialPowers.spawn(
      tab.linkedBrowser,
      [
        ResizeMoveTest.WindowWidth,
        ResizeMoveTest.WindowHeight,
        ResizeMoveTest.WindowLeft,
        ResizeMoveTest.WindowTop,
      ],
      async (aWidth, aHeight, aLeft, aTop) => {
        let win = this.content.open(
          this.content.document.location.href,
          "_blank",
          `left=${aLeft},top=${aTop},width=${aWidth},height=${aHeight}`
        );
        this.content.popup = win;

        await new Promise(r => (win.onload = r));

        return win.browsingContext;
      }
    );

    return ResizeMoveTest.popupBrowsingContext;
  }

  static async Cleanup() {
    let browsingContext = ResizeMoveTest.popupBrowsingContext;
    if (browsingContext) {
      await SpecialPowers.spawn(browsingContext, [], () => {
        this.content.close();
      });
      delete ResizeMoveTest.popupBrowsingContext;
    }

    let tab = ResizeMoveTest.tab;
    if (tab) {
      await BrowserTestUtils.removeTab(tab);
      delete ResizeMoveTest.tab;
    }
    ResizeMoveTest.hasCleanUpTask = false;
  }
}

function chaosRequestLongerTimeout(aDoRequest) {
  if (aDoRequest && parseInt(Services.env.get("MOZ_CHAOSMODE"), 16)) {
    requestLongerTimeout(2);
  }
}

function createGenericResizeTests(aFirstValue, aSecondValue, aInstant, aMsg) {
  // Runtime almost doubles in chaos mode on Mac.
  chaosRequestLongerTimeout(AppConstants.platform == "macosx");

  let { crossBoundsMapping, crossAxisMapping } = ResizeMoveTest.PropInfo;

  for (let prop of ["innerWidth", "outerHeight"]) {
    // Mixing inner and outer property.
    for (let secondProp of [prop, crossBoundsMapping[prop]]) {
      let first = {};
      first[prop] = aFirstValue;
      let second = {};
      second[secondProp] = aSecondValue;
      new ResizeMoveTest(
        [first, second],
        aInstant,
        `${aMsg} ${prop},${secondProp}`
      );
    }
  }

  for (let prop of ["innerHeight", "outerWidth"]) {
    let first = {};
    first[prop] = aFirstValue;
    let second = {};
    second[prop] = aSecondValue;

    // Setting property of other axis before/between two changes.
    let otherProps = [
      crossAxisMapping[prop],
      crossAxisMapping[crossBoundsMapping[prop]],
    ];
    for (let interferenceProp of otherProps) {
      let interference = {};
      interference[interferenceProp] = 20;
      new ResizeMoveTest(
        [first, interference, second],
        aInstant,
        `${aMsg} ${prop},${interferenceProp},${prop}`
      );
      new ResizeMoveTest(
        [interference, first, second],
        aInstant,
        `${aMsg} ${interferenceProp},${prop},${prop}`
      );
    }
  }
}

function createGenericMoveTests(aInstant, aMsg) {
  // Runtime almost doubles in chaos mode on Mac.
  chaosRequestLongerTimeout(AppConstants.platform == "macosx");

  let { crossAxisMapping } = ResizeMoveTest.PropInfo;

  for (let prop of ["screenX", "screenY"]) {
    for (let [v1, v2, msg] of [
      [9, 10, `${aMsg}`],
      [11, 11, `${aMsg} repeat`],
      [12, 0, `${aMsg} revert`],
    ]) {
      let first = {};
      first[prop] = v1;
      let second = {};
      second[prop] = v2;
      new ResizeMoveTest([first, second], aInstant, `${msg} ${prop},${prop}`);

      let interferenceProp = crossAxisMapping[prop];
      let interference = {};
      interference[interferenceProp] = 20;
      new ResizeMoveTest(
        [first, interference, second],
        aInstant,
        `${aMsg} ${prop},${interferenceProp},${prop}`
      );
      new ResizeMoveTest(
        [interference, first, second],
        aInstant,
        `${msg} ${interferenceProp},${prop},${prop}`
      );
    }
  }
}
