// @ts-nocheck
/* global Dialog */
import { buildBoardContext, resolveToggleTarget } from '../../utils/boardUtils.js';
import { generateOpId } from '../../utils/uuid.js';
import { PresetSaveDialog } from './PresetSaveDialog.js';
import { PresetLoadDialog } from './PresetLoadDialog.js';
import { PresetExportDialog } from './PresetExportDialog.js';
import { PresetImportDialog } from './PresetImportDialog.js';
import { ScenePresetPanel } from './ScenePresetPanel.js';

// Conditional base class — test environment lacks foundry globals.
// At module load time in tests, foundry is undefined → fallback class is used.

/** @private */
const _AppBase =
  typeof foundry !== 'undefined' &&
  foundry.applications?.api?.HandlebarsApplicationMixin &&
  foundry.applications?.api?.ApplicationV2
    ? foundry.applications.api.HandlebarsApplicationMixin(
        foundry.applications.api.ApplicationV2
      )
    : class _FallbackApp {
        static DEFAULT_OPTIONS = {};
        static PARTS = {};
        constructor(options = {}) {
          this.options = options;
        }
        get rendered() { return this._rendered ?? false; }
        set rendered(v) { this._rendered = v; }
        get element() { return this._element ?? null; }
        set element(v) { this._element = v; }
        async render() { this._rendered = true; }
        async close() { this._rendered = false; }
        async _prepareContext() { return {}; }
        _onRender() {}
        _onClose() {}
        _onPosition() {}
      };

/**
 * Floating GM-only Director's Board window.
 * Displays all connected participants as a seating-chart grid with per-participant
 * visibility toggle. Extends ApplicationV2 via HandlebarsApplicationMixin.
 */
export class DirectorsBoard extends _AppBase {
  static DEFAULT_OPTIONS = {
    id: 'scrying-pool-directors-board',
    classes: ['scrying-pool', 'directors-board'],
    window: { title: "Director's Board", resizable: true },
    position: { width: 420, height: 480, left: 20, top: 100 },
  };

  static PARTS = {
    board: {
      template: 'modules/scrying-pool/templates/directors-board.hbs',
    },
  };

  /**
   * @param {object} stateStore
   * @param {object} controller
   * @param {object} adapter
   * @param {import('../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
   * @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager
   * @param {object} [options]
   */
  constructor(stateStore, controller, adapter, scenePresetManager, playerPrivacyManager, options = {}) {
    super(options);
    this._stateStore = stateStore;
    this._controller = controller;
    this._adapter = adapter;
    this._scenePresetManager = scenePresetManager;
    this._playerPrivacyManager = playerPrivacyManager;
    this._hookId = null;
    /** @type {Map<string, string>|null} Pre-bulk-action snapshot for single-step undo */
    this._undoSnapshot = null;
    /** @type {Map<string, string>|null} Pre-spotlight snapshot for restore */
    this._spotlightSnapshot = null;
    /** @type {string|null} Currently keyboard-focused participant userId */
    this._focusedUserId = null;
    /** @type {Function|null} Click handler reference for cleanup */
    this._clickHandler = null;
    /** @type {Function|null} Focusin handler reference for cleanup */
    this._focusinHandler = null;
    /** @type {Function|null} Keydown handler reference for cleanup */
    this._keydownHandler = null;
    /** @type {PresetSaveDialog|null} Reference to open save dialog for cleanup */
    this._saveDialog = null;
    /** @type {PresetLoadDialog|null} Reference to open load dialog for cleanup */
    this._loadDialog = null;
    /** @type {PresetExportDialog|null} Reference to open export dialog for cleanup */
    this._exportDialog = null;
    /** @type {PresetImportDialog|null} Reference to open import dialog for cleanup */
    this._importDialog = null;
    /** @type {ScenePresetPanel|null} Reference to scene preset panel for cleanup */
    this._presetPanel = null;
    
    // Load saved position from user flags
    this._loadPosition();
  }

  /**
   * Returns the ScenePresetPanel instance, creating it lazily if needed.
   * Story 3.2: Scene Auto-Apply & ConfirmationBar
   * @returns {ScenePresetPanel} The panel instance.
   * @private
   */
  _getPresetPanel() {
    if (!this._presetPanel) {
      this._presetPanel = new ScenePresetPanel(this._adapter, this._scenePresetManager);
      this._presetPanel.init();
    }
    return this._presetPanel;
  }

