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

import itertools
import json
from collections import defaultdict

import jingrow
import jingrow.desk.form.load
import jingrow.desk.form.meta
from jingrow import _
from jingrow.model.meta import is_single
from jingrow.modules import load_pagetype_module


@jingrow.whitelist()
def get_submitted_linked_pgs(pagetype: str, name: str, ignore_pagetypes_on_cancel_all=None) -> list[tuple]:
	"""Get all the nested submitted documents those are present in referencing tables (dependent tables).

	:param pagetype: Page type
	:param name: Name of the document

	Use-case:
	* User should be able to cancel the linked documents along with the one user trying to cancel.

	Case1: If document sd1-n1 (document name n1 from submittable pagetype sd1) is linked to sd2-n2 and sd2-n2 is linked to sd3-n3,
	        Getting submittable linked pgs of `sd1-n1`should give both sd2-n2 and sd3-n3.
	Case2: If document sd1-n1 (document name n1 from submittable pagetype sd1) is linked to d2-n2 and d2-n2 is linked to sd3-n3,
	        Getting submittable linked pgs of `sd1-n1`should give None. (because d2-n2 is not a submittable pagetype)
	Case3: If document sd1-n1 (document name n1 from submittable pagetype sd1) is linked to d2-n2 & sd2-n2. d2-n2 is linked to sd3-n3.
	        Getting submittable linked pgs of `sd1-n1`should give sd2-n2.

	Logic:
	-----
	1. We can find linked documents only if we know how the pagetypes are related.
	2. As we need only submittable documents, we can limit pagetype relations search to submittable pagetypes by
	        finding the relationships(Foreign key references) across submittable pagetypes.
	3. Searching for links is going to be a tree like structure where at every level,
	        you will be finding documents using parent document and parent document links.
	"""

	if isinstance(ignore_pagetypes_on_cancel_all, str):
		ignore_pagetypes_on_cancel_all = json.loads(ignore_pagetypes_on_cancel_all)

	jingrow.has_permission(pagetype, pg=name)
	tree = SubmittableDocumentTree(pagetype, name)
	visited_documents = tree.get_all_children(ignore_pagetypes_on_cancel_all)
	pgs = []

	for dt, names in visited_documents.items():
		pgs.extend([{"pagetype": dt, "name": name, "pagestatus": 1} for name in names])

	return {"pgs": pgs, "count": len(pgs)}


