#!/usr/bin/python3

#	cve-manager : CVE management tool
#	Copyright (C) 2017-2026 Alexey Appolonov
#
#	This program is free software: you can redistribute it and/or modify
#	it under the terms of the GNU General Public License as published by
#	the Free Software Foundation, either version 3 of the License, or
#	(at your option) any later version.
#
#	This program is distributed in the hope that it will be useful,
#	but WITHOUT ANY WARRANTY; without even the implied warranty of
#	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#	GNU General Public License for more details.
#
#	You should have received a copy of the GNU General Public License
#	along with this program.  If not, see <http://www.gnu.org/licenses/>.

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

from os                 import path
from re                 import sub as re_sub
from collections        import defaultdict
from sisyphus.repo      import RemoveSynonymousPackages
from cve_manager.common import GetDef
from cve_manager.const  import NVD_DATA_SRC
from cve_manager.ignored_pairs import IgnoredPairs
from cpe_map.const      import M_PRESCRIBED
from cpe_map.init       import Init
from cpe_map.common     import NewArgParser
from cpe_map.control    import TerminateIfAbsent, TerminateOrSkip

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Form a dict of mapping prescriptions

def GetPresc(target, conf_dir):

	presc = defaultdict(set)
	warnings = []

	FILE_NAME = f'{"cpe" if target == NVD_DATA_SRC else target}-mapping.csv'
	FILE_PATH = path.join(conf_dir, FILE_NAME)

	# If file exists
	if path.isfile(FILE_PATH):
		got_header = False
		line_n = 0
		# Parsing a file line by line
		with open(FILE_PATH, 'r') as f:
			while True:
				line = f.readline()
				line_n += 1
				if not line:
					break
				# Omitting a header
				if not got_header:
					got_header = True
					continue
				# Skipping empty lines
				line = line.strip()
				if not line:
					continue
				fields = [field.strip() for field in line.split(',')]
				if len(fields) < 2:
					warn = f'Wrong format of "{FILE_PATH}" file ' \
						f'in line {line_n}{f": {line}" if line else ""}'
					warnings.append(warn)
					continue
				package, products = fields[0], fields[1]
				if presc.get(package):
					warn = f'Reassignment of {package} in line {line_n} ' \
						f'of "{FILE_PATH}" file'
					warnings.append(warn)
				presc[package] |= set(products.split())

	return presc, warnings

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Convert initial dict {<branch>: {<package_name>: {<package_url>: {<product>:
# {<vendor>}}}}} to {<package_name>: {<product>}}

def ConvIgnoredPairs(ignored_pairs):

	ignored_pairs_conv = defaultdict(set)

	for branch, packages in ignored_pairs.items():
		for package_name, package_urls in packages.items():
			for package_url, products in package_urls.items():
				for product_name, vendors in products.items():
					if any(el in vendors for el in ('*', '')):
						ignored_pairs_conv[package_name].add(product_name)

	return ignored_pairs_conv

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Match packages that are related to already matched packages; There is a
# condition that must be fulfilled - there must be a group of at least two
# packages that are prescribed to match with the same products, and names
# of all these packages are differ only by [0-9\.]+ in the suffix

def MatchRelatedPackages(matches, ignored_pairs, related_package_names):

	# Generate a dict where the key is a pair of 1) a package name without a
	# numeric suffix and 2) a string consisting of matching products separated
	# by a space symbol, and the value is a set of original names of the
	# matching packages
	buff = defaultdict(set)
	for package, products in matches.items():
		package_name_without_num_part = re_sub(r'[0-9\.]+$', '', package)
		k = (package_name_without_num_part, ' '.join(sorted(products)))
		buff[k].add(package)

	# Check the condition and make an update if related package is not yet
	# matched with some product
	for simplified_package_name_and_matched_products, matched_packages \
			in buff.items():
		if len(matched_packages) < 2:
			continue
		_, matched_products = simplified_package_name_and_matched_products
		all_relatives = {related_package_name
			for package in matched_packages
				for related_package_name in related_package_names.get(package, [])}
		for package in all_relatives:
			if matches.get(package):
				continue
			matches[package] = set()
			ignored_matches_for_this_product = ignored_pairs.get(package, set())
			for product in matched_products.split():
				if product in ignored_matches_for_this_product:
					print(f'{package} ~ {product}')
					continue
				matches[package].add(product)

	return

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# Get matches between our packages, NVD/FSTEC names and prescripted pairs

def GetMatches(our_names, their_names, presc, ignored_pairs,
		related_package_names, package_prefixes):

	matches = defaultdict(set)
	unused_prescriptions = defaultdict(set)

	for package, products in presc.items():
		for product in products:
			if package in our_names and product in their_names:
				matches[package].add(product)
			else:
				# Notifying about unused prescriptions
				unused_prescriptions[package].add(product)

	MatchRelatedPackages(matches, ignored_pairs, related_package_names)

	matches = {package: '  '.join(products)
		for package, products in matches.items()}

	# Don't display a warning message that states that a prescription
	# for a package gem-<basename> was not unused if there is a used
	# prescription for a package ruby-<basename> (same for python packages etc.)
	unused_prescriptions_filterd = {package: unused_prescriptions[package]
		for package in
			RemoveSynonymousPackages(unused_prescriptions, matches, package_prefixes)}

	warnings = sorted([f'{package} >> {product}' for package, products in
		unused_prescriptions_filterd.items() for product in products])

	return matches, warnings

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

if __name__ == '__main__':

	# Parsing the args and getting the helper objects
	args = NewArgParser(ptype='p').parse_args()
	conf, speaker, mediator = Init(M_PRESCRIBED, args)

	# Forming a dict of prescribed mapping pairs
	all_presc = {}
	conf_dir = GetDef('CONF_DIR', args.debug)
	for data_source in args.data_sources:
		speaker.Say(f'Getting {data_source.upper()} prescriptions')
		all_presc[data_source], warnings = GetPresc(data_source, conf_dir)
		speaker.Status(extra_warn=warnings)
	if not all_presc or all([not presc for presc in all_presc]):
		exit(0)

	# Getting package names
	selection = {package for presc in all_presc.values()
		for package in presc.keys()
		if not args.packages or package in args.packages}
	if not selection:
		msg = 'There are no prescriptions'
		if args.packages:
			msg += ' for a given selection'
		speaker.Say(f'{msg}, nothing to do.', endl=True)
		exit(0)
	our_names = mediator.QueryPackages(selection, args.names)
	TerminateIfAbsent(our_names)

	related_package_names = mediator.QueryRelatives(selection)
	if related_package_names == None:
		exit(1)
	package_prefixes = mediator.GetPackagePrefixes()

	# For every type of selected data source (nvd, fstec)
	for data_source, presc in all_presc.items():

		# Read a dict of ignored matches that is used when matching related
		# packages
		ignored_pairs, msg = \
			IgnoredPairs(GetDef('CONF_DIR', args.debug), data_source).Read()
		if ignored_pairs == None:
			err = '[ERROR: Can\'t read the list of ignored mapping pairs' + \
				(f': {msg}' if msg else '') + ']'
			speaker.Status(err=msg)
			exit(1)

		# Remove everything except package names and product names from the dict
		ignored_pairs_conv = ConvIgnoredPairs(ignored_pairs)

		# Getting product names
		their_names = mediator.QueryProducts(data_source,
			{product for products in presc.values() for product in products})
		if TerminateOrSkip(their_names):
			continue

		speaker.Op(data_source, our_names, their_names)

		# Getting matches and a list of messages about unused prescriptions
		matches, warnings = GetMatches(our_names, their_names, presc,
			ignored_pairs_conv, related_package_names, package_prefixes)
		if warnings:
			speaker.Status(warn='Unused prescriptions left', extra=warnings)
		else:
			speaker.Status(f'All {len(presc)} prescriptions been used')

		if args.noupdate:
			speaker.Matches(matches)
			continue

		# Updating the table with the results
		if not mediator.SendMatches(data_source, matches):
			exit(1)

	exit(0)
