import { Prisma } from '@prisma/client';
import type { Logger } from 'nestjs-pino';
import type { PrismaService } from 'nestjs-prisma';
import {
  PersonAndUserProvisioner,
  splitDisplayName,
  type EnsureUserInput,
} from './person-and-user-provisioner.service';

interface PrismaStub {
  user: {
    findUnique: jest.Mock;
    update: jest.Mock;
    create: jest.Mock;
  };
}

function makeLogger() {
  return { log: jest.fn(), warn: jest.fn(), error: jest.fn() } as unknown as Logger & {
    log: jest.Mock;
    warn: jest.Mock;
    error: jest.Mock;
  };
}

function makeSubject(opts?: { findUnique?: jest.Mock; update?: jest.Mock; create?: jest.Mock }): {
  service: PersonAndUserProvisioner;
  prisma: PrismaStub;
  logger: ReturnType<typeof makeLogger>;
} {
  const prisma: PrismaStub = {
    user: {
      findUnique: opts?.findUnique ?? jest.fn().mockResolvedValue(null),
      update: opts?.update ?? jest.fn().mockResolvedValue(undefined),
      create:
        opts?.create ?? jest.fn().mockResolvedValue({ id: 'user-uuid', personId: 'person-uuid' }),
    },
  };
  const logger = makeLogger();
  const service = new PersonAndUserProvisioner(prisma as unknown as PrismaService, logger);
  return { service, prisma, logger };
}

const INPUT: EnsureUserInput = {
  oid: 'entra-oid-1',
  tenantId: 'tenant-1',
  displayName: 'Jane Doe',
  email: 'jane.doe@apf.example',
};

describe('PersonAndUserProvisioner.ensureUser — first sign-in (cold path)', () => {
  it('creates Person + User in a single nested-create call', async () => {
    const { service, prisma } = makeSubject();
    await service.ensureUser(INPUT);
    expect(prisma.user.findUnique).toHaveBeenCalledWith({
      where: { entraOid: 'entra-oid-1' },
      select: { id: true, personId: true },
    });
    expect(prisma.user.create).toHaveBeenCalledTimes(1);
    const createArgs = prisma.user.create.mock.calls[0]?.[0] as {
      data: {
        entraOid: string;
        tenantId: string;
        lastSignInAt: Date;
        person: {
          create: { firstName: string; lastName: string; email: string | null; source: string };
        };
      };
    };
    expect(createArgs.data.entraOid).toBe('entra-oid-1');
    expect(createArgs.data.tenantId).toBe('tenant-1');
    expect(createArgs.data.lastSignInAt).toBeInstanceOf(Date);
    expect(createArgs.data.person.create.firstName).toBe('Jane');
    expect(createArgs.data.person.create.lastName).toBe('Doe');
    expect(createArgs.data.person.create.email).toBe('jane.doe@apf.example');
    expect(createArgs.data.person.create.source).toBe('self-signin');
  });

  it('returns the UUIDs the caller needs to populate the Principal', async () => {
    const { service } = makeSubject();
    const result = await service.ensureUser(INPUT);
    expect(result).toEqual({ userId: 'user-uuid', personId: 'person-uuid' });
  });

  it('writes Person.email = null when no email is provided', async () => {
    const { service, prisma } = makeSubject();
    await service.ensureUser({ ...INPUT, email: null });
    const args = prisma.user.create.mock.calls[0]?.[0] as {
      data: { person: { create: { email: string | null } } };
    };
    expect(args.data.person.create.email).toBeNull();
  });

  it('logs the create with the resulting UUIDs', async () => {
    const { service, logger } = makeSubject();
    await service.ensureUser(INPUT);
    expect(logger.log).toHaveBeenCalledWith(
      expect.objectContaining({
        event: 'provisioner.person_and_user_created',
        oid: 'entra-oid-1',
        userId: 'user-uuid',
        personId: 'person-uuid',
      }),
      'PersonAndUserProvisioner',
    );
  });
});

