/**
 * ScryingPoolController — Orchestrates GM visibility actions with optimistic state updates.
 *
 * Handles: GM authorization, latest-revision-wins guard, last-intent guard, PendingOp
 * lifecycle, optimistic setVisibility, socket emit, and echo reconciliation.
 *
 * Import rule: may only import from src/contracts/ and src/utils/.
 * Constructors are side-effect free — call init() from module.js Hooks.once('ready').
 *
 * @module core/ScryingPoolController
 */

import { createPendingOp } from '../contracts/pending-op.js';
import { createSocketIntentMessage, SOCKET_EVENTS } from '../contracts/socket-message.js';
import { VISIBILITY_STATES } from '../contracts/visibility-matrix.js';

/**
 * Orchestrates GM visibility actions: auth, optimistic state, socket emit, echo reconciliation.
 */
export class ScryingPoolController {
  /**
   * @param {import('./StateStore.js').StateStore} stateStore
   * @param {{ emit(event: string, payload: object): void, registerPendingOp(op: object, event: string, payload: object): void, confirmPendingOp(opId: string): void, setReady(handler: object): void }} socketHandler
   * @param {{ users: { isGM(): boolean }, socket: { on(event: string, handler: (...args: unknown[]) => void): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void, callAll(event: string, data: unknown): void } }} adapter
   */
  constructor(stateStore, socketHandler, adapter) {
    this._stateStore = stateStore;
    this._socketHandler = socketHandler;
    this._adapter = adapter;
    /** @type {Map<string, import('../contracts/pending-op.js').PendingOp>} participantId → PendingOp */
    this._pendingOps = new Map();
    /** @type {Map<string, number>} participantId → last-confirmed revision */
    this._revisions = new Map();
  }

