"""
Verbose
=======

Define verbose output, logging, and warning management utilities.
"""

from __future__ import annotations

import functools
import logging
import os
import sys
import traceback
import typing
import warnings
from collections import defaultdict
from contextlib import contextmanager, suppress
from itertools import chain
from textwrap import TextWrapper
from warnings import filterwarnings, formatwarning, warn

import numpy as np

if typing.TYPE_CHECKING:
    from colour.hints import (
        Any,
        Callable,
        ClassVar,
        Dict,
        Generator,
        List,
        Literal,
        Mapping,
        Self,
        TextIO,
        Type,
    )

from colour.hints import LiteralWarning, cast

__author__ = "Colour Developers"
__copyright__ = "Copyright 2013 Colour Developers"
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
__maintainer__ = "Colour Developers"
__email__ = "colour-developers@colour-science.org"
__status__ = "Production"

__all__ = [
    "LOGGER",
    "MixinLogging",
    "ColourWarning",
    "ColourUsageWarning",
    "ColourRuntimeWarning",
    "message_box",
    "show_warning",
    "warning",
    "runtime_warning",
    "usage_warning",
    "filter_warnings",
    "as_bool",
    "suppress_warnings",
    "suppress_stdout",
    "numpy_print_options",
    "ANCILLARY_COLOUR_SCIENCE_PACKAGES",
    "ANCILLARY_RUNTIME_PACKAGES",
    "ANCILLARY_DEVELOPMENT_PACKAGES",
    "ANCILLARY_EXTRAS_PACKAGES",
    "describe_environment",
    "multiline_str",
    "multiline_repr",
]

LOGGER = logging.getLogger(__name__)


class MixinLogging:
    """
    Provide logging capabilities through mixin inheritance.

    This mixin extends class functionality to enable structured logging,
    allowing consistent logging behaviour across the codebase.

    Attributes
    ----------
    -   :func:`~colour.utilities.MixinLogging.MAPPING_LOGGING_LEVEL_TO_CALLABLE`

    Methods
    -------
    -   :func:`~colour.utilities.MixinLogging.log`
    """

    MAPPING_LOGGING_LEVEL_TO_CALLABLE: ClassVar = {  # pyright: ignore
        "critical": LOGGER.critical,
        "error": LOGGER.error,
        "warning": LOGGER.warning,
        "info": LOGGER.info,
        "debug": LOGGER.debug,
    }

    def log(
        self,
        message: str,
        verbosity: Literal[
            "critical",
            "error",
            "warning",
            "info",
            "debug",
        ] = "info",
    ) -> None:
        """
        Log the specified message using the specified verbosity level.

        Parameters
        ----------
        message
            Message to log.
        verbosity
            Verbosity level.
        """

        self.MAPPING_LOGGING_LEVEL_TO_CALLABLE[verbosity](  # pyright: ignore
            "%s: %s",
            self.name,  # pyright: ignore
            message,
        )


class ColourWarning(Warning):
    """
    Define the base class for *Colour* warnings.

    This class serves as the foundational warning type for the *Colour*
    library, inheriting from the standard :class:`Warning` class to provide
    consistent warning behaviour across the library.
    """


class ColourUsageWarning(Warning):
    """
    Define the base class for *Colour* usage warnings.

    This class serves as the foundation for all usage-related warnings in the
    *Colour* library, providing a consistent interface for alerting users to
    non-critical issues during runtime operations. It is a subclass of the
    :class:`colour.utilities.ColourWarning` class.
    """


class ColourRuntimeWarning(Warning):
    """
    Define the base class for *Colour* runtime warnings.

    This class serves as the foundation for all runtime warnings in the
    *Colour* library, providing a consistent interface for alerting users to
    runtime issues. It is a subclass of the
    :class:`colour.utilities.ColourWarning` class.
    """


