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

import json
from datetime import datetime, timedelta
from random import randint

import click
from croniter import CroniterBadCronError, croniter

import jingrow
from jingrow import _
from jingrow.model.page import Page
from jingrow.utils import get_datetime, now_datetime
from jingrow.utils.background_jobs import enqueue, is_job_enqueued


class ScheduledJobType(Page):
	# 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

		create_log: DF.Check
		cron_format: DF.Data | None
		frequency: DF.Literal["All", "Hourly", "Hourly Long", "Daily", "Daily Long", "Weekly", "Weekly Long", "Monthly", "Monthly Long", "Cron", "Yearly", "Annual"]
		last_execution: DF.Datetime | None
		method: DF.Data
		scheduler_event: DF.Link | None
		server_script: DF.Link | None
		stopped: DF.Check
	# end: auto-generated types
	def autoname(self):
		self.name = ".".join(self.method.split(".")[-2:])

	def validate(self):
		if self.frequency != "All":
			# force logging for all events other than continuous ones (ALL)
			self.create_log = 1

		if self.frequency == "Cron":
			if not self.cron_format:
				jingrow.throw(_("Cron format is required for job types with Cron frequency."))
			try:
				croniter(self.cron_format)
			except CroniterBadCronError:
				jingrow.throw(
					_("{0} is not a valid Cron expression.").format(f"<code>{self.cron_format}</code>"),
					title=_("Bad Cron Expression"),
				)

	def enqueue(self, force=False) -> bool:
		# enqueue event if last execution is done
		if self.is_event_due() or force:
			if not self.is_job_in_queue():
				enqueue(
					"jingrow.core.pagetype.scheduled_job_type.scheduled_job_type.run_scheduled_job",
					queue=self.get_queue_name(),
					job_type=self.method,
					job_id=self.rq_job_id,
				)
				return True
			else:
				jingrow.logger("scheduler").error(
					f"Skipped queueing {self.method} because it was found in queue for {jingrow.local.site}"
				)

		return False

	def is_event_due(self, current_time=None):
		"""Return true if event is due based on time lapsed since last execution"""
		# if the next scheduled event is before NOW, then its due!
		return self.get_next_execution() <= (current_time or now_datetime())

	def is_job_in_queue(self) -> bool:
		return is_job_enqueued(self.rq_job_id)

	@property
	def rq_job_id(self):
		"""Unique ID created to deduplicate jobs with single RQ call."""
		return f"scheduled_job::{self.method}"

	@property
	def next_execution(self):
		return self.get_next_execution()

	def get_next_execution(self):
		CRON_MAP = {
			"Yearly": "0 0 1 1 *",
			"Annual": "0 0 1 1 *",
			"Monthly": "0 0 1 * *",
			"Monthly Long": "0 0 1 * *",
			"Weekly": "0 0 * * 0",
			"Weekly Long": "0 0 * * 0",
			"Daily": "0 0 * * *",
			"Daily Long": "0 0 * * *",
			"Hourly": "0 * * * *",
			"Hourly Long": "0 * * * *",
			"All": f"*/{(jingrow.get_conf().scheduler_interval or 240) // 60} * * * *",
		}

		if not self.cron_format:
			self.cron_format = CRON_MAP.get(self.frequency)

		# If this is a cold start then last_execution will not be set.
		# Creation is set as fallback because if very old fallback is set job might trigger
		# immediately, even when it's meant to be daily.
		# A dynamic fallback like current time might miss the scheduler interval and job will never start.
		last_execution = get_datetime(self.last_execution or self.creation)
		next_execution = croniter(self.cron_format, last_execution).get_next(datetime)

		jitter = 0
		if "Long" in self.frequency:
			jitter = randint(1, 600)
		return next_execution + timedelta(seconds=jitter)

	def execute(self):
		self.scheduler_log = None
		try:
			self.log_status("Start")
			if self.server_script:
				script_name = jingrow.db.get_value("Server Script", self.server_script)
				if script_name:
					jingrow.get_pg("Server Script", script_name).execute_scheduled_method()
			else:
				# 支持 method:agent_name 格式自动传参
				method_path = self.method
				args = []
				if ':' in method_path:
					method_path, arg = method_path.split(':', 1)
					args = [arg]
				func = jingrow.get_attr(method_path)
				if hasattr(self, "code") and self.code:
					func(self.code)
				else:
					func(*args)
			jingrow.db.commit()
			self.log_status("Complete")
		except Exception:
			jingrow.db.rollback()
			self.log_status("Failed")

	def log_status(self, status):
		# log file
		jingrow.logger("scheduler").info(f"Scheduled Job {status}: {self.method} for {jingrow.local.site}")
		self.update_scheduler_log(status)

	def update_scheduler_log(self, status):
		if not self.create_log:
			# self.get_next_execution will work properly iff self.last_execution is properly set
			self.db_set("last_execution", now_datetime(), update_modified=False)
			jingrow.db.commit()
			return
		if not self.scheduler_log:
			self.scheduler_log = jingrow.get_pg(
				dict(pagetype="Scheduled Job Log", scheduled_job_type=self.name)
			).insert(ignore_permissions=True)
		self.scheduler_log.db_set("status", status)
		if jingrow.debug_log:
			self.scheduler_log.db_set("debug_log", "\n".join(jingrow.debug_log))
		if status == "Failed":
			self.scheduler_log.db_set("details", jingrow.get_traceback(with_context=True))
		if status == "Start":
			self.db_set("last_execution", now_datetime(), update_modified=False)
		jingrow.db.commit()

	def get_queue_name(self):
		return "long" if ("Long" in self.frequency) else "default"

	def on_trash(self):
		jingrow.db.delete("Scheduled Job Log", {"scheduled_job_type": self.name})


