// Break the ESM import chain that @Auth → @aurora/decorators → @aurora/modules/authentication
// → authentication.module → aurora-hub-consumer.adapter → jwks-rsa (ESM) would trigger.
// jwks-rsa is an ESM-only package that ts-jest cannot transform under CommonJS mode.
// See project memory: [Jest + @Auth ESM limitation].
// We mock the barrel so the decorator becomes a no-op in this test environment.
jest.mock('@aurora/decorators', () => ({
  Auth:
    (..._args: unknown[]) =>
    (_target: unknown, _key: unknown, descriptor: PropertyDescriptor) =>
      descriptor,
}));

import { HttpService } from '@nestjs/axios';
import { BadRequestException, HttpException, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import type { AxiosError } from 'axios';
import type { Response } from 'express';
import { of, throwError } from 'rxjs';

import { PkceVerifierStoreService } from '../pkce/pkce-verifier-store.service';
import { AuthController } from './auth.controller';

/**
 * Builds a minimal AxiosError-like object that resembles what Axios throws when the hub
 * returns a non-2xx response. The `config.headers.Authorization` field holds the encoded
 * Basic secret — the fix must ensure this never leaks through to NestJS error responses.
 */
function makeAxiosError(status: number, errorBody: Record<string, string>): AxiosError {
  const sensitiveConfig = {
    headers: { Authorization: 'Basic dGVzdC1zZWNyZXQ6dGVzdC1zZWNyZXQ=' },
  };
  const error = new Error('Request failed') as AxiosError;
  error.isAxiosError = true;
  // Cast through unknown to avoid fighting Axios's InternalAxiosRequestConfig internal types.
  error.config = sensitiveConfig as unknown as AxiosError['config'];
  error.response = {
    status,
    data: errorBody,
    statusText: String(status),
    headers: {},
    config: sensitiveConfig as unknown as AxiosError['config'],
  } as AxiosError['response'];
  return error;
}

/**
 * Unit tests for AuthController.
 *
 * The BFF calls the hub via HttpService — all hub calls are mocked.
 * The controller is PUBLIC (no @Auth on the login/token/refresh paths), so it is
 * unit-testable without the ESM-import limitation noted in project memory
 * [Jest + @Auth ESM limitation]. The @Auth-decorated endpoints (verify, me) are
 * covered by code-review + smoke as per that convention.
 *
 * Tasks covered:
 *   3.2 — login: PKCE redirect assertions
 *   4.1 — exchangeToken: RFC /token assertions
 *   4.3 — refreshToken: /token refresh grant
 *   Fix #2 — hub error mapping (clean HttpException, no secret/config leak)
 */
describe('AuthController', () => {
  let controller: AuthController;
  let verifierStore: PkceVerifierStoreService;

  const mockConfigService = {
    get: jest.fn((key: string, defaultValue?: string) => {
      const config: Record<string, string> = {
        OAUTH_HUB_SERVER_URL: 'http://hub:8080',
        // OAUTH_APPLICATION_CODE holds the application CODE (e.g. 'aurora'), not a UUID.
        // See design.md D2 and auth.controller.ts comment.
        OAUTH_APPLICATION_CODE: 'aurora',
        OAUTH_APPLICATION_SECRET: 'test-secret',
        OAUTH_REDIRECT_URI: 'http://localhost:4201/callback',
        APP_FRONTEND_URL: 'http://localhost:4201',
      };
      return config[key] ?? defaultValue ?? '';
    }),
  };

  const mockHttpService = {
    post: jest.fn(),
  };

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

    const moduleRef = await Test.createTestingModule({
      providers: [
        AuthController,
        { provide: ConfigService, useValue: mockConfigService },
        { provide: HttpService, useValue: mockHttpService },
        {
          provide: PkceVerifierStoreService,
          useValue: new PkceVerifierStoreService(),
        },
      ],
    }).compile();

    controller = moduleRef.get(AuthController);
    verifierStore = moduleRef.get(PkceVerifierStoreService);
  });

  // ---------------------------------------------------------------------------
  // Task 3.2 — GET /auth/login
  // ---------------------------------------------------------------------------
  describe('login', () => {
    let redirectSpy: jest.Mock;
    let fakeRes: Response;

    beforeEach(() => {
      redirectSpy = jest.fn();
      fakeRes = { redirect: redirectSpy } as unknown as Response;
    });

    it('redirects to the hub /authorize endpoint', () => {
      controller.login(fakeRes);

      expect(redirectSpy).toHaveBeenCalledTimes(1);
      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      expect(redirectUrl.origin + redirectUrl.pathname).toBe(
        'http://hub:8080/api/o-auth/authorize',
      );
    });

    it('redirect URL contains response_type=code', () => {
      controller.login(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      expect(redirectUrl.searchParams.get('response_type')).toBe('code');
    });

    it('redirect URL contains a non-empty state', () => {
      controller.login(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      const state = redirectUrl.searchParams.get('state');
      expect(state).toBeTruthy();
      expect(state!.length).toBeGreaterThan(0);
    });

    it('redirect URL contains code_challenge', () => {
      controller.login(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      expect(redirectUrl.searchParams.get('code_challenge')).toBeTruthy();
    });

    it('redirect URL contains code_challenge_method=S256', () => {
      controller.login(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      expect(redirectUrl.searchParams.get('code_challenge_method')).toBe('S256');
    });

    it('redirect URL contains client_id equal to the application code (not a UUID)', () => {
      controller.login(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      const clientId = redirectUrl.searchParams.get('client_id');
      expect(clientId).toBe('aurora');

      // Ensure it is NOT a UUID shape (application code, not OAuthClient UUID).
      const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
      expect(clientId).not.toMatch(uuidPattern);
    });

    it('redirect URL does NOT contain the code_verifier', () => {
      controller.login(fakeRes);

      const rawUrl = redirectSpy.mock.calls[0][0] as string;
      expect(rawUrl).not.toContain('code_verifier');
    });

    it('stashes the verifier in the store under the emitted state', () => {
      const putSpy = jest.spyOn(verifierStore, 'put');

      controller.login(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      const state = redirectUrl.searchParams.get('state')!;

      expect(putSpy).toHaveBeenCalledWith(state, expect.any(String));
    });

    it('each call produces a fresh state and code_challenge (randomness)', () => {
      const redirect1 = jest.fn();
      const redirect2 = jest.fn();
      const res1 = { redirect: redirect1 } as unknown as Response;
      const res2 = { redirect: redirect2 } as unknown as Response;

      controller.login(res1);
      controller.login(res2);

      const url1 = new URL(redirect1.mock.calls[0][0] as string);
      const url2 = new URL(redirect2.mock.calls[0][0] as string);

      expect(url1.searchParams.get('state')).not.toBe(url2.searchParams.get('state'));
      expect(url1.searchParams.get('code_challenge')).not.toBe(
        url2.searchParams.get('code_challenge'),
      );
    });
  });

  // ---------------------------------------------------------------------------
  // Task 4.1 — POST /auth/token (exchangeToken)
  // ---------------------------------------------------------------------------
  describe('exchangeToken', () => {
    const hubTokenResponse = {
      data: {
        access_token: 'at-hub',
        token_type: 'Bearer',
        expires_in: 3600,
        refresh_token: 'rt-hub',
      },
    };

    beforeEach(() => {
      // Pre-stash a verifier so exchange can proceed.
      verifierStore.put('valid-state', 'test-verifier-value');
      mockHttpService.post.mockReturnValue(of(hubTokenResponse));
    });

    it('posts to {hub}/api/o-auth/token (NOT /credentials)', async () => {
      await controller.exchangeToken({ code: 'auth-code-1', state: 'valid-state' });

      const postedUrl = mockHttpService.post.mock.calls[0][0] as string;
      expect(postedUrl).toBe('http://hub:8080/api/o-auth/token');
      expect(postedUrl).not.toContain('credentials');
    });

    it('sends Content-Type application/x-www-form-urlencoded', async () => {
      await controller.exchangeToken({ code: 'auth-code-1', state: 'valid-state' });

      const config = mockHttpService.post.mock.calls[0][2] as Record<string, unknown>;
      expect((config['headers'] as Record<string, string>)['Content-Type']).toBe(
        'application/x-www-form-urlencoded',
      );
    });

    it('body contains grant_type, code, redirect_uri, and code_verifier', async () => {
      await controller.exchangeToken({ code: 'auth-code-1', state: 'valid-state' });

      const rawBody = mockHttpService.post.mock.calls[0][1] as string;
      const params = new URLSearchParams(rawBody);

      expect(params.get('grant_type')).toBe('authorization_code');
      expect(params.get('code')).toBe('auth-code-1');
      expect(params.get('redirect_uri')).toBeTruthy();
      expect(params.get('code_verifier')).toBe('test-verifier-value');
    });

    it('sends Authorization: Basic header and does NOT put the secret in the body', async () => {
      await controller.exchangeToken({ code: 'auth-code-1', state: 'valid-state' });

      const config = mockHttpService.post.mock.calls[0][2] as Record<string, unknown>;
      const authHeader = (config['headers'] as Record<string, string>)['Authorization'];

      expect(authHeader).toMatch(/^Basic /);

      // Decode and verify shape: applicationCode:secret
      const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString();
      expect(decoded).toBe('aurora:test-secret');

      // Secret must NOT appear in the body.
      const rawBody = mockHttpService.post.mock.calls[0][1] as string;
      expect(rawBody).not.toContain('test-secret');
    });

    it('consumes the verifier from the store (single-use)', async () => {
      await controller.exchangeToken({ code: 'auth-code-1', state: 'valid-state' });

      // After exchange, the verifier entry must be gone.
      expect(verifierStore.take('valid-state')).toBeNull();
    });

    it('maps hub response to { accessToken, refreshToken } for the frontend', async () => {
      const result = await controller.exchangeToken({ code: 'auth-code-1', state: 'valid-state' });

      expect(result).toEqual({ accessToken: 'at-hub', refreshToken: 'rt-hub' });
    });

    it('rejects with BadRequestException when state is unknown', async () => {
      await expect(
        controller.exchangeToken({ code: 'auth-code-1', state: 'state-never-issued' }),
      ).rejects.toThrow(BadRequestException);

      // Hub should NOT have been called.
      expect(mockHttpService.post).not.toHaveBeenCalled();
    });

    it('rejects with BadRequestException when state has already been consumed', async () => {
      verifierStore.put('once-only', 'verifier');
      await controller.exchangeToken({ code: 'first', state: 'once-only' });
      jest.clearAllMocks();
      mockHttpService.post.mockReturnValue(of(hubTokenResponse));

      // Second exchange with the same state must fail without calling the hub.
      await expect(
        controller.exchangeToken({ code: 'second', state: 'once-only' }),
      ).rejects.toThrow(BadRequestException);

      expect(mockHttpService.post).not.toHaveBeenCalled();
    });

    // -------------------------------------------------------------------------
    // Fix #2 — hub error mapping
    // -------------------------------------------------------------------------

    it('throws a clean HttpException (not a raw AxiosError / 500) when hub returns 400 invalid_grant', async () => {
      verifierStore.put('state-for-error', 'verifier');
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(400, { error: 'invalid_grant' })),
      );

      const rejection = controller.exchangeToken({ code: 'bad-code', state: 'state-for-error' });

      await expect(rejection).rejects.toBeInstanceOf(BadRequestException);
    });

    it('throws UnauthorizedException when hub returns 401 invalid_client', async () => {
      verifierStore.put('state-for-401', 'verifier');
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(401, { error: 'invalid_client' })),
      );

      await expect(
        controller.exchangeToken({ code: 'bad-code', state: 'state-for-401' }),
      ).rejects.toBeInstanceOf(UnauthorizedException);
    });

    it('thrown error from exchangeToken does NOT expose axios config, headers, or the secret', async () => {
      verifierStore.put('state-secret-check', 'verifier');
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(400, { error: 'invalid_grant' })),
      );

      let caughtError: unknown;
      try {
        await controller.exchangeToken({ code: 'bad', state: 'state-secret-check' });
      } catch (err) {
        caughtError = err;
      }

      const serialized = JSON.stringify(caughtError);
      expect(serialized).not.toContain('config');
      expect(serialized).not.toContain('Authorization');
      expect(serialized).not.toContain('dGVzdC1zZWNyZXQ6dGVzdC1zZWNyZXQ='); // the encoded secret
    });
  });

  // ---------------------------------------------------------------------------
  // Task 4.3 — POST /auth/refresh (refreshToken)
  // ---------------------------------------------------------------------------
  describe('refreshToken', () => {
    const hubRefreshResponse = {
      data: {
        access_token: 'at-new',
        token_type: 'Bearer',
        expires_in: 3600,
        refresh_token: 'rt-new',
      },
    };

    beforeEach(() => {
      mockHttpService.post.mockReturnValue(of(hubRefreshResponse));
    });

    it('posts to {hub}/api/o-auth/token (NOT /credentials)', async () => {
      await controller.refreshToken({ refreshToken: 'my-refresh-token' });

      const postedUrl = mockHttpService.post.mock.calls[0][0] as string;
      expect(postedUrl).toBe('http://hub:8080/api/o-auth/token');
      expect(postedUrl).not.toContain('credentials');
    });

    it('sends form-urlencoded with grant_type=refresh_token and refresh_token param', async () => {
      await controller.refreshToken({ refreshToken: 'my-refresh-token' });

      const rawBody = mockHttpService.post.mock.calls[0][1] as string;
      const params = new URLSearchParams(rawBody);

      expect(params.get('grant_type')).toBe('refresh_token');
      expect(params.get('refresh_token')).toBe('my-refresh-token');
    });

    it('sends Authorization: Basic header', async () => {
      await controller.refreshToken({ refreshToken: 'my-refresh-token' });

      const config = mockHttpService.post.mock.calls[0][2] as Record<string, unknown>;
      const authHeader = (config['headers'] as Record<string, string>)['Authorization'];

      expect(authHeader).toMatch(/^Basic /);
    });

    it('maps hub response to { accessToken, refreshToken } for the frontend', async () => {
      const result = await controller.refreshToken({ refreshToken: 'my-refresh-token' });

      expect(result).toEqual({ accessToken: 'at-new', refreshToken: 'rt-new' });
    });

    // -------------------------------------------------------------------------
    // Fix #2 — hub error mapping (refreshToken)
    // -------------------------------------------------------------------------

    it('throws a clean HttpException (not a raw AxiosError / 500) when hub returns 400 invalid_grant on refresh', async () => {
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(400, { error: 'invalid_grant' })),
      );

      await expect(
        controller.refreshToken({ refreshToken: 'expired-token' }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('throws UnauthorizedException when hub returns 401 invalid_client on refresh', async () => {
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(401, { error: 'invalid_client' })),
      );

      await expect(
        controller.refreshToken({ refreshToken: 'bad-token' }),
      ).rejects.toBeInstanceOf(UnauthorizedException);
    });

    it('thrown error from refreshToken does NOT expose axios config, headers, or the secret', async () => {
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(400, { error: 'invalid_grant' })),
      );

      let caughtError: unknown;
      try {
        await controller.refreshToken({ refreshToken: 'expired-token' });
      } catch (err) {
        caughtError = err;
      }

      const serialized = JSON.stringify(caughtError);
      expect(serialized).not.toContain('config');
      expect(serialized).not.toContain('Authorization');
      expect(serialized).not.toContain('dGVzdC1zZWNyZXQ6dGVzdC1zZWNyZXQ=');
    });
  });

  // ---------------------------------------------------------------------------
  // Task 2.1 — POST /auth/logout (un-stub hub revocation)
  // ---------------------------------------------------------------------------
  describe('logout', () => {
    const hubRevokeUrl = 'http://hub:8080/api/o-auth/revoke';

    it('posts to {hub}/api/o-auth/revoke to revoke the refresh token', async () => {
      mockHttpService.post.mockReturnValue(of({ data: {} }));

      await controller.logout({ refreshToken: 'rt-to-revoke' });

      const postedUrl = mockHttpService.post.mock.calls[0][0] as string;
      expect(postedUrl).toBe(hubRevokeUrl);
    });

    it('sends Content-Type application/x-www-form-urlencoded for the revocation request', async () => {
      mockHttpService.post.mockReturnValue(of({ data: {} }));

      await controller.logout({ refreshToken: 'rt-to-revoke' });

      const config = mockHttpService.post.mock.calls[0][2] as Record<string, unknown>;
      expect((config['headers'] as Record<string, string>)['Content-Type']).toBe(
        'application/x-www-form-urlencoded',
      );
    });

    it('sends token and token_type_hint=refresh_token in the revocation body', async () => {
      mockHttpService.post.mockReturnValue(of({ data: {} }));

      await controller.logout({ refreshToken: 'my-refresh-token' });

      const rawBody = mockHttpService.post.mock.calls[0][1] as string;
      const params = new URLSearchParams(rawBody);

      expect(params.get('token')).toBe('my-refresh-token');
      expect(params.get('token_type_hint')).toBe('refresh_token');
    });

    it('sends Authorization: Basic header for hub client authentication', async () => {
      mockHttpService.post.mockReturnValue(of({ data: {} }));

      await controller.logout({ refreshToken: 'rt-to-revoke' });

      const config = mockHttpService.post.mock.calls[0][2] as Record<string, unknown>;
      const authHeader = (config['headers'] as Record<string, string>)['Authorization'];

      expect(authHeader).toMatch(/^Basic /);

      // The secret must be application-side only — decode and check shape.
      const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString();
      expect(decoded).toBe('aurora:test-secret');
    });

    it('does NOT expose the client secret in the revocation request body', async () => {
      mockHttpService.post.mockReturnValue(of({ data: {} }));

      await controller.logout({ refreshToken: 'rt-to-revoke' });

      const rawBody = mockHttpService.post.mock.calls[0][1] as string;
      expect(rawBody).not.toContain('test-secret');
    });

    it('returns { ok: true } when revocation succeeds', async () => {
      mockHttpService.post.mockReturnValue(of({ data: {} }));

      const result = await controller.logout({ refreshToken: 'rt-to-revoke' });

      expect(result).toEqual({ ok: true });
    });

    it('still returns { ok: true } (best-effort) when the hub revocation fails (network error)', async () => {
      // D6: a hub-side failure MUST NOT prevent the logout from completing.
      mockHttpService.post.mockReturnValue(throwError(() => new Error('Network error')));

      const result = await controller.logout({ refreshToken: 'rt-to-revoke' });

      expect(result).toEqual({ ok: true });
    });

    it('still returns { ok: true } (best-effort) when the hub returns a non-2xx error', async () => {
      mockHttpService.post.mockReturnValue(
        throwError(() => makeAxiosError(400, { error: 'invalid_token' })),
      );

      const result = await controller.logout({ refreshToken: 'rt-to-revoke' });

      expect(result).toEqual({ ok: true });
    });

    it('still returns { ok: true } when no refresh token is provided (no-op revocation)', async () => {
      // When the caller does not have a refresh token, there is nothing to revoke.
      // The logout should still complete successfully.
      const result = await controller.logout({});

      // Hub should NOT have been called — nothing to revoke.
      expect(mockHttpService.post).not.toHaveBeenCalled();
      expect(result).toEqual({ ok: true });
    });
  });

  // ---------------------------------------------------------------------------
  // Task 5.1 — GET /auth/logout (BFF navigable redirect to hub, D8)
  // ---------------------------------------------------------------------------
  describe('logoutRedirect (GET /auth/logout)', () => {
    let redirectSpy: jest.Mock;
    let fakeRes: Response;

    beforeEach(() => {
      redirectSpy = jest.fn();
      fakeRes = { redirect: redirectSpy } as unknown as Response;
    });

    it('issues a 302 redirect (calls res.redirect exactly once)', () => {
      controller.logoutRedirect(fakeRes);

      expect(redirectSpy).toHaveBeenCalledTimes(1);
    });

    it('redirect target origin+path is {hubApiUrl}/api/o-auth/logout', () => {
      controller.logoutRedirect(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      expect(redirectUrl.origin + redirectUrl.pathname).toBe(
        'http://hub:8080/api/o-auth/logout',
      );
    });

    it('redirect URL contains client_id equal to the application code', () => {
      controller.logoutRedirect(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      expect(redirectUrl.searchParams.get('client_id')).toBe('aurora');
    });

    it('redirect URL contains post_logout_redirect_uri pointing at the satellite /signed-out page', () => {
      controller.logoutRedirect(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      const postLogoutUri = redirectUrl.searchParams.get('post_logout_redirect_uri');
      expect(postLogoutUri).toBeTruthy();
      expect(postLogoutUri).toContain('/signed-out');
      // Must be an absolute URL (hub validates it against the registered allowlist).
      expect(postLogoutUri).toMatch(/^https?:\/\//);
    });

    it('redirect URL contains a non-empty state', () => {
      controller.logoutRedirect(fakeRes);

      const redirectUrl = new URL(redirectSpy.mock.calls[0][0] as string);
      const state = redirectUrl.searchParams.get('state');
      expect(state).toBeTruthy();
      expect(state!.length).toBeGreaterThan(0);
    });

    it('each call produces a fresh state (random, not static)', () => {
      const redirect1 = jest.fn();
      const redirect2 = jest.fn();
      const res1 = { redirect: redirect1 } as unknown as Response;
      const res2 = { redirect: redirect2 } as unknown as Response;

      controller.logoutRedirect(res1);
      controller.logoutRedirect(res2);

      const url1 = new URL(redirect1.mock.calls[0][0] as string);
      const url2 = new URL(redirect2.mock.calls[0][0] as string);

      expect(url1.searchParams.get('state')).not.toBe(url2.searchParams.get('state'));
    });

    it('hub URL (hubApiUrl) is NOT returned to the browser — only the redirect target is sent', () => {
      // The hub URL must appear only as the redirect destination, not as a separate field.
      // Verifying that the raw redirect value is a single URL starting with the hub origin.
      controller.logoutRedirect(fakeRes);

      const rawRedirect = redirectSpy.mock.calls[0][0] as string;
      const redirectUrl = new URL(rawRedirect);
      // The hub origin is present only as the target of the redirect, not exposed elsewhere.
      expect(redirectUrl.hostname).toBe('hub');
    });
  });
});
