/**
 * Unit tests for OAuthLogoutController.
 *
 * The controller is PUBLIC (no @Auth decorator) — authentication state is carried
 * by the hub_session cookie, not a Bearer token. The cookie clearing is handled
 * by `OAuthHubSessionService.clearSessionCookie(res)` unconditionally.
 *
 * Tests assert:
 *   - hub_session cookie is cleared (clearSessionCookie called) — always, idempotent
 *   - post_logout_redirect_uri accepted when it matches the client allowlist → 302
 *   - post_logout_redirect_uri rejected when it does NOT match → 400, no redirect
 *   - relative registered redirect_uri (misconfiguration) → 400, not 500 (C1)
 *   - state is echoed on the redirect URL when supplied
 *   - missing client_id or unknown client → 400, no redirect
 *   - Cache-Control: no-store is always set before redirecting
 *
 * Controller is instantiated directly (not via TestingModule) to avoid the
 * @Auth/ESM limitation documented in [Jest + @Auth ESM limitation].
 * `OAuthFindApplicationService` and `OAuthHubSessionService` are mocked.
 *
 * ESM break: `@app/o-auth/application` barrel pulls in field-schema → @aurorajs.dev/core-back
 * → aurora-core-common → mime/lite (ESM-only). Mock the barrel so ts-jest stays in CJS mode.
 * Similarly, `@app/o-auth/flows/application/session/o-auth-hub-session.service` pulls in
 * @aurora/modules/authentication ports which also pull in ESM.
 * `@app/o-auth/shared` is mocked to isolate the controller from the resolver's DB call.
 */

// Break the ESM import chain via the application barrel and the hub session service.
jest.mock('@app/o-auth/application', () => ({
  OAuthFindApplicationService: class OAuthFindApplicationService {},
}));
jest.mock('@app/o-auth/flows/application/session/o-auth-hub-session.service', () => ({
  OAuthHubSessionService: class OAuthHubSessionService {},
}));

// Mock the shared module so the controller's call to oAuthResolveApplicationClientWithoutSecret
// can be controlled in tests without hitting the DB (same pattern as o-auth-authorize.handler.spec.ts).
jest.mock('@app/o-auth/shared', () => ({
  oAuthResolveApplicationClientWithoutSecret: jest.fn(),
}));

import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import type { OAuthFindApplicationService } from '@app/o-auth/application';
import type { OAuthHubSessionService } from '@app/o-auth/flows/application/session/o-auth-hub-session.service';
import { oAuthResolveApplicationClientWithoutSecret } from '@app/o-auth/shared';
import type { Response } from 'express';
import { OAuthLogoutController } from './o-auth-logout.controller';

const mockResolveClientWithoutSecret = oAuthResolveApplicationClientWithoutSecret as jest.Mock;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

// The registered OAuthClient.redirect is the login callback.
// Logout destination shares the same origin but differs in path.
const REGISTERED_REDIRECT = 'http://localhost:4200/callback';
const ALLOWED_REDIRECT = 'http://localhost:4200/signed-out';
const CLIENT_ID = 'aurora';
const CLIENT_UUID = 'cli-aaaa-aaaa-aaaa-aaaaaaaaaaaa';

/**
 * Builds a minimal OAuthClient stub returned by the mocked resolver.
 * The default registered redirect is `REGISTERED_REDIRECT` (the login callback URL).
 * `redirectOverride` replaces the default when provided.
 * Pass `null` to simulate a client with a null redirect (misconfiguration).
 * Pass `''` to simulate a client with an empty redirect.
 */
const buildClient = (redirectOverride?: string | null) => ({
  id: CLIENT_UUID,
  grantType: 'AUTHORIZATION_CODE',
  redirect: redirectOverride !== undefined ? redirectOverride : REGISTERED_REDIRECT,
});

const makeRes = (): jest.Mocked<Pick<Response, 'setHeader' | 'redirect'>> => ({
  setHeader: jest.fn(),
  redirect: jest.fn(),
});

// ---------------------------------------------------------------------------
// Controller-level tests
// ---------------------------------------------------------------------------

