"""Remet draw_acportail.drawio d'accord avec la base locale, puis retrace les liens.

    python outils/drawio/drawio.py                     # met à jour le schéma
    python outils/drawio/drawio.py --placer laquages=-451,1080 --png

- Le contenu de chaque table vient de MySQL (outils/drawio/schema.php) ; une
  table nouvelle se pose près d'une table liée, une table disparue est retirée.
  La position des tables existantes n'est jamais touchée (sauf --placer).
- Les liens sont tous retracés : départ sur la ligne de la colonne, arrivée sur
  un point libre du bord de la table visée, jamais sur le trajet d'un autre
  lien. Les croisements font un saut.
- Les références sans clé étrangère (ids Gestan, colonnes polymorphes, auteurs)
  se déclarent dans RELATIONS_SANS_CLE.
"""
import argparse, datetime, heapq, html, json, os, re, subprocess, sys, tempfile
import xml.etree.ElementTree as ET

ICI = os.path.dirname(os.path.abspath(__file__))
RACINE = os.path.normpath(os.path.join(ICI, '..', '..'))
FICHIER = os.path.join(RACINE, 'draw_acportail.drawio')
DRAWIO = r'C:\Program Files\draw.io\draw.io.exe'

# Tables jamais dessinées (en plus des temp_)
TECHNIQUES = {'cache', 'cache_locks', 'failed_jobs', 'job_batches', 'jobs', 'migrations',
              'password_reset_tokens', 'personal_access_tokens', 'sessions', 'produits'}

# (table, colonne) -> table visée, pour ce qui n'a pas de clé étrangère : trait pointillé
RELATIONS_SANS_CLE = {
    ('commandes', 'devis_id'): 'gestan_devis', ('commandes', 'client_id'): 'gestan_clients',
    ('plannings', 'client_id'): 'gestan_clients', ('devis_planning', 'devis_id'): 'gestan_devis',
    ('gestan_devis', 'client_id'): 'gestan_clients',
    ('adresses_chantier', 'devis_id'): 'gestan_devis',
    ('intervention_planning', 'intervention_id'): 'gestan_interventions',
    ('intervention_user', 'intervention_id'): 'gestan_interventions',
    ('intervention_document', 'intervention_id'): 'gestan_interventions',
    ('intervention_clotures', 'intervention_id'): 'gestan_interventions',
    ('fiches_intervention', 'intervention_id'): 'gestan_interventions',
    ('rapports', 'intervention_id'): 'gestan_interventions',
    ('logs', 'user_id'): 'users',
    ('rapports', 'approuve_par'): 'users', ('rapports', 'corrige_par'): 'users',
    ('intervention_clotures', 'terminee_par'): 'users', ('intervention_clotures', 'reouverte_par'): 'users',
    ('model_has_roles', 'model_id'): 'users', ('model_has_permissions', 'model_id'): 'users',
    # copies locales -> api Gestan
    ('gestan_clients', 'id'): 'clients (api gestan)', ('gestan_devis', 'id'): 'devis (api gestan)',
    ('gestan_interventions', 'id'): 'interventions (api gestan)',
}
LIBELLES = {('gestan_clients', 'clients (api gestan)'): 'copie', ('gestan_devis', 'devis (api gestan)'): 'copie',
            ('gestan_interventions', 'interventions (api gestan)'): 'copie'}
# liens entre les boîtes de l'api Gestan (pas dans MySQL) : trait plein
RELATIONS_API = {('devis (api gestan)', 'IDCONTACT'): 'clients (api gestan)',
                 ('interventions (api gestan)', 'IDCONTACT'): 'clients (api gestan)',
                 ('factures (api gestan)', 'IDCONTACT'): 'clients (api gestan)'}
MORPH = {('logs', 'loggable_id'), ('notifications', 'reference_id'),
         ('model_has_roles', 'model_id'), ('model_has_permissions', 'model_id')}
