/**
 * End-to-end tests for Relay AI Chat Flow & Multi-User Isolation
 *
 * Tests verify:
 * 1. Relay message routing (UI → Backend → CLI → AI → Response)
 * 2. Streaming event translation (relay-stream → content_block_delta)
 * 3. Multi-user isolation (broadcasts, sessions, projects, relay connections)
 * 4. Session locking and ownership
 * 5. Agent abstraction (executeAction dispatch)
 *
 * Run: node --test backend/server/tests/relay-flow.test.js
 */

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

// ── Test 1: Relay Connection Isolation ──

describe('Relay Connection Isolation', () => {
  it('relayConnections map keys by userId — different users get separate entries', () => {
    const relayConnections = new Map();

    // User A connects
    relayConnections.set(1, { ws: { readyState: 1 }, user: { userId: 1, username: 'userA' }, connectedAt: Date.now() });
    // User B connects
    relayConnections.set(2, { ws: { readyState: 1 }, user: { userId: 2, username: 'userB' }, connectedAt: Date.now() });

    assert.equal(relayConnections.size, 2, 'Two separate relay connections');
    assert.equal(relayConnections.get(1).user.username, 'userA');
    assert.equal(relayConnections.get(2).user.username, 'userB');

    // User A's lookup cannot access User B's relay
    const relayA = relayConnections.get(1);
    const relayB = relayConnections.get(2);
    assert.notEqual(relayA.ws, relayB.ws, 'Different users have different WebSocket connections');
  });

  it('hasActiveRelay returns true only for the correct user', () => {
    const relayConnections = new Map();
    relayConnections.set(1, { ws: { readyState: 1 } });
    relayConnections.set(2, { ws: { readyState: 3 } }); // User 2 disconnected (readyState=3)

    function hasActiveRelay(userId) {
      if (!userId) return false;
      const relay = relayConnections.get(Number(userId));
      return relay && relay.ws.readyState === 1;
    }

    assert.equal(hasActiveRelay(1), true, 'User 1 has active relay');
    assert.ok(!hasActiveRelay(2), 'User 2 relay is disconnected');
    assert.ok(!hasActiveRelay(3), 'User 3 has no relay');
    assert.ok(!hasActiveRelay(null), 'Null userId returns falsy');
    assert.ok(!hasActiveRelay(undefined), 'Undefined userId returns falsy');
  });

  it('sendRelayCommand targets only the correct userId relay', () => {
    const relayConnections = new Map();
    const sentMessages = { user1: [], user2: [] };

    relayConnections.set(1, {
      ws: {
        readyState: 1,
        send: (msg) => sentMessages.user1.push(JSON.parse(msg)),
      }
    });
    relayConnections.set(2, {
      ws: {
        readyState: 1,
        send: (msg) => sentMessages.user2.push(JSON.parse(msg)),
      }
    });

    // Send command to user 1's relay
    const relay = relayConnections.get(1);
    relay.ws.send(JSON.stringify({ type: 'relay-command', action: 'claude-query', command: 'hello' }));

    assert.equal(sentMessages.user1.length, 1, 'User 1 received the command');
    assert.equal(sentMessages.user2.length, 0, 'User 2 did NOT receive the command');
    assert.equal(sentMessages.user1[0].action, 'claude-query');
  });
});

// ── Test 2: Broadcast User Filtering ──

