# Dircolors, a Python library for colorizing and formatting filenames like GNU Coreutils'
# ls and dircolors programs.
# Requires python 3.3 or later
#
# Copyright 2019 Allen Wild <allenwild93@gmail.com>
# Modifications copyright 2020 John Skilleter <john@skilleter.org.uk>
# SPDX-License-Identifier: Apache-2.0

""" dircolors, a Python library to colorize filenames based on their type
for terminal use, like GNU ls and dircolors. """

from collections import OrderedDict
from io import StringIO, TextIOBase
import os
import stat

from . import dc_defaults
from . import dc_util

__all__ = ['Dircolors']

_CODE_MAP = OrderedDict()

def _init_code_map():
    """Populate the mapping from .dircolors keys to LS_COLORS codes.

    Returns:
        None.
    """
    # This code is wrapped in a function so we can disable pylint's whitespace check
    # on a limited scope.
    # pylint: disable=bad-whitespace
    _CODE_MAP['RESET']                  = 'rs'
    _CODE_MAP['DIR']                    = 'di'
    _CODE_MAP['LINK']                   = 'ln'
    _CODE_MAP['MULTIHARDLINK']          = 'mh'
    _CODE_MAP['FIFO']                   = 'pi'
    _CODE_MAP['SOCK']                   = 'so'
    _CODE_MAP['DOOR']                   = 'do'
    _CODE_MAP['BLK']                    = 'bd'
    _CODE_MAP['CHR']                    = 'cd'
    _CODE_MAP['ORPHAN']                 = 'or'
    _CODE_MAP['MISSING']                = 'mi'
    _CODE_MAP['SETUID']                 = 'su'
    _CODE_MAP['SETGID']                 = 'sg'
    _CODE_MAP['CAPABILITY']             = 'ca'
    _CODE_MAP['STICKY_OTHER_WRITABLE']  = 'tw'
    _CODE_MAP['OTHER_WRITABLE']         = 'ow'
    _CODE_MAP['STICKY']                 = 'st'
    _CODE_MAP['EXEC']                   = 'ex'

_init_code_map()
del _init_code_map

