#!/usr/bin/env python3
# clasificar: escribe NOMBRE.tipo al lado de cada .d y .ds del corpus dods,
# con las categorias del ejemplo (ver README.md, "Clasificacion").
#
# Uso: clasificar [ARCHIVO.d|ARCHIVO.ds ...]   (sin argumentos, todo el corpus)
#
# No corre simulaciones: lee el original, su .ds2 (las secciones), el .dat que
# da d2datdinc desde el original (en un temporal), lo que dice d2datdinc
# --topologia y los .din de d2datdinc que haya. Hasta el 30/09/2026 leia el
# .ds2 con ds2dat (Simulparser); se paso a d2datdinc, que expande los modelos
# de SCEPTRE y revisa GND despues de expandirlos (pedido del autor).
# Si un .d y un .ds tienen el mismo nombre, el del .ds es NOMBRE-ds.tipo.

import collections, glob, json, os, re, subprocess, sys, tempfile

# Las herramientas viven en siru/corpus/ y los circuitos en siru/corpus/corpus/
AQUI = os.path.dirname(os.path.abspath(__file__))
DODS = os.path.join(AQUI, 'corpus')
SIRU = os.path.abspath(os.path.join(AQUI, '..', '..'))
D2 = os.path.join(SIRU, 'slex', 'd2datdinc')
D2DATDINC = [os.path.join(D2, 'd2datdinc'), '-c', os.path.join(D2, 'd2datdinc.slex'),
             '-b', os.path.join(D2, 'biblioteca')]
EXAMPLES = os.path.join(SIRU, 'siruviejo', 'varios', 'Siru', 'perl', 'examples')
INVENTADAS = {'moddes1.tbl'}          # tablas que no existian y se inventaron (r1560)

# clase de cada tipo de elemento por la letra con que empieza
CLASE = {'R': 'resistencia', 'C': 'capacitor', 'L': 'bobina', 'E': 'fuente de tension',
         'J': 'fuente de corriente', 'S': 'llave', 'T': 'transformador', 'M': 'otro'}
SIN_DERIVADA = {'CC', 'LC'}           # por incrementos, sin derivada declarada
# la magnitud de estado de un modelo, por su nombre en la biblioteca
LETRA_ESTADO = {'CHARGE': 'Q', 'VOLTAGE': 'V', 'CURRENT': 'I', 'FLUX': 'F'}


def secciones(texto):
    """{SECCION: [lineas con contenido]} del .ds2 (sin comentarios ni blancos)."""
    res, actual = collections.defaultdict(list), None
    for linea in texto.splitlines():
        m = re.match(r'^([A-Z][A-Z_]+)\s*(#.*)?$', linea)
        if m and m.group(1) not in ('END',):
            actual = m.group(1)
            res.setdefault(actual, [])
            continue
        s = linea.split('#', 1)[0].strip()
        if actual and s:
            res[actual].append(s)
    return res


# los .ds que hacen include examples/... la toman de donde vienen (Siru)
CON_EXAMPLES = tempfile.mkdtemp(prefix='clasificar-')
os.symlink(EXAMPLES, os.path.join(CON_EXAMPLES, 'examples'))


def d2datdinc(orig, desde, *mas):
    r = subprocess.run(D2DATDINC + [orig] + list(mas), cwd=desde, capture_output=True, text=True)
    if r.returncode != 0 and 'examples/' in r.stderr:
        r = subprocess.run(D2DATDINC + [orig] + list(mas), cwd=CON_EXAMPLES, capture_output=True, text=True)
    return r


def leer(orig, desde):
    fd, dat = tempfile.mkstemp(suffix='.dat')
    os.close(fd)
    r = d2datdinc(orig, desde, '-o', dat)
    datos = None
    if r.returncode == 0:
        try:
            datos = json.load(open(dat))
        except Exception:
            datos = None
    return dat, datos, (r.stderr.strip().splitlines() or [''])[-1]


