# Copyright (C) 2026 Marc Haber <mh+debian-packages@zugschlus.de>

# SPDX-License-Identifier: GPL-2.0-or-later
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version. See the LICENSE file for details.
"""Generates a one-shot shell script to configure a rescue system's
network according to the current network configuration of the running
system.

Inspects the running system via ``ip -json`` (parsed with the stdlib
:mod:`json` module) and emits a standalone POSIX ``/bin/sh`` script that
recreates that configuration -- e.g. for use when booting a rescue image
that needs to bring a box back onto its usual network without DHCP being
available or desired there.

Only a single interface is configured, chosen by `resolve_target_interface`:
``--interface``, then the config file's ``interface`` key, then
whichever interface carries the default route. If IPv4 and IPv6 default
routes disagree on the interface, the one belonging to the *preferred*
family is used (``--prefer``/config ``prefer``, default ``ipv6``).

Data model (see the dataclasses below): addresses, routes, and
nameservers are represented with the stdlib :mod:`ipaddress` types.

The generated script is rendered via a Jinja2 template
(:data:`SCRIPT_TEMPLATE`). It defines shell variables (``IFACE``, ``ADDR``,
``GATEWAY``, ``NAMESERVER``) as space-separated lists for values likely to
need hand-editing, and it ends with a trailing comment block of plain-text
``ip`` output for human reference only.

Usage::

    gen-rescue-netscript                  # print the script to stdout
    gen-rescue-netscript --output rescue-net.sh
                                          # write it to a file
    gen-rescue-netscript --efi            # write it to the EFI partition,
                                          # as rescue-net/network.sh
    gen-rescue-netscript --interface eth1 # configure eth1 regardless of routes

``--efi`` locates a partition whose filesystem label or GPT partition label is
``EFI`` or ``ESP`` and writes the script there, at a path configurable via
``--output``/config ``output``.

Requires: ``ip(8)`` with ``-json`` support (iproute2 >= 4.9), PyYAML,
Jinja2. ``--efi`` additionally requires ``blkid(8)`` and
``mount(8)``/``umount(8)``.
"""

from __future__ import annotations

import argparse
import configparser
import ipaddress
import json
import os
import socket
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union


# Dataclasses below are plain data containers with no behaviour of their
# own; that's normal and doesn't warrant a "too few public methods" nag.
# pylint: disable=too-few-public-methods

PROG = "gen-rescue-netscript"

# define dual-stack types from ipaddress data types
IPAddr = Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
IPIface = Union[ipaddress.IPv4Interface, ipaddress.IPv6Interface]
IPNet = Union[ipaddress.IPv4Network, ipaddress.IPv6Network]


@dataclass
class Address:
    """A single configured IP address (address + prefixlen)."""

    interface: IPIface
    dynamic: bool = False

    @property
    def version(self) -> int:
        """4 or 6, derived from the address itself."""
        return self.interface.version


@dataclass
class Route:
    """A single route to (re)create."""

    dst: IPNet
    dev: str
    gateway: Optional[IPAddr] = None
    prefsrc: Optional[IPAddr] = None
    metric: Optional[int] = None
    statement: Optional[str] = None

    @property
    def is_default(self) -> bool:
        """Whether this is a default route (prefixlen 0), for either family."""
        return self.dst.prefixlen == 0


def log(msg: str) -> None:
    """Print an informational message to stderr."""
    print(f"{PROG}: {msg}", file=sys.stderr)


def warn(msg: str) -> None:
    """Print a warning message to stderr."""
    print(f"{PROG}: WARNING: {msg}", file=sys.stderr)


def die(msg: str) -> None:
    """Print an error message to stderr and exit with status 1."""
    print(f"{PROG}: ERROR: {msg}", file=sys.stderr)
    sys.exit(1)


#: Filesystem/partition label that marks the EFI system partition.
EFI_LABEL = ["EFI", "ESP"]

#: Default path, relative to the EFI partition's root, for --efi output.
DEFAULT_EFI_RELPATH = Path("rescue-net") / "network.sh"