def message_box(
    message: str,
    width: int = 79,
    padding: int = 3,
    print_callable: Callable = print,
) -> None:
    """
    Print a message inside a formatted box.

    Parameters
    ----------
    message
        Message to print inside the box.
    width
        Width of the message box in characters.
    padding
        Number of spaces for padding on each side of the message.
    print_callable
        Callable used to print the formatted message box.

    Examples
    --------
    >>> message = (
    ...     "Lorem ipsum dolor sit amet, consectetur adipiscing elit, "
    ...     "sed do eiusmod tempor incididunt ut labore et dolore magna "
    ...     "aliqua."
    ... )
    >>> message_box(message, width=75)
    ===========================================================================
    *                                                                         *
    *   Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do       *
    *   eiusmod tempor incididunt ut labore et dolore magna aliqua.           *
    *                                                                         *
    ===========================================================================
    >>> message_box(message, width=60)
    ============================================================
    *                                                          *
    *   Lorem ipsum dolor sit amet, consectetur adipiscing     *
    *   elit, sed do eiusmod tempor incididunt ut labore et    *
    *   dolore magna aliqua.                                   *
    *                                                          *
    ============================================================
    >>> message_box(message, width=75, padding=16)
    ===========================================================================
    *                                                                         *
    *                Lorem ipsum dolor sit amet, consectetur                  *
    *                adipiscing elit, sed do eiusmod tempor                   *
    *                incididunt ut labore et dolore magna                     *
    *                aliqua.                                                  *
    *                                                                         *
    ===========================================================================
    """

    ideal_width = width - padding * 2 - 2

    def inner(text: str) -> str:
        """Format and pads inner text for the message box."""

        return (
            f"*{' ' * padding}"
            f"{text}{' ' * (width - len(text) - padding * 2 - 2)}"
            f"{' ' * padding}*"
        )

    print_callable("=" * width)
    print_callable(inner(""))

    wrapper = TextWrapper(
        width=ideal_width, break_long_words=False, replace_whitespace=False
    )

    lines = [wrapper.wrap(line) for line in message.split("\n")]
    for line in chain(*[" " if len(line) == 0 else line for line in lines]):
        print_callable(inner(line.expandtabs()))

    print_callable(inner(""))
    print_callable("=" * width)


def show_warning(
    message: Warning | str,
    category: Type[Warning],
    filename: str,
    lineno: int,
    file: TextIO | None = None,
    line: str | None = None,
) -> None:
    """
    Display a warning message with enhanced formatting that enables
    traceback printing.

    This definition is activated by setting the
    *COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK* environment
    variable before importing *colour*.

    Parameters
    ----------
    message
        Warning message.
    category
        :class:`Warning` sub-class.
    filename
        File path to read the line at ``lineno`` from if ``line`` is
        None.
    lineno
        Line number to read the line at in ``filename`` if ``line`` is
        None.
    file
        :class:`file` object to write the warning to, defaults to
        :attr:`sys.stderr` attribute.
    line
        Source code to be included in the warning message.

    Notes
    -----
    -   Setting the
        *COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK*
        environment variable replaces the :func:`warnings.showwarning`
        definition with the :func:`colour.utilities.show_warning`
        definition, providing complete traceback from the point where
        the warning occurred.
    """

    frame_range = (1, None)

    file = sys.stderr if file is None else file

    if file is None:
        return

    try:
        # Generating a traceback to print useful warning origin.
        frame_in, frame_out = frame_range

        try:
            raise ZeroDivisionError  # noqa: TRY301
        except ZeroDivisionError:
            exception_traceback = sys.exc_info()[2]
            frame = (
                exception_traceback.tb_frame.f_back
                if exception_traceback is not None
                else None
            )
            while frame_in and frame is not None:
                frame = frame.f_back
                frame_in -= 1

        traceback.print_stack(frame, frame_out, file)
        file.write(formatwarning(message, category, filename, lineno, line))
    except (OSError, UnicodeError):
        pass


if os.environ.get(  # pragma: no cover
    "COLOUR_SCIENCE__COLOUR__SHOW_WARNINGS_WITH_TRACEBACK"
):
    warnings.showwarning = show_warning  # pragma: no cover


