#!/usr/bin/env python3
"""Censo de consumidores con dos lentes: grep y grafo. Medición reproducible.

PREGUNTA: si cambio el comportamiento de un módulo compartido, ¿qué ficheros pueden romperse?
A eso lo llamamos el RADIO DE EXPLOSIÓN. Las dos lentes son dos formas de estimarlo.

  Lente A (grep del símbolo): buscar el nombre del símbolo por todo el repo. Es lo que hace
  todo el mundo. Cuenta ficheros distintos, excluyendo el fichero que lo define.

  Lente B (grafo del fichero): ficheros con arista `imports_from` hacia el fichero que define
  el símbolo. Consultar por el nodo del FICHERO, no por el del símbolo, es deliberado: quien
  importa un símbolo HERMANO del mismo módulo también está en el radio de explosión, porque
  el módulo se toca entero.

CLASIFICACIÓN DE LOS ACIERTOS DE GREP, fijada ANTES de mirar los resultados:
  - REAL: la línea es un import del símbolo, o una llamada/uso en código.
  - RUIDO: la línea es un comentario (empieza por //, /*, *, #) o el símbolo aparece dentro
    de una cadena de texto. Son menciones que no rompen nada si cambias el código.

LO QUE ESTA MEDICIÓN NO DICE: no mide despacho en tiempo de ejecución (interfaces con varias
implementaciones, inyección de dependencias, reflexión). Ahí el grafo tampoco acierta, y es una
limitación real señalada por terceros. Mide alcance estático, que es el 90% del día a día.

Uso: censo.py <repo> <grafo.json> [--n 6]
"""
import json
import re
import subprocess
import sys
from collections import defaultdict
from pathlib import Path

EXCLUIR = ("node_modules/", "dist/", ".git/", "coverage/", "build/", ".wrangler/")
COMENTARIO = re.compile(r"^\s*(//|/\*|\*|#)")


def carga_grafo(p: Path):
    g = json.loads(p.read_text())
    porid, importadores = {}, defaultdict(set)
    for n in g["nodes"]:
        porid[n["id"]] = n
    for l in g["links"]:
        if l.get("relation") == "imports_from":
            importadores[l["target"]].add(l["source"])
    return g, porid, importadores


def fichero_de(nodo):
    return (nodo or {}).get("source_file") or ""


def grep_censo(repo: Path, simbolo: str, definido_en: str):
    """Devuelve (ficheros_reales, ficheros_ruido). Un fichero cuenta como real si tiene AL
    MENOS una línea real; si solo tiene menciones en comentarios o cadenas, cuenta como ruido."""
    try:
        out = subprocess.run(
            ["grep", "-rnw", "--include=*.ts", "--include=*.tsx", "--include=*.js", simbolo, "."],
            cwd=repo, capture_output=True, text=True, timeout=120,
        ).stdout
    except subprocess.TimeoutExpired:
        return set(), set()
    reales, ruido = set(), set()
    for linea in out.splitlines():
        partes = linea.split(":", 2)
        if len(partes) < 3:
            continue
        fich, _, texto = partes
        fich = fich.lstrip("./")
        if any(x in fich for x in EXCLUIR) or fich == definido_en:
            continue
        if COMENTARIO.match(texto):
            ruido.add(fich)
        elif re.search(rf"['\"`][^'\"`]*\b{re.escape(simbolo)}\b[^'\"`]*['\"`]", texto) and simbolo not in re.sub(
            r"['\"`][^'\"`]*['\"`]", "", texto
        ):
            ruido.add(fich)  # solo aparece dentro de una cadena
        else:
            reales.add(fich)
    return reales, ruido - reales



IMPORT_LINEA = re.compile(r"\b(?:import|require|export)\b")


def grep_estricto(repo: Path, simbolo: str, definido_en: str):
    """Censo ESTRICTO: solo cuenta como consumidor quien IMPORTA el simbolo.

    Devuelve (por_linea, por_bloque). Son dos variantes a proposito:

      por_linea  — la linea que contiene el simbolo contiene ademas import/require/export.
                   Es el "grep estricto" que se puede escribir de memoria en la terminal.
      por_bloque — ademas reconoce los import MULTILINEA de TS/JS
                   (`import {\n  X,\n} from "..."`), donde la linea del simbolo NO
                   contiene la palabra import. Para esto hay que leer el fichero y seguir
                   el bloque: ya no es un grep, es un mini-parser.

    La distancia entre las dos es el precio de hacer grep "bien".
    """
    try:
        out = subprocess.run(
            ["grep", "-rnw", "--include=*.ts", "--include=*.tsx", "--include=*.js", simbolo, "."],
            cwd=repo, capture_output=True, text=True, timeout=120,
        ).stdout
    except subprocess.TimeoutExpired:
        return set(), set()

    por_linea, candidatos = set(), defaultdict(list)
    for linea in out.splitlines():
        partes = linea.split(":", 2)
        if len(partes) < 3:
            continue
        fich, num, texto = partes
        fich = fich.lstrip("./")
        if any(x in fich for x in EXCLUIR) or fich == definido_en:
            continue
        if COMENTARIO.match(texto):
            continue
        if IMPORT_LINEA.search(texto):
            por_linea.add(fich)
        else:
            candidatos[fich].append(int(num) if num.isdigit() else 0)

    # variante bloque: para los ficheros que NO casaron por linea, mirar si el simbolo cae
    # dentro de un bloque `import { ... } from` abierto en lineas anteriores.
    por_bloque = set(por_linea)
    for fich, nums in candidatos.items():
        src = repo / fich
        if not src.exists():
            continue
        try:
            lineas = src.read_text(errors="ignore").splitlines()
        except Exception:
            continue
        for n in nums:
            i = n - 1
            if not (0 <= i < len(lineas)):
                continue
            # retrocede hasta 15 lineas buscando la apertura de un import sin cerrar
            for j in range(i, max(-1, i - 15), -1):
                l = lineas[j]
                if IMPORT_LINEA.search(l) and "{" in l:
                    trozo = "\n".join(lineas[j:i + 1])
                    if trozo.count("}") == 0:          # el bloque sigue abierto en la linea del simbolo
                        por_bloque.add(fich)
                    break
                if l.strip().endswith(";") and j != i:  # sentencia anterior cerrada: no es un import
                    break
    return por_linea, por_bloque


