import os
import inspect
import enum
import random
import collections
import logging
import datetime
import calendar
import sched
import threading

import mini_buildd.threads

LOG = logging.getLogger(__name__)


def _rdate(base_date, h_start, h_stop):
    return datetime.datetime(year=base_date.year, month=base_date.month, day=base_date.day,
                             hour=random.randrange(h_start, h_stop),
                             minute=random.randrange(60), second=random.randrange(60))


def next_yearly_run():
    """First day of next month between 0am and 1am."""
    today = datetime.date.today()
    return _rdate(datetime.datetime(year=today.year + 1, month=1, day=1), 0, 6)


def next_monthly_run():
    """First day of next month between 0am and 1am."""
    today = datetime.date.today()
    return _rdate(today.replace(day=1) + datetime.timedelta(calendar.monthrange(today.year, today.month)[1]), 0, 1)


def next_weekly_run():
    """Next sunday between 2am and 3am."""
    now = datetime.datetime.now()
    return _rdate(now + datetime.timedelta(days=7 - (now.isoweekday() % 7)), 2, 3)


def next_daily_run():
    """Next day between 4am and 6am."""
    return _rdate(datetime.datetime.now() + datetime.timedelta(days=1), 4, 6)


def next_minutely_run():
    """For testing only; start in one minute."""
    return datetime.datetime.now() + datetime.timedelta(minutes=1)


class Type(enum.Enum):
    YEARLY = enum.auto()
    MONTHLY = enum.auto()
    WEEKLY = enum.auto()
    DAILY = enum.auto()
    MINUTELY = enum.auto()


NEXT_RUN = {Type.YEARLY: next_yearly_run,
            Type.MONTHLY: next_monthly_run,
            Type.WEEKLY: next_weekly_run,
            Type.DAILY: next_daily_run,
            Type.MINUTELY: next_minutely_run}


class _Job():
    def __enter(self):
        self.next_run = NEXT_RUN[self.type]()
        self.next_run_utc = self.next_run.astimezone(tz=datetime.timezone.utc)
        self.event = self.scheduler.enter((self.next_run - datetime.datetime.now()).total_seconds(), 1, self.__action, argument=self._argument, kwargs=self._kwargs)
        LOG.info(f"Scheduling cronjob '{self.desc}' for {self.next_run} (LOCAL), {self.next_run_utc} (UTC)")

    def __init__(self, scheduler, type_, action, argument=None, kwargs=None):
        self.scheduler = scheduler
        self.type = type_
        self._action = action
        self._argument = () if argument is None else argument
        self._kwargs = {} if kwargs is None else kwargs
        self._run_lock = threading.Lock()

        self.name = action.__name__
        self.desc = inspect.getdoc(self._action)
        self.logfile = mini_buildd.config.ROUTES["crontab"].path.join(f"{self.type.name}_{self.name}.log")
        self.log = collections.deque(self.readlog(), maxlen=20)

        self.__enter()

    def id(self):
        return f"{self.name} ({self.type.name})"

    def readlog(self):
        if os.path.exists(self.logfile):
            with mini_buildd.fopen(self.logfile) as log:
                return [line.rstrip() for line in log]
        return []

    def savelog(self):
        with mini_buildd.fopen(self.logfile, "w") as log:
            for msg in self.log:
                log.write(msg + "\n")

    def __run(self, *args, **kwargs):
        with self._run_lock:
            msg = f"Cronjob '{self.name}'"
            try:
                t0 = mini_buildd.misc.Datetime.now()
                self._action(*args, **kwargs)
                msg = f"I: {msg}: Successful"
                LOG.info(msg)
                return True
            except Exception as e:
                msg = f"E: {msg}: Failed: {mini_buildd.e2http(e)}"
                mini_buildd.log_exception(LOG, msg, e)
                return False
            finally:
                msg += f" ({t0}: {(mini_buildd.misc.Datetime.now() - t0).total_seconds()} seconds)"
                self.log.appendleft(msg)
                self.savelog()

    def run(self):
        """Run now (out of schedule)."""
        return self.__run(*self._argument, **self._kwargs)

    def __action(self, *args, **kwargs):
        self.__run(*args, **kwargs)
        self.__enter()


