#!/usr/bin/env python3
"""Check Ansible playbook packages against official Arch repos and AUR.

Finds packages that have:
- Moved from official repos to AUR (or disappeared entirely)
- Moved from AUR to official repos (or disappeared entirely)
"""

import argparse
import json
import re
import subprocess
import sys
import tarfile
import time
import urllib.parse
import urllib.request
from pathlib import Path

import yaml

warnings = []

DB_DIR = Path("/var/lib/pacman/sync")
DB_STALE_HOURS = 24


# ------------------------------------------------------------------ variables


def load_variables(playbook_dir):
    """Load variables from group_vars/all."""
    vars_file = playbook_dir / "group_vars" / "all"
    if not vars_file.exists():
        return {}
    with open(vars_file) as f:
        return yaml.safe_load(f) or {}


def resolve_var(value, variables):
    """Resolve a Jinja2 variable reference like '{{ var }}' or '{{ dict.key }}'.

    Returns a list of package names, or None if the variable cannot be resolved.
    """
    match = re.fullmatch(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}", value.strip())
    if not match:
        return None

    node = variables
    for key in match.group(1).split("."):
        if isinstance(node, dict) and key in node:
            node = node[key]
        else:
            return None

    if isinstance(node, list):
        return [str(p) for p in node]
    if isinstance(node, str):
        return [node]
    return None


# ------------------------------------------------------------------ collection


def extract_pkg_names(module_args, variables, source_file, playbook_dir):
    """Extract package name(s) from a pacman/aur module argument dict."""
    if not isinstance(module_args, dict) or "name" not in module_args:
        return []

    name = module_args["name"]

    if isinstance(name, list):
        return [str(n) for n in name]

    if isinstance(name, str):
        if "{{" in name:
            resolved = resolve_var(name, variables)
            if resolved is None:
                try:
                    rel = source_file.relative_to(playbook_dir)
                except ValueError:
                    rel = source_file
                warnings.append(f"cannot resolve '{name}' in {rel}")
                return []
            return resolved
        return [name]

    return []


def collect_packages(playbook_dir, variables):
    """Walk all role task files and collect official and AUR packages."""
    official = {}  # pkg -> first source file
    aur = {}

    task_files = sorted(playbook_dir.rglob("roles/*/tasks/*.yml"))
    for task_file in task_files:
        try:
            with open(task_file) as f:
                tasks = yaml.safe_load(f)
        except Exception as e:
            warnings.append(f"could not parse {task_file}: {e}")
            continue

        if not isinstance(tasks, list):
            continue

        for task in tasks:
            if not isinstance(task, dict):
                continue

            for module_key, target in (
                ("community.general.pacman", official),
                ("kewlfft.aur.aur", aur),
            ):
                if module_key in task:
                    module_args = task[module_key]
                    # Skip removal tasks — we only care about packages being installed
                    if isinstance(module_args, dict):
                        state = module_args.get("state", "present")
                        if state in ("absent", "removed"):
                            continue
                    pkgs = extract_pkg_names(
                        module_args, variables, task_file, playbook_dir
                    )
                    for pkg in pkgs:
                        if pkg not in target:
                            target[pkg] = task_file

    return official, aur


# ------------------------------------------------------------------ pacman


def check_db_freshness():
    """Warn if the sync DBs haven't been refreshed recently."""
    cutoff = time.time() - DB_STALE_HOURS * 3600
    oldest = min(DB_DIR.glob("*.db"), key=lambda p: p.stat().st_mtime, default=None)
    if oldest and oldest.stat().st_mtime < cutoff:
        age_h = (time.time() - oldest.stat().st_mtime) / 3600
        warnings.append(
            f"sync DB is {age_h:.0f}h old — run 'sudo pacman -Sy' or pass --refresh"
        )


def refresh_db():
    """Refresh pacman sync databases."""
    print("Refreshing package databases (sudo pacman -Sy)...")
    result = subprocess.run(["sudo", "pacman", "-Sy"])
    if result.returncode != 0:
        sys.exit("error: failed to refresh package databases")


def pacman_lookup(packages):
    """Check packages against the official repos using pacman -Si and -Sg.

    Package groups are considered valid (community.general.pacman installs them).
    Returns (found: set, not_found: set).
    """
    if not packages:
        return set(), set()

    result = subprocess.run(
        ["pacman", "-Si", "--", *packages],
        capture_output=True,
        text=True,
    )

    found = set()
    for line in result.stdout.splitlines():
        if line.startswith("Name"):
            found.add(line.split(":", 1)[1].strip())

    # For packages -Si didn't find, check if they're valid package groups
    not_found = set(packages) - found
    for pkg in not_found:
        r = subprocess.run(["pacman", "-Sg", pkg], capture_output=True)
        if r.returncode == 0:
            found.add(pkg)

    return found, not_found - found


