import HandlebarsApplicationMixin from "../../api/handlebars-application.mjs";
import ApplicationV2 from "../../api/application.mjs";
import DragDrop from "../../ux/drag-drop.mjs";
import SearchFilter from "../../ux/search-filter.mjs";
import FilterMenu from "../../ux/filter-menu.mjs";
import PlaceableFilter from "../filters/placeable-filter.mjs";
import DialogV2 from "@client/applications/api/dialog.mjs";

/**
 * @import {
 *   ApplicationConfiguration, ApplicationRenderContext, ApplicationRenderOptions, FormFooterButton
 * } from "../../_types.mjs";
 * @import {HandlebarsRenderOptions} from "../../api/handlebars-applications.mjs";
 * @import Document from "@common/abstract/document.mjs";
 */

/**
 * @typedef _PlaceableTabConfiguration
 * @property {string} collectionName         The name of the Scene collection represented in this tab.
 * @property {PlaceableDirectory} directory  The parent directory this tab is part of.
 */

/**
 * @typedef {ApplicationConfiguration & _PlaceableTabConfiguration} PlaceableTabConfiguration
 */

/**
 * @typedef {ApplicationRenderOptions & HandlebarsRenderOptions} PlaceableTabRenderOptions
 */

/**
 * @typedef PlaceableTabEntryContext
 * @property {Partial<FormFooterButton>[]} buttons
 * @property {string} id
 * @property {string} label
 */

/**
 * An application responsible for rendering a view of all placeables of a given type on the viewed Scene.
 * @extends {ApplicationV2<ApplicationRenderContext, PlaceableTabConfiguration>}
 * @mixes HandlebarsApplication
 */