#: Default mountpoint
DEFAULT_MOUNTPOINT = Path("/mnt/esp")

#: path to resolv.conf
RESOLV_CONF = Path("/etc/resolv.conf")


#: Optional INI config file:
CONFIG_FILE_PATH = Path("/etc/gen-rescue-netscript.conf")


def run_ip_json(args: List[str]) -> list:
    """Run ``ip -json <args>`` and return the parsed JSON result.

    If ``optional``, a failure is a warning (returns ``[]``) rather than
    fatal -- for queries some minimal environments don't support.
    """
    proc = subprocess.run(
        ["ip", "-json", *args], capture_output=True, text=True, check=True
    )

    return json.loads(proc.stdout) if proc.stdout.strip() else []


def compute_eui64_link_local(mac: str) -> Optional[ipaddress.IPv6Address]:
    """Compute the interface's kernel-auto-assigned EUI-64 link-local address.

    Returns ``None`` for an unparseable MAC. Interfaces using
    stable-privacy (RFC 7217) link-local generation won't match this and
    will be (harmlessly) treated as explicitly configured -- see
    :func:`build_addresses`.

    This is probably overzealous since "0xFFFE" in the middle might be
    enough to detect an EUI64 address.
    """
    octets = mac.split(":")
    if len(octets) != 6:
        return None
    try:
        byte = [int(o, 16) for o in octets]
    except ValueError:
        return None
    eui64 = [
        byte[0] ^ 0x02,
        byte[1],
        byte[2],
        0xFF,
        0xFE,
        byte[3],
        byte[4],
        byte[5],
    ]
    groups = [(eui64[i] << 8) | eui64[i + 1] for i in range(0, 8, 2)]
    try:
        return ipaddress.IPv6Address(
            "fe80::" + ":".join(f"{g:x}" for g in groups)
        )
    except ValueError:
        return None


# ---------------------------------------------------------------------------
# Gathering and interface selection
# ---------------------------------------------------------------------------
def find_link(addr_data: list, ifname: str) -> Optional[dict]:
    """Return the link information for ``ifname``, if present."""
    for link in addr_data:
        if link.get("ifname") == ifname:
            return link
    return None


def build_addresses(addr_data: list) -> List[Address]:
    """Turn one link entry into a list of `Address`.

    * IPv6 link-local matching the interface's EUI-64 address (see
      :func:`compute_eui64_link_local`) is skipped as kernel-auto; any
      *other* link-local address is kept as explicitly configured. IPv4
      link-local (169.254.0.0/16) is always skipped.
    * IPv6 SLAAC-derived global addresses (incl. privacy/"temporary")
      are skipped -- ``dynamic`` set but not ``mngtmpaddr``.
    * DHCP(v6)-leased addresses are kept, pinned as static.

    Link-local/loopback classification uses :mod:`ipaddress`
    (``is_link_local``) rather than the JSON "scope" field.
    """
    addresses: List[Address] = []
    for link in addr_data:
        # auto_ll = compute_eui64_link_local(link.get("address", ""))
        for addr_info in link.get("addr_info") or []:
            local = addr_info.get("local")
            prefixlen = addr_info.get("prefixlen")
            if not local or prefixlen is None:
                continue
            iface = ipaddress.ip_interface(f"{local}/{prefixlen}")
            dynamic = bool(addr_info.get("dynamic", False))
            # mngtmpaddr = bool(addr_info.get("mngtmpaddr", False))

            if iface.ip.is_link_local:
                continue

            addresses.append(Address(interface=iface, dynamic=dynamic))
    return addresses


def build_networks(addresses: list) -> list:
    """
    Return the unique directly attached networks
    for a list of IP interfaces.
    """
    return sorted(
        {address.interface.network for address in addresses},
        key=lambda net: (net.version, net.network_address)
    )


