import { OAuthClientGrantTypeEnum } from '@api/graphql';
import { OAuthCreateAuthorizationCodeService } from '@app/o-auth/authorization-code';
import { OAuthFindApplicationService } from '@app/o-auth/application';
import { oAuthResolveApplicationClientWithoutSecret } from '@app/o-auth/shared';
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { randomBytes, randomUUID } from 'node:crypto';
import { InvalidRequestError } from '../../domain/errors';
import type {
  OAuthAuthorizeInput,
  OAuthAuthorizeResult,
} from '../../domain/flows.types';

const CODE_TTL_SECONDS_DEFAULT = 60;

/**
 * Authorize handler for `GET /api/o-auth/authorize` (RFC 6749 §4.1.1 + RFC 7636 PKCE).
 *
 * Business invariants enforced (Design decisions D3, D5):
 *   - response_type must be 'code' — only Authorization Code flow is supported.
 *   - state must be non-empty — the satellite uses it to guard against CSRF.
 *   - PKCE: codeChallenge must be present; codeChallengeMethod must be 'S256'.
 *     'plain' and absent method are rejected immediately (D3).
 *   - Client is resolved via oAuthResolveApplicationClient (grantType=AUTHORIZATION_CODE).
 *     Unknown or unauthorized clients throw BadRequestException — no redirect (D5 safe-redirect).
 *   - redirect_uri must exactly match OAuthClient.redirect (D5 safe-redirect).
 *     Mismatch throws BadRequestException — no redirect.
 *
 * Session path:
 *   - accountId null → 302 to {frontend}/sign-in?continue=<authorizeUrl> (D2).
 *   - accountId present → mint code, persist with TTL + bindings + PKCE, return redirect URL.
 *
 * The controller owns the HTTP mechanics (reading cookie, reading query params, issuing 302).
 * This handler owns the business logic (validation, code creation, redirect computation).
 */
@Injectable()
export class OAuthAuthorizeHandler {
  constructor(
    private readonly createAuthCodeService: OAuthCreateAuthorizationCodeService,
    private readonly findApplication: OAuthFindApplicationService,
    private readonly configService: ConfigService,
  ) {}

