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

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function makeAdapter({
  currentUserId = 'gm-user',
  isGM = true,
  verbosity = 'all',
  users = {},
} = {}) {
  const notifSpy = {
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
  };
  const adpt = createFoundryAdapterMock({
    notifications: notifSpy,
    users: {
      get: vi.fn((id) => users[id] ?? { id, name: id }),
      isGM: () => isGM,
      current: () => (currentUserId ? { id: currentUserId } : null),
      ...users._overrides,
    },
    settings: {
      register: vi.fn(),
      get: vi.fn().mockReturnValue(verbosity),
      set: vi.fn(),
    },
    i18n: {
      localize: vi.fn((key, data) => {
        // Simple mock that returns the key with data substituted
        const messages = {
          'scrying-pool.notifications.personalHidden': 'GM has hidden your camera. Your portrait is shown to other Participants.',
          'scrying-pool.notifications.personalShowed': 'Your camera is now visible to the table.',
          'scrying-pool.notifications.gmHid': 'GM hid {name}\'s camera',
          'scrying-pool.notifications.gmShowed': 'GM showed {name}\'s camera',
        };
        let msg = messages[key] ?? key;
        if (data?.name) {
          msg = msg.replace('{name}', data.name);
        }
        return msg;
      }),
    },
  });
  // expose spy for assertions
  adpt._notifSpy = notifSpy;
  return adpt;
}

