import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { exportJWK, generateKeyPair, type JWK } from 'jose';
import { PrismaService } from '../../common/prisma.service';
import { FaydaConfig } from '../../config/fayda.config';
import { VerifaydaService } from './verifayda.service';

function buildPrismaMock() {
  return {
    faydaVerificationSession: {
      create: jest.fn(),
      findUnique: jest.fn(),
      update: jest.fn(),
      updateMany: jest.fn(),
    },
    bookingSeat: {
      updateMany: jest.fn(),
    },
    user: {
      findUnique: jest.fn(),
      findFirst: jest.fn(),
      create: jest.fn(),
      update: jest.fn(),
    },
    passenger: { create: jest.fn() },
    loyaltyAccount: { create: jest.fn() },
    walletAccount: { create: jest.fn() },
    userPreferences: { create: jest.fn() },
    verifaydaVerification: { create: jest.fn() },
  };
}

function buildJwtMock(): jest.Mocked<JwtService> {
  return {
    sign: jest.fn(() => 'signed.jwt.token'),
  } as unknown as jest.Mocked<JwtService>;
}

function buildConfig(overrides?: Partial<FaydaConfig>): FaydaConfig {
  return {
    enabled: true,
    clientId: 'edr-test-client',
    authorizationEndpoint: 'https://esignet.test/authorize',
    tokenEndpoint: 'https://esignet.test/token',
    userInfoEndpoint: 'https://esignet.test/userinfo',
    redirectUri: 'http://localhost:4000/fayda/verification/complete',
    privateJwk: { kty: 'RSA', n: '', e: '', d: '' },
    scope: 'openid profile email',
    acrValues: 'mosip:idp:acr:generated-code',
    claimsLocales: 'en am',
    sessionTtlMinutes: 10,
    ...overrides,
  };
}

function buildConfigService(faydaConfig: FaydaConfig): jest.Mocked<ConfigService> {
  return {
    get: jest.fn((key: string, defaultValue?: unknown) => {
      if (key === 'fayda') return faydaConfig;
      if (key === 'VERIFAYDA_ENABLED') return false;
      return defaultValue;
    }),
  } as unknown as jest.Mocked<ConfigService>;
}

