import {
  Injectable,
  Inject,
  UnauthorizedException,
  ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { randomBytes, createHash } from "crypto";
import type { JWTPayload } from "jose";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth, UserRole } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { JoseJwtService } from "./jose-jwt.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";

export interface RefreshTokenPayload extends JWTPayload {
  userId: string;
  /**
   * Refresh token family identifier (stable across rotations).
   * Present on newly issued tokens; legacy tokens used `tokenId` for this value.
   */
  familyId?: string | undefined;
  /**
   * Refresh token identifier (unique per token). Used for replay/reuse detection.
   * For legacy tokens, this was equal to the family id.
   */
  tokenId: string;
  deviceId?: string | undefined;
  userAgent?: string | undefined;
  type: "refresh";
}

/**
 * Auth Token Service
 *
 * Handles token generation and refresh operations.
 * Delegates storage operations to TokenStorageService.
 * Delegates revocation operations to TokenRevocationService.
 */
@Injectable()
export class AuthTokenService {
  private readonly ACCESS_TOKEN_EXPIRY = "15m";
  private readonly REFRESH_TOKEN_EXPIRY = "7d";
  private readonly allowRedisFailOpen: boolean;
  private readonly requireRedisForTokens: boolean;
  private readonly maintenanceMode: boolean;
  private readonly maintenanceMessage: string;

  constructor(
    private readonly jwtService: JoseJwtService,
    private readonly configService: ConfigService,
    private readonly storage: TokenStorageService,
    private readonly revocation: TokenRevocationService,
    @Inject("REDIS_CLIENT") private readonly redis: Redis,
    @Inject(Logger) private readonly logger: Logger,
    private readonly usersService: UsersService
  ) {
    this.allowRedisFailOpen =
      this.configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
    this.requireRedisForTokens =
      this.configService.get("AUTH_REQUIRE_REDIS_FOR_TOKENS", "false") === "true";
    this.maintenanceMode = this.configService.get("AUTH_MAINTENANCE_MODE", "false") === "true";
    this.maintenanceMessage = this.configService.get(
      "AUTH_MAINTENANCE_MESSAGE",
      "Authentication service is temporarily unavailable for maintenance. Please try again later."
    );
  }

  /**
   * Check if authentication service is available
   */
  private checkServiceAvailability(): void {
    if (this.maintenanceMode) {
      this.logger.warn("Authentication service in maintenance mode", {
        maintenanceMessage: this.maintenanceMessage,
      });
      throw new ServiceUnavailableException(this.maintenanceMessage);
    }

    if (this.requireRedisForTokens && this.redis.status !== "ready") {
      this.logger.error("Redis required for token operations but not available", {
        redisStatus: this.redis.status,
        requireRedisForTokens: this.requireRedisForTokens,
      });
      throw new ServiceUnavailableException("Authentication service temporarily unavailable");
    }
  }

  /**
   * Generate a new token pair with refresh token rotation
   */
  async generateTokenPair(
    user: {
      id: string;
      email: string;
      role?: UserRole;
    },
    deviceInfo?: {
      deviceId?: string;
      userAgent?: string;
    }
  ): Promise<AuthTokens> {
    // Validate required user fields
    if (!user.id || typeof user.id !== "string" || user.id.trim().length === 0) {
      this.logger.error("Invalid user ID provided for token generation", {
        userId: user.id,
      });
      throw new Error("Invalid user ID for token generation");
    }
    if (!user.email || typeof user.email !== "string" || user.email.trim().length === 0) {
      this.logger.error("Invalid user email provided for token generation", {
        userId: user.id,
      });
      throw new Error("Invalid user email for token generation");
    }

    this.checkServiceAvailability();

    const accessTokenId = this.generateTokenId();
    const refreshFamilyId = this.generateTokenId();
    const refreshTokenId = this.generateTokenId();

    // Create access token payload
    const accessPayload = {
      sub: user.id,
      email: user.email,
      role: user.role || "USER",
      tokenId: accessTokenId,
      type: "access",
    };

    // Create refresh token payload
    const refreshPayload: RefreshTokenPayload = {
      userId: user.id,
      familyId: refreshFamilyId,
      tokenId: refreshTokenId,
      deviceId: deviceInfo?.deviceId,
      userAgent: deviceInfo?.userAgent,
      type: "refresh",
    };

    // Generate tokens
    const accessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);

