# For license information, please see license.txt
from __future__ import annotations

import json
from contextlib import suppress
from functools import cached_property
from itertools import chain
from typing import TYPE_CHECKING, TypedDict

import jingrow
import jingrow.query_builder
import semantic_version as sv
from jingrow import _
from jingrow.core.pagetype.version.version import get_diff
from jingrow.core.utils import find, find_all
from jingrow.model.page import Page
from jingrow.model.naming import append_number_if_name_exists
from jingrow.query_builder.functions import Count
from jingrow.utils import cstr, flt, get_url, sbool
from jingrow.utils.caching import redis_cache

from jcloud.api.client import dashboard_whitelist
from jcloud.overrides import get_permission_query_conditions_for_pagetype
from jcloud.jcloud.pagetype.app.app import new_app
from jcloud.jcloud.pagetype.app_source.app_source import AppSource, create_app_source
from jcloud.jcloud.pagetype.deploy_candidate.utils import is_suspended
from jcloud.jcloud.pagetype.resource_tag.tag_helpers import TagHelpers
from jcloud.jcloud.pagetype.server.server import Server
from jcloud.utils import (
	get_app_tag,
	get_client_blacklisted_keys,
	get_current_team,
	get_last_pg,
	log_error,
)

if TYPE_CHECKING:
	from datetime import datetime
	from typing import Any

DEFAULT_DEPENDENCIES = [
	{"dependency": "NVM_VERSION", "version": "0.36.0"},
	{"dependency": "NODE_VERSION", "version": "14.19.0"},
	{"dependency": "PYTHON_VERSION", "version": "3.7"},
	{"dependency": "WKHTMLTOPDF_VERSION", "version": "0.12.5"},
	{"dependency": "BENCH_VERSION", "version": "5.15.2"},
]


class LastDeployInfo(TypedDict):
	name: str
	status: str
	creation: datetime


if TYPE_CHECKING:
	from jcloud.jcloud.pagetype.app.app import App
	from jcloud.jcloud.pagetype.deploy_candidate.deploy_candidate import DeployCandidate


