/**
 * Discord Voice Channel integration for NanoClaw.
 *
 * - Auto-joins the target voice channel when a user enters.
 * - Auto-leaves when the last user exits.
 * - Listens for speech, transcribes via STT (whisper-cpp or ElevenLabs),
 *   injects as nanoclaw messages into the companion text channel.
 * - When the agent sends a reply to that text channel, plays TTS
 *   (msedge-tts or ElevenLabs) in the voice channel.
 *
 * Set ELEVENLABS_API_KEY to enable ElevenLabs for both STT and TTS.
 * Override individually with VOICE_STT_PROVIDER=whisper|elevenlabs
 * and VOICE_TTS_PROVIDER=msedge|elevenlabs.
 */

import {
  AudioPlayerStatus,
  EndBehaviorType,
  NoSubscriberBehavior,
  VoiceConnectionStatus,
  createAudioPlayer,
  createAudioResource,
  entersState,
  getVoiceConnection,
  joinVoiceChannel,
} from '@discordjs/voice';
import { ChannelType, Client, Events, VoiceState } from 'discord.js';
import { MsEdgeTTS, OUTPUT_FORMAT } from 'msedge-tts';
import { execFile } from 'child_process';
import { createRequire } from 'module';
import { promisify } from 'util';
import fs from 'fs';
import path from 'path';
import os from 'os';

import { logger } from '../logger.js';
import { OnInboundMessage } from '../types.js';

const require = createRequire(import.meta.url);
// prism-media is CJS with no TypeScript types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prism = require('prism-media') as any;

const execFileAsync = promisify(execFile);

export interface VoiceManagerOpts {
  /** The discord.js Client instance (already connected). */
  client: Client;
  /** ID of the Discord voice channel to monitor (numbers-only, no "dc:" prefix). */
  targetVoiceChannelId: string;
  /** JID of the companion text channel where voice transcriptions are routed. */
  textChannelJid: string;
  /** nanoclaw inbound message callback — same as used by DiscordChannel. */
  onMessage: OnInboundMessage;
}

type SttProvider = 'whisper' | 'elevenlabs' | 'groq';
type TtsProvider = 'msedge' | 'elevenlabs';

export class DiscordVoiceManager {
  private client: Client;
  private targetVoiceChannelId: string;
  readonly textChannelJid: string;
  private onMessage: OnInboundMessage;

  /** Non-bot users currently in the target voice channel. */
  private activeUsers = new Set<string>();
  /** Guild ID, set on first VoiceStateUpdate. */
  private guildId: string | null = null;
  /** TTS playback queue. */
  private ttsQueue: string[] = [];
  private ttsPlaying = false;
  /** Guard: true while ensureJoined() is in progress. */
  private joining = false;
  /** Track active subscriptions per user to prevent duplicates. */
  private activeSubscriptions = new Set<string>();

  private whisperBin: string;
  private whisperModel: string;
  private elevenlabsApiKey: string;
  private sttProvider: SttProvider;
  private ttsProvider: TtsProvider;
  private elevenlabsVoiceId: string;
  private groqApiKey: string;
  /** Our active voice connection, tracked locally to avoid stale getVoiceConnection refs. */
  private connection: import('@discordjs/voice').VoiceConnection | null = null;

  constructor(opts: VoiceManagerOpts) {
    this.client = opts.client;
    this.targetVoiceChannelId = opts.targetVoiceChannelId;
    this.textChannelJid = opts.textChannelJid;
    this.onMessage = opts.onMessage;
    this.whisperBin = process.env.VOICE_WHISPER_BIN || 'whisper-cpp';
    this.whisperModel =
      process.env.NANOCLAW_WHISPER_MODEL_FILE ||
      '/var/lib/nanoclaw/models/whisper/ggml-base.en.bin';

    // Provider config
    this.elevenlabsApiKey = process.env.ELEVENLABS_API_KEY || '';
    this.groqApiKey = process.env.GROQ_API_KEY || '';
    const hasElevenLabs = !!this.elevenlabsApiKey;
    const hasGroq = !!this.groqApiKey;
    this.sttProvider = (process.env.VOICE_STT_PROVIDER as SttProvider) ||
      (hasGroq ? 'groq' : hasElevenLabs ? 'elevenlabs' : 'whisper');
    this.ttsProvider = (process.env.VOICE_TTS_PROVIDER as TtsProvider) ||
      (hasElevenLabs ? 'elevenlabs' : 'msedge');
    this.elevenlabsVoiceId = process.env.ELEVENLABS_VOICE_ID || 'JBFqnCBsd6RMkjVDRZzb';

    logger.info(
      { sttProvider: this.sttProvider, ttsProvider: this.ttsProvider },
      'Voice providers configured',
    );
  }

