"""Hermes/OpenClaw-style interactive TUI for NexusOS.

Optional: needs the ``tui`` extra (Textual). Launched by a bare ``nexus`` when
stdin/stdout are a TTY. Classic one-shots (``nexus chat send``, ``nexus monitor``,
``nexus status``, …) stay on the argparse tree.
"""
from __future__ import annotations

import asyncio
import json
import threading
import uuid
from typing import Any

import httpx

from synapse.nexus_config import settings
from synapse.slash_commands import parse_slash_command

from .monitor import collect_snapshot

# Between SSE chunks a silent backend must not pin the UI forever. Connect stays
# short; the overall stream may run minutes.
_STREAM_TIMEOUT = httpx.Timeout(None, connect=5.0, read=120.0, write=30.0, pool=5.0)
_APPROVAL_TIMEOUT = httpx.Timeout(10.0, connect=5.0)


def _require_textual():
    try:
        from textual.app import App
        from textual.binding import Binding
        from textual.widgets import Footer, Header, Input, RichLog, Static
    except ImportError as e:  # pragma: no cover - optional extra
        raise ImportError(
            "The interactive TUI needs the 'tui' extra — "
            "pip install 'nexusos-ai[tui]' (or: pip install textual)."
        ) from e
    return App, Binding, Footer, Header, Input, RichLog, Static


def _escape(text: str) -> str:
    """Make model/user text safe for Rich markup widgets.

    Rich's own escape is the only version that round-trips. Escaping every
    backslash by hand looks equivalent but is not: Rich un-escapes ``\\[`` and
    never collapses ``\\\\``, so doubling them puts the doubles on screen -
    every Windows path and regex escape in a reply renders wrong.

    Imported inside the function so the module still loads without the ``tui``
    extra. Rich is not declared in pyproject: Textual depends on it, so it is
    present whenever the TUI can run at all, and tests/test_packaging_deps.py
    lists it in TRANSITIVE for that reason.
    """
    from rich.markup import escape

    return escape(text)


def format_user_line(message: str) -> str:
    return f"[bold green]you>[/] {_escape(message)}"


def format_assistant_line(text: str) -> str:
    return f"[bold blue]nexus>[/] {_escape(text)}"


def _deny_tool_request(
    *,
    api_url: str,
    conversation_id: str,
    payload: str,
    client_factory=httpx.Client,
) -> list[str]:
    """Immediately deny a TUI action request and let the stream resume.

    The web client presents an approval dialog, but the TUI does not yet have
    that interaction. Denying with the stream's capability token preserves the
    ``ask`` safety boundary without leaving the backend waiting for five minutes.
    """
    request = json.loads(payload)
    token = request.get("token") or ""
    actions = request.get("actions") or []
    names = [
        action.get("name", "")
        for action in actions
        if isinstance(action, dict) and action.get("name")
    ]
    if not token or not names:
        raise ValueError("invalid tool approval request")
    body = {
        "conversation_id": conversation_id,
        "token": token,
        "decisions": {name: False for name in names},
    }
    with client_factory(base_url=api_url, timeout=_APPROVAL_TIMEOUT) as client:
        response = client.post("/chat/approve", json=body)
        response.raise_for_status()
    return names


def _status_line(snap: dict | None = None) -> str:
    """Format a snapshot. Pass ``snap`` — do not omit it on the UI thread."""
    if snap is None:
        snap = collect_snapshot()
    svcs = snap.get("services") or {}
    api = snap.get("api") or {}
    host = snap.get("host") or {}
    parts = [f"NexusOS {snap.get('version', '')}"]
    for key in ("backend", "memory", "provider"):
        info = svcs.get(key) or {}
        if key == "provider":
            up = bool(info.get("reachable"))
        else:
            up = bool(info.get("running"))
        parts.append(f"{key}={'UP' if up else 'DOWN'}")
    if api.get("online"):
        parts.append(f"tools={api.get('action_tool_policy') or '—'}")
    cpu = host.get("cpu_pct")
    if cpu is not None:
        parts.append(f"cpu={cpu:.0f}%")
    chains = snap.get("toolchains") or []
    ready = [c["lang"] for c in chains if c.get("ready")]
    if ready:
        parts.append("run=" + ",".join(ready))
    return " · ".join(parts)