class ReleaseGroup(Page, TagHelpers):
	# begin: auto-generated types
	# This code is auto-generated. Do not modify anything in this block.

	from typing import TYPE_CHECKING

	if TYPE_CHECKING:
		from jingrow.types import DF

		from jcloud.jcloud.pagetype.common_site_config.common_site_config import CommonSiteConfig
		from jcloud.jcloud.pagetype.release_group_app.release_group_app import ReleaseGroupApp
		from jcloud.jcloud.pagetype.release_group_dependency.release_group_dependency import (
			ReleaseGroupDependency,
		)
		from jcloud.jcloud.pagetype.release_group_mount.release_group_mount import (
			ReleaseGroupMount,
		)
		from jcloud.jcloud.pagetype.release_group_package.release_group_package import (
			ReleaseGroupPackage,
		)
		from jcloud.jcloud.pagetype.release_group_server.release_group_server import (
			ReleaseGroupServer,
		)
		from jcloud.jcloud.pagetype.release_group_variable.release_group_variable import (
			ReleaseGroupVariable,
		)
		from jcloud.jcloud.pagetype.resource_tag.resource_tag import (
			ResourceTag,
		)

		apps: DF.Table[ReleaseGroupApp]
		bench_config: DF.Code | None
		build_server: DF.Link | None
		central_bench: DF.Check
		common_site_config: DF.Code | None
		common_site_config_table: DF.Table[CommonSiteConfig]
		compress_app_cache: DF.Check
		default: DF.Check
		dependencies: DF.Table[ReleaseGroupDependency]
		enabled: DF.Check
		environment_variables: DF.Table[ReleaseGroupVariable]
		gunicorn_threads_per_worker: DF.Int
		is_code_server_enabled: DF.Check
		is_push_to_deploy_enabled: DF.Check
		is_redisearch_enabled: DF.Check
		last_dependency_update: DF.Datetime | None
		max_background_workers: DF.Int
		max_gunicorn_workers: DF.Int
		merge_all_rq_queues: DF.Check
		merge_default_and_short_rq_queues: DF.Check
		min_background_workers: DF.Int
		min_gunicorn_workers: DF.Int
		mounts: DF.Table[ReleaseGroupMount]
		packages: DF.Table[ReleaseGroupPackage]
		public: DF.Check
		redis_cache_size: DF.Int
		saas_app: DF.Link | None
		saas_bench: DF.Check
		servers: DF.Table[ReleaseGroupServer]
		tags: DF.Table[ResourceTag]
		team: DF.Link
		title: DF.Data
		use_app_cache: DF.Check
		use_delta_builds: DF.Check
		use_rq_workerpool: DF.Check
		version: DF.Link
	# end: auto-generated types

	dashboard_fields = ("title", "version", "apps", "team", "public", "tags")

	@staticmethod
	def get_list_query(query, filters, **list_args):
		ReleaseGroupServer = jingrow.qb.PageType("Release Group Server")
		ReleaseGroup = jingrow.qb.PageType("Release Group")
		Bench = jingrow.qb.PageType("Bench")
		Site = jingrow.qb.PageType("Site")

		site_count = (
			jingrow.qb.from_(Site)
			.select(jingrow.query_builder.functions.Count("*"))
			.where(Site.group == ReleaseGroup.name)
			.where(Site.status != "Archived")
		)

		active_benches = (
			jingrow.qb.from_(Bench)
			.select(jingrow.query_builder.functions.Count("*"))
			.where(Bench.group == ReleaseGroup.name)
			.where(Bench.status == "Active")
		)

		query = (
			query.where(ReleaseGroup.team == jingrow.local.team().name)
			.where(ReleaseGroup.enabled == 1)
			.where(ReleaseGroup.public == 0)
			.select(site_count.as_("site_count"), active_benches.as_("active_benches"))
		)

		if server := filters.get("server"):
			query = (
				query.inner_join(ReleaseGroupServer)
				.on(ReleaseGroupServer.parent == ReleaseGroup.name)
				.where(ReleaseGroupServer.server == server)
			)

		return query

	def get_pg(self, pg):
		pg.deploy_information = self.deploy_information()
		pg.status = self.status
		pg.actions = self.get_actions()
		pg.are_builds_suspended = are_builds_suspended()
		pg.eol_versions = jingrow.db.get_all(
			"Jingrow Version",
			filters={"status": "End of Life"},
			fields=["name"],
			order_by="name desc",
			pluck="name",
		)

		if len(self.servers) == 1:
			server = jingrow.db.get_value("Server", self.servers[0].server, ["team", "title"], as_dict=True)
			pg.server = self.servers[0].server
			pg.server_title = server.title
			pg.server_team = server.team

		pg.enable_inplace_updates = jingrow.get_value(
			"Team",
			self.team,
			"enable_inplace_updates",
		)
		if pg.enable_inplace_updates:
			pg.inplace_update_failed_benches = self.get_inplace_update_failed_benches()

	def get_inplace_update_failed_benches(self):
		return jingrow.db.get_all(
			"Bench",
			{"group": self.name, "status": "Active", "last_inplace_update_failed": True},
			pluck="name",
		)

	def get_actions(self):
		return [
			{
				"action": "Rename Bench Group",
				"description": "Rename the bench group",
				"button_label": "Rename",
				"pg_method": "rename",
			},
			{
				"action": "Transfer Bench Group",
				"description": "Transfer ownership of this bench group to another team",
				"button_label": "Transfer",
				"pg_method": "send_change_team_request",
			},
			{
				"action": "Drop Bench Group",
				"description": "Drop the bench group",
				"button_label": "Drop",
				"pg_method": "drop",
				"group": "Dangerous Actions",
			},
		]

	def validate(self):
		self.validate_title()
		self.validate_jingrow_app()
		self.validate_duplicate_app()
		self.validate_app_versions()
		self.validate_servers()
		self.validate_rq_queues()
		self.validate_max_min_workers()
		self.validate_feature_flags()

	def before_insert(self):
		# to avoid adding deps while cloning a release group
		if len(self.dependencies) == 0:
			self.fetch_dependencies()
		self.set_default_app_cache_flags()
		self.set_default_delta_builds_flags()
		self.setup_default_feature_flags()

	def after_insert(self):
		from jcloud.jcloud.pagetype.jcloud_role.jcloud_role import (
			add_permission_for_newly_created_pg,
		)

		add_permission_for_newly_created_pg(self)

	def on_update(self):
		old_pg = self.get_pg_before_save()
		if self.flags.in_insert or self.is_new() or not old_pg:
			return
		diff = get_diff(old_pg, self) or {}
		for row in chain(diff.get("row_changed", []), diff.get("added", [])):
			if row[0] == "dependencies":
				self.db_set("last_dependency_update", jingrow.utils.now_datetime())
				break
		if self.has_value_changed("team"):
			jingrow.db.delete("Jcloud Role Permission", {"release_group": self.name})

	def on_trash(self):
		candidates = jingrow.get_all("Deploy Candidate", {"group": self.name})
		for candidate in candidates:
			jingrow.delete_pg("Deploy Candidate", candidate.name)

	def before_save(self):
		self.update_common_site_config_preview()

	def update_common_site_config_preview(self):
		"""Regenerates rg.common_site_config on each rg.before_save
		from the rg.common_site_config child table data"""
		new_config = {}

		for row in self.common_site_config_table:
			# update internal flag from master
			row.internal = jingrow.db.get_value("Site Config Key", row.key, "internal")
			key_type = row.type or row.get_type()
			row.type = key_type

			if key_type == "Number":
				key_value = int(row.value) if isinstance(row.value, (float, int)) else json.loads(row.value)
			elif key_type == "Boolean":
				key_value = row.value if isinstance(row.value, bool) else bool(json.loads(cstr(row.value)))
			elif key_type == "JSON":
				key_value = json.loads(cstr(row.value))
			else:
				key_value = row.value

			new_config[row.key] = key_value

		self.common_site_config = json.dumps(new_config, indent=4)

	@dashboard_whitelist()
	def update_dependency(self, dependency_name, version, is_custom):
		"""Updates a dependency version in the Release Group Dependency table"""
		for dependency in self.dependencies:
			if dependency.name == dependency_name:
				dependency.version = version
				dependency.is_custom = is_custom
				self.save()
				return

	@dashboard_whitelist()
	def delete_config(self, key):
		"""Deletes a key from the common_site_config_table"""

		if key in get_client_blacklisted_keys():
			return

		updated_common_site_config = []
		for row in self.common_site_config_table:
			if row.key != key and not row.internal:
				updated_common_site_config.append({"key": row.key, "value": row.value, "type": row.type})

		# using a tuple to avoid updating bench_config
		# TODO: remove tuple when bench_config is removed and field for http_timeout is added
		self.update_config_in_release_group(updated_common_site_config, ())

	@dashboard_whitelist()
	def update_config(self, config):
		sanitized_common_site_config = [
			{"key": c.key, "type": c.type, "value": c.value} for c in self.common_site_config_table
		]
		sanitized_bench_config = []
		bench_config_keys = ["http_timeout"]

		config = jingrow.parse_json(config)

		for key, value in config.items():
			if key in get_client_blacklisted_keys():
				jingrow.throw(_(f"The key <b>{key}</b> is blacklisted or is internal and cannot be updated"))

			config_type = get_config_type(value)

			if jingrow.db.exists("Site Config Key", key):
				config_type = jingrow.db.get_value("Site Config Key", key, "type")

			value = get_formatted_config_value(
				config_type,
				value,
				key,
				self.name,
			)

			if key in bench_config_keys:
				sanitized_bench_config.append({"key": key, "value": value, "type": config_type})

			# update existing key
			for row in sanitized_common_site_config:
				if row["key"] == key:
					row["value"] = value
					row["type"] = config_type
					break
			else:
				sanitized_common_site_config.append({"key": key, "value": value, "type": config_type})

		self.update_config_in_release_group(sanitized_common_site_config, sanitized_bench_config)
		self.update_benches_config()

	def update_config_in_release_group(self, common_site_config, bench_config):
		"""Updates bench_config and common_site_config in the Release Group

		Args:
		config (list): List of dicts with key, value, and type
		"""
		blacklisted_config = [
			x for x in self.common_site_config_table if x.key in get_client_blacklisted_keys()
		]
		self.common_site_config_table = []

		# Maintain keys that aren't accessible to Dashboard user
		for i, _config in enumerate(blacklisted_config):
			_config.idx = i + 1
			self.common_site_config_table.append(_config)

		for d in common_site_config:
			d = jingrow._dict(d)
			if isinstance(d.value, (dict, list)):
				value = json.dumps(d.value)
			else:
				value = d.value
			self.append("common_site_config_table", {"key": d.key, "value": value, "type": d.type})
			# redis_cache_size is a field on release group but we want to treat it as config key
			# TODO: add another interface for updating similar values
			if d["key"] == "redis_cache_size":
				self.redis_cache_size = int(d.value)

		for d in bench_config:
			if d["key"] == "http_timeout":
				# http_timeout should be the only thing configurable in bench_config
				self.bench_config = json.dumps({"http_timeout": int(d["value"])}, indent=4)

		if bench_config == []:
			self.bench_config = json.dumps({})

		self.save()

	@dashboard_whitelist()
	def update_environment_variable(self, environment_variables: dict):
		for key, value in environment_variables.items():
			is_updated = False
			for env_var in self.environment_variables:
				if env_var.key == key:
					if env_var.internal:
						jingrow.throw(f"Environment variable {env_var.key} is internal and cannot be updated")
					else:
						env_var.value = value
						is_updated = True
			if not is_updated:
				self.append("environment_variables", {"key": key, "value": value, "internal": False})
		self.save()

	@dashboard_whitelist()
	def delete_environment_variable(self, key):
		updated_env_variables = []
		for env_var in self.environment_variables:
			if env_var.key != key or env_var.internal:
				updated_env_variables.append(env_var)
		self.environment_variables = updated_env_variables
		self.save()

	def validate_title(self):
		if jingrow.get_all(
			"Release Group",
			{
				"title": self.title,
				"team": self.team or "",
				"name": ("!=", self.name),
				"enabled": True,
			},
			limit=1,
		):
			jingrow.throw(
				f"Bench Group of name {self.title} already exists. Please try another name.",
				jingrow.ValidationError,
			)

	def validate_jingrow_app(self):
		if self.apps[0].app != "jingrow":
			jingrow.throw("First app must be Jingrow", jingrow.ValidationError)

	def validate_duplicate_app(self):
		apps = set()
		for app in self.apps:
			app_name = app.app
			if app_name in apps:
				jingrow.throw(f"App {app.app} can be added only once", jingrow.ValidationError)
			apps.add(app_name)

	def validate_app_versions(self):
		# App Source should be compatible with Release Group's version
		with suppress(AttributeError, RuntimeError):
			if (
				not jingrow.flags.in_test
				and jingrow.request.path == "/api/action/jcloud.api.bench.change_branch"
			):
				return  # Separate validation exists in set_app_source
		for app in self.apps:
			self.validate_app_version(app)

	def validate_app_version(self, app: "ReleaseGroupApp"):
		source = jingrow.get_pg("App Source", app.source)
		if all(row.version != self.version for row in source.versions):
			branch, repo = jingrow.db.get_values("App Source", app.source, ("branch", "repository"))[0]
			msg = f"{repo.rsplit('/')[-1] or repo.rsplit('/')[-2]}:{branch} branch is no longer compatible with {self.version} version of Jingrow"
			jingrow.throw(msg, jingrow.ValidationError)

	def validate_servers(self):
		if self.servers:
			servers = set(server.server for server in self.servers)
			if len(servers) != len(self.servers):
				jingrow.throw("Servers can be added only once", jingrow.ValidationError)
		elif self.is_new():
			server_for_new_bench = Server.get_prod_for_new_bench()
			if server_for_new_bench:
				self.append("servers", {"server": server_for_new_bench})

	def fetch_dependencies(self):
		jingrow_version = jingrow.get_pg("Jingrow Version", self.version)

		for d in jingrow_version.dependencies:
			self.append("dependencies", {"dependency": d.dependency, "version": d.version})

	def validate_rq_queues(self):
		if self.merge_all_rq_queues and self.merge_default_and_short_rq_queues:
			jingrow.throw(
				"Can't set Merge All RQ Queues and Merge Short and Default RQ Queues at once",
				jingrow.ValidationError,
			)

	def validate_max_min_workers(self):
		if (
			self.max_gunicorn_workers
			and self.min_gunicorn_workers
			and self.max_gunicorn_workers < self.min_gunicorn_workers
		):
			jingrow.throw(
				"Max Gunicorn Workers can't be less than Min Gunicorn Workers",
				jingrow.ValidationError,
			)
		if (
			self.max_background_workers
			and self.min_background_workers
			and self.max_background_workers < self.min_background_workers
		):
			jingrow.throw(
				"Max Background Workers can't be less than Min Background Workers",
				jingrow.ValidationError,
			)

	def validate_feature_flags(self) -> None:
		if self.use_app_cache and not self.can_use_get_app_cache():
			jingrow.throw(_("Use App Cache cannot be set, BENCH_VERSION must be 5.22.1 or later"))

	def can_use_get_app_cache(self) -> bool:
		version = find(
			self.dependencies,
			lambda x: x.dependency == "BENCH_VERSION",
		).version

		try:
			return sv.Version(version) in sv.SimpleSpec(">=5.22.1")
		except ValueError:
			return False

	@jingrow.whitelist()
	def create_duplicate_deploy_candidate(self):
		return self.create_deploy_candidate([])

	@dashboard_whitelist()
	def redeploy(self):
		dc = self.create_duplicate_deploy_candidate()
		dc.schedule_build_and_deploy()

	@dashboard_whitelist()
	def initial_deploy(self):
		dc = self.create_deploy_candidate()
		dc.schedule_build_and_deploy()

	@jingrow.whitelist()
	def create_deploy_candidate(
		self,
		apps_to_update=None,
		run_will_fail_check=False,
	) -> "DeployCandidate | None":
		if not self.enabled:
			return None

		apps = self.get_apps_to_update(apps_to_update)
		if apps_to_update is None:
			self.validate_dc_apps_against_rg(apps)

		dependencies = [{"dependency": d.dependency, "version": d.version} for d in self.dependencies]

		packages = [
			{
				"package_manager": p.package_manager,
				"package": p.package,
				"package_prerequisites": p.package_prerequisites,
				"after_install": p.after_install,
			}
			for p in self.packages
		]

		environment_variables = [{"key": v.key, "value": v.value} for v in self.environment_variables]

		# Create and deploy the DC
		new_dc: "DeployCandidate" = jingrow.get_pg(
			{
				"pagetype": "Deploy Candidate",
				"group": self.name,
				"apps": apps,
				"dependencies": dependencies,
				"packages": packages,
				"environment_variables": environment_variables,
			}
		)

		if run_will_fail_check:
			from jcloud.jcloud.pagetype.deploy_candidate.validations import (
				check_if_update_will_fail,
			)

			check_if_update_will_fail(self, new_dc)

		new_dc.insert()
		return new_dc

	def validate_dc_apps_against_rg(self, dc_apps) -> None:
		app_map = {app["app"]: app for app in dc_apps}
		not_found = []
		for app in self.apps:
			if app.app in app_map:
				continue
			not_found.append(app.app)

		if not not_found:
			return

		msg = _("Following apps {0} not found. Potentially due to not approved App Releases.").format(
			not_found
		)
		jingrow.throw(msg)

	def get_apps_to_update(self, apps_to_update):
		# If apps_to_update is None, try to update all apps
		if apps_to_update is None:
			apps_to_update = self.apps

		apps = []
		last_deployed_bench = get_last_pg("Bench", {"group": self.name, "status": "Active"})

		for app in self.deploy_information().apps:
			app_to_update = find(apps_to_update, lambda x: x.get("app") == app.app)
			# If we want to update the app and there's an update available
			if app_to_update and app["update_available"]:
				# Use a specific release if mentioned, otherwise pick the most recent one
				target_release = app_to_update.get("release", app.next_release)
				apps.append(
					{
						"app": app["app"],
						"source": app["source"],
						"release": target_release,
						"hash": jingrow.db.get_value("App Release", target_release, "hash"),
					}
				)
			else:
				# Either we don't want to update the app or there's no update available
				if last_deployed_bench:
					# Find the last deployed release and use it
					app_to_keep = find(last_deployed_bench.apps, lambda x: x.app == app.app)
					if app_to_keep:
						apps.append(
							{
								"app": app_to_keep.app,
								"source": app_to_keep.source,
								"release": app_to_keep.release,
								"hash": app_to_keep.hash,
							}
						)

		return self.get_sorted_based_on_rg_apps(apps)

	def get_sorted_based_on_rg_apps(self, apps):
		# Rearrange Apps to match release group ordering
		sorted_apps = []

		for app in self.apps:
			dc_app = find(apps, lambda x: x["app"] == app.app)
			if dc_app:
				sorted_apps.append(dc_app)

		for app in apps:
			if not find(sorted_apps, lambda x: x["app"] == app["app"]):
				sorted_apps.append(app)

		return sorted_apps

	@jingrow.whitelist()
	def deploy_information(self):
		out = jingrow._dict(update_available=False)

		last_deployed_bench = get_last_pg("Bench", {"group": self.name, "status": "Active"})
		out.apps = self.get_app_updates(last_deployed_bench.apps if last_deployed_bench else [])
		out.last_deploy = self.last_dc_info
		out.deploy_in_progress = self.deploy_in_progress

		out.removed_apps = self.get_removed_apps()
		out.update_available = (
			any([app["update_available"] for app in out.apps])
			or (len(out.removed_apps) > 0)
			or self.dependency_update_pending
		)
		out.number_of_apps = len(self.apps)

		out.sites = [
			site.update({"skip_failing_patches": False, "skip_backups": False})
			for site in jingrow.get_all(
				"Site",
				{"group": self.name, "status": ("in", ["Active", "Broken"])},
				["name", "server", "bench"],
			)
		]

		return out

	@dashboard_whitelist()
	def deployed_versions(self):
		Bench = jingrow.qb.PageType("Bench")
		Server = jingrow.qb.PageType("Server")
		deployed_versions = (
			jingrow.qb.from_(Bench)
			.left_join(Server)
			.on(Server.name == Bench.server)
			.where((Bench.group == self.name) & (Bench.status != "Archived"))
			.groupby(Bench.name)
			.select(Bench.name, Bench.status, Bench.is_ssh_proxy_setup, Server.proxy_server)
			.orderby(Bench.creation, order=jingrow.qb.desc)
			.run(as_dict=True)
		)

		rg_version = self.version

		sites_in_group_details = jingrow.db.get_all(
			"Site",
			filters={
				"group": self.name,
				"status": ("not in", ("Archived", "Suspended")),
				"is_standby": 0,
			},
			fields=["name", "status", "cluster", "plan", "creation", "bench"],
		)

		if sites_in_group_details:
			Cluster = jingrow.qb.PageType("Cluster")
			cluster_data = (
				jingrow.qb.from_(Cluster)
				.select(Cluster.name, Cluster.title, Cluster.image)
				.where(Cluster.name.isin([site.cluster for site in sites_in_group_details]))
				.run(as_dict=True)
			)

			Plan = jingrow.qb.PageType("Site Plan")
			plan_data = (
				jingrow.qb.from_(Plan)
				.select(Plan.name, Plan.plan_title, Plan.price_cny, Plan.price_usd)
				.where(Plan.name.isin([site.plan for site in sites_in_group_details]))
				.run(as_dict=True)
			)

			ResourceTag = jingrow.qb.PageType("Resource Tag")
			tag_data = (
				jingrow.qb.from_(ResourceTag)
				.select(ResourceTag.tag_name, ResourceTag.parent)
				.where(ResourceTag.parent.isin([site.name for site in sites_in_group_details]))
				.run(as_dict=True)
			)

		cur_user_ssh_key = jingrow.get_all(
			"User SSH Key", {"user": jingrow.session.user, "is_default": 1}, limit=1
		)

		benches = [dn.name for dn in deployed_versions]
		benches_with_patches = jingrow.get_all(
			"App Patch",
			fields=["bench"],
			filters={"bench": ["in", benches], "status": "Applied"},
			pluck="bench",
		)

		for version in deployed_versions:
			version.has_app_patch_applied = version.name in benches_with_patches
			version.has_ssh_access = version.is_ssh_proxy_setup and len(cur_user_ssh_key) > 0
			version.sites = find_all(sites_in_group_details, lambda x: x.bench == version.name)
			for site in version.sites:
				site.version = rg_version
				site.server_region_info = find(cluster_data, lambda x: x.name == site.cluster)
				site.plan = find(plan_data, lambda x: x.name == site.plan)
				tags = find_all(tag_data, lambda x: x.parent == site.name)
				site.tags = [tag.tag_name for tag in tags]

			version.deployed_on = jingrow.db.get_value(
				"Agent Job",
				{"bench": version.name, "job_type": "New Bench", "status": "Success"},
				"end",
			)

		return deployed_versions

	@dashboard_whitelist()
	def get_app_versions(self, bench):
		apps = jingrow.db.get_all(
			"Bench App",
			{"parent": bench},
			["name", "app", "hash", "source"],
			order_by="idx",
		)
		for app in apps:
			app.update(
				jingrow.db.get_value(
					"App Source",
					app.source,
					("branch", "repository", "repository_owner", "repository_url"),
					as_dict=1,
					cache=True,
				)
			)
			app.tag = get_app_tag(app.repository, app.repository_owner, app.hash)
		return apps

	@dashboard_whitelist()
	def send_change_team_request(self, team_mail_id: str, reason: str):
		"""Send email to team to accept bench transfer request"""

		if self.team != get_current_team():
			jingrow.throw(
				"You should belong to the team owning the bench to initiate a bench ownership transfer."
			)

		if not jingrow.db.exists("Team", {"user": team_mail_id, "enabled": 1}):
			jingrow.throw("No Active Team record found.")

		old_team = jingrow.db.get_value("Team", self.team, "user")

		if old_team == team_mail_id:
			jingrow.throw(f"Bench group is already owned by the team {team_mail_id}")

		key = jingrow.generate_hash("Release Group Transfer Link", 20)
		jingrow.get_pg(
			{
				"pagetype": "Team Change",
				"document_type": "Release Group",
				"document_name": self.name,
				"to_team": jingrow.db.get_value("Team", {"user": team_mail_id, "enabled": 1}),
				"from_team": self.team,
				"reason": reason or "",
				"key": key,
			}
		).insert()

		link = get_url(f"/api/action/jcloud.api.bench.confirm_bench_transfer?key={key}")

		if jingrow.conf.developer_mode:
			print(f"Bench transfer link for {team_mail_id}\n{link}\n")

		jingrow.sendmail(
			recipients=team_mail_id,
			subject="Transfer Bench Ownership Confirmation",
			template="transfer_team_confirmation",
			args={
				"name": self.title or self.name,
				"type": "bench",
				"old_team": old_team,
				"new_team": team_mail_id,
				"transfer_url": link,
			},
		)

	@dashboard_whitelist()
	def generate_certificate(self):
		user_ssh_key = jingrow.get_all(
			"User SSH Key",
			{"user": jingrow.session.user, "is_default": True},
			pluck="name",
			limit=1,
		)

		if not user_ssh_key:
			jingrow.throw(_("Please set a SSH key to generate certificate"))

		return jingrow.get_pg(
			{
				"pagetype": "SSH Certificate",
				"certificate_type": "User",
				"group": self.name,
				"user": jingrow.session.user,
				"user_ssh_key": user_ssh_key[0],
				"validity": "6h",
			}
		).insert()

	@dashboard_whitelist()
	def get_certificate(self):
		user_ssh_key = jingrow.db.get_all(
			"User SSH Key", {"user": jingrow.session.user, "is_default": True}, pluck="name"
		)
		if not len(user_ssh_key):
			return False
		certificates = jingrow.db.get_all(
			"SSH Certificate",
			{
				"user": jingrow.session.user,
				"valid_until": [">", jingrow.utils.now()],
				"group": self.name,
				"user_ssh_key": user_ssh_key[0],
			},
			pluck="name",
			limit=1,
		)
		if certificates:
			return jingrow.get_pg("SSH Certificate", certificates[0])
		return False

	@property
	def dependency_update_pending(self):
		if not self.last_dependency_update or not self.last_dc_info:
			return False
		return jingrow.utils.get_datetime(self.last_dependency_update) > self.last_dc_info.creation

	@property
	def deploy_in_progress(self):
		from jcloud.jcloud.pagetype.bench.bench import TRANSITORY_STATES as BENCH_TRANSITORY
		from jcloud.jcloud.pagetype.deploy_candidate.deploy_candidate import (
			TRANSITORY_STATES as DC_TRANSITORY,
		)

		if self.last_dc_info and self.last_dc_info.status in DC_TRANSITORY:
			return True

		if any(i["status"] in BENCH_TRANSITORY for i in self.last_benches_info):
			return True

		update_jobs = get_job_names(self.name, "Update Bench In Place", ["Pending", "Running"])
		if len(update_jobs):
			return True

		return False

	@property
	def status(self):
		active_benches = jingrow.db.get_all(
			"Bench", {"group": self.name, "status": "Active"}, limit=1, order_by="creation desc"
		)
		return "Active" if active_benches else "Awaiting Deploy"

	@cached_property
	def last_dc_info(self) -> "LastDeployInfo | None":
		dc = jingrow.qb.PageType("Deploy Candidate")

		query = (
			jingrow.qb.from_(dc)
			.where(dc.group == self.name)
			.select(dc.name, dc.status, dc.creation)
			.orderby(dc.creation, order=jingrow.qb.desc)
			.limit(1)
		)

		results = query.run(as_dict=True)

		if len(results) > 0:
			return results[0]
		return None

	@cached_property
	def last_benches_info(self) -> "list[LastDeployInfo]":
		if not (name := (self.last_dc_info or {}).get("name")):
			return []

		b = jingrow.qb.PageType("Bench")
		query = (
			jingrow.qb.from_(b)
			.where(b.candidate == name)
			.select(b.name, b.status, b.creation)
			.orderby(b.creation, order=jingrow.qb.desc)
			.limit(1)
		)
		return query.run(as_dict=True)

	def get_app_updates(self, current_apps):
		next_apps = self.get_next_apps(current_apps)

		apps = []
		for app in next_apps:
			bench_app = find(current_apps, lambda x: x.app == app.app)
			current_hash = bench_app.hash if bench_app else None
			source = jingrow.get_pg("App Source", app.source)

			will_branch_change = False
			current_branch = source.branch
			if bench_app:
				current_source_branch = jingrow.db.get_value("App Source", bench_app.source, "branch")
				will_branch_change = current_source_branch != source.branch
				current_branch = current_source_branch

			current_tag = (
				get_app_tag(source.repository, source.repository_owner, current_hash)
				if current_hash
				else None
			)

			for release in app.releases:
				release.tag = get_app_tag(source.repository, source.repository_owner, release.hash)

			next_hash = app.hash

			update_available = not current_hash or current_hash != next_hash or will_branch_change
			if not app.releases:
				update_available = False

			apps.append(
				jingrow._dict(
					{
						"title": app.title,
						"app": app.app,
						"name": app.app,
						"source": source.name,
						"repository": source.repository,
						"repository_owner": source.repository_owner,
						"repository_url": source.repository_url,
						"branch": source.branch,
						"current_hash": current_hash,
						"current_tag": current_tag,
						"current_release": bench_app.release if bench_app else None,
						"releases": app.releases,
						"next_release": app.release,
						"will_branch_change": will_branch_change,
						"current_branch": current_branch,
						"update_available": update_available,
					}
				)
			)
		return apps

	def get_next_apps(self, current_apps):
		marketplace_app_sources = self.get_marketplace_app_sources()
		current_team = get_current_team(True)
		app_publishers_team = [current_team.name]

		if current_team.parent_team:
			app_publishers_team.append(current_team.parent_team)

		only_approved_for_sources = [self.apps[0].source]  # add jingrow app source
		if marketplace_app_sources:
			AppSource = jingrow.qb.PageType("App Source")
			only_approved_for_sources.append(
				jingrow.qb.from_(AppSource)
				.where(AppSource.name.isin(marketplace_app_sources))
				.where(AppSource.team.notin(app_publishers_team))
				.select(AppSource.name)
				.run(as_dict=True, pluck="name")
			)

		next_apps = []

		app_sources = [app.source for app in self.apps]
		AppRelease = jingrow.qb.PageType("App Release")
		latest_releases = (
			jingrow.qb.from_(AppRelease)
			.where(AppRelease.source.isin(app_sources))
			.select(
				AppRelease.name,
				AppRelease.source,
				AppRelease.public,
				AppRelease.status,
				AppRelease.hash,
				AppRelease.message,
				AppRelease.creation,
			)
			.orderby(AppRelease.creation, order=jingrow.qb.desc)
			.run(as_dict=True)
		)

		for app in self.apps:
			latest_app_release = None
			latest_app_releases = find_all(latest_releases, lambda x: x.source == app.source)

			if app.source in only_approved_for_sources:
				latest_app_release = find(latest_app_releases, can_use_release)
				latest_app_releases = find_all(latest_app_releases, can_use_release)
			else:
				latest_app_release = find(latest_app_releases, lambda x: x.source == app.source)

			# No release exists for this source
			if not latest_app_release:
				continue

			bench_app = find(current_apps, lambda x: x.app == app.app)

			upcoming_release = latest_app_release.name if latest_app_release else bench_app.release
			upcoming_hash = latest_app_release.hash if latest_app_release else bench_app.hash

			upcoming_releases = latest_app_releases
			if bench_app:
				new_branch = jingrow.db.get_value("App Source", app.source, "branch")
				old_branch = jingrow.db.get_value("App Source", bench_app.source, "branch")

				if new_branch == old_branch:
					current_release_creation = jingrow.db.get_value(
						"App Release", bench_app.release, "creation"
					)
					upcoming_releases = [
						release
						for release in latest_app_releases
						if release.creation > current_release_creation
					]

			next_apps.append(
				jingrow._dict(
					{
						"app": app.app,
						"source": app.source,
						"release": upcoming_release,
						"hash": upcoming_hash,
						"title": app.title,
						"releases": upcoming_releases[:16],
					}
				)
			)

		return next_apps

	def get_removed_apps(self):
		# Apps that were removed from the release group
		# but were in the last deployed bench
		removed_apps = []

		latest_bench = get_last_pg("Bench", {"group": self.name, "status": "Active"})

		if latest_bench:
			bench_apps = latest_bench.apps

			for bench_app in bench_apps:
				if not find(self.apps, lambda rg_app: rg_app.app == bench_app.app):
					app_title = jingrow.db.get_value("App", bench_app.app, "title")
					removed_apps.append({"name": bench_app.app, "title": app_title})

		return removed_apps

	def update_source(self, source: "AppSource", is_update: bool = False):
		self.remove_app_if_invalid(source)
		if is_update:
			update_rg_app_source(self, source)
		else:
			self.append("apps", {"source": source.name, "app": source.app})
		self.save()

	def remove_app_if_invalid(self, source: "AppSource"):
		"""
		Remove app if previously added app has an invalid
		repository URL and GitHub responds with a 404 when
		fetching the app information.
		"""
		matching_apps = [a for a in self.apps if a.app == source.app]
		if not matching_apps:
			return

		rg_app = matching_apps[0]
		value = jingrow.get_value(
			"App Source",
			rg_app.source,
			["last_github_poll_failed", "last_github_response", "repository_url"],
			as_dict=True,
		)

		if value.repository_url == source.repository_url:
			return

		if not value.last_github_poll_failed or not value.last_github_response:
			return

		if '"Not Found"' not in value.last_github_response:
			return

		self.remove_app(source.app)

	@dashboard_whitelist()
	def change_app_branch(self, app: str, to_branch: str) -> None:
		current_app_source = self.get_app_source(app)

		# Already on that branch
		if current_app_source.branch == to_branch:
			jingrow.throw(f"App already on branch {to_branch}!")

		required_app_source = jingrow.get_all(
			"App Source",
			filters={"repository_url": current_app_source.repository_url, "branch": to_branch},
			or_filters={"team": current_app_source.team, "public": 1},
			limit=1,
		)

		if required_app_source:
			required_app_source = required_app_source[0]
		else:
			versions = jingrow.get_all(
				"App Source Version", filters={"parent": current_app_source.name}, pluck="version"
			)

			required_app_source = create_app_source(
				app, current_app_source.repository_url, to_branch, versions
			)
			required_app_source.reload()
			required_app_source.github_installation_id = current_app_source.github_installation_id
			required_app_source.save()

		self.set_app_source(app, required_app_source.name)

	def get_app_source(self, app: str) -> AppSource:
		source = jingrow.get_all(
			"Release Group App", filters={"parent": self.name, "app": app}, pluck="source"
		)

		if source:
			source = source[0]
		else:
			jingrow.throw("Release group app does not exist!")

		return jingrow.get_pg("App Source", source)

	def set_app_source(self, target_app: str, source: str) -> None:
		"""Set `target_app`'s source in release group to `source`"""
		for app in self.apps:
			if app.app == target_app:
				app.source = source
				app.save()
				break
		self.validate_app_version(app)
		self.save()

	def get_marketplace_app_sources(self) -> list[str]:
		all_marketplace_sources = jingrow.get_all("Marketplace App Version", pluck="source")
		return [app.source for app in self.apps if app.source in all_marketplace_sources]

	def get_clusters(self):
		"""Get unique clusters corresponding to self.servers"""
		servers = jingrow.db.get_all("Release Group Server", {"parent": self.name}, pluck="server")
		return jingrow.get_all("Server", {"name": ("in", servers)}, pluck="cluster", distinct=True)

	@dashboard_whitelist()
	def add_region(self, region):
		"""
		Add new region to release group (limits to 2). Meant for dashboard use only.
		"""

		if len(self.get_clusters()) >= 2:
			jingrow.throw("More than 2 regions for bench not allowed")
		self.add_cluster(region)

	def add_cluster(self, cluster: str):
		"""
		Add new server belonging to cluster.

		Deploys bench if no update available
		"""
		server = Server.get_prod_for_new_bench({"cluster": cluster})
		if not server:
			log_error("No suitable server for new bench")
			jingrow.throw(f"No suitable server for new bench in {cluster}")
		app_update_available = self.deploy_information().update_available
		self.add_server(server, deploy=not app_update_available)

	def get_last_successful_candidate(self) -> "DeployCandidate":
		return jingrow.get_last_pg("Deploy Candidate", {"status": "Success", "group": self.name})

	def get_last_deploy_candidate(self):
		try:
			dc: "DeployCandidate" = jingrow.get_last_pg(
				"Deploy Candidate",
				{
					"status": ["!=", "Draft"],
					"group": self.name,
				},
			)
			return dc
		except jingrow.DoesNotExistError:
			return None

	@jingrow.whitelist()
	def add_server(self, server: str, deploy=False):
		self.append("servers", {"server": server, "default": False})
		self.save()
		if deploy:
			return self.get_last_successful_candidate()._create_deploy([server])
		return None

	@jingrow.whitelist()
	def change_server(self, server: str):
		"""
		Create latest candidate in given server and tries to move sites there.

		If only 1 server in server list, removes it, else schedules site
		migrations from first server in list to given.
		"""
		if len(self.servers) == 1:
			self.remove(self.servers[0])
		self.add_server(server, deploy=True)

	@jingrow.whitelist()
	def update_benches_config(self):
		"""Update benches config for all benches in the release group"""
		benches = jingrow.get_all("Bench", "name", {"group": self.name, "status": "Active"})
		for bench in benches:
			jingrow.get_pg("Bench", bench.name).update_bench_config(force=True)

	@dashboard_whitelist()
	def add_app(self, app, is_update: bool = False):
		if isinstance(app, str):
			app = json.loads(app)

		if not (name := app.get("name")):
			return

		if jingrow.db.exists("App", name):
			app_pg: "App" = jingrow.get_pg("App", name)
		else:
			app_pg = new_app(name, app["title"])

		source = app_pg.add_source(
			self.version,
			app["repository_url"],
			app["branch"],
			self.team,
			app.get("github_installation_id", None),
		)
		self.update_source(source, is_update)

	@dashboard_whitelist()
	def remove_app(self, app: str):
		"""Remove app from release group"""

		app_pg_to_remove = find(self.apps, lambda x: x.app == app)
		if app_pg_to_remove:
			self.remove(app_pg_to_remove)

		self.save()
		return app

	@dashboard_whitelist()
	def fetch_latest_app_update(self, app: str):
		app_source = self.get_app_source(app)
		app_source.create_release(force=True)

	@jingrow.whitelist()
	def archive(self):
		benches = jingrow.get_all("Bench", filters={"group": self.name, "status": "Active"}, pluck="name")
		for bench in benches:
			jingrow.get_pg("Bench", bench).archive()

		new_name = f"{self.title}.archived"
		self.title = append_number_if_name_exists("Release Group", new_name, "title", separator=".")
		self.enabled = 0
		self.save()

		jingrow.db.delete("Jcloud Role Permission", {"release_group": self.name})

	@dashboard_whitelist()
	def delete(self) -> None:
		# Note: using delete instead of archive to avoid client api fetching the pg again

		self.archive()

	def set_default_app_cache_flags(self):
		if self.use_app_cache:
			return

		if not jingrow.db.get_single_value("Jcloud Settings", "use_app_cache"):
			return

		if not self.can_use_get_app_cache():
			return

		self.use_app_cache = 1
		self.compress_app_cache = jingrow.db.get_single_value(
			"Jcloud Settings",
			"compress_app_cache",
		)

	def set_default_delta_builds_flags(self):
		if not jingrow.db.get_single_value("Jcloud Settings", "use_delta_builds"):
			return

		self.use_delta_builds = 0

	def is_version_14_or_higher(self):
		return jingrow.get_cached_value("Jingrow Version", self.version, "number") >= 14

	def setup_default_feature_flags(self):
		DEFAULT_FEATURE_FLAGS = {
			"v0.4": {"merge_default_and_short_rq_queues": True},
			"v1": {
				"gunicorn_threads_per_worker": "4",
				"merge_default_and_short_rq_queues": True,
				"use_rq_workerpool": True,
			},
			"v0.1": {
				"gunicorn_threads_per_worker": "4",
				"merge_default_and_short_rq_queues": True,
				"use_rq_workerpool": True,
			},
		}
		flags = DEFAULT_FEATURE_FLAGS.get(self.version, {})
		for key, value in flags.items():
			setattr(self, key, value)


