# Copyright 1998-2025 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

__docformat__ = "epytext"

import errno
import itertools
import os
import re
import socket
import subprocess
import sys

import portage
from portage.const import COLOR_MAP_FILE
from portage.exception import (
    FileNotFound,
    ParseError,
    PermissionDenied,
    PortageException,
)
from portage.localization import _
from portage.util import formatter

havecolor = 1
dotitles = 1

"""Maps style class to tuple of attribute names."""
_styles = {}

"""Maps attribute name to ansi code."""

esc_seq = "\x1b["

codes = {
    "normal": f"{esc_seq}0m",
    "reset": f"{esc_seq}39;49;00m",
    "bold": f"{esc_seq}01m",
    "faint": f"{esc_seq}02m",
    "standout": f"{esc_seq}03m",
    "underline": f"{esc_seq}04m",
    "blink": f"{esc_seq}05m",
    "overline": f"{esc_seq}06m",
    "reverse": f"{esc_seq}07m",
    "invisible": f"{esc_seq}08m",
    "no-attr": f"{esc_seq}22m",
    "no-standout": f"{esc_seq}23m",
    "no-underline": f"{esc_seq}24m",
    "no-blink": f"{esc_seq}25m",
    "no-overline": f"{esc_seq}26m",
    "no-reverse": f"{esc_seq}27m",
    "bg_black": f"{esc_seq}40m",
    "bg_darkred": f"{esc_seq}41m",
    "bg_darkgreen": f"{esc_seq}42m",
    "bg_brown": f"{esc_seq}43m",
    "bg_darkblue": f"{esc_seq}44m",
    "bg_purple": f"{esc_seq}45m",
    "bg_teal": f"{esc_seq}46m",
    "bg_lightgray": f"{esc_seq}47m",
    "bg_default": f"{esc_seq}49m",
    "bg_darkyellow": f"{esc_seq}43m",
}


def color(fg, bg="default", attr=["normal"]):
    myansicodechain = itertools.chain((codes[fg]), (codes[x] for x in [bg, *attr]))
    return "".join(myansicodechain)


ansi_codes = [y for x in range(30, 38) for y in (f"{x}m", f"{x};01m")]

rgb_ansi_colors = [
    "0x000000",
    "0x555555",
    "0xAA0000",
    "0xFF5555",
    "0x00AA00",
    "0x55FF55",
    "0xAA5500",
    "0xFFFF55",
    "0x0000AA",
    "0x5555FF",
    "0xAA00AA",
    "0xFF55FF",
    "0x00AAAA",
    "0x55FFFF",
    "0xAAAAAA",
    "0xFFFFFF",
]

for x in range(len(rgb_ansi_colors)):
    codes[rgb_ansi_colors[x]] = esc_seq + ansi_codes[x]

del x

codes["black"] = codes["0x000000"]
codes["darkgray"] = codes["0x555555"]

codes["red"] = codes["0xFF5555"]
codes["darkred"] = codes["0xAA0000"]

codes["green"] = codes["0x55FF55"]
codes["darkgreen"] = codes["0x00AA00"]

codes["yellow"] = codes["0xFFFF55"]
codes["brown"] = codes["0xAA5500"]

codes["blue"] = codes["0x5555FF"]
codes["darkblue"] = codes["0x0000AA"]

codes["fuchsia"] = codes["0xFF55FF"]
codes["purple"] = codes["0xAA00AA"]

codes["turquoise"] = codes["0x55FFFF"]
codes["teal"] = codes["0x00AAAA"]

codes["white"] = codes["0xFFFFFF"]
codes["lightgray"] = codes["0xAAAAAA"]

codes["darkteal"] = codes["turquoise"]
# Some terminals have darkyellow instead of brown.
codes["0xAAAA00"] = codes["brown"]
codes["darkyellow"] = codes["0xAAAA00"]

# Colors from /lib/gentoo/functions.sh
_styles["BAD"] = ("red",)
_styles["BRACKET"] = ("blue",)
_styles["ERR"] = ("red",)
_styles["GOOD"] = ("green",)
_styles["HILITE"] = ("teal",)
_styles["INFO"] = ("darkgreen",)
_styles["LOG"] = ("green",)
_styles["NORMAL"] = ("normal",)
_styles["QAWARN"] = ("brown",)
_styles["WARN"] = ("yellow",)