# De que forma escribe su sistema una fuente sin circuito. Son varias, y cada
# una necesita otra cosa de libdin, asi que conviene tenerlas separadas:
#
#   DEFINED PARAMETERS   la convencion de Simusol: un parametro D<X> es la
#                        derivada de <X>. Resuelto en r1614
#   EQUATIONS            las ecuaciones completas, sin derivadas: es un sistema
#                        algebraico, no hay nada que integrar
#   LINEAL:              la forma por coeficientes, '-1,1 V:DY1,Y2', que los dos
#                        parsers pierden: llega con eq null y data vacio
#   SIN RESOLVER         ninguna de las anteriores: hay que mirarla
def _forma(datos):
    if not isinstance(datos, dict):
        return 'SIN RESOLVER: el dato no se pudo leer'
    g = datos.get('GLOBAL') or {}
    dp = g.get('defpar') or {}
    if any(k.startswith('D') and k[1:] in dp for k in dp):
        return 'derivadas en DEFINED PARAMETERS (D+nombre), resuelta'
    nucleo = [h for h in (g.get('hequations') or []) if h.get('nucleo')]
    if not nucleo:
        return 'SIN RESOLVER: no se reconoce la forma'
    if any(not h.get('eq') for h in nucleo):
        return 'SIN RESOLVER: ecuaciones con LINEAL:, cuyos coeficientes se pierden al leer'
    return 'ecuaciones completas en EQUATIONS, sin derivadas: sistema algebraico'


# Por donde va a simular: los cuatro casos de siruviejo/LLAVES.md 12, con la regla
# de Siru::Circuito::caso, para que diga lo mismo que siru: sin estados, 4;
# RK vectorial, llaves, lazos o cortes, 2; sin ramas, 1; si no, 3. Es otra
# cosa que 'clase', que dice QUE ES el archivo; esto dice QUE CAMINO toma.
#
#   1  sin circuito, con derivadas            un .din, se integra
#   2  con llaves, lazos de C o cortes de L   mdin por configuracion
#   3  circuito comun                         un .din, se integra
#   4  un solo punto, sin estados             un .din, Calcula lo resuelve una vez
#
# El 4 se separa de los otros por una sola pregunta: hay estados? Si no hay,
# no hay nada que integrar. Vale con circuito y sin el: un circuito puramente
# resistivo tambien es del caso 4.
def _clase_problema(datos, ramas, topo, forma):
    if not isinstance(datos, dict):
        return 'SIN RESOLVER: el dato no se pudo leer'
    g = datos.get('GLOBAL') or {}
    if not ramas and forma and forma.startswith('SIN RESOLVER'):
        return 'SIN RESOLVER: ' + forma.split(': ', 1)[-1]
    if topo.get('falla'):
        return 'SIN RESOLVER: libdin no arma el sistema: ' + topo['falla']
    if not topo.get('estados'):
        return '4: un solo punto, sin estados; Calcula lo resuelve una vez'
    rk = ((g.get('control') or {}).get('RK') or {}).get('value')
    if isinstance(rk, list) and len(rk) > 1:
        return '2: RK vectorial, un metodo por paso; mdin'
    if topo.get('llaves'):
        return '2: con llaves; un mdin por configuracion'
    if topo.get('lazos') or topo.get('cortes'):
        return '2: con lazos de capacitores o cortes de bobinas; mdin con equilibrio'
    if not ramas:
        return '1: sin circuito, con derivadas; un .din que se integra'
    return '3: circuito comun; un .din que se integra'


