/**
 * @beignet/provider-redis
 *
 * Redis provider that extends ports with cache capabilities using ioredis.
 *
 * Configuration:
 * - REDIS_URL: Redis connection URL (required)
 * - REDIS_DB: Redis database number (optional, default: 0)
 * - REDIS_CONNECT_TIMEOUT_MS: Initial connection timeout in milliseconds (optional, default: 5000)
 * - REDIS_MAX_RETRIES_PER_REQUEST: Per-command retry limit (optional, default: 2)
 * - REDIS_CONNECT_MAX_ATTEMPTS: Connection attempts before startup fails (optional, default: 3)
 *
 * @example
 * ```ts
 * import { createNextServer } from "@beignet/next";
 * import { redisProvider } from "@beignet/provider-redis";
 *
 * const server = await createNextServer({
 *   ports: basePorts,
 *   providers: [redisProvider],
 *   // ...
 * });
 *
 * // In your use cases:
 * async function myUseCase(ctx: AppCtx) {
 *   const cached = await ctx.ports.cache.get("key");
 *   if (!cached) {
 *     const value = await fetchData();
 *     await ctx.ports.cache.set("key", value, { ttlSeconds: 3600 });
 *   }
 * }
 * ```
 */

import type { CachePort } from "@beignet/core/ports";
import {
  createProvider,
  createProviderInstrumentation,
} from "@beignet/core/providers";
import { Redis } from "ioredis";
import { z } from "zod";

const DEFAULT_CONNECT_TIMEOUT_MS = 5000;
const DEFAULT_MAX_RETRIES_PER_REQUEST = 2;
const DEFAULT_CONNECT_MAX_ATTEMPTS = 3;
const RECONNECT_BACKOFF_CAP_MS = 2000;

const NumberString = z
  .string()
  .regex(/^\d+$/, "Expected a non-negative integer string")
  .transform((value) => Number.parseInt(value, 10));

/**
 * Configuration schema for the Redis provider.
 * Validates environment variables with REDIS_ prefix.
 */
const RedisConfigSchema = z.object({
  /**
   * Redis connection URL.
   * Example: "redis://localhost:6379"
   */
  URL: z.string().url(),

  /**
   * Redis database number (optional).
   * Defaults to 0 if not specified.
   */
  DB: NumberString.optional(),

  /**
   * Timeout for the initial connection attempt, in milliseconds.
   * Defaults to 5000.
   */
  CONNECT_TIMEOUT_MS: NumberString.default(DEFAULT_CONNECT_TIMEOUT_MS),

  /**
   * How many times a single command is retried before its promise rejects.
   * Defaults to 2.
   */
  MAX_RETRIES_PER_REQUEST: NumberString.default(
    DEFAULT_MAX_RETRIES_PER_REQUEST,
  ),

  /**
   * Connection attempts allowed before startup `connect()` rejects.
   * Defaults to 3.
   */
  CONNECT_MAX_ATTEMPTS: NumberString.default(DEFAULT_CONNECT_MAX_ATTEMPTS),
});

/**
 * Inferred configuration type for Redis provider.
 */
export type RedisConfig = z.infer<typeof RedisConfigSchema>;

/**
 * Options for {@link createRedisProvider}.
 *
 * Numeric options become schema defaults, so matching `REDIS_*` environment
 * variables still win when both are set.
 */
export interface RedisProviderOptions {
  /**
   * Timeout for the initial connection attempt, in milliseconds.
   *
   * @default 5000
   */
  connectTimeoutMs?: number;

  /**
   * How many times a single command is retried before its promise rejects.
   *
   * @default 2
   */
  maxRetriesPerRequest?: number;

  /**
   * Custom ioredis retry strategy. Replaces the default strategy, which stops
   * retrying after `REDIS_CONNECT_MAX_ATTEMPTS` during the initial connect and
   * reconnects with capped exponential backoff afterwards.
   */
  retryStrategy?: (times: number) => number | null;

  /**
   * Redis database number.
   *
   * @default 0
   */
  db?: number;
}

export interface RedisHealth {
  ok: boolean;
  message?: string;
  metadata: {
    adapter: "redis";
    response?: string;
  };
}

/**
 * Escape hatch for apps that need the raw ioredis client.
 */
export interface RedisEscapeHatch {
  /**
   * Raw ioredis client.
   * Use this for Redis operations the stable cache port does not model.
   */
  client: Redis;
  /**
   * Check whether Redis responds to a cheap PING command.
   */
  checkHealth(): Promise<RedisHealth>;
}

/**
 * Ports contributed by the Redis provider.
 */
