#!/usr/bin/python3

# SPDX-License-Identifier: GPL-3.0-or-later

# Copyright 2025 Alexey Minnekhanov <alexeymin@postmarketos.org>

import argparse
import configparser
import csv
import glob
import math
import os
from pathlib import Path
import stat
import subprocess
import sys
import time
from typing import List


g_verbose = False
g_apkbuilds = {}
PMAPORTS_CLONE_URL = 'https://gitlab.postmarketos.org/postmarketOS/pmaports.git'
FETCH_TIMEOUT = 1800  # do not update repository more often than once in 30 minutes


# spec: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html
def xdg_cache_home() -> Path:
    if (value := os.environ.get("XDG_CACHE_HOME")) and (path := Path(value)).is_absolute():
        return path
    return Path.home() / ".cache"


def check_git_installed() -> bool:
    try:
        res = subprocess.run(['git', '--version'], capture_output=True)
        if (res.returncode != 0) or (b'git version' not in res.stdout):
            return False
    except FileNotFoundError:
        return False
    return True


def detect_sudo_doas() -> str:
    doas_path = Path('/usr/bin/doas')
    if doas_path.exists() and doas_path.is_file():
        # maybe checking executable bit is too much?
        st = doas_path.stat()
        if st.st_mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) > 0:
            return 'doas'
    return 'sudo'


# == for edge:
# PRETTY_NAME="postmarketOS edge"
# VERSION_ID="edge"
# VERSION="edge"
# == for 24.06
# PRETTY_NAME="postmarketOS v24.06"
# VERSION_ID="v24.06"
# VERSION="v24.06"
def get_pmos_version() -> str:
    os_release = {'VERSION': 'edge'}
    try:
        with open('/etc/os-release', 'rt') as f:
            csv_reader = csv.reader(f, delimiter='=')
            os_release = dict(csv_reader)
    except OSError:
        pass
    return os_release['VERSION']


def store_last_fetch_timestamp() -> None:
    ts_path = xdg_cache_home() / 'install-recommends' / 'last_fetch_time'
    cur_time = math.floor(time.clock_gettime(time.CLOCK_MONOTONIC))
    try:
        with ts_path.open('wt') as f:
            f.write(str(cur_time))
    except OSError:
        pass


def get_last_fetch_time() -> int:
    ts_path = xdg_cache_home() / 'install-recommends' / 'last_fetch_time'
    last_fetch_time = 0
    try:
        with ts_path.open('rt') as f:
            last_fetch_time = int(f.readline())
    except OSError:
        pass
    return last_fetch_time


def prepare_pmaports_git_repo() -> None:
    global g_verbose
    cache_home = xdg_cache_home() / 'install-recommends'
    cache_home = cache_home.resolve()
    pmaports_dir = cache_home / 'pmaports'
    if not cache_home.exists():
        os.makedirs(str(cache_home))
    if not pmaports_dir.exists():
        # do initial clone
        if g_verbose:
            print("Will run 'git clone {}' in {}".format(PMAPORTS_CLONE_URL, cache_home))
        subprocess.run(['git', '-C', str(cache_home), 'clone', PMAPORTS_CLONE_URL], check=True)
        store_last_fetch_timestamp()

    # switch pmaports branch depending on postmarketOS version
    pmos_ver = get_pmos_version()

    command = ['git', '-C', str(pmaports_dir), 'show', 'origin/master:channels.cfg']
    run_res = subprocess.run(command, check=True, capture_output=True)
    channels_cfg = run_res.stdout.decode('utf-8')

    cfg = configparser.ConfigParser()
    cfg.read_string(channels_cfg)
    branch = cfg.get(pmos_ver, 'branch_pmaports')

    if g_verbose:
        print('You are on OS version: {}, using branch: {}'.format(pmos_ver, branch))
    command = ['git', '-C', str(pmaports_dir), 'switch', branch]
    subprocess.run(command, check=True)

    # update repo (if needed)
    cur_time = time.clock_gettime(time.CLOCK_MONOTONIC)
    last_fetch_time = get_last_fetch_time()
    if cur_time - last_fetch_time > FETCH_TIMEOUT:
        if g_verbose:
            print("Running 'git fetch' in {}".format(pmaports_dir))
        # fetch + hard reset instead of pull, to be safe against potential force-pushes on the branch
        subprocess.run(['git', '-C', str(pmaports_dir), 'fetch', 'origin'], check=True)
        subprocess.run(['git', '-C', str(pmaports_dir), 'reset', '--hard', f"origin/{branch}"], check=True)
        store_last_fetch_timestamp()


