import json
import os
import re
import string
import threading
import time
from collections import OrderedDict
from concurrent import futures
from dataclasses import dataclass
from datetime import datetime, timezone
from fnmatch import fnmatch
from typing import Any

import grpc
import rich_click as click
from opentelemetry.proto.collector.trace.v1 import (
    trace_service_pb2,
    trace_service_pb2_grpc,
)
from rich.console import Console
from rich.text import Text
from rich.tree import Tree

from planoai.consts import PLANO_COLOR

DEFAULT_GRPC_PORT = 4317
MAX_TRACES = 50
MAX_SPANS_PER_TRACE = 500


class TraceListenerBindError(RuntimeError):
    """Raised when the OTLP/gRPC listener cannot bind to the requested address."""


def _trace_listener_bind_error_message(address: str) -> str:
    return (
        f"Failed to start OTLP listener on {address}: address is already in use.\n"
        "Stop the process using that port or run `planoai trace listen --port <PORT>`."
    )


@dataclass
class TraceSummary:
    trace_id: str
    start_ns: int
    end_ns: int

    @property
    def total_ms(self) -> float:
        return max(0, (self.end_ns - self.start_ns) / 1_000_000)

    @property
    def timestamp(self) -> str:
        if self.start_ns <= 0:
            return "unknown"
        dt = datetime.fromtimestamp(self.start_ns / 1_000_000_000, tz=timezone.utc)
        return dt.astimezone().strftime("%Y-%m-%d %H:%M:%S")


def _parse_filter_patterns(filter_patterns: tuple[str, ...]) -> list[str]:
    parts: list[str] = []
    for raw in filter_patterns:
        for token in raw.split(","):
            part = token.strip()
            if not part:
                raise ValueError("Filter contains empty tokens.")
            parts.append(part)
    return parts


def _is_hex(value: str, length: int) -> bool:
    if len(value) != length:
        return False
    return all(char in string.hexdigits for char in value)


def _parse_where_filters(where_filters: tuple[str, ...]) -> list[tuple[str, str]]:
    parsed: list[tuple[str, str]] = []
    invalid: list[str] = []
    key_pattern = re.compile(r"^[A-Za-z0-9_.:-]+$")
    for raw in where_filters:
        if raw.count("=") != 1:
            invalid.append(raw)
            continue
        key, value = raw.split("=", 1)
        key = key.strip()
        value = value.strip()
        if not key or not value or not key_pattern.match(key):
            invalid.append(raw)
            continue
        parsed.append((key, value))
    if invalid:
        invalid_list = ", ".join(invalid)
        raise click.ClickException(
            f"Invalid --where filter(s): {invalid_list}. Use key=value."
        )
    return parsed


def _collect_attr_keys(traces: list[dict[str, Any]]) -> set[str]:
    keys: set[str] = set()
    for trace in traces:
        for span in trace.get("spans", []):
            for item in span.get("attributes", []):
                key = item.get("key")
                if key:
                    keys.add(str(key))
    return keys


def _fetch_traces_raw() -> list[dict[str, Any]]:
    port = os.environ.get("PLANO_TRACE_PORT", str(DEFAULT_GRPC_PORT))
    target = f"127.0.0.1:{port}"
    try:
        channel = grpc.insecure_channel(target)
        stub = channel.unary_unary(
            "/plano.TraceQuery/GetTraces",
            request_serializer=lambda x: x,
            response_deserializer=lambda x: x,
        )
        response = stub(b"", timeout=3)
        channel.close()
        data = json.loads(response)
        traces = data.get("traces", [])
        if isinstance(traces, list):
            return traces
    except Exception:
        pass
    return []


