# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
import sys
from contextlib import nullcontext as does_not_raise
from pathlib import Path
from unittest.mock import MagicMock

from omegaconf import OmegaConf
from pytest import MonkeyPatch, mark, raises

import nemo_gym.global_config
import nemo_gym.server_utils
from nemo_gym import CACHE_DIR, WORKING_DIR
from nemo_gym.config_types import (
    AlmostServerError,
    ConfigError,
    ConfigMissingValuesError,
    ConfigPathNotFoundError,
    MalformedConfigPathsError,
    NoServerInstancesError,
    ServerRefNotFoundError,
)
from nemo_gym.global_config import (
    DEFAULT_HEAD_SERVER_PORT,
    NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME,
    GlobalConfigDictParser,
    GlobalConfigDictParserConfig,
    find_open_port,
    get_first_server_config_dict,
    get_global_config_dict,
)
from nemo_gym.server_utils import (
    DictConfig,
)


class TestGlobalConfig:
    def _mock_versions_for_testing(self, monkeypatch: MonkeyPatch) -> None:
        monkeypatch.setattr(nemo_gym.global_config, "openai_version", "test openai version")
        monkeypatch.setattr(nemo_gym.global_config, "ray_version", "test ray version")

        python_version_mock = MagicMock(return_value="test python version")
        monkeypatch.setattr(nemo_gym.global_config, "python_version", python_version_mock)

    @property
    def _default_global_config_dict_values(self) -> dict:
        return {
            "use_absolute_ip": False,
            "head_server": {"host": "127.0.0.1", "port": 11000},
            "disallowed_ports": [11000],
            "port_range_low": 10_001,
            "port_range_high": 20_000,
            # From self._mock_versions_for_testing
            "head_server_deps": ["ray[default]==test ray version", "openai==test openai version"],
            "python_version": "test python version",
            "skip_venv_if_present": False,
            "dry_run": False,
            "uv_cache_dir": str(CACHE_DIR / "uv"),
            "uv_venv_dir": str(WORKING_DIR),
        }

    def test_get_global_config_dict_sanity(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig({})
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        global_config_dict = get_global_config_dict()
        assert self._default_global_config_dict_values == global_config_dict

    def test_get_global_config_dict_global_exists(self, monkeypatch: MonkeyPatch) -> None:
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", "my_dict")

        global_config_dict = get_global_config_dict()
        assert "my_dict" == global_config_dict

    def test_get_global_config_dict_global_env_var(self, monkeypatch: MonkeyPatch) -> None:
        # Clear any lingering env vars.
        monkeypatch.setenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, "a: 2")

        global_config_dict = get_global_config_dict()
        assert {"a": 2} == global_config_dict

    def test_get_global_config_dict_config_paths_sanity(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = True
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig({"config_paths": ["/var", "var"]})
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        # Override OmegaConf.load to avoid file reads.
        omegaconf_load_mock = MagicMock()
        omegaconf_load_mock.side_effect = lambda path: (
            DictConfig({}) if "env" not in str(path) else DictConfig({"extra_dot_env_key": 2})
        )
        monkeypatch.setattr(nemo_gym.server_utils.OmegaConf, "load", omegaconf_load_mock)

        global_config_dict = get_global_config_dict()
        assert (
            self._default_global_config_dict_values
            | {
                "config_paths": ["/var", "var"],
                "extra_dot_env_key": 2,
            }
            == global_config_dict
        )

    def test_get_global_config_dict_config_paths_recursive(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = True
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig({"config_paths": ["/var", "var", "recursive_config_path_parent"]})
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        # Override OmegaConf.load to avoid file reads.
        omegaconf_load_mock = MagicMock()

        def omegaconf_load_mock_side_effect(path):
            if "recursive_config_path_parent" in str(path):
                return DictConfig({"config_paths": ["recursive_config_path_child"]})
            elif "recursive_config_path_child" in str(path):
                return DictConfig({"recursive_config_path_child_key": 3})
            elif "env" in str(path):
                return DictConfig({"extra_dot_env_key": 2})
            else:
                return DictConfig({})

        omegaconf_load_mock.side_effect = omegaconf_load_mock_side_effect
        monkeypatch.setattr(nemo_gym.server_utils.OmegaConf, "load", omegaconf_load_mock)

        global_config_dict = get_global_config_dict()
        assert (
            self._default_global_config_dict_values
            | {
                "config_paths": [
                    "/var",
                    "var",
                    "recursive_config_path_parent",
                    "recursive_config_path_child",
                ],
                "extra_dot_env_key": 2,
                "recursive_config_path_child_key": 3,
            }
            == global_config_dict
        )

    def test_get_global_config_dict_server_host_port_defaults(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Fix the port returned
        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "a": {"responses_api_models": {"c": {"entrypoint": "app.py"}}},
                    "b": {"c": {"d": {}}},
                    "c": 2,
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        global_config_dict = get_global_config_dict()
        assert (
            self._default_global_config_dict_values
            | {
                "a": {"responses_api_models": {"c": {"entrypoint": "app.py", "host": "127.0.0.1", "port": 12345}}},
                "b": {"c": {"d": {}}},
                "c": 2,
                "disallowed_ports": [11000, 12345],
            }
            == global_config_dict
        )

    def test_get_global_config_dict_server_refs_sanity(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Fix the port returned
        find_open_port_mock = MagicMock()
        find_open_port_mock.side_effect = [12345, 123456]
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "agent_name": {
                        "responses_api_agents": {
                            "agent_type": {
                                "entrypoint": "app.py",
                                "d": {
                                    "type": "resources_servers",
                                    "name": "resources_name",
                                },
                                "e": 2,
                            }
                        }
                    },
                    "resources_name": {
                        "resources_servers": {
                            "c": {
                                "entrypoint": "app.py",
                                "domain": "other",
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        global_config_dict = get_global_config_dict()
        assert (
            self._default_global_config_dict_values
            | {
                "agent_name": {
                    "responses_api_agents": {
                        "agent_type": {
                            "entrypoint": "app.py",
                            "d": {
                                "type": "resources_servers",
                                "name": "resources_name",
                            },
                            "e": 2,
                            "host": "127.0.0.1",
                            "port": 12345,
                        }
                    }
                },
                "resources_name": {
                    "resources_servers": {
                        "c": {
                            "entrypoint": "app.py",
                            "host": "127.0.0.1",
                            "port": 123456,
                            "domain": "other",
                        }
                    }
                },
                "disallowed_ports": [11000, 12345, 123456],
            }
            == global_config_dict
        )

    def test_get_global_config_dict_server_refs_errors_on_missing(self, monkeypatch: MonkeyPatch) -> None:
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Fix the port returned
        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "find_open_port", find_open_port_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        # Test errors on missing
        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "agent_name": {
                        "responses_api_agents": {
                            "agent_type": {
                                "entrypoint": "app.py",
                                "d": {
                                    "type": "resources_servers",
                                    "name": "resources_name",
                                },
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        with raises(ServerRefNotFoundError) as exc_info:
            get_global_config_dict()

        # The error should name the offending instance, the field, and the missing ref.
        message = str(exc_info.value)
        assert "agent_name" in message
        assert "'d'" in message
        assert "resources_servers/'resources_name'" in message

    def test_get_global_config_dict_server_refs_errors_on_wrong_type(self, monkeypatch: MonkeyPatch) -> None:
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        # Explicitly handle any local .env.yaml files. Either read or don't read.
        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        # Fix the port returned
        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "find_open_port", find_open_port_mock)

        # Override the hydra main wrapper call. At runtime, this will use sys.argv.
        # Here we assume that the user sets sys.argv correctly (we are not trying to test Hydra) and just return some DictConfig for our test.
        hydra_main_mock = MagicMock()

        # Test errors on missing
        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "agent_name": {
                        "responses_api_agents": {
                            "agent_type": {
                                "entrypoint": "app.py",
                                "d": {
                                    "type": "resources_servers",
                                    "name": "resources_name",
                                },
                            }
                        }
                    },
                    "resources_name": {
                        "responses_api_models": {
                            "c": {
                                "entrypoint": "app.py",
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        with raises(ServerRefNotFoundError):
            get_global_config_dict()

    def test_get_global_config_dict_server_refs_suggests_close_match(self, monkeypatch: MonkeyPatch) -> None:
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "find_open_port", find_open_port_mock)

        hydra_main_mock = MagicMock()

        # The agent references "resource" but the defined resources server is "resources" — a typo.
        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "agent_name": {
                        "responses_api_agents": {
                            "agent_type": {
                                "entrypoint": "app.py",
                                "resources_server": {
                                    "type": "resources_servers",
                                    "name": "resource",
                                },
                            }
                        }
                    },
                    "resources": {
                        "resources_servers": {
                            "c": {
                                "entrypoint": "app.py",
                                "domain": "other",
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        with raises(ServerRefNotFoundError) as exc_info:
            get_global_config_dict()

        message = str(exc_info.value)
        # Fuzzy match should suggest the correctly-spelled resources server, scoped to the same type.
        assert "Did you mean" in message
        assert "'resources'" in message

    def test_collect_missing_value_paths(self) -> None:
        config = DictConfig(
            {
                "a": "???",
                "b": {"c": "value", "d": "???"},
                "e": ["ok", "???"],
            }
        )
        missing = GlobalConfigDictParser().collect_missing_value_paths(config)
        assert missing == ["a", "b.d", "e[1]"]

    def test_missing_value_check_ignores_deleted_branch(self) -> None:
        # A '???' inside a branch removed by _delete_key must not be reported as missing (the swap
        # runs first and deletes it), while a genuinely-unset '???' elsewhere still is. Also exercises
        # that _recursively_swap_keys no longer crashes when a live '???' is present.
        parser = GlobalConfigDictParser()
        config = DictConfig(
            {
                "policy_model": {
                    "responses_api_models": {
                        "_delete_key": "old_model",
                        "old_model": {"entrypoint": "app.py", "model": "???"},
                        "new_model": {"entrypoint": "app.py", "model": "actual-model"},
                    }
                },
                "agent": {
                    "responses_api_agents": {
                        "simple_agent": {"entrypoint": "app.py", "container_formatter": "???"},
                    }
                },
            }
        )
        parser._recursively_swap_keys(config)

        assert "old_model" not in config.policy_model.responses_api_models
        missing = parser.collect_missing_value_paths(config)
        assert missing == ["agent.responses_api_agents.simple_agent.container_formatter"]

    def test_missing_value_check_handles_copy_and_inherit(self) -> None:
        # A '???' that flows through _copy / _inherit_from into a target is still reported (the swap
        # propagates it), so missing values in copied/inherited branches are not silently lost.
        parser = GlobalConfigDictParser()
        config = DictConfig(
            {
                "base": {"a": "set", "b": "???"},
                "viacopy": "${copy:base}",  # copies base, including b='???'
                "viainherit": {"_inherit_from": "base"},  # inherits base (moves it), including b='???'
            }
        )
        parser._recursively_swap_keys(config)

        missing = parser.collect_missing_value_paths(config)
        assert "viacopy.b" in missing
        assert "viainherit.b" in missing

    def test_copy_of_missing_leaf_is_reported_not_opaque_error(self) -> None:
        # `${copy:source.model}` where source.model is unset must not raise an opaque "path does not
        # exist" error; the copy propagates MISSING so both the target and source are reported.
        parser = GlobalConfigDictParser()
        config = DictConfig({"target": "${copy:source.model}", "source": {"model": "???"}})

        parser._recursively_swap_keys(config)  # must not raise

        missing = parser.collect_missing_value_paths(config)
        assert "target" in missing
        assert "source.model" in missing

    @mark.parametrize(
        "target_value",
        [
            "${inherit_from:source.model}",  # string inherit, missing leaf
            "${inherit_from:source.model.name}",  # string inherit, missing parent
            "${copy:source.model.name}",  # string copy, missing parent
            {"_copy": "source.model"},  # property copy, missing leaf
            {"_copy": "source.model.name"},  # property copy, missing parent
            {"_inherit_from": "source.model"},  # property inherit, missing leaf
            {"_inherit_from": "source.model.name"},  # property inherit, missing parent
        ],
    )
    def test_swap_copy_inherit_of_missing_value_does_not_crash(self, target_value) -> None:
        # All four quadrants {string, property} x {missing leaf, missing parent}, for both copy and
        # inherit_from: swapping a '???' source must not raise an opaque error (AttributeError from
        # .pop() on a bare string, or OmegaConf.merge ValueError). The target inherits MISSING and is
        # reported by the aggregated scan instead.
        parser = GlobalConfigDictParser()
        config = DictConfig({"target": target_value, "source": {"model": "???"}})

        parser._recursively_swap_keys(config)  # must not raise

        assert "target" in parser.collect_missing_value_paths(config)

    def test_inherit_of_missing_surfaces_aggregated_error_not_opaque(self) -> None:
        # End-to-end (swap -> raise_on_missing_values, the core of parse()): a property inherit of an
        # unset value yields the friendly ConfigMissingValuesError, not an AttributeError/ValueError.
        parser = GlobalConfigDictParser()
        config = DictConfig({"target": {"_inherit_from": "source.model"}, "source": {"model": "???"}})

        parser._recursively_swap_keys(config)
        with raises(ConfigMissingValuesError):
            parser.raise_on_missing_values(config)

    def test_missing_value_in_list_is_reported_not_crash(self) -> None:
        # A '???' as a list element (or inside a dict nested in a list) must not crash the swap; it is
        # reported by collect_missing_value_paths with its indexed path.
        parser = GlobalConfigDictParser()
        config = DictConfig({"server": {"items": ["a", "???"], "nested": [{"k": "???"}]}})

        parser._recursively_swap_keys(config)  # must not raise

        missing = parser.collect_missing_value_paths(config)
        assert "server.items[1]" in missing
        assert "server.nested[0].k" in missing

    def test_get_global_config_dict_raises_on_missing_values(self, monkeypatch: MonkeyPatch) -> None:
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        hydra_main_mock = MagicMock()

        # A model server that leaves a required value unset ('???') — as base configs do.
        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "policy_model": {
                        "responses_api_models": {
                            "openai_model": {
                                "entrypoint": "app.py",
                                "openai_api_key": "???",
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        with raises(ConfigMissingValuesError) as exc_info:
            get_global_config_dict()

        message = str(exc_info.value)
        # Names the full dotted path and shows how to override it.
        assert "policy_model.responses_api_models.openai_model.openai_api_key" in message
        assert "++policy_model.responses_api_models.openai_model.openai_api_key=<value>" in message

    def test_get_global_config_dict_ignores_missing_in_deleted_branch(self, monkeypatch: MonkeyPatch) -> None:
        # Regression: a '???' inside a branch removed by _delete_key must NOT raise
        # ConfigMissingValuesError. The missing-value scan runs after _recursively_swap_keys, which
        # deletes the branch first, so the only surviving config has no unset values. End-to-end
        # guard through the full get_global_config_dict() parse path.
        self._mock_versions_for_testing(monkeypatch)

        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "policy_model": {
                        "responses_api_models": {
                            "_delete_key": "old_model",
                            # old_model carries the only '???' and is removed before the scan.
                            "old_model": {"entrypoint": "app.py", "openai_api_key": "???"},
                            "new_model": {"entrypoint": "app.py"},
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        # Must not raise — the only '???' lived in the deleted branch.
        config = get_global_config_dict()
        models = config["policy_model"]["responses_api_models"]
        assert "old_model" not in models
        assert "new_model" in models

    def test_get_global_config_dict_aggregates_multiple_missing(self, monkeypatch: MonkeyPatch) -> None:
        # End-to-end through get_global_config_dict() -> parse(): more than one unset '???' is
        # collected into a single ConfigMissingValuesError listing every missing path.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "policy_model": {
                        "responses_api_models": {
                            "openai_model": {
                                "entrypoint": "app.py",
                                "openai_api_key": "???",
                                "openai_model": "???",
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        with raises(ConfigMissingValuesError) as exc_info:
            get_global_config_dict()

        message = str(exc_info.value)
        assert "2 required" in message
        assert "policy_model.responses_api_models.openai_model.openai_api_key" in message
        assert "policy_model.responses_api_models.openai_model.openai_model" in message

    def test_plain_interpolation_resolves_through_parse(self, monkeypatch: MonkeyPatch) -> None:
        # Regression: a plain OmegaConf interpolation `${a.b.c}` (not a swap directive) must still
        # resolve through the full parse after _recursively_swap_keys was made missing-tolerant.
        self._mock_versions_for_testing(monkeypatch)

        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig({"a": {"b": {"c": 3}}, "x": "${a.b.c}"})
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        config = get_global_config_dict()
        assert config["x"] == 3

    def test_get_first_server_config_dict(self) -> None:
        global_config_dict = DictConfig(
            {
                "a": {
                    "b": {
                        "c": {"my_key": "my_value"},
                        "d": None,
                    },
                    "e": None,
                },
                "f": None,
            }
        )
        assert {"my_key": "my_value"} == get_first_server_config_dict(global_config_dict, "a")

    def test_find_open_port_avoids_disallowed_ports(self, monkeypatch: MonkeyPatch) -> None:
        """Test that find_open_port retries when the head server port is returned."""
        randint_mock = MagicMock()
        randint_mock.side_effect = [
            DEFAULT_HEAD_SERVER_PORT,  # first attempt: 11000 (conflict)
            12345,  # second attempt (safe)
        ]
        monkeypatch.setattr(nemo_gym.global_config, "randint", randint_mock)

        socket_mock = MagicMock()
        socket_instance = MagicMock()
        socket_mock.return_value.__enter__ = MagicMock(return_value=socket_instance)
        socket_mock.return_value.__exit__ = MagicMock(return_value=False)
        monkeypatch.setattr(nemo_gym.global_config, "socket", socket_mock)

        get_global_config_dict_mock = MagicMock()
        get_global_config_dict_mock.return_value = {"port_range_low": 10_001, "port_range_high": 20_000}
        monkeypatch.setattr(nemo_gym.global_config, "get_global_config_dict", get_global_config_dict_mock)

        port = find_open_port(disallowed_ports=[DEFAULT_HEAD_SERVER_PORT])

        assert port == 12345
        assert randint_mock.call_count == 2  # first: conflict, second: success

    def test_find_open_port_raises_after_max_retries(self, monkeypatch: MonkeyPatch) -> None:
        """Test that find_open_port raises RuntimeError after exhausting retries."""
        socket_mock = MagicMock()
        socket_instance = MagicMock()
        socket_mock.return_value.__enter__ = MagicMock(return_value=socket_instance)
        socket_mock.return_value.__exit__ = MagicMock(return_value=False)
        socket_instance.bind.side_effect = OSError("")
        monkeypatch.setattr(nemo_gym.global_config, "socket", socket_mock)

        get_global_config_dict_mock = MagicMock()
        get_global_config_dict_mock.return_value = {"port_range_low": 10_001, "port_range_high": 20_000}
        monkeypatch.setattr(nemo_gym.global_config, "get_global_config_dict", get_global_config_dict_mock)

        with raises(RuntimeError) as exc_info:
            find_open_port(disallowed_ports=[], max_retries=5)

        assert "Unable to find an open port" in str(exc_info.value)
        assert "after 5 attempts" in str(exc_info.value)
        assert socket_instance.bind.call_count == 5

    def test_get_global_config_dict_prevents_port_conflict_with_head_server(self, monkeypatch: MonkeyPatch) -> None:
        """Integration test: verify that child servers never get the head server port."""
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        mock_find_open_port_mock = MagicMock(return_value=12345)
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", mock_find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            """Trigger find_open_port by excluding port from the config"""
            config_dict = DictConfig(
                {"test_resource": {"resources_servers": {"test_server": {"entrypoint": "app.py", "domain": "other"}}}}
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        global_config_dict = get_global_config_dict()

        resource_port = global_config_dict["test_resource"]["resources_servers"]["test_server"]["port"]
        head_port = global_config_dict["head_server"]["port"]

        assert resource_port == 12345
        assert head_port == 11000
        assert resource_port != head_port
        assert "disallowed_ports" in global_config_dict
        assert 11000 in global_config_dict["disallowed_ports"]
        assert 12345 in global_config_dict["disallowed_ports"]

    def test_almost_servers_detection_and_warning(self, monkeypatch) -> None:
        """Test the default flag error_on_almost_servers=true raises ValueError."""
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        rich_print_mock = MagicMock()
        monkeypatch.setattr(nemo_gym.global_config.rich, "print", rich_print_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "test_resources_server": {
                        "resources_servers": {"test_server": {"entrypoint": "app.py", "domain": "invalid_domain"}}
                    },
                    "test_agent": {
                        "responses_api_agents": {
                            "simple_agent": {
                                "entrypoint": "app.py",
                                "datasets": [
                                    {
                                        "name": "train",
                                        "type": "train",
                                        "jsonl_fpath": "data/train.jsonl",
                                        "gitlab_identifier": {
                                            "dataset_name": "test",
                                            "version": "0.0.1",
                                            "artifact_fpath": "train.jsonl",
                                        },
                                        "license": "Invalid License",
                                    }
                                ],
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        # AlmostServerError is a ConfigError, so the CLI reports it cleanly (no traceback).
        with raises(AlmostServerError, match="almost-server.*validation errors") as exc_info:
            get_global_config_dict()
        assert isinstance(exc_info.value, ConfigError)

    def test_almost_servers_error_flag_bypasses_value_error(self, monkeypatch: MonkeyPatch) -> None:
        """
        Test that error_on_almost_servers=false does not raise ValueError.
        Almost-servers are still detected and warnings are printed.
        """
        # Clear any lingering env vars.
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        rich_print_mock = MagicMock()
        monkeypatch.setattr(nemo_gym.global_config.rich, "print", rich_print_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "error_on_almost_servers": False,
                    "test_resources_server": {
                        "resources_servers": {"test_server": {"entrypoint": "app.py", "domain": "invalid_domain"}}
                    },
                    "test_agent": {
                        "responses_api_agents": {
                            "simple_agent": {
                                "entrypoint": "app.py",
                                "datasets": [
                                    {
                                        "name": "train",
                                        "type": "train",
                                        "jsonl_fpath": "data/train.jsonl",
                                        "gitlab_identifier": {
                                            "dataset_name": "test",
                                            "version": "0.0.1",
                                            "artifact_fpath": "train.jsonl",
                                        },
                                        "license": "Invalid License",
                                    }
                                ],
                            }
                        }
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        with does_not_raise():
            global_config_dict = get_global_config_dict()

        assert global_config_dict is not None

        printed_messages = " ".join(str(call) for call in rich_print_mock.call_args_list)
        assert "Almost-Server" in printed_messages
        assert "test_resources_server" in printed_messages
        assert "test_agent" in printed_messages
        assert "Configuration Warnings" in printed_messages
        assert "license" in printed_messages
        assert "domain" in printed_messages

    def test_use_absolute_ip(self, monkeypatch: MonkeyPatch) -> None:
        """Test that use_absolute_ip=True uses machine's hostname ip for default_host."""
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "find_open_port", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "use_absolute_ip": True,
                    "test_resource": {"responses_api_models": {"test_model": {"entrypoint": "app.py"}}},
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        expected_ip = "abcd ip"
        gethostbyname_mock = MagicMock(return_value=expected_ip)
        monkeypatch.setattr(nemo_gym.global_config, "gethostbyname", gethostbyname_mock)

        global_config_dict = get_global_config_dict()

        assert global_config_dict["head_server"]["host"] == expected_ip
        assert global_config_dict["test_resource"]["responses_api_models"]["test_model"]["host"] == expected_ip

    def test_recursively_hide_secrets(self) -> None:
        dict_config = DictConfig(
            {
                "dict": {
                    "key": "key",
                    "not": "not",
                },
                "list": [
                    {"key": "key", "not": "not"},
                ],
                "key": "key",
                "not": "not",
            }
        )
        GlobalConfigDictParser()._recursively_hide_secrets(dict_config)
        assert OmegaConf.to_container(dict_config) == {
            "dict": {"key": "****", "not": "not"},
            "list": [{"key": "****", "not": "not"}],
            "key": "****",
            "not": "not",
        }

    def test_recursively_replace_keys(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "policy_model": "${inherit_from:test_resource}",
                    "test_resource": {"responses_api_models": {"test_model": {"entrypoint": "app.py"}}},
                    "policy_model_2": {
                        "_inherit_from": "test_resource_2",
                        "responses_api_models": {"test_model": {"entrypoint": "app2.py"}},
                    },
                    "test_resource_2": {"responses_api_models": {"test_model": {"entrypoint": "app.py"}}},
                    "a": {"b": {"c": 3}},
                    "a_prime": {"b_prime": "${inherit_from:a.b.c}"},
                    "test_resource_3_copy1": "${copy:test_resource_3}",
                    "test_resource_3": {"responses_api_models": {"test_model": {"entrypoint": "app.py"}}},
                    "test_resource_3_copy2": {
                        "_copy": "test_resource_3",
                        "responses_api_models": {"test_model": {"entrypoint": "app2.py"}},
                    },
                    "test_resource_3_copy3_delete": {
                        "_copy": "test_resource_3",
                        "_delete_key": "responses_api_models",
                        "responses_api_models_2": {"test_model": {"entrypoint": "app.py"}},
                    },
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        actual_global_config_dict = OmegaConf.to_container(get_global_config_dict())
        expected_global_config_dict = self._default_global_config_dict_values | {
            "policy_model": {
                "responses_api_models": {"test_model": {"entrypoint": "app.py", "host": "127.0.0.1", "port": 12345}}
            },
            "policy_model_2": {
                "responses_api_models": {"test_model": {"entrypoint": "app2.py", "host": "127.0.0.1", "port": 12345}}
            },
            "test_resource_3_copy1": {
                "responses_api_models": {"test_model": {"entrypoint": "app.py", "host": "127.0.0.1", "port": 12345}}
            },
            "test_resource_3": {
                "responses_api_models": {"test_model": {"entrypoint": "app.py", "host": "127.0.0.1", "port": 12345}}
            },
            "test_resource_3_copy2": {
                "responses_api_models": {"test_model": {"entrypoint": "app2.py", "host": "127.0.0.1", "port": 12345}},
            },
            "disallowed_ports": [11000, 12345, 12345, 12345, 12345, 12345],
            "a": {"b": {}},
            "a_prime": {"b_prime": 3},
            "test_resource_3_copy3_delete": {
                "responses_api_models_2": {"test_model": {"entrypoint": "app.py"}},
            },
        }

        assert expected_global_config_dict == actual_global_config_dict

    def test_recursively_replace_keys_multiple_ref_one(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "policy_model": "${inherit_from:test_resource}",
                    "test_resource": {"responses_api_models": {"test_model": {"entrypoint": "app.py"}}},
                    "policy_model_2": {
                        "_inherit_from": "test_resource",
                        "responses_api_models": {"test_model": {"entrypoint": "app2.py"}},
                    },
                    "a": {"b": {"c": 3}},
                    "a_prime": {"b_prime": "${inherit_from:a.b.c}"},
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        actual_global_config_dict = OmegaConf.to_container(get_global_config_dict())
        expected_global_config_dict = self._default_global_config_dict_values | {
            "policy_model": {
                "responses_api_models": {"test_model": {"entrypoint": "app.py", "host": "127.0.0.1", "port": 12345}}
            },
            "policy_model_2": {
                "responses_api_models": {"test_model": {"entrypoint": "app2.py", "host": "127.0.0.1", "port": 12345}}
            },
            "disallowed_ports": [11000, 12345, 12345],
            "a": {"b": {}},
            "a_prime": {"b_prime": 3},
        }

        assert expected_global_config_dict == actual_global_config_dict

    def test_dummy_model_sanity(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig({})
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        actual_global_config_dict = OmegaConf.to_container(
            get_global_config_dict(
                global_config_dict_parser_config=GlobalConfigDictParserConfig(
                    initial_global_config_dict=GlobalConfigDictParserConfig.NO_MODEL_GLOBAL_CONFIG_DICT,
                )
            )
        )
        expected_global_config_dict = self._default_global_config_dict_values | {
            "disallowed_ports": [11000, 12345],
            "policy_model": {
                "responses_api_models": {
                    "dummy_model": {
                        "entrypoint": "app.py",
                        "host": "127.0.0.1",
                        "port": 12345,
                    }
                }
            },
            "policy_base_url": "",
            "policy_api_key": "",
            "policy_model_name": "",
        }

        assert expected_global_config_dict == actual_global_config_dict

    def test_dummy_model_override(self, monkeypatch: MonkeyPatch) -> None:
        self._mock_versions_for_testing(monkeypatch)

        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        exists_mock = MagicMock()
        exists_mock.return_value = False
        monkeypatch.setattr(nemo_gym.global_config.Path, "exists", exists_mock)

        find_open_port_mock = MagicMock()
        find_open_port_mock.return_value = 12345
        monkeypatch.setattr(nemo_gym.global_config, "_find_open_port_using_range", find_open_port_mock)

        hydra_main_mock = MagicMock()

        def hydra_main_wrapper(fn):
            config_dict = DictConfig(
                {
                    "policy_model": {"responses_api_models": {"test_model": {"entrypoint": "app.py"}}},
                }
            )
            return lambda: fn(config_dict)

        hydra_main_mock.return_value = hydra_main_wrapper
        monkeypatch.setattr(nemo_gym.global_config.hydra, "main", hydra_main_mock)

        actual_global_config_dict = OmegaConf.to_container(
            get_global_config_dict(
                global_config_dict_parser_config=GlobalConfigDictParserConfig(
                    initial_global_config_dict=GlobalConfigDictParserConfig.NO_MODEL_GLOBAL_CONFIG_DICT,
                )
            )
        )
        expected_global_config_dict = self._default_global_config_dict_values | {
            "disallowed_ports": [11000, 12345],
            "policy_model": {
                "responses_api_models": {
                    "test_model": {
                        "entrypoint": "app.py",
                        "host": "127.0.0.1",
                        "port": 12345,
                    }
                }
            },
            "policy_base_url": "",
            "policy_api_key": "",
            "policy_model_name": "",
        }

        assert expected_global_config_dict == actual_global_config_dict

    def test_load_extra_config_paths_prefers_cwd(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
        parser = GlobalConfigDictParser()

        (tmp_path / "my_config.yaml").write_text("my_key: from_cwd\n")
        monkeypatch.chdir(tmp_path)

        config_paths, extra_configs = parser.load_extra_config_paths(["my_config.yaml"])
        assert extra_configs[0]["my_key"] == "from_cwd"

    def test_load_extra_config_paths_falls_back_to_parent_dir(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
        parser = GlobalConfigDictParser()

        parent_dir = tmp_path / "parent"
        parent_dir.mkdir()
        (parent_dir / "my_config.yaml").write_text("my_key: from_parent\n")

        cwd_dir = tmp_path / "cwd"
        cwd_dir.mkdir()
        monkeypatch.chdir(cwd_dir)
        monkeypatch.setattr(nemo_gym.global_config, "PARENT_DIR", parent_dir)

        config_paths, extra_configs = parser.load_extra_config_paths(["my_config.yaml"])
        assert extra_configs[0]["my_key"] == "from_parent"

    def test_env_yaml_loaded_from_cwd(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
        self._mock_versions_for_testing(monkeypatch)
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        (tmp_path / "env.yaml").write_text("custom_env_key: from_cwd\n")
        monkeypatch.chdir(tmp_path)
        empty_parent = tmp_path / "empty_parent"
        empty_parent.mkdir()
        monkeypatch.setattr(nemo_gym.global_config, "PARENT_DIR", empty_parent)

        parser = GlobalConfigDictParser()
        global_config_dict = parser.parse(GlobalConfigDictParserConfig(skip_load_from_cli=True))
        assert global_config_dict["custom_env_key"] == "from_cwd"

    def test_env_yaml_falls_back_to_parent_dir(self, monkeypatch: MonkeyPatch, tmp_path: Path) -> None:
        self._mock_versions_for_testing(monkeypatch)
        monkeypatch.delenv(NEMO_GYM_CONFIG_DICT_ENV_VAR_NAME, raising=False)
        monkeypatch.setattr(nemo_gym.global_config, "_GLOBAL_CONFIG_DICT", None)

        parent_dir = tmp_path / "parent"
        parent_dir.mkdir()
        (parent_dir / "env.yaml").write_text("custom_env_key: from_parent\n")

        cwd_dir = tmp_path / "cwd"
        cwd_dir.mkdir()
        monkeypatch.chdir(cwd_dir)
        monkeypatch.setattr(nemo_gym.global_config, "PARENT_DIR", parent_dir)

        parser = GlobalConfigDictParser()
        global_config_dict = parser.parse(GlobalConfigDictParserConfig(skip_load_from_cli=True))
        assert global_config_dict["custom_env_key"] == "from_parent"

    def test_help(self, monkeypatch) -> None:
        monkeypatch.setattr(sys, "argv", ["++abc=2", "--help"])

        # Without the help override, this will SystemExit.
        GlobalConfigDictParser.parse_global_config_dict_from_cli(None)


class TestConfigLoadErrors:
    """Actionable, fail-fast errors for bad/malformed/empty config_paths (no raw traceback)."""

    def test_load_extra_config_paths_missing_relative_lists_both_locations(
        self, monkeypatch: MonkeyPatch, tmp_path: Path
    ) -> None:
        cwd, parent = tmp_path / "cwd", tmp_path / "parent"
        cwd.mkdir()
        parent.mkdir()
        monkeypatch.chdir(cwd)
        monkeypatch.setattr(nemo_gym.global_config, "PARENT_DIR", parent)

        parser = GlobalConfigDictParser()
        with raises(ConfigPathNotFoundError) as exc_info:
            parser.load_extra_config_paths(["missing/nope.yaml"])

        message = str(exc_info.value)
        assert "missing/nope.yaml" in message
        assert str(cwd / "missing/nope.yaml") in message
        assert str(parent / "missing/nope.yaml") in message
        assert "spelled correctly" in message

    def test_load_extra_config_paths_missing_dedups_when_cwd_is_install_root(
        self, monkeypatch: MonkeyPatch, tmp_path: Path
    ) -> None:
        monkeypatch.chdir(tmp_path)
        monkeypatch.setattr(nemo_gym.global_config, "PARENT_DIR", tmp_path)

        parser = GlobalConfigDictParser()
        with raises(ConfigPathNotFoundError) as exc_info:
            parser.load_extra_config_paths(["missing/nope.yaml"])

        assert str(exc_info.value).count("  - ") == 1

    def test_load_extra_config_paths_missing_absolute_path(self, tmp_path: Path) -> None:
        missing = tmp_path / "absent.yaml"
        parser = GlobalConfigDictParser()
        with raises(ConfigPathNotFoundError) as exc_info:
            parser.load_extra_config_paths([str(missing)])

        message = str(exc_info.value)
        assert str(missing) in message
        assert message.count("  - ") == 1

    def test_parse_malformed_config_paths_raises_actionable_error(self) -> None:
        parser = GlobalConfigDictParser()
        parse_config = GlobalConfigDictParserConfig(
            initial_global_config_dict=DictConfig({"config_paths": "not_a_list.yaml"}),
            skip_load_from_cli=True,
            skip_load_from_dotenv=True,
        )
        with raises(MalformedConfigPathsError) as exc_info:
            parser.parse(parse_config)

        message = str(exc_info.value)
        assert "config_paths" in message
        assert "list" in message

    def test_raise_on_no_server_instances_raises_when_empty(self) -> None:
        parser = GlobalConfigDictParser()
        config = DictConfig({"config_paths": [], "head_server": {"port": 11000}})
        with raises(NoServerInstancesError) as exc_info:
            parser.raise_on_no_server_instances(config)
        assert "gym env start" in str(exc_info.value)

    def test_raise_on_no_server_instances_passes_with_a_server(self) -> None:
        parser = GlobalConfigDictParser()
        config = DictConfig({"my_server": {"resources_servers": {"x": {"entrypoint": "app.py", "domain": "other"}}}})
        parser.raise_on_no_server_instances(config)
