# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2015-2016, 2026 InterGenJLU
"""Bootloader installation for InterGenOS installer.

Two paths:
- BIOS legacy: install GRUB to MBR/GPT bios_grub partition (no Secure Boot).
- EFI: install signed boot chain (shim + GRUB signed with MOK + signed kernel).

The signed boot chain layout on the ESP:
  /boot/efi/EFI/InterGenOS/
    shimx64.efi       — Microsoft-signed shim (from shim-signed package)
    grubx64.efi       — Our GRUB, signed with the MOK private key
    mmx64.efi         — MokManager (from shim-signed; for first-boot enrollment)
    grub.cfg          — Generated by grub-mkconfig with os-prober enabled

Shim is registered as the primary UEFI boot entry. On boot:
  shim verifies grubx64.efi against the MOK enrolled at first boot
  (or against the embedded vendor cert if shim is from Fedora's piggyback build)
  -> GRUB loads, verifies kernel signature
  -> kernel boots, MODULE_SIG_FORCE=y rejects unsigned modules
"""

import logging
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

from . import trace
from .hooks import (
    mount_efivars,
    unmount_efivars,
)
from .mok import sign_efi_binary

# Host-side path: shipped on every InterGenOS live ISO. Belt-suspenders
# below copies from HERE (host) to <target>/boot/grub/fonts/ so the
# font lands regardless of whether the target's grub package archive
# was rebuilt with the do_install do_install changes that ship
# /usr/share/grub/unicode.pf2 on the target itself.
HOST_GRUB_UNICODE_PF2 = Path("/usr/share/grub/unicode.pf2")

logger = logging.getLogger(__name__)


# Where shim-signed package installs its artifacts (provided by the package)
SHIM_SOURCE_DIR = "/usr/share/shim-signed"
SHIM_BINARY = "shimx64.efi"
MOKMANAGER_BINARY = "mmx64.efi"

# Where we write the signed boot chain on the ESP
ESP_BOOT_DIR = "/boot/efi/EFI/InterGenOS"
GRUB_BINARY = "grubx64.efi"
BOOTLOADER_ID = "InterGenOS"


def verify_shim_assets_present():
    """Pre-flight check: shim-signed binaries must exist in the live ISO.

    Called before partition_disk() on EFI installs (C-003 audit row;
    T0-2 closure dispatch 2026-05-18T20:25:59Z). Fails closed so a
    regression in the live-ISO build pipeline (e.g. shim-signed package
    not wired through chroot-build-core-extra.sh — A-001) is caught
    BEFORE the destructive partition write rather than after, when the
    target disk is already modified.

    Raises:
        RuntimeError if shimx64.efi or mmx64.efi are missing from
        SHIM_SOURCE_DIR.
    """
    missing = []
    for binary in (SHIM_BINARY, MOKMANAGER_BINARY):
        if not Path(SHIM_SOURCE_DIR, binary).is_file():
            missing.append(f"{SHIM_SOURCE_DIR}/{binary}")
    if missing:
        raise RuntimeError(
            "shim-signed binaries missing from live ISO: "
            + ", ".join(missing)
            + ". The shim-signed package is not wired into this build. "
            "Aborting BEFORE partitioning to preserve disk state. "
            "Re-run install on a build where the shim-signed package "
            "is wired through phase_image (audit row A-001)."
        )


# Host-side path where the live ISO ships the microcode-cpio helper.
# scripts/chroot-build-bootloader.sh:114 stages the helper into the live
# ISO's /usr/lib/intergen/ at chroot-build time. stage_uki_prereqs()
# copies it onto the target so the linux-kernel post_install hook (and
# every future `pkm upgrade linux-kernel`) can regenerate microcode
# cpios without depending on the live ISO being mounted.
HOST_MICROCODE_HELPER = Path("/usr/lib/intergen/build-microcode-cpio.sh")