def _attrs(span: dict[str, Any]) -> dict[str, str]:
    attrs = {}
    for item in span.get("attributes", []):
        key = item.get("key")
        value_obj = item.get("value", {})
        value = value_obj.get("stringValue")
        if value is None and "intValue" in value_obj:
            value = value_obj.get("intValue")
        if value is None and "doubleValue" in value_obj:
            value = value_obj.get("doubleValue")
        if value is None and "boolValue" in value_obj:
            value = value_obj.get("boolValue")
        if key is not None and value is not None:
            attrs[str(key)] = str(value)
    return attrs


def _safe_int(value: Any, default: int = 0) -> int:
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def _parse_since_seconds(value: str | None) -> int | None:
    if not value:
        return None
    value = value.strip()
    if not value:
        return None
    if len(value) < 2:
        return None
    number, unit = value[:-1], value[-1]
    try:
        qty = int(number)
    except ValueError:
        return None
    multiplier = {"m": 60, "h": 60 * 60, "d": 60 * 60 * 24}.get(unit)
    if multiplier is None:
        return None
    return qty * multiplier


def _matches_pattern(value: str, pattern: str) -> bool:
    if pattern == "*":
        return True
    if "*" not in pattern:
        return value == pattern
    parts = [part for part in pattern.split("*") if part]
    if not parts:
        return True
    remaining = value
    for idx, part in enumerate(parts):
        pos = remaining.find(part)
        if pos == -1:
            return False
        if idx == 0 and not pattern.startswith("*") and pos != 0:
            return False
        remaining = remaining[pos + len(part) :]
    if not pattern.endswith("*") and remaining:
        return False
    return True


def _attribute_map(span: dict[str, Any]) -> dict[str, str]:
    attrs = {}
    for item in span.get("attributes", []):
        key = item.get("key")
        value_obj = item.get("value", {})
        value = value_obj.get("stringValue")
        if value is None and "intValue" in value_obj:
            value = value_obj.get("intValue")
        if value is None and "doubleValue" in value_obj:
            value = value_obj.get("doubleValue")
        if value is None and "boolValue" in value_obj:
            value = value_obj.get("boolValue")
        if key is not None and value is not None:
            attrs[str(key)] = str(value)
    return attrs


def _filter_attributes(span: dict[str, Any], patterns: list[str]) -> dict[str, Any]:
    if not patterns:
        return span
    attributes = span.get("attributes", [])
    filtered = [
        item
        for item in attributes
        if any(
            _matches_pattern(str(item.get("key", "")), pattern) for pattern in patterns
        )
    ]
    cloned = dict(span)
    cloned["attributes"] = filtered
    return cloned


def _filter_traces(
    traces: list[dict[str, Any]],
    filter_patterns: list[str],
    where_filters: list[tuple[str, str]],
    since_seconds: int | None,
) -> tuple[list[dict[str, Any]], list[str]]:
    now_nanos = int(time.time() * 1_000_000_000)
    since_nanos = now_nanos - (since_seconds * 1_000_000_000) if since_seconds else None

    filtered_traces: list[dict[str, Any]] = []
    for trace in traces:
        spans = trace.get("spans", []) or []
        if since_nanos is not None:
            spans = [
                span
                for span in spans
                if _safe_int(span.get("startTimeUnixNano", 0)) >= since_nanos
            ]
        if filter_patterns:
            spans = [_filter_attributes(span, filter_patterns) for span in spans]
        if not spans:
            continue

        candidate = dict(trace)
        candidate["spans"] = spans
        filtered_traces.append(candidate)

    if where_filters:

        def matches_where(trace: dict[str, Any]) -> bool:
            for key, value in where_filters:
                if not any(
                    _attribute_map(span).get(key) == value
                    for span in trace.get("spans", [])
                ):
                    return False
            return True

        filtered_traces = [trace for trace in filtered_traces if matches_where(trace)]

    trace_ids = [trace.get("trace_id", "") for trace in filtered_traces]
    return filtered_traces, trace_ids


