"""
Unit tests for build_tree cycle detection in get_trace_tree.

Reproduces the production error:
  "maximum recursion depth exceeded in comparison"
caused by cyclic parent_span_id references in OTEL span data
(commonly from Vercel AI SDK telemetry).

These tests exercise the tree-building and hardening logic directly
without needing a database connection.
"""
import sys
import os
from typing import Any, Dict, List, Optional, Set

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))


def _make_span(
    span_id: str,
    parent_span_id: Optional[str],
    name: str = "span",
) -> Dict[str, Any]:
    return {
        "span_id": span_id,
        "parent_span_id": parent_span_id,
        "name": name,
        "start_time": 1000000000,
        "end_time": 2000000000,
        "duration": 1.0,
        "status_code": None,
        "attributes": {},
    }


def _build_tree_from_spans(
    spans: List[Dict[str, Any]],
    trace_id: str = "test_trace_001",
) -> Dict[str, Any]:
    """
    Replica of the tree-building logic from PostgreSQLStorage.get_trace_tree,
    including synthetic root recovery, hardening, and cycle detection.
    """
    # Find root spans
    root_spans = [s for s in spans if not s["parent_span_id"]]

    if not root_spans:
        # Synthetic root recovery
        span_ids = {s["span_id"] for s in spans}
        synthetic_root_id = f"synthetic_root_{trace_id[:16]}"
        synthetic_root = _make_span(synthetic_root_id, None, "RecoveredRoot")
        synthetic_root["attributes"] = {
            "cascade.span_type": "function",
            "cascade.synthetic_root": True,
        }
        reparented = False
        for span in spans:
            pid = span.get("parent_span_id")
            if pid not in span_ids:
                span["parent_span_id"] = synthetic_root_id
                reparented = True

        if not reparented and spans:
            # All parents exist within the trace → closed cycle.
            # Break it by forcefully attaching the first span to the synthetic root.
            spans[0]["parent_span_id"] = synthetic_root_id

        spans.append(synthetic_root)
        root_spans = [synthetic_root]

    # Hardening: re-parent orphans, break self-references
    root_span_id = root_spans[0]["span_id"]
    span_ids = {s["span_id"] for s in spans}
    for span in spans:
        parent_id = span.get("parent_span_id")
        if not parent_id:
            if span["span_id"] != root_span_id:
                span["parent_span_id"] = root_span_id
        elif parent_id == span["span_id"]:
            span["parent_span_id"] = (
                root_span_id if span["span_id"] != root_span_id else None
            )
        elif parent_id not in span_ids:
            span["parent_span_id"] = root_span_id

    # Build parent→children index
    children_by_parent: Dict[Optional[str], List[Dict]] = {}
    for span in spans:
        pid = span["parent_span_id"]
        if pid not in children_by_parent:
            children_by_parent[pid] = []
        children_by_parent[pid].append(span)

    # Build tree with cycle protection
    visited: Set[str] = set()

    def build_tree(span: Dict[str, Any]) -> Dict[str, Any]:
        sid = span["span_id"]
        visited.add(sid)
        node = {
            "span_id": sid,
            "name": span["name"],
            "duration": span["duration"],
            "start_time": span["start_time"],
            "end_time": span["end_time"],
            "status_code": span["status_code"],
            "attributes": span["attributes"],
            "children": [],
        }
        for child in children_by_parent.get(sid, []):
            if child["span_id"] not in visited:
                node["children"].append(build_tree(child))
        return node

    root = build_tree(root_spans[0])
    return {"trace_id": trace_id, "root": root}


def _collect_span_ids(node: Dict[str, Any]) -> set:
    """Recursively collect all span_ids from a tree node."""
    ids = {node["span_id"]}
    for child in node.get("children", []):
        ids |= _collect_span_ids(child)
    return ids


# ─── Test cases ──────────────────────────────────────────────────────