    const refreshExpirySeconds = this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
    const refreshToken = await this.jwtService.sign(refreshPayload, refreshExpirySeconds);

    // Store refresh token in Redis
    const refreshTokenHash = this.hashToken(refreshToken);
    const refreshAbsoluteExpiresAt = new Date(
      Date.now() + refreshExpirySeconds * 1000
    ).toISOString();

    // Store refresh token in Redis - this is required for secure token rotation
    if (this.redis.status !== "ready") {
      this.logger.error("Redis not ready for token issuance", {
        status: this.redis.status,
        requireRedisForTokens: this.requireRedisForTokens,
      });
      // Always fail if Redis is unavailable - tokens without storage cannot be
      // securely rotated or revoked, creating a security vulnerability
      throw new ServiceUnavailableException("Authentication service temporarily unavailable");
    }

    try {
      await this.storage.storeRefreshToken(
        user.id,
        refreshFamilyId,
        refreshTokenHash,
        deviceInfo,
        refreshExpirySeconds,
        refreshAbsoluteExpiresAt
      );
    } catch (error) {
      this.logger.error("Failed to store refresh token in Redis", {
        error: error instanceof Error ? error.message : String(error),
        userId: user.id,
      });
      // Always fail on storage error - issuing tokens that can't be validated
      // or rotated creates a security vulnerability
      throw new ServiceUnavailableException("Authentication service temporarily unavailable");
    }

    const accessExpiresAt = new Date(
      Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
    ).toISOString();
    const refreshExpiresAt = refreshAbsoluteExpiresAt;

    this.logger.debug("Generated new token pair", {
      userId: user.id,
      accessTokenId,
      refreshFamilyId,
      refreshTokenId,
    });