@redis_cache(ttl=60)
def are_builds_suspended() -> bool:
	return is_suspended()


def new_release_group(title, version, apps, team=None, cluster=None, saas_app="", server=None):
	if cluster:
		if not server:
			restricted_release_group_names = jingrow.db.get_all(
				"Site Plan Release Group",
				pluck="release_group",
				filters={"parenttype": "Site Plan", "parentfield": "release_groups"},
				distinct=True,
			)
			restricted_server_names = jingrow.db.get_all(
				"Release Group Server",
				pluck="server",
				filters={
					"parenttype": "Release Group",
					"parentfield": "servers",
					"parent": ("in", restricted_release_group_names),
				},
				distinct=True,
			)
			server = jingrow.get_all(
				"Server",
				{
					"status": "Active",
					"cluster": cluster,
					"use_for_new_benches": True,
					"name": ("not in", restricted_server_names),
				},
				pluck="name",
				limit=1,
			)[0]
		servers = [{"server": server}]
	elif server:
		servers = [{"server": server}]
	else:
		servers = []
	return jingrow.get_pg(
		{
			"pagetype": "Release Group",
			"title": title,
			"version": version,
			"apps": apps,
			"servers": servers,
			"team": team,
			"saas_app": saas_app,
		}
	).insert()


def get_status(name):
	return (
		"Active"
		if jingrow.get_all("Bench", {"group": name, "status": "Active"}, limit=1, order_by="creation desc")
		else "Awaiting Deploy"
	)


