"""Voice-enabled chat session.

Wraps :class:`~cadecoder.ui.session.ChatSession` with microphone input
(STT) and speaker output (TTS), keeping the full existing text pipeline
intact.  All messages still appear in the terminal — voice is an
additional I/O layer.
"""

import asyncio
import json
import select
import signal
import sys
import threading
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any

from rich.markdown import Markdown
from rich.markup import escape
from rich.table import Table

from cadecoder.core.config import VoiceConfig, get_config
from cadecoder.core.errors import VoiceError
from cadecoder.core.logging import log
from cadecoder.core.types import ExecutionEventType
from cadecoder.execution.orchestrator import ExecutionContext
from cadecoder.ui.display import (
    console,
    display_git_branch_info,
    display_messages,
    display_thread_header,
    display_tool_result,
    get_display_tool_name,
    get_tool_context,
    strip_control_signals,
)
from cadecoder.ui.session import ChatSession
from cadecoder.voice.audio import (
    DEFAULT_SAMPLE_RATE,
    AudioPlayer,
    AudioRecorder,
    check_mic_permission,
    list_audio_devices,
    record_fixed_duration,
    resolve_device,
)
from cadecoder.voice.stt import WhisperSTT
from cadecoder.voice.tts import KokoroTTS

# Box-drawing characters that break Rich Markdown rendering
_BOX_CHARS = frozenset("┌┐└┘├┤┬┴┼─│═║╔╗╚╝╠╣╦╩╬")


def _has_box_chars(text: str) -> bool:
    """Check if text contains box-drawing characters."""
    return bool(_BOX_CHARS & set(text))


def _display_audio_devices() -> None:
    """Display available audio input and output devices."""
    try:
        devices = list_audio_devices()
    except Exception as exc:
        console.print(f"[red]Error listing devices: {escape(str(exc))}[/red]")
        return

    table = Table(title="Audio Devices")
    table.add_column("Index", style="cyan", width=6)
    table.add_column("Name", style="white", max_width=50)
    table.add_column("In", style="green", width=4)
    table.add_column("Out", style="blue", width=4)
    table.add_column("Rate", style="dim", width=8)

    for dev in devices:
        has_input = "✓" if dev["max_input_channels"] > 0 else ""
        has_output = "✓" if dev["max_output_channels"] > 0 else ""
        rate = f"{int(dev['default_samplerate'])}"
        table.add_row(str(dev["index"]), dev["name"], has_input, has_output, rate)

    console.print(table)
    console.print("\n[dim]Set device in ~/.cadecoder/cadecoder.toml under [voice_settings]:[/dim]")
    console.print('[dim]  audio_input_device = "Device Name"[/dim]')
    console.print('[dim]  audio_output_device = "Device Name"[/dim]')