def stage_uki_prereqs(target):
    """Stage UKI build prerequisites onto /mnt/target BEFORE PHASE_PACKAGES.

    D-005 UKI primary path. The linux-kernel package's post_install hook
    (packages/core/linux-kernel/hooks/post-install.sh — runs inside the
    chroot during PHASE_PACKAGES) bundles five sections into the UKI:

        .linux    — the kernel image (always present)
        .cmdline  — sourced from /etc/kernel/cmdline (pre-staged by
                    install.py at PHASE_VIRTUAL_FS+ via
                    config.generate_kernel_cmdline)
        .osrel    — auto-generated by ukify from /etc/os-release
        .initrd   — bundled if /boot/initramfs.img exists
        .ucode    — bundled if /boot/{intel,amd}-ucode.img exist

    Without prereq staging, .initrd and .ucode are silently absent
    because their source files don't exist on /mnt/target until much
    later (or never on plain installs). Surfaced 2026-05-27 install #28
    trace: UKI shipped without .initrd or .ucode sections, anomaly A.

    Three prereqs land here, in order:

      1. /usr/lib/intergen/build-microcode-cpio.sh   — copied from live
         ISO so the kernel hook can regenerate cpios on every
         `pkm upgrade linux-kernel`. Becomes a permanent part of the
         installed system, not a one-time install-time artifact.
      2. /boot/intel-ucode.img + /boot/amd-ucode.img — generated by
         running the helper against the live ISO's /lib/firmware (which
         the build-microcode-cpio.sh reads, NOT the target's). Output
         lands on the target's /boot/ for the hook's `[ -f ]` checks.
      3. /boot/initramfs.img — minimal empty newc cpio (gzipped, ~52
         bytes). The kernel-builtin storage drivers + PARTUUID + rootwait
         handle the actual root mount on plain installs; the initramfs
         is a stub so ukify's --initrd= contract is satisfied and the
         UKI gets a .initrd section. LUKS installs replace this stub
         with the FDE initramfs at the kernel hook level (see
         packages/core/linux-kernel/hooks/post-install.sh: Phase D).
    """
    target_path = Path(target)
    target_intergen_dir = target_path / "usr/lib/intergen"
    target_boot = target_path / "boot"
    target_boot.mkdir(parents=True, exist_ok=True)

    # 1. Stage the microcode helper onto target
    target_helper = target_intergen_dir / "build-microcode-cpio.sh"
    if HOST_MICROCODE_HELPER.is_file():
        target_intergen_dir.mkdir(parents=True, exist_ok=True)
        trace.traced_copy_file(
            HOST_MICROCODE_HELPER, target_helper,
            phase="virtual_fs",
            intent="stage build-microcode-cpio.sh onto target so kernel "
                   "post_install hook (Forge install + pkm upgrade) can "
                   "regenerate /boot/{intel,amd}-ucode.img"
        )
        target_helper.chmod(0o755)
    else:
        logger.warning(
            "host %s missing; UKIs will lack .ucode sections until the "
            "helper is staged. Live ISO build pipeline regression — "
            "scripts/chroot-build-bootloader.sh:114 should ship it.",
            HOST_MICROCODE_HELPER,
        )

    # 2. Generate the microcode cpios on the target's /boot. Run the
    # host-side helper (NOT chroot exec) because the host live ISO has
    # iucode_tool + /lib/firmware/intel-ucode + /lib/firmware/amd-ucode,
    # while the target chroot at this point only has minimal contents.
    if HOST_MICROCODE_HELPER.is_file():
        env = {**os.environ, "OUTPUT_DIR": str(target_boot)}
        result = trace.traced_run(
            ["bash", str(HOST_MICROCODE_HELPER)],
            env=env,
            phase="virtual_fs",
            intent="generate /boot/{intel,amd}-ucode.img on target for "
                   "kernel hook UKI .ucode embedding",
        )
        if result.returncode != 0:
            logger.warning(
                "build-microcode-cpio.sh rc=%d: %s — UKI .ucode sections "
                "may be missing", result.returncode, result.stderr.strip(),
            )

    # 3. Minimal initramfs.img stub. Empty newc cpio piped through gzip
    # is the smallest valid cpio archive; the kernel's early-userspace
    # finds no /init and proceeds to mount root per cmdline. ukify
    # accepts it as --initrd= input and produces a .initrd section in
    # the UKI containing the stub bytes. LUKS installs overwrite this
    # with the FDE initramfs at hook fire time.
    initramfs = target_boot / "initramfs.img"
    if not initramfs.exists():
        result = trace.traced_run(
            ["bash", "-c",
             f"printf '' | cpio -o -H newc 2>/dev/null | "
             f"gzip > {initramfs}"],
            phase="virtual_fs",
            intent="generate minimal /boot/initramfs.img stub so kernel "
                   "hook's ukify --initrd= contract is met (plain install; "
                   "LUKS installs replace this with the FDE initramfs)",
        )
        if result.returncode != 0:
            logger.warning(
                "minimal initramfs.img generation rc=%d: %s",
                result.returncode, result.stderr.strip(),
            )


