#!/usr/bin/env python3

"""Backup file and directories."""

import argparse
import configparser
import datetime
import subprocess
import logging
import os
import smtplib
import socket
import sys

from email.mime.text import MIMEText


__version__ = "1.1.0"
__executable__ = os.path.basename(sys.argv[0])


DEFAULT_CONFIG_FILE = os.path.join(sys.prefix, "etc", "autobackup.cfg")
DEFAULT_LOG_DIR = "/var/log/autobackup"


RSYNC_DEFAULT_FLAGS = ["-a", "-v", "--delete"]


class RsyncCompletedProcess(subprocess.CompletedProcess):
    """Helps parsing rsync output to detect errors."""

    @property
    def stdout_str(self):
        """Returns stdout decoded using utf-8."""
        return self.stdout.decode("utf-8")

    @property
    def stderr_str(self):
        """Returns stderr decoded using utf-8."""
        return self.stderr.decode("utf-8")

    @classmethod
    def from_parent(cls, parent):
        """Initialization from another instance."""
        return cls(parent.args, parent.returncode,
                   parent.stdout, parent.stderr)

    def has_errors(self):
        """Returns True if rsync exited with errors (return status is not 0)."""
        return self.returncode != 0

    def has_no_space_left_error(self):
        """Returns True if a no space left error was detected.
        Check if stderr contains 'No space left on device (28)' or
        'Result too large (34)'.
        """
        return (b"No space left on device (28)" in self.stderr or
                b"Result too large (34)" in self.stderr)

    def print_errors(self):
        """Prints process standard output and error to stderr."""
        out = f"process standard output:\n{self.stdout_str}"
        err = f"process error output:\n{self.stderr_str}"
        print(out, file=sys.stderr)
        print(err, file=sys.stderr)

    def assert_has_no_errors(self):
        """Raises an AssertionError return code is not 0."""
        if self.has_errors():
            err = "rsync process had errors:\n" + self.stderr.decode("utf-8")
            raise AssertionError(err)


