import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerPromptRoutes } from '../src/routes/prompts.js';
import { PromptService } from '../src/services/prompt.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IPromptRepository } from '../src/repositories/prompt.repository.js';
import type { IPromptRequestRepository } from '../src/repositories/prompt-request.repository.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { Prompt, PromptRequest, Project } from '@prisma/client';

let app: FastifyInstance;

function makePrompt(overrides: Partial<Prompt> = {}): Prompt {
  return {
    id: 'prompt-1',
    name: 'test-prompt',
    content: 'Hello world',
    projectId: null,
    agentId: null,
    priority: 5,
    summary: null,
    chapters: null,
    linkTarget: null,
    semver: '0.1.0',
    currentRevisionId: null,
    version: 1,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  };
}

function makePromptRequest(overrides: Partial<PromptRequest> = {}): PromptRequest {
  return {
    id: 'req-1',
    name: 'test-request',
    content: 'Proposed content',
    projectId: null,
    priority: 5,
    createdBySession: 'session-abc',
    createdByUserId: null,
    createdAt: new Date(),
    ...overrides,
  };
}

function makeProject(overrides: Partial<Project> = {}): Project {
  return {
    id: 'proj-1',
    name: 'homeautomation',
    description: '',
    prompt: '',
    proxyModel: '',
    gated: true,
    llmProvider: null,
    llmModel: null,
    ownerId: 'user-1',
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides,
  } as Project;
}

function mockPromptRepo(): IPromptRepository {
  return {
    findAll: vi.fn(async () => []),
    findGlobal: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByNameAndProject: vi.fn(async () => null),
    create: vi.fn(async (data) => makePrompt(data)),
    update: vi.fn(async (id, data) => makePrompt({ id, ...data })),
    delete: vi.fn(async () => {}),
  };
}

function mockPromptRequestRepo(): IPromptRequestRepository {
  return {
    findAll: vi.fn(async () => []),
    findGlobal: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByNameAndProject: vi.fn(async () => null),
    findBySession: vi.fn(async () => []),
    create: vi.fn(async (data) => makePromptRequest(data)),
    update: vi.fn(async (id, data) => makePromptRequest({ id, ...data })),
    delete: vi.fn(async () => {}),
  };
}

function makeProjectWithServers(
  overrides: Partial<Project> = {},
  serverNames: string[] = [],
) {
  return {
    ...makeProject(overrides),
    servers: serverNames.map((name, i) => ({
      id: `ps-${i}`,
      projectId: overrides.id ?? 'proj-1',
      serverId: `srv-${i}`,
      server: { id: `srv-${i}`, name },
    })),
  };
}

function mockProjectRepo(): IProjectRepository {
  return {
    findAll: vi.fn(async () => []),
    findById: vi.fn(async () => null),
    findByName: vi.fn(async () => null),
    create: vi.fn(async (data) => makeProject(data)),
    update: vi.fn(async (_id, data) => makeProject({ ...data as Partial<Project> })),
    delete: vi.fn(async () => {}),
  };
}

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

function buildApp(opts?: {
  promptRepo?: IPromptRepository;
  promptRequestRepo?: IPromptRequestRepository;
  projectRepo?: IProjectRepository;
}) {
  const promptRepo = opts?.promptRepo ?? mockPromptRepo();
  const promptRequestRepo = opts?.promptRequestRepo ?? mockPromptRequestRepo();
  const projectRepo = opts?.projectRepo ?? mockProjectRepo();
  const service = new PromptService(promptRepo, promptRequestRepo, projectRepo);

  app = Fastify();
  app.setErrorHandler(errorHandler);
  registerPromptRoutes(app, service, projectRepo);
  return { app, promptRepo, promptRequestRepo, projectRepo, service };
}

