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

/**
 * Core cache service
 *
 * Provides Redis-backed caching infrastructure for the entire application.
 * This is a low-level service - domain-specific cache services should wrap this
 * to provide higher-level caching patterns (CDC invalidation, request coalescing, etc).
 *
 * Features:
 * - JSON serialization/deserialization
 * - TTL support (optional)
 * - Pattern-based operations (scan, delete, count)
 * - Memory usage tracking
 * - Automatic error handling and logging
 */
@Injectable()
export class CacheService {
  constructor(
    @Inject("REDIS_CLIENT") private readonly redis: Redis,
    @Inject(Logger) private readonly logger: Logger
  ) {}

  /**
   * Get a cached value by key
   * Returns null if key doesn't exist or value cannot be parsed
   */
  async get<T>(key: string): Promise<T | null> {
    const value = await this.redis.get(key);
    if (!value) {
      return null;
    }

    try {
      return JSON.parse(value) as T;
    } catch (error) {
      this.logger.warn({ key, error }, "Failed to parse cached value; evicting entry");
      await this.redis.del(key);
      return null;
    }
  }

  /**
   * Set a cached value
   * @param key Cache key
   * @param value Value to cache (will be JSON serialized)
   * @param ttlSeconds Optional TTL in seconds (null = no expiry)
   */
  async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
    const serialized = JSON.stringify(value);
    if (ttlSeconds !== undefined) {
      const ttl = Math.max(0, Math.floor(ttlSeconds));
      if (ttl > 0) {
        await this.redis.set(key, serialized, "EX", ttl);
      } else {
        // Allow callers to request immediate expiry without leaking stale values
        await this.redis.set(key, serialized, "PX", 1);
      }
      return;
    }

