import { defaultCaptchaGenerators } from "./challenges/index.ts";
import { resolveCaptchaImageStyle } from "./challenges/styles.ts";
import { AssetAudioRenderer } from "./audio/renderer.ts";
import {
  bindingHash,
  constantTimeEqual,
  defaultRandomBytes,
  hmacSha256,
  randomId,
  sha256,
} from "./crypto.ts";
import {
  normalizeSelections,
  normalizeTextAnswer,
  normalizedSubmittedAnswer,
} from "./normalize.ts";
import { MemoryCaptchaStore } from "./stores/memory.ts";
import type {
  CaptchaBinding,
  CaptchaChallenge,
  CaptchaChallengeGenerator,
  CaptchaChallengeRecord,
  CaptchaChallengeType,
  CaptchaDifficulty,
  CaptchaEngine,
  CaptchaEngineOptions,
  CaptchaGeneratorContext,
  CaptchaResponseTokenRecord,
  CaptchaStore,
  CaptchaAudioRenderer,
  CaptchaVerificationResult,
  CreateCaptchaOptions,
  VerifyCaptchaInput,
} from "./types.ts";

const DEFAULT_CHALLENGE_TTL_MS = 120_000;
const DEFAULT_TOKEN_TTL_MS = 300_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_MIN_COMPLETION_MS = 800;
const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response";
const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [
  "number",
  "alpha",
  "alphanumeric",
  "calculation",
  "image",
];

function failure(
  action: string,
  code: string,
  message: string,
  challengeId?: string,
): CaptchaVerificationResult {
  return { success: false, provider: "self-hosted", action, code, message, challengeId };
}

function assertAction(action: string): string {
  const value = action.trim();
  if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) {
    throw new TypeError(
      "CAPTCHA action must be a non-empty stable identifier up to 128 characters",
    );
  }
  return value;
}

function assertPositiveInteger(value: number, name: string): number {
  if (!Number.isInteger(value) || value < 1)
    throw new RangeError(`${name} must be a positive integer`);
  return value;
}

function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDifficulty): number {
  const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50;
  if (value === undefined) return fallback;
  const numeric = Number(value);
  if (!Number.isFinite(numeric))
    throw new RangeError("disturbance must be a finite number between 25 and 75");
  const normalized = Math.round(numeric);
  if (normalized < 25 || normalized > 75) {
    throw new RangeError("disturbance must be between 25 and 75");
  }
  return normalized;
}

