# vim: fileencoding=utf-8
#
from subprocess import Popen, PIPE, STDOUT
from io import TextIOWrapper
from os import path
from .tools import GenericFS, get_traceback_caller
from glob import glob
import os
import sys
import contextlib


class FilesError(Exception):
    pass


class PipeProcess():
    def _get_stdout(self):
        return PIPE

    def close(self):
        pass

    @property
    def shell_command(self):
        return ''


class KeyboardInputProcess():
    def _get_stdout(self):
        return None

    def close(self):
        pass

    @property
    def shell_command(self):
        return ''


class Process:
    '''Класс-обертка для работы с процессами.'''
    STDOUT = STDOUT
    PIPE = PIPE

    def __init__(self, command, *parameters, **kwargs):
        if 'stdin' not in kwargs:
            self._stdin = PipeProcess()
        elif kwargs['stdin'] == PIPE:
            self._stdin = PipeProcess()
        elif kwargs['stdin'] is None:
            self._stdin = KeyboardInputProcess()
        else:
            self._stdin = kwargs['stdin']

        self._stdout = kwargs.get('stdout', PIPE)
        self._stderr = kwargs.get('stderr', PIPE)

        self._envdict = kwargs.get('envdict', os.environ.copy())
        self._envdict['LANG'] = kwargs.get('lang', 'C')

        self._timeout = kwargs.get('timeout', None)
        self._cwd = kwargs.get('cwd', None)

        self._command = get_program_path(command)
        if not self._command:
            raise FilesError("command not found '{}'".format(command))
        self._command = [self._command, *parameters]

        self._process = None
        self._iterator = iter([])

        # Flags.
        self._opened = False
        self._is_read = False

        self._readable = False
        self._writable = False
        self._readable_errors = False

        # I/O handlers.
        self.stdin_handler = None
        self.stdout_handler = None
        self.stderr_handler = None

        # Caches.
        self._output_cache = ''
        self._error_cache = ''

    def _get_stdout(self):
        self._open_process()
        return self.stdout_handler

    def _get_stdin(self):
        return self.stdin_handler

    def _open_process(self):
        '''Метод для открытия процесса.'''
        try:
            piped_stdin = self._stdin._get_stdout()
            self._process = Popen(self._command,
                                  stdout=self._stdout,
                                  stdin=piped_stdin,
                                  stderr=self._stderr,
                                  cwd=self._cwd,
                                  close_fds=True,
                                  env=self._envdict)

            if piped_stdin == PIPE:
                self.stdin_handler = TextIOWrapper(self._process.stdin,
                                                   encoding='utf8')
                self._writable = True
            elif piped_stdin is not None:
                self.stdin_handler = self._stdin._get_stdin()
                self._writable = True

            if self._stdout == PIPE:
                self.stdout_handler = TextIOWrapper(self._process.stdout,
                                                    encoding='utf8')
                self._readable = True

            if self._stderr == PIPE:
                self.stderr_handler = TextIOWrapper(self._process.stderr,
                                                    encoding='utf8')
                self._readable_errors = True

            self._opened = True
        except Exception as error:
            raise FilesError(f'Can not open process. Reason: {error}')

    def close(self):
        '''Метод для закрытия процесса.'''
        if self._opened:
            if self._process.stdin:
                self.stdin_handler.close()
        self._stdin.close()
        self._opened = False

    def write(self, data):
        '''Метод для записи данных в stdin процесса.'''
        if not self._opened:
            self._open_process()
            self._is_read = False
            self._output_cache = ''
        try:
            if self._writable:
                self.stdin_handler.write(data)
                self.stdin_handler.flush()
            else:
                raise FilesError('Process stdin is not writable.')
        except IOError as error:
            raise FilesError(str(error))

    def read(self):
        '''Метод для чтения данных из stdout процесса.'''
        if not self._opened and not self._writable:
            self._open_process()

        if not self._readable:
            raise FilesError('Process is not readable.')
        try:
            if not self._is_read:
                if self._writable:
                    self.close()

                if self._readable:
                    self._output_cache = self.stdout_handler.read()

                if self._readable_errors:
                    self._error_cache = self.stderr_handler.read()

                self._process.poll()
                self._is_read = True
            return self._output_cache
        except KeyboardInterrupt:
            self.kill()
            raise

    def read_error(self):
        '''Метод для чтения ошибок, появившихся при выполнении процесса, из его
        stderr.'''
        self.read()
        if not self._error_cache:
            try:
                self._error_cache = self.stderr_handler.read()
            except IOError:
                self._error_cache = ''
        return self._error_cache

    def kill(self):
        '''Метод для удаления процесса, если он работает.'''
        if self._opened:
            self._process.kill()

    def read_lines(self):
        return self.read().split('\n')

    def __iter__(self):
        if not self._iterator:
            self._iterator = iter(self.read_lines())
        return self._iterator

    def next(self):
        return next(self.__iter__(), None)

    @property
    def writable(self):
        '''Метод для проверки возможности записи данных в во входной поток
        процесса.'''
        return self._writable

    @property
    def readable(self):
        '''Метод для проверки возможности чтения вывода процесса.'''
        return self._readable

    @property
    def readable_errors(self):
        '''Метод для проверки возможности чтения ошибок.'''
        return self._readable_errors

    def return_code(self):
        '''Метод возвращающий код возвращенный процессом.'''
        self.read()
        return self._process.returncode

    @property
    def shell_command(self):
        '''Метод для получения эквивалентной консольной команды.'''
        command = ' '.join(self._command)
        previous_commands = self._stdin.shell_command
        if previous_commands == '':
            return command
        else:
            return ' | '.join([previous_commands, command])

    def success(self):
        '''Метод для проверки успешности выполнения процесса.'''
        return self.return_code() == 0

    def failed(self):
        '''Метод для проверки неуспешности выполнения процесса.'''
        return self.return_code() != 0


