import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createHash } from "crypto";
import { Redis } from "ioredis";
import { Logger } from "nestjs-pino";
import { parseJwtExpiry } from "../../utils/jwt-expiry.util.js";
import { JoseJwtService } from "./jose-jwt.service.js";

@Injectable()
export class TokenBlacklistService {
  private readonly failClosed: boolean;

  constructor(
    @Inject("REDIS_CLIENT") private readonly redis: Redis,
    private readonly configService: ConfigService,
    private readonly jwtService: JoseJwtService,
    @Inject(Logger) private readonly logger: Logger
  ) {
    // Default to fail-closed in production for security, fail-open in development for convenience
    const nodeEnv = this.configService.get<string>("NODE_ENV", "development");
    const explicitSetting = this.configService.get<string>("AUTH_BLACKLIST_FAIL_CLOSED");
    if (explicitSetting === undefined) {
      this.failClosed = nodeEnv === "production";
    } else {
      this.failClosed = explicitSetting === "true";
    }
  }

  async blacklistToken(token: string): Promise<void> {
    // Validate token format first
    if (!token || typeof token !== "string" || token.split(".").length !== 3) {
      this.logger.warn("Invalid token format provided for blacklisting");
      return;
    }

    // Safety notes:
    // - Logout can be called on an expired session and is public.
    // - We MUST avoid writing arbitrary attacker-controlled tokens to Redis.
    // - Therefore, only blacklist tokens that have a valid signature (allowing expiry).
    try {
      const decoded = await this.jwtService.verifyAllowExpired<{ sub?: string; exp?: number }>(
        token
      );

      if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) {
        this.logger.warn("Invalid JWT payload structure for blacklisting");
        return;
      }

      const { sub, exp } = decoded;
      if (typeof sub !== "string" || typeof exp !== "number") {
        this.logger.warn("Invalid JWT payload structure for blacklisting");
        return;
      }

      const expiryTime = exp * 1000; // Convert to milliseconds
      const currentTime = Date.now();
      const rawTtl = Math.max(0, Math.floor((expiryTime - currentTime) / 1000)); // seconds

      // Cap TTL to prevent extremely long-lived keys in case of mis-issued tokens.
      // Access tokens should be short-lived anyway.
      const maxTtlSeconds = 60 * 60 * 24 * 30; // 30 days
      const ttl = Math.min(rawTtl, maxTtlSeconds);

      if (ttl > 0) {
        await this.redis.setex(this.buildBlacklistKey(token), ttl, "1");
        this.logger.debug(`Token blacklisted successfully for ${ttl} seconds`);
      } else {
        this.logger.debug("Token already expired, not blacklisting");
      }
    } catch {
      // If we can't parse the token, blacklist it for the default JWT expiry time
      try {
        const defaultTtl = parseJwtExpiry(this.configService.get("JWT_EXPIRES_IN", "7d"));
        const maxTtlSeconds = 60 * 60 * 24 * 30; // 30 days
        const ttl = Math.min(defaultTtl, maxTtlSeconds);
        await this.redis.setex(this.buildBlacklistKey(token), ttl, "1");
        this.logger.debug(`Token blacklisted with default TTL: ${defaultTtl} seconds`);
      } catch (err) {
        this.logger.warn(
          "Failed to write token to Redis blacklist; proceeding without persistence",
          {
            error: err instanceof Error ? err.message : String(err),
          }
        );
      }
    }
  }

  async isTokenBlacklisted(token: string): Promise<boolean> {
    try {
      const result = await this.redis.get(this.buildBlacklistKey(token));
      return result !== null;
    } catch (err) {
      if (this.failClosed) {
        this.logger.error("Redis unavailable during blacklist check; failing closed", {
          error: err instanceof Error ? err.message : String(err),
        });
        throw new ServiceUnavailableException("Authentication temporarily unavailable");
      }

      // Default: fail open to avoid blocking auth when Redis is unavailable
      this.logger.warn("Redis unavailable during blacklist check; allowing request", {
        error: err instanceof Error ? err.message : String(err),
      });
      return false;
    }
  }

  private buildBlacklistKey(token: string): string {
    return `blacklist:${this.hashToken(token)}`;
  }

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