#!/usr/bin/env python3
"""Check kernel.org releases and build native Debian kernel packages."""
import argparse
from datetime import datetime, timezone
import fcntl
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import shlex
import shutil
import subprocess
import sys
import tarfile
import tempfile
import urllib.request

VERSION = '2.0.1'
API = 'https://www.kernel.org/releases.json'
CDN = 'https://cdn.kernel.org/pub/linux/kernel'
DEPS = ['build-essential', 'bc', 'bison', 'flex', 'libssl-dev', 'libelf-dev',
        'libncurses-dev', 'libdw-dev', 'dwarves', 'rsync', 'cpio', 'kmod', 'debhelper',
        'dpkg-dev', 'fakeroot', 'xz-utils', 'zstd', 'python3', 'ca-certificates', 'git']


class KcheckError(Exception):
    pass


def say(message):
    print(message, flush=True)


def version_tuple(value):
    match = re.match(r'^(\d+)\.(\d+)(?:\.(\d+))?', value)
    if not match:
        raise KcheckError('Version de noyau illisible : ' + value)
    return tuple(int(n or 0) for n in match.groups())


def stable_version(value):
    if not re.fullmatch(r'[3-9]\d*\.\d+(?:\.\d+)?|[12]\d+\.\d+(?:\.\d+)?', value):
        raise KcheckError('Version attendue : X.Y ou X.Y.Z, sans rc ni suffixe.')
    return value


def request(url, timeout):
    if not url.startswith('https://'):
        raise KcheckError('URL HTTPS requise.')
    response = urllib.request.urlopen(
        urllib.request.Request(url, headers={'User-Agent': 'kcheck/' + VERSION}), timeout=timeout)
    if not response.geturl().startswith('https://'):
        response.close()
        raise KcheckError('Redirection vers une URL non HTTPS refusee.')
    return response


def read_releases(timeout):
    with request(API, timeout) as response:
        data = json.loads(response.read(2 * 1024 * 1024))
    if not isinstance(data.get('releases'), list):
        raise KcheckError('Reponse kernel.org incomplete : releases absent.')
    return data


def available(data):
    return [dict(r, moniker='stable' if r.get('moniker') == 'mainline' else r['moniker'])
            for r in data['releases'] if r.get('moniker') in ('stable', 'longterm', 'mainline')
            and not r.get('iseol') and re.fullmatch(r'\d+\.\d+(?:\.\d+)?', r.get('version', ''))]


def select_release(data, series=None, exact=None):
    releases = available(data)
    if exact:
        stable_version(exact)
        found = next((r for r in releases if r['version'] == exact), None)
        return found or {'version': exact, 'moniker': 'version explicite', 'released': {}}
    if series:
        releases = [r for r in releases if '.'.join(r['version'].split('.')[:2]) == series]
    else:
        latest = data.get('latest_stable', {}).get('version')
        preferred = next((r for r in releases if r['version'] == latest), None)
        if preferred:
            return preferred
        releases = [r for r in releases if r['moniker'] == 'stable']
    if not releases:
        raise KcheckError('Aucune release stable maintenue pour cette selection.')
    return max(releases, key=lambda r: version_tuple(r['version']))


def source_url(version):
    stable_version(version)
    return f'{CDN}/v{version.split(".")[0]}.x/linux-{version}.tar.xz'


def status(data, release, running=None):
    running = running or platform.release()
    target = release['version']
    comparison = (version_tuple(running) > version_tuple(target)) - (version_tuple(running) < version_tuple(target))
    state = {-1: 'plus_recent_disponible', 0: 'meme_version_upstream', 1: 'noyau_actif_plus_recent'}[comparison]
    if '-rc' in running:
        state = 'noyau_actif_rc'
    return {'running': running, 'uname': ' '.join(os.uname()),
            'available': target, 'released': release.get('released', {}).get('isodate'),
            'branch': release.get('moniker'), 'status': state, 'source': source_url(target)}


