/**
 * Gespraechsmodus / Wake Word Service
 *
 * Wake-Word-Engine: openWakeWord (https://github.com/dscripka/openWakeWord),
 * komplett on-device via ONNX Runtime in Native-Kotlin (siehe
 * OpenWakeWordModule.kt + assets/openwakeword/). Kein API-Key, kein Cloud-
 * Roundtrip, kein Cent Lizenzgebuehren.
 *
 * Drei Zustaende:
 *   off        — Ohr aus, nichts laeuft
 *   armed      — Ohr aktiv, openWakeWord hoert passiv auf das Wake-Word.
 *                Das Mikro ist von OpenWakeWord belegt; AudioRecorder ist aus.
 *   conversing — Wake-Word getriggert (oder Ohr-Tap manuell):
 *                aktive Konversation. OpenWakeWord pausiert (gibt Mikro frei),
 *                AudioRecorder uebernimmt fuer die Aufnahme.
 *                Nach jeder ARIA-Antwort oeffnet das Mikro fuer X Sekunden
 *                (Conversation-Window). Stille im Fenster → zurueck zu armed.
 *
 * Faellt das Native-Modul aus (alte App-Version, ONNX-Init-Fehler), geht
 * 'start' direkt in 'conversing' (klassischer Direkt-Aufnahme-Modus).
 */

import { NativeEventEmitter, NativeModules, ToastAndroid } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { acquireBackgroundAudio } from './backgroundAudio';

type WakeWordCallback = () => void;
type StateCallback = (state: WakeWordState) => void;
type PassiveListenCallback = () => void;

export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';

/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
 *  in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
 *  fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';

export async function loadPassiveListenMs(): Promise<number> {
  try {
    const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
    if (raw) {
      const n = parseInt(raw, 10);
      if (isFinite(n) && n >= 0 && n <= 120_000) return n;
    }
  } catch {}
  return PASSIVE_LISTEN_DEFAULT_MS;
}

export async function savePassiveListenMs(ms: number): Promise<void> {
  await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}

export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';

/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
 *  android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
 *  Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
 *  werden — Diagnostic-Upload ist Phase 2. */
export const WAKE_KEYWORDS = [
  'hey_jarvis',
  'computer',
  'alexa',
  'hey_mycroft',
  'hey_rhasspy',
] as const;
export type WakeKeyword = typeof WAKE_KEYWORDS[number];
export const DEFAULT_KEYWORD: WakeKeyword = 'hey_jarvis';

/** Hilfs-Mapping fuer die Anzeige im UI. */
export const KEYWORD_LABELS: Record<WakeKeyword, string> = {
  hey_jarvis: 'Hey Jarvis',
  computer: 'Computer',
  alexa: 'Alexa',
  hey_mycroft: 'Hey Mycroft',
  hey_rhasspy: 'Hey Rhasspy',
};

// Detection-Tuning — kann in Settings spaeter konfigurierbar werden.
const DEFAULT_THRESHOLD = 0.5;
const DEFAULT_PATIENCE = 2;
const DEFAULT_DEBOUNCE_MS = 1500;

interface OpenWakeWordModule {
  init(modelName: string, threshold: number, patience: number, debounceMs: number): Promise<boolean>;
  start(): Promise<boolean>;
  stop(): Promise<boolean>;
  dispose(): Promise<boolean>;
  isAvailable(): Promise<boolean>;
}

const { OpenWakeWord } = NativeModules as { OpenWakeWord?: OpenWakeWordModule };

