#!/usr/bin/env python3
# Copyright (C) 2026 Alexander Wilms, Christian Wirtz
# SPDX-License-Identifier: GPL-2.0-only
"""mcactl - control tool of the Monitoring Coverage Analyzer (MCA).

Run as the OMD site user:

    mcactl setup       install / update the cron jobs (once after install or update)
    mcactl status      show cron jobs and the state of the last runs
    mcactl uninstall   remove the cron jobs
    mcactl fullrun     run the analysis if the configured interval has elapsed (cron, daily)
    mcactl rerun       run the analysis right away (also started by the GUI page)
    mcactl refresh     re-send the last result as piggyback data (cron, every 5 min)
    mcactl support-data  create an encrypted support file for a bug report (see --help)
"""
# MKPs cannot register cron jobs themselves (etc/cron.d/ is not a
# PackagePart), hence "setup" copies the cron template shipped in the MKP.
#
# refresh, status and the "fullrun due?" check run WITHOUT the Checkmk GUI
# (lib/runstate.py). Starting the GUI (main_modules.register) takes 3-20 s
# depending on the system and is only needed for an actual analysis run.
from __future__ import annotations

import filecmp
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import traceback

PKG = "monitoring_coverage_analyzer"
TOOL = "mcactl"
ANALYSIS_MODES = ("fullrun", "rerun")
COMMANDS = ("setup", "status", "uninstall", "fullrun", "rerun", "refresh", "support-data")

_TTY = sys.stdout.isatty()


def _c(code: str, text: str) -> str:
    return f"\033[{code}m{text}\033[0m" if _TTY else text


def step(msg: str) -> None:
    print("  " + _c("1", f"> {msg}"))


def ok(msg: str) -> None:
    print("  " + _c("0;32", f"OK   {msg}"))


def warn(msg: str) -> None:
    print("  " + _c("0;33", f"WARN {msg}"))


def die(msg: str) -> int:
    print("\n" + _c("0;31", f"Error: {msg}") + "\n", file=sys.stderr)
    return 1


def _paths(root: str) -> dict[str, str]:
    return {
        # PackagePart.DOC installs to local/share/doc/check_mk/ (cmk.utils.paths.local_doc_dir)
        "template": os.path.join(root, "local", "share", "doc", "check_mk", PKG, f"{PKG}.cron"),
        "cron_file": os.path.join(root, "etc", "cron.d", PKG),
        "tool": os.path.join(root, "local", "bin", TOOL),
        "cache": os.path.join(root, "var", "check_mk", "web", f"{PKG}_cache.json"),
    }


def _cron_active() -> bool:
    try:
        out = subprocess.run(["crontab", "-l"], capture_output=True, text=True, check=False).stdout
    except OSError:
        return False
    return any(
        f"{TOOL} " in line for line in out.splitlines() if not line.lstrip().startswith("#")
    )


def _reload_crontab() -> None:
    subprocess.run(["omd", "reload", "crontab"], stdout=subprocess.DEVNULL, check=False)


def _header(title: str, site: str, root: str) -> None:
    print()
    print(_c("1", f"Monitoring Coverage Analyzer (MCA) - {title}"))
    print(f"  Site: {site}  ({root})")
    print()


# ---------------------------------------------------------------------------
# setup / status / uninstall
# ---------------------------------------------------------------------------


def cmd_setup(site: str, root: str) -> int:
    p = _paths(root)
    if not os.path.isfile(p["template"]):
        return die(f"Cron template not found at {p['template']}\nDid you install the {PKG} MKP first?")
    if not os.access(p["tool"], os.X_OK):
        return die(f"{TOOL} not found/executable at {p['tool']}\nDid you install the {PKG} MKP first?")
    _header("Setup", site, root)

    step("Installing cron jobs")
    cron_file = p["cron_file"]
    os.makedirs(os.path.dirname(cron_file), exist_ok=True)
    if os.path.isfile(cron_file) and filecmp.cmp(p["template"], cron_file, shallow=False):
        ok(f"{cron_file} already up to date")
    else:
        # Atomic replace: stage next to the target, then rename().
        fd, tmp = tempfile.mkstemp(prefix=os.path.basename(cron_file) + ".", dir=os.path.dirname(cron_file))
        os.close(fd)
        shutil.copyfile(p["template"], tmp)
        os.chmod(tmp, 0o644)
        os.replace(tmp, cron_file)
        ok(f"{cron_file} written")

    step("Reloading site crontab")
    _reload_crontab()
    if not _cron_active():
        return die(
            "Cron jobs not found in the site crontab after 'omd reload crontab'.\n"
            "Is the crontab service enabled? (omd config show CRONTAB)"
        )
    ok("Cron jobs active")

    print()
    print(_c("0;32;1", "  Setup complete."))
    print()
    print("  Jobs:")
    with open(cron_file, encoding="utf-8") as handle:
        for line in handle:
            if line.strip() and not line.lstrip().startswith("#"):
                print("    " + line.rstrip())
    print()
    print("  The full analysis runs daily at 05:00 (if due).")
    print("  To run it right away:")
    print(f"    {TOOL} rerun")
    print()
    return 0


