#!/usr/bin/env python3
# Copyright (C) 2026 Christian Wirtz <doc@snowheaven.de>
# License: GPL-2.0-only, see LICENSE in the repository root.
"""
mkp-update - check for, download and install newer versions of installed
Checkmk extension packages (MKPs)

For every selected package, mkp-update finds the newest available version
(first match wins):

  1. an explicit source for this package (--source, or the sources file
     $OMD_ROOT/etc/mkp-update.sources)
  2. the package repositories (--repo, or '@repo' in the sources file): a
     directory or an internal web server holding .mkp files for all packages,
     e.g. your organization's tested and approved MKPs
  3. unless --offline: the package's own Download-URL - GitHub, GitLab or
     Gitea releases, any other git server's tags, or a direct .mkp URL
  4. unless --offline/--no-exchange: the Checkmk Exchange catalog

Updates are downloaded and verified (package name and version from the
manifest inside the .mkp, the Checkmk version range it declares) and - after
a confirmation prompt per package, or automatically with --auto-install -
installed via `mkp add` + `mkp enable`. The previously enabled version is then
disabled (reversible via `mkp enable`; mkp-cleanup removes it for good).

Must be run as the Checkmk site user (e.g. via `omd su <site>`).
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import tempfile
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field

try:
    from cmk_addons.plugins.mkp_updates.lib import cli
except ImportError:  # running from a source checkout: package/bin/ -> package/
    sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
    from cmk_addons.plugins.mkp_updates.lib import cli
from cmk_addons.plugins.mkp_updates.lib import sources
from cmk_addons.plugins.mkp_updates.lib.cli import Colors, log_line, print_table
from cmk_addons.plugins.mkp_updates.lib.http import Http, SourceError
from cmk_addons.plugins.mkp_updates.lib.mkpfile import safe_filename, verify_download
from cmk_addons.plugins.mkp_updates.lib.packages import (
    STATE_ACTIVE,
    STATE_DISABLED,
    MkpError,
    check_site_context,
    fetch_packages,
    find_mkp_binary,
    get_current_cmk_version,
    run_mkp,
)
from cmk_addons.plugins.mkp_updates.lib.sources import (
    STATUS_ERROR,
    STATUS_UPDATE_AVAILABLE,
    Config,
    Resolver,
    Result,
)
from cmk_addons.plugins.mkp_updates.lib.versions import package_version_key

PROG = "mkp-update"
VERSION = cli.VERSION

# The command line is interactive - someone running it wants to see a release from
# today, so the shared Exchange catalog cache is only trusted for an hour here (the
# special agent uses a day). A fresh fetch also refreshes the cache for the agent.
EXCHANGE_MAX_AGE = 60 * 60

STATUS_LABELS = {
    sources.STATUS_UPDATE_AVAILABLE: "update available",
    sources.STATUS_UP_TO_DATE: "up-to-date",
    sources.STATUS_AHEAD: "newer than latest release",
    sources.STATUS_NO_SOURCE: "no source",
    sources.STATUS_NO_RELEASES: "no releases",
    sources.STATUS_ERROR: "error",
    sources.STATUS_UNKNOWN: "versions not comparable",
}

# Environment variables used as tokens without further configuration
DEFAULT_TOKEN_ENV = (("github\\.com", "GITHUB_TOKEN"), ("gitlab\\.com", "GITLAB_TOKEN"))


@dataclass
class Item:
    name: str
    installed: str                                  # version the update is compared against
    download_url: str = ""
    versions: dict = field(default_factory=dict)    # every version on the site -> state
    result: Result | None = None

    @property
    def release(self):
        return self.result.release if self.result else None

    @property
    def status(self) -> str:
        return self.result.status if self.result else ""


# --------------------------------------------------------------------------- #
# selection
# --------------------------------------------------------------------------- #

def build_items(packages: list, includes: list, excludes: list, include_disabled: bool) -> list:
    by_name: dict = {}
    for pkg in packages:
        by_name.setdefault(pkg.name, {})[pkg.version] = pkg

    items = []
    for name in sorted(by_name):
        if includes and not any(p.fullmatch(name) for p in includes):
            continue
        if any(p.fullmatch(name) for p in excludes):
            continue
        versions = by_name[name]
        enabled = [v for v, p in versions.items() if p.state != STATE_DISABLED]
        if not enabled and not include_disabled:
            continue
        installed = max(enabled or list(versions), key=package_version_key)
        items.append(Item(name=name, installed=installed, download_url=versions[installed].download_url,
                          versions={v: p.state for v, p in versions.items()}))
    return items


# --------------------------------------------------------------------------- #
# installation
# --------------------------------------------------------------------------- #

def ask(prompt: str) -> str:
    """Returns 'y', 'n', 'a' (yes to all) or 'q' (quit)."""
    try:
        answer = input(f"{prompt} [y/N/a(ll)/q(uit)]: ").strip().lower()
    except EOFError:
        return "n"
    return {"y": "y", "yes": "y", "a": "a", "all": "a", "q": "q", "quit": "q"}.get(answer, "n")


def download_auth(item: Item, url: str, credentials: list) -> dict:
    """The provider's auth header for a release asset on the same host as the token."""
    host = urllib.parse.urlsplit(url).hostname or ""
    token = sources.pick_credential(host, credentials)
    if not token:
        return {}
    return {
        "gitlab": {"PRIVATE-TOKEN": token},
        "github": {"Authorization": f"Bearer {token}"},
        "gitea": {"Authorization": f"token {token}"},
    }.get(item.result.provider or "", {})


