import { OAuthCreateAuthorizationCodeService } from '@app/o-auth/authorization-code';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { oAuthResolveApplicationClientWithoutSecret } from '@app/o-auth/shared';
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { InvalidRequestError } from '../../domain/errors';
import type { OAuthAuthorizeInput, OAuthAuthorizeCodeResult } from '../../domain/flows.types';
import { OAuthAuthorizeHandler } from './o-auth-authorize.handler';

// NOTE: the real oAuthResolveApplicationClientWithoutSecret throws InvalidRequestError (400) —
// NOT InvalidClientError (401) — for an unknown client_id at /authorize. The regression
// assertion for that specific behavior lives in o-auth-resolve-application-client.function.spec.ts
// (the un-masked, function-level test). This file tests the handler's response to whatever
// the resolver throws; it uses InvalidRequestError here to stay consistent with the real behavior.

// Mock the shared module — only the function used by OAuthAuthorizeHandler.
jest.mock('@app/o-auth/shared', () => ({
  oAuthResolveApplicationClientWithoutSecret: jest.fn(),
}));

const mockResolveClientWithoutSecret = oAuthResolveApplicationClientWithoutSecret as jest.Mock;

const STUB_CLIENT = {
  id: 'client-uuid-1',
  redirect: 'https://satellite.example.com/callback',
};

const BASE_INPUT: OAuthAuthorizeInput = {
  clientId: 'aurora-app',
  redirectUri: 'https://satellite.example.com/callback',
  responseType: 'code',
  state: 'xyz-state-opaque',
  codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
  codeChallengeMethod: 'S256',
  accountId: 'account-uuid-1',
  authorizeUrl: '/api/o-auth/authorize?client_id=aurora-app&response_type=code&state=xyz-state-opaque&redirect_uri=https%3A%2F%2Fsatellite.example.com%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256',
};

