import type { CaptchaChallengeRecord, CaptchaResponseTokenRecord, CaptchaStore } from "../types.ts";

export interface RedisCaptchaClient {
  get(key: string): Promise<string | null> | string | null;
  set(
    key: string,
    value: string,
    options?: { px?: number; nx?: boolean },
  ): Promise<unknown> | unknown;
  del(key: string): Promise<number> | number;
  eval?(
    script: string,
    options: { keys: string[]; arguments: string[] },
  ): Promise<unknown> | unknown;
  scanIterator?(options?: { match?: string; count?: number }): AsyncIterable<string>;
}

export interface RedisCaptchaStoreOptions {
  prefix?: string;
}

const MUTATE_CHALLENGE = `
local raw = redis.call('GET', KEYS[1])
if not raw then return nil end
local value = cjson.decode(raw)
local now = tonumber(ARGV[1])
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
if ARGV[2] == 'attempt' then
  value.attempts = tonumber(value.attempts) + 1
else
  value.consumedAt = now
end
local encoded = cjson.encode(value)
local ttl = math.max(1, tonumber(value.expiresAt) - now)
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
return encoded
`;

const CONSUME_TOKEN = `
local raw = redis.call('GET', KEYS[1])
if not raw then return nil end
local value = cjson.decode(raw)
local now = tonumber(ARGV[1])
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
value.consumedAt = now
local encoded = cjson.encode(value)
local ttl = math.max(1, tonumber(value.expiresAt) - now)
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
return encoded
`;

export class RedisCaptchaStore implements CaptchaStore {
  private readonly prefix: string;
  private readonly locks = new Map<string, Promise<void>>();

  constructor(
    private readonly redis: RedisCaptchaClient,
    options: RedisCaptchaStoreOptions = {},
  ) {
    this.prefix = options.prefix ?? "wrn:captcha:";
  }

  async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
    const ttl = Math.max(1, record.expiresAt - Date.now());
    await this.redis.set(this.challengeKey(record.id), JSON.stringify(record), { px: ttl });
  }

  async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
    return this.read<CaptchaChallengeRecord>(this.challengeKey(id));
  }

  async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
    return this.mutateChallenge(id, now, "attempt");
  }

  async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
    return this.mutateChallenge(id, now, "consume");
  }

  async deleteChallenge(id: string): Promise<void> {
    await this.redis.del(this.challengeKey(id));
  }

  async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
    const ttl = Math.max(1, record.expiresAt - Date.now());
    await this.redis.set(this.tokenKey(record.tokenHash), JSON.stringify(record), { px: ttl });
  }

  async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
    return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
  }

  async consumeToken(
    tokenHash: string,
    now: number,
  ): Promise<CaptchaResponseTokenRecord | undefined> {
    const key = this.tokenKey(tokenHash);
    if (this.redis.eval) {
      const raw = await this.redis.eval(CONSUME_TOKEN, {
        keys: [key],
        arguments: [String(now)],
      });
      return typeof raw === "string" ? (JSON.parse(raw) as CaptchaResponseTokenRecord) : undefined;
    }
    return this.withLock(key, async () => {
      const current = await this.read<CaptchaResponseTokenRecord>(key);
      if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
      current.consumedAt = now;
      await this.redis.set(key, JSON.stringify(current), {
        px: Math.max(1, current.expiresAt - now),
      });
      return current;
    });
  }

  async deleteToken(tokenHash: string): Promise<void> {
    await this.redis.del(this.tokenKey(tokenHash));
  }

  async gc(): Promise<void> {
    // Redis TTLs are authoritative, so no explicit sweep is required.
  }

  private async mutateChallenge(
    id: string,
    now: number,
    operation: "attempt" | "consume",
  ): Promise<CaptchaChallengeRecord | undefined> {
    const key = this.challengeKey(id);
    if (this.redis.eval) {
      const raw = await this.redis.eval(MUTATE_CHALLENGE, {
        keys: [key],
        arguments: [String(now), operation],
      });
      return typeof raw === "string" ? (JSON.parse(raw) as CaptchaChallengeRecord) : undefined;
    }
    return this.withLock(key, async () => {
      const current = await this.read<CaptchaChallengeRecord>(key);
      if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
      if (operation === "attempt") current.attempts += 1;
      else current.consumedAt = now;
      await this.redis.set(key, JSON.stringify(current), {
        px: Math.max(1, current.expiresAt - now),
      });
      return current;
    });
  }

  private async read<T>(key: string): Promise<T | undefined> {
    const value = await this.redis.get(key);
    return value ? (JSON.parse(value) as T) : undefined;
  }

  private challengeKey(id: string): string {
    return `${this.prefix}challenge:${id}`;
  }

  private tokenKey(hash: string): string {
    return `${this.prefix}token:${hash}`;
  }

  private async withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
    const previous = this.locks.get(key) ?? Promise.resolve();
    let release!: () => void;
    const current = new Promise<void>((resolve) => {
      release = resolve;
    });
    const chain = previous.then(() => current);
    this.locks.set(key, chain);
    await previous;
    try {
      return await task();
    } finally {
      release();
      if (this.locks.get(key) === chain) this.locks.delete(key);
    }
  }
}

export function createRedisCaptchaStore(
  redis: RedisCaptchaClient,
  options?: RedisCaptchaStoreOptions,
): RedisCaptchaStore {
  return new RedisCaptchaStore(redis, options);
}
