#!/usr/bin/env python3
"""graph-cluster.py — Detección de comunidades en el grafo de dependencias SWL.

Portado directamente desde graphify-3/graphify/cluster.py con mínimas
adaptaciones para el dominio de swl-ses.

Intenta Leiden (graspologic) si está instalado; cae a Louvain de networkx.
Para 164 nodos (53 agentes + 111 skills), Louvain es suficiente.

API:
  cluster(G)                      → {community_id: [node_ids]}
  cohesion_score(G, nodes)        → float 0.0–1.0
  score_all(G, communities)       → {community_id: float}
  label_communities(G, communities) → {community_id: str}

Uso CLI:
  python scripts/lib/graph-cluster.py          → tabla de comunidades
  python scripts/lib/graph-cluster.py json     → salida JSON
"""
from __future__ import annotations

import argparse
import inspect
import io
import json
import sys
from pathlib import Path

try:
    import networkx as nx
    HAS_NX = True
except ImportError:
    HAS_NX = False

# ---------------------------------------------------------------------------
# Partición interna
# ---------------------------------------------------------------------------

def _partition(G: 'nx.Graph') -> dict[str, int]:
    """Ejecuta detección de comunidades. Retorna {node_id: community_id}.

    Intenta Leiden (graspologic) primero — mejor calidad.
    Cae a Louvain de networkx si graspologic no está disponible.
    """
    try:
        from graspologic.partition import leiden
        old_stderr = sys.stderr
        try:
            sys.stderr = io.StringIO()
            result = leiden(G)
        finally:
            sys.stderr = old_stderr
        return result
    except ImportError:
        pass

    kwargs: dict = {'seed': 42, 'threshold': 1e-4}
    if 'max_level' in inspect.signature(nx.community.louvain_communities).parameters:
        kwargs['max_level'] = 10
    communities = nx.community.louvain_communities(G, **kwargs)
    return {node: cid for cid, nodes in enumerate(communities) for node in nodes}


_MAX_COMMUNITY_FRACTION = 0.25
_MIN_SPLIT_SIZE         = 10


def _split_community(G: 'nx.Graph', nodes: list[str]) -> list[list[str]]:
    """Segunda pasada de partición en una comunidad oversized."""
    subgraph = G.subgraph(nodes)
    if subgraph.number_of_edges() == 0:
        return [[n] for n in sorted(nodes)]
    try:
        sub_part: dict[str, int] = {}
        try:
            from graspologic.partition import leiden
            old_stderr = sys.stderr
            try:
                sys.stderr = io.StringIO()
                sub_part = leiden(subgraph)
            finally:
                sys.stderr = old_stderr
        except ImportError:
            comms = nx.community.louvain_communities(subgraph, seed=42, threshold=1e-4)
            sub_part = {node: cid for cid, nodes_ in enumerate(comms) for node in nodes_}

        sub_comms: dict[int, list[str]] = {}
        for node, cid in sub_part.items():
            sub_comms.setdefault(cid, []).append(node)
        if len(sub_comms) <= 1:
            return [sorted(nodes)]
        return [sorted(v) for v in sub_comms.values()]
    except Exception:
        return [sorted(nodes)]


# ---------------------------------------------------------------------------
# API pública
# ---------------------------------------------------------------------------

def cluster(G: 'nx.Graph') -> dict[int, list[str]]:
    """Detecta comunidades en el grafo.

    Retorna {community_id: [node_ids]} ordenado por tamaño descendente.
    Las comunidades oversized (> 25% del grafo, mín 10 nodos) se dividen.
    """
    if not HAS_NX:
        return {}
    if G.number_of_nodes() == 0:
        return {}
    if G.number_of_edges() == 0:
        return {i: [n] for i, n in enumerate(sorted(G.nodes))}

    # Leiden/Louvain no acepta nodos aislados
    isolates   = [n for n in G.nodes() if G.degree(n) == 0]
    connected  = G.subgraph([n for n in G.nodes() if G.degree(n) > 0])

    raw: dict[int, list[str]] = {}
    if connected.number_of_nodes() > 0:
        partition = _partition(connected)
        for node, cid in partition.items():
            raw.setdefault(cid, []).append(node)

    next_cid = max(raw.keys(), default=-1) + 1
    for node in isolates:
        raw[next_cid] = [node]
        next_cid += 1

    max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION))
    final: list[list[str]] = []
    for nodes in raw.values():
        if len(nodes) > max_size:
            final.extend(_split_community(G, nodes))
        else:
            final.append(nodes)

    final.sort(key=len, reverse=True)
    return {i: sorted(nodes) for i, nodes in enumerate(final)}


