#!/usr/bin/env python3
"""graph-analyze.py — Análisis topológico del grafo de dependencias SWL.

Portado y adaptado desde graphify-3/graphify/analyze.py para el dominio
específico de swl-ses: agentes, skills, hooks, hook-libs y comandos.

Funciones principales:
  god_nodes(G, top_n)             → nodos más conectados del sistema
  blast_radius(G, node_id, depth) → nodos afectados al cambiar un componente
  graph_diff(G_old, G_new)        → qué cambió entre dos snapshots del grafo
  surprising_connections(G, ...)  → aristas cross-community inesperadas
  suggest_questions(G, ...)       → preguntas arquitectónicas derivadas del grafo

Uso CLI:
  python scripts/lib/graph-analyze.py                        → god nodes
  python scripts/lib/graph-analyze.py god_nodes [--top N]    → top N god nodes
  python scripts/lib/graph-analyze.py blast_radius <nombre>  → blast radius
  python scripts/lib/graph-analyze.py diff <graph1> <graph2> → diff entre grafos
  python scripts/lib/graph-analyze.py sorpresivo             → aristas inesperadas
  python scripts/lib/graph-analyze.py preguntas              → preguntas del grafo
"""
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

try:
    import networkx as nx
    HAS_NX = True
except ImportError:
    HAS_NX = False

# ---------------------------------------------------------------------------
# Utilidades internas
# ---------------------------------------------------------------------------

def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
    """Invierte el mapa de comunidades: node_id → community_id."""
    return {n: cid for cid, nodes in communities.items() for n in nodes}


def _is_modelo_node(G: 'nx.DiGraph', node_id: str) -> bool:
    """Los nodos de tipo 'modelo' son sintéticos, no son componentes reales."""
    return G.nodes[node_id].get('type') == 'modelo'


# ---------------------------------------------------------------------------
# god_nodes
# ---------------------------------------------------------------------------

def god_nodes(G: 'nx.DiGraph', top_n: int = 10) -> list[dict]:
    """Retorna los top_n nodos más conectados del sistema.

    Los nodos de tipo 'modelo' se excluyen — acumulan aristas mecánicamente
    y no representan abstracciones arquitectónicas reales.

    Returns:
        [{"id": ..., "label": ..., "type": ..., "grado": ..., "descripcion": ...}]
    """
    degree = dict(G.degree())
    sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
    result = []
    for node_id, deg in sorted_nodes:
        if _is_modelo_node(G, node_id):
            continue
        attrs = G.nodes[node_id]
        result.append({
            'id':          node_id,
            'label':       attrs.get('label', node_id),
            'type':        attrs.get('type', '?'),
            'grado':       deg,
            'descripcion': attrs.get('description', ''),
        })
        if len(result) >= top_n:
            break
    return result


# ---------------------------------------------------------------------------
# blast_radius
# ---------------------------------------------------------------------------