def rsync(source, dest, exe="rsync", flags=None):
    """Runs rsync.

    Args:
        source (str): path to source
        dest (str): path to destination
        exe (str): path to rsync executable
        flags (list[str]): flags to be passed to rsync

    Returns:
        RsyncCompletedProcess: rsync output data.
    """
    if flags is None:
        flags = RSYNC_DEFAULT_FLAGS
    cmd = [exe] + flags + [source] + [dest]
    logging.debug("rsync command: %s", ' '.join(cmd))
    process = subprocess.run(cmd, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    logging.debug("rsync process complete")
    return RsyncCompletedProcess.from_parent(process)


def now(fmt="%y%m%d-%H%M%S"):
    """Returns the date as a string in the format `fmt`.
    Args:
        fmt (str): date string format compatible with `datetime.strftime`.

    Returns:
        str: date as a string.
    """
    return datetime.datetime.today().strftime(fmt)


def read_config_file(path):
    """Reads the configuration file."""
    logging.info("Reading configuration file %s", path)

    if not os.path.isfile(path):
        raise FileNotFoundError(f"No such file or directory: {path}")

    config = configparser.ConfigParser()
    config.read(path)
    return config


def program_name():
    """Returns the program name."""
    return os.path.basename(sys.argv[0])


def custom_logging(message):
    """Prints a logging message on sys.stderr.

    This function is supposed to be used when logging has not been initialized.
    """
    message = f"{program_name()}: {message}"
    print(message, file=sys.stderr)


def init_logging():
    """Initializes logging parameters."""
    fmt = "%(asctime)s - %(levelname)-8s %(message)s"

    # Directory where log file will be written.
    logfiledir = DEFAULT_LOG_DIR

    # Create directory if necessary.
    if not os.path.isdir(logfiledir):
        msg = f"Logging directory not found, creating {logfiledir}"
        custom_logging(msg)
        os.mkdir(logfiledir)

    # Log files names are based on current date and time.
    logfile_debug = os.path.join(logfiledir, now() + "_backup-full.log")
    logfile_info = os.path.join(logfiledir, now() + "_backup.log")
    custom_logging(f"full log file to {logfile_debug}")
    custom_logging(f"info log file to {logfile_info}")

    # This logger will log at DEBUG level.
    logging.basicConfig(level=logging.DEBUG,
                        format=fmt,
                        filename=logfile_debug,
                        filemode="w")

    # This logger will log at INFO level.
    info_handler = logging.FileHandler(logfile_info)
    info_handler.setLevel(logging.INFO)
    formatter = logging.Formatter(fmt)
    info_handler.setFormatter(formatter)
    logging.getLogger("").addHandler(info_handler)

    return logfile_info, logfile_debug


def backup_from_config(section):
    """Backups from info stored in configuration file section.

    Returns:
        bool: success status
    """
    source = section["source"]
    dest = section["dest"]
    logging.info("backing up %s to %s", source, dest)

    result = rsync(source, dest)

    if result.has_errors():
        message = f"error backing up {source} to {dest}:\n{result.stderr_str}"
        logging.error(message)
        return False

    logging.debug(result.stdout_str)
    logging.info("done, no error detected")
    return True


def send_email(exp, to, content, subject="", server="localhost"):
    """Sends a generic email."""
    logging.debug("Sending an email from %s@%s to %s", exp, server, to)
    exp = f"{exp}@{server}"

    msg = MIMEText(content)
    msg["Subject"] = subject
    msg["From"] = exp
    msg["To"] = to

    smtp = smtplib.SMTP(server)
    smtp.send_message(msg)
    smtp.quit()


def send_report_email(to, logfile_info):
    """Sends the content of `logfile_info` to recipient.

    Returns:
        bool: success status
    """
    if not os.path.exists(logfile_info):
        raise FileNotFoundError(f"Log file not found: {logfile_info}")

    with open(logfile_info, "rt") as fileobj:
        content = fileobj.read()

    has_errors = "ERROR" in content

    hostname = socket.gethostname()
    subject = f"[backup@{hostname}]: "
    if has_errors:
        subject += "ended with errors"
    else:
        subject += "success"

    try:
        send_email(os.getlogin(),
                   to=to,
                   subject=subject,
                   server="localhost",
                   content=content)
    except Exception as expt:
        logging.error("Report e-mail not sent: %s", str(expt))
        return False

    logging.info("Report e-mail successfully sent to: %s", to)
    return True


def parse_command_line():
    """Parses the command line."""
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--version", action="version",
                        version="%(prog)s " + __version__)
    parser.add_argument("-c", "--config", default=DEFAULT_CONFIG_FILE,
                        help="configuration file")
    return parser.parse_args()


def main():
    """Main process."""
    args = parse_command_line()

    logfile_info, logfile_debug = init_logging()
    logging.info("This is %s version %s", __executable__, __version__)
    logging.info("Start time: %s", now("%H:%M:%S"))
    logging.info("Log file: %s", logfile_info)
    logging.info("Full log file: %s", logfile_debug)

    # Read configuration file.
    config = read_config_file(args.config)

    # Get the alert mail address.
    if "alert_email" not in config:
        raise KeyError("no alert_email section found in configuration file")
    alert_email = config["alert_email"].get("address", fallback=None)
    if alert_email is None:
        raise KeyError("no address section found in alert_email section")
    config.remove_section("alert_email")

    # Actual backup procedure.
    nerrors = 0
    for i, name in enumerate(config.sections()):
        logging.info("----------------------------")

        success = backup_from_config(config[name])
        if not success:
            nerrors += 1

        if i == len(config.sections()) - 1:
            logging.info("----------------------------")

    if nerrors:
        logging.error("%d error %s occured during backup", nerrors,
                      "s" if nerrors > 1 else "")
    else:
        logging.info("Backup successful")

    # Send report via email (on failure only).
    if nerrors:
        send_report_email(alert_email, logfile_info)
        sys.exit(1)

    logging.info("End time: %s", now("%H:%M:%S"))


if __name__ == "__main__":
    main()
