# Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import copy
import os
import re
import urllib.error
import urllib.request
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Iterator

from streamlit import cli_util, url_util
from streamlit.config_option import ConfigOption
from streamlit.elements.lib.color_util import is_css_color_like
from streamlit.errors import (
    StreamlitInvalidThemeError,
    StreamlitInvalidThemeOptionError,
    StreamlitInvalidThemeSectionError,
)

# Maximum size for theme files (1MB). Theme files should be small configuration
# files containing only theme options, not large data files.
_MAX_THEME_FILE_SIZE_BYTES = 1024 * 1024  # 1MB


def _get_logger() -> Any:
    """Get logger for this module. Separate function to avoid circular imports."""
    from streamlit.logger import get_logger

    return get_logger(__name__)


def server_option_changed(
    old_options: dict[str, ConfigOption], new_options: dict[str, ConfigOption]
) -> bool:
    """Return True if and only if an option in the server section differs
    between old_options and new_options.
    """
    for opt_name, opt_val in old_options.items():
        if not opt_name.startswith("server"):
            continue

        old_val = opt_val.value
        new_val = new_options[opt_name].value
        if old_val != new_val:
            return True

    return False


def show_config(
    section_descriptions: dict[str, str],
    config_options: dict[str, ConfigOption],
) -> None:
    """Print the given config sections/options to the terminal."""

    out = []
    out.append(
        _clean(
            """
        # Below are all the sections and options you can have in
        ~/.streamlit/config.toml.
    """
        )
    )

    def append_desc(text: str) -> None:
        out.append("# " + cli_util.style_for_cli(text, bold=True))

    def append_comment(text: str) -> None:
        out.append("# " + cli_util.style_for_cli(text))

    def append_section(text: str) -> None:
        out.append(cli_util.style_for_cli(text, bold=True, fg="green"))

    def append_setting(text: str) -> None:
        out.append(cli_util.style_for_cli(text, fg="green"))

    for section in section_descriptions:
        # We inject a fake config section used for unit tests that we exclude here as
        # its options are often missing required properties, which confuses the code
        # below.
        if section == "_test":
            continue

        section_options = {
            k: v
            for k, v in config_options.items()
            if v.section == section and v.visibility == "visible" and not v.is_expired()
        }

        # Only show config header if section is non-empty.
        if len(section_options) == 0:
            continue

        out.append("")
        append_section(f"[{section}]")
        out.append("")

        for option in section_options.values():
            key = option.key.split(".")[-1]
            description_paragraphs = _clean_paragraphs(option.description or "")

            last_paragraph_idx = len(description_paragraphs) - 1

            for i, paragraph in enumerate(description_paragraphs):
                # Split paragraph into lines
                lines = paragraph.rstrip().split(
                    "\n"
                )  # Remove trailing newline characters

                # If the first line is empty, remove it
                if lines and not lines[0].strip():
                    lines = lines[1:]

                # Choose function based on whether it's the first paragraph or not
                append_func = append_desc if i == 0 else append_comment

                # Add comment character to each line and add to out
                for line in lines:
                    append_func(line.lstrip())

                # # Add a line break after a paragraph only if it's not the last paragraph
                if i != last_paragraph_idx:
                    append_comment("")

            if option.deprecated:
                if out[-1] != "#":
                    append_comment("")
                append_comment(
                    cli_util.style_for_cli("THIS IS DEPRECATED.", fg="yellow")
                )
                append_comment("")
                for line in _clean_paragraphs(option.deprecation_text):
                    append_comment(line)
                append_comment("")
                append_comment(
                    f"This option will be removed on or after {option.expiration_date}."
                )

            import toml

            toml_default = toml.dumps({"default": option.default_val})
            toml_default = toml_default[10:].strip()

            if len(toml_default) > 0:
                # Ensure a line break before appending "Default" comment, if not already there
                if out[-1] != "#":
                    append_comment("")
                append_comment(f"Default: {toml_default}")
            else:
                # Don't say "Default: (unset)" here because this branch applies
                # to complex config settings too.
                pass

            option_is_manually_set = (
                option.where_defined != ConfigOption.DEFAULT_DEFINITION
            )

            if option_is_manually_set:
                if out[-1] != "# ":
                    append_comment("")
                append_comment(f"The value below was set in {option.where_defined}")

            toml_setting = toml.dumps({key: option.value})

            if len(toml_setting) == 0:
                toml_setting = f"# {key} =\n"
            elif not option_is_manually_set:
                toml_setting = f"# {toml_setting}"

            append_setting(toml_setting)

    cli_util.print_to_cli("\n".join(out))