    return {
      accessToken,
      refreshToken,
      expiresAt: accessExpiresAt,
      refreshExpiresAt,
      tokenType: "Bearer",
    };
  }

  /**
   * Refresh access token using refresh token rotation
   */
  async refreshTokens(
    refreshToken: string,
    deviceInfo?: {
      deviceId?: string | undefined;
      userAgent?: string | undefined;
    }
  ): Promise<{ tokens: AuthTokens; user: UserAuth }> {
    if (!refreshToken) {
      throw new UnauthorizedException("Invalid refresh token");
    }

    this.checkServiceAvailability();

    if (!this.allowRedisFailOpen && this.redis.status !== "ready") {
      this.logger.error("Redis unavailable for token refresh", {
        redisStatus: this.redis.status,
      });
      throw new ServiceUnavailableException("Token refresh temporarily unavailable");
    }

    try {
      // Verify refresh token
      const payload = await this.jwtService.verify<RefreshTokenPayload>(refreshToken);

      if (payload.type !== "refresh") {
        this.logger.warn("Token presented to refresh endpoint is not a refresh token", {
          tokenId: payload.tokenId,
        });
        throw new UnauthorizedException("Invalid refresh token");
      }
      if (!payload.userId || typeof payload.userId !== "string") {
        throw new UnauthorizedException("Invalid refresh token");
      }
      if (!payload.tokenId || typeof payload.tokenId !== "string") {
        throw new UnauthorizedException("Invalid refresh token");
      }

      const familyId =
        typeof payload.familyId === "string" && payload.familyId.length > 0
          ? payload.familyId
          : payload.tokenId;

      const refreshTokenHash = this.hashToken(refreshToken);

      // Fetch token and family data from storage
      const { token: storedTokenData, family: familyData } = await this.storage.getTokenAndFamily(
        refreshTokenHash,
        familyId
      );

      if (!storedTokenData) {
        this.logger.warn("Refresh token not found or expired", {
          tokenHash: refreshTokenHash.slice(0, 8),
        });
        await this.revocation.invalidateTokenFamily(familyId);
        throw new UnauthorizedException("Invalid refresh token");
      }

      const tokenRecord = this.storage.parseRefreshTokenRecord(storedTokenData);
      if (!tokenRecord) {
        this.logger.warn("Stored refresh token payload was invalid JSON", {
          tokenHash: refreshTokenHash.slice(0, 8),
        });
        await this.storage.deleteToken(refreshTokenHash);
        throw new UnauthorizedException("Invalid refresh token");
      }

      if (tokenRecord.familyId !== familyId || tokenRecord.userId !== payload.userId) {
        this.logger.warn("Refresh token record mismatch", {
          tokenHash: refreshTokenHash.slice(0, 8),
        });
        await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
        throw new UnauthorizedException("Invalid refresh token");
      }

      if (!tokenRecord.valid) {
        this.logger.warn("Refresh token marked as invalid", {
          tokenHash: refreshTokenHash.slice(0, 8),
        });
        await this.revocation.invalidateTokenFamily(tokenRecord.familyId);
        throw new UnauthorizedException("Invalid refresh token");
      }

      const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;
      if (family && family.tokenHash !== refreshTokenHash) {
        this.logger.warn("Refresh token does not match current family token", {
          familyId: familyId.slice(0, 8),
          tokenHash: refreshTokenHash.slice(0, 8),
        });
        await this.revocation.invalidateTokenFamily(familyId);
        throw new UnauthorizedException("Invalid refresh token");
      }

      // Determine remaining lifetime for this refresh session
      let remainingSeconds: number | null = null;
      let absoluteExpiresAt: string | undefined = family?.absoluteExpiresAt;

      if (absoluteExpiresAt) {
        const absMs = Date.parse(absoluteExpiresAt);
        if (Number.isNaN(absMs)) {
          absoluteExpiresAt = undefined;
        } else {
          remainingSeconds = Math.max(0, Math.floor((absMs - Date.now()) / 1000));
        }
      }

      if (remainingSeconds === null) {
        const familyKey = `${this.storage.REFRESH_TOKEN_FAMILY_PREFIX}${familyId}`;
        const tokenKey = `${this.storage.REFRESH_TOKEN_PREFIX}${refreshTokenHash}`;
        const ttl = await this.storage.getTtl(familyKey);
        if (typeof ttl === "number" && ttl > 0) {
          remainingSeconds = ttl;
        } else {
          const tokenTtl = await this.storage.getTtl(tokenKey);
          remainingSeconds =
            typeof tokenTtl === "number" && tokenTtl > 0
              ? tokenTtl
              : this.parseExpiryToSeconds(this.REFRESH_TOKEN_EXPIRY);
        }
        absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString();
      }

      if (!remainingSeconds || remainingSeconds <= 0) {
        await this.revocation.invalidateTokenFamily(familyId);
        throw new UnauthorizedException("Invalid refresh token");
      }

      // Get user info from database
      const user = await this.usersService.findByIdInternal(payload.userId);
      if (!user) {
        this.logger.warn("User not found during token refresh", { userId: payload.userId });
        throw new UnauthorizedException("User not found");
      }

      const userProfile = mapPrismaUserToDomain(user);

      // Generate new token pair
      const accessTokenId = this.generateTokenId();
      const refreshTokenId = this.generateTokenId();

      const accessPayload = {
        sub: user.id,
        email: user.email,
        role: user.role || "USER",
        tokenId: accessTokenId,
        type: "access",
      };

      const newRefreshPayload: RefreshTokenPayload = {
        userId: user.id,
        familyId,
        tokenId: refreshTokenId,
        deviceId: deviceInfo?.deviceId,
        userAgent: deviceInfo?.userAgent,
        type: "refresh",
      };

      const newAccessToken = await this.jwtService.sign(accessPayload, this.ACCESS_TOKEN_EXPIRY);
      const newRefreshToken = await this.jwtService.sign(newRefreshPayload, remainingSeconds);
      const newRefreshTokenHash = this.hashToken(newRefreshToken);

      const createdAt = family?.createdAt ?? new Date().toISOString();
      const refreshExpiresAt =
        absoluteExpiresAt ?? new Date(Date.now() + remainingSeconds * 1000).toISOString();

      // Use atomic token rotation to prevent race conditions
      // This ensures that concurrent refresh requests with the same token
      // cannot both succeed - only one will rotate the token
      const rotationResult = await this.storage.atomicTokenRotation({
        oldTokenHash: refreshTokenHash,
        newTokenHash: newRefreshTokenHash,
        familyId,
        userId: user.id,
        deviceInfo,
        createdAt,
        absoluteExpiresAt: refreshExpiresAt,
        ttlSeconds: remainingSeconds,
      });

      if (!rotationResult.success) {
        this.logger.warn("Atomic token rotation failed - possible concurrent refresh", {
          error: rotationResult.error,
          familyId: familyId.slice(0, 8),
          tokenHash: refreshTokenHash.slice(0, 8),
        });
        // Invalidate the entire family on rotation failure for security
        await this.revocation.invalidateTokenFamily(familyId);
        throw new UnauthorizedException("Invalid refresh token");
      }

      const accessExpiresAt = new Date(
        Date.now() + this.parseExpiryToMs(this.ACCESS_TOKEN_EXPIRY)
      ).toISOString();

      this.logger.debug("Refreshed token pair", { userId: payload.userId });

      return {
        tokens: {
          accessToken: newAccessToken,
          refreshToken: newRefreshToken,
          expiresAt: accessExpiresAt,
          refreshExpiresAt,
          tokenType: "Bearer",
        },
        user: userProfile,
      };
    } catch (error) {
      // Rethrow known exception types to preserve error context
      if (error instanceof UnauthorizedException || error instanceof ServiceUnavailableException) {
        throw error;
      }

      this.logger.error("Token refresh failed with unexpected error", {
        error: error instanceof Error ? error.message : String(error),
      });

      // Check Redis status for appropriate error response
      if (this.redis.status !== "ready") {
        this.logger.error("Redis unavailable for token refresh - failing closed for security", {
          redisStatus: this.redis.status,
          securityReason: "refresh_token_rotation_requires_redis",
        });
        throw new ServiceUnavailableException("Token refresh temporarily unavailable");
      }

      throw new UnauthorizedException("Invalid refresh token");
    }
  }

  /**
   * Revoke a specific refresh token
   */
  async revokeRefreshToken(refreshToken: string): Promise<void> {
    return this.revocation.revokeRefreshToken(refreshToken);
  }

  /**
   * Get all active refresh token families for a user
   */
  async getUserRefreshTokenFamilies(userId: string): Promise<
    Array<{
      familyId: string;
      deviceId?: string | undefined;
      userAgent?: string | undefined;
      createdAt?: string | undefined;
    }>
  > {
    return this.revocation.getUserRefreshTokenFamilies(userId);
  }

  /**
   * Revoke all refresh tokens for a user
   */
  async revokeAllUserTokens(userId: string): Promise<void> {
    return this.revocation.revokeAllUserTokens(userId);
  }

  private generateTokenId(): string {
    return randomBytes(32).toString("hex");
  }

  private hashToken(token: string): string {
    return createHash("sha256").update(token).digest("hex");
  }

  private parseExpiryToMs(expiry: string): number {
    const unit = expiry.slice(-1);
    const value = Number.parseInt(expiry.slice(0, -1));

    switch (unit) {
      case "s":
        return value * 1000;
      case "m":
        return value * 60 * 1000;
      case "h":
        return value * 60 * 60 * 1000;
      case "d":
        return value * 24 * 60 * 60 * 1000;
      default:
        return 15 * 60 * 1000;
    }
  }

  private parseExpiryToSeconds(expiry: string): number {
    return Math.floor(this.parseExpiryToMs(expiry) / 1000);
  }
}
