#!/usr/bin/env python3
"""Installer safety guards contract (flake check `installer-safety`).

Usage: check-install-safety.py <nomarchy-install.sh> [<patch-template.py> <template>]

Locks the BACKLOG #54 safety guards (+ #61 review Source line) against
silent regression:

  1. Offline compose-lock failure fail-closes (no network `flake lock`).
  2. Disk signature warn for NTFS / BitLocker / Microsoft / LUKS.
  3. HM pre-activate failure drops NOMARCHY-DESKTOP-NOT-THEMED.txt.
  4. Review panel Source line tracks the same OFFLINE probe as the install path.

Also re-checks the password ≥8 interactive floors (V0-complete), the
BACKLOG #92 catalog-only keyboard boundary, and, when given the patcher +
template, the BACKLOG #91 no-keyboard-variant path. Pure — no VM, no ISO.
"""

from __future__ import annotations

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

if len(sys.argv) not in (2, 4):
    sys.exit(
        f"usage: {sys.argv[0]} <nomarchy-install.sh> "
        "[<patch-template.py> <template>]"
    )

script = Path(sys.argv[1]).read_text(encoding="utf-8")


def check(cond: bool, msg: str) -> None:
    if not cond:
        sys.exit(f"installer-safety: {msg}")


# ── 1. Offline compose-lock fail-closed ─────────────────────────────────
# The offline branch must fail hard; `nix flake lock` only on the online path.
check(
    "Offline lock composition failed and there is no network to fall back to"
    in script,
    "missing offline compose-lock fail-closed message",
)
# Fail-closed is gated on OFFLINE=true.
check(
    re.search(
        r'if \[\[ "\$OFFLINE" == true \]\]; then\s*\n'
        r'\s*fail "Offline lock composition failed',
        script,
    ),
    "offline fail-closed is not gated on OFFLINE=true",
)
# Network flake lock must sit in the non-offline fallback, not the offline arm.
flake_lock = script.find('nix --extra-experimental-features "nix-command flakes" flake lock')
offline_fail = script.find("Offline lock composition failed and there is no network")
check(flake_lock > 0 and offline_fail > 0, "compose-lock / flake lock markers missing")
check(
    flake_lock > offline_fail,
    "network `flake lock` appears before the offline fail-closed arm",
)
# Between offline fail and flake lock there must not be an unguarded lock call
# earlier in the file for this recovery path.
check(
    "Offline lock composition failed — resolving over the network." in script,
    "missing online-only compose-lock fallback warn",
)

# ── 1b. Disk picker: exclude floppy/pseudo/tiny (#112) ─────────────────
check(
    "list_installable_disks" in script,
    "missing list_installable_disks helper (#112)",
)
check(
    "/dev/fd*" in script or r"/dev/fd*" in script,
    "disk filter must exclude /dev/fd* (floppy — OVMF lists fd0 first)",
)
check(
    "MIN_DISK_BYTES" in script,
    "disk filter must enforce a minimum size floor",
)
check(
    "8 * 1024 * 1024 * 1024" in script or "8 * 1024**3" in script
    or "$((8 * 1024 * 1024 * 1024))" in script,
    "default min disk size should be 8 GiB (catches fd0 and crumbs)",
)

# ── 2. Disk signature warn ──────────────────────────────────────────────
sig_re = re.search(
    r"grep -qiE '([^']+)'\s*<<<\s*\"\$existing_sig\"",
    script,
)
check(sig_re is not None, "disk signature grep missing")
pat = sig_re.group(1)
check(
    all(tok in pat for tok in ("ntfs", "bitlocker", "microsoft", "crypto_luks")),
    f"signature pattern incomplete: {pat!r}",
)
check(
    "Windows/BitLocker/NTFS or LUKS" in script,
    "missing user-facing disk-signature warn text",
)

# Pure behavioural: the same regex the installer uses.
rx = re.compile(pat, re.I)
samples_hit = [
    "ntfs\n",
    "TYPE=\"ntfs\"",
    "BitLocker",
    "Microsoft reserved",
    "crypto_LUKS",
    "TYPE=\"crypto_LUKS\"",
]
samples_miss = [
    "",
    "ext4\n",
    "btrfs\nvfat",
    "TYPE=\"xfs\"",
]
for s in samples_hit:
    check(bool(rx.search(s)), f"signature regex should match {s!r}")
for s in samples_miss:
    check(not rx.search(s), f"signature regex should NOT match {s!r}")

