import { logger } from "@formbricks/logger";
import type { RedisClient } from "@/types/client";
import { type CacheError, CacheErrorClass, ErrorCode, type Result, err, ok } from "@/types/error";
import type { CacheKey } from "@/types/keys";
import { ZCacheKey } from "@/types/keys";
import { ZTtlMs, ZTtlMsOptional } from "@/types/service";
import { validateInputs } from "./utils/validation";

/**
 * Core cache service providing basic Redis operations with JSON serialization
 */
export class CacheService {
  constructor(private readonly redis: RedisClient) {}

  /**
   * Wraps Redis operations with connection check and timeout to prevent hanging
   */
  private async withTimeout<T>(operation: Promise<T>, timeoutMs = 1000): Promise<T> {
    return Promise.race([
      operation,
      new Promise<T>((_, reject) => {
        setTimeout(() => {
          reject(new CacheErrorClass(ErrorCode.RedisOperationError, "Cache operation timeout"));
        }, timeoutMs);
      }),
    ]);
  }

  /**
   * Get the underlying Redis client for advanced operations (e.g., Lua scripts)
   * Use with caution - prefer cache service methods when possible
   * @returns The Redis client instance or null if not ready
   */
  getRedisClient(): RedisClient | null {
    if (!this.isRedisClientReady()) {
      return null;
    }
    return this.redis;
  }

  /**
   * Get a value from cache with automatic JSON deserialization
   * @param key - Cache key to retrieve
   * @returns Result containing parsed value, null if not found, or an error
   */
  async get<T>(key: CacheKey): Promise<Result<T | null, CacheError>> {
    // Check Redis availability first
    if (!this.isRedisClientReady()) {
      return err({
        code: ErrorCode.RedisConnectionError,
      });
    }

    const validation = validateInputs([key, ZCacheKey]);
    if (!validation.ok) {
      return validation;
    }

    try {
      const value = await this.withTimeout(this.redis.get(key));
      if (value === null) {
        return ok(null);
      }

      // Parse JSON - all data should be valid JSON since we stringify on set
      try {
        return ok(JSON.parse(value) as T);
      } catch (parseError) {
        // JSON parse failure indicates corrupted cache data - treat as cache miss
        logger.warn(
          {
            key,
            parseError,
          },
          "Corrupted cache data detected, treating as cache miss"
        );
        return err({
          code: ErrorCode.CacheCorruptionError,
        });
      }
    } catch (error) {
      logger.error({ error, key }, "Cache get operation failed");
      return err({
        code: ErrorCode.RedisOperationError,
      });
    }
  }

  /**
   * Check if a key exists in cache (for distinguishing cache miss from cached null)
   * @param key - Cache key to check
   * @returns Result containing boolean indicating if key exists
   */
  async exists(key: CacheKey): Promise<Result<boolean, CacheError>> {
    // Check Redis availability first
    if (!this.isRedisClientReady()) {
      return err({
        code: ErrorCode.RedisConnectionError,
      });
    }

    const validation = validateInputs([key, ZCacheKey]);
    if (!validation.ok) {
      return validation;
    }

    try {
      const exists = await this.withTimeout(this.redis.exists(key));
      return ok(exists > 0);
    } catch (error) {
      logger.error({ error, key }, "Cache exists operation failed");
      return err({
        code: ErrorCode.RedisOperationError,
      });
    }
  }

