import logging
import os
import re
import shutil
import traceback
from io import StringIO
from threading import Thread
from typing import List, Set, Tuple, Dict, Iterable, Optional, Any, Pattern, Collection

from colorama import Fore

from bauh.commons import system
from bauh.commons.system import run_cmd, new_subprocess, new_root_subprocess, SystemProcess, SimpleProcess
from bauh.commons.util import size_to_byte
from bauh.gems.arch.exceptions import PackageNotFoundException, PackageInHoldException

RE_DEPS = re.compile(r'[\w\-_]+:[\s\w_\-.]+\s+\[\w+]')
RE_OPTDEPS = re.compile(r'[\w._\-]+\s*:')
RE_DEP_NOTFOUND = re.compile(r'error:.+\'(.+)\'')
RE_DEP_OPERATORS = re.compile(r'[<>=]')
RE_REPOSITORY_FIELDS = re.compile(r'(Repository|Name|Description|Version|Install Date|Validated By)\s*:\s*(.+)')
RE_INSTALLED_SIZE = re.compile(r'Installed Size\s*:\s*([0-9,.]+)\s(\w+)\n?', re.IGNORECASE)
RE_DOWNLOAD_SIZE = re.compile(r'Download Size\s*:\s*([0-9,.]+)\s(\w+)\n?', re.IGNORECASE)
RE_UPDATE_REQUIRED_FIELDS = re.compile(r'(\bProvides\b|\bInstalled Size\b|\bConflicts With\b)\s*:\s(.+)\n')
RE_REMOVE_TRANSITIVE_DEPS = re.compile(r'removing\s([\w\-_]+)\s.+required\sby\s([\w\-_]+)\n?')
RE_AVAILABLE_MIRRORS = re.compile(r'.+\s+OK\s+.+\s+(\d+:\d+)\s+.+(http.+)')
RE_PACMAN_SYNC_FIRST = re.compile(r'SyncFirst\s*=\s*(.+)')
RE_DESKTOP_FILES = re.compile(r'\n?([\w\-_]+)\s+(/usr/share/.+\.desktop)')
RE_IGNORED_PACKAGES: Optional[Pattern] = None


def is_available() -> bool:
    return bool(shutil.which('pacman'))


def get_repositories(pkgs: Iterable[str]) -> dict:
    pkgre = '|'.join(pkgs).replace('+', r'\+').replace('.', r'\.')

    searchres = new_subprocess(['pacman', '-Ss', pkgre]).stdout
    repositories = {}

    for line in new_subprocess(['grep', '-E', '.+/({}) '.format(pkgre)], stdin=searchres).stdout:
        if line:
            match = line.decode()
            for p in pkgs:
                if p in match:
                    repositories[p] = match.split('/')[0]

    not_found = {pkg for pkg in pkgs if pkg and pkg not in repositories}

    if not_found:  # if there are some packages not found, try to find via the single method:
        for dep in not_found:
            repo_data = guess_repository(dep)

            if repo_data:
                repositories[repo_data[0]] = repo_data[1]

    return repositories


def get_info(pkg_name, remote: bool = False) -> str:
    return run_cmd('pacman -{}i {}'.format('Q' if not remote else 'S', pkg_name), print_error=False)


def get_info_list(pkg_name: str, remote: bool = False) -> List[tuple]:
    info = get_info(pkg_name, remote)
    if info:
        return re.findall(r'(\w+\s?\w+)\s*:\s*(.+(\n\s+.+)*)', info)


def get_info_dict(pkg_name: str, remote: bool = False) -> Optional[dict]:
    list_attrs = {'depends on', 'required by', 'conflicts with'}
    info_list = get_info_list(pkg_name, remote)

    if info_list:
        info_dict = {}
        for info_data in info_list:
            attr = info_data[0].lower().strip()
            info_dict[attr] = info_data[1]

            if info_dict[attr] == 'None':
                info_dict[attr] = None

            if attr == 'optional deps' and info_dict[attr]:
                info_dict[attr] = info_dict[attr].split('\n')
            elif attr in list_attrs and info_dict[attr]:
                info_dict[attr] = [d.strip() for d in info_dict[attr].split(' ') if d]

        return info_dict


def check_installed(pkg: str) -> bool:
    res = run_cmd('pacman -Qq ' + pkg, print_error=False)
    return bool(res)


