#!/usr/bin/env python3
"""graph-builder.py — Construye el grafo de dependencias de swl-ses.

Parsea el frontmatter YAML de todos los agentes, skills, hooks y librerías
del sistema para construir un DiGraph de NetworkX con nodos y aristas
tipadas. El grafo se persiste en .planning/graph.json (formato node-link
de networkx) y se genera un reporte legible en .planning/REPORTE-GRAFO.md.

Tipos de nodo:
  - agente   : agentes/XXX.md
  - skill    : habilidades/XXX/SKILL.md
  - hook     : hooks/XXX.js
  - hook-lib : hooks/lib/XXX.js
  - comando  : comandos/swl/XXX.md

Tipos de arista:
  - invoca          : agente → skill (skillsInvocables en frontmatter)
  - usa_herramienta : agente → herramienta (tools en frontmatter)
  - depende_de      : hook → hook-lib (require() detectado en código)
  - usa_modelo      : agente → modelo (model field)

Uso:
  python scripts/lib/graph-builder.py          -- construir/reconstruir
  python scripts/lib/graph-builder.py --force  -- ignorar cache SHA256
  python scripts/lib/graph-builder.py --stats  -- mostrar estadísticas
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path

# networkx es opcional: si no está disponible, el builder muestra aviso
# pero el resto del sistema sigue funcionando.
try:
    import networkx as nx
    HAS_NX = True
except ImportError:
    HAS_NX = False

# ---------------------------------------------------------------------------
# Constantes
# ---------------------------------------------------------------------------

GRAPH_FILE   = Path('.planning') / 'graph.json'
REPORT_FILE  = Path('.planning') / 'REPORTE-GRAFO.md'
CACHE_FILE   = Path('.planning') / 'graph-cache.json'

FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---', re.DOTALL)
REQUIRE_RE     = re.compile(r"""require\(['"]\.\.?/(?:lib/)?([^'"]+)['"]\)""")
PATH_JOIN_RE   = re.compile(r"""path\.join\(__dirname,\s*['"]lib['"]\s*,\s*['"]([^'"]+)\.js['"]\)""")

# ---------------------------------------------------------------------------
# SHA-256 cache
# ---------------------------------------------------------------------------

def _file_hash(path: Path) -> str:
    """SHA256 de contenido + ruta resuelta para evitar colisiones."""
    h = hashlib.sha256()
    h.update(path.read_bytes())
    h.update(b'\x00')
    h.update(str(path.resolve()).encode())
    return h.hexdigest()


def _load_cache(cwd: Path) -> dict:
    p = cwd / CACHE_FILE
    if p.exists():
        try:
            return json.loads(p.read_text(encoding='utf-8'))
        except Exception:
            pass
    return {}


def _save_cache(cwd: Path, cache: dict) -> None:
    p = cwd / CACHE_FILE
    p.parent.mkdir(parents=True, exist_ok=True)
    tmp = p.with_suffix('.tmp')
    try:
        tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding='utf-8')
        os.replace(tmp, p)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise

# ---------------------------------------------------------------------------
# Parseo de frontmatter YAML (zero-deps)
# ---------------------------------------------------------------------------

def _parse_frontmatter(text: str) -> dict:
    """Parsea frontmatter YAML mínimo: strings, listas y valores simples."""
    m = FRONTMATTER_RE.match(text)
    if not m:
        return {}
    result: dict = {}
    current_key: str | None = None
    current_list: list | None = None

    for line in m.group(1).splitlines():
        # Lista item
        if current_list is not None and line.startswith('  - '):
            current_list.append(line[4:].strip())
            continue
        # Fin de lista en contexto de otro bloque
        if current_list is not None and not line.startswith('  '):
            current_list = None

        if ':' not in line:
            continue
        key, _, raw = line.partition(':')
        key  = key.strip()
        raw  = raw.strip()

        if not key:
            continue

        # Lista multilínea: key:\n  - item
        if raw == '':
            current_key = key
            current_list = []
            result[key]  = current_list
            continue

        # Lista en una sola línea: key: [a, b]
        if raw.startswith('[') and raw.endswith(']'):
            items = [x.strip().strip('"\'') for x in raw[1:-1].split(',') if x.strip()]
            result[key] = items
            continue

        # Valor escalar
        result[key] = raw.strip('"\'')
        current_list = None

    return result


# ---------------------------------------------------------------------------
# Extracción de nodos desde el codebase
# ---------------------------------------------------------------------------

def _extract_agents(cwd: Path, cache: dict, force: bool) -> list[dict]:
    nodes = []
    for md in sorted((cwd / 'agentes').glob('*.md')):
        h     = _file_hash(md)
        key   = str(md)
        entry = cache.get(key, {})
        if not force and entry.get('hash') == h and entry.get('node'):
            nodes.append(entry['node'])
            continue
        text = md.read_text(encoding='utf-8')
        fm   = _parse_frontmatter(text)
        if not fm.get('name'):
            continue
        node = {
            'id':          fm['name'],
            'label':       fm['name'],
            'type':        'agente',
            'source_file': str(md.relative_to(cwd)),
            'model':       fm.get('model', ''),
            'riesgo':      fm.get('nivelRiesgo', ''),
            'version':     fm.get('version', ''),
            'skills':      fm.get('skillsInvocables', []),
            'tools':       [t.strip() for t in fm.get('tools', '').split(',') if t.strip()],
        }
        cache[key] = {'hash': h, 'node': node}
        nodes.append(node)
    return nodes


def _extract_skills(cwd: Path, cache: dict, force: bool) -> list[dict]:
    nodes = []
    for skill_md in sorted((cwd / 'habilidades').rglob('SKILL.md')):
        h     = _file_hash(skill_md)
        key   = str(skill_md)
        entry = cache.get(key, {})
        if not force and entry.get('hash') == h and entry.get('node'):
            nodes.append(entry['node'])
            continue
        text = skill_md.read_text(encoding='utf-8')
        fm   = _parse_frontmatter(text)
        if not fm.get('name'):
            continue
        node = {
            'id':          fm['name'],
            'label':       fm['name'],
            'type':        'skill',
            'source_file': str(skill_md.relative_to(cwd)),
            'version':     fm.get('version', ''),
            'description': fm.get('description', ''),
        }
        cache[key] = {'hash': h, 'node': node}
        nodes.append(node)
    return nodes


def _extract_hooks(cwd: Path, cache: dict, force: bool) -> list[dict]:
    nodes = []
    # Hooks principales
    for js in sorted((cwd / 'hooks').glob('*.js')):
        h     = _file_hash(js)
        key   = str(js)
        entry = cache.get(key, {})
        if not force and entry.get('hash') == h and entry.get('node'):
            nodes.append(entry['node'])
            continue
        text = js.read_text(encoding='utf-8', errors='replace')
        deps = REQUIRE_RE.findall(text) + PATH_JOIN_RE.findall(text)
        node = {
            'id':          js.stem,
            'label':       js.stem,
            'type':        'hook',
            'source_file': str(js.relative_to(cwd)),
            'deps':        [Path(d).stem for d in deps],
        }
        cache[key] = {'hash': h, 'node': node}
        nodes.append(node)
    # Librerías de hooks
    for js in sorted((cwd / 'hooks' / 'lib').glob('*.js')):
        h     = _file_hash(js)
        key   = str(js)
        entry = cache.get(key, {})
        if not force and entry.get('hash') == h and entry.get('node'):
            nodes.append(entry['node'])
            continue
        text = js.read_text(encoding='utf-8', errors='replace')
        deps = REQUIRE_RE.findall(text)
        node = {
            'id':          js.stem,
            'label':       js.stem,
            'type':        'hook-lib',
            'source_file': str(js.relative_to(cwd)),
            'deps':        [Path(d).stem for d in deps],
        }
        cache[key] = {'hash': h, 'node': node}
        nodes.append(node)
    return nodes


def _extract_commands(cwd: Path, cache: dict, force: bool) -> list[dict]:
    nodes = []
    for cmd in sorted((cwd / 'comandos' / 'swl').glob('*.md')):
        h     = _file_hash(cmd)
        key   = str(cmd)
        entry = cache.get(key, {})
        if not force and entry.get('hash') == h and entry.get('node'):
            nodes.append(entry['node'])
            continue
        name = 'swl:' + cmd.stem
        node = {
            'id':          name,
            'label':       name,
            'type':        'comando',
            'source_file': str(cmd.relative_to(cwd)),
        }
        cache[key] = {'hash': h, 'node': node}
        nodes.append(node)
    return nodes


# ---------------------------------------------------------------------------
# Construcción del grafo
# ---------------------------------------------------------------------------

def build_graph(cwd: Path, force: bool = False) -> 'nx.DiGraph':
    """Construye el DiGraph de dependencias de swl-ses."""
    if not HAS_NX:
        sys.stderr.write('[graph-builder] ERROR: networkx no está instalado. Ejecuta: pip install networkx\n')
        sys.exit(1)

    cache = _load_cache(cwd)
    G     = nx.DiGraph()

    agents   = _extract_agents(cwd, cache, force)
    skills   = _extract_skills(cwd, cache, force)
    hooks    = _extract_hooks(cwd, cache, force)
    commands = _extract_commands(cwd, cache, force)

    # Agregar nodos al grafo
    for node in agents + skills + hooks + commands:
        nid = node['id']
        G.add_node(nid, **{k: v for k, v in node.items() if k != 'id'})

    # Aristas: agente → skills invocables
    skill_ids = {n['id'] for n in skills}
    for node in agents:
        raw_skills = node.get('skills', [])
        # skillsInvocables puede ser string CSV en algunos agentes
        if isinstance(raw_skills, str):
            # "todos" = el agente puede invocar cualquier skill del sistema
            if raw_skills.strip().lower() == 'todos':
                raw_skills = list(skill_ids)
            else:
                raw_skills = [s.strip() for s in raw_skills.split(',') if s.strip()]
        for skill_name in raw_skills:
            if skill_name in G.nodes:
                G.add_edge(node['id'], skill_name,
                           relation='invoca', confidence='EXTRACTED',
                           source_file=node.get('source_file', ''))
            # Si el skill no existe como nodo, no crear arista colgante

    # Aristas: agente → modelo
    model_set: set[str] = set()
    for node in agents:
        model = node.get('model', '')
        if model:
            if model not in G.nodes:
                G.add_node(model, label=model, type='modelo', source_file='')
                model_set.add(model)
            G.add_edge(node['id'], model,
                       relation='usa_modelo', confidence='EXTRACTED',
                       source_file=node.get('source_file', ''))

    # Aristas: hook / hook-lib → deps (require)
    hook_lib_ids = {n['id'] for n in hooks}
    for node in hooks:
        for dep in node.get('deps', []):
            if dep in G.nodes:
                G.add_edge(node['id'], dep,
                           relation='depende_de', confidence='EXTRACTED',
                           source_file=node.get('source_file', ''))

    _save_cache(cwd, cache)
    return G


# ---------------------------------------------------------------------------
# Serialización
# ---------------------------------------------------------------------------

def save_graph(G: 'nx.DiGraph', cwd: Path) -> None:
    """Persiste el grafo en .planning/graph.json (formato node-link)."""
    out = cwd / GRAPH_FILE
    out.parent.mkdir(parents=True, exist_ok=True)
    data = nx.node_link_data(G, edges='links')
    tmp  = out.with_suffix('.tmp')
    try:
        tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
        os.replace(tmp, out)
    except Exception:
        tmp.unlink(missing_ok=True)
        raise


def load_graph(cwd: Path) -> 'nx.DiGraph | None':
    """Carga el grafo desde .planning/graph.json. Retorna None si no existe."""
    if not HAS_NX:
        return None
    p = cwd / GRAPH_FILE
    if not p.exists():
        return None
    try:
        data = json.loads(p.read_text(encoding='utf-8'))
        return nx.node_link_graph(data, directed=True, edges='links')
    except Exception:
        return None


# ---------------------------------------------------------------------------
# Reporte legible
# ---------------------------------------------------------------------------

def _node_type_counts(G: 'nx.DiGraph') -> dict[str, int]:
    counts: dict[str, int] = {}
    for _, data in G.nodes(data=True):
        t = data.get('type', 'desconocido')
        counts[t] = counts.get(t, 0) + 1
    return counts


def generate_report(G: 'nx.DiGraph', cwd: Path) -> None:
    """Genera .planning/REPORTE-GRAFO.md con resumen topológico."""
    from datetime import datetime

    counts   = _node_type_counts(G)
    degree   = dict(G.degree())
    top10    = sorted(degree.items(), key=lambda x: x[1], reverse=True)[:10]

    # Detectar nodos sin aristas (posibles huérfanos)
    orphans = [n for n, d in degree.items()
               if d == 0 and G.nodes[n].get('type') not in ('modelo',)]

    lines = [
        '# REPORTE-GRAFO.md — Grafo de dependencias SWL',
        f'',
        f'> Generado: {datetime.now().strftime("%Y-%m-%d %H:%M")}',
        f'> Nodos: {G.number_of_nodes()}  |  Aristas: {G.number_of_edges()}',
        '',
        '## Inventario por tipo',
        '',
    ]
    for tipo, cnt in sorted(counts.items()):
        lines.append(f'- **{tipo}**: {cnt}')

    lines += [
        '',
        '## Nodos más conectados (god nodes)',
        '',
        '| Nodo | Tipo | Grado |',
        '|------|------|-------|',
    ]
    for nid, deg in top10:
        t = G.nodes[nid].get('type', '?')
        lines.append(f'| `{nid}` | {t} | {deg} |')

    if orphans:
        lines += [
            '',
            f'## Nodos huérfanos ({len(orphans)})',
            '',
            '_Componentes sin conexiones declaradas — posibles gaps de documentación:_',
            '',
        ]
        for n in sorted(orphans):
            t = G.nodes[n].get('type', '?')
            lines.append(f'- `{n}` ({t})')

    lines += [
        '',
        '## Cómo usar este reporte',
        '',
        '- `god nodes` = componentes más invocados — modificarlos tiene alto blast radius',
        '- `huérfanos` = skills/hooks sin aristas declaradas — revisar si faltan dependencias',
        '- Para análisis topológico completo: `python scripts/lib/graph-analyze.py`',
        '- Para detección de comunidades: `python scripts/lib/graph-cluster.py`',
        '',
    ]

    out = cwd / REPORT_FILE
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text('\n'.join(lines), encoding='utf-8')


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    if hasattr(sys.stdout, 'reconfigure'):
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    parser = argparse.ArgumentParser(
        description='Construye el grafo de dependencias de swl-ses'
    )
    parser.add_argument('--force',  action='store_true', help='Ignorar cache SHA256')
    parser.add_argument('--stats',  action='store_true', help='Solo mostrar estadísticas del grafo existente')
    parser.add_argument('--cwd',    default='.', help='Directorio raíz del proyecto')
    args = parser.parse_args()

    cwd = Path(args.cwd).resolve()

    out = sys.stdout.write

    if args.stats:
        G = load_graph(cwd)
        if G is None:
            sys.stderr.write('No existe .planning/graph.json — ejecuta sin --stats para construirlo.\n')
            sys.exit(1)
        counts = _node_type_counts(G)
        out(f'\nGrafo de dependencias SWL ({G.number_of_nodes()} nodos, {G.number_of_edges()} aristas)\n')
        out('-' * 52 + '\n')
        for tipo, cnt in sorted(counts.items()):
            out(f'  {tipo:<15} {cnt}\n')
        out('\n')
        return

    out('[graph-builder] Construyendo grafo de dependencias...\n')
    G = build_graph(cwd, force=args.force)
    counts = _node_type_counts(G)
    out(f'  Nodos: {G.number_of_nodes()}  |  Aristas: {G.number_of_edges()}\n')
    for tipo, cnt in sorted(counts.items()):
        out(f'    {tipo:<15} {cnt}\n')

    save_graph(G, cwd)
    out(f'  [OK] Guardado:{GRAPH_FILE}\n')

    generate_report(G, cwd)
    out(f'  [OK] Reporte:{REPORT_FILE}\n')


if __name__ == '__main__':
    main()
