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

from mini_buildd import config, daemon, threads, util

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_hourly_run():
    """Run every hour"""
    return datetime.datetime.now() + datetime.timedelta(hours=1)


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()
    HOURLY = 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.HOURLY: next_hourly_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.debug("Scheduling cronjob '%s' for %s (LOCAL), %s (UTC)", self.desc, self.next_run, self.next_run_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 = 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 util.fopen(self.logfile) as log:
                return [line.rstrip() for line in log]
        return []

    def savelog(self):
        with util.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}'"
            t0 = util.Datetime.now()
            try:
                self._action(*args, **kwargs)
                msg = f"I: {msg}: Successful"
                LOG.info(msg)
                return True
            except Exception as e:
                msg = f"E: {msg}: Failed: {util.e2http(e)}"
                util.log_exception(LOG, msg, e)
                return False
            finally:
                msg += f" ({t0}: {(util.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(threads.PollerThread):
    def cronjob_debug(self):
        """For debugging cron only"""
        if random.getrandbits(1):
            LOG.debug("Running debug cron job for %s", self)
        else:
            raise util.HTTPBadRequest(f"Failing randomly for {self}")

    @classmethod
    def cronjob_retry_uploads(cls):
        """Retry failed (buildresult) uploads"""
        daemon.get().builder.uploader.retry_failed()

    @classmethod
    def cronjob_setup_inspect(cls):
        """Inspect setup and store report (hover ``Setup`` Menu to view)"""
        daemon.get().mbd_inspect()

    @classmethod
    def cronjob_expire_builds(cls):
        """Expire build directories (configure in Daemon setup)"""
        config.ROUTES["builds"].path.expire(daemon.get_model().expire_builds_after)

    @classmethod
    def cronjob_expire_events(cls):
        """Expire event directories (configure in Daemon setup)"""
        config.ROUTES["events"].path.expire(daemon.get_model().expire_events_after)

    @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 util.models().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_diststr(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("%s snapshot for %s failed: %s", crontype.name, dist_str, e)

                    try:
                        expire = datetime.datetime.utcnow().replace(second=0, microsecond=0) - keep[crontype]
                        LOG.info("Expiring all %s snapshpots for %s older than %s", crontype.name, dist_str, 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.debug("KEEP: %s: %s, %s, %s", snap, keep[crontype], stamp, expire)
                    except Exception as e:  # on error, we should continue trying the other dists
                        LOG.error("%s expiration code for %s failed: %s", crontype.name, dist_str, e)

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

        self.add(Type.HOURLY, self.cronjob_retry_uploads)
        self.add(Type.WEEKLY, self.cronjob_setup_inspect)
        self.add(Type.DAILY, 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 __str__(self):
        return f"{super().__str__()} ({len(self.jobs)} jobs)"

    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 ""