# Path on the target where the kernel post_install hook writes its
# diagnostic log. ingest_kernel_hook_log() reads this after PHASE_PACKAGES
# and emits its contents as structured trace events so the Forge install
# log captures the hook's ukify output verbatim.
KERNEL_HOOK_LOG = Path("var/log/intergen-kernel-postinstall.log")


def ingest_kernel_hook_log(target):
    """Emit `kernel_hook_log_*` trace events from the kernel hook's logfile.

    The linux-kernel post_install hook runs inside the chroot during
    PHASE_PACKAGES. Its stdout/stderr is captured by pkm into the
    package_install event's `message` field, BUT only the first hook's
    one-line "OK" summary is captured — the ukify invocation's output
    (cmdline + ukify stdout + UKI section audit) is written to the
    hook's own logfile and never reaches Forge's trace.

    This helper, called from install.py after PHASE_PACKAGES completes,
    reads the logfile and emits its contents as structured trace events.
    Result: the Forge install log now shows exactly which ukify args
    fired, what ukify produced, and which UKI sections were embedded.
    Surfaced 2026-05-27 install #28 anomaly A: UKI missing sections but
    no trace surfaced why because hook output was swallowed.

    Idempotent and fail-open: missing logfile or read failure emits a
    sentinel event and returns; never raises into the install pipeline.
    """
    logfile = Path(target) / KERNEL_HOOK_LOG
    if not logfile.exists():
        trace.trace_event("kernel_hook_log_absent", path=str(logfile))
        return

    try:
        content = logfile.read_text(encoding="utf-8", errors="replace")
    except OSError as exc:
        trace.trace_event(
            "kernel_hook_log_read_failed",
            path=str(logfile), error=str(exc),
        )
        return

    lines = content.splitlines()
    trace.trace_event(
        "kernel_hook_log_begin",
        path=str(logfile),
        size_bytes=len(content),
        line_count=len(lines),
    )
    for line in lines:
        trace.trace_event("kernel_hook_log_line", text=line)
    trace.trace_event("kernel_hook_log_end", path=str(logfile))