# Portage functions
_styles["INFORM"] = ("darkgreen",)
_styles["UNMERGE_WARN"] = ("red",)
_styles["SECURITY_WARN"] = ("red",)
_styles["MERGE_LIST_PROGRESS"] = ("yellow",)
_styles["PKG_BLOCKER"] = ("red",)
_styles["PKG_BLOCKER_SATISFIED"] = ("teal",)
_styles["PKG_MERGE"] = ("darkgreen",)
_styles["PKG_MERGE_SYSTEM"] = ("darkgreen",)
_styles["PKG_MERGE_WORLD"] = ("green",)
_styles["PKG_BINARY_MERGE"] = ("purple",)
_styles["PKG_BINARY_MERGE_SYSTEM"] = ("purple",)
_styles["PKG_BINARY_MERGE_WORLD"] = ("fuchsia",)
_styles["PKG_UNINSTALL"] = ("red",)
_styles["PKG_NOMERGE"] = ("teal",)
_styles["PKG_NOMERGE_SYSTEM"] = ("teal",)
_styles["PKG_NOMERGE_WORLD"] = ("blue",)
_styles["PROMPT_CHOICE_DEFAULT"] = ("green",)
_styles["PROMPT_CHOICE_OTHER"] = ("red",)


def _parse_color_map(config_root="/", onerror=None):
    """
    Parse /etc/portage/color.map and return a dict of error codes.

    @param onerror: an optional callback to handle any ParseError that would
            otherwise be raised
    @type onerror: callable
    @rtype: dict
    @return: a dictionary mapping color classes to color codes
    """
    global codes, _styles
    myfile = os.path.join(config_root, COLOR_MAP_FILE)
    ansi_code_pattern = re.compile("^[0-9;]*m$")
    quotes = "'\""

    def strip_quotes(token):
        if token[0] in quotes and token[0] == token[-1]:
            token = token[1:-1]
        return token

    try:
        with open(
            myfile,
            encoding="utf-8",
            errors="replace",
        ) as f:
            lines = f.readlines()
        for lineno, line in enumerate(lines):
            commenter_pos = line.find("#")
            line = line[:commenter_pos].strip()

            if len(line) == 0:
                continue

            split_line = line.split("=")
            if len(split_line) != 2:
                e = ParseError(
                    _("'%s', line %s: expected exactly one occurrence of '=' operator")
                    % (myfile, lineno)
                )
                raise e
                if onerror:
                    onerror(e)
                else:
                    raise e
                continue

            k = strip_quotes(split_line[0].strip())
            v = strip_quotes(split_line[1].strip())
            if k not in _styles and k not in codes:
                e = ParseError(
                    _("'%s', line %s: Unknown variable: '%s'") % (myfile, lineno, k)
                )
                if onerror:
                    onerror(e)
                else:
                    raise e
                continue
            if ansi_code_pattern.match(v):
                if k in _styles:
                    _styles[k] = (esc_seq + v,)
                elif k in codes:
                    codes[k] = esc_seq + v
            else:
                code_list = []
                for x in v.split():
                    if x in codes:
                        if k in _styles:
                            code_list.append(x)
                        elif k in codes:
                            code_list.append(codes[x])
                    else:
                        e = ParseError(
                            _("'%s', line %s: Undefined: '%s'") % (myfile, lineno, x)
                        )
                        if onerror:
                            onerror(e)
                        else:
                            raise e
                if k in _styles:
                    _styles[k] = tuple(code_list)
                elif k in codes:
                    codes[k] = "".join(code_list)
    except OSError as e:
        if e.errno == errno.ENOENT:
            raise FileNotFound(myfile)
        elif e.errno == errno.EACCES:
            raise PermissionDenied(myfile)
        raise


def nc_len(mystr):
    tmp = re.sub(esc_seq + "^m]+m", "", mystr)
    return len(tmp)