# ── 3. Pre-activate recovery hint ───────────────────────────────────────
check(
    "NOMARCHY-DESKTOP-NOT-THEMED.txt" in script,
    "missing pre-activate hint filename",
)
check(
    "home-manager switch --flake ~/.nomarchy -b bak" in script,
    "hint must tell the user the recovery command",
)
check(
    'hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"' in script
    or 'hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"'
    in script.replace(" ", ""),
    "hint must land on the target home under /mnt",
)
# Hint is only written on the failure arm (else of nixos-enter activate).
activate_if = script.find("if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh")
hint_pos = script.find("NOMARCHY-DESKTOP-NOT-THEMED.txt")
check(activate_if > 0 and hint_pos > activate_if, "hint is not on the activate-failure arm")

# ── Password floors (V0-complete, keep the floor) ───────────────────────
check(
    re.search(r"password for \$USERNAME \(min 8 chars\)", script) is not None,
    "account password min-8 prompt missing",
)
check(
    re.search(r"LUKS passphrase \(min 8 chars\)", script) is not None,
    "LUKS passphrase min-8 prompt missing",
)
check(
    script.count("[[ ${#") >= 2 and " -ge 8 ]]" in script,
    "min-8 length checks missing",
)

# ── 4. Review Source line tracks OFFLINE (BACKLOG #61) ──────────────────
check(
    "pinned into the ISO, no network needed" in script,
    "missing offline Review Source wording",
)
check(
    "may use network binary caches" in script,
    "missing online Review Source wording",
)
check(
    re.search(
        r'if \[\[ "\$OFFLINE" == true \]\]; then\s*\n'
        r'\s*SOURCE_NET="pinned into the ISO, no network needed"',
        script,
    ),
    "offline Source wording is not gated on OFFLINE=true via SOURCE_NET",
)
check(
    'SOURCE_NET="pinned into the ISO; may use network binary caches"' in script,
    "online SOURCE_NET assignment missing",
)
# Source gum line must expand $SOURCE_NET (not hardcode offline-only text).
# The line embeds nested quotes, so match from Source: through SOURCE_NET.
check(
    re.search(r'"Source:\s+nomarchy.*\$SOURCE_NET"', script, re.DOTALL)
    is not None,
    "Source gum line missing or does not expand $SOURCE_NET",
)
# The only "no network needed" occurrence must be the offline SOURCE_NET assign.
check(
    script.count("no network needed") == 1,
    "offline Source phrase should appear exactly once (SOURCE_NET assign)",
)