describe('Broadcast User Filtering', () => {
  it('relay-status broadcast only reaches clients with matching userId', () => {
    const connectedClients = new Set();
    const received = { userA: [], userB: [], noAuth: [] };

    const clientA = {
      readyState: 1,
      _wsUser: { userId: 1 },
      send: (msg) => received.userA.push(JSON.parse(msg)),
    };
    const clientB = {
      readyState: 1,
      _wsUser: { userId: 2 },
      send: (msg) => received.userB.push(JSON.parse(msg)),
    };
    const clientNoAuth = {
      readyState: 1,
      _wsUser: null,
      send: (msg) => received.noAuth.push(JSON.parse(msg)),
    };

    connectedClients.add(clientA);
    connectedClients.add(clientB);
    connectedClients.add(clientNoAuth);

    // Broadcast relay-status for userId=1
    const userId = 1;
    for (const client of connectedClients) {
      try {
        if (client.readyState === 1 && client._wsUser?.userId === userId) {
          client.send(JSON.stringify({ type: 'relay-status', userId, connected: true }));
        }
      } catch { /* ignore */ }
    }

    assert.equal(received.userA.length, 1, 'User A received the broadcast');
    assert.equal(received.userB.length, 0, 'User B did NOT receive it');
    assert.equal(received.noAuth.length, 0, 'Unauthenticated client did NOT receive it');
    assert.equal(received.userA[0].type, 'relay-status');
    assert.equal(received.userA[0].connected, true);
  });

  it('projects_updated broadcast only sends user-owned projects', () => {
    const connectedClients = new Set();
    const received = { userA: [], userB: [] };

    const clientA = {
      readyState: 1,
      _wsUser: { userId: 1 },
      send: (msg) => received.userA.push(JSON.parse(msg)),
    };
    const clientB = {
      readyState: 1,
      _wsUser: { userId: 2 },
      send: (msg) => received.userB.push(JSON.parse(msg)),
    };
    connectedClients.add(clientA);
    connectedClients.add(clientB);

    // Simulate per-user project lists
    const userProjects = {
      1: [{ name: 'projectA' }],
      2: [{ name: 'projectB' }],
    };

    for (const client of connectedClients) {
      if (client.readyState !== 1) continue;
      const uid = client._wsUser?.userId;
      if (uid && userProjects[uid]) {
        client.send(JSON.stringify({ type: 'projects_updated', projects: userProjects[uid] }));
      }
    }

    assert.equal(received.userA.length, 1);
    assert.equal(received.userB.length, 1);
    assert.equal(received.userA[0].projects[0].name, 'projectA', 'User A sees only projectA');
    assert.equal(received.userB[0].projects[0].name, 'projectB', 'User B sees only projectB');
  });
});

// ── Test 3: Streaming Event Translation ──

describe('Streaming Event Translation', () => {
  it('relay-stream with claude-response translates to content_block_delta', () => {
    const frontendMessages = [];
    const writer = {
      send: (msg) => frontendMessages.push(msg),
    };

    // Simulate onStream callback from routeViaRelay
    const responseType = 'claude-response';
    const sessionId = 'session-123';
    let fullContent = '';

    function onStream(streamData) {
      if (streamData.type === 'claude-response') {
        const chunk = streamData.content || '';
        if (chunk) {
          fullContent += chunk;
          writer.send({
            type: responseType,
            data: { type: 'content_block_delta', delta: { text: chunk } },
            sessionId,
          });
        }
      }
    }

    // Simulate streaming chunks
    onStream({ type: 'claude-response', content: 'Hello' });
    onStream({ type: 'claude-response', content: ', I am Claude.' });
    onStream({ type: 'claude-response', content: ' How can I help?' });

    assert.equal(frontendMessages.length, 3, 'Three streaming events sent to frontend');
    assert.equal(frontendMessages[0].data.type, 'content_block_delta');
    assert.equal(frontendMessages[0].data.delta.text, 'Hello');
    assert.equal(frontendMessages[1].data.delta.text, ', I am Claude.');
    assert.equal(frontendMessages[2].data.delta.text, ' How can I help?');
    assert.equal(fullContent, 'Hello, I am Claude. How can I help?', 'Full content accumulated correctly');
  });

  it('claude-system event translates to relay-session-id', () => {
    const frontendMessages = [];
    const writer = { send: (msg) => frontendMessages.push(msg) };
    const sessionId = 'session-456';
    let capturedCliSessionId = null;

    function onStream(streamData) {
      if (streamData.type === 'claude-system' && streamData.sessionId) {
        capturedCliSessionId = streamData.sessionId;
        writer.send({
          type: 'relay-session-id',
          sessionId,
          cliSessionId: capturedCliSessionId,
          model: streamData.model,
          cwd: streamData.cwd,
        });
      }
    }

    onStream({ type: 'claude-system', sessionId: 'cli-sess-789', model: 'claude-sonnet-4-6', cwd: '/home/user/project' });

    assert.equal(frontendMessages.length, 1);
    assert.equal(frontendMessages[0].type, 'relay-session-id');
    assert.equal(frontendMessages[0].cliSessionId, 'cli-sess-789');
    assert.equal(frontendMessages[0].model, 'claude-sonnet-4-6');
    assert.equal(capturedCliSessionId, 'cli-sess-789');
  });

  it('completion event includes viaRelay: true', () => {
    const frontendMessages = [];
    const writer = { send: (msg) => frontendMessages.push(msg) };

    // Simulate routeViaRelay completion
    writer.send({
      type: 'claude-complete',
      sessionId: 'session-123',
      cliSessionId: 'cli-sess-456',
      exitCode: 0,
      isNewSession: true,
      viaRelay: true,
    });

    assert.equal(frontendMessages[0].type, 'claude-complete');
    assert.equal(frontendMessages[0].viaRelay, true, 'Must indicate response came via relay');
    assert.equal(frontendMessages[0].exitCode, 0);
  });
});