class ProgramPathCache:
    '''Класс, для поиска и кэширования путей к исполнительным файлам различных
    команд.'''
    def __init__(self):
        self._cache = {}

    def __call__(self, program_name, prefix='/'):
        program_base_name = path.basename(program_name)
        PATH = os.environ['PATH']
        PATH = PATH.split(':')
        cache_key = (program_base_name, prefix)

        if cache_key in self._cache:
            self._cache[cache_key]

        if program_name.startswith('/'):
            if path.exists(join_paths(prefix, program_name)):
                self._cache[cache_key] = program_name
                return program_name
        for program_name in (join_paths(bin_path, program_base_name)
                             for bin_path in PATH):
            if path.exists(join_paths(prefix, program_name)):
                self._cache[cache_key] = program_name
                return program_name
        return False


get_program_path = ProgramPathCache()


def check_command(*utils):
    '''Функция для проверки наличия той или иной команды системе.'''
    output = []
    for util in utils:
        util_path = get_program_path(util)
        if not util_path:
            raise FilesError("Command not found '{}'".
                             format(os.path.basename(util)))
        output.append(util)
    if len(output) == 1:
        return output[0]
    else:
        return output


def join_paths(*paths):
    '''Функция для объединения путей. Объединяет также абсолютные пути.'''
    if len(paths) == 1:
        return next(iter(paths))

    paths_to_join = []
    for _path in paths[1:]:
        if _path.startswith('/'):
            _path = _path.strip()[1:]
        else:
            _path = _path.strip()
        if _path and _path != "/":
            paths_to_join.append(_path)

    output_path = path.join(paths[0], *paths_to_join)
    return output_path


def read_link(file_path):
    '''Функция для получения целевого пути символьной ссылки.'''
    try:
        if path.exists(file_path):
            source_path = os.readlink(file_path)
            return source_path
        else:
            return None
    except (OSError, IOError) as error:
        mod, lineno = get_traceback_caller(*sys.exc_info())
        FilesError("can not read link: {}({}:{})".
                   format(str(error), mod, lineno))


def get_target_from_link(link_path, link_source, chroot_path='/'):
    '''Метод для получения целевого пути из целевого пути символьной ссылки
    с учетом того, что целевой путь символьной ссылки может быть
    относительным.'''
    if os.path.isabs(link_source):
        if chroot_path != '/':
            target_path = join_paths(chroot_path,
                                     link_source)
            return target_path
    else:
        link_source = link_source.split('/')
        link_dir = os.path.dirname(link_path).split('/')
        if link_source[0] == '.':
            link_source.pop()
        else:
            while link_source[0] == '..':
                link_source.pop(0)
                link_dir.pop(-1)
        link_dir.extend(link_source)
        return '/'.join(link_dir)


def read_file(file_path):
    '''Функция для чтения файлов, возвращает текст файла.'''
    try:
        if path.exists(file_path):
            with open(file_path, 'r') as opened_file:
                return opened_file.read()
    except (OSError, IOError) as error:
        mod, lineno = get_traceback_caller(*sys.exc_info())
        raise FilesError("file read error, {0}({1}:{2})".
                         format(str(error), mod, lineno))


def write_file(file_path):
    '''Функция для открытия и записи файлов. Создает директории на пути к
    целевому файлу если это необходимо. Возвращает файловый объект.'''
    directory_path = path.dirname(file_path)
    if not path.exists(directory_path):
        os.makedirs(directory_path)
    file_to_write = open(file_path, 'w')
    return file_to_write


def read_file_lines(file_name, grab=False):
    '''Функция для чтения файлов построчно.'''
    try:
        if path.exists(file_name):
            for file_line in open(file_name, 'r'):
                if grab:
                    file_line = file_line.strip()
                    if not file_line.startswith('#'):
                        yield file_line
                else:
                    yield file_line.strip()
    except (OSError, IOError):
        pass


def quite_unlink(file_path):
    try:
        if path.lexists(file_path):
            os.unlink(file_path)
    except OSError:
        pass


def list_directory(directory_path, fullpath=False, only_dir=False):
    if not path.exists(directory_path):
        return []
    try:
        if fullpath:
            if only_dir:
                return [node.path for node in os.scandir(directory_path)
                        if os.path.isdir(node.path)]
            else:
                return [node.path for node in os.scandir(directory_path)]
        else:
            if only_dir:
                return [node.name for node in os.scandir(directory_path)
                        if os.path.isdir(node.path)]
            else:
                return os.listdir(directory_path)
    except OSError:
        return []