def build_routes(addr_data: list, route_data: list) -> List[Route]:
    """Turn ``ip -json route show`` output into a list of `Route`.

    ``default_net`` (``0.0.0.0/0`` or ``::/0``) resolves iproute2's
    literal "default" ``dst`` into a concrete network for the family
    this data came from. Routes with ``protocol == "kernel"`` (on-link
    routes the kernel recreates automatically once an address is
    assigned) are skipped.
    """
    addresses = build_addresses(addr_data)
    local_networks = build_networks(addresses)
    routes = []
    for entry in route_data:
        dev = entry.get("dev")
        if (
            not dev or
            entry.get("protocol") == "kernel" or
            (
                entry.get("dst") != 'default' and
                ipaddress.ip_network(entry.get("dst")) in local_networks
            )
        ):
            continue
        value = entry.get("gateway")
        gateway = ipaddress.ip_address(value) if value else None
        dst_raw = entry.get("dst", "default")
        cls = (
            ipaddress.IPv4Network
            if gateway.version == 4
            else ipaddress.IPv6Network
        )
        dst = (
            cls((0, 0))
            if dst_raw == "default"
            else ipaddress.ip_network(dst_raw, strict=False)
        )
        prefsrc = (
            ipaddress.ip_address(entry["prefsrc"])
            if entry.get("prefsrc")
            else None
        )
        routes.append(
            Route(
                dst=dst,
                dev=dev,
                gateway=gateway,
                prefsrc=prefsrc,
                metric=entry.get("metric")
            )
        )
    return routes


def default_route_gateways(routes: List[Route]) -> List[IPAddr]:
    """Distinct gateway(s) used by default routes, in order.

    ``ip route replace default via <gw> dev <iface>`` infers the family
    from the gateway, so a dual-stack pair can share one loop.
    """
    gateways: List[IPAddr] = []
    for route in routes:
        if (
            route.is_default and
            route.gateway and
            route.gateway not in gateways
        ):
            gateways.append(route.gateway)
    return gateways


def select_target_interface(routes: List[Route], prefer: str = "ipv6") -> str:
    """Return the interface to configure, from the default route(s).

    If IPv4 and IPv6 default routes name the same interface (or only one
    family has one), that's used. If they disagree, the interface
    belonging to ``prefer`` ("ipv4" or "ipv6") is used. Dies if either
    family alone has multiple conflicting default-route interfaces (not
    resolvable by a family preference), or if there's no default route
    at all.
    """
    v4_devs = {r.dev for r in routes if r.is_default and r.dst.version == 4}
    v6_devs = {r.dev for r in routes if r.is_default and r.dst.version == 6}

    if not v4_devs and not v6_devs:
        die(
            "no default route found; "
            "cannot determine which interface to configure"
        )

    if v4_devs and v6_devs and v4_devs != v6_devs:
        preferred = v4_devs if prefer == "ipv4" else v6_devs
        target_dev = next(iter(preferred))
        log(
            f"IPv4 default route uses '{next(iter(v4_devs))}', IPv6 uses "
            f"'{next(iter(v6_devs))}'; preferring {prefer} -> '{target_dev}'"
        )
        return target_dev

    target_dev = next(iter(v4_devs or v6_devs))
    log(f"selected interface '{target_dev}' (has the default route)")
    return target_dev


def load_config(
    path: str = CONFIG_FILE_PATH,
    section: str = "config"
) -> dict:
    """ read INI configuration """
    if not os.path.exists(path):
        return {}

    cp = configparser.ConfigParser(inline_comment_prefixes=('#', ';'))
    cp.optionxform = str  # preserve key case
    cp.read(path, encoding='utf-8')
    if not cp.has_section(section):
        raise ValueError(f"missing required section [{section}]")

    result = {}
    for key, value in cp[section].items():
        value = value.strip()
        if (
            len(value) >= 2 and
            value[0] == value[-1] and
            value[0] in ('"', "'")
        ):
            value = value[1:-1]
        result[key] = value
    return result


def resolve_target_interface(
    all_routes: List[Route], config: Dict
) -> str:
    """
    Priority: ``--interface``,
    then config ``interface``,
    then the default route."""

    config_interface = config.get("interface", None)
    if config_interface:
        log(f"using interface '{config_interface}'")
        return str(config_interface)

    return select_target_interface(all_routes, config.get("prefer", "ipv6"))