describe('VerifaydaService (OIDC, client-callback)', () => {
  let prisma: ReturnType<typeof buildPrismaMock>;
  let jwt: jest.Mocked<JwtService>;
  let service: VerifaydaService;
  let realPrivateJwk: JWK;

  beforeAll(async () => {
    const kp = await generateKeyPair('RS256', { extractable: true });
    realPrivateJwk = await exportJWK(kp.privateKey);
    realPrivateJwk.kty = 'RSA';
  });

  beforeEach(() => {
    prisma = buildPrismaMock();
    jwt = buildJwtMock();
    const cfg = buildConfig({ privateJwk: realPrivateJwk as FaydaConfig['privateJwk'] });
    service = new VerifaydaService(
      buildConfigService(cfg),
      prisma as unknown as PrismaService,
      jwt,
    );
    (global as any).fetch = jest.fn();
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  describe('startVerification', () => {
    it('persists a session and returns a fully-formed authorize URL', async () => {
      prisma.faydaVerificationSession.create.mockResolvedValue({});

      const url = await service.startVerification({
        purpose: 'PURCHASE',
        userId: 'user-1',
        saveToAccount: true,
      });

      const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
      expect(created.purpose).toBe('PURCHASE');
      expect(created.platform).toBe('WEB');
      expect(typeof created.state).toBe('string');
      expect(typeof created.codeVerifier).toBe('string');

      const parsed = new URL(url);
      expect(parsed.origin + parsed.pathname).toBe('https://esignet.test/authorize');
      expect(parsed.searchParams.get('client_id')).toBe('edr-test-client');
      expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
      expect(parsed.searchParams.get('redirect_uri')).toBe(
        'http://localhost:4000/fayda/verification/complete',
      );
      expect(parsed.searchParams.get('state')).toBe(created.state);
    });

    it('uses the same single redirect_uri regardless of platform (platform is only recorded)', async () => {
      prisma.faydaVerificationSession.create.mockResolvedValue({});

      const url = await service.startVerification({
        purpose: 'LOGIN',
        platform: 'MOBILE',
      });

      const created = prisma.faydaVerificationSession.create.mock.calls[0][0].data;
      expect(created.platform).toBe('MOBILE');
      expect(new URL(url).searchParams.get('redirect_uri')).toBe(
        'http://localhost:4000/fayda/verification/complete',
      );
    });

    it('throws ServiceUnavailable when fayda integration is disabled', async () => {
      const disabledService = new VerifaydaService(
        buildConfigService(buildConfig({ enabled: false })),
        prisma as unknown as PrismaService,
        jwt,
      );
      await expect(
        disabledService.startVerification({ purpose: 'PURCHASE' }),
      ).rejects.toMatchObject({ status: 503 });
    });
  });

  describe('completeVerification — validation', () => {
    function pendingSession(overrides: Partial<any> = {}) {
      return {
        id: 'session-1',
        state: 'state-abc',
        codeVerifier: 'verifier-xyz',
        purpose: 'PURCHASE',
        platform: 'WEB',
        saveToAccount: false,
        status: 'PENDING',
        errorCode: null,
        errorDescription: null,
        userId: null,
        bookingId: null,
        expiresAt: new Date(Date.now() + 60_000),
        ...overrides,
      };
    }

    it('throws and marks failed when callback carries an error', async () => {
      prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
      await expect(
        service.completeVerification({
          error: 'access_denied',
          error_description: 'user cancelled',
          state: 'state-abc',
        }),
      ).rejects.toMatchObject({ status: 400 });
      expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
    });

    it('throws FAYDA_MISSING_PARAMETERS when code/state absent', async () => {
      await expect(service.completeVerification({})).rejects.toMatchObject({
        status: 400,
      });
    });

    it('throws FAYDA_INVALID_STATE for unknown state', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(null);
      await expect(
        service.completeVerification({ code: 'c', state: 'bogus' }),
      ).rejects.toMatchObject({ status: 400 });
    });

    it('throws FAYDA_INVALID_STATE for a non-pending session', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(
        pendingSession({ status: 'COMPLETED' }),
      );
      await expect(
        service.completeVerification({ code: 'c', state: 'state-abc' }),
      ).rejects.toMatchObject({ status: 400 });
    });

    it('throws FAYDA_SESSION_EXPIRED and marks failed for an expired session', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(
        pendingSession({ expiresAt: new Date(Date.now() - 1000) }),
      );
      prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
      await expect(
        service.completeVerification({ code: 'c', state: 'state-abc' }),
      ).rejects.toMatchObject({ status: 400 });
      expect(prisma.faydaVerificationSession.updateMany).toHaveBeenCalled();
    });
  });

  describe('completeVerification — PURCHASE', () => {
    function pendingSession(overrides: Partial<any> = {}) {
      return {
        id: 'session-1',
        state: 'state-abc',
        codeVerifier: 'verifier-xyz',
        purpose: 'PURCHASE',
        platform: 'WEB',
        saveToAccount: false,
        status: 'PENDING',
        userId: null,
        bookingId: null,
        expiresAt: new Date(Date.now() + 60_000),
        ...overrides,
      };
    }

    function mockFetchSequence(...responses: Array<Partial<Response>>) {
      const queue = responses.map((r) => ({
        ok: true,
        status: 200,
        text: async () => '',
        json: async () => ({}),
        headers: new Headers({ 'content-type': 'application/json' }),
        ...r,
      }));
      (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
    }

    it('stamps the booking seats and returns { verified, fullName }', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(
        pendingSession({ bookingId: 'booking-1' }),
      );
      prisma.faydaVerificationSession.update.mockResolvedValue({});
      prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });

      mockFetchSequence(
        { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
        {
          headers: new Headers({ 'content-type': 'application/json' }),
          text: async () =>
            JSON.stringify({ sub: 'fayda-sub-1', name: 'Test User' }),
        },
      );

      const result = await service.completeVerification({
        code: 'authcode',
        state: 'state-abc',
      });

      expect(result).toMatchObject({
        purpose: 'PURCHASE',
        verified: true,
        fullName: 'Test User',
      });
      expect(result.token).toBeUndefined();
      expect(prisma.bookingSeat.updateMany).toHaveBeenCalledWith({
        where: { bookingId: 'booking-1' },
        data: expect.objectContaining({ faydaSub: 'fayda-sub-1' }),
      });
    });

    it('saves to the User account when saveToAccount=true and no conflict', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(
        pendingSession({ userId: 'user-1', saveToAccount: true }),
      );
      prisma.user.findFirst.mockResolvedValue(null);
      prisma.user.update.mockResolvedValue({});
      prisma.faydaVerificationSession.update.mockResolvedValue({});

      mockFetchSequence(
        { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
        {
          headers: new Headers({ 'content-type': 'application/json' }),
          text: async () =>
            JSON.stringify({ sub: 'fayda-sub-2', name: 'Test User' }),
        },
      );

      const result = await service.completeVerification({
        code: 'authcode',
        state: 'state-abc',
      });

      expect(result.verified).toBe(true);
      expect(prisma.user.update).toHaveBeenCalledWith({
        where: { id: 'user-1' },
        data: expect.objectContaining({ faydaVerified: true, faydaSub: 'fayda-sub-2' }),
      });
    });

    it('throws identity_conflict (409) when faydaSub belongs to another user', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(
        pendingSession({ userId: 'user-1', saveToAccount: true }),
      );
      prisma.user.findFirst.mockResolvedValue({ id: 'other-user' });
      prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });

      mockFetchSequence(
        { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
        {
          headers: new Headers({ 'content-type': 'application/json' }),
          text: async () =>
            JSON.stringify({ sub: 'fayda-sub-3', name: 'Test User' }),
        },
      );

      await expect(
        service.completeVerification({ code: 'authcode', state: 'state-abc' }),
      ).rejects.toMatchObject({ status: 409 });
      expect(prisma.user.update).not.toHaveBeenCalled();
    });

    it('throws 502 when the token endpoint returns 4xx', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
      prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
      mockFetchSequence({
        ok: false,
        status: 400,
        text: async () => '{"error":"invalid_assertion"}',
      });

      await expect(
        service.completeVerification({ code: 'authcode', state: 'state-abc' }),
      ).rejects.toMatchObject({ status: 502 });
    });

    it('throws 502 when userinfo is an unsupported format', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(pendingSession());
      prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });
      mockFetchSequence(
        { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
        {
          headers: new Headers({ 'content-type': 'text/plain' }),
          text: async () => 'not-a-jwt-not-a-json',
        },
      );

      await expect(
        service.completeVerification({ code: 'authcode', state: 'state-abc' }),
      ).rejects.toMatchObject({ status: 502 });
    });

    it('falls back to localized name (name#en) when name is missing', async () => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(
        pendingSession({ bookingId: 'booking-2' }),
      );
      prisma.faydaVerificationSession.update.mockResolvedValue({});
      prisma.bookingSeat.updateMany.mockResolvedValue({ count: 1 });

      mockFetchSequence(
        { json: async () => ({ access_token: 'tok', token_type: 'Bearer' }) },
        {
          headers: new Headers({ 'content-type': 'application/json' }),
          text: async () =>
            JSON.stringify({
              sub: 'fayda-sub-4',
              'name#en': 'English Name',
              'name#am': 'Amharic Name',
            }),
        },
      );

      const result = await service.completeVerification({
        code: 'c',
        state: 'state-abc',
      });
      expect(result.fullName).toBe('English Name');
      expect(prisma.bookingSeat.updateMany.mock.calls[0][0].data.faydaVerifiedName).toBe(
        'English Name',
      );
    });
  });

  describe('completeVerification — LOGIN', () => {
    function loginSession(overrides: Partial<any> = {}) {
      return {
        id: 'login-session',
        state: 'state-login',
        codeVerifier: 'verifier-xyz',
        purpose: 'LOGIN',
        platform: 'WEB',
        saveToAccount: false,
        status: 'PENDING',
        userId: null,
        bookingId: null,
        expiresAt: new Date(Date.now() + 60_000),
        ...overrides,
      };
    }

    function mockLoginFetch(userInfo: Record<string, unknown>) {
      const queue = [
        {
          ok: true,
          status: 200,
          json: async () => ({ access_token: 'tok', token_type: 'Bearer' }),
          text: async () => '',
          headers: new Headers({ 'content-type': 'application/json' }),
        },
        {
          ok: true,
          status: 200,
          json: async () => ({}),
          text: async () => JSON.stringify(userInfo),
          headers: new Headers({ 'content-type': 'application/json' }),
        },
      ];
      (global as any).fetch = jest.fn(() => Promise.resolve(queue.shift()));
    }

    /** user.findUnique answers the faydaSub lookup and the issueLoginToken id lookup. */
    function mockUserFindUnique(bySub: any, fullUser: any) {
      prisma.user.findUnique.mockImplementation(async (args: any) => {
        if (args?.where?.faydaSub !== undefined) return bySub;
        if (args?.where?.id !== undefined) return fullUser;
        return null;
      });
    }

    beforeEach(() => {
      prisma.faydaVerificationSession.findUnique.mockResolvedValue(loginSession());
    });

    it('creates a new user when no match and returns { token, user }', async () => {
      const fullUser = {
        id: 'new-user',
        email: 'new@example.com',
        role: 'PASSENGER',
        passenger: { id: 'p-new' },
        agent: null,
      };
      mockUserFindUnique(null, fullUser);
      prisma.user.findFirst.mockResolvedValue(null);
      prisma.user.create.mockResolvedValue({ id: 'new-user' });
      prisma.passenger.create.mockResolvedValue({ id: 'p-new' });
      prisma.loyaltyAccount.create.mockResolvedValue({});
      prisma.walletAccount.create.mockResolvedValue({});
      prisma.userPreferences.create.mockResolvedValue({});
      prisma.faydaVerificationSession.update.mockResolvedValue({});

      mockLoginFetch({ sub: 'login-sub-1', name: 'New Person', email: 'new@example.com' });

      const result = await service.completeVerification({
        code: 'c',
        state: 'state-login',
      });

      expect(result).toMatchObject({
        purpose: 'LOGIN',
        verified: true,
        token: 'signed.jwt.token',
        user: { id: 'new-user', passengerId: 'p-new' },
      });
      expect(prisma.user.create).toHaveBeenCalledWith(
        expect.objectContaining({
          data: expect.objectContaining({
            faydaSub: 'login-sub-1',
            faydaVerified: true,
            email: 'new@example.com',
          }),
        }),
      );
      expect(prisma.passenger.create).toHaveBeenCalled();
      expect(jwt.sign).toHaveBeenCalledWith(
        expect.objectContaining({ sub: 'new-user', passengerId: 'p-new' }),
      );
    });

    it('logs in an existing user already linked by faydaSub', async () => {
      const fullUser = {
        id: 'known-user',
        email: 'k@example.com',
        role: 'PASSENGER',
        passenger: { id: 'p-k' },
        agent: null,
      };
      mockUserFindUnique({ id: 'known-user' }, fullUser);
      prisma.faydaVerificationSession.update.mockResolvedValue({});

      mockLoginFetch({ sub: 'login-sub-2', name: 'Known' });

      const result = await service.completeVerification({
        code: 'c',
        state: 'state-login',
      });

      expect(result.user?.id).toBe('known-user');
      expect(prisma.user.create).not.toHaveBeenCalled();
    });

    it('links Fayda to an existing account matched by email', async () => {
      const fullUser = {
        id: 'acc-1',
        email: 'match@example.com',
        role: 'PASSENGER',
        passenger: { id: 'p-1' },
        agent: null,
      };
      mockUserFindUnique(null, fullUser);
      prisma.user.findFirst.mockResolvedValue({ id: 'acc-1', faydaSub: null });
      prisma.user.update.mockResolvedValue({});
      prisma.faydaVerificationSession.update.mockResolvedValue({});

      mockLoginFetch({ sub: 'login-sub-3', email: 'match@example.com' });

      const result = await service.completeVerification({
        code: 'c',
        state: 'state-login',
      });

      expect(result.user?.id).toBe('acc-1');
      expect(prisma.user.update).toHaveBeenCalledWith(
        expect.objectContaining({
          where: { id: 'acc-1' },
          data: expect.objectContaining({ faydaSub: 'login-sub-3' }),
        }),
      );
      expect(prisma.user.create).not.toHaveBeenCalled();
    });

    it('throws identity_conflict (409) when matched account has a different faydaSub', async () => {
      mockUserFindUnique(null, null);
      prisma.user.findFirst.mockResolvedValue({ id: 'acc-2', faydaSub: 'someone-else' });
      prisma.faydaVerificationSession.updateMany.mockResolvedValue({ count: 1 });

      mockLoginFetch({ sub: 'login-sub-4', email: 'match@example.com' });

      await expect(
        service.completeVerification({ code: 'c', state: 'state-login' }),
      ).rejects.toMatchObject({ status: 409 });
      expect(prisma.user.update).not.toHaveBeenCalled();
      expect(prisma.user.create).not.toHaveBeenCalled();
    });
  });

  describe('getVerificationStatus', () => {
    it('returns verified=true when User row has the flag', async () => {
      prisma.user.findUnique.mockResolvedValue({
        faydaVerified: true,
        faydaVerifiedAt: new Date('2026-01-01T00:00:00Z'),
        fullName: 'Test User',
      });
      const result = await service.getVerificationStatus('user-1');
      expect(result).toEqual({
        verified: true,
        verifiedAt: new Date('2026-01-01T00:00:00Z'),
        fullName: 'Test User',
      });
    });

    it('returns verified=false when User row is missing or unverified', async () => {
      prisma.user.findUnique.mockResolvedValue(null);
      const result = await service.getVerificationStatus('user-x');
      expect(result).toEqual({ verified: false });
    });
  });
});