class _TraceStore:
    """Thread-safe in-memory store backed by a fixed-length deque.

    Spans may arrive with **different** ``traceId`` values but are
    linked via ``parentSpanId``.  This store groups them into logical
    traces by following parent-child span relationships, so all
    connected spans end up under a single trace group regardless of
    the ``traceId`` they were emitted with.
    """

    def __init__(self, max_traces: int = MAX_TRACES) -> None:
        self._traces: OrderedDict[str, dict[str, Any]] = OrderedDict()
        self._seen_spans: dict[str, set[str]] = {}
        # span_id → group key (the trace_id used as the dict key)
        self._span_to_group: dict[str, str] = {}
        # parent_span_id → group key for spans whose parent arrived first
        self._parent_to_group: dict[str, str] = {}
        self._max_traces = max_traces
        self._lock = threading.Lock()

    def _evict_oldest(self) -> None:
        """Remove the oldest trace group (caller must hold *_lock*)."""
        if not self._traces:
            return
        oldest_id, oldest = self._traces.popitem(last=False)
        self._seen_spans.pop(oldest_id, None)
        for span in oldest.get("spans", []):
            sid = span.get("spanId", "")
            self._span_to_group.pop(sid, None)
            self._parent_to_group.pop(sid, None)

    def _merge_groups(self, src_key: str, dst_key: str) -> None:
        """Move all spans from *src_key* group into *dst_key* (caller holds lock)."""
        if src_key == dst_key or src_key not in self._traces:
            return
        src = self._traces.pop(src_key)
        dst = self._traces[dst_key]
        dst_seen = self._seen_spans[dst_key]
        src_seen = self._seen_spans.pop(src_key, set())
        for span in src.get("spans", []):
            sid = span.get("spanId", "")
            if sid and sid not in dst_seen:
                dst["spans"].append(span)
                dst_seen.add(sid)
            self._span_to_group[sid] = dst_key
        for sid in src_seen:
            self._span_to_group[sid] = dst_key
        # Update parent→group mappings that pointed to src.
        for pid, gid in list(self._parent_to_group.items()):
            if gid == src_key:
                self._parent_to_group[pid] = dst_key

    def merge_spans(self, trace_id: str, spans: list[dict[str, Any]]) -> None:
        """Merge *spans* into the correct trace group.

        The group is determined by following ``parentSpanId`` /
        ``spanId`` links, falling back to *trace_id* when no link
        exists.
        """
        with self._lock:
            for span in spans:
                span_id = span.get("spanId", "")
                parent_id = span.get("parentSpanId", "")

                # Determine which group this span belongs to.
                group_key: str | None = None

                # 1. Does the parent already live in a group?
                if parent_id and parent_id in self._span_to_group:
                    group_key = self._span_to_group[parent_id]

                # 2. Is this span already known as a parent of another group?
                if group_key is None and span_id and span_id in self._parent_to_group:
                    group_key = self._parent_to_group.pop(span_id)

                # 3. Fall back to the wire trace_id.
                if group_key is None:
                    group_key = trace_id

                # Create the group if needed.
                if group_key not in self._traces:
                    if len(self._traces) >= self._max_traces:
                        self._evict_oldest()
                    self._traces[group_key] = {"trace_id": group_key, "spans": []}
                    self._seen_spans[group_key] = set()
                else:
                    self._traces.move_to_end(group_key)

                # Insert span (deduplicate).
                seen = self._seen_spans[group_key]
                if span_id and span_id in seen:
                    continue
                if span_id:
                    seen.add(span_id)
                    self._span_to_group[span_id] = group_key
                if len(self._traces[group_key]["spans"]) < MAX_SPANS_PER_TRACE:
                    self._traces[group_key]["spans"].append(span)

                # Record parent link so future spans can find this group.
                if parent_id and parent_id not in self._span_to_group:
                    self._parent_to_group[parent_id] = group_key

                # If this span's span_id is the parent of an existing
                # *different* group, merge that group into this one.
                if span_id and span_id in self._parent_to_group:
                    other = self._parent_to_group.pop(span_id)
                    if other != group_key and other in self._traces:
                        self._merge_groups(other, group_key)

    def snapshot(self) -> list[dict[str, Any]]:
        """Return traces ordered newest-first."""
        with self._lock:
            traces = list(self._traces.values())
        traces.reverse()
        return traces