def blast_radius(
    G: 'nx.DiGraph',
    node_id: str,
    depth: int = 3,
) -> dict:
    """Calcula el blast radius de un nodo: qué componentes quedan afectados
    si ese nodo cambia o se elimina.

    Usa BFS desde node_id en el grafo INVERTIDO (predecesores → quién depende
    de él). También incluye los sucesores directos (qué usa este nodo).

    Args:
        G       : DiGraph del sistema
        node_id : ID del nodo a analizar (ej: 'event-bus', 'fastapi-experto')
        depth   : Profundidad máxima del BFS (default: 3)

    Returns:
        {
          "nodo": str,
          "tipo": str,
          "dependientes": [{"id": ..., "tipo": ..., "distancia": ...}],
          "dependencias":  [{"id": ..., "tipo": ..., "relacion": ...}],
          "resumen": str
        }
    """
    if node_id not in G.nodes:
        return {
            'nodo': node_id,
            'tipo': '?',
            'dependientes': [],
            'dependencias': [],
            'resumen': f'Nodo "{node_id}" no encontrado en el grafo.',
        }

    G_rev = G.reverse(copy=False)

    # BFS en grafo invertido → quién depende de node_id (afectados si cambia)
    dependientes = []
    visited: set[str] = {node_id}
    queue  = [(n, 1) for n in G_rev.successors(node_id)]

    while queue:
        current, dist = queue.pop(0)
        if current in visited or dist > depth:
            continue
        visited.add(current)
        attrs = G.nodes[current]
        if not _is_modelo_node(G, current):
            dependientes.append({
                'id':       current,
                'tipo':     attrs.get('type', '?'),
                'distancia': dist,
            })
        if dist < depth:
            queue.extend((n, dist + 1) for n in G_rev.successors(current) if n not in visited)

    # Sucesores directos → qué usa este nodo (sus dependencias)
    dependencias = []
    for succ in G.successors(node_id):
        if _is_modelo_node(G, succ):
            continue
        edge_data   = G.edges[node_id, succ]
        attrs_succ  = G.nodes[succ]
        dependencias.append({
            'id':      succ,
            'tipo':    attrs_succ.get('type', '?'),
            'relacion': edge_data.get('relation', '?'),
        })

    tipo_nodo = G.nodes[node_id].get('type', '?')
    resumen = (
        f'"{node_id}" ({tipo_nodo}) — '
        f'{len(dependientes)} componentes afectados si cambia, '
        f'{len(dependencias)} dependencias directas.'
    )

    return {
        'nodo':         node_id,
        'tipo':         tipo_nodo,
        'dependientes': dependientes,
        'dependencias': dependencias,
        'resumen':      resumen,
    }


# ---------------------------------------------------------------------------
# graph_diff
# ---------------------------------------------------------------------------

def graph_diff(G_old: 'nx.DiGraph', G_new: 'nx.DiGraph') -> dict:
    """Compara dos snapshots del grafo y retorna qué cambió.

    Returns:
        {
          "nodos_nuevos":   [{"id": ..., "tipo": ..., "label": ...}],
          "nodos_eliminados": [...],
          "aristas_nuevas":  [{"origen": ..., "destino": ..., "relacion": ...}],
          "aristas_eliminadas": [...],
          "resumen": str
        }
    """
    old_nodes = set(G_old.nodes())
    new_nodes = set(G_new.nodes())

    nodos_nuevos = [
        {'id': n, 'tipo': G_new.nodes[n].get('type', '?'), 'label': G_new.nodes[n].get('label', n)}
        for n in (new_nodes - old_nodes)
    ]
    nodos_eliminados = [
        {'id': n, 'tipo': G_old.nodes[n].get('type', '?'), 'label': G_old.nodes[n].get('label', n)}
        for n in (old_nodes - new_nodes)
    ]

    def edge_key(u: str, v: str, data: dict) -> tuple:
        return (u, v, data.get('relation', ''))

    old_edges = {edge_key(u, v, d) for u, v, d in G_old.edges(data=True)}
    new_edges = {edge_key(u, v, d) for u, v, d in G_new.edges(data=True)}

    aristas_nuevas = [
        {'origen': u, 'destino': v, 'relacion': d.get('relation', '')}
        for u, v, d in G_new.edges(data=True)
        if edge_key(u, v, d) in (new_edges - old_edges)
    ]
    aristas_eliminadas = [
        {'origen': u, 'destino': v, 'relacion': d.get('relation', '')}
        for u, v, d in G_old.edges(data=True)
        if edge_key(u, v, d) in (old_edges - new_edges)
    ]

    partes = []
    if nodos_nuevos:
        partes.append(f'{len(nodos_nuevos)} nodo(s) nuevo(s)')
    if nodos_eliminados:
        partes.append(f'{len(nodos_eliminados)} nodo(s) eliminado(s)')
    if aristas_nuevas:
        partes.append(f'{len(aristas_nuevas)} arista(s) nueva(s)')
    if aristas_eliminadas:
        partes.append(f'{len(aristas_eliminadas)} arista(s) eliminada(s)')
    resumen = ', '.join(partes) if partes else 'sin cambios'

    return {
        'nodos_nuevos':        nodos_nuevos,
        'nodos_eliminados':    nodos_eliminados,
        'aristas_nuevas':      aristas_nuevas,
        'aristas_eliminadas':  aristas_eliminadas,
        'resumen':             resumen,
    }