  /**
   * Attach VoiceStateUpdate listener to the client.
   * Call this once after the client is ready.
   */
  wire(): void {
    this.client.on(
      Events.VoiceStateUpdate,
      async (oldState: VoiceState, newState: VoiceState) => {
        // Ignore bots (including our own join/leave events)
        const selfId = this.client.user?.id;
        if (newState.member?.user.bot || oldState.member?.user.bot) return;
        if (selfId && (newState.id === selfId || oldState.id === selfId)) return;

        const userId = newState.id;
        const joinedTarget =
          newState.channelId === this.targetVoiceChannelId;
        const leftTarget =
          oldState.channelId === this.targetVoiceChannelId &&
          newState.channelId !== this.targetVoiceChannelId;

        if (joinedTarget) {
          if (!this.guildId && newState.guild) {
            this.guildId = newState.guild.id;
          }
          this.activeUsers.add(userId);
          logger.info(
            { userId, channel: this.targetVoiceChannelId },
            'User entered voice channel',
          );
          const wasConnected = this.isActive();
          await this.ensureJoined(newState);
          this.subscribeToUser(userId, newState);
          // Pre-warm the container when Gaia first joins the channel
          if (!wasConnected && this.isActive()) {
            this.preWarmContainer(newState.guild);
          }
        } else if (leftTarget) {
          this.activeUsers.delete(userId);
          logger.info(
            { userId, remaining: this.activeUsers.size },
            'User left voice channel',
          );
          if (this.activeUsers.size === 0) {
            // If we're mid-join, don't destroy yet — ensureJoined will check activeUsers after
            if (!this.joining && newState.guild) {
              await this.leave(newState.guild.id);
            }
          }
        }
      },
    );

    logger.info(
      { voiceChannelId: this.targetVoiceChannelId, textChannelJid: this.textChannelJid },
      'DiscordVoiceManager wired',
    );

    // Auto-join if users are already in the voice channel at startup
    this.checkExistingUsers();
  }

  /** Check if users are already in the voice channel and join if so. */
  private async checkExistingUsers(): Promise<void> {
    try {
      const channel = this.client.channels.cache.get(this.targetVoiceChannelId);
      if (!channel || channel.type !== ChannelType.GuildVoice) return;
      const voiceChannel = channel as import('discord.js').VoiceChannel;
      const nonBotMembers = voiceChannel.members.filter(m => !m.user.bot);
      if (nonBotMembers.size === 0) return;

      this.guildId = voiceChannel.guild.id;
      for (const [id] of nonBotMembers) {
        this.activeUsers.add(id);
      }
      logger.info(
        { userCount: nonBotMembers.size, channelId: this.targetVoiceChannelId },
        'Users already in voice channel at startup, joining',
      );

      const connection = joinVoiceChannel({
        channelId: this.targetVoiceChannelId,
        guildId: voiceChannel.guild.id,
        adapterCreator: voiceChannel.guild.voiceAdapterCreator,
        selfDeaf: false,
      });
      this.connection = connection;

      connection.on('stateChange', (_oldState, newState) => {
        logger.info({ from: _oldState.status, to: newState.status }, 'Voice connection state change');
      });

      await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
      logger.info({ channelId: this.targetVoiceChannelId }, 'Bot joined voice channel (startup)');

      // Subscribe to all existing users and pre-warm
      for (const [id, member] of nonBotMembers) {
        const voiceState = member.voice;
        if (voiceState) this.subscribeToUser(id, voiceState);
      }
      this.preWarmContainer(voiceChannel.guild);

      // Debug speaking events
      connection.receiver.speaking.on('start', (userId: string) => {
        logger.info({ userId }, 'User started speaking');
      });
      connection.receiver.speaking.on('end', (userId: string) => {
        logger.info({ userId }, 'User stopped speaking');
      });
    } catch (err) {
      logger.error({ err }, 'Failed to auto-join voice channel at startup');
      if (this.connection) {
        try { this.connection.destroy(); } catch { /* ignore */ }
      }
      this.connection = null;
    }
  }

  /** Returns true if the bot is currently in the voice channel. */
  isActive(): boolean {
    return !!this.connection && this.connection.state.status !== VoiceConnectionStatus.Destroyed;
  }

