#!/usr/bin/env python3
"""Query the context graph via BFS traversal.

Returns related nodes for a given starting node, filtered by depth and
relationship type. Output as compressed markdown or JSON.

Usage:
    python3 .claude/skills/flydocs-workflow/scripts/graph_query.py \\
        --node decision:001 [--depth 2] [--rel EXTENDS] \\
        [--direction out|in|both] [--format md|json]

Direction is about traversal, not storage. Edges are stored one way only —
`A BLOCKS B` — so "what blocks B?" is `--node B --rel BLOCKS --direction in`.
There is no stored BLOCKED_BY relation.
"""

import argparse
import sys
from collections import deque
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from graph_utils import (
    VALID_REL_TYPES,
    fail,
    find_project_root,
    find_workspace_root,
    load_graph,
    normalize_node_id,
    normalize_rel_type,
    output_json,
)

DIRECTIONS = ("out", "in", "both")

# BLOCKED_BY is the one invented relation in circulation — activate.md asked
# for it for months. It has no stored form; it is BLOCKS walked backwards.
BLOCKED_BY_HINT = (
    "BLOCKED_BY is not a stored relation — it is BLOCKS traversed backwards. "
    "Use --rel BLOCKS --direction in (or --direction both)."
)


def build_adjacency(edges, rel_filter, direction):
    """Adjacency list for the requested traversal direction.

    Each entry is (neighbor, rel, edge_direction) where edge_direction records
    how the edge was walked: "out" along storage, "in" against it.
    """
    adjacency = {}
    for edge in edges:
        if rel_filter and edge["rel"] not in rel_filter:
            continue

        if direction in ("out", "both"):
            adjacency.setdefault(edge["from"], []).append(
                (edge["to"], edge["rel"], "out")
            )
        if direction in ("in", "both"):
            adjacency.setdefault(edge["to"], []).append(
                (edge["from"], edge["rel"], "in")
            )

    return adjacency


def bfs_traverse(graph, start_node, max_depth, rel_filter, direction="out"):
    """BFS traversal from start_node, returning discovered nodes with paths.

    Returns list of (node_id, depth, edge_rel, via_node, edge_direction) tuples.
    """
    nodes = graph.get("nodes", {})
    edges = graph.get("edges", [])

    if start_node not in nodes:
        return []

    adjacency = build_adjacency(edges, rel_filter, direction)

    visited = {start_node}
    queue = deque()
    results = []

    for neighbor, rel, edge_dir in adjacency.get(start_node, []):
        if neighbor not in visited:
            queue.append((neighbor, 1, rel, start_node, edge_dir))
            visited.add(neighbor)

    while queue:
        node_id, depth, rel, via, edge_dir = queue.popleft()
        results.append((node_id, depth, rel, via, edge_dir))

        if depth < max_depth:
            for neighbor, next_rel, next_dir in adjacency.get(node_id, []):
                if neighbor not in visited:
                    queue.append((neighbor, depth + 1, next_rel, node_id, next_dir))
                    visited.add(neighbor)

    return results


def format_markdown(graph, start_node, results):
    """Format traversal results as compressed markdown context block."""
    nodes = graph.get("nodes", {})
    start_info = nodes.get(start_node, {})
    label = start_info.get("label", start_node)

    lines = [f"## Context for: {label}"]
    lines.append("")

    if not results:
        lines.append("No related nodes found.")
        return "\n".join(lines)

    # Group results by node type
    by_type = {}
    for node_id, depth, rel, via, edge_dir in results:
        node = nodes.get(node_id, {})
        node_type = node.get("type", "unknown")
        if node_type not in by_type:
            by_type[node_type] = []
        by_type[node_type].append((node_id, node, rel, depth, edge_dir))

    # Type display order
    type_labels = {
        "repo": "Repos",
        "decision": "Decisions",
        "skill": "Skills",
        "module": "Modules",
        "issue": "Issues",
        "session": "Sessions",
        "concept": "Concepts",
    }

    for node_type in ["repo", "decision", "skill", "module", "issue", "session", "concept"]:
        entries = by_type.get(node_type, [])
        if not entries:
            continue

        type_label = type_labels.get(node_type, node_type.title())
        lines.append(f"**{type_label}:**")

        for node_id, node, rel, depth, edge_dir in entries:
            node_label = node.get("label", node_id)
            # An incoming edge means the OTHER node is the subject: an
            # incoming BLOCKS is a blocker, an outgoing one is blocked.
            rel_str = f"{rel}, incoming" if edge_dir == "in" else rel
            # Repo nodes show purpose; others show status
            if node_type == "repo":
                purpose = node.get("purpose", "")
                extra = f" — {purpose}" if purpose else ""
                iface = ""
                # Check if the edge that reached this node has interface info
                for e in graph.get("edges", []):
                    if (e.get("from") == node_id or e.get("to") == node_id) and e.get("interface"):
                        iface = f" via {e['interface']}"
                        break
                lines.append(f"- {node_id}: {node_label} ({rel_str}){iface}{extra}")
            else:
                status = node.get("status", "")
                status_str = f" [{status}]" if status else ""
                lines.append(f"- {node_id}: {node_label} ({rel_str}){status_str}")

        lines.append("")

    return "\n".join(lines)


