#!/usr/bin/env python3
"""Generate and run nvchecker for archinstoo's tracked packages.

Tracks (into nvchecker.toml) the Arch packages archinstoo touches:
- Direct dependencies (PKGBUILD depends)
- Optional dependencies (PKGBUILD optdepends)
- All installable packages (schema.jsonc)

Subcommands:
    ./NVGEN gen            write nvchecker.toml in place (default; --stdout to pipe)
    ./NVGEN check          gen + nvchecker + nvcmp, print what's new
                           (exits if the sync DB is stale; pass --force to skip)
    ./NVGEN take --all     promote new_ver.json into old_ver.json (ack everything)
    ./NVGEN take PKG...    ack only the named packages

How nvchecker works: pacman entries read the LOCAL sync db, so results are only
as fresh as the last `pacman -Sy`. `check` guards on this via checkupdates and
exits asking you to refresh rather than running sudo itself.
"""

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import tarfile
from pathlib import Path

# desc field block: `%KEY%` then its (possibly multi-line) value until a blank line
_DESC_FIELD_RE = re.compile(r'%([A-Z0-9]+)%\n((?:.+\n)*)')

NVDIR = Path(__file__).parent
ROOT = NVDIR.parent  # nvchecker/ -> repo root
TOML = NVDIR / 'nvchecker.toml'
SCHEMA_PATH = ROOT / 'installer' / 'archinstoo' / 'schema.jsonc'
PKGBUILD_DEV = ROOT / 'PKGBUILD'
PKGBUILD_REL = ROOT / 'installer' / 'PKGBUILD'

def log(msg: str) -> None:
	# progress to stderr so `gen --stdout` stays pipe-clean
	print(msg, file=sys.stderr)


def _require(binary: str) -> None:
	if not shutil.which(binary):
		sys.exit(f'error: {binary} not found; install with `pacman -S nvchecker`')


def _needs_update() -> bool:
	# True if the local sync DB is behind the mirrors, in which case nvchecker would
	# compare against stale "latest" versions. checkupdates (pacman-contrib) refreshes
	# a temp DB copy WITHOUT root: exit 0 = updates pending, 2 = up to date, anything
	# else = couldn't tell (don't block). we report rather than run `pacman -Sy`.
	if not shutil.which('checkupdates'):
		log('checkupdates not found (pacman-contrib); skipping freshness guard')
		return False
	proc = subprocess.run(['checkupdates'], capture_output=True, text=True)
	if proc.returncode == 0:
		pending = len([ln for ln in proc.stdout.splitlines() if ln.strip()])
		log(f'{pending} pending update(s): local sync DB is behind the mirrors.')
		return True
	return False


def load_schema() -> dict:
	# strip // comments, then parse as json
	text = SCHEMA_PATH.read_text()
	text = re.sub(r'(?m)^\s*//.*$|(?<=,)\s*//.*$', '', text)
	return json.loads(text)


def parse_pkgbuild_array(path: Path, name: str) -> list[str]:
	# extract a bash array (depends, optdepends, ...) from a PKGBUILD
	text = path.read_text()
	pattern = rf'{name}=\(\s*([\s\S]*?)\)'
	match = re.search(pattern, text)
	if not match:
		return []

	content = match.group(1)
	# quoted strings, minus 'pkg: description' suffixes and version constraints
	items = re.findall(r"['\"]([^'\"]+)['\"]", content)
	cleaned = []
	for item in items:
		pkg = item.split(':')[0].split('>=')[0].split('<=')[0].split('=')[0].strip()
		if pkg:
			cleaned.append(pkg)
	return cleaned


def _pacman_db_path() -> Path:
	# honour a custom DBPath in pacman.conf, else the default
	try:
		for line in Path('/etc/pacman.conf').read_text().splitlines():
			if m := re.match(r'\s*DBPath\s*=\s*(\S+)', line):
				return Path(m.group(1))
	except OSError:
		pass
	return Path('/var/lib/pacman')


def sync_db_groups() -> dict[str, set[str]]:
	# group -> member packages, read straight from the pacman sync DB desc files
	# (the same on-disk source grimoire uses). avoids a `pacman -Sg` subprocess and,
	# more importantly, a hardcoded group list that silently rots (cf. cutefish).
	# each desc carries %NAME% and an optional multi-line %GROUPS%.
	groups: dict[str, set[str]] = {}
	sync = _pacman_db_path() / 'sync'
	try:
		dbs = sorted(sync.glob('*.db'))
	except OSError:
		return groups

	for db in dbs:
		try:
			with tarfile.open(db, 'r:*') as tar:
				for member in tar:
					if not member.name.endswith('/desc'):
						continue
					if (handle := tar.extractfile(member)) is None:
						continue
					fields = dict(_DESC_FIELD_RE.findall(handle.read().decode()))
					name = fields.get('NAME', '').strip()
					if not name:
						continue
					for grp in fields.get('GROUPS', '').split():
						groups.setdefault(grp, set()).add(name)
		except (OSError, tarfile.TarError):
			continue
	return groups


def extract_schema_packages(schema: dict, groups: dict[str, set[str]]) -> set[str]:
	# collect package names (list strings only; dict keys are category labels).
	# schema meta-packages that are really pacman groups (gnome, xfce4, ...) expand
	# to their members so each member gets version-tracked individually.
	pkgs: set[str] = set()

	def walk(obj):
		if isinstance(obj, list):
			for item in obj:
				if isinstance(item, str):
					name = item.lower()
					if name in groups:
						pkgs.update(groups[name])
					else:
						pkgs.add(name)
				else:
					walk(item)
		elif isinstance(obj, dict):
			for v in obj.values():
				walk(v)

	walk(schema)
	return pkgs