async def _run_device_test(voice_config: VoiceConfig) -> None:
    """Run a full audio device round-trip test.

    Steps:
    1. Show configured devices and resolve them.
    2. Record 3 seconds of audio (fixed duration, no VAD) with live level meter.
    3. Play the recording back through the output device.
    4. Report success/failure at each step.
    """
    import sys

    console.print("\n[bold cyan]Audio Device Test[/bold cyan]")
    console.print("─" * 50)

    # --- Step 1: Show and resolve devices ---
    in_cfg = voice_config.audio_input_device
    out_cfg = voice_config.audio_output_device
    console.print(f"  Configured input device:  [yellow]{in_cfg or '(system default)'}[/yellow]")
    console.print(f"  Configured output device: [yellow]{out_cfg or '(system default)'}[/yellow]")

    try:
        resolved_in = resolve_device(in_cfg, kind="input")
        if resolved_in is not None:
            console.print(f"  Resolved input device:    [green]{resolved_in}[/green]")
        else:
            import sounddevice as sd

            default_in = sd.query_devices(kind="input")
            console.print(f"  Using system default input: [green]{default_in['name']}[/green]")
    except VoiceError as exc:
        console.print(f"  [red]Input device error: {escape(str(exc))}[/red]")
        console.print("  [dim]Run /devices to see available devices.[/dim]")
        return

    try:
        resolved_out = resolve_device(out_cfg, kind="output")
        if resolved_out is not None:
            console.print(f"  Resolved output device:   [green]{resolved_out}[/green]")
        else:
            import sounddevice as sd

            default_out = sd.query_devices(kind="output")
            console.print(f"  Using system default output: [green]{default_out['name']}[/green]")
    except VoiceError as exc:
        console.print(f"  [red]Output device error: {escape(str(exc))}[/red]")
        console.print("  [dim]Run /devices to see available devices.[/dim]")
        return

    # --- Step 2: Record 3 seconds ---
    console.print()
    console.print("[bold green]Recording 3 seconds — speak now...[/bold green]")

    peak_rms = 0.0

    def _test_status(speech_detected: bool, elapsed: float, rms: float) -> None:
        nonlocal peak_rms
        peak_rms = max(peak_rms, rms)
        bar_len = min(30, int(rms * 300))
        bar = "█" * bar_len + "░" * (30 - bar_len)
        line = f"\r  {elapsed:4.1f}s  [{bar}]  RMS: {rms:.4f}  "
        sys.stderr.write(line)
        sys.stderr.flush()

    try:
        audio = await asyncio.get_running_loop().run_in_executor(
            None,
            lambda: record_fixed_duration(
                duration_s=3.0,
                device=in_cfg,
                on_status=_test_status,
            ),
        )
    except VoiceError as exc:
        sys.stderr.write("\r" + " " * 70 + "\r")
        console.print(f"  [red]Recording failed: {escape(str(exc))}[/red]")
        return

    sys.stderr.write("\r" + " " * 70 + "\r")
    sys.stderr.flush()

    duration = len(audio) / DEFAULT_SAMPLE_RATE
    console.print(f"  Recorded {duration:.1f}s, {len(audio)} samples")
    console.print(f"  Peak RMS level: [bold]{peak_rms:.4f}[/bold]")

    if peak_rms < 0.001:
        console.print("  [red]Microphone returned silence (all zeros).[/red]")
        import platform

        if platform.system() == "Darwin":
            console.print()
            console.print("  [yellow]macOS is likely blocking microphone access.[/yellow]")
            console.print("  To fix:")
            console.print(
                "    1. Open [bold]System Settings > Privacy & Security > Microphone[/bold]"
            )
            console.print("    2. Find your terminal app (Terminal, iTerm2, Warp, etc.)")
            console.print("    3. Toggle it [bold]ON[/bold]")
            console.print("    4. Restart your terminal and try again")
        else:
            console.print("  [dim]Check that the device is unmuted and working.[/dim]")
        return
    elif peak_rms < 0.01:
        console.print(
            "  [yellow]Low audio level — try speaking louder or moving closer to the mic.[/yellow]"
        )
    else:
        console.print("  [green]Audio level looks good.[/green]")

    # --- Step 3: Play back ---
    console.print()
    console.print("[bold blue]Playing back through output device...[/bold blue]")

    player = AudioPlayer(sample_rate=DEFAULT_SAMPLE_RATE, device=out_cfg)
    try:
        await player.play(audio)
        console.print("  [green]Playback complete.[/green]")
    except VoiceError as exc:
        console.print(f"  [red]Playback failed: {escape(str(exc))}[/red]")
        return

    console.print()
    console.print(
        "[green]Device test passed.[/green] If you heard your voice, devices are working."
    )
    console.print("[dim]If silent, check output device in System Settings > Sound > Output.[/dim]")