def install_bootloader(target, disk, partitions, mok_keypair=None,
                       detect_other_oses=True):
    """Install bootloader on the target system.

    Args:
        target: target root path
        disk: disk device (e.g., /dev/sda or /dev/nvme0n1)
        partitions: dict from disks.partition_disk()
        mok_keypair: dict from mok.generate_mok_keypair() with key_path/cert_path.
                     Required for EFI installs (signed boot chain).
                     Ignored for BIOS installs.
        detect_other_oses: Option C 2026-05-24 — user's install-time
                     choice on whether GRUB should run os-prober to
                     detect other OSes (Windows alongside install, other
                     Linux distros on adjacent partitions). The same
                     value is written permanently to /etc/default/grub
                     by config.generate_grub_defaults so kernel updates
                     reproduce the install-time setting. Default True.

    Raises:
        RuntimeError if any step fails.
        ValueError if EFI install requested without mok_keypair.
    """
    target = str(target)
    efi = partitions.get("efi", False)

    if efi and mok_keypair is None:
        raise ValueError(
            "EFI install requires mok_keypair from mok.generate_mok_keypair() "
            "to sign the GRUB binary"
        )

    # C-006: orchestrator (install.py PHASE_VIRTUAL_FS) owns virtual_fs
    # lifecycle. This function runs between PHASE_VIRTUAL_FS and
    # PHASE_CLEANUP so /dev /proc /sys /run /dev/pts are already bind-
    # mounted into target; re-mounting here stacks them and leaks on
    # cleanup (unmount only removes the top layer).
    if efi:
        _install_signed_efi_chain(target, partitions, mok_keypair)
    else:
        _install_bios_grub(target, disk)

    # Generate GRUB config (both modes).
    # The one-shot env-var here matches what config.generate_grub_defaults
    # wrote to /etc/default/grub. Without the env-var, this first
    # grub-mkconfig would read the just-written file and produce the
    # same result; the env-var is belt-and-suspenders for the unusual
    # case where this call somehow precedes the config write. Either
    # way: install-time setting + every-kernel-update setting are now
    # consistent (was a real defect pre-Option-C: install-time runs
    # with =false override, post-install kernel updates run with the
    # file's hardcoded =false → Windows entry persisted as expected;
    # but if the file had said =true, Windows would have vanished on
    # the first kernel update. Threading detect_other_oses through both
    # paths eliminates that class of asymmetry).
    os_prober_value = "false" if detect_other_oses else "true"
    rc, _, stderr = trace.traced_run_chroot(target,
        f"GRUB_DISABLE_OS_PROBER={os_prober_value} grub-mkconfig -o /boot/grub/grub.cfg"
    )
    if rc != 0:
        raise RuntimeError(f"grub-mkconfig failed: {stderr}")

    # Belt-and-suspenders: ensure /boot/grub/fonts/unicode.pf2 exists.
    # The generated grub.cfg unconditionally calls `terminal_output
    # gfxterm` after an `if loadfont unicode; then insmod gfxterm; fi`
    # block. If unicode.pf2 is absent, gfxterm activates without a
    # font and renders every character as the missing-glyph placeholder
    # — on x86_64 EFI gfxterm this manifests as a whole screen of "?"-
    # in-tofu-boxes (loadfont silently failed and gfxterm couldn't
    # resolve any glyphs), making the menu completely unreadable.
    # Surfaced 2026-05-26 install attempt #21 first-boot triage.
    #
    # Refactor 2026-05-27: copy from HOST (live ISO) not TARGET (chroot).
    # The earlier chroot belt-suspenders checked target's
    # /usr/share/grub/unicode.pf2 — but the target's grub package
    # archive may not ship the font (the do_install changes in 73011b57
    # only land if the grub archive was rebuilt). Install #23 hit
    # exactly this gap: chroot test -f failed, install skipped, fonts
    # dir empty, tofu boxes. Host-side copy works regardless of grub
    # package state because the live ISO always ships unicode.pf2 at
    # /usr/share/grub/.
    if HOST_GRUB_UNICODE_PF2.exists():
        target_font = Path(target) / "boot/grub/fonts/unicode.pf2"
        trace.traced_copy_file(
            HOST_GRUB_UNICODE_PF2, target_font,
            phase="bootloader",
            intent="ship unicode.pf2 to target /boot/grub/fonts/ so "
                   "GRUB loadfont succeeds + gfxterm renders ASCII",
        )
        target_font.chmod(0o644)
    else:
        # Should be impossible on a real live ISO; raise so the failure
        # surfaces here (where the cause is clear) instead of at first
        # boot where the operator sees only a screen of "?" tofu boxes
        # and no useful diagnostic.
        raise trace.install_failure(
            where="bootloader.py:install_bootloader / unicode.pf2 belt-suspenders",
            why=(f"host {HOST_GRUB_UNICODE_PF2} missing. Live ISO build "
                 "must ship grub package + grub-mkfont-generated font. "
                 "Without this, GRUB at first boot renders the whole "
                 "menu as missing-glyph tofu boxes (loadfont silently "
                 "fails, gfxterm activates without a font)."),
            extra={"host_path": str(HOST_GRUB_UNICODE_PF2)},
        )


