import { OAuthClientGrantTypeEnum } from '@api/graphql';
import { OAuthCreateAccessTokenService } from '@app/o-auth/access-token/application/create/o-auth-create-access-token.service';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { OAuthSequelizeAuthorizationCodeRepository } from '@app/o-auth/authorization-code';
import { OAuthCreateCredentialService } from '@app/o-auth/credential/application/create/o-auth-create-credential.service';
import { OAuthCreateRefreshTokenService } from '@app/o-auth/refresh-token/application/create/o-auth-create-refresh-token.service';
import {
  ACCOUNT_LOADER,
  JWT_SIGNER,
} from '@aurora/modules/authentication/domain/ports';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash } from 'node:crypto';
import { InvalidGrantError } from '../../../domain/errors';
import type { OAuthCreateTokenAuthorizationCodeInput } from '../../../domain/flows.types';
import { OAuthCreateTokenAuthorizationCodeService } from './o-auth-create-token-authorization-code.service';

// ────────────────────────────────────────────────────────────────────────────────
// Test fixtures
// ────────────────────────────────────────────────────────────────────────────────

const ACCOUNT_ID = 'acc-2222-2222-2222-222222222222';
const CLIENT_ID = 'cli-2222-2222-2222-222222222222';
const AUTH_CODE_ID = 'cod-2222-2222-2222-222222222222';
const APPLICATION_CODE = 'aurora-hub';
const APPLICATION_SECRET = 'hub-secret-xyz';
const REDIRECT_URI = 'https://satellite.example.com/callback';
const CODE_VALUE = 'dGVzdC1jb2RlLXZhbHVl';

const PKCE_VERIFIER = 'aurora-pkce-verifier-test-1234567890abcdefghij';
const PKCE_CHALLENGE = createHash('sha256').update(PKCE_VERIFIER).digest('base64url');

const buildApplication = (overrides: Record<string, unknown> = {}) => ({
  id: 'app-2222-2222-2222-222222222222',
  code: APPLICATION_CODE,
  secret: APPLICATION_SECRET,
  clients: [{ id: CLIENT_ID, grantType: OAuthClientGrantTypeEnum.AUTHORIZATION_CODE }],
  ...overrides,
});

const buildAuthCode = (overrides: Record<string, unknown> = {}) => ({
  id: AUTH_CODE_ID,
  code: CODE_VALUE,
  clientId: CLIENT_ID,
  accountId: ACCOUNT_ID,
  redirectUri: REDIRECT_URI,
  scopes: null,
  codeChallenge: PKCE_CHALLENGE,
  codeChallengeMethod: 'S256',
  expiresAt: new Date(Date.now() + 60_000).toISOString(), // valid: 60s from now
  isUsed: false,
  ...overrides,
});

const buildInput = (
  overrides: Partial<OAuthCreateTokenAuthorizationCodeInput> = {},
): OAuthCreateTokenAuthorizationCodeInput => ({
  code: CODE_VALUE,
  clientId: APPLICATION_CODE,
  clientSecret: APPLICATION_SECRET,
  redirectUri: REDIRECT_URI,
  codeVerifier: PKCE_VERIFIER,
  ipAddress: '10.0.0.1',
  userAgent: 'satellite-bff/1.0',
  ...overrides,
});

// ────────────────────────────────────────────────────────────────────────────────
// Test suite
// ────────────────────────────────────────────────────────────────────────────────

