import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerAgentRoutes } from '../src/routes/agents.js';
import { registerAgentChatRoutes } from '../src/routes/agent-chat.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import { ConflictError, NotFoundError } from '../src/services/mcp-server.service.js';
import type { AgentService, AgentView } from '../src/services/agent.service.js';
import type { ChatService } from '../src/services/chat.service.js';

const NOW = new Date();

function makeView(overrides: Partial<AgentView> = {}): AgentView {
  return {
    id: 'agent-1',
    name: 'reviewer',
    description: '',
    systemPrompt: '',
    llm: { id: 'llm-1', name: 'qwen3-thinking' },
    project: null,
    proxyModelName: null,
    defaultParams: {},
    extras: {},
    ownerId: 'owner-1',
    version: 1,
    createdAt: NOW,
    updatedAt: NOW,
    ...overrides,
  };
}

function mockAgentService(initial: AgentView[] = []): AgentService {
  const rows = new Map(initial.map((r) => [r.id, r]));
  return {
    list: vi.fn(async () => [...rows.values()]),
    listByProject: vi.fn(async (projectName: string) =>
      [...rows.values()].filter((r) => r.project?.name === projectName)),
    getById: vi.fn(async (id: string) => {
      const r = rows.get(id);
      if (!r) throw new NotFoundError(`Agent not found: ${id}`);
      return r;
    }),
    getByName: vi.fn(async (name: string) => {
      for (const r of rows.values()) if (r.name === name) return r;
      throw new NotFoundError(`Agent not found: ${name}`);
    }),
    create: vi.fn(async (input: unknown) => {
      const data = input as { name: string };
      for (const r of rows.values()) if (r.name === data.name) throw new ConflictError(`Agent already exists: ${data.name}`);
      const v = makeView({ id: `agent-${String(rows.size + 1)}`, name: data.name });
      rows.set(v.id, v);
      return v;
    }),
    update: vi.fn(async (id: string, input: unknown) => {
      const existing = rows.get(id);
      if (!existing) throw new NotFoundError(`Agent not found: ${id}`);
      const next = { ...existing, ...(input as Partial<AgentView>) };
      rows.set(id, next);
      return next;
    }),
    delete: vi.fn(async (id: string) => {
      if (!rows.has(id)) throw new NotFoundError(`Agent not found: ${id}`);
      rows.delete(id);
    }),
    upsertByName: vi.fn(),
    deleteByName: vi.fn(),
  } as unknown as AgentService;
}

function mockChatService(): ChatService {
  return {
    chat: vi.fn(async (args: { agentName: string; userMessage?: string }) => ({
      threadId: 'thread-1', assistant: `echo: ${args.userMessage ?? ''}`, turnIndex: 1,
    })),
    chatStream: vi.fn(async function*() {
      yield { type: 'text' as const, delta: 'hi' };
      yield { type: 'final' as const, threadId: 'thread-1', turnIndex: 1 };
    }),
    createThread: vi.fn(async () => ({ id: 'thread-2' })),
    listThreads: vi.fn(async () => [
      { id: 'thread-1', title: 't1', lastTurnAt: NOW, createdAt: NOW },
    ]),
    listMessages: vi.fn(async () => []),
  } as unknown as ChatService;
}

let app: FastifyInstance;

afterEach(async () => {
  if (app) await app.close();
});

async function createApp(opts: { agents?: AgentService; chat?: ChatService } = {}): Promise<FastifyInstance> {
  app = Fastify({ logger: false });
  app.setErrorHandler(errorHandler);
  registerAgentRoutes(app, opts.agents ?? mockAgentService());
  registerAgentChatRoutes(app, opts.chat ?? mockChatService());
  await app.ready();
  return app;
}

