# Copyright (c) 2021, JINGROW and Contributors
# License: MIT. See LICENSE

import base64
import hashlib
import io
import json
import mimetypes
import os
from copy import copy
from urllib.parse import unquote

import jingrow
from jingrow import _, conf
from jingrow.query_builder.utils import PageType
from jingrow.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method


class MaxFileSizeReachedError(jingrow.ValidationError):
	pass


def get_bucket_path(content_hash: str | None) -> str:
	"""Generate a 2-level bucket directory path from content_hash.

	Uses the first 4 hex characters of MD5 content_hash to create a
	256 x 256 bucket structure for filesystem performance at scale.

	Example: content_hash="a1b2c3d4e5f6..." → "a1/b2"
	Returns empty string if content_hash is None or too short.
	"""
	if not content_hash or len(content_hash) < 4:
		return ""
	return f"{content_hash[:2]}/{content_hash[2:4]}"


def safe_b64decode(binary: bytes) -> bytes:
	"""Adds padding if doesn't already exist before decoding.

	This attempts to avoid the `binascii.Error: Incorrect padding` error raised
	when the number of trailing = is simply not enough :crie:. Although, it may
	be an indication of corrupted data.

	Refs:
	        * https://en.wikipedia.org/wiki/Base64
	        * https://stackoverflow.com/questions/2941995/python-ignore-incorrect-padding-error-when-base64-decoding
	"""
	return base64.b64decode(binary + b"===")


def get_file_url(file_data_name):
	data = jingrow.db.get_value("File", file_data_name, ["file_name", "file_url"], as_dict=True)
	return data.file_url or data.file_name


def upload():
	# get record details
	dt = jingrow.form_dict.pagetype
	dn = jingrow.form_dict.pagename
	file_url = jingrow.form_dict.file_url
	filename = jingrow.form_dict.filename
	jingrow.form_dict.is_private = cint(jingrow.form_dict.is_private)

	if not filename and not file_url:
		jingrow.msgprint(_("Please select a file or url"), raise_exception=True)

	file_pg = get_file_pg()

	comment = {}
	if dt and dn:
		file_url = file_pg.file_url.replace("#", "%23") if file_pg.file_name else file_pg.file_url
		icon = ' <i class="fa fa-lock text-warning"></i>' if file_pg.is_private else ""
		file_name = file_pg.file_name or file_pg.file_url
		comment = jingrow.get_pg(dt, dn).add_comment(
			"Attachment",
			f"<a href='{file_url}' target='_blank'>{file_name}</a>{icon}",
		)

	return {
		"name": file_pg.name,
		"file_name": file_pg.file_name,
		"file_url": file_pg.file_url,
		"is_private": file_pg.is_private,
		"comment": comment.as_dict() if comment else {},
	}


def get_file_pg(dt=None, dn=None, folder=None, is_private=None, df=None):
	"""returns File object (Page) from given parameters or form_dict"""
	r = jingrow.form_dict

	if dt is None:
		dt = r.pagetype
	if dn is None:
		dn = r.pagename
	if df is None:
		df = r.pagefield
	if folder is None:
		folder = r.folder
	if is_private is None:
		is_private = r.is_private

	if r.filedata:
		file_pg = save_uploaded(dt, dn, folder, is_private, df)

	elif r.file_url:
		file_pg = save_url(r.file_url, r.filename, dt, dn, folder, is_private, df)

	return file_pg


def save_uploaded(dt, dn, folder, is_private, df=None):
	fname, content = get_uploaded_content()
	if content:
		return save_file(fname, content, dt, dn, folder, is_private=is_private, df=df)
	else:
		raise Exception


def save_url(file_url, filename, dt, dn, folder, is_private, df=None):
	# if not (file_url.startswith("http://") or file_url.startswith("https://")):
	# 	jingrow.msgprint("URL must start with 'http://' or 'https://'")
	# 	return None, None

	file_url = unquote(file_url)
	file_size = jingrow.form_dict.file_size

	f = jingrow.get_pg(
		{
			"pagetype": "File",
			"file_url": file_url,
			"file_name": filename,
			"attached_to_pagetype": dt,
			"attached_to_name": dn,
			"attached_to_field": df,
			"folder": folder,
			"file_size": file_size,
			"is_private": is_private,
		}
	)
	f.flags.ignore_permissions = True
	try:
		f.insert()
	except jingrow.DuplicateEntryError:
		return jingrow.get_pg("File", f.duplicate_entry)
	return f


def get_uploaded_content():
	# should not be unicode when reading a file, hence using jingrow.form
	if "filedata" in jingrow.form_dict:
		if "," in jingrow.form_dict.filedata:
			jingrow.form_dict.filedata = jingrow.form_dict.filedata.rsplit(",", 1)[1]
		jingrow.uploaded_content = safe_b64decode(jingrow.form_dict.filedata)
		jingrow.uploaded_filename = jingrow.form_dict.filename
		return jingrow.uploaded_filename, jingrow.uploaded_content
	else:
		jingrow.msgprint(_("No file attached"))
		return None, None