describe('OAuthCreateTokenAuthorizationCodeService', () => {
  let service: OAuthCreateTokenAuthorizationCodeService;
  let accountLoader: { loadById: jest.Mock };
  let jwtSigner: { signAccess: jest.Mock; signRefresh: jest.Mock };
  let findApplication: { main: jest.Mock };
  let createAccessToken: { main: jest.Mock };
  let createRefreshToken: { main: jest.Mock };
  let createCredential: { main: jest.Mock };
  let authCodeRepo: { consumeCode: jest.Mock; repository: { findOne: jest.Mock } };

  beforeEach(async () => {
    accountLoader = { loadById: jest.fn().mockResolvedValue({ id: ACCOUNT_ID }) };
    jwtSigner = {
      signAccess: jest.fn().mockResolvedValue({
        token: 'access.jwt.token',
        expiresAt: new Date(Date.now() + 3600_000),
      }),
      signRefresh: jest.fn().mockResolvedValue({
        token: 'opaque-refresh-token-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
        expiresAt: new Date(Date.now() + 2_592_000_000),
      }),
    };
    findApplication = { main: jest.fn().mockResolvedValue(buildApplication()) };
    createAccessToken = { main: jest.fn().mockResolvedValue(undefined) };
    createRefreshToken = { main: jest.fn().mockResolvedValue(undefined) };
    createCredential = { main: jest.fn().mockResolvedValue(undefined) };
    authCodeRepo = {
      consumeCode: jest.fn().mockResolvedValue(true), // default: successful atomic consume
      repository: {
        findOne: jest.fn().mockResolvedValue(buildAuthCode()),
      },
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OAuthCreateTokenAuthorizationCodeService,
        { provide: ACCOUNT_LOADER, useValue: accountLoader },
        { provide: JWT_SIGNER, useValue: jwtSigner },
        { provide: OAuthFindApplicationService, useValue: findApplication },
        { provide: OAuthCreateAccessTokenService, useValue: createAccessToken },
        { provide: OAuthCreateRefreshTokenService, useValue: createRefreshToken },
        { provide: OAuthCreateCredentialService, useValue: createCredential },
        { provide: OAuthSequelizeAuthorizationCodeRepository, useValue: authCodeRepo },
      ],
    }).compile();

    service = module.get(OAuthCreateTokenAuthorizationCodeService);
  });

  // ────────────────────────────────────────────────────────────────────────────
  // Golden path
  // ────────────────────────────────────────────────────────────────────────────

  describe('golden path', () => {
    it('returns the RFC 6749 §5.1 response shape on success', async () => {
      const result = await service.main(buildInput());

      expect(result).toEqual({
        access_token: 'access.jwt.token',
        token_type: 'Bearer',
        expires_in: expect.any(Number),
        refresh_token: expect.stringMatching(/^opaque-refresh/),
      });
      expect(result.expires_in).toBeGreaterThan(3500);
      expect(result.expires_in).toBeLessThanOrEqual(3600);
    });

    it('persists access token, refresh token, and credential with AUTHORIZATION_CODE grant', async () => {
      await service.main(buildInput());

      expect(createAccessToken.main).toHaveBeenCalledTimes(1);
      expect(createRefreshToken.main).toHaveBeenCalledTimes(1);
      expect(createCredential.main).toHaveBeenCalledWith(
        expect.objectContaining({
          grantType: 'AUTHORIZATION_CODE',
          accountId: ACCOUNT_ID,
        }),
      );
    });

    it('calls consumeCode atomically before issuing tokens', async () => {
      await service.main(buildInput());

      // consumeCode must be called before signAccess (atomic consume happens first)
      const consumeCallOrder = authCodeRepo.consumeCode.mock.invocationCallOrder[0];
      const signAccessCallOrder = jwtSigner.signAccess.mock.invocationCallOrder[0];
      expect(consumeCallOrder).toBeLessThan(signAccessCallOrder);
    });

    it('truncates ipAddress to 45 chars before persisting the credential', async () => {
      // OAuthCredential.ipAddress has maxLength 45. Once trust-proxy / X-Forwarded-For is
      // enabled, IPv6 + multiple hops can exceed that. The service must truncate defensively.
      const longIp = 'a'.repeat(60); // 60 chars > maxLength 45
      await service.main(buildInput({ ipAddress: longIp }));

      const [credentialPayload] = createCredential.main.mock.calls[0];
      expect(credentialPayload.ipAddress).toBe('a'.repeat(45));
    });

    it('persists null ipAddress as null (no truncation applied to null)', async () => {
      await service.main(buildInput({ ipAddress: null }));

      const [credentialPayload] = createCredential.main.mock.calls[0];
      expect(credentialPayload.ipAddress).toBeNull();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — code reuse (atomic update returns false: 0 rows affected)
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — reused code', () => {
    it('throws InvalidGrantError when consumeCode returns false (code already used)', async () => {
      authCodeRepo.consumeCode.mockResolvedValue(false);

      await expect(service.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(jwtSigner.signAccess).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — code not found
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — code not found', () => {
    it('throws InvalidGrantError when the authorization code does not exist', async () => {
      authCodeRepo.repository.findOne.mockResolvedValue(null);

      await expect(service.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(jwtSigner.signAccess).not.toHaveBeenCalled();
      expect(authCodeRepo.consumeCode).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — expired code
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — expired code', () => {
    it('throws InvalidGrantError when the code expiresAt is in the past', async () => {
      authCodeRepo.repository.findOne.mockResolvedValue(
        buildAuthCode({ expiresAt: new Date(Date.now() - 1000).toISOString() }),
      );

      await expect(service.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(jwtSigner.signAccess).not.toHaveBeenCalled();
      expect(authCodeRepo.consumeCode).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — clientId mismatch
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — clientId mismatch', () => {
    it('throws InvalidGrantError when the code was issued for a different client', async () => {
      authCodeRepo.repository.findOne.mockResolvedValue(
        buildAuthCode({ clientId: 'cli-different-0000-0000-000000000000' }),
      );

      await expect(service.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(jwtSigner.signAccess).not.toHaveBeenCalled();
      expect(authCodeRepo.consumeCode).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — redirect_uri mismatch
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — redirect_uri mismatch', () => {
    it('throws InvalidGrantError when redirect_uri does not exactly match the stored value', async () => {
      await expect(
        service.main(buildInput({ redirectUri: 'https://evil.example.com/callback' })),
      ).rejects.toBeInstanceOf(InvalidGrantError);

      expect(jwtSigner.signAccess).not.toHaveBeenCalled();
      expect(authCodeRepo.consumeCode).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — PKCE S256 mismatch (RFC 7636 §4.6: token endpoint returns invalid_grant)
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — PKCE S256 mismatch', () => {
    it('throws InvalidGrantError when code_verifier does not hash to code_challenge', async () => {
      await expect(
        service.main(buildInput({ codeVerifier: 'wrong-verifier-xxxxxxxxxxxxxxxxxxxxxxxxxxx' })),
      ).rejects.toBeInstanceOf(InvalidGrantError);

      expect(jwtSigner.signAccess).not.toHaveBeenCalled();
      expect(authCodeRepo.consumeCode).not.toHaveBeenCalled();
    });
  });

  // ────────────────────────────────────────────────────────────────────────────
  // invalid_grant — account load failure
  // ────────────────────────────────────────────────────────────────────────────

  describe('invalid_grant — account load failure', () => {
    it('throws InvalidGrantError when accountLoader returns null', async () => {
      accountLoader.loadById.mockResolvedValue(null);

      await expect(service.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(createAccessToken.main).not.toHaveBeenCalled();
      // The code IS burned before account load — consume precedes load in the happy path
      // and this test locks that the code is consumed even when account load fails.
      expect(authCodeRepo.consumeCode).toHaveBeenCalledTimes(1);
    });
  });
});