_legal_terms_re = re.compile(
    r"^(xterm|xterm-color|Eterm|aterm|rxvt|screen|kterm|rxvt-unicode|gnome|interix|tmux|st-256color|alacritty|konsole|foot)"
)
_disable_xtermTitle = None
_max_xtermTitle_len = 253


def xtermTitle(mystr, raw=False):
    global _disable_xtermTitle
    if _disable_xtermTitle is None:
        _disable_xtermTitle = not (
            sys.__stderr__.isatty()
            and "TERM" in os.environ
            and _legal_terms_re.match(os.environ["TERM"]) is not None
        )

    if dotitles and not _disable_xtermTitle:
        if not raw:
            hostname = socket.gethostname()
            mystr = f"{hostname}: {mystr}"
        # If the title string is too big then the terminal can
        # misbehave. Therefore, truncate it if it's too big.
        if len(mystr) > _max_xtermTitle_len:
            mystr = mystr[:_max_xtermTitle_len]
        if not raw:
            mystr = f"\x1b]0;{mystr}\x07"

        # avoid potential UnicodeEncodeError
        mystr = mystr.encode("utf-8", "backslashreplace")
        f = sys.stderr.buffer
        f.write(mystr)
        f.flush()


default_xterm_title = None


def xtermTitleReset():
    from portage.process import find_binary, spawn

    global default_xterm_title
    if default_xterm_title is None:
        prompt_command = os.environ.get("PROMPT_COMMAND")
        if prompt_command == "":
            default_xterm_title = ""
        elif prompt_command is not None:
            if (
                dotitles
                and "TERM" in os.environ
                and _legal_terms_re.match(os.environ["TERM"]) is not None
                and sys.__stderr__.isatty()
            ):
                shell = os.environ.get("SHELL")
                if not shell or not os.access(shell, os.EX_OK):
                    shell = find_binary("sh")
                if shell:
                    spawn(
                        [shell, "-c", prompt_command],
                        env=os.environ,
                        fd_pipes={
                            0: portage._get_stdin().fileno(),
                            1: sys.__stderr__.fileno(),
                            2: sys.__stderr__.fileno(),
                        },
                    )
                else:
                    os.system(prompt_command)
            return
        else:
            pwd = os.environ.get("PWD", "")
            home = os.environ.get("HOME", "")
            if home != "" and pwd.startswith(home):
                pwd = "~" + pwd[len(home) :]
            default_xterm_title = "\x1b]0;{}@{}:{}\x07".format(
                os.environ.get("LOGNAME", ""),
                os.environ.get("HOSTNAME", "").split(".", 1)[0],
                pwd,
            )
    xtermTitle(default_xterm_title, raw=True)


def notitles():
    "turn off title setting"
    dotitles = 0


def nocolor():
    "turn off colorization"
    global havecolor
    havecolor = 0


def resetColor():
    return codes["reset"]


def style_to_ansi_code(style):
    """
    @param style: A style name
    @type style: String
    @rtype: String
    @return: A string containing one or more ansi escape codes that are
            used to render the given style.
    """
    ret = ""
    for attr_name in _styles[style]:
        # allow stuff that has found it's way through ansi_code_pattern
        ret += codes.get(attr_name, attr_name)
    return ret


def colormap():
    mycolors = []
    for c in (
        "BAD",
        "BRACKET",
        "ERR",
        "GOOD",
        "HILITE",
        "INFO",
        "LOG",
        "NORMAL",
        "QAWARN",
        "WARN",
    ):
        mycolors.append(f"PORTAGE_COLOR_{c}=$'{style_to_ansi_code(c)}'")
    return "\n".join(mycolors)


def colorize(color_key, text):
    global havecolor
    if havecolor:
        if color_key in codes:
            return codes[color_key] + text + codes["reset"]
        if color_key in _styles:
            return style_to_ansi_code(color_key) + text + codes["reset"]
    return text


compat_functions_colors = [
    "bold",
    "white",
    "teal",
    "turquoise",
    "darkteal",
    "fuchsia",
    "purple",
    "blue",
    "darkblue",
    "green",
    "darkgreen",
    "yellow",
    "brown",
    "darkyellow",
    "red",
    "darkred",
]