class SubmittableDocumentTree:
	def __init__(self, pagetype: str, name: str):
		"""Construct a tree for the submitable linked documents.

		* Node has properties like pagetype and pagenames. Represented as Node(pagetype, pagenames).
		* Nodes are linked by pagetype relationships like table, link and dynamic links.
		* Node is referenced(linked) by many other documents and those are the child nodes.

		NOTE: child document is a property of child node (not same as Jingrow child pgs of a table field).
		"""
		self.root_pagetype = pagetype
		self.root_pagename = name

		# Documents those are yet to be visited for linked documents.
		self.to_be_visited_documents = {pagetype: [name]}
		self.visited_documents = defaultdict(list)

		self._submittable_pagetypes = None  # All submittable pagetypes in the system
		self._references_across_pagetypes = None  # pagetype wise links/references

	def get_all_children(self, ignore_pagetypes_on_cancel_all):
		"""Get all nodes of a tree except the root node (all the nested submitted
		documents those are present in referencing tables dependent tables).
		"""
		while self.to_be_visited_documents:
			next_level_children = defaultdict(list)
			for parent_dt in list(self.to_be_visited_documents):
				parent_pgs = self.to_be_visited_documents.get(parent_dt)
				if not parent_pgs or (
					ignore_pagetypes_on_cancel_all and parent_dt in ignore_pagetypes_on_cancel_all
				):
					del self.to_be_visited_documents[parent_dt]
					continue

				child_pgs = self.get_next_level_children(parent_dt, parent_pgs)
				self.visited_documents[parent_dt].extend(parent_pgs)
				for linked_dt, linked_names in child_pgs.items():
					not_visited_child_pgs = set(linked_names) - set(
						self.visited_documents.get(linked_dt, [])
					)
					next_level_children[linked_dt].extend(not_visited_child_pgs)

			self.to_be_visited_documents = next_level_children

		# Remove root node from visited documents
		if self.root_pagename in self.visited_documents.get(self.root_pagetype, []):
			self.visited_documents[self.root_pagetype].remove(self.root_pagename)

		return self.visited_documents

	def get_next_level_children(self, parent_dt, parent_names):
		"""Get immediate children of a Node(parent_dt, parent_names)"""
		referencing_fields = self.get_pagetype_references(parent_dt)

		child_pgs = defaultdict(list)
		for field in referencing_fields:
			if field["fieldname"] == "amended_from":
				# perf: amended_from links are always linked to cancelled documents.
				continue

			links = (
				get_referencing_documents(
					parent_dt,
					parent_names.copy(),
					field,
					get_parent_if_child_table_pg=True,
					parent_filters=[("pagestatus", "=", 1)],
					allowed_parents=self.get_link_sources(),
				)
				or {}
			)
			for dt, names in links.items():
				child_pgs[dt].extend(names)
		return child_pgs

	def get_pagetype_references(self, pagetype):
		"""Get references for a given document."""
		if self._references_across_pagetypes is None:
			get_links_to = self.get_document_sources()
			limit_link_pagetypes = self.get_link_sources()
			self._references_across_pagetypes = get_references_across_pagetypes(
				get_links_to, limit_link_pagetypes
			)
		return self._references_across_pagetypes.get(pagetype, [])

	def get_document_sources(self):
		"""Returns list of pagetypes from where we access submittable documents."""
		return list(set([*self.get_link_sources(), self.root_pagetype]))

	def get_link_sources(self):
		"""limit pagetype links to these pagetypes."""
		return list(set(self.get_submittable_pagetypes()) - set(get_exempted_pagetypes() or []))

	def get_submittable_pagetypes(self) -> list[str]:
		"""Returns list of submittable pagetypes."""
		if not self._submittable_pagetypes:
			self._submittable_pagetypes = jingrow.get_all(
				"PageType", {"is_submittable": 1}, pluck="name", order_by=None
			)
		return self._submittable_pagetypes


def get_child_tables_of_pagetypes(pagetypes: list[str] | None = None):
	"""Returns child tables by pagetype."""
	filters = [["fieldtype", "=", "Table"]]
	filters_for_pagefield = filters
	filters_for_customfield = filters

	if pagetypes:
		filters_for_pagefield = [*filters, ["parent", "in", tuple(pagetypes)]]
		filters_for_customfield = [*filters, ["dt", "in", tuple(pagetypes)]]

	links = jingrow.get_all(
		"PageField",
		fields=["parent", "fieldname", "options as child_table"],
		filters=filters_for_pagefield,
		as_list=1,
		order_by=None,
	)

	links += jingrow.get_all(
		"Custom Field",
		fields=["dt as parent", "fieldname", "options as child_table"],
		filters=filters_for_customfield,
		as_list=1,
		order_by=None,
	)

	child_tables_by_pagetype = defaultdict(list)
	for pagetype, fieldname, child_table in links:
		child_tables_by_pagetype[pagetype].append(
			{"pagetype": pagetype, "fieldname": fieldname, "child_table": child_table}
		)
	return child_tables_by_pagetype


def get_references_across_pagetypes(
	to_pagetypes: list[str] | None = None, limit_link_pagetypes: list[str] | None = None
) -> list:
	"""Find pagetype wise foreign key references.

	:param to_pagetypes: Get links of these pagetypes.
	:param limit_link_pagetypes: limit links to these pagetypes.

	* Include child table, link and dynamic link references.
	"""
	if limit_link_pagetypes:
		child_tables_by_pagetype = get_child_tables_of_pagetypes(limit_link_pagetypes)
		all_child_tables = [
			each["child_table"] for each in itertools.chain(*child_tables_by_pagetype.values())
		]
		limit_link_pagetypes = limit_link_pagetypes + all_child_tables
	else:
		child_tables_by_pagetype = get_child_tables_of_pagetypes()
		all_child_tables = [
			each["child_table"] for each in itertools.chain(*child_tables_by_pagetype.values())
		]

	references_by_link_fields = get_references_across_pagetypes_by_link_field(to_pagetypes, limit_link_pagetypes)
	references_by_dlink_fields = get_references_across_pagetypes_by_dynamic_link_field(
		to_pagetypes, limit_link_pagetypes
	)

	references = references_by_link_fields.copy()
	for k, v in references_by_dlink_fields.items():
		references.setdefault(k, []).extend(v)

	for links in references.values():
		for link in links:
			link["is_child"] = link["pagetype"] in all_child_tables
	return references


