import ftplib
import gzip
import re
from datetime import datetime
from pathlib import Path
from tempfile import NamedTemporaryFile
from time import gmtime, strftime

import click
import decouple


class CommonTools(object):

    TIMESTAMP = strftime("%Y%m%d%H%M%S", gmtime())

    @staticmethod
    def get_timestamp(name):
        """
        Gets the timestamp from a given file name (not a pathlib.Path)
        :param name: (string) Path of a file generated by AlchemyDumps
        :return: (string) The backup numeric id (in case of success) or False
        """
        pattern = r"(.*)(-)(?P<timestamp>[\d]{14})(-)(.*)(.gz)"
        match = re.search(pattern, name)
        return match.group("timestamp") if match else False

    @staticmethod
    def parse_timestamp(timestamp):
        """Transforms a timestamp ID in a humanized date"""
        date_parsed = datetime.strptime(timestamp, "%Y%m%d%H%M%S")
        return date_parsed.strftime("%b %d, %Y at %H:%M:%S")


class LocalTools(CommonTools):
    """Manage backup directory and files in local file system"""

    def __init__(self, backup_path):
        self.path = Path(backup_path).absolute()
        self.path.mkdir(exist_ok=True)

    def get_files(self):
        """List all files in the backup directory"""
        yield from (
            path
            for path in self.path.glob("*")
            if path.is_file() and self.get_timestamp(path.name)
        )

    def create_file(self, name, contents):
        """
        Creates a gzip file
        :param name: (str) Name of the file to be created (without path)
        :param contents: (bytes) Contents to be written in the file
        :return: (pathlib.Path) Path of the created file
        """
        path = self.path / name
        with gzip.open(path, "wb") as handler:
            handler.write(contents)
        return path

    def read_file(self, name):
        """
        Reads the contents of a gzip file
        :param name: (str) Name of the file to be read (without path)
        :return: (bytes) Content of the file
        """
        path = self.path / name
        with gzip.open(path, "rb") as handler:
            return handler.read()

    def delete_file(self, name):
        """
        Delete a file
        :param name: (str) Name of the file to be deleted (without path)
        """
        path = self.path / name
        path.unlink()


class RemoteTools(CommonTools):
    """Manage backup files in a remote file system via FTP"""

    def __init__(self, ftp):
        """Receives a Python FTP class instance"""
        self.ftp = ftp
        self.path = self.normalize_path()

    def normalize_path(self):
        """Add missing slash to the end of the FTP url to be used in stdout"""
        url = f"ftp://{self.ftp.host}{self.ftp.pwd()}"
        return url if url.endswith("/") else url + "/"

    def get_files(self):
        """List all files in the backup directory"""
        yield from (name for name in self.ftp.nlst() if self.get_timestamp(name))

    def create_file(self, name, contents):
        """
        Creates a gzip file
        :param name: (str) Name of the file to be created (without path)
        :param contents: (bytes) Contents to be written in the file
        :return: (str) path of the created file
        """
        with NamedTemporaryFile() as tmp:
            with gzip.open(tmp.name, "wb") as handler:
                handler.write(contents)
            with open(tmp.name, "rb") as handler:
                self.ftp.storbinary(f"STOR {name}", handler)
        return f"{self.path}{name}"

    def read_file(self, name):
        """
        Reads the contents of a gzip file
        :param name: (str) Name of the file to be read (without path)
        :return: (bytes) Content of the file
        """
        with NamedTemporaryFile() as tmp:
            with open(tmp.name, "wb") as handler:
                self.ftp.retrbinary(f"RETR {name}", handler.write)
        with gzip.open(tmp.name, "rb") as handler:
            return handler.read()

    def delete_file(self, name):
        """
        Delete a file
        :param name: (str) Name of the file to be deleted (without path)
        """
        self.ftp.delete(name)


class Backup(object):

    DIR = "alchemydumps-backup"
    PRE = "db-bkp"

    def __init__(self):
        """
        Bridge backups to local file system or to FTP server according to env
        vars set to allow FTP usage (see connect method).
        """
        self.ftp = self.ftp_connect()
        self.dir = decouple.config("ALCHEMYDUMPS_DIR", default=self.DIR)
        self.prefix = decouple.config("ALCHEMYDUMPS_PREFIX", default=self.PRE)
        self.files = None
        self.target = self.get_target()

    def ftp_connect(self):
        """
        Tries to connect to FTP server according to env vars:
        * `ALCHEMYDUMPS_FTP_SERVER`
        * `ALCHEMYDUMPS_FTP_USER`
        * `ALCHEMYDUMPS_FTP_PASSWORD`
        * `ALCHEMYDUMPS_FTP_PATH`
        :return: Python FTP class instance or False
        """
        server = decouple.config("ALCHEMYDUMPS_FTP_SERVER", default=None)
        user = decouple.config("ALCHEMYDUMPS_FTP_USER", default=None)
        password = decouple.config("ALCHEMYDUMPS_FTP_PASSWORD", default=None)
        path = decouple.config("ALCHEMYDUMPS_FTP_PATH", default=None)
        if not server or not user:
            return False

        try:
            ftp = ftplib.FTP(server, user, password)
        except ftplib.error_perm:
            click.secho(f"==> Couldn't connect to {server}", fg="red")
            return False

        return self.ftp_change_path(ftp, path)

    @staticmethod
    def ftp_change_path(ftp, path):
        """
        Changes path at FTP server
        :param ftp: Python FTP class instance
        :param path: (str) Path at the FTP server
        :return: Python FTP class instance or False
        """
        change_path = ftp.cwd(path)
        if not change_path.startswith("250 "):
            click.secho(f"==> Path doesn't exist: {path}", fg="red")
            ftp.quit()
            return False
        return ftp

    def close_ftp(self):
        if self.ftp:
            self.ftp.quit()

    def get_target(self):
        """Returns the object to manage backup files (Local or Remote)"""
        return RemoteTools(self.ftp) if self.ftp else LocalTools(self.dir)

    def get_timestamps(self):
        """
        Gets the different existing timestamp numeric IDs
        :return: (tuple) Existing timestamps in backup directory
        """
        if not self.files:
            self.files = tuple(self.target.get_files())

        timestamps = set(self.target.get_timestamp(path.name) for path in self.files)
        return tuple(timestamp for timestamp in timestamps if timestamp)

    def by_timestamp(self, timestamp):
        """
        Gets the list of all backup files with a given timestamp
        :param timestamp: (str) Timestamp to be used as filter
        :param files: (list) List of backup file names
        :return: (generator) Backup file names matching the timestamp
        """
        if not self.files:
            self.files = tuple(self.target.get_files())

        for path in self.files:
            if timestamp == self.target.get_timestamp(path.name):
                yield path

    def valid(self, timestamp):
        """Check backup files for the given timestamp"""
        if timestamp and timestamp in self.get_timestamps():
            return True

        click.secho(
            '==> Invalid id. Use "history" to list existing downloads', fg="red"
        )
        return False

    def get_name(self, class_name, timestamp=None):
        """
        Gets a backup file name given the timestamp and the name of the
        SQLAlchemy mapped class.
        """
        timestamp = timestamp or self.target.TIMESTAMP
        return f"{self.prefix}-{timestamp}-{class_name}.gz"