class VoiceChatSession:
    """Chat session with voice input and output.

    Wraps a standard :class:`ChatSession` and adds:
    - Microphone recording with WebRTC voice activity detection
    - Whisper-based transcription of recorded audio
    - Kokoro TTS playback of assistant responses
    - Interruption support (Ctrl+C stops TTS or cancels recording)
    - Audio device selection

    The text pipeline is unchanged — all messages are displayed in the
    terminal exactly as in text mode, and are persisted to the thread
    database.

    Args:
        chat_session: The underlying text-based chat session.
        voice_config: Voice configuration (models, voice preset, etc.).
    """

    def __init__(
        self,
        chat_session: ChatSession,
        voice_config: VoiceConfig | None = None,
    ) -> None:
        self.chat = chat_session
        self.config = voice_config or get_config().voice_settings

        # Initialize voice components (lazy-loaded on first use)
        self.stt = WhisperSTT(
            model=self.config.stt_model,
            language="en",
        )
        self.tts = KokoroTTS(
            model=self.config.tts_model,
            voice=self.config.tts_voice,
            speed=self.config.tts_speed,
            device=self.config.audio_output_device,
        )
        self.recorder = AudioRecorder(
            vad_aggressiveness=self.config.vad_aggressiveness,
            silence_frames=self.config.silence_frames,
            device=self.config.audio_input_device,
        )

        self._cancelled = False

    async def handle_voice_command(self, cmd: str) -> bool:
        """Handle voice-specific slash commands.

        Args:
            cmd: The slash command string.

        Returns:
            True if the command was handled, False to delegate to ChatSession.
        """
        cmd = cmd.strip()
        if cmd == "/devices":
            _display_audio_devices()
            return True
        if cmd == "/devices test":
            await _run_device_test(self.config)
            return True
        return False

    async def process_voice_turn(self, user_text: str) -> str:
        """Process a single voice turn: send text, stream response, return speakable text.

        This mirrors :meth:`ChatSession.process_input` but also collects
        the assistant's response text for TTS output.

        Args:
            user_text: Transcribed user utterance.

        Returns:
            The assistant's response text (for TTS).
        """
        # Handle slash commands
        if user_text.startswith("/"):
            handled = await self.handle_voice_command(user_text)
            if not handled:
                await self.chat.handle_command(user_text)
            return ""

        history = self.chat.get_conversation_history()
        self.chat.save_user_message(user_text)

        context = ExecutionContext(
            task=user_text,
            conversation_history=history,
            metadata={"thread_id": self.chat.thread_id, "persona": self.chat.persona},
        )

        display_content = ""
        response_text = ""

        console.print()

        try:
            async for event in self.chat.orchestrator.stream(context):
                if event.type == ExecutionEventType.CONTENT:
                    display_content += event.content or ""
                    response_text += event.content or ""

                elif event.type == ExecutionEventType.TOOL_CALL:
                    # Flush accumulated text before tool line
                    if display_content:
                        stripped = strip_control_signals(display_content, strip_whitespace=True)
                        if stripped:
                            if _has_box_chars(stripped):
                                console.print(stripped)
                            else:
                                console.print(Markdown(stripped))
                        display_content = ""

                    # Reset TTS text — only speak content after the last tool interaction
                    response_text = ""

                    tc = event.metadata.get("tool_call", {})
                    func = tc.get("function", {})
                    tool_name = func.get("name", "unknown")

                    args_str = func.get("arguments", "{}")
                    try:
                        args = json.loads(args_str)
                    except (json.JSONDecodeError, TypeError):
                        args = {}

                    display_name = get_display_tool_name(tool_name, args)
                    tool_ctx = get_tool_context(tool_name, args)
                    console.print(f"[dim]Calling tool: {display_name}{tool_ctx}[/dim]")

                elif event.type == ExecutionEventType.TOOL_RESULT:
                    tool_name = event.metadata.get("tool_name", "unknown")
                    tool_status = event.metadata.get("status", "success")
                    result_content = event.content or ""
                    tool_call = event.metadata.get("tool_call", {})
                    auth_url = event.metadata.get("authorization_url")

                    args = {}
                    if tool_call:
                        func = tool_call.get("function", {})
                        args_str = func.get("arguments", "{}")
                        try:
                            args = json.loads(args_str)
                        except (json.JSONDecodeError, TypeError):
                            pass

                    display_tool_result(
                        tool_name,
                        result_content,
                        status=tool_status,
                        args=args,
                        authorization_url=auth_url,
                    )

                    # Reset TTS text — only speak content after all tools finish
                    response_text = ""

                elif event.type == ExecutionEventType.ASSISTANT_TURN_END:
                    # Flush remaining display content
                    if display_content:
                        stripped = strip_control_signals(display_content, strip_whitespace=True)
                        if stripped:
                            if _has_box_chars(stripped):
                                console.print(stripped)
                            else:
                                console.print(Markdown(stripped))

                    # Save messages
                    turn_content = event.content
                    turn_tool_calls = event.metadata.get("tool_calls", [])
                    turn_tool_results = event.metadata.get("tool_results", [])

                    if turn_content or turn_tool_calls:
                        self.chat.save_assistant_message(turn_content, turn_tool_calls)

                    for tr in turn_tool_results:
                        self.chat.save_tool_message(
                            tr.get("tool_call_id", ""),
                            tr.get("content", ""),
                            tr.get("tool_name", "unknown"),
                        )

                    display_content = ""

        except asyncio.CancelledError:
            console.print("\n[yellow]Operation cancelled.[/yellow]")
            return ""
        except Exception as e:
            console.print(f"\n[red]Error: {escape(str(e))}[/red]")
            log.error(f"Error processing voice input: {e}", exc_info=True)
            return ""

        console.print()
        return strip_control_signals(response_text, strip_whitespace=True)