def get_references_across_pagetypes_by_link_field(
	to_pagetypes: list[str] | None = None, limit_link_pagetypes: list[str] | None = None
):
	"""Find pagetype wise foreign key references based on link fields.

	:param to_pagetypes: Get links to these pagetypes.
	:param limit_link_pagetypes: limit links to these pagetypes.
	"""
	filters = [["fieldtype", "=", "Link"]]

	if to_pagetypes:
		filters += [["options", "in", tuple(to_pagetypes)]]

	filters_for_pagefield = filters[:]
	filters_for_customfield = filters[:]

	if limit_link_pagetypes:
		filters_for_pagefield += [["parent", "in", tuple(limit_link_pagetypes)]]
		filters_for_customfield += [["dt", "in", tuple(limit_link_pagetypes)]]

	links = jingrow.get_all(
		"PageField",
		fields=["parent", "fieldname", "options as linked_to"],
		filters=filters_for_pagefield,
		as_list=1,
	)

	links += jingrow.get_all(
		"Custom Field",
		fields=["dt as parent", "fieldname", "options as linked_to"],
		filters=filters_for_customfield,
		as_list=1,
	)

	links_by_pagetype = defaultdict(list)
	for pagetype, fieldname, linked_to in links:
		links_by_pagetype[linked_to].append({"pagetype": pagetype, "fieldname": fieldname})
	return links_by_pagetype


def get_references_across_pagetypes_by_dynamic_link_field(
	to_pagetypes: list[str] | None = None, limit_link_pagetypes: list[str] | None = None
):
	"""Find pagetype wise foreign key references based on dynamic link fields.

	:param to_pagetypes: Get links to these pagetypes.
	:param limit_link_pagetypes: limit links to these pagetypes.
	"""

	filters = [["fieldtype", "=", "Dynamic Link"]]

	filters_for_pagefield = filters[:]
	filters_for_customfield = filters[:]

	if limit_link_pagetypes:
		filters_for_pagefield += [["parent", "in", tuple(limit_link_pagetypes)]]
		filters_for_customfield += [["dt", "in", tuple(limit_link_pagetypes)]]

	# find dynamic links of parents
	links = jingrow.get_all(
		"PageField",
		fields=["parent as pagetype", "fieldname", "options as pagetype_fieldname"],
		filters=filters_for_pagefield,
		as_list=1,
		order_by=None,
	)

	links += jingrow.get_all(
		"Custom Field",
		fields=["dt as pagetype", "fieldname", "options as pagetype_fieldname"],
		filters=filters_for_customfield,
		as_list=1,
		order_by=None,
	)

	links_by_pagetype = defaultdict(list)
	for pagetype, fieldname, pagetype_fieldname in links:
		try:
			filters = [[pagetype_fieldname, "in", to_pagetypes]] if to_pagetypes else []
			for linked_to in jingrow.get_all(pagetype, pluck=pagetype_fieldname, filters=filters, distinct=1):
				if linked_to:
					links_by_pagetype[linked_to].append(
						{"pagetype": pagetype, "fieldname": fieldname, "pagetype_fieldname": pagetype_fieldname}
					)
		except jingrow.db.ProgrammingError:
			# TODO: FIXME
			continue
	return links_by_pagetype