def gather_network_config(config: Dict) -> Dict:
    """Query the running system and return the configuration to recreate."""
    addr_data = run_ip_json(["addr", "show"])
    route4_data = run_ip_json(["route", "show"])
    route6_data = run_ip_json(["-6", "route", "show"])

    all_routes = build_routes(addr_data, (route4_data+route6_data))
    target_dev = resolve_target_interface(all_routes, config)

    link = find_link(addr_data, target_dev)
    if link is None:
        die(f"interface '{target_dev}' not found in 'ip addr show' output")

    network_config = {}
    network_config["routes"] = [
        route for route in all_routes
        if route.dev == target_dev
    ]

    network_config["target_dev"] = target_dev
    network_config["addresses"] = build_addresses([link])

    # build route statement (with ip knowledge)
    for route in network_config["routes"]:
        if route.dst == ipaddress.IPv6Network('::/0'):
            network_config["gateway_v6"] = route.gateway
        if route.dst == ipaddress.IPv4Network('0.0.0.0/0'):
            network_config["gateway_v4"] = route.gateway
        statement = [f"run_cmd ip route replace {route.dst}"]
        if route.gateway:
            statement.append(f"via {route.gateway}")
        statement.append('dev "$IFACE"')
        if route.prefsrc:
            statement.append(f"src {route.prefsrc}")
        if route.metric:
            statement.append(f"metric {route.metric}")
        route.statement = statement

    network_config["nameservers"] = determine_nameservers(target_dev)
    network_config["resolv_conf_target"] = determine_resolv_conf_target()

    return network_config


# ---------------------------------------------------------------------------
# /etc/resolv.conf
# ---------------------------------------------------------------------------
#: Used only when no DNS resolver configuration can be determined at all.
FALLBACK_NAMESERVER: IPAddr = ipaddress.ip_address("1.1.1.1")


def determine_resolv_conf_target(
    path: Path = RESOLV_CONF
) -> Path:
    """Resolve ``path`` if it's a symlink
    (e.g. into ``/run`` for systemd-resolved)."""
    return path.resolve if path.is_symlink() else path


def nameservers_from_resolvectl(target_dev: str) -> List[IPAddr]:
    """Parse ``resolvectl dns <dev>`` plain-text output.

    Tries the per-link query first, then the global one. resolvectl's
    ``--json=`` output was deliberately not used here: too new to be
    reliably present across a real fleet in 2026.
    """
    for args in (["dns", target_dev], ["dns"]):
        try:
            proc = subprocess.run(
                ["resolvectl", *args],
                capture_output=True,
                text=True,
                check=False,
            )
        except FileNotFoundError:
            return []
        if proc.returncode != 0 or not proc.stdout:
            continue
        servers: List[IPAddr] = []
        for line in proc.stdout.splitlines():
            if ":" not in line:
                continue
            _, _, rest = line.partition(":")
            for token in rest.split():
                try:
                    addr = ipaddress.ip_address(token)
                except ValueError:
                    continue
                if addr not in servers:
                    servers.append(addr)
        if servers:
            return servers
    return []


def nameservers_from_resolv_conf(
    path: Path = RESOLV_CONF
) -> List[IPAddr]:
    """Parse plain "nameserver" lines from resolv.conf,
    dropping loopback stubs."""
    servers: List[IPAddr] = []
    try:
        with open(path, encoding="utf-8") as resolv_file:
            for line in resolv_file:
                parts = line.split()
                if len(parts) < 2 or parts[0] != "nameserver":
                    continue
                try:
                    servers.append(ipaddress.ip_address(parts[1]))
                except ValueError:
                    continue
    except OSError:
        return []
    return [server for server in servers if not server.is_loopback]


