import {DocumentSheetV2, HandlebarsApplicationMixin} from "../api/_module.mjs";
import FormDataExtended from "../ux/form-data-extended.mjs";
import Scene from "@client/documents/scene.mjs";
import Hooks from "@client/helpers/hooks.mjs";
import DragDrop from "../ux/drag-drop.mjs";
import TextEditor from "../ux/text-editor.mjs";

/**
 * @import {ApplicationClickAction, FormFooterButton} from "../_types.mjs";
 * @import EmbeddedCollection from "@common/abstract/embedded-collection.mjs";
 */

/**
 * The Application responsible for configuring a single Scene document.
 * @extends DocumentSheetV2
 * @mixes HandlebarsApplication
 */
export default class SceneConfig extends HandlebarsApplicationMixin(DocumentSheetV2) {

  /** @inheritDoc */
  static DEFAULT_OPTIONS = {
    classes: ["scene-config"],
    window: {
      contentClasses: ["standard-form"],
      icon: "fa-solid fa-map"
    },
    position: {width: 600},
    form: {
      closeOnSubmit: true
    },
    actions: {
      addLevel: SceneConfig.#onAddLevel,
      capturePosition: SceneConfig.#onCapturePosition,
      editLevel: SceneConfig.#onEditLevel,
      toggleLinkDimensions: SceneConfig.#onToggleLinkDimensions,
      openGridConfig: SceneConfig.#onOpenGridConfig,
      removeLevel: SceneConfig.#onRemoveLevel,
      resetEnvironment: SceneConfig.#onResetEnvironment,
      transitionPlay: SceneConfig.#onTransitionPlay
    }
  };

  /** @override */
  static PARTS = {
    tabs: {template: "templates/generic/tab-navigation.hbs"},
    basics: {template: "templates/scene/config/basics.hbs", scrollable: [""]},
    grid: {template: "templates/scene/config/grid.hbs"},
    levels: {template: "templates/scene/config/levels.hbs", scrollable: [".levels"]},
    visibility: {template: "templates/scene/config/visibility.hbs", scrollable: [""]},
    environment: {template: "templates/scene/config/environment.hbs", scrollable: [""]},
    misc: {template: "templates/scene/config/misc.hbs", scrollable: [""]},
    footer: {template: "templates/generic/form-footer.hbs"}
  };

  /** @override */
  static TABS = {
    sheet: {
      tabs: [
        {id: "basics", icon: "fa-solid fa-image"},
        {id: "grid", icon: "fa-solid fa-grid"},
        {id: "levels", icon: "fa-solid fa-layer-group"},
        {id: "visibility", icon: "fa-solid fa-eye"},
        {id: "environment", icon: "fa-solid fa-sun-cloud"},
        {id: "misc", icon: "fa-solid fa-shapes"}
      ],
      initial: "basics",
      labelPrefix: "SCENE.TABS.SHEET"
    }
  };

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

  /**
   * Should the width and height change together to maintain aspect ratio?
   * @type {boolean}
   */
  #linkedDimensions = true;