def cmd_status(site: str, root: str) -> int:
    p = _paths(root)
    _header("Status", site, root)
    if os.path.isfile(p["cron_file"]):
        ok(f"Cron file present: {p['cron_file']}")
        if os.path.isfile(p["template"]) and not filecmp.cmp(p["template"], p["cron_file"], shallow=False):
            warn(f"Cron file differs from the MKP template - run: {TOOL} setup")
    else:
        warn(f"Cron file missing - run: {TOOL} setup")
    if _cron_active():
        ok("Cron jobs active in site crontab")
    else:
        warn("Cron jobs NOT active in site crontab")
    try:
        with open(p["cache"], encoding="utf-8") as handle:
            data = json.load(handle)
    except (OSError, ValueError):
        warn(f"No analysis result yet ({p['cache']})")
    else:
        def fmt(ts: object) -> str:
            return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(float(ts))) if ts else "never"

        print("  Last full run:               ", fmt(data.get("last_full_run_timestamp")))
        print("  Last piggyback refresh:      ", fmt(data.get("last_piggyback_refresh_timestamp")))
    from cmk_addons.plugins.monitoring_coverage_analyzer.lib import runstate

    enabled = runstate.generate_piggyback_data_enabled()
    print("  Piggyback generation:        ", "enabled" if enabled else "disabled (global setting)")
    print("  Hosts with MCA piggyback data:", len(runstate.piggyback_hosts_with_data()))
    print()
    return 0


def cmd_uninstall(site: str, root: str) -> int:
    p = _paths(root)
    _header("Uninstall cron jobs", site, root)
    step("Removing cron jobs")
    try:
        os.remove(p["cron_file"])
    except FileNotFoundError:
        pass
    _reload_crontab()
    if _cron_active():
        return die("Cron jobs still present in the site crontab.")
    ok("Cron jobs removed")
    print()
    print("  " + _c("0;33;1", "Next step (optional): remove the MKP package:"))
    print(f"    mkp disable {PKG}")
    print(f"    mkp remove {PKG}")
    print()
    return 0


# ---------------------------------------------------------------------------
# refresh / fullrun / rerun
# ---------------------------------------------------------------------------


def _app_context():
    """GUI application + request context (moved in Checkmk 3.0)."""
    try:
        from cmk.gui.wsgi.app import application_and_request_context
    except ImportError:
        from cmk.gui.utils.script_helpers import application_and_request_context
    return application_and_request_context


def _run_analysis() -> tuple[int, int, str | None]:
    """Loads the GUI and the page module and runs a complete analysis
    (including saving the result and writing piggyback data)."""
    # main_modules.register() must run before application_and_request_context()
    # (otherwise KeyError in features_registry) - same call as in
    # cmk-update-config.
    from cmk.ccc.version import edition
    from cmk.gui import main_modules
    from cmk.utils import paths as cmk_paths

    main_modules.register(edition(cmk_paths.omd_root))

    application_and_request_context = _app_context()

    with application_and_request_context():
        # Page plug-ins are loaded by the GUI via exec() and are not
        # importable - hence load them directly via the file path.
        import importlib.util
        from pathlib import Path

        plugin_path = Path(cmk_paths.local_web_dir) / "plugins" / "pages" / f"{PKG}.py"
        spec = importlib.util.spec_from_file_location(f"{PKG}_page_module", plugin_path)
        if spec is None or spec.loader is None:  # pragma: no cover - defensive
            raise RuntimeError(f"cannot load GUI page module {plugin_path}")
        page_module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(page_module)
        return page_module._run_analysis_and_store()