def fill_ignored_packages(output: Set[str]):
    output.update(list_ignored_packages())


def map_packages(names: Optional[Iterable[str]] = None, remote: bool = False, signed: bool = True,
                 not_signed: bool = True, skip_ignored: bool = False) -> Dict[str, Dict[str, Dict[str, str]]]:
    if not signed and not not_signed:
        return {}

    ignored, thread_ignored = None, None
    if not skip_ignored:
        ignored = set()
        thread_ignored = Thread(target=fill_ignored_packages, args=(ignored,), daemon=True)
        thread_ignored.start()

    env = system.gen_env()
    env['LC_TIME'] = ''

    code, allinfo = system.execute(f"pacman -{'S' if remote else 'Q'}i {' '.join(names) if names else ''}",
                                   shell=True, custom_env=env)

    pkgs = {'signed': {}, 'not_signed': {}}

    if code == 0 and allinfo:
        current_pkg = {}

        for idx, field_tuple in enumerate(RE_REPOSITORY_FIELDS.findall(allinfo)):
            if field_tuple[0].startswith('R'):
                current_pkg['repository'] = field_tuple[1].strip()
            elif field_tuple[0].startswith('N'):
                current_pkg['name'] = field_tuple[1].strip()
            elif field_tuple[0].startswith('Ve'):
                current_pkg['version'] = field_tuple[1].strip()
            elif field_tuple[0].startswith('D'):
                current_pkg['description'] = field_tuple[1].strip()
            elif field_tuple[0].startswith('I'):
                current_pkg['install_date'] = field_tuple[1].strip()
            elif field_tuple[0].startswith('Va'):
                if not_signed and field_tuple[1].strip().lower() == 'none':
                    pkgs['not_signed'][current_pkg['name']] = current_pkg
                    del current_pkg['name']
                elif signed:
                    pkgs['signed'][current_pkg['name']] = current_pkg
                    del current_pkg['name']

                current_pkg = {}

    if thread_ignored and (pkgs['signed'] or pkgs['not_signed']):
        thread_ignored.join()

        if ignored:
            for key in ('signed', 'not_signed'):
                if pkgs.get(key):
                    to_del = set()
                    for pkg in pkgs[key].keys():
                        if pkg in ignored:
                            to_del.add(pkg)

                    for pkg in to_del:
                        del pkgs[key][pkg]
    return pkgs


def install_as_process(pkgpaths: Iterable[str], root_password: Optional[str], file: bool, pkgdir: str = '.',
                       overwrite_conflicting_files: bool = False, simulate: bool = False, as_deps: bool = False) -> SimpleProcess:
    cmd = ['pacman', '-U'] if file else ['pacman', '-S']
    cmd.extend(pkgpaths)

    if simulate:
        cmd.append('--confirm')
    else:
        cmd.append('--noconfirm')
        cmd.append('-dd')

    if overwrite_conflicting_files:
        cmd.append('--overwrite=*')

    if as_deps:
        cmd.append('--asdeps')

    return SimpleProcess(cmd=cmd,
                         root_password=root_password,
                         cwd=pkgdir,
                         error_phrases={"error: failed to prepare transaction", 'error: failed to commit transaction', 'error: target not found'},
                         shell=False)


def map_desktop_files(*pkgnames) -> Dict[str, List[str]]:
    res = {}

    if pkgnames:
        output = run_cmd('pacman -Ql {}'.format(' '.join(pkgnames)), print_error=False)

        if output:
            for match in RE_DESKTOP_FILES.findall(output):
                pkgfiles = res.get(match[0], [])
                res[match[0]] = pkgfiles
                pkgfiles.append(match[1])

    return res


def list_installed_files(pkgname: str) -> List[str]:
    installed_files = run_cmd('pacman -Qlq {}'.format(pkgname), print_error=False)

    paths = []

    if installed_files:
        for f in installed_files.split('\n'):
            if f:
                f_strip = f.strip()

                if f_strip and not f_strip.endswith('/'):
                    paths.append(f_strip)

    return paths


def verify_pgp_key(key: str) -> bool:
    list_key = new_subprocess(['pacman-key', '-l']).stdout

    for out in new_subprocess(['grep', " " + key], stdin=list_key).stdout:
        if out:
            line = out.decode().strip()
            if line and key in line:
                return True

    return False


