"""Long-running listener that picks up queued commands from the Maeris backend."""

import asyncio
import collections
import json
import os
import platform
import re
import select as _select
import shlex
import shutil
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path

import click

from ._core import cli
from ._utils import _repo_config_dir

POLL_INTERVAL = 3            # seconds between polls
PROGRESS_BATCH_INTERVAL = 0.5  # seconds between progress uploads

# Commands that need --source or --target injection (local path)
SOURCE_COMMANDS = {
    "create tests": "--target",
    "create test": "--target",
    "scan security": "--source",
    "scan api": "--source",
    "fix tests": "--source",
    "recalibrate": "--source",
    "tidy": "--target",
    "coverage ui": "--source",
    "coverage api": "--source",
    "push tests": "--target",
}


def _pid_file(config_dir: Path) -> Path:
    return config_dir / "listener.pid"


def _heartbeat_file(config_dir: Path) -> Path:
    return config_dir / "listener.heartbeat"


def _stdout_log_file(config_dir: Path) -> Path:
    return config_dir / "listener.stdout.log"


def _stderr_log_file(config_dir: Path) -> Path:
    return config_dir / "listener.stderr.log"


def _safe_unlink(path: Path) -> bool:
    """Best-effort file delete; never raises."""
    try:
        path.unlink(missing_ok=True)
        return True
    except OSError:
        return False


def _is_listener_running(pid_file: Path) -> tuple[bool, int]:
    """Return (is_running, pid). Removes stale PID file if process is dead."""
    if not pid_file.exists():
        return (False, 0)
    try:
        pid = int(pid_file.read_text().strip())
    except (ValueError, OSError):
        _safe_unlink(pid_file)
        return (False, 0)

    # Liveness check
    try:
        if platform.system() == "Windows":
            import ctypes
            SYNCHRONIZE = 0x00100000
            handle = ctypes.windll.kernel32.OpenProcess(SYNCHRONIZE, False, pid)
            if handle == 0:
                raise ProcessLookupError
            ctypes.windll.kernel32.CloseHandle(handle)
        else:
            os.kill(pid, 0)
    except (ProcessLookupError, PermissionError, OSError):
        _safe_unlink(pid_file)
        return (False, 0)

    return (True, pid)


def _write_heartbeat(config_dir: Path) -> None:
    """Write current epoch time to heartbeat file (best-effort)."""
    try:
        _heartbeat_file(config_dir).write_text(str(time.time()))
    except OSError:
        pass


def _is_listener_healthy(config_dir: Path, max_stale_seconds: int = 20) -> bool:
    """Check whether listener heartbeat is recent enough."""
    hb = _heartbeat_file(config_dir)
    if not hb.exists():
        return False
    try:
        ts = float(hb.read_text().strip())
    except (OSError, ValueError):
        return False
    return (time.time() - ts) <= max_stale_seconds


def _tail_text(path: Path, max_chars: int = 800) -> str:
    """Return the tail of a text file (best-effort)."""
    try:
        txt = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return ""
    return txt[-max_chars:].strip()


