#!/usr/bin/env python3
"""Shared utilities for context graph scripts."""

import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path


GRAPH_REL_PATH = os.path.join("flydocs", "context", "graph.json")

VALID_NODE_TYPES = {"skill", "decision", "issue", "module", "session", "concept", "repo"}
VALID_REL_TYPES = {
    "EXTENDS", "IMPLEMENTS", "DELEGATES_TO", "PRECEDES", "MODIFIES",
    "WORKED_ON", "PRODUCED", "RELATES_TO", "SUPERSEDES", "BLOCKS",
    "PROVIDES", "CONSUMES",
}

# A bare issue reference as stage docs and humans write it: FLY-123, ENG-7.
BARE_ISSUE_REF_RE = re.compile(r"^[A-Za-z][A-Za-z0-9]*-\d+$")
# Any already-prefixed node ID: <type>:<local part>.
PREFIXED_NODE_ID_RE = re.compile(r"^([A-Za-z]+):(.+)$")


def normalize_node_id(node_id, nodes=None):
    """Canonicalize a node reference to the ID scheme the build side emits.

    `graph_build.py` and `graph_session.py` are the authority for IDs (see
    reference/graph-schema.md): `issue:FLY-56`, `decision:001`,
    `skill:kebab-name`, `repo:owner/name`. Queries arrive in looser shapes —
    a bare issue ref pasted from a stage doc, a lowercased prefix, an
    unpadded ADR number — and an exact-match lookup turns every one of those
    into "Node not found", which callers are told to skip silently.

    An exact hit in `nodes` always wins, so a graph that legitimately holds an
    unusual ID is never normalized away from itself.
    """
    if not isinstance(node_id, str):
        return node_id

    candidate = node_id.strip()
    if nodes and candidate in nodes:
        return candidate

    if BARE_ISSUE_REF_RE.match(candidate):
        candidate = f"issue:{candidate.upper()}"
    else:
        match = PREFIXED_NODE_ID_RE.match(candidate)
        if match:
            prefix = match.group(1).lower()
            local = match.group(2).strip()
            if prefix == "issue":
                local = local.upper()
            elif prefix == "decision" and local.isdigit():
                local = local.zfill(3)
            candidate = f"{prefix}:{local}"

    if nodes and candidate not in nodes:
        # Last resort for the ID families with no casing rule of their own —
        # repo slugs and skill directory names.
        lowered = candidate.lower()
        for existing in nodes:
            if existing.lower() == lowered:
                return existing

    return candidate


def normalize_rel_type(rel):
    """Uppercase a relationship name. Relations are uppercase by schema."""
    return rel.strip().upper() if isinstance(rel, str) else rel


def find_project_root(start=None):
    """Walk up from start (or cwd) to find the directory containing .flydocs/."""
    current = Path(start) if start else Path.cwd()
    for parent in [current] + list(current.parents):
        if (parent / ".flydocs").is_dir():
            return parent
    return None


def find_workspace_root(start=None):
    """Find the workspace root by looking for .flydocs-workspace.json."""
    current = Path(start) if start else Path.cwd()
    for parent in [current] + list(current.parents):
        if (parent / ".flydocs-workspace.json").is_file():
            return parent
    return None


def read_workspace_repos(workspace_root):
    """Read repo entries from .flydocs-workspace.json.

    Returns list of (name, absolute_path) tuples.
    """
    ws_file = workspace_root / ".flydocs-workspace.json"
    if not ws_file.is_file():
        return []
    try:
        data = json.loads(ws_file.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError):
        return []

    repos = []
    for name, entry in data.get("repos", {}).items():
        repo_path = (workspace_root / entry.get("path", "")).resolve()
        if repo_path.is_dir():
            repos.append((name, repo_path))
    return repos


def graph_path(root):
    """Return the absolute path to graph.json."""
    return os.path.join(str(root), GRAPH_REL_PATH)


def empty_graph():
    """Return a new empty graph structure."""
    return {
        "version": 1,
        "updated": datetime.now(timezone.utc).isoformat(),
        "nodes": {},
        "edges": [],
    }


def load_graph(root):
    """Load graph.json from the project root. Returns empty graph if missing."""
    path = graph_path(root)
    if not os.path.exists(path):
        return empty_graph()
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_graph(root, graph):
    """Write graph.json to the project root. Creates parent dirs if needed."""
    path = graph_path(root)
    os.makedirs(os.path.dirname(path), exist_ok=True)
    graph["updated"] = datetime.now(timezone.utc).isoformat()
    with open(path, "w", encoding="utf-8") as f:
        json.dump(graph, f, indent=2)
        f.write("\n")


def output_json(data):
    """Print JSON to stdout."""
    print(json.dumps(data, indent=2))


def fail(message):
    """Print error to stderr and exit 1."""
    print(message, file=sys.stderr)
    sys.exit(1)


def parse_frontmatter(text):
    """Parse YAML frontmatter from a SKILL.md file (no PyYAML dependency).

    Returns dict with 'name', 'description', 'triggers' keys or None.
    """
    match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
    if not match:
        return None

    block = match.group(1)
    result = {}
    current_key = None
    current_list = None

    for line in block.split("\n"):
        # List item
        if re.match(r"^\s+-\s+", line) and current_key:
            value = re.sub(r"^\s+-\s+", "", line).strip()
            if current_list is None:
                current_list = []
            current_list.append(value)
            result[current_key] = current_list
            continue

        # Key: value
        kv = re.match(r"^(\w+)\s*:\s*(.*)", line)
        if kv:
            if current_list is not None:
                current_list = None
            current_key = kv.group(1)
            value = kv.group(2).strip().rstrip("|").rstrip(">").strip()
            if value:
                result[current_key] = value
            continue

        # Continuation of block scalar
        if current_key and current_key in result and isinstance(result[current_key], str):
            result[current_key] = result[current_key] + " " + line.strip()

    # Clean up description whitespace
    if "description" in result and isinstance(result["description"], str):
        result["description"] = re.sub(r"\s+", " ", result["description"]).strip()

    return result
