"""
validate_sysml.py - parse and validate generated SysML v2 text with the OMG pilot
implementation (the Jupyter kernel's engine, run headlessly, no Jupyter, no conda).

    python validate_sysml.py sysml/*.sysml [--kernel <dir or jar>] [--java <java.exe>]

The kernel jar is the fat jar shipped inside the conda package
`conda-forge/jupyter-sysml-kernel` (it bundles the pilot's Xtext parser, the
semantic validator and the standard `sysml.library`). Point --kernel at the
extracted package dir (this script finds the jar) or at the jar itself; default
is tools/sysml-kernel next to this file.

Each file is fed to org.omg.sysml.interactive.SysMLInteractive, the same class
the Jupyter kernel calls for a cell: it parses, links against the standard
library and runs the validation rules. The result is what a SysML v2 tool would
say, not what this generator hopes. Exit 1 if any file has errors; a file that
produces no result at all is reported as UNKNOWN, never as a pass.
"""

import argparse
import glob
import os
import re
import subprocess
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_KERNEL = os.path.join(HERE, "tools", "sysml-kernel")
DEFAULT_JAVA = r"D:\Java\jdk-21.0.12+8\bin\java.exe"   # the 0.61.0 kernel is class version 65: Java 21, not 17
ENTRY = "org.omg.sysml.interactive.SysMLInteractive"


def find_jar(kernel):
    if os.path.isfile(kernel) and kernel.endswith(".jar"):
        return kernel
    hits = [p for p in glob.glob(os.path.join(kernel, "**", "*.jar"), recursive=True)
            if "sysml" in os.path.basename(p).lower() and "kernel" in os.path.basename(p).lower()]
    if not hits:
        hits = [p for p in glob.glob(os.path.join(kernel, "**", "*.jar"), recursive=True)]
    if not hits:
        sys.exit("no kernel jar under {}. Install the conda-forge jupyter-sysml-kernel package there "
                 "(extracted), or pass --kernel <jar>.".format(kernel))
    return max(hits, key=os.path.getsize)


def run_one(java, jar, path, timeout=180):
    text = open(path, encoding="utf-8").read()
    # SysMLInteractive.main reads standard input; an input block ends at a blank
    # line, and the process ends at end of input. The file plus a trailing blank
    # line is one evaluation.
    # %exit ends the REPL; without it the interactive loops on an empty scanner
    # for ever (EXERCISED 2026-09-06). And it evaluates EACH INPUT LINE as its own
    # block (a multi-line file failed at line 1 with "expecting '}'"), so the file
    # is fed as ONE line: line comments become block comments first, and `starts`
    # maps a reported column back to a source line.
    pieces, starts, col = [], [], 0
    for n, l in enumerate(text.splitlines(), 1):
        s = re.sub(r"//(.*)$", lambda m: "/*" + m.group(1).replace("*/", "* /") + "*/", l).strip()
        if not s:
            continue
        starts.append((col, n))
        pieces.append(s)
        col += len(s) + 1
    one_block = " ".join(pieces)
    run_one.starts = starts
    # The standard library is passed as the program argument: ISYSML_LIBRARY_PATH
    # (env or -D) is read by the Jupyter kernel class only, and without the library
    # even `part def A;` fails on its implicit Parts::Part specialization
    # (EXERCISED 2026-09-06: a library-less run reports errors on valid text).
    lib = os.path.join(os.path.dirname(jar), "sysml.library")
    if not os.path.isdir(lib):
        sys.exit("standard library not found next to the jar: {}".format(lib))
    proc = subprocess.run([java, "-cp", jar, ENTRY, lib], input=one_block + "\n\n%exit\n", capture_output=True,
                          text=True, timeout=timeout, encoding="utf-8", errors="replace",
                          cwd=os.path.dirname(jar))
    out = (proc.stdout or "") + (proc.stderr or "")
    return proc.returncode, out


def judge(out):
    """('PASS'|'FAIL'|'UNKNOWN', summary line, [issues])"""
    out = "\n".join(l for l in out.splitlines() if "log4j" not in l)
    issues = [l.strip() for l in out.splitlines() if re.search(r"\bERROR\b|\bWARNING\b|Exception|error:|Couldn't resolve", l)]
    errors = [l for l in issues if re.search(r"\bERROR\b|Exception|error:|Couldn't resolve", l)]
    if errors:
        return "FAIL", "{} error line(s), {} issue line(s)".format(len(errors), len(issues)), issues
    # On success the interactive prints the accepted element with its UUID, e.g.
    # "1> Package WildfireFullTwin (acc775f6-...)". Only that line is a PASS:
    # library-loading chatter ("Reading ...") is ~94 lines for every file and
    # must never count as a verdict.
    # The `N>` prompt prefixes only the FIRST accepted element of an evaluation.
    # A file whose first construct is a block comment yields "1> Comment (uuid)"
    # and puts the real root on the NEXT, unprefixed line, so requiring the prefix
    # reported a clean pass as UNKNOWN (EXERCISED 2026-09-06 on qx250-model.sysml:
    # zero errors, zero warnings, verdict UNKNOWN). The prefix is now optional, and
    # a bare "Comment (uuid)" is skipped so the named root is what gets reported.
    # The line shape (`Kind Name (uuid)`) is still specific enough that none of the
    # ~94 "Reading <path>..." library lines can match it.
    accepted = re.findall(r"^(?:\d+>\s*)?(\w+) (\S+) \(([0-9a-f-]{36})\)\s*$", out, flags=re.M)
    accepted = [m for m in accepted if m[0] != "Comment"] or accepted
    if accepted:
        kind, name, uid = accepted[0]
        return "PASS", "accepted {} {} ({})".format(kind, name, uid), issues
    return "UNKNOWN", "no accepted element printed ({} output line(s))".format(len(out.splitlines())), issues


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("files", nargs="+")
    ap.add_argument("--kernel", default=DEFAULT_KERNEL)
    ap.add_argument("--java", default=DEFAULT_JAVA)
    ap.add_argument("--show", action="store_true", help="print the kernel's full output per file")
    a = ap.parse_args()
    jar = find_jar(a.kernel)
    print("kernel jar:", jar, "({:.1f} MB)".format(os.path.getsize(jar) / 1e6))
    files = [f for pat in a.files for f in (glob.glob(pat) or [pat])]
    worst = 0
    for f in files:
        try:
            rc, out = run_one(a.java, jar, f)
        except subprocess.TimeoutExpired:
            print("TIMEOUT  {}".format(f)); worst = 1; continue
        verdict, summary, issues = judge(out)
        print("{:<8} {}  ({}; java exit {})".format(verdict, f, summary, rc))
        starts = getattr(run_one, "starts", [])

        def src_line(issue):
            m = re.search(r"column : (\d+)", issue)
            if not m or not starts:
                return ""
            c = int(m.group(1)) - 1
            n = max((ln for st, ln in starts if st <= c), default=starts[0][1])
            return " [source line {}]".format(n)
        for l in issues[:12]:
            print("    " + l[:200] + src_line(l))
        if a.show:
            print(out)
        if verdict != "PASS":
            worst = 1
    sys.exit(worst)


if __name__ == "__main__":
    main()