def _analysis_job(runstate, mode: str) -> int:
    with runstate.job_lock() as acquired:
        if not acquired:
            print(f"{mode}: skipped, another analysis run is active")
            return 0
        started = time.time()
        runstate.save_job_state(state="running", mode=mode, started=started, finished=None, error=None)
        try:
            hosts, written, error = _run_analysis()
        except Exception as exc:  # record the error for the GUI page
            traceback.print_exc()
            runstate.save_job_state(state="failed", finished=time.time(), error=f"{exc!r}")
            return 1
        runstate.save_job_state(
            state="done" if error is None else "failed",
            finished=time.time(),
            hosts=hosts,
            error=None if error is None else f"piggyback: {error}",
        )
        print(
            f"{mode}: hosts={hosts} piggyback_written={written} error={error} "
            f"duration={time.time() - started:.1f}s"
        )
        return 0 if error is None else 1


def cmd_refresh() -> int:
    from cmk_addons.plugins.monitoring_coverage_analyzer.lib import runstate

    if not runstate.generate_piggyback_data_enabled():
        removed = runstate.remove_all_piggyback()
        print(f"refresh: piggyback generation disabled, nothing written (removed={removed})")
        return 0
    written, error = runstate.run_piggyback_refresh()
    print(f"refresh: written={written} error={error}")
    return 0 if error is None else 1


def cmd_analysis(mode: str) -> int:
    from cmk_addons.plugins.monitoring_coverage_analyzer.lib import runstate

    if mode == "fullrun":
        due, reason = runstate.is_full_run_due(force=False)
        if not due:
            print(f"fullrun: skipped, not due ({reason})")
            return 0
    return _analysis_job(runstate, mode)


# ---------------------------------------------------------------------------
# support-data
# ---------------------------------------------------------------------------

SUPPORT_DATA_HELP = """\
mcactl support-data - create an encrypted support file for a bug report

    mcactl support-data [--host HOST] [--with-runtime] [--output DIR] [--yes]
    mcactl support-data resolve [PSEUDONYM ...]

Collects the MCA result and the facts it is based on, shows you what is
included and, after your confirmation, writes an encrypted transport file.
Only the maintainer can read it, so it may be attached to a (public) bug
report. Nothing is sent anywhere. The data helps to analyze the report and,
in aggregated form, to improve the detection rules.

The content is anonymized: host, site, folder and server names, IP
addresses and domains are replaced by pseudonyms (stable per site, so
"host-1a2b3c4d" stays the same in later reports). A second file with the
"resolve" shows the real host name of a pseudonym from a reply (without a
pseudonym: all hosts). It recomputes the pseudonyms from the current hosts,
so a host deleted or renamed since the report is not found.

Options:
  --host HOST      only this host (and the MCA Setup rules that apply to it)
  --with-runtime   include the names of Windows services, systemd units and
                   processes per host without asking (asked interactively
                   otherwise; with --yes they are left out)
  --output DIR     directory for the files (default: current directory)
  --yes            no dialog: create the transport file right away
  --no-anonymize   do not anonymize anything (only on request of the
                   maintainer, the file then contains all real names)
"""


def _ask(question: str, default_yes: bool = True) -> bool:
    suffix = "(Y/n)" if default_yes else "(y/N)"
    try:
        answer = input(f"  {question} {suffix} ").strip().lower()
    except EOFError:
        return default_yes
    if not answer:
        return default_yes
    return answer in ("y", "yes", "j", "ja")


def _write_private(path: str, payload: object) -> None:
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        json.dump(payload, handle, indent=1, sort_keys=True, default=str)
        handle.write("\n")


def _write_private_bytes(path: str, payload: bytes) -> None:
    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "wb") as handle:
        handle.write(payload)


def _choose_transport(doc: object) -> bool:
    """Dialog: view the content, create the transport file or abort."""
    import pydoc

    while True:
        try:
            answer = input("  [v] View the full content   [c] Create the encrypted file   [a] Abort: ")
        except EOFError:
            answer = "a"
        answer = answer.strip().lower()
        if answer in ("v", "view"):
            pydoc.pager(json.dumps(doc, indent=1, sort_keys=True, default=str))
        elif answer in ("c", "create"):
            return True
        elif answer in ("a", "abort", "q"):
            return False


