#!/usr/bin/env python3
"""Rebuild the context graph from project sources.

Scans skills (SKILL.md frontmatter) and ADRs (decisions/*.md cross-references)
to produce flydocs/context/graph.json. Preserves manually-added nodes and edges.

Usage:
    python3 .claude/skills/flydocs-workflow/scripts/graph_build.py [--root PATH]
    python3 .claude/skills/flydocs-workflow/scripts/graph_build.py --workspace
"""

import argparse
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from graph_utils import (
    find_project_root,
    find_workspace_root,
    read_workspace_repos,
    load_graph,
    save_graph,
    output_json,
    fail,
    parse_frontmatter,
)


# --- Skill scanning ---


def scan_skills(root):
    """Scan .claude/skills/*/SKILL.md and return skill nodes + edges."""
    skills_dir = root / ".claude" / "skills"
    nodes = {}
    edges = []

    if not skills_dir.is_dir():
        return nodes, edges

    for skill_dir in sorted(skills_dir.iterdir()):
        skill_md = skill_dir / "SKILL.md"
        if not skill_md.is_file():
            continue

        text = skill_md.read_text(encoding="utf-8")
        fm = parse_frontmatter(text)
        if not fm or "name" not in fm:
            continue

        name = fm["name"]
        node_id = f"skill:{name}"

        node = {
            "type": "skill",
            "label": name,
            "path": str(skill_md.relative_to(root)),
        }

        # Classify tier based on directory name
        dir_name = skill_dir.name
        if dir_name.startswith("flydocs-"):
            if dir_name in ("flydocs-local", "flydocs-cloud"):
                node["tier"] = "mechanism"
            elif dir_name in ("flydocs-figma", "flydocs-estimates"):
                node["tier"] = "premium"
            else:
                node["tier"] = "core"
        else:
            node["tier"] = "behavioral"

        nodes[node_id] = node

        # Extract PRECEDES edges from loads_after frontmatter
        loads_after = fm.get("loads_after")
        if loads_after:
            if isinstance(loads_after, str):
                loads_after = [loads_after]
            for dep in loads_after:
                dep = dep.strip()
                if dep:
                    # dep PRECEDES this skill (dep should load before this one)
                    edges.append({
                        "from": f"skill:{dep}",
                        "to": node_id,
                        "rel": "PRECEDES",
                        "weight": 0.7,
                    })

    return nodes, edges


# --- ADR scanning ---


def scan_adrs(root):
    """Scan flydocs/knowledge/decisions/*.md and return decision nodes + edges."""
    decisions_dir = root / "flydocs" / "knowledge" / "decisions"
    nodes = {}
    edges = []

    if not decisions_dir.is_dir():
        return nodes, edges

    for adr_file in sorted(decisions_dir.glob("*.md")):
        # Extract ADR number from filename (e.g., 001 from 001-skills-architecture.md)
        num_match = re.match(r"^(\d+)-", adr_file.name)
        if not num_match:
            continue

        number = num_match.group(1)
        node_id = f"decision:{number}"

        text = adr_file.read_text(encoding="utf-8")

        # Extract title from first heading
        title_match = re.search(r"^#\s+(?:ADR-\d+:\s*)?(.+)$", text, re.MULTILINE)
        title = title_match.group(1).strip() if title_match else adr_file.stem

        # Extract status
        status_match = re.search(r"\*\*Status\*\*:\s*(.+?)(?:\n|$)", text)
        status = status_match.group(1).strip() if status_match else "unknown"

        nodes[node_id] = {
            "type": "decision",
            "label": title,
            "path": str(adr_file.relative_to(root)),
            "status": status,
        }

        # Extract cross-references to other ADRs
        # Look for ADR-NNN patterns in relationship sections
        rel_edges = extract_adr_relationships(node_id, text)
        edges.extend(rel_edges)

    return nodes, edges


def extract_adr_relationships(from_id, text):
    """Extract edges from ADR cross-reference sections.

    Looks for sections like "## Relationship to Other ADRs" and parses
    ADR-NNN references with their relationship descriptions.
    """
    edges = []

    # Find relationship sections — various headings used
    rel_section = None
    for pattern in [
        r"##\s+Relationship to Other ADRs\s*\n(.*?)(?=\n##\s|\n---|\Z)",
        r"##\s+Alignment with.*?\n(.*?)(?=\n##\s|\n---|\Z)",
    ]:
        match = re.search(pattern, text, re.DOTALL)
        if match:
            rel_section = match.group(1)
            break

    if not rel_section:
        return edges

    # Parse each ADR reference in the section
    # Pattern: **ADR-NNN (Title)**: description -or- - ADR-NNN: description
    for ref_match in re.finditer(r"ADR-(\d+)", rel_section):
        target_num = ref_match.group(1).zfill(3)
        target_id = f"decision:{target_num}"

        if target_id == from_id:
            continue

        # Determine relationship type from surrounding text
        # Get the sentence/bullet containing this reference
        start = max(0, ref_match.start() - 10)
        end = min(len(rel_section), ref_match.end() + 300)
        context = rel_section[start:end].lower()

        rel_type = classify_relationship(context)

        edges.append({
            "from": from_id,
            "to": target_id,
            "rel": rel_type,
            "weight": 0.8,
        })

    return edges