def test_normal_tree_no_cycle():
    """Sanity check: a well-formed tree builds correctly."""
    spans = [
        _make_span("root", None, "Root"),
        _make_span("child1", "root", "Child1"),
        _make_span("child2", "root", "Child2"),
        _make_span("grandchild", "child1", "Grandchild"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    assert root["span_id"] == "root"
    assert len(root["children"]) == 2
    child_ids = {c["span_id"] for c in root["children"]}
    assert child_ids == {"child1", "child2"}

    child1 = next(c for c in root["children"] if c["span_id"] == "child1")
    assert len(child1["children"]) == 1
    assert child1["children"][0]["span_id"] == "grandchild"


def test_self_referencing_span():
    """A span whose parent_span_id equals its own span_id must not infinite-loop."""
    spans = [
        _make_span("root", None, "Root"),
        _make_span("loop", "loop", "SelfLoop"),  # parent = itself
        _make_span("normal", "root", "Normal"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    assert root["span_id"] == "root"
    all_ids = _collect_span_ids(root)
    assert "loop" in all_ids, "Self-referencing span should still appear in tree"
    assert "normal" in all_ids


def test_two_span_cycle():
    """A↔B cycle: A's parent is B, B's parent is A. Neither has NULL parent."""
    spans = [
        _make_span("A", "B", "SpanA"),
        _make_span("B", "A", "SpanB"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    assert "A" in all_ids or "B" in all_ids, "At least one cyclic span should appear"


def test_three_span_cycle():
    """A→B→C→A cycle (the exact pattern from the production error)."""
    spans = [
        _make_span("A", "B", "SpanA"),
        _make_span("B", "C", "SpanB"),
        _make_span("C", "A", "SpanC"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    assert "A" in all_ids
    assert "B" in all_ids
    assert "C" in all_ids


def test_cycle_with_normal_children():
    """
    Reproduces the production scenario: 5 spans, no NULL-parent root,
    a 3-span cycle (A→B→C→A) with 2 normal children (D→A, E→B).
    Synthetic root recovery kicks in, then build_tree must not crash.
    """
    spans = [
        _make_span("A", "B", "SpanA"),
        _make_span("B", "C", "SpanB"),
        _make_span("C", "A", "SpanC"),
        _make_span("D", "A", "SpanD"),
        _make_span("E", "B", "SpanE"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    # All 5 original spans + synthetic root should be present
    assert len(all_ids) >= 5, f"Expected >=5 spans in tree, got {len(all_ids)}: {all_ids}"
    for sid in ["A", "B", "C", "D", "E"]:
        assert sid in all_ids, f"Span {sid} missing from tree"


def test_synthetic_root_id_collides_with_existing_span():
    """
    When trace_metadata.root_span_id matches an existing span,
    the synthetic root shares a span_id. Must not cause duplicate
    processing or infinite recursion.
    """
    spans = [
        _make_span("A", "external_missing", "SpanA"),
        _make_span("B", "A", "SpanB"),
        _make_span("C", "B", "SpanC"),
    ]
    # Simulate: synthetic_root_id would be auto-generated (no collision here)
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    assert "A" in all_ids
    assert "B" in all_ids
    assert "C" in all_ids


def test_long_chain_cycle():
    """A→B→C→D→E→A: 5-node cycle with no legitimate root."""
    spans = [
        _make_span("A", "B", "SpanA"),
        _make_span("B", "C", "SpanB"),
        _make_span("C", "D", "SpanC"),
        _make_span("D", "E", "SpanD"),
        _make_span("E", "A", "SpanE"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    for sid in ["A", "B", "C", "D", "E"]:
        assert sid in all_ids, f"Span {sid} missing from tree"


def test_multiple_roots():
    """Multiple spans with parent_span_id=NULL should all appear in tree."""
    spans = [
        _make_span("root1", None, "Root1"),
        _make_span("root2", None, "Root2"),
        _make_span("root3", None, "Root3"),
        _make_span("child", "root1", "Child"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    assert "root1" in all_ids
    assert "root2" in all_ids
    assert "root3" in all_ids
    assert "child" in all_ids


def test_orphan_spans_reparented():
    """Spans with parent_span_id pointing to missing spans get re-parented."""
    spans = [
        _make_span("root", None, "Root"),
        _make_span("orphan1", "nonexistent_1", "Orphan1"),
        _make_span("orphan2", "nonexistent_2", "Orphan2"),
        _make_span("normal", "root", "Normal"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    assert "orphan1" in all_ids, "Orphan span should be re-parented to root"
    assert "orphan2" in all_ids
    assert "normal" in all_ids


def test_single_span_self_referencing():
    """Edge case: only one span and it points to itself as parent."""
    spans = [
        _make_span("alone", "alone", "Alone"),
    ]
    result = _build_tree_from_spans(spans)
    root = result["root"]

    all_ids = _collect_span_ids(root)
    assert "alone" in all_ids
