#!/usr/bin/env python3
#
# ex:set ai shiftwidth=4 inputtab=spaces smarttab noautotab:

"""
Copyright (c) 2017-2025  Christoph Willing, Sydney Australia
SPDX-License-Identifier: GPL-3.0-or-later
"""

from __future__ import print_function

import sys, os
import fnmatch
import pickle
import argparse
import logging
import configparser
import re
from datetime import datetime, timezone


# How we were called
(apppath, appname) = os.path.split(sys.argv[0])

HOOREX_VERSION = '0.10.3'

# XDG definitions
_home = os.path.expanduser('~')
xdg_data_home = os.environ.get('XDG_DATA_HOME') or os.path.join(_home, '.local', 'share')
xdg_config_home = os.environ.get('XDG_CONFIG_HOME') or os.path.join(_home, '.config')

logging.basicConfig(format='%(levelname)s: %(message)s')
hlog = logging.getLogger(appname)

# The field name to search for in .info files
DEPTARGET = os.getenv('DEPTARGET','REQUIRES')

# How far into requirements tree to go
DEPTH_LIMIT  = 10

quirk_file = ""

def main():
    # Ensure some default locations exist
    config_dir = os.path.join(xdg_config_home, appname)
    os.makedirs(config_dir, exist_ok=True)
    data_dir = os.path.join(xdg_data_home, appname)
    os.makedirs(data_dir, exist_ok=True)


    # User configuration
    default_config = load_user_config()

    # Command line arguments
    parser = argparse.ArgumentParser(description="This program shows which \
                    other packages require a given SBo package")
    parser.add_argument("-c", "--config", action="store_true", dest="showConfig",
                default=False,
                help="display HooRex configfuration and exit")
    parser.add_argument("-f", "--force", action="store_true", dest="force",
                default=default_config.getboolean('HooRex', 'force'),
                help="force (re)processing of repo data")
    parser.add_argument("-m", "--multilevel", action="store_true", dest="multilevel",
                default=default_config.getboolean('HooRex', 'multilevel'),
                help="after finding immediate requirers, show who requires those")
    parser.add_argument("-d", "--debug", action="store_true", dest="debug_mode",
                default=default_config.getboolean('HooRex', 'debug'),
                help="show additional additional debugging information, \
                    not just calculated package names")
    parser.add_argument('-g', '--group', dest='sbo_group', action='store', nargs='+',
                help="select all packages in the given SBo category \
                    e.g. --group audio")
    parser.add_argument("-1", "--column", action="store_true", dest="output_column",
                default=default_config.getboolean('HooRex', 'output_column'),
                help="show output as a sinlge column")
    parser.add_argument("-l", "--long", action="store_true", dest="output_long",
                default=default_config.getboolean('HooRex', 'output_long'),
                help="show containing directory (category) for each package")
    parser.add_argument("-i", "--installed", action="store_true", dest="output_installed",
                default=default_config.getboolean('HooRex', 'output_installed'),
                help="show only packages already installed, except that target package(s) are included even if not already installed")
    parser.add_argument("-I", "--installed_strict", action="store_true", dest="output_strictly_installed",
                default=False,
                help="really show only packages already installed, not even target packages")
    parser.add_argument("-p", "--dataPath", action="store", dest="data_path",
                help="specify the directory path to be used for repo data cache storage")
    parser.add_argument("-q", "--quirkFile", action="store", dest="quirk_file",
                help="specify the location of the quirk file")
    parser.add_argument("-r", "--reverse", action="store_true", dest="reverse_lookup",
                default=default_config.getboolean('HooRex', 'reverse_lookup'),
                help="show build dependencies for target package(s)")
    parser.add_argument("-R", "--restricted", action="store_true", dest="output_only_input",
                default=False,
                help="show only packages from the input list")
    parser.add_argument('-s', '--slackbuilds', dest='sbo_path', action='store',
                help="set the full filesystem path to the local slackbuilds \
                    repository e.g. -s /home/jerry/slackbuilds")
    parser.add_argument("-U", "--unknown_strict", action="store_true", dest="unknown_strict",
                default=default_config.getboolean('HooRex', 'unknown_strict'),
                help="Be strict about about unknown package names - report them, then exit")
    parser.add_argument("-V", "--version", dest="app_version", action="store_true",
                help="display version number and exit")
    parser.add_argument("target", metavar='PKG', type=str, nargs='*',
                help="package(s) to process")

    args = parser.parse_args()

    # Allow packages to be taken from stdin as well as a command argument
    if not sys.stdin.isatty():
        pipe_targets = sys.stdin.read().split()
    else:
        pipe_targets = []

    if args.app_version:
        print(HOOREX_VERSION)
        sys.exit(0)

    if args.showConfig:
        displayConfiguration()
        sys.exit(0)

    if args.debug_mode:
        hlog.setLevel(logging.DEBUG)
        hlog.debug("VERBOSE mode set at command line")
    else:
        hlog.setLevel(logging.INFO)

    if args.sbo_path is not None:
        sbo_path = args.sbo_path.split()[-1]
        user_config_set(default_config, 'sbo_path', sbo_path)
        sbo_path = default_config['HooRex']['sbo_path']
        hlog.debug("Set sbo_path to %s in config file" % sbo_path)
    else:
        try:
            sbo_path = default_config['HooRex']['sbo_path']
        except:
            sbo_path = "/var/empty"
    hlog.debug("sbo_path: %s" % sbo_path)

    # Repo Data
    if args.data_path is not None:
        data_dir = args.data_path
    else:
        data_dir = os.path.join(xdg_data_home, appname)
    os.makedirs(data_dir, exist_ok=True)

    SAVED_DATA=os.path.join(data_dir, 'repoData.pkl')
    SAVED_DATA_STAMP=os.path.join(data_dir, 'repoData.stamp')

    # Quirk file
    # Default quirk file created if necessary
    # User specified (temp) file must already exist
    #
    if args.quirk_file is not None:
        quirk_file = args.quirk_file
        if not os.path.exists(quirk_file):
            hlog.critical("Couldn't locate specified quirk file: %s" % quirk_file)
            sys.exit(4)
        user_config_set(default_config, 'quirk_file', quirk_file)
        # Ensure dependency tree is rebuilt
        args.force = True
    else:
        quirk_file = default_config.get('HooRex', 'quirk_file')
        try:
            open(quirk_file, "a").close()
        except:
            hlog.critical("Couldn't create quirk file (%s)" % quirk_file)
            sys.exit(4)



    # Our personal database of package relationships
    PkgData = dict()

    if args.force == False and os.path.exists(SAVED_DATA):
        # First try to load preexisting data
        hlog.debug("Loading existing repo data")
        pkl_file = open(SAVED_DATA, 'rb')
        PkgData = pickle.load(pkl_file)
        pkl_file.close()
        hlog.debug("PkgData loaded from file OK")
    else:
        if args.force == True:
            hlog.debug("(re)build of repo data forced by -f option - please wait ...")
        elif not os.path.exists(SAVED_DATA):
            hlog.debug("Couldn't open data file, generating new data - please wait ...")
        build_dicts(sbo_path, PkgData, quirk_file=quirk_file, deptarget=DEPTARGET)
        output = open(SAVED_DATA, 'wb')
        # Pickle the list using the highest protocol available.
        pickle.dump(PkgData, output, -1)
        output.close()
        with open(SAVED_DATA_STAMP, 'w') as file:
            file.write((str(datetime.now(timezone.utc).timestamp())) + '\n')
        # Warn if sbo_path didn't point to a valid SBo repo
        if not os.path.isdir(os.path.join(sbo_path, 'academic')):
            hlog.debug("Warning: %s wasn't a real SBo repository" % sbo_path)

    # At version 0.6.0, PkgData has an additional entry PkgDataInfo.
    # Force an update if current repo doesn't have it
    if not 'PkgDataInfo' in PkgData:
        hlog.debug("(re)build of repo data forced by out of date data format - please wait ...")
        build_dicts(sbo_path, PkgData, deptarget=DEPTARGET, quirk_file=quirk_file)
        output = open(SAVED_DATA, 'wb')
        # Pickle the list using the highest protocol available.
        pickle.dump(PkgData, output, -1)
        output.close()

    PkgDataInfo = PkgData['PkgDataInfo']
    hlog.debug("REPO has version %s    " % PkgDataInfo['hoorex_data_version'])
    hlog.debug("REPO uses deptarget %s " % PkgDataInfo['deptarget'])
    DirectRequires = PkgData['DirectRequires']
    PkgRequires = PkgData['PkgRequires']
    PkgNeededBy = PkgData['PkgNeededBy']
    PkgCategory = PkgData['PkgCategory']

    # Warn and exit if DEPTARGET doesn't match current data
    if DEPTARGET != PkgDataInfo['deptarget']:
        hlog.critical("Requested dependency field (%s) doesn't match current data (using %s)" % (DEPTARGET,PkgDataInfo['deptarget']))
        hlog.critical("Please regenerate data index with something like:")
        hlog.critical("\tDEPTARGET=%s hoorex -f" % DEPTARGET)
        sys.exit(0)


    # This is our input - the package names being queried
    # via pipe and/or command line or a predefined group.
    #
    # First handle -g|--group
    # Typically its a category of the SBo repo
    # but we can define special groups ourselves here like the 'all' group.
    if args.sbo_group:
        targets = []
        if 'all' in args.sbo_group:
            targets = PkgCategory.keys()
        for (k,v) in PkgCategory.items():
            if v in args.sbo_group:
                targets.append(k)
    else:
        # We strip off any category directory name
        # that might be attached e.g. from the --long output of a previous stage
        # of a pipeline.
        targets = [os.path.split(t)[-1] for t in list(set(pipe_targets + args.target))]

    # This is "raw" output (unsorted, unfiltered)
    multi_list = []

    # First check that the input targets exist in the repo
    bad_target = False
    for target in reversed(targets):
        if not target in PkgRequires:
            bad_target = True
            if args.unknown_strict:
                hlog.critical("Unknown package: %s" %target)
            else:
                targets.remove(target)
    if bad_target and args.unknown_strict:
        sys.exit(2)


    hlog.debug("Processing initial targets of: %s" % targets)
    if args.reverse_lookup:
        hlog.debug("REVERSE mode set at command line")
        multi_list.append(targets)
        for package in targets:
            if package in PkgRequires:
                multi_list.append(PkgRequires[package])
    else:
        multilevel_depth = 0
        multi_list.append(targets)
        while True:
            if len(targets) < 1:
                break
            hlog.debug("At LEVEL %d, required by: %s" % (multilevel_depth, targets))
            pkglist = []
            for package in targets:
                if package in DirectRequires:
                    if package in PkgNeededBy:
                        hlog.debug("\t%s is needed by: %s" % (package, PkgNeededBy[package]))
                        pkglist.extend(PkgNeededBy[package])
                    else:
                        hlog.debug("\t%s isn't needed by any other package" % package)
                else:
                    hlog.debug("\tSkipping package \"%s\" (unknown package)" % package)

            if len(pkglist) > 0:
                multi_list.append(pkglist)
            if not args.multilevel or len(targets) < 1:
                break
            targets = list(set(pkglist))
            multilevel_depth += 1
            if multilevel_depth > DEPTH_LIMIT:
                break

    # Everything (including initial pkgs enquired about)
    hlog.debug(multi_list)

    # Flatten multi_list, then filter and sort for output
    raw_output = []
    for pkgs in multi_list:
        for prereq in pkgs:
            # Step 0 filter: only add it if we know about it
            if prereq in PkgRequires:
                raw_output.append(prereq)
    # Filter the list, if necessary
    sorted_output = []
    if args.output_only_input:
        # -R option given
        sorted_output = sort_output([name for name in targets if name in set(raw_output)], PkgRequires)
    elif args.output_installed or args.output_strictly_installed:
        repo_path = default_config.get('HooRex', 'slackware_repo')
        if args.output_strictly_installed:
            # -I option
            all_installed = ['-'.join(pkgname.split('-')[:-3]) for pkgname in os.listdir(repo_path)]
        else:
            # -i option
            all_installed = set(targets + ['-'.join(pkgname.split('-')[:-3]) for pkgname in os.listdir(repo_path)])
        sorted_output = sort_output([name for name in all_installed if name in set(raw_output)], PkgRequires)
    else:
        # No filter on output
        sorted_output = sort_output(list(set(raw_output)), PkgRequires)

    # OUTPUT
    # Empty sorted_output is suspicious unless -f or -s options used
    if (args.force != True) and (args.sbo_path == None):
        #print("Should start counting")
        count = 0
        for req in sorted_output:
            count += 1
        if count == 0:
            hlog.debug("Warning: No results! Check for typos. Check whether %s is a real SBo repository" % sbo_path)

    for req in sorted_output:
        if req in targets:
            if sys.stdout.isatty():
                # Bold
                opfmt = '\033[1m'
            else:
                opfmt = ''
        else:
            if sys.stdout.isatty():
                # Plain
                opfmt= '\033[0m'
            else:
                opfmt = ''
        if sys.stdout.isatty():
            opfmtend = '\033[0m'
        else:
            opfmtend = ''

        if args.output_long:
            if args.output_column:
                print(opfmt + os.path.join(PkgCategory[req], req) + opfmtend)
            else:
                print(opfmt + os.path.join(PkgCategory[req], req) + opfmtend, end=' ')
        else:
            if args.output_column:
                print(opfmt + req + opfmtend)
            else:
                print(opfmt + req + opfmtend, end=' ')

    if not args.output_column:
        print()