ID_GESTAN = {('produits_sav', 'produit_id'), ('fiche_intervention_lignes', 'produit_id')} | {
    k for k, v in RELATIONS_SANS_CLE.items() if v.startswith('gestan_')}

G, LIGNE = 8, 16          # grille des liens, hauteur d'une ligne de table
COUDE, CROISEMENT, CHEVAUCHEMENT = 4, 6, 400

SWIM = ('swimlane;fontStyle=0;childLayout=stackLayout;horizontal=1;startSize=26;horizontalStack=0;'
        'resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;align=center;fontSize=14;')
TEXTE = ('text;strokeColor=none;fillColor=none;spacing=0;spacingTop=4;spacingLeft=4;spacingRight=4;'
         'overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontSize=12;'
         'whiteSpace=wrap;html=1;verticalAlign=top;align=left;')
TD = 'style="padding:0 6px 0 0;white-space:nowrap;height:16px;line-height:16px;"'


def type_affiche(t, c, ty, cle, fks):
    if (t, c) in fks: return 'FOREIGN ID'
    if (t, c) in MORPH: return 'MORPH ID'
    if (t, c) in ID_GESTAN: return 'ID GESTAN'
    if (t, c) in RELATIONS_SANS_CLE and c != 'id': return 'FOREIGN ID'
    if cle == 'PRI' and c == 'id': return 'PRIMARY'
    ty = ty.lower()
    for debut, nom in (('tinyint(1)', 'BOOLEAN'), ('tinyint', 'TINYINT'), ('smallint', 'SMALLINT'),
                       ('bigint', 'BIGINT'), ('int', 'INTEGER'), ('varchar', 'STRING'), ('char', 'STRING')):
        if ty.startswith(debut): return nom
    if ty.startswith('decimal'): return ty.upper().replace(' UNSIGNED', '')
    if ty in ('longtext', 'mediumtext'): return 'LONGTEXT'
    if ty == 'text': return 'TEXT'
    if ty in ('timestamp', 'datetime'): return 'DATETIME'
    return ty.upper()


def largeur_texte(s):
    l = {'i': 2.7, 'l': 2.7, 'j': 2.7, 't': 3.3, 'f': 3.3, 'r': 4, 'm': 10, 'w': 8.7, ' ': 3.3}
    return sum(l.get(ch, 8 if ch.isupper() else 6.7) for ch in s)


def tableau(lignes):
    return ('<table style="border-collapse:collapse;font-size:12px;">'
            + ''.join(f'<tr><td {TD}>{html.escape(a)}</td><td {TD}>{html.escape(b)}</td></tr>' for a, b in lignes)
            + '</table>')


def lignes_du_texte(valeur):
    """Relit les lignes (nom, type) d'une boîte : tableau HTML ou ancien texte à tabulations."""
    v = html.unescape(valeur or '')
    cases = re.findall(r'<tr>(.*?)</tr>', v, re.S)
    if cases:
        out = []
        for tr in cases:
            tds = [html.unescape(re.sub(r'<[^>]+>', '', x)).strip() for x in re.findall(r'<td[^>]*>(.*?)</td>', tr, re.S)]
            out.append((tds[0], tds[1] if len(tds) > 1 else ''))
        return out
    v = re.sub(r'<br\s*/?>|</div>|<div>', '\n', v)
    v = re.sub(r'<[^>]+>', ' ', v).replace('\xa0', ' ')
    out = []
    for l in v.split('\n'):
        l = re.sub(r'\s+', ' ', l).strip()
        if l:
            a, _, b = l.partition(' ')
            out.append((a, b))
    return out


def style_pose(st, k, v):
    st = re.sub(r'(^|;)' + re.escape(k) + r'=[^;]*;?', r'\1', st or '')
    if st and not st.endswith(';'): st += ';'
    return st + (f'{k}={v};' if v is not None else '')