function makeHookCapture() {
  const handlers = {};
  return {
    stub: {
      on: vi.fn((event, handler) => {
        handlers[event] = handler;
        return Symbol('hookId');
      }),
      off: vi.fn(),
      once: vi.fn(),
      callAll: vi.fn(),
    },
    fire(event, data) {
      if (handlers[event]) handlers[event](data);
    },
    handlers,
  };
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('NotificationBus', () => {
  let hooks;

  beforeEach(() => {
    hooks = makeHookCapture();
    vi.stubGlobal('Hooks', hooks.stub);
  });

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

  // ── Constructor ────────────────────────────────────────────────────────────

  describe('constructor', () => {
    it('stores adapter without side effects', () => {
      const adapter = makeAdapter();
      const bus = new NotificationBus(adapter);
      expect(bus._adapter).toBe(adapter);
      expect(Hooks.on).not.toHaveBeenCalled();
    });

    it('coalesceMap is empty after construction', () => {
      const adapter = makeAdapter();
      const bus = new NotificationBus(adapter);
      expect(bus._coalesceMap.size).toBe(0);
    });
  });

  // ── init() ─────────────────────────────────────────────────────────────────

  describe('init()', () => {
    it('subscribes to scrying-pool:stateChanged hook', () => {
      const bus = new NotificationBus(makeAdapter());
      bus.init();
      expect(Hooks.on).toHaveBeenCalledWith('scrying-pool:stateChanged', expect.any(Function));
    });

    it('stores the hook id returned by Hooks.on', () => {
      const adapter = makeAdapter();
      // Hooks.on stub returns a Symbol per makeHookCapture, check hookId is stored
      const bus = new NotificationBus(adapter);
      bus.init();
      expect(bus._hookId).toBeDefined();
      expect(bus._hookId).not.toBeNull();
    });
  });

  // ── Personal notifications (AC-2) ──────────────────────────────────────────

  describe('personal notifications — current user is the affected participant', () => {
    it('fires immediate info when own camera is hidden (verbosity=all)', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all' });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'hidden',
        previousState: 'active',
      });

      expect(adapter._notifSpy.info).toHaveBeenCalledWith(
        "GM has hidden your camera. Your portrait is shown to other Participants."
      );
      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
    });

    it('fires immediate info when own camera is shown (not hidden)', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'all' });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'active',
        previousState: 'hidden',
      });

      expect(adapter._notifSpy.info).toHaveBeenCalledWith(
        "Your camera is now visible to the table."
      );
    });

    it('personal notification fires even when verbosity=silent', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'silent' });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'hidden',
        previousState: 'active',
      });

      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
    });

    it('personal notification fires even when verbosity=gm-only and user is not GM', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'gm-only' });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'hidden',
        previousState: 'active',
      });

      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
    });

    it('personal notification does NOT go through coalescing (fires immediately, no timer)', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'hidden',
        previousState: 'active',
      });

      // No timer advance needed — should fire immediately
      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
      expect(bus._coalesceMap.size).toBe(0);
    });
  });

  // ── Verbosity filtering — non-personal (AC-4, AC-5) ───────────────────────

  describe('verbosity filtering — other participant changes', () => {
    it('verbosity=silent blocks notification for non-affected user', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'gm-user', isGM: true, verbosity: 'silent' });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'other-player',
        state: 'hidden',
        previousState: 'active',
      });

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();
    });

    it('verbosity=gm-only blocks notification for non-GM player', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: 'player-1', isGM: false, verbosity: 'gm-only' });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'other-player',
        state: 'hidden',
        previousState: 'active',
      });

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();
    });

    it('verbosity=gm-only allows notification for GM user', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        isGM: true,
        verbosity: 'gm-only',
        users: { 'other-player': { id: 'other-player', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'other-player',
        state: 'hidden',
        previousState: 'active',
      });

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
    });

    it('verbosity=all allows notification for non-GM player about other participant', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'player-1',
        isGM: false,
        verbosity: 'all',
        users: { 'other-player': { id: 'other-player', name: 'Bob' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'other-player',
        state: 'active',
        previousState: 'hidden',
      });

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Bob's camera");
    });
  });

  // ── Coalescing timer (AC-3) ────────────────────────────────────────────────

  describe('coalescing — 3s debounce window', () => {
    it('fires notification after 3000ms debounce window', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'hidden',
        previousState: 'active',
      });

      // Before window closes — no notification yet
      vi.advanceTimersByTime(2_999);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();

      // Window closes — fires
      vi.advanceTimersByTime(2);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
    });

    it('resets debounce window when new change arrives before timer fires', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      // First change at t=0: active → hidden
      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'hidden',
        previousState: 'active',
      });

      // 1s later — second change arrives (hidden → self-muted), resets window
      vi.advanceTimersByTime(1_000);
      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-1',
        state: 'self-muted',
        previousState: 'hidden',
      });

      // Original 3s from first event (t=3000) should NOT fire — timer was reset
      vi.advanceTimersByTime(2_001);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();

      // New 3s window from second event fires at t=1000+3000=4000
      // Net: active → self-muted (not net-zero) → fires "GM showed" (self-muted != 'hidden')
      // 2 changes total: active→hidden, hidden→self-muted
      vi.advanceTimersByTime(1_000);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Alice's camera (2 changes)");
    });

    it('coalesces multiple changes into single notification with final state', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      // Three changes in rapid succession
      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden',   previousState: 'active' });
      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active',   previousState: 'hidden' });
      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden',   previousState: 'active' });

      vi.advanceTimersByTime(3_001);
      // Only one notification, based on final state, with change count
      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera (3 changes)");
    });

    it('net-zero suppression: no notification when final state equals original state', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      // hide then show — net state unchanged
      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' });

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();
    });

    it('independent timers per participant', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        users: {
          'player-1': { id: 'player-1', name: 'Alice' },
          'player-2': { id: 'player-2', name: 'Bob' },
        },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
      vi.advanceTimersByTime(1_000);
      hooks.fire('scrying-pool:stateChanged', { userId: 'player-2', state: 'hidden', previousState: 'active' });

      // t=3001 — player-1 fires
      vi.advanceTimersByTime(2_001);
      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(1);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");

      // t=4001 — player-2 fires
      vi.advanceTimersByTime(1_000);
      expect(adapter._notifSpy.info).toHaveBeenCalledTimes(2);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Bob's camera");
    });

    it('falls back to userId when user name cannot be resolved', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        currentUserId: 'gm-user',
        // users map returns null for unknown users (default createFoundryAdapterMock: get: () => null)
      });
      // Override users.get to return null for player-x
      adapter.users.get = vi.fn().mockReturnValue(null);
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', {
        userId: 'player-x',
        state: 'hidden',
        previousState: 'active',
      });

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid player-x's camera");
    });
  });

  // ── Message format (AC-1) ──────────────────────────────────────────────────

  describe('message format', () => {
    it('uses "GM hid [name]\'s camera" when final state is hidden', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        users: { 'player-1': { id: 'player-1', name: 'Aria' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
      vi.advanceTimersByTime(3_001);

      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM hid Aria's camera");
    });

    it('uses "GM showed [name]\'s camera" when final state is not hidden', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        users: { 'player-1': { id: 'player-1', name: 'Aria' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'active', previousState: 'hidden' });
      vi.advanceTimersByTime(3_001);

      expect(adapter._notifSpy.info).toHaveBeenCalledWith("GM showed Aria's camera");
    });
  });

  // ── teardown() ─────────────────────────────────────────────────────────────

  describe('teardown()', () => {
    it('unregisters Hooks listener', () => {
      const adapter = makeAdapter();
      const bus = new NotificationBus(adapter);
      bus.init();
      const storedId = bus._hookId;

      bus.teardown();

      expect(Hooks.off).toHaveBeenCalledWith('scrying-pool:stateChanged', storedId);
    });

    it('clears hookId after teardown', () => {
      const adapter = makeAdapter();
      const bus = new NotificationBus(adapter);
      bus.init();
      bus.teardown();
      expect(bus._hookId).toBeNull();
    });

    it('cancels pending timers — no notification fires after teardown', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
      bus.teardown();

      vi.advanceTimersByTime(5_000);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();
    });

    it('clears coalesceMap after teardown', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      const bus = new NotificationBus(adapter);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
      expect(bus._coalesceMap.size).toBe(1);

      bus.teardown();
      expect(bus._coalesceMap.size).toBe(0);
    });

    it('is safe to call teardown before init', () => {
      const adapter = makeAdapter();
      const bus = new NotificationBus(adapter);
      expect(() => bus.teardown()).not.toThrow();
    });
  });

  // ── Guard: missing userId ──────────────────────────────────────────────────

  describe('guards', () => {
    it('ignores stateChanged event without userId', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter();
      const bus = new NotificationBus(adapter);
      bus.init();

      expect(() => {
        hooks.fire('scrying-pool:stateChanged', { state: 'hidden', previousState: 'active' });
      }).not.toThrow();

      vi.advanceTimersByTime(3_001);
      expect(adapter._notifSpy.info).not.toHaveBeenCalled();
    });

    it('handles null current user gracefully (treats as non-personal)', () => {
      vi.useFakeTimers();
      const adapter = makeAdapter({ currentUserId: null, isGM: false, verbosity: 'all' });
      adapter.users.current = () => null;
      const adapter2 = makeAdapter({
        currentUserId: null,
        verbosity: 'all',
        users: { 'player-1': { id: 'player-1', name: 'Alice' } },
      });
      adapter2.users.current = () => null;
      const bus = new NotificationBus(adapter2);
      bus.init();

      hooks.fire('scrying-pool:stateChanged', { userId: 'player-1', state: 'hidden', previousState: 'active' });
      vi.advanceTimersByTime(3_001);

      expect(adapter2._notifSpy.info).toHaveBeenCalledWith("GM hid Alice's camera");
    });
  });
});