describe('Prompt routes', () => {
  describe('GET /api/v1/prompts', () => {
    it('returns all prompts without project filter', async () => {
      const promptRepo = mockPromptRepo();
      const globalPrompt = makePrompt({ id: 'p-1', name: 'global-rule', projectId: null });
      const scopedPrompt = makePrompt({ id: 'p-2', name: 'scoped-rule', projectId: 'proj-1' });
      vi.mocked(promptRepo.findAll).mockResolvedValue([globalPrompt, scopedPrompt]);

      const { app: a } = buildApp({ promptRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Prompt[];
      expect(body).toHaveLength(2);
      expect(promptRepo.findAll).toHaveBeenCalledWith(undefined);
    });

    it('filters by project name when ?project= is given', async () => {
      const promptRepo = mockPromptRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1', name: 'homeautomation' }));
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'ha-rule', projectId: 'proj-1' }),
        makePrompt({ id: 'p-2', name: 'global-rule', projectId: null }),
      ]);

      const { app: a } = buildApp({ promptRepo, projectRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=homeautomation' });

      expect(res.statusCode).toBe(200);
      expect(projectRepo.findByName).toHaveBeenCalledWith('homeautomation');
      expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
    });

    it('returns only global prompts when ?scope=global', async () => {
      const promptRepo = mockPromptRepo();
      const globalOnly = [makePrompt({ id: 'p-g', name: 'global-rule', projectId: null })];
      vi.mocked(promptRepo.findGlobal).mockResolvedValue(globalOnly);

      const { app: a } = buildApp({ promptRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?scope=global' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Prompt[];
      expect(body).toHaveLength(1);
      expect(promptRepo.findGlobal).toHaveBeenCalled();
      expect(promptRepo.findAll).not.toHaveBeenCalled();
    });

    it('returns 404 when ?project= references unknown project', async () => {
      const { app: a } = buildApp();
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?project=nonexistent' });

      expect(res.statusCode).toBe(404);
      const body = res.json() as { error: string };
      expect(body.error).toContain('Project not found');
    });
  });

  describe('GET /api/v1/promptrequests', () => {
    it('returns all prompt requests without project filter', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      vi.mocked(promptRequestRepo.findAll).mockResolvedValue([
        makePromptRequest({ id: 'r-1', name: 'req-a' }),
      ]);

      const { app: a } = buildApp({ promptRequestRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests' });

      expect(res.statusCode).toBe(200);
      expect(promptRequestRepo.findAll).toHaveBeenCalledWith(undefined);
    });

    it('returns only global prompt requests when ?scope=global', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      vi.mocked(promptRequestRepo.findGlobal).mockResolvedValue([]);

      const { app: a } = buildApp({ promptRequestRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?scope=global' });

      expect(res.statusCode).toBe(200);
      expect(promptRequestRepo.findGlobal).toHaveBeenCalled();
      expect(promptRequestRepo.findAll).not.toHaveBeenCalled();
    });

    it('filters by project name when ?project= is given', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' }));

      const { app: a } = buildApp({ promptRequestRepo, projectRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=homeautomation' });

      expect(res.statusCode).toBe(200);
      expect(promptRequestRepo.findAll).toHaveBeenCalledWith('proj-1');
    });

    it('returns 404 for unknown project on promptrequests', async () => {
      const { app: a } = buildApp();
      const res = await a.inject({ method: 'GET', url: '/api/v1/promptrequests?project=nope' });

      expect(res.statusCode).toBe(404);
    });
  });

  describe('POST /api/v1/promptrequests', () => {
    it('creates a global prompt request (no project)', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      const { app: a } = buildApp({ promptRequestRepo });
      const res = await a.inject({
        method: 'POST',
        url: '/api/v1/promptrequests',
        payload: { name: 'global-req', content: 'some content' },
      });

      expect(res.statusCode).toBe(201);
      expect(promptRequestRepo.create).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'global-req', content: 'some content' }),
      );
    });

    it('resolves project name to ID when project given', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      const projectRepo = mockProjectRepo();
      const proj = makeProject({ id: 'proj-1', name: 'myproj' });
      vi.mocked(projectRepo.findByName).mockResolvedValue(proj);
      vi.mocked(projectRepo.findById).mockResolvedValue(proj);

      const { app: a } = buildApp({ promptRequestRepo, projectRepo });
      const res = await a.inject({
        method: 'POST',
        url: '/api/v1/promptrequests',
        payload: { name: 'scoped-req', content: 'text', project: 'myproj' },
      });

      expect(res.statusCode).toBe(201);
      expect(projectRepo.findByName).toHaveBeenCalledWith('myproj');
      expect(promptRequestRepo.create).toHaveBeenCalledWith(
        expect.objectContaining({ name: 'scoped-req', projectId: 'proj-1' }),
      );
    });

    it('returns 404 for unknown project name', async () => {
      const { app: a } = buildApp();
      const res = await a.inject({
        method: 'POST',
        url: '/api/v1/promptrequests',
        payload: { name: 'bad-req', content: 'x', project: 'nope' },
      });

      expect(res.statusCode).toBe(404);
    });
  });

  describe('POST /api/v1/promptrequests/:id/approve', () => {
    it('atomically approves a prompt request', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      const promptRepo = mockPromptRepo();
      const req = makePromptRequest({ id: 'req-1', name: 'my-rule', projectId: 'proj-1' });
      vi.mocked(promptRequestRepo.findById).mockResolvedValue(req);

      const { app: a } = buildApp({ promptRepo, promptRequestRepo });
      const res = await a.inject({ method: 'POST', url: '/api/v1/promptrequests/req-1/approve' });

      expect(res.statusCode).toBe(200);
      expect(promptRepo.create).toHaveBeenCalledWith({
        name: 'my-rule',
        content: 'Proposed content',
        projectId: 'proj-1',
      });
      expect(promptRequestRepo.delete).toHaveBeenCalledWith('req-1');
    });
  });

  describe('Security: projectId tampering', () => {
    it('rejects projectId in prompt update payload', async () => {
      const promptRepo = mockPromptRepo();
      vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt({ id: 'p-1', projectId: 'proj-a' }));

      const { app: a } = buildApp({ promptRepo });
      const res = await a.inject({
        method: 'PUT',
        url: '/api/v1/prompts/p-1',
        payload: { content: 'new content', projectId: 'proj-evil' },
      });

      // Should succeed but ignore projectId — UpdatePromptSchema strips it.
      // PR-2: a content change auto-bumps the patch number, so the update
      // call also carries the new semver.
      expect(res.statusCode).toBe(200);
      expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content', semver: '0.1.1' });
      // projectId must NOT be in the update call
      const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1];
      expect(updateArg).not.toHaveProperty('projectId');
    });

    it('rejects projectId in promptrequest update payload', async () => {
      const promptRequestRepo = mockPromptRequestRepo();
      vi.mocked(promptRequestRepo.findById).mockResolvedValue(makePromptRequest({ id: 'r-1', projectId: 'proj-a' }));

      const { app: a } = buildApp({ promptRequestRepo });
      const res = await a.inject({
        method: 'PUT',
        url: '/api/v1/promptrequests/r-1',
        payload: { content: 'new content', projectId: 'proj-evil' },
      });

      expect(res.statusCode).toBe(200);
      expect(promptRequestRepo.update).toHaveBeenCalledWith('r-1', { content: 'new content' });
      const updateArg = vi.mocked(promptRequestRepo.update).mock.calls[0]![1];
      expect(updateArg).not.toHaveProperty('projectId');
    });
  });

  describe('linkStatus enrichment', () => {
    it('returns linkStatus=null for non-linked prompts', async () => {
      const promptRepo = mockPromptRepo();
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'plain', linkTarget: null }),
      ]);

      const { app: a } = buildApp({ promptRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Array<{ linkStatus: string | null }>;
      expect(body[0]!.linkStatus).toBeNull();
    });

    it('returns linkStatus=alive when project and server exist', async () => {
      const promptRepo = mockPromptRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'source-proj/docmost-mcp:docmost://pages/abc' }),
      ]);
      vi.mocked(projectRepo.findByName).mockImplementation(async (name) => {
        if (name === 'source-proj') {
          return makeProjectWithServers({ id: 'sp-1', name: 'source-proj' }, ['docmost-mcp']) as never;
        }
        return null;
      });

      const { app: a } = buildApp({ promptRepo, projectRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Array<{ linkStatus: string }>;
      expect(body[0]!.linkStatus).toBe('alive');
    });

    it('returns linkStatus=dead when source project not found', async () => {
      const promptRepo = mockPromptRepo();
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'broken', linkTarget: 'missing-proj/srv:some://uri' }),
      ]);

      const { app: a } = buildApp({ promptRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Array<{ linkStatus: string }>;
      expect(body[0]!.linkStatus).toBe('dead');
    });

    it('returns linkStatus=dead when server not in project', async () => {
      const promptRepo = mockPromptRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'wrong-srv', linkTarget: 'proj/wrong-server:some://uri' }),
      ]);
      vi.mocked(projectRepo.findByName).mockResolvedValue(
        makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['other-server']) as never,
      );

      const { app: a } = buildApp({ promptRepo, projectRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Array<{ linkStatus: string }>;
      expect(body[0]!.linkStatus).toBe('dead');
    });

    it('enriches single prompt GET with linkStatus', async () => {
      const promptRepo = mockPromptRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(promptRepo.findById).mockResolvedValue(
        makePrompt({ id: 'p-1', name: 'linked', linkTarget: 'proj/srv:some://uri' }),
      );
      vi.mocked(projectRepo.findByName).mockResolvedValue(
        makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never,
      );

      const { app: a } = buildApp({ promptRepo, projectRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts/p-1' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as { linkStatus: string };
      expect(body.linkStatus).toBe('alive');
    });

    it('caches project lookup for multiple linked prompts', async () => {
      const promptRepo = mockPromptRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'link-a', linkTarget: 'proj/srv:uri-a' }),
        makePrompt({ id: 'p-2', name: 'link-b', linkTarget: 'proj/srv:uri-b' }),
      ]);
      vi.mocked(projectRepo.findByName).mockResolvedValue(
        makeProjectWithServers({ id: 'sp-1', name: 'proj' }, ['srv']) as never,
      );

      const { app: a } = buildApp({ promptRepo, projectRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts' });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Array<{ linkStatus: string }>;
      expect(body).toHaveLength(2);
      expect(body[0]!.linkStatus).toBe('alive');
      expect(body[1]!.linkStatus).toBe('alive');
      // Should only call findByName once (cached)
      expect(projectRepo.findByName).toHaveBeenCalledTimes(1);
    });

    it('supports ?projectId= query parameter', async () => {
      const promptRepo = mockPromptRepo();
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ id: 'p-1', name: 'scoped', projectId: 'proj-1' }),
      ]);

      const { app: a } = buildApp({ promptRepo });
      const res = await a.inject({ method: 'GET', url: '/api/v1/prompts?projectId=proj-1' });

      expect(res.statusCode).toBe(200);
      expect(promptRepo.findAll).toHaveBeenCalledWith('proj-1');
    });
  });

  describe('GET /api/v1/projects/:name/prompts/visible', () => {
    it('returns approved prompts + session pending requests', async () => {
      const promptRepo = mockPromptRepo();
      const promptRequestRepo = mockPromptRequestRepo();
      const projectRepo = mockProjectRepo();
      vi.mocked(projectRepo.findByName).mockResolvedValue(makeProject({ id: 'proj-1' }));
      vi.mocked(promptRepo.findAll).mockResolvedValue([
        makePrompt({ name: 'approved-one', projectId: 'proj-1' }),
        makePrompt({ name: 'global-one', projectId: null }),
      ]);
      vi.mocked(promptRequestRepo.findBySession).mockResolvedValue([
        makePromptRequest({ name: 'pending-one', projectId: 'proj-1' }),
      ]);

      const { app: a } = buildApp({ promptRepo, promptRequestRepo, projectRepo });
      const res = await a.inject({
        method: 'GET',
        url: '/api/v1/projects/homeautomation/prompts/visible?session=sess-123',
      });

      expect(res.statusCode).toBe(200);
      const body = res.json() as Array<{ name: string; type: string }>;
      expect(body).toHaveLength(3);
      expect(body.map((b) => b.name)).toContain('approved-one');
      expect(body.map((b) => b.name)).toContain('global-one');
      expect(body.map((b) => b.name)).toContain('pending-one');
      const pending = body.find((b) => b.name === 'pending-one');
      expect(pending?.type).toBe('promptrequest');
    });

    it('returns 404 for unknown project', async () => {
      const { app: a } = buildApp();
      const res = await a.inject({
        method: 'GET',
        url: '/api/v1/projects/nonexistent/prompts/visible',
      });

      expect(res.statusCode).toBe(404);
    });
  });
});