# ---------------------------------------------------------------------------
# surprising_connections
# ---------------------------------------------------------------------------

def surprising_connections(
    G: 'nx.DiGraph',
    communities: dict[int, list[str]] | None = None,
    top_n: int = 5,
) -> list[dict]:
    """Detecta aristas cross-community inesperadas — acoplamiento oculto.

    En el grafo de swl-ses, "sorpresivo" significa:
    - Un agente que depende de un skill de otro dominio
    - Un hook que usa una lib de otro módulo funcional
    - Una conexión que Louvain detecta como cross-community

    Returns:
        [{"origen": ..., "destino": ..., "relacion": ..., "nota": ..., "score": ...}]
    """
    comms = communities or {}
    node_community = _node_community_map(comms)
    candidatos = []

    # Aristas que cruzan comunidades
    for u, v, data in G.edges(data=True):
        if _is_modelo_node(G, u) or _is_modelo_node(G, v):
            continue
        cid_u = node_community.get(u)
        cid_v = node_community.get(v)

        score   = 0
        razones = []

        # Cross-community es el indicador principal
        if cid_u is not None and cid_v is not None and cid_u != cid_v:
            score   += 3
            razones.append(f'cruza comunidades {cid_u}→{cid_v}')

        # Tipos heterogéneos son más sorpresivos
        tipo_u = G.nodes[u].get('type', '')
        tipo_v = G.nodes[v].get('type', '')
        if tipo_u != tipo_v:
            score   += 2
            razones.append(f'conecta tipos distintos ({tipo_u} ↔ {tipo_v})')

        # Nodo periférico conectado a un hub
        deg_u = G.degree(u)
        deg_v = G.degree(v)
        if min(deg_u, deg_v) <= 2 and max(deg_u, deg_v) >= 5:
            score   += 1
            periferico = u if deg_u <= 2 else v
            hub        = v if deg_u <= 2 else u
            razones.append(f'nodo periférico `{periferico}` conecta a hub `{hub}`')

        if score > 0:
            candidatos.append({
                '_score':  score,
                'origen':  u,
                'destino': v,
                'relacion': data.get('relation', '?'),
                'tipo_origen':  tipo_u,
                'tipo_destino': tipo_v,
                'nota':    '; '.join(razones) if razones else 'conexión cross-community',
            })

    candidatos.sort(key=lambda x: x['_score'], reverse=True)
    for c in candidatos:
        c.pop('_score')

    return candidatos[:top_n]


# ---------------------------------------------------------------------------
# suggest_questions
# ---------------------------------------------------------------------------