def main():
    repo, grafo = Path(sys.argv[1]).expanduser(), Path(sys.argv[2]).expanduser()
    n_casos = int(sys.argv[sys.argv.index("--n") + 1]) if "--n" in sys.argv else 6
    g, porid, importadores = carga_grafo(grafo)

    # Candidatos: ficheros de código con 3 o más importadores. Se ordenan por número de
    # importadores y se toman los N primeros, SIN mirar antes cómo va a salir cada uno.
    cands = []
    for tid, imps in importadores.items():
        nodo = porid.get(tid)
        f = fichero_de(nodo)
        if not f or not f.endswith((".ts", ".tsx", ".js")) or any(x in f for x in EXCLUIR):
            continue
        if len(imps) >= 3:
            cands.append((len(imps), f, tid, imps))
    cands.sort(key=lambda x: -x[0])

    print(f"repo={repo.name} grafo={g.get('built_at_commit','?')[:7]} candidatos={len(cands)}\n")
    filas = []
    # Se recorren TODOS los candidatos hasta reunir n_casos con simbolo exportado
    # localizable. Antes se cortaba en los n_casos primeros y los que no casaban con la
    # expresion regular se perdian, asi que el censo salia corto sin avisar.
    PATRONES = [
        r"export\s+(?:async\s+)?function\s+([A-Za-z_]\w+)",
        r"export\s+const\s+([A-Za-z_]\w+)",
        r"export\s+(?:abstract\s+)?class\s+([A-Za-z_]\w+)",
        r"export\s+(?:type|interface)\s+([A-Za-z_]\w+)",
        r"export\s*\{\s*([A-Za-z_]\w+)",
    ]
    for _, f, tid, imps in cands:
        if len(filas) >= n_casos:
            break
        src = repo / f
        if not src.exists():
            continue
        texto = src.read_text(errors="ignore")
        sim = next((m.group(1) for pat in PATRONES if (m := re.search(pat, texto))), None)
        if not sim:
            continue
        reales, ruido = grep_censo(repo, sim, f)
        est_linea, est_bloque = grep_estricto(repo, sim, f)
        ficheros_grafo = {fichero_de(porid.get(i)) for i in imps} - {""}
        filas.append(
            {"modulo": f, "simbolo": sim, "grep_real": len(reales), "grep_ruido": len(ruido),
             "grafo": len(ficheros_grafo), "solo_grafo": len(ficheros_grafo - reales),
             "solo_grep": len(reales - ficheros_grafo),
             "estricto_linea": len(est_linea), "estricto_bloque": len(est_bloque),
             "solo_grafo_vs_estricto": len(ficheros_grafo - est_bloque),
             "solo_estricto_vs_grafo": len(est_bloque - ficheros_grafo)}
        )
        print(f"{f}  ·  {sim}()")
        print(f"   grep: {len(reales)} reales, {len(ruido)} ruido   |   grafo: {len(ficheros_grafo)}"
              f"   |   solo grafo: {len(ficheros_grafo - reales)}   solo grep: {len(reales - ficheros_grafo)}")
        print(f"   estricto: {len(est_linea)} por linea, {len(est_bloque)} con multilinea"
              f"   |   vs grafo -> solo grafo: {len(ficheros_grafo - est_bloque)}   solo estricto: {len(est_bloque - ficheros_grafo)}")

    if filas:
        tr, tn, tg, ts, tp = (sum(x[k] for x in filas) for k in ("grep_real", "grep_ruido", "grafo", "solo_grafo", "solo_grep"))
        print(f"\nTOTAL sobre {len(filas)} módulos: grep {tr} reales + {tn} ruido · grafo {tg}")
        print(f"  solo el grafo los ve: {ts}   ·   solo grep los ve: {tp}   (las dos lentes fallan, en direcciones distintas)")
        el, eb, sge, seg = (sum(x[k] for x in filas) for k in ("estricto_linea", "estricto_bloque", "solo_grafo_vs_estricto", "solo_estricto_vs_grafo"))
        print(f"  ESTRICTO (solo imports): {el} por linea · {eb} contando multilinea  (+{eb - el} que un grep de terminal NO ve)")
        print(f"  estricto vs grafo: solo grafo {sge} · solo estricto {seg}")
    print("\n" + json.dumps(filas, ensure_ascii=False))


if __name__ == "__main__":
    main()