describe('PersonAndUserProvisioner.ensureUser — subsequent sign-in (warm path)', () => {
  it('refreshes lastSignInAt and returns the existing UUIDs without re-creating', async () => {
    const findUnique = jest.fn().mockResolvedValue({ id: 'user-uuid', personId: 'person-uuid' });
    const update = jest.fn().mockResolvedValue(undefined);
    const create = jest.fn();
    const { service } = makeSubject({ findUnique, update, create });
    const result = await service.ensureUser(INPUT);
    expect(create).not.toHaveBeenCalled();
    expect(update).toHaveBeenCalledTimes(1);
    const updateArgs = update.mock.calls[0]?.[0] as {
      where: { id: string };
      data: { lastSignInAt: Date };
    };
    expect(updateArgs.where).toEqual({ id: 'user-uuid' });
    expect(updateArgs.data.lastSignInAt).toBeInstanceOf(Date);
    expect(result).toEqual({ userId: 'user-uuid', personId: 'person-uuid' });
  });
});

describe('PersonAndUserProvisioner.ensureUser — race condition on first sign-in', () => {
  it('catches the P2002 unique violation and re-runs', async () => {
    // First call: findUnique misses, create raises P2002 (race
    // loser). Second call: findUnique now finds the row written by
    // the winner; update refreshes lastSignInAt, return.
    const findUnique = jest
      .fn()
      .mockResolvedValueOnce(null)
      .mockResolvedValueOnce({ id: 'user-uuid', personId: 'person-uuid' });
    const create = jest.fn().mockRejectedValueOnce(
      new Prisma.PrismaClientKnownRequestError('Unique constraint failed', {
        code: 'P2002',
        clientVersion: '6.19.3',
      }),
    );
    const update = jest.fn().mockResolvedValue(undefined);
    const { service, logger } = makeSubject({ findUnique, update, create });
    const result = await service.ensureUser(INPUT);
    expect(create).toHaveBeenCalledTimes(1);
    expect(findUnique).toHaveBeenCalledTimes(2);
    expect(update).toHaveBeenCalledTimes(1); // refresh on the retry's warm path
    expect(result).toEqual({ userId: 'user-uuid', personId: 'person-uuid' });
    expect(logger.warn).toHaveBeenCalledWith(
      expect.objectContaining({ event: 'provisioner.race_retry', oid: 'entra-oid-1' }),
      'PersonAndUserProvisioner',
    );
  });

  it('propagates non-P2002 Prisma errors unchanged (no retry)', async () => {
    const create = jest.fn().mockRejectedValueOnce(
      new Prisma.PrismaClientKnownRequestError('Connection error', {
        code: 'P1001',
        clientVersion: '6.19.3',
      }),
    );
    const { service } = makeSubject({ create });
    await expect(service.ensureUser(INPUT)).rejects.toMatchObject({ code: 'P1001' });
  });

  it('propagates non-Prisma errors unchanged', async () => {
    const create = jest.fn().mockRejectedValueOnce(new Error('boom'));
    const { service } = makeSubject({ create });
    await expect(service.ensureUser(INPUT)).rejects.toThrow('boom');
  });
});

describe('splitDisplayName', () => {
  it('splits on the first space', () => {
    expect(splitDisplayName('Jane Doe')).toEqual({ firstName: 'Jane', lastName: 'Doe' });
  });

  it('absorbs trailing whitespace in the last-name half', () => {
    expect(splitDisplayName('Jean-Pierre Dupont De La Mer')).toEqual({
      firstName: 'Jean-Pierre',
      lastName: 'Dupont De La Mer',
    });
  });

  it('returns lastName = "" for single-token names', () => {
    expect(splitDisplayName('Madonna')).toEqual({ firstName: 'Madonna', lastName: '' });
  });

  it('trims surrounding whitespace before splitting', () => {
    expect(splitDisplayName('  Jane Doe  ')).toEqual({ firstName: 'Jane', lastName: 'Doe' });
  });

  it('returns both empty for empty input', () => {
    expect(splitDisplayName('')).toEqual({ firstName: '', lastName: '' });
    expect(splitDisplayName('   ')).toEqual({ firstName: '', lastName: '' });
  });
});
