# ContentDB
# Copyright (C) rubenwardy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

from typing import List, Dict, Optional, Tuple

import sqlalchemy

from app.models import PackageType, Package, PackageState, PackageGameSupport
from app.utils import post_bot_message


minetest_game_mods = {
	"beds", "boats", "bucket", "carts", "default", "dungeon_loot", "env_sounds", "fire", "flowers",
	"give_initial_stuff", "map", "player_api", "sethome", "spawn", "tnt", "walls", "wool",
	"binoculars", "bones", "butterflies", "creative", "doors", "dye", "farming", "fireflies", "game_commands",
	"keys", "mtg_craftguide", "screwdriver", "sfinv", "stairs", "vessels", "weather", "xpanes",
}


mtg_mod_blacklist = {
	"pacman", "tutorial", "runorfall", "realtest_mt5", "mevo", "xaenvironment",
	"survivethedays", "holidayhorrors",
}


class GSPackage:
	author: str
	name: str
	type: PackageType

	provides: set[str]
	depends: set[str]

	user_supported_games: set[str]
	user_unsupported_games: set[str]
	detected_supported_games: set[str]
	supports_all_games: bool

	detection_disabled: bool

	is_confirmed: bool
	errors: set[str]

	def __init__(self, author: str, name: str, type: PackageType, provides: set[str]):
		self.author = author
		self.name = name
		self.type = type
		self.provides = provides
		self.depends = set()
		self.user_supported_games = set()
		self.user_unsupported_games = set()
		self.detected_supported_games = set()
		self.supports_all_games = False
		self.detection_disabled = False
		self.is_confirmed = type == PackageType.GAME
		self.errors = set()

		# For dodgy games, discard MTG mods
		if self.type == PackageType.GAME and self.name in mtg_mod_blacklist:
			self.provides.difference_update(minetest_game_mods)

	@property
	def id_(self) -> str:
		return f"{self.author}/{self.name}"

	@property
	def supported_games(self) -> set[str]:
		ret = set()
		ret.update(self.user_supported_games)
		if not self.detection_disabled:
			ret.update(self.detected_supported_games)
		ret.difference_update(self.user_unsupported_games)
		return ret

	@property
	def unsupported_games(self) -> set[str]:
		return self.user_unsupported_games


