#!/usr/bin/env python3
"""Run the declared minimum, pinned Nix default, and Thanos Emacs fork."""

import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile


LABELS = {
    "minimum": "Package-Requires minimum",
    "default": "Pinned nixpkgs default Emacs",
    "fork": "Thanos Emacs fork",
}


def minimum_version(source):
    header = (source / "lisp/jabber.el").read_text()
    matches = re.findall(r'^;; Package-Requires:.*\(emacs "([0-9]+(?:\.[0-9]+)+)"\)',
                         header, re.MULTILINE)
    if len(matches) != 1:
        raise ValueError("Expected one exact Emacs Package-Requires minimum")
    return matches[0]


def executable(command):
    found = shutil.which(command)
    if not found:
        raise ValueError(f"Required executable unavailable: {command}")
    return str(Path(found).resolve())


def snapshot(source, destination):
    """Copy only manifest inputs; reject symlinks and path escapes."""
    files = json.loads((source / "admin/source-manifest.json").read_text())
    for name in files:
        relative = Path(name)
        if relative.is_absolute() or ".." in relative.parts:
            raise ValueError(f"Invalid source manifest path: {name}")
        origin = source / relative
        if not origin.is_file() or origin.resolve() != source.resolve() / relative:
            raise ValueError(f"Source input is missing or symlinked: {name}")
        target = destination / relative
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(origin, target)


def validate_completion(build, files):
    """Validate actual workers and summaries without invoking Emacs again."""
    if not files or len(files) != len(set(files)):
        raise ValueError("Empty or duplicate matrix test selection")
    isolated = [".test-results/" + Path(file).stem + ".stamp" for file in files]
    inventory = []
    totals = {}
    for phase, stamps in (("isolated", isolated), ("combined", [".test-results/oneshot.stamp"])):
        total = 0
        for index, stamp in enumerate(stamps):
            if (build / stamp).read_text().strip() != "0":
                raise ValueError(f"Failed worker: {stamp}")
            receipt = json.loads((build / (stamp + ".ert.json")).read_text())
            selected = files if phase == "combined" else [files[index]]
            if not isinstance(receipt, dict):
                raise ValueError(f"Invalid ERT receipt: {stamp}")
            runs = receipt.get("runs")
            if receipt.get("files") != selected or not isinstance(runs, list) or len(runs) != (2 if phase == "combined" else 1):
                raise ValueError(f"Incomplete selected ERT runs: {stamp}")
            for run in runs:
                if not isinstance(run, dict):
                    raise ValueError(f"Invalid ERT run: {stamp}")
                tests = run.get("tests")
                counts = [run.get(key) for key in ("total", "completed", "expected", "unexpected", "skipped")]
                if (not isinstance(tests, list) or not tests
                        or not all(isinstance(test, str) and test for test in tests)
                        or len(set(tests)) != len(tests)
                        or not all(type(count) is int and count >= 0 for count in counts)
                        or counts != [len(tests), len(tests), len(tests), 0, 0]):
                    raise ValueError(f"Invalid or unsuccessful ERT completion: {stamp}")
                if phase == "isolated":
                    inventory.extend(tests)
                elif sorted(tests) != sorted(inventory):
                    raise ValueError("Combined ERT inventory differs from isolated tests")
                total += len(tests)
        summary = json.loads((build / (stamps[0] + ".summary.json")).read_text())
        if (not isinstance(summary, dict)
                or not all(type(summary.get(key)) is int for key in ("total", "expected", "unexpected", "skipped"))
                or summary != {"stamps": stamps, "total": total, "expected": total, "unexpected": 0, "skipped": 0}):
            raise ValueError(f"Incomplete {phase} ERT summary")
        totals[phase] = total
    if len(inventory) != len(set(inventory)):
        raise ValueError("Duplicate isolated ERT test identities")
    # These outputs must be freshly produced in the source-only lane copy.
    suffix = ".dylib" if sys.platform == "darwin" else ".so"
    for artifact in ("lisp/jabber-autoloads.el", "lisp/jabber-omemo-core" + suffix):
        if not (build / artifact).is_file() or (build / artifact).stat().st_size == 0:
            raise ValueError(f"Missing build completion artifact: {artifact}")
    return {"files": files, "tests": sorted(inventory), "totals": totals}