def _clean(txt: str) -> str:
    """Replace sequences of multiple spaces with a single space, excluding newlines.

    Preserves leading and trailing spaces, and does not modify spaces in between lines.
    """
    return re.sub(" +", " ", txt)


def _clean_paragraphs(txt: str) -> list[str]:
    """Split the text into paragraphs, preserve newlines within the paragraphs."""
    # Strip both leading and trailing newlines.
    txt = txt.strip("\n")
    paragraphs = txt.split("\n\n")
    return [
        "\n".join(_clean(line) for line in paragraph.split("\n"))
        for paragraph in paragraphs
    ]


# Theme configuration - theme.base support functions


def _check_color_value(value: Any, option_name: str) -> None:
    """Validate theme color configuration option values.

    Validates that the value is a string (or list of strings, in the case of
    chartCategoricalColors and chartSequentialColors) and is not empty.

    Handles both single color strings (like primaryColor, backgroundColor)
    and arrays of color strings (like chartCategoricalColors, chartSequentialColors).

    Parameters
    ----------
    value : Any
        The color value to validate. Can be a string or list of strings.
    option_name : str
        The name of the theme option being validated (e.g., "theme.primaryColor").

    Raises
    ------
    StreamlitInvalidThemeOptionError
        If the value is not a string/list of strings, is empty, or contains
        empty values in the case of arrays.

    Notes
    -----
    Logs warnings for potentially invalid colors, since more comprehensive
    validation happens on the frontend.
    """
    logger = _get_logger()

    # Handle array color options (chartCategoricalColors, chartSequentialColors)
    if isinstance(value, list):
        if not value:
            raise StreamlitInvalidThemeOptionError(
                f"Theme option '{option_name}' cannot be an empty array"
            )

        for i, color in enumerate(value):
            if not isinstance(color, str):
                raise StreamlitInvalidThemeOptionError(
                    f"Theme option '{option_name}[{i}]' must be a string, got {type(color).__name__}: {color}"
                )

            color_str = color.strip()
            if not color_str:
                raise StreamlitInvalidThemeOptionError(
                    f"Theme option '{option_name}[{i}]' cannot be empty"
                )

            # Lightweight color validation with warning
            if not is_css_color_like(color_str):
                logger.warning(
                    "Theme option '%s[%s]' may be an invalid color: %s. "
                    "Expected formats: hex, rgb, and rgba colors",
                    option_name,
                    i,
                    color_str,
                )

        return  # All colors in array have been checked

    # Handle single color options (primaryColor, backgroundColor, etc.)
    if not isinstance(value, str):
        raise StreamlitInvalidThemeOptionError(
            f"Theme option '{option_name}' must be a string or array of strings, got {type(value).__name__}: {value}"
        )

    value_str: str = value.strip()

    if not value_str:
        raise StreamlitInvalidThemeOptionError(
            f"Theme option '{option_name}' cannot be empty"
        )

    # Lightweight color validation with warning
    if not is_css_color_like(value_str):
        logger.warning(
            "Theme option '%s' may be an invalid color: %s. "
            "Expected formats: hex, rgb, and rgba colors",
            option_name,
            value_str,
        )


def _iterate_theme_config_options(
    config_options: dict[str, ConfigOption],
) -> Iterator[tuple[str, Any]]:
    """
    Iterate through theme config options, yielding (option_path, value) pairs.
    Returns: theme.primaryColor, #ff0000, ...

    Leveraged by _extract_current_theme_config() to retrieve main config.toml theme options.
    """
    for opt_name, opt_val in config_options.items():
        if opt_name.startswith("theme.") and opt_val.value is not None:
            yield opt_name, opt_val.value


