#!/usr/bin/python3
#
# suse-lifecycle
# Copyright (C) 2025  SUSE LLC
#
# 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.
#

DEFAULT_DATABASE_PATH = '/usr/share/suse-lifecycles'

import argparse
import yaml
import datetime
import glob
import sys

debugEnabled = False

def debug(*args, **kwargs):
	if debugEnabled:
		print(*args, **kwargs)

def error(msg):
	print(f"Error: {msg}", file = sys.stderr)

def fatal(msg):
	print(f"Error: {msg}", file = sys.stderr)
	exit(1)

class LifecycleApplication(object):
	def __init__(self, name):
		self.name = name
		self.args = argparse.ArgumentParser(name)
		self.opts = None
		self.db = None

		self.args.add_argument('-D', '--database', default = DEFAULT_DATABASE_PATH,
				help = f'Specify an alternative directory location where to find life cycle data [{DEFAULT_DATABASE_PATH}]')
		self.args.add_argument('--lts', default = False, action = 'store_true',
				help = 'Show results for a Long-Term Support subscription')
		self.args.add_argument('--lifecycle-info', default = False, action = 'store_true',
				help = 'Display detailed per-lifecycle information')
		self.args.add_argument('-t', '--terse', default = False, action = 'count',
				help = 'Decrease verbosity')
		self.args.add_argument('action',
				help = 'Type of query (package, lifecycle, check)')
		self.args.add_argument('names', nargs = '*')

	def run(self):
		if self.opts is not None:
			raise Exception(f"You cannot step into the same river twice")

		self.opts = self.args.parse_args()

		self.loadDB(self.opts.database)
		if self.db.defaultLifecycle is None:
			print(f"No life cycle information available for this platform")
			return

		if self.opts.action == 'package':
			self.queryPackages()
		elif self.opts.action == 'lifecycle':
			self.queryLifecycles()
		elif self.opts.action == 'check':
			self.checkInstallation()
		else:
			fatal(f"Unknown action {self.opts.action}")

	def queryPackages(self):
		if not self.opts.names:
			fatal(f"please specify one or more package names to query for.")

		display = PackageDisplay(self)
		display.begin()

		for name in self.opts.names:
			lifecycle = self.db.getLifecycleByPackage(name)
			if lifecycle is None:
				lifecycle = self.db.defaultLifecycle

			product = self.db.getProductByPackage(name)
			if self.opts.lts:
				contract = lifecycle.lts
			else:
				contract = lifecycle.general

			display.show(name, product, lifecycle, contract)

		display.end()

	def queryLifecycles(self):
		if not self.opts.names:
			fatal(f"please specify one or more life cycle names to query for.")

		display = LifecycleDisplay(self)
		display.begin()

		for name in self.opts.names:
			lifecycle = self.db.getLifecycleByName(name)
			if lifecycle is None:
				lifecycle = self.db.defaultLifecycle

			display.show(lifecycle)

		display.end()

	def checkInstallation(self):
		raise Exception(f"Action {self.opts.action} not yet implemented")

	def loadDB(self, path):
		debug(f"Loading database from {path}")
		self.db = LifecycleDBLoader(path).load()

class PackageDisplay(object):
	def __init__(self, application):
		self.verbose = application.opts.terse == 0

		if application.opts.terse <= 1:
			self.lifecycleDisplay = LifecycleDisplay(application)
			self.lifecyclesSeen = set()
		else:
			self.lifecycleDisplay = None
			self.lifecyclesSeen = None

		self.today = datetime.date.today()

	def row(self, *values):
		if not values:
			return ""

		values = list(values)
		first = values.pop(0)
		row = [f"{first:20}"]

		for field in values:
			field = str(field)
			row.append(f"{field:16}")
		print(' '.join(row))

	def begin(self):
		self.row("Package", "Product", "Until", "Life Cycle")

	def show(self, packageName, product, lifecycle, contract):
		if product is None:
			self.row(packageName, "(3rd party)", "n/a", "n/a")
			return

		eol = None
		if contract and contract.supported:
			eol = contract.end
			if eol < self.today:
				eol = "(end of life)"
		if eol is None:
			eol = "(not supported)"

		# For all remaining life cycle info we display, use the life cycle this implements
		# (eg rust1.87 -> rust; go1.24 -> go; ...)
		if lifecycle.implements is not None:
			lifecycle = lifecycle.implements

		lifecycleDescription = lifecycle.name
		if not self.lifecycleDisplay:
			if lifecycle.mode not in (None, 'sequential'):
				lifecycleDescription += f"; mode={lifecycle.mode}"
			if lifecycle.stability not in (None, 'compatible'):
				lifecycleDescription += f"; stability={lifecycle.stability}"
			if lifecycle.cadence is not None:
				lifecycleDescription += f"; cadence={lifecycle.cadence}"

		self.row(packageName, product.id, eol, lifecycleDescription)
		if self.lifecyclesSeen is not None:
			self.lifecyclesSeen.add(lifecycle)

	def end(self):
		if self.lifecycleDisplay and self.lifecyclesSeen:
			self.lifecycleDisplay.begin()
			for lifecycle in sorted(self.lifecyclesSeen, key = str):
				self.lifecycleDisplay.show(lifecycle)
			self.lifecycleDisplay.end()