  /**
   * The last inputted scene dimension values, or otherwise the source values: used to track successive changes while
   * dimensions are linked.
   * @type {{width: number; height: number}}
   */
  #lastDimensionValues = {
    width: this.document._source.width,
    height: this.document._source.height
  };

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

  /**
   * The level to draw delegated fields from. This will be the viewed level if this scene is the viewed scene, otherwise
   * it will be the scene's active level.
   * @type {Level}
   */
  get defaultLevel() {
    if ( this.document.isView && canvas.level ) return canvas.level;
    return this.document.initialLevel;
  }

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

  /**
   * Get an enumeration of the available grid types which can be applied to this Scene
   * @returns {Record<GRID_TYPES, string>}
   * @internal
   */
  static _getGridTypes() {
    const labels = {
      GRIDLESS: "SCENE.GridGridless",
      SQUARE: "SCENE.GridSquare",
      HEXODDR: "SCENE.GridHexOddR",
      HEXEVENR: "SCENE.GridHexEvenR",
      HEXODDQ: "SCENE.GridHexOddQ",
      HEXEVENQ: "SCENE.GridHexEvenQ"
    };
    return Object.entries(CONST.GRID_TYPES).reduce((types, [key, value]) => {
      types[value] = _loc(labels[key]);
      return types;
    }, {});
  }

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

  /** @inheritDoc */
  _configureRenderOptions(options) {
    super._configureRenderOptions(options);
    if ( !options.renderContext || !["createlevels", "updatelevels", "deletelevels"].includes(options.renderContext) ) return;
    options.parts = ["levels"];
    const defaultId = this.defaultLevel.id;
    const defaultChange = options.renderData?.some(l => defaultId === (typeof l === "string" ? l : l._id));
    if ( defaultChange ) options.parts.push("basics", "visibility");
  }

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

  /** @inheritDoc */
  async _prepareContext(options) {
    const context = await super._prepareContext(options);
    context.tabs = this._prepareTabs("sheet");
    context.tabClasses = "top-tabs";
    return context;
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    const defaultLevel = context.defaultLevel = this.defaultLevel?.toObject() ?? {};
    context.levelLabel = this.document.isView && canvas.level ? "SCENE.CurrentLevel" : "SCENE.InitialLevel";
    const levelTooltip = this.document.levels?.size > 1
      ? _loc("SCENE.InheritedField", { level: defaultLevel.name })
      : null;
    context.gridTypes = this.constructor._getGridTypes();
    context.environmentFields = context.fields.environment.fields;
    switch ( partId ) {
      case "basics":
        context.ownerships = [
          {value: 0, label: _loc("SCENE.AccessibilityGM")},
          {value: 2, label: _loc("SCENE.AccessibilityAll")}
        ];
        context.background = {
          color: {
            field: new foundry.data.fields.ColorField({
              nullable: false, label: "SCENE.FIELDS.backgroundColor.label"
            }, { name: "backgroundColor" }),
            tooltip: levelTooltip,
            value: defaultLevel.background?.color
          },
          src: {
            field: new foundry.data.fields.FilePathField({
              required: true, categories: ["TEXTURE"], virtual: true, label: "SCENE.FIELDS.background.src.label",
              hint: "SCENE.FIELDS.background.src.hint"
            }, { name: "background.src" }),
            tooltip: levelTooltip,
            value: defaultLevel.background?.src
          }
        };
        context.weatherTypes = Object.entries(CONFIG.weatherEffects).reduce((types, [key, value]) => {
          types[key] = _loc(value.label);
          return types;
        }, {});
        break;
      case "grid":
        context.pixelsLabel = _loc("MEASUREMENT.Pixels");
        context.minGrid = CONST.GRID_MIN_SIZE;
        context.gridStyles = Object.entries(CONFIG.Canvas.gridStyles).reduce((styles, [key, value]) => {
          styles[key] = _loc(value.label);
          return styles;
        }, {});
        break;
      case "levels":
        context.initialLevel = this.document.initialLevel.id;
        context.levels = this.document.levels.reverseSorted.map(l => {
          const { id, name, elevation } = l;
          const { top, bottom } = elevation;
          return {
            id, name,
            label: l.name,
            value: id,
            viewed: this.document.isView && canvas.level === l,
            meta: Number.isFinite(top) || Number.isFinite(bottom)
              ? `[${Number.isFinite(bottom) ? bottom.toNearest(.01).toLocaleString(game.i18n.lang) : "-∞"}, `
                + `${Number.isFinite(top) ? top.toNearest(.01).toLocaleString(game.i18n.lang) : "+∞"}]`
              : ""
          };
        });
      case "visibility":
        context.globalLight = context.source.environment.globalLight;
        context.fogFields = context.fields.fog.fields;
        context.fogExplorationModes = this.constructor._getFogExplorationModes();
        context.fog = {
          overlay: {
            field: new foundry.data.fields.FilePathField({
              categories: ["TEXTURE"], virtual: true, label: "SCENE.FIELDS.fog.overlay.label",
              hint: "SCENE.FIELDS.fog.overlay.hint"
            }, { name: "fog.overlay" }),
            tooltip: levelTooltip,
            value: defaultLevel.fog?.src
          }
        };
        break;
      case "environment":
        context.baseFields = context.fields.environment.fields.base.fields;
        context.darkFields = context.fields.environment.fields.dark.fields;
        context.baseHueDisabled = (context.source.environment.base.intensity === 0);
        context.darkHueDisabled = (context.source.environment.dark.intensity === 0);
        break;
      case "misc": {
        context.pages = this.#documentsToOptions(context.document.journal?.pages, {sortBy: "sort"});
        context.sounds = this.#documentsToOptions(context.document.playlist?.sounds, {sortBy: "name"});
        context.transitionTypes = Object.entries(CONFIG.Canvas.sceneTransitions).reduce((types, [key, value]) => {
          types[key] = _loc(value.label ?? `SCENE.Transition.Types.${key}`);
          return types;
        }, {});
        break;
      }
      case "footer":
        context.buttons = this.#prepareButtons();
    }
    if ( partId in context.tabs ) context.tab = context.tabs[partId];
    return context;
  }

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

  /**
   * Get an enumeration of the available fog exploration modes for this Scene.
   * @returns {Record<FOG_EXPLORATION_MODES, string>}
   * @internal
   */
  static _getFogExplorationModes() {
    const prefix = "SCENE.FIELDS.fog.MODES";
    return Object.entries(CONST.FOG_EXPLORATION_MODES).reduce((modes, [key, value]) => {
      const k = `${prefix}.${key}`;
      modes[value] = game.i18n.has(k) ? game.i18n.localize(k) : key;
      return modes;
    }, {});
  }

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

  /**
   * Prepare buttons that are presented for this application view.
   * @returns {FormFooterButton[]}
   */
  #prepareButtons() {
    return [
      {
        type: "button",
        icon: "fa-solid fa-arrow-rotate-left",
        label: "SCENE.Environment.Reset",
        action: "resetEnvironment"
      },
      {type: "submit", icon: "fa-solid fa-floppy-disk", label: "SETTINGS.Save"}
    ];
  }

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

  /** @inheritDoc */
  changeTab(tab, group, options) {
    super.changeTab(tab, group, options);
    this.#onChangeTab();
  }

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

  /**
   * Get the embedded documents of a linked JournalEntry or Playlist
   * @param {EmbeddedCollection|undefined} collection
   * @param {object} options
   * @param {"name"|"sort"} options.sortBy
   * @returns {{value: string; label: string}[]}
   */
  #documentsToOptions(collection, {sortBy}) {
    if ( !collection ) return [];
    const sorted = sortBy === "name"
      ? collection.contents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang))
      : collection.contents.sort((a, b) => a.sort - b.sort);
    return sorted.reduce((options, document) => {
      options[document.id] = document.name;
      return options;
    }, {});
  }

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

  /**
   * Live update the scene as certain properties are changed.
   * @param {string} changed The changed property
   * @param {object} [options]
   * @param {boolean} [options.force] Should the preview be forced, regardless of changes?
   * @internal
   */
  _previewScene(changed, {force=false}={}) {
    if ( !this.document.isView || !canvas.ready || (!changed && !force) ) return;

    // Preview triggered for the grid
    if ( force || ["grid.style", "grid.thickness", "grid.color", "grid.alpha"].includes(changed) ) {
      const elements = this.form.elements;
      canvas.interface.grid.initializeMesh({
        style: elements["grid.style"].value,
        thickness: Math.max(1, Number(elements["grid.thickness"].value) || 0),
        color: elements["grid.color"].value,
        alpha: Number(elements["grid.alpha"].value) || 0
      });
    }

    // Preview triggered for environment changes or the ambience manager
    const colorChanged = /color/i.test(changed);
    const environmentChanged = changed.includes("environment.");
    if ( force || colorChanged || environmentChanged ) canvas.environment.initialize(this.#getAmbienceFormData());
  }

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

  /**
   * Get the ambience form data.
   * @returns {object}
   */
  #getAmbienceFormData() {
    const formData = new FormDataExtended(this.form);
    const submitData = foundry.utils.expandObject(formData.object);
    return {
      backgroundColor: submitData.backgroundColor,
      fogExploredColor: submitData.fog.colors.explored,
      fogUnexploredColor: submitData.fog.colors.unexplored,
      environment: submitData.environment
    };
  }

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

  /**
   * Reset the previewed darkness level, background color, grid alpha, and grid color back to their true values.
   */
  #resetScenePreview() {
    if ( !this.document.isView || !canvas.ready ) return;
    canvas.scene.reset();
    canvas.environment.initialize();
    canvas.interface.grid.initializeMesh(canvas.scene.grid);
  }

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

  /** @inheritDoc */
  _processFormData(event, form, formData) {
    const submitData = super._processFormData(event, form, formData);
    const level = {};
    for ( const [sceneKey, levelKey] of foundry.documents.BaseScene._LEVELS_PROPERTY_MAP ) {
      const value = foundry.utils.getProperty(submitData, sceneKey);
      if ( value !== undefined ) foundry.utils.setProperty(level, levelKey, value);
    }
    if ( !foundry.utils.isEmpty(level) ) {
      level._id = this.defaultLevel?.id ?? this.document.constructor.metadata.defaultLevelId;
      submitData.levels = [level];
    }

    // Handle transition "None" special case
    if ( submitData.transition.type === "" ) {
      submitData.transition.type = null;
    }

    return submitData;
  }

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

  /** @override */
  async _processSubmitData(event, form, submitData, options) {
    const scene = this.document;
    const current = scene._source;
    const initialLevelId = submitData.initialLevel ?? current.initialLevel;
    const initialLevel = (initialLevelId ? scene.levels.get(initialLevelId) : null) ?? scene.firstLevel;
    const backgrounds = new Map();
    for ( const changed of (submitData.levels ?? []) ) {
      if ( changed.background?.src ) backgrounds.set(changed._id, changed.background.src);
    }
    const getLevelBackground = level => backgrounds.get(level?.id) ?? level?.background?.src;

    // Ensure submitted background textures are loaded before accessing them from cache or generating thumbnails.
    if ( backgrounds.size && !game.settings.get("core", "noCanvas") ) {
      await foundry.canvas.TextureLoader.loader.load(Array.from(backgrounds.values()), {
        localize: true,
        message: "SCENE.DeterminingDimensions"
      });
    }

    // Auto-set scene dimensions to the max of all submitted background texture dimensions if the scene has default
    // dimensions, or if the dimensions have been explicitly cleared.
    const hasDefaultDims = scene.levels.every(l => !l.background?.src)
      && (current.width === 4000) && (current.height === 3000);
    const clearedDims = (submitData.width === null) || (submitData.height === null);
    if ( backgrounds.size && (clearedDims || hasDefaultDims) ) {
      let width = 0;
      let height = 0;
      for ( const src of backgrounds.values() ) {
        const texture = foundry.canvas.getTexture(src);
        if ( texture ) {
          width = Math.max(width, texture.width);
          height = Math.max(height, texture.height);
        }
      }
      if ( width && height ) {
        submitData.width = width;
        submitData.height = height;
      }
    }

    // Generate a thumbnail if the scene doesn't already have one and has a background image on any level.
    if ( !current.thumb ) {
      // Find the level whose background to use: initial level > viewed level > any level.
      const srcLevel = getLevelBackground(initialLevel)
        ? initialLevel
        : scene.isView && getLevelBackground(canvas.level)
          ? canvas.level
          : scene.levels.find(getLevelBackground);

      if ( srcLevel ) {
        if ( game.settings.get("core", "noCanvas") ) {
          ui.notifications.warn("SCENE.GenerateThumbNoCanvas", { localize: true });
        } else {
          try {
            const clone = scene.clone();
            for ( const changed of (submitData.levels ?? []) ) clone.levels.get(changed._id)?.updateSource(changed);
            if ( submitData.width ) clone.updateSource({ width: submitData.width, height: submitData.height });
            const { thumb } = await clone.createThumbnail({ level: srcLevel.id });
            submitData.thumb = thumb;
          } catch(error) {
            Hooks.onError("SceneConfig#_processSubmitData", error, {
              msg: "Thumbnail generation for Scene failed",
              notify: "error",
              log: "error",
              scene: current._id
            });
          }
        }
      }
    }

    // Warn if scene dimensions are changing
    const delta = foundry.utils.diffObject(current, submitData);
    const changes = foundry.utils.flattenObject(delta);
    if ( "grid.size" in changes ) {
      const confirm = await foundry.applications.api.DialogV2.confirm({
        window: {title: "SCENE.DimensionChangeTitle"},
        content: `<p>${_loc("SCENE.DimensionChangeWarning")}</p>`
      });
      if ( !confirm ) return {};
    }

    // Handle nonuniform canvas size changes
    let autoReposition = false;
    const hasContent = scene.levels.some(l => l.background?.src || l.foreground?.src);
    if ( hasContent && ["width", "height", "padding", "grid.size"].some(x => x in changes) ) {
      autoReposition = true;
      const aspectRatioChanged = ("width" in changes) && ("height" in changes)
        && ((submitData.width / submitData.height) !== (current.width / current.height));

      if ( aspectRatioChanged || ["width", "height"].some(k => k in changes) ) {
        const confirm = await foundry.applications.api.DialogV2.confirm({
          window: {title: "SCENE.DistortedDimensionsTitle"},
          content: _loc("SCENE.DistortedDimensionsWarning")
        });
        if ( !confirm ) autoReposition = false;
      }
    }

    // Remove unneeded fields and perform the update
    delete submitData.environment.darknessLock;
    const updated = await scene.update(submitData, {...options, autoReposition});
    return updated ? {updated} : {};
  }

  /* -------------------------------------------- */
  /*  Event Listeners and Handlers                */
  /* -------------------------------------------- */

  /**
   * Retrieve the scene level from a DOM event.
   * @param {Event} event  The event.
   * @returns {Level}
   */
  #getEventLevel(event) {
    const { levelId } = event.target.closest("[data-level-id]")?.dataset ?? {};
    return this.document.levels.get(levelId);
  }

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

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this._createContextMenu(this._getLevelContextOptions, ".levels .level", {
      hookName: "getLevelContextOptions", parentClassHooks: false, fixed: true
    });
  }

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

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    this.#onChangeTab();
    this.#updateTransitionControls();
    if ( options.parts.includes("grid") ) {
      const {width, height} = this.document._source;
      this.#lastDimensionValues = {width, height};
    }
    new DragDrop.implementation({
      dragSelector: ".draggable",
      callbacks: {
        dragstart: this._onDragStart.bind(this),
        drop: this._onDrop.bind(this)
      }
    }).bind(this.element);
  }

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

  /**
   * Get the set of ContextMenu options for levels in the scene config.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getLevelContextOptions() {
    const getLevel = li => this.document.levels.get(li.dataset.levelId);
    return [
      {
        label: "SCENE.View",
        icon: "fa-solid fa-eye",
        visible: li => !getLevel(li)?.isView,
        onClick: (event, li) => this.document.view({ level: li.dataset.levelId })
      },
      {
        label: "SIDEBAR.Edit",
        icon: "fa-solid fa-edit",
        visible: this.isEditable,
        onClick: (event, li) => getLevel(li)?.sheet.render({ force: true })
      },
      {
        label: "SIDEBAR.Duplicate",
        icon: "fa-regular fa-copy",
        visible: this.isEditable,
        onClick: (event, li) => {
          const level = getLevel(li);
          return level?.clone({ name: game.i18n.format("DOCUMENT.CopyOf", { name: level.name }) }, { save: true });
        }
      },
      {
        label: "SIDEBAR.Delete",
        icon: "fa-solid fa-trash",
        visible: this.isEditable,
        onClick: (event, li) => getLevel(li)?.deleteDialog()
      }
    ];
  }

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

  /**
   * Handle creating a new level.
   * @this {SceneConfig}
   */
  static #onAddLevel() {
    foundry.documents.Level.createDialog({}, { parent: this.document, renderSheet: true });
  }

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

  /**
   * Show or hide the environment reset button depending on which tab and subtab are in view.
   */
  #onChangeTab() {
    const resetButton = this.element.querySelector("button[data-action=resetEnvironment]");
    resetButton.hidden = (this.tabGroups.sheet !== "lighting");
  }

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

  /** @inheritDoc */
  _onChangeForm(formConfig, event) {
    super._onChangeForm(formConfig, event);
    const formElements = this.form.elements;
    const name = event.target.name || event.target.dataset.name;

    // Keep form values with the same name in-sync across the form.
    for ( const el of this.form.querySelectorAll(`[name="${name}"], [data-name="${name}"]`) ) {
      if ( el === event.target ) continue;
      if ( el.type === "radio" ) continue; // Skip actual radio inputs.
      if ( el.type === "checkbox" ) el.checked = event.target.checked;
      else if ( el.hasAttribute("value") || foundry.utils.isElementInstanceOf(el, HTMLSelectElement) ) {
        // Special handling for AbstractFormInputElements. We need to prevent the value assignment from triggering
        // another change event, otherwise we end up in an infinite loop. Normal inputs do not fire a change event on
        // programmatic updates.
        if ( foundry.utils.isElementInstanceOf(el, foundry.applications.elements.AbstractFormInputElement) ) {
          el._setValue(event.target.value);
          el._refresh();
        }
        else el.value = event.target.value;
      }
    }

    switch ( event.target ) {
      case formElements.journal:
        return this.#onChangeJournalEntry(event.target.value);
      case formElements.playlist:
        return this.#onChangePlaylist(event.target.value);
      case formElements.width:
      case formElements.height:
        this.#onChangeDimensions(event.target);
        break;
      case formElements["environment.base.intensity"]:
      case formElements["environment.dark.intensity"]: {
        this.#onChangeIntensity();
        break;
      }
      case formElements["transition.type"]:
        this.#updateTransitionControls();
        break;
      case formElements["environment.darknessLock"]:
        this.#onChangeDarknessLock(event.target.checked);
        break;
      case formElements["environment.cycle"]:
        this.#onChangeBlend();
        break;
    }
    this._previewScene(event.target.name || event.target.dataset.name);
  }

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

  /**
   * Handle enabling or disabling darkness ambience based on whether blending is enabled.
   */
  #onChangeBlend() {
    const blend = this.form.elements["environment.cycle"];
    for ( const input of blend.closest("fieldset").querySelectorAll("input") ) {
      if ( input !== blend ) input.disabled = !blend.checked;
    }
    this.#onChangeIntensity();
  }

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

  /**
   * Handle hue slider interactivity based on intensity.
   */
  #onChangeIntensity() {
    ["environment.base.intensity", "environment.dark.intensity"].forEach(name => {
      const intensity = this.form.elements[name];
      const slider = intensity.closest("fieldset").querySelector("hue-slider input");
      slider.disabled = intensity.disabled || (intensity.value === 0);
    });
  }

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

  /**
   * Handle updating the select menu of JournalEntryPage options when the JournlEntry is changed.
   * @param {string} entryId The id of the parent JournalEntry
   */
  #onChangeJournalEntry(entryId) {
    const entry = game.journal.get(entryId);
    const pages = entry?.pages.contents.sort((a, b) => a.name.localeCompare(b.name, game.i18n.lang)) ?? [];
    const options = pages.map(page => {
      const option = document.createElement("option");
      option.value = page.id;
      option.innerText = page.name;
      return option.outerHTML;
    }).join("");
    const select = this.form.elements.journalEntryPage;
    select.innerHTML = `<option></option>${options}`;
    const currentId = this.document.journalEntryPage;
    if ( entry?.pages.has(currentId) ) select.value = currentId;
  }

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

  /**
   * Handle updating the select menu of PlaylistSound options when the Playlist is changed.
   * @param {string} playlistId The id of the parent Playlist
   */
  #onChangePlaylist(playlistId) {
    const playlist = game.playlists.get(playlistId);
    const sounds = playlist?.sounds.contents.sort((a, b) => a.sort - b.sort) ?? [];
    const options = sounds.map(sound => {
      const option = document.createElement("option");
      option.value = sound.id;
      option.innerText = sound.name;
      return option.outerHTML;
    }).join("");
    const select = this.form.elements.playlistSound;
    select.innerHTML = `<option></option>${options}`;
    const currentId = this.document.playlistSound?.id;
    if ( playlist?.sounds.has(currentId) ) select.value = currentId;
  }

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

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static #onCapturePosition(event) {
    if ( !canvas.ready ) return;
    const form = this.form;
    form.elements["initial.x"].value = parseInt(canvas.stage.pivot.x);
    form.elements["initial.y"].value = parseInt(canvas.stage.pivot.y);
    form.elements["initial.scale"].value = canvas.stage.scale.x.toNearest(.001);
    ui.notifications.info("SCENE.CaptureInitialViewPosition", {localize: true});
  }

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

  /**
   * Handle editing a level.
   * @this {SceneConfig}
   * @param {PointerEvent} event        The triggering event.
   * @param {HTMLButtonElement} target  The action target.
   */
  static #onEditLevel(event, target) {
    this.#getEventLevel(event)?.sheet.render({ force: true });
  }

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

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static #onOpenGridConfig() {
    new foundry.applications.apps.GridConfig({document: this.document}).render({force: true});
    return this.minimize();
  }

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

  /**
   * Handle removing a level.
   * @this {SceneConfig}
   * @param {PointerEvent} event        The triggering event.
   * @param {HTMLButtonElement} target  The action target.
   */
  static #onRemoveLevel(event, target) {
    this.#getEventLevel(event)?.deleteDialog();
  }

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

  /**
   * Enable or disable transition duration and activeOnly depending on the selected type.
   * When no transition type is selected ("None"), those controls are disabled.
   * @private
   */
  #updateTransitionControls() {
    const form = this.form;
    if ( !form ) return;

    const typeInput = form.elements["transition.type"];
    const durationInput = form.elements["transition.duration"];
    const activeOnlyInput = form.elements["transition.activeOnly"];
    if ( !typeInput || !durationInput || !activeOnlyInput ) return;

    const disabled = typeInput.value === "";
    durationInput.disabled = disabled;
    activeOnlyInput.disabled = disabled;
  }

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

  /**
   * Preview the configured scene transition on the current canvas.
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static async #onTransitionPlay() {
    if ( !canvas.ready ) return;

    const formData = new FormDataExtended(this.form);
    const submitData = foundry.utils.expandObject(formData.object);

    const formTransition = submitData.transition ?? {};
    const current = this.document._source.transition ?? {};

    const type = formTransition.type ?? current.type;
    if ( !type ) return; // "None" selected, nothing to preview

    // Resolve duration
    const def = CONFIG.Canvas.sceneTransitions[type];
    const duration = formTransition.duration ?? current.duration ?? def.defaultDuration
      ?? canvas.transition.defaultDuration ?? 1000;

    // Launch the preview
    await canvas.transition.run({
      fromBlack: true,
      duration,
      transitionType: type
    });
  }

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

  /**
   * Link or unlink the scene dimensions
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static #onToggleLinkDimensions(_event, button) {
    this.#linkedDimensions = !this.#linkedDimensions;
    const icon = button.querySelector("i");
    icon.classList.toggle("fa-link-simple", this.#linkedDimensions);
    icon.classList.toggle("fa-link-simple-slash", !this.#linkedDimensions);

    // Update Tooltip
    button.dataset.tooltip = this.#linkedDimensions ? "SCENE.DimensionLinked" : "SCENE.DimensionUnlinked";
    button.ariaLabel = _loc(button.dataset.tooltip);
    game.tooltip.activate(button);
  }

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

  /**
   * Handle updating dimensions given the dimension link is toggled on
   * @param {HTMLInputElement} input The width or height input
   */
  #onChangeDimensions(input) {
    if ( !this.#linkedDimensions ) return;
    const widthInput = this.form.elements.width;
    const heightInput = this.form.elements.height;
    const value = input.valueAsNumber;
    const oldValue = this.#lastDimensionValues[input.name];
    const scale = value / oldValue;
    const otherInput = input === widthInput ? heightInput : widthInput;
    const otherValue = otherInput.valueAsNumber * scale;

    // If the new values are not integers, display an error and revert
    if ( Number.isInteger(value) && Number.isInteger(otherValue) ) otherInput.value = otherValue;
    else {
      ui.notifications.error(_loc("SCENE.InvalidDimension"));
      input.value = oldValue;
    }
    this.#lastDimensionValues = {
      width: widthInput.valueAsNumber,
      height: heightInput.valueAsNumber
    };
  }

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

  /**
   * Handle darkness lock change and update immediately the database.
   * @param {boolean} darknessLock Is the darkness lock checked?
   */
  async #onChangeDarknessLock(darknessLock) {
    const levelInput = this.form.elements["environment.darknessLevel"];
    levelInput.disabled = darknessLock;
    const darknessLevel = levelInput.value;
    await this.document.update({environment: {darknessLock, darknessLevel}}, {render: false});
  }

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

  /**
   * Capture the current Scene position and zoom level as the initial view in the Scene config
   * @this {SceneConfig}
   * @type {ApplicationClickAction}
   */
  static async #onResetEnvironment() {
    const document = this.document;
    const def = Scene.cleanData().environment;
    const ori = foundry.utils.deepClone(document._source.environment);
    const defaults = {base: def.base, dark: def.dark};
    const original = {base: ori.base, dark: ori.dark};

    // Reset the elements to the default values
    const elements = this.form.elements;
    elements["environment.globalLight.enabled"].checked = def.globalLight.enabled;
    elements["environment.globalLight.darkness.max"].value = def.globalLight.darkness.max;
    elements["environment.darknessLevel"].value = def.darknessLevel;
    elements["environment.darknessLock"].checked = def.darknessLock;
    for ( const target of ["base", "dark"] ) {
      elements[`environment.${target}.hue`].disabled = (defaults[target].intensity === 0);
      elements[`environment.${target}.intensity`].value = defaults[target].intensity;
      elements[`environment.${target}.luminosity`].value = defaults[target].luminosity;
      elements[`environment.${target}.saturation`].value = defaults[target].saturation;
      elements[`environment.${target}.shadows`].value = defaults[target].shadows;
      elements[`environment.${target}.hue`].value = defaults[target].hue;
    }

    // Update the document with the default environment values
    document.updateSource({environment: defaults});

    // Preview the scene and re-render the config
    this._previewScene("", {force: true});
    await this.render();

    // Restore original environment values
    document.updateSource({environment: original});
  }

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

  /** @inheritDoc */
  _onClose(options) {
    super._onClose(options);
    this.#resetScenePreview();
  }

  /* -------------------------------------------- */
  /*  Drag & Drop                                 */
  /* -------------------------------------------- */

  /**
   * Handle drag start.
   * @param {DragEvent} event  The initial event.
   * @protected
   */
  _onDragStart(event) {
    const dragData = this.#getEventLevel(event)?.toDragData();
    if ( dragData ) event.dataTransfer.setData("text/plain", JSON.stringify(dragData));
  }

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

  /**
   * Handle dropping some data onto the sheet.
   * @param {DragEvent} event  The triggering event.
   * @protected
   */
  async _onDrop(event) {
    const data = TextEditor.implementation.getDragEventData(event);
    if ( data.type !== "Level" ) return;
    const level = await foundry.documents.Level.fromDropData(data);
    if ( level?.parent === this.document ) return this._onSortLevel(event, level);
  }

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

  /**
   * Handle re-ordering levels via scene config.
   * @param {DragEvent} event  The triggering event.
   * @param {Level} level      The scene level.
   * @returns {Promise<Level[]>|void}
   * @protected
   */
  _onSortLevel(event, level) {
    const levels = this.document.levels;
    const dropTarget = event.target.closest("[data-level-id]");
    if ( !dropTarget ) return;
    const target = levels.get(dropTarget.dataset.levelId);
    if ( level.id === target.id ) return;
    const siblings = levels.filter(l => l !== level);
    const updates = foundry.utils.performIntegerSort(level, { target, siblings });
    return this.document.updateEmbeddedDocuments("Level", updates.map(u => ({ _id: u.target._id, ...u.update })));
  }
}