def build_dicts(sbo_path, PkgData, deptarget='REQUIRES', quirk_file='/dev/null' ):

    here = os.getcwd()
    os.chdir(sbo_path)
    hlog.debug("Using %s to index dependencies" % deptarget)
    reg_requires = re.compile(r'(?P<name>REQUIRES)="(?P<value>.*?)"', re.DOTALL)
    if deptarget == 'PREREQS':
        reg = re.compile(r'(?P<name>PREREQS)="(?P<value>.*?)"', re.DOTALL)
    elif deptarget == 'REQUIRES':
        reg = reg_requires
    else:
        hlog.critical("Unknown dependency target name (%s)" % deptarget)
        return

    PkgDataInfo = dict()
    DirectRequires = dict()
    PkgRequires = dict()
    PkgCategory = dict()
    PkgNeededBy = dict()

    PkgDataInfo['hoorex_data_version'] = HOOREX_VERSION
    PkgDataInfo['deptarget'] = deptarget

    # Step 1 - Create record of all SBo apps and their direct deps.
    #          Save as DirectRequires (which is later extended into PkgRequires)
    for dirpath, dirnames, filenames in os.walk('.'):
        for filenm in filenames:
            if fnmatch.fnmatch(filenm, '*.info'):
                #print(os.path.split(dirpath)[0].strip('.').strip('/'))
                #print(os.path.join(dirpath, filenm))
                category = os.path.split(dirpath)[0].strip('.').strip('/')
                pkgname = os.path.split(dirpath)[-1]
                if pkgname + '.info' == filenm:
                    with open(os.path.join(dirpath, filenm), 'r') as f:
                        # This try/except to ignore errant characters in the file
                        try:
                            txt = f.read()
                        except:
                            pass
                    m = reg.search(txt)
                    if m:
                        value = ''
                        if m.group('value'):
                            # Remove line continuation backslashes
                            value = m.group('value').replace(r'\\', r'')
                        hlog.debug("Adding %s ----- (%s)" % (pkgname, value))
                        PkgCategory[pkgname] = category
                        # Add any additional required packages from quirks_file
                        #DirectRequires[pkgname] = value.split()
                        DirectRequires[pkgname] = list(set(value.split() + add_quirk_entries(pkgname, quirk_file)))
                        # Expand any $REQUIRES in the list (as may be the case with some other DEPTARGET)
                        if '$REQUIRES' in DirectRequires[pkgname]:
                            requires_reg = reg_requires
                            m = requires_reg.search(txt)
                            if m:
                                value = ''
                                if m.group('value'):
                                    # Remove line continuation backslashes
                                    value = m.group('value').replace('\\', '')
                                hlog.debug("Adding %s ----- (%s)" % (pkgname, value))
                                DirectRequires[pkgname].remove('$REQUIRES')
                                DirectRequires[pkgname].extend(value.split())
                        try:
                            DirectRequires[pkgname].remove('%README%')
                        except:
                            pass

    #print
    hlog.debug("Step 1 done - %d entries" % len(DirectRequires))

    # Step 2 - Find extended requirements by traversing dependency tree.
    #          Save result as PkgRequires
    for (k,v) in DirectRequires.items():
        multilevel_depth = 0
        targets = v
        multi_list = []
        multi_list.extend(targets)
        while True:
            if len(targets) < 1:
                break

            pkglist = []
            for package in targets:
                if package in DirectRequires:
                    pkglist.extend(DirectRequires[package])

            if len(pkglist) > 0:
                multi_list.extend(pkglist)
            targets = list(set(pkglist))
            try:
                targets.remove('%README%')
            except:
                pass
            multilevel_depth += 1
            if multilevel_depth > DEPTH_LIMIT:
                break

        PkgRequires[k] = multi_list
    hlog.debug("Step 2 done - %d entries" % len(PkgRequires))


    # Step 3 - Generate record of which other pkgs require each SBo pkg
    #          Save as PkgNeededBy
    for k, v in DirectRequires.items():
        #print("%s ----- %s" % (k, v))
        if len(v) > 0:
            #print("%s ----- %s" % (k, v))
            for required in v:
                if not required in PkgNeededBy:
                    PkgNeededBy[required] = []
                PkgNeededBy[required].extend(k.split())
    hlog.debug("Step 3 done - %d entries" % len(PkgNeededBy))

    PkgData['PkgDataInfo'] = PkgDataInfo
    PkgData['DirectRequires'] = DirectRequires
    PkgData['PkgRequires'] = PkgRequires
    PkgData['PkgNeededBy'] = PkgNeededBy
    PkgData['PkgCategory'] = PkgCategory

    os.chdir(here)


