"""Interactive TUI explorer for topology frames and signals."""

from __future__ import annotations

import collections
import json
from datetime import datetime
from typing import Any, Iterable, Optional

from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
from textual.widgets import Footer, Header, Input, Static, Tree
from textual.widgets.tree import TreeNode
from textual_plotext import PlotextPlot

from remotivelabs.cli.search.base import SearchOptions
from remotivelabs.cli.search.query import ParsedQuery, Query
from remotivelabs.cli.search.sqlite.index import SQLiteSearch

_GRPC_SOURCE = "grpc"
_ALL_ROWS_QUERY = Query(ParsedQuery(terms=[], active_term=None))
_CHART_WINDOW_SECS = 30


def _build_tree_data(rows: list[dict[str, Any]]) -> dict[str, dict[str, list[dict[str, Any]]]]:
    """Group flat search rows into namespace → frame → [signal rows]."""
    tree: dict[str, dict[str, list[dict[str, Any]]]] = {}
    frames: dict[tuple[str, str], dict[str, Any]] = {}

    for row in rows:
        ns = row.get("namespace") or "(unknown)"
        if ns not in tree:
            tree[ns] = {}
        if row.get("type") == "frame":
            frame_name = row["frame_name"]
            frames[(ns, frame_name)] = row
            if frame_name not in tree[ns]:
                tree[ns][frame_name] = []
        elif row.get("type") == "signal":
            frame_name = row.get("frame_name") or "(no frame)"
            if frame_name not in tree[ns]:
                tree[ns][frame_name] = []
            tree[ns][frame_name].append(row)

    # Attach frame rows as metadata under a special key so detail pane can use them
    for (ns, frame_name), frame_row in frames.items():
        if ns in tree and frame_name in tree[ns]:
            # Store frame row at index -1 convention: we use node.data directly
            _ = frame_row  # accessed via node.data on the frame node itself

    return tree


def _is_present(value: Any) -> bool:
    return value is not None and value not in ("", [])


def _format_signal_fields(row: dict[str, Any], _add: Any) -> None:
    _add("Frame", row.get("frame_name"))
    _add("Start bit", row.get("start_bit"))
    _add("Length (bits)", row.get("size"))
    _add("Factor", row.get("factor"))
    _add("Offset", row.get("offset"))
    mn, mx = row.get("min"), row.get("max")
    if mn is not None or mx is not None:
        _add("Min / Max", f"{mn} / {mx}")
    signed = row.get("is_signed")
    if signed is not None:
        _add("Signed", "Yes" if signed else "No")
    big_endian = row.get("is_big_endian")
    if big_endian is not None:
        _add("Big endian", "Yes" if big_endian else "No")
    if row.get("is_ieee_floating_type"):
        _add("IEEE float", "Yes")
    if row.get("is_raw"):
        _add("Raw signal", "Yes")


def _format_named_values(row: dict[str, Any], lines: list[str]) -> None:
    nv_json = row.get("named_values_json")
    if not nv_json:
        return
    try:
        nv = json.loads(nv_json)
        if nv:
            lines.append("")
            lines.append("[dim]Named values:[/dim]")
            for k, v in nv.items():
                lines.append(f"  [yellow]{k}[/yellow] = {v}")
    except (json.JSONDecodeError, AttributeError):
        pass


def _format_detail(row: dict[str, Any]) -> str:
    """Format a signal or frame row dict as a Rich markup string."""
    kind = row.get("type", "signal").capitalize()
    name = row.get("signal_name") or row.get("frame_name") or ""
    ns = row.get("namespace") or ""
    lines: list[str] = [f"[bold]{kind}:[/bold] [cyan]{name}[/cyan]", ""]

    def _add(label: str, value: Any) -> None:
        if _is_present(value):
            lines.append(f"[dim]{label:<18}[/dim] {value}")

    _add("Namespace", ns)
    _add("Description", row.get("description"))
    _add("Unit", row.get("unit"))

    if row.get("type") == "signal":
        _format_signal_fields(row, _add)
    else:
        _add("Payload size", row.get("payload_size"))
        _add("Cycle time", row.get("cycle_time"))

    _add("Senders", row.get("senders"))
    _add("Receivers", row.get("receivers"))
    _format_named_values(row, lines)

    return "\n".join(lines)


class _StatusBar(Static):
    """One-line status message shown above the tree."""

    DEFAULT_CSS = """
    _StatusBar {
        height: 1;
        padding: 0 1;
        color: $text-muted;
    }
    """


