"""Textual TUI application shell."""

from __future__ import annotations

import importlib
import os
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, ClassVar, Protocol

import structlog
from rich.markup import escape as _escape

from cleveragents.a2a.models import A2aRequest
from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.session import (
    SessionActorNotConfiguredError,
    SessionNotFoundError,
)
from cleveragents.tui.first_run import create_default_persona_for_actor, is_first_run
from cleveragents.tui.input.modes import InputMode, InputModeRouter
from cleveragents.tui.input.reference_parser import suggestions
from cleveragents.tui.persona.state import PersonaState
from cleveragents.tui.slash_catalog import slash_command_specs
from cleveragents.tui.widgets.actor_selection_overlay import ActorSelectionOverlay
from cleveragents.tui.widgets.help_panel_overlay import (
    HelpPanelOverlay,
    resolve_help_context,
)
from cleveragents.tui.widgets.persona_bar import PersonaBar
from cleveragents.tui.widgets.prompt import PromptInput
from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay
from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay

if TYPE_CHECKING:
    from cleveragents.a2a.facade import A2aLocalFacade

InputSubmittedEvent = Any

logger = structlog.get_logger(__name__)

# Sentinel session ID used when the real DB session cannot be created.
_FALLBACK_SESSION_ID = "default"
# Session ID key used to look up the active persona before the real session
# exists (persona resolution happens before session creation in run_tui).
_PRE_SESSION_PERSONA_KEY = "default"

# Separator rendered between conversation exchanges in the Static widget.
_TRANSCRIPT_SEPARATOR = "\n\n" + "─" * 48 + "\n\n"


def _run_llm_dispatch(
    facade: A2aLocalFacade,
    session_id: str,
    message: str,
    actor_override: str | None = None,
) -> str:
    """Dispatch *message* to the LLM via the A2A facade and return display text.

    This is a pure module-level function (no Textual dependency) so it can be
    unit-tested directly without a running event loop or mocked Textual.

    Parameters
    ----------
    facade:
        The wired A2A local facade.
    session_id:
        Database-backed session ULID.
    message:
        User message text (with ``@`` references already expanded).
    actor_override:
        When set, overrides the session's default actor for this request.
        Used to pass the active persona's actor on every dispatch so that
        persona cycling (``tab`` / ``action_cycle_preset``) is reflected
        immediately without requiring a session restart.

    Returns a formatted string ready for the conversation widget:
    - On success: ``"You: <message>\\n\\nAssistant: <reply>"``
    - On no-actor / session-not-found / database error: friendly guidance
    - On any other error: ``"[Error] <description>"``

    Note on the catch-all ``except Exception`` branch (marked
    ``# pragma: no cover``): the three specific exception types above cover
    all expected A2A / domain failure modes.  The catch-all is a safety net
    for genuinely unexpected errors (e.g. ``MemoryError`` subclasses that
    ARE ``Exception``); it converts them to a user-visible ``[Error]``
    string so the TUI stays alive rather than crashing.  This branch is
    intentionally excluded from coverage because triggering it requires an
    exception type that is neither a domain error nor a BaseException
    subclass — effectively impossible in the current call chain.
    """
    params: dict[str, Any] = {"session_id": session_id, "message": message}
    if actor_override:
        params["actor"] = actor_override

    try:
        response = facade.dispatch(A2aRequest(method="message/send", params=params))
        if response.error is not None:
            logger.warning(
                "tui.llm_dispatch.a2a_error",
                session_id=session_id,
                error=response.error.message,
            )
            return f"[Error] {response.error.message}"
        result_data: dict[str, Any] = response.result or {}
        assistant_text: str = (
            result_data.get("assistant_message", "") or "(no response)"
        )
        return f"You: {message}\n\nAssistant: {assistant_text}"
    except SessionActorNotConfiguredError:
        logger.info(
            "tui.llm_dispatch.no_actor", session_id=session_id, actor=actor_override
        )
        return "No actor configured. Use /persona set <name> or select an actor first."
    except SessionNotFoundError:
        logger.warning("tui.llm_dispatch.session_not_found", session_id=session_id)
        return (
            "[Error] Session not found. Please restart the TUI to create a new session."
        )
    except DatabaseError as exc:
        logger.exception("tui.llm_dispatch.database_error", session_id=session_id)
        return f"[Error] Database error: {exc}"
    except Exception as exc:  # pragma: no cover
        logger.exception("tui.llm_dispatch.unexpected_error", session_id=session_id)
        return f"[Error] {exc}"


