import collections
import configparser
import contextlib
import dataclasses
import difflib
import enum
import functools
import shlex
import textwrap
import tomllib
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence, Set
from pathlib import Path
from typing import Any, Final, Literal, Self, assert_never

from packaging.specifiers import SpecifierSet

from cibuildwheel import errors
from cibuildwheel.architecture import Architecture
from cibuildwheel.environment import EnvironmentParseError, ParsedEnvironment, parse_environment
from cibuildwheel.frontend import BuildFrontendConfig
from cibuildwheel.logger import log
from cibuildwheel.oci_container import OCIContainerEngineConfig
from cibuildwheel.projectfiles import get_requires_python_str, resolve_dependency_groups
from cibuildwheel.selector import BuildSelector, EnableGroup, TestSelector, selector_matches
from cibuildwheel.typing import PLATFORMS, PlatformName
from cibuildwheel.util import resources
from cibuildwheel.util.helpers import format_safe, parse_key_value_string, strtobool, unwrap
from cibuildwheel.util.packaging import DependencyConstraints

MANYLINUX_ARCHS: Final[tuple[str, ...]] = (
    "x86_64",
    "i686",
    "pypy_x86_64",
    "aarch64",
    "ppc64le",
    "s390x",
    "armv7l",
    "riscv64",
    "pypy_aarch64",
    "pypy_i686",
)

MUSLLINUX_ARCHS: Final[tuple[str, ...]] = (
    "x86_64",
    "i686",
    "aarch64",
    "ppc64le",
    "s390x",
    "armv7l",
    "riscv64",
)


@dataclasses.dataclass(kw_only=True)
class CommandLineArguments:
    platform: Literal["auto", "linux", "macos", "windows"] | None
    archs: str | None
    output_dir: Path
    only: str | None
    config_file: str
    package_dir: Path
    print_build_identifiers: bool
    allow_empty: bool
    debug_traceback: bool
    enable: list[str]
    clean_cache: bool

    @classmethod
    def defaults(cls) -> Self:
        return cls(
            platform="auto",
            allow_empty=False,
            archs=None,
            only=None,
            config_file="",
            output_dir=Path("wheelhouse"),
            package_dir=Path(),
            print_build_identifiers=False,
            debug_traceback=False,
            enable=[],
            clean_cache=False,
        )


@dataclasses.dataclass(frozen=True, kw_only=True)
class GlobalOptions:
    package_dir: Path
    output_dir: Path
    build_selector: BuildSelector
    test_selector: TestSelector
    architectures: set[Architecture]
    allow_empty: bool


@dataclasses.dataclass(frozen=True)
class TestRuntimeConfig:
    args: Sequence[str] = ()

    @classmethod
    def from_config_string(cls, config_string: str) -> Self:
        config_dict = parse_key_value_string(config_string, [], ["args"])
        args = config_dict.get("args") or []
        return cls(args=args)

    def options_summary(self) -> str | dict[str, str]:
        return {"args": repr(self.args)}


@dataclasses.dataclass(frozen=True, kw_only=True)
class BuildOptions:
    globals: GlobalOptions
    environment: ParsedEnvironment
    before_all: str
    before_build: str | None
    xbuild_tools: list[str] | None
    repair_command: str
    manylinux_images: dict[str, str] | None
    musllinux_images: dict[str, str] | None
    dependency_constraints: DependencyConstraints
    test_command: str | None
    before_test: str | None
    test_sources: list[str]
    test_requires: list[str]
    test_extras: str
    test_groups: list[str]
    test_environment: ParsedEnvironment
    test_runtime: TestRuntimeConfig
    build_verbosity: int
    build_frontend: BuildFrontendConfig
    config_settings: str
    container_engine: OCIContainerEngineConfig
    pyodide_version: str | None

    @property
    def package_dir(self) -> Path:
        return self.globals.package_dir

    @property
    def output_dir(self) -> Path:
        return self.globals.output_dir

    @property
    def build_selector(self) -> BuildSelector:
        return self.globals.build_selector

    @property
    def test_selector(self) -> TestSelector:
        return self.globals.test_selector

    @property
    def architectures(self) -> set[Architecture]:
        return self.globals.architectures


SettingLeaf = str | int | bool
SettingList = Sequence[SettingLeaf]
SettingTable = Mapping[str, SettingLeaf | SettingList]
SettingValue = SettingTable | SettingList | SettingLeaf