_TRACE_STORE = _TraceStore()


def _anyvalue_to_python(value_obj: Any) -> Any:
    """Convert an opentelemetry AnyValue protobuf to a Python primitive."""
    if hasattr(value_obj, "string_value") and value_obj.HasField("value"):
        kind = value_obj.WhichOneof("value")
        if kind == "string_value":
            return value_obj.string_value
        if kind == "int_value":
            return value_obj.int_value
        if kind == "double_value":
            return value_obj.double_value
        if kind == "bool_value":
            return value_obj.bool_value
    return None


def _proto_span_to_dict(span: Any, service_name: str) -> dict[str, Any]:
    """Convert a protobuf Span message to the dict format used internally."""
    span_dict: dict[str, Any] = {
        "traceId": span.trace_id.hex(),
        "spanId": span.span_id.hex(),
        "parentSpanId": span.parent_span_id.hex() if span.parent_span_id else "",
        "name": span.name,
        "startTimeUnixNano": str(span.start_time_unix_nano),
        "endTimeUnixNano": str(span.end_time_unix_nano),
        "service": service_name,
        "attributes": [],
    }
    for kv in span.attributes:
        py_val = _anyvalue_to_python(kv.value)
        if py_val is not None:
            value_dict: dict[str, Any] = {}
            if isinstance(py_val, str):
                value_dict["stringValue"] = py_val
            elif isinstance(py_val, bool):
                value_dict["boolValue"] = py_val
            elif isinstance(py_val, int):
                value_dict["intValue"] = str(py_val)
            elif isinstance(py_val, float):
                value_dict["doubleValue"] = py_val
            span_dict["attributes"].append({"key": kv.key, "value": value_dict})
    return span_dict


class _OTLPTraceServicer(trace_service_pb2_grpc.TraceServiceServicer):
    """gRPC servicer that receives OTLP ExportTraceServiceRequest and
    merges incoming spans into the global _TRACE_STORE by trace_id."""

    _console = Console(stderr=True)

    def Export(self, request, context):  # noqa: N802
        for resource_spans in request.resource_spans:
            service_name = "unknown"
            for attr in resource_spans.resource.attributes:
                if attr.key == "service.name":
                    val = _anyvalue_to_python(attr.value)
                    if val is not None:
                        service_name = str(val)
                    break

            for scope_spans in resource_spans.scope_spans:
                for span in scope_spans.spans:
                    trace_id = span.trace_id.hex()
                    if not trace_id:
                        continue
                    span_dict = _proto_span_to_dict(span, service_name)
                    _TRACE_STORE.merge_spans(trace_id, [span_dict])
                    short_id = trace_id[:8]
                    short_span = span.span_id.hex()[:8]
                    span_start = (
                        datetime.fromtimestamp(
                            span.start_time_unix_nano / 1_000_000_000, tz=timezone.utc
                        )
                        .astimezone()
                        .strftime("%H:%M:%S.%f")[:-3]
                    )
                    dur_ns = span.end_time_unix_nano - span.start_time_unix_nano
                    dur_s = dur_ns / 1_000_000_000
                    dur_str = f"{dur_s:.3f}".rstrip("0").rstrip(".")
                    dur_str = f"{dur_str}s"
                    self._console.print(
                        f"[dim]{span_start}[/dim], "
                        f"trace=[yellow]{short_id}[/yellow], "
                        f"span=[yellow]{short_span}[/yellow], "
                        f"[bold {_service_color(service_name)}]{service_name}[/bold {_service_color(service_name)}] "
                        f"[cyan]{span.name}[/cyan] "
                        f"[dim]({dur_str})[/dim]"
                    )

        return trace_service_pb2.ExportTraceServiceResponse()