def _extract_current_theme_config(
    config_options: dict[str, ConfigOption],
) -> dict[str, Any]:
    """
    Extract current theme configuration from config options.
    Returns a dictionary with the current theme options in nested format.
    """
    current_theme_options = {}

    for opt_name, opt_value in _iterate_theme_config_options(config_options):
        parts = opt_name.split(".")
        if len(parts) == 2:  # theme.option
            _, option = parts
            if option != "base":  # Don't include the base option itself
                current_theme_options[option] = opt_value
        elif len(parts) == 3:  # theme.sidebar.option or theme.light.option
            _, section, option = parts
            if section not in current_theme_options:
                current_theme_options[section] = {}
            current_theme_options[section][option] = opt_value
        elif len(parts) == 4:  # theme.light.sidebar.option or theme.dark.sidebar.option
            _, section, subsection, option = parts
            if section not in current_theme_options:
                current_theme_options[section] = {}
            if subsection not in current_theme_options[section]:
                current_theme_options[section][subsection] = {}
            current_theme_options[section][subsection][option] = opt_value

    return current_theme_options


def _get_valid_theme_options(
    config_options_template: dict[str, ConfigOption],
) -> tuple[set[str], set[str]]:
    """Get valid theme configuration options for main theme and theme sections.

    Extracts valid theme options from the config options template to ensure they
    stay in sync with the actual theme options defined via _create_theme_options() calls.

    Parameters
    ----------
    config_options_template : dict[str, ConfigOption]
        Template of all available configuration options.

    Returns
    -------
    tuple[set[str], set[str]]
        A tuple (main_theme_options, section_theme_options) where:
        - main_theme_options: Valid theme options for the main theme (without "theme." prefix)
        - section_theme_options: Valid theme options for sections/subsections
          (sidebar, light, dark, light.sidebar, dark.sidebar)

    Notes
    -----
    All non-main theme sections have the same valid options, so we only need to
    extract them once.
    """
    # Extract options dynamically from the config template
    main_theme_options = set()
    section_theme_options = set()

    # Extract theme options from the config template
    for option_key in config_options_template:
        if option_key.startswith("theme."):
            parts = option_key.split(".")
            # Direct theme options like "theme.primaryColor"
            if parts[0] == "theme" and len(parts) == 2:
                _, option_name = parts
                main_theme_options.add(option_name)
            # Subsection options like "theme.sidebar.primaryColor"
            elif parts[0] == "theme" and parts[1] == "sidebar" and len(parts) == 3:
                # All subsections (sidebar, light, dark, light.sidebar, dark.sidebar)
                # get the same options as theme.sidebar (which excludes main-only options)
                _, _, option_name = parts
                section_theme_options.add(option_name)

    return main_theme_options, section_theme_options


def _invalid_theme_option_warning(
    option_name: str,
    file_path_or_url: str,
    valid_options: set[str],
    section_name: str = "theme",
) -> None:
    """Helper function to log a warning for an invalid theme option."""

    if section_name == "theme":
        full_option_name = f"{section_name}.{option_name}"
    else:
        # Handle sections like "sidebar" -> "theme.sidebar.{option_name}"
        # or subsections like "light.sidebar" -> "theme.light.sidebar.{option_name}"
        full_option_name = f"theme.{section_name}.{option_name}"

    valid_options_list = "\n".join(f"  • {opt}" for opt in sorted(valid_options))
    _get_logger().warning(
        "Theme file %s contains invalid theme option: '%s'.\n\n"
        "Valid '%s' options are:\n%s",
        file_path_or_url,
        full_option_name,
        section_name,
        valid_options_list,
    )


