#!/usr/bin/env python3
"""Answer, without building, the questions a build would otherwise answer slowly.

Called by build/preflight.sh; see that file's header for why this exists.

Three checks, in the order they were learned the expensive way on 2026-09-04:

  var-payload   Every package this mint installs, asked for its /var content
                with `repoquery -l`, diffed against the tmpfiles.d declarations.
                `bootc container lint` fails a build on undeclared /var content,
                and it is step 101 of 103, so this class costs a whole image.

  requires      The capabilities the host's ROLE needs, resolved with
                `repoquery --whatprovides` and checked against what the
                Containerfile installs. The recipes say which dials to set; they
                have never said what has to come out the other side.

  guards        Known workarounds, asserted to still be present. The only check
                here that is about regression rather than absence: NO_STRIP
                lived in _private/scripts/build-dist.sh, the move to Bento
                recipes dropped it, and a build rediscovered it months later.

The parser is deliberately dial-aware rather than approximate. A check that
reports a package this host does not install is a false failure, and a false
failure is how an instrument stops being read -- the same rule build/check-host.sh
states about SKIP.
"""

import os
import re
import subprocess
import sys

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# /var paths that are the build host's leavings rather than anything a running
# machine needs. The Containerfile deletes these before the lint, so they are
# never in the built image and must not be reported as undeclared.
# Packages the real build installs from a repo the probe does not have. The
# Containerfile adds Tailscale's own repo and Terra/COPRs before it installs; a
# bare base resolves some of those names to a DIFFERENT build of the same
# software, with a different payload. Measured 2026-09-04: the probe's tailscale
# ships /var/lib/tailscale and the real image has no such directory, so leaving
# it in produced a var-payload finding against a mint that passes bootc lint.
#
# Excluded and reported rather than installed, so the probe is narrower than the
# real build and never wrong about it. A false finding is worse than a missing
# one here: the whole argument for this script is that it can be trusted without
# a build to check it.
THIRD_PARTY = (
    "tailscale",
    "terra-release",
    "swayosd", "satty", "cliphist", "starship", "swww", "yazi",
    "bibata-cursor-theme", "bottom",
)

VAR_IGNORE = (
    "/var/cache/dnf", "/var/lib/dnf", "/var/log", "/var/tmp", "/var/run",
    "/var/lib/rpm", "/var/lib/authselect", "/var/cache/ldconfig",
)


def run(cmd, **kw):
    return subprocess.run(cmd, capture_output=True, text=True, **kw)


def read_recipe(host):
    """The dials, read the way build/host-recipe.sh reads them."""
    path = os.path.join(REPO, "build", "hosts", host + ".env")
    if not os.path.exists(path):
        sys.exit("preflight: no recipe at %s" % path)
    dials = {}
    for line in open(path):
        line = line.split("#", 1)[0].strip()
        if not line or "=" not in line:
            continue
        k, v = line.split("=", 1)
        dials[k.strip()] = v.strip()
    # The Containerfile's own defaults, for a dial the recipe does not set.
    dials.setdefault("PROFILE", "client")
    dials.setdefault("BROWSER", "firefox")
    dials.setdefault("LANGS", "")
    dials.setdefault("DB", "none")
    dials.setdefault("GUI", "none")
    dials.setdefault("TRIM", "unused")
    return dials


def eval_condition(frag, dials):
    """One `if` fragment against the recipe's dials: True, False, or None.

    Only the shape the Containerfile uses is read -- `[ "$VAR" = value ]` and its
    `!=`, joined by `&&`. Anything else returns None and is treated as taken,
    so a new guard shape costs a wide probe rather than a silent one.
    """
    if "||" in frag:
        return None
    tests = re.findall(r'\[\s*"\$(\w+)"\s*(=|!=)\s*([A-Za-z0-9._+-]+)\s*\]', frag)
    if not tests:
        return None
    # Every `[ ... ]` in the fragment has to be one this understands, or the
    # conjunction is being evaluated against half its terms.
    if len(tests) != frag.count("[ "):
        return None
    for var, op, want in tests:
        have = dials.get(var, "")
        if (have == want) != (op == "="):
            return False
    return True


def branch_taken(if_stack):
    """Whether the fragment's enclosing if/else frames all lead here."""
    for cond, in_else in if_stack:
        if cond is None:
            continue
        if cond == in_else:   # False and not in else, or True and in else
            return False
    return True


