import { OAuthSequelizeAccessTokenRepository } from '@app/o-auth/access-token';
import { OAuthCreateAccessTokenService } from '@app/o-auth/access-token/application/create/o-auth-create-access-token.service';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { OAuthCreateCredentialService } from '@app/o-auth/credential/application/create/o-auth-create-credential.service';
import { OAuthSequelizeRefreshTokenRepository } from '@app/o-auth/refresh-token';
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 {
  InvalidClientError,
  InvalidGrantError,
  UnauthorizedClientError,
} from '../../../domain/errors';
import type { OAuthCreateTokenRefreshInput } from '../../../domain/flows.types';
import { OAuthCreateTokenRefreshService } from './o-auth-create-token-refresh.service';

const ACCOUNT_ID = 'acc-1111-1111-1111-111111111111';
const PASSWORD_CLIENT_ID = 'cli-1111-1111-1111-111111111111';
const OTHER_APP_CLIENT_ID = 'cli-9999-9999-9999-999999999999';
const APPLICATION_CODE = 'aurora';
const APPLICATION_SECRET = 'top-secret-12345';
const ACCESS_TOKEN_ID = 'tok-1111-1111-1111-111111111111';
const REFRESH_TOKEN_ID = 'ref-1111-1111-1111-111111111111';

/** Application that only has a PASSWORD client — the production scenario that triggered the bug. */
const buildApplicationPasswordOnly = (overrides: Record<string, unknown> = {}) => ({
  id: 'app-1111-1111-1111-111111111111',
  code: APPLICATION_CODE,
  secret: APPLICATION_SECRET,
  clients: [{ id: PASSWORD_CLIENT_ID, grantType: 'PASSWORD' }],
  ...overrides,
});

const buildInput = (
  overrides: Partial<OAuthCreateTokenRefreshInput> = {},
): OAuthCreateTokenRefreshInput => ({
  clientId: APPLICATION_CODE,
  clientSecret: APPLICATION_SECRET,
  refreshToken: 'opaque-refresh-token-value',
  ipAddress: '127.0.0.1',
  userAgent: 'curl/8',
  ...overrides,
});

const futureDate = (): Date => new Date(Date.now() + 30 * 24 * 3_600_000);
const pastDate = (): Date => new Date(Date.now() - 1000);

