"""Root Textual application — 60/40 split-pane installer + agent chat.

Phase 5+: Agent Johnny 5 with MCP, UI polish, and agent recovery tools.

Concurrency boundaries:
  - Installer (StepRunner): @work(exclusive=True) async on the main loop.
    Uses asyncio.create_subprocess_shell — truly non-blocking.
  - Agent (AgentBrain): @work(thread=True) in a BACKGROUND THREAD.
    Uses httpx.Client (sync) for streaming, subprocess.run for tools.
    MCP connection established lazily in the agent thread.
    All UI updates routed via app.call_from_thread().

Input strategy (v0.7.7 — raw key capture):
  Textual's Input widget does NOT work in web terminals (Hostinger,
  etc.). We capture ALL keystrokes at the App level via on_key() and
  maintain a simple text buffer. This uses the same event pathway as
  ctrl+c (proven to work), bypassing Textual's widget input entirely.

Orphan protection:
  Three-layer failsafe prevents zombie processes when SSH disconnects:
  1. SIGHUP handler — immediate exit on terminal hangup signal
  2. Terminal watchdog — polls /dev/tty every 30s, exits if gone
  3. Max runtime — hard exit after 2 hours (no install takes that long)
"""

from __future__ import annotations

import asyncio
import logging
import os
import signal
import sys

# ── Redirect ALL output to a log file ──
# Textual owns the terminal — ANY write to stdout/stderr from Python
# logging, print(), library warnings, or uncaught tracebacks will
# corrupt the TUI rendering (text leaks at top edge) and break input
# handling (frozen prompt). We redirect everything to a file.
logging.basicConfig(
    filename="easyclaw-installer.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
    force=True,
)


class _StreamToLogger:
    """File-like object that redirects writes to a logging instance."""

    def __init__(self, logger: logging.Logger, level: int) -> None:
        self._logger = logger
        self._level = level

    def write(self, buf: str) -> None:
        for line in buf.rstrip().splitlines():
            self._logger.log(self._level, line.rstrip())

    def flush(self) -> None:
        pass


# Redirect stderr only — catches warnings, tracebacks, library errors.
# Do NOT redirect stdout: Textual uses it to render the TUI to the terminal.
sys.stderr = _StreamToLogger(logging.getLogger("STDERR"), logging.ERROR)  # type: ignore[assignment]

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual import work

from .messages import (
    AgentReply,
    AgentToolCall,
    AgentTyping,
    LogLine,
    StepStarted,
    StepCompleted,
    InstallFinished,
)
from .state import InstallState
from .constants import BUILTIN_API_KEY, OPENROUTER_MODEL
from .installer.runner import StepRunner
from .agent.brain import AgentBrain
from .ui.log_pane import LogPane
from .ui.chat_pane import ChatPane
from .ui.status_bar import StatusBar