def warning(*args: Any, **kwargs: Any) -> None:
    """
    Issue a warning.

    Other Parameters
    ----------------
    args
        Warning message and optional arguments for message formatting.
    kwargs
        Keyword arguments for controlling warning behaviour, including
        category, stacklevel, and source filtering options.

    Examples
    --------
    >>> warning("This is a warning!")  # doctest: +SKIP
    """

    kwargs["category"] = kwargs.get("category", ColourWarning)

    warn(*args, **kwargs)


def runtime_warning(*args: Any, **kwargs: Any) -> None:
    """
    Issue a runtime warning.

    Other Parameters
    ----------------
    args
        Warning message and optional arguments for message formatting.
    kwargs
        Keyword arguments for controlling warning behaviour.

    Examples
    --------
    >>> runtime_warning("This is a runtime warning!")  # doctest: +SKIP
    """

    kwargs["category"] = ColourRuntimeWarning

    warning(*args, **kwargs)


def usage_warning(*args: Any, **kwargs: Any) -> None:
    """
    Issue a usage warning.

    Other Parameters
    ----------------
    args
        Warning message and optional arguments for message formatting.
    kwargs
        Keyword arguments for controlling warning behaviour.

    Examples
    --------
    >>> usage_warning("This is an usage warning!")  # doctest: +SKIP
    """

    kwargs["category"] = ColourUsageWarning

    warning(*args, **kwargs)


def filter_warnings(
    colour_runtime_warnings: bool | LiteralWarning | None = None,
    colour_usage_warnings: bool | LiteralWarning | None = None,
    colour_warnings: bool | LiteralWarning | None = None,
    python_warnings: bool | LiteralWarning | None = None,
) -> None:
    """
    Filter *Colour* and optionally overall Python warnings.

    The possible values for all the actions, i.e., each argument, are as
    follows:

    - *None* (No action is taken)
    - *True* (*ignore*)
    - *False* (*default*)
    - *error*
    - *ignore*
    - *always*
    - *default*
    - *module*
    - *once*

    Parameters
    ----------
    colour_runtime_warnings
        Whether to filter *Colour* runtime warnings using the specified
        action value.
    colour_usage_warnings
        Whether to filter *Colour* usage warnings using the specified
        action value.
    colour_warnings
        Whether to filter *Colour* warnings, this also filters *Colour*
        usage and runtime warnings using the specified action value.
    python_warnings
        Whether to filter *Python* warnings using the specified action
        value.

    Examples
    --------
    Filtering *Colour* runtime warnings:

    >>> filter_warnings(colour_runtime_warnings=True)

    Filtering *Colour* usage warnings:

    >>> filter_warnings(colour_usage_warnings=True)

    Filtering *Colour* warnings:

    >>> filter_warnings(colour_warnings=True)

    Filtering all the *Colour* and also Python warnings:

    >>> filter_warnings(python_warnings=True)

    Enabling all the *Colour* and Python warnings:

    >>> filter_warnings(*[False] * 4)

    Enabling all the *Colour* and Python warnings using the *default* action:

    >>> filter_warnings(*["default"] * 4)

    Setting back the default state:

    >>> filter_warnings(colour_runtime_warnings=True)
    """

    for action, category in [
        (colour_warnings, ColourWarning),
        (colour_runtime_warnings, ColourRuntimeWarning),
        (colour_usage_warnings, ColourUsageWarning),
        (python_warnings, Warning),
    ]:
        if action is None:
            continue

        if isinstance(action, str):
            action = cast("LiteralWarning", str(action))  # noqa: PLW2901
        else:
            action = "ignore" if action else "default"  # noqa: PLW2901

        filterwarnings(action, category=category)


