import Redis from 'ioredis';
import { getRedisUrl } from '../config';

export const MOCK_TRANSLATION_TTL_SECONDS = 24 * 60 * 60;
export const MOCK_TRANSLATION_KEY_PREFIX = 'mock-translator:v1:job:';

export interface MockTranslationObservation {
  calls: number;
  attempts: number;
  released: boolean;
}

interface RedisClient {
  incr(key: string): Promise<number>;
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ...args: Array<string | number>): Promise<unknown>;
  expire(key: string, seconds: number): Promise<number>;
  del(...keys: string[]): Promise<number>;
  publish(channel: string, message: string): Promise<number>;
  duplicate?: () => RedisClient;
  subscribe?: (channel: string) => Promise<unknown>;
  unsubscribe?: (channel: string) => Promise<unknown>;
  on?: (event: string, listener: (...args: unknown[]) => void) => RedisClient;
  removeListener?: (event: string, listener: (...args: unknown[]) => void) => RedisClient;
  quit?: () => Promise<unknown>;
}

export interface MockTranslationStoreOptions {
  redis?: RedisClient;
  subscriber?: RedisClient;
  pollIntervalMs?: number;
}

function assertJobId(jobId: string): void {
  if (typeof jobId !== 'string' || !isUuid(jobId)) {
    throw new Error('Mock translation job ID must be a valid UUID');
  }
}

function isUuid(value: string): boolean {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}

function asCount(value: string | null): number {
  const parsed = Number(value ?? 0);
  return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0;
}

export class MockTranslationStore {
  private readonly redis: RedisClient;
  private readonly subscriber?: RedisClient;
  private readonly pollIntervalMs: number;

  constructor(options: MockTranslationStoreOptions | RedisClient = {}) {
    if (isRedisClient(options)) {
      this.redis = options;
      this.pollIntervalMs = 25;
      return;
    }

    this.redis = options.redis ?? new Redis(getRedisUrl()) as unknown as RedisClient;
    this.subscriber = options.subscriber;
    this.pollIntervalMs = options.pollIntervalMs ?? 25;
  }

  key(jobId: string, suffix: 'gate' | 'calls' | 'attempts'): string {
    assertJobId(jobId);
    return `${MOCK_TRANSLATION_KEY_PREFIX}${jobId}:${suffix}`;
  }

  channel(jobId: string): string {
    assertJobId(jobId);
    return `${MOCK_TRANSLATION_KEY_PREFIX}${jobId}:release`;
  }

  async recordCall(jobId: string, attempt: number): Promise<MockTranslationObservation> {
    assertJobId(jobId);
    if (!Number.isSafeInteger(attempt) || attempt < 1) {
      throw new Error('Mock translation attempt must be a positive integer');
    }

    const callsKey = this.key(jobId, 'calls');
    const attemptsKey = this.key(jobId, 'attempts');
    const gateKey = this.key(jobId, 'gate');
    await this.redis.incr(callsKey);
    await this.redis.set(attemptsKey, String(attempt), 'EX', MOCK_TRANSLATION_TTL_SECONDS);
    await Promise.all([
      this.redis.expire(callsKey, MOCK_TRANSLATION_TTL_SECONDS),
      this.redis.expire(gateKey, MOCK_TRANSLATION_TTL_SECONDS),
    ]);
    return this.getObservation(jobId);
  }

  async getObservation(jobId: string): Promise<MockTranslationObservation> {
    assertJobId(jobId);
    const [gate, calls, attempts] = await Promise.all([
      this.redis.get(this.key(jobId, 'gate')),
      this.redis.get(this.key(jobId, 'calls')),
      this.redis.get(this.key(jobId, 'attempts')),
    ]);
    return {
      calls: asCount(calls),
      attempts: asCount(attempts),
      released: gate === 'released',
    };
  }

  async waitForRelease(jobId: string, signal?: AbortSignal): Promise<void> {
    assertJobId(jobId);
    if (signal?.aborted) throw abortError();
    if ((await this.redis.get(this.key(jobId, 'gate'))) === 'released') return;
    if (signal?.aborted) throw abortError();

    const subscriber = this.subscriber ?? this.redis.duplicate?.();
    const channel = this.channel(jobId);
    let pollTimer: ReturnType<typeof setTimeout> | undefined;
    let settled = false;

    return new Promise<void>((resolve, reject) => {
      const cleanup = async (): Promise<void> => {
        if (pollTimer) clearTimeout(pollTimer);
        signal?.removeEventListener('abort', onAbort);
        if (subscriber?.unsubscribe) await subscriber.unsubscribe(channel);
        if (subscriber?.quit && subscriber !== this.redis) await subscriber.quit();
        if (subscriber?.removeListener) subscriber.removeListener('message', onMessage);
      };
      const finish = (error?: Error): void => {
        if (settled) return;
        settled = true;
        void cleanup().then(() => error ? reject(error) : resolve());
      };
      const check = async (): Promise<void> => {
        if ((await this.redis.get(this.key(jobId, 'gate'))) === 'released') finish();
        else if (!settled) pollTimer = setTimeout(() => {
          void check().catch((error) => finish(error instanceof Error ? error : new Error('Mock release wait failed')));
        }, this.pollIntervalMs);
      };
      const onAbort = (): void => finish(abortError());
      const onMessage = (receivedChannel: unknown): void => {
        if (receivedChannel === channel) void check();
      };

      signal?.addEventListener('abort', onAbort, { once: true });
      if (subscriber?.on) subscriber.on('message', onMessage);
      void (async () => {
        try {
          if (subscriber?.subscribe) await subscriber.subscribe(channel);
          await check();
        } catch (error) {
          finish(error instanceof Error ? error : new Error('Mock release wait failed'));
        }
      })();
    });
  }

  async release(jobId: string): Promise<MockTranslationObservation> {
    assertJobId(jobId);
    const gateKey = this.key(jobId, 'gate');
    await this.redis.set(gateKey, 'released', 'EX', MOCK_TRANSLATION_TTL_SECONDS);
    await this.redis.publish(this.channel(jobId), 'released');
    return this.getObservation(jobId);
  }

  async reset(jobId: string): Promise<void> {
    assertJobId(jobId);
    await this.redis.del(
      this.key(jobId, 'gate'),
      this.key(jobId, 'calls'),
      this.key(jobId, 'attempts')
    );
  }
}

function isRedisClient(value: MockTranslationStoreOptions | RedisClient): value is RedisClient {
  return typeof value === 'object' && value !== null && 'get' in value;
}

function abortError(): Error {
  const error = new Error('Mock translation wait aborted');
  error.name = 'AbortError';
  return error;
}

export const mockTranslationStore = new MockTranslationStore();

export default mockTranslationStore;
