import { OAuthClientGrantTypeEnum } from '@api/graphql';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { oAuthResolveApplicationClientWithoutSecret } from '@app/o-auth/shared';
import { OAuthHubSessionService } from '@app/o-auth/flows/application/session/o-auth-hub-session.service';
import { BadRequestException, Controller, Get, Query, Res } from '@nestjs/common';
import type { Response } from 'express';

/**
 * REST endpoint `GET /api/o-auth/logout` — RP-initiated hub logout (Design Decision D1–D4).
 *
 * Pure transport layer:
 *   - Calls `OAuthHubSessionService.clearSessionCookie(res)` unconditionally before any
 *     further validation (idempotent: absent/invalid session still clears + redirects, no error).
 *     The cookie is NOT read or parsed — it is cleared regardless of its presence or validity.
 *   - Resolves the client by `client_id` (= `OAuthApplication.code`) using the shared
 *     `oAuthResolveApplicationClientWithoutSecret` helper — the single source of truth for
 *     AUTHORIZATION_CODE client resolution (Design decision D4). The helper rejects unknown
 *     client_ids and ambiguous multi-client configurations rather than silently picking the first.
 *   - Validates `post_logout_redirect_uri` by SAME-ORIGIN against `OAuthClient.redirect` (D4).
 *     `OAuthClient.redirect` is single-valued and holds the login callback (`/callback`); the
 *     logout destination (`/signed-out`) shares the same origin but has a different path. Exact
 *     match is intentionally relaxed to same-origin here — the `/authorize` endpoint keeps its
 *     stricter discipline (BR-OAUTH-AUTHCODE-005). Cross-origin destinations are rejected WITHOUT
 *     redirecting to them (anti-open-redirect).
 *   - Both the registered redirect and the requested URI must be absolute http(s) URLs. A
 *     relative path would cause `new URL(...)` to throw a TypeError → HTTP 500. Fail-closed: reject with 400.
 *   - On success: 302-redirects to `post_logout_redirect_uri`, echoing `state` when supplied.
 *
 * This endpoint is PUBLIC (no @Auth decorator) — authentication state is carried by the
 * hub_session cookie, not a Bearer token. This also sidesteps the known Jest + @Auth ESM
 * limitation (see project memory [Jest + @Auth ESM limitation]).
 *
 * @rule BR-OAUTH-AUTHCODE-005 — anti-open-redirect principle applied to post_logout_redirect_uri.
 */
@Controller('o-auth')
export class OAuthLogoutController {
  constructor(
    private readonly findApplicationService: OAuthFindApplicationService,
    private readonly sessionService: OAuthHubSessionService,
  ) {}

  @Get('logout')
  async main(
    @Query('client_id') clientId: string,
    @Query('post_logout_redirect_uri') postLogoutRedirectUri: string,
    @Query('state') state: string | undefined,
    @Res() res: Response,
  ): Promise<void> {
    // 1. Validate client_id is present before touching the session or making DB calls.
    if (!clientId) {
      throw new BadRequestException('client_id is required.');
    }

    // 2. Clear the hub_session cookie unconditionally (idempotent — D1, spec requirement).
    // The cookie clear happens BEFORE allowlist validation so that even a bad-destination
    // request still terminates the session. The rejection is of the redirect target,
    // not of the logout itself.
    this.sessionService.clearSessionCookie(res);

    // 3. Resolve the AUTHORIZATION_CODE client using the shared helper (D4 single source of truth).
    // This is the same helper used by /authorize — it:
    //   - Looks up OAuthApplication by code (= client_id); unknown → InvalidRequestError (400).
    //   - Selects the unique OAuthClient for AUTHORIZATION_CODE grant.
    //   - Rejects ambiguous configs (>1 matching client) with InternalServerErrorException.
    // Using the typed enum constant (OAuthClientGrantTypeEnum.AUTHORIZATION_CODE) instead of
    // a string literal ensures the compiler catches grant-type renames (anti-pattern #6).
    const client = await oAuthResolveApplicationClientWithoutSecret(this.findApplicationService, {
      clientId,
      grantType: OAuthClientGrantTypeEnum.AUTHORIZATION_CODE,
    });

    const registeredRedirect = client.redirect ?? null;

    // 4. Validate post_logout_redirect_uri against the client's registered allowlist.
    // Empty/missing registered redirect is always a misconfiguration — reject.
    // Empty/missing post_logout_redirect_uri is always invalid — reject.
    if (!registeredRedirect || !postLogoutRedirectUri) {
      throw new BadRequestException(
        'post_logout_redirect_uri is missing or the client has no registered redirect URI.',
      );
    }

    // Validate that the registered redirect is an absolute http(s) URL. A relative path like
    // '/signed-out' would make new URL(postLogoutRedirectUri) throw a TypeError → 500.
    // Fail-closed: reject with 400 rather than exposing a 500 to the client.
    // Same discipline as /authorize (lines 87-90 of o-auth-authorize.handler.ts).
    if (!/^https?:\/\//i.test(registeredRedirect)) {
      throw new BadRequestException(
        'Registered post_logout_redirect_uri must be an absolute http(s) URL.',
      );
    }

    // Validate that the requested post_logout_redirect_uri is an absolute http(s) URL.
    // A relative URL (e.g. '/signed-out') would cause new URL() to throw a TypeError → 500.
    // Fail-closed: reject with 400 so callers receive a clear client-error response.
    if (!/^https?:\/\//i.test(postLogoutRedirectUri)) {
      throw new BadRequestException(
        'post_logout_redirect_uri must be an absolute http(s) URL.',
      );
    }

    // Same-origin check (D4) — OAuthClient.redirect is single-valued and holds the login
    // callback (e.g. '/callback'). Exact-match would reject the logout destination
    // ('/signed-out') even though they share the same origin.
    //
    // The post-logout destination is less sensitive than the /authorize redirect_uri
    // (it receives no code/token), so constraining it to the SAME ORIGIN as the
    // registered redirect is a sound anti-open-redirect boundary.
    //
    // Both URLs are now known to be absolute http(s) (guards above passed), so
    // new URL() is safe and will not throw a TypeError.
    const registeredOrigin = new URL(registeredRedirect).origin;
    const requestedOrigin = new URL(postLogoutRedirectUri).origin;

    if (registeredOrigin !== requestedOrigin) {
      throw new BadRequestException(
        'post_logout_redirect_uri origin does not match client configuration (anti-open-redirect).',
      );
    }

    // 5. Build the redirect URL, echoing state when supplied (D4, spec requirement).
    // Use the URL API for safe query parameter appending.
    // postLogoutRedirectUri has been validated to be an absolute http(s) URL above,
    // so new URL() is safe here (no TypeError risk).
    const redirectUrlObj = new URL(postLogoutRedirectUri);
    if (state) {
      redirectUrlObj.searchParams.set('state', state);
    }

    // @Res() without passthrough: true hands full response control to Express —
    // NestJS does NOT apply route-level @Header() metadata in this mode.
    // Set the RFC 6749 §5.1 cache-prevention header explicitly before redirecting.
    res.setHeader('Cache-Control', 'no-store');
    res.redirect(redirectUrlObj.toString());
  }
}