def _install_signed_efi_chain(target, partitions, mok_keypair):
    """EFI install with signed boot chain (shim + signed GRUB)."""
    esp_mount = f"{target}/boot/efi"
    Path(esp_mount).mkdir(parents=True, exist_ok=True)

    # Mount ESP if not already mounted
    if partitions.get("esp"):
        subprocess.run(
            f"mountpoint -q {esp_mount} || mount {partitions['esp']} {esp_mount}",
            shell=True, capture_output=True
        )

    # Install GRUB to a staging location (we'll sign and copy to ESP manually)
    # --no-nvram: we register the boot entry via efibootmgr against shim, not GRUB
    # --no-bootsector: don't write to disk MBR (we're EFI)
    # --modules: include modules needed for our boot (ext4, normal, etc.)
    rc, _, stderr = trace.traced_run_chroot(target,
        "grub-install --target=x86_64-efi "
        "--efi-directory=/boot/efi "
        f"--bootloader-id={BOOTLOADER_ID} "
        "--no-nvram "
        "--modules='all_video boot btrfs cat chain configfile echo efifwsetup "
        "efinet ext2 fat font gfxmenu gfxterm gzio halt hfsplus iso9660 jpeg "
        "loadenv loopback linux ls lsefi lsefimmap lsefisystab lssal "
        "memdisk minicmd normal part_apple part_msdos part_gpt password_pbkdf2 "
        "png reboot search search_fs_uuid search_fs_file search_label sleep "
        "smbios squash4 test true video xfs zfs zfscrypt zfsinfo'"
    )
    if rc != 0:
        raise RuntimeError(f"grub-install staging failed: {stderr}")

    # Sign the staged GRUB binary with the MOK key
    grub_efi_chroot_path = f"{ESP_BOOT_DIR}/{GRUB_BINARY}"
    sign_efi_binary(
        target,
        binary_path=grub_efi_chroot_path,
        key_path=mok_keypair["key_path"],
        cert_path=mok_keypair["cert_path"],
    )

    # Copy shim + MokManager from shim-signed package to ESP
    rc, _, stderr = trace.traced_run_chroot(target,
        f"mkdir -p {ESP_BOOT_DIR} && "
        f"cp {SHIM_SOURCE_DIR}/{SHIM_BINARY} {ESP_BOOT_DIR}/ && "
        f"cp {SHIM_SOURCE_DIR}/{MOKMANAGER_BINARY} {ESP_BOOT_DIR}/"
    )
    if rc != 0:
        raise RuntimeError(f"shim staging to ESP failed: {stderr}")

    # Fallback auto-discovery path: some older UEFI firmware only looks at
    # /EFI/BOOT/bootx64.efi when no matching UEFI boot variable is registered.
    # Mirror the MS-signed shim and the MOK-signed GRUB there so the chain
    # is self-contained — shim stays signed by MS so SecureBoot validation
    # passes, and it will find grubx64.efi in the same directory.
    rc, _, stderr = trace.traced_run_chroot(target,
        f"mkdir -p /boot/efi/EFI/BOOT && "
        f"cp {ESP_BOOT_DIR}/{SHIM_BINARY} /boot/efi/EFI/BOOT/bootx64.efi && "
        f"cp {ESP_BOOT_DIR}/{GRUB_BINARY} /boot/efi/EFI/BOOT/{GRUB_BINARY}"
    )
    if rc != 0:
        raise RuntimeError(f"EFI/BOOT fallback staging failed: {stderr}")

    # Sign every /boot/vmlinuz-* kernel image with the MOK. This closes
    # the middle link in the v2 playbook's 3-check signature chain:
    #   shim → GRUB (signed above)
    #   GRUB → kernel (signed here)
    #   kernel → modules (MODULE_SIG_FORCE ephemeral sig at build time)
    # Without this, GRUB with check_signatures=enforce refuses to load
    # the kernel. Even with check_signatures=off, leaving the kernel
    # unsigned means we don't detect tampered kernel images.
    #
    # Safety notes:
    #   - sbverify --list pre-check makes the loop idempotent on install
    #     retry — already-signed kernels are skipped rather than re-signed
    #     (behavior of re-signing varies across sbsigntools versions:
    #     some replace the signature, some append, some refuse).
    #   - sbsign writes to $k.new then renames — explicit atomicity
    #     regardless of sbsigntools version. A crash mid-write leaves
    #     the pre-signed kernel intact, not a truncated binary.
    rc, _, stderr = trace.traced_run_chroot(target,
        f"for k in /boot/vmlinuz-*; do "
        f"  [ -f \"$k\" ] || continue; "
        f"  if sbverify --cert {mok_keypair['cert_path']} \"$k\" >/dev/null 2>&1; then "
        f"    echo \"skipping $k (already signed with MOK)\"; "
        f"    continue; "
        f"  fi; "
        f"  sbsign --key {mok_keypair['key_path']} "
        f"--cert {mok_keypair['cert_path']} "
        f"--output \"$k.new\" \"$k\" || exit 1; "
        f"  mv \"$k.new\" \"$k\" || exit 1; "
        f"done"
    )
    if rc != 0:
        raise RuntimeError(f"kernel image signing failed: {stderr}")

    # Register shim as the primary UEFI boot entry via efibootmgr.
    # The disk + partition number come from partitions['esp'].
    #
    # efibootmgr requires /sys/firmware/efi/efivars inside the chroot to
    # write firmware boot variables. The host-booted-EFI case (USB installer)
    # has efivars available; bind-mount it in. The build-VM case (no UEFI
    # firmware) falls back to the soft-warn path with user-recovery guidance.
    esp_dev = partitions.get("esp", "")
    disk_dev, part_num = _split_partition(esp_dev)

    efivars_ok = mount_efivars(target)
    try:
        # Cleanup BEFORE --create so re-installs onto the same machine
        # don't accumulate stale "InterGenOS" entries in NVRAM. 2026-05-27
        # install #28 trace showed five stale Boot000{2,3,4,5} entries
        # from prior installs alongside the newly-created Boot0006 — each
        # install added one without removing duplicates (anomaly D).
        _cleanup_stale_efi_entries(target, BOOTLOADER_ID)

        rc, _, stderr = trace.traced_run_chroot(target,
            f"efibootmgr --create --disk {disk_dev} --part {part_num} "
            f"--label '{BOOTLOADER_ID}' "
            f"--loader '\\EFI\\InterGenOS\\{SHIM_BINARY}'"
        )
    finally:
        if efivars_ok:
            unmount_efivars(target)

    if rc != 0:
        # Two legitimate failure modes here:
        #   (1) Host has no EFI firmware (build VM). efivars_ok is False above;
        #       user must rely on /EFI/BOOT/bootx64.efi auto-discovery.
        #   (2) Host IS EFI but efibootmgr still failed (read-only efivars,
        #       firmware bug, etc.). User can retry manually post-boot.
        #
        # Fallback path: /EFI/BOOT/bootx64.efi auto-discovery works on most
        # firmware. On older/custom firmware that doesn't auto-discover,
        # the logged command below lets the user register by hand.
        host_efi_note = (
            "host has no EFI firmware — expected in build VM"
            if not efivars_ok
            else "host is EFI but efibootmgr still failed"
        )
        logger.warning(
            "efibootmgr registration failed (rc=%d, %s): %s. "
            "UEFI boot order NOT updated by installer. The EFI/BOOT/bootx64.efi "
            "fallback still allows auto-discovery on next boot. "
            "To register manually post-install: sudo efibootmgr --create "
            "--disk %s --part %d --label '%s' --loader '\\EFI\\InterGenOS\\%s'",
            rc, host_efi_note, stderr.strip(),
            disk_dev, part_num, BOOTLOADER_ID, SHIM_BINARY,
        )