function randomInteger(
  randomBytes: (length: number) => Uint8Array,
  min: number,
  max: number,
): number {
  if (!Number.isInteger(min) || !Number.isInteger(max) || max < min)
    throw new RangeError("invalid random range");
  const span = max - min + 1;
  if (span === 1) return min;
  const limit = Math.floor(0x1_0000_0000 / span) * span;
  while (true) {
    const bytes = randomBytes(4);
    const value = ((bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!) >>> 0;
    if (value < limit) return min + (value % span);
  }
}

async function matchesHash(
  expected: string | undefined,
  raw: string | undefined,
): Promise<boolean> {
  if (!expected) return true;
  if (!raw) return false;
  return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
}

export class DefaultCaptchaEngine implements CaptchaEngine {
  readonly provider = "self-hosted" as const;
  readonly basePath: string;
  private readonly secret: string;
  private readonly store: CaptchaStore;
  private readonly generators = new Map<CaptchaChallengeType, CaptchaChallengeGenerator>();
  private readonly audioRenderer: CaptchaAudioRenderer;
  private readonly challengeTtlMs: number;
  private readonly responseTokenTtlMs: number;
  private readonly maxAttempts: number;
  private readonly minCompletionMs: number;
  private readonly responseField: string;
  private readonly defaultType: CaptchaChallengeType;
  private readonly defaultDifficulty: CaptchaDifficulty;
  private readonly bindIp: boolean;
  private readonly now: () => number;
  private readonly randomBytes: (length: number) => Uint8Array;

  constructor(options: CaptchaEngineOptions) {
    if (!options.secret || options.secret.length < 32) {
      throw new TypeError("CAPTCHA secret must contain at least 32 characters");
    }
    this.secret = options.secret;
    this.store = options.store ?? new MemoryCaptchaStore();
    this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer();
    this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`;
    this.challengeTtlMs = assertPositiveInteger(
      options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS,
      "challengeTtlMs",
    );
    this.responseTokenTtlMs = assertPositiveInteger(
      options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS,
      "responseTokenTtlMs",
    );
    this.maxAttempts = assertPositiveInteger(
      options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
      "maxAttempts",
    );
    this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS);
    this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD;
    this.defaultType = options.defaultType ?? "alphanumeric";
    this.defaultDifficulty = options.defaultDifficulty ?? "normal";
    this.bindIp = options.bindIp ?? false;
    this.now = options.now ?? Date.now;
    this.randomBytes = options.randomBytes ?? defaultRandomBytes;
    for (const generator of options.generators ?? defaultCaptchaGenerators())
      this.generators.set(generator.type, generator);
    if (this.defaultType !== "random" && !this.generators.has(this.defaultType))
      throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
  }

  async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
    const action = assertAction(options.action);
    const configuredType = options.type ?? this.defaultType;
    const randomInt = (min: number, max: number): number =>
      randomInteger(this.randomBytes, min, max);
    const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type));
    if (configuredType === "random" && randomTypes.length === 0)
      throw new Error("No interactive CAPTCHA generators are registered");
    const requestedType =
      configuredType === "random"
        ? randomTypes[randomInt(0, randomTypes.length - 1)]
        : configuredType;
    const requestedPresentation =
      options.presentation ??
      (requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
        ? "invisible"
        : "visual");
    const actualType =
      requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
    const generator = this.generators.get(actualType);
    if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`);
    const now = this.now();
    const difficulty = options.difficulty ?? this.defaultDifficulty;
    const disturbance = normalizeDisturbance(options.disturbance, difficulty);
    const imageStyle = resolveCaptchaImageStyle({
      imageStyle: options.imageStyle,
      allowedStyles: options.allowedStyles,
      excludedStyles: options.excludedStyles,
      randomizeStyle: options.randomizeStyle ?? false,
      randomInt,
    });
    const context: CaptchaGeneratorContext = {
      difficulty,
      disturbance,
      imageStyle: imageStyle.resolved,
      requestedImageStyle: imageStyle.requested,
      imageStylePool: imageStyle.pool,
      locale: options.locale ?? "en",
      length: options.length,
      caseSensitive: options.caseSensitive ?? false,
      minCompletionMs: Math.max(0, options.minCompletionMs ?? this.minCompletionMs),
      randomInt,
      randomFloat: () => randomInteger(this.randomBytes, 0, 0xffff_ffff) / 0xffff_ffff,
      randomId: (bytes = 18) => randomId(this.randomBytes, bytes),
    };
    const generated = await generator.generate(context);
    const id = randomId(this.randomBytes, 24);
    const answerSalt = randomId(this.randomBytes, 16);
    const caseSensitive = options.caseSensitive ?? false;
    const normalizedAnswer =
      generated.answerKind === "selections"
        ? normalizeSelections(generated.answer.split(","))
        : generated.answerKind === "text"
          ? normalizeTextAnswer(generated.answer, caseSensitive)
          : generated.answer;
    const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`);
    const expiresAt =
      now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs");
    const maxAttempts = assertPositiveInteger(
      options.maxAttempts ?? this.maxAttempts,
      "maxAttempts",
    );
    const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined;
    const responseField = options.responseField ?? this.responseField;
    const presentation =
      requestedPresentation === "audio" && generated.audioSequence?.length
        ? "audio"
        : generated.presentation;

    const publicChallenge: CaptchaChallenge = {
      id,
      provider: "self-hosted",
      type: generated.type,
      presentation,
      action,
      prompt: presentation === "audio" ? "Listen and enter the spoken answer" : generated.prompt,
      createdAt: now,
      expiresAt,
      responseField,
      inputMode: generated.inputMode,
      image: presentation === "audio" ? undefined : generated.image,
      items: presentation === "audio" ? undefined : generated.items,
      minSelections: generated.minSelections,
      maxSelections: generated.maxSelections,
      audioUrl: audioKey
        ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}`
        : undefined,
      refreshUrl: `${this.basePath}/challenge`,
      verifyUrl: `${this.basePath}/verify`,
      honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined,
      timingToken: String(generated.metadata?.timingToken ?? "") || undefined,
      metadata: {
        difficulty,
        disturbance,
        imageStyle: context.imageStyle,
        requestedImageStyle: context.requestedImageStyle,
        imageStylePool: [...context.imageStylePool],
        locale: context.locale,
        ...(generated.answerKind === "invisible"
          ? { minCompletionMs: context.minCompletionMs }
          : {}),
        ...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
        ...(requestedType === "image" && actualType !== requestedType
          ? { alternativeFor: requestedType }
          : {}),
        ...options.metadata,
      },
    };

    const record: CaptchaChallengeRecord = {
      id,
      provider: "self-hosted",
      type: generated.type,
      presentation,
      action,
      publicChallenge,
      answerDigest,
      answerSalt,
      answerKind: generated.answerKind,
      caseSensitive,
      createdAt: now,
      expiresAt,
      attempts: 0,
      maxAttempts,
      hostnameHash: await bindingHash(options.hostname),
      sessionHash: await bindingHash(options.sessionId),
      ipHash: this.bindIp ? await bindingHash(options.ip) : undefined,
      metadata: {
        ...generated.metadata,
        audioKey,
        audioSequence: generated.audioSequence,
        locale: context.locale,
        imageStyle: context.imageStyle,
        requestedImageStyle: context.requestedImageStyle,
        imageStylePool: [...context.imageStylePool],
        minCompletionMs: context.minCompletionMs,
      },
    };
    await this.store.createChallenge(record);
    return structuredClone(publicChallenge);
  }

  async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
    if (input.responseToken) return this.verifyResponseToken(input);
    const action = assertAction(input.action);
    if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id");
    const now = this.now();
    const record = await this.store.getChallenge(input.challengeId);
    if (!record)
      return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId);
    if (record.expiresAt <= now)
      return failure(action, "expired", "The CAPTCHA challenge expired", record.id);
    if (record.consumedAt)
      return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id);
    const bindingFailure = await this.checkChallengeBinding(record, input, action);
    if (bindingFailure) return bindingFailure;

    const attempted = await this.store.incrementAttempts(record.id, now);
    if (!attempted)
      return failure(
        action,
        "already-used",
        "The CAPTCHA challenge is no longer available",
        record.id,
      );
    if (attempted.attempts > attempted.maxAttempts) {
      await this.store.consumeChallenge(attempted.id, now);
      return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id);
    }

    if (attempted.answerKind === "invisible") {
      const minimum = Number(attempted.metadata.minCompletionMs ?? this.minCompletionMs);
      if (now - attempted.createdAt < minimum) {
        return failure(action, "risk-rejected", "The form was completed too quickly", record.id);
      }
    }

    const submitted = normalizedSubmittedAnswer(attempted, input);
    const digest = await hmacSha256(
      this.secret,
      `${attempted.id}:${attempted.answerSalt}:${submitted}`,
    );
    if (!constantTimeEqual(attempted.answerDigest, digest)) {
      const exhausted = attempted.attempts >= attempted.maxAttempts;
      if (exhausted) await this.store.consumeChallenge(attempted.id, now);
      return failure(
        action,
        exhausted ? "attempts-exhausted" : "incorrect-answer",
        exhausted ? "Too many CAPTCHA attempts" : "The CAPTCHA answer is incorrect",
        attempted.id,
      );
    }

    const consumed = await this.store.consumeChallenge(attempted.id, now);
    if (!consumed)
      return failure(
        action,
        "already-used",
        "The CAPTCHA challenge was already used",
        attempted.id,
      );
    const plainToken = randomId(this.randomBytes, 32);
    const tokenHash = await sha256(plainToken);
    const expiresAt = now + this.responseTokenTtlMs;
    const tokenRecord: CaptchaResponseTokenRecord = {
      tokenHash,
      provider: "self-hosted",
      challengeId: consumed.id,
      action,
      createdAt: now,
      expiresAt,
      hostnameHash: consumed.hostnameHash,
      sessionHash: consumed.sessionHash,
      ipHash: consumed.ipHash,
      metadata: optionsMetadata(consumed),
    };
    await this.store.createToken(tokenRecord);
    return {
      success: true,
      provider: "self-hosted",
      action,
      responseToken: plainToken,
      expiresAt,
      hostname: input.hostname,
      challengeId: consumed.id,
    };
  }

  async verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
    const action = assertAction(input.action);
    const token = input.responseToken ?? input.providerToken;
    if (!token) return failure(action, "missing-input", "Missing CAPTCHA response token");
    if (token.length > 4096) return failure(action, "invalid-input", "CAPTCHA token is too long");
    const now = this.now();
    const tokenHash = await sha256(token);
    const existing = await this.store.getToken(tokenHash);
    if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token");
    if (existing.expiresAt <= now)
      return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId);
    if (existing.consumedAt)
      return failure(
        action,
        "already-used",
        "The CAPTCHA response token was already used",
        existing.challengeId,
      );
    if (existing.action !== action)
      return failure(
        action,
        "action-mismatch",
        "The CAPTCHA action does not match",
        existing.challengeId,
      );
    const bindingFailure = await this.checkTokenBinding(existing, input, action);
    if (bindingFailure) return bindingFailure;
    const record =
      input.consume === false ? existing : await this.store.consumeToken(tokenHash, now);
    if (!record)
      return failure(
        action,
        "already-used",
        "The CAPTCHA response token was already used",
        existing.challengeId,
      );
    return {
      success: true,
      provider: "self-hosted",
      action,
      expiresAt: record.expiresAt,
      hostname: input.hostname,
      challengeId: record.challengeId,
      score: record.score,
      metadata: record.metadata,
    };
  }

  async renderAudio(
    challengeId: string,
    key: string,
  ): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
    const record = await this.store.getChallenge(challengeId);
    if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined;
    const expectedKey = String(record.metadata.audioKey ?? "");
    if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined;
    const sequence = record.metadata.audioSequence;
    if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string"))
      return undefined;
    const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en"));
    return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" };
  }

  async gc(): Promise<void> {
    await this.store.gc?.(this.now());
  }

  private async checkChallengeBinding(
    record: CaptchaChallengeRecord,
    input: CaptchaBinding,
    action: string,
  ): Promise<CaptchaVerificationResult | undefined> {
    if (record.action !== action)
      return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id);
    if (!(await matchesHash(record.hostnameHash, input.hostname))) {
      return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id);
    }
    if (!(await matchesHash(record.sessionHash, input.sessionId))) {
      return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id);
    }
    if (!(await matchesHash(record.ipHash, input.ip))) {
      return failure(
        action,
        "ip-mismatch",
        "The CAPTCHA network binding does not match",
        record.id,
      );
    }
    return undefined;
  }

  private async checkTokenBinding(
    record: CaptchaResponseTokenRecord,
    input: CaptchaBinding,
    action: string,
  ): Promise<CaptchaVerificationResult | undefined> {
    if (!(await matchesHash(record.hostnameHash, input.hostname))) {
      return failure(
        action,
        "hostname-mismatch",
        "The CAPTCHA hostname does not match",
        record.challengeId,
      );
    }
    if (!(await matchesHash(record.sessionHash, input.sessionId))) {
      return failure(
        action,
        "session-mismatch",
        "The CAPTCHA session does not match",
        record.challengeId,
      );
    }
    if (!(await matchesHash(record.ipHash, input.ip))) {
      return failure(
        action,
        "ip-mismatch",
        "The CAPTCHA network binding does not match",
        record.challengeId,
      );
    }
    return undefined;
  }
}

function optionsMetadata(record: CaptchaChallengeRecord): Record<string, unknown> {
  const metadata = { ...record.publicChallenge.metadata };
  delete metadata.secret;
  return metadata;
}

export function createCaptchaEngine(options: CaptchaEngineOptions): CaptchaEngine {
  return new DefaultCaptchaEngine(options);
}