class _TraceQueryHandler(grpc.GenericRpcHandler):
    """gRPC handler that serves stored traces to the CLI show command."""

    def service(self, handler_call_details):
        if handler_call_details.method == "/plano.TraceQuery/GetTraces":
            return grpc.unary_unary_rpc_method_handler(
                self._get_traces,
                request_deserializer=lambda x: x,
                response_serializer=lambda x: x,
            )
        return None

    @staticmethod
    def _get_traces(_request, _context):
        traces = _TRACE_STORE.snapshot()
        return json.dumps({"traces": traces}, separators=(",", ":")).encode("utf-8")


def _create_trace_server(host: str, grpc_port: int) -> grpc.Server:
    """Create, bind, and start an OTLP/gRPC trace-collection server.

    Returns the running ``grpc.Server``.  The caller is responsible
    for calling ``server.stop()`` when done.
    """
    grpc_server = grpc.server(
        futures.ThreadPoolExecutor(max_workers=4),
        handlers=[_TraceQueryHandler()],
    )
    trace_service_pb2_grpc.add_TraceServiceServicer_to_server(
        _OTLPTraceServicer(), grpc_server
    )
    address = f"{host}:{grpc_port}"
    try:
        bound_port = grpc_server.add_insecure_port(address)
    except RuntimeError as exc:
        raise TraceListenerBindError(
            _trace_listener_bind_error_message(address)
        ) from exc
    if bound_port == 0:
        raise TraceListenerBindError(_trace_listener_bind_error_message(address))
    grpc_server.start()
    return grpc_server


def _start_trace_listener(host: str, grpc_port: int) -> None:
    """Start the OTLP/gRPC listener and block until interrupted."""
    console = Console()
    try:
        grpc_server = _create_trace_server(host, grpc_port)
    except TraceListenerBindError as exc:
        raise click.ClickException(str(exc)) from exc

    console.print()
    console.print(f"[bold {PLANO_COLOR}]Listening for traces...[/bold {PLANO_COLOR}]")
    console.print(
        f"[green]●[/green] gRPC (OTLP receiver) on [cyan]{host}:{grpc_port}[/cyan]"
    )
    console.print("[dim]Press Ctrl+C to stop.[/dim]")
    console.print()
    try:
        grpc_server.wait_for_termination()
    except KeyboardInterrupt:
        pass
    finally:
        grpc_server.stop(grace=2)


def start_trace_listener_background(
    host: str = "0.0.0.0", grpc_port: int = DEFAULT_GRPC_PORT
) -> grpc.Server:
    """Start the trace listener in the background (non-blocking).

    Returns the running ``grpc.Server`` so the caller can call
    ``server.stop()`` later.
    """
    return _create_trace_server(host, grpc_port)


def _span_time_ns(span: dict[str, Any], key: str) -> int:
    try:
        return int(span.get(key, 0))
    except (TypeError, ValueError):
        return 0


def _trace_id_short(trace_id: str) -> str:
    return trace_id[:8] if trace_id else "unknown"


def _trace_summary(trace: dict[str, Any]) -> TraceSummary:
    spans = trace.get("spans", [])
    start_ns = min((_span_time_ns(s, "startTimeUnixNano") for s in spans), default=0)
    end_ns = max((_span_time_ns(s, "endTimeUnixNano") for s in spans), default=0)
    return TraceSummary(
        trace_id=trace.get("trace_id", "unknown"),
        start_ns=start_ns,
        end_ns=end_ns,
    )


def _service_color(service: str) -> str:
    service = service.lower()
    if "inbound" in service:
        return "white"
    if "outbound" in service:
        return "white"
    if "orchestrator" in service:
        return PLANO_COLOR
    if "routing" in service:
        return "magenta"
    if "agent" in service:
        return "cyan"
    if "llm" in service:
        return "green"
    return "white"