def install_sites(dials):
    """Package names this mint installs, with the dial arm that guards each.

    The Containerfile is joined into logical lines first. A `RUN` is one command
    however many backslashes it spans, so a `case` statement and every arm inside
    it arrive together; a parser that read physical lines would see the package
    list of the base block (one name per line) as no packages at all.

    Comment-only lines are dropped before joining, which is what podman's own
    parser does with them inside a continuation -- that is why the file can carry
    a comment between two package names at all.

    Attribution is by the innermost enclosing `case "$VAR" in` and the arm label
    in force, so a package inside `postgres16)` is dropped when the recipe says
    DB=none. Approximating this instead would report packages the mint does not
    install, and a false failure is how an instrument stops being read.

    `if [ "$VAR" = value ]` is read the same way, and it is not a nicety: the
    profile split is written as an if/else rather than a case, so a parser that
    saw only `case` attributed every client-only package to the base. Measured
    2026-09-04 on astra, the first server recipe anyone ran this against: its
    package set came back one package short of fw13's (firefox), carrying sway,
    greetd, cups and fontconfig, and the probe then failed bootc's /var lint on
    the client tmpfiles file a server mint correctly drops. A finding against a
    mint that would pass, which is the failure mode this file exists to avoid.
    """
    logical, buf = [], ""
    for raw in open(os.path.join(REPO, "Containerfile")):
        line = raw.rstrip("\n")
        if line.strip().startswith("#"):
            continue
        if line.rstrip().endswith("\\"):
            buf += line.rstrip()[:-1] + " "
            continue
        logical.append(buf + line)
        buf = ""
    if buf:
        logical.append(buf)

    pkgs = {}
    for line in logical:
        case_stack, arm = [], None
        # if/else frames, innermost last. Each is [condition, in_else], where a
        # condition of None means a shape this does not read -- those count as
        # taken, because dropping packages on an unrecognised guard would hide
        # real findings, which is the one failure worse than reporting extra.
        if_stack = []
        for frag in re.split(r'[;]', line):
            f = frag.strip()
            if not f:
                continue
            # A fragment carries whatever keywords preceded it with no
            # semicolon between them: `RUN if [ ... ]` is one, and so is
            # `then dnf install ...`. Anchoring on the bare keyword found
            # nothing, which is how PROFILE=server kept every client package.
            kw = re.sub(r'^((RUN|then|do|else)\s+)+', '', f)
            if re.match(r'^(el)?if\s', kw):
                cond = eval_condition(kw, dials)
                if kw.startswith("elif"):
                    if if_stack:
                        if_stack[-1] = [cond, False]
                else:
                    if_stack.append([cond, False])
                continue
            if re.match(r'^else\b', f) and if_stack:
                if_stack[-1][1] = True
                # `else` and the command it guards can share a fragment, so
                # this falls through rather than continuing.
            if re.match(r'^fi\b', f):
                if if_stack:
                    if_stack.pop()
                continue
            m = re.search(r'case\s+"\$(\w+)"\s+in', f)
            if m:
                case_stack.append(m.group(1))
                arm = None
                # The opening `case`, its first arm label and that arm's `dnf
                # install` all land in one fragment, because nothing separates
                # them with a semicolon. Anchoring the arm pattern at the start
                # of the fragment finds it for every arm except the first, which
                # is how GUI=tauri and DB=postgres16 both read as unguarded and
                # then got dropped.
                rest = f[m.end():].strip()
                m2 = re.match(r'^([\w|]+)\)', rest)
                if m2:
                    arm = m2.group(1)
            else:
                m2 = re.match(r'^([\w|]+)\)', f)
                if m2 and case_stack:
                    arm = m2.group(1)
            idx = f.find("dnf install")
            # `dnf install` also appears inside error messages the Containerfile
            # prints, e.g. "...a dnf install that moved above the layer setting
            # it." Reading that as a command turns its prose into package names,
            # which is how `above`, `moved`, `layer` and `setting` ended up in a
            # probe's install list. An odd number of quotes before the match
            # means the match is inside a string.
            if idx >= 0 and f[:idx].count('"') % 2 == 0 and branch_taken(if_stack):
                tail = f[idx + len("dnf install"):]
                tail = re.split(r'&&|\|\|', tail)[0]
                guard = "base" if not case_stack else "%s=%s" % (case_stack[-1], arm)
                for tok in tail.split():
                    if tok.startswith("-") or tok.startswith("$"):
                        continue
                    if not re.match(r'^[A-Za-z0-9][A-Za-z0-9._+-]*$', tok):
                        continue
                    pkgs.setdefault(tok, guard)
            if re.search(r'\besac\b', f):
                if case_stack:
                    case_stack.pop()
                arm = None

    selected = {}
    for pkg, guard in pkgs.items():
        if guard == "base":
            selected[pkg] = guard
            continue
        var, want = guard.split("=", 1)
        # The language block loops `for lang in $(echo "$LANGS" | tr ',' ' ')`
        # and switches on `$lang`, so the guard names the loop variable rather
        # than the dial. Without this every language package reads as unselected.
        if var == "lang":
            var = "LANGS"
        have = dials.get(var, "")
        values = [v.strip() for v in have.split(",")] if var == "LANGS" else [have]
        if any(v in (want or "").split("|") for v in values):
            selected[pkg] = guard
    return selected


