import time
from datetime import timedelta, datetime as dt
from monthdelta import monthdelta
from dateutil import tz
import re
import threading
import inspect
import hashlib
import traceback
import socket
import subprocess

from . import print_logger



class BadScheduleError(Exception):
	pass



def _get_eom(d):
	return ((d + monthdelta(1)).replace(day=1) - timedelta(days=1))

def _is_eom(d, hols):
	eom = _get_eom(d)
	return d.date() == eom.date()

def _is_eom_weekday(d, hols):
	eom = _get_eom(d)
	while eom.isoweekday() >= 6:
		eom -= timedelta(days=1)
	return d.date() == eom.date()

def _is_eom_businessday(d, hols):
	eom = _get_eom(d)
	while eom in hols or eom.isoweekday() >= 6:
		eom -= timedelta(days=1)
	return d.date() == eom.date()



RUNABLE_DAYS = {
	'day': lambda d, hols : True,
	'weekday': lambda d, hols : d.isoweekday() < 6,
	'weekend': lambda d, hols : d.isoweekday() > 5,
	'businessday': lambda d, hols : d not in hols and d.isoweekday() < 6,
	'holiday': lambda d, hols : d in hols or d.isoweekday() > 5,
	'trading-holiday': lambda d, hols : d in hols and d.isoweekday() < 6, # trading-holidays don't count if they fall on weekends
	# days of the week
	'monday': lambda d, hols: d.isoweekday() == 1,
	'tuesday': lambda d, hols: d.isoweekday() == 2,
	'wednesday': lambda d, hols: d.isoweekday() == 3,
	'thursday': lambda d, hols: d.isoweekday() == 4,
	'friday': lambda d, hols: d.isoweekday() == 5,
	'saturday': lambda d, hols: d.isoweekday() == 6,
	'sunday': lambda d, hols: d.isoweekday() == 7,
	# end of month
	'eom': _is_eom,
	'eom-weekday': _is_eom_weekday,
	'eom-businessday': _is_eom_businessday,
}


_SERVER_INFO = {} # cache
def _get_server_info():
	'''called to get server and git info while reporting errors'''
	global _SERVER_INFO

	if _SERVER_INFO:
		return _SERVER_INFO

	hostname =  socket.gethostname()
	_server_info = {
		'hostname': hostname,
		'ip_addr': socket.gethostbyname(hostname),
		'git_url': 'Not found'
	}
	try:
		git_remote = subprocess.run(['git', 'remote', '-v'], capture_output=True, text=True, check=True)
		for line in git_remote.stdout.split('\n'):
			line = line.strip()
			if not line:
				continue
			name = line[:line.find('http')].strip()
			if name == 'origin':
				_server_info['git_url'] = line[line.find('http'): line.find(".git ")].strip() + ".git"
	except:
		pass

	_SERVER_INFO = _server_info # cache fields
	return _SERVER_INFO



