/**
 * @import { DetachedWindowOptions } from "../_types.mjs";
 */

/**
 * @typedef DetachedWindowDescriptor
 * @property {WindowProxy} window                       The window instance.
 * @property {Map<string, ApplicationV2>} applications  A mapping of applications rendered in the window.
 */

/**
 * A singleton class that provides an API for spawning and managing detached windows.
 */
export default class DetachedWindowManager {
  constructor() {
    if ( globalThis.foundry?.applications.detached ) {
      throw new Error("You may not re-construct the singleton DetachedWindowManager.");
    }
  }

  /* -------------------------------------------- */
  /*  Properties                                  */
  /* -------------------------------------------- */

  /**
   * The currently-focused window.
   * @type {WindowProxy|null}
   */
  get focused() {
    if ( document.hasFocus() ) return document.defaultView;
    for ( const { window: win } of this.#windows.values() ) {
      if ( win.document.hasFocus() ) return win;
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * A registry of detached window instances.
   * @type {Map<string, DetachedWindowDescriptor>}
   */
  get windows() {
    return this.#windows;
  }

  #windows = new Map();

  /* -------------------------------------------- */
  /*  Public API                                  */
  /* -------------------------------------------- */

  /**
   * Check if a detached window contains any applications, and closes it if there are none.
   * @param {WindowProxy} win  The window.
   */
  checkEmpty(win) {
    if ( win?.id && !this.#windows.get(win.id)?.applications?.size ) win.close();
  }

  /* -------------------------------------------- */

  /**
   * Open a window detached from the main application window.
   * @param {DetachedWindowOptions} [options]
   * @returns {Promise<WindowProxy>}
   */
  openWindow({ id, position={}, timeout=10_000, source=window }={}) {
    id ??= foundry.utils.randomID();
    const { top=0, left=0, width=1024, height=1024 } = position;
    const attrs = ["popup", Object.entries({ top, left, width, height }).map(([k, v]) => {
      return `${k}=${Math.max(v, 0)}`;
    })].join(",");
    // If the source is a known detached window, dispatch a custom event so that window.open() is called from within
    // that window's execution context.
    let win;
    if ( this.#windows.has(source.id) ) {
      const event = new CustomEvent("foundry:openChildWindow", { detail: { id, attrs } });
      source.document.dispatchEvent(event);
      win = event.result ?? null;
    }
    else win = source.open("detached/index.html", id, attrs);
    const { promise, resolve, reject } = Promise.withResolvers();
    if ( !win || win.closed ) {
      focus();
      ui.notifications.error("ERROR.DETACHED.PopupBlocked", { localize: true, console: false });
      reject(new Error(`Failed to load detached window '${id}'.`));
      return promise;
    }
    const wait = setTimeout(() => {
      ui.notifications.error("ERROR.DETACHED.TimedOut", { localize: true, console: false });
      reject(new Error(`Attempting to load detached window '${id}' timed out.`));
      win?.close();
    }, timeout);
    win.id = id;
    win.addEventListener("DOMContentLoaded", async () => {
      clearTimeout(wait);
      try {
        await this.#applyHarness(win);
      } catch(err) {
        return reject(err);
      }
      this.#windows.set(id, { window: win, applications: new Map() });
      Hooks.callAll("openDetachedWindow", id, win);
      resolve(win);
    }, { once: true, passive: true });
    return promise;
  }

  /* -------------------------------------------- */
  /*  Helpers                                     */
  /* -------------------------------------------- */

  /**
   * Adopt nodes into a new host window and append them to the given target.
   * @param {HTMLElement} target  The target.
   * @param {...Node} nodes       The nodes.
   */
  adoptNodes(target, ...nodes) {
    const document = target.ownerDocument;
    for ( const node of nodes ) target.append(document.adoptNode(node));
  }

  /* -------------------------------------------- */

  /**
   * Copy attributes between two elements.
   * @param {HTMLElement} from                  The element to copy from.
   * @param {HTMLElement} to                    The element to copy to.
   * @param {object} [options]
   * @param {Iterable<string>} [options.attrs]  Copy only the specified attributes.
   */
  copyAttributes(from, to, { attrs }={}) {
    for ( const attr of attrs ?? from.attributes ) {
      const name = typeof attr === "string" ? attr : attr.name;
      to.setAttribute(name, from.getAttribute(name));
    }
  }

  /* -------------------------------------------- */

  /**
   * Import nodes into a new host window and append them to the given target.
   * @param {HTMLElement} target  The target.
   * @param {...Node} nodes       The nodes.
   */
  importNodes(target, ...nodes) {
    const document = target.ownerDocument;
    for ( const node of nodes ) target.append(document.importNode(node, true));
  }

  /* -------------------------------------------- */

  /**
   * Performs a DOM query across all detached windows and the main workspace, and return the first match.
   * @param {string} selector  The query selector.
   * @returns {HTMLElement|null}
   */
  querySelector(selector) {
    const match = document.querySelector(selector);
    if ( match ) return match;
    for ( const { window: win } of this.windows.values() ) {
      const match = win.document.querySelector(selector);
      if ( match ) return match;
    }
    return null;
  }

  /* -------------------------------------------- */

  /**
   * Perform a DOM query across all detached windows and the main workspace, and return all matches.
   * @param {string} selector  The query selector.
   * @returns {HTMLElement[]}
   */
  querySelectorAll(selector) {
    const matches = [...document.querySelectorAll(selector)];
    for ( const { window: win } of this.windows.values() ) {
      matches.push(...win.document.querySelectorAll(selector));
    }
    return matches;
  }

  /* -------------------------------------------- */
  /*  Private API                                 */
  /* -------------------------------------------- */

  /**
   * Apply scaffolding to allow Applications rendered in the given window to operate correctly.
   * @param {WindowProxy} win  The detached window.
   * @returns {Promise<void>}
   */
  async #applyHarness(win) {
    const parent = globalThis.document;
    const { document } = win;

    // Set the route prefix.
    const base = document.createElement("base");
    base.href = foundry.utils.getRoute("/");
    document.head.append(base);

    // Add icon.
    const icon = document.createElement("link");
    Object.assign(icon, { rel: "icon", href: "icons/vtt.png" });
    document.head.append(icon);

    // Sync DOM.
    this.copyAttributes(parent.documentElement, document.documentElement);
    this.copyAttributes(parent.body, document.body);
    document.body.classList.add("detached");

    // Import styles.
    this.importNodes(document.head, ...parent.head.querySelectorAll('link[rel="stylesheet"], style'));

    // Apply harness.
    const harness = document.createElement("script");
    const harnessLoaded = Promise.withResolvers();
    harness.onload = harnessLoaded.resolve;
    harness.onerror = () => harnessLoaded.reject(new Error("Failed to load detached window harness."));
    Object.assign(harness, { src: "scripts/detached/harness.mjs", type: "module" });
    document.head.append(harness);
    await harnessLoaded.promise;

    // Bind global listeners.
    game.activateListeners(document);
    game.keyboard._activateListeners(document);
    foundry.applications.ux.ContextMenu.implementation.activateListeners(document);
    foundry.applications.ux.Autocomplete.implementation.activateListeners(document);
    foundry.prosemirror.ProseMirrorMenu.activateListeners(document);

    // Re-register custom elements.
    Object.values(foundry.applications.elements).forEach(Element => {
      if ( Element.tagName ) win.customElements.define(Element.tagName, Element);
    });

    // Load fonts.
    await foundry.applications.settings.menus.FontConfig._loadFonts({ document });
  }

  /* -------------------------------------------- */

  /**
   * Handle tear-down when a detached window is closed.
   * @param {WindowProxy} win  The window instance.
   * @internal
   */
  _onWindowClosed(win) {
    setTimeout(() => {
      if ( win.closed ) {
        const { applications } = this.#windows.get(win.id) ?? {};
        this.#windows.delete(win.id);
        applications?.values().forEach(app => app.close());
        Hooks.callAll("closeDetachedWindow", win.id, win);
      }
    }, 1000);
  }
}
