import { Color, DOMCoordinates, HeaderIndex, Pixel } from "../../src";
import { iterateChildren } from "../../src/components/helpers/dom_helpers";
import { HEADER_HEIGHT, HEADER_WIDTH } from "../../src/constants";
import { toHex } from "../../src/helpers/color";
import { lettersToNumber, toCartesian } from "../../src/helpers/coordinates";
import { MIN_DELAY, scrollDelay } from "../../src/helpers/edge_scrolling";
import { ViewportCollection } from "../../src/helpers/viewport_collection";
import { positionToZone, toZone } from "../../src/helpers/zones";
import { ViewportsStore } from "../../src/stores/viewports_store";
import { ZoomStore } from "../../src/stores/zoom_store";
import { SpreadsheetChildEnv } from "../../src/types/spreadsheet_env";
import { nextTick } from "./helpers";

export type DOMTarget = string | Element | Document | Window | null;

export async function simulateClick(
  selector: DOMTarget,
  x: number = 10,
  y: number = 10,
  extra: MouseEventInit = { bubbles: true, cancelable: true },
  mode: "click" | "doubleClick" = "click"
) {
  const target = getTarget(selector);
  const pointerDownEv = triggerMouseEvent(selector, "pointerdown", x, y, extra);
  if (target !== document.activeElement) {
    const oldActiveEl = document.activeElement;
    // Only trigger blur if  the pointerdown event was not prevented
    if (!pointerDownEv.defaultPrevented) {
      (document.activeElement as HTMLElement | null)?.dispatchEvent(
        new FocusEvent("blur", { relatedTarget: getFocusableParent(target) })
      );
    }
    /** Dispatching a crafted FocusEvent does not actually focus the target.
     * JSDom pretty much requires us to rely on Element.focus()
     * Because of the problematic behavior addressed in 71a9c8c2,
     * we have to check a posteriori if the target has been properly focused
     * and if not, dispatch a FocusEvent with the relatedTarget to ensure a proper
     * fallback behaviour.
     */
    if (target instanceof HTMLElement) {
      target.focus();
    }
    if (document.activeElement !== target) {
      target.dispatchEvent(new FocusEvent("focus", { relatedTarget: oldActiveEl }));
    }
  }
  triggerMouseEvent(selector, "pointerup", x, y, extra);
  triggerMouseEvent(selector, "click", x, y, extra);
  if (mode === "doubleClick") {
    triggerMouseEvent(selector, "pointerdown", x, y, extra);
    triggerMouseEvent(selector, "pointerup", x, y, extra);
    triggerMouseEvent(selector, "click", x, y, extra);
    triggerMouseEvent(selector, "dblclick", x, y, extra);
  }
  await nextTick();
}

function getFocusableParent(el: Element | Document | Window): Element | undefined {
  if (el instanceof Window) {
    return el.document.body;
  }
  if (el instanceof Document) {
    return el.body;
  }
  if (el.attributes["tabindex"] || el.attributes["contenteditable"]) {
    return el;
  }
  if (
    el.tagName === "A" ||
    el.tagName === "BUTTON" ||
    el.tagName === "INPUT" ||
    el.tagName === "TEXTAREA"
  ) {
    return el;
  }
  return el.parentElement ? getFocusableParent(el.parentElement) : undefined;
}

export function getTarget(target: DOMTarget): Element | Document | Window {
  if (target === null) {
    throw new Error("Target is null");
  }
  if (typeof target === "string") {
    // TODO: use `findElement` instead, and fix tests w/ multiple matched elements
    const els = document.querySelectorAll(target);
    if (els.length === 0) {
      throw new Error(`No element found (selector: ${target})`);
    }
    return els[0];
  } else {
    return target;
  }
}

function findElement(el: Element, selector: string): Element {
  let target = el;
  if (selector) {
    const els = el.querySelectorAll(selector);
    if (els.length === 0) {
      throw new Error(`No element found (selector: ${selector})`);
    }
    if (els.length > 1) {
      throw new Error(`Found ${els.length} elements, instead of 1 (selector: ${selector})`);
    }
    target = els[0];
  }
  return target;
}

export async function click(el: Element, selector: string = "") {
  const event = new MouseEvent("click", { bubbles: true, cancelable: true });
  const target = findElement(el, selector);
  target.dispatchEvent(event);
  await nextTick();
}

export async function doubleClick(el: Element, selector: string = "") {
  const event = new MouseEvent("dblclick", { bubbles: true, cancelable: true });
  const target = findElement(el, selector);
  target.dispatchEvent(event);
  await nextTick();
}

