import { Controller, Get, Inject, Query, Req, Res, UnauthorizedException } from '@nestjs/common';
import { ApiCookieAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName } from '../security/csrf-cookie';
import { sessionCookieName } from '../session/session-cookie';
import {
  PRE_AUTH_COOKIE_NAME,
  clearPreAuthCookieOptions,
  preAuthCookieOptions,
} from './auth.cookie';
import { AuthCodeFlowException, type AuthCodeFlowError, authErrorCode } from './auth.errors';
import { AuthService, type AuthenticatedUser, type PreAuthPayload } from './auth.service';
import { ENTRA_CONFIG, type EntraConfig } from './entra-config.token';
import { SessionEstablisher } from './session-establisher.service';

/**
 * OIDC routes mounted under `/api/auth/` per ADR-0009.
 *
 * Routes shipped here: `GET /login`, `GET /callback`, `GET /me`,
 * `GET /logout`. The callback persists the resolved identity into
 * the express-session store (ADR-0010); `/me` reads it back; the
 * logout endpoint destroys the session, clears the cookie, and
 * redirects to Entra's RP-initiated logout endpoint so the user is
 * signed out at the IdP too.
 */
@ApiTags('auth (user portal)')
@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly logger: Logger,
    @Inject(ENTRA_CONFIG) private readonly entra: EntraConfig,
    private readonly audit: AuditWriter,
    private readonly sessionEstablisher: SessionEstablisher,
  ) {}

  /**
   * Starts the auth flow. Generates a fresh state + PKCE pair via
   * `AuthService`, stashes them in a short-lived signed cookie so
   * `/callback` can verify the round-trip, and 302s the browser to
   * Entra's authorize endpoint.
   *
   * The cookie carries everything the callback needs — no
   * server-side state. That keeps `/login` stateless and friendly
   * to horizontal scaling once the BFF runs more than one instance.
   */
  @ApiOperation({
    summary: 'Start the OIDC Auth Code + PKCE flow (302 to Entra)',
  })
  @Get('login')
  async login(@Res() res: Response): Promise<void> {
    const { authUrl, preAuthPayload } = await this.authService.beginAuthCodeFlow(
      this.entra.redirectUri,
    );
    res.cookie(PRE_AUTH_COOKIE_NAME, JSON.stringify(preAuthPayload), preAuthCookieOptions());
    res.redirect(302, authUrl);
  }

  /**
   * Second leg of the OIDC flow. Entra redirects the user here with
   * `?code=…&state=…` (success) or `?error=…&error_description=…`
   * (failure). The pre-auth cookie set by `/login` carries the
   * matching state + PKCE verifier so we can verify the round-trip
   * and finish the token exchange.
   *
   * On success, the user identity is logged via Pino and the
   * browser is redirected to the SPA. No session is persisted yet —
   * that's the next PR (Redis-backed sessions per ADR-0010); until
   * then the SPA cannot tell the user is "logged in".
   *
   * On failure (any branch — state mismatch, expired cookie,
   * missing `amr` per ADR-0011, MSAL token-exchange error, or
   * Entra-side error from the query) the user lands on the SPA
   * with `?auth_error=<code>` so the front-end can surface a
   * specific message. The pre-auth cookie is cleared on every
   * exit path: it is single-use by design.
   */
  @ApiOperation({
    summary: 'Entra callback — completes the OIDC exchange, establishes the session, 302 to SPA',
  })
  @Get('callback')
  async callback(
    @Req() req: Request,
    @Res() res: Response,
    @Query('code') code?: string,
    @Query('state') state?: string,
    @Query('error') entraError?: string,
    @Query('error_description') entraErrorDescription?: string,
  ): Promise<void> {
    // Drop the single-use cookie regardless of outcome.
    res.clearCookie(PRE_AUTH_COOKIE_NAME, clearPreAuthCookieOptions());

    // Entra signalled a failure (e.g. user cancelled the consent
    // screen, MFA challenge failed, app-registration mismatch).
    if (entraError) {
      this.logger.warn(
        {
          event: 'auth.entra_error',
          entraError,
          entraErrorDescription,
        },
        'AuthCallback',
      );
      await this.audit.signInFailed({
        failureKind: 'entra-error',
        payload: { entraError, entraErrorDescription },
      });
      return this.redirectWithError(res, 'token-exchange-failed');
    }

    if (typeof code !== 'string' || typeof state !== 'string') {
      await this.audit.signInFailed({ failureKind: 'missing-code-or-state' });
      return this.redirectWithError(res, 'token-exchange-failed');
    }

    const preAuth = readPreAuthCookie(req);
    if (!preAuth) {
      // No cookie → either the user opened /callback directly, or
      // their browser dropped the cookie (TTL elapsed in
      // a 3rd-party-cookie blocker, etc.). Treat as flow-expired.
      this.logger.warn({ event: 'auth.no_pre_auth_cookie' }, 'AuthCallback');
      await this.audit.signInFailed({ failureKind: 'no-pre-auth-cookie' });
      return this.redirectWithError(res, 'flow-expired');
    }

    try {
      const user = await this.authService.completeAuthCodeFlow(
        code,
        state,
        preAuth,
        this.entra.redirectUri,
      );
      await this.sessionEstablisher.establish({ user, req, res, surface: 'user' });
      res.redirect(302, this.entra.postLogoutRedirectUri);
    } catch (err) {
      if (err instanceof AuthCodeFlowException) {
        this.logger.warn({ event: 'auth.flow_error', failure: err.failure }, 'AuthCallback');
        await this.audit.signInFailed({ failureKind: err.failure.kind });
        return this.redirectWithError(res, err.failure.kind);
      }
      throw err;
    }
  }

  /**
   * Read-only view of the current session for the SPA. Returns the
   * curated subset of the resolved identity — `amr` and other
   * auth-internal claims stay server-side.
   *
   * 401 (with no body beyond a `{error}` tag) on missing session
   * lets the SPA distinguish "anonymous" from any other failure;
   * the browser's session cookie was either absent, expired, or
   * pointed at a Redis key that no longer exists.
   */
  @ApiOperation({ summary: 'Current session payload (curated public view)' })
  @ApiCookieAuth('portal_session')
  @Get('me')
  me(@Req() req: Request, @Res() res: Response): void {
    const user = req.session.user;
    if (!user) {
      // Throw rather than write the response directly: the global
      // `StructuredErrorFilter` then formats it as the shared
      // `{ error: { code, message, traceId } }` envelope. Code is
      // set via the response-object form of HttpException so the
      // filter picks it up verbatim.
      throw new UnauthorizedException({
        code: 'unauthenticated',
        message: 'Unauthenticated',
      });
    }
    res.json(toPublicUser(user));
  }

  /**
   * RP-initiated logout per ADR-0009. Destroys the BFF session
   * (DEL on the Redis key), clears the session cookie, then
   * redirects the browser to Entra's `/oauth2/v2.0/logout` so the
   * IdP-side session is killed too — single sign-out behaviour. The
   * post-logout redirect on the Entra end lands the user back on
   * the SPA root.
   *
   * Idempotent: if the user was already anonymous, the destroy is
   * a no-op and we redirect anyway. CSRF for v1 relies on
   * SameSite=Lax (subresource requests don't carry the cookie); a
   * dedicated CSRF middleware lands with phase-2 security.
   */
  @ApiOperation({ summary: 'RP-initiated logout — destroys session + 302 to Entra logout' })
  @ApiCookieAuth('portal_session')
  @Get('logout')
  async logout(@Req() req: Request, @Res() res: Response): Promise<void> {
    const user = req.session.user;
    const wasAuthenticated = Boolean(user);
    const logoutUrl = this.authService.buildLogoutUrl(this.entra.postLogoutRedirectUri);

    await this.sessionEstablisher.destroy({ actor: user, req });

    res.clearCookie(sessionCookieName(), { path: '/' });
    res.clearCookie(csrfCookieName(), { path: '/' });
    this.logger.log({ event: 'auth.signed_out', surface: 'user', wasAuthenticated }, 'AuthLogout');
    res.redirect(302, logoutUrl);
  }

  private redirectWithError(res: Response, kind: AuthCodeFlowError['kind']): void {
    const url = new URL(this.entra.postLogoutRedirectUri);
    url.searchParams.set('auth_error', authErrorCode({ kind } as AuthCodeFlowError));
    res.redirect(302, url.toString());
  }
}

interface PublicUser {
  oid: string;
  tid: string;
  username: string;
  displayName: string;
}

function toPublicUser(user: AuthenticatedUser): PublicUser {
  return {
    oid: user.oid,
    tid: user.tid,
    username: user.username,
    displayName: user.displayName,
  };
}

function readPreAuthCookie(req: Request): PreAuthPayload | null {
  const raw = (req.signedCookies as Record<string, unknown>)[PRE_AUTH_COOKIE_NAME];
  if (typeof raw !== 'string') {
    return null;
  }
  try {
    const parsed = JSON.parse(raw) as Partial<PreAuthPayload>;
    if (
      typeof parsed.state === 'string' &&
      typeof parsed.codeVerifier === 'string' &&
      typeof parsed.createdAt === 'number'
    ) {
      return {
        state: parsed.state,
        codeVerifier: parsed.codeVerifier,
        createdAt: parsed.createdAt,
      };
    }
    return null;
  } catch {
    return null;
  }
}
