#!/usr/pkg/bin/python3.13
#
# $NetBSD: python-versions-check,v 1.8 2024/03/06 13:38:18 wiz Exp $
#
# Copyright (c) 2023 The NetBSD Foundation, Inc.
# All rights reserved.
#
# This code is derived from software contributed to The NetBSD Foundation
# by Thomas Klausner.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE NETBSD FOUNDATION, INC. AND CONTRIBUTORS
# ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
# TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
# PURPOSE ARE DISCLAIMED.  IN NO EVENT SHALL THE FOUNDATION OR CONTRIBUTORS
# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#

'''For a given Python package, find all packages that use it
(by looking at DEPENDS, BUILD_DEPENDS, TOOL_DEPENDS)
the compare acceptable and incompatible versions.'''

import argparse
from collections import defaultdict
import glob
import os
import pathlib
import re
import sys

# only accept includes with ../../ or in the current directory
include_re = re.compile(r'\s*\.\s*include\s+"(\.\./\.\./[^/]*/[^/]*/|)([^/]*)"')
depends_re = re.compile(r'[^#]*DEPENDS.*:(\.\./\.\./.*)')
# '+=', '?='
pva_re = re.compile(r'PYTHON_VERSIONS_ACCEPTED\s*\+?\??\s*=\s*([0-9 ]*)')
pvi_re = re.compile(r'PYTHON_VERSIONS_INCOMPATIBLE\s*\+?\??\s*=\s*([0-9 ]*)')


# all available Python versions
existing = set([])
# dictionary for pkg_path -> dependencies
includes = defaultdict(set)
# dictionary for pkg_path -> allowed Python versions
python_versions = defaultdict(set)


def supported_versions(pkg_path):
    '''Return Python versions supported by a package.'''
    if pkg_path in python_versions:
        return python_versions[pkg_path]
    return existing


def extract_python_versions(path, apply_existing=True):
    '''Find the supported Python versions for a package.'''
    accepted = set([])
    if apply_existing:
        accepted = existing
    with open(path, 'r', encoding='utf-8') as input_file:
        for line in input_file.readlines():
            if m := pva_re.match(line):
                accepted = set(m.group(1).split())
            elif m := pvi_re.match(line):
                accepted = accepted - set(m.group(1).split())
    return accepted


def extract_includes(path, dict_key=None):
    '''Read the interesting parts of a Makefile (fragment).'''
    pkg_path = get_pkg_path(path)
    directory = path[:path.rfind('/')+1]
    if not dict_key:
        dict_key = pkg_path
    # elif dict_key in includes:
    #     # already handled
    #     return includes[dict_key]
    any_python_include = False
    if args.debug:
        print(f"DEBUG: parsing {path}")
    with open(path, 'r', encoding='utf-8') as input_file:
        for line in input_file.readlines():
            if m := include_re.match(line):
                file_path = m.group(1)
                file_name = m.group(2)
                # skip any unexpanded variables - we're not a full parser
                # skip 'mk' includes
                if '/mk/' in file_path or '${' in file_path or '${' in file_name:
                    continue
                if 'lang/python/' in file_path:
                    any_python_include = True
                # local includes and Makefile.common includes are parsed immediately
                if len(file_path) == 0:
                    extract_includes(directory + m.group(2), dict_key)
                elif pkg_path + '/' in file_path or file_name == 'Makefile.common':
                    full_path = absolute_path(file_path + file_name)
                    extract_includes(full_path, dict_key)
                # non-local ones are dependencies
                elif 'lang/lua/' in file_path \
                     or 'print/texlive' in file_path \
                     or 'lang/python' in file_path:
                    # has no Makefile
                    continue
                else:
                    includes[dict_key].add(get_pkg_path(file_path))
            elif m := depends_re.match(line):
                file_path = m.group(1)
                if '${' in file_path:
                    continue
                includes[dict_key].add(get_pkg_path(m.group(1)))
            elif m := pva_re.match(line):
                python_versions[dict_key] = set(m.group(1).split())
            elif m := pvi_re.match(line):
                python_versions[dict_key] = supported_versions(dict_key) - set(m.group(1).split())
    if not any_python_include:
        includes[dict_key] = set([])
    if args.debug:
        print(f"DEBUG: result {path} (for {dict_key}) supports {python_versions[dict_key]}")
    return includes[dict_key]


def absolute_path(path):
    '''Convert relative path to absolute one.'''
    if path.startswith('../../'):
        return args.pkgsrcdir + '/' + path[6:]
    return path


