#!/usr/bin/env python3
# separar-llaves: saca los controles de una rama y los pone en llaves puras.
#
# Uso: separar-llaves [--probar] ARCHIVO.d|.ds|.ds2 ...
#
# En Siru una rama puede llevar, despues de su valor, hasta cuatro controles
# (Simusol::Definitions::controlrama):
#   rama, a-b = valor = activa | inicial | nodo_a | nodo_b
#   activa   condicion para que la rama este (1 siempre, 0 nunca)
#   inicial  0 si arranca inactiva
#   nodo_a   formula que devuelve el nodo al que se conecta el extremo a
#   nodo_b   lo mismo para el extremo b
# En los .ds los controles se separan con & (cada uno con su V:), en los .d
# con |. Cada rama con controles que no son triviales se reemplaza por:
#   - la rama con su valor y sin controles, entre nodos nuevos;
#   - si hay activa: una llave en serie cuyo valor es esa condicion;
#   - si hay nodo_a (o nodo_b): un nodo intermedio y una llave por cada nodo
#     posible p (los resultados de los if(...) de la formula), de valor
#     abs((formula)-p)<0.5 (1 si la formula da p; sin '=', que ds2dat y
#     d2datdinc toman como separador del control).
# La llave es la pura de siruviejo/LLAVES.md (seccion 4): tipo SB de la
# biblioteca, VOLTAGE * x1 + CURRENT * (1 - x1), y la condicion es su valor
# x1 (1 cerrada, 0 abierta), sin controles. El control 'inicial' no hace
# falta: el estado inicial es lo que vale la condicion en t = 0. Los
# nodos nuevos son numeros (el mayor del archivo mas uno...), porque ds2dat
# descarta sin avisar las ramas con nodos con nombre.
# Los controles triviales (un '= 1' solo, o una formula de nodo que es un
# numero fijo) se sacan: '= 1' es como no tener nada.
# Con --probar muestra el resultado y no escribe.

import re, sys

LINEA = re.compile(r'^(\s*)(?:([A-Za-z0-9_]+)-)?([A-Za-z0-9_]+)(\s*,\s*)([A-Za-z0-9_]+)(\s*-\s*)([A-Za-z0-9_]+)(\s*)=(.*)$')


def partir_igual(texto):
    """Parte en el primer = que no es parte de ==, <=, >=, != y esta fuera de parentesis."""
    nivel = 0
    for i, ch in enumerate(texto):
        if ch in '([':
            nivel += 1
        elif ch in ')]':
            nivel -= 1
        elif ch == '=' and nivel == 0:
            antes = texto[i - 1] if i else ''
            despues = texto[i + 1] if i + 1 < len(texto) else ''
            if antes not in '=<>!' and despues != '=':
                return texto[:i], texto[i + 1:]
    return texto, None


def partir(texto, sep):
    """Parte en sep fuera de parentesis."""
    partes, nivel, actual = [], 0, ''
    for ch in texto:
        if ch in '([':
            nivel += 1
        elif ch in ')]':
            nivel -= 1
        if ch == sep and nivel == 0:
            partes.append(actual)
            actual = ''
        else:
            actual += ch
    partes.append(actual)
    return partes


def control(texto):
    """'expr V:a,b' -> (expr, 'a,b')."""
    if 'V:' in texto:
        e, v = texto.split('V:', 1)
        return e.strip(), v.strip()
    return texto.strip(), ''


def candidatos(expr, original):
    """Los nodos que puede devolver la formula: los resultados de sus if(...)."""
    nodos = []
    if re.fullmatch(r'[A-Za-z0-9_]+', expr):
        nodos.append(expr)
    for m in re.finditer(r'\bif\s*\(', expr, re.I):
        i, nivel, args, actual = m.end(), 1, [], ''
        while i < len(expr) and nivel:
            ch = expr[i]
            if ch == '(':
                nivel += 1
            elif ch == ')':
                nivel -= 1
                if nivel == 0:
                    break
            if ch == ',' and nivel == 1:
                args.append(actual)
                actual = ''
            else:
                actual += ch
            i += 1
        args.append(actual)
        if len(args) != 3:
            return None
        for a in args[1:]:
            a = a.strip()
            if re.fullmatch(r'[0-9]+', a):
                nodos.append(a)
            elif not re.match(r'\bif\s*\(', a, re.I):
                return None
    if not nodos:
        return None
    # el nodo original no: con la condicion como valor, la formula elige el
    # nodo desde t = 0, y una llave al original que la formula nunca devuelve
    # no se cerraria nunca
    return list(dict.fromkeys(nodos))


