import {
  Controller,
  Post,
  Get,
  Body,
  Param,
  Req,
  Res,
  UseGuards,
  HttpCode,
  HttpStatus,
  ParseUUIDPipe,
  UnauthorizedException,
  BadRequestException,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Throttle } from '@nestjs/throttler';
import { ClsService } from 'nestjs-cls';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { LoginDto, ForgotPasswordDto, ResetPasswordDto } from './dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { PermissionGuard } from './guards/permission.guard';
import { Permissions } from './decorators/permissions.decorator';
import { AccessLevel, ModuleName } from '../entities/role-permission.entity';
import { CLS_TENANT_ID } from '../common/cls/cls.constants';
import { successResponse } from '../common/responses/api-response';
import {
  COOKIE_ACCESS_NAME,
  COOKIE_REFRESH_NAME,
  accessCookieOpts,
  refreshCookieOpts,
  clearAccessCookie,
  clearRefreshCookie,
} from './cookie-config';

/**
 * AuthController — login, profile, refresh, logout, and password-
 * reset endpoints.
 *
 * POST /api/auth/login            — public. Sets httpOnly access +
 *                                    refresh cookies. Also returns
 *                                    access_token in the body during
 *                                    the transition window so existing
 *                                    Bearer clients keep working.
 * POST /api/auth/refresh          — public. Rotates the refresh
 *                                    cookie + issues a new access
 *                                    cookie. Body contains no tokens.
 * POST /api/auth/logout           — public. Revokes the presented
 *                                    refresh token and clears both
 *                                    cookies. Idempotent — a caller
 *                                    with no cookies still gets 200.
 * GET  /api/auth/profile          — protected (JWT via cookie or Bearer).
 * POST /api/auth/forgot-password  — public, rate-limited.
 * POST /api/auth/reset-password   — public, rate-limited.
 *
 * Forgot/Reset are unauthenticated by design. Tenant identity flows
 * from the X-Tenant-Id header via TenantMiddleware for forgot; reset
 * resolves tenant from the token row itself.
 */