def receive_key(key: str, root_password: Optional[str]) -> SystemProcess:
    return SystemProcess(new_root_subprocess(['pacman-key', '-r', key], root_password=root_password), check_error_output=False)


def sign_key(key: str, root_password: Optional[str]) -> SystemProcess:
    return SystemProcess(new_root_subprocess(['pacman-key', '--lsign-key', key], root_password=root_password), check_error_output=False)


def list_ignored_packages(config_path: str = '/etc/pacman.conf') -> Set[str]:
    ignored = set()

    try:
        with open(config_path, 'r') as f:
            file_content = f.read()

        global RE_IGNORED_PACKAGES

        if not RE_IGNORED_PACKAGES:
            RE_IGNORED_PACKAGES = re.compile(r'[\s#]*ignorepkg\s*=\s*.+', re.IGNORECASE)

        for raw_line in RE_IGNORED_PACKAGES.findall(file_content):
            line = raw_line.strip()

            if line and not line.startswith("#"):
                ignored.add(line.split("=")[1].strip())
    except (FileNotFoundError, OSError):
        pass
    except Exception:
        traceback.print_exc()

    return ignored


def check_missing(names: Set[str]) -> Set[str]:
    installed = new_subprocess(['pacman', '-Qq', *names])

    not_installed = set()

    for o in installed.stderr:
        if o:
            err_line = o.decode()

            if err_line:
                not_found = [n for n in RE_DEP_NOTFOUND.findall(err_line) if n]

                if not_found:
                    not_installed.update(not_found)

    return not_installed


def read_repository_from_info(name: str) -> Optional[str]:
    info = new_subprocess(['pacman', '-Si', name])

    not_found = False
    for o in info.stderr:
        if o:
            err_line = o.decode()
            if RE_DEP_NOTFOUND.findall(err_line):
                not_found = True

    if not_found:
        return

    repository = None

    for o in new_subprocess(['grep', '-Po', r"Repository\s+:\s+\K.+"], stdin=info.stdout).stdout:
        if o:
            line = o.decode().strip()

            if line:
                repository = line

    return repository


def guess_repository(name: str) -> Tuple[str, str]:
    if not name:
        raise Exception("'name' cannot be None or blank")

    only_name = RE_DEP_OPERATORS.split(name)[0]
    res = run_cmd('pacman -Ss {}'.format(only_name))

    if res:
        lines = res.split('\n')

        if lines:
            for line in lines:
                if line and not line.startswith(' '):
                    data = line.split('/')
                    line_name, line_repo = data[1].split(' ')[0], data[0]

                    provided = read_provides(line_name)

                    if provided:
                        found = {p for p in provided if only_name == RE_DEP_OPERATORS.split(p)[0]}

                        if found:
                            return line_name, line_repo


def read_provides(name: str) -> Set[str]:
    dep_info = new_subprocess(['pacman', '-Si', name])

    not_found = False

    for o in dep_info.stderr:
        if o:
            err_line = o.decode()

            if err_line:
                if RE_DEP_NOTFOUND.findall(err_line):
                    not_found = True

    if not_found:
        raise PackageNotFoundException(name)

    provides = None

    for out in new_subprocess(['grep', '-Po', r'Provides\s+:\s\K(.+)'], stdin=dep_info.stdout).stdout:
        if out:
            provided_names = [p.strip() for p in out.decode().strip().split(' ') if p]

            if provided_names[0].lower() == 'none':
                provides = {name}
            else:
                provides = {name, *provided_names}

    return provides


def read_dependencies(name: str) -> Set[str]:
    dep_info = new_subprocess(['pacman', '-Si', name])

    not_found = False

    for o in dep_info.stderr:
        if o:
            err_line = o.decode()

            if err_line:
                if RE_DEP_NOTFOUND.findall(err_line):
                    not_found = True

    if not_found:
        raise PackageNotFoundException(name)

    depends_on = set()
    for out in new_subprocess(['grep', '-Po', r'Depends\s+On\s+:\s\K(.+)'], stdin=dep_info.stdout).stdout:
        if out:
            line = out.decode().strip()

            if line:
                depends_on.update([d for d in line.split(' ') if d and d.lower() != 'none'])

    return depends_on


def sync_databases(root_password: Optional[str], force: bool = False) -> SimpleProcess:
    return SimpleProcess(cmd=['pacman', '-Sy{}'.format('y' if force else '')],
                         root_password=root_password)