describe('OAuthLogoutController', () => {
  let controller: OAuthLogoutController;
  let mockFindApplication: jest.Mocked<Pick<OAuthFindApplicationService, 'main'>>;
  let mockSessionService: jest.Mocked<Pick<OAuthHubSessionService, 'clearSessionCookie'>>;

  beforeEach(() => {
    jest.clearAllMocks();
    mockFindApplication = { main: jest.fn() };
    mockSessionService = { clearSessionCookie: jest.fn() };

    // Default: resolver returns a valid client.
    mockResolveClientWithoutSecret.mockResolvedValue(buildClient());

    controller = new OAuthLogoutController(
      mockFindApplication as unknown as OAuthFindApplicationService,
      mockSessionService as unknown as OAuthHubSessionService,
    );
  });

  // ── Cookie clearing ─────────────────────────────────────────────────────────

  describe('hub_session cookie clearing', () => {
    it('always calls clearSessionCookie unconditionally (idempotent)', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response);

      expect(mockSessionService.clearSessionCookie).toHaveBeenCalledWith(res);
    });
  });

  // ── Redirect allowlist: accept ───────────────────────────────────────────────

  describe('post_logout_redirect_uri allowed', () => {
    it('302-redirects to post_logout_redirect_uri when it matches the client allowlist', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response);

      expect(res.redirect).toHaveBeenCalledWith(ALLOWED_REDIRECT);
    });

    it('sets Cache-Control: no-store before redirecting', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response);

      expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
      expect(res.redirect).toHaveBeenCalled();
    });
  });

  // ── State echo ───────────────────────────────────────────────────────────────

  describe('state parameter', () => {
    it('echoes state as a query parameter on the redirect URL when state is supplied', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, 'csrf-state-token', res as unknown as Response);

      const redirectUrl = new URL(res.redirect.mock.calls[0][0] as unknown as string);
      expect(redirectUrl.searchParams.get('state')).toBe('csrf-state-token');
    });

    it('does NOT append state query parameter when state is absent', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response);

      const redirectUrl = new URL(res.redirect.mock.calls[0][0] as unknown as string);
      expect(redirectUrl.searchParams.has('state')).toBe(false);
    });
  });

  // ── Same-origin validation (D4, Task 6.1) ───────────────────────────────────
  //
  // TDD (Round 2): these tests were written BEFORE the controller was changed from
  // exact-match to same-origin validation (red → green). With exact-match they FAIL
  // because REGISTERED_REDIRECT ('…/callback') !== ALLOWED_REDIRECT ('…/signed-out').
  // After the same-origin implementation they pass (green phase).

  describe('same-origin validation of post_logout_redirect_uri (D4)', () => {
    it('accepts post_logout_redirect_uri that shares origin with the registered redirect but has a different path', async () => {
      // Registered: http://localhost:4200/callback  (login callback)
      // Requested:  http://localhost:4200/signed-out (logout destination)
      // Same origin → MUST be accepted (D4 rationale: single-valued OAuthClient.redirect).
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response);

      // Must redirect, not throw.
      expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining(ALLOWED_REDIRECT));
    });

    it('accepts post_logout_redirect_uri that is identical to the registered redirect (exact is still same-origin)', async () => {
      // Exact match is a special case of same-origin — must still pass.
      mockResolveClientWithoutSecret.mockResolvedValue(buildClient(REGISTERED_REDIRECT));
      const res = makeRes();

      await controller.main(CLIENT_ID, REGISTERED_REDIRECT, undefined, res as unknown as Response);

      expect(res.redirect).toHaveBeenCalledWith(expect.stringContaining(REGISTERED_REDIRECT));
    });

    it('rejects post_logout_redirect_uri whose origin differs from the registered redirect (cross-origin)', async () => {
      // Registered: http://localhost:4200/callback
      // Requested:  https://evil.example.com/phish  (different origin → open redirect)
      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, 'https://evil.example.com/phish', undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });

    it('rejects a non-absolute post_logout_redirect_uri with 400 (not 500)', async () => {
      // A relative URL causes new URL(relative) to throw TypeError → would be 500.
      // The controller must catch this and respond 400.
      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, '/relative-path', undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });
  });

  // ── Redirect allowlist: reject ───────────────────────────────────────────────

  describe('post_logout_redirect_uri rejected (anti-open-redirect)', () => {
    it('throws BadRequestException and does NOT redirect when the URI does not match the allowlist', async () => {
      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, 'http://attacker.example.com/phish', undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });

    it('still calls clearSessionCookie before the allowlist rejection (idempotent clear)', async () => {
      // Per spec: the endpoint is idempotent — even when the redirect is rejected
      // the cookie must still be cleared (the rejection is of the destination, not the request).
      const res = makeRes();

      try {
        await controller.main(CLIENT_ID, 'http://evil.example.com', undefined, res as unknown as Response);
      } catch {
        // expected BadRequestException
      }

      expect(mockSessionService.clearSessionCookie).toHaveBeenCalledWith(res);
    });
  });

  // ── Absolute-URL guard (C1) ───────────────────────────────────────────────────

  describe('absolute-URL guard on registered redirect (C1)', () => {
    it('throws BadRequestException (400, not 500) when the registered redirect is a relative path', async () => {
      // A misconfigured OAuthClient with redirect='/signed-out' (non-absolute) would cause
      // new URL('/signed-out') to throw a TypeError → HTTP 500. The controller must validate
      // the URL is absolute http(s) and fail-closed with 400 instead.
      mockResolveClientWithoutSecret.mockResolvedValue(buildClient('/signed-out'));

      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, '/signed-out', undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });

    it('throws BadRequestException (400, not 500) when the registered redirect uses a non-http scheme', async () => {
      mockResolveClientWithoutSecret.mockResolvedValue(buildClient('ftp://files.example.com/out'));

      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, 'ftp://files.example.com/out', undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });
  });

  // ── Client resolution via shared helper (C2) ─────────────────────────────────

  describe('client resolution via oAuthResolveApplicationClientWithoutSecret (C2)', () => {
    it('delegates client resolution to oAuthResolveApplicationClientWithoutSecret', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response);

      expect(mockResolveClientWithoutSecret).toHaveBeenCalledWith(
        mockFindApplication,
        expect.objectContaining({ clientId: CLIENT_ID }),
      );
    });

    it('throws (propagates) when the resolver throws BadRequestException (unknown client_id)', async () => {
      mockResolveClientWithoutSecret.mockRejectedValue(new BadRequestException('unknown client'));

      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });

    it('propagates InternalServerErrorException when the resolver detects ambiguous client config', async () => {
      // selectClientByGrantType throws InternalServerErrorException when >1 client matches.
      // The controller must propagate it (500) rather than silently picking the first.
      mockResolveClientWithoutSecret.mockRejectedValue(new InternalServerErrorException());

      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response),
      ).rejects.toThrow(InternalServerErrorException);

      expect(res.redirect).not.toHaveBeenCalled();
    });
  });

  // ── Client resolution errors ─────────────────────────────────────────────────

  describe('client resolution failures', () => {
    it('throws BadRequestException when client_id is empty (before resolver is called)', async () => {
      const res = makeRes();

      await expect(
        controller.main('', ALLOWED_REDIRECT, undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);
    });

    it('throws BadRequestException when the client has no registered redirect (null)', async () => {
      mockResolveClientWithoutSecret.mockResolvedValue(buildClient(null));

      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });

    it('throws BadRequestException when the client has an empty registered redirect', async () => {
      mockResolveClientWithoutSecret.mockResolvedValue(buildClient(''));

      const res = makeRes();

      await expect(
        controller.main(CLIENT_ID, ALLOWED_REDIRECT, undefined, res as unknown as Response),
      ).rejects.toThrow(BadRequestException);

      expect(res.redirect).not.toHaveBeenCalled();
    });
  });

  // ── Idempotency ──────────────────────────────────────────────────────────────

  describe('idempotency', () => {
    it('completes and redirects even when no hub_session cookie was set', async () => {
      const res = makeRes();

      await controller.main(CLIENT_ID, ALLOWED_REDIRECT, 'state-value', res as unknown as Response);

      expect(res.redirect).toHaveBeenCalled();
      const redirectUrl = new URL(res.redirect.mock.calls[0][0] as unknown as string);
      expect(redirectUrl.searchParams.get('state')).toBe('state-value');
    });
  });
});
