import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { CaptchaAudioRenderer } from "../types.ts";

interface ParsedWav {
  sampleRate: number;
  channels: number;
  bitsPerSample: number;
  data: Uint8Array;
}

function u16(view: DataView, offset: number): number {
  return view.getUint16(offset, true);
}

function u32(view: DataView, offset: number): number {
  return view.getUint32(offset, true);
}

function parseWav(bytes: Uint8Array): ParsedWav {
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
  if (bytes.length < 44 || String.fromCharCode(...bytes.slice(0, 4)) !== "RIFF") {
    throw new Error("CAPTCHA audio asset is not a WAV file");
  }

  let offset = 12;
  let sampleRate = 0;
  let channels = 0;
  let bitsPerSample = 0;
  let data: Uint8Array | undefined;

  while (offset + 8 <= bytes.length) {
    const id = String.fromCharCode(...bytes.slice(offset, offset + 4));
    const size = u32(view, offset + 4);
    const start = offset + 8;

    if (id === "fmt ") {
      const format = u16(view, start);
      if (format !== 1) throw new Error("CAPTCHA audio assets must use PCM WAV");
      channels = u16(view, start + 2);
      sampleRate = u32(view, start + 4);
      bitsPerSample = u16(view, start + 14);
    } else if (id === "data") {
      data = bytes.slice(start, start + size);
    }

    offset = start + size + (size % 2);
  }

  if (!sampleRate || !channels || !bitsPerSample || !data) {
    throw new Error("Invalid CAPTCHA WAV asset");
  }

  return { sampleRate, channels, bitsPerSample, data };
}

function writeAscii(target: Uint8Array, offset: number, value: string): void {
  for (let index = 0; index < value.length; index++) {
    target[offset + index] = value.charCodeAt(index);
  }
}

function createWav(
  parts: Uint8Array[],
  sampleRate: number,
  channels: number,
  bitsPerSample: number,
): Uint8Array {
  const dataLength = parts.reduce((sum, part) => sum + part.length, 0);
  const out = new Uint8Array(44 + dataLength);
  const view = new DataView(out.buffer);
  const blockAlign = channels * (bitsPerSample / 8);
  const byteRate = sampleRate * blockAlign;

  writeAscii(out, 0, "RIFF");
  view.setUint32(4, 36 + dataLength, true);
  writeAscii(out, 8, "WAVE");
  writeAscii(out, 12, "fmt ");
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, channels, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, byteRate, true);
  view.setUint16(32, blockAlign, true);
  view.setUint16(34, bitsPerSample, true);
  writeAscii(out, 36, "data");
  view.setUint32(40, dataLength, true);

  let cursor = 44;
  for (const part of parts) {
    out.set(part, cursor);
    cursor += part.length;
  }

  return out;
}

function safeToken(value: string): string {
  const token = value.toLowerCase().trim();
  if (!/^[a-z0-9]+$/.test(token)) {
    throw new Error(`Unsupported audio token: ${value}`);
  }
  return token;
}

function addCandidate(candidates: string[], value: string | undefined): void {
  if (!value) return;
  const normalized = value.trim();
  if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
}

/**
 * Resolve the bundled audio directory in source, workspace, installed-package,
 * and bundled-server layouts. A production server bundle changes import.meta.url,
 * so package resolution and cwd fallbacks are required in addition to the
 * source-relative path.
 */
export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
  const candidates: string[] = [];

  addCandidate(candidates, explicitDir);
  addCandidate(candidates, process.env.WRNEXUS_CAPTCHA_AUDIO_DIR);

  try {
    const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
    addCandidate(candidates, join(dirname(dirname(dirname(packageEntry))), "assets", "audio"));
  } catch {
    // The source-relative and cwd fallbacks below still support direct source use.
  }

  const sourcePackageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
  addCandidate(candidates, join(sourcePackageRoot, "assets", "audio"));

  let current = process.cwd();
  for (let depth = 0; depth < 8; depth += 1) {
    addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
    addCandidate(
      candidates,
      join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"),
    );

    const parent = dirname(current);
    if (parent === current) break;
    current = parent;
  }

  for (const candidate of candidates) {
    if (existsSync(join(candidate, "en"))) return candidate;
  }

  throw new Error(
    [
      "Unable to locate WRNexusJS CAPTCHA audio assets.",
      "Set WRNEXUS_CAPTCHA_AUDIO_DIR or pass assetsDir to AssetAudioRenderer.",
      `Checked: ${candidates.join(", ")}`,
    ].join(" "),
  );
}

export interface AssetAudioRendererOptions {
  assetsDir?: string;
  gapMs?: number;
}

/**
 * Concatenates bundled English PCM word clips. Applications can replace this
 * renderer with cloud TTS or their own localized renderer without changing the
 * challenge engine.
 */
export class AssetAudioRenderer implements CaptchaAudioRenderer {
  readonly contentType = "audio/wav";
  private readonly assetsDir: string;
  private readonly gapMs: number;
  private readonly cache = new Map<string, ParsedWav>();

  constructor(options: AssetAudioRendererOptions = {}) {
    this.assetsDir = resolveCaptchaAudioAssetsDir(options.assetsDir);
    this.gapMs = options.gapMs ?? 180;
  }

  async render(sequence: string[], locale: string): Promise<Uint8Array> {
    const language = locale.toLowerCase().split("-")[0] || "en";
    if (language !== "en") {
      throw new Error(
        `No bundled CAPTCHA audio assets for locale '${locale}'. Supply a custom CaptchaAudioRenderer.`,
      );
    }
    if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");

    const clips = await Promise.all(sequence.map((token) => this.load(language, safeToken(token))));
    const first = clips[0]!;

    for (const clip of clips) {
      if (
        clip.sampleRate !== first.sampleRate ||
        clip.channels !== first.channels ||
        clip.bitsPerSample !== first.bitsPerSample
      ) {
        throw new Error("CAPTCHA audio assets must share one PCM format");
      }
    }

    const bytesPerSample = first.channels * (first.bitsPerSample / 8);
    const silenceBytes = Math.floor((first.sampleRate * this.gapMs) / 1000) * bytesPerSample;
    const silence = new Uint8Array(silenceBytes);
    const parts: Uint8Array[] = [];

    for (let index = 0; index < clips.length; index += 1) {
      if (index) parts.push(silence);
      parts.push(clips[index]!.data);
    }

    return createWav(parts, first.sampleRate, first.channels, first.bitsPerSample);
  }

  private async load(language: string, token: string): Promise<ParsedWav> {
    const key = `${language}/${token}`;
    const cached = this.cache.get(key);
    if (cached) return cached;

    const bytes = new Uint8Array(await readFile(join(this.assetsDir, language, `${token}.wav`)));
    const parsed = parseWav(bytes);
    this.cache.set(key, parsed);
    return parsed;
  }
}

export function createAssetAudioRenderer(options?: AssetAudioRendererOptions): AssetAudioRenderer {
  return new AssetAudioRenderer(options);
}