def show_status(info):
    say('Kernel actif       : ' + info['running'])
    say('Systeme            : ' + info['uname'])
    say('Kernel disponible  : ' + info['available'] + ' (' + str(info['branch']) + ')')
    say('Date de publication: ' + (info['released'] or 'non indiquee dans le flux courant'))
    texts = {'plus_recent_disponible': 'Une version upstream plus recente est disponible.',
             'meme_version_upstream': 'Tu utilises deja cette version upstream (suffixes locaux ignores).',
             'noyau_actif_plus_recent': 'Ton noyau actif est plus recent que la version selectionnee.',
             'noyau_actif_rc': 'Le noyau actif est une release candidate ; cible stable affichee ci-dessus.'}
    say('Etat               : ' + texts[info['status']])


def auto_jobs():
    cpus = len(os.sched_getaffinity(0)) if hasattr(os, 'sched_getaffinity') else (os.cpu_count() or 1)
    try:
        match = re.search(r'^MemAvailable:\s+(\d+)', Path('/proc/meminfo').read_text(), re.M)
        by_memory = max(1, int(match.group(1)) // (2 * 1024 * 1024))
    except (OSError, AttributeError):
        by_memory = 1
    return max(1, min(cpus, by_memory))


def build_env():
    env = os.environ.copy()
    for key in list(env):
        if key.startswith('KCONFIG_') or key in ('MAKEFLAGS', 'MFLAGS', 'ARCH', 'CROSS_COMPILE',
                'KBUILD_OUTPUT', 'KBUILD_SRC', 'KERNELRELEASE', 'LOCALVERSION', 'CONFIG_'):
            env.pop(key)
    env.update({'DEBIAN_FRONTEND': 'noninteractive', 'KCONFIG_NONINTERACTIVE': '1'})
    return env


def run(command, cwd=None, env=None, capture=False):
    say('+ ' + shlex.join([str(arg) for arg in command]))
    result = subprocess.run([str(arg) for arg in command], cwd=cwd, env=env,
                            stdin=subprocess.DEVNULL, check=True, text=True,
                            stdout=subprocess.PIPE if capture else None)
    return result.stdout.strip() if capture else None


def missing_deps():
    if not shutil.which('dpkg-query'):
        raise KcheckError('Cette fonction requiert Debian et dpkg-query.')
    result = subprocess.run(['dpkg-query', '-W', '-f=${binary:Package}\t${db:Status-Status}\n', *DEPS],
                            stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
    installed = set()
    for line in result.stdout.splitlines():
        fields = line.split('\t')
        if len(fields) == 2 and fields[1] == 'installed':
            installed.add(fields[0].split(':')[0])
    return [p for p in DEPS if p not in installed]


def check_deps(install=False):
    missing = missing_deps()
    if missing and install:
        if os.geteuid() != 0:
            raise KcheckError('--install-deps requiert root.')
        run(['apt-get', 'update'], env=build_env())
        run(['apt-get', 'install', '-y', *missing], env=build_env())
        missing = missing_deps()
    if missing:
        raise KcheckError('Paquets manquants : ' + ' '.join(missing) + '\nLancer : kcheck --install-deps')
    say('[OK] Dependances Debian disponibles.')


def sha256(path):
    digest = hashlib.sha256()
    with path.open('rb') as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b''):
            digest.update(chunk)
    return digest.hexdigest()


def archive_checksum(version, timeout):
    url = source_url(version)
    with request(url.rsplit('/', 1)[0] + '/sha256sums.asc', timeout) as response:
        text = response.read(16 * 1024 * 1024).decode('utf-8')
    filename = 'linux-' + version + '.tar.xz'
    match = re.search(r'^([a-fA-F0-9]{64})\s+\*?' + re.escape(filename) + r'\s*$', text, re.M)
    if not match:
        raise KcheckError('Empreinte SHA256 officielle introuvable pour ' + filename)
    return match.group(1).lower()


def download_archive(version, cache, timeout):
    expected = archive_checksum(version, timeout)
    cache.mkdir(parents=True, exist_ok=True)
    target = cache / ('linux-' + version + '.tar.xz')
    if target.is_symlink():
        raise KcheckError('Archive en lien symbolique refusee : ' + str(target))
    if target.exists():
        if sha256(target) != expected:
            raise KcheckError('Archive en cache corrompue : ' + str(target) + '. La deplacer puis relancer.')
        say('[OK] Archive en cache, SHA256 verifie.')
        return target, expected
    fd, temporary = tempfile.mkstemp(prefix=target.name + '.', suffix='.part', dir=cache)
    try:
        count = 0
        mark = 0
        with os.fdopen(fd, 'wb') as output, request(source_url(version), timeout) as response:
            for chunk in iter(lambda: response.read(1024 * 1024), b''):
                output.write(chunk)
                count += len(chunk)
                if count // (16 * 1024 * 1024) > mark:
                    mark = count // (16 * 1024 * 1024)
                    say(f'[DOWNLOAD] {count // (1024 * 1024)} MiB')
        if sha256(Path(temporary)) != expected:
            raise KcheckError('SHA256 incorrect : archive non utilisee.')
        os.replace(temporary, target)
    finally:
        if os.path.exists(temporary):
            os.unlink(temporary)
    say('[OK] Telechargement et controle SHA256 termines (pas de verification PGP).')
    return target, expected


def extract_archive(archive, workspace, version):
    if not hasattr(tarfile, 'data_filter'):
        raise KcheckError('Python doit disposer du filtre tarfile.data_filter (Debian a jour).')
    expected = 'linux-' + version
    with tarfile.open(archive, 'r:xz') as tar:
        members = tar.getmembers()
        if any(m.name.split('/')[0] != expected or '..' in Path(m.name).parts for m in members):
            raise KcheckError('Structure inattendue dans l archive du noyau.')
        tar.extractall(workspace, members=members, filter='data')


def config_commands(keep_debug=False, localversion=''):
    command = ['./scripts/config', '--file', '.config', '--disable', 'MODULE_SIG_ALL',
               '--set-str', 'MODULE_SIG_KEY', '', '--set-str', 'SYSTEM_TRUSTED_KEYS', '',
               '--set-str', 'SYSTEM_REVOCATION_KEYS', '',
               '--set-str', 'LOCALVERSION', localversion, '--disable', 'LOCALVERSION_AUTO']
    if not keep_debug:
        command += ['--disable', 'DEBUG_INFO', '--disable', 'DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT',
                    '--disable', 'DEBUG_INFO_DWARF4', '--disable', 'DEBUG_INFO_DWARF5',
                    '--disable', 'DEBUG_INFO_BTF', '--disable', 'DEBUG_INFO_BTF_MODULES',
                    '--enable', 'DEBUG_INFO_NONE']
    return command


def config_values(path):
    values = {}
    for line in path.read_text().splitlines():
        match = re.match(r'CONFIG_([A-Z0-9_]+)=(.*)$', line)
        if match:
            values[match.group(1)] = match.group(2)
    return values


def prepare_config(source, metadata, env):
    config = source / '.config'
    if not config.exists():
        base = Path(metadata['config_source'])
        if not base.is_file():
            raise KcheckError('Configuration source absente : ' + str(base) + '. Utiliser --config FICHIER.')
        shutil.copyfile(base, config)
        shutil.copyfile(base, source.parent / 'config.initial')
    # Resolve new symbols, edit actual string/boolean types, then resolve again.
    run(['make', 'olddefconfig'], cwd=source, env=env)
    run(config_commands(metadata['keep_debug'], metadata['localversion']), cwd=source, env=env)
    run(['make', 'olddefconfig'], cwd=source, env=env)
    values = config_values(config)
    for key in ('MODULE_SIG_KEY', 'SYSTEM_TRUSTED_KEYS', 'SYSTEM_REVOCATION_KEYS'):
        if values.get(key, '""') != '""':
            raise KcheckError('La configuration n a pas conserve ' + key + '="".')
    if values.get('MODULE_SIG_ALL') == 'y':
        raise KcheckError('MODULE_SIG_ALL est encore actif.')
    if values.get('MODULE_SIG_FORCE') == 'y':
        raise KcheckError('MODULE_SIG_FORCE=y : cette configuration exige des modules signes. Aucun paquet installe.')
    if not metadata['keep_debug'] and values.get('DEBUG_INFO') == 'y':
        raise KcheckError('DEBUG_INFO est encore actif apres olddefconfig.')
    shutil.copyfile(config, source.parent / 'config.final')


def collect_packages(workspace, kernelrelease, package_version):
    packages = []
    installable = []
    for path in sorted(workspace.glob('*.deb')):
        if path.is_symlink():
            raise KcheckError('Paquet en lien symbolique refuse : ' + str(path))
        result = subprocess.run(['dpkg-deb', '-f', str(path), 'Package', 'Version'],
                                check=True, capture_output=True, text=True)
        fields = dict(line.split(': ', 1) for line in result.stdout.splitlines() if ': ' in line)
        if fields.get('Version') != package_version:
            continue
        packages.append(path)
        if fields.get('Package') in ('linux-image-' + kernelrelease, 'linux-headers-' + kernelrelease):
            installable.append(path)
    for kind in ('image', 'headers'):
        if not any(p.name.startswith('linux-' + kind + '-' + kernelrelease + '_') for p in installable):
            raise KcheckError('Paquet ' + kind + ' correspondant a ce build introuvable.')
    return packages, installable


def build(args, release=None):
    env = build_env()
    if args.resume:
        workspace = Path(args.resume).expanduser().resolve()
        state = workspace / 'kcheck-build.json'
        metadata = json.loads(state.read_text())
        stable_version(metadata['version'])
        if metadata.get('format') != 1:
            raise KcheckError('Format de reprise inconnu.')
    else:
        version = stable_version(release['version'])
        workspace = Path(args.workdir).expanduser().resolve() / 'kcheck' / ('linux-' + version + args.localversion)
        state = workspace / 'kcheck-build.json'
        metadata = {'format': 1, 'version': version, 'localversion': args.localversion,
                    'keep_debug': args.keep_debug, 'config_source': str(Path(args.config).expanduser().resolve()),
                    'package_version': version + '-' + datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S'),
                    'released': release.get('released', {}).get('isodate')}
    version = metadata['version']
    source = workspace / ('linux-' + version)
    jobs = args.jobs or auto_jobs()
    say('Dossier de build   : ' + str(workspace))
    say('Parallelisme       : ' + str(jobs) + ' job(s)')
    say('Profil             : modules non signes ; debug ' + ('conserve' if metadata['keep_debug'] else 'desactive'))
    if args.dry_run:
        say('[PLAN] Telecharger ' + source_url(version) + ' et verifier son SHA256 officiel.')
        say('[PLAN] Extraire dans ' + str(source))
        say('[PLAN] Copier ' + metadata['config_source'] + ' vers .config (nouveau build uniquement).')
        for command in (['make', 'olddefconfig'], config_commands(metadata['keep_debug'], metadata['localversion']),
                        ['make', 'olddefconfig'], ['make', '-j' + str(jobs), 'bindeb-pkg',
                        'KDEB_PKGVERSION=' + metadata['package_version']]):
            say('[PLAN] ' + shlex.join(command))
        say('[PLAN] ' + ('Installer uniquement les paquets image et headers de ce build.' if args.install else 'Afficher les .deb produits ; ne pas les installer.'))
        return
    if args.install and os.geteuid() != 0:
        raise KcheckError('--install requiert root ; le build seul peut etre execute sans root.')
    check_deps()
    if not args.resume and workspace.exists():
        raise KcheckError('Ce dossier existe deja. Pour reprendre : kcheck --resume ' + shlex.quote(str(workspace)))
    if args.resume and metadata.get('extracted') and not source.is_dir():
        raise KcheckError('Sources absentes du dossier de reprise : ' + str(source))
    if not args.resume:
        base = Path(metadata['config_source'])
        if not base.is_file():
            raise KcheckError('Configuration source absente : ' + str(base) + '. Utiliser --config FICHIER.')
        archive, checksum = download_archive(version, Path(args.workdir).expanduser().resolve() / 'kcheck/downloads', args.timeout)
        workspace.mkdir(parents=True, exist_ok=False)
        metadata['source_sha256'] = checksum
        state.write_text(json.dumps(metadata, indent=2) + '\n')
    with (workspace / '.build.lock').open('a') as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            raise KcheckError('Une compilation kcheck utilise deja ce dossier.')
        try:
            if not metadata.get('extracted'):
                if args.resume:
                    archive, checksum = download_archive(version, workspace.parent / 'downloads', args.timeout)
                    if checksum != metadata.get('source_sha256'):
                        raise KcheckError('Empreinte de la source differente du build initial.')
                extract_archive(archive, workspace, version)
                metadata['extracted'] = True
                state.write_text(json.dumps(metadata, indent=2) + '\n')
            prepare_config(source, metadata, env)
            kernelrelease = run(['make', '-s', 'kernelrelease'], cwd=source, env=env, capture=True).splitlines()[-1]
            if not re.fullmatch(r'[A-Za-z0-9.+_-]+', kernelrelease):
                raise KcheckError('Nom de kernelrelease inattendu.')
            run(['make', '-j' + str(jobs), 'bindeb-pkg', 'KDEB_PKGVERSION=' + metadata['package_version']], cwd=source, env=env)
            packages, installable = collect_packages(workspace, kernelrelease, metadata['package_version'])
            metadata.update({'kernelrelease': kernelrelease, 'packages': [str(p) for p in packages],
                             'built_at': datetime.now(timezone.utc).isoformat()})
            state.write_text(json.dumps(metadata, indent=2) + '\n')
            say('[OK] Paquets produits :')
            for package in packages:
                say('  ' + str(package))
            say('Installation image + headers : ' + shlex.join(['dpkg', '-i', *map(str, installable)]))
            if args.install:
                run(['dpkg', '-i', *installable], env=env)
                say('[OK] Installation terminee. Aucun redemarrage lance.')
            else:
                say('[OK] Build termine. Aucun paquet installe, aucun redemarrage lance.')
        except BaseException:
            say('Reprise : kcheck --resume ' + shlex.quote(str(workspace)) + (' --install' if args.install else ''))
            raise


def positive(value):
    number = int(value)
    if number <= 0:
        raise argparse.ArgumentTypeError('La valeur doit etre strictement positive.')
    return number


def parser():
    p = argparse.ArgumentParser(description='Kernel actif, derniere stable kernel.org et compilation Debian non interactive.',
        formatter_class=argparse.RawDescriptionHelpFormatter, epilog='''Exemples :
  kcheck                                  statut, version disponible et date
  kcheck --list                           stable et branches LTS maintenues
  kcheck --latest                         version stable seule (usage script)
  kcheck --build-latest --dry-run          voir le plan sans modifier de fichiers
  kcheck --install-deps                    installer les dependances Debian
  kcheck --build-latest                    produire les .deb sans installation
  kcheck --build-latest --install          compiler puis installer image + headers
  kcheck --build-latest --jobs 4           limiter le parallelisme
  kcheck --build-latest --series 6.18      compiler la derniere 6.18.x maintenue
  kcheck --build 7.2.11 --localversion=-teuk
  kcheck --resume /home/src/kcheck/linux-7.2.11

Sources/builds : /home/src/kcheck ; tes dossiers /home/src/linux-* sont preserves.
Profil par defaut : configuration active, nouveaux choix par defaut, sans debug,
sans signature automatique des modules ni chemins de certificats de distribution.
--install exclut linux-libc-dev et les paquets de debug. Aucun reboot automatique.
Comparaison des numeros upstream uniquement, pas du niveau de correctifs Debian.
Codes : 0 succes (meme si une nouvelle version existe), 1 erreur, 2 usage incorrect.
''')
    group = p.add_mutually_exclusive_group()
    group.add_argument('--build-latest', action='store_true', help='compiler la stable selectionnee')
    group.add_argument('--build', metavar='VERSION', help='compiler une version stable precise de kernel.org')
    group.add_argument('--resume', metavar='DOSSIER', help='reprendre un dossier kcheck ; ne requiert pas le flux de releases')
    group.add_argument('--list', action='store_true', help='lister les versions stable/LTS maintenues et leurs dates')
    group.add_argument('--latest', action='store_true', help='afficher uniquement le numero de version selectionne')
    group.add_argument('--check-deps', action='store_true', help='verifier les dependances sans les installer')
    p.add_argument('--series', metavar='X.Y', help='selectionner une branche stable/LTS maintenue')
    p.add_argument('--workdir', default='/home/src', metavar='DOSSIER', help='racine de travail (defaut : /home/src)')
    p.add_argument('--config', default='/boot/config-' + platform.release(), metavar='FICHIER', help='configuration de depart (defaut : /boot/config-$(uname -r))')
    p.add_argument('-j', '--jobs', type=positive, metavar='N', help='jobs make (defaut : CPU disponibles, limites a environ 1 job/2 GiB de RAM disponible)')
    p.add_argument('--localversion', default='', metavar='SUFFIXE', help='suffixe du noyau, ex. --localversion=-teuk (defaut : aucun)')
    p.add_argument('--keep-debug', action='store_true', help='conserver les options debug de la configuration de depart')
    p.add_argument('--install', action='store_true', help='installer image + headers apres compilation (root requis)')
    p.add_argument('--install-deps', action='store_true', help='installer les dependances manquantes via apt (root requis)')
    p.add_argument('--dry-run', action='store_true', help='afficher le plan du build sans ecrire de fichiers')
    p.add_argument('--json', action='store_true', help='sortie JSON pour le statut ou --list')
    p.add_argument('--timeout', type=positive, default=30, metavar='SECONDES', help='timeout reseau (defaut : 30 s)')
    p.add_argument('--version', action='version', version='kcheck ' + VERSION)
    return p


def main(argv=None):
    p = parser()
    args = p.parse_args(argv)
    building = bool(args.build_latest or args.build or args.resume)
    if args.install and not building:
        p.error('--install doit accompagner --build-latest, --build ou --resume.')
    if args.dry_run and not building:
        p.error('--dry-run doit accompagner une action de compilation.')
    if args.json and (building or args.latest or args.install_deps or args.check_deps):
        p.error('--json concerne uniquement le statut et --list.')
    if args.dry_run and args.install_deps:
        p.error('--dry-run ne peut pas etre combine avec --install-deps.')
    if args.series and not re.fullmatch(r'\d+\.\d+', args.series):
        p.error('--series attend X.Y, par exemple 6.18.')
    if args.series and (args.build or args.resume):
        p.error('--series ne se combine pas avec --build VERSION ou --resume.')
    if args.localversion and not re.fullmatch(r'[-+][A-Za-z0-9._+-]+', args.localversion):
        p.error('--localversion doit commencer par - ou +, sans espace ni chemin.')
    if args.resume and (args.keep_debug or args.localversion or args.config != '/boot/config-' + platform.release()):
        p.error('--resume reutilise son profil et sa configuration ; ne pas ajouter --config/--keep-debug/--localversion.')
    if args.install_deps:
        check_deps(install=True)
        if not building and not args.check_deps:
            return 0
    if args.check_deps:
        check_deps()
        return 0
    if args.resume:
        build(args)
        return 0
    data = read_releases(args.timeout)
    if args.list:
        releases = sorted(available(data), key=lambda r: version_tuple(r['version']), reverse=True)
        if args.series:
            releases = [r for r in releases if '.'.join(r['version'].split('.')[:2]) == args.series]
        if args.json:
            say(json.dumps(releases, ensure_ascii=False, indent=2))
        else:
            say(f'{"BRANCHE":12} {"VERSION":16} DATE')
            for release in releases:
                say(f'{release["moniker"]:12} {release["version"]:16} {release.get("released", {}).get("isodate", "?")}')
        return 0
    release = select_release(data, args.series, args.build)
    if args.latest:
        say(release['version'])
        return 0
    info = status(data, release)
    if args.json:
        say(json.dumps(info, ensure_ascii=False, indent=2))
    else:
        show_status(info)
        if building:
            build(args, release)
        else:
            say('Compilation        : kcheck --build-latest' + (' --series ' + args.series if args.series else ''))
            say('Toutes les options : kcheck --help')
    return 0


if __name__ == '__main__':
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        print('\n[STOP] Interrompu.', file=sys.stderr)
        sys.exit(130)
    except (KcheckError, OSError, ValueError, KeyError, tarfile.TarError, subprocess.CalledProcessError) as error:
        print('[KO] ' + str(error), file=sys.stderr)
        sys.exit(1)
