"""Welcome banner widget."""

from __future__ import annotations

import asyncio
import os
import random
from typing import TYPE_CHECKING, Any

from textual.color import Color as TColor
from textual.content import Content
from textual.style import Style as TStyle
from textual.widgets import Static

if TYPE_CHECKING:
    from textual.events import Click, MouseMove

from deepagents_code import theme
from deepagents_code._env_vars import (
    DANGEROUSLY_OVERRIDE_STARTUP_SUBHEADER,
    HIDE_CWD,
    HIDE_LANGSMITH_TRACING,
    HIDE_SPLASH_TIPS,
    HIDE_SPLASH_VERSION,
    SHOW_LANGSMITH_REPLICA_TRACING,
    is_env_truthy,
)
from deepagents_code._version import __version__
from deepagents_code.config import (
    _get_editable_install_path,
    _is_editable_install,
    fetch_langsmith_project_url,
    get_banner,
    get_glyphs,
    get_langsmith_project_name,
    get_langsmith_replica_project,
)
from deepagents_code.widgets._links import open_style_link

_LANGSMITH_UTM_SOURCE = "deepagents-code"

_TIPS: dict[str, int] = {
    "Use @ to reference files and / for commands": 3,
    "Try /threads to resume a previous conversation": 2,
    "Use /offload when your conversation gets long": 2,
    "Use /copy to copy the latest assistant message": 3,
    "Use /mcp to search your MCP servers and inspect tool parameters": 1,
    "Use /mcp login <server> to authenticate MCP OAuth servers without leaving the TUI": 1,  # noqa: E501
    "Use /remember to save learnings from this conversation": 1,
    "Use /model to switch models mid-conversation": 2,
    "Press ctrl+x to compose prompts in your external editor": 1,
    "Press ctrl+u to delete to the start of the line in the chat input": 1,
    "Use /skill:<name> to invoke a skill directly": 1,
    "Type /update to check for and install updates": 1,
    "Use /install <extra> to add optional dependencies (e.g. /install daytona)": 1,
    "Use /theme to customize the TUI's colors": 1,
    "In /theme, press N to toggle labels/keys, T to set for the current terminal": 1,
    "Use /skill-creator to build reusable agent skills": 1,
    "Ask for a workflow to fan work out to subagents in parallel": 3,
    "Use /auto-update to toggle automatic updates": 1,
    "Use /timestamps to show or hide message timestamp footers": 1,
    "Use /agents to browse and switch between your available agents": 2,
    "In /agents, press Ctrl+S to set the highlighted agent as your default": 1,
    "Press Shift+Tab to toggle auto-approve mode": 2,
    "Use --startup-cmd to run a shell command before the first prompt": 1,
    "Use !! for incognito shell commands that stay out of model context": 1,
    "Deep Agents can explain its own features and look up its docs. Ask it how to use.": 3,  # noqa: E501
}
"""Rotating tips shown in the welcome footer, with relative selection weights.

One is picked per session. Higher weights are picked more often.
"""


def _pick_tip() -> str:
    """Pick a tip from `_TIPS` weighted by its associated weight.

    Returns:
        A single tip string, selected with probability proportional to its
        weight in `_TIPS`.
    """
    tips = list(_TIPS.keys())
    weights = list(_TIPS.values())
    return random.choices(tips, weights=weights, k=1)[0]  # noqa: S311


def _langsmith_project_link(project_url: str) -> str:
    """Append the Deep Agents source tag to a LangSmith project URL.

    Args:
        project_url: LangSmith project URL.

    Returns:
        Project URL with the Deep Agents source tag.
    """
    return f"{project_url}?utm_source={_LANGSMITH_UTM_SOURCE}"


def _langsmith_project_link_style(
    project_url: str,
    *,
    ansi: bool,
    colors: theme.ThemeColors,
) -> TStyle:
    """Build the clickable style for a LangSmith project name.

    Args:
        project_url: LangSmith project URL.
        ansi: Whether the active theme is an ANSI terminal theme.
        colors: Active Deep Agents theme colors.

    Returns:
        Link style for a LangSmith project name.
    """
    link = _langsmith_project_link(project_url)
    if ansi:
        return TStyle(bold=True, link=link)
    return TStyle(foreground=TColor.parse(colors.primary), link=link)