  async main(input: OAuthAuthorizeInput): Promise<OAuthAuthorizeResult> {
    // 1. Validate response_type — must be 'code' (RFC 6749 §4.1.1).
    if (input.responseType !== 'code') {
      throw new BadRequestException(
        'Invalid response_type. Only "code" is supported.',
      );
    }

    // 2. Validate state — must be non-empty (D5: satellite validates it for CSRF defense).
    if (!input.state) {
      throw new BadRequestException('state parameter is required and must be non-empty.');
    }

    // 3. Validate PKCE before touching the client (D3: fail-fast, no info leak).
    if (!input.codeChallenge) {
      throw new InvalidRequestError();
    }
    if (input.codeChallengeMethod !== 'S256') {
      throw new InvalidRequestError();
    }

    // 4. Resolve the application client for AUTHORIZATION_CODE grant.
    // Per RFC 6749 §4.1.1, the /authorize endpoint is a browser redirect (public channel) —
    // NO client secret is presented here. Secret verification happens at the /token exchange
    // (D1). Using oAuthResolveApplicationClientWithoutSecret avoids the invalid_client error
    // that oAuthResolveApplicationClient would throw for any real (non-empty) application secret.
    // Unknown or unauthorized clients → BadRequestException (safe redirect: do NOT redirect).
    const client = await oAuthResolveApplicationClientWithoutSecret(this.findApplication, {
      clientId: input.clientId,
      grantType: OAuthClientGrantTypeEnum.AUTHORIZATION_CODE,
    });

    // 5. Validate redirect_uri — explicit blank checks before equality (D5).
    // A blank registered redirect or blank presented redirect_uri is always a misconfiguration
    // or attack: reject before the equality check so '' never matches '' on a null redirect.
    if (!client.redirect || !input.redirectUri) {
      throw new BadRequestException(
        '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
    // '/callback' would make new URL(input.redirectUri) below throw a TypeError → 500.
    // Fail-closed: reject with 400 rather than exposing a 500 to the client.
    if (!/^https?:\/\//i.test(client.redirect)) {
      throw new BadRequestException(
        'Registered redirect_uri must be an absolute http(s) URL.',
      );
    }
    if (client.redirect !== input.redirectUri) {
      throw new BadRequestException(
        'redirect_uri does not match client configuration.',
      );
    }
    // RFC 6749 §3.1.2 forbids fragments in redirect URIs. If the registered redirect
    // contains '#', appending ?code=&state= would place them inside the fragment and
    // the client would never receive the code. Reject at authorize time (no redirect —
    // error must not be communicated via the redirect per RFC 6749 §4.1.2.1 when the
    // redirect itself is the problem).
    if (client.redirect.includes('#')) {
      throw new BadRequestException(
        'Registered redirect_uri must not contain a fragment (RFC 6749 §3.1.2).',
      );
    }

    // 6. Check session. If absent, redirect to the hub sign-in page (D2).
    if (!input.accountId) {
      const frontendUrl = this.configService.get<string>(
        'OAUTH_SIGN_IN_FRONTEND_URL',
        'http://localhost:4200',
      );
      // The hub frontend mounts its sign-in under /auth/sign-in (scaffold route convention:
      // app.routes `path: 'auth'` → child `sign-in`). Redirecting to a bare /sign-in yields
      // Angular NG04002 (no matching route) → blank page. Configurable via OAUTH_SIGN_IN_PATH
      // for deployments that mount the sign-in route elsewhere.
      const signInPath = this.configService.get<string>('OAUTH_SIGN_IN_PATH', '/auth/sign-in');
      const continueParam = encodeURIComponent(input.authorizeUrl);
      const signInUrl = `${frontendUrl}${signInPath}?continue=${continueParam}`;
      return { kind: 'redirect', signInUrl };
    }

    // 7. Mint the authorization code (random hex, 64 chars = 32 bytes).
    const code = randomBytes(32).toString('hex');
    const rawTtl = Number(
      this.configService.get<string>(
        'OAUTH_AUTHORIZATION_CODE_TTL',
        String(CODE_TTL_SECONDS_DEFAULT),
      ),
    );
    // Guard against a non-numeric env value (e.g. OAUTH_AUTHORIZATION_CODE_TTL='')
    // which would make Number() return NaN → Date.now() + NaN = Invalid Date.
    const ttlSeconds = Number.isFinite(rawTtl) ? rawTtl : CODE_TTL_SECONDS_DEFAULT;
    const expiresAt = new Date(Date.now() + ttlSeconds * 1000);

    // 8. Persist the authorization code with all bindings + PKCE (D3, D4, D5).
    await this.createAuthCodeService.main({
      id: randomUUID(),
      code,
      clientId: client.id,
      accountId: input.accountId,
      redirectUri: input.redirectUri,
      scopes: input.scope ? input.scope.split(' ') : null,
      codeChallenge: input.codeChallenge,
      codeChallengeMethod: input.codeChallengeMethod,
      expiresAt,
      isUsed: false,
    });

    // 9. Build the redirect URL: redirect_uri?code=<code>&state=<state> (RFC 6749 §4.1.2).
    // Use the URL API (not string concat + `includes('?')`) so existing query params are
    // preserved correctly and a fragment in the URI cannot swallow the code parameter.
    // client.redirect is validated above to be non-empty and fragment-free, so new URL()
    // is safe here (no need to catch — it will only fail on a non-URL, which was already
    // rejected by the equality check against a registered value that was stored as-is).
    const redirectUrlObj = new URL(input.redirectUri);
    redirectUrlObj.searchParams.set('code', code);
    redirectUrlObj.searchParams.set('state', input.state);
    const redirectUrl = redirectUrlObj.toString();
    return { kind: 'code', redirectUrl };
  }
}
