import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import type { Redis } from "ioredis";
import { randomUUID } from "crypto";

export type OperationType = "voice" | "network" | "plan" | "cancellation";

interface OperationTimestamps {
  voice?: number;
  network?: number;
  plan?: number;
  cancellation?: number;
}

/**
 * Rate limiter for Freebit API operations.
 *
 * Enforces timing constraints between operations to prevent them from
 * canceling each other:
 * - PA05-06 (voice features): Runs with immediate effect
 * - PA05-38 (contract line): Runs with immediate effect
 * - PA05-21 (plan change): Requires runTime parameter, scheduled for 1st of following month
 * - PA05-21 and PA02-04 (cancellation) cannot coexist
 * - These must run 30 minutes apart to avoid canceling each other
 */
@Injectable()
export class FreebitRateLimiterService {
  private readonly operationTimestamps = new Map<string, OperationTimestamps>();
  private lastCleanupTime = 0;
  private readonly cleanupIntervalMs = 5 * 60 * 1000; // Run cleanup at most every 5 minutes
  private readonly staleThresholdMs = 35 * 60 * 1000; // Remove entries older than 35 minutes
  private readonly windowMs = 30 * 60 * 1000; // 30 minute window between operations
  private readonly lockTtlMs = Math.min(this.windowMs, 10 * 60 * 1000);

  constructor(
    @Inject("REDIS_CLIENT") private readonly redis: Redis,
    @Inject(Logger) private readonly logger: Logger
  ) {}

  /**
   * Cleanup stale entries from operationTimestamps to prevent memory leak.
   * Entries are considered stale when all their timestamps are older than staleThresholdMs.
   */
  private cleanupStaleEntries(): void {
    const now = Date.now();

    // Rate limit cleanup to avoid performance overhead
    if (now - this.lastCleanupTime < this.cleanupIntervalMs) {
      return;
    }
    this.lastCleanupTime = now;

    const staleThreshold = now - this.staleThresholdMs;
    const accountsToRemove: string[] = [];

    for (const [account, entry] of this.operationTimestamps) {
      const timestamps = [entry.voice, entry.network, entry.plan, entry.cancellation].filter(
        (t): t is number => typeof t === "number"
      );

      // If all timestamps are stale (or entry is empty), mark for removal
      // EXCEPT: Never clean up entries with a cancellation - cancellation blocks plan changes permanently
      // until the account is actually cancelled or the cancellation is reverted externally
      const hasCancellation = typeof entry.cancellation === "number";
      if (
        !hasCancellation &&
        (timestamps.length === 0 || timestamps.every(t => t < staleThreshold))
      ) {
        accountsToRemove.push(account);
      }
    }

    for (const account of accountsToRemove) {
      this.operationTimestamps.delete(account);
    }

    if (accountsToRemove.length > 0) {
      this.logger.debug(`Cleaned up ${accountsToRemove.length} stale operation timestamp entries`);
    }
  }

  /**
   * Get operation timestamps for an account, initializing if needed.
   */
  private getOperationWindowFromMemory(account: string): OperationTimestamps {
    this.cleanupStaleEntries();

    if (!this.operationTimestamps.has(account)) {
      this.operationTimestamps.set(account, {});
    }
    return this.operationTimestamps.get(account)!;
  }

  private buildKey(account: string): string {
    return `freebit:ops:${account}`;
  }

  private buildLockKey(account: string): string {
    return `freebit:ops:lock:${account}`;
  }

  private async acquireLock(account: string, token: string): Promise<boolean> {
    const key = this.buildLockKey(account);
    const result = await this.redis.set(key, token, "PX", this.lockTtlMs, "NX");
    return result === "OK";
  }

