/**
 * Tests for POST /api/agents/bulk — multi-agent action fanout.
 *
 * Pattern: mini Express harness via http.createServer + fetch, mirroring
 * archivedAgentDelete.test.js. The route handler under test is inlined
 * from src/interfaces/webServer.js — when the production handler
 * changes, this inline copy MUST be updated in the same commit.
 */
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import express from 'express';
import { createServer } from 'http';

const SILENT_LOGGER = { info() {}, warn() {}, error() {}, debug() {} };

let server, baseUrl, orch;

function makeOrchestrator(overrides = {}) {
  const agentPool = {
    getAgent: jest.fn(async (id) => (id.startsWith('missing-') ? null : { id, capabilities: ['memory', 'tools'] })),
    ...(overrides.agentPool || {}),
  };
  const processRequest = overrides.processRequest || jest.fn(async ({ action, payload }) => ({ success: true, data: { action, payload } }));
  return {
    config: { project: { directory: '/tmp/test' } },
    agentPool,
    processRequest,
    stateManager: overrides.stateManager || {},
  };
}

// Inlined handler — keep in sync with src/interfaces/webServer.js.
function bulkHandler(orchestrator, logger) {
  return async (req, res) => {
    const BULK_MAX_AGENTS = 100;
    const VALID_ACTIONS = ['delete', 'unload', 'pause', 'resume', 'set-model', 'enable-tool', 'disable-tool', 'archive'];
    try {
      const { action, agentIds, params = {}, sessionId } = req.body || {};
      if (!action || !VALID_ACTIONS.includes(action)) {
        return res.status(400).json({ success: false, error: `Invalid action: ${action}. Must be one of: ${VALID_ACTIONS.join(', ')}` });
      }
      if (!Array.isArray(agentIds) || agentIds.length === 0) {
        return res.status(400).json({ success: false, error: 'agentIds must be a non-empty array' });
      }
      if (agentIds.length > BULK_MAX_AGENTS) {
        return res.status(422).json({ success: false, error: `Too many agents in one call: ${agentIds.length}. Maximum is ${BULK_MAX_AGENTS}.` });
      }
      if (action === 'set-model' && (!params.model || typeof params.model !== 'string')) {
        return res.status(400).json({ success: false, error: 'set-model requires params.model (string)' });
      }
      if ((action === 'enable-tool' || action === 'disable-tool') && (!params.tool || typeof params.tool !== 'string')) {
        return res.status(400).json({ success: false, error: `${action} requires params.tool (string)` });
      }
      const projectDir = orchestrator.config.project?.directory || process.cwd();
      const effectiveSessionId = sessionId || 'bulk-action';
      const actionMap = { 'delete': 'delete_agent', 'unload': 'unload_agent', 'pause': 'pause_agent', 'resume': 'resume_agent', 'set-model': 'switch_model' };
      const runOne = async (agentId) => {
        try {
          const agent = await orchestrator.agentPool.getAgent(agentId).catch(() => null);
          if (!agent && action !== 'delete') return { agentId, success: false, error: 'Agent not found' };
          if (actionMap[action]) {
            const payload = action === 'set-model' ? { agentId, model: params.model } : { agentId };
            const r = await orchestrator.processRequest({ sessionId: effectiveSessionId, interface: 'web', action: actionMap[action], payload, projectDir });
            return { agentId, success: r?.success !== false, error: r?.error || null };
          }
          if (action === 'enable-tool' || action === 'disable-tool') {
            if (!agent) return { agentId, success: false, error: 'Agent not loaded' };
            const caps = Array.isArray(agent.capabilities) ? [...agent.capabilities] : [];
            const tool = params.tool;
            const i = caps.indexOf(tool);
            if (action === 'enable-tool' && i === -1) caps.push(tool);
            if (action === 'disable-tool' && i !== -1) caps.splice(i, 1);
            const r = await orchestrator.processRequest({ sessionId: effectiveSessionId, interface: 'web', action: 'update_agent', payload: { agentId, updates: { capabilities: caps } }, projectDir });
            return { agentId, success: r?.success !== false, error: r?.error || null };
          }
          if (action === 'archive') {
            if (orchestrator.stateManager?.archiveAgent) {
              await orchestrator.stateManager.archiveAgent(agentId, projectDir);
              return { agentId, success: true };
            }
            const r = await orchestrator.processRequest({ sessionId: effectiveSessionId, interface: 'web', action: 'unload_agent', payload: { agentId }, projectDir });
            return { agentId, success: r?.success !== false, error: r?.error || null };
          }
          return { agentId, success: false, error: `Unhandled action: ${action}` };
        } catch (err) {
          return { agentId, success: false, error: err.message || String(err) };
        }
      };
      const results = [];
      for (const id of agentIds) results.push(await runOne(id));
      const ok = results.filter(r => r.success).length;
      const failed = results.length - ok;
      res.json({ success: failed === 0, summary: { total: results.length, ok, failed }, results });
    } catch (error) {
      logger.error('Bulk action failed', { error: error.message });
      res.status(500).json({ success: false, error: error.message });
    }
  };
}