class SignalsExplorer(App[None]):
    """Interactive TUI explorer for topology frames and signals."""

    TITLE = "Topology Signal Explorer"
    CSS = """
    Screen {
        layout: vertical;
    }

    #search-input {
        dock: top;
        height: 3;
        margin: 0 0;
    }

    #main-area {
        layout: horizontal;
        height: 1fr;
    }

    #left-pane {
        width: 40%;
        border-right: tall $primary-darken-2;
    }

    #right-pane {
        width: 60%;
        layout: vertical;
    }

    #detail-container {
        padding: 1 2;
        height: auto;
        max-height: 50%;
    }

    #detail-panel {
        width: 100%;
    }

    #signal-chart {
        height: 1fr;
        display: none;
    }

    #signal-chart.visible {
        display: block;
    }

    #status-bar {
        height: 1;
        padding: 0 1;
        color: $text-muted;
        background: $surface-darken-1;
        dock: bottom;
    }
    """

    BINDINGS = [
        Binding("q", "quit", "Quit", priority=True),
        Binding("r", "reindex", "Re-index"),
        Binding("e", "expand_all", "Expand all"),
        Binding("c", "collapse_all", "Collapse all"),
        Binding("/", "focus_search", "Search"),
        Binding("escape", "clear_search", "Clear search", show=False),
        Binding("s", "subscribe", "Subscribe"),
    ]

    def __init__(self, broker_url: str, api_key: Optional[str], index: SQLiteSearch) -> None:
        super().__init__()
        self._broker_url = broker_url
        self._api_key = api_key
        self._index = index
        self._current_filter = ""
        self._frames_expanded = False
        self._subscription: Any = None
        self._broker: Any = None
        self._chart_values: collections.deque[tuple[int, float]] = collections.deque()
        self._current_signal_row: dict[str, Any] | None = None

    def compose(self) -> ComposeResult:
        yield Header()
        yield Input(placeholder="/ Search frames and signals...", id="search-input")
        with Horizontal(id="main-area"):
            with VerticalScroll(id="left-pane"):
                yield Tree("Namespaces", id="signal-tree")
            with Vertical(id="right-pane"):
                with ScrollableContainer(id="detail-container"):
                    yield Static("", id="detail-panel", markup=True)
                yield PlotextPlot(id="signal-chart")
        yield Static("Indexing signals from broker...", id="status-bar")
        yield Footer()

    def on_mount(self) -> None:
        self.run_worker(self._do_index, exclusive=True, thread=True)

    def _do_index(self) -> None:
        self._set_status("Indexing signals from broker...")
        try:
            count = self._index.index_grpc_signals(self._broker_url, self._api_key)
            self._set_status(f"Indexed {count} entries from {self._broker_url}")
        except Exception as exc:
            self._set_status(f"[red]Index error:[/red] {exc}")
            return
        self.call_from_thread(self._rebuild_tree, self._current_filter)

    def _set_status(self, message: str) -> None:
        self.call_from_thread(self._update_status_widget, message)

    def _update_status_widget(self, message: str) -> None:
        self.query_one("#status-bar", Static).update(message)

    def _rebuild_tree(self, filter_str: str) -> None:
        opts = SearchOptions(source_filter=_GRPC_SOURCE)
        if filter_str.strip():
            query = Query.parse(filter_str)
        else:
            query = _ALL_ROWS_QUERY

        rows = self._index.search(query, limit=10000, opts=opts)

        tree = self.query_one("#signal-tree", Tree)
        tree.clear()
        tree.root.expand()

        grouped = _build_tree_data(rows)
        for ns in sorted(grouped):
            ns_node = tree.root.add(f"[bold]{ns}[/bold]", expand=True)
            frames = grouped[ns]
            for frame_name in sorted(frames):
                frame_row = self._find_frame_row(rows, ns, frame_name)
                frame_node: TreeNode[dict[str, Any]] = ns_node.add(
                    f"[yellow]{frame_name}[/yellow]",
                    data=frame_row,
                    expand=self._frames_expanded,
                )
                for sig_row in sorted(frames[frame_name], key=lambda r: r.get("signal_name", "")):
                    frame_node.add_leaf(
                        f"[green]{sig_row['signal_name']}[/green]",
                        data=sig_row,
                    )

        total_signals = sum(len(sigs) for frames in grouped.values() for sigs in frames.values())
        total_frames = sum(len(frames) for frames in grouped.values())
        label = f"  {len(grouped)} namespace(s)  ·  {total_frames} frame(s)  ·  {total_signals} signal(s)"
        if filter_str.strip():
            label += f"  ·  filter: [italic]{filter_str}[/italic]"
        self.query_one("#status-bar", Static).update(label)

    def _find_frame_row(self, rows: list[dict[str, Any]], ns: str, frame_name: str) -> dict[str, Any]:
        for row in rows:
            if row.get("type") == "frame" and row.get("namespace") == ns and row.get("frame_name") == frame_name:
                return row
        return {"type": "frame", "frame_name": frame_name, "namespace": ns}

    def _show_node_detail(self, node: TreeNode[Any]) -> None:
        row: dict[str, Any] | None = node.data
        if row is None:
            return
        detail = self.query_one("#detail-panel", Static)
        detail.update(_format_detail(row))

    def on_tree_node_highlighted(self, event: Tree.NodeHighlighted[Any]) -> None:
        self._cancel_subscription()
        self._show_node_detail(event.node)
        self._current_signal_row = event.node.data if event.node.data and event.node.data.get("type") == "signal" else None

    def on_tree_node_selected(self, event: Tree.NodeSelected[Any]) -> None:
        self._show_node_detail(event.node)

    def on_input_changed(self, event: Input.Changed) -> None:
        if event.input.id == "search-input":
            self._current_filter = event.value
            self._rebuild_tree(event.value)

    def action_reindex(self) -> None:
        self.query_one("#detail-panel", Static).update("")
        self.run_worker(self._do_index, exclusive=True, thread=True)

    def action_expand_all(self) -> None:
        self._frames_expanded = True
        tree = self.query_one("#signal-tree", Tree)
        for ns_node in tree.root.children:
            ns_node.expand()
            for frame_node in ns_node.children:
                frame_node.expand()

    def action_collapse_all(self) -> None:
        self._frames_expanded = False
        tree = self.query_one("#signal-tree", Tree)
        for ns_node in tree.root.children:
            for frame_node in ns_node.children:
                frame_node.collapse()

    def action_focus_search(self) -> None:
        self.query_one("#search-input", Input).focus()

    def action_clear_search(self) -> None:
        search = self.query_one("#search-input", Input)
        search.clear()
        self._current_filter = ""
        self.query_one("#signal-tree", Tree).focus()

    def action_subscribe(self) -> None:
        row = self._current_signal_row
        if row is None or row.get("type") != "signal":
            self._set_status("Select a signal node to subscribe")
            return
        self._cancel_subscription()
        self._chart_values.clear()
        chart = self.query_one("#signal-chart", PlotextPlot)
        chart.add_class("visible")
        self.run_worker(lambda: self._do_subscribe(row), thread=True)

    def _do_subscribe(self, row: dict[str, Any]) -> None:
        from remotivelabs.cli.broker.lib.broker import Broker, SubscribableSignal

        if self._broker is None:
            self._broker = Broker(self._broker_url, self._api_key)

        signal = SubscribableSignal(name=row["signal_name"], namespace=row["namespace"])
        self._set_status(f"Subscribing to {row['namespace']}:{row['signal_name']}...")

        def on_frames(frames: Iterable[Any]) -> None:
            for frame in frames:
                value = frame.get("value")
                ts = frame.get("timestamp_us", 0)
                if isinstance(value, (int, float)) and isinstance(ts, int):
                    self._chart_values.append((ts, float(value)))
                    self.call_from_thread(self._update_chart, row["signal_name"])

        self._subscription = self._broker.long_name_subscribe([signal], on_frames, changed_values_only=False)

    def _update_chart(self, signal_name: str) -> None:
        chart = self.query_one("#signal-chart", PlotextPlot)
        plt = chart.plt
        plt.clear_data()
        plt.clear_figure()
        all_data = list(self._chart_values)
        if not all_data:
            return
        cutoff_us = all_data[-1][0] - _CHART_WINDOW_SECS * 1_000_000
        data = [(ts, v) for ts, v in all_data if ts >= cutoff_us]
        times = [datetime.fromtimestamp(ts / 1_000_000).strftime("%H:%M:%S") for ts, _ in data]
        values = [v for _, v in data]
        plt.date_form("H:M:S")
        plt.plot(times, values, marker="braille", label=signal_name)
        plt.title(signal_name)
        plt.xlabel("time")
        chart.refresh()

    def _cancel_subscription(self) -> None:
        if self._subscription is not None:
            try:
                self._subscription.cancel()
            except Exception:
                pass
            self._subscription = None
        self._chart_values.clear()
        try:
            chart = self.query_one("#signal-chart", PlotextPlot)
            chart.remove_class("visible")
        except Exception:
            pass

    def on_unmount(self) -> None:
        self._cancel_subscription()
