import { Auth } from '@aurora/decorators';
import { HttpService } from '@nestjs/axios';
import {
  BadRequestException,
  Body,
  Controller,
  Get,
  Logger,
  Post,
  Req,
  Res,
  UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { AxiosError } from 'axios';
import type { Request, Response } from 'express';
import { randomBytes } from 'node:crypto';
import { lastValueFrom } from 'rxjs';

import { generatePkceChallenge, generatePkceVerifier } from '../pkce/pkce-helpers';
import { PkceVerifierStoreService } from '../pkce/pkce-verifier-store.service';

/**
 * Response shape from the hub's RFC 6749 POST /api/o-auth/token endpoint.
 */
interface HubTokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  refresh_token: string;
}

/**
 * Shape returned to the satellite frontend for all token operations.
 * Kept identical to the legacy shape so the frontend needs no changes.
 */
interface FrontendTokenResponse {
  accessToken: string;
  refreshToken: string;
}

@Controller('auth')
export class AuthController {
  private readonly logger = new Logger(AuthController.name);
  private readonly hubApiUrl: string;

  /**
   * `applicationCode` is the `OAuthApplication.code` on the hub (e.g. "aurora").
   *
   * ENV variable: `OAUTH_APPLICATION_CODE`
   * IMPORTANT: this variable now holds the APPLICATION CODE, not an OAuthClient UUID.
   * Set it to the application code value that the hub was seeded with (typically "aurora").
   * The hub's `/authorize` endpoint resolves `client_id` as `OAuthApplication.code`.
   * See design.md D2.
   *
   * If you prefer a dedicated variable name, rename to `OAUTH_APPLICATION_CODE` in your
   * .env and update the `configService.get` call below — the logic is identical.
   */
  private readonly applicationCode: string;

  /** Used ONLY in the HTTP Basic header sent to the hub. Never sent to the browser. */
  private readonly applicationSecret: string;

  private readonly redirectUri: string;
  private readonly frontendUrl: string;

  constructor(
    private readonly configService: ConfigService,
    private readonly httpService: HttpService,
    private readonly verifierStore: PkceVerifierStoreService,
  ) {
    this.hubApiUrl = this.configService.get('OAUTH_HUB_SERVER_URL', 'http://localhost:8080');

    // OAUTH_APPLICATION_CODE = application code (e.g. "aurora"), NOT the OAuthClient UUID.
    this.applicationCode = this.configService.get('OAUTH_APPLICATION_CODE', '');

    this.applicationSecret = this.configService.get('OAUTH_APPLICATION_SECRET', '');
    this.redirectUri = this.configService.get(
      'OAUTH_REDIRECT_URI',
      'http://localhost:4200/callback',
    );
    this.frontendUrl = this.configService.get('APP_FRONTEND_URL', 'http://localhost:4200');
  }

  /**
   * Step 1: Satellite frontend calls this to start the Authorization Code + PKCE flow.
   *
   * Generates a fresh `state` (CSRF correlation key), `code_verifier` (PKCE secret), and
   * `code_challenge = BASE64URL(SHA256(code_verifier))` (sent to the hub, safe to expose).
   *
   * The `code_verifier` is stashed server-side in the PkceVerifierStoreService keyed by
   * `state`. It NEVER touches the browser. The browser only receives the 302 redirect.
   *
   * On the hub side, `/api/o-auth/authorize` resolves `client_id` as `OAuthApplication.code`,
   * so we send `applicationCode` (e.g. "aurora"), not the OAuthClient UUID.
   */
  @Get('login')
  login(@Res() res: Response): void {
    const state = randomBytes(16).toString('hex');
    const codeVerifier = generatePkceVerifier();
    const codeChallenge = generatePkceChallenge(codeVerifier);

    // Stash the verifier server-side. It will be retrieved by `state` at exchange time.
    this.verifierStore.put(state, codeVerifier);

    const authorizeUrl = new URL(`${this.hubApiUrl}/api/o-auth/authorize`);
    // client_id = application code (OAuthApplication.code), per RFC + hub contract (D2).
    authorizeUrl.searchParams.set('client_id', this.applicationCode);
    authorizeUrl.searchParams.set('redirect_uri', this.redirectUri);
    authorizeUrl.searchParams.set('response_type', 'code');
    authorizeUrl.searchParams.set('state', state);
    authorizeUrl.searchParams.set('code_challenge', codeChallenge);
    authorizeUrl.searchParams.set('code_challenge_method', 'S256');

    res.redirect(authorizeUrl.toString());
  }