export async function pointerDown(target: DOMTarget) {
  triggerMouseEvent(target, "pointerdown");
  await nextTick();
}

export async function pointerUp(target: DOMTarget) {
  triggerMouseEvent(target, "pointerup");
  await nextTick();
}

/**
 * Simulate hovering a cell for a given amount of time.
 * Don't forget to use `useJestFakeTimers();` when using
 * this helper.
 */
export async function hoverCell(
  env: SpreadsheetChildEnv,
  xc: string,
  delay: number,
  viewports?: ViewportCollection
) {
  const model = env.model;
  const zone = toZone(xc);
  viewports = viewports || env.getStore(ViewportsStore).viewports;
  const zoom = viewports.getZoomLevel();
  let { x, y, width, height } = viewports.getVisibleRectWithZoom(
    model.getters.getActiveSheetId(),
    zone
  );
  x -= viewports.getGridOffsetX() * zoom;
  y -= viewports.getGridOffsetY() * zoom;
  x += width / 2;
  y += height / 2;
  triggerMouseEvent(".o-grid-overlay", "pointermove", x, y);
  jest.advanceTimersByTime(delay);
  await nextTick();
}

export async function clickCell(
  env: SpreadsheetChildEnv,
  xc: string,
  extra: MouseEventInit = { bubbles: true },
  option: {
    clickInMiddle?: boolean;
    offsetX?: number;
    offsetY?: number;
    doubleClick?: boolean;
  } = {
    clickInMiddle: false,
    offsetX: 0,
    offsetY: 0,
  }
) {
  const viewStore = env.getStore(ViewportsStore);
  const zone = toZone(xc);
  const sheetId = env.model.getters.getActiveSheetId();
  const zoom = env.getStore(ZoomStore).zoomLevel;
  if (!viewStore.viewports.isVisibleInViewport({ sheetId, col: zone.left, row: zone.top })) {
    throw new Error(`You can't click on ${xc} because it is not visible`);
  }
  let { x, y, width, height } = viewStore.viewports.getVisibleRectWithZoom(sheetId, zone);
  x -= viewStore.gridOffset.x * zoom;
  y -= viewStore.gridOffset.y * zoom;
  if (option.clickInMiddle) {
    x += width / 2;
    y += height / 2;
  }
  x += option.offsetX || 0;
  y += option.offsetY || 0;
  const clickMode = option.doubleClick ? "doubleClick" : "click";
  await simulateClick(".o-grid-overlay", Math.ceil(x), Math.ceil(y), extra, clickMode);
}

export async function clickHeader(
  env: SpreadsheetChildEnv,
  dim: "COL" | "ROW",
  header: HeaderIndex,
  extra: MouseEventInit = { bubbles: true }
) {
  const model = env.model;
  let x = 1;
  let y = 1;
  const sheetZone = model.getters.getSheetZone(model.getters.getActiveSheetId());
  if (dim === "COL") {
    x = env
      .getStore(ViewportsStore)
      .viewports.getVisibleRectWithZoom(model.getters.getActiveSheetId(), {
        left: header,
        right: header,
        top: sheetZone.top,
        bottom: sheetZone.bottom,
      }).x;
  } else {
    y = env
      .getStore(ViewportsStore)
      .viewports.getVisibleRectWithZoom(model.getters.getActiveSheetId(), {
        left: sheetZone.left,
        right: sheetZone.right,
        top: header,
        bottom: header,
      }).y;
  }
  await simulateClick(".o-grid-overlay", x, y, extra);
}

export function getGridIconEventPosition(env: SpreadsheetChildEnv, xc: string) {
  const model = env.model;
  const viewStore = env.getStore(ViewportsStore);
  const sheetId = viewStore.displayedSheetId;
  const position = { ...toCartesian(xc), sheetId };
  const icon = model.getters.getCellIcons(position)[0];
  if (!icon) {
    throw new Error(`No icon inside cell ${xc}`);
  }
  const gridOffset = viewStore.gridOffset;
  const merge = model.getters.getMerge(position);
  const zone = merge || positionToZone(position);
  const cellRect = viewStore.viewports.getRect(sheetId, zone);
  const rect = model.getters.getCellIconRect(icon, cellRect);
  const x = rect.x + rect.width / 2 - gridOffset.x;
  const y = rect.y + rect.height / 2 - gridOffset.y;
  return { x, y };
}

