#!/usr/bin/env python3
"""dibuja: el esquema de un circuito de Simul (.d, .ds, .ds2 o .dat) como SVG.

    dibuja circuito.d                 escribe circuito.svg
    dibuja circuito.dat -o otro.svg
    dibuja circuito.ds --json         solo el netlist para netlistsvg

De un .d o un .ds saca el dato con d2datdinc (el del arbol, siru/slex, o el
instalado); un .dat ya es el dato. Cada rama (tipo, dos nodos, valor) pasa a
una celda del netlist de netlistsvg, y netlistsvg la dibuja con sus simbolos
analogicos y la ubica solo (ELK):

    R  resistencia     C  capacitor     L  bobina
    E  fuente de tension (+ en el segundo nodo)
    J  fuente de corriente (+ en el segundo nodo)
    otra rama: una caja con su nombre y su tipo

Cada conexion a masa (GND o 0) lleva su propio simbolo de masa. El valor de
la rama, si es un numero, va con prefijo y unidad (10 kOhm, 100 uF); si es
una expresion, como esta.

Si el dato trae GLOBAL.formas (la seccion FORMAS del .ds), se usan esas
formas con prioridad sobre NOMBRE.formas. Si junto al circuito esta
NOMBRE.formas (lo escribe dia2formas a partir del
.dia de Simusol), cada rama se dibuja con la forma de Dia con que se dibujo
en Simusol (el tanque, la resistencia convectiva...), de la piel
pieles/simusol.svg (dia2piel). Con --termico, sin .formas, las ramas van
con las formas termicas: C tanque, R conduccion, E fuente de temperatura,
J flujo, la masa a 0 grados C.

netlistsvg (npm) se instala con 'npm install' en este directorio, o se usa
el que este en el PATH.

Copyright (C) Diego Saravia <dsa@unsa.edu.ar>
GPL version 3 o posterior.
"""
import argparse
import json
import math
import os
import shutil
import subprocess
import sys
import re
import tempfile

AQUI = os.path.dirname(os.path.realpath(__file__))
MASAS = {"GND", "gnd", "0"}

# primera letra del tipo de rama -> (celda de netlistsvg, unidad, nodo1, nodo2)
CELDAS = {
    "R": ("r_v", "Ω", "A", "B"),
    "C": ("c_v", "F", "A", "B"),
    "L": ("l_v", "H", "A", "B"),
    "E": ("v", "V", "-", "+"),
    "J": ("i", "A", "-", "+"),
}

