import {
  Injectable,
  Inject,
  UnauthorizedException,
  ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import type { AuthTokens } from "@customer-portal/domain/auth";
import type { UserAuth } from "@customer-portal/domain/customer";
import { UsersService } from "@bff/modules/users/application/users.service.js";
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
import { parseJwtExpiry, parseJwtExpiryMs } from "../../utils/jwt-expiry.util.js";
import { hashToken } from "../../utils/hash-token.util.js";
import type { RefreshTokenPayload, DeviceInfo } from "./token.types.js";
import { ACCESS_TOKEN_EXPIRY, REFRESH_TOKEN_EXPIRY } from "./token.constants.js";
import { TokenGeneratorService } from "./token-generator.service.js";
import { TokenStorageService } from "./token-storage.service.js";
import { TokenRevocationService } from "./token-revocation.service.js";
import { JoseJwtService } from "./jose-jwt.service.js";

const ERROR_INVALID_REFRESH_TOKEN = "Invalid refresh token";
const ERROR_TOKEN_REFRESH_UNAVAILABLE = "Token refresh temporarily unavailable";

interface ValidatedTokenContext {
  payload: RefreshTokenPayload;
  familyId: string;
  refreshTokenHash: string;
  remainingSeconds: number;
  absoluteExpiresAt: string;
  createdAt: string;
}

/**
 * Token Refresh Service
 *
 * Handles refresh token validation, rotation, and re-issuance.
 */
@Injectable()
export class TokenRefreshService {
  private readonly allowRedisFailOpen: boolean;

  constructor(
    private readonly generator: TokenGeneratorService,
    private readonly storage: TokenStorageService,
    private readonly revocation: TokenRevocationService,
    private readonly jwtService: JoseJwtService,
    private readonly usersService: UsersService,
    configService: ConfigService,
    @Inject("REDIS_CLIENT") private readonly redis: Redis,
    @Inject(Logger) private readonly logger: Logger
  ) {
    this.allowRedisFailOpen =
      configService.get("AUTH_ALLOW_REDIS_TOKEN_FAILOPEN", "false") === "true";
  }

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

    this.checkRedisForRefresh();

    try {
      const tokenContext = await this.validateAndExtractTokenContext(refreshToken);
      return await this.performTokenRotation(tokenContext, deviceInfo);
    } catch (error) {
      return this.handleRefreshError(error);
    }
  }

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

  private async validateAndExtractTokenContext(
    refreshToken: string
  ): Promise<ValidatedTokenContext> {
    const payload = await this.verifyRefreshTokenPayload(refreshToken);
    const familyId = this.extractFamilyId(payload);
    const refreshTokenHash = hashToken(refreshToken);

    await this.validateStoredToken(refreshTokenHash, familyId, payload);
    const { remainingSeconds, absoluteExpiresAt, createdAt } = await this.calculateTokenLifetime(
      familyId,
      refreshTokenHash
    );

    return {
      payload,
      familyId,
      refreshTokenHash,
      remainingSeconds,
      absoluteExpiresAt,
      createdAt,
    };
  }

  private async verifyRefreshTokenPayload(refreshToken: string): Promise<RefreshTokenPayload> {
    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(ERROR_INVALID_REFRESH_TOKEN);
    }
    if (!payload.userId || typeof payload.userId !== "string") {
      throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
    }
    if (!payload.tokenId || typeof payload.tokenId !== "string") {
      throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
    }

    return payload;
  }

  private extractFamilyId(payload: RefreshTokenPayload): string {
    return typeof payload.familyId === "string" && payload.familyId.length > 0
      ? payload.familyId
      : payload.tokenId;
  }

  private async validateStoredToken(
    refreshTokenHash: string,
    familyId: string,
    payload: RefreshTokenPayload
  ): Promise<void> {
    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(ERROR_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(ERROR_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(ERROR_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(ERROR_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(ERROR_INVALID_REFRESH_TOKEN);
    }
  }

  private async calculateTokenLifetime(
    familyId: string,
    refreshTokenHash: string
  ): Promise<{ remainingSeconds: number; absoluteExpiresAt: string; createdAt: string }> {
    const { family: familyData } = await this.storage.getTokenAndFamily(refreshTokenHash, familyId);
    const family = familyData ? this.storage.parseRefreshTokenFamilyRecord(familyData) : null;

    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) {
      remainingSeconds = await this.calculateRemainingSecondsFromTtl(familyId, refreshTokenHash);
      absoluteExpiresAt = new Date(Date.now() + remainingSeconds * 1000).toISOString();
    }

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

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

    return { remainingSeconds, absoluteExpiresAt: expiresAt, createdAt };
  }

  private async calculateRemainingSecondsFromTtl(
    familyId: string,
    refreshTokenHash: string
  ): Promise<number> {
    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) {
      return ttl;
    }

    const tokenTtl = await this.storage.getTtl(tokenKey);
    return typeof tokenTtl === "number" && tokenTtl > 0
      ? tokenTtl
      : parseJwtExpiry(REFRESH_TOKEN_EXPIRY);
  }

  private async performTokenRotation(
    context: ValidatedTokenContext,
    deviceInfo?: DeviceInfo
  ): Promise<{ tokens: AuthTokens; user: UserAuth }> {
    const user = await this.usersService.findByIdInternal(context.payload.userId);
    if (!user) {
      this.logger.warn("User not found during token refresh", { userId: context.payload.userId });
      throw new UnauthorizedException("User not found");
    }

    const userProfile = mapPrismaUserToDomain(user);
    const { newAccessToken, newRefreshToken, newRefreshTokenHash } =
      await this.generator.generateRotationTokenPair(
        user,
        context.familyId,
        context.remainingSeconds,
        deviceInfo
      );

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

    const rotationResult = await this.storage.atomicTokenRotation({
      oldTokenHash: context.refreshTokenHash,
      newTokenHash: newRefreshTokenHash,
      familyId: context.familyId,
      userId: user.id,
      deviceInfo,
      createdAt: context.createdAt,
      absoluteExpiresAt: refreshExpiresAt,
      ttlSeconds: context.remainingSeconds,
    });

    if (!rotationResult.success) {
      this.logger.warn("Atomic token rotation failed - possible concurrent refresh", {
        error: rotationResult.error,
        familyId: context.familyId.slice(0, 8),
        tokenHash: context.refreshTokenHash.slice(0, 8),
      });
      await this.revocation.invalidateTokenFamily(context.familyId);
      throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
    }

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

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

    return {
      tokens: {
        accessToken: newAccessToken,
        refreshToken: newRefreshToken,
        expiresAt: accessExpiresAt,
        refreshExpiresAt,
        tokenType: "Bearer",
      },
      user: userProfile,
    };
  }

  private handleRefreshError(error: unknown): never {
    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),
    });

    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(ERROR_TOKEN_REFRESH_UNAVAILABLE);
    }

    throw new UnauthorizedException(ERROR_INVALID_REFRESH_TOKEN);
  }
}