def _format_worker_outcome(
    result: str | None,
    error: BaseException | None,
) -> str | None:
    """Translate a completed Textual worker's outcome into a display string.

    This module-level helper is extracted from the ``_on_llm_done`` closure
    so it can be unit-tested independently of the Textual event loop.

    ``BaseException`` subclasses that are not ``Exception`` (e.g.
    ``KeyboardInterrupt``, ``SystemExit``) are re-raised rather than
    stringified so they can propagate correctly to the Textual runtime.

    Returns
    -------
    str | None
        A string to display in the conversation widget, or ``None`` if there
        is nothing to show (both *result* and *error* are ``None``).
        The ``return None`` branch is marked ``# pragma: no cover`` because
        Textual guarantees either ``result`` or ``error`` is set when the
        worker completes; the branch is unreachable in practice.
    """
    if result is not None:
        return result
    if error is not None:
        if not isinstance(error, Exception):
            raise error  # re-raise KeyboardInterrupt, SystemExit, etc.
        logger.warning("tui.worker.error", error=str(error))
        return f"[Error] {error}"
    return None  # pragma: no cover


_TEXTUAL_AVAILABLE = False
_TextualApp: type[Any] = object
_Vertical: type[Any] = object
_Header: type[Any] = object
_Footer: type[Any] = object
_Static: type[Any] = object
try:  # pragma: no branch - import gate for optional dependency
    _textual_app = importlib.import_module("textual.app")
    _textual_containers = importlib.import_module("textual.containers")
    _textual_widgets = importlib.import_module("textual.widgets")
    _TextualApp = _textual_app.App
    _Vertical = _textual_containers.Vertical
    _Header = _textual_widgets.Header
    _Footer = _textual_widgets.Footer
    _Static = _textual_widgets.Static
    _TEXTUAL_AVAILABLE = True
except Exception:  # pragma: no cover
    _TEXTUAL_AVAILABLE = False


def textual_available() -> bool:
    """Return whether Textual import succeeded."""
    return _TEXTUAL_AVAILABLE


@dataclass(slots=True)
class SessionView:
    """Minimal per-session TUI view model."""

    session_id: str
    transcript: list[str] = field(default_factory=list)


def _strip_pending_reference_token(value: str) -> str:
    """Remove the most recent ``@`` reference trigger (if any).

    This trims whitespace and removes the final token that begins with ``@``. The
    token is removed even when additional text follows it so prompts such as
    ``"draft @README.md summary"`` normalise to ``"draft summary"``. Escaped
    triggers (``\\@``) are preserved to avoid stripping literal ``@``
    characters.
    """

    if not isinstance(value, str):
        return ""

    candidate = value.rstrip()
    if "@" not in candidate:
        return candidate

    last_at = candidate.rfind("@")
    if last_at == -1:
        return candidate

    if last_at > 0 and candidate[last_at - 1] == "\\":
        return candidate

    prefix = candidate[:last_at]
    suffix = candidate[last_at + 1 :]

    token_end = 0
    while token_end < len(suffix) and not suffix[token_end].isspace():
        token_end += 1

    suffix_after = suffix[token_end:]
    prefix_clean = prefix.rstrip()
    suffix_clean = suffix_after.lstrip()

    if prefix_clean and suffix_clean:
        return f"{prefix_clean} {suffix_clean}"
    if prefix_clean:
        return prefix_clean
    if suffix_clean:
        return suffix_clean
    return ""


class _CommandRouter(Protocol):
    """Router protocol used by the TUI input-mode command handler."""

    def handle(self, raw: str, *, session_id: str) -> str:
        """Dispatch a slash command and return a rendered response."""
        ...