def _validate_theme_section_recursive(
    section_configs: dict[str, Any],
    section_path: str,
    file_path_or_url: str,
    section_options: set[str],
    filtered_parent: dict[str, Any],
    allow_sidebar_subsection: bool = False,
) -> None:
    """Recursively validate a theme section and its subsection/options.

    Parameters
    ----------
    section_configs : dict[str, Any]
        The section configs to validate.
    section_path : str
        Path like 'sidebar', 'light', 'light.sidebar'.
    file_path_or_url : str
        Theme file path for error messages.
    section_options : set[str]
        Valid options for this section.
    filtered_parent : dict[str, Any]
        Parent section to populate/filter out invalid options.
    allow_sidebar_subsection : bool, optional
        Allow sidebar subsection (only "light" and "dark" sections), by default False.

    Raises
    ------
    StreamlitInvalidThemeSectionError
        If an invalid subsection is found.
    """
    for option_name, option_value in section_configs.items():
        if isinstance(option_value, dict):
            # This is a subsection
            if not allow_sidebar_subsection or option_name != "sidebar":
                raise StreamlitInvalidThemeSectionError(
                    f"theme.{section_path}.{option_name}",
                    file_path_or_url,
                )

            # Create and validate the subsection's options
            if option_name not in filtered_parent:
                filtered_parent[option_name] = {}

            _validate_theme_section_recursive(
                option_value,
                f"{section_path}.{option_name}",
                file_path_or_url,
                section_options,
                filtered_parent[option_name],
                False,  # sidebar subsection can't have further subsections
            )
        elif option_name not in section_options:
            # This is an invalid section option
            _invalid_theme_option_warning(
                option_name,
                file_path_or_url,
                section_options,
                section_path,
            )
            # Remove the invalid option from the filtered theme
            filtered_parent.pop(option_name, None)
        else:
            # Valid option - add to filtered theme and check color values
            filtered_parent[option_name] = option_value
            full_option_name = f"theme.{section_path}.{option_name}"
            if "color" in full_option_name.lower():
                _check_color_value(option_value, full_option_name)


def _validate_theme_file_content(
    theme_content: dict[str, Any],
    file_path_or_url: str,
    config_options_template: dict[str, ConfigOption],
) -> dict[str, Any]:
    """
    Validate that a theme file contains only valid theme sections and config options.

    If invalid sections are found in the theme file, a StreamlitInvalidThemeSectionError is raised.

    If invalid config options are found in the theme file, a warning is logged with the valid
    options for the given section.

    Returns
    -------
        A filtered copy of the theme content with invalid options removed.
    """
    # Get valid options for each type of section
    valid_main_options, valid_section_options = _get_valid_theme_options(
        config_options_template
    )
    # Valid theme sections
    valid_sections = {"sidebar", "light", "dark"}

    theme_section = theme_content.get("theme", {})

    # Create a filtered copy of the theme content
    filtered_theme = copy.deepcopy(theme_content)
    filtered_theme_section = filtered_theme.get("theme", {})

    # Validate theme options
    for option_name, option_value in theme_section.items():
        # This is a section like theme.sidebar, theme.light, theme.dark
        if isinstance(option_value, dict):
            # Invalid section: raise error
            if option_name not in valid_sections:
                raise StreamlitInvalidThemeSectionError(
                    option_name,
                    file_path_or_url,
                )

            # Create the section in our filtered theme and validate it
            if option_name not in filtered_theme_section:
                filtered_theme_section[option_name] = {}

            # Subsection can only be sidebar from within light and dark sections
            allow_sidebar_subsection = option_name in {"light", "dark"}

            _validate_theme_section_recursive(
                option_value,
                option_name,
                file_path_or_url,
                valid_section_options,
                filtered_theme_section[option_name],
                allow_sidebar_subsection,
            )

        elif option_name not in valid_main_options:
            # Invalid main theme option
            _invalid_theme_option_warning(
                option_name,
                file_path_or_url,
                valid_main_options,
            )
            # Remove the invalid option from the filtered theme
            filtered_theme_section.pop(option_name, None)

        else:
            # Valid main theme option - if color config, check color value
            full_option_name = f"theme.{option_name}"
            if "color" in full_option_name.lower():
                _check_color_value(option_value, full_option_name)

    return filtered_theme