  private async releaseLock(account: string, token: string): Promise<void> {
    const key = this.buildLockKey(account);
    const script =
      "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    try {
      await this.redis.eval(script, 1, key, token);
    } catch (error) {
      this.logger.warn("Failed to release Freebit operation lock", {
        account,
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }

  /**
   * Execute a Freebit operation with spacing checks and a distributed lock.
   * Ensures no concurrent operations violate the 30-minute spacing rule.
   */
  async executeWithSpacing<T>(
    account: string,
    op: OperationType,
    operation: () => Promise<T>
  ): Promise<T> {
    const token = randomUUID();

    try {
      const acquired = await this.acquireLock(account, token);
      if (!acquired) {
        throw new BadRequestException(
          "Another SIM operation is in progress. Please try again shortly."
        );
      }

      await this.assertOperationSpacing(account, op);

      const result = await operation();
      await this.stampOperation(account, op);
      return result;
    } finally {
      await this.releaseLock(account, token);
    }
  }

  private async getOperationWindow(account: string): Promise<OperationTimestamps> {
    const key = this.buildKey(account);
    try {
      const raw = await this.redis.hgetall(key);
      const parsed: OperationTimestamps = {};
      for (const [field, value] of Object.entries(raw)) {
        const num = Number(value);
        if (!Number.isNaN(num)) {
          parsed[field as keyof OperationTimestamps] = num;
        }
      }
      return parsed;
    } catch (error) {
      this.logger.warn("Failed to read Freebit rate limit window from Redis", {
        account,
        error: error instanceof Error ? error.message : String(error),
      });
      return this.getOperationWindowFromMemory(account);
    }
  }

  /**
   * Assert that an operation can be performed based on timing constraints.
   * Throws BadRequestException if the operation violates timing rules.
   */
  async assertOperationSpacing(account: string, op: OperationType): Promise<void> {
    const now = Date.now();
    const entry = await this.getOperationWindow(account);

    if (op === "voice") {
      if (entry.plan && now - entry.plan < this.windowMs) {
        throw new BadRequestException(
          "Voice feature changes must be at least 30 minutes apart from plan changes. Please try again later."
        );
      }
      if (entry.network && now - entry.network < this.windowMs) {
        throw new BadRequestException(
          "Voice feature changes must be at least 30 minutes apart from network type updates. Please try again later."
        );
      }
    }

    if (op === "network") {
      if (entry.voice && now - entry.voice < this.windowMs) {
        throw new BadRequestException(
          "Network type updates must be requested 30 minutes after voice option changes. Please try again later."
        );
      }
      if (entry.plan && now - entry.plan < this.windowMs) {
        throw new BadRequestException(
          "Network type updates must be requested at least 30 minutes apart from plan changes. Please try again later."
        );
      }
    }

    if (op === "plan") {
      if (entry.voice && now - entry.voice < this.windowMs) {
        throw new BadRequestException(
          "Plan changes must be requested 30 minutes after voice option changes. Please try again later."
        );
      }
      if (entry.network && now - entry.network < this.windowMs) {
        throw new BadRequestException(
          "Plan changes must be requested 30 minutes after network type updates. Please try again later."
        );
      }
      if (entry.cancellation) {
        throw new BadRequestException(
          "This subscription has a pending cancellation. Plan changes are no longer permitted."
        );
      }
    }
  }

  /**
   * Clear all rate limit timestamps for an account (for testing/debugging)
   */
  async clearRateLimitForAccount(account: string): Promise<void> {
    const key = this.buildKey(account);
    try {
      await this.redis.del(key);
      this.operationTimestamps.delete(account);
      this.logger.log(`Cleared rate limit state for account ${account}`);
    } catch (error) {
      this.logger.warn("Failed to clear rate limit state", {
        account,
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }

  /**
   * Get remaining wait time for a specific operation type (in seconds)
   */
  async getRemainingWaitTime(account: string, op: OperationType): Promise<number> {
    const entry = await this.getOperationWindow(account);
    const now = Date.now();

    if (op === "network") {
      const voiceWait = entry.voice ? Math.max(0, this.windowMs - (now - entry.voice)) : 0;
      const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0;
      return Math.ceil(Math.max(voiceWait, planWait) / 1000);
    }

    if (op === "voice") {
      const planWait = entry.plan ? Math.max(0, this.windowMs - (now - entry.plan)) : 0;
      const networkWait = entry.network ? Math.max(0, this.windowMs - (now - entry.network)) : 0;
      return Math.ceil(Math.max(planWait, networkWait) / 1000);
    }

    return 0;
  }

  /**
   * Record that an operation was performed for an account.
   */
  async stampOperation(account: string, op: OperationType): Promise<void> {
    const now = Date.now();
    const entry = this.getOperationWindowFromMemory(account);
    entry[op] = now;

    const key = this.buildKey(account);
    try {
      await this.redis.hset(key, op, String(now));
      const hasCancellation = op === "cancellation" || entry.cancellation !== undefined;
      if (hasCancellation) {
        await this.redis.persist(key);
      } else {
        await this.redis.expire(key, Math.ceil(this.staleThresholdMs / 1000));
      }
    } catch (error) {
      this.logger.warn("Failed to persist Freebit rate limit window to Redis", {
        account,
        op,
        error: error instanceof Error ? error.message : String(error),
      });
    }
  }
}