def as_bool(a: str) -> bool:
    """
    Convert the specified string to a boolean value.

    The following string values evaluate to *True*: "1", "On", and "True".
    All other string values evaluate to *False*.

    Parameters
    ----------
    a
        String to convert to boolean.

    Returns
    -------
    :class:`bool`
        Boolean representation of the specified string.

    Examples
    --------
    >>> as_bool("1")
    True
    >>> as_bool("On")
    True
    >>> as_bool("True")
    True
    >>> as_bool("0")
    False
    >>> as_bool("Off")
    False
    >>> as_bool("False")
    False
    """

    return a.lower() in ["1", "on", "true"]


# Defaulting to filter *Colour* runtime warnings.
filter_warnings(
    colour_runtime_warnings=as_bool(
        os.environ.get("COLOUR_SCIENCE__FILTER_RUNTIME_WARNINGS", "True")
    )
)

if (
    os.environ.get("COLOUR_SCIENCE__FILTER_USAGE_WARNINGS") is not None
):  # pragma: no cover
    filter_warnings(
        colour_usage_warnings=as_bool(
            os.environ["COLOUR_SCIENCE__FILTER_USAGE_WARNINGS"]
        )
    )

if (
    os.environ.get("COLOUR_SCIENCE__FILTER_COLOUR_WARNINGS") is not None
):  # pragma: no cover
    filter_warnings(
        colour_usage_warnings=as_bool(
            os.environ["COLOUR_SCIENCE__FILTER_WARNINGS"],
        )
    )

if (
    os.environ.get("COLOUR_SCIENCE__FILTER_PYTHON_WARNINGS") is not None
):  # pragma: no cover
    filter_warnings(
        colour_usage_warnings=as_bool(
            os.environ["COLOUR_SCIENCE__FILTER_PYTHON_WARNINGS"]
        )
    )


@contextmanager
def suppress_warnings(
    colour_runtime_warnings: bool | LiteralWarning | None = None,
    colour_usage_warnings: bool | LiteralWarning | None = None,
    colour_warnings: bool | LiteralWarning | None = None,
    python_warnings: bool | LiteralWarning | None = None,
) -> Generator:
    """
    Suppress *Colour* and optionally overall Python warnings within a
    context.

    The possible values for all the actions, i.e., each argument, are as
    follows:

    - *None* (No action is taken)
    - *True* (*ignore*)
    - *False* (*default*)
    - *error*
    - *ignore*
    - *always*
    - *default*
    - *module*
    - *once*

    Parameters
    ----------
    colour_runtime_warnings
        Whether to filter *Colour* runtime warnings according to the
        specified action value.
    colour_usage_warnings
        Whether to filter *Colour* usage warnings according to the
        specified action value.
    colour_warnings
        Whether to filter *Colour* warnings, this also filters *Colour*
        usage and runtime warnings according to the specified action
        value.
    python_warnings
        Whether to filter *Python* warnings according to the specified
        action value.
    """

    filters = warnings.filters
    show_warnings = warnings.showwarning

    filter_warnings(
        colour_warnings=colour_warnings,
        colour_runtime_warnings=colour_runtime_warnings,
        colour_usage_warnings=colour_usage_warnings,
        python_warnings=python_warnings,
    )

    try:
        yield
    finally:
        warnings.filters = filters
        warnings.showwarning = show_warnings


class suppress_stdout:
    """
    Define a context manager and decorator to temporarily suppress standard output.

    Examples
    --------
    >>> with suppress_stdout():
    ...     print("Hello World!")
    >>> print("Hello World!")
    Hello World!
    """

    def __enter__(self) -> Self:
        """
        Redirect standard output to null device upon context manager entry.
        """

        self._stdout = sys.stdout
        sys.stdout = open(os.devnull, "w")

        return self

    def __exit__(self, *args: Any) -> None:
        """
        Restore standard output upon context manager exit.
        """

        sys.stdout.close()
        sys.stdout = self._stdout

    def __call__(self, function: Callable) -> Callable:  # pragma: no cover
        """Call the wrapped definition with suppressed output."""

        @functools.wraps(function)
        def wrapper(*args: Any, **kwargs: Any) -> Callable:
            with self:
                return function(*args, **kwargs)

        return wrapper