def _start_listener_background(cwd: str, config_dir: Path, direct_api: bool = False) -> int:
    """Spawn a detached background listener process and write its PID file."""
    # On Windows, force interpreter launch to avoid spawning a visible console window.
    if platform.system() == "Windows":
        cmd = [sys.executable, "-m", "maeris_mcp.cli", "listen"]
    else:
        maeris_bin = shutil.which("maeris")
        if maeris_bin:
            cmd = [maeris_bin, "listen"]
        else:
            cmd = [sys.executable, "-m", "maeris_mcp.cli", "listen"]

    if direct_api:
        cmd.append("--direct-api")

    env = os.environ.copy()
    env["MAERIS_CONFIG_DIR"] = str(config_dir)

    stdout_target = subprocess.DEVNULL
    stderr_target = subprocess.DEVNULL
    stdout_handle = None
    stderr_handle = None
    try:
        config_dir.mkdir(parents=True, exist_ok=True)
        stdout_handle = open(_stdout_log_file(config_dir), "a", encoding="utf-8", errors="replace")
        stderr_handle = open(_stderr_log_file(config_dir), "a", encoding="utf-8", errors="replace")
        stdout_target = stdout_handle
        stderr_target = stderr_handle
    except OSError:
        pass

    kwargs: dict = dict(
        stdin=subprocess.DEVNULL,
        stdout=stdout_target,
        stderr=stderr_target,
        cwd=cwd,
        env=env,
    )
    if platform.system() == "Windows":
        DETACHED_PROCESS = 0x00000008
        CREATE_NO_WINDOW = 0x08000000
        kwargs["creationflags"] = DETACHED_PROCESS | CREATE_NO_WINDOW
    else:
        kwargs["start_new_session"] = True

    # Clear stale heartbeat before spawning.
    _safe_unlink(_heartbeat_file(config_dir))
    try:
        proc = subprocess.Popen(cmd, **kwargs)
    except Exception:
        if stdout_handle:
            stdout_handle.close()
        if stderr_handle:
            stderr_handle.close()
        raise
    if stdout_handle:
        stdout_handle.close()
    if stderr_handle:
        stderr_handle.close()

    pid_path = _pid_file(config_dir)
    _safe_unlink(pid_path)
    try:
        pid_path.write_text(str(proc.pid))
    except OSError as e:
        # PID tracking is required for reliable stop/status behavior.
        try:
            os.kill(proc.pid, signal.SIGTERM)
        except OSError:
            pass
        raise RuntimeError(f"could not write PID file at {pid_path}: {e}") from e

    # Wait briefly for a real listener heartbeat before reporting success.
    deadline = time.time() + 6.0
    while time.time() < deadline:
        if _is_listener_healthy(config_dir, max_stale_seconds=60):
            return proc.pid
        if proc.poll() is not None:
            err_tail = _tail_text(_stderr_log_file(config_dir))
            if err_tail:
                raise RuntimeError(
                    f"listener process exited (code {proc.returncode}). "
                    f"Recent error output: {err_tail}"
                )
            raise RuntimeError(f"listener process exited (code {proc.returncode})")
        time.sleep(0.3)

    # Process never became healthy within startup window.
    try:
        os.kill(proc.pid, signal.SIGTERM)
    except OSError:
        pass
    raise RuntimeError(
        f"listener did not become ready (no heartbeat). "
        f"Check {_stderr_log_file(config_dir)} for details."
    )


def _stop_listener(config_dir: Path) -> bool:
    """Stop the background listener. Returns True if it was running."""
    pid_file = _pid_file(config_dir)
    running, pid = _is_listener_running(pid_file)
    if not running:
        return False
    try:
        os.kill(pid, signal.SIGTERM)
    except OSError:
        pass
    _safe_unlink(pid_file)
    _safe_unlink(_heartbeat_file(config_dir))
    return True


def _inject_local_path(command: str, cwd: str) -> str:
    """Inject --source/--target with local cwd if the command needs it."""
    for pattern, flag in SOURCE_COMMANDS.items():
        if f"maeris {pattern}" in command:
            if "--target" in command or "--source" in command or "-t " in command or "-s " in command:
                return command
            return f'{command} {flag} "{cwd}"'
    return command


@cli.command("listen")
@click.option("--direct-api", is_flag=True, default=False,
              help="Force the direct API path (skip Claude Code CLI even if installed).")
@click.option("--background", "-b", is_flag=True, default=False,
              help="Start listener in the background and return immediately.")
@click.option("--stop", is_flag=True, default=False,
              help="Stop a running background listener for this repository.")