class LifecycleDisplay(object):
	def __init__(self, application):
		self.verbose = application.opts.terse == 0

	def begin(self):
		print()
		print("Life cycle information")

	def end(self):
		pass

	def show(self, lifecycle):
		print(f"   {lifecycle}")

		self.renderMode(lifecycle)
		self.renderStability(lifecycle)
		self.renderCadence(lifecycle)
		self.renderDescription(lifecycle)
		print()

	def renderMode(self, lifecycle):
		value = lifecycle.mode
		if self.verbose:
			if lifecycle.mode == 'sequential':
				value = "sequential (new version replaces all previous versions)"
			elif lifecycle.mode == 'versioned':
				value = "versioned (new version coexists with one or more previous versions)"
		self.renderLifecycleDetail('mode:', value)

	def renderStability(self, lifecycle):
		value = lifecycle.stability
		if self.verbose:
			if lifecycle.stability == 'compatible':
				value = "compatible (new versions are backward compatible unless noted)"
			elif lifecycle.stability == 'upstream':
				value = "upstream (emphasis is on following upstream project)"
			elif lifecycle.stability == 'none':
				value = "none (may change in incompatible ways)"
		self.renderLifecycleDetail('stability:', value)

	def renderCadence(self, lifecycle):
		value = lifecycle.cadence
		if self.verbose:
			if value == 'minor_release':
				value += " (new versions may be introduced with product minor release)"
		self.renderLifecycleDetail('cadence:', value)

	def renderDescription(self, lifecycle):
		if not self.verbose or \
		   lifecycle.description is None:
			return

		key = 'description:'
		for line in lifecycle.description.split('\n'):
			self.renderLifecycleDetail(key, line)
			key = ''

	def renderLifecycleDetail(self, key, value):
		if value is not None:
			print(f"      {key:10} {value}")


class LifecycleDB(object):
	class Product(object):
		def __init__(self, id = None, name = None, type = None, base_product = None):
			assert(id is not None and name is not None)
			assert(type != 'extension' or base_product is not None)

			self.id = id
			self.name = name
			self.type = type
			self.base_product = base_product

	class Lifecycle(object):
		def __init__(self, name):
			self.name = name
			self.description = None
			self.mode = None
			self.cadence = None
			self.stability = None
			self.release = None
			self.general = None
			self.lts = None
			self.rpms = []
			self.implementations = None
			self.implements = None

			self.context = f"life cycle {self.name}"

		def __str__(self):
			return self.name

		def constrain(self, other):
			if self.general is not None:
				self.general.constrain(other.general)
			if self.lts is not None:
				self.lts.constrain(other.lts)

	class Contract(object):
		def __init__(self, name, owner = None):
			self.name = name
			self.supported = False
			self.end = None

			self.context = f"{owner.context}/contract {name}"

		def constrain(self, other):
			if other is None:
				return
			if not other.supported:
				self.supported = False
			if not self.supported:
				return
			if self.end is None or self.end > other.end:
				debug(f"{self.context}: constrain to {other.end}")
				self.end = other.end

	def __init__(self, pathname):
		self.path = pathname
		self._lifecycles = {}
		self._rpmLifecycles = {}
		self._rpmProducts = {}
		self._products = {}
		self.defaultLifecycle = None

	@property
	def lifecycles(self):
		return self._lifecycles.values()

	def getLifecycleByName(self, name):
		return self._lifecycles.get(name)

	def getLifecycleByPackage(self, name):
		return self._rpmLifecycles.get(name)

	def getProductByPackage(self, name):
		return self._rpmProducts.get(name)

	def createProduct(self, **kwargs):
		product = self.Product(**kwargs)
		self._products[product.id] = product
		return product

	def createLifecycle(self, name):
		assert(name not in self._lifecycles)

		lifecycle = self.Lifecycle(name)
		self._lifecycles[name] = lifecycle
		return lifecycle

	def createContract(self, lifecycle, name):
		if name not in ('general', 'lts'):
			print(f"Warning: {lifecycle.context} - ignoring unknown support contract \"{name}\"")
			return

		contract = self.Contract(name, owner = lifecycle)
		if name == 'general':
			lifecycle.general = contract
		if name == 'lts':
			lifecycle.lts = contract
		return contract