def prune_servers_without_sites():
	rg_servers = jingrow.qb.PageType("Release Group Server")
	rg = jingrow.qb.PageType("Release Group")
	groups_with_multiple_servers = (
		jingrow.qb.from_(rg_servers)
		.inner_join(rg)
		.on(rg.name == rg_servers.parent)
		.where(rg.enabled == 1)
		.where(rg.public == 0)
		.where(rg.central_bench == 0)
		.where(rg.team != "team@jingrow.com")
		.where(
			rg.modified < jingrow.utils.add_to_date(None, days=-7)
		)  # use this timestamp to assume server added time
		.groupby(rg_servers.parent)
		.having(Count("*") > 1)
		.select(rg_servers.parent)
		.run(as_dict=False)
	)
	groups_with_multiple_servers = [x[0] for x in groups_with_multiple_servers]
	groups_with_multiple_servers = jingrow.get_all(
		"Release Group Server",
		filters={"parent": ("in", groups_with_multiple_servers)},
		fields=["parent", "server"],
		order_by="parent",
		as_list=True,
	)

	from jcloud.jcloud.pagetype.bench.bench import (
		get_scheduled_version_upgrades,
		get_unfinished_site_migrations,
	)

	for group, server in groups_with_multiple_servers:
		sites = jingrow.get_all(
			"Site",
			{"status": ("!=", "Archived"), "group": group, "server": server},
			["name"],
		)
		if not sites:
			benches = jingrow.get_all(
				"Bench",
				{"group": group, "server": server, "status": "Active"},
				["name", "server", "group"],
			)
			for bench in benches:
				if get_unfinished_site_migrations(bench.name) or get_scheduled_version_upgrades(bench):
					continue
			jingrow.db.delete("Release Group Server", {"parent": group, "server": server})
			jingrow.db.commit()