async def run_voice_session(session: ChatSession, voice_config: VoiceConfig | None = None) -> None:
    """Run the voice-enabled chat session loop.

    Replaces the standard prompt-based input loop with a
    record → transcribe → process → speak cycle.

    Args:
        session: The underlying ChatSession instance.
        voice_config: Voice configuration (uses global config if None).
    """
    from cadecoder.core.git import get_current_branch_name

    voice = VoiceChatSession(session, voice_config)

    # Display header (same as text mode)
    branch_name, _ = get_current_branch_name()
    display_git_branch_info(branch_name)

    thread_name = session.thread.name if session.thread else session.thread_id[:8]
    display_thread_header(thread_name)
    console.print("[bold cyan]Voice mode enabled[/bold cyan] — speak after the prompt")
    console.print("[dim]Press Enter to start recording, Ctrl+C to cancel/exit[/dim]")
    console.print("[dim]Type /devices to list audio devices, /devices test to test them[/dim]")

    # Display existing messages
    messages = session.history_manager.get_messages(session.thread_id)
    if messages:
        display_messages(messages)

    # Warm up models in background to reduce first-turn latency
    console.print("[dim]Loading voice models...[/dim]")
    try:
        voice.stt._ensure_loaded()
        voice.tts._ensure_loaded()
        console.print("[green]Voice models ready.[/green]")
    except VoiceError as exc:
        console.print(f"[red]Failed to load voice models: {escape(str(exc))}[/red]")
        return

    # Check microphone access before proceeding
    console.print("[dim]Checking microphone access...[/dim]")
    mic_ok, mic_msg = check_mic_permission(device=voice.config.audio_input_device)
    if not mic_ok:
        console.print(f"[red]{escape(mic_msg)}[/red]")
        return
    console.print(f"[green]{escape(mic_msg)}[/green]")

    # Announce readiness
    try:
        await voice.tts.speak("Ready.")
    except VoiceError as exc:
        console.print(f"[yellow]TTS warning: {escape(str(exc))}[/yellow]")

    loop = asyncio.get_running_loop()

    # Use our own thread pool so we can shut it down cleanly on exit
    executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="voice")
    loop.set_default_executor(executor)

    try:
        await _voice_session_loop(voice, session, loop, executor)
    finally:
        # Cleanup must happen BEFORE asyncio.run() closes the loop.
        # This prevents "Event loop is closed" errors from MCP subprocess
        # transports trying to schedule work on a dead loop.
        voice.tts.stop()
        try:
            await session.orchestrator.tool_manager.close()
        except Exception:
            pass
        executor.shutdown(wait=False, cancel_futures=True)
        console.print("[dim]Voice session ended.[/dim]")