def listen_cmd(direct_api: bool, background: bool, stop: bool) -> None:
    """Listen for commands from the Maeris web UI and execute them locally.

    \b
    Run this in your project root directory. When you (or a teammate)
    use the Maeris assistant in the browser, commands that need source
    code will be routed here instead of cloning the repo on the server.

    \b
    Examples:
      cd my-project && maeris listen
      cd my-project && maeris listen --background
      cd my-project && maeris listen --stop
      cd my-project && maeris listen --direct-api
    """
    from maeris_mcp.auth.token_store import TokenStore

    config_dir = _repo_config_dir()

    # ── --stop flag ──────────────────────────────────────────────────────────
    if stop:
        killed = _stop_listener(config_dir)
        if killed:
            click.secho("✓ Listener stopped.", fg="green")
        else:
            click.echo("No listener is running.")
        return

    # ── --background flag ────────────────────────────────────────────────────
    if background:
        token_store = TokenStore(config_dir=config_dir)
        if not token_store.is_authenticated():
            click.secho("Not authenticated. Run 'maeris login' first.", fg="red")
            sys.exit(1)
        if token_store.is_token_expired():
            click.secho("Token expired. Run 'maeris login' to re-authenticate.", fg="red")
            sys.exit(1)

        pid_file = _pid_file(config_dir)
        running, existing_pid = _is_listener_running(pid_file)
        if running:
            if _is_listener_healthy(config_dir):
                click.secho(f"Listener already running in background (PID {existing_pid}).", fg="yellow")
                try:
                    restart = click.confirm("  Restart it?", default=False)
                except click.Abort:
                    click.echo()
                    return
                if not restart:
                    return
                _stop_listener(config_dir)
            else:
                click.secho(
                    f"Listener process found (PID {existing_pid}) but heartbeat is stale. Restarting...",
                    fg="yellow",
                )
                _stop_listener(config_dir)

        cwd = str(Path.cwd().resolve())
        try:
            pid = _start_listener_background(cwd, config_dir, direct_api=direct_api)
            is_running, _ = _is_listener_running(_pid_file(config_dir))
            if not is_running:
                raise RuntimeError("listener started but is not running (or PID tracking failed)")
        except Exception as e:
            click.secho(f"✗ Could not start listener: {e}", fg="red")
            sys.exit(1)
        click.secho(f"✓ Listener started in background (PID {pid}).", fg="green")
        click.echo(f"  Directory: {cwd}")
        click.echo("  To stop:   maeris listen --stop")
        return

    token_store = TokenStore(config_dir=config_dir)

    if not token_store.is_authenticated():
        click.secho("Not authenticated. Run 'maeris login' first.", fg="red")
        sys.exit(1)
    if token_store.is_token_expired():
        click.secho("Token expired. Run 'maeris login' to re-authenticate.", fg="red")
        sys.exit(1)

    app_name = token_store.get_app_name() or token_store.get_app_id() or "?"
    cwd = str(Path.cwd().resolve())

    click.secho("Listening for commands...", fg="green")
    click.echo(f"  App:       {app_name}")
    click.echo(f"  Directory: {cwd}")
    click.echo(f"  Poll interval: {POLL_INTERVAL}s")
    click.echo()
    click.echo("  Press Ctrl+C to stop.")
    click.echo()

    shutdown = threading.Event()

    def _handle_signal(signum, frame):
        click.echo("\nShutting down...")
        shutdown.set()

    signal.signal(signal.SIGINT, _handle_signal)
    signal.signal(signal.SIGTERM, _handle_signal)

    async def _poll_loop():
        from maeris_mcp.maeris.client import MaerisClient

        async with MaerisClient() as client:
            while not shutdown.is_set():
                try:
                    _write_heartbeat(config_dir)
                    pending = await client.poll_commands()
                    if not pending:
                        await asyncio.sleep(POLL_INTERVAL)
                        continue

                    cmd_id = pending["id"]
                    command = pending["command"]
                    message_type = pending.get("message_type", "cli_command")

                    click.echo(f"  Received: {command[:80]}")

                    claimed = await client.claim_command(cmd_id)
                    if not claimed:
                        click.secho(f"  Could not claim {cmd_id[:8]} (already taken?)", fg="yellow")
                        continue

                    click.secho(f"  Claimed {cmd_id[:8]}, executing...", fg="cyan")

                    if message_type == "ai_message":
                        # New path: pass raw user message to Claude Code + MCP
                        await _execute_via_claude(client, cmd_id, command, cwd,
                                                  force_direct_api=direct_api)
                    else:
                        # Legacy path: run CLI command as subprocess
                        full_command = _inject_local_path(command, cwd)
                        await _execute_and_stream(client, cmd_id, full_command, cwd)

                except Exception as e:
                    click.secho(f"  Error polling for commands: {e}", fg="red")
                    await asyncio.sleep(POLL_INTERVAL)

    asyncio.run(_poll_loop())
    click.echo("Listener stopped.")


