#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Валидация сгенерированного проекта seeed-clicker.

1. Баланс скобок + разбор s-выражений (sexpdata) всех файлов.
2. Загрузка схем через kicad-skip (объектная модель eeschema).
3. Сверка библиотек: каждый lib_id схемы есть в libs/seeed-clicker.kicad_sym,
   каждый footprint из символа/PCB есть в libs/seeed-clicker.pretty.
4. Сверка PCB с сетлистом схемы: набор (номер пада, сеть) у каждого референса.
5. Проверка наложений футпринтов на платах (габариты + поля Reference).

Запуск:  python3 tools/validate.py
"""

import sys
from pathlib import Path

import sexpdata
from sexpdata import loads as sexp_loads

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "tools"))

SCH_FILES = ["seeed-clicker.kicad_sch", "clicker.kicad_sch", "dongle.kicad_sch"]
PCB_FILE = "seeed-clicker.kicad_pcb"
SYM_LIB = "libs/seeed-clicker.kicad_sym"
FP_DIR = Path("libs/seeed-clicker.pretty")

errors = []
warnings = []


def err(msg):
    errors.append(msg)


def warn(msg):
    warnings.append(msg)


def balance_check(path: Path):
    s = path.read_text(encoding="utf-8")
    depth = 0
    instr = False
    esc = False
    line = 1
    for ch in s:
        if ch == "\n":
            line += 1
        if instr:
            if esc:
                esc = False
            elif ch == "\\":
                esc = True
            elif ch == '"':
                instr = False
            continue
        if ch == '"':
            instr = True
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
            if depth < 0:
                err("%s:%d — лишняя закрывающая скобка" % (path.name, line))
                return
    if depth != 0:
        err("%s — незакрытых скобок: %d" % (path.name, depth))


def sexp_check(path: Path):
    try:
        return sexp_loads(path.read_text(encoding="utf-8"))
    except Exception as e:
        err("%s — sexpdata: %s" % (path.name, e))
        return None


def sym(fp, name):
    """True, если fp — список с головным символом name (или одним из name)."""
    if not isinstance(fp, list) or not fp:
        return False
    head = fp[0]
    head = head.value() if hasattr(head, "value") and callable(head.value) else head
    head = str(head)
    if isinstance(name, (tuple, list, set)):
        return head in name
    return head == name


def atoms(node):
    return [x for x in node if not isinstance(x, list)]


# ---------------------------------------------------------------------------
def collect_sym_lib(tree):
    """Словарь: имя символа -> список имён пинов (номер -> имя)."""
    out = {}
    for node in tree[1:]:
        if not isinstance(node, list) or not sym(node, "symbol"):
            continue
        symname = str(node[1])
        pins = {}
        for sub in node:
            if isinstance(sub, list) and sym(sub, "symbol"):
                for leaf in sub:
                    if isinstance(leaf, list) and sym(leaf, "pin"):
                        num = pname = None
                        for f in leaf:
                            if isinstance(f, list) and sym(f, "number"):
                                num = f[1]
                            if isinstance(f, list) and sym(f, "name"):
                                pname = f[1]
                        if num is not None:
                            pins[str(num)] = pname
        out[symname] = pins
    return out


def collect_sch_symbols(tree):
    """Список (lib_id, reference, at) из листа схемы."""
    res = []
    for node in tree:
        if not isinstance(node, list) or not sym(node, "symbol"):
            continue
        if not any(isinstance(f, list) and sym(f, "lib_id") for f in node):
            continue  # это определение из lib_symbols
        lib_id = ref = at = None
        for f in node:
            if isinstance(f, list):
                if sym(f, "lib_id"):
                    lib_id = f[1]
                elif sym(f, "at"):
                    at = tuple(f[1:4])
                elif sym(f, "property") and f[1] == "Reference":
                    ref = f[2]
        res.append((str(lib_id), str(ref), at))
    return res


def collect_lib_symbols(tree):
    """lib_symbols -> множество полных имён (name)."""
    names = set()
    for node in tree:
        if isinstance(node, list) and sym(node, "lib_symbols"):
            for s in node[1:]:
                if isinstance(s, list) and sym(s, "symbol"):
                    names.add(str(s[1]))
    return names


# ---------------------------------------------------------------------------
def collect_pcb(tree):
    fps = []
    nets = {}
    for node in tree:
        if isinstance(node, list) and sym(node, "net") and len(node) >= 3:
            nets[int(node[1])] = str(node[2])
        if not isinstance(node, list) or not sym(node, "footprint"):
            continue
        fpname = str(node[1])
        ref = None
        at = (0, 0, 0)
        pads = []
        for f in node:
            if not isinstance(f, list):
                continue
            if sym(f, "at"):
                at = tuple(f[1:4]) + (0,) * (3 - len(f[1:4]))
            elif sym(f, "property") and f[1] == "Reference":
                ref = str(f[2])
            elif sym(f, "pad"):
                num = str(f[1])
                net_id = None
                net_name = None
                xy = (0, 0)
                for g in f:
                    if isinstance(g, list):
                        if sym(g, "net"):
                            net_id = int(g[1])
                            net_name = str(g[2]) if len(g) > 2 else None
                        elif sym(g, "at"):
                            xy = (float(g[1]), float(g[2]))
                pads.append(dict(num=num, net=net_name, net_id=net_id, xy=xy))
        fps.append(dict(fp=fpname, ref=ref, at=at, pads=pads))
    return fps, nets


# ---------------------------------------------------------------------------
def geom_check(tree, fname):
    """Геометрия листа: метки и точки подключения пинов на проводниках."""
    # провода
    segs = []
    pts = set()
    for n in tree:
        if isinstance(n, list) and sym(n, "wire"):
            p = [f for f in n if isinstance(f, list) and sym(f, "pts")]
            if not p:
                continue
            xy = [(round(float(f[1]), 2), round(float(f[2]), 2))
                  for f in p[0] if isinstance(f, list) and sym(f, "xy")]
            if len(xy) == 2:
                segs.append(tuple(xy))
                pts.update(xy)

    def on_wire(x, y):
        x, y = round(x, 2), round(y, 2)
        if (x, y) in pts:
            return True
        for (x1, y1), (x2, y2) in segs:
            if x1 == x2 == x and min(y1, y2) <= y <= max(y1, y2):
                return True
            if y1 == y2 == y and min(x1, x2) <= x <= max(x1, x2):
                return True
        return False

    ncs = set()
    for n in tree:
        if isinstance(n, list) and sym(n, "no_connect"):
            at = [f for f in n if isinstance(f, list) and sym(f, "at")][0]
            ncs.add((round(float(at[1]), 2), round(float(at[2]), 2)))

    # локальные метки
    for n in tree:
        if isinstance(n, list) and sym(n, "label"):
            at = [f for f in n if isinstance(f, list) and sym(f, "at")][0]
            if not on_wire(float(at[1]), float(at[2])):
                err("%s: метка %s (%s, %s) не на проводнике"
                    % (fname, n[1], at[1], at[2]))

    # концы пинов: точка подключения = at пина символа (+ поворот символа)
    import math
    libs = {}
    for n in tree:
        if isinstance(n, list) and sym(n, "lib_symbols"):
            for s in n[1:]:
                if not (isinstance(s, list) and sym(s, "symbol")):
                    continue
                pins = []
                for sub in s:
                    if isinstance(sub, list) and sym(sub, "symbol"):
                        for leaf in sub:
                            if isinstance(leaf, list) and sym(leaf, "pin"):
                                at = [g for g in leaf if isinstance(g, list)
                                      and sym(g, "at")][0]
                                num = [g for g in leaf if isinstance(g, list)
                                       and sym(g, "number")]
                                pins.append((float(at[1]), float(at[2]),
                                             str(num[0][1]) if num else "?"))
                libs[str(s[1])] = pins

    n_missing = 0
    for n in tree:
        if not (isinstance(n, list) and sym(n, "symbol")):
            continue
        if not any(isinstance(f, list) and sym(f, "lib_id") for f in n):
            continue
        lib = [f for f in n if isinstance(f, list) and sym(f, "lib_id")][0][1]
        at = [f for f in n if isinstance(f, list) and sym(f, "at")][0]
        ref = [f for f in n if isinstance(f, list) and sym(f, "property")
               and f[1] == "Reference"][0][2]
        sx, sy = float(at[1]), float(at[2])
        srot = float(at[3]) if len(at) > 3 else 0.0
        for (px, py, pnum) in libs.get(str(lib), []):
            r = math.radians(srot)
            dx = px * math.cos(r) - py * math.sin(r)
            dy = px * math.sin(r) + py * math.cos(r)
            gx, gy = sx + dx, sy - dy
            if (round(gx, 2), round(gy, 2)) in ncs:
                continue
            if not on_wire(gx, gy):
                err("%s: пин %s.%s (%.2f, %.2f) не подключён и не NC"
                    % (fname, ref, pnum, gx, gy))
                n_missing += 1
    return len(segs)


def sym_bbox(tree, node):
    """Габарит символа на листе: прямоугольник/toplevel-графика + концы пинов."""
    import math
    libs = {}
    for n in tree:
        if isinstance(n, list) and sym(n, "lib_symbols"):
            for s in n[1:]:
                if not (isinstance(s, list) and sym(s, "symbol")):
                    continue
                xs, ys = [], []
                pins = []
                for sub in s:
                    if isinstance(sub, list) and sym(sub, "symbol"):
                        for leaf in sub:
                            if isinstance(leaf, list) and sym(leaf, "rectangle"):
                                st = [g for g in leaf if isinstance(g, list)
                                      and sym(g, "start")][0]
                                en = [g for g in leaf if isinstance(g, list)
                                      and sym(g, "end")][0]
                                xs += [float(st[1]), float(en[1])]
                                ys += [float(st[2]), float(en[2])]
                            if isinstance(leaf, list) and sym(leaf, "polyline"):
                                p = [g for g in leaf if isinstance(g, list)
                                     and sym(g, "pts")]
                                if p:
                                    for q in p[0]:
                                        if isinstance(q, list) and sym(q, "xy"):
                                            xs.append(float(q[1]))
                                            ys.append(float(q[2]))
                            if isinstance(leaf, list) and sym(leaf, "pin"):
                                at = [g for g in leaf if isinstance(g, list)
                                      and sym(g, "at")][0]
                                pins.append((float(at[1]), float(at[2])))
                libs[str(s[1])] = (min(xs) if xs else 0, min(ys) if ys else 0,
                                   max(xs) if xs else 0, max(ys) if ys else 0, pins)
    lib = [f for f in node if isinstance(f, list) and sym(f, "lib_id")][0][1]
    at = [f for f in node if isinstance(f, list) and sym(f, "at")][0]
    sx, sy = float(at[1]), float(at[2])
    srot = float(at[3]) if len(at) > 3 else 0.0
    x1, y1, x2, y2, pins = libs.get(str(lib), (0, 0, 0, 0, []))
    r = math.radians(srot)
    pts = [(x1, y1), (x2, y1), (x1, y2), (x2, y2)] + list(pins)
    out = []
    for (px, py) in pts:
        dx = px * math.cos(r) - py * math.sin(r)
        dy = px * math.sin(r) + py * math.cos(r)
        out.append((sx + dx, sy - dy))
    return (min(p[0] for p in out), min(p[1] for p in out),
            max(p[0] for p in out), max(p[1] for p in out))


def layout_check(tree, fname):
    """Текстовые примечания не должны перекрывать символы; символы — друг друга."""
    boxes = []
    for n in tree:
        if isinstance(n, list) and sym(n, "symbol") \
                and any(isinstance(f, list) and sym(f, "lib_id") for f in n):
            ref = [f for f in n if isinstance(f, list) and sym(f, "property")
                   and f[1] == "Reference"][0][2]
            boxes.append((str(ref), sym_bbox(tree, n)))
    # тексты примечаний (размер берём из effects/font/size)
    texts = []
    for n in tree:
        if isinstance(n, list) and sym(n, "text"):
            at = [f for f in n if isinstance(f, list) and sym(f, "at")][0]
            size = 1.27
            for f in n:
                if isinstance(f, list) and sym(f, "effects"):
                    for g in f:
                        if isinstance(g, list) and sym(g, "font"):
                            for h in g:
                                if isinstance(h, list) and sym(h, "size"):
                                    size = float(h[1])
            s = str(n[1])
            # приблизительная ширина stroke-шрифта KiCad
            w = len(s) * size * 0.72
            x, y = float(at[1]), float(at[2])
            texts.append(("text", (x, y - size, x + w, y + 0.3 * size)))

    def overlap(a, b, tol=0.5):
        return (a[0] < b[2] - tol and b[0] < a[2] - tol
                and a[1] < b[3] - tol and b[1] < a[3] - tol)

    for i, (ra, ba) in enumerate(boxes):
        for rb, bb in boxes[i + 1:]:
            if overlap(ba, bb):
                err("%s: наложение символов %s и %s" % (fname, ra, rb))
        for tn, tb in texts:
            if overlap(ba, tb):
                err("%s: примечание перекрывает символ %s" % (fname, ra))
    return len(boxes), len(texts)


# ---------------------------------------------------------------------------
def main():
    # 1. структурные проверки
    all_files = SCH_FILES + [PCB_FILE, SYM_LIB] + [
        str(p.relative_to(ROOT)) for p in sorted((ROOT / FP_DIR).glob("*.kicad_mod"))
    ]
    trees = {}
    for f in all_files:
        p = ROOT / f
        if not p.exists():
            err("нет файла %s" % f)
            continue
        balance_check(p)
        trees[f] = sexp_check(p)

    # 2. kicad-skip для схем + геометрия
    try:
        import skip
        for f in SCH_FILES:
            if f not in trees:
                continue
            try:
                s = skip.Schematic(str(ROOT / f))
                ns = len(list(s.symbol))
                nw = len(list(s.wire))
                nl = len(list(s.label))
                print("kicad-skip OK  %-24s symbols=%d wires=%d labels=%d"
                      % (f, ns, nw, nl))
                if f != "seeed-clicker.kicad_sch" and ns == 0:
                    err("%s: нет символов" % f)
            except Exception as e:
                err("kicad-skip не смог прочитать %s: %s" % (f, e))
    except ImportError:
        warn("kicad-skip не установлен — объектная проверка схем пропущена")

    for f in SCH_FILES:
        if trees.get(f):
            nseg = geom_check(trees[f], f)
            nsym, ntxt = layout_check(trees[f], f)
            print("геометрия %-24s проводов: %d, символов: %d, текстов: %d"
                  % (f, nseg, nsym, ntxt))

    # 3. библиотеки
    symtree = trees.get(SYM_LIB)
    libsyms = collect_sym_lib(symtree) if symtree else {}
    print("библиотека символов: %d символов" % len(libsyms))

    fppaths = sorted((ROOT / FP_DIR).glob("*.kicad_mod"))
    fpnames = {p.stem for p in fppaths}
    print("библиотека футпринтов: %d" % len(fpnames))

    # 4. сверка схем с библиотеками
    sch_refs = {}          # ref -> (sheet, lib_id)
    for f in SCH_FILES:
        tree = trees.get(f)
        if not tree:
            continue
        used_in_file = collect_lib_symbols(tree)
        for lib_id, ref, at in collect_sch_symbols(tree):
            name = lib_id.split(":", 1)[-1]
            if name not in libsyms:
                err("%s: символ %s (ref %s) отсутствует в библиотеке" % (f, lib_id, ref))
            if lib_id not in used_in_file and f != "seeed-clicker.kicad_sch":
                err("%s: %s не найден в lib_symbols" % (f, lib_id))
            if ref in sch_refs and not ref.startswith("#"):
                err("референс %s продублирован: %s и %s" % (ref, sch_refs[ref][0], f))
            sch_refs[ref] = (f, lib_id)

    # 5. PCB
    pc = trees.get(PCB_FILE)
    if pc:
        fps, nets = collect_pcb(pc)
        print("PCB: %d футпринтов, %d сетей" % (len(fps), len(nets)))
        pcb_refs = {fp["ref"]: fp for fp in fps}
        # 5.1 референсы PCB == референсы схемы (без #PWR/#FLG)
        sch_board = {r for r, (f, _) in sch_refs.items()
                     if not r.startswith("#") and f != "seeed-clicker.kicad_sch"}
        pcb_set = set(pcb_refs)
        for r in sorted(sch_board - pcb_set):
            err("PCB: нет футпринта для %s" % r)
        for r in sorted(pcb_set - sch_board):
            err("PCB: лишний футпринт %s" % r)
        # 5.2 имя футпринта существует
        for fp in fps:
            n = fp["fp"].split(":", 1)[-1]
            if n not in fpnames:
                err("PCB: футпринт %s (ref %s) не найден в .pretty"
                    % (fp["fp"], fp["ref"]))
            if fp["ref"] not in sch_board:
                err("PCB: референс %s не найден в схеме" % fp["ref"])
        # 5.3 сверка падов PCB с сетлистом схемы (tools/gen.py — источник истины)
        try:
            import gen
            ref_sheet = {}
            expected = {}
            for sh in (gen.CLICKER, gen.DONGLE):
                for cc in sh.comps:
                    ref_sheet[cc["ref"]] = sh.name
                for r_, pin_, net_ in sh.netmap:
                    pcbnet = net_ if net_ in gen.POWER_NETS \
                        else "/%s/%s" % (sh.name.lower(), net_)
                    expected[(sh.name, r_, pin_)] = pcbnet
            connected = set()
            for fp in fps:
                sheet = ref_sheet.get(fp["ref"])
                for p in fp["pads"]:
                    key = (sheet, fp["ref"], p["num"])
                    if p["net"] is None:
                        # без сети — допустимо, только если пин в схеме не подключён (NC)
                        if key in expected:
                            err("%s pad %s: в схеме сеть %s, а на паде сети нет"
                                % (fp["ref"], p["num"], expected[key]))
                        continue
                    connected.add(key)
                    if p["net_id"] is None or p["net_id"] not in nets:
                        err("%s pad %s: net_id %s отсутствует в объявлении сетей"
                            % (fp["ref"], p["num"], p["net_id"]))
                    if key not in expected:
                        err("%s pad %s: сеть %s есть на PCB, но нет в схеме"
                            % (fp["ref"], p["num"], p["net"]))
                    elif expected[key] != p["net"]:
                        err("%s pad %s: сеть %s (PCB) != %s (схема)"
                            % (fp["ref"], p["num"], p["net"], expected[key]))
            for key in sorted(set(expected) - connected):
                if not key[1].startswith("#"):
                    err("%s pad %s: подключён в схеме (%s), но не на PCB"
                        % (key[1], key[2], expected[key]))
            n_nc = sum(1 for fp in fps for p in fp["pads"] if p["net"] is None)
            print("сверка PCB<->схема: %d падов с сетью, %d NC" % (len(connected), n_nc))
        except ImportError:
            warn("tools/gen.py недоступен — сверка PCB со схемой пропущена")
        # 5.4 наложение футпринтов: габарит по F.CrtYd (как DRC в KiCad),
        #     а при его отсутствии — по падам и всей графике
        boxes = {}
        for fp in fps:
            src = trees.get(str(FP_DIR / (fp["fp"].split(":", 1)[-1] + ".kicad_mod")))
            if not src:
                continue

            def collect(only_crtyd):
                pts = []
                for node in src:
                    if not isinstance(node, list):
                        continue
                    if sym(node, "pad"):
                        if only_crtyd:
                            continue
                        px = py = sw = sh = 0.0
                        for g in node:
                            if isinstance(g, list) and sym(g, "at"):
                                px, py = float(g[1]), float(g[2])
                            if isinstance(g, list) and sym(g, "size"):
                                sw, sh = float(g[1]) / 2, float(g[2]) / 2
                        pts.extend([(px - sw, py - sh), (px + sw, py + sh)])
                    elif sym(node, ("fp_line", "fp_rect", "fp_circle", "fp_poly",
                                    "fp_text", "property")):
                        layer = [g for g in node if isinstance(g, list)
                                 and sym(g, "layer")]
                        if not layer:
                            continue
                        if only_crtyd and str(layer[0][1]) != "F.CrtYd":
                            continue
                        if not only_crtyd and str(layer[0][1]) in (
                                "F.SilkS", "F.Fab") and sym(node, ("fp_text",
                                                                   "property")):
                            continue   # текст — не габарит
                        for g in node:
                            if isinstance(g, list) and sym(g, ("start", "end",
                                                               "center", "at",
                                                               "xy")):
                                try:
                                    pts.append((float(g[1]), float(g[2])))
                                except (ValueError, IndexError):
                                    pass
                return pts

            pts = collect(True) or collect(False)
            if not pts:
                continue
            rot = int(fp["at"][2]) % 360
            import math as _m
            ca, sa = _m.cos(_m.radians(rot)), _m.sin(_m.radians(rot))
            tr = [(fp["at"][0] + x * ca - y * sa, fp["at"][1] + x * sa + y * ca)
                  for x, y in pts]
            boxes[fp["ref"]] = (min(p[0] for p in tr), min(p[1] for p in tr),
                                max(p[0] for p in tr), max(p[1] for p in tr))
        refs = sorted(boxes)
        for i, a in enumerate(refs):
            for b in refs[i + 1:]:
                ax1, ay1, ax2, ay2 = boxes[a]
                bx1, by1, bx2, by2 = boxes[b]
                if ax1 < bx2 and bx1 < ax2 and ay1 < by2 and by1 < ay2:
                    err("PCB: наложение корпусов %s и %s" % (a, b))
        # 5.5 все футпринты внутри контуров плат (Edge.Cuts gr_rect)
        outlines = []
        for node in pc:
            if isinstance(node, list) and sym(node, "gr_rect"):
                layer = [g for g in node if isinstance(g, list) and sym(g, "layer")]
                if not layer or str(layer[0][1]) != "Edge.Cuts":
                    continue
                st = [g for g in node if isinstance(g, list) and sym(g, "start")][0]
                en = [g for g in node if isinstance(g, list) and sym(g, "end")][0]
                outlines.append((min(float(st[1]), float(en[1])),
                                 min(float(st[2]), float(en[2])),
                                 max(float(st[1]), float(en[1])),
                                 max(float(st[2]), float(en[2]))))
        if not outlines:
            err("PCB: нет контуров Edge.Cuts")
        for r in refs:
            b = boxes[r]
            hit = any(o[0] - 0.01 <= b[0] and o[1] - 0.01 <= b[1]
                      and b[2] <= o[2] + 0.01 and b[3] <= o[3] + 0.01
                      for o in outlines)
            if not hit:
                err("PCB: %s выходит за контур платы (габарит %s, контуры %s)"
                    % (r, tuple(round(v, 2) for v in b),
                       [tuple(round(v, 1) for v in o) for o in outlines]))
        # 5.6 информация: центроиды по платам
        if refs:
            print("PCB: габариты по платам: " + "; ".join(
                "%s[%s]" % (r, ",".join("%.0f" % v for v in boxes[r]))
                for r in refs))

    # итог
    print()
    if warnings:
        print("Предупреждения (%d):" % len(warnings))
        for w in warnings:
            print("  !", w)
    if errors:
        print("ОШИБКИ (%d):" % len(errors))
        for e in errors:
            print("  -", e)
        sys.exit(1)
    print("ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ")


if __name__ == "__main__":
    main()
