import record from "./record.js";
import koffi from "koffi";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, chmodSync } from "node:fs";
import readline from "node:readline";

/* ------------------------------------------------------------------ */
/* 0. Helpers                                                         */
/* ------------------------------------------------------------------ */
const here = dirname(fileURLToPath(import.meta.url));

const ASAR_TOKEN = "app.asar";
const ASAR_UNPACKED_TOKEN = "app.asar.unpacked";

// Ensure native libs can load from app.asar.unpacked when packaged
const maybeUnpackedPath = (libPath) => {
	if (typeof libPath !== "string") return libPath;
	if (!libPath.includes(ASAR_TOKEN)) return libPath;
	if (libPath.includes(ASAR_UNPACKED_TOKEN)) return libPath;
	const unpacked = libPath.replace(ASAR_TOKEN, ASAR_UNPACKED_TOKEN);
	return existsSync(unpacked) ? unpacked : libPath;
};
const ensureWinBinOnPath = (libPath) => {
	if (process.platform !== "win32") return;
	const dir = dirname(libPath);
	const current = process.env.Path || process.env.PATH || "";
	const parts = current.split(delimiter).filter(Boolean);
	if (!parts.includes(dir)) {
		process.env.Path = [dir, ...parts].join(delimiter);
	}
};
const originalKoffiLoad = koffi.load.bind(koffi);
koffi.load = (libPath, ...rest) => {
	const resolved = maybeUnpackedPath(libPath);
	if (resolved !== libPath && typeof resolved === "string") {
		ensureWinBinOnPath(resolved);
	}
	return originalKoffiLoad(resolved, ...rest);
};

const { Model, Recognizer, setLogLevel } = await import("vosk-koffi");

function unpacked(p) {
	if (!p || !p.includes(ASAR_TOKEN)) return p;
	if (p.includes(ASAR_UNPACKED_TOKEN)) return p;
	return p.replace(ASAR_TOKEN, ASAR_UNPACKED_TOKEN);
}

const UNKNOWN_TOKEN = "[unk]";
const UNKNOWN_TOKEN_NORMALIZED = "unk";
const normalizePhrase = (phrase = "") => phrase.trim().toLowerCase();
const toBool = (v = "") =>
	["1", "true", "yes", "y"].includes(`${v}`.trim().toLowerCase());
const tokenize = (phrase = "") =>
	(phrase ?? "")
		.toString()
		.toLowerCase()
		.replace(/[^a-z0-9]+/gi, " ")
		.trim()
		.split(/\s+/)
		.filter(Boolean);
const tokensEqual = (a = [], b = []) =>
	a.length === b.length && a.every((token, idx) => token === b[idx]);
const trimUnknownBoundaryTokens = (tokens = []) => {
	let start = 0;
	let end = tokens.length;
	while (
		start < end &&
		(tokens[start] === UNKNOWN_TOKEN_NORMALIZED || tokens[start] === UNKNOWN_TOKEN)
	) {
		start += 1;
	}
	while (
		end > start &&
		(tokens[end - 1] === UNKNOWN_TOKEN_NORMALIZED || tokens[end - 1] === UNKNOWN_TOKEN)
	) {
		end -= 1;
	}
	return tokens.slice(start, end);
};
const tokensContainSequence = (tokens = [], phraseTokens = []) => {
	if (!phraseTokens.length || tokens.length < phraseTokens.length) return false;
	for (let i = 0; i <= tokens.length - phraseTokens.length; i += 1) {
		let matches = true;
		for (let j = 0; j < phraseTokens.length; j += 1) {
			if (tokens[i + j] !== phraseTokens[j]) {
				matches = false;
				break;
			}
		}
		if (matches) return true;
	}
	return false;
};

/* ------------------------------------------------------------------ */
/* 1. Resolve SoX binary and audio device                            */
/* ------------------------------------------------------------------ */
const defaultExeName = {
	win32: "sox.exe",
	darwin: "soxmac",
	linux: "soxlinux",
}[process.platform];
const exeName = defaultExeName;
const MATCH_SENTENCE = toBool(process.env.LUMIA_VOICE_MATCH_SENTENCE);
const DISABLE_GRAMMAR = toBool(process.env.LUMIA_VOICE_DISABLE_GRAMMAR);