def get_version_for_not_installed(pkgname: str) -> str:
    output = run_cmd('pacman -Ss {}'.format(pkgname), print_error=False)

    if output:
        return output.split('\n')[0].split(' ')[1].strip()


def map_repositories(pkgnames: Iterable[str] = None) -> Dict[str, str]:
    info = run_cmd(f"pacman -Si {' '.join(pkgnames) if pkgnames else ''}", print_error=False, ignore_return_code=True)
    if info:
        repos = re.findall(r'(Name|Repository)\s*:\s*(.+)', info)

        if repos:
            return {repos[idx+1][1].strip(): repo_data[1].strip() for idx, repo_data in enumerate(repos) if idx % 2 == 0}

    return {}


def list_repository_updates() -> Dict[str, str]:
    output = run_cmd('pacman -Qu')
    res = {}
    if output:
        for line in output.split('\n'):
            if line:
                line_split = line.split(' ')
                res[line_split[0]] = line_split[-1]
    return res


def get_build_date(pkgname: str) -> str:
    output = run_cmd('pacman -Qi {}'.format(pkgname))

    if output:
        bdate_line = [l for l in output.split('\n') if l.startswith('Build Date')]

        if bdate_line:
            return ':'.join(bdate_line[0].split(':')[1:]).strip()


def search(words: str) -> Dict[str, dict]:
    output = run_cmd('pacman -Ss ' + words, print_error=False)

    found = {}
    if output:
        current = {}
        for l in output.split('\n'):
            if l:
                if l.startswith(' '):
                    current['description'] = l.strip()
                    found[current['name']] = current
                    del current['name']
                    current = None
                else:
                    if current is None:
                        current = {}

                    repo_split = l.split('/')
                    current['repository'] = repo_split[0]

                    data_split = repo_split[1].split(' ')
                    current['name'] = data_split[0]

                    current['version'] = data_split[1]
    return found


def get_databases() -> Set[str]:
    with open('/etc/pacman.conf') as f:
        conf_str = f.read()

    return {db for db in re.findall(r'[\n|\s]+\[(\w+)]', conf_str) if db != 'options'}


def can_refresh_mirrors() -> bool:
    return is_mirrors_available()


def refresh_mirrors(root_password: Optional[str]) -> SimpleProcess:
    return SimpleProcess(cmd=['pacman-mirrors', '-g'], root_password=root_password)


def update_mirrors(root_password: Optional[str], countries: List[str]) -> SimpleProcess:
    return SimpleProcess(cmd=['pacman-mirrors', '-c', ','.join(countries)], root_password=root_password)


def sort_fastest_mirrors(root_password: Optional[str], limit: int) -> SimpleProcess:
    cmd = ['pacman-mirrors', '--fasttrack']

    if limit > 0:
        cmd.append(str(limit))

    return SimpleProcess(cmd=cmd, root_password=root_password)


def list_mirror_countries() -> List[str]:
    output = run_cmd('pacman-mirrors -l')

    if output:
        return [c for c in output.split('\n') if c]


def get_current_mirror_countries() -> List[str]:
    output = run_cmd('pacman-mirrors -lc').strip()
    return ['all'] if not output else [c for c in output.split('\n') if c]


def is_mirrors_available() -> bool:
    return bool(shutil.which('pacman-mirrors'))


def map_update_sizes(pkgs: List[str]) -> Dict[str, float]:  # bytes:
    output = run_cmd('pacman -Si {}'.format(' '.join(pkgs)))

    if output:
        return {pkgs[idx]: size_to_byte(size[0], size[1]) for idx, size in enumerate(RE_INSTALLED_SIZE.findall(output))}

    return {}


def map_download_sizes(pkgs: List[str]) -> Dict[str, float]:  # bytes:
    output = run_cmd('pacman -Si {}'.format(' '.join(pkgs)))

    if output:
        return {pkgs[idx]: size_to_byte(size[0], size[1]) for idx, size in enumerate(RE_DOWNLOAD_SIZE.findall(output))}

    return {}


def get_installed_size(pkgs: List[str]) -> Dict[str, float]:  # bytes
    output = run_cmd('pacman -Qi {}'.format(' '.join(pkgs)))

    if output:
        return {pkgs[idx]: size_to_byte(size[0], size[1]) for idx, size in enumerate(RE_INSTALLED_SIZE.findall(output))}

    return {}