  /**
   * Called by DiscordChannel.sendMessage when the target textChannelJid is addressed.
   * Queues TTS playback of the response in the voice channel.
   */
  async speakResponse(text: string): Promise<void> {
    if (!this.isActive()) return;
    this.ttsQueue.push(text);
    if (!this.ttsPlaying) {
      void this.drainTtsQueue();
    }
  }

  /** Pre-warm the agent container by injecting a join notification listing all users in the channel. */
  private preWarmContainer(guild: import('discord.js').Guild): void {
    const names: string[] = [];
    for (const uid of this.activeUsers) {
      const member = guild.members.cache.get(uid);
      if (member) names.push(member.displayName || member.user.username);
    }
    let joinMsg: string;
    if (names.length === 1) {
      joinMsg = `[${names[0]} joined the voice channel]`;
    } else if (names.length === 2) {
      joinMsg = `[${names[0]} and ${names[1]} are in the voice channel]`;
    } else {
      joinMsg = `[${names.join(', ')} are in the voice channel. Greet everyone.]`;
    }
    logger.info({ users: names }, 'Pre-warming agent container via join notification');
    this.onMessage(this.textChannelJid, {
      id: `voice-join-${Date.now()}`,
      chat_jid: this.textChannelJid,
      sender: 'system',
      sender_name: 'System',
      content: joinMsg,
      timestamp: new Date().toISOString(),
      is_from_me: false,
      is_trigger: true,
    });
  }

  // ── Private helpers ─────────────────────────────────────────────────────────

  private async ensureJoined(voiceState: VoiceState): Promise<void> {
    const guildId = voiceState.guild.id;

    // Re-use our tracked connection if still alive
    if (this.connection && this.connection.state.status !== VoiceConnectionStatus.Destroyed) {
      return;
    }
    this.connection = null;

    // If already joining from another event, skip
    if (this.joining) return;
    this.joining = true;

    try {
      const channel = voiceState.guild.channels.cache.get(
        this.targetVoiceChannelId,
      );
      if (!channel || channel.type !== ChannelType.GuildVoice) {
        logger.warn(
          { channelId: this.targetVoiceChannelId },
          'Target voice channel not found or wrong type',
        );
        return;
      }

      // Clean up any lingering global connection state to avoid double-destroy errors
      const stale = getVoiceConnection(guildId);
      if (stale) {
        try { stale.destroy(); } catch { /* already destroyed, ignore */ }
        // Give the adapter a moment to clean up
        await new Promise<void>((r) => setTimeout(r, 500));
      }

      const connection = joinVoiceChannel({
        channelId: this.targetVoiceChannelId,
        guildId,
        adapterCreator: voiceState.guild.voiceAdapterCreator,
        selfDeaf: false,
      });
      this.connection = connection;

      // Debug: log all state transitions
      connection.on('stateChange', (_oldState, newState) => {
        logger.info(
          { from: _oldState.status, to: newState.status },
          'Voice connection state change',
        );
      });

      await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
      logger.info({ channelId: this.targetVoiceChannelId }, 'Bot joined voice channel');

      // Debug: log speaking events
      connection.receiver.speaking.on('start', (userId: string) => {
        logger.info({ userId }, 'User started speaking');
      });
      connection.receiver.speaking.on('end', (userId: string) => {
        logger.info({ userId }, 'User stopped speaking');
      });

      // If all users left while we were connecting, leave immediately
      if (this.activeUsers.size === 0) {
        logger.info('All users left during join — leaving voice channel');
        await this.leave(guildId);
      }
    } catch (err) {
      logger.error({ err }, 'Failed to join voice channel');
      if (this.connection) {
        try { this.connection.destroy(); } catch { /* ignore */ }
      }
      this.connection = null;
    } finally {
      this.joining = false;
    }
  }

