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

type RedisDouble = {
  get: jest.Mock;
  set: jest.Mock;
  del: jest.Mock;
  incr: jest.Mock;
  expire: jest.Mock;
  eval: jest.Mock;
  exists: jest.Mock;
};

const redisDouble = (): RedisDouble => ({
  get: jest.fn(), set: jest.fn(), del: jest.fn(), incr: jest.fn(), expire: jest.fn(),
  eval: jest.fn(), exists: jest.fn(),
});

const redisService = (client: RedisDouble | null): RedisService =>
  ({ getClient: jest.fn(() => client), getKeyPrefix: jest.fn(() => 'spec') }) as unknown as RedisService;

type CacheInternals = {
  normalizeLockTtlMilliseconds(value: number): number;
  normalizeDurationMilliseconds(value: number, minimum: number): number;
  withJitter(value: number): number;
  errorMessage(error: unknown): string;
};

describe('AppCacheService behavior', () => {
  it('supports memory get, set, expiration, deletion, and absent writes', async () => {
    const service = new AppCacheService(redisService(null));
    await service.set('plain', { id: 1 });
    await expect(service.get('plain')).resolves.toEqual({ id: 1 });
    await expect(service.setIfAbsent('plain', 2, 10)).resolves.toBe(false);
    await service.del('plain');
    await expect(service.get('plain')).resolves.toBeNull();
    await expect(service.setIfAbsent('plain', 2, 0)).resolves.toBe(true);
    await expect(service.get('plain')).resolves.toBe(2);

    const now = jest.spyOn(Date, 'now');
    now.mockReturnValueOnce(1000);
    await service.set('short', 'value', 1);
    now.mockReturnValue(2001);
    await expect(service.get('short')).resolves.toBeNull();
    now.mockRestore();
  });

  it('uses Redis for CRUD, NX, and JSON serialization', async () => {
    const redis = redisDouble();
    const service = new AppCacheService(redisService(redis));
    redis.get.mockResolvedValueOnce(null).mockResolvedValueOnce('{"id":4}');
    await expect(service.get('missing')).resolves.toBeNull();
    await expect(service.get('item')).resolves.toEqual({ id: 4 });
    await service.set('ttl', { yes: true }, 4);
    expect(redis.set).toHaveBeenCalledWith('spec:ttl', '{"yes":true}', 'EX', 4);
    await service.set('forever', 1, 0);
    expect(redis.set).toHaveBeenCalledWith('spec:forever', '1');
    redis.set.mockResolvedValueOnce('OK').mockResolvedValueOnce(null);
    await expect(service.setIfAbsent('one', 1, 0)).resolves.toBe(true);
    await expect(service.setIfAbsent('one', 1, 10)).resolves.toBe(false);
    expect(redis.set).toHaveBeenCalledWith('spec:one', '1', 'EX', 1, 'NX');
    await service.del('one');
    expect(redis.del).toHaveBeenCalledWith('spec:one');
  });

  it('increments Redis values and expires only a newly created counter', async () => {
    const redis = redisDouble();
    const service = new AppCacheService(redisService(redis));
    redis.incr.mockResolvedValueOnce(1).mockResolvedValueOnce(2);
    await expect(service.incr('counter', 5)).resolves.toBe(1);
    await expect(service.incr('counter', 5)).resolves.toBe(2);
    expect(redis.expire).toHaveBeenCalledTimes(1);
    expect(redis.expire).toHaveBeenCalledWith('spec:counter', 5);
  });

  it('increments memory counters and resets expired values', async () => {
    const service = new AppCacheService(redisService(null));
    await expect(service.incr('counter')).resolves.toBe(1);
    await expect(service.incr('counter')).resolves.toBe(2);
    const now = jest.spyOn(Date, 'now');
    now.mockReturnValueOnce(1000);
    await service.set('expiring-counter', 9, 1);
    now.mockReturnValue(2001);
    await expect(service.incr('expiring-counter', 5)).resolves.toBe(1);
    now.mockRestore();
  });

  it('enforces memory lock ownership, extension, expiration, and one-time release', async () => {
    const service = new AppCacheService(redisService(null));
    const lock = await service.acquireFillLock('fill', 10);
    expect(lock.acquired).toBe(true);
    expect((await service.acquireFillLock('fill', 10)).acquired).toBe(false);
    await expect(lock.extend(20)).resolves.toBe(true);
    await expect(lock.release()).resolves.toBe(true);
    await expect(lock.extend()).resolves.toBe(false);
    await expect(lock.release()).resolves.toBe(false);

    const now = jest.spyOn(Date, 'now');
    now.mockReturnValueOnce(1000);
    await service.acquireFillLock('expired', 1);
    now.mockReturnValue(3000);
    expect((await service.acquireFillLock('expired', 1)).acquired).toBe(true);
    now.mockRestore();
  });

  it('handles Redis lock contention, extension, and release', async () => {
    const redis = redisDouble();
    const service = new AppCacheService(redisService(redis));
    redis.set.mockResolvedValueOnce(null);
    const unavailable = await service.acquireFillLock('fill', Number.NaN);
    expect(unavailable.acquired).toBe(false);
    await expect(unavailable.extend()).resolves.toBe(false);
    await expect(unavailable.release()).resolves.toBe(false);

    redis.set.mockResolvedValueOnce('OK');
    redis.eval.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
    const lock = await service.acquireFillLock('fill', 0);
    expect(lock.acquired).toBe(true);
    await expect(lock.extend(2)).resolves.toBe(true);
    await expect(lock.release()).resolves.toBe(true);
    await expect(lock.release()).resolves.toBe(false);
    expect(redis.set).toHaveBeenLastCalledWith(expect.stringContaining('__fill_lock'), expect.any(String), 'PX', 1000, 'NX');
  });

  it('returns immediately when waiting has no time budget', async () => {
    const service = new AppCacheService(redisService(null));
    await expect(service.waitForValue('never', 0, Number.NaN)).resolves.toBeNull();
  });

  it('releases a remember lease even when factories reject and tolerates release failures', async () => {
    const service = new AppCacheService(redisService(null));
    const release = jest.fn().mockRejectedValue(new Error('Redis unavailable'));
    const lock: CacheFillLock = { acquired: true, extend: jest.fn().mockResolvedValue(true), release };
    jest.spyOn(service, 'acquireFillLock').mockResolvedValue(lock);
    await expect(service.remember('broken', 10, async () => { throw new Error('factory failed'); })).rejects.toThrow('factory failed');
    expect(release).toHaveBeenCalled();
  });

  it('normalizes unsafe durations and formats unknown errors', () => {
    const service = new AppCacheService(redisService(null));
    const target = service as unknown as CacheInternals;
    expect(target.normalizeLockTtlMilliseconds(-1)).toBe(1000);
    expect(target.normalizeLockTtlMilliseconds(0.0001)).toBe(1);
    expect(target.normalizeDurationMilliseconds(Number.NaN, 3)).toBe(3);
    expect(target.normalizeDurationMilliseconds(2.9, 3)).toBe(3);
    expect(target.withJitter(0)).toBeGreaterThanOrEqual(1);
    expect(target.errorMessage(new Error('boom'))).toBe('boom');
    expect(target.errorMessage('boom')).toBe('boom');
  });
});
