// Optional Redis connection manager.
//
// Redis is entirely optional. When REDIS_URL is unset the app runs exactly as
// before with in-memory backends. When set, this module owns a single shared
// command connection plus a dedicated subscriber connection (a connection in
// subscribe mode cannot run normal commands), with auto-reconnect, capped
// backoff, an active PING probe, and a health flag that callers and the health
// endpoint can read.
//
// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path
// selects a DB index (e.g. redis://host:6379/1) when sharing an instance.
// We deliberately do not use ioredis's keyPrefix option: all keys are already
// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a
// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not)
// that would silently break the payment SSE channel.

import Redis from 'ioredis';

let client: Redis | null = null;
let subscriber: Redis | null = null;
let healthy = false;
let initialized = false;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let lastPingOkAt: string | null = null;
let lastPingMs: number | null = null;

// Debounce repeated error logs during a sustained outage: transitions are
// always logged, repeated per-retry errors at most once per LOG_EVERY_MS.
const ERROR_LOG_EVERY_MS = 30_000;
let lastErrorLogAt = 0;

/** Whether Redis is configured via REDIS_URL. */
export function isRedisEnabled(): boolean {
  return !!process.env.REDIS_URL;
}

/** Whether the Redis connection is currently usable. */
export function isRedisHealthy(): boolean {
  return isRedisEnabled() && healthy;
}

/** Detail for the health endpoint: when the last successful PING happened. */
export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } {
  return { lastPingOkAt, lastPingMs };
}

function setHealthy(next: boolean, context: string): void {
  if (next !== healthy) {
    if (next) {
      console.log(`[redis] healthy (${context})`);
    } else {
      console.warn(`[redis] unhealthy (${context})`);
    }
  }
  healthy = next;
}

function logErrorDebounced(label: string, err: unknown): void {
  const now = Date.now();
  if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) {
    lastErrorLogAt = now;
    console.error(`[redis] (${label}) error:`, (err as any)?.message || err);
  }
}

function buildClient(label: string): Redis {
  const url = process.env.REDIS_URL as string;
  const instance = new Redis(url, {
    // Keep the process responsive: fail fast on a per-command basis and let the
    // callers degrade to their in-memory fallback rather than hanging. Do not
    // queue commands while disconnected — with fail-open callers everywhere a
    // growing offline queue would only add latency and memory pressure.
    maxRetriesPerRequest: 1,
    enableOfflineQueue: false,
    lazyConnect: false,
    connectTimeout: 5000,
    retryStrategy(times) {
      // Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
      const delay = Math.min(times * 200, 5000);
      return delay;
    },
  });

  instance.on('connect', () => {
    console.log(`[redis] (${label}) connecting`);
  });
  instance.on('ready', () => {
    setHealthy(true, `${label} ready`);
  });
  instance.on('error', (err) => {
    setHealthy(false, `${label} error`);
    logErrorDebounced(label, err);
  });
  instance.on('reconnecting', () => {
    setHealthy(false, `${label} reconnecting`);
  });
  instance.on('end', () => {
    setHealthy(false, `${label} connection closed`);
  });

  return instance;
}

// Actively probe the command connection. The event-driven flag alone misses a
// silently hung connection; a periodic PING with a hard timeout catches it.
async function pingOnce(): Promise<void> {
  if (!client) return;
  const started = Date.now();
  try {
    await Promise.race([
      client.ping(),
      new Promise((_, reject) => {
        const t = setTimeout(() => reject(new Error('ping timeout')), 2000);
        (t as any).unref?.();
      }),
    ]);
    lastPingMs = Date.now() - started;
    lastPingOkAt = new Date().toISOString();
    setHealthy(true, 'ping ok');
  } catch (err) {
    setHealthy(false, 'ping failed');
    logErrorDebounced('ping', err);
  }
}

function ensureInit(): void {
  if (initialized || !isRedisEnabled()) return;
  initialized = true;
  client = buildClient('commands');
  subscriber = buildClient('subscriber');
  pingTimer = setInterval(() => {
    void pingOnce();
  }, 10_000);
  (pingTimer as any).unref?.();
}

/** Shared command connection, or null when Redis is not configured. */
export function getRedis(): Redis | null {
  ensureInit();
  return client;
}

/** Dedicated subscriber connection, or null when Redis is not configured. */
export function getSubscriber(): Redis | null {
  ensureInit();
  return subscriber;
}

/** Close connections (used for graceful shutdown). */
export async function closeRedis(): Promise<void> {
  if (pingTimer) {
    clearInterval(pingTimer);
    pingTimer = null;
  }
  const tasks: Promise<unknown>[] = [];
  if (client) tasks.push(client.quit().catch(() => undefined));
  if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
  await Promise.all(tasks);
  client = null;
  subscriber = null;
  initialized = false;
  healthy = false;
}