async def _voice_session_loop(
    voice: VoiceChatSession,
    session: ChatSession,
    loop: asyncio.AbstractEventLoop,
    executor: ThreadPoolExecutor,
) -> None:
    """Inner voice session loop, separated so cleanup always runs via finally."""
    ctrl_c_count = 0

    while True:
        # === INPUT PHASE: wait for Enter or typed command, then record ===
        try:
            console.print()
            raw_input = await loop.run_in_executor(
                executor, lambda: input("[Press Enter to speak, or type a /command] ")
            )
        except EOFError:
            console.print("\n[dim]Exiting voice mode.[/dim]")
            break
        except KeyboardInterrupt:
            ctrl_c_count += 1
            if ctrl_c_count >= 2:
                console.print("\n[dim]Exiting voice mode.[/dim]")
                break
            console.print("\n[yellow]Press Ctrl+C again to exit[/yellow]")
            continue

        ctrl_c_count = 0

        # Allow typed commands without recording
        raw_input = raw_input.strip()
        if raw_input.startswith("/"):
            handled = await voice.handle_voice_command(raw_input)
            if not handled:
                await voice.chat.handle_command(raw_input)
            continue

        # If user typed text instead of pressing Enter, use it directly
        if raw_input:
            user_text = raw_input
            console.print(f"[bold blue]You:[/bold blue] {escape(user_text)}")
        else:
            # Record audio with live level meter
            def _recording_status(speech_detected: bool, elapsed: float, rms: float) -> None:
                """Show live audio level during recording (called from thread)."""
                bar_len = min(20, int(rms * 200))
                bar = "█" * bar_len + "░" * (20 - bar_len)
                state = "🔴 Speaking" if speech_detected else "⏳ Waiting "
                line = f"\r  {state}  {elapsed:4.1f}s  [{bar}]  "
                sys.stderr.write(line)
                sys.stderr.flush()

            console.print("[bold green]Listening...[/bold green]")
            try:
                audio = await voice.recorder.record_until_silence(
                    on_status=_recording_status,
                )
            except VoiceError as exc:
                sys.stderr.write("\r" + " " * 60 + "\r")
                console.print(f"[red]Recording error: {escape(str(exc))}[/red]")
                continue

            # Clear the status line
            sys.stderr.write("\r" + " " * 60 + "\r")
            sys.stderr.flush()

            if audio.size == 0:
                console.print(
                    "[yellow]No speech detected. Try speaking louder or closer to mic.[/yellow]"
                )
                continue

            duration = len(audio) / voice.recorder.sample_rate
            console.print(f"[dim]({duration:.1f}s recorded)[/dim]")

            # Transcribe
            console.print("[dim]Transcribing...[/dim]", end=" ")
            try:
                user_text = await voice.stt.transcribe(audio)
            except VoiceError as exc:
                console.print(f"\n[red]Transcription error: {escape(str(exc))}[/red]")
                continue

            if not user_text.strip():
                console.print("[yellow]Could not understand audio.[/yellow]")
                continue

            console.print(f"[bold blue]You:[/bold blue] {escape(user_text)}")

        # Check for exit commands
        lower = user_text.lower().strip()
        if lower in ("exit", "quit", "goodbye", "bye"):
            try:
                await voice.tts.speak("Goodbye.")
            except VoiceError:
                pass
            console.print("[dim]Exiting voice mode.[/dim]")
            break

        # === PROCESSING PHASE ===
        task = asyncio.create_task(voice.process_voice_turn(user_text))

        # Allow Ctrl+C to cancel the current task
        def _cancel_task() -> None:
            voice.tts.stop()
            if task and not task.done():
                task.cancel()

        try:
            loop.add_signal_handler(signal.SIGINT, _cancel_task)
        except NotImplementedError:
            pass

        try:
            response_text = await task
        except asyncio.CancelledError:
            console.print("\n[yellow]Cancelled.[/yellow]")
            response_text = ""
        finally:
            try:
                loop.remove_signal_handler(signal.SIGINT)
            except NotImplementedError:
                pass

        # === TTS PHASE: speak the response (Enter to skip) ===
        if response_text:
            tts_task = asyncio.create_task(voice.tts.speak(response_text))

            # Wait for Enter using select() so the thread doesn't leak
            skip_event = asyncio.Event()
            thread_stop = threading.Event()

            def _safe_call_soon(callback: Callable[[], Any]) -> None:
                """Safely signal event loop, handling shutdown race."""
                try:
                    if not loop.is_closed():
                        loop.call_soon_threadsafe(callback)
                except RuntimeError:
                    pass

            def _wait_for_skip() -> None:
                """Poll stdin with select() so this thread exits when thread_stop is set."""
                try:
                    while not thread_stop.is_set():
                        ready, _, _ = select.select([sys.stdin], [], [], 0.2)
                        if ready:
                            sys.stdin.readline()
                            _safe_call_soon(skip_event.set)
                            return
                except (EOFError, KeyboardInterrupt, OSError):
                    _safe_call_soon(skip_event.set)

            skip_thread = threading.Thread(target=_wait_for_skip, daemon=True)
            skip_thread.start()

            try:
                skip_wait_task = asyncio.create_task(skip_event.wait())
                done, _ = await asyncio.wait(
                    [tts_task, skip_wait_task],
                    return_when=asyncio.FIRST_COMPLETED,
                )

                if skip_event.is_set() and not tts_task.done():
                    voice.tts.stop()
                    tts_task.cancel()
                    try:
                        await tts_task
                    except (asyncio.CancelledError, VoiceError):
                        pass
                    console.print("[dim]Skipped.[/dim]")
                else:
                    try:
                        await tts_task
                    except VoiceError as exc:
                        log.warning(f"TTS playback failed: {exc}")
                    except asyncio.CancelledError:
                        voice.tts.stop()

                skip_wait_task.cancel()
            finally:
                thread_stop.set()
                skip_thread.join(timeout=0.5)