class Dircolors:
    """ Main dircolors class. Contains a database of formats corresponding to file types,
    modes, and extensions. Use the format() method to check a file and color it appropriately.
    """

    def __init__(self, load=True):
        """Initialize a Dircolors object and optionally load data.

        Args:
            load: If True, attempt to load LS_COLORS or defaults.

        Returns:
            None.
        """
        self._loaded = False
        self._codes = OrderedDict()
        self._extensions = OrderedDict()
        if load:
            if not self.load_from_environ():
                self.load_defaults()

    def __bool__(self):
        """Return True if a dircolors database has been loaded.

        Returns:
            True when data is loaded, otherwise False.
        """
        return self._loaded

    @property
    def loaded(self):
        """Return whether valid dircolors data has been loaded.

        Returns:
            True when data is loaded, otherwise False.
        """
        return self._loaded

    def clear(self):
        """Clear any loaded dircolors data.

        Returns:
            None.
        """
        self._loaded = False
        self._codes.clear()
        self._extensions.clear()

    def load_from_lscolors(self, lscolors):
        """Load the dircolors database from an LS_COLORS string.

        Args:
            lscolors: String in the same format as the LS_COLORS environment variable.

        Returns:
            True if data was loaded, otherwise False.
        """
        self.clear()
        if not lscolors:
            return False

        for item in lscolors.split(':'):
            try:
                code, color = item.split('=', 1)
            except ValueError:
                continue  # no key=value, just ignore
            if code.startswith('*.'):
                self._extensions[code[1:]] = color
            else:
                self._codes[code] = color

        if self._codes or self._extensions:
            self._loaded = True
        return self._loaded

    def load_from_environ(self, envvar='LS_COLORS'):
        """Load the dircolors database from an environment variable.

        Args:
            envvar: Environment variable name to read.

        Returns:
            True if data was loaded, otherwise False.
        """
        return self.load_from_lscolors(os.environ.get(envvar))

    def load_from_dircolors(self, database, strict=False):
        """Load the dircolors database from a GNU-compatible .dircolors file.

        Args:
            database: Filename or text-mode file-like object.
            strict: If True, raise ValueError on the first unparsed line.

        Returns:
            True if any data was loaded, otherwise False.

        Raises:
            ValueError: If `database` is not a supported type or parsing fails in strict mode.
            OSError: If reading the file fails.
        """
        self.clear()
        need_close = False
        if isinstance(database, str):
            file = open(database, 'r')
            need_close = True
        elif isinstance(database, TextIOBase):
            file = database
        else:
            raise ValueError('database must be str or io.TextIOBase, not %s' % type(database))

        try:
            for line in file:
                # remove comments and skip empty lines
                line = line.split('#')[0].strip()
                if not line:
                    continue

                # make sure there's two space-separated fields
                split = line.split()
                if len(split) != 2:
                    if strict:
                        raise ValueError('Warning: unable to parse dircolors line "%s"' % line)
                    continue

                key, val = split
                if key == 'TERM':
                    continue  # ignore TERM directives
                elif key in _CODE_MAP:
                    self._codes[_CODE_MAP[key]] = val
                elif key.startswith('*.'):
                    self._extensions[key[1:]] = val
                elif key.startswith('.'):
                    self._extensions[key] = val
                elif strict:
                    raise ValueError('Warning: unable to parse dircolors line "%s"' % line)
                # elif not strict, skip

            if self._codes or self._extensions:
                self._loaded = True
            return self._loaded
        finally:
            if need_close:
                file.close()

    def load_defaults(self):
        """Load the default dircolors database.

        Returns:
            True if data was loaded, otherwise False.
        """
        self.clear()
        return self.load_from_dircolors(StringIO(dc_defaults.DEFAULT_DIRCOLORS), True)

    def generate_lscolors(self):
        """Generate an LS_COLORS string from the current database.

        Returns:
            LS_COLORS-compatible string, or an empty string if no data is loaded.
        """
        if not self._loaded:
            return ''

        def gen_pairs():
            """Yield (key, value) pairs for LS_COLORS generation.

            Yields:
                Tuple of (key, value) strings.
            """
            for pair in self._codes.items():
                yield pair
            for pair in self._extensions.items():
                # change .xyz to *.xyz
                yield '*' + pair[0], pair[1]

        return ':'.join('%s=%s' % pair for pair in gen_pairs())

    def _format_code(self, text, code):
        """Format text with an LS_COLORS code.

        Args:
            text: Text to format.
            code: LS_COLORS code to use.

        Returns:
            Formatted text, or the original text if the code is not found.
        """
        val = self._codes.get(code, None)
        if val:
            return '\033[%sm%s\033[%sm' % (val, text, self._codes.get('rs', '0'))
        return text

    def _format_ext(self, text, ext):
        """Format text according to the given file extension.

        Args:
            text: Text to format.
            ext: Extension string with a leading dot (e.g. ``".txt"``).

        Returns:
            Formatted text, or the original text if the extension is not found.
        """
        val = self._extensions.get(ext, '0')
        if val:
            return '\033[%sm%s\033[%sm' % (val, text, self._codes.get('rs', '0'))
        return text

    def format_mode(self, text, mode):
        """Format and color text based on a file mode.

        Args:
            text: Text to format.
            mode: File mode integer or `os.stat_result`.

        Returns:
            Formatted text, or the original text if no data is loaded.

        Raises:
            ValueError: If `mode` is not an int or `os.stat_result`.
        """
        if not self._loaded:
            return text

        if isinstance(mode, int):
            pass
        elif isinstance(mode, os.stat_result):
            mode = mode.st_mode
        else:
            raise ValueError('mode must be int or os.stat_result, not %s' % type(mode))

        if mode:
            if stat.S_ISDIR(mode):
                if (mode & (stat.S_ISVTX | stat.S_IWOTH)) == (stat.S_ISVTX | stat.S_IWOTH):
                    # sticky and world-writable
                    return self._format_code(text, 'tw')
                if mode & stat.S_ISVTX:
                    # sticky but not world-writable
                    return self._format_code(text, 'st')
                if mode & stat.S_IWOTH:
                    # world-writable but not sticky
                    return self._format_code(text, 'ow')
                # normal directory
                return self._format_code(text, 'di')

            # special file?
            # pylint: disable=bad-whitespace
            special_types = (
                (stat.S_IFLNK,  'ln'),  # symlink
                (stat.S_IFIFO,  'pi'),  # pipe (FIFO)
                (stat.S_IFSOCK, 'so'),  # socket
                (stat.S_IFBLK,  'bd'),  # block device
                (stat.S_IFCHR,  'cd'),  # character device
                (stat.S_ISUID,  'su'),  # setuid
                (stat.S_ISGID,  'sg'),  # setgid
            )

            for mask, code in special_types:
                if (mode & mask) == mask:
                    return self._format_code(text, code)

            # executable file?
            if mode & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
                return self._format_code(text, 'ex')

        # regular file, format according to its extension
        _, ext = os.path.splitext(text)
        if ext:
            return self._format_ext(text, ext)
        return text

    def format(self, file, cwd=None, follow_symlinks=False, show_target=False):
        """Format and color a file name based on filesystem metadata.

        Args:
            file: File name to format.
            cwd: Directory to resolve `file` against (path string or dirfd), or None.
            follow_symlinks: If True, dereference symlinks fully.
            show_target: If True and `follow_symlinks` is False, append the link target.

        Returns:
            Formatted file name.
        """

        if not self.loaded:
            return file

        if os.path.exists(file):
            try:
                statbuf = dc_util.stat_at(file, cwd, follow_symlinks)
            except OSError as e:
                return '%s [Error stat-ing: %s]' % (file, e.strerror)

            mode = statbuf.st_mode
            if (not follow_symlinks) and show_target and stat.S_ISLNK(mode):
                target_path = dc_util.readlink_at(file, cwd)
                try:
                    dc_util.stat_at(target_path, cwd)  # check for broken link
                    target = self.format(target_path, cwd, False, False)
                except OSError:
                    # format as "orphan"
                    target = self._format_code(target_path, 'or') + ' [broken link]'
                return self._format_code(file, 'ln') + ' -> ' + target
        else:
            mode = 0

        return self.format_mode(file, mode)