  /**
   * Set a value in cache with automatic JSON serialization and optional TTL
   * @param key - Cache key to store under
   * @param value - Value to store
   * @param ttlMs - Time to live in milliseconds (optional - if omitted, key persists indefinitely)
   * @returns Result containing void or an error
   */
  async set(key: CacheKey, value: unknown, ttlMs?: number): Promise<Result<void, CacheError>> {
    // Check Redis availability first
    if (!this.isRedisClientReady()) {
      return err({
        code: ErrorCode.RedisConnectionError,
      });
    }

    // Validate key and optional TTL
    const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMsOptional]);
    if (!validation.ok) {
      return validation;
    }

    try {
      // Normalize undefined to null to maintain consistent cached-null semantics
      const normalizedValue = value === undefined ? null : value;
      const serialized = JSON.stringify(normalizedValue);

      if (ttlMs === undefined) {
        // Set without expiration (persists indefinitely)
        await this.withTimeout(this.redis.set(key, serialized));
      } else {
        // Set with expiration
        await this.withTimeout(this.redis.setEx(key, Math.floor(ttlMs / 1000), serialized));
      }
      return ok(undefined);
    } catch (error) {
      logger.error({ error, key, ttlMs }, "Cache set operation failed");
      return err({
        code: ErrorCode.RedisOperationError,
      });
    }
  }

  /**
   * Delete one or more keys from cache (idempotent)
   * @param keys - Array of keys to delete
   * @returns Result containing void or an error
   */
  async del(keys: CacheKey[]): Promise<Result<void, CacheError>> {
    // Check Redis availability first
    if (!this.isRedisClientReady()) {
      return err({
        code: ErrorCode.RedisConnectionError,
      });
    }

    // Validate all keys using generic validation
    for (const key of keys) {
      const validation = validateInputs([key, ZCacheKey]);
      if (!validation.ok) {
        return validation;
      }
    }

    try {
      if (keys.length > 0) {
        await this.withTimeout(this.redis.del(keys));
      }
      return ok(undefined);
    } catch (error) {
      logger.error({ error, keys }, "Cache delete operation failed");
      return err({
        code: ErrorCode.RedisOperationError,
      });
    }
  }

  /**
   * Try to acquire a distributed lock (atomic SET NX operation)
   * @param key - Lock key
   * @param value - Lock value (typically "locked" or instance identifier)
   * @param ttlMs - Time to live in milliseconds (lock expiration)
   * @returns Result containing boolean indicating if lock was acquired, or an error
   */
  async tryLock(key: CacheKey, value: string, ttlMs: number): Promise<Result<boolean, CacheError>> {
    // Check Redis availability first
    if (!this.isRedisClientReady()) {
      return err({
        code: ErrorCode.RedisConnectionError,
      });
    }

    const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
    if (!validation.ok) {
      return validation;
    }

    try {
      // Use SET with NX (only set if not exists) and PX (expiration in milliseconds) for atomic lock acquisition
      const result = await this.withTimeout(
        this.redis.set(key, value, {
          NX: true,
          PX: ttlMs,
        })
      );
      // SET returns "OK" if lock was acquired, null if key already exists
      return ok(result === "OK");
    } catch (error) {
      logger.error({ error, key, ttlMs }, "Cache lock operation failed");
      return err({
        code: ErrorCode.RedisOperationError,
      });
    }
  }

  /**
   * Cache wrapper for functions (cache-aside).
   * Never throws due to cache errors; function errors propagate without retry.
   * Must include null in T to support cached null values.
   * @param fn - Function to execute (and optionally cache).
   * @param key - Cache key
   * @param ttlMs - Time to live in milliseconds
   * @returns Cached value if present, otherwise fresh result from fn()
   */
  async withCache<T>(fn: () => Promise<T>, key: CacheKey, ttlMs: number): Promise<T> {
    if (!this.isRedisClientReady()) {
      return await fn();
    }

    const validation = validateInputs([key, ZCacheKey], [ttlMs, ZTtlMs]);
    if (!validation.ok) {
      logger.warn({ error: validation.error, key }, "Invalid cache inputs, executing function directly");
      return await fn();
    }

    const cachedValue = await this.tryGetCachedValue<T>(key, ttlMs);
    if (cachedValue !== undefined) {
      return cachedValue;
    }

    const fresh = await fn();
    await this.trySetCache(key, fresh, ttlMs);
    return fresh;
  }

  private async tryGetCachedValue<T>(key: CacheKey, ttlMs: number): Promise<T | undefined> {
    try {
      const cacheResult = await this.get<T>(key);
      if (cacheResult.ok && cacheResult.data !== null) {
        return cacheResult.data;
      }

      if (cacheResult.ok && cacheResult.data === null) {
        const existsResult = await this.exists(key);
        if (existsResult.ok && existsResult.data) {
          return null as T;
        }
      }

      if (!cacheResult.ok) {
        logger.debug(
          { error: cacheResult.error, key, ttlMs },
          "Cache get operation failed, fetching fresh data"
        );
      }
    } catch (error) {
      logger.debug({ error, key, ttlMs }, "Cache get/exists threw; proceeding to compute fresh value");
    }

    return undefined;
  }

  private async trySetCache(key: CacheKey, value: unknown, ttlMs: number): Promise<void> {
    if (value === undefined) {
      return; // Skip caching undefined values
    }

    try {
      const setResult = await this.set(key, value, ttlMs);
      if (!setResult.ok) {
        logger.debug(
          { error: setResult.error, key, ttlMs },
          "Failed to cache fresh data, but returning result"
        );
      }
    } catch (error) {
      logger.debug({ error, key, ttlMs }, "Cache set threw; returning fresh result");
    }
  }

  /**
   * Check if Redis is available and healthy by testing connectivity with ping
   * @returns Promise<boolean> indicating if Redis is available and responsive
   */
  async isRedisAvailable(): Promise<boolean> {
    if (!this.isRedisClientReady()) {
      return false;
    }

    try {
      await this.withTimeout(this.redis.ping());
      return true;
    } catch (error) {
      logger.debug({ error }, "Redis ping failed during availability check");
      return false;
    }
  }

  /**
   * Fast synchronous check of Redis client state for internal use
   * @returns Boolean indicating if Redis client is ready and connected
   */
  private isRedisClientReady(): boolean {
    return this.redis.isReady && this.redis.isOpen;
  }
}