# ── 5. Keyboard no-variant normalization (BACKLOG #91) ────────────────
if len(sys.argv) == 4:
    # The picker sentinel must be normalized after both input paths converge,
    # not only inside the interactive gum branch.
    normalize_re = re.compile(
        r'if \[\[ "\$KB_VARIANT" == "\(none\)" \]\]; then\s+'
        r'KB_VARIANT=""\s+fi'
    )
    normalizations = list(normalize_re.finditer(script))
    check(
        len(normalizations) == 1,
        "expected one set -e-safe keyboard `(none)` normalization",
    )
    normalize_at = normalizations[0].start() if normalizations else -1
    input_branch_end = script.find(
        '[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]]', normalize_at
    )
    unattended_at = script.find('KB_VARIANT="${NOMARCHY_KB_VARIANT:-}"')
    check(
        unattended_at < normalize_at < input_branch_end,
        "keyboard variant normalization is not on the shared input boundary",
    )

    # ── 6. Catalog-only keyboard input (BACKLOG #92) ──────────────────
    # gum input is search-only: selections are parsed from rows generated by
    # the installed layout/variant catalogs, then both interactive and
    # unattended paths meet the same exact-membership validator.
    check(
        "keyboard_pick=$(keyboard_layout_choices" in script
        and 'gum filter --strict' in script
        and 'KB_LAYOUT="${keyboard_pick%%$\'\\t\'*}"' in script,
        "layout picker is not strict/catalog-constrained",
    )
    check(
        'keyboard_variant_choices "$KB_LAYOUT"' in script
        and script.count("gum filter --strict") == 2
        and 'KB_VARIANT="${keyboard_pick%%$\'\\t\'*}"' in script,
        "variant picker is not strict/catalog-constrained",
    )
    check(
        not re.search(r"KB_(?:LAYOUT|VARIANT)=\$\(gum input", script),
        "keyboard choice must not come from a free-form gum input",
    )
    validation_at = script.find(
        'keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT"'
    )
    prewipe_at = script.find('prewipe "$TARGET_DISK"')
    patch_at = script.find('python3 "$SHARE/patch-template.py"')
    check(
        normalize_at < validation_at < prewipe_at < patch_at,
        "shared keyboard validation must fail before disk changes/generated Nix",
    )
    check(
        '"Keyboard:   $KEYBOARD_SUMMARY"' in script
        and "standard keys (no special variant)" in script,
        "review does not show a plain-language resolved keyboard choice",
    )

    # Exercise the real shell validator with a tiny deterministic XKB rules
    # catalog. Exact boundaries matter: a prefix or a made-up unattended
    # value must fail here, not become an XKB/Nix build error later.
    with tempfile.TemporaryDirectory(prefix="nomarchy-keyboard-catalog-") as tmp:
        rules = Path(tmp) / "base.lst"
        rules.write_text(
            """! layout
  us          English (US)
  gb          English (UK)
! variant
  intl        us: English (US, intl., with dead keys)
  dvorak      us: English (Dvorak)
  extd        gb: English (UK, extended, with Win keys)
! option
""",
            encoding="utf-8",
        )
        env = os.environ.copy()
        env["NOMARCHY_XKB_RULES"] = str(rules)

        def keyboard_validate(layout: str, variant: str) -> subprocess.CompletedProcess[str]:
            return subprocess.run(
                ["bash", str(sys.argv[1]), "--validate-keyboard", layout, variant],
                text=True,
                capture_output=True,
                check=False,
                env=env,
            )

        for layout, variant in (("us", ""), ("us", "intl"), ("gb", "extd")):
            result = keyboard_validate(layout, variant)
            check(
                result.returncode == 0,
                f"valid keyboard choice {layout!r}/{variant!r} rejected: {result.stderr.strip()}",
            )

        invalid = (
            ("u", "", "Unknown keyboard layout 'u'"),
            ("made-up", "", "Unknown keyboard layout 'made-up'"),
            ("us", "int", "Unknown keyboard variant 'int' for layout 'us'"),
            ("gb", "intl", "Unknown keyboard variant 'intl' for layout 'gb'"),
        )
        for layout, variant, message in invalid:
            result = keyboard_validate(layout, variant)
            check(
                result.returncode == 2 and message in result.stderr,
                f"invalid keyboard choice {layout!r}/{variant!r} did not fail friendly/early: "
                f"rc={result.returncode}, stderr={result.stderr.strip()!r}",
            )
            check(
                "NOMARCHY_INSTALL_SHARE" not in result.stderr,
                "invalid keyboard choice reached normal installer setup",
            )

    values = {
        "hostname": "keyboard-test",
        "username": "nomarchy",
        "timezone": "UTC",
        "locale": "en_US.UTF-8",
        "keyboardLayout": "us",
        # This is the no-variant row as gum presents it.  Even if a caller
        # reaches the patcher before shell normalization, it must stay data,
        # never become an XKB include name.
        "keyboardVariant": "(none)",
        "hashedPassword": "$6$test$hash",
        "autoLogin": False,
        "laptop": False,
        "thermald": False,
        "hardwareProfiles": [],
        "hardware": {},
        "resumeOffset": None,
        "rootUuid": None,
    }

    patcher = Path(sys.argv[2])
    template = Path(sys.argv[3])
    with tempfile.TemporaryDirectory(prefix="nomarchy-keyboard-") as tmp:
        generated = Path(tmp) / "flake"
        shutil.copytree(template, generated)
        # Nix-store templates are read-only; the real installer copy is
        # writable, so reproduce that property in the build sandbox.
        for path in generated.iterdir():
            if path.is_file():
                path.chmod(path.stat().st_mode | 0o200)
        result = subprocess.run(
            [sys.executable, str(patcher), str(generated)],
            input=json.dumps(values),
            text=True,
            capture_output=True,
            check=False,
        )
        check(
            result.returncode == 0,
            f"keyboard fixture patch failed: {result.stderr.strip()}",
        )

        home = (generated / "home.nix").read_text(encoding="utf-8")
        system = (generated / "system.nix").read_text(encoding="utf-8")
        expected = (
            (home, 'nomarchy.keyboard.layout = "us";'),
            (home, 'nomarchy.keyboard.variant = "";'),
            (system, 'services.xserver.xkb.layout = "us";'),
            (system, 'services.xserver.xkb.variant = "";'),
        )
        for text, line in expected:
            check(line in text, f"generated configuration missing {line!r}")
        check(
            "(none)" not in home and "(none)" not in system,
            "keyboard picker sentinel leaked into generated Nix",
        )

print("installer-safety: all contracts hold")
