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

import asyncio
import collections
import json
import os
import platform
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 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

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

        done_event = asyncio.Event()
        cancel_check_counter = 0

        async def _flush_progress():
            nonlocal cancel_check_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
                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

        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
    done_event = asyncio.Event()
    cancel_check_counter = 0

    async def _flush_progress():
        nonlocal cancel_check_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

    flush_task = asyncio.create_task(_flush_progress())

    try:
        # Run Claude Code in a thread (it's blocking)
        loop = asyncio.get_event_loop()
        rc, result_text = await 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,
            ),
        )

        # 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.

    Both stdout and stderr are streamed as progress lines so the UI
    shows output in real time instead of waiting until the process ends.
    """
    try:
        args = shlex.split(command)
    except ValueError as e:
        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.DEVNULL,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
            env=env,
            cwd=cwd,
        )

        # Shared queue for progress lines from both stdout and stderr
        pending_lines: collections.deque = collections.deque()
        stdout_lines: list = []       # Keep stdout separately for final output
        stderr_lines: list = []       # Keep stderr separately for error reporting
        stdout_done = threading.Event()
        stderr_done = threading.Event()

        def drain_stdout():
            for line in proc.stdout:
                stripped = line.rstrip()
                if stripped:
                    stdout_lines.append(stripped)
                    pending_lines.append(stripped)
                    click.echo(f"    {stripped}")
            stdout_done.set()

        def drain_stderr():
            for line in proc.stderr:
                stripped = line.rstrip()
                if stripped:
                    stderr_lines.append(stripped)
                    pending_lines.append(stripped)
                    click.echo(f"    {stripped}")
            stderr_done.set()

        threading.Thread(target=drain_stdout, daemon=True).start()
        threading.Thread(target=drain_stderr, daemon=True).start()

        last_flush = time.time()
        while proc.poll() is None:
            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()
            await asyncio.sleep(0.1)

        # Wait for pipe readers to finish
        stdout_done.wait(timeout=5)
        stderr_done.wait(timeout=5)

        # Flush any remaining progress 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:
            # Use stderr for error message; fall back to stdout if stderr is empty
            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")

    except Exception as e:
        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")