def classify_relationship(context):
    """Classify the relationship type from surrounding text."""
    if any(w in context for w in ["extends", "refines", "builds on", "extension"]):
        return "EXTENDS"
    if any(w in context for w in ["implements", "realizes", "application of"]):
        return "IMPLEMENTS"
    if any(w in context for w in ["supersedes", "replaces", "replaced by"]):
        return "SUPERSEDES"
    if any(w in context for w in ["delegates", "hands off"]):
        return "DELEGATES_TO"
    if any(w in context for w in ["precedes", "before", "prerequisite"]):
        return "PRECEDES"
    if any(w in context for w in ["modifies", "changes", "affects"]):
        return "MODIFIES"
    return "RELATES_TO"


# --- Service descriptor scanning ---


def scan_service_descriptor(root):
    """Scan flydocs/context/service.json and return repo node + edges."""
    nodes = {}
    edges = []

    service_file = root / "flydocs" / "context" / "service.json"
    if not service_file.is_file():
        return nodes, edges

    import json
    try:
        data = json.loads(service_file.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return nodes, edges

    if data.get("version") not in (1, 2):
        return nodes, edges

    repo_slug = data.get("repoSlug", "")
    if not repo_slug:
        return nodes, edges

    node_id = f"repo:{repo_slug}"
    nodes[node_id] = {
        "type": "repo",
        "label": data.get("name", repo_slug),
        "path": "flydocs/context/service.json",
        "purpose": data.get("purpose", ""),
        "stack": data.get("stack", []),
    }

    # Create CONSUMES edges from dependencies
    for dep in data.get("dependencies", []):
        service = dep.get("service", "")
        if not service:
            continue
        target_id = f"repo:{service}"
        edges.append({
            "from": node_id,
            "to": target_id,
            "rel": "CONSUMES",
            "weight": 1.0,
            "interface": dep.get("interface", ""),
            "description": dep.get("description", ""),
        })

    return nodes, edges


def scan_sibling_descriptors(root):
    """Scan sibling repos' service.json files for cross-repo edges.

    Only reads descriptors from sibling directories that have .flydocs/ and
    flydocs/context/service.json. Does NOT generate descriptors for other repos.
    """
    nodes = {}
    edges = []

    parent = root.parent
    if not parent.is_dir():
        return nodes, edges

    try:
        siblings = sorted(parent.iterdir())
    except PermissionError:
        return nodes, edges

    for sibling in siblings:
        if sibling == root or not sibling.is_dir():
            continue
        try:
            if not (sibling / ".git").exists():
                continue
        except PermissionError:
            continue

        service_file = sibling / "flydocs" / "context" / "service.json"
        if not service_file.is_file():
            continue

        import json
        try:
            data = json.loads(service_file.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            continue

        if data.get("version") not in (1, 2):
            continue

        repo_slug = data.get("repoSlug", "")
        if not repo_slug:
            continue

        node_id = f"repo:{repo_slug}"
        nodes[node_id] = {
            "type": "repo",
            "label": data.get("name", repo_slug),
            "path": str(service_file),
            "purpose": data.get("purpose", ""),
            "stack": data.get("stack", []),
        }

        # Create CONSUMES edges from this sibling's dependencies
        for dep in data.get("dependencies", []):
            service = dep.get("service", "")
            if not service:
                continue
            target_id = f"repo:{service}"
            edges.append({
                "from": node_id,
                "to": target_id,
                "rel": "CONSUMES",
                "weight": 1.0,
                "interface": dep.get("interface", ""),
                "description": dep.get("description", ""),
            })

    return nodes, edges


# --- Graph merge ---


def merge_graph(existing, new_nodes, new_edges):
    """Merge scanned nodes/edges into existing graph, preserving manual entries."""
    graph = {
        "version": 1,
        "updated": "",
        "nodes": {},
        "edges": [],
    }

    # Preserve manual nodes from existing graph
    for node_id, node in existing.get("nodes", {}).items():
        if node.get("manual"):
            graph["nodes"][node_id] = node

    # Add scanned nodes (overwrite non-manual)
    for node_id, node in new_nodes.items():
        graph["nodes"][node_id] = node

    # Preserve manual edges from existing graph
    for edge in existing.get("edges", []):
        if edge.get("manual"):
            graph["edges"].append(edge)

    # Add scanned edges (deduplicate)
    existing_edge_keys = {
        (e["from"], e["to"], e["rel"]) for e in graph["edges"]
    }
    for edge in new_edges:
        key = (edge["from"], edge["to"], edge["rel"])
        if key not in existing_edge_keys:
            graph["edges"].append(edge)
            existing_edge_keys.add(key)

    return graph


# --- Workspace build ---


def build_workspace_graph(workspace_root):
    """Build a workspace-level graph by aggregating child repo graphs.

    Collects all nodes and edges from child repo graphs, prefixes node IDs
    with repo name for disambiguation, and adds cross-repo dependency edges
    from service descriptors.
    """
    repos = read_workspace_repos(workspace_root)
    if not repos:
        fail("No repos found in workspace")

    existing = load_graph(workspace_root)
    all_nodes = {}
    all_edges = []

    for repo_name, repo_path in repos:
        # Load child repo graph
        child_graph = load_graph(repo_path)

        # Add child nodes with repo prefix for non-unique types
        for node_id, node in child_graph.get("nodes", {}).items():
            node_type = node.get("type", "")
            # Session and issue nodes get repo prefix to avoid collisions
            if node_type in ("session", "concept", "module"):
                ws_id = f"{node_id}@{repo_name}"
            else:
                ws_id = node_id

            if ws_id not in all_nodes:
                ws_node = dict(node)
                ws_node["repo"] = repo_name
                all_nodes[ws_id] = ws_node

        # Add child edges with updated references
        for edge in child_graph.get("edges", []):
            from_id = edge["from"]
            to_id = edge["to"]

            # Remap prefixed node types
            from_type = child_graph.get("nodes", {}).get(from_id, {}).get("type", "")
            to_type = child_graph.get("nodes", {}).get(to_id, {}).get("type", "")

            if from_type in ("session", "concept", "module"):
                from_id = f"{from_id}@{repo_name}"
            if to_type in ("session", "concept", "module"):
                to_id = f"{to_id}@{repo_name}"

            ws_edge = dict(edge)
            ws_edge["from"] = from_id
            ws_edge["to"] = to_id
            all_edges.append(ws_edge)

        # Scan service descriptor for cross-repo edges
        svc_nodes, svc_edges = scan_service_descriptor(repo_path)
        all_nodes.update(svc_nodes)
        all_edges.extend(svc_edges)

    # Merge with existing workspace graph
    graph = merge_graph(existing, all_nodes, all_edges)
    save_graph(workspace_root, graph)

    return graph, repos


# --- Main ---


def main():
    parser = argparse.ArgumentParser(
        description="Rebuild context graph from project sources"
    )
    parser.add_argument(
        "--root", type=str, default=None,
        help="Project root (default: auto-detect from .flydocs/)"
    )
    parser.add_argument(
        "--workspace", action="store_true",
        help="Build workspace-level graph aggregating all child repos"
    )
    args = parser.parse_args()

    if args.workspace:
        workspace_root = find_workspace_root()
        if not workspace_root:
            fail("No workspace found (no .flydocs-workspace.json)")

        graph, repos = build_workspace_graph(workspace_root)
        output_json({
            "success": True,
            "path": str(graph_path_for_report(workspace_root)),
            "nodes": len(graph["nodes"]),
            "edges": len(graph["edges"]),
            "repos": len(repos),
            "repoNames": [name for name, _ in repos],
        })
        return

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

    # Load existing graph to preserve manual entries
    existing = load_graph(root)

    # Scan sources
    skill_nodes, skill_edges = scan_skills(root)
    adr_nodes, adr_edges = scan_adrs(root)
    service_nodes, service_edges = scan_service_descriptor(root)
    sibling_nodes, sibling_edges = scan_sibling_descriptors(root)

    # Merge all scanned nodes and edges
    all_nodes = {}
    all_nodes.update(skill_nodes)
    all_nodes.update(adr_nodes)
    all_nodes.update(service_nodes)
    all_nodes.update(sibling_nodes)

    all_edges = []
    all_edges.extend(skill_edges)
    all_edges.extend(adr_edges)
    all_edges.extend(service_edges)
    all_edges.extend(sibling_edges)

    # Merge with existing graph (preserving manual entries)
    graph = merge_graph(existing, all_nodes, all_edges)

    # Save
    save_graph(root, graph)

    # Report
    repo_count = len(service_nodes) + len(sibling_nodes)
    output_json({
        "success": True,
        "path": str(graph_path_for_report(root)),
        "nodes": len(graph["nodes"]),
        "edges": len(graph["edges"]),
        "skills": len(skill_nodes),
        "decisions": len(adr_nodes),
        "repos": repo_count,
    })


def graph_path_for_report(root):
    """Return the relative graph path for reporting."""
    return os.path.join("flydocs", "context", "graph.json")


if __name__ == "__main__":
    main()