@contextmanager
def numpy_print_options(*args: Any, **kwargs: Any) -> Generator:
    """
    Implement a context manager for temporarily modifying *NumPy* array
    print options.

    Other Parameters
    ----------------
    args
        Positional arguments passed to :func:`numpy.set_printoptions`.
    kwargs
        Keyword arguments passed to :func:`numpy.set_printoptions`.

    Examples
    --------
    >>> np.array([np.pi])  # doctest: +ELLIPSIS
    array([ 3.1415926...])
    >>> with numpy_print_options(formatter={"float": "{:0.1f}".format}):
    ...     np.array([np.pi])
    array([3.1])
    """

    options = np.get_printoptions()
    np.set_printoptions(*args, **kwargs)
    try:
        yield
    finally:
        np.set_printoptions(**options)


ANCILLARY_COLOUR_SCIENCE_PACKAGES: Dict[str, str] = {}
"""
Ancillary *colour-science.org* packages to describe.

ANCILLARY_COLOUR_SCIENCE_PACKAGES
"""

ANCILLARY_RUNTIME_PACKAGES: Dict[str, str] = {}
"""
Ancillary runtime packages to describe.

ANCILLARY_RUNTIME_PACKAGES
"""

ANCILLARY_DEVELOPMENT_PACKAGES: Dict[str, str] = {}
"""
Ancillary development packages to describe.

ANCILLARY_DEVELOPMENT_PACKAGES
"""

ANCILLARY_EXTRAS_PACKAGES: Dict[str, str] = {}
"""
Ancillary extras packages to describe.

ANCILLARY_EXTRAS_PACKAGES
"""


