import { Injectable, NestMiddleware, ForbiddenException, Inject } 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";
import { devAuthConfig } from "../../config/auth-dev.config";

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

type QueryValue = string | string[] | undefined;

type CsrfRequestQuery = Record<string, QueryValue>;

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

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

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

/**
 * CSRF Protection Middleware
 * Implements double-submit cookie pattern with additional security measures
 */
@Injectable()
export class CsrfMiddleware implements NestMiddleware {
  private readonly isProduction: boolean;
  private readonly exemptPaths: Set<string>;
  private readonly exemptMethods: Set<string>;

  constructor(
    private readonly csrfService: CsrfService,
    private readonly configService: ConfigService,
    @Inject(Logger) private readonly logger: Logger
  ) {
    this.isProduction = this.configService.get("NODE_ENV") === "production";

    // 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/link-whmcs", // Public auth endpoint for WHMCS account linking
      "/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.exemptMethods = 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 methods
    if (this.isExempt(req)) {
      return next();
    }

    // For state-changing requests, validate CSRF token
    if (this.requiresCsrfProtection(req)) {
      this.validateCsrfToken(req, res, next);
    } else {
      // For safe requests, generate and set CSRF token if needed
      this.ensureCsrfToken(req, res, next);
    }
  }

  private isExempt(req: CsrfRequest): boolean {
    // Check if path is exempt
    if (this.exemptPaths.has(req.path)) {
      return true;
    }

    // Check if method is exempt (safe methods)
    if (this.exemptMethods.has(req.method)) {
      return true;
    }

    // Check for API endpoints that might be exempt
    if (req.path.startsWith("/api/webhooks/")) {
      return true;
    }

    return false;
  }

  private requiresCsrfProtection(req: CsrfRequest): boolean {
    // State-changing methods require CSRF protection
    return ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);
  }

  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;

    this.logger.debug("CSRF validation successful", {
      method: req.method,
      path: req.path,
      userId,
      sessionId,
    });

    next();
  }

  private ensureCsrfToken(req: CsrfRequest, res: Response, next: NextFunction): void {
    const existingSecret = this.extractSecretFromCookie(req);
    const sessionId = req.user?.sessionId || this.extractSessionId(req);
    const userId = req.user?.id;

    const tokenData = this.csrfService.generateToken(
      existingSecret,
      sessionId ?? undefined,
      userId
    );

    this.setCsrfSecretCookie(res, tokenData.secret, tokenData.expiresAt);

    res.setHeader(this.csrfService.getHeaderName(), tokenData.token);

    this.logger.debug("CSRF token generated and set", {
      method: req.method,
      path: req.path,
      userId,
      sessionId,
    });

    next();
  }

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

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

    // 2. X-Requested-With header (alternative)
    token = req.get("X-Requested-With");
    if (token && token !== "XMLHttpRequest") return token;

    // 3. Authorization header (if using Bearer token pattern)
    const authHeader = req.get("Authorization");
    if (authHeader && authHeader.startsWith("CSRF ")) {
      return authHeader.substring(5);
    }

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

    // 5. Query parameter (least secure, only for GET requests)
    if (req.method === "GET") {
      const queryToken =
        this.normalizeTokenValue(req.query._csrf) ?? this.normalizeTokenValue(req.query.csrfToken);
      if (queryToken) {
        return queryToken;
      }
    }

    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 setCsrfSecretCookie(res: Response, secret: string, expiresAt?: Date): void {
    const cookieOptions = {
      httpOnly: true,
      secure: this.isProduction,
      sameSite: "strict" as const,
      maxAge: expiresAt ? Math.max(0, expiresAt.getTime() - Date.now()) : undefined,
      path: "/",
    };

    res.cookie(this.csrfService.getCookieName(), secret, cookieOptions);
  }

  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;
  }
}
