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

"use strict";

const { CalEvent } = ChromeUtils.importESModule(
  "resource:///modules/CalEvent.sys.mjs"
);
const { cal } = ChromeUtils.importESModule(
  "resource:///modules/calendar/calUtils.sys.mjs"
);
const { CalendarTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/calendar/CalendarTestUtils.sys.mjs"
);
const { CalRecurrenceInfo } = ChromeUtils.importESModule(
  "resource:///modules/CalRecurrenceInfo.sys.mjs"
);
const { DEFAULT_DIALOG_MARGIN } = ChromeUtils.importESModule(
  "chrome://messenger/content/calendar-dialog.mjs",
  { global: "current" }
);

const { weekView } = CalendarTestUtils;
const SCREEN_MARGIN = 10;
const SMALL_WINDOW_WIDTH = 1300;
const SMALL_WINDOW_HEIGHT = 700;
const LARGE_WINDOW_WIDTH = window.screen.availWidth - SCREEN_MARGIN;
const LARGE_WINDOW_HEIGHT = window.screen.availHeight - SCREEN_MARGIN;
const todayDate = new Date();
const sizes = [
  { x: LARGE_WINDOW_WIDTH, y: LARGE_WINDOW_HEIGHT, name: "large" },
  { x: SMALL_WINDOW_WIDTH, y: SMALL_WINDOW_HEIGHT, name: "small" },
  { x: SMALL_WINDOW_WIDTH, y: LARGE_WINDOW_HEIGHT, name: "extra narrow" },
  { x: LARGE_WINDOW_WIDTH, y: SMALL_WINDOW_HEIGHT, name: "extra wide" },
  { y: LARGE_WINDOW_HEIGHT, name: "tall" },
  { x: LARGE_WINDOW_WIDTH, name: "wide" },
  { x: SMALL_WINDOW_WIDTH, name: "narrow" },
  { y: SMALL_WINDOW_HEIGHT, name: "short" },
  { name: "default" },
];

const scrollPositions = [
  { block: "start", inline: "start" },
  { block: "end", inline: "start" },
  { block: "start", inline: "end" },
  { block: "end", inline: "end" },
  { block: "center", inline: "start" },
  { block: "center", inline: "end" },
  { block: "end", inline: "center" },
  { block: "start", inline: "center" },
  { block: "center", inline: "center" },
];

const durationTests = [
  {
    duration: 3,
    hours: [0, 11, 21],
  },
  {
    duration: 6,
    hours: [0, 11, 18],
  },
  {
    duration: 12,
    hours: [0, 11],
  },
  {
    duration: 24,
    hours: [0],
  },
  {
    duration: 36,
    hours: [0, 12, 23],
  },
  {
    duration: 72,
    hours: [0, 12, 23],
  },
  {
    duration: 144,
    hours: [0, 12, 23],
  },
];

let count = 0;

todayDate.setHours(0);
todayDate.setMinutes(0);
todayDate.setSeconds(0);
todayDate.setMilliseconds(0);

/**
 * Creates and registers a new calendar with the calendar manager. The
 * created calendar will be set as the default calendar.
 *
 * @param {object} options - Options to create the calendar with.
 * @param {string} options.name [name="Test"] - Name.
 * @param {string} options.type [type="storage"] - Type.
 * @param {string} [options.color] - Color of the calendar.
 *
 * @returns {calICalendar}
 */
function createCalendar({
  name = `Test Event - ${count++}`,
  type = "storage",
  color,
} = {}) {
  const calendar = cal.manager.createCalendar(
    type,
    Services.io.newURI(`moz-${type}-calendar://`)
  );
  calendar.name = name;
  calendar.setProperty("calendar-main-default", true);
  // This is done so that calItemBase#isInvitation returns true.
  calendar.setProperty("organizerId", `mailto:organizer@example.com`);
  if (color) {
    calendar.setProperty("color", color);
  }
  cal.manager.registerCalendar(calendar);
  return calendar;
}