  private subscribeToUser(userId: string, voiceState: VoiceState): void {
    const connection = this.connection;
    if (!connection || connection.state.status === VoiceConnectionStatus.Destroyed) return;

    // Prevent duplicate subscriptions for the same user
    if (this.activeSubscriptions.has(userId)) return;
    this.activeSubscriptions.add(userId);

    logger.info({ userId }, 'Subscribing to user audio');

    const opusStream = connection.receiver.subscribe(userId, {
      end: { behavior: EndBehaviorType.AfterSilence, duration: 500 },
    });

    let opusPackets = 0;
    let hadError = false;
    opusStream.on('data', () => {
      opusPackets++;
      if (opusPackets === 1) logger.info({ userId }, 'First opus packet received');
    });
    opusStream.on('end', () => logger.info({ userId, opusPackets }, 'Opus stream ended'));
    opusStream.on('error', (err: Error) => {
      hadError = true;
      logger.warn({ userId, msg: err.message }, 'Opus stream error (will resubscribe)');
      this.activeSubscriptions.delete(userId);
      this.resubscribe(userId, voiceState);
    });

    // Decode Opus frames → raw PCM (s16le, 48 kHz, stereo)
    const decoder = new prism.opus.Decoder({
      rate: 48000,
      channels: 2,
      frameSize: 960,
    });

    const pcmChunks: Buffer[] = [];
    const pcmStream = opusStream.pipe(decoder);

    pcmStream.on('data', (chunk: Buffer) => pcmChunks.push(chunk));

    pcmStream.on('end', async () => {
      this.activeSubscriptions.delete(userId);

      // Skip transcription if the opus stream errored (corrupt data)
      if (hadError) return;

      // Ignore very short captures (background noise / accidental triggers)
      const pcm = Buffer.concat(pcmChunks);
      if (pcm.length < 48000 * 2 * 2 * 0.3) {
        // < 300 ms of audio
        this.resubscribe(userId, voiceState);
        return;
      }

      // Skip if TTS is playing — avoids feeding bot's own audio back as input
      if (this.ttsPlaying) {
        this.resubscribe(userId, voiceState);
        return;
      }

      const member = voiceState.guild.members.cache.get(userId);
      const senderName =
        member?.displayName || member?.user.username || userId;

      await this.transcribeAndInject(pcm, userId, senderName);
      this.resubscribe(userId, voiceState);
    });

    pcmStream.on('error', (err: Error) => {
      this.activeSubscriptions.delete(userId);
      logger.warn({ userId, msg: err.message }, 'PCM decode error (will resubscribe)');
      this.resubscribe(userId, voiceState);
    });
  }

  /** Re-subscribe to a user's audio after an utterance ends (if still present). */
  private resubscribe(userId: string, voiceState: VoiceState): void {
    if (!this.activeUsers.has(userId)) return;
    const conn = this.connection;
    if (!conn || conn.state.status === VoiceConnectionStatus.Destroyed) return;
    // Small delay to avoid tight loops on edge cases
    setTimeout(() => {
      if (this.activeUsers.has(userId)) {
        this.subscribeToUser(userId, voiceState);
      }
    }, 100);
  }

  // ── STT ───────────────────────────────────────────────────────────────────