# Attributes to show for inbound/outbound spans when not verbose (trimmed view).
_INBOUND_OUTBOUND_ATTR_KEYS = (
    "http.method",
    "http.target",
    "http.status_code",
    "url.scheme",
    "guid:x-request-id",
    "request_size",
    "response_size",
)


def _trim_attrs_for_display(
    attrs: dict[str, str], service: str, verbose: bool
) -> dict[str, str]:
    if verbose:
        return attrs
    if "inbound" in service.lower() or "outbound" in service.lower():
        attrs = {k: v for k, v in attrs.items() if k in _INBOUND_OUTBOUND_ATTR_KEYS}
    return {k: v for k, v in attrs.items() if k != "service.name.override"}


def _sorted_attr_items(attrs: dict[str, str]) -> list[tuple[str, str]]:
    priority = [
        "http.method",
        "http.target",
        "http.status_code",
        "guid:x-request-id",
        "request_size",
        "response_size",
        "routing.determination_ms",
        "route.selected_model",
        "selection.agents",
        "selection.agent_count",
        "agent.name",
        "agent.sequence",
        "duration_ms",
        "llm.model",
        "llm.is_streaming",
        "llm.time_to_first_token",
        "llm.duration_ms",
        "llm.response_bytes",
    ]
    prioritized = [(k, attrs[k]) for k in priority if k in attrs]
    prioritized_keys = {k for k, _ in prioritized}
    remaining = [(k, v) for k, v in attrs.items() if k not in prioritized_keys]
    remaining.sort(key=lambda item: item[0])
    return prioritized + remaining


def _display_attr_value(key: str, value: str) -> str:
    if key == "http.status_code" and value != "200":
        return f"{value} ⚠️"
    return value


def _build_tree(trace: dict[str, Any], console: Console, verbose: bool = False) -> None:
    spans = trace.get("spans", [])
    if not spans:
        console.print("[yellow]No spans found for this trace.[/yellow]")
        return

    start_ns = min((_span_time_ns(s, "startTimeUnixNano") for s in spans), default=0)
    end_ns = max((_span_time_ns(s, "endTimeUnixNano") for s in spans), default=0)
    total_ms = max(0, (end_ns - start_ns) / 1_000_000)

    trace_id = trace.get("trace_id", "unknown")
    console.print(
        f"\n[bold]Trace:[/bold] {trace_id} [dim]({total_ms:.0f}ms total)[/dim]\n"
    )

    spans.sort(key=lambda s: _span_time_ns(s, "startTimeUnixNano"))
    tree = Tree("", guide_style="dim")

    for span in spans:
        service = span.get("service", "plano(unknown)")
        name = span.get("name", "")
        offset_ms = max(
            0, (_span_time_ns(span, "startTimeUnixNano") - start_ns) / 1_000_000
        )
        color = _service_color(service)
        label = Text(f"{offset_ms:.0f}ms ", style="yellow")
        label.append(service, style=f"bold {color}")
        if name:
            label.append(f" {name}", style="dim white")

        node = tree.add(label)
        attrs = _trim_attrs_for_display(_attrs(span), service, verbose)
        sorted_items = list(_sorted_attr_items(attrs))
        for idx, (key, value) in enumerate(sorted_items):
            attr_line = Text()
            attr_line.append(f"{key}: ", style="white")
            attr_line.append(
                _display_attr_value(key, str(value)),
                style=f"{PLANO_COLOR}",
            )
            if idx == len(sorted_items) - 1:
                attr_line.append("\n")
            node.add(attr_line)

    console.print(tree)
    console.print()