def _all_host_names() -> list[str]:
    """Host names of all sites (same source as the support data)."""
    page_module = _load_page_module_in_context()
    with page_module["context"]():
        from cmk.gui import sites

        rows = sites.live().query("GET hosts\nColumns: name\n")
    return [row[0] for row in rows]


def cmd_support_data_resolve(site: str, root: str, pseudonyms: list[str]) -> int:
    from cmk_addons.plugins.monitoring_coverage_analyzer.lib import support_data

    bad = [p for p in pseudonyms if not support_data.is_host_pseudonym(p)]
    if bad:
        return die(f"Not a host pseudonym: {', '.join(bad)} (expected e.g. host-1a2b3c4d)")
    key_path = os.path.join(root, "var", "check_mk", f"{PKG}_support_data.key")
    if not os.path.exists(key_path):
        return die("No support data key on this site - no report was created here yet.")
    key = support_data.load_or_create_key(key_path)
    found = support_data.resolve_hosts(key, _all_host_names(), pseudonyms)
    width = max((len(p) for p in found), default=0)
    missing = 0
    for pseudonym, names in found.items():
        if names:
            print(f"  {pseudonym.ljust(width)}  {', '.join(names)}")
        else:
            missing += 1
            print(f"  {pseudonym.ljust(width)}  not found (host deleted or renamed since the report?)")
    return 1 if missing else 0


def cmd_support_data(site: str, root: str, args: list[str]) -> int:
    if args[:1] == ["resolve"]:
        return cmd_support_data_resolve(site, root, args[1:])
    opts = {"--with-runtime": False, "--yes": False, "--no-anonymize": False}
    outdir = os.getcwd()
    only_host = None
    rest = list(args)
    while rest:
        arg = rest.pop(0)
        if arg in ("-h", "--help"):
            print(SUPPORT_DATA_HELP)
            return 0
        if arg == "--output" and rest:
            outdir = rest.pop(0)
        elif arg == "--host" and rest:
            only_host = rest.pop(0)
        elif arg in opts:
            opts[arg] = True
        else:
            print(SUPPORT_DATA_HELP, file=sys.stderr)
            return 2
    if not os.path.isdir(outdir) or not os.access(outdir, os.W_OK):
        return die(f"Output directory not writable: {outdir}")
    if not opts["--yes"] and not sys.stdin.isatty():
        return die("support-data asks for confirmation and needs a terminal (or --yes).")
    interactive = not opts["--yes"]
    anonymize = not opts["--no-anonymize"]

    from cmk_addons.plugins.monitoring_coverage_analyzer.lib import support_data, transport

    if not transport.public_key_configured():
        return die("This package contains no maintainer key, no transport file can be created.")

    _header("Support data", site, root)
    if anonymize:
        scope = f"for the host {only_host}" if only_host else "for every host analyzed by MCA"
        print(f"  This collects, {scope}:")
        print("    - the MCA result (findings, evidence, coverage) and your MCA Setup rules")
        print("    - Checkmk version, MKP version, MCA settings, the rules file in use")
        print("    - built-in host labels and tags (cmk/...), custom ones only as a count")
        print("    - names of the monitored check plug-ins (no service names)")
        print("    - names of the agent sections and their line counts")
        print()
        print("  Host, site, folder and server names, IP addresses and domains are")
        print("  replaced by pseudonyms. You see the content before anything is written.")
        print()
        with_runtime = opts["--with-runtime"]
        if not with_runtime and interactive:
            print("  Optional, but very helpful for finding undetected software:")
            print("  the names of all Windows services, systemd units and processes per")
            print("  host. These names are NOT anonymized and may reveal applications or")
            print("  your organization (e.g. a service called 'acme-billing').")
            with_runtime = _ask("Include service and process names?", default_yes=False)
            print()
    else:
        print("  " + _c("0;31;1", "--no-anonymize: NOTHING is anonymized."))
        print("  The file contains all host, site and folder names, IP addresses,")
        print("  labels, tags, service names, Setup rules and the names of all")
        print("  Windows services, systemd units and processes of the analyzed hosts.")
        print()
        if interactive:
            try:
                answer = input("  Type 'yes' to continue: ").strip()
            except EOFError:
                answer = ""
            if answer != "yes":
                print("  Aborted.")
                return 1
        with_runtime = True

    step("Collecting (this takes as long as an analysis run)")
    page_module = _load_page_module_in_context()
    with page_module["context"]():
        page = page_module["load"]()
        raw = support_data.collect_raw(page, root, site, with_runtime, only_host)
    if not raw.get("hosts"):
        if only_host:
            return die(f"Host '{only_host}' is not in the MCA result (exact host name needed).")
        warn("No analysis result found - run 'mcactl rerun' first.")

    if anonymize:
        key = support_data.load_or_create_key(os.path.join(root, "var", "check_mk", f"{PKG}_support_data.key"))
        doc, mapping = support_data.anonymize(raw, key, with_runtime)
        residual = support_data.residual_findings(
            doc, support_data.identifying_names(mapping), ignore=support_data.residual_ignore(raw)
        )
        name = doc["meta"]["site"]
    else:
        doc, mapping, residual, name = support_data.unanonymized(raw), None, [], f"{site}-UNANONYMIZED"
    ok(f"{len(raw.get('hosts') or {})} host(s), {len(raw['meta'].get('errors') or [])} collection errors")

    print()
    print("  Content of the support file:")
    for line in support_data.summary(doc, residual):
        print(f"    {line}")
    if residual:
        print()
        warn("Some strings still look like names or addresses - view the content")
        print("       and abort if they should not leave your organization.")
    print()
    if interactive and not _choose_transport(doc):
        print("  Aborted, nothing was written.")
        print()
        return 1

    stamp = time.strftime("%Y%m%d-%H%M%S")
    base = os.path.join(outdir, f"mca-support-data-{name}-{stamp}")
    out_file = base + ".json.enc"
    _write_private_bytes(out_file, transport.encrypt(doc))
    print()
    print("  Encrypted file to attach to the bug report (only the maintainer can read it):")
    print(f"    {out_file}  ({os.path.getsize(out_file) // 1024 + 1} KiB)")
    if mapping:
        print("  The maintainer only sees pseudonyms such as host-1a2b3c4d. To look up")
        print("  the host a reply refers to, run: mcactl support-data resolve <pseudonym>")
    print()
    return 0