async def _execute_via_claude(client, cmd_id: str, user_message: str, cwd: str,
                              *, force_direct_api: bool = False):
    """Execute a user message via Claude Code + MCP tools.

    Instead of translating to a CLI command, we pass the raw user message
    directly to Claude Code with the Maeris MCP server attached. Claude Code
    figures out which tools to call.

    If force_direct_api is True, skip Claude Code CLI even if installed.
    """
    from ._claude_helpers import (
        ensure_mcp_json,
        cleanup_mcp_json,
        run_claude_streaming,
        HEARTBEAT_MESSAGES,
    )

    # Set MAERIS_CONFIG_DIR so the MCP server can find credentials
    os.environ.setdefault("MAERIS_CONFIG_DIR", str(_repo_config_dir()))

    system_prompt = (
        "You are the Maeris assistant — a specialized tool for API testing, security scanning, "
        "test generation, and test management. You are NOT a general-purpose chatbot.\n"
        "You ONLY help with Maeris-supported tasks. If the user asks something outside your scope, "
        "politely decline and list what you can help with.\n\n"

        "SUPPORTED TASKS (only handle requests related to these):\n"
        "- Security scanning (OWASP, GIGW, WCAG, accessibility)\n"
        "- API scanning, management, collections, environments, assertions, flows, execution\n"
        "- UI test generation (Playwright), execution, and auto-fix\n"
        "- Test case management (create, edit, list, search, import, run, delete)\n"
        "- Automation setup (API, UI, security)\n"
        "- API coverage analysis\n"
        "- Application management (create, switch, status)\n"
        "- Pushing results to the Maeris portal\n"
        "- Questions about Maeris features, tools, or how to use the above capabilities\n\n"

        "You have access to Maeris MCP tools and standard tools (Read, Glob, Grep, Bash, etc.). "
        "The project source code is in the current working directory.\n\n"

        "RULES:\n"
        "1. Do ONLY what the user asked. Do not combine steps (e.g. generate + run + push).\n"
        "2. If the user mentions a specific flow/feature (e.g. 'signup', 'login'), ONLY operate on that flow.\n"
        "3. Action mapping:\n"
        "   - 'verify/check/test X' → Run ONLY existing tests matching X. Do NOT generate new tests.\n"
        "   - 'generate/create tests for X' → Use generate_tests for X ONLY. Do NOT run or push.\n"
        "   - 'run tests' → Use run_and_fix_tests on existing tests. Do NOT generate.\n"
        "   - 'push' → Use push_tests_to_maeris or push_to_maeris.\n"
        "   - 'scan security' → Use security_scan. Do NOT push.\n"
        "   - 'scan api' → Use api_scan. Do NOT push.\n"
        "   - Questions about Maeris features or how to use these tools → Answer directly.\n"
        "   - Anything else (general knowledge, coding help unrelated to Maeris, creative writing, etc.) "
        "→ Politely decline: 'I can only help with Maeris tasks like security scanning, "
        "test generation, API management, and test execution. Please ask about one of these.'\n\n"

        "4. NEVER ask the user questions — you are running in one-shot mode and cannot receive answers. "
        "Use your tools (Glob, Grep, Read, Bash) to find what you need. "
        "Look in playwright_tests/ for test files, ~/.maeris/sessions/ for session files. "
        "If you need a base URL, search config files, .env files, package.json, or existing Maeris environment configs — never ask. If you cannot find one, use http://localhost:3000 as default and proceed. "
        "If you truly cannot proceed, explain what you tried and what is missing.\n"
        "5. The user may provide 'Context from previous command' — if present, USE IT. "
        "If no context is provided, use your tools to discover session IDs, test files, and scan results on your own.\n"
        "6. IMPORTANT — Always include session IDs and scan IDs in your final response so follow-ups work. "
        "Write them clearly, e.g. 'Session ID: abc123'.\n"
        "7. Keep responses concise and plain text. No markdown (no **, no `, no #).\n"
        "8. When pushing, if the user says 'new folder', create one named after the flow.\n"
        "9. Filesystem tools (Read, Glob, Grep, Bash, Write, Edit) are ONLY for supporting Maeris tasks "
        "(e.g., reading code for test generation, writing test files). "
        "Do NOT use them to fulfill arbitrary user requests unrelated to Maeris."
    )

    # Collect progress lines to batch-post to backend
    pending_lines: collections.deque = collections.deque()
    result_lines: list = []
    lock = threading.Lock()
    cancel_event = threading.Event()

    def _normalize_error_payload(raw_error: str) -> tuple[str, str]:
        """Return (output_json, legacy_error_text) for completion payloads."""
        try:
            parsed = json.loads(raw_error)
            if isinstance(parsed, dict) and "error_message" in parsed:
                legacy_error = str(parsed.get("error_message") or parsed.get("error") or "Task failed.")
                return json.dumps(parsed), legacy_error
        except (json.JSONDecodeError, TypeError):
            pass
        return "", raw_error or "Task failed."

    # ── Route based on AI mode ──────────────────────────────────────────────
    from maeris_mcp.auth.ai_config import AIConfig
    ai_mode = AIConfig().get_ai_mode()

    if ai_mode == "maeris" or force_direct_api:
        # Maeris mode: always use the direct API (Maeris backend proxy)
        claude_bin = None
    elif ai_mode == "external_llm":
        # External LLM mode: must use Claude Code CLI
        claude_bin = shutil.which("claude")
        if not claude_bin:
            error_msg = (
                "External LLM mode requires Claude Code CLI but it is not installed. "
                "Install it from https://claude.ai/download or switch to Maeris AI "
                "with 'maeris app init'."
            )
            await client.complete_command(cmd_id, output="", error=error_msg, success=False)
            click.secho(f"  {error_msg}", fg="red")
            return
    else:
        # Not configured: fall back to Claude Code CLI if available, else direct API
        claude_bin = shutil.which("claude")

    if not claude_bin:
        from ._direct_api import execute_via_direct_api
        from ._claude_helpers import HEARTBEAT_MESSAGES

        def on_progress(line: str):
            with lock:
                pending_lines.append(line)

        done_event = asyncio.Event()
        cancel_check_counter = 0
        keepalive_counter = 0
        last_post_time = [time.time()]
        heartbeat_idx = [0]

        async def _flush_progress():
            nonlocal cancel_check_counter, keepalive_counter
            while not done_event.is_set():
                await asyncio.sleep(PROGRESS_BATCH_INTERVAL)
                batch = []
                with lock:
                    while pending_lines:
                        batch.append(pending_lines.popleft())
                if batch:
                    try:
                        await client.post_progress(cmd_id, batch)
                        last_post_time[0] = time.time()
                    except Exception:
                        pass
                elif time.time() - last_post_time[0] >= 8:
                    # Emit a heartbeat so the UI log panel stays alive during long API calls
                    hb = HEARTBEAT_MESSAGES[heartbeat_idx[0] % len(HEARTBEAT_MESSAGES)]
                    heartbeat_idx[0] += 1
                    last_post_time[0] = time.time()
                    try:
                        await client.post_progress(cmd_id, [hb])
                    except Exception:
                        pass
                cancel_check_counter += 1
                if cancel_check_counter >= 10:
                    cancel_check_counter = 0
                    try:
                        status = await client.get_command_status(cmd_id)
                        if status == "cancelled":
                            click.secho(f"  Command {cmd_id[:8]} was cancelled by user", fg="yellow")
                            cancel_event.set()
                            done_event.set()
                            return
                    except Exception:
                        pass
                keepalive_counter += 1
                if keepalive_counter >= 60:
                    keepalive_counter = 0
                    await client.listener_keepalive()

        flush_task = asyncio.create_task(_flush_progress())
        try:
            success, result_text = await execute_via_direct_api(
                client, cmd_id, user_message, cwd,
                system_prompt, on_progress, cancel_event,
            )

            done_event.set()
            await flush_task
            # Final flush
            batch = []
            with lock:
                while pending_lines:
                    batch.append(pending_lines.popleft())
            if batch:
                try:
                    await client.post_progress(cmd_id, batch)
                except Exception:
                    pass

            if cancel_event.is_set():
                click.secho(f"  Command {cmd_id[:8]} cancelled", fg="yellow")
            elif success:
                await client.complete_command(cmd_id, output=result_text or "Task completed.", error="", success=True)
                click.secho(f"  Completed {cmd_id[:8]} (success)", fg="green")
            else:
                structured_output, legacy_error = _normalize_error_payload(result_text or "")
                await client.complete_command(
                    cmd_id,
                    output=structured_output,
                    error=legacy_error,
                    success=False,
                )
                click.secho(f"  Completed {cmd_id[:8]} (failed)", fg="red")
        except Exception as e:
            done_event.set()
            try:
                await flush_task
            except Exception:
                pass
            try:
                await client.complete_command(
                    cmd_id, output="",
                    error="Something went wrong while running this command. Please try again.",
                    success=False,
                )
            except Exception:
                pass
            click.secho(f"  Execution error: {e}", fg="red")
        return

    # ── Claude Code CLI path ─────────────────────────────────────

    # Ensure .mcp.json exists so Claude Code can discover the MCP server
    mcp_was_new, mcp_original = ensure_mcp_json(cwd)

    def on_progress(line: str):
        with lock:
            pending_lines.append(line)

    # Background task to flush progress to backend + check cancellation + keepalive
    done_event = asyncio.Event()
    cancel_check_counter = 0
    keepalive_counter = 0

    async def _flush_progress():
        nonlocal cancel_check_counter, keepalive_counter
        while not done_event.is_set():
            await asyncio.sleep(PROGRESS_BATCH_INTERVAL)
            batch = []
            with lock:
                while pending_lines:
                    batch.append(pending_lines.popleft())
            if batch:
                try:
                    await client.post_progress(cmd_id, batch)
                except Exception:
                    pass

            # Check for cancellation every ~5 seconds (10 flush cycles)
            cancel_check_counter += 1
            if cancel_check_counter >= 10:
                cancel_check_counter = 0
                try:
                    status = await client.get_command_status(cmd_id)
                    if status == "cancelled":
                        click.secho(f"  Command {cmd_id[:8]} was cancelled by user", fg="yellow")
                        cancel_event.set()
                        done_event.set()
                        return
                except Exception:
                    pass

            # Keepalive every ~30 seconds (60 flush cycles) so listener-status stays green
            keepalive_counter += 1
            if keepalive_counter >= 60:
                keepalive_counter = 0
                await client.listener_keepalive()

    flush_task = asyncio.create_task(_flush_progress())

    # Emit a startup line immediately so the UI shows something before Claude initialises
    on_progress("Starting up Claude Code...")
    try:
        await client.post_progress(cmd_id, list(pending_lines))
        with lock:
            pending_lines.clear()
    except Exception:
        pass

    try:
        # Run Claude Code in a thread (it's blocking) with a 5-minute timeout
        loop = asyncio.get_event_loop()
        try:
            rc, result_text = await asyncio.wait_for(
                loop.run_in_executor(
                    None,
                    lambda: run_claude_streaming(
                        claude_bin, user_message, "claude-sonnet-4-6", cwd,
                        system_prompt=system_prompt,
                        on_progress=on_progress,
                        cancel_event=cancel_event,
                    ),
                ),
                timeout=600,
            )
        except asyncio.TimeoutError:
            cancel_event.set()
            on_progress("⚠ Task timed out after 10 minutes. Please try again.")
            done_event.set()
            await flush_task
            await client.complete_command(cmd_id, output="", error="Task timed out after 10 minutes.", success=False)
            click.secho(f"  Command {cmd_id[:8]} timed out", fg="red")
            return

        # Final flush
        done_event.set()
        await flush_task
        batch = []
        with lock:
            while pending_lines:
                batch.append(pending_lines.popleft())
        if batch:
            try:
                await client.post_progress(cmd_id, batch)
            except Exception:
                pass

        # Don't complete if already cancelled
        if cancel_event.is_set():
            click.secho(f"  Command {cmd_id[:8]} cancelled", fg="yellow")
        elif rc == 0:
            output = result_text or "Task completed successfully."
            await client.complete_command(cmd_id, output=output, error="", success=True)
            click.secho(f"  Completed {cmd_id[:8]} (success)", fg="green")
        else:
            error = result_text or "This command did not complete successfully. Please try again."
            await client.complete_command(
                cmd_id, output="",
                error=error,
                success=False,
            )
            click.secho(f"  Completed {cmd_id[:8]} (failed)", fg="red")

    except Exception as e:
        done_event.set()
        try:
            await flush_task
        except Exception:
            pass
        try:
            await client.complete_command(
                cmd_id, output="",
                error="Something went wrong while running this command. Please try again.",
                success=False,
            )
        except Exception:
            pass
        click.secho(f"  Execution error: {e}", fg="red")
    finally:
        cleanup_mcp_json(cwd, mcp_was_new, mcp_original)