def _select_request(
    console: Console, traces: list[dict[str, Any]]
) -> dict[str, Any] | None:
    try:
        import questionary
        from questionary import Choice
        from prompt_toolkit.styles import Style
    except ImportError as exc:
        raise click.ClickException(
            "Interactive selection requires 'questionary'. "
            "Install it or rerun with --json."
        ) from exc

    if not traces:
        return None

    style = Style.from_dict(
        {
            "qmark": f"fg:{PLANO_COLOR} bold",
            "question": "bold",
            "answer": f"fg:{PLANO_COLOR} bold",
            "pointer": f"fg:{PLANO_COLOR} bold",
            "highlighted": f"fg:{PLANO_COLOR} bold",
            "selected": f"fg:{PLANO_COLOR}",
            "instruction": "fg:#888888",
            "text": "",
            "disabled": "fg:#666666",
        }
    )

    choices = []
    for trace in traces:
        summary = _trace_summary(trace)
        label = f"{_trace_id_short(summary.trace_id)} ({summary.total_ms:.0f}ms total • {summary.timestamp})"
        choices.append(Choice(label, value=trace))

    selected = questionary.select(
        "Select a trace to view:",
        choices=choices,
        style=style,
        pointer="❯",
    ).ask()

    if not selected:
        console.print("[dim]Cancelled.[/dim]")
        return None
    return selected


@click.argument("target", required=False)
@click.option(
    "--filter",
    "filter_patterns",
    multiple=True,
    help=(
        "Limit displayed attributes to matching keys "
        "(wildcards supported). Repeatable."
    ),
)
@click.option(
    "--where",
    "where_filters",
    multiple=True,
    help="Match traces that contain key=value. Repeatable (AND semantics).",
)
@click.option("--list", "list_only", is_flag=True, help="List trace IDs only.")
@click.option(
    "--no-interactive",
    is_flag=True,
    help="Disable interactive prompts and selections.",
)
@click.option("--limit", type=int, default=None, help="Limit results.")
@click.option("--since", default=None, help="Look back window (e.g. 5m, 2h, 1d).")
@click.option("--json", "json_out", is_flag=True, help="Output raw JSON.")
@click.option(
    "--verbose",
    "-v",
    is_flag=True,
    help="Show all span attributes; default trims inbound/outbound to a few keys.",
)
def _run_trace_show(
    target,
    filter_patterns,
    where_filters,
    list_only,
    no_interactive,
    limit,
    since,
    json_out,
    verbose,
):
    """Trace requests from the local OTLP listener."""
    console = Console()

    try:
        patterns = _parse_filter_patterns(filter_patterns)
    except ValueError as exc:
        raise click.ClickException(str(exc)) from exc

    parsed_where = _parse_where_filters(where_filters)
    if limit is not None and limit < 0:
        raise click.ClickException("Limit must be greater than or equal to 0.")
    since_seconds = _parse_since_seconds(since)

    if target is None:
        target = "any" if list_only or since or limit else "last"

    if list_only and target not in (None, "last", "any"):
        raise click.ClickException("Target and --list cannot be used together.")

    short_target = None
    if isinstance(target, str) and target not in ("last", "any"):
        target_lower = target.lower()
        if len(target_lower) == 8:
            if not _is_hex(target_lower, 8) or target_lower == "00000000":
                raise click.ClickException("Short trace ID must be 8 hex characters.")
            short_target = target_lower
        elif len(target_lower) == 32:
            if not _is_hex(target_lower, 32) or target_lower == "0" * 32:
                raise click.ClickException("Trace ID must be 32 hex characters.")
        else:
            raise click.ClickException("Trace ID must be 8 or 32 hex characters.")

    traces_raw = _fetch_traces_raw()
    if traces_raw:
        available_keys = _collect_attr_keys(traces_raw)
        if parsed_where:
            missing_keys = [key for key, _ in parsed_where if key not in available_keys]
            if missing_keys:
                missing_list = ", ".join(missing_keys)
                raise click.ClickException(f"Unknown --where key(s): {missing_list}")
        if patterns:
            unmatched = [
                pattern
                for pattern in patterns
                if not any(fnmatch(key, pattern) for key in available_keys)
            ]
            if unmatched:
                unmatched_list = ", ".join(unmatched)
                console.print(
                    f"[yellow]Warning:[/yellow] Filter key(s) not found: {unmatched_list}. "
                    "Returning unfiltered traces."
                )

    traces, trace_ids = _filter_traces(
        traces_raw, patterns, parsed_where, since_seconds
    )

    if target == "last":
        traces = traces[:1]
        trace_ids = trace_ids[:1]
    elif target not in (None, "any") and short_target is None:
        traces = [trace for trace in traces if trace.get("trace_id") == target]
        trace_ids = [trace.get("trace_id") for trace in traces]
    if short_target:
        traces = [
            trace
            for trace in traces
            if trace.get("trace_id", "").lower().startswith(short_target)
        ]
        trace_ids = [trace.get("trace_id") for trace in traces]

    if limit is not None:
        if list_only:
            trace_ids = trace_ids[:limit]
        else:
            traces = traces[:limit]

    if json_out:
        if list_only:
            console.print_json(data={"trace_ids": trace_ids})
        else:
            console.print_json(data={"traces": traces})
        return

    if list_only:
        if traces and console.is_terminal and not no_interactive:
            selected = _select_request(console, traces)
            if selected:
                _build_tree(selected, console, verbose=verbose)
            return

        if traces:
            trace_ids = [_trace_id_short(_trace_summary(t).trace_id) for t in traces]

        if not trace_ids:
            console.print("[yellow]No trace IDs found.[/yellow]")
            return

        console.print("\n[bold]Trace IDs:[/bold]")
        for trace_id in trace_ids:
            console.print(f"  [dim]-[/dim] {trace_id}")
        return

    if not traces:
        console.print("[yellow]No traces found.[/yellow]")
        return

    trace_obj = traces[0]
    _build_tree(trace_obj, console, verbose=verbose)


