// @ts-nocheck
/* global directorsBoard */
import { generateOpId } from '../../utils/uuid.js';
import { StripOverlayLayer } from '../shared/StripOverlayLayer.js';

/**
 * Canonical action labels — never use inline string literals.
 * @type {Readonly<{HIDE_FROM_TABLE: string, SHOW_TO_TABLE: string, FIRST_TOOLTIP: string}>}
 */
export const LABELS = Object.freeze({
  HIDE_FROM_TABLE: 'Hide from table',
  SHOW_TO_TABLE: 'Show to table',
  FIRST_TOOLTIP: 'Hide this participant from other players.',
});

/**
 * Resolves the target state for a hide/show toggle action.
 * @param {string} currentState
 * @returns {'hidden'|'active'}
 */
export function resolveTargetState(currentState) {
  return currentState === 'hidden' ? 'active' : 'hidden';
}

/**
 * Builds a participant list array for getData().
 * @param {string[]} userIds
 * @param {object} stateStore
 * @param {object} controller
 * @param {object} adapter
 * @param {boolean} hasStreamAccess - Whether stream access is available for video replacement
 * @param {object|null} [portraitFallbackHandler] - PortraitFallbackHandler for custom portrait lookup
 * @returns {Array<object>}
 */
export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false, portraitFallbackHandler = null) {
  return userIds.map(userId => {
    const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
    const state = stateStore.getState(userId) ?? 'active';

    // Priority: custom fallback portrait → user.avatar → character portrait → mystery-man
    const avatarSrc = portraitFallbackHandler?.getFallbackImageURL(userId)
      || user.avatar
      || user.character?.img
      || 'icons/svg/mystery-man.svg';

    return {
      userId,
      name: user.name ?? userId,
      avatarSrc,
      state,
      stateLabel: _stateLabel(state),
      hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
      isCurrentUser: adapter.users.current?.()?.id === userId,
      hasStreamAccess,
    };
  });
}

/**
 * Returns a human-readable label for a state string.
 * @param {string} state
 * @returns {string}
 */
function _stateLabel(state) {
  const LABELS_MAP = {
    active: 'Active',
    hidden: 'Hidden',
    'self-muted': 'Self-muted',
    offline: 'Offline',
    'cam-lost': 'Camera lost',
    reconnecting: 'Reconnecting',
    'never-connected': 'Never connected',
    ghost: 'Ghost',
  };
  return LABELS_MAP[state] ?? state;
}

// 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;
          this.position = { left: 0, top: 0 };
        }
        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() {}
        setPosition() {}
      };

/**
 * GM-only floating control strip showing all connected participants.
 * Extends ApplicationV2 via HandlebarsApplicationMixin.
 */
export class ScryingPoolStrip extends _AppBase {
  static DEFAULT_OPTIONS = {
    id: 'scrying-pool-strip',
    classes: ['scrying-pool-strip'],
    window: { title: 'Scrying Pool', resizable: false },
    position: { width: 240 },
  };

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

  /**
   * @param {object} stateStore
   * @param {object} controller
   * @param {object} avTileAdapter
   * @param {object} adapter
   * @param {object} [options]
   */
  constructor(stateStore, controller, avTileAdapter, adapter, options = {}) {
    const { portraitFallbackHandler = null, ...appOptions } = options;
    super(appOptions);
    this._stateStore = stateStore;
    this._controller = controller;
    this._avTileAdapter = avTileAdapter;
    this._adapter = adapter;
    this._portraitFallbackHandler = portraitFallbackHandler;
    /** @type {ActionPopover|null} */
    this._activePopover = null;
    /** @type {StripOverlayLayer|null} */
    this._stripOverlayLayer = null;

    /** @type {Map<string, {retryCount: number, retryTimer: number|null}>} */
    this._videoStreamRetries = new Map();
    /** @type {number|null} */
    this._healthCheckInterval = null;
    /** @type {number|string|null} */
    this._userConnectedHookId = null;
    /** @type {string|null} */
    this._focusedUserId = null;
    /** @type {number|null} */
    this._positionSaveTimer = null;

    // Load saved position from user flags
    this._loadPosition();

    /** Bound keydown handler for Escape-to-exit-focus */
    this._onDocumentKeydown = (e) => {
      if (e.key === 'Escape' && this._focusedUserId) {
        this._focusedUserId = null;
        if (typeof this.render === 'function') {
          this.render({ force: false });
        }
      }
    };
    if (typeof document?.addEventListener === 'function') {
      document.addEventListener('keydown', this._onDocumentKeydown);
    }
  }

  /** Loads saved window position from GM user flag. */
  _loadPosition() {
    try {
      const saved = game.user?.getFlag?.('scrying-pool', 'stripState');
      if (saved?.left != null && saved?.top != null) {
        if (saved.left < 0 || saved.top < 0) return;
        const screenW = typeof window !== 'undefined' ? (window.screen?.availWidth ?? Infinity) : Infinity;
        const screenH = typeof window !== 'undefined' ? (window.screen?.availHeight ?? Infinity) : Infinity;
        if (saved.left < screenW - 50 && saved.top < screenH - 50) {
          if (typeof this.setPosition === 'function') {
            this.setPosition({ left: saved.left, top: saved.top });
          }
        }
      }
    } catch (_e) { /* no-op in test environment */ }
  }