def save_file(fname, content, dt, dn, folder=None, decode=False, is_private=0, df=None):
	if decode:
		if isinstance(content, str):
			content = content.encode("utf-8")

		if b"," in content:
			content = content.split(b",")[1]
		content = safe_b64decode(content)

	file_size = check_max_file_size(content)
	content_hash = get_content_hash(content)
	content_type = mimetypes.guess_type(fname)[0]
	fname = get_file_name(fname, content_hash[-6:], content_hash=content_hash)
	file_data = get_file_data_from_hash(content_hash, is_private=is_private)
	if not file_data:
		call_hook_method("before_write_file", file_size=file_size)

		write_file_method = get_hook_method("write_file", fallback=save_file_on_filesystem)
		file_data = write_file_method(
			fname, content, content_type=content_type, is_private=is_private, content_hash=content_hash
		)
		file_data = copy(file_data)

	file_data.update(
		{
			"pagetype": "File",
			"attached_to_pagetype": dt,
			"attached_to_name": dn,
			"attached_to_field": df,
			"folder": folder,
			"file_size": file_size,
			"content_hash": content_hash,
			"is_private": is_private,
		}
	)

	f = jingrow.get_pg(file_data)
	f.flags.ignore_permissions = True
	try:
		f.insert()
	except jingrow.DuplicateEntryError:
		return jingrow.get_pg("File", f.duplicate_entry)

	return f


def get_file_data_from_hash(content_hash, is_private=0):
	for name in jingrow.get_all(
		"File", {"content_hash": content_hash, "is_private": is_private}, pluck="name"
	):
		b = jingrow.get_pg("File", name)
		return {k: b.get(k) for k in jingrow.get_hooks()["write_file_keys"]}
	return False


def save_file_on_filesystem(fname, content, content_type=None, is_private=0, content_hash=None):
	fpath = write_file(content, fname, is_private, content_hash=content_hash)

	bucket = get_bucket_path(content_hash)
	prefix = "/private/files/" if is_private else "/files/"
	file_url = f"{prefix}{bucket}/{fname}" if bucket else f"{prefix}{fname}"

	return {"file_name": os.path.basename(fpath), "file_url": file_url}


def get_max_file_size():
	return conf.get("max_file_size") or 10485760


def check_max_file_size(content):
	max_file_size = get_max_file_size()
	file_size = len(content)

	if file_size > max_file_size:
		jingrow.msgprint(
			_("File size exceeded the maximum allowed size of {0} MB").format(max_file_size / 1048576),
			raise_exception=MaxFileSizeReachedError,
		)

	return file_size


def write_file(content, fname, is_private=0, content_hash=None):
	"""write file to disk with a random name (to compare)"""
	file_path = get_files_path(is_private=is_private)

	# Create bucket subdirectory from content_hash for filesystem scalability
	bucket = get_bucket_path(content_hash)
	if bucket:
		file_path = os.path.join(file_path, bucket)

	# create directory (if not exists)
	jingrow.create_folder(file_path)
	# write the file
	if isinstance(content, str):
		content = content.encode()
	with open(os.path.join(file_path.encode("utf-8"), fname.encode("utf-8")), "wb+") as f:
		f.write(content)

	if bucket:
		return get_files_path(*bucket.split("/"), fname, is_private=is_private)
	return get_files_path(fname, is_private=is_private)


def remove_all(dt, dn, from_delete=False, delete_permanently=False):
	"""remove all files in a transaction"""
	try:
		for fid in jingrow.get_all("File", {"attached_to_pagetype": dt, "attached_to_name": dn}, pluck="name"):
			if from_delete:
				# If deleting a pg, directly delete files
				jingrow.delete_pg("File", fid, ignore_permissions=True, delete_permanently=delete_permanently)
			else:
				# Removes file and adds a comment in the document it is attached to
				remove_file(
					fid=fid,
					attached_to_pagetype=dt,
					attached_to_name=dn,
					from_delete=from_delete,
					delete_permanently=delete_permanently,
				)
	except Exception as e:
		if e.args[0] != 1054:
			raise  # (temp till for patched)


def remove_file(
	fid=None,
	attached_to_pagetype=None,
	attached_to_name=None,
	from_delete=False,
	delete_permanently=False,
):
	"""Remove file and File entry"""
	file_name = None
	if not (attached_to_pagetype and attached_to_name):
		attached = jingrow.db.get_value("File", fid, ["attached_to_pagetype", "attached_to_name", "file_name"])
		if attached:
			attached_to_pagetype, attached_to_name, file_name = attached

	ignore_permissions, comment = False, None
	if attached_to_pagetype and attached_to_name and not from_delete:
		pg = jingrow.get_pg(attached_to_pagetype, attached_to_name)
		ignore_permissions = pg.has_permission("write") or False
		if jingrow.flags.in_web_form:
			ignore_permissions = True
		if not file_name:
			file_name = jingrow.db.get_value("File", fid, "file_name")
		comment = pg.add_comment("Attachment Removed", file_name)
		jingrow.delete_pg(
			"File", fid, ignore_permissions=ignore_permissions, delete_permanently=delete_permanently
		)

	return comment