class GameSupport:
	packages: Dict[str, GSPackage]
	modified_packages: set[GSPackage]
	dependency_cache: Dict[str, Tuple[Optional[bool], Optional[set[str]]]]

	def __init__(self):
		self.packages = {}
		self.modified_packages = set()
		self.dependency_cache = {}

	@property
	def all_confirmed(self):
		return all([x.is_confirmed for x in self.packages.values()])

	@property
	def has_errors(self):
		return any([len(x.errors) > 0 for x in self.packages.values()])

	@property
	def error_count(self):
		return sum([len(x.errors) for x in self.packages.values()])

	@property
	def all_errors(self) -> set[str]:
		errors = set()
		for package in self.packages.values():
			for err in package.errors:
				errors.add(package.id_ + ": " + err)
		return errors

	def add(self, package: GSPackage) -> GSPackage:
		self.packages[package.id_] = package
		return package

	def get(self, id_: str) -> Optional[GSPackage]:
		return self.packages.get(id_)

	def get_all_that_provide(self, modname: str) -> List[GSPackage]:
		return [package for package in self.packages.values() if modname in package.provides]

	def get_all_that_depend_on(self, modname: str) -> List[GSPackage]:
		return [package for package in self.packages.values() if modname in package.depends]

	def _get_supported_games_for_modname(self, depend: str, visited: list[str]):
		dep_supports_all = False
		for_dep = set()

		if depend in self.dependency_cache:
			dep_supports_all, for_dep = self.dependency_cache[depend]
			return dep_supports_all, for_dep

		for provider in self.get_all_that_provide(depend):
			found_in = self._get_supported_games(provider, visited)
			if found_in is None:
				# Unsupported, keep going
				pass
			elif len(found_in) == 0:
				dep_supports_all = True
				break
			else:
				for_dep.update(found_in)

		self.dependency_cache[depend] = (dep_supports_all, for_dep)
		return dep_supports_all, for_dep

	def _get_supported_games_for_deps(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
		ret = set()

		for depend in package.depends:
			dep_supports_all, for_dep = self._get_supported_games_for_modname(depend, visited)

			if dep_supports_all:
				# Dep is game independent
				pass
			elif len(for_dep) == 0:
				package.errors.add(f"Unable to fulfill dependency {depend}")
				return None
			elif len(ret) == 0:
				ret = for_dep
			else:
				ret.intersection_update(for_dep)
				if len(ret) == 0:
					package.errors.add("Game support conflict, unable to install package on any games")
					return None

		return ret

	def _get_supported_games(self, package: GSPackage, visited: list[str]) -> Optional[set[str]]:
		if package.id_ in visited:
			first_idx = visited.index(package.id_)
			visited = visited[first_idx:]
			err = f"Dependency cycle detected: {' -> '.join(visited)} -> {package.id_}"
			for id_ in visited:
				package2 = self.get(id_)
				package2.errors.add(err)
			return None

		visited = visited.copy()
		visited.append(package.id_)

		if package.type == PackageType.GAME:
			return {package.name}
		elif package.is_confirmed:
			return package.supported_games
		else:
			ret = self._get_supported_games_for_deps(package, visited)
			if ret is None:
				assert len(package.errors) > 0
				return None

			ret = ret.copy()
			ret.difference_update(package.user_unsupported_games)
			package.detected_supported_games = ret
			self.modified_packages.add(package)

			if len(ret) > 0:
				for supported in package.user_supported_games:
					if supported not in ret:
						package.errors.add(f"`{supported}` is specified in supported_games but it is impossible to run {package.name} in that game. " +
								f"Its dependencies can only be fulfilled in {', '.join([f'`{x}`' for x in ret])}. " +
								"Check your hard dependencies.")

				if package.supports_all_games:
					package.errors.add(
							"This package cannot support all games as some dependencies require specific game(s): " +
							", ".join([f'`{x}`' for x in ret]))

			package.is_confirmed = True
			return package.supported_games

	def on_update(self, package: GSPackage, old_provides: Optional[set[str]] = None):
		self.dependency_cache = {}
		to_update = {package}
		checked = set()

		while len(to_update) > 0:
			current_package = to_update.pop()
			if current_package.id_ in self.packages and current_package.type != PackageType.GAME:
				current_package.is_confirmed = False
				current_package.detected_supported_games = []
				self._get_supported_games(current_package, [])

			provides = current_package.provides
			if current_package == package and old_provides is not None:
				provides = provides.union(old_provides)

			for modname in provides:
				for depending_package in self.get_all_that_depend_on(modname):
					if depending_package not in checked:
						to_update.add(depending_package)
						checked.add(depending_package)

	def on_remove(self, package: GSPackage):
		self.dependency_cache = {}
		del self.packages[package.id_]
		self.on_update(package)

	def on_first_run(self):
		self.dependency_cache = {}
		for package in self.packages.values():
			if not package.is_confirmed:
				self.on_update(package)


def _convert_package(support: GameSupport, package: Package) -> GSPackage:
	# Unapproved packages shouldn't be considered to fulfill anything
	provides = set()
	if package.state == PackageState.APPROVED:
		provides = set([x.name for x in package.provides])

	gs_package = GSPackage(package.author.username, package.name, package.type, provides)
	gs_package.depends = set([x.meta_package.name for x in package.dependencies if not x.optional])
	gs_package.detection_disabled = not package.enable_game_support_detection
	gs_package.supports_all_games = package.supports_all_games

	existing_game_support = (package.supported_games
			.filter(PackageGameSupport.game.has(state=PackageState.APPROVED),
					PackageGameSupport.confidence > 5)
			.all())
	if not package.supports_all_games:
		gs_package.user_supported_games = [x.game.name for x in existing_game_support if x.supports]
	gs_package.user_unsupported_games = [x.game.name for x in existing_game_support if not x.supports]
	return support.add(gs_package)


def _create_instance(session: sqlalchemy.orm.Session) -> GameSupport:
	support = GameSupport()

	packages: List[Package] = (session.query(Package)
			.filter(Package.state == PackageState.APPROVED, Package.type.in_([PackageType.GAME, PackageType.MOD]))
			.all())

	for package in packages:
		_convert_package(support, package)

	return support


def _persist(session: sqlalchemy.orm.Session, support: GameSupport):
	for gs_package in support.packages.values():
		if len(gs_package.errors) != 0:
			msg = "\n".join([f"- {x}" for x in gs_package.errors])
			package = session.query(Package).filter(
					Package.author.has(username=gs_package.author),
					Package.name == gs_package.name).one()
			post_bot_message(package, "Error when checking game support", msg, session)

	for gs_package in support.modified_packages:
		if not gs_package.detection_disabled:
			package = session.query(Package).filter(
					Package.author.has(username=gs_package.author),
					Package.name == gs_package.name).one()

			# Clear existing
			session.query(PackageGameSupport) \
				.filter_by(package=package, confidence=1) \
				.delete()

			# Add new
			supported_games = gs_package.supported_games \
				.difference(gs_package.user_supported_games)
			for game_name in supported_games:
				game_id = session.query(Package.id) \
					.filter(Package.type == PackageType.GAME, Package.name == game_name, Package.state == PackageState.APPROVED) \
					.one()[0]

				new_support = PackageGameSupport()
				new_support.package = package
				new_support.game_id = game_id
				new_support.confidence = 1
				new_support.supports = True
				session.add(new_support)


def game_support_update(session: sqlalchemy.orm.Session, package: Package, old_provides: Optional[set[str]]) -> set[str]:
	support = _create_instance(session)
	gs_package = support.get(package.get_id())
	if gs_package is None:
		gs_package = _convert_package(support, package)
	support.on_update(gs_package, old_provides)
	_persist(session, support)
	return gs_package.errors


def game_support_update_all(session: sqlalchemy.orm.Session):
	support = _create_instance(session)
	support.on_first_run()
	_persist(session, support)


def game_support_remove(session: sqlalchemy.orm.Session, package: Package):
	support = _create_instance(session)
	gs_package = support.get(package.get_id())
	if gs_package is None:
		gs_package = _convert_package(support, package)
	support.on_remove(gs_package)
	_persist(session, support)


def game_support_set(session, package: Package, game_is_supported: Dict[int, bool], confidence: int):
	previous_supported: Dict[int, PackageGameSupport] = {}
	for support in package.supported_games.all():
		previous_supported[support.game.id] = support

	for game_id, supports in game_is_supported.items():
		game = session.query(Package).get(game_id)
		lookup = previous_supported.pop(game_id, None)
		if lookup is None:
			support = PackageGameSupport()
			support.package = package
			support.game = game
			support.confidence = confidence
			support.supports = supports
			session.add(support)
		elif lookup.confidence <= confidence:
			lookup.supports = supports
			lookup.confidence = confidence

	for game, support in previous_supported.items():
		if support.confidence == confidence:
			session.delete(support)