import datetime
import os
import shutil
import sys
import tempfile
import time
import unittest

from huey import SqliteHuey
from huey.api import crontab
from huey.consumer import Consumer
from huey.consumer import Scheduler
from huey.consumer_options import ConsumerConfig
from huey.exceptions import TaskException
from huey.exceptions import TaskTimeout
from huey.tests.base import BaseTestCase
from huey.tests.base import slow_test


class TestConsumer(Consumer):
    class _Scheduler(Scheduler):
        def sleep_for_interval(self, current, interval):
            pass
    scheduler_class = _Scheduler


class TestConsumerIntegration(BaseTestCase):
    consumer_class = TestConsumer

    def test_consumer_minimal(self):
        @self.huey.task()
        def task_a(n):
            return n + 1

        with self.consumer_context():
            result = task_a(1)
            self.assertEqual(result.get(blocking=True, timeout=2), 2)

    def work_on_tasks(self, consumer, n=1, now=None):
        worker, _ = consumer.worker_threads[0]
        for i in range(n):
            self.assertEqual(len(self.huey), n - i)
            worker.loop(now)

    def schedule_tasks(self, consumer, now=None):
        scheduler = consumer._create_scheduler()
        scheduler._next_loop = time.monotonic() + 60
        scheduler._next_periodic = time.monotonic() - 60
        scheduler.loop(now)

    @slow_test()
    def test_consumer_timeout(self):
        @self.huey.task(timeout=0.1, context=True)
        def t(n, task=None):
            if n:
                for _ in range(100):
                    task.check_timeout()
                    time.sleep(n / 100)
            return n

        r1 = t(0)
        r2 = t(0.2)
        consumer = self.consumer(workers=1)
        self.work_on_tasks(consumer, 2)
        self.assertEqual(r1.get(), 0)
        with self.assertRaises(TaskException):
            r2.get()
        try:
            r2.get()
        except TaskException as exc:
            self.assertEqual(exc.metadata['error'],
                             'TaskTimeout(\'timeout 0.1s\')')

    def test_consumer_schedule_task(self):
        @self.huey.task()
        def task_a(n):
            return n + 1

        now = datetime.datetime.now()
        eta = now + datetime.timedelta(days=1)
        r60 = task_a.schedule((2,), delay=60)
        rday = task_a.schedule((3,), eta=eta)

        consumer = self.consumer(workers=1)
        self.work_on_tasks(consumer, 2)  # Process the two messages.

        self.assertEqual(len(self.huey), 0)
        self.assertEqual(self.huey.scheduled_count(), 2)

        self.schedule_tasks(consumer, now)
        self.assertEqual(len(self.huey), 0)
        self.assertEqual(self.huey.scheduled_count(), 2)

        # Ensure that the task that had a delay of 60s is read from schedule.
        later = now + datetime.timedelta(seconds=65)
        self.schedule_tasks(consumer, later)
        self.assertEqual(len(self.huey), 1)
        self.assertEqual(self.huey.scheduled_count(), 1)

        # We can now work on our scheduled task.
        self.work_on_tasks(consumer, 1, later)
        self.assertEqual(r60.get(), 3)

        # Verify the task was run and that there is only one task remaining to
        # be scheduled (in a day).
        self.assertEqual(len(self.huey), 0)
        self.assertEqual(self.huey.scheduled_count(), 1)

        tomorrow = now + datetime.timedelta(days=1)
        self.schedule_tasks(consumer, tomorrow)
        self.work_on_tasks(consumer, 1, tomorrow)
        self.assertEqual(rday.get(), 4)
        self.assertEqual(len(self.huey), 0)
        self.assertEqual(self.huey.scheduled_count(), 0)

    def test_consumer_periodic_tasks(self):
        state = []

        @self.huey.periodic_task(crontab(minute='*/10'))
        def task_p1():
            state.append('p1')

        @self.huey.periodic_task(crontab(minute='0', hour='0'))
        def task_p2():
            state.append('p2')

        consumer = self.consumer(workers=1)
        dt = datetime.datetime(2000, 1, 1, 0, 0)
        self.schedule_tasks(consumer, dt)
        self.assertEqual(len(self.huey), 2)
        self.work_on_tasks(consumer, 2)
        self.assertEqual(state, ['p1', 'p2'])

        dt = datetime.datetime(2000, 1, 1, 12, 0)
        self.schedule_tasks(consumer, dt)
        self.assertEqual(len(self.huey), 1)
        self.work_on_tasks(consumer, 1)
        self.assertEqual(state, ['p1', 'p2', 'p1'])

        task_p1.revoke()
        self.schedule_tasks(consumer, dt)
        self.assertEqual(len(self.huey), 1)  # Enqueued despite being revoked.
        self.work_on_tasks(consumer, 1)
        self.assertEqual(state, ['p1', 'p2', 'p1'])  # No change, not executed.

    def test_scheduler_periodic_catch_up(self):
        @self.huey.periodic_task(crontab(minute='*'))
        def task_p():
            pass

        consumer = self.consumer(workers=1)
        scheduler = consumer._create_scheduler()
        scheduler._next_loop = time.monotonic() + 60

        # Simulate a 5-minute stall (e.g. suspend/resume): the periodic check
        # timestamp is far in the past. The scheduler skips the missed checks
        # and fires once for the current minute, rather than running the
        # back-to-back checks, which would enqueue duplicate tasks.
        scheduler._next_periodic = time.monotonic() - 300
        scheduler.loop(datetime.datetime(2000, 1, 1, 0, 0))
        self.assertEqual(len(self.huey), 1)
        self.assertTrue(scheduler._next_periodic > time.monotonic())

        # Subsequent iterations do not re-fire for the stalled period.
        scheduler.loop(datetime.datetime(2000, 1, 1, 0, 0))
        self.assertEqual(len(self.huey), 1)