async def _execute_and_stream(client, cmd_id: str, command: str, cwd: str):
    """Execute command as subprocess, stream progress to backend.

    Uses select() + os.read() in drain threads so partial prompt lines
    (no trailing newline) are forwarded to the UI immediately. stdin is kept
    as a real pipe so interactive CLI prompts block correctly, and user input
    typed in the web UI is fed back via poll_input().
    """
    try:
        args = shlex.split(command)
    except ValueError:
        await client.complete_command(cmd_id, output="", error="Could not understand this command. Please try again.", success=False)
        return

    env = os.environ.copy()
    env["PYTHONUNBUFFERED"] = "1"
    env.setdefault("MAERIS_CONFIG_DIR", str(_repo_config_dir()))

    try:
        proc = subprocess.Popen(
            args,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            bufsize=0,          # unbuffered — critical for prompt detection
            env=env,
            cwd=cwd,
        )
    except Exception as e:
        await client.complete_command(cmd_id, output="", error="Something went wrong while running this command. Please try again.", success=False)
        click.secho(f"  Spawn error: {e}", fg="red")
        return

    pending_lines: collections.deque = collections.deque()
    stdout_lines: list = []
    stderr_lines: list = []
    stdout_done = threading.Event()
    stderr_done = threading.Event()

    # Prompt pattern: line ends with "? " or ": " (interactive CLI prompts)
    _PROMPT_RE = re.compile(r"[\?:]\s*$")

    def _drain(raw_stream, lines_list, done_evt):
        """Read raw_stream with select() so partial prompt lines are forwarded."""
        fd = raw_stream.fileno()
        buf = b""
        while True:
            try:
                readable, _, _ = _select.select([fd], [], [], 0.3)
            except (ValueError, OSError):
                break
            if readable:
                try:
                    chunk = os.read(fd, 4096)
                except OSError:
                    break
                if not chunk:       # EOF — process closed the pipe
                    break
                buf += chunk
                text = buf.decode("utf-8", errors="replace")
                # Emit all complete lines
                while "\n" in text:
                    line, text = text.split("\n", 1)
                    stripped = line.rstrip()
                    if stripped:
                        lines_list.append(stripped)
                        pending_lines.append(stripped)
                        click.echo(f"    {stripped}")
                buf = text.encode("utf-8")
                # Immediately emit partial line if it looks like a prompt
                stripped = text.rstrip()
                if stripped and _PROMPT_RE.search(stripped):
                    lines_list.append(stripped)
                    pending_lines.append(stripped)
                    click.echo(f"    {stripped}")
                    buf = b""
            else:
                # select() timed out — flush stale prompt from buffer
                if buf:
                    text = buf.decode("utf-8", errors="replace")
                    stripped = text.rstrip()
                    if stripped and _PROMPT_RE.search(stripped):
                        lines_list.append(stripped)
                        pending_lines.append(stripped)
                        click.echo(f"    {stripped}")
                        buf = b""
        # Flush anything left when the stream closes
        if buf:
            stripped = buf.decode("utf-8", errors="replace").rstrip()
            if stripped:
                lines_list.append(stripped)
                pending_lines.append(stripped)
        done_evt.set()

    threading.Thread(target=_drain, args=(proc.stdout, stdout_lines, stdout_done), daemon=True).start()
    threading.Thread(target=_drain, args=(proc.stderr, stderr_lines, stderr_done), daemon=True).start()

    last_flush = time.time()
    while proc.poll() is None:
        # Batch-flush progress lines to backend
        if pending_lines and (time.time() - last_flush >= PROGRESS_BATCH_INTERVAL):
            batch = []
            while pending_lines:
                batch.append(pending_lines.popleft())
            try:
                await client.post_progress(cmd_id, batch)
            except Exception:
                pass
            last_flush = time.time()

        # Feed any user input from the web UI into the process stdin
        try:
            input_line = await client.poll_input(cmd_id)
            if input_line is not None:
                try:
                    proc.stdin.write((input_line + "\n").encode())
                    proc.stdin.flush()
                except OSError:
                    pass
        except Exception:
            pass

        await asyncio.sleep(0.1)

    stdout_done.wait(timeout=5)
    stderr_done.wait(timeout=5)

    # Final flush of any remaining lines
    if pending_lines:
        batch = list(pending_lines)
        pending_lines.clear()
        try:
            await client.post_progress(cmd_id, batch)
        except Exception:
            pass

    success = proc.returncode == 0
    stdout_data = "\n".join(stdout_lines)
    stderr_data = "\n".join(stderr_lines)

    if success:
        await client.complete_command(cmd_id, output=stdout_data, error="", success=True)
        click.secho(f"  Completed {cmd_id[:8]} (success)", fg="green")
    else:
        error_msg = stderr_data or stdout_data or "This command did not complete successfully. Please try again."
        await client.complete_command(cmd_id, output=stdout_data, error=error_msg, success=False)
        click.secho(f"  Completed {cmd_id[:8]} (failed)", fg="red")