class _FallbackCleverAgentsTuiApp:  # pragma: no cover
    """Fallback app that raises actionable dependency error."""

    def __init__(
        self,
        *,
        command_router: _CommandRouter,
        persona_state: PersonaState,
        facade: A2aLocalFacade | None = None,
        session_id: str = _FALLBACK_SESSION_ID,
    ) -> None:
        del command_router, persona_state, facade, session_id

    def run(self) -> None:
        raise RuntimeError(
            "Textual dependency missing. Install with: pip install 'cleveragents[tui]'"
        )


_ResolvedTuiApp: type[Any] = _FallbackCleverAgentsTuiApp
if _TEXTUAL_AVAILABLE:

    class _TextualCleverAgentsTuiApp(_TextualApp):
        """Main TUI app."""

        CSS_PATH: ClassVar[str] = "cleveragents.tcss"
        THEME: ClassVar[str] = "dracula"
        BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
            ("ctrl+q", "quit", "Quit"),
            ("f1", "help", "Help"),
            ("ctrl+t", "cycle_preset", "Cycle Preset"),
            ("escape", "escape", "Close Overlay"),
        ]

        def __init__(
            self,
            *,
            command_router: _CommandRouter,
            persona_state: PersonaState,
            facade: A2aLocalFacade | None = None,
            session_id: str = _FALLBACK_SESSION_ID,
        ) -> None:
            super().__init__()
            self._command_router = command_router
            self._persona_state = persona_state
            self._facade = facade
            self._session = SessionView(session_id=session_id)
            # Monotonically increasing counter — incremented on each dispatch.
            # _on_llm_done checks it to discard callbacks from cancelled workers
            # (exclusive=True cancels in-flight workers; their done_callback still
            # fires and must not corrupt the transcript with stale data).
            self._dispatch_gen: int = 0
            # Cached after mount to avoid repeated query_one() on every submit.
            self._conversation: Any = None

        def compose(self) -> Any:
            yield _Header(show_clock=True)
            with _Vertical(id="main-column"):
                yield _Static("CleverAgents TUI", id="conversation")
                yield HelpPanelOverlay(id="help-panel")
                yield ReferencePickerOverlay(id="reference-picker")
                yield SlashCommandOverlay(id="slash-overlay")
                yield ActorSelectionOverlay(id="actor-selection")
                yield PromptInput(
                    placeholder="Type message, /command, or !shell ...", id="prompt"
                )
                yield PersonaBar(id="persona-bar")
            yield _Footer()

        def on_mount(self) -> None:
            # Check first-run BEFORE _refresh_persona_bar, which calls
            # ensure_default() and would create a persona, masking the check.
            first_run = is_first_run(self._persona_state.registry)
            self._refresh_persona_bar()
            help_panel = self.query_one("#help-panel", HelpPanelOverlay)
            help_panel.hide()
            ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
            ref_picker.set_suggestions("", [])
            slash = self.query_one("#slash-overlay", SlashCommandOverlay)
            slash.set_commands("", slash_command_specs())
            actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay)
            if first_run:
                actor_overlay.show()
            else:
                actor_overlay.hide()
            # Cache the conversation widget to avoid repeated query_one() on
            # every on_input_submitted call.
            self._conversation = self.query_one("#conversation", _Static)
            # Explicitly focus the prompt so keyboard input is received immediately.
            # Textual does not auto-focus any widget at startup; without this call
            # the inner Input never gets focus and typing appears to do nothing.
            prompt = self.query_one("#prompt", PromptInput)
            prompt.focus()

        def _complete_first_run(self, actor: str) -> None:
            """Persist the chosen actor as the default persona and refresh the bar."""
            create_default_persona_for_actor(self._persona_state.registry, actor)
            self._refresh_persona_bar()
            # Refocus prompt after first-run overlay closes.
            prompt = self.query_one("#prompt", PromptInput)
            prompt.focus()

        def action_help(self) -> None:
            prompt = self.query_one("#prompt", PromptInput)
            help_panel = self.query_one("#help-panel", HelpPanelOverlay)
            context_name = resolve_help_context(prompt.value)
            help_panel.toggle(context_name)

        def action_cycle_preset(self) -> None:
            self._persona_state.cycle_preset(self._session.session_id)
            self._refresh_persona_bar()

        def action_escape(self) -> None:
            prompt = self.query_one("#prompt", PromptInput)

            actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay)
            if actor_overlay.visible:
                actor_overlay.hide()
                return

            help_panel = self.query_one("#help-panel", HelpPanelOverlay)
            if help_panel.visible:
                help_panel.hide()
                return

            slash_overlay = self.query_one("#slash-overlay", SlashCommandOverlay)
            if getattr(slash_overlay, "visible", False):
                slash_overlay.hide()
                return

            ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
            if getattr(ref_picker, "visible", False):
                prompt_value = getattr(prompt, "value", "")
                if isinstance(prompt_value, str):
                    prompt.value = _strip_pending_reference_token(prompt_value)
                ref_picker.hide()
                focus_method = getattr(prompt, "focus", None)
                if callable(focus_method):
                    focus_method()
                return

            focus_method = getattr(prompt, "focus", None)
            if callable(focus_method):
                focus_method()

        def _refresh_persona_bar(self) -> None:
            persona = self._persona_state.active_persona(self._session.session_id)
            preset = self._persona_state.current_preset(self._session.session_id)
            scope_count = len(persona.scoped_projects) + len(persona.scoped_plans)
            scope_text = f"{scope_count} scope refs"
            bar = self.query_one("#persona-bar", PersonaBar)
            bar.set_content(
                persona_name=persona.name,
                actor_name=persona.actor,
                preset_name=preset,
                scope_text=scope_text,
            )

        def _render_transcript(
            self, conversation: Any, *, thinking: bool = False
        ) -> None:
            """Render the accumulated transcript into the conversation widget.

            Transcript entries are pre-escaped when stored, so this method
            only needs to join them — avoiding O(N) re-escaping of the full
            history on every render call.

            Accumulates all exchanges so multi-turn conversations remain visible
            as the session grows (spec §29269: scrollable message stream).

            Spec gap (acknowledged, intentional simplification): the spec calls
            for a per-message ``VerticalScroll`` with ``RichLog`` widgets and
            block-cursor navigation (§29269).  This PR uses a single ``_Static``
            widget with accumulated text to keep the scope focused on LLM
            dispatch wiring.  A follow-up task should replace this with a
            ``RichLog``-based implementation for proper scrolling and per-message
            metadata.

            TODO: implement conversation pruning per spec §30492 to bound memory
            usage and rendering latency for long-running sessions.
            """
            # Entries are already escaped when appended — join only.
            entries = list(self._session.transcript)
            if thinking and entries:
                entries[-1] = entries[-1] + "\n\n⏳ Thinking..."
            elif thinking:
                entries = [_escape("⏳ Thinking...")]
            text = (
                _TRANSCRIPT_SEPARATOR.join(entries) if entries else "CleverAgents TUI"
            )
            conversation.update(text)

        def on_input_changed(self, event: InputSubmittedEvent) -> None:
            """Update overlays live as the user types.

            - When text starts with ``/``, filter slash commands by the query
              after the slash so the overlay narrows in real time.
            - When text contains ``@``, extract the token after the last ``@``
              and update the reference picker with matching suggestions.
            - Otherwise reset both overlays to their default (unfiltered) state.
            """
            del event
            prompt = self.query_one("#prompt", PromptInput)
            text = prompt.text
            slash = self.query_one("#slash-overlay", SlashCommandOverlay)
            ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
            stripped = text.lstrip()
            if stripped.startswith("/"):
                query = stripped[1:]
                slash.set_commands(query, slash_command_specs())
                ref_picker.set_suggestions("", [])
            elif "@" in text:
                at_index = text.rfind("@")
                tail = text[at_index + 1 :]
                tokens = tail.split()
                query = tokens[0] if tokens else ""
                ref_picker.set_suggestions(query, suggestions(query))
                slash.set_commands("", slash_command_specs())
            else:
                slash.set_commands("", slash_command_specs())
                ref_picker.set_suggestions("", [])

        def on_input_submitted(self, event: InputSubmittedEvent) -> None:
            del event
            prompt = self.query_one("#prompt", PromptInput)
            payload = prompt.consume_text()
            text = payload.text.strip()
            if not text:
                return

            mode_router = InputModeRouter(
                command_handler=lambda raw: self._command_router.handle(
                    raw, session_id=self._session.session_id
                ),
                shell_confirm=lambda _cmd: (
                    os.environ.get("CLEVERAGENTS_ALLOW_DANGEROUS_SHELL", "").strip()
                    in {"1", "true"}
                ),
            )
            result = mode_router.process(text)
            # Use the cached widget (set in on_mount) to avoid repeated
            # query_one() lookups on every submission.
            conversation = self._conversation or self.query_one(
                "#conversation", _Static
            )

            if result.mode == InputMode.COMMAND:
                conversation.update(result.command_result or "")
                self._refresh_persona_bar()
                return
            if result.mode == InputMode.SHELL:
                shell = result.shell_result
                if shell is None:
                    conversation.update("(no shell output)")
                    return
                output = (
                    shell.stdout.strip() or shell.stderr.strip() or "(empty output)"
                )
                conversation.update(_escape(f"$ {shell.command}\n{output}"))
                return

            expanded = result.expanded_text
            if "@" in text:
                ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
                at_tokens = re.findall(r"@(\S+)", text)
                if at_tokens:
                    query = at_tokens[-1]
                    ref_picker.set_suggestions(query, suggestions(query))

            if self._facade is None:
                # Facade not wired yet — preview only (graceful degradation)
                conversation.update(_escape(expanded))
                return

            # Pre-escape the entry on storage so _render_transcript only joins
            # (avoids O(N) re-escaping of the full history on every render).
            self._session.transcript.append(_escape(f"You: {expanded}"))
            self._render_transcript(conversation, thinking=True)

            # Run blocking LLM call off the main Textual thread so the
            # event loop stays responsive during the API round-trip.
            # exclusive=True serialises dispatches: a new message cancels
            # any in-flight worker, preventing concurrent writes to the
            # same session and conversation widget thrashing.
            # Pass the active persona's actor as an override on every
            # request so persona cycling (tab) takes effect immediately.
            #
            # Note on testability: the composition of run_worker(thread=True,
            # exclusive=True) + done_callback wiring below is intentionally
            # NOT covered by Behave unit tests because it requires a running
            # Textual event loop.  The constituent parts ARE tested in isolation:
            # _run_llm_dispatch() via tui_llm_dispatch.feature, and
            # _format_worker_outcome() via its own scenarios.  Manual
            # verification confirmed the full path works end-to-end.
            facade = self._facade
            session_id = self._session.session_id
            actor = self._persona_state.active_persona(session_id).actor or None
            transcript = self._session.transcript

            # Capture the current generation so the callback can detect if a
            # newer dispatch has superseded this one (exclusive=True cancels the
            # in-flight worker but its done_callback still fires — without this
            # guard the cancelled callback would overwrite transcript[-1] with
            # stale data from the older request).
            self._dispatch_gen += 1
            current_gen = self._dispatch_gen

            def _dispatch_llm() -> str:
                return _run_llm_dispatch(facade, session_id, expanded, actor)

            def _on_llm_done(worker: Any) -> None:
                """Accumulate outcome into transcript and re-render."""
                # Discard callbacks from cancelled workers.
                # worker.is_cancelled is a public bool property on Textual's
                # Worker — no WorkerState import needed (Textual is optional).
                if getattr(worker, "is_cancelled", False):
                    return
                # Also discard if a newer dispatch has already started.
                if self._dispatch_gen != current_gen:
                    return

                outcome = _format_worker_outcome(worker.result, worker.error)
                if outcome is not None and transcript:
                    # Pre-escape and store; replace the placeholder entry.
                    # outcome is "You: {msg}\n\nAssistant: {reply}" on success
                    # or an error string — always prefix with user message so
                    # the exchange is self-contained in the transcript.
                    if outcome.startswith("You: "):
                        transcript[-1] = _escape(outcome)
                    else:
                        transcript[-1] = _escape(f"You: {expanded}\n\n{outcome}")
                self._render_transcript(conversation, thinking=False)

            worker = self.run_worker(
                _dispatch_llm, thread=True, exclusive=True, name="llm-dispatch"
            )
            worker.done_callback = _on_llm_done

    _ResolvedTuiApp = _TextualCleverAgentsTuiApp

CleverAgentsTuiApp = _ResolvedTuiApp
