/**
 * SocketHandler — queued, timeout-aware socket emit with PendingOp lifecycle.
 *
 * Wraps the adapter.socket surface to provide:
 *   - Readiness queue (messages emitted before setReady are drained at setReady time)
 *   - PendingOp registration + confirmation (opId keyed, 3s timeout, one retry, revert)
 *   - Payload size guard (throws on payload ≥ MAX_PAYLOAD_BYTES)
 *
 * Import rule: may only import from src/contracts/ and src/utils/.
 * No game.* access — all FoundryVTT interaction flows through injected surfaces.
 * Throws errors — callers are responsible for catching.
 *
 * @module core/SocketHandler
 */

import { MAX_PAYLOAD_BYTES } from '../contracts/socket-message.js';

// Maximum queued messages before we start dropping (prevents unbounded memory growth)
export const MAX_QUEUE_SIZE = 1000;

export const SOCKET_TIMEOUT_MS = 3000;

/**
 * Queued socket handler with PendingOp lifecycle management (timeout, retry, revert).
 */
export class SocketHandler {
  /**
   * @param {{ emit(event: string, payload: object): void, on(event: string, handler: Function): void, off(event: string, handler: Function): void }} socket
   *   Injected adapter.socket surface.
   * @param {{ on(event: string, handler: Function): void, once(event: string, handler: Function): void }} hooks
   *   Injected adapter.hooks surface (reserved for Story 1.4 hook-driven flows).
   */
  constructor(socket, hooks) {
    if (!socket || typeof socket !== 'object' || typeof socket.emit !== 'function') {
      throw new TypeError('SocketHandler: socket argument is required and must have an emit method');
    }
    if (!hooks || typeof hooks !== 'object') {
      throw new TypeError('SocketHandler: hooks argument is required and must be an object');
    }
    this._socket = socket;
    this._hooks = hooks;
    /** @type {Array<{event: string, payload: object}>} */
    this._messageQueue = [];
    this._isReady = false;
    /** @type {Map<string, Record<string, any>>} opId → PendingOp */
    this._pendingOps = new Map();
    /** @type {{ onRevert(pendingOp: object): void } | null} */
    this._handler = null;
    /** @type {number} */
    this._droppedCount = 0;
  }

  /**
   * Marks the handler as ready, stores the revert callback, and drains the queue.
   * Null handler: logs a warning and proceeds — safe to call with null.
   * In Story 1.3, module.js does NOT call setReady — that is Story 1.4's responsibility.
   * @param {{ onRevert(pendingOp: object): void } | null} handler
   */
  setReady(handler) {
    if (!handler) {
      console.warn('[ScryingPool] SocketHandler.setReady: handler is null — revert callbacks will be skipped');
    }
    this._isReady = true;
    this._handler = handler;
    this._drainQueue();
  }

  /**
   * Destroys the handler, clearing all pending operations and timers.
   * Should be called when the handler is no longer needed to prevent memory leaks.
   */
  destroy() {
    for (const [, op] of this._pendingOps) {
      if (op.timeoutId) {
        clearTimeout(op.timeoutId);
      }
    }
    this._pendingOps.clear();
    this._messageQueue = [];
    this._isReady = false;
    this._handler = null;
    this._droppedCount = 0;
  }

  /**
   * Emits an event on the socket. If not yet ready, queues the message for later.
   * @param {string} event
   * @param {object} payload
   * @throws {Error} if payload JSON exceeds MAX_PAYLOAD_BYTES (4096 bytes)
   * @throws {TypeError} if event is not a non-empty string
   */
  emit(event, payload) {
    if (typeof event !== 'string' || event.length === 0) {
      throw new TypeError('SocketHandler: event must be a non-empty string');
    }
    let encoded;
    try {
      encoded = JSON.stringify(payload);
    } catch (err) {
      throw new Error(`[ScryingPool] SocketHandler: payload is not JSON-serializable: ${err instanceof Error ? err.message : String(err)}`);
    }
    if (encoded.length >= MAX_PAYLOAD_BYTES) {
      throw new Error(`[ScryingPool] SocketHandler: payload exceeds ${MAX_PAYLOAD_BYTES} bytes (${encoded.length})`);
    }
    if (!this._isReady) {
      // Drop if queue is full to prevent unbounded memory growth
      if (this._messageQueue.length >= MAX_QUEUE_SIZE) {
        this._droppedCount++;
        if (this._droppedCount === 1 || this._droppedCount % 100 === 0) {
          console.warn(`[ScryingPool] SocketHandler: dropped ${this._droppedCount} queued messages due to queue overflow`);
        }
        return;
      }
      this._messageQueue.push({ event, payload });
      return;
    }
    this._socket.emit(event, payload);
  }