describe('OAuthAuthorizeHandler', () => {
  let handler: OAuthAuthorizeHandler;
  let createAuthCodeService: { main: jest.Mock };
  let findApplicationService: { main: jest.Mock };

  const buildModule = async (): Promise<void> => {
    createAuthCodeService = { main: jest.fn().mockResolvedValue(undefined) };
    findApplicationService = { main: jest.fn() };

    // Default: client resolves and redirect matches.
    mockResolveClientWithoutSecret.mockResolvedValue(STUB_CLIENT);

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        OAuthAuthorizeHandler,
        { provide: OAuthCreateAuthorizationCodeService, useValue: createAuthCodeService },
        { provide: OAuthFindApplicationService, useValue: findApplicationService },
        {
          provide: ConfigService,
          useValue: {
            get: jest.fn((key: string, defaultValue?: unknown) => {
              const map: Record<string, unknown> = {
                OAUTH_SIGN_IN_FRONTEND_URL: 'http://localhost:4200',
                OAUTH_AUTHORIZATION_CODE_TTL: '60',
              };
              return map[key] ?? defaultValue;
            }),
          },
        },
      ],
    }).compile();

    handler = module.get(OAuthAuthorizeHandler);
  };

  beforeEach(async () => {
    jest.clearAllMocks();
    await buildModule();
  });

  describe('no-secret resolver delegation', () => {
    it('delegates client resolution to oAuthResolveApplicationClientWithoutSecret (not the with-secret variant)', async () => {
      // This test proves the handler calls oAuthResolveApplicationClientWithoutSecret.
      // The real no-secret regression assertion (verifying a non-empty secret does NOT
      // block /authorize) lives in o-auth-resolve-application-client.function.spec.ts.
      const result = await handler.main(BASE_INPUT);

      expect(mockResolveClientWithoutSecret).toHaveBeenCalledWith(
        findApplicationService,
        expect.objectContaining({ clientId: 'aurora-app' }),
      );
      expect(result.kind).toBe('code');
    });
  });

  describe('valid session path', () => {
    it('returns kind=code with a redirect URL containing code + state when session is present', async () => {
      const result = await handler.main(BASE_INPUT);

      expect(result.kind).toBe('code');
      const redirectUrl = (result as OAuthAuthorizeCodeResult).redirectUrl;
      const parsed = new URL(redirectUrl);
      expect(parsed.searchParams.get('state')).toBe('xyz-state-opaque');
      expect(parsed.searchParams.get('code')).toBeTruthy();
      expect(redirectUrl.startsWith('https://satellite.example.com/callback')).toBe(true);
    });

    it('persists the authorization code with correct bindings and codeChallenge', async () => {
      const result = await handler.main(BASE_INPUT);

      expect(createAuthCodeService.main).toHaveBeenCalledTimes(1);
      const [persisted] = createAuthCodeService.main.mock.calls[0];
      expect(persisted.clientId).toBe(STUB_CLIENT.id);
      expect(persisted.accountId).toBe('account-uuid-1');
      expect(persisted.redirectUri).toBe('https://satellite.example.com/callback');
      expect(persisted.codeChallenge).toBe(BASE_INPUT.codeChallenge);
      expect(persisted.codeChallengeMethod).toBe('S256');
      expect(persisted.isUsed).toBe(false);
      expect(typeof persisted.code).toBe('string');
      expect(persisted.code.length).toBeGreaterThan(0);
      // TTL: expiresAt must be in the future
      const expiresAt = new Date(persisted.expiresAt);
      expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
      expect(result.kind).toBe('code');
    });
  });

  describe('no session path', () => {
    it('returns kind=redirect with signInUrl containing continue param when accountId is null', async () => {
      const result = await handler.main({ ...BASE_INPUT, accountId: null });

      expect(result.kind).toBe('redirect');
      if (result.kind === 'redirect') {
        expect(result.signInUrl).toContain('http://localhost:4200/auth/sign-in');
        expect(result.signInUrl).toContain('continue=');
      }
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });
  });

  describe('response_type validation', () => {
    it('throws BadRequestException when response_type is not "code"', async () => {
      await expect(
        handler.main({ ...BASE_INPUT, responseType: 'token' }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });
  });

  describe('client validation', () => {
    it('throws BadRequestException (HTTP 400, not 401) when client is unknown — resolver throws InvalidRequestError', async () => {
      // The real resolver throws InvalidRequestError (400) for an unknown client_id at /authorize.
      // See the un-masked regression assertion in o-auth-resolve-application-client.function.spec.ts.
      // The handler propagates the error as-is; the test verifies it is a 400-class exception.
      mockResolveClientWithoutSecret.mockRejectedValue(new InvalidRequestError());

      const rejection = handler.main(BASE_INPUT);

      await expect(rejection).rejects.toBeInstanceOf(InvalidRequestError);
      await expect(rejection).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('throws BadRequestException (no redirect) when redirect_uri does not match client', async () => {
      mockResolveClientWithoutSecret.mockResolvedValue({
        ...STUB_CLIENT,
        redirect: 'https://other.example.com/callback',
      });

      await expect(
        handler.main({ ...BASE_INPUT, redirectUri: 'https://satellite.example.com/callback' }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('throws BadRequestException when client.redirect is blank/null (misconfigured client)', async () => {
      // A misconfigured client with redirect=null or empty string must never match
      // an empty redirect_uri and must not redirect to an empty base URL.
      mockResolveClientWithoutSecret.mockResolvedValue({
        ...STUB_CLIENT,
        redirect: null,
      });

      await expect(
        handler.main({ ...BASE_INPUT, redirectUri: '' }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('throws BadRequestException when presented redirect_uri is empty', async () => {
      await expect(
        handler.main({ ...BASE_INPUT, redirectUri: '' }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('throws BadRequestException (not 500) when the registered redirect_uri is a relative path', async () => {
      // A misconfigured client with redirect='/callback' (non-absolute) would cause
      // new URL('/callback') to throw a TypeError → 500. The handler must catch this
      // early and respond with 400 (fail-closed).
      mockResolveClientWithoutSecret.mockResolvedValue({
        ...STUB_CLIENT,
        redirect: '/callback',
      });

      await expect(
        handler.main({ ...BASE_INPUT, redirectUri: '/callback' }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });
  });

  describe('state validation', () => {
    it('throws BadRequestException when state is empty', async () => {
      await expect(
        handler.main({ ...BASE_INPUT, state: '' }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });
  });

  describe('PKCE validation', () => {
    it('throws InvalidRequestError when codeChallenge is missing', async () => {
      await expect(
        handler.main({ ...BASE_INPUT, codeChallenge: '' }),
      ).rejects.toBeInstanceOf(InvalidRequestError);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('throws InvalidRequestError when codeChallengeMethod is plain', async () => {
      await expect(
        handler.main({ ...BASE_INPUT, codeChallengeMethod: 'plain' }),
      ).rejects.toBeInstanceOf(InvalidRequestError);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('throws InvalidRequestError when codeChallengeMethod is missing', async () => {
      await expect(
        handler.main({ ...BASE_INPUT, codeChallengeMethod: '' }),
      ).rejects.toBeInstanceOf(InvalidRequestError);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });
  });

  describe('TTL NaN guard', () => {
    it('falls back to the 60-second default when OAUTH_AUTHORIZATION_CODE_TTL is non-numeric', async () => {
      // A misconfigured env var like OAUTH_AUTHORIZATION_CODE_TTL='' makes Number('') return 0,
      // and OAUTH_AUTHORIZATION_CODE_TTL='abc' makes Number('abc') return NaN → Invalid Date.
      // The NaN guard must fall back to CODE_TTL_SECONDS_DEFAULT (60) in either case.
      const module: TestingModule = await Test.createTestingModule({
        providers: [
          OAuthAuthorizeHandler,
          { provide: OAuthCreateAuthorizationCodeService, useValue: createAuthCodeService },
          { provide: OAuthFindApplicationService, useValue: findApplicationService },
          {
            provide: ConfigService,
            useValue: {
              get: jest.fn((key: string, defaultValue?: unknown) => {
                const map: Record<string, unknown> = {
                  OAUTH_SIGN_IN_FRONTEND_URL: 'http://localhost:4200',
                  OAUTH_AUTHORIZATION_CODE_TTL: 'not-a-number',
                };
                return map[key] ?? defaultValue;
              }),
            },
          },
        ],
      }).compile();

      const nanGuardHandler = module.get(OAuthAuthorizeHandler);
      mockResolveClientWithoutSecret.mockResolvedValue(STUB_CLIENT);

      await nanGuardHandler.main(BASE_INPUT);

      const [persisted] = createAuthCodeService.main.mock.calls[0];
      // With the guard in place, expiresAt must be roughly now + 60 s (not Invalid Date).
      const expiresAt = new Date(persisted.expiresAt);
      expect(expiresAt.getTime()).not.toBeNaN();
      expect(expiresAt.getTime()).toBeGreaterThan(Date.now());
      // At most 2 minutes in the future (default 60 s + test slack).
      expect(expiresAt.getTime()).toBeLessThan(Date.now() + 120_000);
    });
  });

  describe('redirect_uri fragment rejection (RFC 6749 §3.1.2)', () => {
    it('throws BadRequestException (no redirect) when the registered client redirect contains a fragment', async () => {
      // RFC 6749 §3.1.2 forbids fragments in redirect URIs.
      // If client.redirect contains '#', code could land inside the fragment and never
      // reach the client. We must reject at authorize time (no redirect on error — D5).
      mockResolveClientWithoutSecret.mockResolvedValue({
        ...STUB_CLIENT,
        redirect: 'https://satellite.example.com/callback#section',
      });

      await expect(
        handler.main({
          ...BASE_INPUT,
          redirectUri: 'https://satellite.example.com/callback#section',
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(createAuthCodeService.main).not.toHaveBeenCalled();
    });

    it('returns a correctly-encoded ?code=&state= URL (URL API, no fragment pollution) for a normal redirect', async () => {
      const result = await handler.main(BASE_INPUT);

      expect(result.kind).toBe('code');
      const redirectUrl = (result as OAuthAuthorizeCodeResult).redirectUrl;

      // Use URL API to assert: code and state must be in searchParams, not inside a fragment.
      const parsed = new URL(redirectUrl);
      const codeParam = parsed.searchParams.get('code');
      const stateParam = parsed.searchParams.get('state');

      expect(codeParam).toBeTruthy();
      expect(stateParam).toBe('xyz-state-opaque');
      // No fragment contamination.
      expect(parsed.hash).toBe('');
    });

    it('places code+state after an existing query string without double-?', async () => {
      // redirect_uri already carries a query param — the URL API must append correctly.
      const redirectWithQuery = 'https://satellite.example.com/callback?app=test';
      mockResolveClientWithoutSecret.mockResolvedValue({
        ...STUB_CLIENT,
        redirect: redirectWithQuery,
      });

      const result = await handler.main({
        ...BASE_INPUT,
        redirectUri: redirectWithQuery,
      });

      expect(result.kind).toBe('code');
      const parsed = new URL((result as OAuthAuthorizeCodeResult).redirectUrl);
      expect(parsed.searchParams.get('code')).toBeTruthy();
      expect(parsed.searchParams.get('state')).toBe('xyz-state-opaque');
      expect(parsed.searchParams.get('app')).toBe('test');
    });
  });
});