class WelcomeBanner(Static):
    """Welcome banner displayed at startup."""

    # Disable Textual's auto_links to prevent a flicker cycle: Style.__add__
    # calls .copy() for linked styles, generating a fresh random _link_id on
    # each render. This means highlight_link_id never stabilizes, causing an
    # infinite hover-refresh loop.
    auto_links = False

    DEFAULT_CSS = """
    WelcomeBanner {
        height: auto;
        padding: 1;
        margin-bottom: 1;
    }
    """

    def __init__(
        self,
        thread_id: str | None = None,
        mcp_tool_count: int = 0,
        *,
        mcp_unauthenticated: int = 0,
        mcp_errored: int = 0,
        mcp_awaiting_reconnect: int = 0,
        **kwargs: Any,
    ) -> None:
        """Initialize the welcome banner.

        Args:
            thread_id: Optional thread ID to display in the banner.
            mcp_tool_count: Number of MCP tools loaded at startup.
            mcp_unauthenticated: Number of MCP servers awaiting login.
            mcp_errored: Number of MCP servers that failed to load.
            mcp_awaiting_reconnect: Number of MCP servers that completed OAuth
                login but are waiting for `/mcp reconnect` before their tools
                can load.
            **kwargs: Additional arguments passed to parent.
        """
        # Avoid collision with Widget._thread_id (Textual internal int)
        self._cli_thread_id: str | None = thread_id
        self._mcp_tool_count = mcp_tool_count
        self._mcp_unauthenticated = mcp_unauthenticated
        self._mcp_errored = mcp_errored
        self._mcp_awaiting_reconnect = mcp_awaiting_reconnect
        self._idle = False
        self._hide_langsmith_tracing = is_env_truthy(HIDE_LANGSMITH_TRACING)
        self._hide_splash_tips = is_env_truthy(HIDE_SPLASH_TIPS)
        self._project_name: str | None = (
            None if self._hide_langsmith_tracing else get_langsmith_project_name()
        )
        show_replica_tracing = is_env_truthy(
            SHOW_LANGSMITH_REPLICA_TRACING,
            default=True,
        )
        replica_project = (
            get_langsmith_replica_project()
            if self._project_name and show_replica_tracing
            else None
        )
        self._replica_projects: list[str] = [replica_project] if replica_project else []
        self._project_urls: dict[str, str] = {}
        self._tip: str | None = None if self._hide_splash_tips else _pick_tip()

        super().__init__(self._build_banner(), **kwargs)

    def on_mount(self) -> None:
        """Kick off background fetch for LangSmith project URL."""
        self.watch(self.app, "theme", self._on_theme_change, init=False)
        if self._project_name:
            self.run_worker(self._fetch_and_update, exclusive=True)

    def _on_theme_change(self) -> None:
        """Re-render the banner when the app theme changes."""
        self.update(self._build_banner())

    async def _fetch_and_update(self) -> None:
        """Fetch the LangSmith URL in a thread and update the banner."""
        if not self._project_name:
            return
        primary = self._project_name
        project_urls: dict[str, str] = {}
        projects = dict.fromkeys([primary, *self._replica_projects])
        for project in projects:
            try:
                project_url = await asyncio.wait_for(
                    asyncio.to_thread(fetch_langsmith_project_url, project),
                    timeout=2.0,
                )
            except (TimeoutError, OSError):
                project_url = None
            if project_url:
                project_urls[project] = project_url
                self._project_urls = dict(project_urls)
                self.update(self._build_banner())

    def update_thread_id(self, thread_id: str) -> None:
        """Update the displayed thread ID and re-render the banner.

        Args:
            thread_id: The new thread ID to display.
        """
        self._cli_thread_id = thread_id
        self.update(self._build_banner())

    def set_connected(
        self,
        mcp_tool_count: int = 0,
        *,
        mcp_unauthenticated: int = 0,
        mcp_errored: int = 0,
        mcp_awaiting_reconnect: int = 0,
    ) -> None:
        """Render the ready banner footer after a successful connect.

        The status bar owns visible connection progress; this just refreshes
        the banner's tool counts and ready footer once the server is reachable.

        Args:
            mcp_tool_count: Number of MCP tools loaded during connection.
            mcp_unauthenticated: Number of MCP servers awaiting login.
            mcp_errored: Number of MCP servers that failed to load.
            mcp_awaiting_reconnect: Number of MCP servers that completed OAuth
                login but are waiting for `/mcp reconnect` before their tools
                can load.
        """
        self._idle = False
        self._mcp_tool_count = mcp_tool_count
        self._mcp_unauthenticated = mcp_unauthenticated
        self._mcp_errored = mcp_errored
        self._mcp_awaiting_reconnect = mcp_awaiting_reconnect
        self.update(self._build_banner())

    def set_connecting(self) -> None:
        """Render the regular banner footer during a reconnect.

        The status bar owns visible connection progress. This method only
        ensures the banner is no longer in the idle failure state.
        """
        self._idle = False
        self.update(self._build_banner())

    def set_idle(self) -> None:
        """Transition to a neutral state with no footer.

        Used after a fatal startup failure so the banner stops claiming
        progress (the failure is communicated via the chat surface). The
        banner keeps its identity rows (title, version, install path,
        LangSmith project, thread ID) but appends no footer line, leaving
        the chat error as the sole source of failure context.
        """
        self._idle = True
        self.update(self._build_banner())

    def on_click(self, event: Click) -> None:  # noqa: PLR6301  # Textual event handler
        """Open style-embedded hyperlinks on single click."""
        open_style_link(event)

    def on_mouse_move(self, event: MouseMove) -> None:
        """Show a hand pointer over link spans and reset it elsewhere.

        `auto_links` is disabled to avoid a hover-refresh flicker, so the
        pointer shape is updated manually from the style under the cursor.
        """
        self.styles.pointer = "pointer" if event.style.link else "default"

    def on_leave(self) -> None:
        """Reset the pointer shape when the mouse leaves the banner."""
        self.styles.pointer = "default"

    def _primary_project_url(
        self,
        project_urls: dict[str, str] | None = None,
    ) -> str | None:
        """Get the resolved LangSmith URL for the primary tracing project.

        Args:
            project_urls: Optional project URL mapping to use instead of cached
                widget state.

        Returns:
            Primary project URL when resolved, otherwise `None`.
        """
        if not self._project_name:
            return None
        urls = self._project_urls if project_urls is None else project_urls
        return urls.get(self._project_name)

    def _build_banner(
        self,
        project_urls: dict[str, str] | None = None,
    ) -> Content:
        """Build the banner content.

        When the primary project URL is resolved and a thread ID is set, the
        thread ID is rendered as a clickable hyperlink to the LangSmith thread
        view.

        Args:
            project_urls: LangSmith project URLs keyed by project name. Project
                names with resolved URLs are rendered as links. When `None`,
                cached widget state is used.

        Returns:
            Content object containing the formatted banner.
        """
        parts: list[str | tuple[str, str | TStyle] | Content] = []
        project_urls = self._project_urls if project_urls is None else project_urls
        project_url = self._primary_project_url(project_urls)
        colors = theme.get_theme_colors(self)
        ansi = self.app.theme in {"ansi-dark", "ansi-light"}

        banner = get_banner()
        primary_style: str | TStyle = (
            "bold"
            if ansi
            else TStyle(foreground=TColor.parse(colors.primary), bold=True)
        )

        hide_version = is_env_truthy(HIDE_SPLASH_VERSION)
        if not hide_version and not ansi and _is_editable_install():
            # Highlight local-install version tag with tool accent; art stays primary.
            dev_style = TStyle(foreground=TColor.parse(colors.tool), bold=True)
            version_tag = f"v{__version__} (local)"
            idx = banner.rfind(version_tag)
            if idx >= 0:
                parts.extend(
                    [
                        (banner[:idx], primary_style),
                        (version_tag, dev_style),
                        (banner[idx + len(version_tag) :] + "\n", primary_style),
                    ]
                )
            else:
                parts.append((banner + "\n", primary_style))
        else:
            parts.append((banner + "\n", primary_style))

        # For ANSI theme, use "bold" (terminal foreground) instead of hex
        accent: str | TStyle = "bold" if ansi else colors.primary
        success_color: str = "bold green" if ansi else colors.success

        hide_editable_path = hide_version or is_env_truthy(HIDE_CWD)
        editable_path = None if hide_editable_path else _get_editable_install_path()
        if editable_path:
            parts.extend([("Installed from: ", "dim"), (editable_path, "dim"), "\n"])

        if self._project_name:
            parts.extend(
                [
                    (f"{get_glyphs().checkmark} ", success_color),
                    "LangSmith tracing: ",
                ]
            )
            if project_url:
                parts.append(
                    (
                        f"'{self._project_name}'",
                        _langsmith_project_link_style(
                            project_url,
                            ansi=ansi,
                            colors=colors,
                        ),
                    )
                )
            else:
                parts.append((f"'{self._project_name}'", accent))
            parts.append("\n")
            if self._replica_projects:
                # `_replica_projects` holds at most one entry today (the server
                # mirrors to a single extra project), but the loop renders any
                # number so the splash stays correct if that limit is lifted.
                parts.append(("  Also tracing to: ", "dim"))
                for idx, name in enumerate(self._replica_projects):
                    if idx:
                        parts.append((", ", "dim"))
                    parts.append(("'", "dim"))
                    replica_url = project_urls.get(name)
                    if replica_url:
                        parts.append(
                            (
                                name,
                                _langsmith_project_link_style(
                                    replica_url,
                                    ansi=ansi,
                                    colors=colors,
                                ),
                            )
                        )
                    else:
                        parts.append((name, "dim"))
                    parts.append(("'", "dim"))
                parts.append("\n")

        if self._cli_thread_id and not self._hide_langsmith_tracing:
            if project_url:
                thread_url = (
                    f"{project_url.rstrip('/')}/t/{self._cli_thread_id}"
                    "?utm_source=deepagents-code"
                )
                parts.extend(
                    [
                        ("  Thread: ", "dim"),
                        (self._cli_thread_id, TStyle(dim=True, link=thread_url)),
                        ("\n", "dim"),
                    ]
                )
            else:
                parts.append((f"  Thread: {self._cli_thread_id}\n", "dim"))

        if self._mcp_tool_count > 0:
            parts.append((f"{get_glyphs().checkmark} ", success_color))
            label = "MCP tool" if self._mcp_tool_count == 1 else "MCP tools"
            parts.append(f"Loaded {self._mcp_tool_count} {label}\n")

        warn_color: str = "bold yellow" if ansi else colors.warning
        if self._mcp_unauthenticated > 0:
            server_label = "server" if self._mcp_unauthenticated == 1 else "servers"
            verb = "needs" if self._mcp_unauthenticated == 1 else "need"
            unauth_text = (
                f"{self._mcp_unauthenticated} MCP {server_label} {verb} login "
                "— open /mcp\n"
            )
            parts.extend(
                [
                    (f"{get_glyphs().warning} ", warn_color),
                    (unauth_text, "dim"),
                ]
            )
        if self._mcp_errored > 0:
            server_label = "server" if self._mcp_errored == 1 else "servers"
            errored_text = (
                f"{self._mcp_errored} MCP {server_label} failed to load "
                "— open /mcp for details\n"
            )
            parts.extend(
                [
                    (f"{get_glyphs().warning} ", warn_color),
                    (errored_text, "dim"),
                ]
            )
        if self._mcp_awaiting_reconnect > 0:
            server_label = "server" if self._mcp_awaiting_reconnect == 1 else "servers"
            awaiting_text = (
                f"{self._mcp_awaiting_reconnect} MCP {server_label} ready to load "
                "— run `/mcp reconnect`\n"
            )
            parts.extend(
                [
                    (f"{get_glyphs().warning} ", warn_color),
                    (awaiting_text, "dim"),
                ]
            )

        if not self._idle:
            ready_color = "bold" if ansi else colors.primary
            parts.append(
                build_welcome_footer(
                    primary_color=ready_color,
                    tip=self._tip,
                    show_tip=not self._hide_splash_tips,
                )
            )
        # `_idle` means no footer; chat-surface owns the failure message.
        return Content.assemble(*parts)