  /**
   * Returns the StripOverlayLayer instance, creating it lazily if needed.
   * @returns {StripOverlayLayer} The overlay layer instance.
   */
  get stripOverlayLayer() {
    if (!this._stripOverlayLayer) {
      this._stripOverlayLayer = new StripOverlayLayer(this._adapter);
      this._stripOverlayLayer.init();
    }
    return this._stripOverlayLayer;
  }

  /** @inheritdoc */
  async _prepareContext(options) {
    const showFirstOpenTip =
      typeof game !== 'undefined' &&
      !game.user?.getFlag?.('scrying-pool', 'firstStripOpen');

    const userIds = this._adapter.users.all
      ? this._adapter.users.all().map(u => u.id)
      : [];

    // Respect the showGMSelfFeed setting: if false, exclude the current GM user
    const showGMSelfFeed = this._adapter.settings?.get?.('showGMSelfFeed') ?? true;
    const currentUserId = this._adapter.users.current?.()?.id;
    const filteredUserIds = showGMSelfFeed
      ? userIds
      : userIds.filter(id => id !== currentUserId);

    // Re-order participants per GM's saved order (drag-and-drop)
    try {
      const savedOrder = game.user?.getFlag?.('scrying-pool', 'participantOrder');
      if (Array.isArray(savedOrder) && savedOrder.length > 0) {
        const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
        filteredUserIds.sort((a, b) => {
          const ai = orderMap.get(a);
          const bi = orderMap.get(b);
          if (ai !== undefined && bi !== undefined) return ai - bi;
          if (ai !== undefined) return -1;
          if (bi !== undefined) return 1;
          return 0;
        });
      }
    } catch (_e) { /* no-op in test environment */ }

    // Check if we have stream access for video replacement (full AV replacement mode)
    const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;

    const participants = buildParticipantList(
      filteredUserIds,
      this._stateStore,
      this._controller,
      this._adapter,
      hasStreamAccess,
      this._portraitFallbackHandler
    );

    // Mark focused participant for gold ring visual
    participants.forEach(p => { p.isFocused = this._focusedUserId === p.userId; });

    // Remove hidden participants from the strip — they are managed via the Directors Board.
    let visibleParticipants = participants.filter(p => p.state !== 'hidden');

    // Spotlight: if a participant is focused, only show that one
    if (this._focusedUserId) {
      visibleParticipants = visibleParticipants.filter(p => p.userId === this._focusedUserId);
    }

    // Dock layout: world setting gives direction+canonical size; client override only applies if user toggled
    const rawLayout = this._adapter.settings?.get?.('dockLayout');
    const baseLayout = typeof rawLayout === 'string' ? rawLayout : 'vertical-sm';
    const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md'
    const parts = baseLayout.split('-');
    const dir = parts.slice(0, -1).join('-');
    const canonicalSize = parts[parts.length - 1]; // size from world setting (GM's choice)
    const effectiveSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize;
    const dockLayout = `${dir}-${effectiveSize}`;
    const isExpanded = dockLayout === 'vertical-md';
    const showName = dockLayout.endsWith('-md');

    // Story 5.2: Video widget width customization
    // Defaults match settings registration in module.js: 83px for sm, 150px for md
    const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83';
    const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
    const isLarge = effectiveSize === 'md';
    const effectiveWidth = isLarge ? widgetWidthMd : widgetWidthSm;
    const isSpotlightActive = !!this._focusedUserId;
    const widgetWidth = isSpotlightActive ? String(parseInt(effectiveWidth, 10) * 2) : effectiveWidth;

    const isGM = this._adapter.users.isGM?.() ?? false;

    // Tile shape for participant avatars
    const tileShape = this._adapter.settings?.get?.('tileShape') ?? 'circle';

    // Tile border around video shapes
    const tileBorderWidth = this._adapter.settings?.get?.('tileBorderWidth') ?? 0;
    const tileBorderColor = this._adapter.settings?.get?.('tileBorderColor') ?? '#ffffff';

    return {
      participants: visibleParticipants,
      isExpanded,
      showName,
      dockLayout,
      isEmpty: visibleParticipants.length === 0,
      showFirstOpenTip: showFirstOpenTip && isGM,
      hasStreamAccess,
      isGM,
      // Story 5.2: Video widget width customization
      isSpotlightActive,
      widgetWidth,
      // Tile shape
      tileShape,
      // Tile border
      tileBorderWidth,
      tileBorderColor,
    };
  }

