#!/usr/bin/env python3
"""
collect.py — Run the unit-test suites and gather coverage for the E2E report.

Produces two JSON artifacts in --out:

  * ``unit_tests.json`` — per-suite {total, passed, failed, skipped, time_s, ok}
    for the C++ GTest binary, the Go chain-client tests, and the Python
    framework tests, plus grand totals.
  * ``coverage.json``  — per-language {pct, avail, note} for Python (coverage.py),
    Go (``go test -cover``) and C++ (lcov, best-effort: only when the build was
    instrumented with .gcno files; otherwise reported unavailable).

Each suite is isolated: a missing toolchain or a failing suite is recorded, never
fatal, so the report always renders. Requires the MARTe env (LD_LIBRARY_PATH) to
already be exported for the GTest binary — the orchestrator does this.
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET


def _run(cmd, cwd=None, env=None, timeout=600):
    try:
        p = subprocess.run(cmd, cwd=cwd, env=env, timeout=timeout,
                           capture_output=True, text=True)
        return p.returncode, p.stdout, p.stderr
    except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
        return 127, "", str(e)


# ── C++ GTest ─────────────────────────────────────────────────────────────────

def gtest_suite(gtest_bin, work):
    s = {"name": "C++ GTest", "lang": "cpp", "total": 0, "passed": 0,
         "failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": False}
    if not gtest_bin or not os.path.exists(gtest_bin):
        s["detail"] = "GTest binary not found"
        return s
    xml_p = os.path.join(work, "gtest.xml")
    rc, out, err = _run([gtest_bin, f"--gtest_output=xml:{xml_p}"], timeout=900)
    s["avail"] = True
    if os.path.exists(xml_p):
        try:
            root = ET.parse(xml_p).getroot()
            s["total"] = int(root.get("tests", 0))
            s["failed"] = int(root.get("failures", 0)) + int(root.get("errors", 0))
            s["skipped"] = int(root.get("disabled", 0)) + int(root.get("skipped", 0))
            s["time_s"] = float(root.get("time", 0.0))
            s["passed"] = s["total"] - s["failed"] - s["skipped"]
            s["ok"] = s["failed"] == 0 and s["total"] > 0
        except (ET.ParseError, ValueError) as e:
            s["detail"] = f"xml parse: {e}"
    else:
        s["detail"] = (err or out or "no xml produced")[-200:]
    return s


# ── C++ Integration (DebugService runtime, non-GTest) ─────────────────────────

def integration_suite(int_bin, work, timeout=220):
    """Run the printf-narrated IntegrationTests.ex binary and heuristically
    derive per-test pass/fail from its stdout.

    This binary predates GTest adoption and always ``return 0`` from main()
    (only an internal 180s SIGALRM timeout or an OS-level crash produce a
    non-zero exit), so exit code alone is not a reliable signal. Each of its
    7 "--- Test N: ..." blocks prints "SUCCESS:"/"VALIDATION SUCCESSFUL:" on
    success or "ERROR:"/"FAILURE:" on failure, so split stdout by those
    headers and flag a block failed if it contains an ERROR/FAILURE marker.
    Exercises DebugServiceBase.cpp/DebugService.cpp runtime logic that the
    header-only DebugServiceGTest suite deliberately does not touch, so it is
    the only source of real coverage for those files.
    """
    s = {"name": "C++ Integration", "lang": "cpp", "total": 0, "passed": 0,
         "failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": False}
    if not int_bin or not os.path.exists(int_bin):
        s["detail"] = "IntegrationTests binary not found"
        return s
    s["avail"] = True
    t0 = time.time()
    rc, out, err = _run([int_bin], timeout=timeout)
    s["time_s"] = round(time.time() - t0, 1)
    text = out + "\n" + err
    blocks = re.split(r"\n(?=--- Test \d+:)", text)
    test_blocks = [b for b in blocks if b.lstrip().startswith("--- Test")]
    finished = "All Integration Tests Finished." in text
    s["total"] = len(test_blocks)
    s["failed"] = sum(1 for b in test_blocks if re.search(r"\b(ERROR|FAILURE):", b))
    if not finished and s["total"] == 0:
        # Crashed/timed out before printing anything useful.
        s["total"] = 1
        s["failed"] = 1
        s["detail"] = f"binary did not complete (rc={rc}): {(err or out)[-200:]}"
    elif not finished:
        s["detail"] = f"binary exited rc={rc} before finishing all tests"
    s["passed"] = s["total"] - s["failed"]
    s["ok"] = finished and s["failed"] == 0 and s["total"] > 0
    return s


# ── Go ────────────────────────────────────────────────────────────────────────

def go_all_suites(repo, work):
    """Run Go test suites across all project modules and aggregate results."""
    modules = [
        (os.path.join(repo, "Test/E2E/suite/client"),
         "Go (chain-client)"),
        (os.path.join(repo, "Common/Client/go"),
         "Go (common udpsprotocol + wshub)"),
        (os.path.join(repo, "Client/debugger"),
         "Go (debugger)"),
    ]
    total_pct = 0.0
    pct_count = 0
    suites = []
    for mod_dir, name in modules:
        s = {"name": name, "lang": "go", "total": 0, "passed": 0,
             "failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": False}
        cov_p = os.path.join(work, f"go_cover_{name.replace(' ', '_')}.out")
        rc, out, err = _run(
            ["go", "test", "-json", f"-coverprofile={cov_p}", "./..."],
            cwd=mod_dir)
        if rc == 127:
            s["detail"] = "go toolchain not found"
            suites.append(s)
            continue
        s["avail"] = True
        cov_pct = _parse_go_json(out, s)
        if cov_pct is not None:
            s["cov_pct"] = cov_pct
            total_pct += cov_pct
            pct_count += 1
        suites.append(s)
    return suites, (round(total_pct / pct_count, 1) if pct_count else None)


def _parse_go_json(out, s):
    """Parse Go test -json output into passed/failed/skipped counts.
    Returns coverage percentage (float or None)."""
    cov_pct = None
    for line in out.splitlines():
        try:
            ev = json.loads(line)
        except json.JSONDecodeError:
            continue
        act = ev.get("Action")
        if act == "pass" and ev.get("Test"):
            s["passed"] += 1
            s["total"] += 1
        elif act == "fail" and ev.get("Test"):
            s["failed"] += 1
            s["total"] += 1
        elif act == "skip" and ev.get("Test"):
            s["skipped"] += 1
            s["total"] += 1
        elif act == "output":
            m = re.search(r"coverage:\s+([\d.]+)%", ev.get("Output", ""))
            if m:
                cov_pct = float(m.group(1))
    s["ok"] = s["failed"] == 0 and s["total"] > 0
    return cov_pct


# ── Python ──────────────────────────────────────────────────────────────────

def py_suite(chain_dir, work):
    s = {"name": "Python (framework)", "lang": "python", "total": 0, "passed": 0,
         "failed": 0, "skipped": 0, "time_s": 0.0, "ok": False, "avail": True}
    cov_json = os.path.join(work, "py_cover.json")
    env = dict(os.environ)
    env["COVERAGE_FILE"] = os.path.join(work, ".coverage")
    have_cov = _run(["coverage", "--version"])[0] == 0
    if have_cov:
        cmd = ["coverage", "run", f"--source={chain_dir}", "-m", "unittest",
               "tests_py", "-v"]
    else:
        cmd = [sys.executable, "-m", "unittest", "tests_py", "-v"]
    rc, out, err = _run(cmd, cwd=chain_dir, env=env)
    text = out + "\n" + err
    m = re.search(r"Ran (\d+) tests? in ([\d.]+)s", text)
    if m:
        s["total"] = int(m.group(1))
        s["time_s"] = float(m.group(2))
    fm = re.search(r"failures=(\d+)", text)
    em = re.search(r"errors=(\d+)", text)
    sm = re.search(r"skipped=(\d+)", text)
    s["failed"] = (int(fm.group(1)) if fm else 0) + (int(em.group(1)) if em else 0)
    s["skipped"] = int(sm.group(1)) if sm else 0
    s["passed"] = s["total"] - s["failed"] - s["skipped"]
    s["ok"] = s["failed"] == 0 and s["total"] > 0
    pct = None
    if have_cov:
        _run(["coverage", "json", "-o", cov_json], cwd=chain_dir, env=env)
        if os.path.exists(cov_json):
            try:
                cj = json.load(open(cov_json))
                pct = round(cj["totals"]["percent_covered"], 1)
            except (KeyError, ValueError):
                pass
    s["cov_pct"] = pct
    return s


# ── C++ coverage (best-effort) ────────────────────────────────────────────────

def _parse_lcov_info(path, repo):
    """Parse an LCOV tracefile into per-file line/function coverage.

    Returns (files, totals) where ``files`` is a list of
    {path (repo-relative), lines_found, lines_hit, pct, funcs_found,
    funcs_hit} sorted worst-covered first, and ``totals`` aggregates the
    same line counts across all files. Only the line counters (DA/LF/LH)
    and function counters (FNF/FNH) are read; branch data is ignored.
    """
    files = []
    cur = None
    repo_abs = os.path.abspath(repo) + os.sep
    try:
        fh = open(path)
    except OSError:
        return [], {"lines_found": 0, "lines_hit": 0, "pct": None}
    with fh:
        for line in fh:
            line = line.rstrip("\n")
            if line.startswith("SF:"):
                src = line[3:]
                rel = src[len(repo_abs):] if src.startswith(repo_abs) else src
                cur = {"path": rel, "lines_found": 0, "lines_hit": 0,
                       "funcs_found": 0, "funcs_hit": 0}
            elif cur is None:
                continue
            elif line.startswith("LF:"):
                cur["lines_found"] = int(line[3:] or 0)
            elif line.startswith("LH:"):
                cur["lines_hit"] = int(line[3:] or 0)
            elif line.startswith("FNF:"):
                cur["funcs_found"] = int(line[4:] or 0)
            elif line.startswith("FNH:"):
                cur["funcs_hit"] = int(line[4:] or 0)
            elif line == "end_of_record":
                lf = cur["lines_found"]
                cur["pct"] = round(100.0 * cur["lines_hit"] / lf, 1) if lf else None
                files.append(cur)
                cur = None
    tot_f = sum(f["lines_found"] for f in files)
    tot_h = sum(f["lines_hit"] for f in files)
    totals = {"lines_found": tot_f, "lines_hit": tot_h,
              "pct": round(100.0 * tot_h / tot_f, 1) if tot_f else None}
    files.sort(key=lambda f: (f["pct"] if f["pct"] is not None else 101.0,
                              f["path"]))
    return files, totals


def cpp_coverage(repo, target):
    cov = {"name": "C++", "avail": False, "pct": None, "files": [],
           "note": "not instrumented (rebuild with --cpp-coverage)"}
    build = os.path.join(repo, "Build", target)
    gcno = []
    for root, _, files in os.walk(build):
        for fn in files:
            if fn.endswith(".gcno"):
                gcno.append(os.path.join(root, fn))
    if not gcno:
        return cov
    # lcov 2.x is strict by default; tolerate the benign mismatches that arise
    # from mixing instrumented project objects with non-instrumented MARTe2/STL
    # headers, and never let a single bad file abort the whole capture.
    ign = ["--ignore-errors",
           "mismatch,source,gcov,unused,empty,negative,unsupported,inconsistent"]
    raw = os.path.join(build, "coverage_raw.info")
    rc, out, err = _run(["lcov", "--capture", "--directory", build,
                         "--output-file", raw, "--quiet"] + ign, timeout=900)
    if rc != 0 or not os.path.exists(raw):
        cov["note"] = "lcov capture failed: " + (err or out or "")[-160:]
        return cov
    # Keep only this repo's own sources so the number reflects project code,
    # not the MARTe2 framework headers dragged in by templates/inlines.
    info = os.path.join(build, "coverage.info")
    rc2, _, e2 = _run(["lcov", "--extract", raw,
                       os.path.join(repo, "Source", "*"),
                       os.path.join(repo, "Test", "*"),
                       "--output-file", info, "--quiet"] + ign, timeout=300)
    summ_file = info if (rc2 == 0 and os.path.exists(info)) else raw
    # Parse the tracefile directly for per-file detail; this also yields the
    # aggregate so the headline number and the per-file table are consistent.
    fdetail, totals = _parse_lcov_info(summ_file, repo)
    if totals["pct"] is not None:
        cov.update(avail=True, pct=totals["pct"], files=fdetail,
                   lines_found=totals["lines_found"],
                   lines_hit=totals["lines_hit"],
                   note="lcov (project sources)" if summ_file == info else "lcov")
        return cov
    # Fall back to lcov --summary if the tracefile had no parseable line data.
    rc3, summ, _ = _run(["lcov", "--summary", summ_file] + ign)
    m = re.search(r"lines[.\s]+:\s+([\d.]+)%", summ)
    if m:
        cov.update(avail=True, pct=float(m.group(1)),
                   note="lcov (project sources)" if summ_file == info else "lcov")
    else:
        cov["note"] = "lcov summary unparsed"
    return cov


def main():
    p = argparse.ArgumentParser(description="Collect unit tests + coverage")
    p.add_argument("--repo", required=True)
    p.add_argument("--target", default="x86-linux")
    p.add_argument("--out", required=True)
    p.add_argument("--work", default=None,
                   help="scratch dir for *.xml/cover files (default: --out)")
    p.add_argument("--cpp-coverage", action="store_true")
    args = p.parse_args()
    os.makedirs(args.out, exist_ok=True)
    work = args.work or args.out
    os.makedirs(work, exist_ok=True)
    chain_dir = os.path.dirname(os.path.abspath(__file__))
    gtest_bin = os.path.join(args.repo, "Build", args.target, "GTest", "MainGTest.ex")
    # BUILD_DIR for Test/Integration doubles the last path component
    # ($(PACKAGE)/$(lastword of CURDIR)) — see MakeStdLibDefs.gcc.
    integration_bin = os.path.join(args.repo, "Build", args.target,
                                    "Test", "Integration", "Integration",
                                    "IntegrationTests.ex")

    go_suites, go_avg_cov = go_all_suites(args.repo, work)
    suites = ([gtest_suite(gtest_bin, work), integration_suite(integration_bin, work)]
              + go_suites + [py_suite(chain_dir, work)])
    totals = {k: sum(s.get(k, 0) for s in suites)
              for k in ("total", "passed", "failed", "skipped")}
    ut = {"suites": suites, "totals": totals,
          "ok": all(s["ok"] for s in suites if s["avail"])}
    with open(os.path.join(args.out, "unit_tests.json"), "w") as f:
        json.dump(ut, f, indent=2)

    langs = []
    py = next(s for s in suites if s["lang"] == "python")
    langs.append({"name": "Python", "avail": py.get("cov_pct") is not None,
                  "pct": py.get("cov_pct"), "note": "coverage.py"})
    langs.append({"name": "Go", "avail": go_avg_cov is not None,
                  "pct": go_avg_cov, "note": "go test -cover (avg across modules)"})
    cpp = cpp_coverage(args.repo, args.target) if args.cpp_coverage else \
        {"name": "C++", "avail": False, "pct": None, "note": "skipped (use --cpp-coverage)"}
    langs.append(cpp)
    with open(os.path.join(args.out, "coverage.json"), "w") as f:
        json.dump({"languages": langs}, f, indent=2)

    print(f"unit_tests: {totals['passed']}/{totals['total']} pass "
          f"({totals['failed']} fail, {totals['skipped']} skip)")
    print("coverage: " + ", ".join(
        f"{l['name']}={l['pct']}%" if l["pct"] is not None else f"{l['name']}=n/a"
        for l in langs))


if __name__ == "__main__":
    main()