def upgrade_system(root_password: Optional[str]) -> SimpleProcess:
    return SimpleProcess(cmd=['pacman', '-Syyu', '--noconfirm'], root_password=root_password)


def _fill_provided_map(key: str, val: str, output: Dict[str, Set[str]]):
    current_val = output.get(key)

    if current_val is None:
        output[key] = {val}
    else:
        current_val.add(val)


def map_provided(remote: bool = False, pkgs: Iterable[str] = None) -> Optional[Dict[str, Set[str]]]:
    output = run_cmd(f"pacman -{'S' if remote else 'Q'}i {' '.join(pkgs) if pkgs else ''}")

    if output:
        provided_map = {}
        latest_name, latest_version, provided = None, None, False

        for l in output.split('\n'):
            if l:
                if l[0] != ' ':
                    line = l.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()
                    val = line[field_sep_idx + 1:].strip()

                    if field == 'Name':
                        latest_name = val
                    elif field == 'Version':
                        latest_version = val.split('=')[0]
                    elif field == 'Provides':
                        _fill_provided_map(latest_name, latest_name, provided_map)
                        _fill_provided_map(f'{latest_name}={latest_version}', latest_name, provided_map)

                        if val != 'None':
                            for w in val.split(' '):
                                if w:
                                    word = w.strip()
                                    _fill_provided_map(word, latest_name, provided_map)

                                    word_split = word.split('=')

                                    if word_split[0] != word:
                                        _fill_provided_map(word_split[0], latest_name, provided_map)
                        else:
                            provided = True

                    elif provided:
                        latest_name = None
                        latest_version = None
                        provided = False

                elif provided:
                    for w in l.split(' '):
                        if w:
                            word = w.strip()
                            _fill_provided_map(word, latest_name, provided_map)

                            word_split = word.split('=')

                            if word_split[0] != word:
                                _fill_provided_map(word_split[0], latest_name, provided_map)

        return provided_map


def list_download_data(pkgs: Iterable[str]) -> List[Dict[str, str]]:
    _, output = system.run(['pacman', '-Si', *pkgs])

    res = []
    if output:
        data = {'a': None, 'v': None, 'r': None, 'n': None}

        for l in output.split('\n'):
            if l:
                if l[0] != ' ':
                    line = l.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()
                    val = line[field_sep_idx + 1:].strip()

                    if field == 'Repository':
                        data['r'] = val
                    elif field == 'Name':
                        data['n'] = val
                    elif field == 'Version':
                        data['v'] = val.split('=')[0]
                    elif field == 'Architecture':
                        data['a'] = val
                    elif data.get('a'):
                        res.append(data)
                        data = {'a': None, 'v': None, 'r': None, 'n': None}

    return res


def map_updates_data(pkgs: Iterable[str], files: bool = False, description: bool = False) -> Optional[Dict[str, Dict[str, object]]]:
    if pkgs:
        if files:
            output = run_cmd('pacman -Qi -p {}'.format(' '.join(pkgs)))
        else:
            output = run_cmd('pacman -Si {}'.format(' '.join(pkgs)))

        res = {}
        if output:
            latest_name = None
            data = {'ds': None, 's': None, 'v': None, 'c': None, 'p': None, 'd': None, 'r': None, 'des': None}
            latest_field = None

            for l in output.split('\n'):
                if l:
                    if l[0] != ' ':
                        line = l.strip()
                        field_sep_idx = line.index(':')
                        field = line[0:field_sep_idx].strip()
                        val = line[field_sep_idx + 1:].strip()

                        if field == 'Repository':
                            data['r'] = val
                            latest_field = 'r'
                        elif field == 'Name':
                            latest_name = val
                            latest_field = 'n'
                        elif field == 'Version':
                            data['v'] = val.split('=')[0]
                            latest_field = 'v'
                        elif description and field == 'Description':
                            data['des'] = val
                            latest_field = 'des'
                        elif field == 'Provides':
                            latest_field = 'p'
                            data['p'] = {latest_name, '{}={}'.format(latest_name, data['v'])}
                            if val != 'None':
                                for w in val.split(' '):
                                    if w:
                                        word = w.strip()
                                        data['p'].add(word)

                                        word_split = word.split('=')

                                        if word_split[0] != word:
                                            data['p'].add(word_split[0])
                        elif field == 'Depends On':
                            val = val.strip()

                            if val == 'None':
                                data['d'] = None
                            else:
                                data['d'] = {w.strip() for w in val.split(' ') if w}
                                latest_field = 'd'
                        elif field == 'Conflicts With':
                            if val == 'None':
                                data['c'] = None
                            else:
                                data['c'] = {w.strip() for w in val.split(' ') if w}

                            latest_field = 'c'
                        elif field == 'Download Size':
                            size = val.split(' ')
                            data['ds'] = size_to_byte(size[0], size[1])
                            latest_field = 'ds'
                        elif field == 'Installed Size':
                            size = val.split(' ')
                            data['s'] = size_to_byte(size[0], size[1])
                            latest_field = 's'
                        elif latest_name and latest_field == 's':
                            res[latest_name] = data
                            latest_name = None
                            latest_field = None
                            data = {'ds': None, 's': None, 'c': None, 'p': None, 'd': None,
                                    'r': None, 'v': None, 'des': None}
                        else:
                            latest_field = None

                    elif latest_field and latest_field in ('p', 'c', 'd'):
                        if latest_field == 'p':
                            for w in l.split(' '):
                                if w:
                                    word = w.strip()
                                    data['p'].add(word)

                                    word_split = word.split('=')

                                    if word_split[0] != word:
                                        data['p'].add(word_split[0])
                        else:
                            data[latest_field].update((w.strip() for w in l.split(' ') if w))

        return res