def determine_nameservers(target_dev: str) -> List[IPAddr]:
    """
    Priority: resolvectl,
    then plain resolv.conf,
    then :data:`FALLBACK_NAMESERVER`.
    """
    servers = nameservers_from_resolvectl(target_dev)
    if servers:
        log(
            "using resolvectl-reported nameserver(s): " +
            ", ".join(str(s) for s in servers)
        )
        return servers

    servers = nameservers_from_resolv_conf()
    if servers:
        log(
            "using nameserver(s) from /etc/resolv.conf: " +
            ", ".join(str(s) for s in servers)
        )
        return servers

    warn(
        "could not determine the active DNS resolver;"
        f"falling back to {FALLBACK_NAMESERVER}"
    )
    return [FALLBACK_NAMESERVER]


# ---------------------------------------------------------------------------
# Rendering the POSIX-sh rescue script (Jinja2)
# ---------------------------------------------------------------------------
#: Commands whose plain-text (non -json) output is captured as a trailing
#: diagnostic comment block, for human reference. "ip rule show" is kept
#: here even though rule *recreation* isn't implemented -- see the
#: module docstring and README.
DIAGNOSTIC_COMMANDS: Tuple[Tuple[str, ...], ...] = (
    ("ip", "addr", "show"),
    ("ip", "route", "show"),
    ("ip", "-6", "route", "show"),
    ("ip", "rule", "show"),
    ("ip", "-6", "rule", "show"),
)


def gather_diagnostics(lineprefix: str) -> List:
    """
    Trailing diagnostic comment block:
    plain-text ip(8) output, never parsed.
    """
    sections = []
    for cmd in DIAGNOSTIC_COMMANDS:
        try:
            proc = subprocess.run(
                [*cmd],
                capture_output=True,
                text=True,
                check=False
            )
            sections.append(f"{lineprefix}$ {' '.join(cmd)}")
            output = (
                proc.stdout.rstrip("\n")
                if proc.returncode == 0
                else (
                    f"(command failed, "
                    f"rc={proc.returncode}: {proc.stderr.strip()})"
                )
            )
            sections.extend(f"{lineprefix}{line}" for line in output.splitlines())
        except FileNotFoundError:
            sections.append(f"{lineprefix}(command {cmd[0]} not found)")
    return sections