class TestConsumerConfig(BaseTestCase):
    def test_default_config(self):
        cfg = ConsumerConfig()
        cfg.validate()
        consumer = self.huey.create_consumer(**cfg.values)
        self.assertEqual(consumer.workers, 1)
        self.assertEqual(consumer.worker_type, 'thread')
        self.assertTrue(consumer.periodic)
        self.assertEqual(consumer.default_delay, 0.1)
        self.assertEqual(consumer.scheduler_interval, 1)
        self.assertTrue(consumer._health_check)

    def test_consumer_config(self):
        cfg = ConsumerConfig(workers=3, worker_type='process', initial_delay=1,
                             backoff=2, max_delay=4, check_worker_health=False,
                             scheduler_interval=30, periodic=False)
        cfg.validate()
        consumer = self.huey.create_consumer(**cfg.values)

        self.assertEqual(consumer.workers, 3)
        self.assertEqual(consumer.worker_type, 'process')
        self.assertFalse(consumer.periodic)
        self.assertEqual(consumer.default_delay, 1)
        self.assertEqual(consumer.backoff, 2)
        self.assertEqual(consumer.max_delay, 4)
        self.assertEqual(consumer.scheduler_interval, 30)
        self.assertFalse(consumer._health_check)

    @unittest.skipIf(sys.platform == 'win32', 'requires fork()')
    def test_process_environment_uses_fork(self):
        # The worker/scheduler runnables cannot be pickled, so the process
        # environment must use the fork start-method regardless of the
        # platform default (spawn on MacOS 3.8+, forkserver on Linux 3.14+).
        cfg = ConsumerConfig(worker_type='process')
        cfg.validate()
        consumer = self.huey.create_consumer(**cfg.values)
        self.assertEqual(consumer.environment.mp.get_start_method(), 'fork')

    def test_invalid_values(self):
        def assertInvalid(**kwargs):
            cfg = ConsumerConfig(**kwargs)
            self.assertRaises(ValueError, cfg.validate)

        assertInvalid(backoff=0.5)
        assertInvalid(scheduler_interval=90)
        assertInvalid(scheduler_interval=7)
        assertInvalid(scheduler_interval=45)


class TestProcessWorkers(unittest.TestCase):
    @unittest.skipIf(sys.platform == 'win32', 'requires fork()')
    @slow_test()
    def test_process_worker_integration(self):
        # End-to-end check that tasks execute in forked worker processes,
        # regardless of the platform's default start-method. Memory storage
        # cannot be used here (the forked workers would operate on copies),
        # so run against a sqlite database shared by parent and children.
        tmp_dir = tempfile.mkdtemp()
        huey = SqliteHuey('proctest', filename=os.path.join(tmp_dir, 'h.db'))

        @huey.task()
        def add_pid(a, b):
            return (a + b, os.getpid())

        consumer = huey.create_consumer(workers=2, worker_type='process')
        consumer.start()
        try:
            r1, r2 = add_pid(1, 2), add_pid(3, 4)
            val1, pid1 = r1.get(blocking=True, timeout=10)
            val2, pid2 = r2.get(blocking=True, timeout=10)
            self.assertEqual((val1, val2), (3, 7))
            # The tasks ran in worker processes, not in this process.
            self.assertNotEqual(pid1, os.getpid())
            self.assertNotEqual(pid2, os.getpid())
        finally:
            consumer.stop(graceful=True)
            shutil.rmtree(tmp_dir, ignore_errors=True)