def upgrade_several(pkgnames: Iterable[str], root_password: Optional[str], overwrite_conflicting_files: bool = False, skip_dependency_checks: bool = False) -> SimpleProcess:
    cmd = ['pacman', '-S', *pkgnames, '--noconfirm']

    if overwrite_conflicting_files:
        cmd.append('--overwrite=*')

    if skip_dependency_checks:
        cmd.append('-dd')

    return SimpleProcess(cmd=cmd,
                         root_password=root_password,
                         error_phrases={'error: failed to prepare transaction', 'error: failed to commit transaction', 'error: target not found'},
                         shell=True)


def download(root_password: Optional[str], *pkgnames: str) -> SimpleProcess:
    return SimpleProcess(cmd=['pacman', '-Swdd', *pkgnames, '--noconfirm', '--noprogressbar'],
                         root_password=root_password,
                         error_phrases={'error: failed to prepare transaction', 'error: failed to commit transaction', 'error: target not found'},
                         shell=True)


def remove_several(pkgnames: Iterable[str], root_password: Optional[str], skip_checks: bool = False) -> SimpleProcess:
    cmd = ['pacman', '-R', *pkgnames, '--noconfirm']

    if skip_checks:
        cmd.append('-dd')

    return SimpleProcess(cmd=cmd, root_password=root_password, wrong_error_phrases={'warning:'}, shell=True)


def _map_optional_dep(line: str, not_installed: bool) -> Optional[Tuple[str, Optional[str]]]:
    if not not_installed or not line.endswith('[installed]'):
        pkg_desc = line.split(':')

        if len(pkg_desc) == 1:
            return pkg_desc[0].split('[installed]')[0].strip(), ''
        elif len(pkg_desc) > 1:
            return pkg_desc[0], pkg_desc[1].split('[installed]')[0].strip()


def map_optional_deps(names: Iterable[str], remote: bool, not_installed: bool = False) -> Dict[str, Dict[str, str]]:
    output = run_cmd('pacman -{}i {}'.format('S' if remote else 'Q', ' '.join(names)))
    res = {}
    if output:
        latest_name, deps = None, None

        for raw_line in output.split('\n'):
            if raw_line:
                if raw_line[0] != ' ':
                    line = raw_line.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()

                    if field == 'Name':
                        val = line[field_sep_idx + 1:].strip()
                        latest_name = val
                    elif field == 'Optional Deps':
                        val = line[field_sep_idx + 1:].strip()
                        deps = {}
                        if val != 'None':
                            dep_desc = _map_optional_dep(val, not_installed)

                            if dep_desc:
                                deps[dep_desc[0]] = dep_desc[1]

                    elif latest_name and deps is not None:
                        res[latest_name] = deps
                        latest_name, deps = None, None

                elif latest_name and deps is not None:
                    dep_desc = _map_optional_dep(raw_line.strip(), not_installed)

                    if dep_desc:
                        deps[dep_desc[0]] = dep_desc[1]

    return res