/**
 * Create an event item in the calendar.
 *
 * @param {object} options - Options to use in creating the event.
 * @param {string} [options.name="Test Event"] - The name of the event.
 * @param {Date} [options.baseDate] - Date the event start should be based on,
 *   defaults to midnight today.
 * @param {number} [options.offset=0] - The number of days from today to offset the
 *  event.
 * @param {number} [options.duration=1] - The duration of the event in hours.
 * @param {object} options.calendar - The calendar to create the event on.
 * @param {string[]} [options.categories=[]] - Categories to assign to the event.
 * @param {boolean} [options.repeats=false] - If the event is repeating.
 * @param {string} [options.location] - Location of the event. Only set if not
 *  falsy.
 *
 * @returns {CalEvent} - The created event.
 */
async function createEvent({
  name = "Test Event",
  baseDate = todayDate,
  offset = 0,
  duration = 1,
  calendar,
  categories = [],
  repeats = false,
  location,
} = {}) {
  let start = new Date(baseDate);
  start.setDate(baseDate.getDate() + offset);
  start = cal.dtz.jsDateToDateTime(start, 0);
  let end = new Date(baseDate);
  const days = Math.trunc(duration / 24);
  end.setHours(end.getHours() + (duration % 24) - 1);
  end.setMinutes(59);
  end.setSeconds(59);
  end.setMilliseconds(99);
  end.setDate(baseDate.getDate() + offset + days);
  end = cal.dtz.jsDateToDateTime(end, 0);
  const event = new CalEvent();
  event.title = name;
  event.startDate = start;
  event.endDate = end;

  if (repeats) {
    event.recurrenceInfo = new CalRecurrenceInfo(event);
    const rule = cal.createRecurrenceRule("RRULE:FREQ=DAILY;COUNT=30");
    event.recurrenceInfo.appendRecurrenceItem(rule);
  }

  event.setCategories(categories);

  if (location) {
    event.setProperty("LOCATION", location);
  }

  return calendar.addItem(event);
}

/**
 * Opens an event on the calendar.
 *
 * @param {HTMLElement} eventBox - Event box element to interact with.
 * @returns {HTMLElement}
 */
async function openEvent({ eventBox }) {
  const dblClickEvent = new MouseEvent("dblclick", {
    view: window,
    bubbles: true,
    cancelable: true,
  });
  const readyPromise = waitForCalendarReady();

  EventUtils.sendMouseEvent(dblClickEvent, eventBox, window);

  await readyPromise;

  return eventBox;
}

/**
 * Show an event on the calendar scrolling it into view.
 *
 * @param {object} options - Options to use for showing the event.
 * @param {Date} [options.baseDate] - Base date for the event to be at. Defaults
 *  to today.
 * @param {number} options.offset - The number of days offset the event is.
 * @param {"start" | "center" | "end"} options.inline - The scroll position in
 *  the inline direction.
 * @param {"start" | "center" | "end"} options.block - The scroll position in
 *  the block direction.
 *
 * @returns {HTMLElement}
 */
async function showEvent({
  baseDate = todayDate,
  offset = 0,
  block = "center",
  inline = "center",
} = {}) {
  const targetDate = new Date(baseDate);
  targetDate.setDate(targetDate.getDate() + offset);
  // Since from other tests we may be elsewhere, make sure we start today.
  await CalendarTestUtils.setCalendarView(window, "week");
  await CalendarTestUtils.goToDate(
    window,
    targetDate.getFullYear(),
    targetDate.getMonth() + 1,
    targetDate.getDate()
  );

  const eventBox = await weekView.waitForEventBoxAt(
    window,
    targetDate.getDay() + 1,
    1
  );

  eventBox.scrollIntoView({ behavior: "instant", block, inline });

  await TestUtils.waitForCondition(() => {
    const boxRect = eventBox.getBoundingClientRect();

    return boxRect.width !== 0 && boxRect.height !== 0;
  }, `event box is visible with dimensions`);

  await new Promise(eventBox.ownerGlobal.requestAnimationFrame);

  return eventBox;
}