def render_rescue_script(network_config: Dict) -> str:
    """
    renders the actual network config script in absensce of jinja2
    """
    addresses = network_config.get("addresses", [])
    dhcp_notes = network_config.get("dhcp_notes", [])
    other_routes = network_config.get("other_routes", [])
    nameservers = network_config.get("nameservers", [])

    lines: list[str] = []

    def add(text: str = "") -> None:
        lines.append(text)

    # --- header ---
    add("#!/bin/sh")
    add(f"# Generated: {datetime.now().astimezone().strftime('%Y-%m-%dT%H:%M:%S%z')}")
    add(f"# Host: {socket.getfqdn()}")
    add(f"# Interface: {network_config["target_dev"]}")
    add("#")
    add("# rescue-net.sh - one-shot network configuration")
    add("# generated by gen-rescue-netscript")
    add("#")
    add("# Recreates network configuration. Safe to re-run.")
    add("")
    add("set -u")
    add("")
    add("PROG=${0##*/}")
    add('SKIP_IFACES=""')
    add("")

    # --- variables (edit these to change the configuration) ---
    add("# --- variables (edit these to change the configuration) ---")
    add(f'IFACE="{network_config["target_dev"]}"')

    if addresses:
        for note in dhcp_notes:
            add(f"# {note}")
        addr_str = " ".join(str(a.interface) for a in addresses)
        add(f'ADDR="{addr_str}"')

    if network_config.get("gateway_v4", None):
        add(f'GATEWAY_V4="{network_config["gateway_v4"]}"')

    if network_config.get("gateway_v6", None):
        add(f'GATEWAY_V6="{network_config["gateway_v6"]}"')

    if other_routes:
        add("# see below for other routes")

    if nameservers:
        add(f'NAMESERVERS="{" ".join(str(ns) for ns in nameservers)}"')
        add(f'RESOLV_TARGET="{network_config["resolv_conf_target"]}"')

    add("")

    # --- script body ---
    add("# --- script body follows")
    add("")
    add("log() { printf '%s: %s\\n' \"$PROG\" \"$*\" >&2; }")
    add("warn() { printf '%s: WARNING: %s\\n' \"$PROG\" \"$*\" >&2; }")
    add('die() { printf \'%s: ERROR: %s\\n\' "$PROG" "$*" >&2; exit 1; }')
    add("")
    add("run_cmd() {")
    add('    orig="$*"')
    add("    for skip in $SKIP_IFACES; do")
    add('        case "$orig" in')
    add('            *"dev $skip"*|*"dev $skip "*)')
    add('                log "skipping (SKIP_IFACES): $orig"')
    add("                return 0")
    add("                ;;")
    add("        esac")
    add("    done")
    add('    log "+ $orig"')
    add('    if "$@"; then')
    add("        return 0")
    add("    fi")
    add("    # 'ip addr/route replace' already creates the object if it doesn't")
    add("    # exist (NLM_F_CREATE|NLM_F_REPLACE) -- this is a defensive fallback")
    add("    # in case some iproute2/kernel combination doesn't honor that.")
    add("    # this is probably redundant and unnecessary.")
    add('    if [ "$1" = "ip" ] && [ "$3" = "replace" ]; then')
    add("        obj=$2")
    add("        shift 3")
    add('        log "retrying as add: ip $obj add $*"')
    add('        if ip "$obj" add "$@"; then')
    add("            return 0")
    add("        fi")
    add("    fi")
    add('    die "command failed: $orig"')
    add("}")
    add("")
    add('command -v ip >/dev/null 2>&1 || die "ip(8) not found"')
    add("")
    add("")

    # --- interface ---
    add(f"# --- {network_config["target_dev"]} ---")
    add('run_cmd ip link set dev "$IFACE" up')
    if addresses:
        add("for a in $ADDR; do")
        add('    run_cmd ip addr replace "$a" dev "$IFACE"')
        add("done")
    add("")

    # --- routes ---
    add("# --- routes ---")
    if network_config.get("gateway_v4", None):
        add('run_cmd ip route replace default via "$GATEWAY_V4" dev "$IFACE"')
    if network_config.get("gateway_v6", None):
        add('run_cmd ip -6 route replace default via "$GATEWAY_V6" dev "$IFACE"')
    for route in other_routes:
        add(route.statement)
    add("")

    # --- resolv.conf ---
    if nameservers:
        add("# --- /etc/resolv.conf ---")
        add('mkdir -p "$(dirname "$RESOLV_TARGET")" || die "could not create $(dirname "$RESOLV_TARGET")"')
        add('printf > "$RESOLV_TARGET" "# generated indirectly by gen-rescue-netscript.sh\\n"')
        add("for ns in $NAMESERVERS; do")
        add('    printf >> "$RESOLV_TARGET" "nameserver %s\\n" "${ns}"')
        add("done")
        add('log "wrote $RESOLV_TARGET"')
        add("")
    add('log "rescue network configuration applied"')

    # --- diagnostics footer ---
    add("# ---------------------------------------------------------------------------")
    add("# Diagnostic snapshot at generation time (informational only, not executed)")
    add("# ---------------------------------------------------------------------------")
    lines.append("\n".join(gather_diagnostics("# ")))

    return "\n".join(lines) + "\n"