def get_pkg_path(full_path):
    '''Strip pkgsrcdir from path.'''
    cand = str(full_path)
    if cand.startswith(args.pkgsrcdir):
        cand = cand[len(args.pkgsrcdir)+1:]
    elif cand.startswith('../../'):
        cand = cand[6:]

    if cand.count('/') == 2:
        cand = cand[:cand.rfind('/')]
    return cand


def report_problem(first, supports, superset, subset):
    '''Pretty-print a problem with mismatching Python versions.'''
    difference = superset - subset
    difference = sorted([int(x) for x in difference])
    supports = sorted([int(x) for x in supports])
    print(f'{first}: supports {supports}, missing: {difference}')


if 'PKGSRCDIR' in os.environ:
    pkgsrcdir = os.environ['PKGSRCDIR']
else:
    pkgsrcdir = '/usr/pkgsrc'

parser = argparse.ArgumentParser(description='compare supported Python versions for package ' +
                                 'and all packages it depends upon and that depend on it')
parser.add_argument('package', nargs='?',
                    help='package whose dependencies we want to check (default: current directory)')
parser.add_argument('-d', dest='debug', default=False,
                    help='debug mode - print each file name when its parsed', action='store_true')
parser.add_argument('-p', dest='pkgsrcdir', default=pkgsrcdir,
                    help='path to the pkgsrc root directory', action='store')
parser.add_argument('-w', dest='wip', default=False,
                    help='include wip in search for packages using it', action='store_true')
args = parser.parse_args()

if not args.package:
    current_path = pathlib.Path().resolve()
    mk = current_path.joinpath('../../mk')
    doc = current_path.joinpath('../../doc')
    if not doc.exists() or not mk.exists():
        print('not inside a pkgsrc directory, can not guess package')
        sys.exit(1)
    args.package = str(current_path.parent.name) + '/' + str(current_path.name)

if not pathlib.Path(args.pkgsrcdir).exists() or \
   not pathlib.Path(args.pkgsrcdir + '/doc').exists() or \
   not pathlib.Path(args.pkgsrcdir + '/mk').exists():
    print(f'invalid pkgsrc directory "{args.pkgsrcdir}"')
    sys.exit(1)

existing = extract_python_versions(args.pkgsrcdir + '/lang/python/pyversion.mk', False)
supported = extract_python_versions(args.pkgsrcdir + '/' + args.package + '/Makefile')

searchlist = set([args.package])
result = set([])
while searchlist:
    entry = searchlist.pop()
    # already handled?
    if entry in result:
        continue
    result.add(entry)
    searchlist |= extract_includes(args.pkgsrcdir + '/' + entry + '/Makefile')

# print(f"dependencies for {args.package}: {sorted(result)}")
print(f"Supported Python versions for {args.package}: {sorted([int(x) for x in supported_versions(args.package)])}")
print(f"Checking packages used by {args.package}:")
for entry in result:
    entry_versions = supported_versions(entry)
    if args.debug:
        print(f"DEBUG: comparing to {entry} - supports {entry_versions}")
    if not entry_versions.issuperset(supported_versions(args.package)):
        report_problem(entry, entry_versions, supported_versions(args.package), entry_versions)

makefiles = glob.glob(args.pkgsrcdir + '/*/*/Makefile*')
makefiles.extend(glob.glob(args.pkgsrcdir + '/*/*/*.mk'))
makefiles = list(filter(lambda name: name.find('/mk/') == -1
                        and not name.endswith('buildlink3.mk')
                        and not name.endswith('cargo-depends.mk')
                        and not name.endswith('go-modules.mk'),
                        makefiles))
if not args.wip:
    makefiles = list(filter(lambda name: name.find('/wip/') == -1, makefiles))

print(f"Checking packages using {args.package}:")
checked_packages = set([])
makefile_content = {}
for makefile in makefiles:
    extract_includes(makefile)

searchlist = set([args.package])
handled = set([])
while searchlist:
    entry = searchlist.pop()
    if entry in handled:
        continue
    handled.add(entry)

    entry_versions = supported_versions(entry)
    for package, dependencies in includes.items():
        if entry in dependencies:
            package_versions = supported_versions(package)
            if not entry_versions.issuperset(package_versions):
                report_problem(package, package_versions, package_versions, entry_versions)
                python_versions[package] = entry_versions
                searchlist.add(package)
