import { RedisService } from '../redis/redis.service';
import { AppCacheService } from './app-cache.service';

class FakeRedis {
  readonly values = new Map<string, string>();

  async get(key: string): Promise<string | null> {
    return this.values.get(key) ?? null;
  }

  async set(
    key: string,
    value: string,
    ...options: Array<string | number>
  ): Promise<'OK' | null> {
    if (options.includes('NX') && this.values.has(key)) {
      return null;
    }
    this.values.set(key, value);
    return 'OK';
  }

  async exists(key: string): Promise<number> {
    return this.values.has(key) ? 1 : 0;
  }

  async eval(
    script: string,
    _numberOfKeys: number,
    key: string,
    ownerToken: string,
    _ttlMs?: number,
  ): Promise<number> {
    if (this.values.get(key) !== ownerToken) {
      return 0;
    }
    if (script.includes("redis.call('pexpire'")) {
      return 1;
    }
    this.values.delete(key);
    return 1;
  }
}

const createRedisService = (client: FakeRedis | null): RedisService =>
  ({
    getClient: jest.fn(() => client),
    getKeyPrefix: jest.fn(() => 'test'),
  }) as unknown as RedisService;

const nextTurn = (): Promise<void> => new Promise((resolve) => setImmediate(resolve));

describe('AppCacheService stampede protection', () => {
  it('coalesces concurrent local cache misses into one factory call', async () => {
    const service = new AppCacheService(createRedisService(null));
    let resolveFactory!: (value: { id: number }) => void;
    const factory = jest.fn(
      () =>
        new Promise<{ id: number }>((resolve) => {
          resolveFactory = resolve;
        }),
    );

    const requests = Array.from({ length: 25 }, () => service.remember('feed:1', 60, factory));
    await nextTurn();
    expect(factory).toHaveBeenCalledTimes(1);
    resolveFactory({ id: 42 });

    await expect(Promise.all(requests)).resolves.toEqual(
      Array.from({ length: 25 }, () => ({ id: 42 })),
    );
    await expect(service.remember('feed:1', 60, factory)).resolves.toEqual({ id: 42 });
    expect(factory).toHaveBeenCalledTimes(1);
  });

  it('clears a failed local single-flight so a later request can retry', async () => {
    const service = new AppCacheService(createRedisService(null));
    const failedFactory = jest.fn().mockRejectedValue(new Error('database unavailable'));

    await expect(service.remember('feed:failure', 60, failedFactory)).rejects.toThrow(
      'database unavailable',
    );
    await expect(
      service.remember('feed:failure', 60, async () => ({ recovered: true })),
    ).resolves.toEqual({ recovered: true });
    expect(failedFactory).toHaveBeenCalledTimes(1);
  });

  it('uses a shared Redis lease to coalesce fills across service instances', async () => {
    const redis = new FakeRedis();
    const firstService = new AppCacheService(createRedisService(redis));
    const secondService = new AppCacheService(createRedisService(redis));
    let resolveFirst!: (value: string) => void;
    const firstFactory = jest.fn(
      () =>
        new Promise<string>((resolve) => {
          resolveFirst = resolve;
        }),
    );
    const secondFactory = jest.fn(async () => 'duplicate');

    const first = firstService.remember('feed:shared', 60, firstFactory);
    await nextTurn();
    expect(firstFactory).toHaveBeenCalledTimes(1);
    const second = secondService.remember('feed:shared', 60, secondFactory);
    await nextTurn();
    resolveFirst('canonical');

    await expect(Promise.all([first, second])).resolves.toEqual(['canonical', 'canonical']);
    expect(secondFactory).not.toHaveBeenCalled();
  });

  it('never releases or extends a Redis lock now owned by another request', async () => {
    const redis = new FakeRedis();
    const service = new AppCacheService(createRedisService(redis));
    const lease = await service.acquireFillLock('feed:ownership', 10);
    const lockKey = 'test:feed:ownership:__fill_lock';

    expect(lease.acquired).toBe(true);
    redis.values.set(lockKey, 'replacement-owner');
    await expect(lease.extend(20)).resolves.toBe(false);
    await expect(lease.release()).resolves.toBe(false);
    expect(redis.values.get(lockKey)).toBe('replacement-owner');
  });

  it('releases its own Redis lock atomically and only once', async () => {
    const redis = new FakeRedis();
    const service = new AppCacheService(createRedisService(redis));
    const lease = await service.acquireFillLock('feed:release', 10);

    await expect(lease.release()).resolves.toBe(true);
    await expect(lease.release()).resolves.toBe(false);
    expect(redis.values.has('test:feed:release:__fill_lock')).toBe(false);
  });

  it('waits for a value populated by another request', async () => {
    const service = new AppCacheService(createRedisService(null));
    const waiting = service.waitForValue<{ ready: boolean }>('feed:waiting', 200, 5);
    setTimeout(() => void service.set('feed:waiting', { ready: true }, 60), 10);
    await expect(waiting).resolves.toEqual({ ready: true });
  });
});