async function startServer(orchestrator) {
  const app = express();
  app.use(express.json());
  app.post('/api/agents/bulk', bulkHandler(orchestrator, SILENT_LOGGER));
  server = createServer(app);
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const { port } = server.address();
  baseUrl = `http://127.0.0.1:${port}`;
}

async function post(body) {
  const r = await fetch(`${baseUrl}/api/agents/bulk`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  let parsed = null;
  try { parsed = await r.json(); } catch { /* ignore */ }
  return { status: r.status, body: parsed };
}

afterEach(async () => {
  if (server) {
    await new Promise((r) => server.close(r));
    server = null;
  }
});

describe('POST /api/agents/bulk — validation', () => {
  it('rejects missing action with 400', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const r = await post({ agentIds: ['a'] });
    expect(r.status).toBe(400);
    expect(r.body.error).toMatch(/Invalid action/);
  });

  it('rejects unknown action with 400', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const r = await post({ action: 'invade-poland', agentIds: ['a'] });
    expect(r.status).toBe(400);
  });

  it('rejects empty agentIds with 400', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const r = await post({ action: 'delete', agentIds: [] });
    expect(r.status).toBe(400);
    expect(r.body.error).toMatch(/non-empty array/);
  });

  it('rejects >100 agents with 422', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const ids = Array.from({ length: 101 }, (_, i) => `a-${i}`);
    const r = await post({ action: 'delete', agentIds: ids });
    expect(r.status).toBe(422);
  });

  it('set-model requires params.model', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const r = await post({ action: 'set-model', agentIds: ['a'] });
    expect(r.status).toBe(400);
    expect(r.body.error).toMatch(/params\.model/);
  });

  it('enable-tool requires params.tool', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const r = await post({ action: 'enable-tool', agentIds: ['a'] });
    expect(r.status).toBe(400);
    expect(r.body.error).toMatch(/params\.tool/);
  });
});