def transformar(lineas):
    nodos = set()
    for l in lineas:
        m = LINEA.match(l.split('#', 1)[0])
        if m:
            nodos.update(x for x in (m.group(5), m.group(7)) if x.isdigit())
    siguiente = max([int(x) for x in nodos] or [0]) + 1
    salida, cambios = [], []
    for l in lineas:
        cuerpo, _, comentario = l.rstrip('\n').partition('#')
        m = LINEA.match(cuerpo)
        if not m:
            salida.append(l)
            continue
        ind, tipo, nombre, coma, a, guion, b, esp, resto = m.groups()
        # varias ramas en una linea (SCEPTRE: 'R1, 1-2 = 200, L1, 2-3 = 1E-3'): no se toca
        if re.search(r'[A-Za-z0-9_]+\s*,\s*[A-Za-z0-9_]+\s*-\s*[A-Za-z0-9_]+\s*=', resto):
            salida.append(l)
            continue
        valor, ctl = partir_igual(resto)
        # sin control, con el control vacio (la linea termina en '=' y en
        # SCEPTRE sigue en la siguiente) o sin valor (sacar el control la dejaria
        # terminada en '='): no se toca
        if ctl is None or not ctl.strip() or not valor.strip():
            salida.append(l)
            continue
        sep = '&' if '&' in ctl else '|'
        c = [control(x) for x in partir(ctl, sep)] + [('', '')] * 4
        activa, inicial, fa, fb = c[0], c[1], c[2], c[3]
        # una formula de nodo que es un numero fijo: si es el mismo nodo no hace
        # nada, si es otro la rama se conecta ahi (sin llave)
        if re.fullmatch(r'[0-9]+', fa[0]):
            a, fa = fa[0], ('', '')
        if re.fullmatch(r'[0-9]+', fb[0]):
            b, fb = fb[0], ('', '')
        if activa[0] in ('1', '') and inicial[0] != '0' and not fa[0] and not fb[0]:
            # controles triviales ('= 1', '= 1||  |3'...): se sacan
            rama = '%s%s%s%s%s%s%s=%s' % (ind, (tipo + '-' if tipo else ''), nombre, coma, a, guion, b + esp,
                                          valor.rstrip())
            salida.append(rama + ((' #' + comentario) if comentario.strip() else '') + '\n')
            cambios.append('%s: sin controles' % nombre)
            continue

        def nuevo():
            nonlocal siguiente
            siguiente += 1
            return str(siguiente - 1)

        def llave(n, x, y, cond, vars_):
            # la llave pura (LLAVES.md, seccion 4): la condicion es su valor,
            # x1 = 1 cerrada, x1 = 0 abierta; sin controles
            nom = ('SB-' if tipo else '') + n
            val = cond + (' V:' + vars_ if vars_ and sep == '&' else '')
            return ind + '%s, %s-%s = %s\n' % (nom, x, y, val)

        nuevas, ea, eb = [], a, b
        for extremo, (expr, vars_), letra in ((a, fa, 'A'), (b, fb, 'B')):
            if not expr:
                continue
            cand = candidatos(expr, extremo)
            if cand is None:
                cambios.append('%s: no entiendo la formula de nodo %r; la rama queda igual' % (nombre, expr))
                salida.append(l)
                break
            intermedio = nuevo()
            for p in cand:
                nuevas.append(llave('S%s%s%s' % (nombre, letra, p), intermedio, p,
                                    'abs((%s)-%s)<0.5' % (expr, p), vars_))
            if letra == 'A':
                ea = intermedio
            else:
                eb = intermedio
        else:
            if activa[0] not in ('1', '') or inicial[0] == '0':
                serie = nuevo()
                cond = activa[0] if activa[0] not in ('1', '') else '1'
                if inicial[0] == '0' and cond == '1':
                    cond = 'T>0'          # arranca abierta y despues esta siempre
                nuevas.insert(0, llave('S' + nombre, ea, serie, cond, activa[1]))
                ea = serie
            rama = '%s%s%s%s-%s%s=%s' % (ind, (tipo + '-' if tipo else '') + nombre, coma, ea, eb, esp,
                                         valor.rstrip())
            nota = '  # %s: controles pasados a %s' % (nombre, ', '.join(
                x.split(',')[0].strip().split('-')[-1] for x in nuevas))
            salida.append(rama + ((' #' + comentario) if comentario.strip() else nota) + '\n')
            salida.extend(nuevas)
            cambios.append('%s: %d llave(s)' % (nombre, len(nuevas)))
    return salida, cambios


def main():
    args = sys.argv[1:]
    probar = '--probar' in args
    for f in [x for x in args if x != '--probar']:
        lineas = open(f, encoding='utf-8', errors='surrogateescape').readlines()
        salida, cambios = transformar(lineas)
        if not cambios:
            continue
        print('%s: %s' % (f, '; '.join(cambios)))
        if probar:
            sys.stdout.writelines(x for x in salida if x not in lineas)
        else:
            open(f, 'w', encoding='utf-8', errors='surrogateescape').writelines(salida)


if __name__ == '__main__':
    main()