def get_referencing_documents(
	reference_pagetype: str,
	reference_names: list[str],
	link_info: dict,
	get_parent_if_child_table_pg: bool = True,
	parent_filters: list[list] | None = None,
	child_filters=None,
	allowed_parents=None,
):
	"""Get linked documents based on link_info.

	:param reference_pagetype: reference pagetype to find links
	:param reference_names: reference document names to find links for
	:param link_info: linking details to get the linked documents
	        Ex: {'pagetype': 'Purchase Invoice Advance', 'fieldname': 'reference_name',
	                'pagetype_fieldname': 'reference_type', 'is_child': True}
	:param get_parent_if_child_table_pg: Get parent record incase linked document is a child table record.
	:param parent_filters: filters to apply on if not a child table.
	:param child_filters: apply filters if it is a child table.
	:param allowed_parents: list of parents allowed in case of get_parent_if_child_table_pg
	        is enabled.
	"""
	from_table = link_info["pagetype"]
	filters = [[link_info["fieldname"], "in", tuple(reference_names)]]
	if link_info.get("pagetype_fieldname"):
		filters.append([link_info["pagetype_fieldname"], "=", reference_pagetype])

	if not link_info.get("is_child"):
		filters.extend(parent_filters or [])
		return {from_table: jingrow.get_all(from_table, filters, pluck="name", order_by=None)}

	filters.extend(child_filters or [])
	res = jingrow.get_all(from_table, filters=filters, fields=["name", "parenttype", "parent"], order_by=None)
	documents = defaultdict(list)

	for parent, rows in itertools.groupby(res, key=lambda row: row["parenttype"]):
		if allowed_parents and parent not in allowed_parents:
			continue
		filters = (parent_filters or []) + [["name", "in", tuple(row.parent for row in rows)]]
		documents[parent].extend(jingrow.get_all(parent, filters=filters, pluck="name", order_by=None) or [])
	return documents


@jingrow.whitelist()
def cancel_all_linked_pgs(pgs, ignore_pagetypes_on_cancel_all=None):
	"""
	Cancel all linked pagetype, optionally ignore pagetypes specified in a list.

	Arguments:
	        pgs (json str) - It contains list of dictionaries of a linked documents.
	        ignore_pagetypes_on_cancel_all (list) - List of pagetypes to ignore while cancelling.
	"""
	if ignore_pagetypes_on_cancel_all is None:
		ignore_pagetypes_on_cancel_all = []

	pgs = json.loads(pgs)
	if isinstance(ignore_pagetypes_on_cancel_all, str):
		ignore_pagetypes_on_cancel_all = json.loads(ignore_pagetypes_on_cancel_all)
	for i, pg in enumerate(pgs, 1):
		if validate_linked_pg(pg, ignore_pagetypes_on_cancel_all):
			linked_pg = jingrow.get_pg(pg.get("pagetype"), pg.get("name"))
			linked_pg.cancel()
		jingrow.publish_progress(percent=i / len(pgs) * 100, title=_("Cancelling documents"))


def validate_linked_pg(pageinfo, ignore_pagetypes_on_cancel_all=None):
	"""
	Validate a document to be submitted and non-exempted from auto-cancel.

	Arguments:
	        pageinfo (dict): The document to check for submitted and non-exempt from auto-cancel
	        ignore_pagetypes_on_cancel_all (list) - List of pagetypes to ignore while cancelling.

	Returns:
	        bool: True if linked document passes all validations, else False
	"""
	# ignore pagetype to cancel
	if pageinfo.get("pagetype") in (ignore_pagetypes_on_cancel_all or []):
		return False

	# skip non-submittable pagetypes since they don't need to be cancelled
	if not jingrow.get_meta(pageinfo.get("pagetype")).is_submittable:
		return False

	# skip draft or cancelled documents
	if pageinfo.get("pagestatus") != 1:
		return False

	# skip other pagetypes since they don't need to be cancelled
	auto_cancel_exempt_pagetypes = get_exempted_pagetypes()
	if pageinfo.get("pagetype") in auto_cancel_exempt_pagetypes:
		return False

	return True


def get_exempted_pagetypes():
	"""Get list of pagetypes exempted from being auto-cancelled"""
	return list(jingrow.get_hooks("auto_cancel_exempted_pagetypes"))