def install(item: Item, version: str, path: str | None, mkp_bin: str, keep_old: bool,
            log_file: str, colors) -> bool:
    if version not in item.versions:
        run_mkp(["add", path], mkp_bin)
        log_line(log_file, f"add {item.name} {version} from {item.release.url}")
    run_mkp(["enable", item.name, version], mkp_bin)
    log_line(log_file, f"enable {item.name} {version}")

    states = {p.version: p.state for p in fetch_packages(mkp_bin) if p.name == item.name}
    if states.get(version) != STATE_ACTIVE:
        print(colors.yellow(f"  {item.name} {version} is enabled but not active on this site "
                            f"({states.get(version, 'unknown state')}) - leaving the old version alone"))
        return False
    print(colors.green(f"  installed {item.name} {version} (active on this site)"))

    if keep_old:
        return True
    for old, state in states.items():
        if old != version and state != STATE_DISABLED:
            try:
                run_mkp(["disable", item.name, old], mkp_bin)
                print(colors.dim(f"  disabled previous version {item.name} {old}"))
                log_line(log_file, f"disable {item.name} {old} (superseded by {version})")
            except MkpError as exc:
                print(colors.yellow(f"  could not disable previous version {old}: {exc}"))
    return True


def default_download_dir() -> str:
    omd_root = os.environ.get("OMD_ROOT")
    return os.path.join(omd_root, "tmp", PROG) if omd_root else os.path.join(tempfile.gettempdir(), PROG)