describe('POST /api/agents/bulk — happy paths', () => {
  it('delete: returns success when every agent deletes ok', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    const r = await post({ action: 'delete', agentIds: ['a', 'b', 'c'] });
    expect(r.status).toBe(200);
    expect(r.body.success).toBe(true);
    expect(r.body.summary).toEqual({ total: 3, ok: 3, failed: 0 });
    expect(orch.processRequest).toHaveBeenCalledTimes(3);
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({
      action: 'delete_agent', payload: expect.objectContaining({ agentId: 'a' }),
    }));
  });

  it('pause routes to pause_agent', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    await post({ action: 'pause', agentIds: ['a'] });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({ action: 'pause_agent' }));
  });

  it('resume routes to resume_agent', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    await post({ action: 'resume', agentIds: ['a'] });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({ action: 'resume_agent' }));
  });

  it('unload routes to unload_agent', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    await post({ action: 'unload', agentIds: ['a'] });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({ action: 'unload_agent' }));
  });

  it('set-model forwards model in payload', async () => {
    orch = makeOrchestrator(); await startServer(orch);
    await post({ action: 'set-model', agentIds: ['a'], params: { model: 'Kimi-K2.6' } });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({
      action: 'switch_model',
      payload: expect.objectContaining({ agentId: 'a', model: 'Kimi-K2.6' }),
    }));
  });

  it('enable-tool: appends to capabilities[]', async () => {
    orch = makeOrchestrator({ agentPool: { getAgent: jest.fn(async (id) => ({ id, capabilities: ['memory'] })) } });
    await startServer(orch);
    await post({ action: 'enable-tool', agentIds: ['a'], params: { tool: 'terminal' } });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({
      action: 'update_agent',
      payload: expect.objectContaining({
        agentId: 'a',
        updates: expect.objectContaining({ capabilities: ['memory', 'terminal'] }),
      }),
    }));
  });

  it('disable-tool: removes from capabilities[]', async () => {
    orch = makeOrchestrator({ agentPool: { getAgent: jest.fn(async (id) => ({ id, capabilities: ['memory', 'terminal'] })) } });
    await startServer(orch);
    await post({ action: 'disable-tool', agentIds: ['a'], params: { tool: 'terminal' } });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({
      action: 'update_agent',
      payload: expect.objectContaining({
        updates: expect.objectContaining({ capabilities: ['memory'] }),
      }),
    }));
  });

  it('archive uses stateManager.archiveAgent when available', async () => {
    const archiveAgent = jest.fn().mockResolvedValue(undefined);
    orch = makeOrchestrator({ stateManager: { archiveAgent } });
    await startServer(orch);
    await post({ action: 'archive', agentIds: ['a', 'b'] });
    expect(archiveAgent).toHaveBeenCalledTimes(2);
  });

  it('archive falls back to unload when stateManager.archiveAgent missing', async () => {
    orch = makeOrchestrator();
    await startServer(orch);
    await post({ action: 'archive', agentIds: ['a'] });
    expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({ action: 'unload_agent' }));
  });
});