export async function clickGridIcon(env: SpreadsheetChildEnv, xc: string) {
  const { x, y } = getGridIconEventPosition(env, xc);
  await simulateClick(".o-grid-overlay", x, y);
}

export async function hoverGridIcon(env: SpreadsheetChildEnv, xc: string) {
  const { x, y } = getGridIconEventPosition(env, xc);
  triggerMouseEvent(".o-grid-overlay", "pointermove", x, y);
  await nextTick();
}

export async function gridMouseEvent(
  env: SpreadsheetChildEnv,
  type: string,
  xc: string,
  extra: MouseEventInit = { bubbles: true }
) {
  const model = env.model;
  const zone = toZone(xc);
  let { x, y } = env
    .getStore(ViewportsStore)
    .viewports.getVisibleRect(model.getters.getActiveSheetId(), zone);
  if (!model.getters.isDashboard()) {
    x -= HEADER_WIDTH;
    y -= HEADER_HEIGHT;
  }
  triggerMouseEvent(".o-grid-overlay", type, x, y, extra);
  await nextTick();
}

export async function rightClickCell(
  env: SpreadsheetChildEnv,
  xc: string,
  extra: MouseEventInit = { bubbles: true }
) {
  await gridMouseEvent(env, "contextmenu", xc, extra);
}

export function triggerMouseEvent(
  selector: DOMTarget,
  type: string,
  offsetX?: number,
  offsetY?: number,
  extra: MouseEventInit = { bubbles: true }
) {
  if (type === "pointermove") {
    extra = { button: -1, ...extra };
  }
  const ev = new MouseEvent(type, {
    // this is only correct if we assume the target is positioned
    // at the very top left corner of the screen
    clientX: offsetX,
    clientY: offsetY,
    bubbles: true,
    ...extra,
  });
  Object.defineProperty(ev, "offsetX", { value: offsetX });
  Object.defineProperty(ev, "offsetY", { value: offsetY });
  const target = getTarget(selector);
  target.dispatchEvent(ev);
  return ev;
}

export function triggerWheelEvent(
  selector: string | EventTarget,
  extra: WheelEventInit = {
    bubbles: true,
    deltaMode: WheelEvent.DOM_DELTA_PIXEL, // = 0
    deltaX: 0,
    deltaY: 0,
  }
) {
  const ev = new WheelEvent("wheel", { bubbles: true, ...extra });
  dispatchEvent(selector, ev);
}

export function triggerKeyboardEvent(
  selector: string | EventTarget,
  type: "keydown" | "keyup",
  eventArgs: KeyboardEventInit
) {
  const ev = new KeyboardEvent(type, { bubbles: true, cancelable: true, ...eventArgs });
  dispatchEvent(selector, ev);
  return ev;
}

export function triggerPointerEvent(
  selector: DOMTarget,
  type: string,
  offsetX?: number,
  offsetY?: number,
  extra: PointerEventInit = { bubbles: true }
) {
  if (type === "pointermove") {
    extra = { button: -1, ...extra };
  }
  const ev = new PointerEvent(type, {
    // this is only correct if we assume the target is positioned
    // at the very top left corner of the screen
    clientX: offsetX,
    clientY: offsetY,
    bubbles: true,
    ...extra,
  });
  Object.defineProperty(ev, "offsetX", { value: offsetX });
  Object.defineProperty(ev, "offsetY", { value: offsetY });
  const target = getTarget(selector);
  target.dispatchEvent(ev);
  return ev;
}

function dispatchEvent(selector: string | EventTarget, ev: Event) {
  if (typeof selector === "string") {
    const el = document.querySelector(selector);
    if (!el) {
      throw new Error(`"${selector}" does not match any element.`);
    }
    el.dispatchEvent(ev);
  } else {
    selector.dispatchEvent(ev);
  }
}

export async function setInputValueAndTrigger(
  selector: DOMTarget,
  value: string,
  mode?: "onlyInput" | "onlyChange"
) {
  const input = getTarget(selector) as HTMLInputElement;
  input.value = value;
  if (mode !== "onlyChange") {
    input.dispatchEvent(new Event("input"));
  }
  if (mode !== "onlyInput") {
    input.dispatchEvent(new Event("change"));
  }
  await nextTick();
}

export function setCheckboxValueAndTrigger(
  selector: string | any,
  checked: boolean,
  eventType: string
): void {
  const checkbox = getTarget(selector) as HTMLInputElement;
  checkbox.checked = checked;
  checkbox.dispatchEvent(new Event(eventType));
}