def _load_theme_file(
    file_path_or_url: str, config_options_template: dict[str, ConfigOption]
) -> dict[str, Any]:
    """
    Load and parse a theme TOML file from a local path or URL.

    Handles raising errors when a file cannot be found, read, parsed,
    or contains invalid theme options.

    Otherwise returns the parsed TOML content as a dictionary.
    """

    def _raise_missing_toml() -> None:
        raise StreamlitInvalidThemeError(
            "The 'toml' package is required to load theme files. "
            "Please install it with 'pip install toml'."
        )

    def _raise_file_not_found() -> None:
        raise FileNotFoundError(f"Theme file not found: {file_path_or_url}")

    def _raise_missing_theme_section() -> None:
        raise StreamlitInvalidThemeSectionError(
            f"Theme file {file_path_or_url} must contain a [theme] section"
        )

    def _raise_file_too_large() -> None:
        content_size = len(content.encode("utf-8"))
        raise StreamlitInvalidThemeError(
            f"Theme file {file_path_or_url} is too large ({content_size:,} bytes). "
            f"Maximum allowed size is {_MAX_THEME_FILE_SIZE_BYTES:,} bytes (1MB). "
            f"Theme files should contain only configuration options, not large data."
        )

    try:
        import toml
    except ImportError:
        _raise_missing_toml()

    # Check if it's a URL using the url_util helper (only allow http/https schemes by default)
    is_valid_url = url_util.is_url(file_path_or_url)

    try:
        if is_valid_url:
            # Load from URL - noqa: S310 suppressed since url_util.is_url() restricts to only
            # http/https schemes by default, preventing file:// or other dangerous schemes
            # 30-second timeout prevents hanging in poor network conditions (same as cli.py)
            with urllib.request.urlopen(file_path_or_url, timeout=30) as response:  # noqa: S310
                content = response.read().decode("utf-8")
        else:
            # Load from local file path
            # Resolve relative paths from the current working directory
            if not os.path.isabs(file_path_or_url):
                file_path_or_url = os.path.join(os.getcwd(), file_path_or_url)

            if not os.path.exists(file_path_or_url):
                _raise_file_not_found()

            with open(file_path_or_url, encoding="utf-8") as f:
                content = f.read()

        # Check file size limit - theme files should be small configuration files
        content_size = len(content.encode("utf-8"))
        if content_size > _MAX_THEME_FILE_SIZE_BYTES:
            _raise_file_too_large()

        # Parse the TOML content
        parsed_theme = toml.loads(content)

        # Validate that the theme file has a theme section
        if "theme" not in parsed_theme:
            _raise_missing_theme_section()

        # Validate that the theme file contains only valid theme options, filtering out invalid ones
        filtered_theme = _validate_theme_file_content(
            parsed_theme, file_path_or_url, config_options_template
        )

        return filtered_theme

    except (
        StreamlitInvalidThemeError,
        StreamlitInvalidThemeOptionError,
        StreamlitInvalidThemeSectionError,
        FileNotFoundError,
    ):
        # Re-raise these specific exceptions
        raise
    except urllib.error.URLError as e:
        raise StreamlitInvalidThemeError(
            f"Could not load theme file from URL {file_path_or_url}: {e}"
        ) from e
    except Exception as e:
        raise StreamlitInvalidThemeError(
            f"Error loading theme file {file_path_or_url}: {e}"
        ) from e


def _deep_merge_theme_dicts(
    base_dict: dict[str, Any], override_dict: dict[str, Any]
) -> dict[str, Any]:
    """
    Recursively merge two dictionaries, with override_dict values taking precedence.
    Handles arbitrary levels of nesting for theme configurations.
    """
    merged = copy.deepcopy(base_dict)

    for key, value in override_dict.items():
        if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
            # Both base and override have dict values for this key, merge recursively
            merged[key] = _deep_merge_theme_dicts(merged[key], value)
        else:
            # Override value takes precedence (either new key or non-dict value)
            merged[key] = copy.deepcopy(value)

    return merged


def _apply_theme_inheritance(
    base_theme: dict[str, Any], override_theme: dict[str, Any]
) -> dict[str, Any]:
    """
    Apply theme inheritance where theme config values from config.toml
    take precedence over the theme configs defined in theme.base toml file.

    Returns a dictionary with the merged theme configuration.
    """
    return _deep_merge_theme_dicts(base_theme, override_theme)


def _set_theme_options_recursive(
    options_dict: dict[str, Any], prefix: str, set_option_func: Any, source: str
) -> None:
    """
    Recursively set theme options from nested dictionary in process_theme_inheritance().
    This utility function traverses nested theme configuration sections/subsection
    and sets each option using the provided set_option_func.
    """
    for option_name, option_value in options_dict.items():
        if option_name == "base" and prefix == "theme":
            # Base is handled separately in theme inheritance
            continue

        current_key = f"{prefix}.{option_name}" if prefix else option_name

        if isinstance(option_value, dict):
            # Recursively handle nested sections
            _set_theme_options_recursive(
                option_value, current_key, set_option_func, source
            )
        else:
            # Set the actual config option
            set_option_func(current_key, option_value, source)