/**
 * Show and open the dialog for an event on the calendar.
 *
 * @param {object} [options] - Options to use for showing the event.
 * @param {Date} [options.baseDate] - Base date the event is placed relative to.
 * @param {number} [options.offset] - The number of days the event is offset from
 *  today.
 * @param {"start" | "center" | "end"} [options.inline] - The scroll position in
 *  the inline direction.
 * @param {"start" | "center" | "end"} [options.block] - The scroll position in
 *  the block direction.
 *
 * @returns {HTMLElement}
 */
async function openAndShowEvent({
  baseDate = todayDate,
  offset = 0,
  block = "center",
  inline = "center",
} = {}) {
  const box = await showEvent({ baseDate, offset, block, inline });
  return openEvent({ eventBox: box, offset });
}

/**
 * Wait for the dialog element to exist.
 */
async function waitForCalendarReady() {
  await BrowserTestUtils.waitForMutationCondition(
    document.documentElement,
    {
      subtree: true,
      childList: true,
    },
    () => document.getElementById("calendarDialog")
  );
}

/**
 * Check that the dialog falls within the expected tollerances of the target
 * and the container elements.
 *
 * @param {HTMLElement} target - The target element to compare against.
 * @param {string} message - The assertion message to display.
 */
function checkTollerance(target, message) {
  const targetRect = target.getBoundingClientRect();
  const dialogRect = document
    .querySelector('[is="calendar-dialog"]')
    .getBoundingClientRect();
  const containerRect = document
    .getElementById("calendarDisplayBox")
    .getBoundingClientRect();

  const horizontalTarget =
    Math.round(targetRect.x - dialogRect.right) === DEFAULT_DIALOG_MARGIN ||
    Math.round(dialogRect.x - targetRect.right) === DEFAULT_DIALOG_MARGIN ||
    (targetRect.right > containerRect.right &&
      dialogRect.right === DEFAULT_DIALOG_MARGIN &&
      dialogRect.x >= DEFAULT_DIALOG_MARGIN) ||
    (targetRect.x <= containerRect.x &&
      dialogRect.x === DEFAULT_DIALOG_MARGIN &&
      dialogRect.right <= DEFAULT_DIALOG_MARGIN) ||
    dialogRect.x - containerRect.x === containerRect.right - dialogRect.right ||
    (targetRect.x >= dialogRect.x && targetRect.right <= dialogRect.right);
  const verticalTarget =
    Math.round(targetRect.y - dialogRect.bottom) === DEFAULT_DIALOG_MARGIN ||
    Math.round(dialogRect.y - targetRect.bottom) === DEFAULT_DIALOG_MARGIN ||
    (targetRect.bottom > containerRect.bottom &&
      Math.round(dialogRect.bottom) === DEFAULT_DIALOG_MARGIN &&
      dialogRect.y >= DEFAULT_DIALOG_MARGIN) ||
    (targetRect.y <= containerRect.y &&
      Math.round(dialogRect.y) === DEFAULT_DIALOG_MARGIN &&
      dialogRect.bottom <= DEFAULT_DIALOG_MARGIN) ||
    Math.round(dialogRect.y - containerRect.y) ===
      Math.round(containerRect.bottom - dialogRect.bottom) ||
    (targetRect.y <= dialogRect.y && targetRect.bottom >= dialogRect.bottom) ||
    (targetRect.y >= dialogRect.y && targetRect.y <= dialogRect.bottom);
  const verticalContainer =
    dialogRect.y - containerRect.y >= DEFAULT_DIALOG_MARGIN;
  const horizontalContainer =
    dialogRect.x - containerRect.x >= DEFAULT_DIALOG_MARGIN &&
    containerRect.right - dialogRect.right >= DEFAULT_DIALOG_MARGIN;

  if (
    !horizontalTarget ||
    !verticalTarget ||
    !horizontalContainer ||
    !verticalContainer
  ) {
    info(JSON.stringify({ dialogRect, targetRect, containerRect }, null, 2));
  }

  Assert.ok(
    horizontalContainer,
    `${message} - within horizontal container tollerance`
  );
  Assert.ok(
    horizontalTarget,
    `${message} - within horizontal target tollerance`
  );
  Assert.ok(
    verticalContainer,
    `${message} - within vertical container tollerance`
  );
  Assert.ok(verticalTarget, `${message} - within vertical  target tollerance`);
}