class create_color_func:
    __slots__ = ("_color_key",)

    def __init__(self, color_key):
        self._color_key = color_key

    def __call__(self, text):
        return colorize(self._color_key, text)


for c in compat_functions_colors:
    globals()[c] = create_color_func(c)


class ConsoleStyleFile:
    """
    A file-like object that behaves something like
    the colorize() function. Style identifiers
    passed in via the new_styles() method will be used to
    apply console codes to output.
    """

    def __init__(self, f):
        self._file = f
        self._styles = None
        self.write_listener = None

    def new_styles(self, styles):
        self._styles = styles

    def write(self, s):
        # In python-2.6, DumbWriter.send_line_break() can write
        # non-unicode '\n' which fails with TypeError if self._file
        # is a text stream such as io.StringIO. Therefore, make sure
        # input is converted to unicode when necessary.
        if isinstance(s, bytes):
            s = s.decode("utf-8", "replace")
        global havecolor
        if havecolor and self._styles:
            styled_s = []
            for style in self._styles:
                styled_s.append(style_to_ansi_code(style))
            styled_s.append(s)
            styled_s.append(codes["reset"])
            self._write(self._file, "".join(styled_s))
        else:
            self._write(self._file, s)
        if self.write_listener:
            self._write(self.write_listener, s)

    def _write(self, f, s):
        # avoid potential UnicodeEncodeError
        if f in (sys.stdout, sys.stderr):
            s = s.encode("utf-8", "backslashreplace")
            f = f.buffer
        f.write(s)

    def writelines(self, lines):
        for s in lines:
            self.write(s)

    def flush(self):
        self._file.flush()

    def close(self):
        self._file.close()


class StyleWriter(formatter.DumbWriter):
    """
    This is just a DumbWriter with a hook in the new_styles() method
    that passes a styles tuple as a single argument to a callable
    style_listener attribute.
    """

    def __init__(self, **kwargs):
        formatter.DumbWriter.__init__(self, **kwargs)
        self.style_listener = None

    def new_styles(self, styles):
        formatter.DumbWriter.new_styles(self, styles)
        if self.style_listener:
            self.style_listener(styles)


def get_term_size(fd=None):
    """
    Get the number of lines and columns of the tty that is connected to
    fd.  Returns a tuple of (lines, columns) or (0, 0) if an error
    occurs. The curses module is used if available, otherwise the output of
    `stty size` is parsed. The lines and columns values are guaranteed to be
    greater than or equal to zero, since a negative COLUMNS variable is
    known to prevent some commands from working (see bug #394091).
    """
    if fd is None:
        fd = sys.stdout
    if not hasattr(fd, "isatty") or not fd.isatty():
        return (0, 0)

    # Do not use curses.tigetnum("lines") or curses.tigetnum("cols") because it
    # returns stale values after terminal resize. Do not use curses.initscr().getmaxyx()
    # since that has unwanted side-effects, requiring a call to `stty sane` to restore a
    # sane state.
    if not sys.stdin.isatty():
        # Use curses if stdin is not a tty since stty fails in that case.
        try:
            import curses

            try:
                curses.setupterm(term=os.environ.get("TERM", "unknown"), fd=fd.fileno())
                return curses.tigetnum("lines"), curses.tigetnum("cols")
            except curses.error:
                pass
        except ImportError:
            pass

    try:
        proc = subprocess.Popen(["stty", "size"], stdout=subprocess.PIPE, stderr=fd)
    except OSError as e:
        if e.errno != errno.ENOENT:
            raise
        # stty command not found
        return (0, 0)

    out = proc.communicate()[0].decode("utf-8", "replace")
    if proc.wait() == os.EX_OK:
        out = out.split()
        if len(out) == 2:
            try:
                val = (int(out[0]), int(out[1]))
            except ValueError:
                pass
            else:
                if val[0] >= 0 and val[1] >= 0:
                    return val
    return (0, 0)