  /**
   * Step 2: Exchange the authorization code for tokens.
   *
   * Body from the frontend: `{ code, state }` (from the /callback redirect).
   *
   * The BFF looks up the `code_verifier` stored by state, then POSTs to the hub's
   * RFC 6749 POST /api/o-auth/token endpoint with:
   *   - Content-Type: application/x-www-form-urlencoded
   *   - body: grant_type=authorization_code, code, redirect_uri, code_verifier
   *   - Authorization: Basic base64(applicationCode:applicationSecret)
   *
   * The secret never enters the request body (D3, D5).
   *
   * Unknown, expired, or already-consumed state → BadRequestException (no token exchange).
   *
   * Returns `{ accessToken, refreshToken }` (same shape as the legacy contract).
   */
  @Post('token')
  async exchangeToken(
    @Body() body: { code: string; state: string },
  ): Promise<FrontendTokenResponse> {
    const codeVerifier = this.verifierStore.take(body.state);

    if (!codeVerifier) {
      // Unknown, expired, or replayed state — reject without contacting the hub.
      throw new BadRequestException('Invalid or expired state parameter.');
    }

    const formBody = new URLSearchParams({
      grant_type: 'authorization_code',
      code: body.code,
      redirect_uri: this.redirectUri,
      code_verifier: codeVerifier,
    }).toString();

    let response;
    try {
      response = await lastValueFrom(
        this.httpService.post<HubTokenResponse>(`${this.hubApiUrl}/api/o-auth/token`, formBody, {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            // HTTP Basic per RFC 6749 §2.3.1 — secret is backend-only (D5).
            Authorization: this.buildBasicAuthHeader(),
          },
        }),
      );
    } catch (error) {
      // Map to a clean exception — never let the raw AxiosError (with config/Authorization) propagate.
      this.mapHubError(error as AxiosError);
    }