def run_updates(args, items: list, mkp_bin: str, http: Http, credentials: list,
                current: str | None, log_file: str, colors) -> int:
    pending = [i for i in items if i.status == STATUS_UPDATE_AVAILABLE]
    if not pending:
        print("\nall selected packages are up to date - nothing to do.")
        return 0

    auto = args.auto_install
    installed = downloaded = failed = skipped = 0
    aborted = False
    log_line(log_file, f"run: mode={'download-only' if args.download_only else 'update'} "
                       f"auto_install={auto} dry_run={args.dry_run} pending="
                       f"{[(i.name, i.installed, i.release.version) for i in pending]}")

    for item in pending:
        rel = item.release
        print(f"\n{colors.bold(item.name)}: {item.installed} -> {colors.green(rel.version)}  "
              f"{colors.dim(f'({item.result.source})')}")
        already_on_site = rel.version in item.versions
        if not already_on_site and not rel.url:
            print(colors.yellow(f"  skipped: the source has no downloadable "
                                f"{item.name}-<version>.mkp for {rel.version} "
                                f"(see {rel.page_url or item.result.source.url})"))
            skipped += 1
            continue
        if args.dry_run:
            if not already_on_site:
                print(colors.dim(f"  [dry-run] would download {rel.url}"))
            if not args.download_only:
                print(colors.dim(f"  [dry-run] would install (mkp add + enable) {item.name} {rel.version}"))
            log_line(log_file, f"[DRY-RUN] {item.name} {item.installed} -> {rel.version} from {rel.url}")
            continue

        path = None
        version = rel.version
        if already_on_site:
            print(colors.dim(f"  {item.name} {rel.version} is already on the site "
                             f"({item.versions[rel.version]}) - no download needed"))
        else:
            os.makedirs(args.download_dir, exist_ok=True)
            path = os.path.join(args.download_dir,
                                safe_filename(rel.filename or f"{item.name}-{rel.version}.mkp"))
            try:
                http.download(rel.url, path, auth=download_auth(item, rel.url, credentials))
                version = verify_download(path, item.name, item.installed, current, rel.md5)
            except SourceError as exc:
                print(colors.red(f"  download/verification failed: {sources.redact_credentials(str(exc))}"))
                log_line(log_file, f"FAILED download {item.name} {rel.version} from {rel.url}: {exc}")
                if os.path.exists(path):
                    os.unlink(path)
                failed += 1
                continue
            downloaded += 1
            print(f"  downloaded and verified: {path}")
            log_line(log_file, f"download {item.name} {version} from {rel.url} -> {path}")

        if args.download_only:
            continue

        if not auto:
            answer = ask(f"  install {item.name} {version} (currently {item.installed})?")
            if answer == "q":
                print("aborted.")
                aborted = True
                break
            if answer == "a":
                auto = True
            elif answer != "y":
                print(colors.dim("  skipped."))
                skipped += 1
                continue

        try:
            if install(item, version, path, mkp_bin, args.keep_old_enabled, log_file, colors):
                installed += 1
            else:
                failed += 1
        except MkpError as exc:
            print(colors.red(f"  FAILED to install {item.name} {version}: {exc}"))
            log_line(log_file, f"FAILED install {item.name} {version}: {exc}")
            failed += 1

    print(f"\n{colors.bold('summary')}: downloaded={downloaded} installed={installed} "
          f"skipped={skipped} failed={failed}{' (dry-run)' if args.dry_run else ''}")
    if installed:
        print(colors.dim("hint: activate pending changes (GUI or `cmk -R`) and, for GUI extensions, "
                         "reload Apache (`omd reload apache`) for new versions to take effect. "
                         "`mkp-cleanup --list` shows older versions that are left over."))
    if failed:
        return 2
    return 1 if aborted else 0


# --------------------------------------------------------------------------- #
# output
# --------------------------------------------------------------------------- #

def item_rows(items: list) -> list:
    rows = []
    for item in items:
        result = item.result
        detail = result.message or (str(result.source) if result.source else "")
        rows.append((item.name, item.installed, item.release.version if item.release else "-",
                     STATUS_LABELS.get(item.status, item.status), detail))
    return rows


def run_json(items: list) -> None:
    print(json.dumps([
        {
            "name": i.name,
            "installed": i.installed,
            "available": i.release.version if i.release else None,
            "status": i.status,
            "provider": i.result.provider,
            "source": {"url": i.result.source.url, "origin": i.result.source.origin}
            if i.result.source else None,
            "download_url": i.release.url if i.release else None,
            "release_page": i.release.page_url if i.release else None,
            "message": i.result.message or None,
        }
        for i in items
    ], indent=2))


def run_nagios(items: list) -> int:
    updates = [i for i in items if i.status == STATUS_UPDATE_AVAILABLE]
    errors = [i for i in items if i.status == STATUS_ERROR]
    perf = f"| mkp_updates={len(updates)} mkp_update_errors={len(errors)}"
    if updates:
        listed = ", ".join(f"{i.name} {i.installed}->{i.release.version}" for i in updates[:10])
        more = f" (+{len(updates) - 10} more)" if len(updates) > 10 else ""
        print(f"WARNING - {len(updates)} MKP update(s) available: {listed}{more} {perf}")
        return 1
    if errors:
        print(f"UNKNOWN - update lookup failed for: {', '.join(i.name for i in errors)} {perf}")
        return 3
    print(f"OK - all {len(items)} checked MKP(s) up to date {perf}")
    return 0


# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #

def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog=PROG,
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
package selection:
  PACKAGE arguments and --exclude are matched against the whole package name;
  regular expressions are allowed ('foo' matches only foo, 'foo.*' every package
  starting with foo).

