#!/usr/bin/env python
# -*- coding: utf-8 -*-

import calendar
import datetime
import os
import site
import sys
import time
import unittest

MINUTE = 60
HOUR = 60 * MINUTE
DAY = 24 * HOUR

# Python 3 compatibility
if isinstance(map, type):
    xrange = range

# Add the parent directory relative to the test case because we assume the
# test will be in a subdirectory relative to the main code base.
sys.path.insert(0, os.path.join(
    os.path.dirname(os.path.realpath(__file__)), os.pardir))

import cronex


class test_testedmodule(unittest.TestCase):
    def test_substitution(self):
        """
        Verify that the special substitutions are replaced by the correct,
        expanded cron expressions.
        """
        testcases = [("@yearly", "0 0 1 1 *"),
            ("@annually", "0 0 1 1 *"),
            ("@monthly", "0 0 1 * *"),
            ("@weekly", "0 0 * * 0"),
            ("@daily", "0 0 * * *"),
            ("@midnight", "0 0 * * *"),
            ("@hourly", "0 * * * *")]
        for a, b in testcases:
            obj = cronex.CronExpression(a)
            self.assertTrue(b in repr(obj))

    def test_compute_numtab(self):
        """
        Verify that calling compute_numtab after modifying the string-version
        of an expression results in the static trigger-value cache being
        updated.
        """
        testex1 = cronex.CronExpression("*/7 5-10 5 * *")
        testex2 = cronex.CronExpression("*/5 23-2 5 8 *")
        self.assertNotEqual(testex1.string_tab, testex2.string_tab)
        self.assertNotEqual(testex1.numerical_tab, testex2.numerical_tab)
        testex1.string_tab = testex2.string_tab
        testex1.compute_numtab()
        self.assertEqual(testex1.string_tab, testex2.string_tab)
        self.assertEqual(testex1.numerical_tab, testex2.numerical_tab)

    def test_periodics_dom(self):
        """
        Verify that arbitrary-period repeaters in the day-of-the-month field
        work as expected.
        """
        now = int(time.time())
        then = time.gmtime(now - 491 * DAY)
        now_tuple = time.gmtime(now)
        testex = cronex.CronExpression("* * %491 * *")
        self.assertFalse(testex.check_trigger(now_tuple[:5]))
        testex.epoch = tuple(list(then[:5]) + [0])
        self.assertTrue(testex.check_trigger(now_tuple[:5]))
        self.assertTrue(testex.check_trigger(then[:5]))

    def test_periodics_hours(self):
        """
        Verify that arbitrary-period repeaters in the hours field work as
        expected.
        """
        now = int(time.time())
        then = time.gmtime(now - 9001 * HOUR)
        now_tuple = time.gmtime(now)
        testex = cronex.CronExpression("* %9001 * * *")
        self.assertFalse(testex.check_trigger(now_tuple[:5]))
        testex.epoch = tuple(list(then[:5]) + [0])
        self.assertTrue(testex.check_trigger(now_tuple[:5]))
        self.assertTrue(testex.check_trigger(then[:5]))

    def test_periodics_minutes(self):
        """
        Verify that arbitrary-period repeaters in the minutes field work as
        expected.
        """
        now = int(time.time())
        then = time.gmtime(now - 814075 * MINUTE)
        now_tuple = time.gmtime(now)
        testex = cronex.CronExpression("%814075 * * * *")
        self.assertFalse(testex.check_trigger(now_tuple[:5]))
        testex.epoch = tuple(list(then[:5]) + [0])
        self.assertTrue(testex.check_trigger(now_tuple[:5]))
        self.assertTrue(testex.check_trigger(then[:5]))

    def test_periodics_month(self):
        """
        Verify that arbitrary-period repeaters in the month field work as
        expected. More specifically, it verifies that the repeaters are
        triggered every "N" calendar months regardless of the day for triggers
        set to go offer every 1 month, ever 2 months, etc... through 18 months
        from 1970 through 1980.
        """
        for period in range(2, 18):
            calendar_months = 0
            description = "* * * %%%i *" % period
            cron_expression = cronex.CronExpression(description)

            for year in range(1970, 1980):
                for month in range(1, 13):
                    days_in_month = calendar.monthrange(year, month)[-1]
                    should_trigger = not (calendar_months % period)

                    for day in range(1, days_in_month + 1):
                        time_tuple = (year, month, day, 0, 0)
                        triggered = cron_expression.check_trigger(time_tuple)
                        self.assertEqual(should_trigger, triggered,
                            "%s: expected trigger to be %r for %r" %
                            (description, should_trigger, time_tuple)
                        )

                    calendar_months += 1

    def test_parse_atom(self):
        """
        Verify that parsing atoms returns sets containing all of the expected
        values.
        """
        input_expect = [
            (('5-10', (1, 20)), set([5, 6, 7, 8, 9, 10])),
            (('10-5', (1, 12)), set([10, 11, 12, 1, 2, 3, 4, 5])),
            (('5-10/2', (1, 20)), set([5, 7, 9])),
            (('10-5/2', (1, 12)), set([10, 12, 2, 4])),
            (('11-5/2', (1, 12)), set([11, 1, 3, 5])),
            (('10-5/3', (1, 11)), set([10, 2, 5])),
            (('11-5/3', (1, 14)), set([11, 14, 3])),
            (('*', (1, 100)), set(xrange(1, 101))),
            (('*/5', (1, 100)), set(xrange(1, 101, 5))),
            (('666', (1, 1000)), set([666])),
            (('21-1', (0, 23)), set([21, 22, 23, 0, 1]))]

        for give_the_function, want_from_function in input_expect:
            self.assertEqual(want_from_function,
                cronex.parse_atom(*give_the_function))

    def test_str_and_repr(self):
        """
        Verify that the __repr__ and __str__ return values can be passed to
        eval to generate an identical CronExpression.
        """
        CronExpression = cronex.CronExpression
        testex1 = cronex.CronExpression("*/15 4 1-7 * * TEST___TEST")
        testex2 = eval(repr(testex1))
        self.assertEqual(testex1.string_tab, testex2.string_tab)
        self.assertEqual(testex1.numerical_tab, testex2.numerical_tab)
        self.assertEqual(testex1.comment, testex2.comment)
        self.assertEqual(repr(testex1), str(testex1))

    def test_dom_substitution(self):
        """
        Verify that shortened month names are correctly translated numeric
        indexes.
        """
        testex1 = cronex.CronExpression(
            "* * * jan,feb,mar,apr,may,jun,jul,aug,sep,oct,nov,dec *")
        testex2 = cronex.CronExpression("* * * 1,2,3,4,5,6,7,8,9,10,11,12 *")
        self.assertEqual(repr(testex1), repr(testex2))

    def test_dow_substitution(self):
        """
        Verify that shortened days-of-the-week are correctly translated numeric
        indexes.
        """
        testex1 = cronex.CronExpression("* * * * sun,mon,tue,wed,thu,fri,sat")
        testex2 = cronex.CronExpression("* * * * 0,1,2,3,4,5,6")
        self.assertEqual(repr(testex1), repr(testex2))

    def test_dom_either_or_dow(self):
        """
        Verify that combining day of-the-month field and day-of-the-week field
        triggers on either condition matching the current day and that using a
        wild-card day of the month with a day of the week entry doesn't trigger
        every single day.
        """
        # Trigger should fire every Monday or on the 5th day of the month.
        testex = cronex.CronExpression("0 0 5 * mon")
        for e in xrange(1, 30):
            if e in (1, 5, 8, 15, 22, 29):
                self.assertTrue(testex.check_trigger((2010, 11, e, 0, 0)))
            else:
                self.assertFalse(testex.check_trigger((2010, 11, e, 0, 0)))

        # Trigger should fire every Wednesday at midnight.
        testex = cronex.CronExpression("0 0 * * wed")
        for d in xrange(1, 32):
            # In July of 2014, every Wednesday just happens to be on a day of
            # the month that's a multiple of 7.
            if not(d % 7):
                self.assertTrue(testex.check_trigger((2010, 7, d, 0, 0)))
            else:
                self.assertFalse(testex.check_trigger((2010, 7, d, 0, 0)))

    def test_L_in_dow(self):
        """
        Verify that having L in the day-of-the-week field with a number always
        triggers on last occurrence of the corresponding day of the week in any
        given month.
        """
        testex = cronex.CronExpression("0 0 * * 6L")
        # The numbers are the dates of the last Saturday in each month of the
        # year 2010.
        tv = [30, 27, 27, 24, 29, 26, 31, 28, 25, 30, 27, 25]
        for v in xrange(0, 12):
            self.assertTrue((testex.check_trigger((2010, v+1, tv[v], 0, 0))))

    def test_L_in_dom(self):
        """
        Verify that having L in the day-of-the-month field always triggers on
        the last day of the month.
        """
        testex = cronex.CronExpression("0 0 L * *")
        for y in xrange(2000, 2009):
            for v in xrange(1, 13):
                # Checks every day from January 1st 2000 through December 31st
                # 2008 an verifies that the trigger only activates on the last
                # day of the month. The year range was chosen to ensure at leas
                # one leap year would be included in the test.
                lastdom = calendar.monthrange(y, v)[-1]
                for d in xrange(1, lastdom + 1):
                    if d < lastdom:
                        self.assertFalse(testex.check_trigger((y, v, d, 0, 0)))
                    else:
                        self.assertTrue(testex.check_trigger((y, v, d, 0, 0)))

    def test_calendar_change_vs_hour_change(self):
        """
        Verify that a periodic trigger for the day of the month is based on
        calendar days, not 24-hour days.
        """
        # epoch and local differ by < 48 hours but it should be reported based
        # on calendar days, not 24 hour days
        epoch = (2010, 11, 16, 23, 59)
        local_time = (2010, 11, 18, 0, 0)
        testex = cronex.CronExpression("0 0 %2 * *", epoch, -6)
        self.assertTrue(testex.check_trigger(local_time, -6))

    def test_asterisk_is_loney(self):
        """
        Verify that asterisk cannot be combined with other atoms.
        """
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* *,1-9 * * *")

    def test_dow_occurence(self):
        """
        Verify that using "#" to find the Nth occurrence of a given day of the
        week works correctly.
        """
        for dow in xrange(0, 7):
            for occurence in (1, 6):
                day = (7 * (occurence - 1)) + dow + 1
                expression = "0 0 * * %i#%i" % (dow, occurence)
                if occurence > 5:
                    # There can never be more than 5 occurrences of a given day
                    # of the week in one month.
                    self.failUnlessRaises(ValueError, cronex.CronExpression,
                        expression)
                elif day < 32:
                    testex = cronex.CronExpression(expression)
                    self.assertTrue(testex.check_trigger(
                        (2011, 5, day, 0, 0)))
                    if day > 8:
                        self.assertFalse(testex.check_trigger(
                            (2011, 5, max(day - 7, 1), 0, 0)))
                    elif day < 25:
                        self.assertFalse(testex.check_trigger(
                            (2011, 5, max(day + 7, 1), 0, 0)))

    def test_nearest_weekday(self):
        """
        Verify that using "W" to find the nearest weekday works correctly.
        """
        month = 4
        year = 1991
        lastdom = calendar.monthrange(year, month)[-1]

        for day in xrange(1, 31):
            dow = (datetime.date.weekday(
                datetime.date(year, month, day)) + 1) % 7
            testex = cronex.CronExpression("0 0 %iW * *" % day)
            if dow == 0 or dow == 6:
                self.assertFalse(testex.check_trigger(
                    (year, month, day, 0, 0)))
                at_least_one_of_them = (
                    testex.check_trigger((year, month, max(day - 1, 1), 0, 0))
                    or
                    testex.check_trigger((year, month, max(day - 2, 1), 0, 0))
                    or
                    testex.check_trigger(
                        (year, month, min(day + 1, lastdom), 0, 0))
                    or
                    testex.check_trigger(
                        (year, month, min(day + 2, lastdom), 0, 0)))
                self.assertTrue(at_least_one_of_them)
            else:
                self.assertTrue(
                    testex.check_trigger((year, month, day, 0, 0)))

    def test_strict_range_bounds(self):
        """
        Verify that having numbers outside of the reasonable ranges for each
        field raises an exception. Tests both upper and lower bounds.
        """
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "1000 * * * *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* 1000 * * *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* * 1000 * *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* * * 1000 *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* * * * 1000")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "-1 * * * *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* -1 * * *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* * 0 * *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* * * 0 *")
        self.failUnlessRaises(ValueError,
            cronex.CronExpression, "* * * * -1")

    def test_catches_bad_modulus(self):
        """
        Verify that having a trigger with an invalid repetition period raises
        an exception.
        """
        badstuff = [
            "* * * * %-1",
            "%1 * * * *",
            "* %1 * * *",
            "* * %1 * *",
            "* * * %1 *",
            "* * * * %1",
            "%0 * * * *",
            "* %0 * * *",
            "* * %0 * *",
            "* * * %0 *",
            "* * * * %0"
        ]
        for case in badstuff:
            self.failUnlessRaises(ValueError,
                cronex.CronExpression, case)

    def test_catches_bad_W(self):
        """
        Verify that having a syntactically invalid "W" raises an exception.
        """
        badstuff = [
            "5W * * * *",
            "* 5W * * *",
            "* * 99W * *",
            "* * 0W * *",
            "* * W0 * *",
            "* * * 5W *",
            "* * * * 5W",
        ]
        for case in badstuff:
            self.failUnlessRaises(ValueError,
                cronex.CronExpression, case)

    def test_catches_bad_L(self):
        """
        Verify that having a syntactically invalid "L" raises an exception.
        """
        badstuff = [
            "L * * * *",
            "* L * * *",
            "* * 99L * *",
            "* * 0L * *",
            "* * * L *",
            "* * * * L",
            "* * * * 9L",
            "* * * * -9L"]
        for case in badstuff:
            self.failUnlessRaises(ValueError,
                cronex.CronExpression, case)

    def test_catches_bad_Pound(self):
        """
        Verify that having a syntactically invalid "#" raises an exception.
        """
        badstuff = [
            "# * * * *",
            "* # * * *",
            "* * # * *",
            "* * * # *",
            "* * * * 9#9L"]
        for case in badstuff:
            self.failUnlessRaises(ValueError,
                cronex.CronExpression, case)

    def test_fail_on_not_enough_fields(self):
        """
        Verify that an exception is thrown when the cron expression has too few
        fields.
        """
        badstuff = ["*", "* *", "* * *", "* * * *"]
        for case in badstuff:
            self.failUnlessRaises(ValueError, cronex.CronExpression, case)

    def test_unicode_is_accepted_in_python2(self):
        if sys.version_info.major > 2:
            return
        cronex.CronExpression(unicode("* * * * * ABC"))


if __name__ == "__main__":
    unittest.main()