def _install_bios_grub(target, disk):
    """BIOS legacy install (no Secure Boot)."""
    rc, _, stderr = trace.traced_run_chroot(target,
        f"grub-install --target=i386-pc {disk}"
    )
    if rc != 0:
        raise RuntimeError(f"grub-install (BIOS) failed: {stderr}")


# `efibootmgr` plain-output format. Each entry is:
#   Boot<HHHH><star_or_space> <label><TAB-OR-2+SPACES><path>
# Active entries have `*` after the bootnum; inactive have a space.
# Real efibootmgr 18+ uses a tab between label and path; older versions
# and some test fixtures use 2-or-more spaces. The label terminator
# accepts either form for resilience. Label runs until the terminator;
# path runs to end-of-line.
_EFIBOOTMGR_LINE_RE = re.compile(
    r"^Boot([0-9A-Fa-f]{4})[*\s]\s+(.+?)(?:\t|  +|$)"
)


def _cleanup_stale_efi_entries(target, label):
    """Delete every existing UEFI boot entry matching `label` before --create.

    Re-installing InterGenOS onto the same machine repeatedly accumulates
    stale `InterGenOS` entries in NVRAM — each install's `efibootmgr
    --create` adds a fresh entry without removing prior ones that point
    at GPT partition UUIDs of older installs. 2026-05-27 install #28
    trace surfaced five stale entries Boot000{2,3,4,5} alongside the
    newly-created Boot0006.

    Strategy: list current entries, find every BootNNNN* whose label
    matches `label` exactly, and `efibootmgr -b NNNN -B` each one.
    Then the caller's --create produces a single clean entry.

    Fail-open: if efibootmgr can't list (no efivars, read-only, etc.),
    skip cleanup — the subsequent --create will surface the underlying
    error with full context.
    """
    rc, stdout, _ = trace.traced_run_chroot(target, "efibootmgr")
    if rc != 0:
        # efibootmgr unavailable — likely no efivars (build VM) or
        # firmware-read failure. Let the --create that follows surface
        # the real error.
        return

    stale_bootnums = []
    for line in stdout.splitlines():
        m = _EFIBOOTMGR_LINE_RE.match(line)
        if m and m.group(2).strip() == label:
            stale_bootnums.append(m.group(1))

    if not stale_bootnums:
        return

    trace.trace_event(
        "efibootmgr_cleanup_stale",
        label=label,
        bootnums=stale_bootnums,
        count=len(stale_bootnums),
        intent="delete stale NVRAM entries with matching label before "
               "efibootmgr --create so re-installs don't accumulate "
               "duplicates (2026-05-27 install #28 anomaly D)",
    )

    for bootnum in stale_bootnums:
        trace.traced_run_chroot(
            target, f"efibootmgr -b {bootnum} -B",
        )


def _split_partition(part_dev):
    """Split a partition device path into disk + partition number.

    Examples:
        /dev/sda1       -> ('/dev/sda', 1)
        /dev/nvme0n1p2  -> ('/dev/nvme0n1', 2)
        /dev/mmcblk0p3  -> ('/dev/mmcblk0', 3)
    """
    if "nvme" in part_dev or "mmcblk" in part_dev:
        # NVMe/MMC use 'p' separator before partition number
        idx = part_dev.rfind("p")
        return part_dev[:idx], int(part_dev[idx + 1:])
    else:
        # SATA/SCSI use bare digit suffix
        i = len(part_dev)
        while i > 0 and part_dev[i - 1].isdigit():
            i -= 1
        return part_dev[:i], int(part_dev[i:])


# Backwards-compat alias for callers still using the old name
install_grub = install_bootloader
