#!/usr/bin/env python3
# Copyright (C) 2026 Christian Wirtz <doc@snowheaven.de>
# License: GPL-2.0-only, see LICENSE in the repository root.
"""Special agent: MKP Updates

Lists all MKP extension packages installed on this Checkmk site (`mkp list --json`) and
finds the newest available version of each - with exactly the same lookup as the
mkp-update command line tool (cmk_addons.plugins.mkp_updates.lib.sources), and the
same sources file ($OMD_ROOT/etc/mkp-update.sources) unless --no-sources-file is given:

  1. an explicit per-package source from the sources file
  2. package repositories (sources file '@repo', or --repo from the rule)
  3. unless offline: the package's Download-URL - GitHub, GitLab, Gitea (REST APIs) or
     any other git server (`git ls-remote --tags`), the provider of an unknown host
     detected by probing it
  4. unless offline or --no-exchange: the Checkmk Exchange catalog (undocumented and
     unofficial, cached and re-fetched at most once a day, regardless of how often
     this agent runs)

The output is a single JSON document in one agent section, parsed by the mkp_updates
check plug-in.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
from pathlib import Path

from cmk_addons.plugins.mkp_updates.lib import sources
from cmk_addons.plugins.mkp_updates.lib.http import Http
from cmk_addons.plugins.mkp_updates.lib.packages import (
    STATE_ACTIVE,
    STATE_INACTIVE,
    MkpError,
    get_current_cmk_version,
    parse_mkp_list_json,
    run_mkp,
)
from cmk_addons.plugins.mkp_updates.lib.versions import package_version_key

AGENT_SECTION_NAME = "mkp_updates"
USER_AGENT = "checkmk-mkp-updates-special-agent (+https://gitlab.thatsallmine.de/checkmk-extensions-private/checkmk_mkp-maintenance)"


def parse_arguments(argv: list) -> argparse.Namespace:
    parser = argparse.ArgumentParser("agent_mkp_updates", description=__doc__)
    parser.add_argument("--timeout", type=float, default=10.0,
                        help="Per-request network timeout in seconds (default: 10).")
    parser.add_argument("--include-prereleases", action="store_true",
                        help="Consider releases marked as pre-release.")
    parser.add_argument("--insecure", action="store_true",
                        help="Disable TLS certificate verification (HTTPS and git).")
    parser.add_argument("--no-exchange", action="store_true",
                        help="Disable the Checkmk Exchange fallback.")
    parser.add_argument("--repo", action="append", default=[], metavar="DIR_OR_URL",
                        help="Package repository, in addition to '@repo' lines of the sources file.")
    parser.add_argument("--offline", action="store_true",
                        help="No internet lookups (Download-URL, Checkmk Exchange).")
    parser.add_argument("--no-sources-file", action="store_true",
                        help="Ignore $OMD_ROOT/etc/mkp-update.sources.")
    parser.add_argument("--sources-file", type=Path, default=None, help=argparse.SUPPRESS)
    parser.add_argument("--show-status", action="append", default=[], dest="show_statuses",
                        metavar="STATUS",
                        help="Only show packages with this status in the service details "
                             "(passed by the rule; every package is still checked and counted).")
    parser.add_argument("--credential-host", action="append", default=[], dest="credential_hosts",
                        metavar="HOST_PATTERN",
                        help="Host regex, paired by position with --credential-token-id.")
    parser.add_argument("--credential-token-id", action="append", default=[], dest="credential_token_ids",
                        metavar="PASSWORD_STORE_REFERENCE",
                        help="Password store reference of the token for the paired host pattern "
                             "(resolved here, so the token never appears on the command line).")
    parser.add_argument("--mkp-json", type=Path, default=None,
                        help=argparse.SUPPRESS)  # debugging aid: `mkp list --json` output from a file
    return parser.parse_args(argv)


def resolve_credentials(host_patterns: list, token_ids: list) -> list:
    """(compiled host pattern, token) pairs. cmk.password_store is imported lazily: it
    exists in every real (Checkmk >= 2.5.0 site) invocation, but not in unit tests."""
    if len(host_patterns) != len(token_ids):
        raise SystemExit("--credential-host and --credential-token-id must be given the same number "
                         f"of times (got {len(host_patterns)} and {len(token_ids)}).")
    if not host_patterns:
        return []
    try:
        from cmk.password_store.v1_unstable import dereference_secret
    except ImportError as exc:
        raise SystemExit("Got --credential-token-id, but 'cmk.password_store' (Checkmk >= 2.5.0) "
                         f"is not available: {exc}") from exc
    credentials = []
    for host_pattern, token_id in zip(host_patterns, token_ids):
        try:
            pattern = re.compile(host_pattern)
        except re.error as exc:
            raise SystemExit(f"Invalid --credential-host pattern {host_pattern!r}: {exc}") from exc
        try:
            token = dereference_secret(token_id).reveal()
        except Exception as exc:  # noqa: BLE001
            raise SystemExit(f"Could not resolve --credential-token-id for {host_pattern!r}: {exc}") from exc
        credentials.append((pattern, token))
    return credentials


def load_local_packages(mkp_json_override: Path | None) -> tuple:
    """One entry per package name (packages, errors). Of several stored versions, the
    active one is reported, else an enabled-but-inactive one, else the highest - an old
    disabled leftover doesn't need its own update check (and network requests)."""
    try:
        if mkp_json_override is not None:
            raw = mkp_json_override.read_text()
        else:
            omd_root = os.environ.get("OMD_ROOT")
            if not omd_root:
                raise SystemExit("OMD_ROOT is not set -- this agent must run inside a Checkmk site.")
            raw = run_mkp(["list", "--json"], str(Path(omd_root) / "bin" / "mkp"))
        stored = parse_mkp_list_json(raw)
    except (OSError, MkpError) as exc:
        return [], [str(exc)]

    by_name: dict = {}
    for pkg in stored:
        by_name.setdefault(pkg.name, []).append(pkg)

    rank = {STATE_ACTIVE: 0, STATE_INACTIVE: 1}
    packages = []
    for name, versions in by_name.items():
        best_rank = min(rank.get(p.state, 2) for p in versions)
        pkg = max((p for p in versions if rank.get(p.state, 2) == best_rank),
                  key=lambda p: package_version_key(p.version))
        packages.append({
            "name": name,
            "title": pkg.title or name,
            "installed_version": pkg.version,
            "author": pkg.author,
            "download_url": pkg.download_url,
            "state": {0: "enabled_active", 1: "enabled_inactive"}.get(best_rank, "disabled"),
        })
    return packages, []