class EasyClawApp(App):
    """EasyClaw Agentic Installer — Agent Johnny 5 + MCP + recovery."""

    # Disable command palette (not useful for this app)
    ENABLE_COMMAND_PALETTE = False

    CSS = """
    Screen {
        layout: horizontal;
    }

    #log-pane {
        width: 3fr;
        height: 1fr;
        border-right: thick #444444;
    }

    #chat-pane {
        width: 2fr;
        height: 1fr;
    }

    /* No focusable widgets — suppress all focus rings */
    *:focus {
        border: none;
    }
    """

    TITLE = "EasyClaw Agentic Installer"
    SUB_TITLE = "Agent Johnny 5"

    BINDINGS = [
        Binding("ctrl+c", "quit", "Quit", priority=True),
    ]

    def __init__(
        self, api_key: str = "", mcp_url: str = "",
        mcp_key: str = "", **kwargs
    ) -> None:
        super().__init__(**kwargs)
        self.state = InstallState()
        self.step_runner = StepRunner(self.state, self)

        # Raw key capture input buffer (replaces Textual Input widget)
        self._input_buffer: str = ""

        # Resolve API key: explicit arg > env var > built-in key (Kimi K2.5)
        resolved_key = (
            api_key
            or os.environ.get("OPENROUTER_API_KEY", "")
            or BUILTIN_API_KEY
        )
        # Resolve MCP key: explicit arg > env var > empty
        resolved_mcp_key = (
            mcp_key
            or os.environ.get("EASYCLAW_MCP_KEY", "")
        )

        self._api_key = resolved_key
        self._model = OPENROUTER_MODEL
        self._mcp_url = mcp_url
        self._mcp_key = resolved_mcp_key

        self.agent = AgentBrain(
            self.state, resolved_key, mcp_url=mcp_url,
            mcp_key=resolved_mcp_key, model=OPENROUTER_MODEL,
        )

    # ------------------------------------------------------------------
    # Compose
    # ------------------------------------------------------------------

    def compose(self) -> ComposeResult:
        yield LogPane(id="log-pane")
        yield ChatPane(id="chat-pane")
        yield StatusBar(id="status-bar")

    # ------------------------------------------------------------------
    # Raw key capture — replaces Textual Input widget
    # ------------------------------------------------------------------

    def on_key(self, event) -> None:
        """Capture ALL keystrokes and route to the chat input buffer.

        This completely replaces Textual's Input widget. We maintain a
        simple string buffer, handle printable chars / Enter / Backspace,
        and update a Static display widget in the ChatPane.

        Why: Textual's Input widget requires terminal escape sequences
        for cursor positioning that web terminals (Hostinger, etc.) don't
        relay correctly. This raw approach uses the same key event system
        that makes ctrl+c work — proven compatible with all terminals.
        """
        key = event.key
        char = event.character

        # Enter → submit the message to Agent Johnny 5
        if key == "enter":
            text = self._input_buffer.strip()
            if text:
                chat_pane = self.query_one("#chat-pane", ChatPane)
                chat_pane.add_user_message(text)
                self.send_to_agent(text)
            self._input_buffer = ""
            self._update_input_display()
            event.prevent_default()
            event.stop()
            return

        # Backspace → delete last character
        if key == "backspace":
            if self._input_buffer:
                self._input_buffer = self._input_buffer[:-1]
                self._update_input_display()
            event.prevent_default()
            event.stop()
            return

        # Escape → clear the entire input buffer
        if key == "escape":
            if self._input_buffer:
                self._input_buffer = ""
                self._update_input_display()
            event.prevent_default()
            event.stop()
            return

        # Printable character → append to buffer
        if char and char.isprintable():
            self._input_buffer += char
            self._update_input_display()
            event.prevent_default()
            event.stop()
            return

    def _update_input_display(self) -> None:
        """Push the current input buffer text to the chat pane display."""
        try:
            chat_pane = self.query_one("#chat-pane", ChatPane)
            chat_pane.update_input_display(self._input_buffer)
        except Exception:
            pass

    # ------------------------------------------------------------------
    # Orphan protection — prevent zombie processes on SSH disconnect
    # ------------------------------------------------------------------

    # Max runtime in seconds (2 hours — no install takes longer)
    _MAX_RUNTIME = 7200

    def _setup_orphan_protection(self) -> None:
        """Three-layer failsafe against orphaned processes.

        When SSH disconnects (browser tab closed, network drop, etc.)
        the Textual app loses its terminal but keeps running — spinning
        the event loop and burning CPU indefinitely.

        Layer 1: SIGHUP — Linux sends this on terminal hangup. Instant.
        Layer 2: /dev/tty watchdog — catches cases where SIGHUP is
                 missed or swallowed. Polls every 30 seconds.
        Layer 3: Max runtime — hard 2-hour ceiling. No install should
                 ever take that long.
        """
        # Layer 1: SIGHUP handler (Linux/macOS only)
        if hasattr(signal, "SIGHUP"):
            signal.signal(signal.SIGHUP, self._on_terminal_hangup)

        # Layer 2: Terminal watchdog — check /dev/tty every 30 seconds
        self.set_interval(30, self._check_terminal_alive)

        # Layer 3: Max runtime safety net
        self.set_timer(self._MAX_RUNTIME, self._on_max_runtime)

    @staticmethod
    def _on_terminal_hangup(signum: int, frame: object) -> None:
        """SIGHUP received — terminal disconnected. Exit immediately."""
        # os._exit() bypasses Textual cleanup (which would hang on a
        # dead terminal) and kills all threads/subprocesses.
        os._exit(0)

    def _check_terminal_alive(self) -> None:
        """Verify the controlling terminal still exists.

        Opens /dev/tty — the process's controlling terminal. If the SSH
        session is gone and the pty was destroyed, this raises OSError
        and we exit. This catches edge cases where SIGHUP wasn't sent
        (e.g., nohup, some container runtimes).
        """
        try:
            fd = os.open("/dev/tty", os.O_RDONLY | os.O_NOCTTY)
            os.close(fd)
        except OSError:
            # Terminal is gone — exit hard to avoid CPU burn
            os._exit(0)

    def _on_max_runtime(self) -> None:
        """Safety net: force exit after max runtime (2 hours)."""
        os._exit(0)

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def on_mount(self) -> None:
        """Start the installer, greet the user, and init MCP."""
        # Disable mouse tracking — web terminals (Hostinger, etc.) don't
        # handle Textual's SGR mouse protocol and render escape codes as
        # garbage text like ^[[<35;150;37M across the screen.
        try:
            driver = self._driver
            if driver and hasattr(driver, "_disable_mouse_support"):
                driver._disable_mouse_support()
        except Exception:
            pass

        # Orphan protection — prevents zombie processes on SSH disconnect
        self._setup_orphan_protection()

        # Store main event loop for agent thread -> main loop scheduling
        self.state.main_loop = asyncio.get_event_loop()

        self.run_installer()

        # Show agent greeting in chat
        chat_pane = self.query_one("#chat-pane", ChatPane)
        chat_pane.add_agent_message(
            "I'm Johnny 5, your AI assistant. "
            "I can see the install logs and help troubleshoot. "
            "Ask me anything — just type and press Enter!"
        )

        # Kick off MCP connection in background thread
        if self._mcp_url:
            self.init_agent_mcp()

    # ------------------------------------------------------------------
    # Installer worker (async on main loop)
    # ------------------------------------------------------------------

    @work(exclusive=True)
    async def run_installer(self) -> None:
        """Drive the StepRunner on the main async loop."""
        await self.step_runner.run_all_steps()

    @work(exclusive=False)
    async def run_resume_pipeline(self, from_step: int) -> None:
        """Resume the installation pipeline from a specific step."""
        await self.step_runner.resume_from(from_step)

    # ------------------------------------------------------------------
    # Agent worker (BACKGROUND THREAD — critical boundary)
    # ------------------------------------------------------------------

    @work(thread=True)
    def init_agent_mcp(self) -> None:
        """Connect to MCP knowledge base in a background thread.

        Non-blocking — the UI starts immediately. MCP tools become
        available once the handshake completes (1-2 seconds).
        """
        self.agent.ensure_mcp(self)

    @work(thread=True, exclusive=True, group="agent-loop")
    def send_to_agent(self, user_text: str) -> None:
        """Run the agent loop in a background thread.

        Uses httpx.Client (sync) for streaming, subprocess.run for
        tools. All UI updates via call_from_thread().

        exclusive=True in group "agent-loop" ensures only one agent
        thread runs at a time — prevents concurrent conversation
        corruption when proactive interventions and user messages
        overlap.
        """
        self.agent.handle_user_message(user_text, self)

    @work(thread=True, exclusive=True, group="agent-loop")
    def agent_proactive_intervention(
        self, step_name: str, error: str
    ) -> None:
        """Agent auto-diagnoses a failed step (background thread).

        Same exclusive group as send_to_agent — if the agent is already
        running (e.g. processing a user message that triggered
        resume_pipeline), this worker will cancel the previous one and
        start fresh with the failure context.
        """
        self.agent.handle_step_failure(step_name, error, self)

    # ------------------------------------------------------------------
    # Message handlers
    # ------------------------------------------------------------------

    def on_log_line(self, message: LogLine) -> None:
        """Route a log line to the left pane."""
        log_pane = self.query_one("#log-pane", LogPane)
        log_pane.append_line(message.text, is_stderr=message.is_stderr)

    def on_step_started(self, message: StepStarted) -> None:
        """Update the status bar when a step begins."""
        status = self.query_one("#status-bar", StatusBar)
        status.update_step(message.index, message.name, message.total)

    def on_step_completed(self, message: StepCompleted) -> None:
        """Record step result, show in log, trigger agent on failure."""
        # Track result in state
        self.state.step_results.append((message.name, message.success))

        # Update status bar counts
        status = self.query_one("#status-bar", StatusBar)
        status.record_step_result(message.success)

        # Color-coded result in log pane
        log_pane = self.query_one("#log-pane", LogPane)
        log_pane.append_step_result(
            message.index,
            message.name,
            self.state.total_steps,
            message.success,
            message.error,
        )

        # On failure, trigger agent proactive intervention
        if not message.success:
            self.agent_proactive_intervention(message.name, message.error)

    def on_install_finished(self, message: InstallFinished) -> None:
        """Update the status bar with final result."""
        status = self.query_one("#status-bar", StatusBar)
        status.show_final(message.success)

    def on_agent_typing(self, message: AgentTyping) -> None:
        """Show typing indicator in chat."""
        chat_pane = self.query_one("#chat-pane", ChatPane)
        chat_pane.show_typing()

    def on_agent_reply(self, message: AgentReply) -> None:
        """Route agent's final reply to the chat pane."""
        chat_pane = self.query_one("#chat-pane", ChatPane)
        chat_pane.add_agent_message(message.text)

    def on_agent_tool_call(self, message: AgentToolCall) -> None:
        """Show tool-call indicator in chat."""
        chat_pane = self.query_one("#chat-pane", ChatPane)
        chat_pane.add_tool_indicator(message.tool_name, message.args)
