// @ts-nocheck
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ScryingPoolController } from '../../../src/core/ScryingPoolController.js';
import { createFoundryAdapterMock } from '../../helpers/foundryAdapterMock.js';
import { StateStore } from '../../../src/core/StateStore.js';

/** @returns {{ emit: Function, registerPendingOp: Function, confirmPendingOp: Function, setReady: Function }} */
function makeSocketHandler() {
  return {
    emit: vi.fn(),
    registerPendingOp: vi.fn(),
    confirmPendingOp: vi.fn(),
    setReady: vi.fn(),
  };
}

/** @returns {StateStore} */
function makeStateStore() {
  const settingsMock = {
    get: vi.fn().mockReturnValue({ _version: 1, matrix: {} }),
    set: vi.fn().mockResolvedValue(undefined),
    register: vi.fn(),
  };
  return new StateStore(settingsMock);
}

describe('ScryingPoolController', () => {
  let adapter;
  let stateStore;
  let socketHandler;
  let controller;
  let hooksStub;

  beforeEach(() => {
    hooksStub = { callAll: vi.fn(), on: vi.fn(), once: vi.fn(), off: vi.fn() };
    vi.stubGlobal('Hooks', hooksStub);

    adapter = createFoundryAdapterMock({ 
      users: { isGM: () => true },
      hooks: hooksStub
    });
    adapter.socket.on = vi.fn();

    stateStore = makeStateStore();
    socketHandler = makeSocketHandler();
    controller = new ScryingPoolController(stateStore, socketHandler, adapter);
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.clearAllMocks();
  });

  // ── AC-1: Construction ────────────────────────────────────────────────────

  describe('constructor (AC-1)', () => {
    it('initialises _pendingOps as an empty Map', () => {
      expect(controller._pendingOps).toBeInstanceOf(Map);
      expect(controller._pendingOps.size).toBe(0);
    });

    it('initialises _revisions as an empty Map', () => {
      expect(controller._revisions).toBeInstanceOf(Map);
      expect(controller._revisions.size).toBe(0);
    });

    it('does NOT register socket listener in constructor (side-effect free)', () => {
      expect(adapter.socket.on).not.toHaveBeenCalled();
    });
  });

  // ── AC-1: init() ─────────────────────────────────────────────────────────

  describe('init() (AC-1)', () => {
    it('registers socket echo listener for scrying-pool.visibility.updated', () => {
      controller.init();
      expect(adapter.socket.on).toHaveBeenCalledWith(
        'scrying-pool.visibility.updated',
        expect.any(Function)
      );
    });
  });

  // ── AC-2: action() happy path ─────────────────────────────────────────────

  describe('action() happy path (AC-2)', () => {
    it('registers a PendingOp via socketHandler.registerPendingOp with correct shape', () => {
      // With self-confirm, _pendingOps is cleared synchronously after action().
      // Verify the op was passed to registerPendingOp before being confirmed.
      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
      expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
        expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
        'scrying-pool.visibility.set',
        expect.objectContaining({ opId: 'op-1' })
      );
    });

    it('calls stateStore.setVisibility with the target state (optimistic update)', () => {
      const setSpy = vi.spyOn(stateStore, 'setVisibility');
      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
      expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
    });

    it('calls socketHandler.emit with VISIBILITY_SET event and correct payload', () => {
      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
      expect(socketHandler.emit).toHaveBeenCalledWith(
        'scrying-pool.visibility.set',
        expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden', baseRevision: 0 })
      );
    });

    it('calls socketHandler.registerPendingOp with the PendingOp, event, and payload', () => {
      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
      expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
        expect.objectContaining({ opId: 'op-1', userId: 'user-1', targetState: 'hidden' }),
        'scrying-pool.visibility.set',
        expect.objectContaining({ opId: 'op-1' })
      );
    });

    it('fires Hooks.callAll scrying-pool:controllerAction after self-confirm', () => {
      // Self-confirm calls _onEcho which fires the hook with source: 'echo'.
      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);
      expect(hooksStub.callAll).toHaveBeenCalledWith(
        'scrying-pool:controllerAction',
        expect.objectContaining({ participantId: 'user-1', targetState: 'hidden', source: 'echo', opId: 'op-1' })
      );
    });

    it('sets previousState to "active" when participant is new (not yet in matrix)', () => {
      // With self-confirm, _pendingOps is cleared synchronously. Verify via registerPendingOp arg.
      controller.action('ui', 'new-user', 'hidden', 'op-1', 0);
      // 'active' is the render-time default for users not in the matrix.
      expect(socketHandler.registerPendingOp).toHaveBeenCalledWith(
        expect.objectContaining({ previousState: 'active' }),
        expect.any(String),
        expect.any(Object)
      );
    });
  });

  // ── AC-5: non-GM authorization ────────────────────────────────────────────

  describe('action() non-GM authorization (AC-5)', () => {
    it('warns and silently drops the action when adapter.users.isGM() is false', () => {
      const nonGmAdapter = createFoundryAdapterMock({ 
        users: { isGM: () => false },
        hooks: hooksStub
      });
      nonGmAdapter.socket.on = vi.fn();
      const playerController = new ScryingPoolController(stateStore, socketHandler, nonGmAdapter);
      const setSpy = vi.spyOn(stateStore, 'setVisibility');
      const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});

      playerController.action('ui', 'user-1', 'hidden', 'op-1', 0);

      expect(warnSpy).toHaveBeenCalledWith('[ScryingPool]', expect.stringContaining('non-GM'));
      expect(setSpy).not.toHaveBeenCalled();
      expect(socketHandler.emit).not.toHaveBeenCalled();
      expect(socketHandler.registerPendingOp).not.toHaveBeenCalled();
      expect(hooksStub.callAll).not.toHaveBeenCalled();

      warnSpy.mockRestore();
    });
  });

  // ── AC-3: latest-revision-wins guard ─────────────────────────────────────

  describe('action() latest-revision-wins guard (AC-3)', () => {
    it('silently drops action when baseRevision < confirmed revision', () => {
      controller._revisions.set('user-1', 5);
      const setSpy = vi.spyOn(stateStore, 'setVisibility');

      controller.action('ui', 'user-1', 'hidden', 'op-2', 3); // 3 < 5 → stale

      expect(setSpy).not.toHaveBeenCalled();
      expect(socketHandler.emit).not.toHaveBeenCalled();
      expect(hooksStub.callAll).not.toHaveBeenCalled();
    });

    it('allows action when baseRevision equals confirmed revision (not stale)', () => {
      controller._revisions.set('user-1', 5);
      const setSpy = vi.spyOn(stateStore, 'setVisibility');

      controller.action('ui', 'user-1', 'hidden', 'op-2', 5); // 5 == 5 → not stale

      expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
    });

    it('allows action with baseRevision=0 when no revision confirmed yet', () => {
      const setSpy = vi.spyOn(stateStore, 'setVisibility');

      controller.action('ui', 'user-1', 'hidden', 'op-1', 0);

      expect(setSpy).toHaveBeenCalled();
    });
  });

  // ── AC-4: last-intent guard ───────────────────────────────────────────────

  describe('action() last-intent guard (AC-4)', () => {
    it('silently drops action when participant is already in targetState', () => {
      // Seed the state store with the current state
      stateStore.setVisibility('user-1', 'hidden');
      vi.clearAllMocks(); // reset all mock call counts

      const setSpy = vi.spyOn(stateStore, 'setVisibility');

      controller.action('ui', 'user-1', 'hidden', 'op-2', 0);

      expect(setSpy).not.toHaveBeenCalled();
      expect(socketHandler.emit).not.toHaveBeenCalled();
    });

    it('allows action when targetState differs from current state', () => {
      stateStore.setVisibility('user-1', 'active');
      vi.clearAllMocks();

      const setSpy = vi.spyOn(stateStore, 'setVisibility');

      controller.action('ui', 'user-1', 'hidden', 'op-3', 0);

      expect(setSpy).toHaveBeenCalledWith('user-1', 'hidden');
    });
  });

  // ── AC-11: echo reconciliation (_onEcho) ──────────────────────────────────

  describe('_onEcho() echo reconciliation (AC-11)', () => {
    // Helper: call init() and return the captured echo handler
    function getEchoHandler() {
      controller.init();
      return adapter.socket.on.mock.calls[0][1];
    }

    // Helper: directly register a pending op (bypasses action() self-confirm)
    function seedPendingOp(userId, opId, targetState = 'hidden') {
      const op = { opId, userId, targetState, previousState: 'active' };
      controller._pendingOps.set(userId, op);
      socketHandler.registerPendingOp(op, 'scrying-pool.visibility.set', {});
    }

    it('calls socketHandler.confirmPendingOp with the opId', () => {
      const echoHandler = getEchoHandler();
      seedPendingOp('user-1', 'op-1');
      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });
      expect(socketHandler.confirmPendingOp).toHaveBeenCalledWith('op-1');
    });

    it('stores the echo revision in _revisions for the userId', () => {
      const echoHandler = getEchoHandler();
      seedPendingOp('user-1', 'op-2');
      echoHandler({ opId: 'op-2', userId: 'user-1', state: 'hidden', revision: 7 });
      expect(controller._revisions.get('user-1')).toBe(7);
    });

    it('calls stateStore.setVisibility with the authoritative state', () => {
      const echoHandler = getEchoHandler();
      seedPendingOp('user-1', 'op-3', 'active');
      const setSpy = vi.spyOn(stateStore, 'setVisibility');

      echoHandler({ opId: 'op-3', userId: 'user-1', state: 'active', revision: 2 });

      expect(setSpy).toHaveBeenCalledWith('user-1', 'active');
    });

    it('fires Hooks.callAll scrying-pool:controllerAction with source: echo', () => {
      const echoHandler = getEchoHandler();
      seedPendingOp('user-1', 'op-4');
      echoHandler({ opId: 'op-4', userId: 'user-1', state: 'hidden', revision: 1 });

      expect(hooksStub.callAll).toHaveBeenCalledWith(
        'scrying-pool:controllerAction',
        expect.objectContaining({ source: 'echo', participantId: 'user-1', targetState: 'hidden', opId: 'op-4' })
      );
    });

    it('removes the participant from _pendingOps after echo', () => {
      const echoHandler = getEchoHandler();
      seedPendingOp('user-1', 'op-1');
      expect(controller._pendingOps.has('user-1')).toBe(true);

      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden', revision: 1 });

      expect(controller._pendingOps.has('user-1')).toBe(false);
    });

    it('defaults revision to 0 when echo payload omits revision field', () => {
      const echoHandler = getEchoHandler();
      seedPendingOp('user-1', 'op-1');
      echoHandler({ opId: 'op-1', userId: 'user-1', state: 'hidden' }); // no revision
      expect(controller._revisions.get('user-1')).toBe(0);
    });
  });

  // ── teardown() — listener cleanup (T-debt deferred from 1.4) ──────────────

  describe('teardown()', () => {
    it('unregisters socket echo listener', () => {
      adapter.socket.off = vi.fn();
      controller.init();
      const handler = controller._echoHandler;

      controller.teardown();

      expect(adapter.socket.off).toHaveBeenCalledWith(
        'scrying-pool.visibility.updated',
        handler
      );
    });

    it('unregisters userConnected hook listener', () => {
      const fakeHookId = 42;
      adapter.hooks.on = vi.fn().mockReturnValue(fakeHookId);
      controller.init();

      controller.teardown();

      expect(adapter.hooks.off).toHaveBeenCalledWith('userConnected', fakeHookId);
    });

    it('clears _pendingOps and _revisions', () => {
      controller.init();
      controller._pendingOps.set('u1', {});
      controller._revisions.set('u1', 5);

      controller.teardown();

      expect(controller._pendingOps.size).toBe(0);
      expect(controller._revisions.size).toBe(0);
    });

    it('nulls _echoHandler after teardown', () => {
      adapter.socket.off = vi.fn();
      controller.init();
      controller.teardown();
      expect(controller._echoHandler).toBeNull();
    });

    it('is safe to call before init()', () => {
      adapter.socket.off = vi.fn();
      expect(() => controller.teardown()).not.toThrow();
    });
  });

  // ── userConnected disconnect cleanup (T-06 debt) ──────────────────────────

  describe('userConnected disconnect cleanup', () => {
    it('cleans up participant on disconnect', () => {
      // Capture the userConnected handler
      let capturedHandler;
      adapter.hooks.on = vi.fn((event, handler) => {
        if (event === 'userConnected') capturedHandler = handler;
        return Symbol();
      });
      controller.init();
      controller._revisions.set('u1', 3);
      controller._pendingOps.set('u1', {});

      capturedHandler({ id: 'u1' }, false); // user 'u1' disconnected

      expect(controller._revisions.has('u1')).toBe(false);
      expect(controller._pendingOps.has('u1')).toBe(false);
    });

    it('does not clean up on connect (connected=true)', () => {
      let capturedHandler;
      adapter.hooks.on = vi.fn((event, handler) => {
        if (event === 'userConnected') capturedHandler = handler;
        return Symbol();
      });
      controller.init();
      controller._revisions.set('u1', 3);

      capturedHandler({ id: 'u1' }, true); // user connected — should not clean up

      expect(controller._revisions.has('u1')).toBe(true);
    });
  });
});