def clasifica(orig):
    base, ext = os.path.splitext(orig)
    carpeta, nombre = os.path.dirname(orig), os.path.basename(base)
    ds2 = base + '.ds2'
    t = collections.OrderedDict()
    t['archivo'] = os.path.relpath(orig, DODS)
    t['origen'] = 'SCEPTRE .d (tipos inferidos por d2ds)' if ext == '.d' else 'Siru .ds (tipos explicitos)'
    # que archivos hay de este ejemplo al lado: el .d y el .ds originales, el
    # .ds2 (d2ds o dsarregla) y el .dat congelado (el que usa compara-dat;
    # si hay .d y .ds del mismo nombre, el del .d es NOMBRE-desde-d.dat)
    t['d'] = 'si' if os.path.exists(base + '.d') else 'no'
    t['ds'] = 'si' if os.path.exists(base + '.ds') else 'no'
    t['ds2'] = 'si' if os.path.exists(ds2) else 'NO'
    dat_congelado = base + ('-desde-d' if ext == '.d' and os.path.exists(base + '.ds') else '') + '.dat'
    t['dat'] = 'si' if os.path.exists(dat_congelado) else 'no'
    # modelos de SCEPTRE (manual 2.3), mirados en la fuente: los que el archivo
    # define (MODEL NOMBRE bajo MODEL DESCRIPTION) y, por aparte, los que usa
    # (una rama NOMBRE, n1-n2-... = MODEL X), que pueden venir de la
    # biblioteca permanente, slex/d2datdinc/biblioteca/modellib.siru (PERM)
    fuente = [l for l in open(orig, encoding='utf-8', errors='replace').read().splitlines()
              if not re.match(r'\s*[#;]', l)]
    definidos = [m.group(1) for l in fuente
                 for m in [re.match(r'\s*MODEL\s+([A-Za-z0-9_]+)', l, re.I)]
                 if m and m.group(1).upper() != 'DESCRIPTION']
    usados = ['%s=%s%s' % (m.group(1), m.group(2), ' (PERM)' if m.group(3) else '') for l in fuente
              for m in re.finditer(r'([A-Za-z][A-Za-z0-9]*)\s*,\s*[A-Za-z0-9]+(?:\s*-\s*[A-Za-z0-9]+)+\s*=\s*'
                                   r'MODEL\s+([A-Za-z0-9_]+)(\s*\(\s*PERM\s*\))?', l, re.I)]
    t['modelos definidos'] = ' '.join(definidos) or 'no'
    t['modelos usados'] = ' '.join(usados) or 'no'
    # el resultado de simular con siru, si hay uno anotado al lado
    # (NOMBRE.informe, como NOMBRE.simul1 de probar-simul1)
    if os.path.exists(base + '.informe'):
        r = dict(l.split(': ', 1) for l in open(base + '.informe', encoding='utf-8', errors='replace').read().splitlines()
                 if ': ' in l and not l.startswith('#'))
        if r.get('resultado'):
            t['siru'] = r['resultado'] + (' (%s)' % r['hasta'] if r.get('hasta') else '')
    if not os.path.exists(ds2):
        return t
    texto = open(ds2, encoding='utf-8', errors='replace').read()
    sec = secciones(texto)
    usa_examples = bool(re.search(r'^\s*#?\s*(include|input)\s+examples/', texto, re.M | re.I))
    t['include examples/'] = 'si' if usa_examples else 'no'

    # Que clase de archivo es. Sin ELEMENTS no es un circuito, y siru --que
    # exige ramas-- no lo va a simular nunca: conviene decirlo aca y no que
    # aparezca como una falla mas. De las 255 fuentes, 48 no tienen ELEMENTS.
    sec_pre = secciones(texto)
    def _hay(*nombres):
        return any(sec_pre.get(x) for x in nombres)
    if sec_pre.get('ELEMENTS'):
        t['clase'] = 'circuito'
    elif _hay('ELEMENT_DESCRIPTION'):
        t['clase'] = 'biblioteca: define modelos, no es un circuito'
    elif _hay('DATA') or re.search(r'\bTable\s*\(', texto, re.I):
        t['clase'] = 'tablas: prueba Table(), no es un circuito'
    elif _hay('FUNCTIONS'):
        t['clase'] = 'funciones: sin circuito'
    elif _hay('EQUATIONS', 'VARIABLES', 'CONTROL_EQUATIONS', 'DEFINED_PARAMETERS'):
        t['clase'] = 'ecuaciones sin circuito: Simusol lo resuelve, siru no'
    else:
        t['clase'] = 'fragmento: ni circuito ni ecuaciones'

    # 2. lectura del circuito
    desde = os.path.dirname(carpeta) if usa_examples else carpeta
    dat, datos, error = leer(orig, desde)
    g = (datos or {}).get('GLOBAL', {})
    ramas = g.get('ramas') or {}
    elementos = [l for l in sec.get('ELEMENTS', [])]
    nodos = set()
    for r in ramas.values():
        nodos.update(r.get('nodos', []))
    # ramas declaradas en ELEMENTS: "[TIPO-]NOMBRE, nodo-nodo = ..."
    declaradas, con_nombre = [], []
    for l in elementos:
        m = re.match(r'^(?:[A-Za-z]+-)?([A-Za-z0-9_]+)\s*,\s*([A-Za-z0-9_]+)\s*-\s*([A-Za-z0-9_]+)', l)
        if m:
            declaradas.append(m.group(1))
            if any(not x.isdigit() for x in m.group(2, 3)):
                con_nombre.append(m.group(1))
    declaradas = list(dict.fromkeys(declaradas))
    faltan = [n for n in declaradas if n not in ramas]
    if datos is None:
        t['lectura'] = 'd2datdinc falla: ' + error
    elif not ramas and not elementos:
        t['lectura'] = 'sin ELEMENTS (solo ecuaciones)'
    elif not faltan:
        t['lectura'] = '%d ramas, %d nodos' % (len(ramas), len(nodos))
    else:
        t['lectura'] = '%d de %d ramas leidas; faltan %s' % (len(ramas), len(declaradas), ' '.join(faltan))
        falta_nombre = [n for n in faltan if n in con_nombre]
        if falta_nombre:
            t['lectura'] += ' (%s con nodos con nombre)' % ('todas' if len(falta_nombre) == len(faltan) else ' '.join(falta_nombre))
    # la forma en que una fuente sin circuito escribe su sistema; el dato hace
    # falta, y recien ahora esta leido
    if t.get('clase', '').startswith('ecuaciones sin circuito'):
        t['forma'] = _forma(datos)

    cerradas = sorted(n for n, r in ramas.items() if len(set(r.get('nodos', []))) == 1)
    if cerradas:
        t['ramas cerradas'] = ' '.join(cerradas)

    # 3. topologia: la de d2datdinc --topologia, la misma deteccion con que
    # libdin arma los sistemas (sin --llaves, lazos y cortes con todas abiertas)
    topo = {}
    if datos is not None:
        r = d2datdinc(orig, desde, '--topologia')
        if r.returncode != 0:
            # libdin no pudo armar el sistema: no se sabe si hay estados, y
            # no hay que decir que no los hay
            topo['falla'] = (r.stderr.strip().splitlines() or ['?'])[-1].split(': ', 2)[-1]
        for l in r.stdout.splitlines():
            k, _, v = l.partition(' ')
            topo[k] = v.strip()
    if ramas:
        lc = [x for x in ('lazos ' + topo.get('lazos', '') if topo.get('lazos') else '',
                          'cortes ' + topo.get('cortes', '') if topo.get('cortes') else '') if x]
        t['topologia'] = 'd2datdinc --topologia falla: ' + topo['falla'] if topo.get('falla') \
            else '; '.join(lc) or 'sin lazos ni cortes degenerados'

    # 4. elementos
    if ramas:
        tipos = collections.Counter(r.get('tipo', '?') for r in ramas.values())
        t['tipos'] = ' '.join('%s:%d' % kv for kv in sorted(tipos.items()))
        clases = sorted({CLASE.get(k[0], 'otro') for k in tipos})
        t['clases'] = ', '.join(clases)
        if SIN_DERIVADA & set(tipos):
            t['tipos sin derivada'] = ' '.join(sorted(SIN_DERIVADA & set(tipos)))
        # que integra el circuito: lo dice cada modelo que las ramas usan, en
        # el .dat --lista[2] es su variable de estado y lista[5] la derivada.
        # Es lo que distingue CB, que desde el 2026-09-29 es la carga, de C,
        # que sigue con PVOLTAGE y DT (ver siruviejo/LLAVES.md).
        por_modelo, letras = {}, set()
        for tipo in sorted(tipos):
            lista = ((datos or {}).get(tipo) or {}).get('lista') or []
            est = (lista[2] if len(lista) > 2 else '') or ''
            der = (lista[5] if len(lista) > 5 else '') or ''
            if not est:
                continue                      # resistencia, fuente: sin estado
            por_modelo[tipo] = est + ('' if der else ' (sin derivada)')
            letras.add(LETRA_ESTADO.get(est.lstrip('P'), '?'))
        if por_modelo:
            t['estado'] = ','.join(sorted(letras))
            t['estado por modelo'] = ' '.join('%s:%s' % kv for kv in sorted(por_modelo.items()))
        # variables del circuito: V, I, DV, DI, G, F, VP, IP... seguidas del nombre de una rama
        de_circuito = re.compile(r'^(?:V|I|DV|DI|G|F|VP|IP|GP|FP)(%s)$' % '|'.join(map(re.escape, ramas)))
        nolineales, controladas, del_tiempo = [], [], []
        for n, r in sorted(ramas.items()):
            valor = re.sub(r"""(['"]).*?\1""", '', str(r.get('valor', '')))
            ids = set(re.findall(r'\b([A-Za-z_]\w*)\b(?!\s*\()', valor))
            ids |= {v for val in r.get('valores', []) for v in (val[1] or [])}
            if any(de_circuito.match(v) for v in ids):
                (controladas if r.get('tipo', ' ')[0] in 'EJ' else nolineales).append(n)
            elif ids & {'T', 'TIME'} or re.search(r'Table\s*\(', str(r.get('valor', '')), re.I):
                del_tiempo.append(n)
        t['elementos'] = 'lineales' if not (nolineales or controladas) else 'con variables del circuito'
        if nolineales:
            t['no lineales'] = ' '.join(nolineales)
        if controladas:
            t['fuentes controladas'] = ' '.join(controladas)
        if del_tiempo:
            t['dependen del tiempo'] = ' '.join(del_tiempo)
        # llaves: el elemento S o SB (siruviejo/LLAVES.md), cuya condicion es su
        # valor; siru/corpus/separar-llaves paso a llaves los controles
        # que tenian las ramas ("valor = activa | inicial | nodo_a | nodo_b")
        # se escribe siempre: 'no' dice que se miro y no hay
        t['llaves'] = ' '.join(sorted(n for n, r in ramas.items() if r.get('tipo', ' ')[0] == 'S')) or 'no'
        con_controles = sorted(n for n, r in ramas.items()
                               if any((c or '').strip() not in ('', '1') for c in r.get('controles') or []))
        if con_controles:
            t['ramas con controles'] = ' '.join(con_controles)

    if 'llaves' not in t:
        t['llaves'] = 'no (sin ramas)' if datos is not None else 'sin dato (ds2dat falla)'

    # 5. datos externos y extras
    tablas = sorted(set(re.findall(r"""Table\s*\(\s*['"]([^'"]+)['"]""", texto, re.I)))
    if tablas:
        estados = []
        for tb in tablas:
            b = os.path.basename(tb)
            if b in INVENTADAS and os.path.exists(os.path.join(carpeta, b)):
                e = 'inventada'
            elif os.path.exists(os.path.join(carpeta, tb)) or os.path.exists(os.path.join(carpeta, b)):
                e = 'presente'
            else:
                e = 'FALTA'
            estados.append('%s (%s%s)' % (tb, e, ', ruta examples/' if tb.startswith('examples/') else ''))
        t['tablas'] = '; '.join(estados)
    if (g.get('DATABLOB') or '').strip() or sec.get('DATABLOB'):
        t['DATABLOB'] = 'si (codigo Perl)'
    for s, k in (('FUNCTIONS', 'funciones'), ('EQUATIONS', 'ecuaciones propias'),
                 ('DEFINED_PARAMETERS', 'parametros definidos')):
        if sec.get(s):
            t[k] = str(len(sec[s]))
    controles = [h.get('eq', '') for h in g.get('hequations') or []]
    if controles:
        t['controles'] = '; '.join(controles)
    for palabra, k in (('RERUN', 'RERUN'), ('CONTINUE', 'CONTINUE'), ('TERMINATE', 'TERMINATE IF'),
                       ('STOP IF', 'STOP IF'), ('SENSITIVITY', 'SENSITIVITY'), ('MONTE', 'MONTE CARLO'),
                       ('MODEL DESCRIPTION', 'MODEL DESCRIPTION')):
        if re.search(r'^\s*[^#\n]*\b' + palabra, open(orig, encoding='utf-8', errors='replace').read(), re.M | re.I):
            t[k] = 'si'

    # 6. integracion
    rk = ((g.get('control') or {}).get('RK') or {}).get('value')
    lista = ((g.get('data') or {}).get('rungekutta')) or []
    nombre_rk = lambda n: '%s (%s)' % (n, lista[n].get('name', '?')) if isinstance(n, int) and 0 <= n < len(lista) and lista[n] else str(n)
    t['metodo RK'] = ', '.join(nombre_rk(x) for x in rk) if rk else 'sin rutina (Euler, 0)'
    pasos = [l for l in sec.get('RUN_CONTROLS', []) if re.search(r'STEP|SIRU|SIMUL2|TOLERANCE', l, re.I)]
    if pasos:
        t['controles de paso'] = '; '.join(pasos)

    # 7. lo que ya hay de d2datdinc (no se corre nada)
    din = os.path.join(carpeta, nombre + '-de-d2datdinc.din')
    eds = sorted(glob.glob(os.path.join(carpeta, nombre + '-de-d2datdinc-ED*.din')))
    if os.path.exists(din):
        pend = sum(1 for f in [din] + eds for l in open(f, errors='replace') if l.startswith('# PENDIENTE'))
        t['din d2datdinc'] = 'base%s%s' % (
            ' y ' + ' '.join(os.path.basename(e).split('-')[-1][:-4] for e in eds) if eds else ', sin ampliado',
            ', %d PENDIENTE' % pend if pend else '')
    else:
        t['din d2datdinc'] = 'NO'
    t['ClaseProblema'] = _clase_problema(datos, ramas, topo, t.get('forma')) \
        if t.get('clase') in ('circuito',) or t.get('clase', '').startswith('ecuaciones sin circuito') \
        else 'no simula: ' + t.get('clase', '?').split(':')[0]
    os.unlink(dat)
    return t


def escribe(orig, t):
    base, ext = os.path.splitext(orig)
    otro = base + ('.ds' if ext == '.d' else '.d')
    salida = base + ('-ds' if ext == '.ds' and os.path.exists(otro) else '') + '.tipo'
    with open(salida, 'w') as f:
        f.write('# categorias del ejemplo, escritas por clasificar (no editar a mano)\n')
        for k, v in t.items():
            f.write('%s: %s\n' % (k, v))
    return salida


def main():
    archivos = sys.argv[1:] or sorted(
        f for f in glob.glob(os.path.join(DODS, '**', '*.d*'), recursive=True)
        if f.endswith(('.d', '.ds')) and '/.svn/' not in f)
    for orig in archivos:
        escribe(os.path.abspath(orig), clasifica(os.path.abspath(orig)))
    print('clasificar: %d archivos' % len(archivos))


if __name__ == '__main__':
    main()