PREFIJOS = [(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k"), (1, ""), (1e-3, "m"),
            (1e-6, "µ"), (1e-9, "n"), (1e-12, "p")]


def con_prefijo(x, unidad):
    """10000 Ω -> '10 kΩ'"""
    if x == 0:
        return "0 " + unidad
    for f, p in PREFIJOS:
        if abs(x) >= f * 0.9999999:
            return "%g %s%s" % (x / f, p, unidad)
    return "%g %s" % (x, unidad)


def valor_legible(valor, unidad, largo=28):
    v = (valor or "").strip()
    try:
        return con_prefijo(float(v), unidad)
    except ValueError:
        return v if len(v) <= largo else v[:largo - 1] + "…"


def d2datdinc():
    """el d2datdinc del arbol (siru/slex/d2datdinc), con su tabla y su
    biblioteca, o el instalado"""
    arbol = os.path.join(AQUI, "..", "slex", "d2datdinc")
    prog = os.path.join(arbol, "d2datdinc")
    if os.access(prog, os.X_OK):
        return [prog, "-c", os.path.join(arbol, "d2datdinc.slex"), "-b", os.path.join(arbol, "biblioteca")]
    instalado = shutil.which("d2datdinc")
    if instalado:
        return [instalado]
    sys.exit("dibuja: no encuentro d2datdinc (ni en siru/slex/d2datdinc ni en el PATH)")


def lee_dato(archivo):
    if archivo.endswith(".dat"):
        with open(archivo, encoding="utf-8") as f:
            return json.load(f)
    r = subprocess.run(d2datdinc() + [archivo, "-o", "-"], capture_output=True, text=True)
    if r.returncode:
        sys.exit("dibuja: d2datdinc no pudo leer %s:\n%s" % (archivo, r.stderr))
    return json.loads(r.stdout)


# con una forma termica, el valor va en las unidades del circuito termico
UNIDAD_TERMICA = {"R": "K/W", "C": "J/K", "E": "tempC", "J": "W"}
TERMICO = {"R": "Termico - vconductiva", "C": "Termico - acumulador", "E": "Termico - fuente",
           "J": "Termico - flujod", "masa": "Termico - 0gradosc"}
# la forma de una rama que no esta en NOMBRE.formas: la del conjunto que
# predomina en el archivo (un .ds armado a mano a partir de un diagrama
# tiene ramas que el diagrama no tiene)
CONJUNTOS = {
    "Termico": TERMICO,
    "Electrico": {"R": "Electrico - vresistor", "C": "Electrico - vcapacitor", "L": "Electrico - vinductor",
                  "E": "Electrico - vfem", "J": "Electrico - vcorriente", "masa": "Electrico - tierra"},
    "Generico": {"R": "Generico - vresistor", "C": "Generico - vcapacitor", "L": "Generico - vinductor",
                 "E": "Generico - vfuente", "J": "Generico - flujod", "masa": "Generico - 0"},
}


def predominante(formas):
    """el conjunto (Termico, Electrico, Generico) de la mayoria de las ramas"""
    cuenta = {}
    for rama, forma in formas.items():
        if rama != "masa":
            g = forma.split(" - ", 1)[0].strip()
            if g in CONJUNTOS:
                cuenta[g] = cuenta.get(g, 0) + 1
    return max(cuenta, key=cuenta.get) if cuenta else None
PIEL = os.path.join(AQUI, "pieles", "simusol.svg")


def tipo_de(forma):
    """'Termico - acumulador' -> 'Termico-acumulador', como en la piel"""
    return re.sub(r"\s*-\s*", "-", forma.strip()).replace(" ", "_")


def puertos_de_la_piel(piel):
    """tipo de celda -> sus puertos p0, p1... (las formas de Dia)"""
    res = {}
    try:
        t = open(piel, encoding="utf-8").read()
    except OSError:
        return res
    for m in re.finditer(r'<g s:type="([^"]+)"[^>]*>(.*?)\n</g>', t, re.S):
        pids = re.findall(r's:pid="(p\d+)"', m.group(2))
        if pids:
            res[m.group(1)] = pids
    return res


def lee_formas(circuito, dato=None):
    """NOMBRE.formas y GLOBAL.formas: la seccion tiene prioridad por nombre."""
    f = os.path.splitext(circuito)[0] + ".formas"
    res = {}
    if os.path.exists(f):
        for l in open(f, encoding="utf-8"):
            l = l.split("#", 1)[0].strip()
            if not l:
                continue
            if "\t" in l:                   # nombre, forma, orientacion, lugar
                campos = l.split("\t")
                nombre = campos[0].strip()
                if nombre.startswith("nodo "):
                    continue
                res.setdefault(nombre, campos[1].strip())   # la primera masa
            else:                           # el formato anterior: rama forma
                rama, _, forma = l.partition(" ")
                res[rama] = forma.strip()
    internas = {}
    for forma in ((dato or {}).get("GLOBAL") or {}).get("formas") or []:
        nombre = forma["nombre"]
        if not nombre.startswith("nodo "):
            internas.setdefault(nombre, forma["forma"])   # la primera masa
    res.update(internas)
    return res


def netlist(dato, nombre, formas=None, termico=False, puertos=None):
    """el dato de Simul -> el netlist de netlistsvg (formato de Yosys); con
    formas (rama -> forma de Dia), esas ramas con su forma"""
    formas = formas or {}
    puertos = puertos or {}
    ramas = (dato.get("GLOBAL") or {}).get("ramas") or {}
    if not ramas:
        sys.exit("dibuja: el dato no tiene ramas (GLOBAL.ramas)")
    nets = {}
    celdas = {}

    def red(nodo):
        if nodo not in nets:
            nets[nodo] = len(nets) + 2      # 0 y 1 son las constantes de Yosys
        return nets[nodo]

    masas = 0
    conjunto = CONJUNTOS.get(predominante(formas)) or (TERMICO if termico else {})
    forma_masa = formas.get("masa") or conjunto.get("masa")
    tipo_masa = tipo_de(forma_masa) if forma_masa and tipo_de(forma_masa) in puertos else None

    def masa():
        nonlocal masas
        masas += 1
        bit = 10000 + masas
        if tipo_masa:
            celdas["masa%d" % masas] = {"type": tipo_masa, "port_directions": {puertos[tipo_masa][0]: "input"},
                                        "connections": {puertos[tipo_masa][0]: [bit]}}
        else:
            celdas["gnd%d" % masas] = {"type": "gnd", "port_directions": {"A": "input"},
                                       "connections": {"A": [bit]}}
        return bit

    for rama in sorted(ramas):
        r = ramas[rama]
        nodos = r.get("nodos") or []
        if len(nodos) != 2:
            continue
        tipo = r.get("tipo", "")
        celda, unidad, p1, p2 = CELDAS.get(tipo[:1].upper(), ("generic", "", "in0", "out0"))
        forma = formas.get(rama) or conjunto.get(tipo[:1].upper())
        if forma and tipo_de(forma) in puertos:
            if forma.startswith("Termico"):
                unidad = UNIDAD_TERMICA.get(tipo[:1].upper(), unidad)
            celdas[rama] = celda_con_forma(tipo_de(forma), puertos[tipo_de(forma)], nodos, red, masa,
                                           valor_legible(r.get("valor"), unidad) if celda != "generic" else tipo)
            continue
        conexiones = {}
        for puerto, nodo in ((p1, nodos[0]), (p2, nodos[1])):
            # cada conexion a masa, con su simbolo
            conexiones[puerto] = [masa() if nodo in MASAS else red(nodo)]
        if celda == "generic":
            atributos = {"value": tipo}
        else:
            atributos = {"value": valor_legible(r.get("valor"), unidad)}
        celdas[rama] = {"type": celda,
                        "port_directions": {p1: "input", p2: "output"},
                        "connections": conexiones,
                        "attributes": atributos}
    return {"modules": {nombre: {"ports": {}, "cells": celdas}}}


def celda_con_forma(tipo, pids, nodos, red, masa, valor):
    """una rama con su forma de Dia. Con dos puertos o mas, el primero va al
    primer nodo y el segundo al segundo; con uno solo (el tanque, la fuente
    de temperatura: el otro extremo es la masa, que la forma ya dibuja), al
    nodo que no es masa"""
    if len(pids) >= 2:
        con = {pids[0]: [masa() if nodos[0] in MASAS else red(nodos[0])],
               pids[1]: [masa() if nodos[1] in MASAS else red(nodos[1])]}
    else:
        nodo = nodos[1] if nodos[0] in MASAS else nodos[0]
        con = {pids[0]: [masa() if nodo in MASAS else red(nodo)]}
    return {"type": tipo, "port_directions": {p: ("input" if i == 0 else "output") for i, p in enumerate(con)},
            "connections": con, "attributes": {"value": valor}}


def netlistsvg():
    local = os.path.join(AQUI, "node_modules", "netlistsvg", "bin", "netlistsvg.js")
    if os.path.isfile(local):
        node = shutil.which("node")
        if not node:
            sys.exit("dibuja: no esta Node.js (node) en el PATH")
        # Los paquetes instalan los .js de share/ sin permiso de ejecucion.
        return [node, local], (PIEL if os.path.exists(PIEL) else os.path.join(AQUI, "node_modules", "netlistsvg", "lib", "analog.svg"))
    global_ = shutil.which("netlistsvg")
    if global_:
        piel = os.path.join(os.path.dirname(os.path.realpath(global_)), "..", "lib", "analog.svg")
        return [global_], (PIEL if os.path.exists(PIEL) else os.path.normpath(piel))
    sys.exit("dibuja: no esta netlistsvg; instalarlo con 'npm install' en %s" % AQUI)


def main():
    a = argparse.ArgumentParser(description="El esquema de un circuito de Simul (.d, .ds, .ds2, .dat) como SVG, con netlistsvg.")
    a.add_argument("circuito")
    a.add_argument("-o", "--salida", help="el SVG (por omision, el nombre del circuito con .svg)")
    a.add_argument("--json", action="store_true", help="escribir solo el netlist de netlistsvg (JSON)")
    a.add_argument("--termico", action="store_true", help="sin formas, las ramas con las formas termicas de Simusol")
    a.add_argument("--electrico", action="store_true", help="ignorar FORMAS y NOMBRE.formas: los simbolos electricos")
    o = a.parse_args()
    base = os.path.splitext(os.path.basename(o.circuito))[0]
    dato = lee_dato(o.circuito)
    formas = {} if o.electrico else lee_formas(o.circuito, dato)
    red = netlist(dato, base, formas, o.termico and not o.electrico, puertos_de_la_piel(PIEL))
    if o.json:
        salida = o.salida or os.path.splitext(o.circuito)[0] + ".json"
        with open(salida, "w", encoding="utf-8") as f:
            json.dump(red, f, indent=1, ensure_ascii=False)
        print(salida)
        return
    salida = o.salida or os.path.splitext(o.circuito)[0] + ".svg"
    prog, piel = netlistsvg()
    with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
        json.dump(red, f, ensure_ascii=False)
        tmp = f.name
    try:
        r = subprocess.run(prog + [tmp, "--skin", piel, "-o", salida], capture_output=True, text=True)
    finally:
        os.unlink(tmp)
    if r.returncode:
        sys.exit("dibuja: netlistsvg fallo:\n%s%s" % (r.stdout, r.stderr))
    ensancha(salida, red)
    print(salida)


def ensancha(svg, red):
    """netlistsvg no cuenta los rotulos al calcular el ancho y los de la
    derecha quedan cortados: se agrega lo que ocupa el mas largo (texto de
    10 px en Courier, unos 6,5 px por letra)"""
    celdas = next(iter(red["modules"].values()))["cells"]
    largo = max([len(n) for n in celdas] +
                [len(c.get("attributes", {}).get("value", "")) for c in celdas.values()] + [0])
    with open(svg, encoding="utf-8") as f:
        t = f.read()
    t = re.sub(r'(<svg[^>]*?\bwidth=")(\d+(?:\.\d+)?)"',
               lambda m: '%s%d"' % (m.group(1), float(m.group(2)) + 6.5 * largo + 20), t, count=1)
    with open(svg, "w", encoding="utf-8") as f:
        f.write(t)


if __name__ == "__main__":
    main()
