import { randomUUID } from 'crypto';
import { Injectable, Logger } from '@nestjs/common';
import { RedisService } from '../redis/redis.service';

type MemoryEntry = {
  value: string;
  expiresAt: number | null;
};

type MemoryLock = {
  ownerToken: string;
  expiresAt: number;
};

export type CacheFillLock = {
  readonly acquired: boolean;
  extend: (ttlSeconds?: number) => Promise<boolean>;
  release: () => Promise<boolean>;
};

const RELEASE_LOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
end
return 0
`;

const EXTEND_LOCK_SCRIPT = `
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('pexpire', KEYS[1], ARGV[2])
end
return 0
`;

@Injectable()
export class AppCacheService {
  private static readonly DEFAULT_FILL_LOCK_TTL_SECONDS = 30;
  private static readonly DEFAULT_FILL_WAIT_TIMEOUT_MS = 30_000;
  private static readonly DEFAULT_FILL_POLL_MS = 50;

  private readonly logger = new Logger(AppCacheService.name);
  private readonly memory = new Map<string, MemoryEntry>();
  private readonly memoryLocks = new Map<string, MemoryLock>();
  private readonly inFlight = new Map<string, Promise<unknown>>();

  constructor(private readonly redisService: RedisService) {}

  async get<T>(key: string): Promise<T | null> {
    const redis = this.redisService.getClient();
    const fullKey = this.buildKey(key);

    if (redis) {
      const value = await redis.get(fullKey);
      if (!value) {
        return null;
      }
      return JSON.parse(value) as T;
    }

    const memoryEntry = this.memory.get(fullKey);
    if (!memoryEntry) {
      return null;
    }

    if (memoryEntry.expiresAt && memoryEntry.expiresAt <= Date.now()) {
      this.memory.delete(fullKey);
      return null;
    }

    return JSON.parse(memoryEntry.value) as T;
  }

  async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
    const redis = this.redisService.getClient();
    const fullKey = this.buildKey(key);
    const serialized = JSON.stringify(value);

    if (redis) {
      if (ttlSeconds && ttlSeconds > 0) {
        await redis.set(fullKey, serialized, 'EX', ttlSeconds);
        return;
      }

      await redis.set(fullKey, serialized);
      return;
    }

    const expiresAt = ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null;
    this.memory.set(fullKey, { value: serialized, expiresAt });
  }

  async setIfAbsent<T>(key: string, value: T, ttlSeconds: number): Promise<boolean> {
    const redis = this.redisService.getClient();
    const fullKey = this.buildKey(key);
    const serialized = JSON.stringify(value);

    if (redis) {
      const result = await redis.set(fullKey, serialized, 'EX', Math.max(1, ttlSeconds), 'NX');
      return result === 'OK';
    }

    const existing = this.memory.get(fullKey);
    if (existing && (!existing.expiresAt || existing.expiresAt > Date.now())) {
      return false;
    }

    this.memory.set(fullKey, {
      value: serialized,
      expiresAt: Date.now() + Math.max(1, ttlSeconds) * 1000,
    });
    return true;
  }

  async del(key: string): Promise<void> {
    const redis = this.redisService.getClient();
    const fullKey = this.buildKey(key);
    if (redis) {
      await redis.del(fullKey);
      return;
    }

    this.memory.delete(fullKey);
  }

  async remember<T>(key: string, ttlSeconds: number, factory: () => Promise<T>): Promise<T> {
    const cached = await this.get<T>(key);
    if (cached !== null) {
      return cached;
    }

    const fullKey = this.buildKey(key);
    const existingFill = this.inFlight.get(fullKey) as Promise<T> | undefined;
    if (existingFill) {
      return existingFill;
    }

    const fill = this.fillRememberedValue(key, ttlSeconds, factory);
    this.inFlight.set(fullKey, fill);

    try {
      return await fill;
    } finally {
      if (this.inFlight.get(fullKey) === fill) {
        this.inFlight.delete(fullKey);
      }
    }
  }

  /**
   * Acquires a short-lived cache-fill lease. The returned release/extend
   * functions are ownership checked, so an expired lock can never be removed
   * or extended by its previous owner.
   */
  async acquireFillLock(key: string, ttlSeconds: number): Promise<CacheFillLock> {
    const redis = this.redisService.getClient();
    const lockKey = this.buildLockKey(key);
    const ownerToken = randomUUID();
    const ttlMs = this.normalizeLockTtlMilliseconds(ttlSeconds);

    if (redis) {
      const result = await redis.set(lockKey, ownerToken, 'PX', ttlMs, 'NX');
      if (result !== 'OK') {
        return this.createUnavailableLock();
      }

      let released = false;
      return {
        acquired: true,
        extend: async (nextTtlSeconds = ttlSeconds): Promise<boolean> => {
          if (released) {
            return false;
          }

          const extended = await redis.eval(
            EXTEND_LOCK_SCRIPT,
            1,
            lockKey,
            ownerToken,
            this.normalizeLockTtlMilliseconds(nextTtlSeconds),
          );
          return Number(extended) === 1;
        },
        release: async (): Promise<boolean> => {
          if (released) {
            return false;
          }

          const deleted = await redis.eval(RELEASE_LOCK_SCRIPT, 1, lockKey, ownerToken);
          if (Number(deleted) === 1) {
            released = true;
            return true;
          }
          return false;
        },
      };
    }

    const now = Date.now();
    const current = this.memoryLocks.get(lockKey);
    if (current && current.expiresAt > now) {
      return this.createUnavailableLock();
    }

    this.memoryLocks.set(lockKey, { ownerToken, expiresAt: now + ttlMs });
    let released = false;

    return {
      acquired: true,
      extend: async (nextTtlSeconds = ttlSeconds): Promise<boolean> => {
        if (released) {
          return false;
        }

        const lock = this.memoryLocks.get(lockKey);
        if (!lock || lock.ownerToken !== ownerToken || lock.expiresAt <= Date.now()) {
          return false;
        }

        lock.expiresAt = Date.now() + this.normalizeLockTtlMilliseconds(nextTtlSeconds);
        return true;
      },
      release: async (): Promise<boolean> => {
        if (released) {
          return false;
        }

        const lock = this.memoryLocks.get(lockKey);
        if (!lock || lock.ownerToken !== ownerToken) {
          return false;
        }

        this.memoryLocks.delete(lockKey);
        released = true;
        return true;
      },
    };
  }

  async waitForValue<T>(key: string, timeoutMs: number, pollMs = 50): Promise<T | null> {
    const timeout = this.normalizeDurationMilliseconds(timeoutMs, 0);
    const pollInterval = this.normalizeDurationMilliseconds(pollMs, 1);
    const deadline = Date.now() + timeout;

    while (true) {
      const cached = await this.get<T>(key);
      if (cached !== null) {
        return cached;
      }

      const remaining = deadline - Date.now();
      if (remaining <= 0) {
        return null;
      }

      await this.delay(Math.min(this.withJitter(pollInterval), remaining));
    }
  }

  async incr(key: string, ttlSeconds?: number): Promise<number> {
    const redis = this.redisService.getClient();
    const fullKey = this.buildKey(key);

    if (redis) {
      const nextValue = await redis.incr(fullKey);
      if (ttlSeconds && ttlSeconds > 0 && nextValue === 1) {
        await redis.expire(fullKey, ttlSeconds);
      }
      return nextValue;
    }

    const existing = this.memory.get(fullKey);
    const currentValue =
      existing && (!existing.expiresAt || existing.expiresAt > Date.now())
        ? (JSON.parse(existing.value) as number)
        : 0;
    const nextValue = currentValue + 1;
    this.memory.set(fullKey, {
      value: JSON.stringify(nextValue),
      expiresAt: ttlSeconds && ttlSeconds > 0 ? Date.now() + ttlSeconds * 1000 : null,
    });
    return nextValue;
  }

  private buildKey(key: string): string {
    return `${this.redisService.getKeyPrefix()}:${key}`;
  }

  private buildLockKey(key: string): string {
    return `${this.buildKey(key)}:__fill_lock`;
  }

  private async fillRememberedValue<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>,
  ): Promise<T> {
    const cachedAfterJoining = await this.get<T>(key);
    if (cachedAfterJoining !== null) {
      return cachedAfterJoining;
    }

    const lockTtlSeconds = AppCacheService.DEFAULT_FILL_LOCK_TTL_SECONDS;
    const deadline = Date.now() + AppCacheService.DEFAULT_FILL_WAIT_TIMEOUT_MS;

    while (Date.now() < deadline) {
      const lock = await this.acquireFillLock(key, lockTtlSeconds);
      if (lock.acquired) {
        return this.fillAsLockOwner(key, ttlSeconds, factory, lock, lockTtlSeconds);
      }

      const value = await this.waitForValueWhileLocked<T>(
        key,
        Math.max(0, deadline - Date.now()),
        AppCacheService.DEFAULT_FILL_POLL_MS,
      );
      if (value !== null) {
        return value;
      }
    }

    // Keep request latency bounded if a remote owner is unhealthy or its
    // factory never completes. This rare fallback may duplicate work, but it
    // prevents an orphaned cache lease from taking down the request path.
    const lastCachedValue = await this.get<T>(key);
    if (lastCachedValue !== null) {
      return lastCachedValue;
    }

    const value = await factory();
    await this.set(key, value, ttlSeconds);
    return value;
  }

  private async fillAsLockOwner<T>(
    key: string,
    ttlSeconds: number,
    factory: () => Promise<T>,
    lock: CacheFillLock,
    lockTtlSeconds: number,
  ): Promise<T> {
    const heartbeat = setInterval(() => {
      void lock.extend(lockTtlSeconds).catch((error: unknown) => {
        this.logger.warn(`Could not extend cache fill lock for ${key}: ${this.errorMessage(error)}`);
      });
    }, Math.max(1_000, Math.floor((lockTtlSeconds * 1000) / 3)));
    heartbeat.unref?.();

    try {
      // A different owner may have filled the value immediately before this
      // process acquired the lease.
      const cached = await this.get<T>(key);
      if (cached !== null) {
        return cached;
      }

      const value = await factory();
      await this.set(key, value, ttlSeconds);
      return value;
    } finally {
      clearInterval(heartbeat);
      try {
        await lock.release();
      } catch (error: unknown) {
        // A successful cache fill must not be turned into a failed request by
        // a transient Redis error during best-effort lease cleanup.
        this.logger.warn(`Could not release cache fill lock for ${key}: ${this.errorMessage(error)}`);
      }
    }
  }

  private async waitForValueWhileLocked<T>(
    key: string,
    timeoutMs: number,
    pollMs: number,
  ): Promise<T | null> {
    const deadline = Date.now() + timeoutMs;

    while (true) {
      const cached = await this.get<T>(key);
      if (cached !== null) {
        return cached;
      }

      if (!(await this.isFillLockHeld(key))) {
        return null;
      }

      const remaining = deadline - Date.now();
      if (remaining <= 0) {
        return null;
      }

      await this.delay(Math.min(this.withJitter(pollMs), remaining));
    }
  }

  private async isFillLockHeld(key: string): Promise<boolean> {
    const redis = this.redisService.getClient();
    const lockKey = this.buildLockKey(key);
    if (redis) {
      return (await redis.exists(lockKey)) === 1;
    }

    const lock = this.memoryLocks.get(lockKey);
    if (!lock) {
      return false;
    }

    if (lock.expiresAt <= Date.now()) {
      this.memoryLocks.delete(lockKey);
      return false;
    }
    return true;
  }

  private createUnavailableLock(): CacheFillLock {
    return {
      acquired: false,
      extend: async () => false,
      release: async () => false,
    };
  }

  private normalizeLockTtlMilliseconds(ttlSeconds: number): number {
    if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
      return 1_000;
    }
    return Math.max(1, Math.ceil(ttlSeconds * 1000));
  }

  private normalizeDurationMilliseconds(value: number, minimum: number): number {
    if (!Number.isFinite(value)) {
      return minimum;
    }
    return Math.max(minimum, Math.floor(value));
  }

  private withJitter(durationMs: number): number {
    return Math.max(1, Math.round(durationMs * (0.8 + Math.random() * 0.4)));
  }

  private delay(durationMs: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, durationMs));
  }

  private errorMessage(error: unknown): string {
    return error instanceof Error ? error.message : String(error);
  }
}