def suggest_questions(
    G: 'nx.DiGraph',
    communities: dict[int, list[str]] | None = None,
    top_n: int = 7,
) -> list[dict]:
    """Genera preguntas arquitectónicas derivadas de la topología del grafo.

    Fuentes de preguntas:
    1. Nodos bridge (alta betweenness centrality)
    2. God nodes con muchos dependientes (posibles bottlenecks)
    3. Nodos huérfanos (posibles gaps de documentación)
    4. Comunidades de baja cohesión (candidatos a refactoring)

    Returns:
        [{"tipo": ..., "pregunta": ..., "por_que": ...}]
    """
    try:
        from scripts.lib.graph_cluster import cohesion_score as _cohesion
    except ImportError:
        _cohesion = None

    comms         = communities or {}
    node_community = _node_community_map(comms)
    preguntas     = []

    # 1. Bridge nodes — alta betweenness centrality
    if G.number_of_edges() > 0:
        betweenness = nx.betweenness_centrality(G)
        bridges = sorted(
            [(n, s) for n, s in betweenness.items()
             if not _is_modelo_node(G, n) and s > 0],
            key=lambda x: x[1],
            reverse=True,
        )[:3]
        for node_id, score in bridges:
            tipo   = G.nodes[node_id].get('type', '?')
            vecinos_comms = {node_community.get(n) for n in G.neighbors(node_id)
                             if node_community.get(n) != node_community.get(node_id)}
            if vecinos_comms:
                preguntas.append({
                    'tipo':    'bridge_node',
                    'pregunta': f'¿Por qué `{node_id}` ({tipo}) conecta múltiples módulos del sistema?',
                    'por_que':  f'Alta centralidad de intermediación ({score:.3f}) — posible God Object.',
                })

    # 2. God nodes con > 5 dependientes directos
    gn = god_nodes(G, top_n=5)
    for n in gn:
        if n['grado'] > 5:
            preguntas.append({
                'tipo':    'god_node',
                'pregunta': f'¿Se puede descomponer `{n["id"]}` ({n["type"]}) para reducir su blast radius?',
                'por_que':  f'Grado {n["grado"]} — modificarlo afecta muchos componentes.',
            })

    # 3. Nodos huérfanos
    orphans = [
        n for n in G.nodes()
        if G.degree(n) == 0 and not _is_modelo_node(G, n)
    ]
    if orphans:
        labels = [G.nodes[n].get('label', n) for n in orphans[:3]]
        preguntas.append({
            'tipo':    'nodos_huerfanos',
            'pregunta': f'¿Qué conecta {", ".join(f"`{l}`" for l in labels)} al resto del sistema?',
            'por_que':  f'{len(orphans)} componente(s) sin aristas declaradas — posibles gaps.',
        })

    # 4. Comunidades de baja cohesión
    if comms and _cohesion:
        for cid, nodes in comms.items():
            score = _cohesion(G, nodes)
            if score < 0.15 and len(nodes) >= 5:
                preguntas.append({
                    'tipo':    'baja_cohesion',
                    'pregunta': f'¿Debería dividirse la comunidad {cid} en módulos más pequeños?',
                    'por_que':  f'Cohesión {score:.2f} — nodos débilmente interconectados.',
                })

    if not preguntas:
        preguntas.append({
            'tipo':    'sin_señal',
            'pregunta': None,
            'por_que':  'No hay suficiente señal topológica. Ejecuta graph-builder con --force para reconstruir.',
        })

    return preguntas[:top_n]


# ---------------------------------------------------------------------------
# I/O helpers
# ---------------------------------------------------------------------------

def _load_graph_from_file(path: Path) -> 'nx.DiGraph | None':
    if not HAS_NX:
        sys.stderr.write('[graph-analyze] ERROR: networkx no instalado.\n')
        return None
    if not path.exists():
        sys.stderr.write(f'[graph-analyze] No encontrado: {path}\n')
        return None
    data = json.loads(path.read_text(encoding='utf-8'))
    return nx.node_link_graph(data, directed=True, edges='links')


def _default_graph_path(cwd: Path) -> Path:
    return cwd / '.planning' / 'graph.json'


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def _write_god_nodes(G: 'nx.DiGraph', top_n: int = 10) -> None:
    nodes = god_nodes(G, top_n)
    out = sys.stdout.write
    if not nodes:
        out('El grafo está vacío.\n')
        return
    out(f'\nGod nodes del sistema SWL (top {len(nodes)}):\n')
    out('-' * 62 + '\n')
    out(f'  {"Componente":<36} {"Tipo":<12} {"Grado":>6}\n')
    out('-' * 62 + '\n')
    for n in nodes:
        out(f'  {n["id"]:<36} {n["type"]:<12} {n["grado"]:>6}\n')
    out('\n')


def _write_blast_radius(G: 'nx.DiGraph', nombre: str, depth: int = 3) -> None:
    result = blast_radius(G, nombre, depth)
    out = sys.stdout.write
    out(f'\nBlast radius de `{result["nodo"]}` ({result["tipo"]})\n')
    out('-' * 52 + '\n')
    out(f'  {result["resumen"]}\n')

    if result['dependientes']:
        out(f'\n  Componentes afectados si cambia ({len(result["dependientes"])}):\n')
        for d in sorted(result['dependientes'], key=lambda x: x['distancia']):
            out(f'    d={d["distancia"]}  {d["id"]:<36} ({d["tipo"]})\n')

    if result['dependencias']:
        out(f'\n  Dependencias directas ({len(result["dependencias"])}):\n')
        for dep in result['dependencias']:
            out(f'    → {dep["id"]:<36} ({dep["tipo"]}) [{dep["relacion"]}]\n')
    out('\n')