describe('POST /api/agents/bulk — partial success', () => {
  it('one failure does NOT abort the batch; per-agent results returned', async () => {
    orch = makeOrchestrator({
      processRequest: jest.fn(async ({ payload }) => payload.agentId === 'b'
        ? { success: false, error: 'agent b is borked' }
        : { success: true }),
    });
    await startServer(orch);
    const r = await post({ action: 'delete', agentIds: ['a', 'b', 'c'] });
    expect(r.status).toBe(200);
    expect(r.body.success).toBe(false);
    expect(r.body.summary).toEqual({ total: 3, ok: 2, failed: 1 });
    expect(r.body.results.find(x => x.agentId === 'a').success).toBe(true);
    expect(r.body.results.find(x => x.agentId === 'b').success).toBe(false);
    expect(r.body.results.find(x => x.agentId === 'b').error).toMatch(/borked/);
    expect(r.body.results.find(x => x.agentId === 'c').success).toBe(true);
  });

  it('thrown error in dispatch is captured as per-agent failure', async () => {
    orch = makeOrchestrator({
      processRequest: jest.fn(async ({ payload }) => {
        if (payload.agentId === 'b') throw new Error('orchestrator on fire');
        return { success: true };
      }),
    });
    await startServer(orch);
    const r = await post({ action: 'unload', agentIds: ['a', 'b'] });
    expect(r.body.success).toBe(false);
    expect(r.body.results.find(x => x.agentId === 'b').error).toMatch(/on fire/);
  });

  it('non-delete action against missing agent: returns "Agent not found"', async () => {
    orch = makeOrchestrator();
    await startServer(orch);
    const r = await post({ action: 'pause', agentIds: ['missing-1', 'a'] });
    expect(r.body.summary).toEqual({ total: 2, ok: 1, failed: 1 });
    expect(r.body.results.find(x => x.agentId === 'missing-1').error).toMatch(/not found/i);
  });

  // Regression guard for the silent "Invalid interface: WEB" bug.
  //
  // The bulk handler used to hardcode `interface: 'WEB'` (uppercase),
  // but orchestrator.processRequest validates against INTERFACE_TYPES
  // — which uses LOWERCASE strings ('web', 'cli', etc.). Every bulk
  // action — delete, pause, resume, unload, set-model, enable-tool,
  // disable-tool, archive — failed with `Invalid interface: WEB`
  // before even dispatching. The error was caught in `runOne`'s
  // try/catch and surfaced as per-agent failure ("Failed to delete
  // N agents") with no hint at the actual cause.
  //
  // Lock the lowercase contract for every action that flows through
  // processRequest, so any case-drift trips this test immediately.
  it('forwards lowercase "web" as the interface (not "WEB") for every dispatch', async () => {
    orch = makeOrchestrator();
    await startServer(orch);
    // Hit one representative of each routed action.
    await post({ action: 'delete', agentIds: ['a'] });
    await post({ action: 'pause',  agentIds: ['a'] });
    await post({ action: 'unload', agentIds: ['a'] });
    await post({ action: 'set-model', agentIds: ['a'], params: { model: 'k' } });

    // Every call to processRequest must have used lowercase 'web'.
    for (const call of orch.processRequest.mock.calls) {
      expect(call[0].interface).toBe('web');
    }
  });

  it('falls flat if the orchestrator rejects "WEB" — regression of the original bug', async () => {
    // Simulate the real orchestrator's validation: throw on anything
    // not matching INTERFACE_TYPES.WEB ('web'). With the fix, every
    // call should succeed because the handler now passes lowercase.
    orch = makeOrchestrator({
      processRequest: jest.fn(async ({ interface: iface }) => {
        if (iface !== 'web') throw new Error(`Invalid interface: ${iface}`);
        return { success: true };
      }),
    });
    await startServer(orch);
    const r = await post({ action: 'delete', agentIds: ['a', 'b'] });
    expect(r.body.success).toBe(true);
    expect(r.body.summary).toEqual({ total: 2, ok: 2, failed: 0 });
  });

  // Regression guard for the My Squadron > group operation (delete) bug.
  // Before the fix, deleting selected agents in the UI failed with
  // "Failed to delete N agents" whenever any of them was unloaded —
  // the bulk handler let `delete` flow through to the orchestrator, but
  // the orchestrator's _handleDeleteAgent threw "Agent not found" for
  // anything not in the live pool. Now agentPool.deleteAgent is
  // idempotent (clears persistent state regardless of pool membership)
  // and the orchestrator surfaces that success up through processRequest.
  // From the bulk route's perspective: every missing-prefixed agent
  // should report success.
  it('delete against missing/unloaded agents succeeds (idempotent cleanup)', async () => {
    orch = makeOrchestrator();
    await startServer(orch);
    const r = await post({ action: 'delete', agentIds: ['missing-1', 'missing-2', 'a'] });
    expect(r.status).toBe(200);
    expect(r.body.success).toBe(true);
    expect(r.body.summary).toEqual({ total: 3, ok: 3, failed: 0 });
    // All three reached processRequest (no short-circuit on missing).
    expect(orch.processRequest).toHaveBeenCalledTimes(3);
    for (const id of ['missing-1', 'missing-2', 'a']) {
      expect(orch.processRequest).toHaveBeenCalledWith(expect.objectContaining({
        action: 'delete_agent',
        payload: expect.objectContaining({ agentId: id }),
      }));
    }
  });
});