def check_dials(dials):
    """The Containerfile's own validator, run without building to reach it.

    That validator is inside the image build, so a recipe that cannot pass it
    fails around step 20 of 103 -- cheap as builds go, and still minutes to learn
    something answerable in milliseconds. Found by this check on 2026-09-04:
    build/hosts/astra.env set PROFILE=server and never set BROWSER, so the ARG
    default of firefox applied and `server:firefox` is exactly what the validator
    refuses. No astra mint had ever been attempted, so nothing had exercised it.

    Kept deliberately narrow: it mirrors rules the Containerfile states, and a
    rule that moves there has to move here. A second copy of a check is a
    liability, so this stays a short list rather than growing into a schema.
    """
    print("== dials: the recipe against the Containerfile's own validator")
    bad = 0
    profile, browser = dials.get("PROFILE"), dials.get("BROWSER")
    if profile == "server" and browser != "none":
        bad += 1
        print("   FAIL PROFILE=server with BROWSER=%s" % browser)
        print("        the server profile ships no graphical session and the")
        print("        validator refuses it. Set BROWSER=none in the recipe.")
    for key, allowed in (("PROFILE", ("client", "server")),
                         ("DB", ("none", "postgres16")),
                         ("GUI", ("none", "tauri")),
                         ("BROWSER", ("firefox", "none")),
                         ("TRIM", ("unused", "keep"))):
        v = dials.get(key)
        if v is not None and v not in allowed:
            bad += 1
            print("   FAIL %s=%s is not one of %s" % (key, v, ", ".join(allowed)))
    for lang in [x.strip() for x in dials.get("LANGS", "").split(",") if x.strip()]:
        if lang not in ("rust", "c", "go", "python", "zig", "js"):
            bad += 1
            print("   FAIL LANGS names %r, which the builder does not offer" % lang)
    if not bad:
        print("   ok: every dial is a value the validator accepts")
    return 1 if bad else 0


def main():
    if len(sys.argv) != 2:
        sys.exit("usage: preflight.py <host>")
    host = sys.argv[1]
    dials = read_recipe(host)
    print("preflight for %s: %s\n" % (host, " ".join("%s=%s" % kv for kv in sorted(dials.items()))))
    pkgs = install_sites(dials)
    print("%d package(s) this mint installs\n" % len(pkgs))
    # Machine-readable, for build/preflight.sh's probe image. Emitted rather than
    # recomputed in shell so there is one parser for the Containerfile, not two.
    probe_set = sorted(p for p in pkgs if p not in THIRD_PARTY)
    skipped = sorted(p for p in pkgs if p in THIRD_PARTY)
    if skipped:
        print("NOT-PROBED: %s" % " ".join(skipped))
    print("PKGSET: %s" % " ".join(probe_set))
    print("TMPFILES-DROP: %s" % " ".join(
        ([] if dials.get("PROFILE") == "client" else ["50-alloy-var-client.conf"])
        + ([] if dials.get("DB", "none") != "none" else ["50-alloy-var-postgres.conf"])))

    rc = 0
    rc |= check_dials(dials)
    return rc


if __name__ == "__main__":
    raise SystemExit(main())