def add_quirk_entries(target, quirk_file):
    q = []
    quirks = configparser.ConfigParser()
    quirks.read(quirk_file)
    if quirks.has_section(target):
        for k,v in quirks.items(target):
            #print(' {} = {}'.format(k,v))
            if k == 'qk_requires':
                return v.strip('\"').strip('\'').split()
    return q

def displayConfiguration():
    # Try to display the user's configuration

    config_dir = os.path.join(xdg_config_home, appname)
    config_file = os.path.join(config_dir, 'defaults.cfg')

    if not os.path.exists(config_file):
        print("Configuration file doesn't exist yet.")
    else:
        with open(config_file, 'r') as fin:
            print(fin.read())


def load_user_config():
    # Try to load the user's configuration
    # Create one if it doesn't already exist

    config_dir = os.path.join(xdg_config_home, appname)
    config_file = os.path.join(config_dir, 'defaults.cfg')
    user_config = configparser.ConfigParser()
    dirty = False

    if not os.path.exists(config_file):
        dirty = True
    else:
        user_config.read(config_file)

    if not 'HooRex' in user_config:
        dirty = True
        user_config['HooRex'] = {}

    if not 'force' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['force'] = 'False'

    if not 'output_column' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['output_column'] =  'False'

    if not 'output_long' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['output_long'] = 'False'

    if not 'output_installed' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['output_installed'] = 'False'

    if not 'multilevel' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['multilevel'] = 'False'

    if not 'reverse_lookup' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['reverse_lookup'] = 'False'

    if not 'unknown_strict' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['unknown_strict'] = 'False'

    if not 'debug' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['debug'] = 'False'

    if not 'slackware_repo' in user_config['HooRex']:
        dirty = True
        user_config['HooRex']['slackware_repo'] = '/var/log/packages'

    if not 'quirk_file' in user_config['HooRex']:
        dirty = True
        # This is the default quirk file location
        user_config['HooRex']['quirk_file'] = os.path.join(xdg_data_home, appname, 'quirks')

    if dirty:
        with open(config_file, 'w') as configfile:
            user_config.write(configfile)

    return user_config