    return {
      accessToken: response.data.access_token,
      refreshToken: response.data.refresh_token,
    };
  }

  /**
   * Verify the current access token. Protected by Passport JWT + JWKS.
   * Contract unchanged — this is a simple proxy check.
   */
  @Get('verify')
  @Auth()
  verifyToken(@Req() req: Request): { valid: boolean; payload: Record<string, unknown> } {
    return { valid: true, payload: req.user as Record<string, unknown> };
  }

  /**
   * Refresh the access token using a valid refresh token.
   *
   * POSTs to the hub's RFC 6749 POST /api/o-auth/token endpoint with:
   *   - Content-Type: application/x-www-form-urlencoded
   *   - body: grant_type=refresh_token, refresh_token
   *   - Authorization: Basic base64(applicationCode:applicationSecret)
   *
   * The legacy `/credentials` endpoint is no longer used.
   */
  @Post('refresh')
  async refreshToken(@Body() body: { refreshToken: string }): Promise<FrontendTokenResponse> {
    const formBody = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: body.refreshToken,
    }).toString();

    let response;
    try {
      response = await lastValueFrom(
        this.httpService.post<HubTokenResponse>(`${this.hubApiUrl}/api/o-auth/token`, formBody, {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            Authorization: this.buildBasicAuthHeader(),
          },
        }),
      );
    } catch (error) {
      // Map to a clean exception — never let the raw AxiosError (with config/Authorization) propagate.
      this.mapHubError(error as AxiosError);
    }

    return {
      accessToken: response.data.access_token,
      refreshToken: response.data.refresh_token,
    };
  }

  /**
   * Get the current user's account data, roles, and permissions from the HUB.
   * Protected by Passport JWT + JWKS. Contract unchanged.
   */
  @Get('me')
  @Auth()
  async getMe(@Req() req: Request): Promise<object> {
    const authorization = req.headers.authorization;

    // Proxy the iamMeAccount query to the HUB's GraphQL endpoint.
    const response = await lastValueFrom(
      this.httpService.post(
        `${this.hubApiUrl}/graphql`,
        {
          query: `query iamMeAccount {
          iamMeAccount {
            id
            clientId
            email
            username
            isActive
            scopes
            dApplicationCodes
            dPermissions
            dTenants
            meta
            roles {
              id
              name
              defaultRedirection
              hasHiddenVerticalNavigation
            }
            tenants {
              id
              name
            }
            user {
              id
              name
              surname
              langId
              meta
            }
            createdAt
            updatedAt
            deletedAt
          }
        }`,
        },
        {
          headers: {
            'Content-Type': 'application/json',
            Authorization: authorization,
          },
        },
      ),
    );

    if (response.data.errors) {
      throw new Error(response.data.errors[0]?.message || 'Failed to fetch user data.');
    }

    return response.data.data.iamMeAccount;
  }

  /**
   * Step 1 of RP-initiated logout (BFF façade, D2/D8).
   *
   * Navigable `GET /auth/logout` — distinct from `POST /auth/logout` (revocation).
   * Same route, different verb.
   *
   * Builds and issues a 302 redirect to the hub's navigable logout endpoint
   * (`GET ${hubApiUrl}/api/o-auth/logout`) carrying:
   *   - `client_id`              = OAuthApplication.code (same as the login flow)
   *   - `post_logout_redirect_uri` = `${frontendUrl}/signed-out` (satellite terminal page)
   *   - `state`                  = fresh random value (CSRF mitigation + round-trip echo)
   *
   * The hub URL is resolved from backend config (`OAUTH_HUB_SERVER_URL`) and NEVER
   * sent to the browser — only the 302 redirect is issued, symmetric to how
   * `GET /auth/login` redirects to the hub `/api/o-auth/authorize`.
   *
   * In `local-provider` mode `hubApiUrl` equals this server's own origin, so the
   * redirect targets the local `/api/o-auth/logout` with no special-casing.
   */
  @Get('logout')
  logoutRedirect(@Res() res: Response): void {
    const state = randomBytes(16).toString('hex');
    const postLogoutRedirectUri = `${this.frontendUrl}/signed-out`;

    const logoutUrl = new URL(`${this.hubApiUrl}/api/o-auth/logout`);
    // client_id = application code (OAuthApplication.code), per hub contract (D8).
    logoutUrl.searchParams.set('client_id', this.applicationCode);
    logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
    logoutUrl.searchParams.set('state', state);

    res.redirect(logoutUrl.toString());
  }

  /**
   * Logout — server-side refresh-token revocation at the hub (best-effort, D6).
   *
   * In `aurora-hub` mode the refresh token was issued by the hub and cannot be revoked
   * locally. This BFF performs a server-to-server revocation via RFC 7009
   * `POST /api/o-auth/revoke` using the same HTTP Basic client auth pattern as token
   * exchange — the client secret stays backend-only and never reaches the browser.
   *
   * Revocation is best-effort: a hub-side failure (network error, hub rejection) MUST NOT
   * prevent logout from completing for the user. The `hub_session` cookie is independently
   * cleared by the RP-initiated logout navigation (D6, Design D1).
   *
   * When no refresh token is provided, the BFF returns success immediately (no-op).
   */
  @Post('logout')
  async logout(@Body() body: { refreshToken?: string }): Promise<{ ok: boolean }> {
    const refreshToken = body?.refreshToken;

    // Skip revocation if no token was provided — nothing to revoke.
    if (!refreshToken) {
      return { ok: true };
    }

    try {
      const formBody = new URLSearchParams({
        token: refreshToken,
        token_type_hint: 'refresh_token',
      }).toString();

      await lastValueFrom(
        this.httpService.post(`${this.hubApiUrl}/api/o-auth/revoke`, formBody, {
          headers: {
            'Content-Type': 'application/x-www-form-urlencoded',
            // HTTP Basic per RFC 6749 §2.3.1 — secret is backend-only (D5, D6).
            Authorization: this.buildBasicAuthHeader(),
          },
        }),
      );
    } catch {
      // Best-effort: log the failure but do not propagate it to the caller (D6).
      // The surviving hub_session cookie is cleared by the RP-initiated logout navigation.
      this.logger.warn('Hub token revocation failed during logout — proceeding with local logout.');
    }

    return { ok: true };
  }

  /**
   * Health/config check endpoint. Exposes non-sensitive configuration for debugging.
   */
  @Get('config')
  getConfig(): Record<string, string> {
    return {
      hubApiUrl: this.hubApiUrl,
      // Expose the application code (not the secret) for diagnostic purposes.
      applicationCode: this.applicationCode,
      redirectUri: this.redirectUri,
      frontendUrl: this.frontendUrl,
      jwksUrl: `${this.hubApiUrl}/.well-known/jwks.json`,
    };
  }

  /**
   * Maps an AxiosError from the hub's RFC 6749 /token endpoint to a clean NestJS
   * HttpException. Prevents the raw AxiosError (which carries `config.headers.Authorization`
   * — the HTTP Basic secret) from propagating to NestJS's default exception handler.
   *
   * Mapping rules (RFC 6749 error codes):
   *   - `invalid_client`  → 401 UnauthorizedException (bad credentials)
   *   - `invalid_grant`   → 400 BadRequestException   (expired / replayed code/token)
   *   - anything else     → 401 UnauthorizedException  (safe default for the auth flow)
   *
   * Only the hub HTTP status and the RFC error code are logged — NEVER the request config
   * or Authorization header.
   */
  private mapHubError(axiosError: AxiosError): never {
    const hubStatus = axiosError.response?.status ?? 0;
    const hubErrorCode = (axiosError.response?.data as Record<string, string> | undefined)?.error;

    // Log only non-sensitive diagnostic info.
    this.logger.warn(`Hub token endpoint returned ${hubStatus}: ${hubErrorCode ?? 'unknown'}`);

    if (hubErrorCode === 'invalid_grant') {
      throw new BadRequestException('Token exchange failed: invalid or expired grant.');
    }

    // invalid_client and all other hub errors → Unauthorized (safe default for auth flows).
    throw new UnauthorizedException('Token exchange failed: client authentication error.');
  }

  /**
   * Builds the HTTP Basic authorization header value.
   *
   * Format: `Basic base64(<applicationCode>:<applicationSecret>)`
   * Per RFC 6749 §2.3.1. The secret stays backend-only (design.md D5).
   */
  private buildBasicAuthHeader(): string {
    const credentials = `${this.applicationCode}:${this.applicationSecret}`;
    return `Basic ${Buffer.from(credentials).toString('base64')}`;
  }
}