@click.group(invoke_without_command=True)
@click.argument("target", required=False)
@click.option(
    "--filter",
    "filter_patterns",
    multiple=True,
    help=(
        "Limit displayed attributes to matching keys "
        "(wildcards supported). Repeatable."
    ),
)
@click.option(
    "--where",
    "where_filters",
    multiple=True,
    help="Match traces that contain key=value. Repeatable (AND semantics).",
)
@click.option("--list", "list_only", is_flag=True, help="List trace IDs only.")
@click.option(
    "--no-interactive",
    is_flag=True,
    help="Disable interactive prompts and selections.",
)
@click.option("--limit", type=int, default=None, help="Limit results.")
@click.option("--since", default=None, help="Look back window (e.g. 5m, 2h, 1d).")
@click.option("--json", "json_out", is_flag=True, help="Output raw JSON.")
@click.option(
    "--verbose",
    "-v",
    is_flag=True,
    help="Show all span attributes; default trims inbound/outbound to a few keys.",
)
@click.pass_context
def trace(
    ctx,
    target,
    filter_patterns,
    where_filters,
    list_only,
    no_interactive,
    limit,
    since,
    json_out,
    verbose,
):
    """Trace requests from the local OTLP listener."""
    if ctx.invoked_subcommand:
        return
    if target == "listen" and not any(
        [
            filter_patterns,
            where_filters,
            list_only,
            no_interactive,
            limit,
            since,
            json_out,
            verbose,
        ]
    ):
        _start_trace_listener("0.0.0.0", DEFAULT_GRPC_PORT)
        return
    _run_trace_show(
        target,
        filter_patterns,
        where_filters,
        list_only,
        no_interactive,
        limit,
        since,
        json_out,
        verbose,
    )


@trace.command("listen")
@click.option("--host", default="0.0.0.0", show_default=True)
@click.option(
    "--port",
    type=int,
    default=DEFAULT_GRPC_PORT,
    show_default=True,
    help="gRPC port for receiving OTLP traces.",
)
def trace_listen(host: str, port: int) -> None:
    """Listen for OTLP/gRPC traces."""
    _start_trace_listener(host, port)