/* Priority for sox path: argv[2] → fallback to sibling binaries/<exe> */
/* Priority for device: argv[3] → env var → default */
let soxPath = process.argv[2];
if (!soxPath || soxPath === "") {
	soxPath = join(here, "..", "binaries", exeName);
}
soxPath = unpacked(soxPath);

// Parse device from argv[3] or environment variable
let audioDeviceRaw = process.argv[3] ?? process.env.AUDIO_DEVICE ?? null;
let audioDevice =
	typeof audioDeviceRaw === "string" ? audioDeviceRaw.trim() : audioDeviceRaw;
if (!audioDevice) audioDevice = null;
// Normalize Windows numeric "0" to SoX's "default" alias
if (process.platform === "win32" && audioDevice === "0") {
	audioDevice = "default";
}

if (!existsSync(soxPath)) throw new Error(`SoX not found: ${soxPath}`);
try {
	chmodSync(soxPath, 0o755);
} catch {
	/* ignore on read‐only FS */
}

/* ------------------------------------------------------------------ */
/* 2. Resolve Vosk model                                              */
/* ------------------------------------------------------------------ */
const envModelPath = (process.env.LUMIA_VOICE_MODEL_PATH || "").trim();
let modelPath =
	envModelPath || join(here, "..", "models", "vosk-model-small-en-us-0.15");
modelPath = unpacked(modelPath);

if (!existsSync(modelPath))
	throw new Error(`Vosk model not found: ${modelPath}`);

/* ------------------------------------------------------------------ */
/* 3. Initialise recogniser                                           */
/* ------------------------------------------------------------------ */
setLogLevel(0);

const SAMPLE_RATE = Number(process.env.SAMPLE_RATE || 16_000);
let GRAMMAR = [UNKNOWN_TOKEN]; // seed; always keep [unk]
let COMMANDS = [];
let EXTRA_GRAMMAR = [];

const model = new Model(modelPath);
const buildRecognizer = () => {
	const recognizer =
		MATCH_SENTENCE || DISABLE_GRAMMAR
			? new Recognizer({ model, sampleRate: SAMPLE_RATE })
			: new Recognizer({ model, sampleRate: SAMPLE_RATE, grammar: GRAMMAR });
	recognizer.setWords(true);
	return recognizer;
};
let rec = buildRecognizer();

/* ------------------------------------------------------------------ */
/* 4. Start the microphone                                            */
/* ------------------------------------------------------------------ */
const recArgs = { sampleRate: SAMPLE_RATE, threshold: 0, binPath: soxPath };
// Feed raw PCM to Vosk for consistent parsing across platforms
recArgs.audioType = "raw";
recArgs.channels = 1;

// Set device based on platform and configuration
if (audioDevice !== null) {
	// User specified a device explicitly
	recArgs.device = audioDevice;
	console.error(`Using audio device: ${audioDevice || "default"}`);
} else if (process.platform === "win32") {
	// Windows: use default alias for reliability
	recArgs.device = "default";
	console.error("Using default Windows audio device: default");
	console.error(
		"To specify a different device, use: AUDIO_DEVICE=<device_id> or pass as 3rd argument",
	);
}

const mic = record.record(recArgs).stream();
// Handle recorder (SoX) errors to avoid unhandled 'error' events
mic.on("error", (err) => {
	const msg = typeof err === "string" ? err : err?.message || String(err);
	console.error(`[wakeword] audio stream error: ${msg}`);
	process.exit(2);
});
// Define a confidence threshold for individual words.
// You might need to adjust this value based on your specific use case.
let WORD_CONFIDENCE_THRESHOLD = 0.7;
const DEBUG_AUDIO = ["1", "true", "yes"].includes(
	(process.env.WAKEWORD_DEBUG || "").toLowerCase(),
);
const LOG_PARTIAL =
	DEBUG_AUDIO ||
	["1", "true", "yes"].includes(
		(process.env.WAKEWORD_LOG_PARTIAL || "").toLowerCase(),
	);