def _compact_status(snap: dict | None = None) -> str:
    """One-line strip for the bar under the chat log."""
    if snap is None:
        snap = collect_snapshot()
    host = snap.get("host") or {}
    api = snap.get("api") or {}
    recent = snap.get("recent_tools") or []
    cpu = host.get("cpu_pct")
    mem = host.get("mem_pct")
    bits = []
    if cpu is not None:
        bits.append(f"cpu {cpu:.0f}%")
    if mem is not None:
        bits.append(f"mem {mem:.0f}%")
    if api.get("online"):
        bits.append(
            f"memories={api.get('memories') if api.get('memories') is not None else '—'} "
            f"chats={api.get('conversations') if api.get('conversations') is not None else '—'}"
        )
    else:
        bits.append("api DOWN — nexus start")
    if recent:
        bits.append("recent " + ", ".join(recent[:4]))
    return "  │  ".join(bits)


class NexusTUI:
    """Factory so Textual imports stay lazy until run()."""

    @staticmethod
    def build_app(*, api_url: str | None = None):
        App, Binding, Footer, Header, Input, RichLog, Static = _require_textual()
        base = (api_url or settings.api_url).rstrip("/")

        class AppImpl(App):
            CSS = """
            Screen { layout: vertical; }
            #status {
                height: 1;
                dock: top;
                background: $boost;
                color: $text;
                padding: 0 1;
            }
            #strip {
                height: 1;
                background: $surface;
                color: $text-muted;
                padding: 0 1;
            }
            #log {
                height: 1fr;
                border: tall $accent;
                padding: 0 1;
            }
            #live {
                height: auto;
                max-height: 8;
                padding: 0 1;
                color: $text;
            }
            #prompt { dock: bottom; }
            """
            BINDINGS = [
                Binding("ctrl+c", "interrupt", "Interrupt", priority=True),
                Binding("ctrl+d", "quit", "Quit", priority=True),
            ]

            def __init__(self):
                super().__init__()
                self.api_url = base
                self.conversation_id: str | None = None
                self.history: list[dict] = []
                self._model: str | None = None
                self._busy = False
                self._stop_stream = threading.Event()
                self._stream_cancel: (
                    tuple[asyncio.AbstractEventLoop, asyncio.Task] | None
                ) = None
                self._status_lock = threading.Lock()
                self._status_pending = False

            def compose(self):
                # Placeholders only — never collect_snapshot() on the UI thread.
                yield Header(show_clock=True)
                yield Static("NexusOS …", id="status")
                yield RichLog(id="log", highlight=True, markup=True, wrap=True)
                yield Static("", id="live")
                yield Static("collecting status…", id="strip")
                yield Input(
                    placeholder="Message Nexus…  (/help for commands)",
                    id="prompt",
                )
                yield Footer()

            def on_mount(self) -> None:
                self.title = "NexusOS"
                self.sub_title = self.api_url
                log = self.query_one("#log", RichLog)
                log.write("[bold]NexusOS[/] interactive TUI")
                log.write(
                    "Type a message and Enter. "
                    "Slash: /help /status /new /model /quit"
                )
                log.write(f"API: {_escape(self.api_url)}")
                log.write("")
                self._schedule_status_refresh()
                self.set_interval(2.0, self._schedule_status_refresh)
                self.query_one("#prompt", Input).focus()

            def _schedule_status_refresh(self) -> None:
                """Kick a worker; never call collect_snapshot on the event loop."""
                with self._status_lock:
                    if self._status_pending:
                        return
                    self._status_pending = True

                def worker():
                    try:
                        snap = collect_snapshot()
                        self._call_ui(self._apply_status, snap)
                    except Exception:
                        pass
                    finally:
                        with self._status_lock:
                            self._status_pending = False

                threading.Thread(target=worker, daemon=True).start()

            def _apply_status(self, snap: dict) -> None:
                self.query_one("#status", Static).update(_status_line(snap))
                self.query_one("#strip", Static).update(_compact_status(snap))

            def _call_ui(self, callback, *args) -> None:
                """call_from_thread, but never after quit (avoids CancelledError
                traceback garbling the restored shell)."""
                if not self.is_running:
                    return
                try:
                    self.call_from_thread(callback, *args)
                except BaseException:
                    # CancelledError is BaseException; also ignore post-exit races.
                    pass

            def _show_error(self, message: str) -> None:
                """Write a stream error to the persistent transcript."""
                self.query_one("#log", RichLog).write(message)

            def _cancel_stream(self) -> None:
                """Cancel the task that owns the socket read.

                Closing a synchronous httpx client from the UI thread does not
                reliably unblock its worker-thread read on macOS. Async task
                cancellation is delivered to the pending read itself.
                """
                self._stop_stream.set()
                cancel = self._stream_cancel
                if cancel is not None:
                    loop, task = cancel
                    loop.call_soon_threadsafe(task.cancel)

            def action_quit(self) -> None:
                self._cancel_stream()
                self.exit()

            def action_interrupt(self) -> None:
                if self._busy:
                    self._cancel_stream()
                    self.query_one("#log", RichLog).write(
                        "[yellow]▸ interrupt requested[/]"
                    )
                else:
                    self.exit()

            def on_input_submitted(self, event: Input.Submitted) -> None:
                text = (event.value or "").strip()
                event.input.value = ""
                if not text:
                    return
                if text.startswith("/"):
                    self._handle_slash(text)
                    return
                if self._busy:
                    self.query_one("#log", RichLog).write(
                        "[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
                    )
                    return
                self._start_chat(text)

            def _handle_slash(self, text: str) -> None:
                log = self.query_one("#log", RichLog)
                cmd, _, rest = text[1:].partition(" ")
                cmd = cmd.lower().strip()
                rest = rest.strip()
                if cmd in ("q", "quit", "exit"):
                    self.exit()
                elif cmd in ("h", "help"):
                    log.write(
                        "[bold]/help[/]   this list\n"
                        "[bold]/status[/] refresh service strip\n"
                        "[bold]/new[/]    fresh conversation\n"
                        "[bold]/model[/] \\[name]  pin model for next turns\n"
                        "[bold]/quit[/]   leave the TUI\n"
                        "One-shot: [dim]nexus chat send \"…\"[/]"
                    )
                elif cmd == "status":
                    self._schedule_status_refresh()
                    log.write("[dim]refreshing status…[/]")
                elif cmd == "new":
                    self.conversation_id = None
                    self.history = []
                    log.write("[bold cyan]— new conversation —[/]")
                elif cmd == "model":
                    if rest:
                        self._model = rest
                        log.write(f"[dim]model pinned:[/] {_escape(rest)}")
                    else:
                        log.write(
                            f"[dim]model:[/] {_escape(self._model or '(auto)')}"
                        )
                elif parse_slash_command(text) is not None:
                    # Shaped like /tool_name(arg=val, ...) rather than one of
                    # the local meta-commands above — not handled here, sent
                    # to the backend as-is. chat_stream_endpoint recognizes
                    # and dispatches it directly (see synapse/slash_commands.py);
                    # a malformed one still goes through so the user sees the
                    # backend's own error, with full context, in one place.
                    if self._busy:
                        log.write(
                            "[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
                        )
                    else:
                        self._start_chat(text)
                else:
                    log.write(
                        f"[red]unknown command[/] /{_escape(cmd)} — try /help"
                    )

            def _start_chat(self, message: str) -> None:
                log = self.query_one("#log", RichLog)
                live = self.query_one("#live", Static)
                log.write(format_user_line(message))
                live.update("[bold blue]nexus>[/] [dim]…[/]")
                self._busy = True
                self._stop_stream.clear()
                if not self.conversation_id:
                    self.conversation_id = str(uuid.uuid4())
                conversation_id = self.conversation_id
                body: dict[str, Any] = {
                    "message": message,
                    "conversation_id": conversation_id,
                    "history": list(self.history),
                }
                if self._model:
                    body["model"] = self._model
                self.history.append({"role": "user", "content": message})

                async def stream_worker():
                    reply_parts: list[str] = []
                    task = asyncio.current_task()
                    loop = asyncio.get_running_loop()
                    if task is None:  # pragma: no cover - asyncio guarantees it
                        raise RuntimeError("stream worker has no task")
                    self._stream_cancel = (loop, task)
                    try:
                        if self._stop_stream.is_set():
                            raise asyncio.CancelledError
                        async with httpx.AsyncClient(
                            base_url=self.api_url, timeout=_STREAM_TIMEOUT
                        ) as client:
                            async with client.stream(
                                "POST", "/chat/stream", json=body
                            ) as resp:
                                if resp.status_code >= 400:
                                    detail = (await resp.aread()).decode(
                                        "utf-8", errors="replace"
                                    )[:300]
                                    self._call_ui(
                                        self._show_error,
                                        f"[red]error HTTP {resp.status_code}[/] "
                                        f"{_escape(detail)}",
                                    )
                                    return
                                event = "message"
                                async for line in resp.aiter_lines():
                                    if self._stop_stream.is_set():
                                        raise asyncio.CancelledError
                                    if line == "":
                                        event = "message"
                                        continue
                                    if line.startswith("event:"):
                                        event = line[6:].strip()
                                        continue
                                    if not line.startswith("data:"):
                                        continue
                                    payload = line[5:].strip()
                                    kind = event
                                    if kind in ("message", ""):
                                        kind = "chunk"
                                        payload = json.loads(payload)
                                    if kind == "chunk":
                                        reply_parts.append(payload)
                                        preview = "".join(reply_parts)
                                        if len(preview) > 4000:
                                            preview = "…" + preview[-4000:]
                                        self._call_ui(
                                            live.update,
                                            format_assistant_line(preview),
                                        )
                                    elif kind == "tool_request":
                                        try:
                                            names = _deny_tool_request(
                                                api_url=self.api_url,
                                                conversation_id=conversation_id,
                                                payload=payload,
                                            )
                                            shown = ", ".join(names)
                                            self._call_ui(
                                                log.write,
                                                "[yellow]▸ denied action tool "
                                                f"{_escape(shown)} — interactive "
                                                "approval is not yet available in "
                                                "the TUI[/]",
                                            )
                                        except Exception as exc:
                                            self._call_ui(
                                                self._show_error,
                                                "[red]tool denial failed:[/] "
                                                f"{_escape(str(exc))}",
                                            )
                                            return
                                    elif kind == "error":
                                        try:
                                            detail = json.loads(payload).get(
                                                "detail", payload
                                            )
                                        except Exception:
                                            detail = payload
                                        self._call_ui(
                                            self._show_error,
                                            f"[red]error:[/] "
                                            f"{_escape(str(detail))}",
                                        )
                                    elif kind == "done":
                                        break
                    except asyncio.CancelledError:
                        pass
                    except httpx.ConnectError:
                        self._call_ui(
                            self._show_error,
                            f"[red]Backend not reachable at "
                            f"{_escape(self.api_url)}. Start it: nexus start[/]",
                        )
                    except Exception as exc:
                        self._call_ui(
                            self._show_error,
                            f"[red]{_escape(type(exc).__name__)}:[/] "
                            f"{_escape(str(exc))}",
                        )
                    finally:
                        if self._stream_cancel == (loop, task):
                            self._stream_cancel = None
                        text = "".join(reply_parts).strip()
                        self._call_ui(self._finish_stream, text)

                threading.Thread(
                    target=lambda: asyncio.run(stream_worker()), daemon=True
                ).start()

            def _finish_stream(self, text: str) -> None:
                log = self.query_one("#log", RichLog)
                live = self.query_one("#live", Static)
                try:
                    if text:
                        log.write(format_assistant_line(text))
                        self.history.append(
                            {"role": "assistant", "content": text}
                        )
                finally:
                    # Always clear busy — a MarkupError must not wedge the TUI.
                    live.update("")
                    self._busy = False
                    self._schedule_status_refresh()

        return AppImpl()


def run_tui(*, api_url: str | None = None) -> int:
    """Run the Textual app. Returns a process exit code."""
    app = NexusTUI.build_app(api_url=api_url)
    app.run()
    return 0