class Job(object):
	'''standard job class'''

	@classmethod
	def is_valid_interval(cls, interval, time_string):
		'''The generic Job class only supports these interval. See subclasses for others'''
		return interval in RUNABLE_DAYS and time_string is not None

	def __init__(self, jobid, every, at, func, kwargs):
		if str(every) == 'holiday':
			print("!!", "="*20, "!!")
			print("'holiday' interval  is deprecated and will be removed. \r\nUse 'weekend' and 'trading-holiday' instead")
			print("!!", "="*20, "!!")
		self.jobid = jobid
		self.interval = every
		self.time_string = at
		self.tzname = None
		self.func = func
		self.kwargs = kwargs
		self.is_running = False
		self.next_timestamp = 0
		self._is_disabled = False
		self._run_silently = False
		self._generic_err_handler = None
		self._err_handler = None
		self._func_src_code = inspect.getsource(self.func)
		# signatures for setters and getters
		self._func_signature = None
		self._job_signature_hash = None
		self._on_complete_cbs = []
		self._on_enable_cbs = []
		self._on_disable_cbs = []

	def init(self, calendar, tzname=None, generic_err_handler=None, startup_grace_mins=0):
		'''initialize extra attributes of job'''
		self.calendar = calendar
		self.tzname = tzname
		self._generic_err_handler = generic_err_handler
		self._startup_grace_mins = startup_grace_mins # look back on tasks if task scheduler just started
		self._run_info = print_logger._PrintLogger(tzname=tzname)
		self.schedule_next_run()
		return self

	def silently(self, run_silently=True):
		self._run_silently = run_silently
		return self

	def catch(self, err_handler):
		'''register job specific error handler'''
		self._err_handler = err_handler
		return self

	def register_callback(self, cb, cb_type="oncomplete"):
		'''
		register a callback function to be called when job completes
		- this callback function should expect a job object as argument
		'''
		if callable(cb) and len(inspect.signature(cb).parameters)==1:
			if cb_type == 'oncomplete':
				self._on_complete_cbs.append(cb)
			elif cb_type == 'ondisable':
				self._on_enable_cbs.append(cb)
			elif cb_type == 'onenable':
				self._on_disable_cbs.append(cb)
			else:
				raise ValueError("unsupported cb_type")
		return self

	@property
	def is_disabled(self):
		return self._is_disabled

	@is_disabled.setter
	def is_disabled(self, disable: bool):
		if disable is True:
			self._is_disabled = True
			self.next_timestamp = 0 # remove next schedule
			# call any registered ondisable callbacks
			for cb in self._on_disable_cbs:
				try:
					cb(self)
				except Exception as e:
					print("on-disable-cb-error:", str(e))
		else:
			self.schedule_next_run() # before enabling, we need to reschedule the job
			self._is_disabled = False
			# call any registered onenable callbacks
			for cb in self._on_enable_cbs:
				try:
					cb(self)
				except Exception as e:
					print("on-enable-cb-error:", str(e))

	def disable(self):
		self.is_disabled = True # calls setter
		return self

	def enable(self):
		self.is_disabled = False # calls setter
		return self

	# important datetime and timezone management methods
	def to_timestamp(self, d: dt):
		return d.timestamp()

	def to_datetime(self, t: float):
		return dt.fromtimestamp(t, tz=tz.gettz(self.tzname))

	def tz_now(self):
		return dt.now(tz=tz.gettz(self.tzname))

	def tz_dt(self, year, month, day, hour=0, minute=0, second=0, microsecond=0):
		d = dt(int(year), int(month), int(day), int(hour), int(minute), int(second), int(microsecond), tzinfo=tz.gettz(self.tzname))
		return tz.resolve_imaginary(d) # handles time that falls in the transition to/from daylight savings
	#

	def attach_upcoming_run_time(self, d: dt, just_ran: bool=False):
		'''
		attaches time to a datetime object based on the value of self.time_string
		note: self.time_string is usually a string of the form %H:%M, but can also be a list of time strings
		- returns earliest datetime that occurs in the future
		- returns None if no datetimes exist in the future
		'''
		dt_list = []
		def _add_dt(at):
			if not isinstance(at, str):
				raise BadScheduleError(f"Invalid time string '{self.time_string}'")
			h, m = at.split(':')
			dt_list.append(self.tz_dt(d.year, d.month, d.day, int(h), int(m)))

		if isinstance(self.time_string, (list,set,tuple)):
			for at in self.time_string:
				_add_dt(at)
		else:
			_add_dt(self.time_string)
		# to find an upcoming time, we need to filter dt_list based on current date and time
		now = self.tz_now().replace(second=0, microsecond=0)
		if just_ran:
			upcoming_list = [n for n in dt_list if n > now]
		else:
			now -= timedelta(minutes=self._startup_grace_mins)
			upcoming_list = [n for n in dt_list if n >= now]
		if len(upcoming_list) == 0:
			return None
		return min(upcoming_list)

	def schedule_next_run(self, just_ran=False):
		'''compute timestamp of the next run'''
		d = self.tz_now()
		upcoming = self.attach_upcoming_run_time(d, just_ran=just_ran)
		if not self._job_must_run_today() or upcoming is None:
			next_day = d + timedelta(days=1)
			while not self._job_must_run_today(next_day):
				next_day += timedelta(days=1)
			upcoming = self.attach_upcoming_run_time(next_day)

		self.next_timestamp = self.to_timestamp(upcoming)

	def _job_must_run_today(self, date=None):
		return RUNABLE_DAYS[self.interval](date or self.tz_now(), self.calendar)

	def is_due(self):
		'''test if job should run now'''
		return self.next_timestamp > 0 and (time.time() >= self.next_timestamp) and not self.is_running and not self.is_disabled

	def did_fail(self):
		'''test if job failed'''
		return self._run_info.error != ''

	def func_signature(self):
		'''create human readable function signature'''
		if self._func_signature is None:
			def readable_trim(s):
				if isinstance(s, (list,tuple)):
					return "[..]"
				elif isinstance(s, set):
					return "(..)"
				elif isinstance(s, dict):
					return "{..}"
				else:
					s_str = str(s)[:6] + ".." if len(str(s))>6 else str(s)
					return s_str.replace("<", "*").replace(">", "*") # escaping html
			arguments = ''
			if self.kwargs:
				arguments = '({})'.format(','.join(['{}={}'.format(k, readable_trim(v)) for k,v in self.kwargs.items()]))
			if self.func.__module__ == "__main__":
				self._func_signature = '{}{}'.format(self.func.__qualname__, arguments)
			else:
				self._func_signature = '{}.{}{}'.format(self.func.__module__, self.func.__qualname__, arguments)
		return self._func_signature

	def signature_hash(self):
		'''create unique job signature hash'''
		if self._job_signature_hash is None:
			sig = "{}-{}-{}.{}({})".format(
				self.interval,
				self.time_string,
				self.func.__module__,
				self.func.__qualname__,
				list(self.kwargs.values())
			)
			self._job_signature_hash = hashlib.sha1(sig.encode()).hexdigest()
		return self._job_signature_hash


	def _run(self, is_rerun: bool, kwargs: dict=None):
		'''this is an internal runner. see self.run() for more'''
		self.is_running = True
		try:
			if not self._run_silently: # add print statements
				print("========== [{:03}] - Job {} [{}] =========".format(
					self.jobid,
					"Rerun Start" if is_rerun else "Start",
					self.tz_now().strftime("%Y-%m-%d %H:%M:%S %Z")
				))
				print("Executing {}".format(self))
				print("*") # job log seperator
			start_time = time.time()

			kw = self.kwargs.copy() # start with default kwargs
			if isinstance(kwargs, dict):
				# if kwargs provided, update
				# however, job will fail if a key is provided that is not expected by the job
				# TODO: should we remove bad arguments here, or let it fail?
				kw.update(kwargs)

			return self.func(**kw)

		except Exception:
			traceback.print_exc()
			print("Job", self.func_signature(), "failed!")
			_server_info = _get_server_info()
			err_msg = "Error in {func}\nhostname: {host}\nip addr: {ip}\ngit origin url: {git_url}\n\n\n{tb}".format(
				func=self.func_signature(),
				host=_server_info['hostname'],
				ip=_server_info['ip_addr'],
				git_url=_server_info['git_url'],
				tb=traceback.format_exc()
			)
			self._run_info.set_error()
			try:
				if self._err_handler is not None:
					self._err_handler(err_msg) # job specific error callback registered through .catch()
				elif self._generic_err_handler is not None:
					self._generic_err_handler(err_msg) # generic error callback from scheduler
			except:
				traceback.print_exc()
		finally:
			# if the job was forced to rerun, we should not schedule the next run
			if not is_rerun:
				self.schedule_next_run(just_ran=True)
			if not self._run_silently: # add print statements
				print("*") # job log seperator
				print( "Finished in {:.2f} minutes".format((time.time()-start_time)/60))
				print(self)
				print("========== [{:03}] - Job {} [{}] =========".format(
					self.jobid,
					"Rerun End" if is_rerun else "End",
					self.tz_now().strftime("%Y-%m-%d %H:%M:%S %Z")
				))
			self.is_running = False


	def run(self, is_rerun: bool=False, kwargs: dict=None):
		'''
		begin job run
		- redirected all print statements to _PrintLogger
		- call error handlers if provided
		- execute registered callback functions
		'''
		with self._run_info.start_capture(): # captures all writes to stdout
			self._run(is_rerun=is_rerun, kwargs=kwargs)

		for cb in self._on_complete_cbs: # call any registered on-complete callbacks
			try:
				cb(self)
			except Exception as e:
				print("on-complete-cb-error:", str(e))


	def _next_run_dt(self):
		return self.to_datetime(self.next_timestamp) if self.next_timestamp!=0 else None

	def _logs_to_dict(self):
		return self._run_info.to_dict() if hasattr(self, '_run_info') else {}

	def _logs_from_dict(self, logs_dict):
		if hasattr(self, '_run_info'):
			self._run_info.from_dict(logs_dict)

	def to_dict(self):
		'''property to access job info dict'''
		return dict(
			jobid=self.jobid,
			func=self.func.__qualname__,
			signature=self.func_signature(),
			src=self._func_src_code,
			doc=self.func.__doc__,
			type=self.__class__.__name__,
			every=self.interval,
			at=self.time_string,
			tzname=self.tzname,
			is_running=self.is_running,
			is_disabled=self.is_disabled,
			next_run=self._next_run_dt(),
			logs=self._logs_to_dict(),
		)

	def __repr__(self):
		d = self._next_run_dt()
		return "{:10} [{:03}] | Next run = {} | {}".format(
			self.__class__.__name__,
			self.jobid,
			d.strftime("%Y-%m-%d %H:%M:%S %Z") if isinstance(d, dt) else 'Never',
			self.func_signature()
		)


