package com.davoice.keywordspotting;

import com.davoice.keywordsdetection.keywordslibrary.KeyWordsDetection;
import com.davoice.keywordsdetection.keywordslibrary.SpeakerVerification;

import org.json.JSONObject;
import org.json.JSONException;

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.*;

import com.facebook.react.bridge.*;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import androidx.annotation.Nullable;
import android.util.Log;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;


public class KeyWordRNBridge extends ReactContextBaseJavaModule {

    private final String TAG = "KeyWordsDetection";
    private static final String REACT_CLASS = "KeyWordRNBridge";
    private static ReactApplicationContext reactContext;
    // ===============================
    // Speaker Verification holders
    // ===============================
    private static final String SV_TAG = "SV.RNBridge";

    private static final class SVEngineHolder {
        String engineId;
        SpeakerVerification.SpeakerVerificationConfig cfg;
        SpeakerVerification.SpeakerVerificationEngine engine;
        SpeakerVerification.SpeakerEnrollment enrollment;
    }

    private final Map<String, SVEngineHolder> svEngines = new ConcurrentHashMap<>();

    private final Map<String, SpeakerVerification.SpeakerVerificationMicController> svMicControllers = new ConcurrentHashMap<>();
    private final Map<String, Boolean> svAutoOnboarding = new ConcurrentHashMap<>();
    private final Map<String, Integer> svAutoTarget = new ConcurrentHashMap<>();
    private final Map<String, Integer> svAutoCollected = new ConcurrentHashMap<>();
    private final Map<String, String> svAutoEnrollmentId = new ConcurrentHashMap<>();
    // IMPORTANT: serialize ALL SV ops to avoid races with mic frames / onboarding state.
    private final ExecutorService svExec = Executors.newSingleThreadExecutor();
    private final AtomicInteger svJobN = new AtomicInteger(0);

    // VAD API:
    private final Map<String, Float>  vadThresholdByInstance = new HashMap<>();
    private final Map<String, Integer> vadMsWindowByInstance = new HashMap<>();
    private static final float DEFAULT_VAD_THRESHOLD = 0.45f;
    private static final int   DEFAULT_VAD_MSWINDOW  = 1000;

    // Map to hold multiple instances
    private Map<String, KeyWordsDetection> instances = new HashMap<>();

    public KeyWordRNBridge(ReactApplicationContext context) {
        super(context);
        reactContext = context;
    }

    @Override
    public String getName() {
        return REACT_CLASS;
    }

    @ReactMethod
    public void setKeywordDetectionLicense(String instanceId, String licenseKey, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        Log.d(TAG, "setKeywordDetectionLicense()");

        Boolean isLicesed = false;
        if (instance != null) {
            isLicesed = instance.setLicenseKey(licenseKey);
        }
        Log.d(TAG, "setKeywordDetectionLicense(): " + (isLicesed ? "Licensed" : "Not Licensed"));

        promise.resolve(isLicesed);
    }

    // Create a new instance efficiently
    @ReactMethod
    public void createInstanceMulti(String instanceId, ReadableArray modelPathsArray, ReadableArray thresholdsArray, ReadableArray bufferCntsArray, ReadableArray msBetweenCallbackArray, Promise promise) {
        if (instances.containsKey(instanceId)) {
            promise.reject("InstanceExists", "Instance already exists with ID: " + instanceId);
            return;
        }

        try {
            int size = modelPathsArray.size();
            if (thresholdsArray.size() != size || bufferCntsArray.size() != size || msBetweenCallbackArray.size() != size) {
                promise.reject("InvalidArguments", "All input arrays must be the same length.");
                return;
            }

            // Convert ReadableArrays to Java arrays
            String[] modelPaths = new String[size];
            float[] thresholds = new float[size];
            int[] bufferCnts = new int[size];
            long[] msBetweenCallback = new long[size];

            for (int i = 0; i < size; i++) {
                modelPaths[i] = modelPathsArray.getString(i);
                thresholds[i] = (float) thresholdsArray.getDouble(i);
                bufferCnts[i] = bufferCntsArray.getInt(i);
                msBetweenCallback[i] = (long) msBetweenCallbackArray.getDouble(i); // RN uses Double for all numbers
            }

            // Create instance
            KeyWordsDetection keyWordsDetection = new KeyWordsDetection(reactContext, modelPaths, thresholds, bufferCnts, msBetweenCallback);
            keyWordsDetection.initialize((detected, modelName) -> onKeywordDetected(instanceId, detected, modelName));

            instances.put(instanceId, keyWordsDetection);
            vadThresholdByInstance.put(instanceId, DEFAULT_VAD_THRESHOLD);
            vadMsWindowByInstance.put(instanceId, DEFAULT_VAD_MSWINDOW);
  
            promise.resolve("Multi-model instance created with ID: " + instanceId);

        } catch (Exception e) {
            promise.reject("CreateError", "Failed to create multi-model instance: " + e.getMessage());
        }
    }