def describe_environment(
    runtime_packages: bool = True,
    development_packages: bool = False,
    extras_packages: bool = False,
    print_environment: bool = True,
    **kwargs: Any,
) -> defaultdict:
    """
    Describe the *Colour* runtime environment, including interpreter details
    and package versions.

    Parameters
    ----------
    runtime_packages
        Whether to return the runtime packages versions.
    development_packages
        Whether to return the development packages versions.
    extras_packages
        Whether to return the extras packages versions.
    print_environment
        Whether to print the environment.

    Other Parameters
    ----------------
    padding
        {:func:`colour.utilities.message_box`},
        Padding on each side of the message.
    print_callable
        {:func:`colour.utilities.message_box`},
        Callable used to print the message box.
    width
        {:func:`colour.utilities.message_box`},
        Message box width.

    Returns
    -------
    :class:`collections.defaultdict`
        Environment.

    Examples
    --------
    >>> environment = describe_environment(width=75)  # doctest: +SKIP
    ===========================================================================
    *                                                                         *
    *   Interpreter :                                                         *
    *       python : 3.12.4 (main, Jun  6 2024, 18:26:44) [Clang 15.0.0       *
    *   (clang-1500.3.9.4)]                                                   *
    *                                                                         *
    *   colour-science.org :                                                  *
    *       colour : v0.4.3-282-gcb450ff50                                    *
    *                                                                         *
    *   Runtime :                                                             *
    *       imageio : 2.35.1                                                  *
    *       matplotlib : 3.9.2                                                *
    *       networkx : 3.3                                                    *
    *       numpy : 2.1.1                                                     *
    *       pandas : 2.2.3                                                    *
    *       pydot : 3.0.2                                                     *
    *       PyOpenColorIO : 2.3.2                                             *
    *       scipy : 1.14.1                                                    *
    *       tqdm : 4.66.5                                                     *
    *       trimesh : 4.4.9                                                   *
    *       OpenImageIO : 2.5.14.0                                            *
    *       xxhash : 3.5.0                                                    *
    *                                                                         *
    ===========================================================================
    >>> environment = describe_environment(True, True, True, width=75)
    ... # doctest: +SKIP
    ===========================================================================
    *                                                                         *
    *   Interpreter :                                                         *
    *       python : 3.12.4 (main, Jun  6 2024, 18:26:44) [Clang 15.0.0       *
    *   (clang-1500.3.9.4)]                                                   *
    *                                                                         *
    *   colour-science.org :                                                  *
    *       colour : v0.4.3-282-gcb450ff50                                    *
    *                                                                         *
    *   Runtime :                                                             *
    *       imageio : 2.35.1                                                  *
    *       matplotlib : 3.9.2                                                *
    *       networkx : 3.3                                                    *
    *       numpy : 2.1.1                                                     *
    *       pandas : 2.2.3                                                    *
    *       pydot : 3.0.2                                                     *
    *       PyOpenColorIO : 2.3.2                                             *
    *       scipy : 1.14.1                                                    *
    *       tqdm : 4.66.5                                                     *
    *       trimesh : 4.4.9                                                   *
    *       OpenImageIO : 2.5.14.0                                            *
    *       xxhash : 3.5.0                                                    *
    *                                                                         *
    *   Development :                                                         *
    *       biblib-simple : 0.1.2                                             *
    *       coverage : 6.5.0                                                  *
    *       coveralls : 4.0.1                                                 *
    *       invoke : 2.2.0                                                    *
    *       pydata-sphinx-theme : 0.15.4                                      *
    *       pyright : 1.1.382.post1                                           *
    *       pytest : 8.3.3                                                    *
    *       pytest-cov : 5.0.0                                                *
    *       restructuredtext-lint : 1.4.0                                     *
    *       sphinxcontrib-bibtex : 2.6.3                                      *
    *       toml : 0.10.2                                                     *
    *       twine : 5.1.1                                                     *
    *                                                                         *
    *   Extras :                                                              *
    *       ipywidgets : 8.1.5                                                *
    *       notebook : 7.2.2                                                  *
    *                                                                         *
    ===========================================================================
    """

    environment: defaultdict = defaultdict(dict)

    environment["Interpreter"]["python"] = sys.version

    import subprocess  # noqa: PLC0415

    import colour  # noqa: PLC0415

    # TODO: Implement support for "pyproject.toml" file whenever "TOML" is
    # supported in the standard library.
    # NOTE: A few clauses are not reached and a few packages are not available
    # during continuous integration and are thus ignored for coverage.
    try:  # pragma: no cover
        output = subprocess.check_output(
            ["git", "describe"],  # noqa: S607
            cwd=colour.__path__[0],
            stderr=subprocess.STDOUT,
        ).strip()
        version = output.decode("utf-8")
    except Exception:  # pragma: no cover # noqa: BLE001
        version = colour.__version__

    environment["colour-science.org"]["colour"] = version
    environment["colour-science.org"].update(ANCILLARY_COLOUR_SCIENCE_PACKAGES)

    if runtime_packages:
        for package in [
            "imageio",
            "matplotlib",
            "networkx",
            "numpy",
            "pandas",
            "pydot",
            "PyOpenColorIO",
            "scipy",
            "tqdm",
            "trimesh",
        ]:
            with suppress(ImportError):
                namespace = __import__(package)
                with suppress(AttributeError):
                    environment["Runtime"][package] = namespace.__version__

        # OpenImageIO
        with suppress(ImportError):  # pragma: no cover
            namespace = __import__("OpenImageIO")
            environment["Runtime"]["OpenImageIO"] = namespace.VERSION_STRING

        # xxhash
        with suppress(ImportError):  # pragma: no cover
            namespace = __import__("xxhash")
            environment["Runtime"]["xxhash"] = namespace.version.VERSION

        environment["Runtime"].update(ANCILLARY_RUNTIME_PACKAGES)

    def _get_package_version(package: str, mapping: Mapping) -> str:
        """Return specified package version."""

        namespace = __import__(package)

        if package in mapping:
            import pkg_resources  # noqa: PLC0415

            distributions = list(pkg_resources.working_set)

            for distribution in distributions:
                if distribution.project_name == mapping[package]:
                    return distribution.version

        return namespace.__version__

    if development_packages:
        mapping = {
            "biblib.bib": "biblib-simple",
            "pydata_sphinx_theme": "pydata-sphinx-theme",
            "pytest_cov": "pytest-cov",
            "pytest_xdist": "pytest-xdist",
            "restructuredtext_lint": "restructuredtext-lint",
            "sphinxcontrib.bibtex": "sphinxcontrib-bibtex",
        }
        for package in [
            "biblib.bib",
            "coverage",
            "coveralls",
            "invoke",
            "jupyter",
            "pydata_sphinx_theme",
            "pyright",
            "pytest",
            "pytest_cov",
            "pytest_xdist",
            "restructuredtext_lint",
            "sphinxcontrib.bibtex",
            "toml",
            "twine",
        ]:
            try:
                version = _get_package_version(package, mapping)
                package = mapping.get(package, package)  # noqa: PLW2901

                environment["Development"][package] = version
            except Exception:  # pragma: no cover  # noqa: BLE001, S112
                continue

        environment["Development"].update(ANCILLARY_DEVELOPMENT_PACKAGES)

    if extras_packages:
        mapping = {}
        for package in ["ipywidgets", "notebook"]:
            try:
                version = _get_package_version(package, mapping)
                package = mapping.get(package, package)  # noqa: PLW2901

                environment["Extras"][package] = version
            except Exception:  # pragma: no cover  # noqa: BLE001, S112
                continue

        environment["Extras"].update(ANCILLARY_EXTRAS_PACKAGES)

    if print_environment:
        message = ""
        for category in (
            "Interpreter",
            "colour-science.org",
            "Runtime",
            "Development",
            "Extras",
        ):
            elements = environment.get(category)
            if not elements:
                continue

            message += f"{category} :\n"
            for key, value in elements.items():
                lines = value.split("\n")
                message += f"    {key} : {lines.pop(0)}\n"
                indentation = len(f"    {key} : ")
                for line in lines:  # pragma: no cover
                    message += f"{' ' * indentation}{line}\n"

            message += "\n"

        message_box(message.strip(), **kwargs)

    return environment