#SCRIPT_TEMPLATE = jinja2.Template(
    """#!/bin/sh
# Generated: {{ timestamp }}
# Host: {{ fqdn }}
# Interface: {{ target_dev }}
#
# rescue-net.sh - one-shot network configuration
# generated by gen-rescue-netscript
#
# Recreates network configuration. Safe to re-run.

set -u

PROG=${0##*/}
SKIP_IFACES=""

# --- variables (edit these to change the configuration) ---
IFACE="{{ target_dev }}"
{% if addresses %}
{% for note in dhcp_notes %}
# {{ note }}
{% endfor %}
ADDR="{{ addresses | map(attribute='interface') | map('string') | join(' ') }}"
{% endif %}
{% if gateway_v4 %}
GATEWAY_V4="{{ gateway_v4 }}"
{% endif %}
{% if gateway_v6 %}
GATEWAY_V6="{{ gateway_v6 }}"
{% endif %}
{% if other_routes is defined %}
# see below for other routes
{% endif %}
{% if nameservers %}
NAMESERVERS="{{ nameservers|join(' ') }}"
{% endif %}

# --- script body follows

log() { printf '%s: %s\\n' "$PROG" "$*" >&2; }
warn() { printf '%s: WARNING: %s\\n' "$PROG" "$*" >&2; }
die() { printf '%s: ERROR: %s\\n' "$PROG" "$*" >&2; exit 1; }

run_cmd() {
    orig="$*"
    for skip in $SKIP_IFACES; do
        case "$orig" in
            *"dev $skip"*|*"dev $skip "*)
                log "skipping (SKIP_IFACES): $orig"
                return 0
                ;;
        esac
    done
    log "+ $orig"
    if "$@"; then
        return 0
    fi
    # 'ip addr/route replace' already creates the object if it doesn't
    # exist (NLM_F_CREATE|NLM_F_REPLACE) -- this is a defensive fallback
    # in case some iproute2/kernel combination doesn't honor that.
    if [ "$1" = "ip" ] && [ "$3" = "replace" ]; then
        obj=$2
        shift 3
        log "retrying as add: ip $obj add $*"
        if ip "$obj" add "$@"; then
            return 0
        fi
    fi
    die "command failed: $orig"
}

command -v ip >/dev/null 2>&1 || die "ip(8) not found"


# --- {{ target_dev }} ---
run_cmd ip link set dev "$IFACE" up
{% if addresses %}
for a in $ADDR; do
    run_cmd ip addr replace "$a" dev "$IFACE"
done
{% endif %}

# --- routes ---
{% if gateway_v4 %}
run_cmd ip route replace default via "$GATEWAY_V4" dev "$IFACE"
{% endif %}
{% if gateway_v6 %}
run_cmd ip -6 route replace default via "$GATEWAY_V6" dev "$IFACE"
{% endif %}
{% for route in other_routes|default([]) %}
{{ route.statement }}
{% endfor %}

# --- /etc/resolv.conf ---
RESOLV_TARGET="{{ resolv_conf_target }}"
mkdir -p "$(dirname "$RESOLV_TARGET")" || die "could not create $(dirname "$RESOLV_TARGET")"
printf > "$RESOLV_TARGET" "# generated indirectly by gen-rescue-netscript.sh\n"
for ns in $NAMESERVER; do
    printf >> "$RESOLV_TARGET" "nameserver %s\n" "${ns}"
done
log "wrote $RESOLV_TARGET"

log "rescue network configuration applied"
# ---------------------------------------------------------------------------
# Diagnostic snapshot at generation time (informational only, not executed)
# ---------------------------------------------------------------------------
"""
# {{ diagnostics | join('\n') }}""",
#    trim_blocks=True,
#    lstrip_blocks=True,
#    keep_trailing_newline=True,
#)


def build_script(network_config: Dict) -> str:
    """ Render the complete rescue script """
    addresses = network_config["addresses"]
    network_config["dhcp_notes"] = [
        f"{addr.interface} was DHCP-leased "
        "at capture time, now pinned static"
        for addr in addresses
        if addr.dynamic
    ]
    return render_rescue_script(network_config)


# ---------------------------------------------------------------------------
# --efi support: locating and writing to the EFI partition
# ---------------------------------------------------------------------------
def find_efi_device() -> Optional[str]:
    """Return the block device path with filesystem LABEL=<label>, if any."""
    try:
        for label in ['EFI', 'efi', 'ESP', 'esp']:
            for s in ['LABEL', 'PARTLABEL']:
                proc = subprocess.run(
                    ["blkid", "-t", f"{s}={label}", "-o", "device"],
                    capture_output=True,
                    text=True,
                    check=False,
                )
                output = proc.stdout.splitlines()
                if proc.returncode == 0 and output:
                    log(f"found a partition with {s}={label} at {output[0]}")
                    return output[0]
    except FileNotFoundError:
        die("blkid(8) not found")
        return None  # unreachable
    return None