def get_linked_pgs(pagetype: str, name: str, linkinfo: dict | None = None) -> dict[str, list]:
	if isinstance(linkinfo, str):
		# additional fields are added in linkinfo
		linkinfo = json.loads(linkinfo)

	results = {}

	if not linkinfo:
		return results

	is_target_pagetype_table = jingrow.get_meta(pagetype).istable

	for linked_pagetype, link_context in linkinfo.items():
		# Don't try to fetch linked documents if the user can't read the pagetype
		if not jingrow.has_permission(linked_pagetype):
			continue

		linked_pagetype_meta = jingrow.get_meta(linked_pagetype)

		if linked_pagetype_meta.issingle:
			continue

		filters = []
		ret = None
		parent_info = None

		fields = [
			d.fieldname
			for d in linked_pagetype_meta.get(
				"fields",
				{
					"in_list_view": 1,
					"fieldtype": ["not in", ("Image", "HTML", "Button", *jingrow.model.table_fields)],
				},
			)
		] + ["name", "modified", "pagestatus"]

		if add_fields := link_context.get("add_fields"):
			fields += add_fields

		fields = [f"`tab{linked_pagetype}`.`{sf.strip()}`" for sf in fields if sf and "`tab" not in sf]

		if filters_ctx := link_context.get("filters"):
			ret = jingrow.get_list(pagetype=linked_pagetype, fields=fields, filters=filters_ctx, order_by=None)

		elif link_context.get("get_parent"):
			# check for child table
			if not is_target_pagetype_table:
				continue

			parent_info = parent_info or jingrow.db.get_value(
				pagetype, name, ["parenttype", "parent"], as_dict=True, order_by=None
			)

			if parent_info and parent_info.parenttype == linked_pagetype:
				ret = jingrow.get_list(
					pagetype=linked_pagetype,
					fields=fields,
					filters=[[linked_pagetype, "name", "=", parent_info.parent]],
					order_by=None,
				)

		elif child_pagetype := link_context.get("child_pagetype"):
			or_filters = [
				[child_pagetype, link_fieldnames, "=", name] for link_fieldnames in link_context["fieldname"]
			]

			# dynamic link_context
			if pagetype_fieldname := link_context.get("pagetype_fieldname"):
				filters.append([child_pagetype, pagetype_fieldname, "=", pagetype])

			ret = jingrow.get_list(
				pagetype=linked_pagetype,
				fields=fields,
				filters=filters,
				or_filters=or_filters,
				distinct=True,
				order_by=None,
			)

		elif link_fieldnames := link_context.get("fieldname"):
			if isinstance(link_fieldnames, str):
				link_fieldnames = [link_fieldnames]
			or_filters = [[linked_pagetype, fieldname, "=", name] for fieldname in link_fieldnames]
			# dynamic link_context
			if pagetype_fieldname := link_context.get("pagetype_fieldname"):
				filters.append([linked_pagetype, pagetype_fieldname, "=", pagetype])
			# check for child table that no one links to
			if linked_pagetype_meta.istable:
				if not (
					jingrow.db.exists("PageField", {"options": linked_pagetype})
					or jingrow.db.exists(linked_pagetype, {"parenttype": pagetype, "parent": name})
				):
					continue
			ret = jingrow.get_list(
				pagetype=linked_pagetype, fields=fields, filters=filters, or_filters=or_filters, order_by=None
			)

		if ret:
			results[linked_pagetype] = ret

	return results


@jingrow.whitelist()
def get(pagetype, pagename):
	jingrow.has_permission(pagetype, pg=pagename)
	linked_pagetypes = get_linked_pagetypes(pagetype=pagetype)
	return get_linked_pgs(pagetype=pagetype, name=pagename, linkinfo=linked_pagetypes)


@jingrow.whitelist()
def get_linked_pagetypes(pagetype, without_ignore_user_permissions_enabled=False):
	"""add list of pagetypes this pagetype is 'linked' with.

	Example, for Customer:

	        {"Address": {"fieldname": "customer"}..}
	"""
	if without_ignore_user_permissions_enabled:
		return jingrow.cache.hget(
			"linked_pagetypes_without_ignore_user_permissions_enabled",
			pagetype,
			lambda: _get_linked_pagetypes(pagetype, without_ignore_user_permissions_enabled),
		)
	else:
		return jingrow.cache.hget("linked_pagetypes", pagetype, lambda: _get_linked_pagetypes(pagetype))


