from __future__ import annotations

import filecmp
import logging
import logging.config
import os
import shutil
import stat
import subprocess
import sys

from collections import deque
from pathlib import Path

from . import constants


logger = logging.getLogger(__name__)


class ColorFilter(logging.Filter):
    class MessageColors:
        ERROR = '\033[91m'    # bright red
        WARNING = '\033[93m'  # bright yellow
        INFO = '\033[94m'     # bright blue
        DEBUG = '\033[95m'    # bright magenta
        OKGREEN = '\033[92m'  # bright green
        DEFAULT = '\033[0m'   # terminal default

    color_map = {
        logging.CRITICAL: MessageColors.ERROR,
        logging.ERROR: MessageColors.ERROR,
        logging.WARNING: MessageColors.WARNING,
        logging.INFO: MessageColors.INFO,
        logging.DEBUG: MessageColors.DEBUG,
        constants.SUCCESS_LOGLEVEL: MessageColors.OKGREEN,
    }

    def filter(self, record):
        record.msg = self.color_map[record.levelno] + record.msg + ColorFilter.MessageColors.DEFAULT
        return record


def configure_logger(verbosity: int, disable_colors: bool = False):
    logging_levels = {
        0: 'ERROR',
        1: 'WARNING',
        2: 'INFO',
        3: 'DEBUG',
    }

    root_logger = logging.getLogger()
    root_logger.setLevel(logging_levels[verbosity])
    handler = logging.StreamHandler(sys.stdout)
    if not disable_colors:
        handler.addFilter(ColorFilter())
    root_logger.addHandler(handler)


def deprecation_notice(msg: str) -> None:
    """Utility function to standardize deprecation notice logging."""
    logger.warning("DEPRECATION NOTICE: %s", msg)


def run_command(command, capture_output=False, allow_error=False):
    logger.info('Running command:')
    logger.info('  %s', ' '.join(command))
    try:
        # pylint: disable=R1732
        process = subprocess.Popen(command,
                                   stdout=subprocess.PIPE,
                                   stderr=subprocess.STDOUT)
    except FileNotFoundError:
        msg = f"You do not have {command[0]} installed."
        if command[0] in constants.runtime_files:
            blurb = {True: 'installed', False: 'not installed'}
            install_summary = ', '.join([
                f'{runtime}: {blurb.get(bool(shutil.which(runtime)))}' for runtime in constants.runtime_files
            ])
            msg += (
                f'\nYou do not have {command[0]} installed.\n'
                f'Please either install {command[0]} or specify an alternative container '
                f'runtime by passing --container-runtime on the command line.\n'
                f'Below are the supported container runtimes and whether '
                f'or not they were found on your system.\n{install_summary}'
            )
        logger.error(msg)
        sys.exit(1)

    output = []
    trailing_output = deque(maxlen=20)
    for line in iter(process.stdout.readline, b''):
        line = line.decode(sys.stdout.encoding)
        if capture_output:
            output.append(line.rstrip())
        trailing_output.append(line.rstrip())
        logger.debug(line.rstrip('\n'))  # line ends added by logger itself
    logger.debug('')

    rc = process.wait()
    if rc is not None and rc != 0 and (not allow_error):
        main_logger = logging.getLogger('ansible_builder')
        if main_logger.level > logging.INFO:
            logger.error('Command that had error:')
            logger.error('  %s', ' '.join(command))
        if main_logger.level > logging.DEBUG:
            if capture_output:
                for line in output:
                    logger.error(line)
                logger.error('')
            else:
                if len(trailing_output) == 20:
                    logger.error('...showing last 20 lines of output...')
                for line in trailing_output:
                    logger.error(line)
                logger.error('')
        logger.error("An error occurred (rc=%s), see output line(s) above for details.", rc)
        sys.exit(1)

    return rc, output


def write_file(filename: str, lines: list) -> bool:
    parent_dir = os.path.dirname(filename)
    if parent_dir and not os.path.exists(parent_dir):
        logger.warning('Creating parent directory for %s', filename)
        os.makedirs(parent_dir)
    new_text = '\n'.join(lines)
    if os.path.exists(filename):
        with open(filename, 'r') as f:
            if f.read() == new_text:
                logger.debug("File %s is already up-to-date.", filename)
                return False
            logger.warning('File %s had modifications and will be rewritten', filename)
    with open(filename, 'w') as f:
        f.write(new_text)
    return True


def copy_directory(source_dir: Path, dest: Path):
    """
    Recursively copy a source directory to a path in the context directory.

    In order to not corrupt the build context cache, if it should exist, we
    attempt to copy files within the source directory to the context directory
    if necessary by utilizing copy_file() on each file, rather than a blind
    recursive copy.
    """

    if not source_dir.is_dir():
        raise Exception(f"Expected a directory at '{source_dir}'")

    for child in source_dir.iterdir():
        copy_location = dest / child.name
        if child.is_dir():
            # a subdir of our build destination directory
            copy_location.mkdir(exist_ok=True)
            copy_directory(child, copy_location)
        else:
            copy_file(str(child), str(copy_location))


def copy_file(source: str, dest: str, ignore_mtime: bool = False) -> bool:
    """
    Used to copy a source file to a destination file in the container runtime
    context subfolder.

    This utility function helps in maintaining the build cache. We only want
    to copy the file if it doesn't exist, or if it has changed between builds.
    See the `copy_directory()` function for the directory copy equivalent.

    :param str source: Path to a source file.
    :param str dest: Path to a destination file within the context subdir.
    :param bool ignore_mtime: Whether mtime should be considered.

    :returns: True if the file was copied, False if not.

    :raises: Exception if called with the path to a directory. This helps to
        catch programming errors.
    """

    should_copy = False

    if os.path.abspath(source) == os.path.abspath(dest):
        logger.info("File %s was placed in build context by user, leaving unmodified.", dest)
        return False
    if Path(source).is_dir():
        raise Exception(f"Source {source} can not be a directory. Please use copy_directory instead.")
    if Path(dest).is_dir():
        raise Exception(f"Destination {dest} can not be a directory. Please use copy_directory instead.")
    if Path(source).is_symlink() and Path(dest).is_symlink() and os.readlink(source) == os.readlink(dest):
        logger.debug("Symlink %s already exists and matches.", dest)
        should_copy = False
    elif not os.path.exists(dest):
        logger.debug("File %s will be created.", dest)
        should_copy = True
    elif not filecmp.cmp(source, dest, shallow=False):
        logger.warning('File %s had modifications and will be rewritten', dest)
        should_copy = True
    elif not ignore_mtime and os.path.getmtime(source) > os.path.getmtime(dest):
        logger.warning('File %s updated time increased and will be rewritten', dest)
        should_copy = True

    if should_copy:
        if Path(dest).is_symlink() or (Path(source).is_symlink() and os.path.exists(dest)):
            os.unlink(dest)

        shutil.copy2(source, dest, follow_symlinks=False)
    else:
        logger.debug("File %s is already up-to-date.", dest)

    return should_copy


def set_default_file_permissions(filename: str) -> None:
    """Set user+rw/grp+r/other+r read permissions, respecting umask"""
    umask = os.umask(0o777)
    os.umask(umask)
    os.chmod(
        filename,
        os.stat(filename).st_mode | ((stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) & ~umask)
    )