def mounted(path: Path) -> Optional[Path]:
    """return mountpoing if mounted"""
    result = subprocess.run(
        ["findmnt", "-n", "-o", "TARGET", path],
        capture_output=True,
        text=True,
        check=True,
    )
    return result.stdout.strip() if result.returncode == 0 else None


def mount(device: Path, mountpoint: Path, mkdir: bool):
    """mount device to mountpoint"""
    options = ["--mkdir"] if mkdir else [""]
    subprocess.run(
        ["mount", options, device, mountpoint],
        check=True,
    )
    return mountpoint


def umount(path: Path):
    """umount path"""
    if path:
        subprocess.run(
            ["umount", path],
            check=True,
        )


def _rmdir_best_effort(path: str) -> None:
    """Remove an empty directory, warning (not raising) if that fails."""
    try:
        os.rmdir(path)
    except OSError as exc:
        warn(f"could not remove temporary mountpoint {path}: {exc}")


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
    """Parse command-line arguments."""
    parser = argparse.ArgumentParser(
        prog=PROG,
        description=(
            "Generates a one-shot shell script to configure a rescue "
            "system's network according to the current network "
            "configuration of the running system."
        ),
    )
    parser.add_argument(
        "--output",
        metavar="OUTPUT",
        help=(
            "without --efi: write the script to OUTPUT instead of stdout. "
            "with --efi: OUTPUT is relative to the EFI partition's root "
            f"(default: the 'path' key in {CONFIG_FILE_PATH}, or "
            f"'{DEFAULT_EFI_RELPATH}')"
        ),
    )
    parser.add_argument(
        "--efi",
        action="store_true",
        default=None,
        help=(
            "write the script onto the partition whose filesystem label "
            "or GPT partition label is EFI or ESP, mounting it first "
            "if necessary -- see --output for where on that partition"
        ),
    )
    parser.add_argument(
        "--interface",
        metavar="IFACE",
        help=(
            "configure IFACE instead of auto-detecting the interface via "
            f"the default route; overrides the 'interface' key in "
            f"{CONFIG_FILE_PATH} if both are set"
        ),
    )
    parser.add_argument(
        "--prefer",
        choices=["ipv4", "ipv6"],
        help=(
            "when IPv4 and IPv6 default routes disagree on the interface, "
            "prefer this family's; overrides the 'prefer' key in "
            f"{CONFIG_FILE_PATH} if both are set (default: ipv6)"
        ),
    )
    return parser.parse_args(argv)


def main() -> None:
    """Entry point."""
    config = load_config(
        CONFIG_FILE_PATH,
        "gen-rescue-netscript",
    )

    from pprint import pprint
    config.update(
        {k: v for k, v in vars(parse_args()).items() if v is not None}
    )

    network_config = gather_network_config(config)
    script = build_script(network_config)

    umountpoint = None
    if config.get("efi", False):
        efi_device = find_efi_device()
        mountpoint = Path(mounted(efi_device))
        if mountpoint is None:
            mountpoint = mount(
                device=efi_device,
                mountpoint=DEFAULT_MOUNTPOINT,
                mkdir=True,
            )
            umountpoint = mountpoint
        output = (
            mountpoint /
            config.get("output", DEFAULT_EFI_RELPATH)
        )
    else:
        output = (
            Path(config.get("output"))
            if config.get("output")
            else None
        )

    if output:
        output.parent.mkdir(parents=True, exist_ok=True)
        with open(output, "w", encoding="utf-8") as outfile:
            outfile.write(script)
        os.chmod(output, 0o755)
        log(f"wrote {output}")
    else:
        sys.stdout.write(script)

    umount(umountpoint)


if __name__ == "__main__":
    main()
