import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { logger } from '../../../utils/logger';
import { settingsService } from '../../../services/settingsService';
import {
  buildRedisUrl,
  config,
  getPayPalConfig,
  publishConfigUpdate,
  refreshConfigFromDatabase,
  startConfigSubscription,
} from '../../../config';

describe('Redis configuration', () => {
  it('builds valid URLs with empty and non-empty passwords', () => {
    expect(buildRedisUrl('redis', 6379, 0, '')).toBe('redis://redis:6379/0');
    expect(buildRedisUrl('redis', 6379, 2, 'p@ss:/word')).toBe(
      'redis://:p%40ss%3A%2Fword@redis:6379/2'
    );
  });

  it('keeps complete PayPal environment fallback during refresh', async () => {
    const original = {
      clientId: config.paypalClientId,
      clientSecret: config.paypalClientSecret,
      webhookId: config.paypalWebhookId,
      mode: config.paypalMode,
    };
    config.paypalClientId = 'environment-client';
    config.paypalClientSecret = 'environment-secret';
    config.paypalWebhookId = 'environment-webhook';
    config.paypalMode = 'sandbox';
    jest.spyOn(settingsService, 'getPayPalConfig').mockResolvedValueOnce(undefined);

    try {
      await expect(refreshConfigFromDatabase(['paypal'])).resolves.toBeUndefined();
      expect(getPayPalConfig()).toMatchObject({
        clientId: 'environment-client',
        clientSecret: 'environment-secret',
        webhookId: 'environment-webhook',
        mode: 'sandbox',
      });
    } finally {
      config.paypalClientId = original.clientId;
      config.paypalClientSecret = original.clientSecret;
      config.paypalWebhookId = original.webhookId;
      config.paypalMode = original.mode;
      jest.restoreAllMocks();
    }
  });
});

describe('publishConfigUpdate', () => {
  const createPublisher = () => ({
    connect: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
    zrangebyscore: jest.fn<() => Promise<string[]>>().mockResolvedValue(['worker-a']),
    set: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    publish: jest.fn<() => Promise<number>>().mockResolvedValue(0),
    smembers: jest.fn<() => Promise<string[]>>().mockResolvedValue(['worker-a']),
    expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
    quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
  });

  it('returns only after every live worker acknowledges the exact version', async () => {
    const publisher = createPublisher();

    const result = await publishConfigUpdate(['gemini', 'pricing'], publisher, {
      changeId: 'change-123',
      now: () => 10_000,
      sleep: async () => undefined,
      settingVersions: {
        gemini_api_key: '2026-07-30T00:00:00.000Z',
      },
    });

    expect(result).toEqual({ changeId: 'change-123', workerIds: ['worker-a'] });
    expect(publisher.zrangebyscore).toHaveBeenCalledWith('worker:heartbeats', -5000, 10_000);
    expect(publisher.set).toHaveBeenCalledWith(
      'config:change:change-123',
      JSON.stringify({
        id: 'change-123',
        categories: ['gemini', 'pricing'],
        workerIds: ['worker-a'],
        settingVersions: {
          gemini_api_key: '2026-07-30T00:00:00.000Z',
        },
      }),
      'EX',
      60
    );
    expect(publisher.set).toHaveBeenCalledWith('config:change:latest', 'change-123', 'EX', 60);
    expect(publisher.publish).toHaveBeenCalledWith(
      'config:updated',
      JSON.stringify({ changeId: 'change-123' })
    );
    expect(publisher.smembers).toHaveBeenCalledWith('config:change:change-123:acks');
    expect(publisher.quit).toHaveBeenCalledTimes(1);
  });

  it('does not treat Redis subscriber count as worker application', async () => {
    const publisher = createPublisher();
    publisher.publish.mockResolvedValueOnce(4);
    publisher.smembers.mockResolvedValue([]);

    await expect(publishConfigUpdate(['credits'], publisher, {
      changeId: 'change-timeout',
      now: (() => {
        let now = 0;
        return () => (now += 100);
      })(),
      acknowledgementTimeoutMs: 50,
      settingVersions: { credits_starter: '2026-07-30T00:00:00.000Z' },
      sleep: async () => undefined,
    })).rejects.toThrow('Timed out waiting for worker configuration acknowledgement');
    expect(publisher.quit).toHaveBeenCalledTimes(1);
  });

  it('rejects when no healthy workers are registered', async () => {
    const publisher = createPublisher();
    publisher.zrangebyscore.mockResolvedValueOnce([]);

    await expect(publishConfigUpdate(['gemini'], publisher, {
      settingVersions: { gemini_api_key: '2026-07-30T00:00:00.000Z' },
    })).rejects.toThrow(
      'No healthy workers available for config update'
    );
    expect(publisher.quit).toHaveBeenCalledTimes(1);
  });
});