    // Create a new instance
    @ReactMethod
    public void createInstance(String instanceId, String modelName, float threshold, int bufferCnt, Promise promise) {
        if (instances.containsKey(instanceId)) {
            promise.reject("InstanceExists", "Instance already exists with ID: " + instanceId);
            return;
        }

        try {
            KeyWordsDetection keyWordsDetection = new KeyWordsDetection(reactContext, modelName, threshold, bufferCnt);
            keyWordsDetection.initialize((detected, ignored) -> onKeywordDetected(instanceId, detected, modelName));
            vadThresholdByInstance.put(instanceId, DEFAULT_VAD_THRESHOLD);
            vadMsWindowByInstance.put(instanceId, DEFAULT_VAD_MSWINDOW);

            instances.put(instanceId, keyWordsDetection);
            promise.resolve("Instance created with ID: " + instanceId);
        } catch (Exception e) {
            promise.reject("CreateError", "Failed to create instance: " + e.getMessage());
        }
    }

    @ReactMethod
    public void getRecordingWav(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        String recordingWav = "";
        if (instance == null) {
            promise.reject("Instance not Exists", "Instance does not exists with ID: " + instanceId);
            return;
        }
        try {
            recordingWav = instance.getRecordingWav();
            promise.resolve(recordingWav);
        } catch (Exception e) {
            promise.reject("GetRecordingWavError", "Failed to get recording WAV: " + e.getMessage());
        }
    }