class EOutput:
    """
    Performs fancy terminal formatting for status and informational messages.

    The provided methods produce identical terminal output to the eponymous
    functions in the shell script C{/sbin/functions.sh} and also accept
    identical parameters.

    This is not currently a drop-in replacement however, as the output-related
    functions in C{/sbin/functions.sh} are oriented for use mainly by system
    init scripts and ebuilds and their output can be customized via certain
    C{RC_*} environment variables (see C{/etc/conf.d/rc}). B{EOutput} is not
    customizable in this manner since it's intended for more general uses.
    Likewise, no logging is provided.

    @ivar quiet: Specifies if output should be silenced.
    @type quiet: BooleanType
    @ivar term_columns: Width of terminal in characters. Defaults to the value
            specified by the shell's C{COLUMNS} variable, else to the queried tty
            size, else to C{80}.
    @type term_columns: IntType
    """

    def __init__(self, quiet=False):
        self.__last_e_cmd = ""
        self.__last_e_len = 0
        self.quiet = quiet
        lines, columns = get_term_size()
        if columns <= 0:
            columns = 80
        self.term_columns = columns
        sys.stdout.flush()
        sys.stderr.flush()

    def _write(self, f, s):
        from portage.util import writemsg

        # avoid potential UnicodeEncodeError
        writemsg(s, noiselevel=-1, fd=f)

    def __eend(self, caller, errno, msg):
        if errno == 0:
            status_brackets = (
                colorize("BRACKET", "[ ")
                + colorize("GOOD", "ok")
                + colorize("BRACKET", " ]")
            )
        else:
            status_brackets = (
                colorize("BRACKET", "[ ")
                + colorize("BAD", "!!")
                + colorize("BRACKET", " ]")
            )
            if msg:
                if caller == "eend":
                    self.eerror(msg[0])
                elif caller == "ewend":
                    self.ewarn(msg[0])
        if self.__last_e_cmd != "ebegin":
            self.__last_e_len = 0
        if not self.quiet:
            out = sys.stdout
            self._write(
                out,
                "%*s%s\n"
                % ((self.term_columns - self.__last_e_len - 7), "", status_brackets),
            )

    def ebegin(self, msg):
        """
        Shows a message indicating the start of a process.

        @param msg: A very brief (shorter than one line) description of the
                starting process.
        @type msg: StringType
        """
        msg += " ..."
        if not self.quiet:
            self.einfon(msg)
        self.__last_e_len = len(msg) + 3
        self.__last_e_cmd = "ebegin"

    def eend(self, errno, *msg):
        """
        Indicates the completion of a process, optionally displaying a message
        via L{eerror} if the process's exit status isn't C{0}.

        @param errno: A standard UNIX C{errno} code returned by processes upon
                exit.
        @type errno: IntType
        @param msg: I{(optional)} An error message, typically a standard UNIX
                error string corresponding to C{errno}.
        @type msg: StringType
        """
        if not self.quiet:
            self.__eend("eend", errno, msg)
        self.__last_e_cmd = "eend"

    def eerror(self, msg):
        """
        Shows an error message.

        @param msg: A very brief (shorter than one line) error message.
        @type msg: StringType
        """
        out = sys.stderr
        if not self.quiet:
            if self.__last_e_cmd == "ebegin":
                self._write(out, "\n")
            self._write(out, colorize("ERR", " * ") + msg + "\n")
        self.__last_e_cmd = "eerror"

    def einfo(self, msg):
        """
        Shows an informative message terminated with a newline.

        @param msg: A very brief (shorter than one line) informative message.
        @type msg: StringType
        """
        out = sys.stdout
        if not self.quiet:
            if self.__last_e_cmd == "ebegin":
                self._write(out, "\n")
            self._write(out, colorize("INFO", " * ") + msg + "\n")
        self.__last_e_cmd = "einfo"

    def einfon(self, msg):
        """
        Shows an informative message terminated without a newline.

        @param msg: A very brief (shorter than one line) informative message.
        @type msg: StringType
        """
        out = sys.stdout
        if not self.quiet:
            if self.__last_e_cmd == "ebegin":
                self._write(out, "\n")
            self._write(out, colorize("INFO", " * ") + msg)
        self.__last_e_cmd = "einfon"

    def eqawarn(self, msg):
        """
        Shows a QA warning.

        @param msg: A very brief (shorter than one line) warning message.
        @type msg: StringType
        """
        out = sys.stderr
        if not self.quiet:
            if self.__last_e_cmd == "ebegin":
                self._write(out, "\n")
            self._write(out, colorize("QAWARN", " * ") + msg + "\n")
        self.__last_e_cmd = "ewarn"

    def elog(self, msg):
        """
        Shows a logged informative message terminated with a newline.

        @param msg: A very brief (shorter than one line) informative message.
        @type msg: StringType
        """
        out = sys.stdout
        if not self.quiet:
            if self.__last_e_cmd == "ebegin":
                self._write(out, "\n")
            self._write(out, colorize("LOG", " * ") + msg + "\n")
        self.__last_e_cmd = "elog"

    def ewarn(self, msg):
        """
        Shows a warning message.

        @param msg: A very brief (shorter than one line) warning message.
        @type msg: StringType
        """
        out = sys.stderr
        if not self.quiet:
            if self.__last_e_cmd == "ebegin":
                self._write(out, "\n")
            self._write(out, colorize("WARN", " * ") + msg + "\n")
        self.__last_e_cmd = "ewarn"

    def ewend(self, errno, *msg):
        """
        Indicates the completion of a process, optionally displaying a message
        via L{ewarn} if the process's exit status isn't C{0}.

        @param errno: A standard UNIX C{errno} code returned by processes upon
                exit.
        @type errno: IntType
        @param msg: I{(optional)} A warning message, typically a standard UNIX
                error string corresponding to C{errno}.
        @type msg: StringType
        """
        if not self.quiet:
            self.__eend("ewend", errno, msg)
        self.__last_e_cmd = "ewend"