def build_replaces_map():
    """Build a reverse map from old package names to new names via %REPLACES%.

    Reads the pacman sync DB tarballs directly — no subprocess needed.
    Returns {old_name: new_package_name}.
    """
    reverse = {}
    for db_file in sorted(DB_DIR.glob("*.db")):
        try:
            with tarfile.open(db_file, "r:gz") as tar:
                for member in tar.getmembers():
                    if not member.name.endswith("/desc"):
                        continue
                    f = tar.extractfile(member)
                    if f is None:
                        continue
                    name = None
                    field = None
                    replaces = []
                    for raw in f.read().decode().splitlines():
                        line = raw.strip()
                        if line.startswith("%") and line.endswith("%"):
                            field = line[1:-1]
                        elif field == "NAME" and line:
                            name = line
                        elif field == "REPLACES" and line:
                            replaces.append(line)
                    if name:
                        for entry in replaces:
                            old = re.split(r"[<>=!]", entry)[0].strip()
                            if old and old not in reverse:
                                reverse[old] = name
        except Exception as e:
            warnings.append(f"could not read {db_file}: {e}")
    return reverse


# ------------------------------------------------------------------ AUR


def aur_lookup(packages, batch_size=200):
    """Query the AUR RPC API for a set of packages.

    Returns (found: set, not_found: set).
    """
    if not packages:
        return set(), set()

    found = set()
    pkg_list = list(packages)

    for i in range(0, len(pkg_list), batch_size):
        chunk = pkg_list[i : i + batch_size]
        query = "&".join("arg[]=" + urllib.parse.quote(p, safe="") for p in chunk)
        url = f"https://aur.archlinux.org/rpc/v5/info?{query}"
        try:
            with urllib.request.urlopen(url, timeout=30) as resp:
                data = json.loads(resp.read())
            for result in data.get("results", []):
                found.add(result["Name"])
        except Exception as e:
            warnings.append(f"AUR RPC request failed: {e}")

    return found, set(pkg_list) - found


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


def fmt_source(path, playbook_dir):
    try:
        return str(path.relative_to(playbook_dir))
    except ValueError:
        return str(path)


# ------------------------------------------------------------------ main


def main():
    parser = argparse.ArgumentParser(
        description="Check Ansible playbook packages against official Arch repos and AUR."
    )
    parser.add_argument(
        "playbook_dir",
        nargs="?",
        default=".",
        help="path to the Ansible playbook directory (default: current directory)",
    )
    parser.add_argument(
        "-y",
        "--refresh",
        action="store_true",
        help="refresh pacman sync databases before checking (runs sudo pacman -Sy)",
    )
    args = parser.parse_args()

    playbook_dir = Path(args.playbook_dir).resolve()
    if not (playbook_dir / "group_vars").exists():
        sys.exit(
            f"error: {playbook_dir} does not look like an Ansible playbook directory"
        )

    if args.refresh:
        refresh_db()
    else:
        check_db_freshness()

    print("Loading variables...")
    variables = load_variables(playbook_dir)

    print("Scanning task files...")
    official, aur = collect_packages(playbook_dir, variables)
    print(f"  found {len(official)} official packages, {len(aur)} AUR packages")

    print("\nBuilding package rename map from sync DB...")
    replaces_map = build_replaces_map()
    print(f"  {len(replaces_map)} Replaces entries indexed")

    print("\nChecking official repo packages against pacman...")
    ok_official, missing_official = pacman_lookup(set(official))
    print(f"  {len(ok_official)} OK, {len(missing_official)} not found")

    renamed_official = {}
    still_missing_official = set()
    for pkg in missing_official:
        if pkg in replaces_map:
            renamed_official[pkg] = replaces_map[pkg]
        else:
            still_missing_official.add(pkg)
    if renamed_official:
        print(f"  {len(renamed_official)} identified as renamed via Replaces metadata")

    moved_to_aur = set()
    if still_missing_official:
        print(
            f"  cross-checking {len(still_missing_official)} remaining missing packages against AUR..."
        )
        moved_to_aur, _ = aur_lookup(still_missing_official)

    print("\nChecking AUR packages against AUR RPC...")
    ok_aur, missing_aur = aur_lookup(set(aur))
    print(f"  {len(ok_aur)} OK, {len(missing_aur)} not found")

    moved_to_official = set()
    if missing_aur:
        print(
            f"  cross-checking {len(missing_aur)} missing AUR packages against pacman..."
        )
        moved_to_official, _ = pacman_lookup(missing_aur)

    # ------------------------------------------------------------------ report
    any_issue = missing_official or missing_aur

    print()
    print("=" * 60)
    print("RESULTS")
    print("=" * 60)

    if not any_issue:
        print("All packages are good — nothing to fix.")
    else:
        if missing_official:
            print()
            print("Official packages no longer in official repos:")
            for pkg in sorted(missing_official):
                src = fmt_source(official[pkg], playbook_dir)
                if pkg in renamed_official:
                    status = f"renamed to {renamed_official[pkg]}"
                elif pkg in moved_to_aur:
                    status = "moved to AUR"
                else:
                    status = "not found anywhere"
                print(f"  {pkg:<40}  [{status}]")
                print(f"    {src}")

        if missing_aur:
            print()
            print("AUR packages no longer in AUR:")
            for pkg in sorted(missing_aur):
                src = fmt_source(aur[pkg], playbook_dir)
                status = (
                    "moved to official repos"
                    if pkg in moved_to_official
                    else "not found anywhere"
                )
                print(f"  {pkg:<40}  [{status}]")
                print(f"    {src}")

    if warnings:
        print()
        print("Warnings:")
        for w in warnings:
            print(f"  {w}")


if __name__ == "__main__":
    main()
