import { type Kysely } from 'kysely';

import { makeTestWithFixture } from '../../test/utils.js';
import type { UserManagementPg } from './index.js';
import UserManagementService from './userManagementService.js';

// Mock dependencies
const mockDb = {
  selectFrom: jest.fn(),
  insertInto: jest.fn(),
  updateTable: jest.fn(),
  deleteFrom: jest.fn(),
  transaction: jest.fn(),
} as unknown as Kysely<UserManagementPg>;

const mockSendEmail = jest.fn();

const mockConfigService = {
  uiUrl: 'http://localhost:3000',
};

describe('UserManagementService', () => {
  const testWithFixtures = makeTestWithFixture(() => {
    const sut = new UserManagementService(
      mockDb,
      mockSendEmail,
      mockConfigService,
    );
    return { sut };
  });

  beforeEach(() => {
    jest.clearAllMocks();
  });

  describe('#generatePasswordResetTokenForUser', () => {
    testWithFixtures(
      'should generate token and send email for valid user in same org',
      async ({ sut }) => {
        const userId = 'user-123';
        const orgId = 'org-456';
        const email = 'test@example.com';

        // Mock user lookup
        const mockSelect = {
          select: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          executeTakeFirst: jest.fn().mockResolvedValue({
            email,
            orgId,
          }),
        };

        // Mock token insertion
        const mockInsert = {
          values: jest.fn().mockReturnThis(),
          execute: jest.fn().mockResolvedValue([]),
        };

        // Mock delete
        const mockDelete = {
          where: jest.fn().mockReturnThis(),
          execute: jest.fn().mockResolvedValue([]),
        };

        (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
        (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert);
        (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete);

        const token = await sut.generatePasswordResetTokenForUser({
          userId,
          invokerOrgId: orgId,
        });

        // Verify token was generated (64 char hex string)
        expect(token).toMatch(/^[a-f0-9]{64}$/);

        // Verify email was sent
        expect(mockSendEmail).toHaveBeenCalledWith(
          expect.objectContaining({
            to: email,
            subject: '[Coop] Reset your password',
            html: expect.stringContaining('password reset'),
          }),
        );

        // Verify token was stored in database
        expect(mockDb.insertInto).toHaveBeenCalledWith(
          'user_management_service.password_reset_tokens',
        );
      },
    );

    testWithFixtures(
      'should throw UnauthorizedError when user is in different org',
      async ({ sut }) => {
        const userId = 'user-123';
        const userOrgId = 'org-456';
        const adminOrgId = 'org-789'; // Different org!

        // Mock user lookup
        const mockSelect = {
          select: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          executeTakeFirst: jest.fn().mockResolvedValue({
            email: 'test@example.com',
            orgId: userOrgId,
          }),
        };

        (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);

        await expect(
          sut.generatePasswordResetTokenForUser({
            userId,
            invokerOrgId: adminOrgId,
          }),
        ).rejects.toThrow(
          expect.objectContaining({
            message: expect.stringContaining(
              'can only reset passwords for users in your organization',
            ),
          }),
        );

        // Verify email was NOT sent
        expect(mockSendEmail).not.toHaveBeenCalled();
      },
    );

    testWithFixtures(
      'should throw NotFoundError when user does not exist',
      async ({ sut }) => {
        const userId = 'nonexistent-user';
        const orgId = 'org-456';

        // Mock user lookup returning null
        const mockSelect = {
          select: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          executeTakeFirst: jest.fn().mockResolvedValue(null),
        };

        (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);

        await expect(
          sut.generatePasswordResetTokenForUser({
            userId,
            invokerOrgId: orgId,
          }),
        ).rejects.toThrow(
          expect.objectContaining({
            message: expect.stringContaining('User not found'),
          }),
        );

        // Verify email was NOT sent
        expect(mockSendEmail).not.toHaveBeenCalled();
      },
    );

    testWithFixtures(
      'should continue if email sending fails (email errors are caught internally)',
      async ({ sut }) => {
        const userId = 'user-123';
        const orgId = 'org-456';
        const email = 'test@example.com';

        // Mock user lookup
        const mockSelect = {
          select: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          executeTakeFirst: jest.fn().mockResolvedValue({
            email,
            orgId,
          }),
        };

        // Mock token insertion
        const mockInsert = {
          values: jest.fn().mockReturnThis(),
          execute: jest.fn().mockResolvedValue([]),
        };

        // Mock delete
        const mockDelete = {
          where: jest.fn().mockReturnThis(),
          execute: jest.fn().mockResolvedValue([]),
        };

        // Mock email sending to fail (but it's caught internally by sendEmail)
        mockSendEmail.mockResolvedValue(undefined); // sendEmail catches errors internally

        (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
        (mockDb.insertInto as jest.Mock).mockReturnValue(mockInsert);
        (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete);

        // Should still return token - email service handles its own errors
        const token = await sut.generatePasswordResetTokenForUser({
          userId,
          invokerOrgId: orgId,
        });

        expect(token).toMatch(/^[a-f0-9]{64}$/);
        expect(mockSendEmail).toHaveBeenCalled();
      },
    );
  });

  describe('#resetPasswordForToken', () => {
    testWithFixtures(
      'invalidates all sessions for the user after resetting the password',
      async ({ sut }) => {
        const userId = 'user-123';

        // Valid, non-expired reset token.
        const mockSelect = {
          selectAll: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          executeTakeFirst: jest.fn().mockResolvedValue({
            hashed_token: 'hashed',
            user_id: userId,
            org_id: 'org-456',
            created_at: new Date(),
          }),
        };

        const mockUpdate = {
          set: jest.fn().mockReturnThis(),
          where: jest.fn().mockReturnThis(),
          execute: jest.fn().mockResolvedValue([]),
        };

        const mockDelete = {
          where: jest.fn().mockReturnThis(),
          execute: jest.fn().mockResolvedValue([]),
        };

        (mockDb.selectFrom as jest.Mock).mockReturnValue(mockSelect);
        (mockDb.updateTable as jest.Mock).mockReturnValue(mockUpdate);
        (mockDb.deleteFrom as jest.Mock).mockReturnValue(mockDelete);
        // Steps 2-4 run inside makeKyselyTransactionWithRetry, which calls
        // `pgQuery.transaction().execute(cb)`. Run the callback against mockDb.
        (mockDb.transaction as jest.Mock).mockReturnValue({
          execute: (cb: (trx: typeof mockDb) => unknown) => cb(mockDb),
        });

        await sut.resetPasswordForToken({
          token: 'plaintext-token',
          newPassword: 'new-password',
        });

        // The password row is updated...
        expect(mockDb.updateTable).toHaveBeenCalledWith('public.users');
        // ...and the user's sessions are deleted so a phished session can't
        // outlive the reset.
        expect(mockDb.deleteFrom).toHaveBeenCalledWith('public.session');
      },
    );
  });
});