let LOG_FINAL = ["1", "true", "yes"].includes(
	(process.env.WAKEWORD_LOG_FINAL || "").toLowerCase(),
);
let emittedMatchesInUtterance = new Set();
let lastLevelLog = 0;

function logAudioLevel(buf) {
	const now = Date.now();
	if (now - lastLevelLog < 1000) return;
	lastLevelLog = now;

	// Expect 16-bit little-endian PCM
	const samples = buf.length / 2;
	if (!samples) return;
	let sumSquares = 0;
	for (let i = 0; i < buf.length; i += 2) {
		const sample = buf.readInt16LE(i);
		sumSquares += sample * sample;
	}
	const rms = Math.sqrt(sumSquares / samples);
	console.error(`[wakeword] audio rms=${rms.toFixed(1)} (0-32768)`);
}

mic.on("data", (buf) => {
	if (DEBUG_AUDIO) logAudioLevel(buf);

	if (rec.acceptWaveform(buf)) {
		const fullResult = rec.result();

		// Check if the result has word details
		if (fullResult && fullResult.result && Array.isArray(fullResult.result)) {
			let recognizedWords = [];
			let totalConfidence = 0;
			let totalConfidenceAll = 0;
			let totalConfidenceCount = 0;

			for (const wordDetail of fullResult.result) {
				if (typeof wordDetail.conf === "number") {
					totalConfidenceAll += wordDetail.conf;
					totalConfidenceCount += 1;
				}
				// Each word has its own confidence ('conf')
				if (wordDetail.conf >= WORD_CONFIDENCE_THRESHOLD) {
					recognizedWords.push(wordDetail.word);
					totalConfidence += wordDetail.conf;
				} else {
					console.log(
						`Discarding low-confidence word: "${
							wordDetail.word
						}" (Conf: ${wordDetail.conf.toFixed(2)})`,
					);
				}
			}

			const finalRecognizedText = recognizedWords.join(" ").trim();
			const averageConfidenceAll =
				totalConfidenceCount > 0
					? totalConfidenceAll / totalConfidenceCount
					: 0;
			const averageConfidence =
				recognizedWords.length > 0
					? totalConfidence / recognizedWords.length
					: averageConfidenceAll;

			handle(finalRecognizedText, averageConfidence, fullResult.text, {
				isPartial: false,
			}); // Pass both the filtered text and an average confidence
		} else if (fullResult && fullResult.text) {
			// Fallback for cases where setWords(true) might not fully apply
			handle(fullResult.text.trim(), 1.0, fullResult.text, {
				isPartial: false,
			}); // Assume high confidence if no word-level details
		}
	} else {
		const partial = rec.partialResult();
		if (partial?.partial && LOG_PARTIAL) {
			console.error(`[wakeword] partial: "${partial.partial}"`);
		}
		if (partial?.partial && !MATCH_SENTENCE) {
			handle(partial.partial.trim(), 1.0, partial.partial, { isPartial: true });
		}
	}
});