  /**
   * Registers a PendingOp and starts its 3-second acknowledgement timeout.
   * Throws if pendingOp is null/undefined or missing opId.
   * Warns and replaces if opId already exists (leaking previous timer).
   * @param {Record<string, any>} pendingOp - Must have at least `{ opId: string }`.
   * @param {string} [event] - Optional socket event name for retry.
   * @param {object} [payload] - Optional payload for retry.
   * @throws {TypeError} if pendingOp is null/undefined or missing opId
   */
  registerPendingOp(pendingOp, event, payload) {
    if (!pendingOp || typeof pendingOp !== 'object') {
      throw new TypeError('SocketHandler.registerPendingOp: pendingOp must be an object');
    }
    if (typeof pendingOp.opId !== 'string' || pendingOp.opId.length === 0) {
      throw new TypeError('SocketHandler.registerPendingOp: pendingOp must have a non-empty opId string');
    }
    
    // Warn if overwriting existing op (previous timer is cleared below)
    if (this._pendingOps.has(pendingOp.opId)) {
      const existingEntry = /** @type {{ pendingOp: Record<string, any>, timeoutId: number, event?: string, payload?: object }} */ (this._pendingOps.get(pendingOp.opId));
      console.warn('[ScryingPool] SocketHandler: overwriting existing pending op', existingEntry.pendingOp.opId);
      clearTimeout(existingEntry.timeoutId);
    }
    
    // Create timeout and store WITHOUT mutating the original pendingOp
    const timeoutId = setTimeout(() => this._onTimeout(pendingOp.opId, false), SOCKET_TIMEOUT_MS);
    this._pendingOps.set(pendingOp.opId, { pendingOp, event, payload, timeoutId });
  }

  /**
   * Confirms a PendingOp by opId — clears its timer and removes it from the map.
   * Unknown/stale opId: discards silently (no error).
   * @param {string} opId
   */
  confirmPendingOp(opId) {
    const entry = this._pendingOps.get(opId);
    if (!entry) return; // stale echo — discard silently
    if (entry.timeoutId) {
      clearTimeout(entry.timeoutId);
    }
    this._pendingOps.delete(opId);
  }

  /** @private */
  _drainQueue() {
    for (const { event, payload } of this._messageQueue) {
      this._socket.emit(event, payload);
    }
    this._messageQueue = [];
  }

  /**
   * Helper to remove internal timeoutId field from an object.
   * @private
   * @param {object} obj
   * @returns {object} Object without timeoutId field
   */
  _stripTimeoutId(obj) {
    if (!obj || typeof obj !== 'object') return obj;
    // eslint-disable-next-line no-unused-vars -- intentionally removing timeoutId field
    const { timeoutId, ...rest } = /** @type {{ timeoutId?: number, [key: string]: any }} */ (obj);
    return rest;
  }

  /**
   * @private
   * @param {string} opId
   * @param {boolean} isRetry - true if this is the second timeout (after one retry attempt)
   */
  _onTimeout(opId, isRetry) {
    const entry = this._pendingOps.get(opId);
    if (!entry) return; // already confirmed — stale timer, ignore

    if (!isRetry) {
      // First timeout: retry once
      // Use stored event/payload if provided; otherwise use pendingOp minus timeoutId
      if (this._isReady) {
        try {
          if (entry.event && entry.payload) {
            // Use stored event and payload (explicit, clean)
            this._socket.emit(entry.event, this._stripTimeoutId(entry.payload));
          } else if (entry.pendingOp) {
            // Fallback: use pendingOp minus timeoutId
            // Use a sensible default event if not stored
            this._socket.emit('scrying-pool.visibility.set', this._stripTimeoutId(entry.pendingOp));
          }
        } catch (err) {
          // ignore emit error on retry — the revert path handles final failure
          console.warn('[ScryingPool] SocketHandler: retry emit failed', err);
        }
      }
      const retryTimeoutId = setTimeout(() => this._onTimeout(opId, true), SOCKET_TIMEOUT_MS);
      entry.timeoutId = retryTimeoutId;
      return;
    }

    // Second timeout — give up: warn + call handler.onRevert with the original pendingOp
    this._pendingOps.delete(opId);
    console.warn('[ScryingPool] SocketHandler: unacknowledged op after retry, reverting', opId);
    if (!this._handler) return;
    this._handler.onRevert(entry.pendingOp);
  }
}
