#!/usr/bin/python3
"""Merge newly shipped Naemon command definitions into the operator's file.

The custom commands file is seeded once, on first install, and is never
touched again so that local edits survive upgrades. The cost of that rule was
invisible: a check shipped in a later release reached the plugin directory and
the GUI, but its command definition never reached an existing installation, so
scheduling it produced a check that could not run.

This closes the gap without giving up the guarantee. A definition is added
only when the name exists nowhere in the configuration - not merely nowhere in
the target file - because Naemon refuses to start on a duplicate command name,
and imported configurations routinely define the standard wrappers themselves.
Definitions that already exist are left exactly as they are, including ones the
administrator has rewritten. If the merged file fails validation, and the file
validated before, the change is rolled back: an upgrade must never leave Naemon
unable to start.

Seeding the file on a fresh install obeys the same rule. It once did not: the
whole shipped file was copied into place unfiltered, so every standard wrapper
was defined both here and in the RPM-shipped vexor-commands.cfg, and Naemon
refused to start on a brand new installation.
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path

DEFAULT_SOURCE = "/opt/vexor/api/packaging/naemon/vexor-wrappers.cfg"
DEFAULT_TARGET = "/etc/naemon/conf.d/vexor-custom-commands.cfg"
DEFAULT_SCAN = "/etc/naemon"
NAEMON_CFG = "/etc/naemon/naemon.cfg"

_BLOCK = re.compile(r"define\s+command\s*\{.*?\}", re.DOTALL)
_NAME = re.compile(r"^\s*command_name\s+(\S+)", re.MULTILINE)


def parse_blocks(textblob: str) -> list[tuple[str, str]]:
    """Every `define command` block, paired with the name it defines."""
    out = []
    for match in _BLOCK.finditer(textblob):
        block = match.group(0)
        name = _NAME.search(block)
        if name:
            out.append((name.group(1), block))
    return out


def defined_names(textblob: str) -> set[str]:
    """Names already present, whatever shape their definition has."""
    return set(_NAME.findall(textblob))


def missing_blocks(
    source: str, target: str, also_defined: set[str] | None = None
) -> list[tuple[str, str]]:
    """Shipped definitions whose name is not already taken.

    `also_defined` carries the names found elsewhere in the configuration. A
    duplicate command name is fatal to Naemon, so a name defined in any other
    file counts as present even though this file does not contain it.
    """
    have = defined_names(target) | (also_defined or set())
    return [(n, b) for n, b in parse_blocks(source) if n not in have]


def prune_duplicates(target: str, also_defined: set[str]) -> tuple[str, list[str]]:
    """Drop blocks whose name is already defined elsewhere in the configuration.

    Repairs installations seeded before the filter existed. Nothing working is
    being changed: Naemon refuses to start while a name is defined twice, so a
    duplicate here has never once been in effect. The packaged definition is
    the one that survives.
    """
    removed: list[str] = []

    def drop(match: "re.Match[str]") -> str:
        name = _NAME.search(match.group(0))
        if name and name.group(1) in also_defined:
            removed.append(name.group(1))
            return ""
        return match.group(0)

    if not also_defined:
        return target, removed
    pruned = _BLOCK.sub(drop, target)
    if not removed:
        return target, removed
    # Collapse the blank runs the removed blocks leave behind.
    return re.sub(r"\n{3,}", "\n\n", pruned), removed


def scan_defined(root: str, skip: str = "") -> set[str]:
    """Every command name defined anywhere under a Naemon config directory."""
    found: set[str] = set()
    for path in Path(root).rglob("*.cfg"):
        if skip and str(path) == skip:
            continue
        try:
            found |= defined_names(path.read_text(encoding="utf-8", errors="replace"))
        except OSError:
            continue
    return found


def _validate() -> bool:
    """Ask Naemon whether the configuration is loadable, as naemon itself."""
    try:
        proc = subprocess.run(
            ["runuser", "-u", "naemon", "--", "naemon", "-v", NAEMON_CFG],
            capture_output=True,
            text=True,
            timeout=120,
        )
        return proc.returncode == 0
    except Exception:
        # No naemon, no runuser, no permission: cannot judge, so do not block.
        return True


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--source", default=DEFAULT_SOURCE)
    p.add_argument("--target", default=DEFAULT_TARGET)
    p.add_argument(
        "--scan-dir",
        default=DEFAULT_SCAN,
        help="configuration tree to scan for names that are already defined",
    )
    p.add_argument("--dry-run", action="store_true")
    p.add_argument("--no-reload", action="store_true")
    args = p.parse_args()

    try:
        with open(args.source, encoding="utf-8") as fh:
            source = fh.read()
    except OSError as exc:
        print(f"vexor-sync-commands: cannot read shipped definitions: {exc}", file=sys.stderr)
        return 1

    creating = False
    try:
        with open(args.target, encoding="utf-8") as fh:
            target = fh.read()
    except FileNotFoundError:
        # A fresh install: there is no file to merge into, but the definitions
        # still have to be filtered. Seeding the file wholesale was how a brand
        # new installation ended up with every standard wrapper defined twice -
        # once here and once in the RPM-shipped vexor-commands.cfg - which left
        # Naemon unable to start at all. The rule is the same whether the file
        # is being created or extended: a name defined anywhere is never added.
        creating = True
        target = ""
    except OSError as exc:
        print(f"vexor-sync-commands: cannot read {args.target}: {exc}", file=sys.stderr)
        return 1

    elsewhere = scan_defined(args.scan_dir, skip=args.target) if args.scan_dir else set()
    pruned, duplicates = ("", [])
    if not creating:
        pruned, duplicates = prune_duplicates(target, elsewhere & defined_names(target))
    missing = missing_blocks(source, target, elsewhere)
    if not missing and not creating and not duplicates:
        print("vexor-sync-commands: no new command definitions to add")
        return 0

    names = ", ".join(n for n, _ in missing)
    if args.dry_run:
        verb = "create" if creating else "add"
        print(f"would {verb} {args.target} with {len(missing)} definition(s): {names}")
        if duplicates:
            print(f"would remove {len(duplicates)} duplicate definition(s): {', '.join(duplicates)}")
        return 0

    was_valid = _validate()
    backup = ""
    if creating:
        # Keep whatever leading comment the shipped file carries: the GUI
        # rewrites this file and its header tells operators not to edit it.
        head = source.split("define", 1)[0].rstrip()
        addition = [head] if head else []
    else:
        # Not a .cfg suffix: Naemon reads every .cfg in the directory, and a
        # backup it could parse would define every command twice.
        backup = f"{args.target}.bak-{datetime.now().strftime('%Y%m%d%H%M%S')}"
        shutil.copyfile(args.target, backup)

        if duplicates:
            # Repair an install seeded before the filter existed: Naemon has
            # been unable to start there since the day it was installed.
            with open(args.target, "w", encoding="utf-8") as fh:
                fh.write(pruned)
            print(
                "vexor-sync-commands: removed "
                f"{len(duplicates)} definition(s) already provided by Vexor: "
                f"{', '.join(duplicates)}"
            )

        stamp = datetime.now().strftime("%Y-%m-%d")
        addition = [
            "",
            f"# Added automatically by vexor-sync-commands on {stamp}.",
            "# These checks ship with Vexor but were released after this file was created.",
            "# Edit them freely: an upgrade only ever adds names that are missing.",
        ]
    if not missing and not creating:
        addition = []
    for _name, block in missing:
        addition.append("")
        addition.append(block)
    if addition:
        addition.append("")

    with open(args.target, "w" if creating else "a", encoding="utf-8") as fh:
        fh.write("\n".join(addition))

    if creating:
        try:
            shutil.chown(args.target, group="naemon")
        except (LookupError, OSError):
            pass  # no naemon group yet: the package %post fixes ownership anyway

    if was_valid and not _validate():
        if creating:
            # The file did not exist a moment ago; leaving a broken one behind
            # would keep Naemon down for every later start.
            try:
                os.unlink(args.target)
            except OSError:
                pass
            print(
                "vexor-sync-commands: seeded configuration failed validation, removed. "
                f"Definitions not added: {names}",
                file=sys.stderr,
            )
            return 1
        shutil.copyfile(backup, args.target)
        print(
            "vexor-sync-commands: merged configuration failed validation, rolled back. "
            f"Definitions not added: {names}. Previous file kept at {backup}",
            file=sys.stderr,
        )
        return 1

    if backup:
        try:
            os.unlink(backup)
        except OSError:
            pass
    if creating:
        print(f"vexor-sync-commands: created {args.target} with {len(missing)} command definition(s)")
    elif missing:
        print(f"vexor-sync-commands: added {len(missing)} command definition(s): {names}")
    _reload(args)
    return 0


def _reload(args) -> None:
    if args.no_reload:
        return
    try:
        subprocess.run(["systemctl", "reload", "naemon"], capture_output=True, timeout=60)
    except Exception:
        pass


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