export interface RedisProviderPorts {
  /**
   * Beignet cache port backed by Redis.
   */
  cache: CachePort;
  /**
   * Raw ioredis client escape hatch.
   */
  redis: RedisEscapeHatch;
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

function redactConnectionUrl(url: string): string {
  try {
    const parsed = new URL(url);
    if (parsed.username) parsed.username = "redacted";
    if (parsed.password) parsed.password = "redacted";
    if (parsed.search) parsed.search = "?redacted";
    parsed.hash = "";
    return parsed.toString();
  } catch {
    return "[invalid URL]";
  }
}

function reconnectBackoff(attempt: number): number {
  return Math.min(2 ** attempt * 100, RECONNECT_BACKOFF_CAP_MS);
}

async function checkRedisHealth(client: Redis): Promise<RedisHealth> {
  try {
    const response = await client.ping();
    return {
      ok: true,
      metadata: { adapter: "redis", response },
    };
  } catch (error) {
    return {
      ok: false,
      message: errorMessage(error),
      metadata: { adapter: "redis" },
    };
  }
}

/**
 * Create an env-backed Redis cache provider.
 *
 * Reads `REDIS_*` env vars, contributes `ports.cache`, and exposes
 * `ports.redis` for raw ioredis client access.
 *
 * @example
 * ```ts
 * const provider = createRedisProvider({ connectTimeoutMs: 2000 });
 * ```
 */
export function createRedisProvider(options: RedisProviderOptions = {}) {
  const ConfigSchema = RedisConfigSchema.extend({
    DB:
      options.db !== undefined
        ? NumberString.default(options.db)
        : RedisConfigSchema.shape.DB,
    CONNECT_TIMEOUT_MS:
      options.connectTimeoutMs !== undefined
        ? NumberString.default(options.connectTimeoutMs)
        : RedisConfigSchema.shape.CONNECT_TIMEOUT_MS,
    MAX_RETRIES_PER_REQUEST:
      options.maxRetriesPerRequest !== undefined
        ? NumberString.default(options.maxRetriesPerRequest)
        : RedisConfigSchema.shape.MAX_RETRIES_PER_REQUEST,
  });

  return createProvider({
    name: "redis",

    config: {
      schema: ConfigSchema,
      envPrefix: "REDIS_",
    },

    async setup({ ports, config }) {
      if (!config) {
        throw new Error(
          "[redisProvider] Missing Redis config. " +
            "Please set REDIS_URL environment variable.",
        );
      }

      const connectMaxAttempts =
        config.CONNECT_MAX_ATTEMPTS ?? DEFAULT_CONNECT_MAX_ATTEMPTS;

      // ioredis uses a single retryStrategy for both the initial connect and
      // later reconnects, so track whether the first connect ever succeeded.
      let connectedOnce = false;

      const defaultRetryStrategy = (attempt: number): number | null => {
        if (!connectedOnce) {
          // Stop retrying during the initial connect so `connect()` below
          // rejects promptly instead of hanging against an unreachable host.
          if (attempt >= connectMaxAttempts) {
            return null;
          }
          return reconnectBackoff(attempt);
        }
        // After a successful connection, keep reconnecting with capped
        // exponential backoff.
        return reconnectBackoff(attempt);
      };

      const client = new Redis(config.URL, {
        db: config.DB ?? options.db ?? 0,
        // Defer connecting until the explicit connect() below so startup can
        // surface a clear error instead of connecting in the background.
        lazyConnect: true,
        connectTimeout:
          config.CONNECT_TIMEOUT_MS ??
          options.connectTimeoutMs ??
          DEFAULT_CONNECT_TIMEOUT_MS,
        maxRetriesPerRequest:
          config.MAX_RETRIES_PER_REQUEST ??
          options.maxRetriesPerRequest ??
          DEFAULT_MAX_RETRIES_PER_REQUEST,
        retryStrategy: options.retryStrategy ?? defaultRetryStrategy,
      });

      // Test connection
      try {
        await client.connect();
        connectedOnce = true;
      } catch (error) {
        client.disconnect();
        throw new Error(
          `[redisProvider] Failed to connect to Redis at ${redactConnectionUrl(
            config.URL,
          )}: ${errorMessage(error)}`,
        );
      }

      const instrumentation = createProviderInstrumentation(ports, {
        providerName: "redis",
        watcher: "cache",
      });

      const recordCacheEvent = (event: {
        operation: string;
        key: string;
        summary: string;
        details?: Record<string, unknown>;
      }) => {
        instrumentation.custom({
          name: `cache.${event.operation}`,
          label: `Cache ${event.operation}`,
          summary: event.summary,
          details: {
            operation: event.operation,
            key: event.key,
            ...event.details,
          },
        });
      };

      // Extend ports with cache API
      const cache: CachePort = {
        get: async (key: string) => {
          const startedAt = Date.now();
          try {
            const value = await client.get(key);
            recordCacheEvent({
              operation: "get",
              key,
              summary: value == null ? "Cache miss" : "Cache hit",
              details: {
                hit: value != null,
                durationMs: Date.now() - startedAt,
              },
            });
            return value;
          } catch (error) {
            recordCacheEvent({
              operation: "get.failed",
              key,
              summary: "Cache get failed",
              details: {
                durationMs: Date.now() - startedAt,
                error: errorMessage(error),
              },
            });
            throw error;
          }
        },

        set: async (key, value, options) => {
          const startedAt = Date.now();
          try {
            if (options?.ttlSeconds != null && options.ttlSeconds > 0) {
              await client.set(key, value, "EX", options.ttlSeconds);
            } else {
              await client.set(key, value);
            }
            recordCacheEvent({
              operation: "set",
              key,
              summary: "Cache set",
              details: {
                ttlSeconds: options?.ttlSeconds ?? null,
                durationMs: Date.now() - startedAt,
              },
            });
          } catch (error) {
            recordCacheEvent({
              operation: "set.failed",
              key,
              summary: "Cache set failed",
              details: {
                ttlSeconds: options?.ttlSeconds ?? null,
                durationMs: Date.now() - startedAt,
                error: errorMessage(error),
              },
            });
            throw error;
          }
        },

        delete: async (key) => {
          const startedAt = Date.now();
          try {
            const deleted = (await client.del(key)) > 0;
            recordCacheEvent({
              operation: "delete",
              key,
              summary: deleted ? "Cache key deleted" : "Cache key missing",
              details: {
                deleted,
                durationMs: Date.now() - startedAt,
              },
            });
            return deleted;
          } catch (error) {
            recordCacheEvent({
              operation: "delete.failed",
              key,
              summary: "Cache delete failed",
              details: {
                durationMs: Date.now() - startedAt,
                error: errorMessage(error),
              },
            });
            throw error;
          }
        },

        has: async (key) => {
          const startedAt = Date.now();
          try {
            const result = await client.exists(key);
            const exists = result === 1;
            recordCacheEvent({
              operation: "has",
              key,
              summary: exists ? "Cache key exists" : "Cache key missing",
              details: {
                exists,
                durationMs: Date.now() - startedAt,
              },
            });
            return exists;
          } catch (error) {
            recordCacheEvent({
              operation: "has.failed",
              key,
              summary: "Cache has failed",
              details: {
                durationMs: Date.now() - startedAt,
                error: errorMessage(error),
              },
            });
            throw error;
          }
        },

        remember: async (key, factory, options) => {
          const startedAt = Date.now();
          try {
            const cached = await client.get(key);
            if (cached != null) {
              recordCacheEvent({
                operation: "remember",
                key,
                summary: "Cache remember hit",
                details: {
                  hit: true,
                  ttlSeconds: options?.ttlSeconds ?? null,
                  durationMs: Date.now() - startedAt,
                },
              });
              return cached;
            }

            const value = await factory();
            if (options?.ttlSeconds != null && options.ttlSeconds > 0) {
              await client.set(key, value, "EX", options.ttlSeconds);
            } else {
              await client.set(key, value);
            }

            recordCacheEvent({
              operation: "remember",
              key,
              summary: "Cache remember miss",
              details: {
                hit: false,
                ttlSeconds: options?.ttlSeconds ?? null,
                durationMs: Date.now() - startedAt,
              },
            });

            return value;
          } catch (error) {
            recordCacheEvent({
              operation: "remember.failed",
              key,
              summary: "Cache remember failed",
              details: {
                ttlSeconds: options?.ttlSeconds ?? null,
                durationMs: Date.now() - startedAt,
                error: errorMessage(error),
              },
            });
            throw error;
          }
        },
      };

      return {
        ports: {
          cache,
          redis: {
            client,
            checkHealth: () => checkRedisHealth(client),
          },
        } satisfies RedisProviderPorts,
        async stop() {
          try {
            await client.quit();
          } catch {
            // Silently ignore errors during shutdown
          }
        },
      };
    },
  });
}

/**
 * Default env-backed Redis provider.
 *
 * Configuration via environment variables:
 * - REDIS_URL: Redis connection URL (required)
 * - REDIS_DB: Redis database number (optional)
 * - REDIS_CONNECT_TIMEOUT_MS: Initial connection timeout in ms (optional)
 * - REDIS_MAX_RETRIES_PER_REQUEST: Per-command retry limit (optional)
 * - REDIS_CONNECT_MAX_ATTEMPTS: Connection attempts before startup fails (optional)
 *
 * @example
 * ```ts
 * const server = await createNextServer({
 *   ports: basePorts,
 *   providers: [redisProvider],
 *   // ...
 * });
 * ```
 */
export const redisProvider = createRedisProvider();
