# 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 StreamlitAPIException

# 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:
    """
    Lightweight check of theme color config option values.

    Validates that the value is a string (or list of strings, in the case of
    chartCategoricalColors and chartSequentialColors) and is not empty.
    Logs warnings for potentially invalid colors, since we do more comprehensive validation on the frontend.

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

    Raises StreamlitAPIException for type errors or empty values.
    Does not return anything - used purely for validation side effects.
    """
    logger = _get_logger()

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

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

            color_str = color.strip()
            if not color_str:
                raise StreamlitAPIException(
                    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 StreamlitAPIException(
            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 StreamlitAPIException(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, ...
    """
    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
            _, section, option = parts
            if section not in current_theme_options:
                current_theme_options[section] = {}
            current_theme_options[section][option] = opt_value

    return current_theme_options


def _get_valid_theme_options(
    config_options_template: dict[str, ConfigOption], section: str = "main"
) -> set[str]:
    """Get the set of valid theme configuration options for a specific section.

    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.

    Returns a set of valid theme option names for a specific section (without the "theme." prefix)
    """
    valid_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(".")
            if section == "main" and len(parts) == 2:
                # Direct theme option like "theme.primaryColor"
                _, option_name = parts
                valid_options.add(option_name)
            elif section == "sidebar" and len(parts) == 3 and parts[1] == "sidebar":
                # Sidebar option like "theme.sidebar.primaryColor"
                _, _, option_name = parts
                valid_options.add(option_name)

    return valid_options


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

    full_option_name = (
        f"{section_name}.{option_name}"
        if section_name == "theme"
        else 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_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 StreamlitAPIException 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.
    """
    valid_main_options = _get_valid_theme_options(config_options_template, "main")
    valid_subsections = {"sidebar"}

    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():
        # Handle subsection (valid subsection is only sidebar for now)
        if isinstance(option_value, dict):
            # Invalid subsection: raise error
            if option_name not in valid_subsections:
                raise StreamlitAPIException(
                    f"Theme file {file_path_or_url} contains invalid theme subsection: '{option_name}'.\n\n"
                    f"Valid subsections are: {valid_subsections}"
                )

            # Rename option_name for clarity - it's actually the section name in this case (i.e. sidebar)
            section_name = option_name

            # Get valid options for this specific subsection
            valid_section_options = _get_valid_theme_options(
                config_options_template, section_name
            )

            # Validate options within the subsection
            for section_option, section_value in option_value.items():
                if section_option not in valid_section_options:
                    _invalid_theme_option_warning(
                        section_option,
                        file_path_or_url,
                        valid_section_options,
                        section_name,
                    )
                    # Remove invalid option from filtered theme
                    if (
                        section_name in filtered_theme_section
                        and section_option in filtered_theme_section[section_name]
                    ):
                        del filtered_theme_section[section_name][section_option]
                # Check color options for subsection
                full_option_name = f"theme.{section_name}.{section_option}"
                if "color" in full_option_name.lower():
                    _check_color_value(section_value, full_option_name)

        # Handle main theme options
        else:
            if option_name not in valid_main_options:
                _invalid_theme_option_warning(
                    option_name,
                    file_path_or_url,
                    valid_main_options,
                )
                # Remove invalid option from filtered theme
                if option_name in filtered_theme_section:
                    del filtered_theme_section[option_name]
            # Check color options for direct theme option
            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 StreamlitAPIException(
            "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 StreamlitAPIException(
            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 StreamlitAPIException(
            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 (StreamlitAPIException, FileNotFoundError):
        # Re-raise these specific exceptions
        raise
    except urllib.error.URLError as e:
        raise StreamlitAPIException(
            f"Could not load theme file from URL {file_path_or_url}: {e}"
        ) from e
    except Exception as e:
        raise StreamlitAPIException(
            f"Error loading theme file {file_path_or_url}: {e}"
        ) from e


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.
    """
    # Start with a deep copy of the base theme
    merged_theme = copy.deepcopy(base_theme)

    # Apply overrides from the config.toml
    for section_name, section_data in override_theme.items():
        if section_name not in merged_theme:
            merged_theme[section_name] = {}

        if isinstance(section_data, dict):
            # Handle nested sections like theme.sidebar
            for key, value in section_data.items():
                if (
                    isinstance(value, dict)
                    and key in merged_theme[section_name]
                    and isinstance(merged_theme[section_name][key], dict)
                ):
                    # Merge nested dictionaries (e.g., theme.sidebar)
                    merged_theme[section_name][key].update(value)
                else:
                    # Direct assignment for non-dict values or new keys
                    merged_theme[section_name][key] = value
        else:
            # Handle direct theme options
            merged_theme[section_name] = section_data

    return merged_theme


# 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 StreamlitAPIException(
            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
        theme_section = merged_theme.get("theme", {})

        for option_name, option_value in theme_section.items():
            if option_name == "base":
                # Already handled above
                continue

            if isinstance(option_value, dict):
                # Handle nested sections like sidebar
                for sub_option, sub_value in option_value.items():
                    set_option_func(
                        f"theme.{option_name}.{sub_option}",
                        sub_value,
                        f"theme file: {base_value}",
                    )
            else:
                set_option_func(
                    f"theme.{option_name}", option_value, 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 (StreamlitAPIException, 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 StreamlitAPIException(
            f"Failed to process theme inheritance from {base_value}: {e}"
        ) from e