class ProgressBar:
    """The interface is copied from the ProgressBar class from the EasyDialogs
    module (which is Mac only)."""

    def __init__(self, title=None, maxval=0, label=None, max_desc_length=25):
        self._title = title or ""
        self._maxval = maxval
        self._label = label or ""
        self._curval = 0
        self._desc = ""
        self._desc_max_length = max_desc_length
        self._set_desc()

    @property
    def curval(self):
        """
        The current value (of type integer or long integer) of the progress
        bar. The normal access methods coerce curval between 0 and maxval. This
        attribute should not be altered directly.
        """
        return self._curval

    @property
    def maxval(self):
        """
        The maximum value (of type integer or long integer) of the progress
        bar; the progress bar (thermometer style) is full when curval equals
        maxval. If maxval is 0, the bar will be indeterminate (barber-pole).
        This attribute should not be altered directly.
        """
        return self._maxval

    def title(self, newstr):
        """Sets the text in the title bar of the progress dialog to newstr."""
        self._title = newstr
        self._set_desc()

    def label(self, newstr):
        """Sets the text in the progress box of the progress dialog to newstr."""
        self._label = newstr
        self._set_desc()

    def _set_desc(self):
        self._desc = "{}{}".format(
            f"{self._title}: " if self._title else "",
            f"{self._label}" if self._label else "",
        )
        if len(self._desc) > self._desc_max_length:  # truncate if too long
            self._desc = f"{self._desc[:self._desc_max_length - 3]}..."
        if len(self._desc):
            self._desc = self._desc.ljust(self._desc_max_length)

    def set(self, value, maxval=None):
        """
        Sets the progress bar's curval to value, and also maxval to max if the
        latter is provided. value is first coerced between 0 and maxval. The
        thermometer bar is updated to reflect the changes, including a change
        from indeterminate to determinate or vice versa.
        """
        if maxval is not None:
            self._maxval = maxval
        if value < 0:
            value = 0
        elif value > self._maxval:
            value = self._maxval
        self._curval = value

    def inc(self, n=1):
        """Increments the progress bar's curval by n, or by 1 if n is not
        provided. (Note that n may be negative, in which case the effect is a
        decrement.) The progress bar is updated to reflect the change. If the
        bar is indeterminate, this causes one ``spin'' of the barber pole. The
        resulting curval is coerced between 0 and maxval if incrementing causes
        it to fall outside this range.
        """
        self.set(self._curval + n)