class Tab(mini_buildd.threads.PollerThread):
    def cronjob_debug(self):
        """For debugging cron only."""
        if random.getrandbits(1):
            LOG.info(f"Running debug cron job for {self}")
        else:
            raise Exception(f"Failing randomly for {self}")

    @classmethod
    def cronjob_check(cls):
        """Check all instances."""
        mini_buildd.get_daemon().check()

    @classmethod
    def cronjob_expire_builds(cls):
        """Expire build directories older than 5 days."""
        mini_buildd.config.ROUTES["builds"].path.expire(datetime.timedelta(days=5))

    @classmethod
    def cronjob_expire_events(cls):
        """Expire event directories older than 1 year."""
        route = mini_buildd.config.ROUTES["events"]
        route.path.expire(datetime.timedelta(weeks=1 * 12 * 4))

    @classmethod
    def cronjob_snapshots(cls, crontype):
        """Generate repository snapshots for all stable distributions."""
        prefix = f"{crontype.name}_"
        keep = {Type.YEARLY: datetime.timedelta(weeks=4 * 12 * 4),  # Keep  4 years
                Type.MONTHLY: datetime.timedelta(weeks=12 * 4),     # Keep 12 months
                Type.WEEKLY: datetime.timedelta(weeks=8),           # Keep  8 weeks: Max 24 automatic snapshots at a time
                Type.DAILY: datetime.timedelta(days=7),             # Keep 7 days (not in use)
                Type.MINUTELY: datetime.timedelta(minutes=5)}       # Keep 5 minutes (for TESTING only)

        for r in mini_buildd.models.repository.Repository.mbd_get_active():
            for d in r.distributions.all():
                for s in r.layout.suiteoption_set.filter(experimental=False, migrates_to=None):  # only for "stable" distributions
                    dist_str = r.mbd_get_distribution_string(d, s)
                    try:
                        r.mbd_reprepro.gen_snapshot(dist_str, f"{prefix}{datetime.datetime.utcnow().isoformat(timespec='minutes')}")
                    except Exception as e:  # on error, we should continue trying the other dists
                        LOG.error(f"{crontype.name} snapshot for {dist_str} failed: {e}")

                    try:
                        expire = datetime.datetime.utcnow().replace(second=0, microsecond=0) - keep[crontype]
                        LOG.info(f"Expiring all {crontype.name} snapshpots for {dist_str} older than {expire}")

                        for snap in r.mbd_reprepro.get_snapshots(dist_str, prefix=prefix):
                            stamp = datetime.datetime.fromisoformat(snap[len(prefix):])
                            if stamp <= expire:
                                r.mbd_reprepro.del_snapshot(dist_str, snap)
                            else:
                                LOG.info(f"KEEP: {snap}: {keep[crontype]}, {stamp}, {expire}")
                    except Exception as e:  # on error, we should continue trying the other dists
                        LOG.error(f"{crontype.name} expiration code for {dist_str} failed: {e}")

    def __init__(self):
        super().__init__(name="crontab")
        self.scheduler = sched.scheduler()
        self.jobs = []

        self.add(Type.WEEKLY, self.cronjob_check)
        self.add(Type.WEEKLY, self.cronjob_expire_builds)
        self.add(Type.WEEKLY, self.cronjob_expire_events)
        self.add(Type.WEEKLY, self.cronjob_snapshots, argument=[Type.WEEKLY])
        self.add(Type.MONTHLY, self.cronjob_snapshots, argument=[Type.MONTHLY])
        self.add(Type.YEARLY, self.cronjob_snapshots, argument=[Type.YEARLY])
        # self.add(Type.MINUTELY, self.cronjob_debug)  # Uncomment for crontab debugging only

    def add(self, type_, *args, **kwargs):
        self.jobs.append(_Job(self.scheduler, type_, *args, **kwargs))
        return self

    def get(self, id_):
        for j in self.jobs:
            if j.id() == id_:
                return j
        return Exception(f"No such cronjob: {id_}")

    def run_poller(self):
        self.scheduler.run(blocking=False)

    def attention(self):
        for j in self.jobs:
            if j.log and j.log[0].startswith("E"):
                return j.log[0]
        return ""