class InheritRule(enum.Enum):
    NONE = enum.auto()
    APPEND = enum.auto()
    PREPEND = enum.auto()


@dataclasses.dataclass(frozen=True)
class Override:
    select_pattern: str
    options: dict[str, SettingValue]
    inherit: dict[str, InheritRule]


MANYLINUX_OPTIONS = {f"manylinux-{build_platform}-image" for build_platform in MANYLINUX_ARCHS}
MUSLLINUX_OPTIONS = {f"musllinux-{build_platform}-image" for build_platform in MUSLLINUX_ARCHS}
DISALLOWED_OPTIONS = {
    "linux": {"dependency-versions"},
    "macos": MANYLINUX_OPTIONS | MUSLLINUX_OPTIONS,
    "windows": MANYLINUX_OPTIONS | MUSLLINUX_OPTIONS,
}


class OptionsReaderError(errors.ConfigurationError):
    pass


class OptionFormat:
    """
    Base class for option format specifiers. These objects describe how values
    can be parsed from rich TOML values and how they're merged together.
    """

    class NotSupported(Exception):
        pass

    def format_list(self, value: SettingList) -> str:  # noqa: ARG002
        raise OptionFormat.NotSupported

    def format_table(self, table: SettingTable) -> str:  # noqa: ARG002
        raise OptionFormat.NotSupported

    def merge_values(self, before: str, after: str) -> str:  # noqa: ARG002
        raise OptionFormat.NotSupported


class ListFormat(OptionFormat):
    """
    A format that joins lists with a separator.
    """

    def __init__(self, sep: str, quote: Callable[[str], str] | None = None) -> None:
        self.sep = sep
        self.quote = quote or (lambda s: s)

    def format_list(self, value: SettingList) -> str:
        return self.sep.join(self.quote(str(v)) for v in value)

    def merge_values(self, before: str, after: str) -> str:
        return f"{before}{self.sep}{after}"


class ShlexTableFormat(OptionFormat):
    """
    The standard table format uses shlex.quote to quote values and shlex.split
    to unquote and split them. When merging values, keys in before are
    replaced by keys in after.
    """

    def __init__(self, sep: str = " ", pair_sep: str = "=", allow_merge: bool = True) -> None:
        self.sep = sep
        self.pair_sep = pair_sep
        self.allow_merge = allow_merge

    def format_table(self, table: SettingTable) -> str:
        assignments: list[tuple[str, str]] = []

        for k, v in table.items():
            if shlex.split(k) != [k]:
                msg = f"Invalid table key: {k}"
                raise OptionsReaderError(msg)

            if isinstance(v, str):
                assignments.append((k, v))
            elif isinstance(v, Sequence):
                assignments.extend((k, str(inner_v)) for inner_v in v)
            else:
                assignments.append((k, str(v)))

        return self.sep.join(f"{k}{self.pair_sep}{shlex.quote(v)}" for k, v in assignments)

    def merge_values(self, before: str, after: str) -> str:
        if not self.allow_merge:
            raise OptionFormat.NotSupported

        before_dict = self.parse_table(before)
        after_dict = self.parse_table(after)

        return self.format_table({**before_dict, **after_dict})

    def parse_table(self, table: str) -> Mapping[str, str | Sequence[str]]:
        assignments: list[tuple[str, str]] = []

        for assignment_str in shlex.split(table):
            key, sep, value = assignment_str.partition(self.pair_sep)

            if not sep:
                msg = f"malformed option with value {assignment_str!r}"
                raise OptionsReaderError(msg)

            assignments.append((key, value))

        result: dict[str, str | list[str]] = {}

        for key, value in assignments:
            if key in result:
                existing_value = result[key]
                if isinstance(existing_value, list):
                    result[key] = [*existing_value, value]
                else:
                    result[key] = [existing_value, value]
            else:
                result[key] = value

        return result


class EnvironmentFormat(OptionFormat):
    """
    The environment format accepts a table of environment variables, where the
    values may contain variables or command substitutions.
    """

    def format_table(self, table: SettingTable) -> str:
        return " ".join(f'{k}="{v}"' for k, v in table.items())

    def merge_values(self, before: str, after: str) -> str:
        return f"{before} {after}"