def find_sbo_path():
    hlog.critical("SBO_PATH is not set. Please set it using the -s (--slackbuilds) option e.g.\n hoorex -s /home/thomas/slackbuilds")
    sys.exit(1)

def user_config_set(user_config, key, value):
    config_dir = os.path.join(xdg_config_home, appname)
    config_file = os.path.join(config_dir, 'defaults.cfg')

    # We assume we have been given a valid key & value
    user_config['HooRex'][key] = value
    with open(config_file, 'w') as configfile:
        user_config.write(configfile)
    configfile.close()

def sort_output(rawpkgdata, PkgRequires):
    orderedpkgdata = []

    # Continuously cycle through rawpkgdata
    # looking for an element with no dependencies still in rawpkgdata
    # at which time it is removed and added to the sorted list.
    # i.e. packages which are now strictly dependencies of others still
    # in the list (i.e. have no deps of their own in the rawpkgdata list)
    # are moved to sorted list before those which depend on them.
    # The sorted list can be guaranteed to have no member appearing in it
    # before any of its dependencies
    while True:
        for x in range(0, len(rawpkgdata)):
            if x >= len(rawpkgdata):
                continue

            has_dep = False
            prereqs = rawpkgdata[x].split() + PkgRequires[os.path.split(rawpkgdata[x])[-1]]
            for y in range(0, len(rawpkgdata)):
                #hlog.debug("Looking for %s in %s prereqs: %s" %(rawpkgdata[y], rawpkgdata[x], prereqs))
                if not rawpkgdata[y] == rawpkgdata[x] and rawpkgdata[y] in prereqs:
                    has_dep = True
                    hlog.debug("%s still needed (in %s)" %(rawpkgdata[y], rawpkgdata[x]))
                    continue
            if not has_dep:
                hlog.debug("Moving %s to sorted queue" % rawpkgdata[x])
                orderedpkgdata.append(rawpkgdata.pop(x))

        if len(rawpkgdata) == 0:
            break

    return orderedpkgdata


if __name__ == '__main__':
    main()
