import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { registerUserRoutes } from '../src/routes/users.js';
import { errorHandler } from '../src/middleware/error-handler.js';

/**
 * Unit tests for the password endpoints. RBAC/auth is enforced by global hooks
 * in main.ts (not registerUserRoutes), so here we set request.userId via a test
 * preHandler from the `x-test-user` header to drive the route logic directly.
 */

let app: FastifyInstance;

function makeUser(over?: Partial<{ id: string; email: string }>) {
  return { id: 'user-1', email: 'me@example.com', name: null, role: 'user', provider: 'local', externalId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), ...over };
}

function makeDeps() {
  return {
    userService: {
      list: vi.fn(async () => []),
      getById: vi.fn(async (id: string) => makeUser({ id })),
      getByEmail: vi.fn(async (email: string) => makeUser({ email, id: 'user-2' })),
      create: vi.fn(async () => makeUser({ id: 'new-user' })),
      delete: vi.fn(async () => {}),
      verifyPassword: vi.fn(async () => true),
      setPassword: vi.fn(async () => {}),
    },
    rbacDefinitionService: {
      upsertByName: vi.fn(async () => ({})),
    },
    prisma: {
      secret: { findUnique: vi.fn(async () => null) }, // no settings secret → default ON
    },
  };
}

type Deps = ReturnType<typeof makeDeps>;

async function createApp(deps: Deps): Promise<FastifyInstance> {
  app = Fastify({ logger: false });
  app.setErrorHandler(errorHandler);
  // Emulate the global auth hook: set userId from a test header when present.
  app.addHook('preHandler', async (request) => {
    const u = request.headers['x-test-user'];
    if (typeof u === 'string') request.userId = u;
  });
  registerUserRoutes(app, deps as unknown as Parameters<typeof registerUserRoutes>[1]);
  await app.ready();
  return app;
}

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

describe('POST /api/v1/users/me/password (self-service)', () => {
  let deps: Deps;
  beforeEach(() => { deps = makeDeps(); });

  it('changes password when current password verifies', async () => {
    await createApp(deps);
    const res = await app.inject({
      method: 'POST', url: '/api/v1/users/me/password',
      headers: { 'x-test-user': 'user-1' },
      payload: { currentPassword: 'oldpass12', newPassword: 'newpass345' },
    });
    expect(res.statusCode).toBe(200);
    expect(res.json()).toEqual({ success: true });
    expect(deps.userService.verifyPassword).toHaveBeenCalledWith('user-1', 'oldpass12');
    expect(deps.userService.setPassword).toHaveBeenCalledWith('user-1', 'newpass345');
  });

  it('rejects with 401 when current password is wrong', async () => {
    deps.userService.verifyPassword.mockResolvedValueOnce(false);
    await createApp(deps);
    const res = await app.inject({
      method: 'POST', url: '/api/v1/users/me/password',
      headers: { 'x-test-user': 'user-1' },
      payload: { currentPassword: 'wrong', newPassword: 'newpass345' },
    });
    expect(res.statusCode).toBe(401);
    expect(deps.userService.setPassword).not.toHaveBeenCalled();
  });

  it('rejects with 401 when unauthenticated', async () => {
    await createApp(deps);
    const res = await app.inject({
      method: 'POST', url: '/api/v1/users/me/password',
      payload: { currentPassword: 'x', newPassword: 'newpass345' },
    });
    expect(res.statusCode).toBe(401);
  });

  it('rejects with 400 when new password too short', async () => {
    await createApp(deps);
    const res = await app.inject({
      method: 'POST', url: '/api/v1/users/me/password',
      headers: { 'x-test-user': 'user-1' },
      payload: { currentPassword: 'oldpass12', newPassword: 'short' },
    });
    expect(res.statusCode).toBe(400);
    expect(deps.userService.setPassword).not.toHaveBeenCalled();
  });
});

describe('PUT /api/v1/users/:id/password (admin reset)', () => {
  let deps: Deps;
  beforeEach(() => { deps = makeDeps(); });

  it('resets by id without requiring a current password', async () => {
    await createApp(deps);
    const res = await app.inject({
      method: 'PUT', url: '/api/v1/users/user-9/password',
      payload: { newPassword: 'resetpass99' },
    });
    expect(res.statusCode).toBe(200);
    expect(deps.userService.setPassword).toHaveBeenCalledWith('user-9', 'resetpass99');
    expect(deps.userService.verifyPassword).not.toHaveBeenCalled();
  });

  it('resolves an email target to its id before resetting', async () => {
    await createApp(deps);
    const res = await app.inject({
      method: 'PUT', url: `/api/v1/users/${encodeURIComponent('other@example.com')}/password`,
      payload: { newPassword: 'resetpass99' },
    });
    expect(res.statusCode).toBe(200);
    expect(deps.userService.getByEmail).toHaveBeenCalledWith('other@example.com');
    expect(deps.userService.setPassword).toHaveBeenCalledWith('user-2', 'resetpass99');
  });
});

describe('POST /api/v1/users — self-permission seeding', () => {
  let deps: Deps;
  beforeEach(() => { deps = makeDeps(); });

  it('seeds the self password permission when the setting is ON (default)', async () => {
    await createApp(deps);
    const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } });
    expect(res.statusCode).toBe(201);
    expect(deps.rbacDefinitionService.upsertByName).toHaveBeenCalledWith({
      name: 'self-new-user',
      subjects: [{ kind: 'User', name: 'me@example.com' }],
      roleBindings: [{ role: 'run', action: 'set-own-password' }],
    });
  });

  it('does NOT seed when the setting is disabled', async () => {
    deps.prisma.secret.findUnique.mockResolvedValueOnce({ name: 'mcpctl-system-settings', data: { allowSelfPasswordChange: false } } as never);
    await createApp(deps);
    const res = await app.inject({ method: 'POST', url: '/api/v1/users', payload: { email: 'x@y.com', password: 'password12' } });
    expect(res.statusCode).toBe(201);
    expect(deps.rbacDefinitionService.upsertByName).not.toHaveBeenCalled();
  });
});