def map_required_dependencies(*names: str) -> Dict[str, Set[str]]:
    output = run_cmd('pacman -Qi {}'.format(' '.join(names) if names else ''))

    if output:
        res = {}
        latest_name, deps, latest_field = None, None, None

        for l in output.split('\n'):
            if l:
                if l[0] != ' ':
                    line = l.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()

                    if field == 'Name':
                        val = line[field_sep_idx + 1:].strip()
                        latest_name = val
                        deps = None
                    elif field == 'Depends On':
                        val = line[field_sep_idx + 1:].strip()

                        if deps is None:
                            deps = set()

                        if val != 'None':
                            deps.update((dep for dep in val.split(' ') if dep))

                    elif latest_name and deps is not None:
                        res[latest_name] = deps
                        latest_name, deps, latest_field = None, None, None

                elif latest_name and deps is not None:
                    deps.update((dep for dep in l.split(' ') if dep))

        return res


def get_cache_dir() -> str:
    dir_pattern = re.compile(r'.*CacheDir\s*=\s*.+')

    if os.path.exists('/etc/pacman.conf'):
        with open('/etc/pacman.conf') as f:
            config_str = f.read()

        cache_dirs = []

        for string in dir_pattern.findall(config_str):
            if not string.strip().startswith('#'):
                cache_dirs.append(string.split('=')[1].strip())

        if cache_dirs:
            if cache_dirs[-1][-1] == '/':
                return cache_dirs[-1][0:-1]
            else:
                return cache_dirs[-1]
        else:
            return '/var/cache/pacman/pkg'


def map_required_by(names: Iterable[str] = None, remote: bool = False) -> Dict[str, Set[str]]:
    output = run_cmd(f"pacman -{'Sii' if remote else 'Qi'} {' '.join(names) if names else ''}".strip(),
                     print_error=False)

    if output:
        latest_name, required = None, None
        res = {}

        for l in output.split('\n'):
            if l:
                if l[0] != ' ':
                    line = l.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()

                    if field == 'Name':
                        val = line[field_sep_idx + 1:].strip()
                        latest_name = val
                    elif field == 'Required By':
                        val = line[field_sep_idx + 1:].strip()
                        required = set()
                        if val != 'None':
                            required.update((d for d in val.split(' ') if d))

                    elif latest_name and required is not None:
                        res[latest_name] = required
                        latest_name, required = None, None

                elif latest_name and required is not None:
                    required.update(required.update((d for d in l.strip().split(' ') if d)))
        return res
    elif names:
        return {n: set() for n in names}
    else:
        return {}


def map_conflicts_with(names: Iterable[str], remote: bool) -> Dict[str, Dict[str, Set[str]]]:
    output = run_cmd('pacman -{}i {}'.format('S' if remote else 'Q', ' '.join(names)))

    if output:
        res = {}
        latest_name, conflicts, replaces, field = None, None, None, None

        for l in output.split('\n'):
            if l:
                if l[0] != ' ':
                    line = l.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()

                    if field == 'Name':
                        field = 'n'
                        val = line[field_sep_idx + 1:].strip()
                        latest_name = val
                    elif field == 'Conflicts With':
                        field = 'c'
                        val = line[field_sep_idx + 1:].strip()
                        conflicts = set()
                        if val != 'None':
                            conflicts.update((d for d in val.split(' ') if d))
                    elif field == 'Replaces':
                        field = 'r'
                        val = line[field_sep_idx + 1:].strip()
                        replaces = set()
                        if val != 'None':
                            replaces.update((d for d in val.split(' ') if d))

                    elif latest_name and conflicts is not None and replaces is not None:
                        field = None
                        res[latest_name] = {'c': conflicts, 'r': replaces}
                        latest_name, conflicts, replaces = None, None, None

                elif latest_name and field:
                    if field == 'c':
                        conflicts.update((d for d in l.strip().split(' ') if d))
                    elif field == 'r':
                        replaces.update((d for d in l.strip().split(' ') if d))

        return res