describe('Agent CRUD routes', () => {
  it('GET /api/v1/agents lists agents', async () => {
    await createApp({ agents: mockAgentService([makeView()]) });
    const res = await app.inject({ method: 'GET', url: '/api/v1/agents' });
    expect(res.statusCode).toBe(200);
    expect(res.json<unknown[]>()).toHaveLength(1);
  });

  it('GET /api/v1/agents/:name resolves by name when not a CUID', async () => {
    await createApp({ agents: mockAgentService([makeView({ id: 'agent-1', name: 'reviewer' })]) });
    const res = await app.inject({ method: 'GET', url: '/api/v1/agents/reviewer' });
    expect(res.statusCode).toBe(200);
    expect(res.json<{ name: string }>().name).toBe('reviewer');
  });

  it('GET /api/v1/agents/:id returns 404 when missing', async () => {
    await createApp();
    const res = await app.inject({ method: 'GET', url: '/api/v1/agents/missing' });
    expect(res.statusCode).toBe(404);
  });

  it('POST /api/v1/agents creates and returns 201', async () => {
    await createApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents',
      payload: { name: 'deployer', llm: { name: 'qwen3-thinking' } },
    });
    expect(res.statusCode).toBe(201);
    expect(res.json<{ name: string }>().name).toBe('deployer');
  });

  it('POST /api/v1/agents returns 409 on duplicate name', async () => {
    await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'dup' })]) });
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents',
      payload: { name: 'dup', llm: { name: 'qwen3-thinking' } },
    });
    expect(res.statusCode).toBe(409);
  });

  it('PUT /api/v1/agents/:name updates by name', async () => {
    await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'editable' })]) });
    const res = await app.inject({
      method: 'PUT',
      url: '/api/v1/agents/editable',
      payload: { description: 'changed' },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json<{ description: string }>().description).toBe('changed');
  });

  it('DELETE /api/v1/agents/:name returns 204', async () => {
    await createApp({ agents: mockAgentService([makeView({ id: 'a1', name: 'doomed' })]) });
    const res = await app.inject({ method: 'DELETE', url: '/api/v1/agents/doomed' });
    expect(res.statusCode).toBe(204);
  });

  it('GET /api/v1/projects/:name/agents lists project-scoped agents', async () => {
    await createApp({
      agents: mockAgentService([
        makeView({ id: 'a1', name: 'in', project: { id: 'p1', name: 'mcpctl-dev' } }),
        makeView({ id: 'a2', name: 'out' }),
      ]),
    });
    const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl-dev/agents' });
    expect(res.statusCode).toBe(200);
    expect(res.json<Array<{ name: string }>>().map((a) => a.name)).toEqual(['in']);
  });
});

describe('Chat + threads routes', () => {
  it('POST /api/v1/agents/:name/chat (non-streaming) returns assistant body', async () => {
    await createApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents/reviewer/chat',
      payload: { message: 'hi' },
    });
    expect(res.statusCode).toBe(200);
    const body = res.json<{ threadId: string; assistant: string }>();
    expect(body.assistant).toContain('echo');
    expect(body.threadId).toBe('thread-1');
  });

  it('POST /api/v1/agents/:name/chat rejects empty body with 400', async () => {
    await createApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents/reviewer/chat',
      payload: {},
    });
    expect(res.statusCode).toBe(400);
  });

  it('POST /api/v1/agents/:name/chat (streaming) emits SSE frames', async () => {
    await createApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents/reviewer/chat',
      payload: { message: 'hi', stream: true },
    });
    expect(res.statusCode).toBe(200);
    expect(res.headers['content-type']).toContain('text/event-stream');
    const body = res.body;
    expect(body).toContain('data: ');
    expect(body).toContain('"type":"text"');
    expect(body).toContain('"type":"final"');
    expect(body.endsWith('data: [DONE]\n\n')).toBe(true);
  });

  it('POST /api/v1/agents/:name/threads returns 201 with new thread id', async () => {
    await createApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents/reviewer/threads',
      payload: { title: 'kickoff' },
    });
    expect(res.statusCode).toBe(201);
    expect(res.json<{ id: string }>().id).toBe('thread-2');
  });

  it('GET /api/v1/agents/:name/threads lists threads', async () => {
    await createApp();
    const res = await app.inject({ method: 'GET', url: '/api/v1/agents/reviewer/threads' });
    expect(res.statusCode).toBe(200);
    const body = res.json<Array<{ id: string }>>();
    expect(body).toHaveLength(1);
    expect(body[0]!.id).toBe('thread-1');
  });

  it('GET /api/v1/threads/:id/messages returns the message log', async () => {
    await createApp();
    const res = await app.inject({ method: 'GET', url: '/api/v1/threads/thread-1/messages' });
    expect(res.statusCode).toBe(200);
    expect(res.json<unknown[]>()).toEqual([]);
  });
});

describe('mapUrlToPermission for agents', () => {
  // The mapping itself is tested implicitly through main.ts; this asserts the
  // shape we export for the chat URL → run:agents:<name>.
  it('routes /agents/:name/chat through run:agents:<name>', async () => {
    // Smoke check via the route working at all — RBAC integration is exercised
    // in main.ts tests; this just guards against regressions in the URL shape.
    await createApp();
    const res = await app.inject({
      method: 'POST',
      url: '/api/v1/agents/r/chat',
      payload: { message: 'x' },
    });
    expect(res.statusCode).toBe(200);
  });
});