const originalWidth = window.outerWidth;
const originalHeight = window.outerHeight;

/**
 * Resize the window and wait for the next frame.
 *
 * @param {object} size - The size of the window in the x and y directions.
 * @param {number} size.x - The size in px in the x direction.
 * @param {number} size.y - The size in px in the y direction.
 *
 * @returns {Promise<void>}
 */
async function resizeWindow({ x = originalWidth, y = originalHeight }) {
  const { promise, resolve } = Promise.withResolvers();
  let timeout;
  function debounceCallback() {
    clearTimeout(timeout);

    timeout = setTimeout(resolve, 100);
  }

  window.addEventListener("resize", debounceCallback);

  window.resizeTo(x, y);

  const skipTimeout = setTimeout(resolve, 400);

  await promise;

  clearTimeout(skipTimeout);

  window.removeEventListener("resize", debounceCallback);

  await new Promise(requestAnimationFrame);
}

/**
 * Run a positioning test based on a given set of options.
 *
 * @param {options} options - The options to use for the test.
 * @param {number} options.duration - The duration of the event in hours.
 * @param {number} options.offset - The the number of days to offset the event.
 * @param {number} options.hour - The hour of the day for the event.
 * @param {object} options.size - The window size object used for the test.
 */
async function positionTest({ calendar, duration = 1, offset, hour, size }) {
  const fixedDate = new Date(todayDate);
  fixedDate.setFullYear(2025);
  fixedDate.setMonth(3);
  fixedDate.setDate(6);
  fixedDate.setHours(hour);
  await createEvent({ calendar, baseDate: fixedDate, offset, duration });
  let eventBox;

  for (const position of scrollPositions) {
    eventBox = await openAndShowEvent({
      ...position,
      baseDate: fixedDate,
      offset,
    });

    checkTollerance(
      eventBox,
      `Duration: ${duration} - Offset: ${offset} - Hour: ${hour} - Window ${size.name} - Position ${JSON.stringify(position)}`
    );
  }

  document.querySelector('[is="calendar-dialog"]')?.close();
  await calendar.deleteItem(eventBox.occurrence);
}

/**
 * Setup a positioning test.
 */
async function setupPositioning() {
  // While the positioning tests are optimized to run at a very high rate
  // (~20k/min), the number of tests required for full coverage of possible
  // position, duration, size, scroll position, etc combinations, means we can
  // still hit the test timeout so increasing here for reliability.
  requestLongerTimeout(3);
  const style = document.createElement("style");
  style.textContent = `[is="calendar-dialog"] { height: 476px; }`;
  document.head.appendChild(style);

  await CalendarTestUtils.setCalendarView(window, "week");
}

/**
 * Test dialog positions with different screen sizes and durations
 *
 * @param {object} size - The size of the screen to test
 */
async function testDurations(size) {
  const calendar = createCalendar();

  await resizeWindow(size);
  for (const offset of [0, 1, 2, 3, 4, 5, 6]) {
    for (const { duration, hours } of durationTests) {
      for (const hour of hours) {
        await positionTest({ calendar, duration, hour, offset, size });
      }
    }
  }

  window.moveTo(0, 0);
  await resizeWindow(originalWidth, originalHeight);

  CalendarTestUtils.removeCalendar(calendar);
}

/**
 * Test dialog positions with different screensizes
 *
 * @param {object[]} windowSizes
 */
async function runPositioningTest(windowSizes) {
  const calendar = createCalendar();

  for (const size of windowSizes) {
    await resizeWindow(size);

    for (const offset of [0, 1, 2, 3, 4, 5, 6]) {
      for (const hour of [0, 6, 12, 18, 23]) {
        await positionTest({ calendar, hour, offset, size });
      }
    }

    window.moveTo(0, 0);
    await resizeWindow(originalWidth, originalHeight);
  }

  CalendarTestUtils.removeCalendar(calendar);
}