class OneTimeJob(Job):
	'''type of job that runs only once'''

	@classmethod
	def is_valid_interval(cls, interval, time_string):
		try:
			dt.strptime(interval, "%Y-%m-%d")
			return True
		except:
			return False

	def schedule_next_run(self, just_ran=False):
		Y, m, d = self.interval.split('-')
		n = self.tz_dt(int(Y), int(m), int(d))
		upcoming = self.attach_upcoming_run_time(n, just_ran=just_ran)

		if upcoming is None:
			self.next_timestamp = 0
		else:
			self.next_timestamp = self.to_timestamp(upcoming)

	def is_due(self):
		if self.next_timestamp==0:
			return False
		return super().is_due()


class RepeatJob(Job):
	'''type of job that runs every n seconds'''

	@classmethod
	def is_valid_interval(cls, interval, time_string):
		return isinstance(interval, (int, float)) and time_string is None

	def schedule_next_run(self, just_ran=False):
		if not isinstance(self.interval, (int, float)) or self.interval <= 0:
			raise BadScheduleError("Illegal interval for repeating job. Expected number of seconds")

		if just_ran:
			self.next_timestamp += self.interval
		else:
			self.next_timestamp = time.time() + self.interval


class MonthlyJob(Job):
	'''
	type of job that can be scheduled to run once per month
	example interval 1st, 22nd, 30th
	limitation: we cannot intuitively handle dates >= 29 for all months
		- ex: 31st will fail for months having less than 31 days, 29th will fail for non leap-Feb
		- use 'self._strict_date' when handing dates >= 29:
			if self._strict_date == True:
				job is scheduled only on months which have the date (ex: 31st)
			elif self._strict_date == False:
				run on the last day of the month if date exceeds current month
	'''

	PATTERN = re.compile(r"^(\d{1,2})(st|nd|rd|th)$", re.IGNORECASE)

	def __init__(self, jobid, every, at, func, kwargs, strict_date):
		if not isinstance(strict_date, bool):
			raise BadScheduleError("call to .strict_date() required for monthly schedule. ex: .every('31st').strict_date(True)..")
		self._strict_date = strict_date
		super().__init__(jobid, every, at, func, kwargs)

	@classmethod
	def is_valid_interval(cls, interval, time_string):
		'''example intervals - 1st, 22nd, 30th'''
		match = cls.PATTERN.match(str(interval))
		return (match is not None and int(match.groups()[0]) <= 31) and time_string is not None

	def schedule_next_run(self, just_ran=False):
		interval = int(self.PATTERN.match(self.interval).groups()[0])
		sched_day = self.tz_now()
		upcoming = self.attach_upcoming_run_time(sched_day, just_ran=just_ran)
		# switch to next month if
		# - task just ran, or
		# - day has already passed, or
		# - day is today, but time has already passed
		# - day is after today, and today is end of month and time has already passed (ex: 31st while current month has 28 or 30 days)
		_pure_time_passed = upcoming is None
		day_passed = interval < sched_day.day # True if day already passed this month
		time_passed = interval == sched_day.day and _pure_time_passed
		last_day_case = interval > sched_day.day and _get_eom(sched_day).day == sched_day.day and _pure_time_passed

		if day_passed or time_passed or last_day_case:
			sched_day += monthdelta(1) # switch to next month

		# handle cases where the interval day doesn't occur in all months (ex: 31st)
		if interval > _get_eom(sched_day).day:
			if self._strict_date==False:
				interval = _get_eom(sched_day).day # if strict is false, run on what ever is last day of the month
			else: # strict
				while interval > _get_eom(sched_day).day: # run only on months which have the date
					sched_day += monthdelta(1)

		n = self.attach_upcoming_run_time(sched_day.replace(day=interval))
		self.next_timestamp = self.to_timestamp(n)

	def __repr__(self):
		r = super().__repr__()
		if self._strict_date:
			r = r.replace(self.__class__.__name__, self.__class__.__name__+"[strict]")
		return r


class AsyncJobWrapper(object):
	'''wrapper to run the job on a parallel thread'''

	def __init__(self, job:Job):
		self.job = job
		self.proc = None

	def __repr__(self):
		r = self.job.__repr__()
		r = r.replace(self.job.__class__.__name__, self.job.__class__.__name__+"(*)") # (*) indicates asynchronous job
		return r

	def __getattr__(self, name):
		return self.job.__getattribute__(name)

	def is_due(self):
		return self.job.is_due()

	def disable(self):
		return self.job.disable()

	def enable(self):
		return self.job.enable()

	def run(self, *args, **kwargs):
		self.proc = threading.Thread(target=self.job.run, args=args, kwargs=kwargs)
		self.proc.daemon = True
		self.proc.start()

	def catch(self, err_handler):
		'''register job specific error handler'''
		return self.job.catch(err_handler)


class NeverJob(Job):
	'''type of job that runs only on demand (using TaskMonitor plugin)'''

	@classmethod
	def is_valid_interval(cls, interval, time_string):
		return interval in ('on-demand', 'never') and time_string is None

	def schedule_next_run(self, just_ran=False):
		self.next_timestamp = 0

	def is_due(self):
		return False