def delete_file_data_content(pg, only_thumbnail=False):
	method = get_hook_method("delete_file_data_content", fallback=delete_file_from_filesystem)
	method(pg, only_thumbnail=only_thumbnail)


def delete_file_from_filesystem(pg, only_thumbnail=False):
	"""Delete file, thumbnail from File document"""
	if only_thumbnail:
		delete_file(pg.thumbnail_url)
	else:
		delete_file(pg.file_url)
		delete_file(pg.thumbnail_url)


def delete_file(path):
	"""Delete file from filesystem, supporting bucket subdirectory paths."""
	if path:
		if ".." in path.split("/"):
			jingrow.msgprint(
				_("It is risky to delete this file: {0}. Please contact your System Manager.").format(path)
			)

		stripped = path.strip("/")
		if stripped.startswith("private/files/"):
			relative = stripped.split("private/files/", 1)[1]
			path = get_files_path(*relative.split("/"), is_private=1)
		elif stripped.startswith("files/"):
			relative = stripped.split("files/", 1)[1]
			path = get_files_path(*relative.split("/"))
		else:
			# Fallback for unexpected formats
			parts = os.path.split(stripped)
			if parts[0] == "files":
				path = jingrow.utils.get_site_path("public", "files", parts[-1])
			else:
				path = jingrow.utils.get_site_path("private", "files", parts[-1])

		path = encode(path)
		if os.path.exists(path):
			os.remove(path)


def get_file(fname):
	"""Returns [`file_name`, `content`] for given file name `fname`"""
	file_path = get_file_path(fname)

	# read the file
	with open(encode(file_path), mode="rb") as f:
		content = f.read()
		try:
			# for plain text files
			content = content.decode()
		except UnicodeDecodeError:
			# for .png, .jpg, etc
			pass

	return [file_path.rsplit("/", 1)[-1], content]


def get_file_path(file_name):
	"""Returns file path from given file name"""
	if "../" in file_name:
		return

	File = PageType("File")

	f = (
		jingrow.qb.from_(File)
		.where((File.name == file_name) | (File.file_name == file_name))
		.select(File.file_url)
		.run()
	)

	if f:
		file_name = f[0][0]

	file_path = file_name

	if "/" not in file_path:
		file_path = "/files/" + file_path

	if file_path.startswith("/private/files/"):
		file_path = get_files_path(*file_path.split("/private/files/", 1)[1].split("/"), is_private=1)

	elif file_path.startswith("/files/"):
		file_path = get_files_path(*file_path.split("/files/", 1)[1].split("/"))

	else:
		jingrow.throw(_("There is some problem with the file url: {0}").format(file_path))

	return file_path


def get_content_hash(content):
	if isinstance(content, str):
		content = content.encode()
	return hashlib.md5(content, usedforsecurity=False).hexdigest()


def get_file_name(fname, optional_suffix, content_hash=None):
	# convert to unicode
	fname = cstr(fname)

	bucket = get_bucket_path(content_hash)
	if bucket:
		exists = os.path.exists(encode(get_files_path(*bucket.split("/"), fname)))
	else:
		exists = os.path.exists(encode(get_files_path(fname)))

	n_records = jingrow.get_all("File", {"file_name": fname}, pluck="name")
	if len(n_records) > 0 or exists:
		f = fname.rsplit(".", 1)
		if len(f) == 1:
			partial, extn = f[0], ""
		else:
			partial, extn = f[0], "." + f[1]
		return f"{partial}{optional_suffix}{extn}"
	return fname


@jingrow.whitelist()
def add_attachments(pagetype, name, attachments):
	"""Add attachments to the given PageType"""
	if isinstance(attachments, str):
		attachments = json.loads(attachments)
	# loop through attachments
	files = []
	for a in attachments:
		if isinstance(a, str):
			attach = jingrow.db.get_value(
				"File", {"name": a}, ["file_name", "file_url", "is_private"], as_dict=1
			)
			# save attachments to new pg
			f = save_url(
				attach.file_url, attach.file_name, pagetype, name, "Home/Attachments", attach.is_private
			)
			files.append(f)

	return files


def is_safe_path(path: str) -> bool:
	if path.startswith(("http://", "https://")):
		return True

	basedir = jingrow.get_site_path()
	# ref: https://pgs.python.org/3/library/os.path.html#os.path.commonpath
	matchpath = os.path.abspath(path)
	basedir = os.path.abspath(basedir)

	return basedir == os.path.commonpath((basedir, matchpath))