class LifecycleDBLoader(object):
	def __init__(self, pathname):
		self.path = pathname

	def load(self):
		db = LifecycleDB(self.path)

		pattern = f"{self.path}/*.yaml"
		found = glob.glob(pattern)
		if not found:
			fatal(f"no life cycle data in {self.path}")

		for filename in found:
			self.loadFile(db, filename)

		return db

	def loadFile(self, db, filename):
		debug(f"Processing {filename}")
		with open(filename) as f:
			data = yaml.full_load(f)
		assert(type(data) is dict)

		if '__info__' not in data:
			raise Exception(f"Error: {self.path} has incompatible schema version {version}; expected 1.0")
		product = self.processInfo(db, filename, data.pop('__info__'))

		for id, lifecycleData in data.items():
			rpms = []
			if 'rpms' in lifecycleData:
				rpms = lifecycleData.pop('rpms')

			lifecycle = None
			if product.type == 'extension':
				lifecycle = db.getLifecycleByName(id)

			if lifecycle is None:
				lifecycle = db.createLifecycle(id)

				if db.defaultLifecycle is None:
					db.defaultLifecycle = lifecycle

				self.processLifecycle(db, lifecycle, lifecycleData)

			for rpm in rpms:
				db._rpmLifecycles[rpm] = lifecycle
				db._rpmProducts[rpm] = product

		for lifecycle in db.lifecycles:
			lifecycle.constrain(db.defaultLifecycle)

		for lifecycle in db.lifecycles:
			if lifecycle.implementations is None:
				continue

			for name in lifecycle.implementations:
				other = db.getLifecycleByName(name)
				if other is None:
					# print(f"Warning: {self.path}: {lifecycle.context} references unknown life cycle {name}")
					continue

				other.implements = lifecycle

	def processInfo(self, db, filename, data):
		version = str(data.get('schema'))
		if version != '1.0':
			raise Exception(f"Error: {filename} has incompatible schema version {version}; expected 1.0")

		pdata = data.get('product')
		if pdata is None:
			raise Exception(f"Error: {filename} lacks product information")

		return db.createProduct(**pdata)

	def processLifecycle(self, db, lifecycle, data):
		for key, value in data.items():
			if key in ('description', 'mode', 'cadence', 'stability', 'release'):
				value = self.processScalar(lifecycle, key, value)
				setattr(lifecycle, key, value)
			elif key in ('general', 'lts'):
				value = self.processDict(lifecycle, key, value)
				if type(value) is not dict:
					raise Exception(f"{self.path}: bad attribute in life cycle {id}: {key} has value type {type(value)}")

				contract = db.createContract(lifecycle, key)
				self.processContract(contract, value)
			elif key == 'rpms':
				# we should check the values
				lifecycle.rpms += value
			elif key == 'implementations':
				# we should check the values
				lifecycle.implementations = value
			elif key == 'roadmap':
				# ignore this for now
				pass
			else:
				print(f"TBD {lifecycle.context} {key}={value}")

	def processContract(self, contract, data):
		for key, value in data.items():
			if key in ('supported', 'end'):
				value = self.processScalar(contract, key, value)
				setattr(contract, key, value)
			else:
				print(f"TBD {contract.context} {key}={value}")

	def processScalar(self, object, key, value):
		t = type(value)
		if t in (bool, str, datetime.date):
			return value

		raise Exception(f"{self.path}: bad attribute in {object.context}: {key} has value type {t}")

	def processDict(self, object, key, value):
		t = type(value)
		if t is dict:
			return value

		raise Exception(f"{self.path}: bad attribute in {object.context}: {key} has value type {t}")

if __name__ == '__main__':
	app = LifecycleApplication('suse-lifecycle')
	app.run()