  /** Loads saved window position from GM user flag. */
  _loadPosition() {
    try {
      const saved = game.user?.getFlag('scrying-pool', 'directorsBoardState');
      if (saved?.open === true && saved.left != null && saved.top != null) {
        // Ensure options.position exists and is mutable
        if (this.options?.position) {
          // Use Object.assign to avoid TypeError when options is frozen (Foundry ApplicationV2)
          Object.assign(this.options.position, {
            left: saved.left,
            top: saved.top,
            width: saved.width ?? 420,
            height: saved.height ?? 480,
          });
        }
      }
    } catch (err) {
      console.error('[ScryingPool] Failed to load directors board position:', err);
    }
  }

  /** Registers the stateChanged hook listener. Call once from module.js ready hook. */
  init() {
    this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
  }

  /** Unregisters the stateChanged hook listener. */
  teardown() {
    if (this._hookId !== null) {
      Hooks.off('scrying-pool:stateChanged', this._hookId);
      this._hookId = null;
    }
    
    // Story 3.2: Tear down ScenePresetPanel
    if (this._presetPanel) {
      this._presetPanel.teardown();
      this._presetPanel = null;
    }
  }

  /** Opens the board if closed; closes it if open (singleton toggle behaviour). */
  async toggle() {
    if (this.rendered) {
      await this.close();
    } else {
      await this.render({ force: true });
    }
  }

  /**
   * Sets all non-ghost participants to `active`. Stores pre-action snapshot for undo.
   * FR-12: ghost participants excluded.
   */
  async showAll() {
    await this._executeBulk('active');
  }

  /**
   * Sets all non-ghost participants to `hidden`. Stores pre-action snapshot for undo.
   * FR-12: ghost participants excluded.
   */
  async hideAll() {
    await this._executeBulk('hidden');
  }