export default class PlaceableTab extends HandlebarsApplicationMixin(ApplicationV2) {
  /** @override */
  static DEFAULT_OPTIONS = {
    id: "{id}",
    tag: "section",
    classes: ["placeable-tab", "flexcol"],
    window: {
      frame: false,
      positioned: false
    },
    actions: {
      createEntry: PlaceableTab.#onCreateEntry,
      filterViewed: PlaceableTab.#onFilterViewed,
      openFilter: { handler: PlaceableTab.#onOpenFilter, buttons: [0, 2] }
    }
  };

  /**
   * The filter application class used by this tab.
   * @type {typeof PlaceableFilter}
   */
  static FILTER_CLASS = PlaceableFilter;

  /**
   * The template of the directory.
   * @type {string}
   */
  static DIRECTORY_PARTIAL = "templates/sidebar/tabs/placeable/placeables.hbs";

  /**
   * The template of a single directory entry.
   * @type {string}
   */
  static ENTRY_PARTIAL = "templates/sidebar/tabs/placeable/placeable.hbs";

  /** @override */
  static PARTS = {
    search: { template: "templates/sidebar/tabs/placeable/search.hbs" },
    directory: {
      template: "templates/sidebar/tabs/placeable/directory.hbs",
      scrollable: [""]
    }
  };

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

  /**
   * The Scene's collection whose contents are shown in this tab.
   * @type {string}
   */
  get collectionName() {
    return this.options.collectionName;
  }

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

  /**
   * The Document class of the entries shown in this tab.
   * @type {typeof Document}
   */
  get documentClass() {
    return foundry.documents.Scene.hierarchy[this.collectionName].model;
  }

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

  /**
   * The canvas layer whose contents are shown in this tab.
   * @returns {PlaceablesLayer}
   */
  get layer() {
    return canvas?.getCollectionLayer(this.collectionName);
  }

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

  /**
   * The schema of the entries shown in this tab.
   * @returns {DataModelSchemaField}
   */
  get schema() {
    return this.documentClass.schema;
  }


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

  /**
   * Track whether the tab is restricted to objects that are currently viewed.
   * @type {boolean}
   */
  static #filterViewed = true;

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

  /**
   * Current filter state.
   * @type {{
   *   dialog: PlaceableFilter|null,
   *   elevation: { bottom, top: number },
   *   levels: Set<string>,
   *   visible: Set<string>,
   *   [p: string]: any
   * }}
   * @protected
   */
  _filterState = {
    dialog: null,
    elevation: { bottom: -Infinity, top: Infinity },
    levels: new Set(),
    visible: new Set()
  };

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

  /**
   * The filter helper.
   * @type {SearchFilter}
   * @protected
   */
  _searchFilter = new SearchFilter({
    inputSelector: "input[type=search]",
    contentSelector: ".placeable-directory",
    callback: this._onSearchFilter.bind(this)
  });

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

  /**
   * Highlight a hovered entry in the sidebar.
   * @param {PlaceableObject} object  The object being hovered on canvas.
   * @param {boolean} hover           The hover state.
   */
  hoverEntry(object, hover) {
    if ( !this.rendered ) return;
    this.element.querySelector(`[data-entry-id="${object.id}"]`)?.classList.toggle("hovered", hover);
  }

  /* -------------------------------------------- */
  /**
   * Determine if a placeable is visible in the sidebar.
   * @param {PlaceableObject} object  The placeable.
   * @returns {boolean}
   */
  isEntryVisible(object) {
    if ( !this.rendered ) return true;
    return this._filterState.visible.has(object.document);
  }

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

  /**
   * Test whether this tab currently has any active filter other than "Filter Viewed".
   * @returns {boolean}
   * @protected
   */
  _isFiltered() {
    return !!this._searchFilter.query || !!this._filterState.levels.size || this._hasAdvancedFilters();
  }

  /* -------------------------------------------- */
  /*  Rendering                                   */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _initializeApplicationOptions(options) {
    const applicationOptions = super._initializeApplicationOptions(options);
    if ( options.collectionName ) {
      applicationOptions.uniqueId = `${options.collectionName}-tab${options.directory?.isPopout ? "-popout" : ""}`;
      applicationOptions.classes.push(`${options.collectionName}-tab`);
    }
    return applicationOptions;
  }

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

  /** @inheritDoc */
  _configureRenderParts(options) {
    const parts = super._configureRenderParts(options);
    parts.directory.templates.push(this.constructor.DIRECTORY_PARTIAL, this.constructor.ENTRY_PARTIAL);
    return parts;
  }

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

  /**
   * Retrieve an entry's label for use in the directory.
   * @param {Document} entry  The directory entry.
   * @returns {string}
   * @protected
   */
  _getEntryLabel(entry) {
    return entry.name || entry.id;
  }

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

  /** @inheritDoc */
  async _onFirstRender(context, options) {
    await super._onFirstRender(context, options);
    this._createContextMenu(this._getEntryContextOptions, ".placeable-entry", {
      fixed: true, parentClassHooks: false,
      hookName: `get${this.documentClass.documentName}PlaceableContextOptions`
    });
  }

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

  /**
   * Prepare render context for the directory.
   * @param {ApplicationRenderContext} context   Render context.
   * @param {PlaceableTabRenderOptions} options  Render options.
   * @returns {Promise}
   * @protected
   */
  async _prepareDirectoryContext(context, options) {
    const controlled = Object.fromEntries(this.layer.controlled.map(o => [o.id, o]));
    context.directoryPartial = this.constructor.DIRECTORY_PARTIAL;
    context.entryPartial = this.constructor.ENTRY_PARTIAL;
    const promises = [];
    for ( const entry of canvas.scene?.[this.collectionName] ?? [] ) {
      if ( !entry.visible ) continue;
      const { id } = entry;
      const css = id in controlled ? "active" : "";
      const ctx = { css, id, label: this._getEntryLabel(entry) };
      promises.push(this._prepareEntry(entry, ctx));
    }
    context.entries = await Promise.all(promises);
    context.entries.sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang));
    return context;
  }

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

  /**
   * Entry-specific preparation.
   * @param {Document} entry                    The Document instance.
   * @param {PlaceableTabEntryContext} context  Render context.
   * @returns {Promise<object>}
   * @protected
   */
  async _prepareEntry(entry, context) {
    context.hidden = entry.hidden;
    context.locked = entry.locked;
    return context;
  }

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

  /** @inheritDoc */
  async _preparePartContext(partId, context, options) {
    context = await super._preparePartContext(partId, context, options);
    switch ( partId ) {
      case "directory": return this._prepareDirectoryContext(context, options);
      case "search": return this._prepareSearchContext(context, options);
    }
    return context;
  }

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

  /**
   * Prepare render context for the search part.
   * @param {ApplicationRenderContext} context   Render context.
   * @param {PlaceableTabRenderOptions} options  Render options.
   * @returns {Promise}
   * @protected
   */
  async _prepareSearchContext(context, options) {
    const hasLevels = (this.schema.has("levels") || this.schema.has("level")) && (canvas.scene?.levels.size > 1);
    const canCreate = this.collectionName === "regions";
    const { label, labelPlural } = this.documentClass.metadata;
    context.labels = {
      create: _loc("SIDEBAR.Create", { type: _loc(label) }),
      placeholder: _loc("SIDEBAR.PLACEABLES.Search.placeholder", { name: _loc(labelPlural) })
    };
    const { elevation, levels } = this._filterState;
    const state = foundry.utils.mergeObject(this._filterState, {
      filteredByLevel: !!levels.size
    }, { inplace: false });
    const filters = [
      {
        action: "filterViewed",
        active: PlaceableTab.#filterViewed,
        cssClass: "fa-solid fa-image",
        tooltip: "SIDEBAR.PLACEABLES.Filter.Viewed"
      },
      {
        action: "filterByLevel",
        active: state.filteredByLevel,
        cssClass: "fa-solid fa-layer-group",
        tooltip: "SIDEBAR.PLACEABLES.Filter.Level"
      },
      {
        action: "openFilter",
        active: Number.isFinite(elevation.bottom) || Number.isFinite(elevation.top),
        cssClass: "fa-solid fa-filter",
        tooltip: "SIDEBAR.PLACEABLES.Filter.Tooltip"
      }
    ];
    Object.assign(context, { canCreate, filters, hasLevels, state });
    return context;
  }

  /* -------------------------------------------- */
  /*  Event Listeners & Handlers                  */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _attachFrameListeners() {
    super._attachFrameListeners();
    this.element.addEventListener("click", this._onClickEntry.bind(this), { passive: true });
    this.element.addEventListener("dblclick", event => this._onConfigureEntry(event, event.target), { passive: true });
    new FilterMenu.implementation(this.element, "search [data-action=filterByLevel]", {
      menuItems: this.#getLevelFilterOptions.bind(this)
    });
  }

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

  /**
   * Get context menu entries for the entries in this tab.
   * @returns {ContextMenuEntry[]}
   * @protected
   */
  _getEntryContextOptions() {
    return [{
      label: "SIDEBAR.Edit",
      icon: "fa-solid fa-edit",
      onClick: (event, li) => this._onConfigureEntry(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Lock",
      icon: "fa-solid fa-lock",
      visible: li => {
        if ( !this.schema.has("locked") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return !doc.locked && doc.canUserModify(game.user, "update", {locked: true});
      },
      onClick: (event, li) => this._onToggleLocked(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Unlock",
      icon: "fa-solid fa-lock-open",
      visible: li => {
        if ( !this.schema.has("locked") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return doc.locked && doc.canUserModify(game.user, "update", {locked: false});
      },
      onClick: (event, li) => this._onToggleLocked(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Hide",
      icon: "fa-solid fa-eye-slash",
      visible: li => {
        if ( !this.schema.has("hidden") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return !doc.hidden && doc.canUserModify(game.user, "update", {hidden: true});
      },
      onClick: (event, li) => this._onToggleHidden(null, li)
    }, {
      label: "SIDEBAR.PLACEABLES.ACTIONS.Show",
      icon: "fa-solid fa-eye",
      visible: li => {
        if ( !this.schema.has("hidden") ) return false;
        const doc = this._getPlaceableFromElement(li);
        return doc.hidden && doc.canUserModify(game.user, "update", {hidden: false});
      },
      onClick: (event, li) => this._onToggleHidden(null, li)
    }, {
      label: "SIDEBAR.Delete",
      icon: "fa-solid fa-trash",
      visible: li => {
        const doc = this._getPlaceableFromElement(li);
        if ( !doc.canUserModify(game.user, "delete") ) return false;
        return !doc.locked;
      },
      onClick: (event, li) => this._onDeleteEntry(null, li)
    }, {
      label: "SIDEBAR.Duplicate",
      icon: "fa-regular fa-copy",
      visible: li => this._getPlaceableFromElement(li).isOwner && this.documentClass.canUserCreate(game.user),
      onClick: (event, li) => this._onDuplicateEntry(null, li)
    }];
  }

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

  /**
   * Get options for filtering by level.
   * @returns {ContextMenuEntry[]}
   */
  #getLevelFilterOptions() {
    const entries = Array.from(canvas.scene.availableLevels.map(({ id, name }) => {
      return {
        label: name,
        icon: this._filterState.levels.has(id) ? "fa-solid fa-check" : "fa-solid",
        onClick: () => this._onFilterByLevel(id)
      };
    }));
    entries.push({
      label: _loc("SIDEBAR.PLACEABLES.ACTIONS.Clear"),
      icon: "fa-solid fa-xmark",
      onClick: () => {
        this._onFilterByLevel(null);
        ui.context.close();
      },
      group: "state"
    });
    return entries;
  }

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

  /**
   * Handle selecting an entry.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onClickEntry(event) {
    if ( event.target.closest("button") ) return;
    if ( !ui.controls.tool?.control ) ui.controls.activate({ tool: "select" });
    const doc = this._getPlaceableFromElement(event.target);
    if ( !doc?.rendered ) return;
    if ( doc.object.controlled && event.shiftKey ) doc.object.release();
    else doc.object.control({ force: true, releaseOthers: !event.shiftKey });
  }

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

  /**
   * Handle configuring an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  _onConfigureEntry(event, target) {
    if ( event?.target.closest("button") ) return;
    return this._getPlaceableFromElement(target)?.sheet.render({ force: true });
  }

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

  /**
   * Handle creating an entry.
   * @this {PlaceableTab}
   * @param {...any} args
   * @returns {Promise<Document|void>}
   */
  static #onCreateEntry(...args) {
    return this._onCreateEntry(...args);
  }

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

  /**
   * Handle toggling viewed filter.
   * @this {PlaceableTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onFilterViewed(event, target) {
    const viewed = target.ariaPressed !== "true";
    PlaceableTab.#filterViewed = viewed;
    target.ariaPressed = viewed;
    this._applyFilters();
  }

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

  /**
   * Handle opening or closing the advanced filter dialog.
   * @this {PlaceableTab}
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   */
  static #onOpenFilter(event, target) {
    if ( event.button === 2 ) {
      this._clearFilters();
      this._applyFilters();
      return;
    }
    const { dialog } = this._filterState;
    if ( dialog?.rendered ) return dialog.close();
    const { left, bottom } = target.getBoundingClientRect();
    this._filterState.dialog = new this.constructor.FILTER_CLASS(this);
    return this._filterState.dialog.render({ force: true, position: { top: bottom, left } });
  }

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

  /**
   * Handle creating an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @protected
   */
  async _onCreateEntry(event, target) {
    if ( game.paused && !game.user.isGM ) {
      ui.notifications.warn("GAME.PausedWarning", { localize: true });
      return;
    }

    const createData = {};
    if ( this.schema.has("name") ) {
      createData.name = this.documentClass.implementation.defaultName({ parent: canvas.scene });
    }
    return this.documentClass.implementation.create(createData, { parent: canvas.scene });
  }

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

  /**
   * Clear state when deactivating the directory of this tab.
   * @internal
   */
  _onDeactivateDirectory() {
    this._filterState.dialog?.close();
  }

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

  /**
   * Clear state when deactivating this tab.
   * @internal
   */
  _onDeactivateTab() {
    this._filterState.elevation = { bottom: -Infinity, top: Infinity };
    this._filterState.visible.clear();
    this._filterState.dialog?.close();
    // Preserve filtered levels when moving between tabs.
  }

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

  /**
   * Handle deleting an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onDeleteEntry(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    if ( canvas.ready && doc.rendered && doc.object.controlled && (this.layer.controlled.length > 1) ) {
      const toDelete = this.layer.controlled.map(o => o.id);
      const question = _loc("COMMON.AreYouSure");
      const warning = _loc("SIDEBAR.DeleteManyWarning", {
        type: _loc(this.documentClass.metadata.labelPlural),
        count: this.layer.controlled.length
      });
      const confirmed = await DialogV2.confirm({
        window: {
          icon: "fa-solid fa-trash",
          title: _loc("DOCUMENT.Delete", { type: _loc(this.documentClass.metadata.label) })
        },
        content: `<p><strong>${question}</strong> ${warning}</p>`
      });
      if ( confirmed ) await canvas.scene.deleteEmbeddedDocuments(this.documentClass.documentName, toDelete);
    }
    else await doc.deleteDialog();
  }

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

  /**
   * Handle duplicating an entry.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<Document>}
   * @protected
   */
  async _onDuplicateEntry(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    const cloneOptions = { addSource: true, discardInvalidEmbedded: true };
    const createOptions = { controlObject: true };
    if ( canvas.ready && doc.rendered && doc.object.controlled ) {
      const toCreate = this.layer.controlled.map(o => {
        const original = o.document;
        const clone = original.clone(this._prepareDuplicateData(original), cloneOptions);
        return clone.toObject();
      });
      await canvas.scene.createEmbeddedDocuments(this.documentClass.documentName, toCreate, createOptions);
    }
    else await doc.clone(this._prepareDuplicateData(doc), { ...cloneOptions, ...createOptions, save: true });
  }

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

  /**
   * Prepares the data for a duplicated Document.
   * @param {Document} document    The Document that is duplicated.
   * @returns {object}             The partial data of the duplicate that overrides the original data.
   * @protected
   */
  _prepareDuplicateData(document) {
    const data = {};
    if ( document._source.name ) data.name = _loc("DOCUMENT.CopyOf", {name: document._source.name});
    return data;
  }

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

  /**
   * Handle dragging an entry.
   * @param {DragEvent} event  The triggering event.
   * @protected
   */
  _onDragStart(event) {
    const doc = this._getPlaceableFromElement(event.currentTarget);
    if ( doc ) event.dataTransfer.setData("text/plain", JSON.stringify(doc.toDragData()));
  }

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

  /**
   * Handle adjusting the levels filter.
   * @param {string|null} id  The ID of the level to add/remove, or null to clear the filter entirely and show all
   *                          levels.
   * @protected
   */
  _onFilterByLevel(id) {
    const { levels } = this._filterState;
    if ( id === null ) levels.clear();
    else if ( levels.has(id) ) levels.delete(id);
    else levels.add(id);
    this.element.querySelector("search [data-action=filterByLevel]").ariaPressed = String(!!levels.size);
    this._applyFilters();
  }

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

  /**
   * Handle hovering over a directory entry.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onHoverInEntry(event) {
    if ( !event.target.matches("[data-entry-id]") || !canvas.ready ) return;
    event.target.classList.add("hovered");
    this._getPlaceableFromElement(event.target)?.object?._onHoverIn(event, {
      hoverOutOthers: true, updateLegend: false
    });
  }

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

  /**
   * Handle no longer hovering over a directory entry.
   * @param {PointerEvent} event  The triggering event.
   * @protected
   */
  _onHoverOutEntry(event) {
    if ( !event.target.matches("[data-entry-id]") || !canvas.ready ) return;
    event.target.classList.remove("hovered");
    this._getPlaceableFromElement(event.target)?.object?._onHoverOut(event, { updateLegend: false });
  }

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

  /** @inheritDoc */
  async _onRender(context, options) {
    await super._onRender(context, options);
    new DragDrop.implementation({
      dragSelector: "[data-entry-id]",
      permissions: {
        dragstart: () => true,
        drop: () => false
      },
      callbacks: {
        dragstart: this._onDragStart.bind(this)
      }
    }).bind(this.element);
    this._searchFilter.bind(this.element);
    for ( const li of this.element.querySelectorAll("[data-entry-id]") ) {
      li.addEventListener("pointerenter", this._onHoverInEntry.bind(this), { passive: true });
      li.addEventListener("pointerleave", this._onHoverOutEntry.bind(this), { passive: true });
    }
    this._applyFilters();
  }

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

  /**
   * Handle post-filter operations.
   * @param {KeyboardEvent} event  The triggering event.
   * @param {string} query         The raw search query.
   * @param {RegExp} rgx           The regular expression to test against.
   * @param {HTMLElement} html     The element that should be filtered.
   * @protected
   */
  _onSearchFilter(event, query, rgx, html) {
    if ( !this.rendered ) return;
    this._applyFilters();
  }

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

  /**
   * Handle toggling an entry's hidden state.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onToggleHidden(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    const { hidden } = doc;
    if ( canvas.ready && doc.rendered && doc.object.controlled ) {
      const toUpdate = this.layer.controlled.map(o => ({ _id: o.id, hidden: !hidden }));
      await canvas.scene.updateEmbeddedDocuments(this.documentClass.documentName, toUpdate);
    }
    else await doc.update({ hidden: !hidden });
  }

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

  /**
   * Handle toggling an entry's locked state.
   * @param {PointerEvent} event  The triggering event.
   * @param {HTMLElement} target  The action target.
   * @returns {Promise<void>}
   * @protected
   */
  async _onToggleLocked(event, target) {
    const doc = this._getPlaceableFromElement(target);
    if ( !doc ) return;
    const { locked } = doc;
    if ( canvas.ready && doc.rendered && doc.object.controlled ) {
      const toUpdate = this.layer.controlled.map(o => ({ _id: o.id, locked: !locked }));
      await canvas.scene.updateEmbeddedDocuments(this.documentClass.documentName, toUpdate);
    }
    else await doc.update({ locked: !locked });
  }

  /* -------------------------------------------- */
  /*  Filtering                                   */
  /* -------------------------------------------- */

  /**
   * Filter out elements from the sidebar based on user input.
   * @protected
   */
  _applyFilters() {
    if ( !this.layer || !canvas.ready ) return; // Still drawing.
    let releasedObject = false;
    const viewed = PlaceableTab.#filterViewed;
    const { elevation, levels, visible } = this._filterState;
    visible.clear();
    const { cleanQuery } = this._searchFilter.constructor;
    const elevationField = this.schema.getField("elevation");
    const hasElevationRange = elevationField instanceof foundry.data.fields.SchemaField;
    const hasElevation = hasElevationRange || (elevationField instanceof foundry.data.fields.NumberField);
    for ( const li of this.element.querySelectorAll("[data-entry-id]") ) {
      const entry = this._getPlaceableFromElement(li);
      const matchesName = this._searchFilter.rgx?.test(cleanQuery(this._getEntryLabel(entry))) !== false;
      let matchesElevation = true;
      if ( hasElevationRange ) {
        const {bottom, top, topInclusive=true} = entry.elevation;
        const a = Math.max(bottom, elevation.bottom);
        const b = Math.min(top, elevation.top);
        if ( topInclusive || (bottom === top) || (top > b) ) matchesElevation = a <= b;
        else matchesElevation = a < b;
      } else if ( hasElevation ) {
        matchesElevation = (entry.elevation >= elevation.bottom) && (entry.elevation <= elevation.top);
      }
      const matchesLevel = !levels.size || levels.some(level => entry.locatedInLevel(level));
      let hidden = !matchesName || !matchesElevation || !matchesLevel || (viewed && !entry.viewed);
      if ( !hidden ) hidden = !this._matchesFilter(entry);
      if ( hidden ) {
        if ( entry.rendered && entry.object.controlled ) {
          entry.object.release({ renderSidebar: false });
          releasedObject = true;
        }
        li.classList.remove("active");
      }
      else visible.add(entry);
      li.hidden = hidden;
    }
    // Recompute grouped category counts from visible entries.
    for ( const group of this.element.querySelectorAll(".placeable-group") ) {
      const meta = group.querySelector(".group-header .meta");
      if ( meta ) {
        const count = group.querySelectorAll("[data-entry-id]").values().reduce((n, li) => n + (li.hidden ? 0 : 1), 0);
        meta.textContent = `(${count})`;
      }
    }
    this.layer.setAllRenderFlags({ refreshState: true });
    const filter = this.element.querySelector("search [data-action=openFilter]");
    if ( filter ) filter.ariaPressed = String(this._hasAdvancedFilters());
    ui.placeables?._updateFilterPip();
    if ( releasedObject && (game.activeTool === "select") ) ui.placeablesPalette?.render();
  }

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

  /**
   * Test whether a placeable entry matches any additional tab-specific filter criteria.
   * Only called for entries that already pass the common name, elevation, and level filters.
   * @param {Document} entry  The placeable document to test.
   * @returns {boolean}       True if the entry should remain visible.
   * @protected
   */
  _matchesFilter(entry) {
    return true;
  }

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

  /**
   * Test whether any advanced filter criteria are currently active.
   * @returns {boolean}
   * @protected
   */
  _hasAdvancedFilters() {
    const { elevation } = this._filterState;
    return Number.isFinite(elevation.bottom) || Number.isFinite(elevation.top);
  }

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

  /**
   * Clear all advanced filter state.
   * @protected
   */
  _clearFilters() {
    this._filterState.elevation = { bottom: -Infinity, top: Infinity };
  }

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

  /**
   * Retrieve the Document instance represented by the given entry's element.
   * @param {HTMLElement} element
   * @returns {Document}
   * @protected
   */
  _getPlaceableFromElement(element) {
    const { entryId } = element.closest("[data-entry-id]")?.dataset ?? {};
    return canvas.scene.getEmbeddedCollection(this.collectionName).get(entryId);
  }
}