def detect_source(pkg: str) -> str:
	return 'pacman'


def generate_toml() -> str:
	log('Loading schema...')
	schema = load_schema()

	log('Parsing PKGBUILDs...')
	depends = set(parse_pkgbuild_array(PKGBUILD_DEV, 'depends'))
	depends.update(parse_pkgbuild_array(PKGBUILD_REL, 'depends'))

	optdepends = set(parse_pkgbuild_array(PKGBUILD_DEV, 'optdepends'))
	optdepends.update(parse_pkgbuild_array(PKGBUILD_REL, 'optdepends'))

	log('Reading package groups from sync DB...')
	groups = sync_db_groups()

	log('Extracting packages from schema...')
	schema_pkgs = extract_schema_packages(schema, groups)

	lines = [
		'# nvchecker.toml - Auto-generated by NVGEN',
		'# Tracks all packages used by archinstoo',
		'#',
		'# Regenerate: ./NVGEN gen',
		'# Check:      ./NVGEN check',
		'# Acknowledge: ./NVGEN take --all',
		'',
		'[__config__]',
		'oldver = "old_ver.json"',
		'newver = "new_ver.json"',
		'',
	]

	def add_section(title: str, pkgs: set[str]) -> None:
		if not pkgs:
			return
		lines.append('# ============================================================')
		lines.append(f'# {title}')
		lines.append('# ============================================================')
		for pkg in sorted(pkgs):
			lines.append(f'[{pkg}]')
			lines.append(f'source = "{detect_source(pkg)}"')
			lines.append('')

	add_section('DIRECT DEPENDENCIES', depends)
	add_section('OPTIONAL DEPENDENCIES', optdepends - depends)
	add_section('INSTALLABLE PACKAGES', schema_pkgs - depends - optdepends)

	total = len(depends | optdepends | schema_pkgs)
	log(f'Done: {total} packages tracked')

	return '\n'.join(lines)


def write_toml() -> None:
	# write atomically so a mid-generation crash can't leave an empty config
	# (the old `./NVGEN > nvchecker.toml` truncated the file before running)
	toml = generate_toml()
	tmp = TOML.with_name(TOML.name + '.tmp')
	tmp.write_text(toml + '\n')
	os.replace(tmp, TOML)
	log(f'wrote {TOML}')


def cmd_gen(args: argparse.Namespace) -> int:
	if args.stdout:
		print(generate_toml())
	else:
		write_toml()
	return 0


def cmd_check(args: argparse.Namespace) -> int:
	_require('nvchecker')
	_require('nvcmp')

	if not args.force and _needs_update():
		sys.exit('error: refresh first with `sudo pacman -Sy` (or -Syu), then re-run `./NVGEN check` (or pass --force)')

	write_toml()

	# fetch quietly (errors still surface); nvcmp is the human-readable report
	nv = ['nvchecker', '-c', str(TOML), '-l', 'warning']
	if args.failures:
		nv.append('--failures')
	if args.tries:
		nv += ['-t', str(args.tries)]
	if args.entry:
		nv += ['-e', args.entry]
	rc = subprocess.run(nv).returncode

	diff = subprocess.run(['nvcmp', '-c', str(TOML)], capture_output=True, text=True)
	out = diff.stdout.strip()
	if out:
		print(out)
	else:
		log('no changes since last `take`')
	return rc


def cmd_take(args: argparse.Namespace) -> int:
	_require('nvtake')
	if not args.all and not args.pkgs:
		sys.exit('error: give package names or --all')
	cmd = ['nvtake', '-c', str(TOML)]
	if args.all:
		cmd.append('--all')
	cmd += args.pkgs
	return subprocess.run(cmd).returncode


def main() -> int:
	parser = argparse.ArgumentParser(prog='NVGEN', description='Generate and run nvchecker for archinstoo.')
	sub = parser.add_subparsers(dest='cmd')

	p_gen = sub.add_parser('gen', help='write nvchecker.toml in place')
	p_gen.add_argument('--stdout', action='store_true', help='print to stdout instead of writing the file')
	p_gen.set_defaults(func=cmd_gen)

	p_check = sub.add_parser('check', help='regen, run nvchecker, show nvcmp diff')
	p_check.add_argument('--force', action='store_true', help='run even if the sync DB looks stale')
	p_check.add_argument('--failures', action='store_true', help='exit 3 if any entry fails to fetch')
	p_check.add_argument('-t', '--tries', type=int, help='retry N times on network errors')
	p_check.add_argument('-e', '--entry', help='only check the named entry (debugging)')
	p_check.set_defaults(func=cmd_check)

	p_take = sub.add_parser('take', help='acknowledge versions (nvtake)')
	p_take.add_argument('-a', '--all', action='store_true', help='acknowledge every changed package')
	p_take.add_argument('pkgs', nargs='*', help='specific packages to acknowledge')
	p_take.set_defaults(func=cmd_take)

	args = parser.parse_args()
	if not args.cmd:  # bare `./NVGEN` == gen to file
		return cmd_gen(argparse.Namespace(stdout=False))
	return args.func(args)


if __name__ == '__main__':
	raise SystemExit(main())