def multiline_str(
    object_: Any,
    attributes: List[dict],
    header_underline: str = "=",
    section_underline: str = "-",
    separator: str = " : ",
) -> str:
    """
    Generate a formatted multi-line string representation of the specified
    object.

    Parameters
    ----------
    object_
        Object to format into a string representation.
    attributes
        Attributes to format, provided as a list of dictionaries with
        formatting specifications.
    header_underline
        Underline character to use for header sections.
    section_underline
        Underline character to use for subsections.
    separator
        Separator to use when formatting the attributes and their values.

    Returns
    -------
    :class:`str`
        Formatted multi-line string representation.

    Examples
    --------
    >>> class Data:
    ...     def __init__(self, a: str, b: int, c: list):
    ...         self._a = a
    ...         self._b = b
    ...         self._c = c
    ...
    ...     def __str__(self) -> str:
    ...         return multiline_str(
    ...             self,
    ...             [
    ...                 {
    ...                     "formatter": lambda x: (
    ...                         f"Object - {self.__class__.__name__}"
    ...                     ),
    ...                     "header": True,
    ...                 },
    ...                 {"line_break": True},
    ...                 {"label": "Data", "section": True},
    ...                 {"line_break": True},
    ...                 {"label": "String", "section": True},
    ...                 {"name": "_a", "label": 'String "a"'},
    ...                 {"line_break": True},
    ...                 {"label": "Integer", "section": True},
    ...                 {"name": "_b", "label": 'Integer "b"'},
    ...                 {"line_break": True},
    ...                 {"label": "List", "section": True},
    ...                 {
    ...                     "name": "_c",
    ...                     "label": 'List "c"',
    ...                     "formatter": lambda x: "; ".join(x),
    ...                 },
    ...             ],
    ...         )
    >>> print(Data("Foo", 1, ["John", "Doe"]))
    Object - Data
    =============
    <BLANKLINE>
    Data
    ----
    <BLANKLINE>
    String
    ------
    String "a"  : Foo
    <BLANKLINE>
    Integer
    -------
    Integer "b" : 1
    <BLANKLINE>
    List
    ----
    List "c"    : John; Doe
    """

    attribute_defaults = {
        "name": None,
        "label": None,
        "formatter": str,
        "header": False,
        "section": False,
        "line_break": False,
    }

    try:
        justify = max(
            len(attribute["label"])
            for attribute in attributes
            if (
                attribute.get("label")
                and not attribute.get("header")
                and not attribute.get("section")
            )
        )
    except ValueError:  # pragma: no cover
        justify = 0

    representation = []
    for attribute in attributes:
        attribute = dict(attribute_defaults, **attribute)  # noqa: PLW2901

        if not attribute["line_break"]:
            if attribute["name"] is not None:
                formatted = attribute["formatter"](getattr(object_, attribute["name"]))
            else:
                formatted = attribute["formatter"](None)

            if (
                attribute["label"] is not None
                and not attribute["header"]
                and not attribute["section"]
            ):
                lines = formatted.splitlines()
                if len(lines) > 1:
                    for i, line in enumerate(lines[1:]):
                        lines[i + 1] = f"{'':{justify}}{' ' * len(separator)}{line}"
                formatted = "\n".join(lines)

                representation.append(
                    f"{attribute['label']:{justify}}{separator}{formatted}",
                )
            elif attribute["label"] is not None and (
                attribute["header"] or attribute["section"]
            ):
                representation.append(attribute["label"])
            else:
                representation.append(f"{formatted}")

            if attribute["header"]:
                representation.append(header_underline * len(representation[-1]))

            if attribute["section"]:
                representation.append(section_underline * len(representation[-1]))
        else:
            representation.append("")

    return "\n".join(representation)