get_permission_query_conditions = get_permission_query_conditions_for_pagetype("Release Group")


def can_use_release(app_src):
	if not app_src.public:
		return True

	return app_src.status == "Approved"


def update_rg_app_source(rg: "ReleaseGroup", source: "AppSource"):
	for app in rg.apps:
		if app.app == source.app:
			app.source = source.name
			break


def get_job_names(rg: str, job_type: str, job_status: list[str]):
	b = jingrow.qb.PageType("Bench")
	aj = jingrow.qb.PageType("Agent Job")

	jobs = (
		jingrow.qb.from_(b)
		.inner_join(aj)
		.on(b.name == aj.bench)
		.where(b.group == rg)
		.where(aj.job_type == job_type)
		.where(aj.status.isin(job_status))
		.select(aj.name)
		.orderby(aj.modified, order=jingrow.query_builder.Order.desc)
	).run()

	return [j[0] for j in jobs]


def get_config_type(value: Any):
	if isinstance(value, (dict, list)):
		return "JSON"

	if isinstance(value, bool):
		return "Boolean"

	if isinstance(value, (int, float)):
		return "Number"

	return "String"


def get_formatted_config_value(config_type: str, value: Any, key: str, name: str):
	if config_type == "Number":
		return flt(value)

	if config_type == "Boolean":
		return bool(sbool(value))

	if config_type == "JSON":
		return jingrow.parse_json(value)

	if config_type == "Password" and value == "*******":
		return jingrow.get_value("Site Config", {"key": key, "parent": name}, "value")

	return value