def build_welcome_footer(
    *,
    primary_color: str = theme.PRIMARY,
    tip: str | None = None,
    show_tip: bool | None = None,
) -> Content:
    """Build the footer shown at the bottom of the welcome banner.

    Includes a tip to help users discover features unless tips are disabled.

    Args:
        primary_color: Color string for the ready prompt.

            Defaults to the module-level ANSI `PRIMARY` constant; widget callers
            should pass the active theme's hex value.
        tip: Tip text to display. When `None`, a random tip is selected.

            Pass an explicit value to keep the tip stable across re-renders.
        show_tip: Whether to show the tip. When `None`, the startup splash tips
            env var controls visibility.

    Returns:
        Content with the ready prompt and, when enabled, a tip.
    """
    if show_tip is None:
        show_tip = not is_env_truthy(HIDE_SPLASH_TIPS)
    if show_tip and tip is None:
        tip = _pick_tip()
    subheader = (
        os.environ.get(DANGEROUSLY_OVERRIDE_STARTUP_SUBHEADER)
        or "Ready to code! What would you like to build?"
    )
    parts: list[tuple[str, str]] = [(f"\n{subheader}", primary_color)]
    if show_tip and tip is not None:
        parts.append((f"\nTip: {tip}", "dim italic"))
    return Content.assemble(*parts)