    await this.redis.set(key, serialized);
  }

  /**
   * Delete a single cache key
   */
  async del(key: string): Promise<void> {
    await this.redis.del(key);
  }

  /**
   * Delete all keys matching a pattern
   * Uses SCAN for safe operation on large datasets
   * @param pattern Redis pattern (e.g., "orders:*", "services:product:*")
   */
  async delPattern(pattern: string): Promise<void> {
    const pipeline = this.redis.pipeline();
    const state = { pending: 0 };

    const flush = async () => {
      if (state.pending === 0) {
        return;
      }
      await pipeline.exec();
      // eslint-disable-next-line require-atomic-updates -- flush is called sequentially, no concurrent access
      state.pending = 0;
    };

    await this.scanPattern(pattern, async keys => {
      for (const key of keys) {
        pipeline.del(key);
        state.pending += 1;
      }
      if (state.pending >= 1000) {
        await flush();
      }
    });

    await flush();
  }

  /**
   * Count keys matching a pattern
   * @param pattern Redis pattern (e.g., "orders:*")
   */
  async countByPattern(pattern: string): Promise<number> {
    let total = 0;
    await this.scanPattern(pattern, keys => {
      total += keys.length;
    });
    return total;
  }

  /**
   * Get total memory usage of keys matching a pattern
   * @param pattern Redis pattern (e.g., "orders:*")
   * @returns Total memory usage in bytes
   */
  async memoryUsageByPattern(pattern: string): Promise<number> {
    let total = 0;
    await this.scanPattern(pattern, async keys => {
      const pipeline = this.redis.pipeline();
      for (const key of keys) {
        pipeline.memory("USAGE", key);
      }
      const results = await pipeline.exec();
      if (!results) {
        return;
      }
      for (const result of results) {
        if (!result) {
          continue;
        }
        const [error, usage] = result as [Error | null, unknown];
        if (!error && typeof usage === "number") {
          total += usage;
        }
      }
    });
    return total;
  }

  /**
   * Check if a key exists
   */
  async exists(key: string): Promise<boolean> {
    return (await this.redis.exists(key)) === 1;
  }

  /**
   * Set a key only if it does not exist (atomic)
   * Useful for idempotency checks and deduplication
   * @param key Cache key
   * @param value Value to cache (will be JSON serialized)
   * @param ttlSeconds TTL in seconds
   * @returns true if key was set (didn't exist), false if key already exists
   */
  async setIfNotExists(key: string, value: unknown, ttlSeconds: number): Promise<boolean> {
    const serialized = JSON.stringify(value);
    const ttl = Math.max(1, Math.floor(ttlSeconds));
    const result = await this.redis.set(key, serialized, "EX", ttl, "NX");
    return result === "OK";
  }

  /**
   * Build a structured cache key
   * @param prefix Key prefix (e.g., "orders", "catalog")
   * @param userId User/account identifier
   * @param parts Additional key parts
   * @returns Colon-separated key (e.g., "orders:user123:summary")
   */
  buildKey(prefix: string, userId: string, ...parts: string[]): string {
    return [prefix, userId, ...parts].join(":");
  }

  /**
   * Atomically verify an OTP code using a Lua script
   *
   * This prevents race conditions where concurrent verification requests
   * could both pass before the first one deletes/updates the OTP.
   *
   * Operations in single atomic transaction:
   * 1. Get OTP data
   * 2. Check expiry (via key existence)
   * 3. Check max attempts
   * 4. Validate code
   * 5. Either delete (success) or increment attempts (failure)
   *
   * @param key - Redis key for OTP data
   * @param expectedCode - The code to verify
   * @param maxAttempts - Maximum allowed verification attempts
   * @returns Verification result with validity and reason
   */
  async verifyOtpAtomic(
    key: string,
    expectedCode: string,
    maxAttempts: number
  ): Promise<{
    valid: boolean;
    reason?: "expired" | "invalid" | "max_attempts";
    attemptsRemaining?: number;
  }> {
    // Lua script for atomic OTP verification
    // This runs as a single atomic operation in Redis
    const script = `
      local data = redis.call('GET', KEYS[1])
      if not data then
        return cjson.encode({valid = false, reason = 'expired'})
      end

      local otpData = cjson.decode(data)
      local maxAttempts = tonumber(ARGV[2])

      -- Check if max attempts already reached
      if otpData.attempts >= maxAttempts then
        redis.call('DEL', KEYS[1])
        return cjson.encode({valid = false, reason = 'max_attempts'})
      end

      -- Check if code matches
      if otpData.code ~= ARGV[1] then
        -- Increment attempts
        otpData.attempts = otpData.attempts + 1
        local remaining = maxAttempts - otpData.attempts

        -- Check if this was the last attempt
        if otpData.attempts >= maxAttempts then
          redis.call('DEL', KEYS[1])
          return cjson.encode({valid = false, reason = 'max_attempts'})
        end

        -- Preserve TTL when updating
        local ttl = redis.call('TTL', KEYS[1])
        if ttl > 0 then
          redis.call('SET', KEYS[1], cjson.encode(otpData), 'EX', ttl)
        elseif ttl == -1 then
          -- Key has no expiry (shouldn't happen for OTP, but handle defensively)
          redis.call('SET', KEYS[1], cjson.encode(otpData), 'EX', 600)
        end
        -- If TTL is -2 (key doesn't exist), we've already read it so this is a race condition
        -- The code will naturally fall through and return the invalid result
        return cjson.encode({valid = false, reason = 'invalid', attemptsRemaining = remaining})
      end

      -- Valid code - delete it (one-time use)
      redis.call('DEL', KEYS[1])
      return cjson.encode({valid = true})
    `;

    try {
      const result = await this.redis.eval(
        script,
        1, // number of keys
        key,
        expectedCode,
        maxAttempts.toString()
      );
      return JSON.parse(result as string) as {
        valid: boolean;
        reason?: "expired" | "invalid" | "max_attempts";
        attemptsRemaining?: number;
      };
    } catch (error) {
      this.logger.error({ key, error }, "Atomic OTP verification failed");
      // Fall back to expired on error (safe default)
      return { valid: false, reason: "expired" };
    }
  }

  /**
   * Get or set pattern: Fetch from cache, or call fetcher and cache result
   * @param key Cache key
   * @param fetcher Function to fetch fresh data on cache miss
   * @param ttlSeconds TTL in seconds (default: 300)
   */
  async getOrSet<T>(key: string, fetcher: () => Promise<T>, ttlSeconds: number = 300): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) {
      return cached;
    }

    const fresh = await fetcher();
    await this.set(key, fresh, ttlSeconds);
    return fresh;
  }

  // ============================================================================
  // Redis SET Operations (Atomic)
  // ============================================================================

  /**
   * Add one or more members to a Redis set (SADD)
   * This is atomic and safe for concurrent access.
   *
   * @param key Redis set key
   * @param members Members to add to the set
   * @returns Number of members added (excludes already existing members)
   */
  async setAdd(key: string, ...members: string[]): Promise<number> {
    if (members.length === 0) {
      return 0;
    }
    return this.redis.sadd(key, ...members);
  }

  /**
   * Remove one or more members from a Redis set (SREM)
   * This is atomic and safe for concurrent access.
   *
   * @param key Redis set key
   * @param members Members to remove from the set
   * @returns Number of members removed
   */
  async setRemove(key: string, ...members: string[]): Promise<number> {
    if (members.length === 0) {
      return 0;
    }
    return this.redis.srem(key, ...members);
  }

  /**
   * Get all members of a Redis set (SMEMBERS)
   *
   * @param key Redis set key
   * @returns Array of set members
   */
  async setMembers(key: string): Promise<string[]> {
    return this.redis.smembers(key);
  }

  /**
   * Add a member to a set and set TTL on the set key
   * Combines SADD and EXPIRE in a pipeline for efficiency.
   *
   * @param key Redis set key
   * @param member Member to add
   * @param ttlSeconds TTL in seconds to set/refresh on the key
   */
  async setAddWithTtl(key: string, member: string, ttlSeconds: number): Promise<void> {
    const ttl = Math.max(1, Math.floor(ttlSeconds));
    const pipeline = this.redis.pipeline();
    pipeline.sadd(key, member);
    pipeline.expire(key, ttl);
    await pipeline.exec();
  }

  /**
   * Scan keys matching a pattern and invoke callback for each batch
   * Uses cursor-based iteration for safe operation on large datasets
   * @param pattern Redis pattern
   * @param onKeys Callback invoked for each batch of matching keys
   */
  private async scanPattern(
    pattern: string,
    onKeys: (keys: string[]) => Promise<void> | void
  ): Promise<void> {
    let cursor = "0";
    do {
      // eslint-disable-next-line no-await-in-loop -- Cursor-based Redis SCAN requires sequential iteration
      const [next, keys] = (await this.redis.scan(
        cursor,
        "MATCH",
        pattern,
        "COUNT",
        1000
      )) as unknown as [string, string[]];
      cursor = next;
      if (keys && keys.length) {
        // eslint-disable-next-line no-await-in-loop -- Sequential processing of batched keys
        await onKeys(keys);
      }
    } while (cursor !== "0");
  }
}