  private async transcribeAndInject(
    pcm: Buffer,
    userId: string,
    senderName: string,
  ): Promise<void> {
    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-voice-'));

    try {
      let transcript: string;

      if (this.sttProvider === 'groq') {
        transcript = await this.transcribeGroq(pcm, tempDir);
      } else if (this.sttProvider === 'elevenlabs') {
        transcript = await this.transcribeElevenLabs(pcm, tempDir);
      } else {
        transcript = await this.transcribeWhisper(pcm, tempDir);
      }

      if (!transcript) {
        logger.debug({ userId }, 'Empty transcript, skipping');
        return;
      }

      logger.info({ userId, senderName, transcript, stt: this.sttProvider }, 'Voice transcription');

      // Inject as a nanoclaw message routed to the companion text channel
      this.onMessage(this.textChannelJid, {
        id: `voice-${Date.now()}-${userId}`,
        chat_jid: this.textChannelJid,
        sender: userId,
        sender_name: senderName,
        content: transcript,
        timestamp: new Date().toISOString(),
        is_from_me: false,
        is_trigger: true, // always trigger the agent
      });
    } catch (err) {
      logger.error({ err, userId, stt: this.sttProvider }, 'Voice transcription failed');
    } finally {
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  }

  private async transcribeWhisper(pcm: Buffer, tempDir: string): Promise<string> {
    const rawPath = path.join(tempDir, 'audio.raw');
    const wavPath = path.join(tempDir, 'audio.wav');

    // Write raw PCM
    fs.writeFileSync(rawPath, pcm);

    // Convert PCM → 16 kHz mono WAV via ffmpeg (whisper-cpp requires this)
    await execFileAsync('ffmpeg', [
      '-y',
      '-f', 's16le',
      '-ar', '48000',
      '-ac', '2',
      '-i', rawPath,
      '-ar', '16000',
      '-ac', '1',
      wavPath,
    ], { timeout: 15_000 });

    // Transcribe
    const { stdout } = await execFileAsync(
      this.whisperBin,
      [
        '-m', this.whisperModel,
        '-f', wavPath,
        '-nt',   // no timestamps
        '-np',   // suppress progress output
        '-l', 'en',
      ],
      { timeout: 30_000 },
    );

    return (stdout || '')
      .trim()
      .replace(/\[BLANK_AUDIO\]/gi, '')
      .replace(/\[.*?\]/g, '') // strip bracketed tags like [Music], [Applause]
      .trim();
  }

  private async transcribeElevenLabs(pcm: Buffer, tempDir: string): Promise<string> {
    const rawPath = path.join(tempDir, 'audio.raw');
    const wavPath = path.join(tempDir, 'audio.wav');

    // Write raw PCM and convert to WAV (ElevenLabs accepts WAV)
    fs.writeFileSync(rawPath, pcm);
    await execFileAsync('ffmpeg', [
      '-y',
      '-f', 's16le',
      '-ar', '48000',
      '-ac', '2',
      '-i', rawPath,
      '-ar', '16000',
      '-ac', '1',
      wavPath,
    ], { timeout: 15_000 });

    const wavData = fs.readFileSync(wavPath);

    // ElevenLabs Speech-to-Text API
    const formBoundary = '----NanoclawBoundary' + Date.now();
    const header = `--${formBoundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`;
    const modelField = `\r\n--${formBoundary}\r\nContent-Disposition: form-data; name="model_id"\r\n\r\nscribe_v1\r\n--${formBoundary}--\r\n`;
    const body = Buffer.concat([
      Buffer.from(header),
      wavData,
      Buffer.from(modelField),
    ]);

    const response = await fetch('https://api.elevenlabs.io/v1/speech-to-text', {
      method: 'POST',
      headers: {
        'xi-api-key': this.elevenlabsApiKey,
        'Content-Type': `multipart/form-data; boundary=${formBoundary}`,
      },
      body,
    });

    if (!response.ok) {
      const errText = await response.text();
      throw new Error(`ElevenLabs STT error ${response.status}: ${errText}`);
    }

    const result = await response.json() as { text?: string };
    return (result.text || '').trim();
  }

  private async transcribeGroq(pcm: Buffer, tempDir: string): Promise<string> {
    const rawPath = path.join(tempDir, 'audio.raw');
    const wavPath = path.join(tempDir, 'audio.wav');

    fs.writeFileSync(rawPath, pcm);
    await execFileAsync('ffmpeg', [
      '-y',
      '-f', 's16le',
      '-ar', '48000',
      '-ac', '2',
      '-i', rawPath,
      '-ar', '16000',
      '-ac', '1',
      wavPath,
    ], { timeout: 15_000 });

    const wavData = fs.readFileSync(wavPath);

    // Groq Whisper API (OpenAI-compatible)
    const formBoundary = '----NanoclawBoundary' + Date.now();
    const header = `--${formBoundary}\r\nContent-Disposition: form-data; name="file"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`;
    const modelField = `\r\n--${formBoundary}\r\nContent-Disposition: form-data; name="model"\r\n\r\nwhisper-large-v3-turbo\r\n--${formBoundary}--\r\n`;
    const body = Buffer.concat([
      Buffer.from(header),
      wavData,
      Buffer.from(modelField),
    ]);

    const response = await fetch('https://api.groq.com/openai/v1/audio/transcriptions', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.groqApiKey}`,
        'Content-Type': `multipart/form-data; boundary=${formBoundary}`,
      },
      body,
    });

    if (!response.ok) {
      const errText = await response.text();
      throw new Error(`Groq STT error ${response.status}: ${errText}`);
    }

    const result = await response.json() as { text?: string };
    return (result.text || '').trim();
  }

  // ── TTS ───────────────────────────────────────────────────────────────────

  private async drainTtsQueue(): Promise<void> {
    this.ttsPlaying = true;
    try {
      while (this.ttsQueue.length > 0) {
        const text = this.ttsQueue.shift()!;
        await this.playTTS(text);
      }
    } finally {
      this.ttsPlaying = false;
    }
  }

  private async playTTS(text: string): Promise<void> {
    const connection = this.connection;
    if (!connection || connection.state.status === VoiceConnectionStatus.Destroyed) return;

    try {
      if (this.ttsProvider === 'elevenlabs') {
        await this.playElevenLabsStreaming(text, connection);
      } else {
        await this.playMsEdge(text, connection);
      }
    } catch (err) {
      logger.error({ err, tts: this.ttsProvider }, 'TTS playback failed');
    }
  }

  private async playMsEdge(
    text: string,
    connection: import('@discordjs/voice').VoiceConnection,
  ): Promise<void> {
    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nc-tts-'));
    try {
      const audioFilePath = await this.synthesizeMsEdge(text, tempDir);
      const player = createAudioPlayer({
        behaviors: { noSubscriber: NoSubscriberBehavior.Pause },
      });
      const resource = createAudioResource(audioFilePath);
      connection.subscribe(player);
      player.play(resource);
      logger.info({ tts: 'msedge', textLength: text.length }, 'TTS playback started');
      await entersState(player, AudioPlayerStatus.Idle, 120_000);
      logger.info('TTS playback finished');
    } finally {
      fs.rmSync(tempDir, { recursive: true, force: true });
    }
  }

  private async playElevenLabsStreaming(
    text: string,
    connection: import('@discordjs/voice').VoiceConnection,
  ): Promise<void> {
    const voiceId = this.elevenlabsVoiceId;
    const modelId = process.env.ELEVENLABS_TTS_MODEL || 'eleven_turbo_v2_5';

    const response = await fetch(
      `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`,
      {
        method: 'POST',
        headers: {
          'xi-api-key': this.elevenlabsApiKey,
          'Content-Type': 'application/json',
          'Accept': 'audio/mpeg',
        },
        body: JSON.stringify({
          text,
          model_id: modelId,
          voice_settings: {
            stability: 0.5,
            similarity_boost: 0.75,
          },
        }),
      },
    );

    if (!response.ok) {
      const errText = await response.text();
      throw new Error(`ElevenLabs streaming TTS error ${response.status}: ${errText}`);
    }

    if (!response.body) {
      throw new Error('ElevenLabs streaming TTS returned no body');
    }

    // Pipe the streaming response into a PassThrough so @discordjs/voice can consume it
    const { Readable } = await import('stream');
    const audioStream = Readable.fromWeb(response.body as import('stream/web').ReadableStream);

    const player = createAudioPlayer({
      behaviors: { noSubscriber: NoSubscriberBehavior.Pause },
    });
    const resource = createAudioResource(audioStream);
    connection.subscribe(player);
    player.play(resource);

    logger.info({ tts: 'elevenlabs-stream', textLength: text.length }, 'TTS streaming playback started');
    await entersState(player, AudioPlayerStatus.Idle, 120_000);
    logger.info('TTS streaming playback finished');
  }

  private async synthesizeMsEdge(text: string, tempDir: string): Promise<string> {
    const tts = new MsEdgeTTS();
    await tts.setMetadata(
      process.env.VOICE_TTS_VOICE || 'en-GB-SoniaNeural',
      OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3,
    );
    const { audioFilePath } = await tts.toFile(tempDir, text);
    tts.close();
    return audioFilePath;
  }

  private async synthesizeElevenLabs(text: string, tempDir: string): Promise<string> {
    const voiceId = this.elevenlabsVoiceId;
    const modelId = process.env.ELEVENLABS_TTS_MODEL || 'eleven_turbo_v2_5';

    const response = await fetch(
      `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
      {
        method: 'POST',
        headers: {
          'xi-api-key': this.elevenlabsApiKey,
          'Content-Type': 'application/json',
          'Accept': 'audio/mpeg',
        },
        body: JSON.stringify({
          text,
          model_id: modelId,
          voice_settings: {
            stability: 0.5,
            similarity_boost: 0.75,
          },
        }),
      },
    );

    if (!response.ok) {
      const errText = await response.text();
      throw new Error(`ElevenLabs TTS error ${response.status}: ${errText}`);
    }

    const audioBuffer = Buffer.from(await response.arrayBuffer());
    const audioFilePath = path.join(tempDir, 'speech.mp3');
    fs.writeFileSync(audioFilePath, audioBuffer);
    return audioFilePath;
  }

  async leave(guildId: string): Promise<void> {
    const connection = this.connection || getVoiceConnection(guildId);
    if (connection) {
      try { connection.destroy(); } catch { /* ignore if already destroyed */ }
      logger.info({ guildId }, 'Bot left voice channel (no users remaining)');
    }
    this.connection = null;
    this.activeUsers.clear();
    this.activeSubscriptions.clear();
    this.ttsQueue = [];
    this.ttsPlaying = false;
  }
}
