import { Injectable, ForbiddenException, Inject } from "@nestjs/common";
import type { NestMiddleware } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino";
import type { Request, Response, NextFunction } from "express";
import { CsrfService } from "../services/csrf.service.js";
import { devAuthConfig } from "../../config/auth-dev.config.js";

interface CsrfRequestBody {
  _csrf?: string | string[];
  csrfToken?: string | string[];
  [key: string]: unknown;
}

type CookieJar = Record<string, string | undefined>;

type BaseExpressRequest = Request<Record<string, string>, unknown, CsrfRequestBody>;

type CsrfRequest = Omit<BaseExpressRequest, "cookies"> & {
  csrfToken?: string;
  user?: { id: string; sessionId?: string };
  sessionID?: string;
  cookies: CookieJar;
};

/**
 * CSRF Protection Middleware
 * Implements a double-submit cookie pattern:
 * - Portal fetches a token from `/api/security/csrf/token` (sets secret cookie + returns token)
 * - All unsafe methods must include `X-CSRF-Token`
 */
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
  private readonly exemptPaths: Set<string>;
  private readonly safeMethods: Set<string>;

  constructor(
    private readonly csrfService: CsrfService,
    _configService: ConfigService,
    @Inject(Logger) private readonly logger: Logger
  ) {
    // Paths that don't require CSRF protection
    this.exemptPaths = new Set([
      "/api/auth/login",
      "/api/auth/signup",
      "/api/auth/refresh",
      "/api/auth/check-password-needed",
      "/api/auth/request-password-reset",
      "/api/auth/reset-password", // Public auth endpoint for password reset
      "/api/auth/set-password", // Public auth endpoint for setting password after WHMCS link
      "/api/auth/migrate", // Public auth endpoint for account migration
      "/api/health",
      "/docs",
      "/api/webhooks", // Webhooks typically don't use CSRF
      "/", // Root path for health checks
    ]);

    // Methods that don't require CSRF protection (safe methods)
    this.safeMethods = new Set(["GET", "HEAD", "OPTIONS"]);
  }

  use(req: CsrfRequest, res: Response, next: NextFunction): void {
    // Skip CSRF protection entirely in development if disabled
    if (devAuthConfig.disableCsrf) {
      if (devAuthConfig.enableDebugLogs) {
        this.logger.debug("CSRF protection disabled in development", {
          method: req.method,
          path: req.path,
        });
      }
      return next();
    }

    // Skip CSRF protection for exempt paths and safe methods
    if (this.isExemptPath(req) || this.safeMethods.has(req.method)) {
      return next();
    }

    // For unsafe requests, validate CSRF token
    this.validateCsrfToken(req, res, next);
  }

  private isExemptPath(req: CsrfRequest): boolean {
    // Check if path is exempt
    if (this.exemptPaths.has(req.path)) return true;
    // Check for API endpoints that might be exempt
    if (req.path.startsWith("/api/webhooks/")) {
      return true;
    }

    return false;
  }

  private validateCsrfToken(req: CsrfRequest, _res: Response, next: NextFunction): void {
    const token = this.extractTokenFromRequest(req);
    const secret = this.extractSecretFromCookie(req);
    const sessionId = req.user?.sessionId || this.extractSessionId(req);
    const userId = req.user?.id;

    if (!token) {
      this.logger.warn("CSRF validation failed - missing token", {
        method: req.method,
        path: req.path,
        userAgent: req.get("user-agent"),
        ip: req.ip,
      });
      throw new ForbiddenException("CSRF token required");
    }

    if (!secret) {
      this.logger.warn("CSRF validation failed - missing secret cookie", {
        method: req.method,
        path: req.path,
        userAgent: req.get("user-agent"),
        ip: req.ip,
      });
      throw new ForbiddenException("CSRF secret required");
    }

    const validationResult = this.csrfService.validateToken(
      token,
      secret,
      sessionId || undefined,
      userId
    );

    if (!validationResult.isValid) {
      this.logger.warn("CSRF validation failed", {
        reason: validationResult.reason,
        method: req.method,
        path: req.path,
        userAgent: req.get("user-agent"),
        ip: req.ip,
        userId,
        sessionId,
      });
      throw new ForbiddenException(`CSRF validation failed: ${validationResult.reason}`);
    }

    // Store validated token in request for potential use by controllers
    req.csrfToken = token;

    next();
  }

  private extractTokenFromRequest(req: CsrfRequest): string | null {
    // Check multiple possible locations for the CSRF token

    // 1. X-CSRF-Token header (most common)
    const token = req.get("X-CSRF-Token");
    if (token) return token;

    // 2. Request body (for form submissions)
    const bodyToken =
      this.normalizeTokenValue(req.body._csrf) ?? this.normalizeTokenValue(req.body.csrfToken);
    if (bodyToken) {
      return bodyToken;
    }

    return null;
  }

  private extractSecretFromCookie(req: CsrfRequest): string | null {
    const cookieName = this.csrfService.getCookieName();
    const cookies = req.cookies;
    if (!cookies) {
      return null;
    }

    const secret = cookies[cookieName];
    return typeof secret === "string" && secret.length > 0 ? secret : null;
  }

  private extractSessionId(req: CsrfRequest): string | null {
    // Try to extract session ID from various sources
    const cookies = req.cookies;
    const sessionId = this.pickFirstString(
      cookies?.["session-id"],
      cookies?.["connect.sid"],
      req.sessionID
    );
    return sessionId ?? null;
  }

  private normalizeTokenValue(value: string | string[] | undefined): string | null {
    if (typeof value === "string") {
      const trimmed = value.trim();
      return trimmed.length > 0 ? trimmed : null;
    }

    if (Array.isArray(value)) {
      for (const entry of value) {
        const normalized = this.normalizeTokenValue(entry);
        if (normalized) {
          return normalized;
        }
      }
    }

    return null;
  }

  private pickFirstString(...values: Array<string | undefined | null>): string | undefined {
    for (const value of values) {
      if (typeof value === "string" && value.length > 0) {
        return value;
      }
    }
    return undefined;
  }
}
