#!/usr/bin/env python3

# This file is part of cron-wrap.
#
# cron-wrap is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# cron-wrap is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with cron-wrap.  If not, see <http://www.gnu.org/licenses/>.

from argparse import ArgumentParser, ArgumentError
from hashlib import md5
from io import StringIO
from smtplib import SMTP, SMTP_SSL
from random import randint
import pickle as pickle
import subprocess as sp
import sys
import time
import os
import signal
import syslog
import getpass


STATEFILE = None
LOGPRI = 0
__version__ = '0.7.0'

# File creation error exit code
E_FC = 1
# Failure limit reached
E_FLR = 2

# Exceptions
class LockError(Exception):
    """
    Error to be raised when a lockfile is found
    """
    pass


class FileCreationError(Exception):
    def __init__(self, msg, errorList=[]):
        self.msg = msg
        self.eList = errorList

    def __str__(self):
        return '{}\n{}'.format(
            self.msg, '\n'.join([str(e) for e in self.eList]))


class CmdTimeout(Exception):
    pass


class MailError(Exception):
    pass


# Define classes
class StateFile:
    def __init__(self, name, mode='rb+', buffering=-1, lockFile=None):
        """
        Override the init function to create a lockfile
        """
        if lockFile:
            self._lockName = lockFile
        else:
            self._lockName = '%s.lock' % name
        self._lock()
        # In order to open this r+, we need to have an existing file.  If it
        # does not exist, we want to create it first
        try:
            self._create(name)
            self._fh = open(name, mode, buffering)
        except Exception:
            self._unlock()
            raise
        self.read = self._fh.read
        self.write = self._fh.write
        self.tell = self._fh.tell
        self.seek = self._fh.seek
        self.truncate = self._f
h.truncate

    def close(self):
        """
        Override close to delete the lockfile
        """
        self._unlock()
        self._fh.close()

    def getObject(self):
        """
        Returns the object stored in the state file
        """
        self.seek(0)
        obj = None
        try:
            obj = pickle.load(self._fh)
        except Exception as e:
            # Can't unpickle for some reas
on, create a new state
            pass
        # Handle any upgrade changes
        self._upgrade(obj)
        return obj

    def saveObject(self, obj):
        """
        This will pickle the object in the current state file
        """
        # delete file contents and add new
        self.seek(0)
        self.truncate(0)
        pickle.dump(obj, self)

    def _upgrade(self, cmdState):
        # Upgrading from 0.5.x to 0.6.x adds the _lastEmailNum var.  If it
        # doesn't exist, we need to initialize it to zero
        if isinstance(cmdState, CommandState) and not \
            hasattr(cmdState, '_lastEmailNum'):
            cmdState._lastEmailNum = 0

    def _create(self, fname):
        if not os.path.exists(fname):
            # Basically "touch" the file
            with open(fname, 'w'):
                pass
        os.chmod(fname, 0o600)

    def _lock(self):
        """
        Create a lockfile or raise LockError if a lockfile is found
        """
        fd = None
        try:
            fd = os.open(self._lockName, os.O_CREAT | os.O_WRONLY | os.O_EXCL)
        except OSError as e:
            raise LockError('Lock file exists, cannot open state file: %s' %
                self._lockName)
        os.fchmod(fd, 0o600)
        os.write(fd, str(os.getpid()).encode('utf-8'))
        os.close(fd)

    def _unlock(self):
        """
        Simply deletes the lockfile
        """
        try:
            with open(self._lockName, 'rb') as fh:
                pid = int(fh.read())
            if pid != os.getpid():
                raise LockError('Lock file does not contain current process '
                    'pid! lock file: {} ; cur pid: {}'.format(pid, os.getpid())
                )
            os.unlink(self._lockName)
        except Exception as e:
            print('Failed to unlock by deleting "{}": {}'.format(
                self._lockName, e), file=sys.stderr)

    @staticmethod
    def getStateFileName(opts, cmdList):
        """
        This basically just hashes the cmdList to create a filename unique
        to this cron commmand.

            opts<optparse.Values>:  The options list from the command line
            cmdList<list>:      The command line wherein each element is an item
                                in the list

            returns -> <str>:   The full path to the state file
        """

        dig = md5(''.join(cmdList).encode('utf-8')).hexdigest()
        # Use the item we are executing as part of the file name
        baseCmd = cmdList[0]
        if opts.singStr:
            baseCmd = cmdList[0].split()[0]

        binExec = baseCmd.replace('/', '-').strip('-.')
        fname = '{}.{}'.format(binExec, dig)

        return os.path.join(opts.stateDir, fname)

    @staticmethod
    def getStateFile(opts, cmdList):
        """
        This will determine the state file name and return the StateFile object
        if it is successful.  If it can't get a statefile within the optionally
        specified period (usually because a previously started process is still
        running), it will raise an OSError.

            opts<optparse.Values>:      Options passed in on the command line
            cmdList<list>:              The command and args list

            returns -> <StateFile>:     A created StateFile instance

            raises -> FileCreationError
        """
        stFName = StateFile.getStateFileName(opts, cmdList)
        lockFile = None
        if opts.lockFile:
            lockFile = opts.lockFile
        errs = []
        sf = None

        for i in range(opts.numRetries + 1):
            try:
                sf = StateFile(stFName, lockFile=lockFile)
            except Exception as e:
                errs.append(e)
                raise
            else:
                break
            if opts.numRetries:
                time.sleep(opts.retrySecs)

        if not sf:
            raise FileCreationError(
                'Could not create state file "{}": {}'.format(
                stFName, errs))

        return sf


class Failure(object):
    """
    This is a simple class to encapsulate the items pertaining to a failure
    """
    mainDelim = '=' * 40
    subDelim = '-' * 40
    def __init__(self, command, timeStarted, runTime, exitCode,
            stdout='', stderr='', pythonError=''):
        """
        Pass in all the info for a command run

            command<list>:      A list containing all the command line items
            timeStarted<float>: The timestamp for when this was started
            runTime<float>:     The amount of time that the process ran for
            exitCode<int>:      The exit code from the process
            stdout<str>:        The output printed to stdout
            stderr<str>:        The output printed to stderr
            pythonError<str>:   An error that occured in Python trying to
                                run the program (exitCode should be -1)
        """
        self.command = command
        self.timeStarted = timeStarted
        self.runTime = runTime
        self.exitCode = exitCode
        self.stdout = stdout
        self.stderr = stderr
        self.pyError = pythonError

    def __str__(self):
        ret = '%s\nCommand: %s\n' % (self.mainDelim, ' '.join(self.command))
        ret += 'Start Time: %s\n' % time.ctime(self.timeStarted)
        ret += 'Run Time (seconds): %.02f\n' % self.runTime
    