def _resolve_cascade(
    *pairs: tuple[SettingValue | None, InheritRule],
    ignore_empty: bool = False,
    option_format: OptionFormat | None = None,
) -> str:
    """
    Given a cascade of values with inherit rules, resolve them into a single
    value.

    'None' values mean that the option was not set at that level, and are
    ignored. If `ignore_empty` is True, empty values are ignored too.

    Values start with defaults, followed by more specific rules. If rules are
    NONE, the last non-null value is returned. If a rule is APPEND or PREPEND,
    the value is concatenated with the previous value.

    The following idiom can be used to get the first matching value:

        _resolve_cascade(("value1", Inherit.NONE), ("value2", Inherit.NONE), ...)))
    """
    if not pairs:
        msg = "pairs cannot be empty"
        raise ValueError(msg)

    result: str | None = None

    for value, rule in pairs:
        if value is None:
            continue

        if ignore_empty and not value and value is not False:
            continue

        value_string = _stringify_setting(value, option_format=option_format)

        result = _apply_inherit_rule(result, value_string, rule=rule, option_format=option_format)

    if result is None:
        msg = "a setting should at least have a default value"
        raise ValueError(msg)

    return result


def _apply_inherit_rule(
    before: str | None, after: str, rule: InheritRule, option_format: OptionFormat | None
) -> str:
    if rule == InheritRule.NONE:
        return after

    if not before:
        # if before is None, we can just return after
        # if before is an empty string, we shouldn't add any separator
        return after

    if not after:
        # if after is an empty string, we shouldn't add any separator
        return before

    if not option_format:
        msg = f"Don't know how to merge {before!r} and {after!r} with {rule}"
        raise OptionsReaderError(msg)

    match rule:
        case InheritRule.APPEND:
            return option_format.merge_values(before, after)
        case InheritRule.PREPEND:
            return option_format.merge_values(after, before)
        case _:
            assert_never(rule)


def _stringify_setting(
    setting: SettingValue,
    option_format: OptionFormat | None,
) -> str:
    match setting:
        case {}:
            assert isinstance(setting, Mapping)  # MyPy 1.15 doesn't narrow this for us
            try:
                if option_format is None:
                    raise OptionFormat.NotSupported
                return option_format.format_table(setting)
            except OptionFormat.NotSupported:
                msg = (
                    f"Error converting {setting!r} to a string: this setting doesn't accept a table"
                )
                raise OptionsReaderError(msg) from None
        case bool() | int():
            return str(setting)
        case [*_]:
            try:
                if option_format is None:
                    raise OptionFormat.NotSupported
                return option_format.format_list(setting)
            except OptionFormat.NotSupported:
                msg = (
                    f"Error converting {setting!r} to a string: this setting doesn't accept a list"
                )
                raise OptionsReaderError(msg) from None
        case _:
            assert isinstance(setting, str)  # MyPy 1.15 doesn't narrow this for us
            return setting


