/**
 * Microphone capture for the CLI: 16 kHz mono s16le PCM, in memory only.
 *
 * Recorder chain (first one that opens wins):
 *   1. @picovoice/pvrecorder-node (in the platform package, Apache-2.0, no
 *      access key): native, prebuilt for macOS x64/arm64, Windows
 *      x64/arm64, Linux x64 and Raspberry Pi. Runs in a worker thread
 *      because its read() blocks.
 *   2. System tools writing raw PCM to stdout (never to a file):
 *      Linux pw-record → parecord → arecord; SoX `rec` (Linux/macOS);
 *      ffmpeg last (pulse/alsa, avfoundation, dshow).
 *   3. Nothing works → one exact install command for this OS.
 *
 * OMNIRUSH_VOICE_INPUT_FILE=<wav> replaces the microphone with the file,
 * paced in real time (tests and e2e); the file is only read.
 *
 * Audio is never written anywhere: tool output is piped, frames are handed
 * to the caller and not retained here.
 */

import { spawn, type ChildProcess } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { rms, VOICE_SAMPLE_RATE as SAMPLE_RATE, VoiceError, WavFileSource, voiceInputFileFromEnv, type AudioSource } from "./core";

/** A core AudioSource with a name for messages ("pvrecorder", "pw-record", "file", …). */
export interface PcmSource extends AudioSource {
  readonly name: string;
}

export interface Candidate {
  name: string;
  kind: "native" | "tool";
  command?: string;
  args?: string[];
}

export class RecorderUnavailableError extends Error {
  constructor(
    message: string,
    readonly hint: string | null,
    readonly tried: Array<{ name: string; reason: string }>,
  ) {
    super(message);
    this.name = "RecorderUnavailableError";
  }
}

// ---------------------------------------------------------------------------
// Environment checks

export interface EnvProbe {
  env: NodeJS.ProcessEnv;
  platform: NodeJS.Platform;
  exists: (p: string) => boolean;
  readText: (p: string) => string | null;
}

export function realProbe(): EnvProbe {
  return {
    env: process.env,
    platform: process.platform,
    exists: (p) => {
      try {
        fs.accessSync(p);
        return true;
      } catch {
        return false;
      }
    },
    readText: (p) => {
      try {
        return fs.readFileSync(p, "utf8");
      } catch {
        return null;
      }
    },
  };
}

/** WSL1 / WSL2 / not WSL. */
export function wslVersion(probe: EnvProbe): 0 | 1 | 2 {
  if (probe.platform !== "linux") return 0;
  const version = (probe.readText("/proc/version") || "").toLowerCase();
  if (!version.includes("microsoft")) return 0;
  return version.includes("wsl2") || probe.exists("/run/WSL") || !!probe.env.WSL_INTEROP ? 2 : 1;
}

function hasSoundServer(probe: EnvProbe): boolean {
  const env = probe.env;
  if (env.PULSE_SERVER) return true;
  const runtime = env.XDG_RUNTIME_DIR;
  if (runtime && (probe.exists(path.join(runtime, "pulse", "native")) || probe.exists(path.join(runtime, "pipewire-0")))) return true;
  return false;
}

/**
 * Why a microphone can't be used here (remote session, cloud workspace,
 * no audio device), or null when capture may work.
 */
export function remoteReason(probe: EnvProbe): string | null {
  const env = probe.env;
  if (env.OMNIRUSH_VOICE_INPUT_FILE) return null;
  if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
    return "this is an SSH session: the microphone is on your own machine, not on the host — run omnirush locally to dictate";
  }
  if (env.CODESPACES === "true" || env.GITPOD_WORKSPACE_ID || env.CLOUD_SHELL === "true" || env.DEVPOD === "true" || env.AWS_CLOUD9_USER) {
    return "this is a cloud workspace with no microphone — run omnirush locally to dictate";
  }
  if (probe.platform === "linux") {
    const wsl = wslVersion(probe);
    if (wsl === 1) return "WSL 1 has no audio input — run omnirush in native Windows (or WSL 2 with WSLg)";
    if (wsl === 2) return null; // WSLg exposes PulseAudio
    if (!probe.exists("/dev/snd") && !hasSoundServer(probe)) {
      const container = probe.exists("/.dockerenv") || probe.exists("/run/.containerenv");
      return container
        ? "no audio device is available in this container"
        : "no audio device is available in this environment (no /dev/snd and no PulseAudio/PipeWire)";
    }
  }
  return null;
}