    @ReactMethod
    public void getRecordingWavArray(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance == null) {
            promise.reject("Instance not Exists", "Instance does not exists with ID: " + instanceId);
            return;
        }
        try {
            String[] recordingWavs = instance.getRecordingWavArray();
            WritableArray out = Arguments.createArray();
            if (recordingWavs != null) {
                for (String recordingWav : recordingWavs) {
                    out.pushString(recordingWav);
                }
            }
            promise.resolve(out);
        } catch (Exception e) {
            promise.reject("GetRecordingWavArrayError", "Failed to get recording WAV array: " + e.getMessage());
        }
    }

    // Create a new instance
    @ReactMethod
    public void replaceKeywordDetectionModel(String instanceId, String modelName, float threshold, int bufferCnt, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance == null) {
            promise.reject("Instance not Exists", "Instance does not exists with ID: " + instanceId);
            return;
        }

        try {
            instance.replaceKeywordDetectionModel(reactContext, modelName, threshold, bufferCnt);
            promise.resolve("Instance ID: " + instanceId + " change model " + modelName);
        } catch (Exception e) {
            promise.reject("CreateError", "Failed to create instance: " + e.getMessage());
        }
    }

    // Start detection for a specific instance
    @ReactMethod
    public void startKeywordDetection(String instanceId, float threshold, @Nullable String speakerVerificationEnrollmentJsonOrPath, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance != null) {
            try {
                instance.startListening(threshold, speakerVerificationEnrollmentJsonOrPath);
                promise.resolve("Started detection for instance: " + instanceId);
            } catch (Exception e) {
                promise.reject("StartDetectionError", "Failed to start detection: " + e.getMessage());
            }
        } else {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
        }
    }

    // Stop detection for a specific instance
    @ReactMethod
    public void stopForegroundService(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance != null) {
            instance.stopForegroundService();
            promise.resolve("stopForegroundService" + instanceId);
        } else {
            promise.reject("stopForegroundService", "No instance found with ID: " + instanceId);
        }
    }
    
    // Stop detection for a specific instance
    @ReactMethod
    public void startForegroundService(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance != null) {
            instance.startForegroundService();
            promise.resolve("startForegroundService" + instanceId);
        } else {
            promise.reject("startForegroundService", "No instance found with ID: " + instanceId);
        }
    }

    // Stop detection for a specific instance
    @ReactMethod
    public void stopKeywordDetection(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance != null) {
            instance.stopListening();
            promise.resolve("Stopped detection for instance: " + instanceId);
        } else {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
        }
    }

    @ReactMethod
    public void pauseDetection(String instanceId, boolean stopMic, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance != null) {
            try {
                instance.pauseDetection(stopMic);
                promise.resolve("Paused detection for instance: " + instanceId + " (stopMic=" + stopMic + ")");
            } catch (Exception e) {
                promise.reject("PauseDetectionError", "Failed to pause detection: " + e.getMessage());
            }
        } else {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
        }
    }

    @ReactMethod
    public void unPauseDetection(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance != null) {
            try {
                instance.unPauseDetection();
                promise.resolve("Unpaused detection for instance: " + instanceId);
            } catch (Exception e) {
                promise.reject("UnPauseDetectionError", "Failed to unpause detection: " + e.getMessage());
            }
        } else {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
        }
    }

    // Destroy an instance
    @ReactMethod
    public void destroyInstance(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.remove(instanceId);
        vadThresholdByInstance.remove(instanceId);   // NEW
        vadMsWindowByInstance.remove(instanceId);    // NEW
        if (instance != null) {
            instance.stopListening();
            // Additional cleanup if necessary
            promise.resolve("Destroyed instance: " + instanceId);
        } else {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
        }
    }

    // Handle keyword detection event
    private void onKeywordDetected(String instanceId, boolean detected, String modelName) {
        if (detected) {
            WritableMap params = Arguments.createMap();
            params.putString("instanceId", instanceId);
            params.putString("phrase", modelName);
            params.putString("modelName", modelName);
            sendEvent("onKeywordDetectionEvent", params);
        }
    }

    // Send event to JavaScript
    private void sendEvent(String eventName, @Nullable WritableMap params) {
        reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
            .emit(eventName, params);
    }

        // VAD API:

    // ===== Add: VAD parity methods (anywhere among other @ReactMethod methods) =====
    @ReactMethod
    public void getVoiceProps(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance == null) {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
            return;
        }
        try {
            @SuppressWarnings("unchecked")
            Map<String, Object> props = instance.getVoiceProps();
            WritableMap out = Arguments.createMap();

            Object err  = props.get("error");
            Object prob = props.get("voiceProbability");
            Object last = props.get("lastTimeHumanVoiceHeard");

            out.putString("error", err == null ? "" : String.valueOf(err));
            out.putDouble("voiceProbability", prob instanceof Number ? ((Number) prob).doubleValue() : 0.0);
            out.putDouble("lastTimeHumanVoiceHeard", last instanceof Number ? ((Number) last).doubleValue() : 0.0);

            promise.resolve(out);
        } catch (Exception e) {
            promise.reject("GetVoicePropsError", e.getMessage());
        }
    }

    @ReactMethod
    public void startVADDetection(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance == null) {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
            return;
        }
        try {
            // after: API-21 safe
            Float _thr = vadThresholdByInstance.get(instanceId);
            float thr = (_thr != null) ? _thr : DEFAULT_VAD_THRESHOLD;
            Integer   _win = vadMsWindowByInstance.get(instanceId);
            int   win = (_win != null) ? _win : DEFAULT_VAD_MSWINDOW;
            instance.setVADParams(thr, win);
            boolean ok = instance.startVADListening();
            promise.resolve(ok);
        } catch (Exception e) {
            promise.reject("StartVADError", e.getMessage());
        }
    }

    @ReactMethod
    public void stopVADDetection(String instanceId, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance == null) {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
            return;
        }
        try {
            instance.stopVADListening();
            promise.resolve("Stopped VAD for instance: " + instanceId);
        } catch (Exception e) {
            promise.reject("StopVADError", e.getMessage());
        }
    }

    @ReactMethod
    public void setVADParams(String instanceId, double threshold, int msWindow, Promise promise) {
        KeyWordsDetection instance = instances.get(instanceId);
        if (instance == null) {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
            return;
        }
        try {
            float thr = (float) threshold;
            vadThresholdByInstance.put(instanceId, thr);
            vadMsWindowByInstance.put(instanceId, msWindow);
            instance.setVADParams(thr, msWindow);
            promise.resolve(null);
        } catch (Exception e) {
            promise.reject("SetVADParamsError", e.getMessage());
        }
    }

    @ReactMethod
    public void getVADParams(String instanceId, Promise promise) {
        if (!instances.containsKey(instanceId)) {
            promise.reject("InstanceNotFound", "No instance found with ID: " + instanceId);
            return;
        }
        WritableMap out = Arguments.createMap();
        Float _thr = vadThresholdByInstance.get(instanceId);
        float thr = (_thr != null) ? _thr : DEFAULT_VAD_THRESHOLD;
        Integer   _win = vadMsWindowByInstance.get(instanceId);
        int   win = (_win != null) ? _win : DEFAULT_VAD_MSWINDOW;
        out.putDouble("threshold", (double) thr);
        out.putInt("msWindow", win);
        promise.resolve(out);
    }

    @ReactMethod
    public void addListener(String eventName) {
        // Set up any upstream listeners or background tasks as necessary
    }

    @ReactMethod
    public void removeListeners(Integer count) {
            // Remove upstream listeners, stop unnecessary background tasks
    }
    
    // Implement other methods as needed, ensuring to use instanceId
    private String stripFileSchemeAndroid(String s) {
        if (s == null) return null;
        if (s.startsWith("file://")) return s.replace("file://", "");
        return s;
    }

    // ********** SPEAKER IDENTIFICATION APIs ********** 
    // Resolves:
    // - absolute filesystem path -> returns as-is
    // - "asset:/name.onnx" or "assets:/name.onnx" -> copies from APK assets to cache, returns real path
    // - "name.onnx" (bundle asset name) -> copies from assets to cache
    private String resolveToRealFilePath(String input) throws IOException {
        input = stripFileSchemeAndroid(input);
        if (input == null || input.isEmpty()) return input;

        // Absolute existing path
        if (input.startsWith("/") && new File(input).exists()) {
            return input;
        }

        // RN Android require(...) often yields "asset:/..."
        String assetName = input;
        if (assetName.startsWith("asset:/")) assetName = assetName.substring("asset:/".length());
        if (assetName.startsWith("assets:/")) assetName = assetName.substring("assets:/".length());

        // If still contains directories like "assets/.../name.onnx", keep last component
        // (your iOS resolver scans bundle; Android assets are flat-ish but can be nested)
        // We'll try direct open first; if fails, try lastPathComponent.
        String try1 = assetName;
        String try2 = new File(assetName).getName();

        File out1 = copyAssetToCacheIfExists(try1);
        if (out1 != null) return out1.getAbsolutePath();

        File out2 = copyAssetToCacheIfExists(try2);
        if (out2 != null) return out2.getAbsolutePath();

        // Not found
        throw new FileNotFoundException("Cannot resolve asset/path: " + input + " (tried " + try1 + " and " + try2 + ")");
    }

    private File copyAssetToCacheIfExists(String assetName) {
        if (assetName == null || assetName.isEmpty()) return null;
        try (InputStream is = reactContext.getAssets().open(assetName)) {
            File out = new File(reactContext.getCacheDir(), assetName);
            // ensure parent
            File parent = out.getParentFile();
            if (parent != null) parent.mkdirs();

            try (OutputStream os = new FileOutputStream(out)) {
                byte[] buf = new byte[64 * 1024];
                int n;
                while ((n = is.read(buf)) > 0) os.write(buf, 0, n);
            }
            return out;
        } catch (Throwable ignore) {
            return null;
        }
    }

    private SpeakerVerification.SpeakerVerificationConfig parseSVMicConfigJson(String configJson) throws JSONException, IOException {
        JSONObject root = new JSONObject(configJson == null ? "{}" : configJson);

        SpeakerVerification.SpeakerVerificationConfig cfg = new SpeakerVerification.SpeakerVerificationConfig();

        // Top-level fields (if you use them)
        String modelPath = root.optString("modelPath", "");
        int sampleRate   = root.optInt("sampleRate", cfg.sampleRate);
        int frameSize    = root.optInt("frameSize", cfg.frameSize);

        // iOS-style puts most stuff in "options"
        JSONObject opts = root.optJSONObject("options");
        if (opts != null) {
            cfg.decisionThreshold = (float) opts.optDouble("decisionThreshold", cfg.decisionThreshold);
            cfg.tailSeconds       = (float) opts.optDouble("tailSeconds", cfg.tailSeconds);
            cfg.maxTailSeconds    = (float) opts.optDouble("maxTailSeconds", cfg.maxTailSeconds);
            cfg.cmn               = opts.optBoolean("cmn", cfg.cmn);
            cfg.expectedLayoutBDT = opts.optBoolean("expectedLayoutBDT", cfg.expectedLayoutBDT);

            // Prefer opts.frameSize if present
            frameSize  = opts.has("frameSize") ? opts.optInt("frameSize", frameSize) : frameSize;
            sampleRate = opts.has("sampleRate") ? opts.optInt("sampleRate", sampleRate) : sampleRate;
        }

        cfg.sampleRate = sampleRate > 0 ? sampleRate : 16000;
        cfg.frameSize  = frameSize > 0 ? frameSize : 1280;

        // Resolve modelPath (MUST be real path for ORT)
        if (modelPath != null && !modelPath.isEmpty()) {
            cfg.modelPath = resolveToRealFilePath(modelPath);
        } else {
            // You can choose to throw here, but keeping consistent with iOS:
            // iOS requires it; so Android should too.
            throw new JSONException("Missing modelPath in configJson");
        }
        return cfg;
    }

    @ReactMethod
    public void createSpeakerVerifier(String engineId,
                                    String modelPathOrName,
                                    String enrollmentJsonPathOrName,
                                    ReadableMap options,
                                    Promise promise) {
        if (svEngines.containsKey(engineId)) {
            promise.reject("SVEngineExists", "Speaker verifier already exists with ID: " + engineId);
            return;
        }

        new Thread(() -> {
            try {
                String modelPath = resolveToRealFilePath(modelPathOrName);
                String jsonPath  = resolveToRealFilePath(enrollmentJsonPathOrName);

                // Build cfg from options (mirror iOS options)
                SpeakerVerification.SpeakerVerificationConfig cfg = new SpeakerVerification.SpeakerVerificationConfig();
                cfg.modelPath = modelPath;

                if (options != null) {
                    if (options.hasKey("decisionThreshold")) cfg.decisionThreshold = (float) options.getDouble("decisionThreshold");
                    if (options.hasKey("frameSize")) cfg.frameSize = options.getInt("frameSize");
                    if (options.hasKey("tailSeconds")) cfg.tailSeconds = (float) options.getDouble("tailSeconds");
                    if (options.hasKey("maxTailSeconds")) cfg.maxTailSeconds = (float) options.getDouble("maxTailSeconds");
                    if (options.hasKey("cmn")) cfg.cmn = options.getBoolean("cmn");
                    if (options.hasKey("expectedLayoutBDT")) cfg.expectedLayoutBDT = options.getBoolean("expectedLayoutBDT");
                }

                // Load enrollment json file -> string -> enrollment object
                String enrollmentJson = readAllText(jsonPath);
                SpeakerVerification.SpeakerEnrollment enrollment = SpeakerVerification.SpeakerEnrollment.fromJson(enrollmentJson);

                SpeakerVerification.SpeakerVerificationEngine engine = new SpeakerVerification.SpeakerVerificationEngine(cfg);
                engine.setEnrollment(enrollment);

                SVEngineHolder h = new SVEngineHolder();
                h.engineId = engineId;
                h.cfg = cfg;
                h.enrollment = enrollment;
                h.engine = engine;
                svEngines.put(engineId, h);

                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("engineId", engineId);
                out.putString("modelPath", modelPath);
                out.putString("enrollmentJsonPath", jsonPath);

                promise.resolve(out);

            } catch (Throwable t) {
                promise.reject("SVCreateError", String.valueOf(t.getMessage()), t);
            }
        }).start();
    }

    private String readAllText(String path) throws IOException {
        try (InputStream is = new FileInputStream(path)) {
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            byte[] buf = new byte[64 * 1024];
            int n;
            while ((n = is.read(buf)) > 0) bos.write(buf, 0, n);
            return bos.toString("UTF-8");
        }
    }

        @ReactMethod
    public void verifySpeakerWavStreaming(String engineId,
                                        String wavPathOrName,
                                        boolean resetState,
                                        Promise promise) {
        SVEngineHolder h = svEngines.get(engineId);
        if (h == null || h.engine == null) {
            promise.reject("SVEngineNotFound", "No speaker verifier with ID: " + engineId);
            return;
        }

        new Thread(() -> {
            try {
                String wavPath = resolveToRealFilePath(wavPathOrName);

                if (resetState) {
                    h.engine.resetStreamingState();
                }

                SpeakerVerification.SpeakerVerificationResult res =
                        runWavThroughEngineStreaming(h.engine, wavPath, h.cfg.frameSize);

                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("engineId", engineId);

                // Mirror iOS-ish keys (your JS can read bestScore/score etc)
                out.putDouble("scoreBest", res.scoreBest);
                out.putDouble("scoreMean", res.scoreMean);
                out.putDouble("scoreWorst", res.scoreWorst);
                out.putBoolean("isMatch", res.isMatch);
                out.putInt("embeddingDim", res.embeddingDim);
                out.putDouble("usedSeconds", res.usedSeconds);

                promise.resolve(out);

            } catch (Throwable t) {
                promise.reject("SVVerifyError", String.valueOf(t.getMessage()), t);
            }
        }).start();
    }

    private SpeakerVerification.SpeakerVerificationResult runWavThroughEngineStreaming(
            SpeakerVerification.SpeakerVerificationEngine engine,
            String wavPath,
            int frameSizeSamples
    ) throws Exception {

        WavPcm16 wav = readWavPcm16Mono(wavPath);
        if (wav.sampleRate != 16000) {
            throw new IllegalArgumentException("WAV must be 16kHz. Got " + wav.sampleRate);
        }

        final short[] pcm = wav.pcm16;
        int i = 0;

        while (i + frameSizeSamples <= pcm.length) {
            float[] f = new float[frameSizeSamples];
            for (int k = 0; k < frameSizeSamples; k++) {
                f[k] = pcm[i + k] / 32768.0f;
            }
            i += frameSizeSamples;

            SpeakerVerification.SpeakerVerificationOutput out = engine.processFrame(f);
            if (out != null && out.type == SpeakerVerification.SpeakerVerificationOutput.Type.RESULT) {
                return out.result;
            }
        }

        throw new IllegalStateException("NO_RESULT: WAV ended before engine produced RESULT");
    }

    // Minimal WAV reader: PCM16 LE, mono.
    private static final class WavPcm16 {
        int sampleRate;
        short[] pcm16;
    }

    private WavPcm16 readWavPcm16Mono(String path) throws IOException {
        try (InputStream is = new BufferedInputStream(new FileInputStream(path))) {
            byte[] header = new byte[12];
            readFully(is, header);

            // "RIFF" .... "WAVE"
            if (!(header[0]=='R' && header[1]=='I' && header[2]=='F' && header[3]=='F')) {
                throw new IOException("Not RIFF WAV");
            }
            if (!(header[8]=='W' && header[9]=='A' && header[10]=='V' && header[11]=='E')) {
                throw new IOException("Not WAVE");
            }

            int channels = -1, sampleRate = -1, bitsPerSample = -1;
            ByteArrayOutputStream data = new ByteArrayOutputStream();

            while (true) {
                byte[] chunkHdr = new byte[8];
                int n = is.read(chunkHdr);
                if (n < 0) break;
                if (n != 8) throw new IOException("Bad WAV chunk header");

                String id = new String(chunkHdr, 0, 4);
                int size = ByteBuffer.wrap(chunkHdr, 4, 4).order(ByteOrder.LITTLE_ENDIAN).getInt();

                if ("fmt ".equals(id)) {
                    byte[] fmt = new byte[size];
                    readFully(is, fmt);

                    int audioFormat = ByteBuffer.wrap(fmt, 0, 2).order(ByteOrder.LITTLE_ENDIAN).getShort() & 0xFFFF;
                    channels = ByteBuffer.wrap(fmt, 2, 2).order(ByteOrder.LITTLE_ENDIAN).getShort() & 0xFFFF;
                    sampleRate = ByteBuffer.wrap(fmt, 4, 4).order(ByteOrder.LITTLE_ENDIAN).getInt();
                    bitsPerSample = ByteBuffer.wrap(fmt, 14, 2).order(ByteOrder.LITTLE_ENDIAN).getShort() & 0xFFFF;

                    if (audioFormat != 1) throw new IOException("WAV must be PCM (audioFormat=1). Got " + audioFormat);
                } else if ("data".equals(id)) {
                    byte[] buf = new byte[64 * 1024];
                    int remaining = size;
                    while (remaining > 0) {
                        int r = is.read(buf, 0, Math.min(buf.length, remaining));
                        if (r < 0) throw new EOFException("WAV data truncated");
                        data.write(buf, 0, r);
                        remaining -= r;
                    }
                } else {
                    // skip other chunks
                    long skipped = is.skip(size);
                    while (skipped < size) {
                        long s = is.skip(size - skipped);
                        if (s <= 0) break;
                        skipped += s;
                    }
                }

                // chunks are word-aligned
                if ((size & 1) == 1) is.skip(1);
            }

            if (channels != 1) throw new IOException("WAV must be mono. Got channels=" + channels);
            if (bitsPerSample != 16) throw new IOException("WAV must be 16-bit. Got bits=" + bitsPerSample);
            if (sampleRate <= 0) throw new IOException("Missing/invalid sampleRate");

            byte[] raw = data.toByteArray();
            short[] pcm16 = new short[raw.length / 2];
            ByteBuffer bb = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN);
            for (int i = 0; i < pcm16.length; i++) pcm16[i] = bb.getShort();

            WavPcm16 out = new WavPcm16();
            out.sampleRate = sampleRate;
            out.pcm16 = pcm16;
            return out;
        }
    }

    private void readFully(InputStream is, byte[] buf) throws IOException {
        int off = 0;
        while (off < buf.length) {
            int n = is.read(buf, off, buf.length - off);
            if (n < 0) throw new EOFException("Unexpected EOF");
            off += n;
        }
    }

    @ReactMethod
    public void destroySpeakerVerifier(String engineId, Promise promise) {
        SVEngineHolder h = svEngines.remove(engineId);
        if (h == null) {
            promise.reject("SVEngineNotFound", "No speaker verifier with ID: " + engineId);
            return;
        }
        WritableMap out = Arguments.createMap();
        out.putBoolean("ok", true);
        out.putString("engineId", engineId);
        promise.resolve(out);
    }

    private WritableMap toWritableMap(Map<String, Object> m) {
        WritableMap out = Arguments.createMap();
        if (m == null) return out;

        for (Map.Entry<String, Object> e : m.entrySet()) {
            String k = e.getKey();
            Object v = e.getValue();
            if (v == null) continue;

            if (v instanceof String) out.putString(k, (String) v);
            else if (v instanceof Boolean) out.putBoolean(k, (Boolean) v);
            else if (v instanceof Integer) out.putInt(k, (Integer) v);
            else if (v instanceof Long) out.putDouble(k, ((Long) v).doubleValue());
            else if (v instanceof Float) out.putDouble(k, ((Float) v).doubleValue());
            else if (v instanceof Double) out.putDouble(k, (Double) v);
            else out.putString(k, String.valueOf(v));
        }
        return out;
    }

    private void sendEventUi(String eventName, WritableMap params) {
        final ReactApplicationContext rc = reactContext;
        if (rc == null) return;
        if (!rc.hasActiveCatalystInstance()) return;
        rc.runOnUiQueueThread(() -> {
            try {
                if (!rc.hasActiveCatalystInstance()) return;
                rc.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                  .emit(eventName, params);
            } catch (Throwable t) {
                // don't crash native if RN is tearing down
                Log.w(SV_TAG, "sendEventUi failed event=" + eventName + " err=" + t);
            }
        });
    }
    @ReactMethod
    public void createSpeakerVerificationMicController(String controllerId,
                                                    String configJson,
                                                    Promise promise) {
        if (svMicControllers.containsKey(controllerId)) {
            promise.reject("SVMicExists", "Speaker mic controller already exists with ID: " + controllerId);
            return;
        }

        new Thread(() -> {
            try {
                SpeakerVerification.SpeakerVerificationConfig cfg = parseSVMicConfigJson(configJson);

                SpeakerVerification.SpeakerVerificationMicController ctrl =
                        new SpeakerVerification.SpeakerVerificationMicController(reactContext, cfg);

                // Delegate proxy -> RN events
                ctrl.delegate = new SpeakerVerification.SpeakerVerificationNativeDelegate() {
                    @Override
                    public void svOnboardingProgress(Map<String, Object> info) {
                        Map<String, Object> m = info == null ? new HashMap<>() : new HashMap<>(info);
                        m.put("controllerId", controllerId);
                        sendEventUi("onSpeakerVerificationOnboardingProgress", toWritableMap(m));
                    }

                    @Override
                    public void svOnboardingDone(Map<String, Object> info) {
                        Map<String, Object> m = info == null ? new HashMap<>() : new HashMap<>(info);
                        m.put("controllerId", controllerId);
                        sendEventUi("onSpeakerVerificationOnboardingDone", toWritableMap(m));
                    }

                    @Override
                    public void svVerifyResult(Map<String, Object> info) {
                        Map<String, Object> m = info == null ? new HashMap<>() : new HashMap<>(info);
                        m.put("controllerId", controllerId);
                        sendEventUi("onSpeakerVerificationVerifyResult", toWritableMap(m));
                    }

                    @Override
                    public void svError(Map<String, Object> info) {
                        Map<String, Object> m = info == null ? new HashMap<>() : new HashMap<>(info);
                        m.put("controllerId", controllerId);
                        sendEventUi("onSpeakerVerificationError", toWritableMap(m));
                    }
                };

                svMicControllers.put(controllerId, ctrl);

                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                promise.resolve(out);

            } catch (Throwable t) {
                promise.reject("SVMicCreateError", String.valueOf(t.getMessage()), t);
            }
        }).start();
    }
    @ReactMethod
    public void destroySpeakerVerificationMicController(String controllerId, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                try { ctrl.stop(); } catch (Throwable ignore) {}
                svMicControllers.remove(controllerId); // remove only after stop
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicDestroyError", String.valueOf(t.getMessage()), t);
            }
        });
    }

    @ReactMethod
    public void svBeginOnboarding(String controllerId,
                                String enrollmentId,
                                int targetEmbeddingCount,
                                boolean reset,
                                Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.beginOnboarding(enrollmentId, targetEmbeddingCount, reset);
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putString("enrollmentId", enrollmentId);
                out.putInt("target", targetEmbeddingCount);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicBeginError", String.valueOf(t.getMessage()), t);
            }
        });
    }

    @ReactMethod
    public void svGetNextEmbeddingFromMic(String controllerId, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.getNextEmbeddingFromMic();
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicGetNextError", String.valueOf(t.getMessage()), t);
            }
        });

    }

    @ReactMethod
    public void svFinalizeOnboardingNow(String controllerId, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.finalizeOnboardingNow();
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicFinalizeError", String.valueOf(t.getMessage()), t);
            }
        });
    }

    @ReactMethod
    public void svSetEnrollmentJson(String controllerId, String enrollmentJson, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }
        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.setEnrollmentJson(enrollmentJson);
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicSetEnrollError", String.valueOf(t.getMessage()), t);
            }
        });

    }

    @ReactMethod
    public void svStartVerifyFromMic(String controllerId, boolean resetState, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.startVerifyFromMic(resetState);
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putBoolean("resetState", resetState);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicStartVerifyError", String.valueOf(t.getMessage()), t);
            }
        });

    }

    @ReactMethod
    public void svStartEndlessVerifyFromMic(String controllerId, double hopSeconds, boolean stopOnMatch, boolean resetState, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.startEndlessVerifyFromMic((float) hopSeconds, stopOnMatch, resetState);
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putDouble("hopSeconds", hopSeconds);
                out.putBoolean("stopOnMatch", stopOnMatch);
                out.putBoolean("resetState", resetState);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicStartEndlessVerifyError", String.valueOf(t.getMessage()), t);
            }
        });
    }

    @ReactMethod
    public void svStopMic(String controllerId, Promise promise) {
        SpeakerVerification.SpeakerVerificationMicController ctrl = svMicControllers.get(controllerId);
        if (ctrl == null) {
            promise.reject("SVMicNotFound", "No speaker mic controller with ID: " + controllerId);
            return;
        }

        final int job = svJobN.incrementAndGet();
        svExec.execute(() -> {
            try {
                ctrl.stop();
                WritableMap out = Arguments.createMap();
                out.putBoolean("ok", true);
                out.putString("controllerId", controllerId);
                out.putInt("job", job);
                promise.resolve(out);
            } catch (Throwable t) {
                promise.reject("SVMicStopError", String.valueOf(t.getMessage()), t);
            }
        });

    }
    @ReactMethod
    public void startVerifyContinuousFromMic(String controllerId, boolean resetState, double hopSeconds, Promise promise) {
        // parity: continuous verify == endless verify
        // choose stopOnMatch=false by default (continuous)
        svStartEndlessVerifyFromMic(controllerId, hopSeconds, false, resetState, promise);
    }

    @ReactMethod
    public void stopVerifyContinuousFromMic(String controllerId, Promise promise) {
        svStopMic(controllerId, promise);
    }
}