class TermProgressBar(ProgressBar):
    """A tty progress bar similar to wget's."""

    def __init__(self, fd=sys.stdout, **kwargs):
        ProgressBar.__init__(self, **kwargs)
        lines, self.term_columns = get_term_size(fd)
        self.file = fd
        self._min_columns = 11
        self._max_columns = 80
        # for indeterminate mode, ranges from 0.0 to 1.0
        self._position = 0.0

    def set(self, value, maxval=None):
        ProgressBar.set(self, value, maxval=maxval)
        self._display_image(self._create_image())

    def _display_image(self, image):
        self.file.write("\r")
        self.file.write(image)
        self.file.flush()

    def _create_image(self):
        cols = self.term_columns
        cols = min(cols, self._max_columns)
        min_columns = self._min_columns
        curval = self._curval
        maxval = self._maxval
        position = self._position
        percentage_str_width = 5
        square_brackets_width = 2
        if cols < percentage_str_width:
            return ""
        bar_space = cols - percentage_str_width - square_brackets_width - 1
        if self._desc:
            bar_space -= self._desc_max_length

        if maxval == 0:
            max_bar_width = bar_space - 3
            _percent = "".ljust(percentage_str_width)
            if cols < min_columns:
                return ""
            if position <= 0.5:
                offset = 2 * position
            else:
                offset = 2 * (1 - position)
            delta = 0.5 / max_bar_width
            position += delta
            if position >= 1.0:
                position = 0.0
            # make sure it touches the ends
            if 1.0 - position < delta:
                position = 1.0
            if position < 0.5 and 0.5 - position < delta:
                position = 0.5
            self._position = position
            bar_width = int(offset * max_bar_width)
            image = "{}{}{}".format(
                self._desc,
                _percent,
                "["
                + (bar_width * " ")
                + "<=>"
                + ((max_bar_width - bar_width) * " ")
                + "]",
            )
            return image

        percentage = 100 * curval // maxval
        max_bar_width = bar_space - 1
        _percent = ("%d%% " % percentage).rjust(percentage_str_width)
        image = f"{self._desc}{_percent}"

        if cols < min_columns:
            return image
        offset = curval / maxval
        bar_width = int(offset * max_bar_width)
        image = (
            image
            + "["
            + (bar_width * "=")
            + ">"
            + ((max_bar_width - bar_width) * " ")
            + "]"
        )
        return image


_color_map_loaded = False


def _init(config_root="/"):
    """
    Load color.map from the given config_root. This is called automatically
    on first access of the codes or _styles attributes (unless it has already
    been called for some other reason).
    """
    from portage.util import writemsg

    global _color_map_loaded, codes, _styles
    if _color_map_loaded:
        return

    _color_map_loaded = True
    codes = object.__getattribute__(codes, "_attr")
    _styles = object.__getattribute__(_styles, "_attr")

    for k, v in codes.items():
        codes[k] = v.decode("utf-8", "replace") if isinstance(v, bytes) else v

    for k, v in _styles.items():
        _styles[k] = v.decode("utf-8", "replace") if isinstance(v, bytes) else v

    try:
        _parse_color_map(
            config_root=config_root,
            onerror=lambda e: writemsg(f"{e!s}\n", noiselevel=-1),
        )
    except FileNotFound:
        pass
    except PermissionDenied as e:
        writemsg(_("Permission denied: '%s'\n") % str(e), noiselevel=-1)
        del e
    except PortageException as e:
        writemsg(f"{e!s}\n", noiselevel=-1)
        del e


class _LazyInitColorMap(portage.proxy.objectproxy.ObjectProxy):
    __slots__ = ("_attr",)

    def __init__(self, attr):
        portage.proxy.objectproxy.ObjectProxy.__init__(self)
        object.__setattr__(self, "_attr", attr)

    def _get_target(self):
        _init()
        return object.__getattribute__(self, "_attr")


codes = _LazyInitColorMap(codes)
_styles = _LazyInitColorMap(_styles)
