text: # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
  # License: MIT. See LICENSE
  
  # imports - standard imports
  import gzip
  import os
  from calendar import timegm
  from datetime import datetime
  from glob import glob
  from shutil import which
  
  
  # imports - third party imports
  import click
  
  # imports - module imports
  import frappe
  from frappe import _, conf
  from frappe.utils import get_file_size, get_url, now, now_datetime, cint
  from frappe.utils.password import get_encryption_key
  
  # backup variable for backwards compatibility
  verbose = False
  compress = False
  _verbose = verbose
  base_tables = ["__Auth", "__global_search", "__UserSettings"]
  
  
  class BackupGenerator:
  	"""
  	This class contains methods to perform On Demand Backup
  
  	To initialize, specify (db_name, user, password, db_file_name=None, db_host="localhost")
  	If specifying db_file_name, also append ".sql.gz"
  	"""
  
  	def __init__(
  		self,
  		db_name,
  		user,
  		password,
  		backup_path=None,
  		backup_path_db=None,
  		backup_path_files=None,
  		backup_path_private_files=None,
  		db_host="localhost",
  		db_port=None,
  		db_type="mariadb",
  		backup_path_conf=None,
  		ignore_conf=False,
  		compress_files=False,
  		include_doctypes="",
  		exclude_doctypes="",
  		verbose=False,
  	):
  		global _verbose
  		self.compress_files = compress_files or compress
  		self.db_host = db_host
  		self.db_port = db_port
  		self.db_name = db_name
  		self.db_type = db_type
  		self.user = user
  		self.password = password
  		self.backup_path = backup_path
  		self.backup_path_conf = backup_path_conf
  		self.backup_path_db = backup_path_db
  		self.backup_path_files = backup_path_files
  		self.backup_path_private_files = backup_path_private_files
  		self.ignore_conf = ignore_conf
  		self.include_doctypes = include_doctypes
  		self.exclude_doctypes = exclude_doctypes
  		self.partial = False
  
  		if not self.db_type:
  			self.db_type = "mariadb"
  
  		if not self.db_port:
  			if self.db_type == "mariadb":
  				self.db_port = 3306
  			if self.db_type == "postgres":
  				self.db_port = 5432
  
  		site = frappe.local.site or frappe.generate_hash(length=8)
  		self.site_slug = site.replace(".", "_")
  		self.verbose = verbose
  		self.setup_backup_directory()
  		self.setup_backup_tables()
  		_verbose = verbose
  
  	def setup_backup_directory(self):
  		specified = (
  			self.backup_path
  			or self.backup_path_db
  			or self.backup_path_files
  			or self.backup_path_private_files
  			or self.backup_path_conf
  		)
  
  		if not specified:
  			backups_folder = get_backup_path()
  			if not os.path.exists(backups_folder):
  				os.makedirs(backups_folder, exist_ok=True)
  		else:
  			if self.backup_path:
  				os.makedirs(self.backup_path, exist_ok=True)
  
  			for file_path in set(
  				[
  					self.backup_path_files,
  					self.backup_path_db,
  					self.backup_path_private_files,
  					self.backup_path_conf,
  				]
  			):
  				if file_path:
  					dir = os.path.dirname(file_path)
  					os.makedirs(dir, exist_ok=True)
  
  	def setup_backup_tables(self):
  		"""Sets self.backup_includes, self.backup_excludes based on passed args"""
  		existing_tables = frappe.db.get_tables()
  
  		def get_tables(doctypes):
  			tables = []
  			for doctype in doctypes:
  				if not doctype:
  					continue
  				table = frappe.utils.get_table_name(doctype)
  				if table in existing_tables:
  					tables.append(table)
  			return tables
  
  		passed_tables = {
  			"include": get_tables(self.include_doctypes.strip().split(",")),
  			"exclude": get_tables(self.exclude_doctypes.strip().split(",")),
  		}
  		specified_tables = get_tables(frappe.conf.get("backup", {}).get("includes", []))
  		include_tables = (specified_tables + base_tables) if specified_tables else []
  
  		conf_tables = {
  			"include": include_tables,
  			"exclude": get_tables(frappe.conf.get("backup", {}).get("excludes", [])),
  		}
  
  		self.backup_includes = passed_tables["include"]
  		self.backup_excludes = passed_tables["exclude"]
  
  		if not (self.backup_includes or self.backup_excludes) and not self.ignore_conf:
  			self.backup_includes = self.backup_includes or conf_tables["include"]
  			self.backup_excludes = self.backup_excludes or conf_tables["exclude"]
  
  		self.partial = (self.backup_includes or self.backup_excludes) and not self.ignore_conf
  
  	@property
  	def site_config_backup_path(self):
  		# For backwards compatibility
  		click.secho(
  			"BackupGenerator.site_config_backup_path has been deprecated in favour of"
  			" BackupGenerator.backup_path_conf",
  			fg="yellow",
  		)
  		return getattr(self, "backup_path_conf", None)
  
  	def get_backup(self, older_than=24, ignore_files=False, force=False):
  		"""
  		Takes a new dump if existing file is old
  		and sends the link to the file as email
  		"""
  		# Check if file exists and is less than a day old
  		# If not Take Dump
  		if not force:
  			(
  				last_db,
  				last_file,
  				last_private_file,
  				site_config_backup_path,
  			) = self.get_recent_backup(older_than)
  		else:
  			last_db, last_file, last_private_file, site_config_backup_path = (
  				False,
  				False,
  				False,
  				False,
  			)
  
  		self.todays_date = now_datetime().strftime("%Y%m%d_%H%M%S")
  
  		if not (
  			self.backup_path_conf
  			and self.backup_path_db
  			and self.backup_path_files
  			and self.backup_path_private_files
  		):
  			self.set_backup_file_name()
  
  		if not (last_db and last_file and last_private_file and site_config_backup_path):
  			self.take_dump()
  			self.copy_site_config()
  			if not ignore_files:
  				self.backup_files()
  
  			if frappe.get_system_settings("encrypt_backup"):
  				self.backup_encryption()
  
  		else:
  			self.backup_path_files = last_file
  			self.backup_path_db = last_db
  			self.backup_path_private_files = last_private_file
  			self.backup_path_conf = site_config_backup_path
  
  	def set_backup_file_name(self):
  		partial = "-partial" if self.partial else ""
  		ext = "tgz" if self.compress_files else "tar"
  		enc = "-enc" if frappe.get_system_settings("encrypt_backup") else ""
  
  
  		for_conf = f"{self.todays_date}-{self.site_slug}-site_config_backup{enc}.json"
  		for_db = f"{self.todays_date}-{self.site_slug}{partial}-database{enc}.sql.gz"
  		for_public_files = f"{self.todays_date}-{self.site_slug}-files{enc}.{ext}"
  		for_private_files = f"{self.todays_date}-{self.site_slug}-private-files{enc}.{ext}"
  		backup_path = self.backup_path or get_backup_path()
  
  		if not self.backup_path_conf:
  			self.backup_path_conf = os.path.join(backup_path, for_conf)
  		if not self.backup_path_db:
  			self.backup_path_db = os.path.join(backup_path, for_db)
  		if not self.backup_path_files:
  			self.backup_path_files = os.path.join(backup_path, for_public_files)
  		if not self.backup_path_private_files:
  			self.backup_path_private_files = os.path.join(backup_path, for_private_files)
  
  	def backup_encryption(self):
  		"""
  		Encrypt all the backups created using gpg.
  		"""
  		paths = (self.backup_path_db, self.backup_path_files, self.backup_path_private_files)
  		for path in paths:
  			if os.path.exists(path):
  				cmd_string = (
  					"gpg --yes --passphrase {passphrase} --pinentry-mode loopback -c {filelocation}"
  				)
  				try:
  					command = cmd_string.format(
  						passphrase=get_encryption_key(),
  						filelocation=path,
  					)
  
  					frappe.utils.execute_in_shell(command)
  					os.rename(path + ".gpg", path)
  
  				except Exception as err:
  					print(err)
  					click.secho("Error occurred during encryption. Files are stored without encryption.", fg="red")
  
  	def get_recent_backup(self, older_than, partial=False):
  		backup_path = get_backup_path()
  
  		if not frappe.get_system_settings("encrypt_backup"):
  			file_type_slugs = {
  				"database": "*-{{}}-{}database.sql.gz".format('*' if partial else ''),
  				"public": "*-{}-files.tar",
  				"private": "*-{}-private-files.tar",
  				"config": "*-{}-site_config_backup.json",
  			}
  		else:
  			file_type_slugs = {
  				"database": "*-{{}}-{}database.enc.sql.gz".format('*' if partial else ''),
  				"public": "*-{}-files.enc.tar",
  				"private": "*-{}-private-files.enc.tar",
  				"config": "*-{}-site_config_backup.json",
  			}
  
  		def backup_time(file_path):
  			file_name = file_path.split(os.sep)[-1]
  			file_timestamp = file_name.split("-")[0]
  			return timegm(datetime.strptime(file_timestamp, "%Y%m%d_%H%M%S").utctimetuple())
  
  		def get_latest(file_pattern):
  			file_pattern = os.path.join(backup_path, file_pattern.format(self.site_slug))
  			file_list = glob(file_pattern)
  			if file_list:
  				return max(file_list, key=backup_time)
  
  		def old_enough(file_path):
  			if file_path:
  				if not os.path.isfile(file_path) or is_file_old(file_path, older_than):
  					return None
  				return file_path
  
  		latest_backups = {
  			file_type: get_latest(pattern) for file_type, pattern in file_type_slugs.items()
  		}
  
  		recent_backups = {
  			file_type: old_enough(file_name) for file_type, file_name in latest_backups.items()
  		}
  
  		return (
  			recent_backups.get("database"),
  			recent_backups.get("public"),
  			recent_backups.get("private"),
  			recent_backups.get("config"),
  		)
  
  	def zip_files(self):
  		# For backwards compatibility - pre v13
  		click.secho(
  			"BackupGenerator.zip_files has been deprecated in favour of"
  			" BackupGenerator.backup_files",
  			fg="yellow",
  		)
  		return self.backup_files()
  
  	def get_summary(self):
  		summary = {
  			"config": {
  				"path": self.backup_path_conf,
  				"size": get_file_size(self.backup_path_conf, format=True),
  			},
  			"database": {
  				"path": self.backup_path_db,
  				"size": get_file_size(self.backup_path_db, format=True),
  			},
  		}
  
  		if os.path.exists(self.backup_path_files) and os.path.exists(
  			self.backup_path_private_files
  		):
  			summary.update(
  				{
  					"public": {
  						"path": self.backup_path_files,
  						"size": get_file_size(self.backup_path_files, format=True),
  					},
  					"private": {
  						"path": self.backup_path_private_files,
  						"size": get_file_size(self.backup_path_private_files, format=True),
  					},
  				}
  			)
  
  		return summary
  
  	def print_summary(self):
  		backup_summary = self.get_summary()
  		print("Backup Summary for {0} at {1}".format(frappe.local.site, now()))
  
  		title = max(len(x) for x in backup_summary)
  		path = max(len(x["path"]) for x in backup_summary.values())
  
  		for _type, info in backup_summary.items():
  			template = "{{0:{0}}}: {{1:{1}}} {{2}}".format(title, path)
  			print(template.format(_type.title(), info["path"], info["size"]))
  
  	def backup_files(self):
  		for folder in ("public", "private"):
  			files_path = frappe.get_site_path(folder, "files")
  			backup_path = (
  				self.backup_path_files if folder == "public" else self.backup_path_private_files
  			)
  
  			if self.compress_files:
  				cmd_string = "tar cf - {1} | gzip > {0}"
  			else:
  				cmd_string = "tar -cf {0} {1}"
  
  			frappe.utils.execute_in_shell(
  				cmd_string.format(backup_path, files_path),
  				verbose=self.verbose,
  				low_priority=True
  			)
  
  	def copy_site_config(self):
  		site_config_backup_path = self.backup_path_conf
  		site_config_path = os.path.join(frappe.get_site_path(), "site_config.json")
  
  		with open(site_config_backup_path, "w") as n, open(site_config_path) as c:
  			n.write(c.read())
  
  	def take_dump(self):
  		import frappe.utils
  		from frappe.utils.change_log import get_app_branch
  
  		db_exc = {
  			"mariadb": ("mysqldump", which("mysqldump")),
  			"postgres": ("pg_dump", which("pg_dump")),
  		}[self.db_type]
  		gzip_exc = which("gzip")
  
  		if not (gzip_exc and db_exc[1]):
  			_exc = "gzip" if not gzip_exc else db_exc[0]
  			frappe.throw(
  				f"{_exc} not found in PATH! This is required to take a backup.",
  				exc=frappe.ExecutableNotFound
  			)
  		db_exc = db_exc[0]
  
  		database_header_content = [
  			f"Backup generated by Frappe {frappe.__version__} on branch {get_app_branch('frappe') or 'N/A'}",
  			"",
  		]
  
  		# escape reserved characters
  		args = frappe._dict(
  			[item[0], frappe.utils.esc(str(item[1]), "$ ")]
  			for item in self.__dict__.copy().items()
  		)
  
  		if self.backup_includes:
  			backup_info = ("Backing Up Tables: ", ", ".join(self.backup_includes))
  		elif self.backup_excludes:
  			backup_info = ("Skipping Tables: ", ", ".join(self.backup_excludes))
  
  		if self.partial:
  			if self.verbose:
  				print(''.join(backup_info), "\n")
  			database_header_content.extend([
  				f"Partial Backup of Frappe Site {frappe.local.site}",
  				("Backup contains: " if self.backup_includes else "Backup excludes: ") + backup_info[1],
  				"",
  			])
  
  		generated_header = "\n".join(f"-- {x}" for x in database_header_content) + "\n"
  
  		with gzip.open(args.backup_path_db, "wt") as f:
  			f.write(generated_header)
  
  		if self.db_type == "postgres":
  			if self.backup_includes:
  				args["include"] = " ".join(
  					["--table='public.\"{0}\"'".format(table) for table in self.backup_includes]
  				)
  			elif self.backup_excludes:
  				args["exclude"] = " ".join(
  					["--exclude-table-data='public.\"{0}\"'".format(table) for table in self.backup_excludes]
  				)
  
  			cmd_string = (
  				"{db_exc} postgres://{user}:{password}@{db_host}:{db_port}/{db_name}"
  				" {include} {exclude} | {gzip} >> {backup_path_db}"
  			)
  
  		else:
  			if self.backup_includes:
  				args["include"] = " ".join(["'{0}'".format(x) for x in self.backup_includes])
  			elif self.backup_excludes:
  				args["exclude"] = " ".join(
  					[
  						"--ignore-table='{0}.{1}'".format(frappe.conf.db_name, table)
  						for table in self.backup_excludes
  					]
  				)
  
  			cmd_string = (
  				"{db_exc} --single-transaction --quick --lock-tables=false -u {user}"
  				" -p{password} {db_name} -h {db_host} -P {db_port} {include} {exclude}"
  				" | {gzip} >> {backup_path_db}"
  			)
  
  		command = cmd_string.format(
  			user=args.user,
  			password=args.password,
  			db_exc=db_exc,
  			db_host=args.db_host,
  			db_port=args.db_port,
  			db_name=args.db_name,
  			backup_path_db=args.backup_path_db,
  			exclude=args.get("exclude", ""),
  			include=args.get("include", ""),
  			gzip=gzip_exc,
  		)
  
  		if self.verbose:
  			print(command + "\n")
  
  		frappe.utils.execute_in_shell(command, low_priority=True)
  
  	def send_email(self):
  		"""
  		Sends the link to backup file located at erpnext/backups
  		"""
  		from frappe.email import get_system_managers
  
  		recipient_list = get_system_managers()
  		db_backup_url = get_url(
  			os.path.join("backups", os.path.basename(self.backup_path_db))
  		)
  		files_backup_url = get_url(
  			os.path.join("backups", os.path.basename(self.backup_path_files))
  		)
  
  		msg = """Hello,
  
  Your backups are ready to be downloaded.
  
  1. [Click here to download the database backup](%(db_backup_url)s)
  2. [Click here to download the files backup](%(files_backup_url)s)
  
  This link will be valid for 24 hours. A new backup will be available for
  download only after 24 hours.""" % {
  			"db_backup_url": db_backup_url,
  			"files_backup_url": files_backup_url,
  		}
  
  		datetime_str = datetime.fromtimestamp(os.stat(self.backup_path_db).st_ctime)
  		subject = (
  			datetime_str.strftime("%d/%m/%Y %H:%M:%S") + """ - Backup ready to be downloaded"""
  		)
  
  		frappe.sendmail(recipients=recipient_list, msg=msg, subject=subject)
  		return recipient_list
  
  
  @frappe.whitelist()
  def fetch_latest_backups(partial=False):
  	"""Fetches paths of the latest backup taken in the last 30 days
  	Only for: System Managers
  
  	Returns:
  		dict: relative Backup Paths
  	"""
  	frappe.only_for("System Manager")
  	odb = BackupGenerator(
  		frappe.conf.db_name,
  		frappe.conf.db_name,
  		frappe.conf.db_password,
  		db_host=frappe.db.host,
  		db_type=frappe.conf.db_type,
  		db_port=frappe.conf.db_port,
  	)
  	database, public, private, config = odb.get_recent_backup(older_than=24 * 30, partial=partial)
  
  	return {"database": database, "public": public, "private": private, "config": config}
  
  
  def scheduled_backup(
  	older_than=6,
  	ignore_files=False,
  	backup_path=None,
  	backup_path_db=None,
  	backup_path_files=None,
  	backup_path_private_files=None,
  	backup_path_conf=None,
  	ignore_conf=False,
  	include_doctypes="",
  	exclude_doctypes="",
  	compress=False,
  	force=False,
  	verbose=False,
  ):
  	"""this function is called from scheduler
  	deletes backups older than 7 days
  	takes backup"""
  	odb = new_backup(
  		older_than=older_than,
  		ignore_files=ignore_files,
  		backup_path=backup_path,
  		backup_path_db=backup_path_db,
  		backup_path_files=backup_path_files,
  		backup_path_private_files=backup_path_private_files,
  		backup_path_conf=backup_path_conf,
  		ignore_conf=ignore_conf,
  		include_doctypes=include_doctypes,
  		exclude_doctypes=exclude_doctypes,
  		compress=compress,
  		force=force,
  		verbose=verbose,
  	)
  	return odb
  
  
  def new_backup(
  	older_than=6,
  	ignore_files=False,
  	backup_path=None,
  	backup_path_db=None,
  	backup_path_files=None,
  	backup_path_private_files=None,
  	backup_path_conf=None,
  	ignore_conf=False,
  	include_doctypes="",
  	exclude_doctypes="",
  	compress=False,
  	force=False,
  	verbose=False,
  ):
  	delete_temp_backups()
  	odb = BackupGenerator(
  		frappe.conf.db_name,
  		frappe.conf.db_name,
  		frappe.conf.db_password,
  		db_host=frappe.db.host,
  		db_port=frappe.db.port,
  		db_type=frappe.conf.db_type,
  		backup_path=backup_path,
  		backup_path_db=backup_path_db,
  		backup_path_files=backup_path_files,
  		backup_path_private_files=backup_path_private_files,
  		backup_path_conf=backup_path_conf,
  		ignore_conf=ignore_conf,
  		include_doctypes=include_doctypes,
  		exclude_doctypes=exclude_doctypes,
  		verbose=verbose,
  		compress_files=compress,
  	)
  	odb.get_backup(older_than, ignore_files, force=force)
  	return odb
  
  
  def delete_temp_backups(older_than=24):
  	"""
  	Cleans up the backup_link_path directory by deleting older files
  	"""
  	older_than = cint(frappe.conf.keep_backups_for_hours) or older_than
  	backup_path = get_backup_path()
  	if os.path.exists(backup_path):
  		file_list = os.listdir(get_backup_path())
  		for this_file in file_list:
  			this_file_path = os.path.join(get_backup_path(), this_file)
  			if is_file_old(this_file_path, older_than):
  				os.remove(this_file_path)
  
  
  def is_file_old(file_path, older_than=24):
  	"""
  	Checks if file exists and is older than specified hours
  	Returns ->
  	True: file does not exist or file is old
  	False: file is new
  	"""
  	if os.path.isfile(file_path):
  		from datetime import timedelta
  
  		# Get timestamp of the file
  		file_datetime = datetime.fromtimestamp(os.stat(file_path).st_ctime)
  		if datetime.today() - file_datetime >= timedelta(hours=older_than):
  			if _verbose:
  				print(f"File {file_path} is older than {older_than} hours")
  			return True
  		else:
  			if _verbose:
  				print(f"File {file_path} is recent")
  			return False
  	else:
  		if _verbose:
  			print(f"File {file_path} does not exist")
  		return True
  
  
  def get_backup_path():
  	backup_path = frappe.utils.get_site_path(conf.get("backup_path", "private/backups"))
  	return backup_path
  
  @frappe.whitelist()
  def get_backup_encryption_key():
  	return frappe.local.conf.encryption_key
  
  class Backup:
  	def __init__(self, file_path):
  		self.file_path = file_path
  
  	def backup_decryption(self,passphrase):
  		"""
  		Decrypts backup at the given path using the passphrase.
  		"""
  		if not os.path.exists(self.file_path):
  			print("Invalid path", self.file_path)
  			return
  		else:
  			os.rename(self.file_path, self.file_path + ".gpg")
  			file_path = self.file_path + ".gpg"
  
  			cmd_string = (
  					"gpg --yes --passphrase {passphrase} --pinentry-mode loopback -o {decrypted_file} -d {file_location}"
  			)
  			command = cmd_string.format(
  				passphrase=passphrase,
  				file_location=file_path,
  				decrypted_file=file_path.rstrip(".gpg"),
  			)
  		frappe.utils.execute_in_shell(command)
  
  
  	def decryption_rollback(self):
  		"""
  		Checks if the decrypted file exists at the given path.
  		if exists
  			Renames the orginal encrypted file.
  		else
  			Removes the decrypted file and rename the original file.
  		"""
  		if os.path.exists(self.file_path + ".gpg"):
  			if os.path.exists(self.file_path):
  				os.remove(self.file_path)
  			if os.path.exists(self.file_path.rstrip(".gz")):
  				os.remove(self.file_path.rstrip(".gz"))
  			os.rename(self.file_path + ".gpg", self.file_path)
  
  
  def backup(
  	with_files=False,
  	backup_path_db=None,
  	backup_path_files=None,
  	backup_path_private_files=None,
  	backup_path_conf=None,
  	quiet=False,
  ):
  	"Backup"
  	odb = scheduled_backup(
  		ignore_files=not with_files,
  		backup_path_db=backup_path_db,
  		backup_path_files=backup_path_files,
  		backup_path_private_files=backup_path_private_files,
  		backup_path_conf=backup_path_conf,
  		force=True,
  	)
  	return {
  		"backup_path_db": odb.backup_path_db,
  		"backup_path_files": odb.backup_path_files,
  		"backup_path_private_files": odb.backup_path_private_files,
  	}
  
  
  if __name__ == "__main__":
  	import sys
  
  	cmd = sys.argv[1]
  
  	db_type = "mariadb"
  	try:
  		db_type = sys.argv[6]
  	except IndexError:
  		pass
  
  	db_port = 3306
  	try:
  		db_port = int(sys.argv[7])
  	except IndexError:
  		pass
  
  	if cmd == "is_file_old":
  		odb = BackupGenerator(
  			sys.argv[2],
  			sys.argv[3],
  			sys.argv[4],
  			sys.argv[5] or "localhost",
  			db_type=db_type,
  			db_port=db_port,
  		)
  		is_file_old(odb.db_file_name)
  
  	if cmd == "get_backup":
  		odb = BackupGenerator(
  			sys.argv[2],
  			sys.argv[3],
  			sys.argv[4],
  			sys.argv[5] or "localhost",
  			db_type=db_type,
  			db_port=db_port,
  		)
  		odb.get_backup()
  
  	if cmd == "take_dump":
  		odb = BackupGenerator(
  			sys.argv[2],
  			sys.argv[3],
  			sys.argv[4],
  			sys.argv[5] or "localhost",
  			db_type=db_type,
  			db_port=db_port,
  		)
  		odb.take_dump()
  
  	if cmd == "send_email":
  		odb = BackupGenerator(
  			sys.argv[2],
  			sys.argv[3],
  			sys.argv[4],
  			sys.argv[5] or "localhost",
  			db_type=db_type,
  			db_port=db_port,
  		)
  		odb.send_email("abc.sql.gz")
  
  	if cmd == "delete_temp_backups":
  		delete_temp_backups()
  