/** In the past, both keyDown and keyUp were awaiting two `nextTick` instead of one.
 * The reason is believed to be a hack trying to address some indeterministic errors in our tests, in vain.
 * Those indeterminisms were properly fixed afterwards which meant we could theoretically get rid of the
 * superfluous `nextTick`.
 *
 * This comment is meant to leave a trace of this change in case some issues were to arise again.
 */
export async function keyDown(eventArgs: KeyboardEventInit): Promise<void> {
  triggerKeyboardEvent(document.activeElement!, "keydown", eventArgs);
  return await nextTick();
}

export async function keyUp(eventArgs: KeyboardEventInit): Promise<void> {
  triggerKeyboardEvent(document.activeElement!, "keyup", eventArgs);
  return await nextTick();
}

export async function focusAndKeyDown(
  selector: DOMTarget,
  eventArgs: KeyboardEventInit
): Promise<void> {
  const target = getTarget(selector);
  (target as HTMLElement).focus?.();
  triggerKeyboardEvent(target, "keydown", eventArgs);
  return await nextTick();
}

export async function focusAndKeyUp(
  selector: DOMTarget,
  eventArgs: KeyboardEventInit
): Promise<void> {
  const target = getTarget(selector);
  (target as HTMLElement).focus?.();
  triggerKeyboardEvent(target, "keyup", eventArgs);
  return await nextTick();
}

export function getElComputedStyle(selector: DOMTarget, style: string): string {
  const element = getTarget(selector);
  if (!element || !(element instanceof Element)) {
    throw new Error(`No element matching selector "${selector}"`);
  }
  return window.getComputedStyle(element)[style];
}

export function getElStyle(selector: string, style: string): string {
  const element = document.querySelector<HTMLElement>(selector);
  if (!element) {
    throw new Error(`No element matching selector "${selector}"`);
  }
  return element.style[style];
}

/**
 * Select a column
 * @param model
 * @param letter Name of the column to click on (Starts at 'A')
 * @param extra shiftKey, ctrlKey
 */
export async function selectColumnByClicking(
  env: SpreadsheetChildEnv,
  letter: string,
  extra: any = {}
) {
  const model = env.model;
  const index = lettersToNumber(letter);
  const zoom = env.getStore(ZoomStore).zoomLevel;
  const x =
    model.getters.getColDimensions(model.getters.getActiveSheetId(), index)!.start * zoom + 1;
  triggerMouseEvent(".o-overlay .o-col-resizer", "pointermove", x, 10);
  await nextTick();
  triggerMouseEvent(".o-overlay .o-col-resizer", "pointerdown", x, 10, extra);
  triggerMouseEvent(window, "pointerup", x, 10);
  await nextTick();
}

export async function clickAndDrag(
  element: Element | string,
  dragOffset: DOMCoordinates,
  startingPosition: DOMCoordinates = { x: 0, y: 0 },
  mouseUp = false,
  shiftKey = false
) {
  const { x: startX, y: startY } = startingPosition;
  const { x: offsetX, y: offsetY } = dragOffset;
  triggerMouseEvent(element, "pointerdown", startX, startY, { shiftKey });
  triggerMouseEvent(element, "pointermove", startX + offsetX, startY + offsetY, { shiftKey });
  if (mouseUp) {
    triggerMouseEvent(element, "pointerup", startX + offsetX, startY + offsetY, { shiftKey });
  }
  await nextTick();
}

export async function scrollGrid(args: { deltaY?: number; shiftKey?: boolean }) {
  triggerWheelEvent(".o-grid", { deltaY: args.deltaY || 0, shiftKey: args.shiftKey });
  await nextTick();
}

/**
 *
 * @param scrollDistance distance of cursor from the edge
 * @param iterations number of time to trigger the "mouseMove" callback
 * @returns number
 */
export function edgeScrollDelay(scrollDistance: Pixel, iterations: number) {
  return scrollDelay(Math.abs(Math.round(scrollDistance))) * (iterations + 1) - MIN_DELAY / 2;
}

export function getComposerColors(composerEl: Element) {
  const colors: Record<string, Color> = {};
  for (const el of iterateChildren(composerEl)) {
    const _el = el as HTMLElement;
    if (_el.tagName === "SPAN") {
      const color = getElComputedStyle(_el, "color");
      colors[_el.textContent!] = color.startsWith("light-dark") ? color : toHex(color);
    }
  }
  return colors;
}

export function getTextNodes(el: Element): Text[] {
  return [...iterateChildren(el)].filter((node) => node.nodeType === Node.TEXT_NODE) as Text[];
}