def result_fields(pkg: dict, result: sources.Result) -> dict:
    release = result.release
    fields = {
        "provider": result.provider,
        "provider_detail": result.provider_detail,
        "latest_version": release.version if release else None,
        "release_url": (release.page_url or release.url) if release else None,
        "status": result.status,
        "message": sources.redact_credentials(result.message),
    }
    # Packages without a Download-URL of their own get the link of where the version
    # was found, so the service details still point somewhere useful.
    if release and not pkg.get("download_url") and result.provider in ("exchange", "repo"):
        fields["download_url"] = release.url
    return fields


def main(argv: list | None = None) -> int:
    args = parse_arguments(sys.argv[1:] if argv is None else argv)
    credentials = resolve_credentials(args.credential_hosts, args.credential_token_ids)
    packages, errors = load_local_packages(args.mkp_json)

    config = sources.Config()
    if not args.no_sources_file:
        path = args.sources_file or sources.default_sources_file()
        if path:
            try:
                config = sources.load_sources_file(str(path))
            except MkpError as exc:
                errors.append(str(exc))
    config.repos.extend(args.repo)
    config.offline = config.offline or args.offline

    http = Http(timeout=args.timeout, verify_tls=not args.insecure, user_agent=USER_AGENT)
    resolver = sources.Resolver(http, config=config, credentials=credentials,
                                current=get_current_cmk_version(),
                                include_prereleases=args.include_prereleases,
                                exchange_fallback=not args.no_exchange, verify_tls=not args.insecure)
    if resolver.repo_index:
        errors.extend(resolver.repo_index.errors)
    if resolver.exchange_fallback and resolver.exchange.packages() is None:
        errors.append("Could not fetch the Checkmk Exchange package catalog -- the Exchange "
                      "fallback is skipped for this run; other sources are unaffected.")

    results = []
    for pkg in packages:
        result = resolver.resolve(pkg["name"], pkg["installed_version"], pkg["download_url"])
        results.append({**pkg, **result_fields(pkg, result)})

    payload = {
        "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
        "site": os.environ.get("OMD_SITE", ""),
        "packages": results,
        "errors": errors,
        # Which status groups the rule wants in the service details. Filtering happens only
        # when rendering, so summary and state always reflect all packages. None = all.
        "show_statuses": args.show_statuses or None,
    }
    print(f"<<<{AGENT_SECTION_NAME}:sep(0)>>>")
    print(json.dumps(payload))
    return 0


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