// ── Test 4: Session Ownership Isolation ──

describe('Session Ownership Isolation', () => {
  it('sessionOwners map enforces user isolation', () => {
    const sessionOwners = new Map();

    // User 1 creates a session
    sessionOwners.set('session-A', 1);
    // User 2 creates a session
    sessionOwners.set('session-B', 2);

    // Simulate getUserSessions filter
    function getUserSessions(userId, allSessions) {
      return allSessions.filter(s => {
        const owner = sessionOwners.get(s.sessionId);
        return !owner || owner === userId;
      });
    }

    const allSessions = [
      { sessionId: 'session-A', provider: 'claude' },
      { sessionId: 'session-B', provider: 'claude' },
      { sessionId: 'session-C', provider: 'cursor' }, // unowned
    ];

    const user1Sessions = getUserSessions(1, allSessions);
    const user2Sessions = getUserSessions(2, allSessions);

    assert.equal(user1Sessions.length, 2, 'User 1 sees session-A + unowned session-C');
    assert.ok(user1Sessions.some(s => s.sessionId === 'session-A'), 'User 1 sees their own session');
    assert.ok(!user1Sessions.some(s => s.sessionId === 'session-B'), 'User 1 cannot see User 2 session');

    assert.equal(user2Sessions.length, 2, 'User 2 sees session-B + unowned session-C');
    assert.ok(user2Sessions.some(s => s.sessionId === 'session-B'), 'User 2 sees their own session');
    assert.ok(!user2Sessions.some(s => s.sessionId === 'session-A'), 'User 2 cannot see User 1 session');
  });

  it('abort rejects when user does not own the session', () => {
    const sessionOwners = new Map();
    sessionOwners.set('session-X', 1); // owned by user 1

    const userId = 2; // user 2 tries to abort
    const owner = sessionOwners.get('session-X');
    const canAbort = !owner || owner === userId;

    assert.equal(canAbort, false, 'User 2 cannot abort User 1 session');
  });
});

// ── Test 5: Agent Action Dispatch ──

describe('Agent Action Dispatch', () => {
  it('executeAction routes to correct handler', async () => {
    const callLog = [];
    const actionMap = new Map([
      ['claude-query', async (params) => { callLog.push('claude'); return { exitCode: 0 }; }],
      ['cursor-query', async (params) => { callLog.push('cursor'); return { exitCode: 0 }; }],
      ['codex-query', async (params) => { callLog.push('codex'); return { exitCode: 0 }; }],
      ['shell-exec', async (params) => { callLog.push('shell'); return { output: 'hello' }; }],
    ]);

    async function executeAction(action, params) {
      const handler = actionMap.get(action);
      if (!handler) throw new Error(`Unknown action: ${action}`);
      return handler(params);
    }

    await executeAction('claude-query', { command: 'hey' });
    await executeAction('cursor-query', { command: 'hey' });
    await executeAction('shell-exec', { command: 'ls' });

    assert.deepEqual(callLog, ['claude', 'cursor', 'shell']);

    // Unknown action throws
    await assert.rejects(
      () => executeAction('unknown-action', {}),
      { message: 'Unknown action: unknown-action' }
    );
  });

  it('streaming actions are identified correctly', () => {
    const streamingActions = new Set(['claude-query', 'claude-task-query', 'codex-query', 'cursor-query']);

    function isStreamingAction(action) {
      return streamingActions.has(action);
    }

    assert.equal(isStreamingAction('claude-query'), true);
    assert.equal(isStreamingAction('codex-query'), true);
    assert.equal(isStreamingAction('cursor-query'), true);
    assert.equal(isStreamingAction('shell-exec'), false);
    assert.equal(isStreamingAction('file-read'), false);
    assert.equal(isStreamingAction('git-status'), false);
  });
});