function handle(processedWord, averageConfidence, originalText, options = {}) {
	const { isPartial = false } = options;
	if (!processedWord && !originalText) return;

	const finalSentence =
		typeof originalText === "string" && originalText.trim()
			? originalText.trim()
			: (processedWord ?? "").toString().trim();
	if (!isPartial && LOG_FINAL && finalSentence) {
		process.stdout?.write(`final|${finalSentence}\n`);
	}

	const normalizedProcessed = normalizePhrase(processedWord);
	const normalizedOriginal = normalizePhrase(originalText);
	const processedTokens = trimUnknownBoundaryTokens(
		tokenize(normalizedProcessed),
	);
	const originalTokens = trimUnknownBoundaryTokens(
		tokenize(normalizedOriginal),
	);
	const matches = new Set();
	const confidentCommands = new Set();

	const findMatches = (tokens, allowedCommands = COMMANDS) => {
		if (!tokens?.length) return;
		const hits = MATCH_SENTENCE
			? allowedCommands.filter((command) =>
					tokensContainSequence(
						tokens,
						trimUnknownBoundaryTokens(tokenize(command)),
					),
				)
			: allowedCommands.filter((command) =>
					tokensEqual(
						tokens,
						trimUnknownBoundaryTokens(tokenize(command)),
					),
				);
		hits.forEach((hit) => matches.add(hit));
	};

	// Only allow sentence matches for commands that were confidently recognized.
	if (normalizedProcessed) {
		COMMANDS.forEach((command) => {
			const commandTokens = trimUnknownBoundaryTokens(tokenize(command));
			const isMatch = MATCH_SENTENCE
				? tokensContainSequence(processedTokens, commandTokens)
				: tokensEqual(processedTokens, commandTokens);
			if (isMatch) {
				confidentCommands.add(command);
			}
		});
	}

	// Try the filtered text first, then fall back to the raw sentence only for confident commands.
	findMatches(processedTokens);
	findMatches(originalTokens, [...confidentCommands]);

	// If word-level confidence filtering removed all words, fall back to the
	// original text when overall confidence is still acceptable.
	if (
		!matches.size &&
		normalizedOriginal &&
		averageConfidence >= WORD_CONFIDENCE_THRESHOLD
	) {
		findMatches(originalTokens);
	}

	if (!matches.size) {
		if (!isPartial) {
			emittedMatchesInUtterance.clear();
		}
		return;
	}

	const uniqueMatches = [...matches].filter(
		(match) => !emittedMatchesInUtterance.has(match),
	);
	if (!uniqueMatches.length) {
		if (!isPartial) {
			emittedMatchesInUtterance.clear();
		}
		return;
	}

	uniqueMatches.forEach((match) => {
		if (finalSentence) {
			process.stdout?.write(`sentence|${finalSentence}\n`);
		}
		process.stdout?.write(`voice|${match}\n`);
		process.stdout?.write(`confidence|${averageConfidence}\n`);
		emittedMatchesInUtterance.add(match);
	});
	if (!isPartial) {
		emittedMatchesInUtterance.clear();
	}
}
/* ------------------------------------------------------------------ */
/* 6. Hot-reload grammar via stdin                                    */
/* ------------------------------------------------------------------ */
const rl = readline.createInterface({ input: process.stdin, terminal: false });

rl.on("line", (line) => {
	const trimmed = line.trim();
	if (
		!trimmed.startsWith("update,") &&
		!trimmed.startsWith("extras,") &&
		!trimmed.startsWith("confidence,") &&
		!trimmed.startsWith("debug,")
	)
		return;

	if (trimmed.startsWith("confidence,")) {
		WORD_CONFIDENCE_THRESHOLD = Number(trimmed.split(",")[1]);
		return;
	}
	if (trimmed.startsWith("debug,")) {
		LOG_FINAL = toBool(trimmed.split(",")[1]);
		return;
	}

	const phrases = trimmed
		.split(",")
		.slice(1)
		.map((s) => normalizePhrase(s))
		.filter(Boolean);

	if (trimmed.startsWith("extras,")) {
		EXTRA_GRAMMAR = phrases;
		GRAMMAR = [...COMMANDS, ...EXTRA_GRAMMAR, UNKNOWN_TOKEN];
		console.error(
			`[wakeword] extra grammar updated (${phrases.length}): ${phrases.join(", ")}`,
		);
		rec = buildRecognizer();
		return;
	}

	if (!phrases.length) return;

	COMMANDS = phrases;
	GRAMMAR = [...COMMANDS, ...EXTRA_GRAMMAR, UNKNOWN_TOKEN];
	console.error(
		`[wakeword] grammar updated (${phrases.length}): ${phrases.join(", ")}`,
	);
	rec = buildRecognizer();
});