  /**
   * Registers the socket echo listener.
   * Called from module.js Hooks.once('ready') — NOT from constructor.
   */
  init() {
    const echoHandler = (payload) => this._onEcho(/** @type {any} */ (payload));
    this._echoHandler = echoHandler;
    this._adapter.socket.on(SOCKET_EVENTS.VISIBILITY_UPDATED, echoHandler);

    // Clean up stale _revisions when a participant disconnects (T-06 deferred debt)
    this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
      if (!connected && user?.id) this.cleanupParticipant(user.id);
    });
  }

  /**
   * Unregisters listeners and clears all in-flight state.
   * Safe to call multiple times.
   */
  teardown() {
    if (this._echoHandler) {
      this._adapter.socket.off(SOCKET_EVENTS.VISIBILITY_UPDATED, this._echoHandler);
      this._echoHandler = null;
    }
    if (this._disconnectHookId != null) {
      this._adapter.hooks.off('userConnected', this._disconnectHookId);
      this._disconnectHookId = null;
    }
    this.cleanupAll();
  }

  /**
   * Returns the last confirmed revision for a participant (0 if unknown).
   * @param {string} participantId
   * @returns {number}
   */
  getRevision(participantId) {
    return this._revisions.get(participantId) ?? 0;
  }

  /**
   * Returns true if a pending op is currently in-flight for the given participant.
   * @param {string} participantId
   * @returns {boolean}
   */
  hasPendingOp(participantId) {
    return this._pendingOps.has(participantId);
  }

  /**
   * Cleans up a pending operation by userId.
   * Called by SocketHandler timeout callback via composite handler in module.js.
   * Also cleans up the revision tracking for this user (T-06).
   * @param {string} userId - The user ID to clean up
   */
  cleanupPendingOp(userId) {
    this._pendingOps.delete(userId);
    this._revisions.delete(userId);
  }

  /**
   * Cleans up state for a disconnected participant.
   * Called when a participant disconnects to prevent memory leaks (T-06).
   * @param {string} userId - The user ID to clean up
   */
  cleanupParticipant(userId) {
    this._pendingOps.delete(userId);
    this._revisions.delete(userId);
  }

  /**
   * Cleans up all state. Useful for module reload.
   */
  cleanupAll() {
    this._pendingOps.clear();
    this._revisions.clear();
  }

  /**
   * Processes a GM visibility toggle request.
   * Guards: isGM, latest-revision-wins, last-intent (idempotent).
   *
   * @param {string} source - Who triggered the action (e.g. 'ui', 'preset').
   * @param {string} participantId - Target userId.
   * @param {string} targetState - Desired VisibilityState.
   * @param {string} opId - Unique operation ID (supplied by caller — Story 1.5 UI).
   * @param {number} baseRevision - StateStore revision at time of intent.
   */
  action(source, participantId, targetState, opId, baseRevision) {
    // 0. Input validation
    if (!participantId || typeof participantId !== 'string') {
      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid participantId');
      return;
    }
    if (!targetState || typeof targetState !== 'string') {
      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid targetState');
      return;
    }
    if (!opId || typeof opId !== 'string') {
      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid opId');
      return;
    }
    if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) {
      console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid baseRevision');
      return;
    }

    // 0b. Validate targetState against known states (T-05)
    if (!VISIBILITY_STATES.includes(targetState)) {
      console.warn('[ScryingPool]', `ScryingPoolController.action: invalid targetState '${targetState}'`);
      return;
    }

    // 1. Authorization
    if (!this._adapter.users.isGM()) {
      console.warn('[ScryingPool]', 'ScryingPoolController.action: non-GM call rejected');
      return;
    }

    // 2. Atomic pending op check + race condition guard (T-01, T-08)
    if (this._pendingOps.has(participantId)) {
      console.warn('[ScryingPool]', `ScryingPoolController.action: pending op already exists for ${participantId}`);
      return;
    }

    // 3. Latest-revision-wins guard (T-04: now strict equality)
    const currentRevision = this._revisions.get(participantId) ?? 0;
    if (baseRevision !== currentRevision) return;

    // 4. Last-intent guard (idempotent)
    let currentState;
    try {
      currentState = this._stateStore.getState(participantId);
    } catch (err) {
      console.error('[ScryingPool] ScryingPoolController.action: getState failed', err);
      return;
    }
    if (currentState === targetState) return;

    // 5. Register PendingOp
    // Use 'active' (not 'never-connected') as the fallback previousState for users not yet in
    // the matrix: 'active' matches the render-time default in buildParticipantList and prevents
    // a socket-timeout revert from persisting 'never-connected' into the world settings.
    const previousState = currentState ?? 'active';
    const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
    this._pendingOps.set(participantId, pendingOp);

    // 6. Optimistic state update (T-07: wrapped in try-catch)
    try {
      this._stateStore.setVisibility(participantId, targetState);
    } catch (err) {
      this._pendingOps.delete(participantId);
      console.error('[ScryingPool] ScryingPoolController.action: setVisibility failed', err);
      return;
    }

    // 7. Socket emit (best-effort broadcast to player clients)
    const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
    this._socketHandler.emit(msg.event, msg.payload);

    // 8. Start acknowledgement timer
    this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);

    // 9. Self-confirm: the GM is the source of truth.
    //    The state was already applied and persisted optimistically (step 6).
    //    Waiting for a socket echo would require a server-side relay that doesn't
    //    exist, resulting in a 6-second timeout + spurious revert warning.
    //    Confirm immediately to close the pending op lifecycle cleanly.
    //    _onEcho also fires scrying-pool:controllerAction, so skip step 10.
    this._onEcho({ opId, userId: participantId, state: targetState, revision: baseRevision + 1 });
  }

  /**
   * Processes an authoritative echo from the socket server.
   * Confirms the pending op, updates revision, and sets the authoritative state.
   * @private
   * @param {{ opId: string, userId: string, state: string, revision?: number }} payload
   */
  _onEcho(payload) {
    // Validate payload fields
    if (!payload || typeof payload !== 'object') {
      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: invalid payload');
      return;
    }
    const { opId, userId, state, revision } = payload;
    if (!opId || typeof opId !== 'string') {
      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid opId');
      return;
    }
    if (!userId || typeof userId !== 'string') {
      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid userId');
      return;
    }
    if (!state || typeof state !== 'string') {
      console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid state');
      return;
    }

    // T-02: Verify pending op exists before confirming (T-03: use consistent participantId key)
    const pendingOp = this._pendingOps.get(userId);
    if (!pendingOp || pendingOp.opId !== opId) {
      console.warn('[ScryingPool]', `ScryingPoolController._onEcho: no matching pending op for ${userId}, opId=${opId}`);
      return;
    }

    this._socketHandler.confirmPendingOp(opId);
    
    // Validate revision is a finite number
    const validatedRevision = (typeof revision === 'number' && Number.isFinite(revision)) ? revision : 0;
    this._revisions.set(userId, validatedRevision);
    this._pendingOps.delete(userId);
    
    try {
      this._stateStore.setVisibility(userId, state);
    } catch (err) {
      console.error('[ScryingPool] ScryingPoolController._onEcho: setVisibility failed', err);
      return;
    }

    try {
      this._adapter.hooks.callAll('scrying-pool:controllerAction', {
        participantId: userId,
        targetState: state,
        source: 'echo',
        opId,
      });
    } catch (hookErr) {
      console.error('[ScryingPool] ScryingPoolController._onEcho: hook emission failed', hookErr);
    }
  }
}