def parse_apkbuild(apkbuild_file: str) -> dict:
    recs = []
    deps = []
    try:
        # output "_pmb_recommends" and "depends" using a single shell call
        command = ['sh', '-c', f"source {apkbuild_file} && echo $_pmb_recommends && echo $depends"]
        run_res = subprocess.run(command, check=True, capture_output=True)
        output_lines = run_res.stdout.decode('utf-8').split('\n')
        recs = output_lines[0].split()
        deps_r = output_lines[1].split()

        # filter deps, come of packages have them listed as "conflicts", e.g. "!gnome-shell-mobile"
        deps = []
        for d in deps_r:
            if not d.startswith('!'):
                deps.append(d)
    except subprocess.CalledProcessError as ex:
        print(f"Failed to parse {apkbuild_file}!")
        print(ex.stderr.decode('utf-8'), file=sys.stderr)
    return {'_pmb_recommends': recs, 'depends': deps}


def get_recommends_for_package(package_name: str, quiet: bool = False) -> List[str] | None:
    global g_apkbuilds
    pmaports_dir = xdg_cache_home() / 'install-recommends' / 'pmaports'

    # find all APKBUILDs ?
    if len(g_apkbuilds.keys()) == 0:
        for apkbuild in glob.iglob('{}/**/*/APKBUILD'.format(str(pmaports_dir)), recursive=True):
            package = os.path.basename(os.path.dirname(apkbuild))
            if package in g_apkbuilds:
                # should not happen in pmaports, ignore
                continue
            g_apkbuilds[package] = apkbuild

    if package_name not in g_apkbuilds:
        if not quiet:
            print('Cannot find APKBUILD for {} in pmaports!'.format(package_name))
        return None

    apkbuild_file = g_apkbuilds[package_name]
    apkbuild = parse_apkbuild(apkbuild_file)
    recommends = apkbuild['_pmb_recommends']
    depends = apkbuild['depends']

    # recursively get recommends for each recommended & dependency package
    add_recommends = []
    for pkg in recommends:
        recs = get_recommends_for_package(pkg, quiet=True)
        if recs is not None:
            add_recommends.extend(recs)
    for pkg in depends:
        recs = get_recommends_for_package(pkg, quiet=True)
        if recs is not None:
            add_recommends.extend(recs)
    recommends.extend(add_recommends)

    return recommends


def main():
    global g_verbose

    parser = argparse.ArgumentParser(prog=sys.argv[0],
                                     description='Install packages from _pmb_recommends')

    parser.add_argument('package',
                        help='package name to take recommends from')
    parser.add_argument('-u', '--uninstall', action='store_true',
                        help='uninstall recommends instead of installing them')
    parser.add_argument('-v', '--verbose', action='store_true',
                        help='print more logs')

    args = parser.parse_args()

    if args.verbose:
        g_verbose = True

    if not check_git_installed():
        print("Program 'git' seems to be not installed. Please install it first!", file=sys.stderr)
        return 1

    os.environ['LANG'] = 'en_US'
    os.environ['LC_ALL'] = 'en_US.UTF-8'

    prepare_pmaports_git_repo()

    package: str = str(args.package)
    recommended_pkgs = get_recommends_for_package(package)

    if recommended_pkgs is None:
        return 1

    if len(recommended_pkgs) == 0:
        print(f'There seems to be no recommends for {package}.')
        return 0

    cmd = 'add'
    if args.uninstall:
        cmd = 'del'

    command = [detect_sudo_doas(), 'apk', cmd, '-i']
    command.extend(recommended_pkgs)

    if g_verbose:
        print('Will run: {}'.format(' '.join(command)))

    run_res = subprocess.run(command)
    return run_res.returncode


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