def lane(source, root, name, emacs, expected):
    """Run one clean build with its own sources, dependencies and state."""
    root.mkdir(parents=True, exist_ok=False)
    build = root / "source"
    snapshot(source, build)
    env = os.environ.copy()
    # Never inherit another Make invocation's runtime, targets or bytecode paths.
    for key in ("MAKEFLAGS", "MFLAGS", "MAKEOVERRIDES", "EMACSLOADPATH",
                "EMACSNATIVELOADPATH", "EMACS_OPTS", "EMACS_CMD"):
        env.pop(key, None)
    for key, directory in (("HOME", "home"), ("TMPDIR", "tmp"),
                           ("XDG_CONFIG_HOME", "config"), ("XDG_CACHE_HOME", "cache"),
                           ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state")):
        env[key] = str(root / directory)
        Path(env[key]).mkdir()
    env["JABBER_ENV_WRAPPED"] = "1"
    env["JABBER_MATRIX_EVIDENCE"] = "1"
    dependencies = root / "dependencies"
    shutil.copytree(env["JABBER_MATRIX_DEPS"], dependencies)
    options = ["-Q", "--batch"]
    for directory in sorted(dependencies.iterdir()):
        options.extend(["-L", str(directory)])
    # EMACS_OPTS is the existing runner's shell-word interface.
    if any(any(char.isspace() for char in item) for item in options + [emacs]):
        raise ValueError("Matrix scratch and executable paths must not contain whitespace")
    version = subprocess.check_output(
        [emacs, "-Q", "--batch", "--eval", "(princ emacs-version)"],
        env=env, text=True).strip()
    print(f"=== {LABELS[name]}: GNU Emacs {version} ({emacs}) ===", flush=True)
    if expected is not None and version != expected:
        raise ValueError(f"{LABELS[name]}: expected {expected}, got {version}")
    receipt = {"lane": name, "label": LABELS[name], "version": version,
               "executable": emacs, "source": str(build),
               "environment": {key: env[key] for key in env if key in (
                   "HOME", "TMPDIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME",
                   "XDG_DATA_HOME", "XDG_STATE_HOME")}}
    (root / "runtime.json").write_text(json.dumps(receipt, indent=2) + "\n")
    command = ["make", "--no-print-directory", "check", f"EMACS_CMD={emacs}",
               "EMACS_OPTS=" + " ".join(options)]
    files = (env["MATRIX_TESTS"].split() if "MATRIX_TESTS" in env
             else sorted(str(path.relative_to(build)) for path in build.glob("tests/jabber-test-*.el")))
    if not files or any(not re.fullmatch(r"tests/jabber-test-[\w-]+\.el", file)
                        or not (build / file).is_file() for file in files):
        raise ValueError("Invalid or missing matrix test selection")
    command.append("TESTS=" + " ".join(files))
    if "MATRIX_JOBS" in env:
        command.append("JOBS=" + env["MATRIX_JOBS"])
    status = subprocess.run(command, cwd=build, env=env, check=False).returncode
    if status:
        raise RuntimeError(f"{LABELS[name]} failed (exit {status}); diagnostics: {root}")
    completion = validate_completion(build, files)
    (root / "completion.json").write_text(json.dumps(completion, indent=2) + "\n")
    (root / "passed").write_text(version + "\n")


def matrix(source):
    # This preflight MUST precede all Nix evaluation/development-shell entry.
    # A missing fork fails its lane, but must not suppress the other attempts.
    fork_error = None
    fork = None
    try:
        if os.environ.get("IN_NIX_SHELL") or os.environ.get("JABBER_ENV_WRAPPED"):
            raise ValueError("Run make test-matrix outside Nix to resolve Thanos Emacs fork")
        fork = executable(os.environ.get("THANOS_EMACS", "emacs"))
        print(f"Thanos Emacs fork resolved before Nix: {fork}", flush=True)
    except ValueError as error:
        fork_error = str(error)
    ref = "git+" + source.as_uri()
    root = Path(tempfile.mkdtemp(prefix="jabber-matrix-"))
    print(f"Matrix evidence: {root}", flush=True)
    failed = []
    archive_error = None
    try:
        # Freeze the Git-aware input once so later lanes cannot read a different
        # working-tree generation.  Archive also realizes the source closure.
        archive = json.loads(subprocess.check_output(
            [executable("nix"), "flake", "archive", "--json", "--no-write-lock-file", ref],
            text=True))
        source = Path(archive["path"])
        if not source.is_dir():
            raise ValueError("Nix did not realize the matrix source")
        ref = str(source)
        (root / "source.json").write_text(json.dumps(archive, indent=2) + "\n")
    except (OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
        archive_error = str(error)
    for name in LABELS:
        log_path = root / (name + ".log")
        status = 1
        with log_path.open("w") as log:
            try:
                if name == "fork" and fork_error:
                    raise ValueError(fork_error)
                if archive_error:
                    raise ValueError(archive_error)
                # Git-aware input avoids copying ignored/private files into the store.
                command = [executable("nix"), "develop",
                           ref + "#matrix-" + ("minimum" if name == "minimum" else "default"),
                           "--command", "python3", str(source / "admin/test-matrix"),
                           "--lane", name, "--root", str(root / name)]
                if name == "fork":
                    command.extend(["--emacs", fork])
                status = subprocess.run(command, stdout=log, stderr=subprocess.STDOUT,
                                        check=False).returncode
            except (OSError, ValueError, subprocess.SubprocessError) as error:
                log.write(str(error) + "\n")
        # A successful subprocess without the lane's completion is not success.
        passed = root / name / "passed"
        ok = status == 0 and passed.is_file() and bool(passed.read_text().strip())
        version = passed.read_text().strip() if ok else "incomplete"
        print(f"{'PASS' if ok else 'FAIL'} {LABELS[name]} ({version}); log: {log_path}", flush=True)
        if not ok:
            failed.append(name)
    if failed:
        raise RuntimeError("Required matrix lanes failed: " + ", ".join(failed))
    print("PASS all three required Emacs lanes", flush=True)


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--lane", choices=LABELS)
    parser.add_argument("--root", type=Path)
    parser.add_argument("--emacs", default="emacs")
    parser.add_argument("--expected")
    args = parser.parse_args()
    source = Path(__file__).resolve().parent.parent
    try:
        if args.lane:
            if not args.root:
                parser.error("--lane requires --root")
            expected = args.expected
            if args.lane != "fork":
                expected = expected or os.environ["JABBER_MATRIX_VERSION"]
            if args.lane == "minimum" and expected != minimum_version(source):
                raise ValueError("Pinned minimum disagrees with Package-Requires")
            lane(source, args.root.resolve(), args.lane, executable(args.emacs), expected)
        else:
            matrix(source)
    except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
        print(f"FAIL: {error}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