/**
 * The Touch API is defined in typescript ^3.7.7 and implemented in most major browsers, but isn't implemented in JSDOM.
 * This implementation is used in test to easily trigger TouchEvents.
 * (TouchEvent is supported by almost all major browsers.)
 *
 * https://developer.mozilla.org/en-US/docs/Web/API/Touch
 */
export class Touch {
  readonly altitudeAngle: number;
  readonly azimuthAngle: number;
  readonly clientX: number;
  readonly clientY: number;
  readonly force: number;
  readonly identifier: number;
  readonly pageX: number;
  readonly pageY: number;
  readonly radiusX: number;
  readonly radiusY: number;
  readonly rotationAngle: number;
  readonly screenX: number;
  readonly screenY: number;
  readonly target: EventTarget;
  readonly touchType: TouchType;

  constructor(touchInitDict: TouchInit) {
    this.identifier = touchInitDict.identifier;
    this.target = touchInitDict.target;
    this.altitudeAngle = touchInitDict.altitudeAngle || 0;
    this.azimuthAngle = touchInitDict.azimuthAngle || 0;
    this.clientX = touchInitDict.clientX || 0;
    this.clientY = touchInitDict.clientY || 0;
    this.force = touchInitDict.force || 0;
    this.pageX = touchInitDict.pageX || 0;
    this.pageY = touchInitDict.pageY || 0;
    this.radiusX = touchInitDict.radiusX || 0;
    this.radiusY = touchInitDict.radiusY || 0;
    this.rotationAngle = touchInitDict.rotationAngle || 0;
    this.screenX = touchInitDict.screenX || 0;
    this.screenY = touchInitDict.screenY || 0;
    this.touchType = touchInitDict.touchType || "direct";
  }
}

export function triggerTouchEvent(
  selector: string | EventTarget,
  type: "touchstart" | "touchend" | "touchmove",
  extra: Partial<Touch>
) {
  const target = typeof selector === "string" ? document.querySelector(selector) : selector;
  if (!target) {
    throw new Error(`"${selector}" does not match any element.`);
  }

  const ev = new TouchEvent(type, {
    cancelable: true,
    bubbles: true,
    touches: [
      new Touch({
        identifier: 1,
        target: target,
        ...extra,
      }),
    ],
  });
  target.dispatchEvent(ev);
}

/** Get the value of an HTML input or select Element */
export function getHTMLInputValue(target: DOMTarget): string {
  const input = getTarget(target) as HTMLInputElement;
  return input.value;
}

export function getHTMLCheckboxValue(target: DOMTarget): boolean {
  const checkbox = getTarget(target) as HTMLInputElement;
  return checkbox.checked;
}

export function getHTMLRadioValue(target: DOMTarget): string {
  const radio = getTarget(target) as HTMLInputElement;
  return radio.querySelector<HTMLInputElement>("input:checked")!.value;
}

export function getRoundColorPickerValue(selector: string) {
  const color = document
    .querySelector<HTMLElement>(selector)!
    .querySelector<HTMLElement>(".o-round-color-picker-button")?.style.background;
  return toHex(color ?? "");
}

export async function changeRoundColorPickerColor(selector: string, color: string | undefined) {
  const button = document
    .querySelector<HTMLElement>(selector)!
    .querySelector(".o-round-color-picker-button")!;
  await click(button);
  if (!color) {
    await click(document.body, ".o-color-picker .o-cancel");
  } else {
    await click(document.body, `.o-color-picker-line-item[data-color='${color}'`);
  }
}

export function getColorPickerWidgetColor(selector: string, widgetTitle: string) {
  const el = document
    .querySelector<HTMLElement>(selector)!
    .querySelector<HTMLElement>(`.o-color-picker-button[title="${widgetTitle}"] .o-colored-border`);

  const color = el?.style.borderColor;
  return toHex(color ?? "");
}

export async function changeColorPickerWidgetColor(
  selector: string,
  widgetTitle: string,
  color: string
) {
  const widgetButton = document
    .querySelector<HTMLElement>(selector)!
    .querySelector<HTMLElement>(`.o-color-picker-button[title="${widgetTitle}"]`)!;

  await click(widgetButton);
  await click(document.querySelector(`.o-color-picker-line-item[data-color="${color}"]`)!);
}

/**
 * Change the value of a Select component (that opens a popover on click instead of using the native HTML select)
 */
export async function editSelectComponent(selector: DOMTarget, value: string) {
  await simulateClick(selector);
  await simulateClick(`.o-popover .o-select-option[data-id="${value}"]`);
}
