/**
 * NotificationBus — coalesced toast layer above ui.notifications.
 *
 * Subscribes to `scrying-pool:stateChanged` and coalesces rapid GM visibility
 * changes for the same participant into a single toast per 3-second window.
 *
 * Verbosity rules (AC-4, AC-5):
 *   - 'all'      → every client sees every general notification
 *   - 'gm-only'  → only the GM + the affected participant are notified
 *   - 'silent'   → only the affected participant is notified (personal only)
 *
 * Personal notification (own camera changed) always fires — never suppressed
 * by verbosity setting (AC-2).
 *
 * Import boundary: src/notifications/ → src/core/, src/contracts/, src/utils/ ONLY.
 * Uses Hooks as a FoundryVTT global (same pattern as StateStore).
 *
 * Constructors are side-effect-free. Call init() from module.js Hooks.once('ready').
 *
 * @module notifications/NotificationBus
 */

/** Coalescing window in milliseconds. Matches AC-3 ("within 3 seconds"). */
const COALESCE_WINDOW_MS = 3_000;

/**
 * Coalesced toast notification layer over ui.notifications.
 *
 * Subscribes to `scrying-pool:stateChanged` and debounces GM visibility
 * changes per participant into a single toast per 3-second window.
 */
export class NotificationBus {
  /**
   * @param {{ notifications: {info(m:string):void, warn(m:string):void, error(m:string):void},
   *            users: {get(id:string):{id:string,name:string}|null, current():{id:string}|null, isGM():boolean},
   *            settings: {get(key:string):unknown},
   *            i18n: {localize(key:string, data?:object):string} }} adapter
   */
  constructor(adapter) {
    this._adapter = adapter;
    /** @type {Map<string, {timer: ReturnType<typeof setTimeout>|null, prevState: string, lastState: string, changeCount: number}>} */
    this._coalesceMap = new Map();
    this._hookId = null;
    this._disconnectHookId = null;
  }

  /** Register hook listener. Call from module.js Hooks.once('ready'). */
  init() {
    // Prevent multiple init calls without teardown
    if (this._hookId != null) {
      console.warn('[ScryingPool] NotificationBus.init: already initialized, call teardown first');
      return;
    }
    this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
    
    // Clean up coalesceMap entries for disconnected users
    this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
      if (!connected && user?.id) {
        const entry = this._coalesceMap.get(user.id);
        if (entry) {
          clearTimeout(entry.timer);
          this._coalesceMap.delete(user.id);
        }
      }
    });
  }

  /** Unregister listeners and cancel all pending timers. Safe to call before init(). */
  teardown() {
    if (this._hookId != null) {
      Hooks.off('scrying-pool:stateChanged', this._hookId);
      this._hookId = null;
    }
    if (this._disconnectHookId != null) {
      this._adapter.hooks.off('userConnected', this._disconnectHookId);
      this._disconnectHookId = null;
    }
    for (const entry of this._coalesceMap.values()) {
      clearTimeout(entry.timer);
    }
    this._coalesceMap.clear();
  }

  // ── Private ────────────────────────────────────────────────────────────────

  /**
   * Handler for `scrying-pool:stateChanged` hook.
   * @param {{ userId?: string, state: string, previousState: string }} data
   */
  _onStateChanged(data) {
    const { userId, state: newState, previousState } = data ?? {};
    if (!userId) return;
    if (typeof newState !== 'string') return;

    const currentUserId = this._adapter.users.current()?.id ?? null;

    // AC-2: Personal notification — fires immediately, never suppressed by verbosity
    if (userId === currentUserId) {
      this._notifyPersonal(newState);
      return;
    }

    // AC-4/AC-5: Verbosity gate for non-personal notifications
    const verbosity = this._adapter.settings.get('notificationVerbosity') ?? 'all';
    // Validate verbosity is one of the allowed choices
    const validVerbosity = ['all', 'gm-only', 'silent'].includes(verbosity) ? verbosity : 'all';
    if (validVerbosity === 'silent') return;
    if (validVerbosity === 'gm-only' && !this._adapter.users.isGM()) return;

    this._enqueue(userId, newState, previousState);
  }

  /**
   * Fire an immediate personal notification for the current user's own camera change.
   * @param {string} newState
   */
  _notifyPersonal(newState) {
    const key = newState === 'hidden'
      ? 'scrying-pool.notifications.personalHidden'
      : 'scrying-pool.notifications.personalShowed';
    const msg = this._adapter.i18n.localize(key);
    this._adapter.notifications.info(msg);
  }

  /**
   * Add or update a coalescing entry for the given participant.
   * Resets the 3-second debounce window on each call.
   * @param {string} userId
   * @param {string} newState
   * @param {string} prevState
   */
  _enqueue(userId, newState, prevState) {
    // Validate required parameters
    if (typeof userId !== 'string' || !userId) return;
    if (typeof newState !== 'string' || typeof prevState !== 'string') return;

    const existing = this._coalesceMap.get(userId);
    if (existing) {
      clearTimeout(existing.timer);
      existing.lastState = newState;
      existing.changeCount += 1;
    } else {
      this._coalesceMap.set(userId, {
        timer: null,
        prevState,
        lastState: newState,
        changeCount: 1,
      });
    }
    const entry = this._coalesceMap.get(userId);
    entry.timer = setTimeout(() => this._flush(userId), COALESCE_WINDOW_MS);
  }

  /**
   * Fire the coalesced notification for a participant, then remove the entry.
   * Net-zero suppression: if final state equals original state, no notification fires.
   * @param {string} userId
   */
  _flush(userId) {
    const entry = this._coalesceMap.get(userId);
    if (!entry) return; // Entry may have been deleted by teardown or disconnect cleanup
    this._coalesceMap.delete(userId);

    // AC-3: Net-zero suppression
    if (entry.lastState === entry.prevState) return;
    
    // Additional safety: ensure we have valid timer to prevent stale closure issues
    if (entry.timer == null) return;

    const name = this._adapter.users.get(userId)?.name ?? userId;
    const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
    const key = entry.lastState === 'hidden'
      ? 'scrying-pool.notifications.gmHid'
      : 'scrying-pool.notifications.gmShowed';
    // Note: changeCount is included in the message suffix for AC-3 compliance
    const msg = this._adapter.i18n.localize(key, { name }) + count;

    this._adapter.notifications.info(msg);
  }
}