def _write_surprising(G: 'nx.DiGraph', communities: dict | None = None) -> None:
    conns = surprising_connections(G, communities, top_n=10)
    out = sys.stdout.write
    if not conns:
        out('No se detectaron conexiones sorpresivas.\n')
        return
    out(f'\nConexiones sorpresivas detectadas ({len(conns)}):\n')
    out('-' * 52 + '\n')
    for c in conns:
        out(f'  {c["origen"]} → {c["destino"]}\n')
        out(f'    relación: {c["relacion"]}\n')
        out(f'    nota:     {c["nota"]}\n')
        out('\n')


def main() -> None:
    if hasattr(sys.stdout, 'reconfigure'):
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    if not HAS_NX:
        sys.stderr.write('[graph-analyze] ERROR: pip install networkx\n')
        sys.exit(1)

    parser = argparse.ArgumentParser(description='Análisis topológico del grafo SWL')
    sub    = parser.add_subparsers(dest='cmd')

    p_god = sub.add_parser('god_nodes', help='Top N nodos más conectados')
    p_god.add_argument('--top', type=int, default=10)
    p_god.add_argument('--graph', default=None)

    p_br = sub.add_parser('blast_radius', help='Blast radius de un componente')
    p_br.add_argument('nombre', help='ID del nodo (ej: event-bus, fastapi-experto)')
    p_br.add_argument('--depth', type=int, default=3)
    p_br.add_argument('--graph', default=None)

    p_diff = sub.add_parser('diff', help='Diff entre dos grafos')
    p_diff.add_argument('grafo_viejo')
    p_diff.add_argument('grafo_nuevo')

    p_sor = sub.add_parser('sorpresivo', help='Aristas cross-community inesperadas')
    p_sor.add_argument('--graph', default=None)

    p_preg = sub.add_parser('preguntas', help='Preguntas arquitectónicas')
    p_preg.add_argument('--graph', default=None)

    args = parser.parse_args()
    cwd  = Path('.').resolve()

    out = sys.stdout.write

    if args.cmd is None or args.cmd == 'god_nodes':
        gpath = Path(getattr(args, 'graph', None) or _default_graph_path(cwd))
        G = _load_graph_from_file(gpath)
        if G:
            _write_god_nodes(G, getattr(args, 'top', 10))

    elif args.cmd == 'blast_radius':
        gpath = Path(args.graph or _default_graph_path(cwd))
        G = _load_graph_from_file(gpath)
        if G:
            _write_blast_radius(G, args.nombre, args.depth)

    elif args.cmd == 'diff':
        G_old = _load_graph_from_file(Path(args.grafo_viejo))
        G_new = _load_graph_from_file(Path(args.grafo_nuevo))
        if G_old and G_new:
            diff = graph_diff(G_old, G_new)
            out(f'\nDiff de grafos: {diff["resumen"]}\n')
            for n in diff['nodos_nuevos']:
                out(f'  + nodo: {n["id"]} ({n["tipo"]})\n')
            for n in diff['nodos_eliminados']:
                out(f'  - nodo: {n["id"]} ({n["tipo"]})\n')
            for e in diff['aristas_nuevas']:
                out(f'  + arista: {e["origen"]} → {e["destino"]} [{e["relacion"]}]\n')
            for e in diff['aristas_eliminadas']:
                out(f'  - arista: {e["origen"]} → {e["destino"]} [{e["relacion"]}]\n')
            out('\n')

    elif args.cmd == 'sorpresivo':
        gpath = Path(args.graph or _default_graph_path(cwd))
        G = _load_graph_from_file(gpath)
        if G:
            _write_surprising(G)

    elif args.cmd == 'preguntas':
        gpath = Path(args.graph or _default_graph_path(cwd))
        G = _load_graph_from_file(gpath)
        if G:
            preguntas = suggest_questions(G)
            out(f'\nPreguntas arquitectónicas ({len(preguntas)}):\n')
            out('-' * 52 + '\n')
            for i, p in enumerate(preguntas, 1):
                if p.get('pregunta'):
                    out(f'\n  {i}. [{p["tipo"]}]\n')
                    out(f'     {p["pregunta"]}\n')
                    out(f'     → {p["por_que"]}\n')
            out('\n')


if __name__ == '__main__':
    main()