def cohesion_score(G: 'nx.Graph', community_nodes: list[str]) -> float:
    """Ratio de aristas internas respecto al máximo posible (0.0–1.0)."""
    n = len(community_nodes)
    if n <= 1:
        return 1.0
    subgraph = G.subgraph(community_nodes)
    actual   = subgraph.number_of_edges()
    possible = n * (n - 1) / 2
    return round(actual / possible, 2) if possible > 0 else 0.0


def score_all(G: 'nx.Graph', communities: dict) -> dict:
    """Calcula cohesion_score para todas las comunidades. {community_id: float}"""
    return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()}


def label_communities(
    G: 'nx.Graph',
    communities: dict[int, list[str]],
) -> dict[int, str]:
    """Genera una etiqueta descriptiva para cada comunidad.

    Usa el tipo de nodo más frecuente + el god node (mayor grado) como nombre.
    """
    labels: dict[int, str] = {}
    for cid, nodes in communities.items():
        if not nodes:
            labels[cid] = f'comunidad-{cid}'
            continue

        # Tipo más frecuente
        tipo_count: dict[str, int] = {}
        for n in nodes:
            t = G.nodes[n].get('type', 'nodo')
            tipo_count[t] = tipo_count.get(t, 0) + 1
        tipo_dom = max(tipo_count, key=tipo_count.get)  # type: ignore[arg-type]

        # Nodo con más conexiones dentro de la comunidad
        sub   = G.subgraph(nodes)
        deg   = dict(sub.degree())
        top_n = max(deg, key=deg.get) if deg else nodes[0]  # type: ignore[arg-type]

        labels[cid] = f'{tipo_dom}:{top_n}'

    return labels


# ---------------------------------------------------------------------------
# I/O helpers
# ---------------------------------------------------------------------------

def _load_graph(path: Path) -> 'nx.DiGraph | None':
    if not HAS_NX:
        return None
    if not path.exists():
        sys.stderr.write(f'[graph-cluster] 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')


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main() -> None:
    if hasattr(sys.stdout, 'reconfigure'):
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    if not HAS_NX:
        sys.stderr.write('[graph-cluster] ERROR: pip install networkx\n')
        sys.exit(1)

    parser = argparse.ArgumentParser(description='Detección de comunidades SWL')
    parser.add_argument('cmd', nargs='?', default='tabla', choices=['tabla', 'json'])
    parser.add_argument('--graph', default='.planning/graph.json')
    args = parser.parse_args()

    G = _load_graph(Path(args.graph))
    if G is None:
        sys.stderr.write('No existe .planning/graph.json — ejecuta primero graph-builder.py\n')
        sys.exit(1)

    comms  = cluster(G)
    scores = score_all(G, comms)
    labels = label_communities(G, comms)
    out    = sys.stdout.write

    if args.cmd == 'json':
        resultado = {
            str(cid): {
                'nodos':    nodes,
                'cohesion': scores[cid],
                'etiqueta': labels[cid],
            }
            for cid, nodes in comms.items()
        }
        out(json.dumps(resultado, ensure_ascii=False, indent=2) + '\n')
        return

    # Tabla legible
    out(f'\nComunidades del grafo SWL ({len(comms)} detectadas):\n')
    out('-' * 70 + '\n')
    out(f'  {"ID":>4}  {"Etiqueta":<30} {"Nodos":>6}  {"Cohesion":>9}\n')
    out('-' * 70 + '\n')
    for cid in sorted(comms):
        nodos    = comms[cid]
        score    = scores[cid]
        etiqueta = labels[cid]
        alerta   = ' [!]' if score < 0.10 and len(nodos) >= 5 else ''
        out(f'  {cid:>4}  {etiqueta:<30} {len(nodos):>6}  {score:>9.2f}{alerta}\n')
    out('\n')
    low = [(cid, scores[cid]) for cid in comms if scores[cid] < 0.10 and len(comms[cid]) >= 5]
    if low:
        out(f'  [!] {len(low)} comunidad(es) con cohesion < 0.10 — posibles candidatos a refactoring.\n')
        out('\n')


if __name__ == '__main__':
    main()