def _load_page_module_in_context() -> dict:
    """GUI context factory and page module loader (see _run_analysis)."""
    from cmk.ccc.version import edition
    from cmk.gui import main_modules
    from cmk.utils import paths as cmk_paths

    main_modules.register(edition(cmk_paths.omd_root))
    application_and_request_context = _app_context()

    def load():
        import importlib.util
        from pathlib import Path

        plugin_path = Path(cmk_paths.local_web_dir) / "plugins" / "pages" / f"{PKG}.py"
        spec = importlib.util.spec_from_file_location(f"{PKG}_page_module", plugin_path)
        if spec is None or spec.loader is None:  # pragma: no cover - defensive
            raise RuntimeError(f"cannot load GUI page module {plugin_path}")
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return module

    return {"context": application_and_request_context, "load": load}


def usage(stream=sys.stdout) -> None:
    print((__doc__ or "").strip(), file=stream)


def main(argv: list[str]) -> int:
    if len(argv) == 2 and argv[1] in ("-h", "--help", "help"):
        usage()
        return 0
    if len(argv) < 2 or argv[1] not in COMMANDS or (len(argv) > 2 and argv[1] != "support-data"):
        usage(sys.stderr)
        return 2
    cmd = argv[1]
    if cmd == "support-data" and any(a in ("-h", "--help") for a in argv[2:]):
        print(SUPPORT_DATA_HELP)
        return 0

    site = os.environ.get("OMD_SITE", "")
    root = os.environ.get("OMD_ROOT", "")
    if not site or not root:
        return die(f"Run as the OMD site user (OMD_SITE / OMD_ROOT must be set).\nExample: su - <SITE> -c '{TOOL} {cmd}'")
    if os.getuid() == 0:
        return die(f"Do not run as root. Example: su - {site} -c '{TOOL} {cmd}'")

    if cmd == "setup":
        return cmd_setup(site, root)
    if cmd == "status":
        return cmd_status(site, root)
    if cmd == "uninstall":
        return cmd_uninstall(site, root)
    if cmd == "refresh":
        return cmd_refresh()
    if cmd == "support-data":
        return cmd_support_data(site, root, argv[2:])
    return cmd_analysis(cmd)


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