/**
 * Linux: the native recorder goes through ALSA, which prints its errors
 * straight to the terminal (fd 2) when there is no sound card — so it is
 * only tried when /proc/asound/cards lists one.
 */
export function nativeRecorderAllowed(probe: EnvProbe): boolean {
  if (probe.platform !== "linux") return true;
  const cards = probe.readText("/proc/asound/cards") || "";
  return /^\s*\d+\s*\[/m.test(cards);
}

/** One exact command that gets a recorder working on this OS. */
export function installHint(probe: EnvProbe): string {
  if (probe.platform === "darwin") return "brew install sox";
  if (probe.platform === "win32") return "npm install -g omnirush (reinstalls the bundled recorder), or: winget install ffmpeg";
  if (wslVersion(probe) === 2) return "sudo apt install sox libsox-fmt-pulse";
  const osRelease = probe.readText("/etc/os-release") || "";
  const field = (name: string) => (new RegExp(`^${name}=["']?([^"'\\n]*)`, "m").exec(osRelease)?.[1] ?? "").toLowerCase();
  const ids = `${field("ID")} ${field("ID_LIKE")}`;
  if (/\b(debian|ubuntu)\b/.test(ids)) return "sudo apt install pulseaudio-utils   (or: sudo apt install alsa-utils)";
  if (/\b(fedora|rhel|centos)\b/.test(ids)) return "sudo dnf install pipewire-utils";
  if (/\barch\b/.test(ids)) return "sudo pacman -S pipewire   (or: sudo pacman -S alsa-utils)";
  if (/\b(suse|opensuse)\b/.test(ids)) return "sudo zypper install pipewire-tools";
  if (/\balpine\b/.test(ids)) return "sudo apk add alsa-utils";
  return "install PipeWire (pw-record), PulseAudio (parecord) or ALSA (arecord) command-line tools, or SoX";
}

// ---------------------------------------------------------------------------
// Candidates

const PCM_OUT = ["-ac", "1", "-ar", String(SAMPLE_RATE), "-f", "s16le", "-"];

/** The recorder chain for a platform, in order. */
export function recorderCandidates(platform: NodeJS.Platform, withNative = true): Candidate[] {
  const out: Candidate[] = [];
  if (withNative) out.push({ name: "pvrecorder", kind: "native" });
  const ffmpeg = (args: string[]): Candidate => ({ name: "ffmpeg", kind: "tool", command: "ffmpeg", args: ["-hide_banner", "-loglevel", "error", "-nostdin", ...args, ...PCM_OUT] });
  const rec: Candidate = { name: "rec", kind: "tool", command: "rec", args: ["-q", "--buffer", "1024", "-t", "raw", "-r", String(SAMPLE_RATE), "-e", "signed", "-b", "16", "-c", "1", "-"] };
  if (platform === "linux") {
    out.push(
      { name: "pw-record", kind: "tool", command: "pw-record", args: ["--rate", String(SAMPLE_RATE), "--channels", "1", "--format", "s16", "-"] },
      { name: "parecord", kind: "tool", command: "parecord", args: ["--raw", `--rate=${SAMPLE_RATE}`, "--channels=1", "--format=s16le"] },
      { name: "arecord", kind: "tool", command: "arecord", args: ["-f", "S16_LE", "-r", String(SAMPLE_RATE), "-c", "1", "-t", "raw", "-q", "-"] },
      rec,
      ffmpeg(["-f", "pulse", "-i", "default"]),
      { ...ffmpeg(["-f", "alsa", "-i", "default"]), name: "ffmpeg-alsa" },
    );
  } else if (platform === "darwin") {
    out.push(rec, ffmpeg(["-f", "avfoundation", "-i", ":0"]));
  } else if (platform === "win32") {
    out.push(ffmpeg(["-f", "dshow", "-i", "audio=default"]));
  } else {
    out.push(rec, ffmpeg(["-f", "pulse", "-i", "default"]));
  }
  return out;
}

// ---------------------------------------------------------------------------
// Sources

/** Raw s16le bytes → Int16Array, carrying an odd trailing byte to the next chunk. */
export function createPcmDecoder(): (chunk: Uint8Array) => Int16Array {
  let carry: Uint8Array | null = null;
  return (chunk) => {
    let bytes = chunk;
    if (carry) {
      const merged = new Uint8Array(carry.length + chunk.length);
      merged.set(carry);
      merged.set(chunk, carry.length);
      bytes = merged;
      carry = null;
    }
    const even = bytes.length - (bytes.length % 2);
    if (even < bytes.length) carry = bytes.slice(even);
    const out = new Int16Array(even / 2);
    const view = new DataView(bytes.buffer, bytes.byteOffset, even);
    for (let i = 0; i < out.length; i++) out[i] = view.getInt16(i * 2, true);
    return out;
  };
}

export interface ToolSourceOptions {
  spawnImpl?: typeof spawn;
  /** How long to wait for the first audio before accepting a silent-but-alive tool. */
  probeMs?: number;
}

/** A command-line recorder piping raw PCM on stdout. */
export class ToolSource implements PcmSource {
  readonly name: string;
  private child: ChildProcess | null = null;
  private stopped = false;

  constructor(
    private readonly candidate: Candidate,
    private readonly o: ToolSourceOptions = {},
  ) {
    this.name = candidate.name;
  }

  start(onPcm: (pcm: Int16Array) => void, onEnd?: (error?: VoiceError) => void): Promise<void> {
    const onError = (error: Error) => onEnd?.(new VoiceError("capture_failed", error.message));
    const spawnImpl = this.o.spawnImpl ?? spawn;
    const probeMs = this.o.probeMs ?? 400;
    return new Promise((resolve, reject) => {
      let settled = false;
      let stderr = "";
      const decode = createPcmDecoder();
      const settle = (error?: Error) => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        if (error) reject(error);
        else resolve();
      };
      let child: ChildProcess;
      try {
        child = spawnImpl(this.candidate.command!, this.candidate.args ?? [], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
      } catch (error: any) {
        settle(Object.assign(new Error(`${this.name}: ${error?.message ?? error}`), { code: error?.code }));
        return;
      }
      this.child = child;
      const timer = setTimeout(() => settle(), probeMs);
      child.on("error", (error: any) => {
        const wrapped = Object.assign(new Error(error?.code === "ENOENT" ? `${this.name} is not installed` : `${this.name}: ${error?.message ?? error}`), { code: error?.code });
        if (!settled) settle(wrapped);
        else if (!this.stopped) onError(wrapped);
      });
      child.stderr?.on("data", (chunk: Buffer) => {
        if (stderr.length < 2048) stderr += chunk.toString("utf8");
      });
      child.stdout?.on("data", (chunk: Buffer) => {
        if (this.stopped) return;
        const pcm = decode(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
        settle();
        if (pcm.length) onPcm(pcm);
      });
      child.on("exit", (code, signal) => {
        if (this.stopped) return;
        const detail = stderr.trim().split("\n")[0] || (signal ? `signal ${signal}` : `exit code ${code}`);
        const error = new Error(`${this.name} stopped (${detail})`);
        if (!settled) settle(error);
        else onError(error);
      });
    });
  }

  stop(): void {
    this.stopped = true;
    const child = this.child;
    this.child = null;
    if (child && child.exitCode === null) {
      try {
        child.kill("SIGTERM");
      } catch {
        /* already gone */
      }
    }
  }
}

/**
 * OMNIRUSH_VOICE_INPUT_FILE: the core's WavFileSource (real-time paced).
 * The file is read here, once; it is never written.
 */
export class FileSource implements PcmSource {
  readonly name = "file";
  private inner: WavFileSource | null = null;

  constructor(private readonly file: string) {}

  async start(onPcm: (pcm: Int16Array) => void, onEnd?: (error?: VoiceError) => void): Promise<void> {
    let bytes: Uint8Array;
    try {
      bytes = new Uint8Array(fs.readFileSync(this.file));
    } catch (error: any) {
      throw new VoiceError("capture_failed", `OMNIRUSH_VOICE_INPUT_FILE: ${error?.message ?? error}`);
    }
    this.inner = new WavFileSource(bytes);
    await this.inner.start(onPcm, onEnd);
  }

  stop(): void {
    this.inner?.stop();
  }
}

/** The package root the launcher hands over (optional native deps live there). */
export function packageRoot(env: NodeJS.ProcessEnv = process.env): string | null {
  const root = (env.OMNIRUSH_PACKAGE_ROOT || "").trim();
  return root || null;
}

/** Can @picovoice/pvrecorder-node be loaded on this machine? (Loads it on the main thread once.) */
export function nativeRecorderPath(env: NodeJS.ProcessEnv = process.env): string | null {
  // Installed packages: the platform package's copy (OMNIRUSH_VOICE_RECORDER,
  // set by the launcher). Dev checkouts: the package root's node_modules.
  const bundled = (env.OMNIRUSH_VOICE_RECORDER || "").trim();
  if (bundled) {
    try {
      return createRequire(path.join(bundled, "package.json")).resolve(bundled);
    } catch {
      /* fall back to the package root */
    }
  }
  const root = packageRoot(env);
  if (!root) return null;
  try {
    const require = createRequire(path.join(root, "package.json"));
    return require.resolve("@picovoice/pvrecorder-node");
  } catch {
    return null;
  }
}

/** pvrecorder in a worker thread (its read() blocks until a frame is ready). */
export class NativeSource implements PcmSource {
  readonly name = "pvrecorder";
  private worker: any = null;
  private stopped = false;

  constructor(
    private readonly modulePath: string,
    private readonly workerFile: string,
    private readonly deviceIndex = -1,
  ) {}

  async start(onPcm: (pcm: Int16Array) => void, onEnd?: (error?: VoiceError) => void): Promise<void> {
    const onError = (error: Error) => onEnd?.(new VoiceError("capture_failed", error.message));
    const { Worker } = await import("node:worker_threads");
    const worker = new Worker(this.workerFile, { workerData: { modulePath: this.modulePath, deviceIndex: this.deviceIndex } });
    this.worker = worker;
    await new Promise<void>((resolve, reject) => {
      const timeout = setTimeout(() => reject(new Error("pvrecorder did not start")), 3000);
      worker.on("message", (message: any) => {
        if (message?.type === "started") {
          clearTimeout(timeout);
          resolve();
        } else if (message?.type === "error") {
          clearTimeout(timeout);
          if (!this.stopped) {
            const error = new Error(`pvrecorder: ${message.message}`);
            reject(error);
            onError(error);
          }
        } else if (message?.type === "pcm" && !this.stopped) {
          onPcm(new Int16Array(message.pcm));
        }
      });
      worker.on("error", (error: Error) => {
        clearTimeout(timeout);
        reject(error);
        if (!this.stopped) onError(error);
      });
    });
  }

  stop(): void {
    this.stopped = true;
    const worker = this.worker;
    this.worker = null;
    if (!worker) return;
    try {
      worker.postMessage("stop");
    } catch {
      /* gone */
    }
    setTimeout(() => worker.terminate().catch?.(() => undefined), 500).unref?.();
  }
}

// ---------------------------------------------------------------------------
// Opening the first working recorder

export interface OpenOptions {
  probe?: EnvProbe;
  spawnImpl?: typeof spawn;
  /** Override the native recorder (tests): a factory or null to skip it. */
  native?: (() => PcmSource) | null;
  candidates?: Candidate[];
  probeMs?: number;
  workerFile?: string;
}

export interface OpenedRecorder {
  source: PcmSource;
  tried: Array<{ name: string; reason: string }>;
}

function defaultWorkerFile(): string | null {
  const root = packageRoot();
  const candidates = [
    root ? path.join(root, "assets", "extensions", "omnirush", "voice", "pvrecorder-worker.cjs") : null,
    (() => {
      try {
        return path.join(path.dirname(new URL(import.meta.url).pathname), "pvrecorder-worker.cjs");
      } catch {
        return null;
      }
    })(),
  ];
  for (const file of candidates) if (file && fs.existsSync(file)) return file;
  return null;
}

/**
 * Starts the first recorder that delivers audio. `onPcm`/`onError` are
 * wired to whichever wins. Throws RecorderUnavailableError (with the
 * install hint) when none does.
 */
export async function openRecorder(
  onPcm: (pcm: Int16Array) => void,
  onEnd: (error?: VoiceError) => void,
  options: OpenOptions = {},
): Promise<OpenedRecorder> {
  const probe = options.probe ?? realProbe();
  const tried: Array<{ name: string; reason: string }> = [];
  const file = voiceInputFileFromEnv(probe.env);
  if (file) {
    const source = new FileSource(file);
    await source.start(onPcm, onEnd);
    return { source, tried };
  }
  const reason = remoteReason(probe);
  if (reason) throw new RecorderUnavailableError(`Voice can't use a microphone here: ${reason}.`, null, tried);
  const candidates = options.candidates ?? recorderCandidates(probe.platform);
  for (const candidate of candidates) {
    let source: PcmSource | null = null;
    if (candidate.kind === "native") {
      if (options.native === null) continue;
      if (options.native) source = options.native();
      else if (!nativeRecorderAllowed(probe)) {
        tried.push({ name: candidate.name, reason: "no ALSA sound card" });
        continue;
      }
      else {
        const modulePath = nativeRecorderPath(probe.env);
        const workerFile = options.workerFile ?? defaultWorkerFile();
        if (!modulePath || !workerFile) {
          tried.push({ name: candidate.name, reason: "not installed" });
          continue;
        }
        source = new NativeSource(modulePath, workerFile);
      }
    } else {
      source = new ToolSource(candidate, { spawnImpl: options.spawnImpl, probeMs: options.probeMs });
    }
    try {
      await source.start(onPcm, onEnd);
      return { source, tried };
    } catch (error: any) {
      source.stop();
      tried.push({ name: candidate.name, reason: String(error?.message ?? error) });
    }
  }
  const installedButFailed = tried.filter((t) => !/not installed|no ALSA sound card/.test(t.reason));
  if (installedButFailed.length) {
    const detail = installedButFailed.map((t) => `${t.name}: ${t.reason}`).join("; ");
    throw new RecorderUnavailableError(
      `No microphone could be opened (${detail}). Check that an input device is connected and that this terminal may use the microphone.`,
      null,
      tried,
    );
  }
  const hint = installHint(probe);
  throw new RecorderUnavailableError(`No audio recorder found. Install one with:  ${hint}`, hint, tried);
}

/** Records `ms` of audio and returns the highest RMS seen (the /voice mic check). */
export async function testCapture(ms: number, options: OpenOptions = {}): Promise<{ maxRms: number; recorder: string }> {
  let maxRms = 0;
  let failure: Error | null = null;
  const opened = await openRecorder(
    (pcm) => {
      const value = rms(pcm);
      if (value > maxRms) maxRms = value;
    },
    (error) => {
      if (error) failure = error;
    },
    options,
  );
  await new Promise((resolve) => setTimeout(resolve, ms));
  opened.source.stop();
  if (failure) throw failure;
  return { maxRms, recorder: opened.source.name };
}