@jingrow.whitelist()
def execute_event(pg: str):
	jingrow.only_for("System Manager")
	pg = json.loads(pg)
	jingrow.get_pg("Scheduled Job Type", pg.get("name")).enqueue(force=True)
	return pg


def run_scheduled_job(job_type: str):
	"""This is a wrapper function that runs a hooks.scheduler_events method"""
	if jingrow.conf.maintenance_mode:
		raise jingrow.InReadOnlyMode("Scheduled jobs can't run in maintenance mode.")
	try:
		jingrow.get_pg("Scheduled Job Type", dict(method=job_type)).execute()
	except Exception:
		print(jingrow.get_traceback())


def sync_jobs(hooks: dict | None = None):
	jingrow.reload_pg("core", "pagetype", "scheduled_job_type")
	scheduler_events = hooks or jingrow.get_hooks("scheduler_events")
	all_events = insert_events(scheduler_events)
	clear_events(all_events)


def insert_events(scheduler_events: dict) -> list:
	cron_jobs, event_jobs = [], []
	for event_type in scheduler_events:
		events = scheduler_events.get(event_type)
		if isinstance(events, dict):
			cron_jobs += insert_cron_jobs(events)
		else:
			# hourly, daily etc
			event_jobs += insert_event_jobs(events, event_type)
	return cron_jobs + event_jobs


def insert_cron_jobs(events: dict) -> list:
	cron_jobs = []
	for cron_format in events:
		for event in events.get(cron_format):
			cron_jobs.append(event)
			insert_single_event("Cron", event, cron_format)
	return cron_jobs


def insert_event_jobs(events: list, event_type: str) -> list:
	event_jobs = []
	for event in events:
		event_jobs.append(event)
		frequency = event_type.replace("_", " ").title()
		insert_single_event(frequency, event)
	return event_jobs


def insert_single_event(frequency: str, event: str, cron_format: str | None = ""):
	try:
		jingrow.get_attr(event)
	except Exception as e:
		click.secho(f"{event} is not a valid method: {e}", fg="yellow")
		return

	pg: ScheduledJobType

	if job_name := jingrow.db.exists("Scheduled Job Type", {"method": event}):
		pg = jingrow.get_pg("Scheduled Job Type", job_name)

		# Update only frequency and cron_format fields if they are different
		# Maintain existing values of other fields
		if pg.frequency != frequency or pg.cron_format != cron_format:
			pg.cron_format = cron_format
			pg.frequency = frequency
			pg.save()
	else:
		pg = jingrow.get_pg(
			{
				"pagetype": "Scheduled Job Type",
				"method": event,
				"cron_format": cron_format,
				"frequency": frequency,
			}
		)

		savepoint = "scheduled_job_type_creation"
		try:
			jingrow.db.savepoint(savepoint)
			pg.insert()
		except jingrow.DuplicateEntryError:
			jingrow.db.rollback(save_point=savepoint)
			pg.delete()
			pg.insert()


def clear_events(all_events: list):
	for event in jingrow.get_all(
		"Scheduled Job Type", fields=["name", "method", "server_script", "scheduler_event"]
	):
		is_server_script = event.server_script
		is_defined_in_hooks = event.method in all_events

		if event.scheduler_event:
			continue

		if not (is_defined_in_hooks or is_server_script):
			jingrow.delete_pg("Scheduled Job Type", event.name)