// ── Test 6: Relay Payload Validation ──

describe('Relay Payload Validation', () => {
  it('validates action is in allowlist', () => {
    const ALLOWED_RELAY_ACTIONS = new Set([
      'claude-query', 'claude-task-query', 'cursor-query', 'codex-query',
      'shell-exec', 'file-read', 'file-write', 'file-list',
      'git-status', 'git-diff', 'git-log', 'git-branches',
      'gitagent-parse', 'detect-agents',
    ]);

    function validateRelayPayload(action) {
      if (!ALLOWED_RELAY_ACTIONS.has(action)) {
        return { valid: false, reason: `Action "${action}" is not allowed` };
      }
      return { valid: true };
    }

    assert.equal(validateRelayPayload('claude-query').valid, true);
    assert.equal(validateRelayPayload('shell-exec').valid, true);
    assert.equal(validateRelayPayload('rm-rf-everything').valid, false, 'Unknown action blocked');
    assert.equal(validateRelayPayload('').valid, false, 'Empty action blocked');
  });
});

// ── Test 7: IP Pinning Security ──

describe('IP Pinning Security', () => {
  it('blocks relay connection from different IP when another is active', () => {
    const relayConnections = new Map();

    // User 1 connects from IP A
    relayConnections.set(1, { ws: { readyState: 1 }, clientIp: '1.2.3.4', connectedAt: Date.now() });

    // Same user tries from IP B
    const existing = relayConnections.get(1);
    const newIp = '5.6.7.8';
    const blocked = existing && existing.ws.readyState === 1 && existing.clientIp !== newIp;

    assert.equal(blocked, true, 'Second connection from different IP is blocked');
  });

  it('allows reconnection from same IP', () => {
    const relayConnections = new Map();
    relayConnections.set(1, { ws: { readyState: 1 }, clientIp: '1.2.3.4' });

    const existing = relayConnections.get(1);
    const sameIp = '1.2.3.4';
    const blocked = existing && existing.ws.readyState === 1 && existing.clientIp !== sameIp;

    assert.equal(blocked, false, 'Reconnection from same IP is allowed');
  });

  it('allows new connection when previous relay is disconnected', () => {
    const relayConnections = new Map();
    relayConnections.set(1, { ws: { readyState: 3 }, clientIp: '1.2.3.4' }); // disconnected

    const existing = relayConnections.get(1);
    const newIp = '5.6.7.8';
    const blocked = existing && existing.ws.readyState === 1 && existing.clientIp !== newIp;

    assert.equal(blocked, false, 'New connection allowed when previous relay is disconnected');
  });
});

// ── Test 8: Dashboard Stats Cache ──

describe('Dashboard Stats Cache', () => {
  it('cache returns fresh data within TTL', () => {
    const cache = new Map();
    const CACHE_TTL = 60_000;

    function getCachedStats(userId) {
      const entry = cache.get(userId);
      if (entry && Date.now() - entry.ts < CACHE_TTL) return entry.data;
      return null;
    }

    function setCachedStats(userId, data) {
      cache.set(userId, { data, ts: Date.now() });
    }

    // Set cache
    const testData = { account: { username: 'testuser' } };
    setCachedStats(1, testData);

    // Immediately retrieve — should hit
    const cached = getCachedStats(1);
    assert.deepEqual(cached, testData, 'Cache hit returns data');

    // Different user — should miss
    const otherUser = getCachedStats(2);
    assert.equal(otherUser, null, 'Different user has no cache');
  });

  it('cache invalidation clears entry for specific user', () => {
    const cache = new Map();

    cache.set(1, { data: { user: 'A' }, ts: Date.now() });
    cache.set(2, { data: { user: 'B' }, ts: Date.now() });

    // Invalidate user 1 only
    cache.delete(1);

    assert.equal(cache.has(1), false, 'User 1 cache invalidated');
    assert.equal(cache.has(2), true, 'User 2 cache unaffected');
  });
});

// ── Test 9: Frontend SessionStorage Stale-While-Revalidate ──