  /** @inheritdoc */
  _onRender(context, options) {
    super._onRender?.(context, options);
    const el = this.element;
    if (!el) return;

    el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
      const userId = btn.dataset.userId;
      btn.addEventListener('click', e => {
        if (e.shiftKey) {
          e.stopPropagation();
          this._toggleFocus(userId);
          return;
        }
        e.stopPropagation();
        this._openPopover(userId, btn);
      });
      btn.addEventListener('contextmenu', e => {
        e.preventDefault();
        e.stopPropagation();
        this._openContextMenu(userId, btn, e);
      });
    });

    // Drag-and-drop reordering of participants
    // Remove old DnD listeners first to prevent duplicates on re-render
    el.querySelectorAll('.sp-strip__participant-item').forEach(item => {
      const existing = item._dndHandlers;
      if (existing) {
        item.removeEventListener('dragstart', existing.dragstart);
        item.removeEventListener('dragover', existing.dragover);
        item.removeEventListener('drop', existing.drop);
        item.removeEventListener('dragend', existing.dragend);
        item.removeEventListener('dragleave', existing.dragleave);
      }
      const handlers = {
        dragstart: e => this._onDragStart(e),
        dragover: e => this._onDragOver(e),
        drop: e => this._onDrop(e),
        dragend: e => this._onDragEnd(e),
        dragleave: e => this._onDragLeave(e),
      };
      item._dndHandlers = handlers;
      item.draggable = true;
      item.addEventListener('dragstart', handlers.dragstart);
      item.addEventListener('dragover', handlers.dragover);
      item.addEventListener('drop', handlers.drop);
      item.addEventListener('dragend', handlers.dragend);
      item.addEventListener('dragleave', handlers.dragleave);
    });

    const toggle = el.querySelector('[data-action="toggle-expanded"]');
    if (toggle) {
      toggle.addEventListener('click', () => this._toggleExpanded());
    }

    // Director's Board CTA button (fallback for sidebar API)
    const directorsBoardBtn = el.querySelector('[data-action="open-directors-board"]');
    if (directorsBoardBtn) {
      directorsBoardBtn.addEventListener('click', () => {
        if (typeof directorsBoard !== 'undefined' && directorsBoard) {
          directorsBoard.toggle();
        }
      });
    }

    // Custom close button (replaces Foundry window header close)
    const closeBtn = el.querySelector('[data-action="close-strip"]');
    if (closeBtn) {
      closeBtn.addEventListener('click', () => this.close());
    }

    // Drag grip — custom drag implementation (Foundry v14 ApplicationV1 does not expose its drag handler)
    // Double-click grip resets participant order to connection order
    const grip = el.querySelector('[data-action="drag-grip"]');
    if (grip) {
      grip.addEventListener('dblclick', e => {
        e.preventDefault();
        this._resetParticipantOrder();
      });
      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);
          this._savePosition();
        };
        document.addEventListener('mousemove', onMove);
        document.addEventListener('mouseup', onUp);
      });
    }

    // First open tip: set flag so it doesn't show again
    const isFirstOpen =
      typeof game !== 'undefined' &&
      !game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
    if (isFirstOpen) {
      game.user?.setFlag?.('scrying-pool', 'firstStripOpen', true);
    }

    // Attach video streams if we have stream access (full AV replacement mode)
    if (this._adapter.webrtc?.getMediaStreamForUser !== undefined) {
      this._attachVideoStreams(el);
    }

    // Start stream monitoring on first render
    if (this._healthCheckInterval === null) {
      this._initStreamMonitoring();
    }

    // Set CSS custom properties — Foundry's setPosition() overwrites inline template styles
    const setVar = (name, value) => {
      if (value !== undefined && value !== null) el.style.setProperty(name, value);
    };
    const widgetWidth = context?.widgetWidth ?? '150';
    setVar('--sp-widget-width', `${widgetWidth}px`);
    const bw = context?.tileBorderWidth ?? 0;
    setVar('--sp-tile-border-width', `${bw}px`);
    setVar('--sp-tile-border-color', context?.tileBorderColor ?? '#ffffff');
    setVar('--sp-tile-border-active', bw > 0 ? '1' : '0');

    // Sync the outer Application window width with the selected dock layout.
    // Spotlight mode doubles the strip window to fit the enlarged tile.
    if (typeof this.setPosition === 'function') {
      const layout = context?.dockLayout ?? 'vertical-sm';
      const n = context?.participants?.length ?? 0;
      const spotlightMultiplier = context?.isSpotlightActive ? 2 : 1;
      const width = this._computeStripWidth(layout, n) * spotlightMultiplier;
      const height = this._computeStripHeight(layout, n);
      const adjustedHeight = height === 'auto' ? 'auto' : height * spotlightMultiplier;
      this.setPosition(adjustedHeight === 'auto' ? { width, height: 'auto' } : { width, height: adjustedHeight });
    }
  }

  /**
   * Gets the widget width for a given layout size.
   * Story 5.2: Video widget width customization
   * @param {'sm'|'md'} size - The size variant
   * @returns {number} Width in pixels.
   */
  _getWidgetWidth(size) {
    const value = this._adapter.settings?.get?.(`widgetWidth${size === 'md' ? 'Md' : 'Sm'}`);
    // Defaults match settings defaults: 83px for sm, 150px for md
    return typeof value === 'string' ? parseInt(value, 10) : (size === 'md' ? 150 : 83);
  }

  /**
   * Computes the strip window width for a given dock layout and participant count.
   * @param {string} layout - The dock layout key.
   * @param {number} participantCount - Number of visible participants.
   * @returns {number} Width in pixels.
   */
  _computeStripWidth(layout, participantCount) {
    const n = Math.max(1, participantCount);
    const GAP = 4, PAD = 8, BORDER_W = 3; // 2px real border + 1px safety
    const maxCols = 4;
    const rowWidth = (tileSize, cols) =>
      cols * tileSize + Math.max(0, cols - 1) * GAP + PAD + BORDER_W;
    
    const smWidth = this._getWidgetWidth('sm');
    const mdWidth = this._getWidgetWidth('md');
    
    switch (layout) {
      case 'vertical-sm':    return rowWidth(smWidth, 1);
      case 'vertical-md':    return rowWidth(mdWidth, 1);
      case 'horizontal-sm':  return rowWidth(smWidth, Math.min(maxCols, n));
      case 'horizontal-md':  return rowWidth(mdWidth, Math.min(maxCols, n));
      case 'mosaic-sm':      return rowWidth(smWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
      case 'mosaic-md':      return rowWidth(mdWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
      default:               return smWidth + 2;
    }
  }

  /**
   * Computes the strip window height for horizontal and mosaic layouts so the
   * window snaps exactly to its content (grip + toolbar + tile grid).
   * Returns 'auto' for vertical layouts where CSS handles the height naturally.
   *
   * Measurements from CSS:
   *   .sp-strip__grip    → 16px
   *   .sp-strip__toolbar → 28px
   *   .sp-strip__participants padding: 4px each side → 8px
   *   tile gap: 4px
   *
   * @param {string} layout - The dock layout key.
   * @param {number} participantCount - Number of visible participants.
   * @returns {number|'auto'} Height in pixels, or 'auto' for vertical layouts.
   */
  _computeStripHeight(layout, participantCount) {
    const n = Math.max(1, participantCount);
    const CHROME = 16 + 29; // grip 16px + toolbar 28px content + 1px border-bottom
    const BORDER_H = 2;     // 1px border top + 1px border bottom on app element
    const GAP = 4, TILE_PAD = 8; // 4px padding each side in .sp-strip__participants
    const maxCols = 4;
    
    const smWidth = this._getWidgetWidth('sm');
    const mdWidth = this._getWidgetWidth('md');
    
    const gridHeight = (tileSize, cols) => {
      const rows = Math.ceil(n / cols);
      return rows * tileSize + Math.max(0, rows - 1) * GAP + TILE_PAD;
    };
    switch (layout) {
      case 'horizontal-sm': return CHROME + gridHeight(smWidth, Math.min(maxCols, n)) + BORDER_H;
      case 'horizontal-md': return CHROME + gridHeight(mdWidth, Math.min(maxCols, n)) + BORDER_H;
      case 'mosaic-sm':     return CHROME + gridHeight(smWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H;
      case 'mosaic-md':     return CHROME + gridHeight(mdWidth, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H;
      default:              return 'auto';
    }
  }

  /** @inheritdoc */
  async close(options = {}) {
    if (this._activePopover) {
      this._activePopover.close('superseded');
      this._activePopover = null;
    }
    
    // Remove document-level keydown listener
    if (typeof document?.removeEventListener === 'function') {
      document.removeEventListener('keydown', this._onDocumentKeydown);
    }
    
    // Tear down stream monitoring
    this._teardownStreamMonitoring();
    
    // Clean up video elements and streams when closing
    this._cleanupVideoStreams();
    
    if (typeof game !== 'undefined') {
      game.user?.setFlag?.('scrying-pool', 'stripState', {
        left: this.position?.left,
        top: this.position?.top,
        open: false,
      });
    }
    return super.close(options);
  }

  /**
   * Removes video elements from the DOM when the strip closes.
   * Does NOT stop the underlying MediaStream tracks — those belong to
   * FoundryVTT's WebRTC system (`game.webrtc.client`) and must stay alive
   * so they can be re-attached if the strip re-opens later.
   */
  _cleanupVideoStreams() {
    if (typeof document === 'undefined') return;
    
    // Clear all retry timers
    for (const [, retryInfo] of this._videoStreamRetries) {
      if (retryInfo.retryTimer !== null) {
        clearTimeout(retryInfo.retryTimer);
      }
    }
    this._videoStreamRetries.clear();
    
    const videoElements = this.element?.querySelectorAll?.('.sp-participant-video__element') ?? [];
    videoElements.forEach(videoEl => {
      videoEl.srcObject = null;
      videoEl.remove();
    });
  }

  /**
   * Toggles between small and large size for the current dock layout direction.
   * Writes to the client-scoped dockLayoutExpanded setting ('' | 'sm' | 'md') so
   * players can toggle without needing world-setting write permission.
   * '' means no personal override — follow the GM's world setting.
   */
  _toggleExpanded() {
    const raw = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm';
    const canonicalSize = typeof raw === 'string' ? raw.split('-').pop() : 'sm';
    const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md'
    const currentSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize;
    const newSize = currentSize === 'md' ? 'sm' : 'md';
    this._adapter.settings?.set?.('dockLayoutExpanded', newSize).catch(err => {
      console.error('[ScryingPool] Failed to toggle dock layout:', err);
    });
    // Strip re-renders via the onChange callback registered in module.js
  }

  /**
   * Opens an ActionPopover for the given participant. Supersedes any open popover.
   * @param {string} participantId
   * @param {HTMLElement} anchorEl
   */
  _openPopover(participantId, anchorEl) {
    if (this._activePopover) {
      this._activePopover.close('superseded');
    }
    const state = this._stateStore.getState(participantId);
    const hasPending = this._controller.hasPendingOp?.(participantId) ?? false;
    const popover = new ActionPopover(
      participantId,
      state,
      anchorEl,
      hasPending,
      (pid, targetState) => this._dispatchAction(pid, targetState)
    );
    popover.open();
    this._activePopover = popover;
    anchorEl.setAttribute('aria-pressed', 'true');
    popover.onClose(() => {
      anchorEl.setAttribute('aria-pressed', 'false');
      anchorEl.focus?.();
      if (this._activePopover === popover) this._activePopover = null;
    });
  }

  /**
   * Opens a native context menu for hide/show actions.
   * @param {string} participantId
   * @param {HTMLElement} anchorEl
   * @param {MouseEvent} event
   */
  _openContextMenu(participantId, anchorEl, event) {
    const currentState = this._stateStore.getState(participantId);
    const isHidden = currentState === 'hidden';
    const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE;
    const iconClass = isHidden ? 'fas fa-eye' : 'fas fa-eye-slash';

    const menu = document.createElement('div');
    menu.className = 'sp-context-menu';
    menu.setAttribute('role', 'menu');

    const item = document.createElement('button');
    item.className = 'sp-context-menu__item';
    item.setAttribute('role', 'menuitem');
    item.innerHTML = `<i class="${iconClass}" aria-hidden="true"></i> ${label}`;
    item.addEventListener('click', () => {
      this._dispatchAction(participantId, isHidden ? 'active' : 'hidden');
      menu.remove();
    });

    menu.appendChild(item);
    document.body.appendChild(menu);

    menu.style.position = 'fixed';
    menu.style.left = `${event.clientX}px`;
    menu.style.top = `${event.clientY}px`;

    const dismiss = e => {
      if (!menu.contains(e.target)) {
        menu.remove();
        document.removeEventListener('click', dismiss);
      }
    };
    setTimeout(() => document.addEventListener('click', dismiss), 0);
  }

  /**
   * Dispatches a visibility change action through the controller.
   * @param {string} participantId
   * @param {string} [explicitTargetState] - if omitted, toggle from current state
   */
  _dispatchAction(participantId, explicitTargetState) {
    const currentState = this._stateStore.getState(participantId);
    const targetState = explicitTargetState ?? resolveTargetState(currentState);
    const opId = generateOpId();
    const baseRevision = this._controller.getRevision?.(participantId) ?? 0;
    this._controller.action('strip', participantId, targetState, opId, baseRevision);
  }

  /**
   * Attaches video stream elements for all participants in the strip.
   * Called from activateListeners() when stream access is available.
   * @param {HTMLElement} container - The container element (html from activateListeners)
   */
  _attachVideoStreams(container) {
    if (!container) return;
    
    const participantItems = container.querySelectorAll?.('.sp-strip__participant-item') ?? [];
    for (const item of participantItems) {
      if (!item) continue;
      const userId = item.querySelector?.('[data-user-id]')?.dataset?.userId;
      if (userId) {
        this._attachVideoStream(userId, item);
      }
    }
  }

  /**
   * Attaches a video element with the WebRTC stream for a specific user.
   * Creates a video element with srcObject set to the user's MediaStream.
   * The video element is appended to the video container within the participant item.
   * @param {string} userId - The user ID to attach video for
   * @param {HTMLElement} participantItem - The participant list item element
   */
  _attachVideoStream(userId, participantItem) {
    // Guard: check webrtc is available
    if (!this._adapter?.webrtc?.getMediaStreamForUser) {
      return;
    }
    
    // Guard: check participantItem is valid
    if (!participantItem) {
      console.warn('[ScryingPool] _attachVideoStream: participantItem is null/undefined');
      return;
    }

    const stream = this._adapter.webrtc.getMediaStreamForUser(userId);
    
    // Check if video container exists
    const videoContainer = participantItem.querySelector?.('.sp-participant-video');
    if (!videoContainer) {
      console.warn('[ScryingPool] No video container found for user:', userId);
      return;
    }

    // Remove any existing video element
    const existingVideo = videoContainer.querySelector?.('video');
    if (existingVideo) {
      existingVideo.remove();
    }

    // If no stream available, don't create video element (will show avatar fallback)
    if (!stream) {
      return;
    }

    // Guard: ensure we're in a browser environment
    if (typeof document === 'undefined' || !document.createElement) {
      console.warn('[ScryingPool] _attachVideoStream: document or createElement not available');
      return;
    }

    // Validate stream is a MediaStream
    if (!(stream instanceof MediaStream)) {
      console.warn('[ScryingPool] _attachVideoStream: stream is not a MediaStream:', typeof stream);
      return;
    }

    // Create new video element
    const videoElement = document.createElement('video');
    videoElement.srcObject = stream;
    videoElement.autoplay = true;
    videoElement.playsInline = true;
    
    // Guard: ensure current user check is safe
    const currentUserId = this._adapter?.users?.current?.()?.id;
    videoElement.muted = userId === currentUserId;

    // Add CSS class for styling
    videoElement.className = 'sp-participant-video__element';

    // Set up error handling
    videoElement.addEventListener('error', () => {
      console.warn('[ScryingPool] Video element error for user:', userId);
      this._onStreamError(userId);
    });

    videoContainer.appendChild(videoElement);

    // Monitor this stream for loss/failure
    this._monitorStream(userId, videoElement, stream);
  }

  /**
   * Refreshes all video streams for participants.
   * Called when stream state changes (user joins/leaves, mute/unmute).
   */
  _refreshVideoStreams() {
    if (!this._adapter.webrtc?.getMediaStreamForUser) {
      return;
    }
    
    // Remove existing video elements before re-rendering to prevent duplicates
    const existingVideos = this.element?.querySelectorAll('.sp-participant-video__element') ?? [];
    existingVideos.forEach(v => v.remove());
    
    // Re-render to ensure DOM is up to date
    if (typeof this.render === 'function') {
      this.render({ force: false });
    }
  }

  // ── Stream monitoring and recovery ─────────────────────────────────────────

  /**
   * Initialises periodic stream health checks and hooks for user connection changes.
   * Safe to call multiple times — guards against double-init via _healthCheckInterval.
   */
  _initStreamMonitoring() {
    // Periodic health check every 15 seconds
    this._healthCheckInterval = setInterval(() => this._checkVideoStreamHealth(), 15000);

    // Auto-save strip position every 30 seconds
    this._positionSaveTimer = setInterval(() => this._savePosition(), 30000);

    // Watch for user connection changes to refresh streams
    if (typeof Hooks !== 'undefined') {
      this._userConnectedHookId = Hooks.on('userConnected', (userId, connected) => {
        if (connected) {
          setTimeout(() => this._refreshVideoStreams(), 1500);
        }
      });
    }
  }

  /**
   * Tears down stream monitoring: clears interval, removes hooks, cancels retries.
   */
  _teardownStreamMonitoring() {
    // Clear health check interval
    if (this._healthCheckInterval !== null) {
      clearInterval(this._healthCheckInterval);
      this._healthCheckInterval = null;
    }

    // Clear position save timer
    if (this._positionSaveTimer !== null) {
      clearInterval(this._positionSaveTimer);
      this._positionSaveTimer = null;
    }

    // Remove userConnected hook
    if (this._userConnectedHookId !== null && typeof Hooks !== 'undefined') {
      Hooks.off('userConnected', this._userConnectedHookId);
      this._userConnectedHookId = null;
    }

    // Cancel all pending retries
    for (const [, retryInfo] of this._videoStreamRetries) {
      if (retryInfo.retryTimer !== null) {
        clearTimeout(retryInfo.retryTimer);
      }
    }
    this._videoStreamRetries.clear();
  }

  /**
   * Monitors a video stream for loss or failure events.
   * Sets up listeners on the track lifecycle and starts health tracking.
   * @param {string} userId
   * @param {HTMLVideoElement} videoEl
   * @param {MediaStream} stream
   */
  _monitorStream(userId, videoEl, stream) {
    // Watch for video track ended (stream truly lost)
    const videoTracks = stream.getVideoTracks();
    for (const track of videoTracks) {
      track.addEventListener('ended', () => this._onVideoTrackEnded(userId), { once: true });
    }

    // Watch for track removal (mute event may indicate transient issue)
    for (const track of videoTracks) {
      track.addEventListener('mute', () => {
        // Brief delay to see if unmute fires naturally
        setTimeout(() => {
          if (track.readyState === 'ended') {
            this._onVideoTrackEnded(userId);
          }
        }, 2000);
      }, { once: true });
    }

    // Watch for playback stall
    videoEl.addEventListener('stalled', () => {
      setTimeout(() => {
        if (videoEl.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
          this._scheduleStreamRetry(userId);
        }
      }, 3000);
    }, { once: true });
  }

  /**
   * Called when a video track is ended (stream disconnected).
   * Shows a warning and schedules a retry.
   * @param {string} userId
   */
  _onVideoTrackEnded(userId) {
    this._notify('warn', 'scrying-pool.stream.lost', userId);
    this._scheduleStreamRetry(userId);
  }

  /**
   * Called when a video element fires an error event.
   * @param {string} userId
   */
  _onStreamError(userId) {
    this._scheduleStreamRetry(userId);
  }

  /**
   * Schedules a retry for a user's video stream with exponential backoff.
   * @param {string} userId
   * @param {number} [delay=2000] - Initial delay in ms
   */
  _scheduleStreamRetry(userId, delay = 2000) {
    // Cancel any existing retry for this user
    this._clearStreamRetry(userId);

    const retryInfo = this._videoStreamRetries.get(userId) ?? { retryCount: 0, retryTimer: null };

    if (retryInfo.retryCount >= 3) {
      this._notify('error', 'scrying-pool.stream.failed', userId);
      return;
    }

    this._notify('info', 'scrying-pool.stream.recovering', userId);

    retryInfo.retryTimer = setTimeout(() => {
      this._retryStream(userId);
    }, delay);

    retryInfo.retryCount++;
    this._videoStreamRetries.set(userId, retryInfo);
  }

  /**
   * Attempts to re-acquire and re-attach a user's video stream.
   * On success, clears retry state and notifies. On failure, schedules next backoff.
   * @param {string} userId
   */
  _retryStream(userId) {
    const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId);
    const avatar = this.element?.querySelector(`[data-user-id="${userId}"]`);

    if (stream instanceof MediaStream && avatar) {
      // Clean up the old video container state
      const videoContainer = avatar.querySelector('.sp-participant-video');
      if (videoContainer) {
        const oldVideo = videoContainer.querySelector('video');
        if (oldVideo) {
          oldVideo.srcObject = null;
          oldVideo.remove();
        }
      }

      // Re-attach with the (possibly new) stream
      this._attachVideoStream(userId, avatar);
      this._clearStreamRetry(userId);
      this._notify('info', 'scrying-pool.stream.recovered', userId);
    } else {
      // Exponential backoff: 2s, 4s, 8s
      const retryInfo = this._videoStreamRetries.get(userId);
      const nextDelay = Math.pow(2, (retryInfo?.retryCount ?? 1)) * 1000;
      this._scheduleStreamRetry(userId, nextDelay);
    }
  }

  /**
   * Cancels any pending retry timer for a user and resets retry state.
   * @param {string} userId
   */
  _clearStreamRetry(userId) {
    const retryInfo = this._videoStreamRetries.get(userId);
    if (retryInfo) {
      if (retryInfo.retryTimer !== null) {
        clearTimeout(retryInfo.retryTimer);
      }
      this._videoStreamRetries.delete(userId);
    }
  }

  /**
   * Periodic health check — inspects all participant video elements and
   * triggers retries for any that are in a failed or stuck state.
   */
  _checkVideoStreamHealth() {
    if (!this.element) return;

    this.element.querySelectorAll('.sp-participant-avatar').forEach(avatar => {
      const userId = avatar.dataset.userId;
      if (!userId) return;

      const videoContainer = avatar.querySelector('.sp-participant-video');
      const videoEl = avatar.querySelector('.sp-participant-video__element');

      // Expected video container but no video element → needs attach
      if (videoContainer && !videoEl) {
        const stream = this._adapter.webrtc?.getMediaStreamForUser?.(userId);
        if (stream instanceof MediaStream) {
          this._attachVideoStream(userId, avatar);
        }
        return;
      }

      // Video element exists but has no stream or ended tracks → trigger retry
      if (videoEl) {
        const stream = videoEl.srcObject;
        if (stream instanceof MediaStream) {
          const hasEndedTrack = stream.getVideoTracks().some(t => t.readyState === 'ended');
          if (hasEndedTrack && !this._videoStreamRetries.has(userId)) {
            this._onVideoTrackEnded(userId);
          }
        }
      }
    });
  }

  // ── Re-order participants (Drag & Drop) ─────────────────────────────────

  /**
   * Handles drag start on a participant item.
   * @param {DragEvent} e
   */
  _onDragStart(e) {
    const item = e.target.closest('.sp-strip__participant-item');
    if (!item) return;
    const userId = item.querySelector('[data-user-id]')?.dataset?.userId;
    if (!userId) return;
    e.dataTransfer.setData('text/plain', userId);
    e.dataTransfer.effectAllowed = 'move';
    item.classList.add('sp-dragging');
  }

  /**
   * Handles drag over on a participant item — shows drop indicator.
   * @param {DragEvent} e
   */
  _onDragOver(e) {
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    const item = e.target.closest('.sp-strip__participant-item');
    if (!item) return;
    item.classList.add('sp-drag-over');
  }

  /**
   * Handles drop — reorders participants and persists the order.
   * @param {DragEvent} e
   */
  _onDrop(e) {
    e.preventDefault();
    const fromUserId = e.dataTransfer.getData('text/plain');
    const toItem = e.target.closest('.sp-strip__participant-item');
    if (!toItem || !fromUserId) return;
    const toUserId = toItem.querySelector('[data-user-id]')?.dataset?.userId;
    if (!toUserId || fromUserId === toUserId) return;

    const el = this.element;
    if (!el) return;
    const items = [...el.querySelectorAll('.sp-strip__participant-item')];
    const userIds = items.map(item => item.querySelector('[data-user-id]')?.dataset?.userId).filter(Boolean);
    const fromIdx = userIds.indexOf(fromUserId);
    const toIdx = userIds.indexOf(toUserId);
    if (fromIdx === -1 || toIdx === -1) return;

    userIds.splice(fromIdx, 1);
    const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx;
    userIds.splice(adjustedTo, 0, fromUserId);

    this._saveParticipantOrder(userIds);
    if (typeof this.render === 'function') {
      this.render({ force: false });
    }
  }

  /**
   * Handles drag end — removes visual indicators.
   */
  _onDragEnd() {
    this.element?.querySelectorAll('.sp-strip__participant-item')?.forEach(item => {
      item.classList.remove('sp-dragging', 'sp-drag-over');
    });
  }

  /**
   * Handles drag leave — removes drop indicator from the exited item.
   * @param {DragEvent} e
   */
  _onDragLeave(e) {
    const item = e.target.closest('.sp-strip__participant-item');
    if (item) item.classList.remove('sp-drag-over');
  }

  /**
   * Persists participant order to GM user flag.
   * @param {string[]} orderedUserIds
   */
  _saveParticipantOrder(orderedUserIds) {
    if (typeof game === 'undefined') return;
    try {
      game.user?.setFlag?.('scrying-pool', 'participantOrder', orderedUserIds);
    } catch (_e) { /* no-op */ }
  }

  /**
   * Clears saved order — next render uses connection order.
   */
  _resetParticipantOrder() {
    if (typeof game === 'undefined') return;
    try {
      game.user?.unsetFlag?.('scrying-pool', 'participantOrder');
    } catch (_e) { /* no-op */ }
    if (typeof this.render === 'function') {
      this.render({ force: false });
    }
  }

  // ── Spotlight / Focus ────────────────────────────────────────────────────

  /**
   * Toggles focus on a participant. If already focused, exits focus mode.
   * @param {string} userId
   */
  _toggleFocus(userId) {
    if (this._focusedUserId === userId) {
      this._focusedUserId = null;
    } else {
      this._focusedUserId = userId;
    }
    if (typeof this.render === 'function') {
      this.render({ force: false });
    }
  }

  // ── Auto-save position ───────────────────────────────────────────────────

  /**
   * Saves current strip position to GM user flag.
   */
  _savePosition() {
    if (typeof game === 'undefined') return;
    try {
      const pos = this.position;
      if (pos?.left == null || pos?.top == null) return;
      game.user?.setFlag?.('scrying-pool', 'stripState', {
        left: pos.left,
        top: pos.top,
        width: pos.width ?? 240,
        height: pos.height ?? 'auto',
        savedAt: Date.now(),
      });
    } catch (_e) { /* no-op */ }
  }

  /**
   * Shows a notification with i18n support and safe fallback for test environments.
   * @param {'info'|'warn'|'error'} level
   * @param {string} i18nKey
   * @param {string} userId
   */
  _notify(level, i18nKey, userId) {
    const userName = this._getUserName(userId);
    let msg;
    try {
      msg = game.i18n?.format?.(i18nKey, { name: userName }) ?? `${i18nKey}: ${userName}`;
    } catch {
      msg = `${i18nKey}: ${userName}`;
    }
    this._adapter.notifications?.[level]?.(msg);
  }

  /**
   * Resolves a display name for a user ID.
   * @param {string} userId
   * @returns {string}
   */
  _getUserName(userId) {
    try {
      return this._adapter.users.get?.(userId)?.name ?? userId;
    } catch {
      return userId;
    }
  }
}

/**
 * Native <dialog>-based per-participant action popover.
 * Not exported — internal to the gm/ layer.
 */
class ActionPopover {
  /**
   * @param {string} participantId
   * @param {string} currentState
   * @param {HTMLElement} anchorEl
   * @param {boolean} hasPendingOp
   * @param {function(string, string): void} onAction
   */
  constructor(participantId, currentState, anchorEl, hasPendingOp, onAction) {
    this._participantId = participantId;
    this._currentState = currentState;
    this._anchorEl = anchorEl;
    this._hasPendingOp = hasPendingOp;
    this._onAction = onAction;
    this._onCloseCb = null;
    this._dialog = this._build();
  }

  /**
   * Builds the <dialog> DOM element.
   * @returns {HTMLDialogElement}
   */
  _build() {
    const isHidden = this._currentState === 'hidden';
    const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE;

    const dialog = document.createElement('dialog');
    dialog.className = 'sp-action-popover';
    dialog.setAttribute('aria-modal', 'true');

    const cta = document.createElement('button');
    cta.className = 'sp-action-popover__cta';
    cta.dataset.action = 'primary-cta';
    cta.textContent = label;

    if (this._hasPendingOp) {
      cta.disabled = true;
      cta.setAttribute('aria-disabled', 'true');
    }

    // First-time tooltip (localStorage-based)
    const hasSeenTooltip = typeof localStorage !== 'undefined'
      ? localStorage.getItem('scrying-pool.firstHideTooltip')
      : '1';
    if (!hasSeenTooltip) {
      cta.title = LABELS.FIRST_TOOLTIP;
      cta.addEventListener('mouseenter', () => {
        if (typeof localStorage !== 'undefined') {
          localStorage.setItem('scrying-pool.firstHideTooltip', '1');
        }
      }, { once: true });
    }

    cta.addEventListener('click', () => {
      const targetState = isHidden ? 'active' : 'hidden';
      this._onAction(this._participantId, targetState);
      this.close('action');
    });

    dialog.appendChild(cta);

    dialog.addEventListener('cancel', e => {
      e.preventDefault();
      this.close('escape');
    });

    dialog.addEventListener('click', e => {
      if (e.target === dialog) {
        this.close('backdrop');
      }
    });

    return dialog;
  }

  /**
   * Opens the popover dialog, positions it near the anchor, and focuses the CTA.
   */
  open() {
    document.body.appendChild(this._dialog);

    const rect = this._anchorEl.getBoundingClientRect();
    this._dialog.style.position = 'fixed';
    this._dialog.style.left = `${rect.right + 4}px`;
    this._dialog.style.top = `${rect.top}px`;
    this._dialog.style.margin = '0';

    if (typeof this._dialog.showModal === 'function') {
      this._dialog.showModal();
    }

    const cta = this._dialog.querySelector('[data-action="primary-cta"]');
    cta?.focus?.();
  }

  /**
   * Closes the popover dialog and invokes the onClose callback.
   * @param {string} [reason]
   */
  close(reason) {
    if (this._dialog.parentNode) {
      if (typeof this._dialog.close === 'function') {
        this._dialog.close(reason ?? '');
      }
      this._dialog.remove();
    }
    this._onCloseCb?.();
  }

  /**
   * Registers a callback for when the popover closes.
   * @param {function(): void} cb
   */
  onClose(cb) {
    this._onCloseCb = cb;
  }
}