def format_json(graph, start_node, results, direction="out"):
    """Format traversal results as JSON."""
    nodes = graph.get("nodes", {})

    related = []
    for node_id, depth, rel, via, edge_dir in results:
        node = nodes.get(node_id, {})
        related.append({
            "id": node_id,
            "type": node.get("type", "unknown"),
            "label": node.get("label", node_id),
            "relationship": rel,
            "direction": edge_dir,
            "depth": depth,
            "via": via,
        })

    return {
        "node": start_node,
        "label": nodes.get(start_node, {}).get("label", start_node),
        "direction": direction,
        "related": related,
    }


def resolve_direction(args):
    """Fold the deprecated --reverse flag into --direction."""
    if not args.reverse:
        return args.direction or "out"

    if args.direction and args.direction != "in":
        fail(
            f"--reverse conflicts with --direction {args.direction}. "
            "--reverse is a deprecated alias for --direction in; pass only one."
        )

    print(
        "warning: --reverse is deprecated; use --direction in",
        file=sys.stderr,
    )
    return "in"


def resolve_rel_filter(rels):
    """Validate and canonicalize --rel values.

    A relation the schema does not define used to filter every edge out and
    exit 0 — an unanswerable query that looked like a clean "nothing found".
    That is how the activation blocker gate reported "no blockers" for months.
    """
    if not rels:
        return None

    normalized = [normalize_rel_type(rel) for rel in rels]
    invalid = [rel for rel in normalized if rel not in VALID_REL_TYPES]
    if invalid:
        message = (
            f"Invalid relationship: {', '.join(invalid)}. "
            f"Valid: {', '.join(sorted(VALID_REL_TYPES))}."
        )
        if "BLOCKED_BY" in invalid:
            message = f"{message} {BLOCKED_BY_HINT}"
        fail(message)

    return set(normalized)


def main():
    parser = argparse.ArgumentParser(description="Query the context graph")
    parser.add_argument(
        "--node", required=True,
        help="Starting node ID (decision:001, skill:typescript-strict). "
             "A bare issue ref like FLY-123 is normalized to issue:FLY-123."
    )
    parser.add_argument(
        "--depth", type=int, default=2,
        help="BFS traversal depth (default: 2)"
    )
    parser.add_argument(
        "--rel", action="append", dest="rels",
        help="Filter by relationship type (repeatable). Must be one of the "
             "twelve schema relations; anything else is an error."
    )
    parser.add_argument(
        "--direction", choices=list(DIRECTIONS), default=None,
        help="Traversal direction: out (default), in, or both"
    )
    parser.add_argument(
        "--reverse", action="store_true",
        help="Deprecated alias for --direction in"
    )
    parser.add_argument(
        "--format", choices=["md", "json"], default="md", dest="fmt",
        help="Output format (default: md)"
    )
    parser.add_argument(
        "--root", type=str, default=None,
        help="Project root (default: auto-detect)"
    )
    parser.add_argument(
        "--workspace", action="store_true",
        help="Query the workspace-level graph instead of per-repo"
    )
    args = parser.parse_args()

    direction = resolve_direction(args)
    rel_filter = resolve_rel_filter(args.rels)

    if args.workspace:
        root = find_workspace_root()
        if not root:
            fail("No workspace found (no .flydocs-workspace.json)")
    else:
        root = Path(args.root) if args.root else find_project_root()
        if not root:
            fail("Could not find project root (no .flydocs/ directory found)")

    graph = load_graph(root)

    node = normalize_node_id(args.node, graph.get("nodes", {}))
    if node not in graph.get("nodes", {}):
        # Name both forms — otherwise a prefix the caller never typed shows up
        # in the error and reads like the script looked for the wrong thing.
        shown = node if node == args.node else f"{args.node} (normalized to {node})"
        fail(f"Node not found: {shown}")

    results = bfs_traverse(graph, node, args.depth, rel_filter, direction)

    if args.fmt == "json":
        output_json(format_json(graph, node, results, direction))
    else:
        print(format_markdown(graph, node, results))


if __name__ == "__main__":
    main()