# Theme configuration - handles theme.base


def process_theme_inheritance(
    config_options: dict[str, ConfigOption] | None,
    config_options_template: dict[str, ConfigOption],
    set_option_func: Any,
) -> None:
    """
    Process theme inheritance if theme.base points to a theme file.

    This function checks if theme.base is set to a file path or URL,
    loads the theme file, and applies inheritance logic where the
    current config.toml values override the theme.base file values.

    Sets the merged theme options to the config.
    """
    # Get the current theme.base value
    if config_options is None:
        return

    base_option = config_options.get("theme.base")
    if not base_option or base_option.value is None:
        return

    base_value = base_option.value

    # Check if it's a file path or URL (not just "light" or "dark")
    if base_value in ("light", "dark"):
        return

    def _raise_invalid_nested_base() -> None:
        raise StreamlitInvalidThemeError(
            f"Theme file {base_value} cannot reference another theme file in its base property. "
            f"Only 'light' and 'dark' are allowed in referenced theme files."
        )

    try:
        # Load the theme file config options
        theme_file_content = _load_theme_file(base_value, config_options_template)

        # Validate that theme.base of the referenced theme file doesn't reference another file
        theme_base = theme_file_content.get("theme", {}).get("base")
        if theme_base and theme_base not in ("light", "dark"):
            _raise_invalid_nested_base()

        # Get current theme options from main config.toml
        current_theme_options = (
            _extract_current_theme_config(config_options) if config_options else {}
        )

        # Apply inheritance: referenced theme file as base, override with theme options specified in config.toml
        merged_theme = _apply_theme_inheritance(
            theme_file_content, {"theme": current_theme_options}
        )

        # Preserve theme options set by env vars and command line flags (higher precedence)
        high_precedence_theme_options = {}
        if config_options is not None:
            for opt_name, opt_config in config_options.items():
                if (
                    opt_name.startswith("theme.")
                    and opt_name != "theme.base"
                    and opt_config.where_defined
                    in (
                        "environment variable",
                        "command-line argument or environment variable",
                    )
                ):
                    high_precedence_theme_options[opt_name] = {
                        "value": opt_config.value,
                        "where_defined": opt_config.where_defined,
                    }

            # Clear existing theme options (except base) to prepare for inheritance
            theme_options_to_remove = [
                opt_name
                for opt_name in config_options
                if opt_name.startswith("theme.") and opt_name != "theme.base"
            ]
            for opt_name in theme_options_to_remove:
                set_option_func(opt_name, None, "reset for theme inheritance")

        # Handle theme.base - always set it to a valid value ("light" or "dark", not a path/URL)
        theme_file_base = theme_file_content.get("theme", {}).get("base")
        if theme_file_base:
            set_option_func("theme.base", theme_file_base, f"theme file: {base_value}")
        else:
            # Theme file doesn't specify a base, default to "light"
            set_option_func(
                "theme.base", "light", f"theme file: {base_value} (default)"
            )

        # Set the merged theme options using recursive helper
        theme_section = merged_theme.get("theme", {})
        _set_theme_options_recursive(
            theme_section, "theme", set_option_func, f"theme file: {base_value}"
        )

        # Finally, restore theme options set by env vars and command line flags (highest precedence)
        for opt_name, opt_data in high_precedence_theme_options.items():
            set_option_func(opt_name, opt_data["value"], opt_data["where_defined"])

    except (
        StreamlitInvalidThemeError,
        StreamlitInvalidThemeOptionError,
        StreamlitInvalidThemeSectionError,
        FileNotFoundError,
    ):
        # Re-raise expected user errors as-is to preserve specific error messages
        raise
    except Exception as e:
        _get_logger().exception("Error processing theme inheritance")
        # Only wrap unexpected errors (not our specific validation errors)
        raise StreamlitInvalidThemeError(
            f"Failed to process theme inheritance from {base_value}: {e}"
        ) from e