def get_directory_contents(path: str):
    '''Функция для получения списка путей ко всем файлам и директориям,
    содержащимся в указанной директории.'''
    output = []
    for entry in os.scandir(path):
        try:
            if entry.is_symlink():
                output.append(entry.path)
            elif entry.is_dir():
                output.append(entry.path)
                output.extend(get_directory_contents(entry.path))
            elif entry.is_file():
                output.append(entry.path)
        except Exception:
            continue
    return output


def make_directory(directory_path, force=False):
    try:
        parent = os.path.split(path.normpath(directory_path))[0]
        if not path.exists(parent):
            make_directory(parent)
        else:
            if os.path.exists(directory_path):
                if force and not os.path.isdir(directory_path):
                    os.remove(directory_path)
                else:
                    return True
        os.mkdir(directory_path)
        return True
    except (OSError, IOError):
        return False


def check_directory_link(link_path, chroot_path='/'):
    '''Функция для проверки наличия зацикливающихся ссылок и их корректности в
    целом. В случае успешной проверки возвращает целевой путь ссылки.'''
    link_target = read_link(link_path)
    link_target = get_target_from_link(link_path, link_target,
                                       chroot_path=chroot_path)

    if link_target is None:
        # Ссылка не существует.
        raise FilesError('the source file does not exist')

    if not os.path.isdir(link_target):
        # Ссылка не на директорию.
        raise FilesError('the source is not directory')

    linked_path = os.path.abspath(link_target)

    # Добавляем / к концу пути, чтобы показать, что это путь к директории.
    if linked_path[-1] != '/':
        linked_path = linked_path + '/'

    # Пути, которые нужно проверить.
    to_check = [linked_path]
    # Целевые пути из встреченных ссылок.
    linked_paths = {linked_path}

    while to_check:
        current_directory = to_check.pop()

        for entry in os.scandir(current_directory):
            # Обходим только директории и ссылки на директории.
            if not entry.is_dir():
                continue
            if entry.is_symlink():
                linked_path = read_link(entry.path)
                linked_path = get_target_from_link(entry.path,
                                                   linked_path,
                                                   chroot_path=chroot_path)
                if linked_path in linked_paths:
                    raise FilesError(
                                  'the source directory contains cycled links')

                linked_paths.add(linked_path)
                to_check.append(linked_path)
            else:
                to_check.append(entry.path)

    return link_target


class RealFS(GenericFS):
    def __init__(self, prefix='/'):
        self.prefix = prefix
        if prefix == '/':
            self.remove_prefix = lambda x: x
        else:
            self.remove_prefix = self._remove_prefix

    def _remove_prefix(self, file_path):
        prefix_length = len(self.prefix)
        return file_path[:prefix_length]

    def _get_path(self, file_path):
        return join_paths(self.prefix, file_path)

    def exists(self, file_path):
        return os.path.lexists(self._get_path(file_path))

    def read(self, file_path):
        return read_file(self._get_path(file_path))

    def glob(self, file_path):
        for glob_path in glob(self._get_path(file_path)):
            yield self.remove_prefix(glob_path)

    def realpath(self, file_path):
        return self.remove_prefix(path.realpath(file_path))

    def write(self, file_path, data):
        with write_file(file_path) as target_file:
            target_file.write(data)

    def listdir(self, file_path, full_path=False):
        if full_path:
            return [self.remove_prefix(listed_path)
                    for listed_path in list_directory(file_path,
                                                      full_path=full_path)]
        else:
            return list_directory(file_path, full_path=full_path)


def get_run_commands(not_chroot=False, chroot=None, uid=None, with_pid=False):
    def get_cmdline(process_number):
        cmdline_file = '/proc/{}/cmdline'.format(process_number)
        try:
            if uid is not None:
                fstat = os.stat('/proc/{}'.format(process_number))
                if fstat.st_uid != uid:
                    return ''
            if path.exists(cmdline_file):
                if not_chroot:
                    root_link = '/proc/{}/root'.format(process_number)
                    if os.readlink(root_link) != '/':
                        return ''
                if chroot is not None:
                    root_link = '/proc/{}/root'.format(process_number)
                    if os.readlink(root_link) != chroot:
                        return ''
                return read_file(cmdline_file).strip()
        except Exception:
            pass
        return ''

    if not os.access('/proc', os.R_OK):
        return []

    proc_directory = list_directory('/proc')
    output = []
    for file_name in proc_directory:
        if file_name.isdigit():
            cmdline = get_cmdline(file_name)
            if cmdline:
                if with_pid:
                    output.append((file_name, cmdline))
                else:
                    output.append(cmdline)
    return output


@contextlib.contextmanager
def stderr_devnull():
    oldstderr = sys.stderr
    sys.stderr = open(os.devnull, 'w')
    try:
        yield
    finally:
        sys.stderr = oldstderr