def _get_linked_pagetypes(pagetype, without_ignore_user_permissions_enabled=False):
	ret = {}
	# find fields where this pagetype is linked
	ret.update(get_linked_fields(pagetype, without_ignore_user_permissions_enabled))
	ret.update(get_dynamic_linked_fields(pagetype, without_ignore_user_permissions_enabled))

	filters = [["fieldtype", "in", jingrow.model.table_fields], ["options", "=", pagetype]]
	if without_ignore_user_permissions_enabled:
		filters.append(["ignore_user_permissions", "!=", 1])
	# find links of parents
	links = jingrow.get_all("PageField", fields=["parent as dt"], filters=filters)
	links += jingrow.get_all("Custom Field", fields=["dt"], filters=filters)

	for (dt,) in links:
		if dt in ret:
			continue
		ret[dt] = {"get_parent": True}

	for dt in list(ret):
		try:
			pagetype_module = load_pagetype_module(dt)
		except (ImportError, KeyError):
			# in case of Custom PageType
			# or in case of module rename eg. (Schools -> Education)
			continue

		if getattr(pagetype_module, "exclude_from_linked_with", False):
			del ret[dt]

	return ret


def get_linked_fields(pagetype, without_ignore_user_permissions_enabled=False):
	filters = [["fieldtype", "=", "Link"], ["options", "=", pagetype]]
	if without_ignore_user_permissions_enabled:
		filters.append(["ignore_user_permissions", "!=", 1])

	# find links of parents
	links = jingrow.get_all("PageField", fields=["parent", "fieldname"], filters=filters, as_list=1)
	links += jingrow.get_all("Custom Field", fields=["dt as parent", "fieldname"], filters=filters, as_list=1)

	ret = {}

	if not links:
		return ret

	links_dict = defaultdict(list)
	for pagetype, fieldname in links:
		links_dict[pagetype].append(fieldname)

	for pagetype_name in links_dict:
		ret[pagetype_name] = {"fieldname": links_dict.get(pagetype_name)}
	table_pagetypes = jingrow.get_all(
		"PageType", filters=[["istable", "=", "1"], ["name", "in", tuple(links_dict)]]
	)
	child_filters = [
		["fieldtype", "in", jingrow.model.table_fields],
		["options", "in", tuple(pagetype.name for pagetype in table_pagetypes)],
	]
	if without_ignore_user_permissions_enabled:
		child_filters.append(["ignore_user_permissions", "!=", 1])

	# find out if linked in a child table
	for parent, options in jingrow.get_all(
		"PageField", fields=["parent", "options"], filters=child_filters, as_list=1
	):
		ret[parent] = {"child_pagetype": options, "fieldname": links_dict[options]}
		if options in ret:
			del ret[options]

	virtual_pagetypes = jingrow.get_all("PageType", {"is_virtual": 1}, pluck="name")
	for dt in virtual_pagetypes:
		ret.pop(dt, None)

	return ret


def get_dynamic_linked_fields(pagetype, without_ignore_user_permissions_enabled=False):
	ret = {}

	filters = [["fieldtype", "=", "Dynamic Link"]]
	if without_ignore_user_permissions_enabled:
		filters.append(["ignore_user_permissions", "!=", 1])

	# find dynamic links of parents
	links = jingrow.get_all(
		"PageField",
		fields=["parent as pagetype", "fieldname", "options as pagetype_fieldname"],
		filters=filters,
	)
	links += jingrow.get_all(
		"Custom Field",
		fields=["dt as pagetype", "fieldname", "options as pagetype_fieldname"],
		filters=filters,
	)

	for df in links:
		if is_single(df.pagetype):
			continue

		meta = jingrow.get_meta(df.pagetype)
		if meta.is_virtual:
			continue

		is_child = meta.istable
		possible_link = jingrow.get_all(
			df.pagetype,
			filters={df.pagetype_fieldname: pagetype},
			fields=["parenttype"] if is_child else None,
			distinct=True,
		)

		if not possible_link:
			continue

		if is_child:
			for d in possible_link:
				ret[d.parenttype] = {
					"child_pagetype": df.pagetype,
					"fieldname": [df.fieldname],
					"pagetype_fieldname": df.pagetype_fieldname,
				}
		else:
			ret[df.pagetype] = {"fieldname": [df.fieldname], "pagetype_fieldname": df.pagetype_fieldname}

	return ret