class WakeWordService {
  private state: WakeWordState = 'off';
  private wakeCallbacks: WakeWordCallback[] = [];
  private stateCallbacks: StateCallback[] = [];
  /** Barge-In-Callbacks: feuern wenn Wake-Word WAEHREND ARIA spricht erkannt
   *  wird. ChatScreen reagiert mit TTS-stop + neuer Aufnahme. */
  private bargeCallbacks: WakeWordCallback[] = [];
  /** True solange Wake-Word parallel zu TTS aktiv ist. */
  private bargeListening: boolean = false;
  /** Anruf-Pause: state wird gemerkt damit nach Auflegen wiederhergestellt wird. */
  private callPaused: boolean = false;
  private preCallState: WakeWordState = 'off';
  /** Cooldown nach App-Resume: kurze Phase in der Wake-Word-Detections
   *  ignoriert werden. Beim Wechsel von Background nach Vordergrund gibt's
   *  oft einen Audio-Pegel-Spike (AudioFocus-Switch, AudioTrack re-route),
   *  der openWakeWord faelschlich triggern kann. */
  private cooldownUntilMs: number = 0;
  /** Zeitpunkt des letzten echten Wake-Word-Triggers — gebraucht damit
   *  ChatScreen entscheiden kann ob ein 'conversing'-State bei App-Resume
   *  ein false-positive war (Wake-Word im Hintergrund getriggert waehrend
   *  Stefan gar nicht in der App war). */
  private lastTriggerAt: number = 0;
  /** App liegt im Hintergrund — alle Detections sperren. Wird vom
   *  AppState-Listener im ChatScreen via setBackground/setForeground gesetzt.
   *  Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
   *  AudioFocus-Switch beim Wechsel zu Musik etc.). */
  private inBackground: boolean = false;
  /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
   *  WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
   *  resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
   *  Event sofort verworfen. Reset beim Verlassen von 'conversing'.
   *  Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
   *  waehrend ARIA noch redet, NICHT vom Guard blockieren. */
  private detectionInProgress: boolean = false;
  /** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
   *  beendet den listening-State und geht zurueck zu armed. */
  private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
  /** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
   *  hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
  private passiveListenCallbacks: PassiveListenCallback[] = [];

  private keyword: WakeKeyword = DEFAULT_KEYWORD;
  private nativeReady: boolean = false;
  private initInProgress: Promise<boolean> | null = null;
  private eventSub: { remove: () => void } | null = null;

  /** Beim App-Start aufrufen — laedt Settings, baut Native-Modul. */
  async loadFromStorage(): Promise<void> {
    try {
      const w = await AsyncStorage.getItem(WAKE_KEYWORD_STORAGE);
      const wt = (w || DEFAULT_KEYWORD).trim() as WakeKeyword;
      this.keyword = (WAKE_KEYWORDS as readonly string[]).includes(wt) ? wt : DEFAULT_KEYWORD;
      await this.initNative();
    } catch (err) {
      console.warn('[WakeWord] loadFromStorage', err);
    }
  }

  /** Settings-Wechsel: anderes Wake-Word. Re-Init des Native-Moduls. */
  async configure(keyword: string): Promise<boolean> {
    const next: WakeKeyword = (WAKE_KEYWORDS as readonly string[]).includes(keyword)
      ? (keyword as WakeKeyword)
      : DEFAULT_KEYWORD;
    this.keyword = next;
    await AsyncStorage.setItem(WAKE_KEYWORD_STORAGE, next);

    // Laufende Instanz stoppen + neu initialisieren
    await this.disposeNative();
    const ok = await this.initNative();
    if (!ok) {
      ToastAndroid.show(
        `Wake-Word "${KEYWORD_LABELS[next]}" konnte nicht initialisiert werden — Logs pruefen`,
        ToastAndroid.LONG,
      );
    }
    return ok;
  }