repositories: a local directory (searched recursively, e.g. an NFS mount or a git
checkout - hidden dirs like .git are skipped; name/version are read from each
.mkp's manifest) or an http(s) directory listing (nginx/apache autoindex) whose
links are named <package_name>-<version>.mkp.

sources file ($OMD_ROOT/etc/mkp-update.sources), also read by the MKP Updates
special agent:
  @repo           /srv/checkmk-mkps/released   # repository (repeatable)
  @repo           https://intranet.example.com/mkps/
  @offline                                     # never ask the internet
  my_plugin       https://gitlab.com/group/checkmk_my_plugin
  other_plugin    https://github.com/owner/repo
  internal_one    https://intranet.example.com/mkps/internal_one-2.0.0.mkp
  self_hosted     gitea:https://git.example.com/owner/repo
  frozen_plugin   -          # never look for updates of this one

credentials: GITHUB_TOKEN and GITLAB_TOKEN are used for github.com/gitlab.com;
for other hosts: --credential 'git\\.example\\.com=MY_TOKEN_VAR' (the token is
read from that environment variable, never from the command line).

examples:
  mkp-update                                   # which updates are available?
  mkp-update --nagios                          # monitoring/cron gate
  mkp-update --update                          # install, asking per package
  mkp-update --auto-install 'checkmk_.*' --exclude checkmk_tapo
  mkp-update --offline --repo /srv/checkmk-mkps/released --update
  mkp-update --download-only --download-dir ~/mkp-downloads
""",
    )
    parser.add_argument("packages", nargs="*", metavar="PACKAGE",
                        help="only these packages (regex, full name match; default: all)")

    mode = parser.add_mutually_exclusive_group()
    mode.add_argument("--check", action="store_true",
                      help="only show which updates are available (default, read-only)")
    mode.add_argument("--update", action="store_true",
                      help="download available updates and install them after asking per package")
    mode.add_argument("--download-only", action="store_true",
                      help="only download (and verify) available updates, don't install them")
    parser.add_argument("-a", "--auto-install", "-y", "--yes", dest="auto_install", action="store_true",
                        help="install updates without asking (implies --update)")

    parser.add_argument("-x", "--exclude", action="append", default=[], metavar="PATTERN",
                        help="skip packages matching this regex (repeatable)")
    parser.add_argument("-i", "--ignore-case", action="store_true",
                        help="match PACKAGE/--exclude patterns case-insensitively")
    parser.add_argument("--include-disabled", action="store_true",
                        help="also check packages that are installed but not enabled at all")
    parser.add_argument("--repo", action="append", default=[], metavar="DIR_OR_URL",
                        help="package repository for all packages: a directory (searched "
                             "recursively) or an http(s) directory listing (repeatable)")
    parser.add_argument("--offline", action="store_true",
                        help="no internet lookups (Download-URL, Checkmk Exchange): only "
                             "repositories and explicit per-package sources")
    parser.add_argument("--source", action="append", default=[], metavar="NAME=URL",
                        help="update source for one package (repeatable)")
    parser.add_argument("--sources-file", metavar="PATH", default=None,
                        help="default: $OMD_ROOT/etc/mkp-update.sources")
    parser.add_argument("--no-exchange", "--no-exchange-fallback", dest="no_exchange", action="store_true",
                        help="don't use the Checkmk Exchange catalog as a fallback")
    parser.add_argument("--include-prereleases", action="store_true",
                        help="also consider releases marked as pre-release by GitHub/GitLab/Gitea")
    parser.add_argument("--credential", action="append", default=[], metavar="HOST_REGEX=ENV_VAR",
                        help="use the token in environment variable ENV_VAR for hosts fully "
                             "matching HOST_REGEX (repeatable)")
    parser.add_argument("--insecure", action="store_true",
                        help="don't verify TLS certificates (internal servers with self-signed certificates)")
    parser.add_argument("--keep-old-enabled", action="store_true",
                        help="after installing an update, leave the previous version enabled")
    parser.add_argument("--download-dir", metavar="DIR", default=None,
                        help=f"where downloaded .mkp files are stored (default: {default_download_dir()})")
    parser.add_argument("--dry-run", action="store_true",
                        help="look up updates, but don't download or install anything")
    parser.add_argument("--timeout", type=float, default=30, metavar="SECONDS",
                        help="network timeout per request (default: 30)")
    parser.add_argument("-j", "--jobs", type=int, default=4, metavar="N",
                        help="parallel update lookups (default: 4)")
    parser.add_argument("--json", action="store_true", help="machine-readable JSON output (check mode only)")
    parser.add_argument("--nagios", action="store_true",
                        help="OK/WARNING/UNKNOWN one-liner + exit code 0/1/3 (check mode only)")
    parser.add_argument("--log-file", metavar="PATH", default=None,
                        help=f"action log (default: {cli.default_log_path(PROG)})")
    parser.add_argument("--no-color", action="store_true", help="disable colored output")
    parser.add_argument("--skip-site-check", action="store_true",
                        help="skip the OMD_SITE/OMD_ROOT check (for testing)")
    parser.add_argument("--version", action="version", version=f"{PROG} {VERSION}")
    return parser


def compile_patterns(parser, patterns: list, flags: int, what: str) -> list:
    compiled = []
    for pattern in patterns:
        try:
            compiled.append(re.compile(pattern, flags))
        except re.error as exc:
            parser.error(f"invalid {what} regex {pattern!r}: {exc}")
    return compiled


def build_credentials(parser, specs: list) -> list:
    pairs = list(DEFAULT_TOKEN_ENV)
    for spec in specs:
        host, sep, env = spec.partition("=")
        if not sep or not host or not env:
            parser.error(f"--credential expects HOST_REGEX=ENV_VAR, got {spec!r}")
        pairs.insert(0, (host, env))  # explicit ones first: the first match wins
    credentials = []
    for host, env in pairs:
        token = os.environ.get(env)
        if token:
            try:
                credentials.append((re.compile(host), token))
            except re.error as exc:
                parser.error(f"invalid --credential host regex {host!r}: {exc}")
    return credentials


def main(argv=None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    if args.auto_install and args.download_only:
        parser.error("--auto-install and --download-only are mutually exclusive")
    if args.auto_install:
        args.update = True
    check_mode = not (args.update or args.download_only)
    if (args.json or args.nagios) and not check_mode:
        parser.error("--json/--nagios are only valid in check mode")
    if args.json and args.nagios:
        parser.error("--nagios and --json are mutually exclusive")
    if args.jobs < 1:
        parser.error("--jobs must be >= 1")

    flags = re.IGNORECASE if args.ignore_case else 0
    includes = compile_patterns(parser, args.packages, flags, "PACKAGE")
    excludes = compile_patterns(parser, args.exclude, flags, "--exclude")
    credentials = build_credentials(parser, args.credential)

    colors = Colors(enabled=(not args.no_color) and sys.stdout.isatty() and not args.json and not args.nagios)
    log_file = args.log_file or cli.default_log_path(PROG)
    args.download_dir = args.download_dir or default_download_dir()

    try:
        check_site_context(args.skip_site_check)
        sources_file = args.sources_file or sources.default_sources_file()
        config = sources.load_sources_file(sources_file) if sources_file else Config()
        for spec in args.source:
            name, sep, url = spec.partition("=")
            if not sep or not name or not url:
                parser.error(f"--source expects NAME=URL, got {spec!r}")
            config.packages[name] = url
        config.repos.extend(args.repo)
        config.offline = config.offline or args.offline
        mkp_bin = find_mkp_binary()
        packages = fetch_packages(mkp_bin)
    except MkpError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    current = get_current_cmk_version()
    http = Http(timeout=args.timeout, verify_tls=not args.insecure)
    items = build_items(packages, includes, excludes, args.include_disabled)
    resolver = Resolver(http, config=config, credentials=credentials, current=current,
                        include_prereleases=args.include_prereleases,
                        exchange_fallback=not args.no_exchange, exchange_max_age=EXCHANGE_MAX_AGE,
                        verify_tls=not args.insecure)
    for error in (resolver.repo_index.errors if resolver.repo_index else []):
        print(f"warning: {error}", file=sys.stderr)

    def resolve(item: Item) -> Item:
        item.result = resolver.resolve(item.name, item.installed, item.download_url)
        return item

    with ThreadPoolExecutor(max_workers=args.jobs) as pool:
        items = list(pool.map(resolve, items))

    if args.nagios:
        return run_nagios(items)
    if args.json:
        run_json(items)
        return 2 if any(i.status == STATUS_ERROR for i in items) else 0

    updates = sum(1 for i in items if i.status == STATUS_UPDATE_AVAILABLE)
    print(colors.bold(f"{PROG} - current Checkmk version: {current or 'unknown'} - "
                      f"{len(items)} package(s) checked, {updates} update(s) available"))
    print_table(["Name", "Installed", "Available", "Status", "Source / details"], item_rows(items))

    if check_mode:
        if updates:
            print(colors.dim("\nrun with --update to download and install them "
                             "(or --auto-install to skip the confirmation)."))
        return 2 if any(i.status == STATUS_ERROR for i in items) else 0
    return run_updates(args, items, mkp_bin, http, credentials, current, log_file, colors)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print("\naborted.", file=sys.stderr)
        sys.exit(130)