describe('startConfigSubscription', () => {
  beforeEach(() => {
    jest.spyOn(logger, 'error').mockImplementation(() => logger);
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  it('acknowledges a version only after applying its exact categories', async () => {
    const handlers: Record<string, (...args: any[]) => Promise<void> | void> = {};
    const subscriber = {
      on: jest.fn((event: string, handler: (...args: any[]) => Promise<void> | void) => {
        handlers[event] = handler;
        return subscriber;
      }),
      subscribe: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const acknowledgements = {
      get: jest.fn<(key: string) => Promise<string | null>>()
        .mockResolvedValueOnce(null)
        .mockResolvedValueOnce(JSON.stringify({
          id: 'change-123',
          categories: ['gemini', 'pricing'],
          workerIds: ['worker-a'],
          settingVersions: { gemini_api_key: '2026-07-30T00:00:00.000Z' },
        })),
      ping: jest.fn<() => Promise<string>>().mockResolvedValue('PONG'),
      sadd: jest.fn<(_key: string, _member: string) => Promise<number>>().mockResolvedValue(1),
      expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const refresh = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
    const verifySettingVersions = jest.fn<() => Promise<boolean>>().mockResolvedValue(true);

    const subscription = startConfigSubscription({
      subscriber,
      acknowledgements,
      refresh,
      verifySettingVersions,
      workerId: 'worker-a',
    });
    await handlers.ready?.();
    await handlers.message?.('config:updated', JSON.stringify({ changeId: 'change-123' }));

    expect(refresh).toHaveBeenCalledWith(['gemini', 'pricing']);
    expect(verifySettingVersions).toHaveBeenCalledWith({
      gemini_api_key: '2026-07-30T00:00:00.000Z',
    });
    expect(acknowledgements.sadd).toHaveBeenCalledWith(
      'config:change:change-123:acks',
      'worker-a'
    );
    expect(refresh.mock.invocationCallOrder[1]).toBeLessThan(
      acknowledgements.sadd.mock.invocationCallOrder[0]
    );
    await subscription.close();
    expect(subscriber.quit).toHaveBeenCalledTimes(1);
    expect(acknowledgements.quit).toHaveBeenCalledTimes(1);
  });

  it('does not acknowledge a version when refresh fails', async () => {
    const handlers: Record<string, (...args: any[]) => Promise<void> | void> = {};
    const subscriber = {
      on: jest.fn((event: string, handler: (...args: any[]) => Promise<void> | void) => {
        handlers[event] = handler;
        return subscriber;
      }),
      subscribe: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const acknowledgements = {
      get: jest.fn<() => Promise<string | null>>().mockResolvedValue(JSON.stringify({
        id: 'change-failed',
        categories: ['paypal'],
        workerIds: ['worker-a'],
        settingVersions: { paypal_client_id: '2026-07-30T00:00:00.000Z' },
      })),
      ping: jest.fn<() => Promise<string>>().mockResolvedValue('PONG'),
      sadd: jest.fn<(_key: string, _member: string) => Promise<number>>().mockResolvedValue(1),
      expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const refresh = jest.fn<() => Promise<void>>().mockRejectedValue(new Error('DB failed'));

    startConfigSubscription({ subscriber, acknowledgements, refresh, workerId: 'worker-a' });
    await handlers.message?.('config:updated', JSON.stringify({ changeId: 'change-failed' }));

    expect(acknowledgements.sadd).not.toHaveBeenCalled();
  });

  it('serializes startup replay and live updates in arrival order', async () => {
    const handlers: Record<string, (...args: any[]) => Promise<void> | void> = {};
    const subscriber = {
      status: 'ready',
      on: jest.fn((event: string, handler: (...args: any[]) => Promise<void> | void) => {
        handlers[event] = handler;
        return subscriber;
      }),
      subscribe: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const records: Record<string, string> = {
      'config:change:replay': JSON.stringify({
        id: 'replay', categories: ['gemini'], workerIds: ['worker-a'],
        settingVersions: { gemini_api_key: 'v1' },
      }),
      'config:change:live': JSON.stringify({
        id: 'live', categories: ['paypal'], workerIds: ['worker-a'],
        settingVersions: { paypal_client_id: 'v2' },
      }),
    };
    const acknowledgements = {
      get: jest.fn(async (key: string) => key === 'config:change:latest' ? 'replay' : records[key] ?? null),
      ping: jest.fn<() => Promise<string>>().mockResolvedValue('PONG'),
      sadd: jest.fn<(_key: string, _member: string) => Promise<number>>().mockResolvedValue(1),
      expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    let releaseInitialRefresh!: () => void;
    const initialRefresh = new Promise<void>((resolve) => { releaseInitialRefresh = resolve; });
    const order: string[] = [];
    const refresh = jest.fn(async (categories?: any[]) => {
      if (!categories) {
        order.push('initial:start');
        await initialRefresh;
        order.push('initial:end');
        return;
      }
      order.push(categories[0]);
    });

    const subscription = startConfigSubscription({
      subscriber, acknowledgements, refresh,
      verifySettingVersions: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
      workerId: 'worker-a',
    });
    const ready = handlers.ready?.();
    await Promise.resolve();
    const live = handlers.message?.('config:updated', JSON.stringify({ changeId: 'live' }));
    await Promise.resolve();
    expect(order).toEqual(['initial:start']);

    releaseInitialRefresh();
    await Promise.all([ready, live]);

    expect(order).toEqual(['initial:start', 'initial:end', 'gemini', 'paypal']);
    expect(acknowledgements.sadd.mock.calls.map(([key]) => key)).toEqual([
      'config:change:replay:acks',
      'config:change:live:acks',
    ]);
    await subscription.close();
  });

  it('reports readiness only after replay and acknowledgement Redis are healthy', async () => {
    const handlers: Record<string, (...args: any[]) => Promise<void> | void> = {};
    const subscriber = {
      status: 'ready',
      on: jest.fn((event: string, handler: (...args: any[]) => Promise<void> | void) => {
        handlers[event] = handler;
        return subscriber;
      }),
      subscribe: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const acknowledgements = {
      get: jest.fn<() => Promise<string | null>>().mockResolvedValue(null),
      ping: jest.fn<() => Promise<string>>().mockResolvedValue('PONG'),
      sadd: jest.fn<(_key: string, _member: string) => Promise<number>>().mockResolvedValue(1),
      expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const subscription = startConfigSubscription({
      subscriber,
      acknowledgements,
      refresh: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
      workerId: 'worker-a',
    });

    await expect(subscription.checkReady()).rejects.toThrow('Config subscription is not ready');
    await handlers.ready?.();
    await expect(subscription.checkReady()).resolves.toBeUndefined();
    acknowledgements.ping.mockRejectedValueOnce(new Error('ACK Redis unavailable'));
    await expect(subscription.checkReady()).rejects.toThrow('ACK Redis unavailable');
    await subscription.close();
  });

  it('acknowledges updates for categories the worker does not consume', async () => {
    const handlers: Record<string, (...args: any[]) => Promise<void> | void> = {};
    const subscriber = {
      status: 'ready',
      on: jest.fn((event: string, handler: (...args: any[]) => Promise<void> | void) => {
        handlers[event] = handler;
        return subscriber;
      }),
      subscribe: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const acknowledgements = {
      get: jest.fn<(key: string) => Promise<string | null>>()
        .mockResolvedValueOnce(null)
        .mockResolvedValueOnce(JSON.stringify({
          id: 'paypal-only',
          categories: ['paypal'],
          workerIds: ['worker-a'],
          settingVersions: { paypal_client_id: 'v1' },
        })),
      ping: jest.fn<() => Promise<string>>().mockResolvedValue('PONG'),
      sadd: jest.fn<(_key: string, _member: string) => Promise<number>>().mockResolvedValue(1),
      expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const refresh = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
    const verifySettingVersions = jest.fn<() => Promise<boolean>>().mockResolvedValue(true);
    const categories = ['gemini', 'pricing', 'credits', 'rateLimits'] as const;
    const subscription = startConfigSubscription({
      subscriber,
      acknowledgements,
      refresh,
      verifySettingVersions,
      categories: [...categories],
      workerId: 'worker-a',
    });

    await handlers.ready?.();
    await handlers.message?.('config:updated', JSON.stringify({ changeId: 'paypal-only' }));

    expect(refresh).toHaveBeenCalledTimes(1);
    expect(refresh).toHaveBeenCalledWith(categories);
    expect(verifySettingVersions).toHaveBeenCalledWith({ paypal_client_id: 'v1' });
    expect(acknowledgements.sadd).toHaveBeenCalledWith(
      'config:change:paypal-only:acks',
      'worker-a'
    );
    await subscription.close();
  });

  it('does not acknowledge when persisted setting versions no longer match the update', async () => {
    const handlers: Record<string, (...args: any[]) => Promise<void> | void> = {};
    const subscriber = {
      on: jest.fn((event: string, handler: (...args: any[]) => Promise<void> | void) => {
        handlers[event] = handler;
        return subscriber;
      }),
      subscribe: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const acknowledgements = {
      get: jest.fn<() => Promise<string | null>>().mockResolvedValue(JSON.stringify({
        id: 'change-overwritten',
        categories: ['gemini'],
        workerIds: ['worker-a'],
        settingVersions: { gemini_api_key: '2026-07-30T00:00:00.000Z' },
      })),
      ping: jest.fn<() => Promise<string>>().mockResolvedValue('PONG'),
      sadd: jest.fn<(_key: string, _member: string) => Promise<number>>().mockResolvedValue(1),
      expire: jest.fn<() => Promise<number>>().mockResolvedValue(1),
      quit: jest.fn<() => Promise<'OK'>>().mockResolvedValue('OK'),
    };
    const refresh = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);
    const verifySettingVersions = jest.fn<() => Promise<boolean>>().mockResolvedValue(false);

    startConfigSubscription({
      subscriber,
      acknowledgements,
      refresh,
      verifySettingVersions,
      workerId: 'worker-a',
    });
    await handlers.message?.('config:updated', JSON.stringify({ changeId: 'change-overwritten' }));

    expect(refresh).toHaveBeenCalledWith(['gemini']);
    expect(acknowledgements.sadd).not.toHaveBeenCalled();
  });
});
