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

export interface SqliteStatementLike {
  run(...params: unknown[]): unknown;
  get(...params: unknown[]): Record<string, unknown> | undefined;
}

export interface SqliteDatabaseLike {
  exec(sql: string): unknown;
  prepare(sql: string): SqliteStatementLike;
}

export interface SqliteCaptchaStoreOptions {
  challengeTable?: string;
  tokenTable?: string;
  initialize?: boolean;
}

function safeIdentifier(value: string): string {
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value))
    throw new TypeError(`Unsafe SQL identifier: ${value}`);
  return value;
}

function parseChallenge(
  row: Record<string, unknown> | undefined,
): CaptchaChallengeRecord | undefined {
  if (!row) return undefined;
  return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
}

function parseToken(
  row: Record<string, unknown> | undefined,
): CaptchaResponseTokenRecord | undefined {
  if (!row) return undefined;
  return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
}

export class SqliteCaptchaStore implements CaptchaStore {
  private readonly challengeTable: string;
  private readonly tokenTable: string;

  constructor(
    private readonly db: SqliteDatabaseLike,
    options: SqliteCaptchaStoreOptions = {},
  ) {
    this.challengeTable = safeIdentifier(options.challengeTable ?? "wrn_captcha_challenges");
    this.tokenTable = safeIdentifier(options.tokenTable ?? "wrn_captcha_tokens");
    if (options.initialize ?? true) this.initialize();
  }

  initialize(): void {
    this.db.exec(`
      CREATE TABLE IF NOT EXISTS ${this.challengeTable} (
        id TEXT PRIMARY KEY,
        payload TEXT NOT NULL,
        expires_at INTEGER NOT NULL,
        consumed_at INTEGER,
        attempts INTEGER NOT NULL DEFAULT 0
      );
      CREATE INDEX IF NOT EXISTS ${this.challengeTable}_expires_idx
        ON ${this.challengeTable}(expires_at);
      CREATE TABLE IF NOT EXISTS ${this.tokenTable} (
        token_hash TEXT PRIMARY KEY,
        payload TEXT NOT NULL,
        expires_at INTEGER NOT NULL,
        consumed_at INTEGER
      );
      CREATE INDEX IF NOT EXISTS ${this.tokenTable}_expires_idx
        ON ${this.tokenTable}(expires_at);
    `);
  }

  async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
    this.db
      .prepare(
        `INSERT OR REPLACE INTO ${this.challengeTable}
          (id, payload, expires_at, consumed_at, attempts)
         VALUES (?, ?, ?, ?, ?)`,
      )
      .run(
        record.id,
        JSON.stringify(record),
        record.expiresAt,
        record.consumedAt ?? null,
        record.attempts,
      );
  }

  async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
    return parseChallenge(
      this.db.prepare(`SELECT payload FROM ${this.challengeTable} WHERE id = ?`).get(id),
    );
  }

  async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
    const current = await this.getChallenge(id);
    if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
    current.attempts += 1;
    const result = this.db
      .prepare(
        `UPDATE ${this.challengeTable}
         SET payload = ?, attempts = ?
         WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
      )
      .run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as {
      changes?: number;
    };
    return result?.changes === 0 ? undefined : current;
  }

  async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
    const current = await this.getChallenge(id);
    if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
    current.consumedAt = now;
    const result = this.db
      .prepare(
        `UPDATE ${this.challengeTable}
         SET payload = ?, consumed_at = ?
         WHERE id = ? AND expires_at > ? AND consumed_at IS NULL`,
      )
      .run(JSON.stringify(current), now, id, now) as { changes?: number };
    return result?.changes === 0 ? undefined : current;
  }

  async deleteChallenge(id: string): Promise<void> {
    this.db.prepare(`DELETE FROM ${this.challengeTable} WHERE id = ?`).run(id);
  }

  async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
    this.db
      .prepare(
        `INSERT OR REPLACE INTO ${this.tokenTable}
          (token_hash, payload, expires_at, consumed_at)
         VALUES (?, ?, ?, ?)`,
      )
      .run(record.tokenHash, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null);
  }

  async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
    return parseToken(
      this.db.prepare(`SELECT payload FROM ${this.tokenTable} WHERE token_hash = ?`).get(tokenHash),
    );
  }

  async consumeToken(
    tokenHash: string,
    now: number,
  ): Promise<CaptchaResponseTokenRecord | undefined> {
    const current = await this.getToken(tokenHash);
    if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
    current.consumedAt = now;
    const result = this.db
      .prepare(
        `UPDATE ${this.tokenTable}
         SET payload = ?, consumed_at = ?
         WHERE token_hash = ? AND expires_at > ? AND consumed_at IS NULL`,
      )
      .run(JSON.stringify(current), now, tokenHash, now) as { changes?: number };
    return result?.changes === 0 ? undefined : current;
  }

  async deleteToken(tokenHash: string): Promise<void> {
    this.db.prepare(`DELETE FROM ${this.tokenTable} WHERE token_hash = ?`).run(tokenHash);
  }

  async gc(now: number): Promise<void> {
    this.db
      .prepare(`DELETE FROM ${this.challengeTable} WHERE expires_at <= ? OR consumed_at <= ?`)
      .run(now, now - 60_000);
    this.db
      .prepare(`DELETE FROM ${this.tokenTable} WHERE expires_at <= ? OR consumed_at <= ?`)
      .run(now, now - 60_000);
  }
}

export function createSqliteCaptchaStore(
  db: SqliteDatabaseLike,
  options?: SqliteCaptchaStoreOptions,
): SqliteCaptchaStore {
  return new SqliteCaptchaStore(db, options);
}