describe('OAuthCreateTokenRefreshService', () => {
  let handler: OAuthCreateTokenRefreshService;
  let accountLoader: { loadById: jest.Mock };
  let jwtSigner: { signAccess: jest.Mock; signRefresh: jest.Mock };
  let findApplication: { main: jest.Mock };
  let refreshTokenRepo: { repository: { findOne: jest.Mock; update: jest.Mock } };
  let accessTokenRepo: { repository: { findByPk: jest.Mock; update: jest.Mock } };
  let createAccessToken: { main: jest.Mock };
  let createRefreshToken: { main: jest.Mock };
  let createCredential: { main: jest.Mock };

  beforeEach(async () => {
    accountLoader = { loadById: jest.fn().mockResolvedValue({ id: ACCOUNT_ID }) };
    jwtSigner = {
      signAccess: jest.fn().mockResolvedValue({
        token: 'new.access.jwt',
        expiresAt: new Date(Date.now() + 3_600_000),
      }),
      signRefresh: jest.fn().mockResolvedValue({
        token: 'new-opaque-refresh-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
        expiresAt: new Date(Date.now() + 2_592_000_000),
      }),
    };
    // Default: app has only a PASSWORD client — the production scenario.
    findApplication = { main: jest.fn().mockResolvedValue(buildApplicationPasswordOnly()) };
    refreshTokenRepo = {
      repository: {
        findOne: jest.fn().mockResolvedValue({
          id: REFRESH_TOKEN_ID,
          accessTokenId: ACCESS_TOKEN_ID,
          isRevoked: false,
          expiresAt: futureDate(),
        }),
        update: jest.fn().mockResolvedValue(undefined),
      },
    };
    // access row carries the issuing client id (PASSWORD_CLIENT_ID — same app).
    accessTokenRepo = {
      repository: {
        findByPk: jest.fn().mockResolvedValue({
          id: ACCESS_TOKEN_ID,
          clientId: PASSWORD_CLIENT_ID,
          accountId: ACCOUNT_ID,
        }),
        update: jest.fn().mockResolvedValue(undefined),
      },
    };
    createAccessToken = { main: jest.fn().mockResolvedValue(undefined) };
    createRefreshToken = { main: jest.fn().mockResolvedValue(undefined) };
    createCredential = { main: jest.fn().mockResolvedValue(undefined) };

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

    handler = module.get(OAuthCreateTokenRefreshService);
  });

  describe('golden path — app has only a PASSWORD client (production scenario, RFC §6 regression)', () => {
    /**
     * REGRESSION TEST: with the OLD code, this throws UnauthorizedClientError because the app
     * has no REFRESH_TOKEN-typed client. RFC 6749 §6 says the refresh token is bound to the
     * issuing client — we reuse that client, NOT look for a REFRESH_TOKEN-typed one.
     */
    it('succeeds when the app has only a PASSWORD client and the access row was issued by that client', async () => {
      const result = await handler.main(buildInput());

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

    it('issues new tokens with the ISSUING client id (not a REFRESH_TOKEN-typed client)', async () => {
      await handler.main(buildInput());

      // The new access token must carry the issuing client id from the access row,
      // NOT a separately resolved REFRESH_TOKEN client.
      expect(createAccessToken.main).toHaveBeenCalledWith(
        expect.objectContaining({ clientId: PASSWORD_CLIENT_ID, accountId: ACCOUNT_ID }),
      );
    });

    it('rotates: revokes old pair, issues new pair, persists credential with grantType=REFRESH_TOKEN', async () => {
      await handler.main(buildInput());

      expect(refreshTokenRepo.repository.update).toHaveBeenCalledWith(
        { isRevoked: true },
        { where: { id: REFRESH_TOKEN_ID } },
      );
      expect(accessTokenRepo.repository.update).toHaveBeenCalledWith(
        { isRevoked: true },
        { where: { id: ACCESS_TOKEN_ID } },
      );
      expect(createCredential.main).toHaveBeenCalledWith(
        expect.objectContaining({ grantType: 'REFRESH_TOKEN', accountId: ACCOUNT_ID }),
      );
    });

    it('truncates userAgent longer than 255 chars', async () => {
      const longUA = 'a'.repeat(300);
      await handler.main(buildInput({ userAgent: longUA }));

      const credentialPayload = createCredential.main.mock.calls[0][0] as {
        userAgent: string;
      };
      expect(credentialPayload.userAgent).toHaveLength(255);
    });
  });

  describe('invalid_client', () => {
    it('throws InvalidClientError when the application is not found', async () => {
      findApplication.main.mockRejectedValue(new Error('NotFound'));
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidClientError);
      expect(refreshTokenRepo.repository.findOne).not.toHaveBeenCalled();
    });

    it('throws InvalidClientError when the application secret mismatches', async () => {
      findApplication.main.mockResolvedValue(buildApplicationPasswordOnly({ secret: 'wrong' }));
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidClientError);
      expect(refreshTokenRepo.repository.findOne).not.toHaveBeenCalled();
    });
  });

  describe('unauthorized_client — binding violation (RFC 6749 §10.4)', () => {
    /**
     * The access row's clientId does NOT belong to the authenticated application.
     * This means someone submitted a refresh token issued by a different app — reject it.
     */
    it('throws UnauthorizedClientError when the issuing client does not belong to the authenticated app', async () => {
      // Access row was issued by a client from a DIFFERENT application.
      accessTokenRepo.repository.findByPk.mockResolvedValue({
        id: ACCESS_TOKEN_ID,
        clientId: OTHER_APP_CLIENT_ID, // not in buildApplicationPasswordOnly().clients
        accountId: ACCOUNT_ID,
      });

      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(UnauthorizedClientError);
      // Binding check must happen before rotation.
      expect(refreshTokenRepo.repository.update).not.toHaveBeenCalled();
      expect(accessTokenRepo.repository.update).not.toHaveBeenCalled();
    });
  });

  describe('invalid_grant', () => {
    it('throws when the refresh token does not exist', async () => {
      refreshTokenRepo.repository.findOne.mockResolvedValue(null);
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(refreshTokenRepo.repository.update).not.toHaveBeenCalled();
    });

    it('throws when the refresh token is revoked', async () => {
      refreshTokenRepo.repository.findOne.mockResolvedValue({
        id: REFRESH_TOKEN_ID,
        accessTokenId: ACCESS_TOKEN_ID,
        isRevoked: true,
        expiresAt: futureDate(),
      });
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      expect(refreshTokenRepo.repository.update).not.toHaveBeenCalled();
    });

    it('throws when the refresh token is expired', async () => {
      refreshTokenRepo.repository.findOne.mockResolvedValue({
        id: REFRESH_TOKEN_ID,
        accessTokenId: ACCESS_TOKEN_ID,
        isRevoked: false,
        expiresAt: pastDate(),
      });
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
    });

    it('throws when the linked access token row is missing', async () => {
      accessTokenRepo.repository.findByPk.mockResolvedValue(null);
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
    });

    it('throws when the account no longer loads (account deleted post-issuance)', async () => {
      accountLoader.loadById.mockResolvedValue(null);
      await expect(handler.main(buildInput())).rejects.toBeInstanceOf(InvalidGrantError);
      // Account loading is verified BEFORE revocation, so the old tokens stay untouched.
      // The user can re-attempt with the same refresh token if the account is restored.
      expect(refreshTokenRepo.repository.update).not.toHaveBeenCalled();
      expect(accessTokenRepo.repository.update).not.toHaveBeenCalled();
    });
  });
});