def map_replaces(names: Iterable[str], remote: bool = False) -> Dict[str, Set[str]]:
    output = run_cmd('pacman -{}i {}'.format('S' if remote else 'Q', ' '.join(names)))

    if output:
        res = {}
        latest_name, replaces = None, None

        for l in output.split('\n'):
            if l:
                if l[0] != ' ':
                    line = l.strip()
                    field_sep_idx = line.index(':')
                    field = line[0:field_sep_idx].strip()

                    if field == 'Name':
                        val = line[field_sep_idx + 1:].strip()
                        latest_name = val
                    elif field == 'Replaces':
                        val = line[field_sep_idx + 1:].strip()
                        replaces = set()
                        if val != 'None':
                            replaces.update((d for d in val.split(' ') if d))

                    elif latest_name and replaces is not None:
                        res[latest_name] = replaces
                        latest_name, replaces = None, None

                elif latest_name and replaces is not None:
                    replaces.update((d for d in l.strip().split(' ') if d))

        return res


def list_installed_names() -> Set[str]:
    output = run_cmd('pacman -Qq', print_error=False)
    return {name.strip() for name in output.split('\n') if name} if output else set()


def list_available_mirrors() -> List[str]:
    _, output = system.run(['pacman-mirrors', '--status', '--no-color'])

    if output:
        mirrors = RE_AVAILABLE_MIRRORS.findall(output)

        if mirrors:
            mirrors.sort(key=lambda o: o[0])
            return [m[1] for m in mirrors]


def get_mirrors_branch() -> str:
    _, output = system.run(['pacman-mirrors', '-G'])
    return output.strip()


def get_packages_to_sync_first() -> Set[str]:
    if os.path.exists('/etc/pacman.conf'):
        with open('/etc/pacman.conf') as f:
            to_sync_first = RE_PACMAN_SYNC_FIRST.findall(f.read())

            if to_sync_first:
                return {s.strip() for s in to_sync_first[0].split(' ') if s and s.strip()}

    return set()


def list_hard_requirements(name: str, logger: Optional[logging.Logger] = None,
                           assume_installed: Optional[Set[str]] = None) -> Optional[Set[str]]:
    cmd = StringIO()
    cmd.write(f'pacman -Rc {name} --print-format=%n ')

    if assume_installed:
        cmd.write(' '.join(f'--assume-installed={provider}' for provider in assume_installed))

    code, output = system.execute(cmd.getvalue(), shell=True)

    if code != 0:
        if 'HoldPkg' in output:
            raise PackageInHoldException()
        elif 'target not found' in output:
            raise PackageNotFoundException(name)
        elif logger:
            logger.error("Unexpected error while listing hard requirements of: {}".format(name))
            print('{}{}{}'.format(Fore.RED, output, Fore.RESET))
    elif output:
        reqs = set()

        for line in output.split('\n'):
            if line:
                line_strip = line.strip()

                if line_strip and line_strip != name:
                    reqs.add(line_strip)

        return reqs


def list_post_uninstall_unneeded_packages(names: Set[str]) -> Set[str]:
    output = run_cmd('pacman -Rss {} --print-format=%n'.format(' '.join(names)), print_error=False)

    reqs = set()
    if output:
        for line in output.split('\n'):
            if line:
                line_strip = line.strip()

                if line_strip and line_strip not in names:
                    reqs.add(line_strip)

    return reqs


def find_one_match(name: str) -> Optional[str]:
    output = run_cmd('pacman -Ssq {}'.format(name), print_error=False)

    if output:
        matches = [l.strip() for l in output.split('\n') if l.strip()]

        if matches and len(matches) == 1:
            return matches[0]


def map_available_packages() -> Optional[Dict[str, Any]]:
    output = run_cmd('pacman -Sl')

    if output:
        res = dict()
        for line in output.split('\n'):
            line_strip = line.strip()

            if line_strip:
                package_data = line.split(' ')

                if len(package_data) >= 3:
                    pkgname = package_data[1].strip()

                    if pkgname:
                        res[pkgname] = {'v': package_data[2].strip(),
                                        'r': package_data[0].strip(),
                                        'i': len(package_data) == 4 and 'installed' in package_data[3]}
        return res


def map_installed(pkgs: Optional[Collection[str]] = None) -> Optional[Dict[str, str]]:
    output = run_cmd(f"pacman -Q {' '.join({*pkgs} if pkgs else '')}".strip(), print_error=False)

    if output:
        res = dict()
        for raw_line in output.split("\n"):
            line = raw_line.strip()

            if line:
                pkg_version = line.split(" ")
                if len(pkg_version) == 2:
                    res[pkg_version[0]] = pkg_version[1]
        return res