@Controller('auth')
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly cls: ClsService,
    private readonly configService: ConfigService,
  ) {}

  @Post('login')
  async login(
    @Body() dto: LoginDto,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ) {
    const tenantId = this.cls.get<string>(CLS_TENANT_ID);
    const data = await this.authService.login(dto, tenantId);

    // Set access cookie (mirrors the JWT).
    res.cookie(COOKIE_ACCESS_NAME, data.access_token, accessCookieOpts(this.configService));

    // Issue a fresh refresh-token family for this login.
    const { raw: refreshToken } = await this.authService.issueRefreshToken(
      { id: data.user.id, tenant_id: data.user.tenant_id },
      { ip: extractIp(req), userAgent: req.headers['user-agent'] || null },
    );
    res.cookie(COOKIE_REFRESH_NAME, refreshToken, refreshCookieOpts(this.configService));

    return successResponse(data, 'Login successful');
  }

  /**
   * Rotate the refresh cookie. No request body — the token is read
   * from the httpOnly cookie only, never from JSON or headers, to
   * keep the refresh flow uniformly cookie-bound.
   */
  @Post('refresh')
  @HttpCode(HttpStatus.OK)
  @Throttle({ default: { limit: 30, ttl: 60 * 60 * 1000 } })
  async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
    const cookies = (req as Request & { cookies?: Record<string, string> }).cookies;
    const raw = cookies?.[COOKIE_REFRESH_NAME];
    if (!raw) {
      throw new UnauthorizedException('Invalid session');
    }

    const rotated = await this.authService.rotateRefreshToken(raw, {
      ip: extractIp(req),
      userAgent: req.headers['user-agent'] || null,
    });

    res.cookie(COOKIE_ACCESS_NAME, rotated.accessToken, accessCookieOpts(this.configService));
    res.cookie(COOKIE_REFRESH_NAME, rotated.refreshToken, refreshCookieOpts(this.configService));

    return successResponse({ refreshed: true }, 'Session refreshed');
  }

  /**
   * Logout — revoke the presented refresh token and clear both
   * cookies. Idempotent: a caller with expired / missing cookies
   * still receives 200 so client-side navigation is clean.
   */
  @Post('logout')
  @HttpCode(HttpStatus.OK)
  async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
    const cookies = (req as Request & { cookies?: Record<string, string> }).cookies;
    const raw = cookies?.[COOKIE_REFRESH_NAME];
    await this.authService.revokeRefreshToken(raw);
    res.cookie(COOKIE_ACCESS_NAME, '', clearAccessCookie(this.configService));
    res.cookie(COOKIE_REFRESH_NAME, '', clearRefreshCookie(this.configService));
    return successResponse(null, 'Logged out');
  }

  @UseGuards(JwtAuthGuard)
  @Get('profile')
  async getProfile(@Req() req: Request) {
    const user = (req as Request & { user: { id: string } }).user;
    const tenantId = this.cls.get<string>(CLS_TENANT_ID);
    const data = await this.authService.getProfile(user.id, tenantId);
    return successResponse(data, 'Profile fetched successfully');
  }

  /**
   * Exit a view-as session — restore the original admin's access cookie
   * (admin id read from the view-as token's impersonator_id). Allowlisted
   * in the ReadOnlyInterceptor so it works despite the read-only session.
   *
   * MUST be declared BEFORE `impersonate/:userId` — otherwise Nest matches
   * `exit` as the `:userId` param and routes here through the permission-
   * gated start handler (specific routes before param routes).
   */
  @UseGuards(JwtAuthGuard)
  @Post('impersonate/exit')
  @HttpCode(HttpStatus.OK)
  async exitImpersonation(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
    const actor = (
      req as Request & { user: { id: string; impersonator_id?: string | null; read_only?: boolean } }
    ).user;
    if (!actor.read_only || !actor.impersonator_id) {
      throw new BadRequestException('Not currently in a view-as session.');
    }
    const tenantId = this.cls.get<string>(CLS_TENANT_ID);
    const { accessToken } = await this.authService.stopImpersonation(
      actor.impersonator_id,
      tenantId,
      { ip: extractIp(req) },
    );
    res.cookie(COOKIE_ACCESS_NAME, accessToken, accessCookieOpts(this.configService));
    return successResponse({ exited: true }, 'Exited view-as session');
  }

  /**
   * Start a read-only "View as user" session. Gated by the Impersonate
   * permission (VIEW). Swaps the access cookie to a token acting as the
   * target user but tagged read_only + impersonator_id, so the whole app
   * renders as them while every write is blocked by the global
   * ReadOnlyInterceptor. Same-tenant only. The FE then re-fetches
   * /auth/profile to pick up the target's menu + permissions.
   */
  @UseGuards(JwtAuthGuard, PermissionGuard)
  @Permissions({ module: ModuleName.IMPERSONATE, accessLevel: AccessLevel.VIEW })
  @Post('impersonate/:userId')
  @HttpCode(HttpStatus.OK)
  @Throttle({ default: { limit: 30, ttl: 60 * 60 * 1000 } })
  async impersonate(
    @Param('userId', ParseUUIDPipe) userId: string,
    @Req() req: Request,
    @Res({ passthrough: true }) res: Response,
  ) {
    const actor = (req as Request & { user: { id: string; read_only?: boolean } }).user;
    // No nesting — exit the current view-as before starting another.
    // (The ReadOnlyInterceptor already blocks this POST in a view-as
    // session; this gives a clearer message.)
    if (actor.read_only) {
      throw new BadRequestException('Exit the current view-as session before starting another.');
    }
    const tenantId = this.cls.get<string>(CLS_TENANT_ID);
    const { accessToken, target } = await this.authService.startImpersonation(
      actor.id,
      tenantId,
      userId,
      { ip: extractIp(req) },
    );
    res.cookie(COOKIE_ACCESS_NAME, accessToken, accessCookieOpts(this.configService));
    return successResponse({ impersonating: target }, `Now viewing as ${target.name} (read-only)`);
  }

  /**
   * Per security.md A04 + A07:
   *   - Rate-limited to 5 / hour by IP (Throttler scope).
   *   - Always returns the same generic 200 message — never
   *     reveals whether the email is registered.
   */
  @Post('forgot-password')
  @HttpCode(HttpStatus.OK)
  @Throttle({ default: { limit: 5, ttl: 60 * 60 * 1000 } })
  async forgotPassword(@Body() dto: ForgotPasswordDto, @Req() req: Request) {
    const tenantId = this.cls.get<string>(CLS_TENANT_ID);
    const ip = extractIp(req);
    const userAgent = req.headers['user-agent'] || null;
    const resetUrlBase = `${this.resolveFrontendOrigin(dto.app_url)}/reset-password`;

    await this.authService.forgotPassword(dto, tenantId, { ip, userAgent, resetUrlBase });
    return successResponse(
      null,
      'If that email is registered, we have sent a password-reset link. Check your inbox.',
    );
  }

  /**
   * Consume the magic-link token and set a new password.
   *
   * Per security.md A04: rate-limited (defends against token-
   * brute-forcing — although the 256-bit token already makes that
   * astronomically unlikely, the limit catches misconfigured
   * clients early).
   */
  @Post('reset-password')
  @HttpCode(HttpStatus.OK)
  @Throttle({ default: { limit: 10, ttl: 60 * 60 * 1000 } })
  async resetPassword(@Body() dto: ResetPasswordDto, @Req() req: Request) {
    const ip = extractIp(req);
    const userAgent = req.headers['user-agent'] || null;
    await this.authService.resetPassword(dto, { ip, userAgent });
    return successResponse(
      null,
      'Password reset successfully. You can now sign in with your new password.',
    );
  }

  /**
   * The emailed link carries a live reset token, so a client-supplied
   * origin is only trusted when its hostname is (a subdomain of) an
   * entry in FRONTEND_ALLOWED_HOSTS. Anything else falls back to the
   * first CORS origin rather than erroring, keeping the response generic.
   */
  private resolveFrontendOrigin(appUrl?: string): string {
    const fallback = this.configService
      .get<string>('CORS_ORIGIN', 'http://localhost:5173')
      .split(',')[0]
      .trim()
      .replace(/\/+$/, '');
    if (!appUrl) return fallback;

    let url: URL;
    try {
      url = new URL(appUrl);
    } catch {
      return fallback;
    }
    if (url.protocol !== 'https:' && url.protocol !== 'http:') return fallback;
    if (url.username || url.password) return fallback;

    const hostname = url.hostname.toLowerCase();
    const allowedHosts = this.configService
      .get<string>('FRONTEND_ALLOWED_HOSTS', 'localhost')
      .split(',')
      .map((h) => h.trim().toLowerCase())
      .filter(Boolean);
    const allowed = allowedHosts.some((h) => hostname === h || hostname.endsWith(`.${h}`));
    return allowed ? url.origin : fallback;
  }
}

function extractIp(req: Request): string | null {
  return req.headers['x-forwarded-for']?.toString().split(',')[0].trim() || req.ip || null;
}
