
import os
import re
import codecs
import contextlib

DEFAULT_CRON_FILE_PATH = '/etc/cron.d/scheduled.cron'
DEFAULT_CRON_FILE_HEADER_COMMENT = (
    '\n'
    '# This file was autogenerated by a script. '
    'Please do not edit it manually.'
    '\n\n'
)
CRON_TABLE_EXPLANATION = (
    '# For more information on the crontab format, \n'
    '# see the manual pages of crontab(5) and cron(8).\n'
    '# m h  dom mon dow'
)
BASH_SHELL_VAR_DECL_REGEX = re.compile(
    '[a-zA-Z_]+'     # First character 
    '[a-zA-Z0-9_]*'  # Rest of varname
    '\s*?=\s*?'      # Equal, with padding
    '.*'             # The rest of the line
)

class CronScheduler(object):

    """ Implements a wrapper around the crontab file 
        specified at `cron_file_path` -- allowing 
        adding, removing, listing, and synchronizing 
        cron jobs in this file. There is also a method 
        to check if a particular cron job exists,
        or to empty the whole file.
        
        Assumes that the ordering of these cron jobs 
        does not matter. Comments will be removed,
        except for those this module writes. Environment 
        variables should be left untouched and in the 
        order they were found, though they will be moved
        to the top.
        
        This should be safe to use with crontabs
        under /etc/cron.d, /etc/crontab, or even 
        under a user's cron spool area.
        
        """

    def __init__(self, cron_file_path=None, 
                 cron_file_header_comment=None,
                 add_cron_table_explanation=None):

        if cron_file_path is None:
            cron_file_path = DEFAULT_CRON_FILE_PATH 
        if cron_file_header_comment is None:
            cron_file_header_comment = DEFAULT_CRON_FILE_HEADER_COMMENT
        if add_cron_table_explanation is None:
            add_cron_table_explanation = True

        self.cron_file_path = cron_file_path
        self.cron_file_header_comment = cron_file_header_comment
        self.add_cron_table_explanation = add_cron_table_explanation

    @contextlib.contextmanager
    def _open_cron_file(self, mode, **kwargs):
        # The file must be written the 
        # first time it is used.
        if not os.path.isfile(self.cron_file_path):
            mode = 'w+'
        with codecs.open(self.cron_file_path, mode=mode, **kwargs) as f:
            yield f

    def _yield_cron_lines(self):
        with self._open_cron_file(mode='r') as f:
            for line in f.readlines():
                line = line.strip()
                # Skip comment lines
                if line and not line.startswith('#') \
                        and not BASH_SHELL_VAR_DECL_REGEX.match(line):
                    yield line

    def _get_shell_variable_declarations(self):
        with self._open_cron_file(mode='r') as f:
            shell_variable_declarations = []
            for line in f.readlines():
                line = line.strip()
                if BASH_SHELL_VAR_DECL_REGEX.match(line):
                    shell_variable_declarations.append(line)
            return shell_variable_declarations

    def _get_cron_set(self):
        return set(self._yield_cron_lines())

    def _write_cron_set(self, cron_set):
        # If there are any variable declarations
        # at the top of the cron file, get them
        # so they're not overwritten
        variable_declarations = \
                self._get_shell_variable_declarations()
        with self._open_cron_file(mode='w') as f:
            self._write_cron_file_header(f, variable_declarations)
            for cron_job in cron_set:
                f.write('{}\n'.format(cron_job))
            f.truncate()

    def _write_cron_file_header(self, f, variable_declarations):
        f.write(self.cron_file_header_comment)
        for declaration in variable_declarations:
            f.write(declaration)
            f.write('\n')
        f.write('\n')
        if self.add_cron_table_explanation:
            f.write(CRON_TABLE_EXPLANATION)
            f.write('\n')

    def cron_job_exists(self, cron_job):
        """
        Takes a string `cron_job`, and checks
        if it is present in the CronScheduler's
        file at `self.cron_file_path`.
        """
        return cron_job in self._get_cron_set()

    def add_cron_job(self, cron_job):
        """
        Takes a string `cron_job`. If it's not already
        present in the CronScheduler's file, adds
        it so that it is present.
        """
        cron_set = self._get_cron_set()
        cron_set.add(cron_job)
        self._write_cron_set(cron_set)

    def remove_cron_job(self, cron_job):
        """
        Takes a string `cron_job`. If it's present in
        the CronScheduler's file, removes it so that
        it is no longer present. Returns whether
        or not it was present.
        """
        cron_set = self._get_cron_set()
        try:
            cron_set.remove(cron_job)
        except KeyError:
            return False
        self._write_cron_set(cron_set)
        return True 

    def list_cron_jobs(self):
        return list(self._get_cron_set())

    def synchronize_cron_jobs(self, new_cron_jobs):
        """
        Takes a list of cron jobs, and synchronizes 
        the CronScheduler's file with the list.

        This means adding any cron jobs that were not
        present but should be, and removing any cron
        jobs that were present but shouldn't be.
        """
        self._write_cron_set(set(new_cron_jobs))

    def reset(self):
        self._write_cron_set(set([]))