def main():
    if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8')
    ap = argparse.ArgumentParser()
    ap.add_argument('--placer', action='append', default=[], metavar='TABLE=X,Y',
                    help='pose une table à cet endroit (coin haut gauche)')
    ap.add_argument('--png', action='store_true', help='exporte un aperçu PNG (draw.io desktop)')
    ap.add_argument('--schema', help='JSON déjà relevé, au lieu de lancer schema.php')
    args = ap.parse_args()

    if args.schema:
        schema = json.load(open(args.schema, encoding='utf-8'))
    else:
        sortie = subprocess.run(['php', os.path.join(ICI, 'schema.php')], cwd=RACINE,
                                capture_output=True, text=True, encoding='utf-8')
        if sortie.returncode: sys.exit(sortie.stderr or sortie.stdout)
        schema = json.loads(sortie.stdout)
    print('Base relevée :', schema['base'])
    cols = {t: c for t, c in schema['cols'].items() if t not in TECHNIQUES and not t.startswith('temp_')}
    fks = {(t, c): rt for t, c, rt, _ in schema['fks'] if t in cols and rt in cols}

    arbre = ET.parse(FICHIER)
    gm = arbre.getroot().find('.//mxGraphModel/root')
    par_id = {c.get('id'): c for c in gm}

    def nom(c): return re.sub(r'<[^>]+>', '', html.unescape(c.get('value') or '')).strip()
    boites = {nom(c): c for c in gm if 'swimlane' in (c.get('style') or '') and c.get('parent') == '1'}
    enfant = {c.get('parent'): c for c in gm if c.get('vertex') == '1' and c.get('parent') != '1'}
    api = {n for n in boites if '(api gestan)' in n}

    # ---------- tables disparues ----------
    for n in sorted(set(boites) - set(cols) - api):
        b = boites.pop(n)
        for c in [c for c in gm if c.get('parent') == b.get('id')]: gm.remove(c)
        gm.remove(b)
        print('Table retirée :', n)

    # ---------- contenu de chaque table ----------
    lignes = {n: [(c, type_affiche(n, c, ty, k, fks)) for c, ty, _, k in cols[n]] for n in cols}
    for n in api:
        lignes[n] = lignes_du_texte(enfant[boites[n].get('id')].get('value'))

    relations = {}   # frozenset -> (porteur, colonne, visée, pointillé)
    def relier(t, c, rt, pointille):
        k = frozenset((t, rt))
        if k in relations and not relations[k][3]: return
        if k in relations and pointille: return
        relations[k] = (t, c, rt, pointille)
    for (t, c), rt in sorted(fks.items()): relier(t, c, rt, False)
    for (t, c), rt in RELATIONS_SANS_CLE.items():
        if t in lignes and (rt in lignes or rt in boites): relier(t, c, rt, True)
    for (t, c), rt in RELATIONS_API.items():
        if t in boites and rt in boites: relier(t, c, rt, False)

    geo = {}
    def depart_de_ligne(start):
        return (-(start + 12)) % G     # y tel que le centre des lignes tombe sur la grille

    places = {}
    for p in args.placer:
        n, _, xy = p.partition('=')
        x, y = (int(float(v)) for v in xy.split(','))
        places[n] = (x, y)

    nouvelles = [n for n in cols if n not in boites]
    for n in list(cols) + sorted(api):
        if n not in boites: continue
        b = boites[n]
        g = b.find('mxGeometry')
        start = int(re.search(r'startSize=(\d+)', b.get('style')).group(1))
        x, y = places.get(n, (float(g.get('x')), float(g.get('y'))))
        geo[n] = [x, y, float(g.get('width')), 0, start]

    def dimensions(n):
        larg = max(largeur_texte(a) for a, _ in lignes[n]) + max(largeur_texte(b) for _, b in lignes[n])
        return int(1.15 * larg) + 26

    # ---------- nouvelles tables : près d'une table liée, sur une place libre ----------
    legende = par_id.get('maj-legende')
    def rectangles():
        r = [(x, y, w, h) for x, y, w, h, _ in geo.values()]
        if legende is not None:
            lg = legende.find('mxGeometry')
            r.append(tuple(float(lg.get(k)) for k in ('x', 'y', 'width', 'height')))
        return r
    def libre(x, y, w, h, marge=32):
        return all(x + w + marge <= rx or rx + rw + marge <= x or y + h + marge <= ry or ry + rh + marge <= y
                   for rx, ry, rw, rh in rectangles())
    for n in nouvelles:
        w = -(-dimensions(n) // G) * G
        h = 26 + LIGNE * len(lignes[n]) + 8
        if n in places:
            x, y = places[n]
        else:
            voisins = [rt if t == n else t for t, c, rt, _ in relations.values() if n in (t, rt)]
            voisins = [v for v in voisins if v in geo] or list(geo)
            vx, vy, vw, vh, _ = geo[voisins[0]]
            cx, cy = vx + vw / 2, vy + vh / 2
            trouve = None
            for rayon in range(0, 3000, 40):
                for dx, dy in ((rayon, 0), (-rayon, 0), (0, rayon), (0, -rayon),
                               (rayon, rayon), (-rayon, rayon), (rayon, -rayon), (-rayon, -rayon)):
                    x, y = cx + dx - w / 2, cy + dy - h / 2
                    if libre(x, y, w, h): trouve = (x, y); break
                if trouve: break
            x, y = trouve
        b = ET.SubElement(gm, 'mxCell', id='table-' + n, parent='1', vertex='1', value=n,
                          style=SWIM + ('strokeColor=#6c8ebf;strokeWidth=2;' if n.startswith('gestan_') else ''))
        ET.SubElement(b, 'mxGeometry', x=str(x), y=str(y), width=str(w), height=str(h)).set('as', 'geometry')
        t = ET.SubElement(gm, 'mxCell', id='table-' + n + '-colonnes', parent=b.get('id'), vertex='1', style=TEXTE)
        ET.SubElement(t, 'mxGeometry', y='26', width=str(w), height='0').set('as', 'geometry')
        boites[n] = b; enfant[b.get('id')] = t
        geo[n] = [x, y, w, 0, 26]
        print(f'Table ajoutée : {n} en {int(x)},{int(y)} (à déplacer avec --placer si besoin)')

    # ---------- calage sur la grille, hauteur selon les colonnes ----------
    for n, (x, y, w, _, start) in geo.items():
        off = depart_de_ligne(start)
        x = round(x / G) * G
        y = round((y - off) / G) * G + off
        w = max(-(-int(w) // G) * G, -(-dimensions(n) // G) * G) if n in nouvelles or n not in api else -(-int(w) // G) * G
        h = start + LIGNE * len(lignes[n]) + 8
        geo[n] = [x, y, w, h, start]
        b = boites[n]
        g = b.find('mxGeometry')
        for k, v in (('x', x), ('y', y), ('width', w), ('height', h)): g.set(k, str(v))
        k = enfant[b.get('id')]
        k.set('style', TEXTE)
        k.set('value', tableau(lignes[n]))
        kg = k.find('mxGeometry')
        for a, v in (('x', 0), ('y', start), ('width', w), ('height', LIGNE * len(lignes[n]) + 8)): kg.set(a, str(v))

    noms = list(geo)
    for i, a in enumerate(noms):
        ax, ay, aw, ah, _ = geo[a]
        for bn in noms[i + 1:]:
            bx, by, bw, bh, _ = geo[bn]
            if ax < bx + bw + 16 and bx < ax + aw + 16 and ay < by + bh + 16 and by < ay + ah + 16:
                print(f'⚠ {a} et {bn} se touchent : déplacer l\'une avec --placer')

    if legende is not None:
        legende.set('value', re.sub(r'à jour au \d\d/\d\d/\d{4}',
                                    'à jour au ' + datetime.date.today().strftime('%d/%m/%Y'), legende.get('value')))

    # ---------- liens ----------
    for c in [c for c in gm if c.get('edge') == '1']: gm.remove(c)
    tracer(gm, geo, lignes, relations, boites, rectangles())

    ET.indent(arbre, '  ')
    arbre.write(FICHIER, encoding='utf-8', xml_declaration=False)
    print('Écrit :', FICHIER)

    if args.png:
        png = os.path.join(tempfile.gettempdir(), 'draw_acportail.png')
        subprocess.run([DRAWIO, '-x', '-f', 'png', '-s', '1', '-o', png, FICHIER], capture_output=True)
        print('Aperçu :', png)


def tracer(gm, geo, lignes, relations, boites, rects):
    xs = [r[0] for r in rects] + [r[0] + r[2] for r in rects]
    ys = [r[1] for r in rects] + [r[1] + r[3] for r in rects]
    X0, Y0 = int(min(xs) // G - 20) * G, int(min(ys) // G - 20) * G
    NX, NY = int((max(xs) - X0) // G) + 21, int((max(ys) - Y0) // G) + 21
    bloque = bytearray(NX * NY)
    def gi(px, py): return int(round((px - X0) / G)), int(round((py - Y0) / G))
    for x, y, w, h in rects:
        i0, j0 = gi(x - 4, y - 4); i1, j1 = gi(x + w + 4, y + h + 4)
        for i in range(max(0, i0), min(NX, i1 + 1)):
            for j in range(max(0, j0), min(NY, j1 + 1)):
                px, py = X0 + i * G, Y0 + j * G
                if x - 4 <= px <= x + w + 4 and y - 4 <= py <= y + h + 4:
                    bloque[j * NX + i] = 1

    H, V = 1, 2
    DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1)]
    usage, coudes = {}, set()

    def cout(k, d):
        if k in coudes: return CHEVAUCHEMENT
        u = usage.get(k, 0)
        if u & (H if d < 2 else V): return CHEVAUCHEMENT
        return CROISEMENT if u else 0

    def chemin(a, col, b):
        ax, ay, aw, ah, ast = geo[a]
        bx, by, bw, bh, _ = geo[b]
        ry = ay + ast + 4 + LIGNE // 2 + LIGNE * [l[0] for l in lignes[a]].index(col)
        tas, meilleur, avant = [], {}, {}
        buts = {}
        def but(px, py, d, port):
            i, j = gi(px, py)
            if 0 <= i < NX and 0 <= j < NY and not bloque[j * NX + i] and (j * NX + i) not in usage:
                buts[(i, j)] = (DIRS.index(d), port)
        for py in range(int(by) + 2 * G - int(by) % G, int(by + bh) - G + 1, G):
            but(bx - G, py, (1, 0), (bx, py)); but(bx + bw + G, py, (-1, 0), (bx + bw, py))
        for px in range(int(bx) + 2 * G, int(bx + bw) - G + 1, G):
            but(px, by - G, (0, 1), (px, by)); but(px, by + bh + G, (0, -1), (px, by + bh))
        bi0, bj0 = gi(bx - G, by - G); bi1, bj1 = gi(bx + bw + G, by + bh + G)
        def h(i, j): return max(bi0 - i, 0, i - bi1) + max(bj0 - j, 0, j - bj1)
        for px, d, port in ((ax - G, 1, (ax, ry)), (ax + aw + G, 0, (ax + aw, ry))):
            i, j = gi(px, ry)
            if 0 <= i < NX and not bloque[j * NX + i]:
                k = j * NX + i
                c0 = 1 + cout(k, d)
                meilleur[(k, d)] = c0; avant[(k, d)] = ('depart', port)
                heapq.heappush(tas, (c0 + h(i, j), c0, i, j, d))
        fin = None
        while tas:
            _, c, i, j, d = heapq.heappop(tas)
            k = j * NX + i
            if meilleur.get((k, d), 1e18) < c: continue
            if (i, j) in buts:
                fin = (c, i, j, d, buts[(i, j)][1]); break
            for nd, (di, dj) in enumerate(DIRS):
                if nd // 2 == d // 2 and nd != d: continue
                ni, nj = i + di, j + dj
                if not (0 <= ni < NX and 0 <= nj < NY): continue
                nk = nj * NX + ni
                if bloque[nk]: continue
                nc = c + 1 + cout(nk, nd) + ((COUDE + (CHEVAUCHEMENT if k in usage else 0)) if nd != d else 0)
                if nc < meilleur.get((nk, nd), 1e18):
                    meilleur[(nk, nd)] = nc; avant[(nk, nd)] = (k, d)
                    heapq.heappush(tas, (nc + h(ni, nj), nc, ni, nj, nd))
        if fin is None: return None
        c, i, j, d, port_t = fin
        pts, etat = [], (j * NX + i, d)
        while True:
            k, dd = etat
            pts.append((X0 + (k % NX) * G, Y0 + (k // NX) * G, dd))
            p = avant[etat]
            if p[0] == 'depart': port_s = p[1]; break
            etat = p
        pts.reverse()
        for n, (px, py, dd) in enumerate(pts):
            k = gi(px, py); k = k[1] * NX + k[0]
            usage[k] = usage.get(k, 0) | (H if dd < 2 else V)
            if n + 1 == len(pts) or pts[n + 1][2] != dd:
                usage[k] |= H | V; coudes.add(k)
        return port_s, pts, port_t, c

    def distance(r):
        a, b = geo[r[0]], geo[r[2]]
        return abs(a[0] + a[2] / 2 - b[0] - b[2] / 2) + abs(a[1] + a[3] / 2 - b[1] - b[3] / 2)

    superposes = []
    for t, col, rt, pointille in sorted(relations.values(), key=distance):
        res = chemin(t, col, rt)
        if res is None:
            print(f'⚠ aucun chemin de {t} vers {rt}'); continue
        port_s, pts, port_t, c = res
        if c >= CHEVAUCHEMENT: superposes.append(f'{t} → {rt}')
        poly = [port_s] + [(p[0], p[1]) for p in pts] + [port_t]
        out = [poly[0]]
        for n in range(1, len(poly) - 1):
            p0, p1, p2 = out[-1], poly[n], poly[n + 1]
            if p0[0] == p1[0] == p2[0] or p0[1] == p1[1] == p2[1]: continue
            out.append(p1)
        out.append(poly[-1])
        sx, sy, sw, sh, _ = geo[t]; tx, ty, tw, th, _ = geo[rt]
        st = ('edgeStyle=none;rounded=0;html=1;jumpStyle=arc;jumpSize=8;endArrow=classic;endSize=6;'
              f'exitX={(port_s[0]-sx)/sw:.6f};exitY={(port_s[1]-sy)/sh:.6f};exitDx=0;exitDy=0;exitPerimeter=0;'
              f'entryX={(port_t[0]-tx)/tw:.6f};entryY={(port_t[1]-ty)/th:.6f};entryDx=0;entryDy=0;entryPerimeter=0;'
              + ('dashed=1;' if pointille else ''))
        e = ET.Element('mxCell', id=f'lien-{t}-{col}', edge='1', parent='1', source=boites[t].get('id'),
                       target=boites[rt].get('id'), value=LIBELLES.get((t, rt), ''), style=st)
        g = ET.SubElement(e, 'mxGeometry', relative='1'); g.set('as', 'geometry')
        arr = ET.SubElement(g, 'Array'); arr.set('as', 'points')
        for px, py in out[1:-1]:
            ET.SubElement(arr, 'mxPoint', x=str(px), y=str(py))
        gm.insert(2, e)     # sous les tables
    print(f'{len(relations)} liens tracés, superposés : {len(superposes)}', superposes or '')


if __name__ == '__main__':
    main()