  private async initNative(): Promise<boolean> {
    if (!OpenWakeWord) {
      console.warn('[WakeWord] OpenWakeWord Native-Modul nicht verfuegbar — Direkt-Aufnahme-Fallback aktiv');
      this.nativeReady = false;
      return false;
    }
    if (this.initInProgress) return this.initInProgress;
    this.initInProgress = (async () => {
      try {
        await OpenWakeWord.init(this.keyword, DEFAULT_THRESHOLD, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
        // Subscribe nur einmal
        if (!this.eventSub) {
          const emitter = new NativeEventEmitter(NativeModules.OpenWakeWord);
          this.eventSub = emitter.addListener('WakeWordDetected', () => {
            console.log('[WakeWord] Native Detection-Event empfangen');
            this.onWakeDetected().catch(err =>
              console.warn('[WakeWord] onWakeDetected crashed:', err));
          });
        }
        this.nativeReady = true;
        console.log('[WakeWord] Init OK (model=%s)', this.keyword);
        return true;
      } catch (err: any) {
        console.warn('[WakeWord] Init fehlgeschlagen:', err?.message || err);
        this.nativeReady = false;
        return false;
      } finally {
        this.initInProgress = null;
      }
    })();
    return this.initInProgress;
  }

  private async disposeNative(): Promise<void> {
    if (!OpenWakeWord) return;
    try { await OpenWakeWord.dispose(); } catch {}
    this.nativeReady = false;
  }

  /** Ohr-Button gedrueckt — startet passives Lauschen oder direkt Konversation. */
  async start(): Promise<boolean> {
    if (this.state !== 'off') return true;
    // Foreground-Service VOR dem Mic-Zugriff hochziehen damit Background-
    // Lauschen funktioniert (Android braucht foregroundServiceType=microphone
    // aktiv zum Zeitpunkt des AudioRecord.startRecording).
    await acquireBackgroundAudio('wake');
    if (this.nativeReady && OpenWakeWord) {
      try {
        await OpenWakeWord.start();
        console.log('[WakeWord] armed — warte auf "%s"', this.keyword);
        // Debug-Log via RVS damit wir auch ohne ADB sehen wann es greift
        import('./logger').then(m => m.reportAppDebug('wake.start', `armed, keyword=${this.keyword}`)).catch(()=>{});
        ToastAndroid.show(`Lausche auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
        this.setState('armed');
        return true;
      } catch (err: any) {
        console.warn('[WakeWord] start fehlgeschlagen — Fallback Direkt-Aufnahme:',
                     err?.message || err);
        ToastAndroid.show(
          `Wake-Word-Start failed: ${err?.message || err}`,
          ToastAndroid.LONG,
        );
      }
    } else {
      console.warn('[WakeWord] Native-Modul nicht bereit — Direkt-Aufnahme-Fallback');
      ToastAndroid.show(
        'Wake-Word nicht aktiv — direkte Aufnahme startet (Mikro hoert mit)',
        ToastAndroid.LONG,
      );
    }
    // Fallback: direkt in Konversation
    console.log('[WakeWord] Direkt-Aufnahme startet (kein Wake-Word)');
    this.setState('conversing');
    setTimeout(() => {
      if (this.state === 'conversing') {
        this.wakeCallbacks.forEach(cb => cb());
      }
    }, 500);
    return true;
  }

  /** Komplett ausschalten (Ohr abschalten) */
  async stop(): Promise<void> {
    console.log('[WakeWord] Ohr deaktiviert');
    this.cancelPassiveListenTimer();
    if (this.nativeReady && OpenWakeWord) {
      try { await OpenWakeWord.stop(); } catch {}
    }
    this.bargeListening = false;
    this.setState('off');
  }

  /** Cooldown setzen — alle Wake-Word-Detections in den naechsten ms ignorieren.
   *  Wird beim App-Resume gerufen weil AppState-Wechsel Audio-Spikes erzeugen
   *  die openWakeWord faelschlich als Trigger interpretiert. */
  setResumeCooldown(ms: number = 1500): void {
    this.cooldownUntilMs = Date.now() + ms;
    console.log('[WakeWord] Cooldown aktiv fuer %dms', ms);
  }

  /** App in den Hintergrund: alle Wake-Word-Detections sperren.
   *  Im Hintergrund will Stefan praktisch nie einen neuen Dialog starten —
   *  was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
  setBackground(): void {
    this.inBackground = true;
    console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
  }

  /** App im Vordergrund: Detections wieder freigeben, plus 3s Cooldown
   *  als Schutz gegen den AudioFocus-/AudioTrack-Spike der direkt nach
   *  dem Resume kommt. Ersetzt das alte setResumeCooldown(3000)-Pattern. */
  setForeground(): void {
    this.inBackground = false;
    this.cooldownUntilMs = Date.now() + 3000;
    console.log('[WakeWord] App im Vordergrund — Cooldown 3s aktiv');
  }

  /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
  private async onWakeDetected(): Promise<void> {
    if (this.inBackground) {
      console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
      import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
      return;
    }
    // Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
    // Ausnahme: Barge-In waehrend ARIA-TTS ist ein legitimer neuer Trigger.
    if (this.detectionInProgress && !this.bargeListening) {
      console.log('[WakeWord] Trigger ignoriert (Detection-Zyklus laeuft schon — Native-Doppel-Event-Race)');
      import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: detectionInProgress')).catch(()=>{});
      return;
    }
    const now = Date.now();
    if (now < this.cooldownUntilMs) {
      const left = this.cooldownUntilMs - now;
      console.log('[WakeWord] Trigger ignoriert (Cooldown noch %dms aktiv — wahrscheinlich App-Resume-Spike)', left);
      return;
    }
    this.detectionInProgress = true;
    console.log('[WakeWord] Wake-Word "%s" erkannt! (state=%s, barge=%s)',
                this.keyword, this.state, this.bargeListening);
    import('./logger').then(m => m.reportAppDebug('wake.detect',
      `keyword=${this.keyword} state=${this.state} barge=${this.bargeListening}`)).catch(()=>{});
    this.lastTriggerAt = now;
    if (this.nativeReady && OpenWakeWord) {
      try {
        await OpenWakeWord.stop();
        import('./logger').then(m => m.reportAppDebug('wake.detect', 'native stop ok')).catch(()=>{});
      } catch (e: any) {
        import('./logger').then(m => m.reportAppDebug('wake.detect', `native stop FAIL ${e?.message}`)).catch(()=>{});
      }
    }
    this.bargeListening = false;
    // Wenn wir bereits in 'conversing' sind und der Trigger waehrend ARIAs TTS
    // kam (Barge-In via Wake-Word), feuern wir einen separaten Callback damit
    // ChatScreen das TTS abbrechen + neue Aufnahme starten kann. Sonst normal.
    if (this.state === 'conversing') {
      import('./logger').then(m => m.reportAppDebug('wake.detect',
        `barge path: cbs=${this.bargeCallbacks.length}`)).catch(()=>{});
      this.bargeCallbacks.forEach(cb => {
        try { cb(); } catch (e) { console.warn('[WakeWord] barge cb err:', e); }
      });
      // Kein erneutes setState — wir bleiben in 'conversing'.
      return;
    }
    this.setState('conversing');
    // Direkt feuern — KEIN setTimeout. Im Hintergrund (Display aus) parkt
    // Android den JS-Thread; ein setTimeout(200ms) kann dann Minuten lang
    // nicht zuendekommen, weil Hermes auf einen Native-Wake-Event wartet.
    // OpenWakeWord.stop() oben ist awaited → Mikro ist schon frei, kein
    // 200ms-Sicherheitsabstand noetig.
    import('./logger').then(m => m.reportAppDebug('wake.detect',
      `state→conversing, firing ${this.wakeCallbacks.length} callback(s) directly`)).catch(()=>{});
    this.wakeCallbacks.forEach(cb => {
      try { cb(); } catch (e) { console.warn('[WakeWord] wake cb err:', e); }
    });
  }

  /** Wake-Word PARALLEL zur TTS-Wiedergabe lauschen lassen — User kann
   *  "Computer" sagen waehrend ARIA noch redet, AcousticEchoCanceler im
   *  Native-Modul verhindert dass ARIAs eigene Stimme triggert.
   *  Voraussetzung: AudioRecorder muss frei sein (Recording aus). Wenn der
   *  AudioRecorder gerade laeuft, hat der Vorrang — Wake-Word geht nicht. */
  async startBargeListening(): Promise<void> {
    if (!this.nativeReady || !OpenWakeWord) return;
    if (this.state !== 'conversing') return;
    if (this.bargeListening) return;
    try {
      await OpenWakeWord.start();
      this.bargeListening = true;
      console.log('[WakeWord] Barge-Listening aktiv (parallel zu TTS)');
    } catch (err) {
      console.warn('[WakeWord] Barge-Listening start fehlgeschlagen:', err);
    }
  }

  /** Barge-Listening wieder aus — z.B. wenn der AudioRecorder fuer die
   *  naechste Aufnahme das Mikro braucht. */
  async stopBargeListening(): Promise<void> {
    if (!this.bargeListening) return;
    if (this.nativeReady && OpenWakeWord) {
      try { await OpenWakeWord.stop(); } catch {}
    }
    this.bargeListening = false;
    console.log('[WakeWord] Barge-Listening aus');
  }

  /** Bei eingehendem Anruf: Wake-Word + Aufnahme stoppen, Pre-Call-State
   *  merken. Telefonie-App belegt das Mikro waehrend des Anrufs, plus ARIA
   *  soll nicht in laufende Telefonate reinhoeren. */
  async pauseForCall(): Promise<void> {
    if (this.callPaused) return;
    this.preCallState = this.state;
    if (this.state === 'off') {
      this.callPaused = true;  // merken dass wir pausiert wurden
      return;
    }
    this.callPaused = true;
    if (this.nativeReady && OpenWakeWord) {
      try { await OpenWakeWord.stop(); } catch {}
    }
    this.bargeListening = false;
    console.log('[WakeWord] Anruf — Wake-Word pausiert (war: %s)', this.preCallState);
  }

  /** Nach Auflegen: Pre-Call-State wiederherstellen. Aktive Konversation
   *  geht zu armed zurueck (User soll nicht in einen halben Dialog springen). */
  async resumeFromCall(): Promise<void> {
    if (!this.callPaused) return;
    const restoreTo = this.preCallState;
    this.callPaused = false;
    this.preCallState = 'off';
    console.log('[WakeWord] Anruf zu Ende — restore state=%s', restoreTo);
    if (restoreTo === 'off') return;
    // Aktive Konversation war wahrscheinlich durch haltAllPlayback eh abgebrochen,
    // sicher zu armed degraden.
    if (restoreTo === 'conversing') this.setState('armed');
    if (this.nativeReady && OpenWakeWord) {
      try { await OpenWakeWord.start(); } catch (err) {
        console.warn('[WakeWord] Restore-Start fehlgeschlagen:', err);
      }
    }
  }

  /** Konversation beenden — User hat im Window nichts gesagt.
   *  Mit Wake-Word: zurueck zu 'armed' (Listener wieder an).
   *  Ohne: zurueck zu 'off'.
   *
   *  WICHTIG: setzt bargeListening=false BEVOR OpenWakeWord.start() laeuft.
   *  Grund: wenn endConversation aus dem onPlaybackFinished-Handler kommt,
   *  feuert direkt danach ein zweiter Listener (stopBargeListening) — der
   *  wuerde sonst OpenWakeWord.stop() rufen weil bargeListening noch true
   *  ist, und unseren frisch re-armierten Listener killen.
   */
  async endConversation(): Promise<void> {
    if (this.state !== 'conversing') {
      import('./logger').then(m => m.reportAppDebug('wake.end',
        `endConversation called but state=${this.state} → noop`)).catch(()=>{});
      return;
    }
    const wasBarge = this.bargeListening;
    // Flag NULLEN bevor wir die Listener triggern. Sonst killt der parallele
    // stopBargeListening-Listener (TTS-end) gleich danach unseren Native-
    // OpenWakeWord, weil er bargeListening=true sieht und annimmt er muss
    // den Listener stoppen.
    this.bargeListening = false;
    import('./logger').then(m => m.reportAppDebug('wake.end',
      `endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});

    // Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
    // fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
    // (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
    // Anrede weitersprechen.
    const passiveMs = await loadPassiveListenMs();
    if (passiveMs > 0 && this.nativeReady) {
      this.enterPassiveListening(passiveMs);
      return;
    }

    if (this.nativeReady && OpenWakeWord) {
      // Wenn wakeword schon laeuft (war Barge-Listener waehrend TTS):
      // OpenWakeWord.start() ist idempotent (Kotlin checkt running.get()
      // und resolved sofort). Wir koennen es trotzdem rufen — billiger
      // als state extra zu fragen, garantiert dass nach diesem Pfad
      // Native auch wirklich an ist falls es out-of-band gestoppt wurde.
      try {
        await OpenWakeWord.start();
        console.log('[WakeWord] Konversation zu Ende — zurueck zu armed (wasBarge=%s)', wasBarge);
        import('./logger').then(m => m.reportAppDebug('wake.end',
          `OpenWakeWord.start() OK → state=armed, wasBarge=${wasBarge}`)).catch(()=>{});
        ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
        this.setState('armed');
        return;
      } catch (err: any) {
        console.warn('[WakeWord] re-arm fehlgeschlagen:', err);
        import('./logger').then(m => m.reportAppDebug('wake.end',
          `OpenWakeWord.start() FAIL: ${err?.message || err} → state=off`,
          )).catch(()=>{});
      }
    }
    console.log('[WakeWord] Konversation zu Ende — Ohr aus');
    import('./logger').then(m => m.reportAppDebug('wake.end',
      `fallback: nativeReady=${this.nativeReady} → state=off`)).catch(()=>{});
    ToastAndroid.show('Mikro aus', ToastAndroid.SHORT);
    this.setState('off');
  }

  /** Eintritt in den Passive-Listen-Modus: state='listening', Timer fuer
   *  Auto-Ende setzen, Callbacks feuern damit ChatScreen die passive
   *  Streaming-Aufnahme startet. OpenWakeWord bleibt AUS (Mic-Exklusivitaet —
   *  audioService braucht das Mikro fuer die passive Aufnahme).
   *  Speaker-ID-Gating (Phase 3) filtert fremde Stimmen auf der Bridge. */
  private enterPassiveListening(durationMs: number): void {
    this.cancelPassiveListenTimer();
    this.setState('listening');
    const seconds = Math.round(durationMs / 1000);
    console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
    import('./logger').then(m => m.reportAppDebug('wake.passive',
      `entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
    ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
    this.passiveListenTimer = setTimeout(() => {
      this.passiveListenTimer = null;
      this.exitPassiveListening('timeout').catch(() => {});
    }, durationMs);
    this.passiveListenCallbacks.forEach(cb => {
      try { cb(); } catch (e) { console.warn('[WakeWord] passive cb err:', e); }
    });
  }

  /** Verlassen des Passive-Listen-Modus.
   *  reason='speech' → User hat was gesagt (STT-Endpoint mit text) → uebergang
   *    in 'conversing' (Brain antwortet, TTS spielt, dann resume → endConversation
   *    → wieder passive listening, repeat).
   *  reason='timeout' → 30s nichts gehoert → zurueck zu armed (Wake-Word wieder an).
   *  reason='manual' → User hat App geschlossen / stopped → zurueck zu armed. */
  async exitPassiveListening(reason: 'timeout' | 'speech' | 'manual'): Promise<void> {
    if (this.state !== 'listening') return;
    this.cancelPassiveListenTimer();
    console.log('[WakeWord] Passive-Listen Ende (reason=%s)', reason);
    import('./logger').then(m => m.reportAppDebug('wake.passive',
      `exit reason=${reason}`)).catch(()=>{});

    if (reason === 'speech') {
      // Wechsel zu 'conversing' damit das Standard-Conversation-Flow greift
      // (Brain-Response, TTS, resume etc.). Wake-Word bleibt aus (Mic belegt).
      this.setState('conversing');
      return;
    }

    // timeout oder manual → Wake-Word reaktivieren, armed-State.
    if (this.nativeReady && OpenWakeWord) {
      try {
        await OpenWakeWord.start();
        console.log('[WakeWord] zurueck zu armed nach passive-listen');
        ToastAndroid.show(`Lausche wieder auf "${KEYWORD_LABELS[this.keyword]}"`, ToastAndroid.SHORT);
        this.setState('armed');
        return;
      } catch (err) {
        console.warn('[WakeWord] re-arm nach passive-listen failed:', err);
      }
    }
    this.setState('off');
  }

  private cancelPassiveListenTimer(): void {
    if (this.passiveListenTimer) {
      clearTimeout(this.passiveListenTimer);
      this.passiveListenTimer = null;
    }
  }

  /** Subscribe auf Passive-Listen-Events: feuert wenn der Service in den
   *  passiven Modus eintritt. ChatScreen startet hier eine streaming-
   *  Aufnahme OHNE User-Bubble (passiv lauschen). */
  onPassiveListen(callback: PassiveListenCallback): () => void {
    this.passiveListenCallbacks.push(callback);
    return () => {
      this.passiveListenCallbacks = this.passiveListenCallbacks.filter(c => c !== callback);
    };
  }

  /** Wenn ein conversing-State auf einem Wake-Word-Trigger juenger als
   *  maxAgeMs basiert: false-positive verwerfen, zurueck zu armed.
   *  Wird vom ChatScreen aufgerufen wenn die App aus laengerem Hintergrund
   *  zurueck kommt — dann ist ein „gerade getriggertes" Wake-Word sehr
   *  wahrscheinlich ein TV-Spike, Husten, ARIAs eigene TTS-Aufnahme etc.
   *  Returnt true wenn verworfen wurde. */
  async discardIfFreshlyTriggered(maxAgeMs: number = 10_000): Promise<boolean> {
    if (this.state !== 'conversing') return false;
    if (this.lastTriggerAt === 0) return false;
    const age = Date.now() - this.lastTriggerAt;
    if (age > maxAgeMs) return false;
    console.log('[WakeWord] Resume: verwerfe verdaechtiges conversing (age=%dms)', age);
    this.lastTriggerAt = 0;
    if (this.nativeReady && OpenWakeWord) {
      try {
        await OpenWakeWord.start();
        ToastAndroid.show('Hintergrund-Trigger verworfen — lausche wieder', ToastAndroid.SHORT);
        this.setState('armed');
        return true;
      } catch (err) {
        console.warn('[WakeWord] re-arm nach discard fehlgeschlagen:', err);
      }
    }
    this.setState('off');
    return true;
  }

  /** Nach ARIA-Antwort (TTS fertig): naechste Aufnahme im Conversation-Window starten.
   *
   *  WICHTIG: setTimeout(800ms) kann im Hintergrund (Display aus) verspaetet
   *  feuern — JS-Thread ist geparkt. Wenn der Timer >2s ueberfaellig ist,
   *  hat der User offensichtlich die App verlassen und kommt erst spaeter
   *  wieder — wir oeffnen das Mikro dann NICHT, sondern beenden die
   *  Konversation. Sonst sieht der User nach dem App-Resume "Mikro plus-
   *  aufnahme laeuft" obwohl er gar nichts gesagt hat → wirkt wie Phantom-
   *  Wake-Word. Klassische Doze-Throttling-Falle wie bei wake.detect frueher. */
  async resume(): Promise<void> {
    if (this.state !== 'conversing') return;
    const scheduledAt = Date.now();
    // Kurze Pause damit TTS-Audio nicht ins Mikrofon geht
    await new Promise(resolve => setTimeout(resolve, 800));
    if (this.state !== 'conversing') return;
    const delay = Date.now() - scheduledAt;
    if (delay > 2800) {
      // Timer war stark verspaetet — JS-Thread war im Hintergrund geparkt.
      // Conversation als beendet behandeln statt das Mikro zu oeffnen.
      console.log('[WakeWord] resume(): %dms statt ~800ms — App war im Background. endConversation statt mic-open', delay);
      import('./logger').then(m => m.reportAppDebug('wake.resume',
        `delayed ${delay}ms (>2800) — endConversation statt mic-open`)).catch(()=>{});
      // Asynchroner Aufruf — endConversation ist async, kein await damit wir
      // hier nicht in einem Promise-Chain haengen.
      this.endConversation().catch(() => {});
      return;
    }
    console.log('[WakeWord] TTS fertig — naechste Aufnahme im Conversation-Window (delay=%dms)', delay);
    this.wakeCallbacks.forEach(cb => cb());
  }

  /** True solange das Ohr aktiv ist (armed ODER conversing). */
  isActive(): boolean {
    return this.state !== 'off';
  }

  isConversing(): boolean {
    return this.state === 'conversing';
  }

  hasWakeWord(): boolean {
    return this.nativeReady;
  }

  getKeyword(): WakeKeyword {
    return this.keyword;
  }

  // --- Callbacks ---

  onWakeWord(callback: WakeWordCallback): () => void {
    this.wakeCallbacks.push(callback);
    return () => {
      this.wakeCallbacks = this.wakeCallbacks.filter(cb => cb !== callback);
    };
  }

  /** Subscribe auf Barge-In-Events: Wake-Word erkannt waehrend ARIA noch
   *  spricht. ChatScreen sollte dann TTS abbrechen + neue Aufnahme starten. */
  onBargeIn(callback: WakeWordCallback): () => void {
    this.bargeCallbacks.push(callback);
    return () => {
      this.bargeCallbacks = this.bargeCallbacks.filter(cb => cb !== callback);
    };
  }

  isBargeListening(): boolean {
    return this.bargeListening;
  }

  onStateChange(callback: StateCallback): () => void {
    this.stateCallbacks.push(callback);
    return () => {
      this.stateCallbacks = this.stateCallbacks.filter(cb => cb !== callback);
    };
  }

  getState(): WakeWordState {
    return this.state;
  }

  private setState(state: WakeWordState): void {
    if (this.state !== state) {
      const wasConversing = this.state === 'conversing';
      this.state = state;
      // Re-Entry-Guard freigeben sobald wir 'conversing' verlassen — Zyklus ist durch
      if (wasConversing && state !== 'conversing') {
        this.detectionInProgress = false;
      }
      this.stateCallbacks.forEach(cb => cb(state));
    }
  }
}

const wakeWordService = new WakeWordService();
export default wakeWordService;