class OptionsReader:
    """
    Gets options from the environment, config or defaults, optionally scoped
    by the platform.

    Example:
      >>> # xdoctest: +SKIP
      >>> options_reader = OptionsReader(config_file, platform='macos')
      >>> options_reader.get('cool-color')

      This will return the value of CIBW_COOL_COLOR_MACOS if it exists,
      otherwise the value of CIBW_COOL_COLOR, otherwise
      'tool.cibuildwheel.macos.cool-color' or 'tool.cibuildwheel.cool-color'
      from `config_file`, or from cibuildwheel/resources/defaults.toml. An
      error is thrown if there are any unexpected keys or sections in
      tool.cibuildwheel.
    """

    def __init__(
        self,
        config_file_path: Path | None = None,
        *,
        platform: PlatformName,
        env: Mapping[str, str],
        disallow: Mapping[str, Set[str]] | None = None,
    ) -> None:
        self.platform = platform
        self.env = env
        self.disallow = disallow or {}

        # Open defaults.toml, loading both global and platform sections
        self.default_options, self.default_platform_options = self._load_file(resources.DEFAULTS)

        # Load the project config file
        config_options: dict[str, Any] = {}
        config_platform_options: dict[str, Any] = {}

        if config_file_path is not None:
            config_options, config_platform_options = self._load_file(config_file_path)

        # Validate project config
        for option_name in config_options:
            self._validate_global_option(option_name)

        for option_name in config_platform_options:
            self._validate_platform_option(option_name)

        self.config_options = config_options
        self.config_platform_options = config_platform_options

        self.overrides: list[Override] = []
        self.current_identifier: str | None = None

        config_overrides = self.config_options.get("overrides")

        if config_overrides is not None:
            if not isinstance(config_overrides, list):
                msg = "'tool.cibuildwheel.overrides' must be a list"
                raise OptionsReaderError(msg)

            for config_override in config_overrides:
                select = config_override.pop("select", None)

                if not select:
                    msg = "'select' must be set in an override"
                    raise OptionsReaderError(msg)

                if isinstance(select, list):
                    select = " ".join(select)

                inherit = config_override.pop("inherit", {})
                if not isinstance(inherit, dict) or not all(
                    i in {"none", "append", "prepend"} for i in inherit.values()
                ):
                    msg = "'inherit' must be a dict containing only {'none', 'append', 'prepend'} values"
                    raise OptionsReaderError(msg)

                inherit_enum = {k: InheritRule[v.upper()] for k, v in inherit.items()}

                self.overrides.append(Override(select, config_override, inherit_enum))

    def _validate_global_option(self, name: str) -> None:
        """
        Raises an error if an option with this name is not allowed in the
        [tool.cibuildwheel] section of a config file.
        """
        allowed_option_names = self.default_options.keys() | PLATFORMS | {"overrides"}

        if name not in allowed_option_names:
            msg = f"Option {name!r} not supported in a config file."
            matches = difflib.get_close_matches(name, allowed_option_names, 1, 0.7)
            if matches:
                msg += f" Perhaps you meant {matches[0]!r}?"
            raise OptionsReaderError(msg)

    def _validate_platform_option(self, name: str) -> None:
        """
        Raises an error if an option with this name is not allowed in the
        [tool.cibuildwheel.<current-platform>] section of a config file.
        """
        disallowed_platform_options = self.disallow.get(self.platform, set())
        if name in disallowed_platform_options:
            msg = f"{name!r} is not allowed in {disallowed_platform_options}"
            raise OptionsReaderError(msg)

        allowed_option_names = self.default_options.keys() | self.default_platform_options.keys()

        if name not in allowed_option_names:
            msg = f"Option {name!r} not supported in the {self.platform!r} section"
            matches = difflib.get_close_matches(name, allowed_option_names, 1, 0.7)
            if matches:
                msg += f" Perhaps you meant {matches[0]!r}?"
            raise OptionsReaderError(msg)

    def _load_file(self, filename: Path) -> tuple[dict[str, Any], dict[str, Any]]:
        """
        Load a toml file, returns global and platform as separate dicts.
        """
        with filename.open("rb") as f:
            config = tomllib.load(f)

        global_options = config.get("tool", {}).get("cibuildwheel", {})
        platform_options = global_options.get(self.platform, {})

        return global_options, platform_options

    @property
    def active_config_overrides(self) -> list[Override]:
        if self.current_identifier is None:
            return []
        return [
            o for o in self.overrides if selector_matches(o.select_pattern, self.current_identifier)
        ]

    @contextlib.contextmanager
    def identifier(self, identifier: str | None) -> Generator[None, None, None]:
        self.current_identifier = identifier
        try:
            yield
        finally:
            self.current_identifier = None

    def get(
        self,
        name: str,
        *,
        env_plat: bool = True,
        option_format: OptionFormat | None = None,
        ignore_empty: bool = False,
        env_rule: InheritRule = InheritRule.NONE,
    ) -> str:
        """
        Get and return the value for the named option from environment,
        configuration file, or the default. If env_plat is False, then don't
        accept platform versions of the environment variable. If this is an
        array it will be merged with "sep" before returning. If it is a table,
        it will be formatted with "table['item']" using {k} and {v} and merged
        with "table['sep']". If sep is also given, it will be used for arrays
        inside the table (must match table['sep']). Empty variables will not
        override if ignore_empty is True.
        """

        if name not in self.default_options and name not in self.default_platform_options:
            msg = f"{name!r} must be in cibuildwheel/resources/defaults.toml file to be accessed."
            raise OptionsReaderError(msg)

        # Environment variable form
        envvar = f"CIBW_{name.upper().replace('-', '_')}"
        plat_envvar = f"{envvar}_{self.platform.upper()}"

        # get the option from the default, then the config file, then finally the environment.
        # platform-specific options are preferred, if they're allowed.
        return _resolve_cascade(
            (self.default_options.get(name), InheritRule.NONE),
            (self.default_platform_options.get(name), InheritRule.NONE),
            (self.config_options.get(name), InheritRule.NONE),
            (self.config_platform_options.get(name), InheritRule.NONE),
            *[
                (o.options.get(name), o.inherit.get(name, InheritRule.NONE))
                for o in self.active_config_overrides
            ],
            (self.env.get(envvar), env_rule),
            (self.env.get(plat_envvar) if env_plat else None, env_rule),
            ignore_empty=ignore_empty,
            option_format=option_format,
        )


