import { describe, it, expect, vi, beforeEach } from 'vitest';
import RedisMock from 'ioredis-mock';

const mocks = vi.hoisted(() => ({ redis: null as any }));

vi.mock('../redis.js', () => ({
  isRedisEnabled: () => true,
  getRedis: () => mocks.redis,
}));

import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js';

describe('MemoryLock', () => {
  it('grants the lock, blocks contenders, and frees on release', async () => {
    const lock = new MemoryLock();
    const token = await lock.acquire('a', 60_000);
    expect(token).toBeTruthy();
    expect(await lock.acquire('a', 60_000)).toBeNull();
    await lock.release('a', token!);
    expect(await lock.acquire('a', 60_000)).toBeTruthy();
  });

  it('ignores release with the wrong token', async () => {
    const lock = new MemoryLock();
    const token = await lock.acquire('a', 60_000);
    await lock.release('a', 'not-the-token');
    expect(await lock.acquire('a', 60_000)).toBeNull();
    await lock.release('a', token!);
  });

  it('withLock runs fn while held and returns null under contention', async () => {
    const lock = new MemoryLock();
    const held = await lock.acquire('a', 60_000);
    expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull();
    await lock.release('a', held!);
    expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran');
    // Released after withLock completes.
    expect(await lock.acquire('a', 60_000)).toBeTruthy();
  });
});

describe('RedisLock', () => {
  beforeEach(async () => {
    mocks.redis = new RedisMock();
    // ioredis-mock shares data between instances by connection string.
    await mocks.redis.flushall();
  });

  it('grants the lock and blocks contenders', async () => {
    const lock = new RedisLock();
    const token = await lock.acquire('a', 60_000);
    expect(token).toBeTruthy();
    expect(await lock.acquire('a', 60_000)).toBeNull();
  });

  it('release is compare-and-delete: wrong token does not free the lock', async () => {
    const lock = new RedisLock();
    const token = await lock.acquire('a', 60_000);
    await lock.release('a', 'not-the-token');
    expect(await lock.acquire('a', 60_000)).toBeNull();
    await lock.release('a', token!);
    expect(await lock.acquire('a', 60_000)).toBeTruthy();
  });

  it('throws LockUnavailableError when the client is not initialized', async () => {
    mocks.redis = null;
    const lock = new RedisLock();
    await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
  });

  it('throws LockUnavailableError when the backend errors', async () => {
    mocks.redis = { set: () => Promise.reject(new Error('connection refused')) };
    const lock = new RedisLock();
    await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError);
  });

  it('withLock skips (returns null, fn not called) when unavailable by default', async () => {
    mocks.redis = null;
    const lock = new RedisLock();
    const fn = vi.fn(async () => 'ran');
    expect(await lock.withLock('a', 60_000, fn)).toBeNull();
    expect(fn).not.toHaveBeenCalled();
  });

  it('withLock runs fn without the lock when onUnavailable is "run"', async () => {
    mocks.redis = null;
    const lock = new RedisLock();
    const fn = vi.fn(async () => 'ran');
    expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran');
    expect(fn).toHaveBeenCalledOnce();
  });

  it('withLock returns fn result and releases the lock afterwards', async () => {
    const lock = new RedisLock();
    expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42);
    expect(await lock.acquire('a', 60_000)).toBeTruthy();
  });
});