  /**
   * Internal bulk-action executor for showAll/hideAll.
   * Captures a pre-action snapshot then dispatches per-participant actions.
   * Uses single getState call per user to avoid race conditions.
   * @param {'active'|'hidden'} targetState
   * @private
   */
  async _executeBulk(targetState) {
    const users = this._adapter.users.all();
    
    // Get all user states in a single pass to avoid race conditions
    const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)]));
    
    // Filter to non-ghost users and capture snapshot atomically
    const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost');
    
    // Capture pre-action snapshot (single-step undo) - use the states we already fetched
    this._undoSnapshot = new Map(nonGhost.map(u => [u.id, userStates.get(u.id)]));
    // Bulk supersedes spotlight restore
    this._spotlightSnapshot = null;

    const promises = [];
    for (const u of nonGhost) {
      if (this._controller.hasPendingOp?.(u.id)) continue;
      const opId = generateOpId();
      const baseRevision = this._controller.getRevision?.(u.id) ?? 0;
      // Ensure we await controller actions when they return promises and catch errors
      const p = Promise.resolve(this._controller.action('board', u.id, targetState, opId, baseRevision))
        .catch(err => ({ __err: err, userId: u.id }));
      promises.push(p);
    }

    const results = await Promise.all(promises);
    const failures = results.filter(r => r && r.__err);
    if (failures.length > 0) {
      console.error('[ScryingPool] Bulk action encountered failures:', failures);
      // Keep the undo snapshot so the GM can retry or manually inspect; do not silently clear it.
    }

    if (this.rendered) this.render({ force: true });
  }

  /**
   * Single-step undo: restores participants to their pre-bulk-action states.
   * No-op if no snapshot exists. Ghost participants are skipped.
   */
  async undo() {
    if (!this._undoSnapshot) return;
    const snapshot = this._undoSnapshot;
    // Do not clear the snapshot until actions have completed successfully
    const promises = [];
    for (const [userId, targetState] of snapshot) {
      // Check current state to avoid restoring ghost users that have transitioned
      if (this._stateStore.getState(userId) === 'ghost') continue;
      if (this._controller.hasPendingOp?.(userId)) continue;
      const opId = generateOpId();
      const baseRevision = this._controller.getRevision?.(userId) ?? 0;
      const p = Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision))
        .catch(err => ({ __err: err, userId }));
      promises.push(p);
    }

    const results = await Promise.all(promises);
    const failures = results.filter(r => r && r.__err);
    if (failures.length > 0) {
      console.error('[ScryingPool] Undo encountered failures:', failures);
      // Keep the snapshot in case the GM wants to retry or investigate
    } else {
      // All succeeded — clear the undo snapshot
      this._undoSnapshot = null;
    }

    if (this.rendered) this.render({ force: true });
  }

  /**
   * Spotlights a single participant: sets them `active`, all others `hidden`.
   * Captures a pre-spotlight snapshot and clears any undo snapshot.
   * Ghost participants are excluded from all operations.
   * @param {string} userId - The participant to spotlight
   */
  async spotlight(userId) {
    // Guard: validate userId exists and is not null/undefined
    if (!userId) return;
    
    const users = this._adapter.users.all();
    
    // Get all user states in a single pass to avoid race conditions
    const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)]));
    
    // Filter to non-ghost users
    const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost');
    
    // Check if the requested userId is valid (exists in non-ghost list)
    const validUserIds = new Set(nonGhost.map(u => u.id));
    if (!validUserIds.has(userId)) {
      console.warn(`[ScryingPool] spotlight: userId "${userId}" not found or is ghost`);
      return;
    }
    
    // Capture pre-spotlight snapshot for ALL users (including ghost for completeness)
    this._spotlightSnapshot = new Map(users.map(u => [u.id, userStates.get(u.id)]));
    this._undoSnapshot = null;

    const promises = [];
    for (const u of nonGhost) {
      if (this._controller.hasPendingOp?.(u.id)) continue;
      const targetState = u.id === userId ? 'active' : 'hidden';
      const opId = generateOpId();
      const baseRevision = this._controller.getRevision?.(u.id) ?? 0;
      const p = Promise.resolve(this._controller.action('board', u.id, targetState, opId, baseRevision))
        .catch(err => ({ __err: err, userId: u.id }));
      promises.push(p);
    }

    const results = await Promise.all(promises);
    const failures = results.filter(r => r && r.__err);
    if (failures.length > 0) {
      console.error('[ScryingPool] Spotlight encountered failures:', failures);
      // Keep snapshot in case GM wants to retry or investigate
    }

    if (this.rendered) this.render({ force: true });
  }

  /**
   * Restores participants to their pre-spotlight states.
   * No-op if no spotlight snapshot exists. Ghost participants are skipped.
   */
  async restoreSpotlight() {
    if (!this._spotlightSnapshot) return;
    const snapshot = this._spotlightSnapshot;
    // Do not clear the snapshot until actions succeed
    const promises = [];
    for (const [userId, targetState] of snapshot) {
      // Check current state to avoid restoring ghost users that have transitioned
      if (this._stateStore.getState(userId) === 'ghost') continue;
      if (this._controller.hasPendingOp?.(userId)) continue;
      const opId = generateOpId();
      const baseRevision = this._controller.getRevision?.(userId) ?? 0;
      const p = Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision))
        .catch(err => ({ __err: err, userId }));
      promises.push(p);
    }

    const results = await Promise.all(promises);
    const failures = results.filter(r => r && r.__err);
    if (failures.length > 0) {
      console.error('[ScryingPool] RestoreSpotlight encountered failures:', failures);
      // Keep the snapshot in case the GM wants to retry
    } else {
      this._spotlightSnapshot = null;
    }

    if (this.rendered) this.render({ force: true });
  }

  /**
   * Spotlights the currently focused participant (keyboard shortcut target).
   * No-op if no participant is focused.
   */
  spotlightFocused() {
    if (!this._focusedUserId) return;
    this.spotlight(this._focusedUserId);
  }

  /** @inheritdoc */
  async _prepareContext() {
    const base = buildBoardContext(this._stateStore, this._controller, this._adapter, this._playerPrivacyManager);
    const presetCount = this._scenePresetManager?.list?.().length ?? 0;
    
    // Get auto-apply config for current scene (Story 3.2)
    let autoApplyConfig = { enabled: false, presetName: null, preDelay: 0 };
    try {
      const scenes = this._adapter.scenes;
      const currentScene = scenes?.current?.();
      if (currentScene) {
        const flagData = this._scenePresetManager?._getSceneFlagData?.(currentScene);
        autoApplyConfig = this._scenePresetManager?._getAutoApplyConfig?.(flagData) ?? autoApplyConfig;
      }
    } catch (err) {
      console.warn('[ScryingPool] Failed to get auto-apply config for context:', err);
    }

    // Dock layout selector
    const currentDockLayout = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm';
    const DOCK_LAYOUTS = [
      { key: 'vertical-sm',    icon: 'fa-grip-vertical',    size: 'S', sepAfter: false },
      { key: 'vertical-md',    icon: 'fa-grip-vertical',    size: 'L', sepAfter: true  },
      { key: 'horizontal-sm',  icon: 'fa-grip-horizontal',  size: 'S', sepAfter: false },
      { key: 'horizontal-md',  icon: 'fa-grip-horizontal',  size: 'L', sepAfter: true  },
      { key: 'mosaic-sm',      icon: 'fa-border-all',       size: 'S', sepAfter: false },
      { key: 'mosaic-md',      icon: 'fa-border-all',       size: 'L', sepAfter: false },
      { key: 'freeform',       icon: 'fa-window-restore',   size: 'W', sepAfter: false },
    ];
    const dockLayouts = DOCK_LAYOUTS.map(l => ({
      ...l,
      isActive: l.key === currentDockLayout,
      label: (typeof game !== 'undefined' ? game.i18n?.localize?.(`scrying-pool.directorsBoard.dockLayout.${l.key}`) : null) ?? l.key,
    }));

    // Story 5.2: Video widget width customization
    // Defaults match the settings registration in module.js
    const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83';
    const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
    const SM_WIDTH_OPTIONS = [
      { value: '60', label: '60px' },
      { value: '70', label: '70px' },
      { value: '80', label: '80px' },
      { value: '90', label: '90px' },
      { value: '100', label: '100px' },
      { value: '120', label: '120px' },
      { value: '140', label: '140px' },
      { value: '160', label: '160px' },
      { value: '180', label: '180px' },
      { value: '200', label: '200px' },
    ];
    const MD_WIDTH_OPTIONS = [
      { value: '60', label: '60px' },
      { value: '80', label: '80px' },
      { value: '100', label: '100px' },
      { value: '120', label: '120px' },
      { value: '150', label: '150px' },
      { value: '200', label: '200px' },
      { value: '250', label: '250px' },
      { value: '300', label: '300px' },
      { value: '350', label: '350px' },
      { value: '400', label: '400px' },
    ];

    // Tile shape selector
    const currentTileShape = this._adapter.settings?.get?.('tileShape') ?? 'circle';
    const TILE_SHAPES = [
      { key: 'rounded', icon: 'fa-square', isActive: currentTileShape === 'rounded', label: 'Rounded' },
      { key: 'circle', icon: 'fa-circle', isActive: currentTileShape === 'circle', label: 'Circle' },
      { key: 'hexagon', icon: 'fa-hexagon', isActive: currentTileShape === 'hexagon', label: 'Hexagon' },
      { key: 'octagon', icon: 'fa-shapes', isActive: currentTileShape === 'octagon', label: 'Octagon' },
    ];

    // Tile border around video shapes
    const currentTileBorderWidth = this._adapter.settings?.get?.('tileBorderWidth') ?? 0;
    const currentTileBorderColor = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff';
    const TILE_BORDER_WIDTHS = [
      { value: 0, label: 'None', isActive: currentTileBorderWidth === 0 },
      { value: 1, label: '1px', isActive: currentTileBorderWidth === 1 },
      { value: 2, label: '2px', isActive: currentTileBorderWidth === 2 },
      { value: 4, label: '4px', isActive: currentTileBorderWidth === 4 },
    ];

    return {
      ...base,
      hasUndo: this._undoSnapshot !== null,
      hasRestore: this._spotlightSnapshot !== null,
      presetCount,
      hasPresets: presetCount > 0,
      hasScene: !!this._adapter.scenes?.current?.(),
      autoApplyEnabled: autoApplyConfig.enabled,
      autoApplyPresetName: autoApplyConfig.presetName,
      autoApplyPreDelay: autoApplyConfig.preDelay,
      presets: this._scenePresetManager?.list?.() ?? [],
      // A/V mode — reflects current world AV state (0 = disabled, 3 = audio+video)
      avModeEnabled: (game.webrtc?.settings?.world?.mode ?? 0) !== 0,
      // Dock layout selector
      dockLayouts,
      // Tile shape selector
      tileShape: currentTileShape,
      tileShapes: TILE_SHAPES,
      // Tile border
      tileBorderWidth: currentTileBorderWidth,
      tileBorderColor: currentTileBorderColor,
      tileBorderWidths: TILE_BORDER_WIDTHS,
      // Story 5.2: Video widget width customization
      smWidthOptions: SM_WIDTH_OPTIONS,
      mdWidthOptions: MD_WIDTH_OPTIONS,
      widgetWidthSm,
      widgetWidthMd,
    };
  }

  /**
   * ApplicationV2 lifecycle — sets up event delegation on every render.
   * Removes old listeners first to prevent memory leaks.
   * @inheritdoc
   */
  _onRender(context, options) {
    super._onRender?.(context, options);
    const root = this.element;
    if (!root) return;
    
    // Remove old listeners if they exist (fixes memory leak and broken listeners after reopen)
    if (this._clickHandler) {
      root.removeEventListener('click', this._clickHandler);
    }
    if (this._focusinHandler) {
      root.removeEventListener('focusin', this._focusinHandler);
    }
    if (this._keydownHandler) {
      root.removeEventListener('keydown', this._keydownHandler);
    }
    
    // Create new bound handlers
    this._clickHandler = (e) => {
      const btn = e.target.closest('[data-action]');
      if (!btn) return;
      e.stopPropagation();
      
      // Handle select element changes (Story 5.2)
      if (e.target.tagName === 'SELECT') {
        const action = e.target.dataset.action;
        if (action === 'set-widget-width-sm') {
          this._onSetWidgetWidth(e.target.value, 'sm');
          return;
        } else if (action === 'set-widget-width-md') {
          this._onSetWidgetWidth(e.target.value, 'md');
          return;
        }
      }
      
      switch (btn.dataset.action) {
        case 'toggle-participant': this._dispatchToggle(btn.dataset.userId); break;
        case 'show-all': this.showAll(); break;
        case 'hide-all': this.hideAll(); break;
        case 'undo': this.undo(); break;
        case 'restore-spotlight': this.restoreSpotlight(); break;
        case 'open-shortcut-panel': this._openShortcutPanel(); break;
        case 'save-preset': this._onSavePreset(); break;
        case 'load-preset': this._onLoadPreset(); break;
        case 'export-presets': this._onExportPresets(); break;
        case 'import-presets': this._onImportPresets(); break;
        // Story 3.2: Scene auto-apply panel toggle
        case 'toggle-preset-panel': this._togglePresetPanel(); break;
        case 'toggle-av-mode': this._onToggleAVMode(); break;
        case 'open-av-config': this._onOpenAVConfig(); break;
        case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break;
        case 'set-tile-shape': this._onSetTileShape(btn.dataset.shape); break;
        case 'set-tile-border-width': this._onSetTileBorderWidth(parseInt(btn.dataset.width, 10)); break;
        case 'reset-strip-position': this._onResetStripPosition(); break;
        case 'close': this.close(); break;
      }
    };
    this._focusinHandler = (e) => {
      const card = e.target.closest('[data-user-id]');
      this._focusedUserId = card?.dataset?.userId ?? null;
    };
    this._keydownHandler = (e) => this._onKeydown(e);
    
    // Add new listeners
    root.addEventListener('click', this._clickHandler);
    root.addEventListener('focusin', this._focusinHandler);
    root.addEventListener('keydown', this._keydownHandler);

    // Story 5.2: Set selected values on widget width dropdowns
    // Get current values from settings to ensure they're up to date
    const currentSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83';
    const currentMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
    
    const smSelect = root.querySelector('select[data-action="set-widget-width-sm"]');
    if (smSelect) {
      smSelect.value = currentSm;
      // Add change handler for direct select interaction
      smSelect.addEventListener('change', () => this._onSetWidgetWidth(smSelect.value, 'sm'));
    }
    const mdSelect = root.querySelector('select[data-action="set-widget-width-md"]');
    if (mdSelect) {
      mdSelect.value = currentMd;
      // Add change handler for direct select interaction
      mdSelect.addEventListener('change', () => this._onSetWidgetWidth(mdSelect.value, 'md'));
    }

    // Tile border color picker — Foundry overwrites inline template styles via setPosition,
    // but the color input's value attribute IS preserved. However, click fires when opening
    // the picker (before the user chooses), so listen for 'change' instead.
    const borderColorInput = root.querySelector('input[data-action="set-tile-border-color"]');
    if (borderColorInput) {
      borderColorInput.value = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff';
      borderColorInput.addEventListener('change', () => this._onSetTileBorderColor(borderColorInput.value));
    }

    // Drag grip — custom drag (ApplicationV2 header is hidden)
    const grip = root.querySelector('[data-action="drag-grip"]');
    if (grip) {
      grip.addEventListener('mousedown', e => {
        if (e.button !== 0) return;
        e.preventDefault();
        const startX = e.clientX;
        const startY = e.clientY;
        const { left: startLeft, top: startTop } = this.position;
        const onMove = mv => this.setPosition({
          left: startLeft + (mv.clientX - startX),
          top: startTop + (mv.clientY - startY),
        });
        const onUp = () => {
          document.removeEventListener('mousemove', onMove);
          document.removeEventListener('mouseup', onUp);
        };
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
      });
    }
    
    // Story 3.2: Append ScenePresetPanel to DOM and refresh
    this._appendPresetPanel(root);
    this._refreshPresetPanel();
  }

  /**
   * Appends the ScenePresetPanel to the DirectorsBoard DOM.
   * Story 3.2: Scene Auto-Apply & ConfirmationBar
   * @param {HTMLElement} root - The DirectorsBoard root element.
   * @private
   */
  _appendPresetPanel(root) {
    const panel = this._getPresetPanel();
    if (!panel || !panel.element) return;
    
    // Find where to insert the panel (after content, before footer)
    const content = root.querySelector('.directors-board__content');
    if (content) {
      // Insert after content
      content.after(panel.element);
    } else {
      // Fallback: prepend to root
      root.prepend(panel.element);
    }
  }

  /**
   * Refreshes the ScenePresetPanel content.
   * Story 3.2: Scene Auto-Apply & ConfirmationBar
   * @private
   */
  _refreshPresetPanel() {
    const panel = this._getPresetPanel();
    if (panel) {
      panel._refresh?.();
    }
  }

  /**
   * Keyboard navigation within the participant grid.
   * ArrowLeft/Right/Up/Down move focus; Space/Enter toggles the focused card.
   * `?` opens the shortcut panel; Ctrl+Shift+P spotlights focused card.
   * @param {KeyboardEvent} e
   */
  _onKeydown(e) {
    const cards = [...(this.element?.querySelectorAll('[data-user-id]') ?? [])];
    if (cards.length === 0) return;
    const current = document.activeElement;
    const idx = cards.indexOf(current);
    
    // Guard against negative index (focus from non-card element)
    if (idx < 0) return;

    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      cards[(idx + 1) % cards.length]?.focus();
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      cards[(idx - 1 + cards.length) % cards.length]?.focus();
    } else if ((e.key === 'Enter' || e.key === ' ') && current?.dataset?.userId) {
      e.preventDefault();
      this._dispatchToggle(current.dataset.userId);
    } else if (e.key === '?') {
      e.preventDefault();
      this._openShortcutPanel();
    } else if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') {
      e.preventDefault();
      this.spotlightFocused();
    }
  }

  /**
   * Dispatches a visibility toggle for a participant through the controller.
   * Matches FR-1: always goes through controller.action(), never direct setState.
   * @param {string} userId
   */
  async _dispatchToggle(userId) {
    if (!userId) return;
    if (this._controller.hasPendingOp?.(userId)) return;
    const currentState = this._stateStore.getState(userId) ?? 'active';
    const targetState = resolveToggleTarget(currentState);
    const opId = generateOpId();
    const baseRevision = this._controller.getRevision?.(userId) ?? 0;
    try {
      await Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision));
    } catch (err) {
      console.error('[ScryingPool] toggle action failed for', userId, err);
    } finally {
      if (this.rendered) this.render({ force: true });
    }
  }

  /**
   * Hook handler — re-renders the board when a participant state changes.
   * @param {object} data
   */
  _onStateChanged(data) {
    // Suppress unused parameter warning - data is intentionally unused
    void data;
    if (this.rendered) {
      this.render({ force: true });
    }
  }

  /**
   * Opens a Dialog showing all Director's Board keyboard shortcuts and their current bindings.
   * Reads from game.keybindings.bindings when available, falling back to defaults.
   */
  _openShortcutPanel() {
    try {
      const localize = (key) => game.i18n?.localize(key) ?? key;

      const getBinding = (actionKey) => {
        // Check both namespaces due to migration from scrying-pool to scrying-pool
        const namespaces = ['scrying-pool', 'scrying-pool'];
        for (const ns of namespaces) {
          const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`);
          if (bindings?.[0]) {
            const b = bindings[0];
            const mods = (b.modifiers ?? []).join('+');
            return mods ? `${mods}+${b.key}` : b.key;
          }
        }
        return null;
      };

      const shortcuts = [
        { label: localize('scrying-pool.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' },
        { label: localize('scrying-pool.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' },
        { label: localize('scrying-pool.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' },
        { label: localize('scrying-pool.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' },
      ];

      // Escape HTML to prevent injection via localised strings or keybinding labels
      const escapeHtml = (str) => String(str ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[c]));

      const rows = shortcuts.map(s => `<tr><td>${escapeHtml(s.label)}</td><td><kbd>${escapeHtml(s.binding)}</kbd></td></tr>`).join('');
      const content = `<table class="directors-board__shortcuts-table"><tbody>${rows}</tbody></table>`;

      if (typeof Dialog !== 'undefined') {
        new Dialog({
          title: localize('scrying-pool.directorsBoard.shortcuts.title'),
          content,
          buttons: { close: { label: 'Close' } },
          default: 'close',
        }).render(true);
      }
    } catch (err) {
      console.error('[ScryingPool] Failed to open shortcut panel:', err);
    }
  }

  /**
   * Toggles the ScenePresetPanel visibility.
   * Story 3.2: Scene Auto-Apply & ConfirmationBar
   */
  _togglePresetPanel() {
    const panel = this._getPresetPanel();
    if (panel) {
      panel.toggle();
    }
  }

  /**
   * Toggles Foundry A/V mode between AUDIO_VIDEO (3) and DISABLED (0).
   * The module is the single point of control for A/V activation — Foundry's
   * native AVConfig dialog is redirected here by initScryingPoolCameraViews.
   *
   * Uses reestablish() rather than explicit connect()/disconnect() to avoid
   * racing with Foundry's internal mode-change listeners (AVMaster hooks into
   * the settings change event itself).
   */
  async _onToggleAVMode() {
    if (!game.webrtc) {
      console.warn('[ScryingPool] DirectorsBoard: game.webrtc not available');
      return;
    }
    const currentMode = game.webrtc.settings?.world?.mode ?? 0;
    // AV_MODES: DISABLED=0, AUDIO=1, VIDEO=2, AUDIO_VIDEO=3
    const newMode = currentMode === 0 ? 3 : 0;
    try {
      await game.webrtc.settings.set('world', 'mode', newMode);
      // reestablish() tears down and rebuilds the WebRTC connection honouring the
      // new mode — same approach used by Foundry's own AVConfig save handler.
      await game.webrtc.reestablish?.();
    } catch (err) {
      console.error('[ScryingPool] DirectorsBoard: failed to toggle A/V mode:', err);
    }
    if (this.rendered) this.render({ force: true });
  }

  /**
   * Opens Foundry's native AVConfig dialog for signaling server configuration.
   * This is separate from the A/V mode toggle — AVConfig is where you set up the
   * LiveKit/WebRTC server address, username, password, etc.
   * The module controls A/V mode (on/off); Foundry's dialog handles infrastructure.
   */
  _onOpenAVConfig() {
    if (!game.webrtc?.config) {
      console.warn('[ScryingPool] DirectorsBoard: game.webrtc.config not available');
      return;
    }
    game.webrtc.config.render({ force: true });
  }

  /**
   * Saves the selected dock layout and re-renders the board.
   * The strip re-renders automatically via the setting's onChange callback.
   * @param {string} layoutKey
   */
  async _onSetDockLayout(layoutKey) {
    if (!layoutKey) return;
    try {
      await this._adapter.settings?.set?.('dockLayout', layoutKey);
      // Reset per-user size override so the world layout takes effect
      await this._adapter.settings?.set?.('dockLayoutExpanded', '');
    } catch (err) {
      console.error('[ScryingPool] Failed to set dockLayout:', err);
    }
    if (this.rendered) this.render({ force: true });
  }

  /**
   * Sets the widget width for small or large tiles.
   * Story 5.2: Video widget width customization
   * @param {string} value - The width value (e.g., '80', '120')
   * @param {'sm'|'md'} size - The size variant
   */
  async _onSetWidgetWidth(value, size) {
    if (!value) return;
    const settingKey = size === 'sm' ? 'widgetWidthSm' : 'widgetWidthMd';
    try {
      await this._adapter.settings?.set?.(settingKey, value);
    } catch (err) {
      console.error('[ScryingPool] Failed to set widget width:', err);
    }
    if (this.rendered) this.render({ force: true });
  }

  /**
   * Sets the tile shape for participant avatars.
   * @param {string} shape - 'rounded', 'circle', 'hexagon', or 'octagon'
   */
  async _onSetTileShape(shape) {
    if (!shape) return;
    try {
      await this._adapter.settings?.set?.('tileShape', shape);
    } catch (err) {
      console.error('[ScryingPool] Failed to set tile shape:', err);
    }
    if (this.rendered) this.render({ force: true });
  }

  /**
   * Sets the border width around video shapes.
   * @param {number} width - 0, 1, 2, or 4
   */
  async _onSetTileBorderWidth(width) {
    if (width == null) return;
    try {
      await this._adapter.settings?.set?.('tileBorderWidth', width);
    } catch (err) {
      console.error('[ScryingPool] Failed to set tile border width:', err);
    }
    if (this.rendered) this.render({ force: true });
  }

  /**
   * Sets the border color around video shapes.
   * @param {string} color - hex color string e.g. '#ff0000'
   */
  async _onSetTileBorderColor(color) {
    if (!color) return;
    try {
      await this._adapter.settings?.set?.('tileBorderColor', color);
    } catch (err) {
      console.error('[ScryingPool] Failed to set tile border color:', err);
    }
    if (this.rendered) this.render({ force: true });
  }

  /**
   * Clears the saved strip position flag so the strip defaults to its original position.
   */
  _onResetStripPosition() {
    try {
      game.user?.setFlag?.('scrying-pool', 'stripState', null);
    } catch (err) {
      console.error('[ScryingPool] Failed to reset strip position:', err);
    }
  }

  /**
   * Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
   */
  _onSavePreset() {
    if (!this._scenePresetManager || !this._adapter) {
      console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available');
      return;
    }
    
    // Close any existing dialog first
    this._closePresetDialogs();
    
    // Create and render the save dialog
    this._saveDialog = new PresetSaveDialog(this._scenePresetManager, this._adapter);
    this._saveDialog.render(true);
  }

  /**
   * Opens the PresetLoadDialog for loading a saved preset.
   */
  _onLoadPreset() {
    if (!this._scenePresetManager || !this._adapter) {
      console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available');
      return;
    }
    
    // Close any existing dialog first
    this._closePresetDialogs();
    
    // Create and render the load dialog
    this._loadDialog = new PresetLoadDialog(this._scenePresetManager, this._adapter);
    this._loadDialog.render(true);
  }

  /**
   * Opens the PresetExportDialog for exporting all presets.
   */
  _onExportPresets() {
    if (!this._scenePresetManager || !this._adapter) {
      console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available');
      return;
    }
    
    // Close any existing dialog first
    this._closePresetDialogs();
    
    // Create and render the export dialog
    this._exportDialog = new PresetExportDialog({
      adapter: this._adapter,
      scenePresetManager: this._scenePresetManager,
    });
    this._exportDialog.render(true);
  }

  /**
   * Opens the PresetImportDialog for importing presets.
   */
  _onImportPresets() {
    if (!this._scenePresetManager || !this._adapter) {
      console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available');
      return;
    }
    
    // Close any existing dialog first
    this._closePresetDialogs();
    
    // Create and render the import dialog
    this._importDialog = new PresetImportDialog({
      adapter: this._adapter,
      scenePresetManager: this._scenePresetManager,
    });
    this._importDialog.render(true);
  }

  /**
   * Closes any open preset dialogs.
   * @private
   */
  _closePresetDialogs() {
    if (this._saveDialog) {
      try {
        this._saveDialog.close();
      } catch {
        // Ignore errors during cleanup
      }
      this._saveDialog = null;
    }
    if (this._loadDialog) {
      try {
        this._loadDialog.close();
      } catch {
        // Ignore errors during cleanup
      }
      this._loadDialog = null;
    }
    if (this._exportDialog) {
      try {
        this._exportDialog.close();
      } catch {
        // Ignore errors during cleanup
      }
      this._exportDialog = null;
    }
    if (this._importDialog) {
      try {
        this._importDialog.close();
      } catch {
        // Ignore errors during cleanup
      }
      this._importDialog = null;
    }
  }

  /**
   * ApplicationV2 lifecycle — clean up event listeners when closed.
   * @inheritdoc
   */
  async _onClose(options) {
    await super._onClose?.(options);
    
    // Close any open preset dialogs
    this._closePresetDialogs();
    
    // Clean up event listeners to prevent memory leaks
    if (this._clickHandler) {
      this.element?.removeEventListener('click', this._clickHandler);
      this._clickHandler = null;
    }
    if (this._focusinHandler) {
      this.element?.removeEventListener('focusin', this._focusinHandler);
      this._focusinHandler = null;
    }
    if (this._keydownHandler) {
      this.element?.removeEventListener('keydown', this._keydownHandler);
      this._keydownHandler = null;
    }
    
    this._savePosition({ open: false });
  }

  /**
   * ApplicationV2 lifecycle — save window position when repositioned.
   * @inheritdoc
   */
  _onPosition(position) {
    super._onPosition?.(position);
    const { left, top, width, height } = position;
    this._savePosition({ left, top, width, height, open: true });
  }

  /**
   * Persists position/open state to GM user flag.
   * @private
   * @param {object} state
   */
  _savePosition(state) {
    try {
      game.user?.setFlag('scrying-pool', 'directorsBoardState', state);
    } catch (err) {
      console.error('[ScryingPool] Failed to save directors board position:', err);
    }
  }
}