describe('Stale-While-Revalidate Pattern', () => {
  it('shows cached data immediately then updates with fresh data', () => {
    // Simulate sessionStorage
    const storage = {};
    const setItem = (k, v) => { storage[k] = v; };
    const getItem = (k) => storage[k] || null;

    // Initial state — no cache
    let stats = null;
    let loading = true;

    const cached = getItem('dash_stats');
    assert.equal(cached, null, 'No cache initially');
    assert.equal(loading, true, 'Loading state true initially');

    // Simulate first load — data comes from server
    const serverData = { account: { username: 'user1' }, apiKeys: { keys: [] } };
    stats = serverData;
    loading = false;
    setItem('dash_stats', JSON.stringify(serverData));

    assert.equal(loading, false);
    assert.deepEqual(stats, serverData);

    // Simulate second page load — cache hit
    stats = null;
    loading = true;

    const cachedStr = getItem('dash_stats');
    if (cachedStr) {
      stats = JSON.parse(cachedStr);
      loading = false; // Show cached data immediately
    }

    assert.equal(loading, false, 'Loading resolved immediately from cache');
    assert.deepEqual(stats.account.username, 'user1', 'Cached data shown immediately');
  });
});

// ── Test 10: Multi-User Chat Isolation (End-to-End) ──

describe('Multi-User Chat Isolation (E2E)', () => {
  it('two users sending commands simultaneously route to correct relays', () => {
    const relayConnections = new Map();
    const relayReceived = { user1: [], user2: [] };

    relayConnections.set(1, {
      ws: { readyState: 1, send: (msg) => relayReceived.user1.push(JSON.parse(msg)) }
    });
    relayConnections.set(2, {
      ws: { readyState: 1, send: (msg) => relayReceived.user2.push(JSON.parse(msg)) }
    });

    // User 1 sends a claude-command
    function routeToRelay(userId, action, command) {
      const relay = relayConnections.get(userId);
      if (!relay || relay.ws.readyState !== 1) throw new Error('No relay');
      relay.ws.send(JSON.stringify({ type: 'relay-command', action, command }));
    }

    routeToRelay(1, 'claude-query', 'Hey, I am user 1');
    routeToRelay(2, 'claude-query', 'Hey, I am user 2');
    routeToRelay(1, 'claude-query', 'Second message from user 1');

    assert.equal(relayReceived.user1.length, 2, 'User 1 relay received 2 commands');
    assert.equal(relayReceived.user2.length, 1, 'User 2 relay received 1 command');
    assert.equal(relayReceived.user1[0].command, 'Hey, I am user 1');
    assert.equal(relayReceived.user2[0].command, 'Hey, I am user 2');
    assert.equal(relayReceived.user1[1].command, 'Second message from user 1');
  });

  it('streaming responses go back to the correct frontend client', () => {
    // Simulate pending relay requests with different userIds
    const pendingRelayRequests = new Map();
    const frontendReceived = { user1: [], user2: [] };

    const writer1 = { send: (msg) => frontendReceived.user1.push(msg) };
    const writer2 = { send: (msg) => frontendReceived.user2.push(msg) };

    pendingRelayRequests.set('req-1', {
      userId: 1,
      onStream: (data) => {
        if (data.type === 'claude-response') {
          writer1.send({ type: 'claude-response', data: { type: 'content_block_delta', delta: { text: data.content } } });
        }
      }
    });
    pendingRelayRequests.set('req-2', {
      userId: 2,
      onStream: (data) => {
        if (data.type === 'claude-response') {
          writer2.send({ type: 'claude-response', data: { type: 'content_block_delta', delta: { text: data.content } } });
        }
      }
    });

    // Relay stream for user 1
    const pending1 = pendingRelayRequests.get('req-1');
    pending1.onStream({ type: 'claude-response', content: 'Hello user 1!' });

    // Relay stream for user 2
    const pending2 = pendingRelayRequests.get('req-2');
    pending2.onStream({ type: 'claude-response', content: 'Hello user 2!' });

    assert.equal(frontendReceived.user1.length, 1);
    assert.equal(frontendReceived.user2.length, 1);
    assert.equal(frontendReceived.user1[0].data.delta.text, 'Hello user 1!');
    assert.equal(frontendReceived.user2[0].data.delta.text, 'Hello user 2!');
  });
});