def multiline_repr(
    object_: Any,
    attributes: List[dict],
    reduce_array_representation: bool = True,
) -> str:
    """
    Generate an evaluable string representation of the specified object.

    Parameters
    ----------
    object_
        Object to format.
    attributes
        Attributes to format.
    reduce_array_representation
        Whether to remove the *Numpy* `array(` and `)` affixes.

    Returns
    -------
    :class:`str`
        (Almost) evaluable string representation.

    Examples
    --------
    >>> class Data:
    ...     def __init__(self, a: str, b: int, c: list):
    ...         self._a = a
    ...         self._b = b
    ...         self._c = c
    ...
    ...     def __repr__(self) -> str:
    ...         return multiline_repr(
    ...             self,
    ...             [
    ...                 {"name": "_a"},
    ...                 {"name": "_b"},
    ...                 {
    ...                     "name": "_c",
    ...                     "formatter": lambda x: repr(x)
    ...                     .replace("[", "(")
    ...                     .replace("]", ")"),
    ...                 },
    ...             ],
    ...         )
    >>> Data("Foo", 1, ["John", "Doe"])
    Data('Foo',
         1,
         ('John', 'Doe'))
    """

    attribute_defaults = {"name": None, "formatter": repr}

    justify = len(f"{object_.__class__.__name__}") + 1

    def _format(attribute: dict) -> str:
        """Format specified attribute and its value."""

        if attribute["name"] is not None:
            value = attribute["formatter"](getattr(object_, attribute["name"]))
        else:
            value = attribute["formatter"](None)

        if value is None:
            return str(None)

        if reduce_array_representation and value.startswith("array("):
            lines = value.splitlines()
            for i, line in enumerate(lines):
                lines[i] = line[6:]
            value = "\n".join(lines)[:-1]

        lines = value.splitlines()

        if len(lines) > 1:
            for i, line in enumerate(lines[1:]):
                lines[i + 1] = f"{'':{justify}}{line}"

        return "\n".join(lines)

    attribute = dict(attribute_defaults, **attributes.pop(0))

    representation = [f"{object_.__class__.__name__}({_format(attribute)}"]

    for attribute in attributes:
        attribute = dict(attribute_defaults, **attribute)  # noqa: PLW2901

        representation.append(f"{'':{justify}}{_format(attribute)}")

    return "{})".format(",\n".join(representation))