class Options:
    pyproject_toml: dict[str, Any] | None

    def __init__(
        self,
        platform: PlatformName,
        command_line_arguments: CommandLineArguments,
        env: Mapping[str, str],
        defaults: bool = False,
    ):
        self.platform = platform
        self.command_line_arguments = command_line_arguments
        self.env = env
        self._defaults = defaults
        self._image_warnings = set[str]()

        self.reader = OptionsReader(
            None if defaults else self.config_file_path,
            platform=platform,
            env=env,
            disallow=DISALLOWED_OPTIONS,
        )

        self.package_dir = Path(command_line_arguments.package_dir)
        try:
            with self.package_dir.joinpath("pyproject.toml").open("rb") as f:
                self.pyproject_toml = tomllib.load(f)
        except FileNotFoundError:
            self.pyproject_toml = None

        # cache the build options method so repeated calls don't need to
        # resolve the options again
        self.build_options = functools.cache(self._compute_build_options)

    @functools.cached_property
    def config_file_path(self) -> Path | None:
        args = self.command_line_arguments

        if args.config_file:
            return Path(format_safe(args.config_file, package=args.package_dir))

        # return pyproject.toml, if it's available
        pyproject_toml_path = Path(args.package_dir) / "pyproject.toml"
        if pyproject_toml_path.exists():
            return pyproject_toml_path

        return None

    @functools.cached_property
    def package_requires_python_str(self) -> str | None:
        return get_requires_python_str(self.package_dir, self.pyproject_toml)

    @functools.cached_property
    def globals(self) -> GlobalOptions:
        args = self.command_line_arguments
        package_dir = args.package_dir
        output_dir = args.output_dir

        build_config = (
            self.reader.get("build", env_plat=False, option_format=ListFormat(sep=" ")) or "*"
        )
        skip_config = self.reader.get("skip", env_plat=False, option_format=ListFormat(sep=" "))
        test_skip = self.reader.get("test-skip", env_plat=False, option_format=ListFormat(sep=" "))

        allow_empty = args.allow_empty or strtobool(self.env.get("CIBW_ALLOW_EMPTY", "0"))

        enable_groups = self.reader.get(
            "enable", env_plat=False, option_format=ListFormat(sep=" "), env_rule=InheritRule.APPEND
        )
        try:
            enable = {
                *EnableGroup.parse_option_value(enable_groups),
                *EnableGroup.parse_option_value(" ".join(args.enable)),
            }
        except ValueError as e:
            msg = f"Failed to parse enable group. {e}. Valid group names are: {', '.join(g.value for g in EnableGroup)}"
            raise errors.ConfigurationError(msg) from e

        # This is not supported in tool.cibuildwheel, as it comes from a standard location.
        # Passing this in as an environment variable will override pyproject.toml, setup.cfg, or setup.py
        requires_python_str: str | None = (
            self.env.get("CIBW_PROJECT_REQUIRES_PYTHON") or self.package_requires_python_str
        )
        requires_python = None if requires_python_str is None else SpecifierSet(requires_python_str)

        archs_config_str = args.archs or self.reader.get(
            "archs", option_format=ListFormat(sep=" "), ignore_empty=True
        )
        architectures = Architecture.parse_config(archs_config_str, platform=self.platform)

        # Process `--only`
        if args.only:
            build_config = args.only
            skip_config = ""
            architectures = Architecture.all_archs(self.platform)
            enable |= EnableGroup.all_groups()
            if args.only.startswith("cp313t-"):
                enable.add(EnableGroup.CPythonFreeThreading)

        build_selector = BuildSelector(
            build_config=build_config,
            skip_config=skip_config,
            requires_python=requires_python,
            enable=frozenset(enable),
        )
        test_selector = TestSelector(skip_config=test_skip)

        return GlobalOptions(
            package_dir=package_dir,
            output_dir=output_dir,
            build_selector=build_selector,
            test_selector=test_selector,
            architectures=architectures,
            allow_empty=allow_empty,
        )

    def _check_pinned_image(self, value: str, pinned_images: Mapping[str, str]) -> None:
        error_set = {"manylinux1", "manylinux2010", "manylinux_2_24", "musllinux_1_1"}
        warning_set: set[str] = set()

        if value in error_set:
            msg = (
                f"cibuildwheel 3.x does not support the image {value!r}. Either upgrade to a "
                "supported image or continue using the image by pinning it directly with"
                " its full OCI registry '<name>{:<tag>|@<digest>}'."
            )
            raise errors.DeprecationError(msg)

        if value in warning_set and value not in self._image_warnings:
            self._image_warnings.add(value)
            msg = (
                f"Deprecated image {value!r}. This value will not work"
                " in a future version of cibuildwheel. Either upgrade to a supported"
                " image or continue using the deprecated image by pinning directly"
                f" to {pinned_images[value]!r}."
            )
            log.warning(msg)

    def _compute_build_options(self, identifier: str | None) -> BuildOptions:
        """
        Compute BuildOptions for a single run configuration. Normally accessed
        through the `build_options` method, which is the same but the result
        is cached.
        """

        with self.reader.identifier(identifier):
            before_all = self.reader.get("before-all", option_format=ListFormat(sep=" && "))

            environment_config = self.reader.get("environment", option_format=EnvironmentFormat())
            environment_pass = self.reader.get(
                "environment-pass", option_format=ListFormat(sep=" ")
            ).split()
            before_build = self.reader.get("before-build", option_format=ListFormat(sep=" && "))
            repair_command = self.reader.get(
                "repair-wheel-command", option_format=ListFormat(sep=" && ")
            )
            config_settings = self.reader.get(
                "config-settings", option_format=ShlexTableFormat(sep=" ", pair_sep="=")
            )

            test_command = self.reader.get("test-command", option_format=ListFormat(sep=" && "))
            before_test = self.reader.get("before-test", option_format=ListFormat(sep=" && "))
            xbuild_tools: list[str] | None = shlex.split(
                self.reader.get(
                    "xbuild-tools", option_format=ListFormat(sep=" ", quote=shlex.quote)
                )
            )
            # ["\u0000"] is a sentinel value used as a default, because TOML
            # doesn't have an explicit NULL value. If xbuild-tools is set to the
            # sentinel, it indicates that the user hasn't defined xbuild-tools
            # *at all* (not even an `xbuild-tools = []` definition).
            if xbuild_tools == ["\u0000"]:
                xbuild_tools = None

            test_sources = shlex.split(
                self.reader.get(
                    "test-sources", option_format=ListFormat(sep=" ", quote=shlex.quote)
                )
            )
            test_environment_config = self.reader.get(
                "test-environment", option_format=EnvironmentFormat()
            )
            try:
                test_environment = parse_environment(test_environment_config)
            except (EnvironmentParseError, ValueError) as e:
                msg = f"Malformed environment option {test_environment_config!r}"
                raise errors.ConfigurationError(msg) from e

            test_runtime_str = self.reader.get(
                "test-runtime",
                env_plat=False,
                option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
            )
            if not test_runtime_str:
                test_runtime = TestRuntimeConfig()
            else:
                try:
                    test_runtime = TestRuntimeConfig.from_config_string(test_runtime_str)
                except ValueError as e:
                    msg = f"Failed to parse test runtime config. {e}"
                    raise errors.ConfigurationError(msg) from e

            test_requires = self.reader.get(
                "test-requires", option_format=ListFormat(sep=" ")
            ).split()
            test_extras = self.reader.get("test-extras", option_format=ListFormat(sep=","))
            test_groups_str = self.reader.get("test-groups", option_format=ListFormat(sep=" "))
            test_groups = [x for x in test_groups_str.split() if x]
            test_requirements_from_groups = resolve_dependency_groups(
                self.pyproject_toml, *test_groups
            )
            build_verbosity_str = self.reader.get("build-verbosity")

            build_frontend_str = self.reader.get(
                "build-frontend",
                env_plat=False,
                option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
            )
            if not build_frontend_str or build_frontend_str == "default":
                build_frontend = BuildFrontendConfig("build")
            else:
                try:
                    build_frontend = BuildFrontendConfig.from_config_string(build_frontend_str)
                except ValueError as e:
                    msg = f"Failed to parse build frontend. {e}"
                    raise errors.ConfigurationError(msg) from e

            try:
                environment = parse_environment(environment_config)
            except (EnvironmentParseError, ValueError) as e:
                msg = f"Malformed environment option {environment_config!r}"
                raise errors.ConfigurationError(msg) from e

            # Pass through environment variables
            if self.platform == "linux":
                for env_var_name in reversed(environment_pass):
                    with contextlib.suppress(KeyError):
                        environment.add(env_var_name, self.env[env_var_name], prepend=True)

            dependency_versions_str = self.reader.get(
                "dependency-versions",
                env_plat=True,
                option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
            )
            try:
                dependency_constraints = DependencyConstraints.from_config_string(
                    dependency_versions_str
                )
            except (ValueError, OSError) as e:
                msg = f"Failed to parse dependency versions. {e}"
                raise errors.ConfigurationError(msg) from e

            if test_extras:
                test_extras = f"[{test_extras}]"

            try:
                build_verbosity = min(3, max(-3, int(build_verbosity_str)))
            except ValueError:
                build_verbosity = 0

            manylinux_images: dict[str, str] = {}
            musllinux_images: dict[str, str] = {}
            container_engine: OCIContainerEngineConfig | None = None

            if self.platform == "linux":
                all_pinned_container_images = _get_pinned_container_images()

                for build_platform in MANYLINUX_ARCHS:
                    pinned_images = all_pinned_container_images[build_platform]
                    config_value = self.reader.get(
                        f"manylinux-{build_platform}-image", ignore_empty=True
                    )
                    self._check_pinned_image(config_value, pinned_images)
                    if config_value in pinned_images:
                        image = pinned_images[config_value]
                    else:
                        image = config_value
                    manylinux_images[build_platform] = image

                for build_platform in MUSLLINUX_ARCHS:
                    pinned_images = all_pinned_container_images[build_platform]
                    config_value = self.reader.get(
                        f"musllinux-{build_platform}-image", ignore_empty=True
                    )
                    self._check_pinned_image(config_value, pinned_images)
                    if config_value in pinned_images:
                        image = pinned_images[config_value]
                    else:
                        image = config_value
                    musllinux_images[build_platform] = image

            container_engine_str = self.reader.get(
                "container-engine",
                option_format=ShlexTableFormat(sep="; ", pair_sep=":", allow_merge=False),
            )

            try:
                container_engine = OCIContainerEngineConfig.from_config_string(container_engine_str)
            except ValueError as e:
                msg = f"Failed to parse container config. {e}"
                raise errors.ConfigurationError(msg) from e

            pyodide_version = self.reader.get("pyodide-version", env_plat=False)

            return BuildOptions(
                globals=self.globals,
                test_command=test_command,
                test_sources=test_sources,
                test_environment=test_environment,
                test_runtime=test_runtime,
                test_requires=[*test_requires, *test_requirements_from_groups],
                test_extras=test_extras,
                test_groups=test_groups,
                before_test=before_test,
                before_build=before_build,
                before_all=before_all,
                build_verbosity=build_verbosity,
                xbuild_tools=xbuild_tools,
                repair_command=repair_command,
                environment=environment,
                dependency_constraints=dependency_constraints,
                manylinux_images=manylinux_images or None,
                musllinux_images=musllinux_images or None,
                build_frontend=build_frontend,
                config_settings=config_settings,
                container_engine=container_engine,
                pyodide_version=pyodide_version or None,
            )

    def check_for_invalid_configuration(self, identifiers: Iterable[str]) -> None:
        if self.platform in {"macos", "windows"}:
            before_all_values = {self.build_options(i).before_all for i in identifiers}

            if len(before_all_values) > 1:
                raise ValueError(
                    unwrap(
                        f"""
                        before_all cannot be set to multiple values. On macOS and Windows,
                        before_all is only run once, at the start of the build. before_all values
                        are: {before_all_values!r}
                        """
                    )
                )

    @functools.cached_property
    def defaults(self) -> Self:
        return self.__class__(
            platform=self.platform,
            command_line_arguments=CommandLineArguments.defaults(),
            env={},
            defaults=True,
        )

    def summary(self, identifiers: Iterable[str], skip_unset: bool = False) -> str:
        lines = []
        global_option_names = sorted(f.name for f in dataclasses.fields(self.globals))

        for option_name in global_option_names:
            option_value = getattr(self.globals, option_name)
            default_value = getattr(self.defaults.globals, option_name)
            line = self.option_summary(
                option_name, option_value, default_value, skip_unset=skip_unset
            )
            if line is not None:
                lines.append(line)

        build_options = self.build_options(identifier=None)
        build_options_defaults = self.defaults.build_options(identifier=None)
        build_options_for_identifier = {
            identifier: self.build_options(identifier) for identifier in identifiers
        }

        build_option_names = sorted(f.name for f in dataclasses.fields(build_options))

        for option_name in build_option_names:
            if option_name == "globals":
                continue

            option_value = getattr(build_options, option_name)
            default_value = getattr(build_options_defaults, option_name)
            overrides = {
                i: getattr(build_options_for_identifier[i], option_name) for i in identifiers
            }

            line = self.option_summary(
                option_name,
                option_value,
                default_value,
                overrides=overrides,
                skip_unset=skip_unset,
            )
            if line is not None:
                lines.append(line)

        return "\n".join(lines)

    def option_summary(
        self,
        option_name: str,
        option_value: Any,  # noqa: ANN401
        default_value: Any,  # noqa: ANN401
        overrides: Mapping[str, Any] | None = None,
        skip_unset: bool = False,
    ) -> str | None:
        """
        Return a summary of the option value, including any overrides, with
        ANSI 'dim' color if it's the default.
        """
        value_str = self.option_summary_value(option_value)
        default_value_str = self.option_summary_value(default_value)
        overrides_value_strs = {
            k: self.option_summary_value(v) for k, v in (overrides or {}).items()
        }
        # if the override value is the same as the non-overridden value, don't print it
        overrides_value_strs = {k: v for k, v in overrides_value_strs.items() if v != value_str}

        has_been_set = (value_str != default_value_str) or overrides_value_strs

        if skip_unset and not has_been_set:
            return None

        c = log.colors

        result = c.gray if not has_been_set else ""
        result += f"{option_name}: "

        if overrides_value_strs:
            overrides_groups = collections.defaultdict(list)
            for k, v in overrides_value_strs.items():
                overrides_groups[v].append(k)

            result += "\n  *: "
            result += self.indent_if_multiline(value_str, "    ")

            for override_value_str, identifiers in overrides_groups.items():
                result += f"\n  {', '.join(identifiers)}: "
                result += self.indent_if_multiline(override_value_str, "    ")
        else:
            result += self.indent_if_multiline(value_str, "  ")

        result += c.end

        return result

    @staticmethod
    def indent_if_multiline(value: str, indent: str) -> str:
        if "\n" in value:
            return "\n" + textwrap.indent(value.strip(), indent)
        else:
            return value

    @staticmethod
    def option_summary_value(option_value: Any) -> str:  # noqa: ANN401
        if hasattr(option_value, "options_summary"):
            option_value = option_value.options_summary()

        if isinstance(option_value, list):
            return "".join(f"{el}\n" for el in option_value)

        if isinstance(option_value, set):
            return ", ".join(str(el) for el in sorted(option_value))

        if isinstance(option_value, dict):
            return "".join(f"{k}: {v}\n" for k, v in option_value.items())

        return str(option_value)


def compute_options(
    platform: PlatformName,
    command_line_arguments: CommandLineArguments,
    env: Mapping[str, str],
) -> Options:
    return Options(platform=platform, command_line_arguments=command_line_arguments, env=env)


@functools.cache
def _get_pinned_container_images() -> Mapping[str, Mapping[str, str]]:
    """
    This looks like a dict of dicts, e.g.
    { 'x86_64': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'},
      'i686': {'manylinux1': '...', 'manylinux2010': '...', 'manylinux2014': '...'},
      'pypy_x86_64': {'manylinux2010': '...' }
      ... }
    """
    all_pinned_images = configparser.ConfigParser()
    all_pinned_images.read(resources.PINNED_DOCKER_IMAGES)
    return all_pinned_images
