/**
 * @beignet/provider-cache-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 { createRedisCacheProvider } from "@beignet/provider-cache-redis";
 *
 * const server = await createNextServer({
 *   ports: basePorts,
 *   providers: [createRedisCacheProvider()],
 *   // ...
 * });
 *
 * // 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 {
  type AnyProviderConfigSchema,
  createProvider,
  createProviderInstrumentation,
  type ProviderInstrumentationTarget,
  type ServiceProvider,
} 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),
}) satisfies z.ZodType<RedisConfig>;

/**
 * Validated configuration for the Redis provider.
 */
export interface RedisConfig {
  URL: string;
  DB?: number;
  CONNECT_TIMEOUT_MS: number;
  MAX_RETRIES_PER_REQUEST: number;
  CONNECT_MAX_ATTEMPTS: number;
}

/**
 * Options for {@link createRedisCacheProvider}.
 *
 * Numeric options become schema defaults, so matching `REDIS_*` environment
 * variables still win when both are set.
 */
export interface RedisCacheProviderOptions {
  /**
   * 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 RedisCacheProviderPorts {
  /**
   * Beignet cache port backed by Redis.
   */
  cache: CachePort;
  /**
   * Raw ioredis client escape hatch.
   */
  redis: RedisEscapeHatch;
}

/**
 * Redis cache lifecycle provider with stable contributed-port typing.
 */
export type RedisCacheProvider = ServiceProvider<
  unknown,
  AnyProviderConfigSchema<RedisConfig>,
  Pick<RedisCacheProviderPorts, keyof RedisCacheProviderPorts>
>;

/**
 * Minimal ioredis client shape required by the direct cache adapter.
 */
export type RedisCacheClient = Pick<Redis, "get" | "set" | "del" | "exists">;

/**
 * Options for adapting an app-owned ioredis client directly.
 */
export interface CreateRedisCacheOptions {
  /**
   * Connected ioredis client. The caller owns connection and shutdown.
   */
  client: RedisCacheClient;
  /**
   * Optional provider instrumentation target.
   */
  instrumentation?: ProviderInstrumentationTarget;
}

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" },
    };
  }
}

/**
 * Adapt an app-owned ioredis client to Beignet's stable CachePort.
 */
export function createRedisCache(options: CreateRedisCacheOptions): CachePort {
  const client = options.client;
  const instrumentation = createProviderInstrumentation(
    options.instrumentation,
    {
      providerName: "cache-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,
      },
    });
  };

  return {
    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, cacheOptions) => {
      const startedAt = Date.now();
      try {
        if (cacheOptions?.ttlSeconds != null && cacheOptions.ttlSeconds > 0) {
          await client.set(key, value, "EX", cacheOptions.ttlSeconds);
        } else {
          await client.set(key, value);
        }
        recordCacheEvent({
          operation: "set",
          key,
          summary: "Cache set",
          details: {
            ttlSeconds: cacheOptions?.ttlSeconds ?? null,
            durationMs: Date.now() - startedAt,
          },
        });
      } catch (error) {
        recordCacheEvent({
          operation: "set.failed",
          key,
          summary: "Cache set failed",
          details: {
            ttlSeconds: cacheOptions?.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, cacheOptions) => {
      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: cacheOptions?.ttlSeconds ?? null,
              durationMs: Date.now() - startedAt,
            },
          });
          return cached;
        }

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

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

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

/**
 * 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 = createRedisCacheProvider({ connectTimeoutMs: 2000 });
 * ```
 */
export function createRedisCacheProvider(
  options: RedisCacheProviderOptions = {},
): RedisCacheProvider {
  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: "cache-redis",

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

    async setup({ ports, config }) {
      if (!config) {
        throw new Error(
          "[createRedisCacheProvider] 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(
          `[createRedisCacheProvider] Failed to connect to Redis at ${redactConnectionUrl(
            config.URL,
          )}: ${errorMessage(error)}`,
        );
      }

      const cache = createRedisCache({ client, instrumentation: ports });

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