"""Extensive tests for !expand and !include YAML tags."""

import sys
from typing import List
from unittest.mock import patch

import pytest
import yaml

from vidhi import (
    BasePolyConfig,
    create_class_from_dict,
    field,
    frozen_dataclass,
    load_yaml_config,
    parse_cli_args,
    parse_cli_sweep,
)
from vidhi.utils import expand_dict
from vidhi.yaml_tags import _MAX_INCLUDE_DEPTH, ExpandList, _make_loader

# =========================================================================
# Fixtures & helpers
# =========================================================================


def _write(path, text):
    """Write text to a file (helper to reduce boilerplate)."""
    path.write_text(text)
    return path


# =========================================================================
# ExpandList marker class
# =========================================================================


class TestExpandListClass:
    """Tests for the ExpandList marker class itself."""

    def test_is_list_subclass(self):
        el = ExpandList([1, 2, 3])
        assert isinstance(el, list)

    def test_has_expand_marker(self):
        el = ExpandList()
        assert el.__vidhi_expand__ is True

    def test_marker_is_class_attribute(self):
        assert ExpandList.__vidhi_expand__ is True

    def test_regular_list_has_no_marker(self):
        assert not getattr([1, 2], "__vidhi_expand__", False)

    def test_equality_with_plain_list(self):
        assert ExpandList([1, 2]) == [1, 2]

    def test_preserves_contents(self):
        el = ExpandList(["a", {"k": "v"}, 42])
        assert el[0] == "a"
        assert el[1] == {"k": "v"}
        assert el[2] == 42
        assert len(el) == 3

    def test_empty_expand_list(self):
        el = ExpandList([])
        assert el.__vidhi_expand__ is True
        assert len(el) == 0

    def test_nested_expand_lists(self):
        inner = ExpandList([1, 2])
        outer = ExpandList([inner, 3])
        assert outer[0].__vidhi_expand__ is True

    def test_copy_loses_marker(self):
        el = ExpandList([1, 2])
        copied = list(el)
        assert not getattr(copied, "__vidhi_expand__", False)


# =========================================================================
# !expand YAML tag — parsing
# =========================================================================


class TestExpandTagParsing:
    """Tests for !expand YAML tag parsing via load_yaml_config."""

    def test_basic_expand(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: !expand [0.01, 0.1]\nepochs: 10\n")
        data = load_yaml_config(str(yml))
        assert isinstance(data["lr"], ExpandList)
        assert data["lr"] == [0.01, 0.1]
        assert data["epochs"] == 10

    def test_expand_integers(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "batch: !expand [16, 32, 64]\n")
        data = load_yaml_config(str(yml))
        assert data["batch"] == [16, 32, 64]
        assert isinstance(data["batch"], ExpandList)

    def test_expand_strings(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "model: !expand [gpt-4, llama-7b]\n")
        data = load_yaml_config(str(yml))
        assert data["model"] == ["gpt-4", "llama-7b"]
        assert isinstance(data["model"], ExpandList)

    def test_expand_booleans(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "flag: !expand [true, false]\n")
        data = load_yaml_config(str(yml))
        assert data["flag"] == [True, False]
        assert isinstance(data["flag"], ExpandList)

    def test_expand_floats(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "temp: !expand [0.0, 0.5, 1.0]\n")
        data = load_yaml_config(str(yml))
        assert data["temp"] == [0.0, 0.5, 1.0]

    def test_expand_mixed_types(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "val: !expand [1, hello, true, 3.14]\n")
        data = load_yaml_config(str(yml))
        assert data["val"] == [1, "hello", True, 3.14]
        assert isinstance(data["val"], ExpandList)

    def test_expand_single_element(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "x: !expand [42]\n")
        data = load_yaml_config(str(yml))
        assert data["x"] == [42]
        assert isinstance(data["x"], ExpandList)

    def test_expand_empty_list(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "x: !expand []\n")
        data = load_yaml_config(str(yml))
        assert data["x"] == []
        assert isinstance(data["x"], ExpandList)

    def test_expand_nested_in_mapping(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "outer:\n  inner: !expand [1, 2]\n  fixed: 42\n",
        )
        data = load_yaml_config(str(yml))
        assert isinstance(data["outer"]["inner"], ExpandList)
        assert data["outer"]["fixed"] == 42

    def test_expand_deeply_nested(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "a:\n  b:\n    c:\n      d: !expand [x, y]\n",
        )
        data = load_yaml_config(str(yml))
        assert isinstance(data["a"]["b"]["c"]["d"], ExpandList)

    def test_expand_multiple_in_same_mapping(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "a: !expand [1, 2]\nb: !expand [3, 4]\nc: !expand [5, 6]\n",
        )
        data = load_yaml_config(str(yml))
        assert all(isinstance(data[k], ExpandList) for k in "abc")

    def test_expand_with_dicts_as_elements(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "item: !expand\n  - name: a\n    val: 1\n  - name: b\n    val: 2\n",
        )
        data = load_yaml_config(str(yml))
        assert isinstance(data["item"], ExpandList)
        assert data["item"][0] == {"name": "a", "val": 1}
        assert data["item"][1] == {"name": "b", "val": 2}

    def test_bare_list_not_marked(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "hosts: [a, b, c]\nport: 8080\n")
        data = load_yaml_config(str(yml))
        assert data["hosts"] == ["a", "b", "c"]
        assert not getattr(data["hosts"], "__vidhi_expand__", False)

    def test_bare_list_block_style_not_marked(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "hosts:\n  - a\n  - b\n")
        data = load_yaml_config(str(yml))
        assert not getattr(data["hosts"], "__vidhi_expand__", False)

    def test_expand_on_non_sequence_errors(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: !expand 0.01\n")
        with pytest.raises(yaml.YAMLError, match="sequence"):
            load_yaml_config(str(yml))

    def test_expand_on_mapping_errors(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: !expand {a: 1}\n")
        with pytest.raises(yaml.YAMLError, match="sequence"):
            load_yaml_config(str(yml))

    def test_expand_coexists_with_bare_list(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "sweep: !expand [1, 2]\nliteral: [3, 4]\n",
        )
        data = load_yaml_config(str(yml))
        assert isinstance(data["sweep"], ExpandList)
        assert not getattr(data["literal"], "__vidhi_expand__", False)


# =========================================================================
# !expand — expand_dict explosion logic
# =========================================================================


class TestExpandDictExplosion:
    """Tests for expand_dict with ExpandList markers."""

    def test_no_expand_no_explosion(self):
        config = {"a": 1, "b": "hello", "c": [1, 2]}
        result = expand_dict(config)
        assert len(result) == 1
        assert result[0] == config

    def test_single_expand(self):
        config = {"a": ExpandList([1, 2]), "b": 3}
        result = expand_dict(config)
        assert len(result) == 2
        assert {"a": 1, "b": 3} in result
        assert {"a": 2, "b": 3} in result

    def test_cartesian_product_2x2(self):
        config = {"a": ExpandList([1, 2]), "b": ExpandList([3, 4])}
        result = expand_dict(config)
        assert len(result) == 4

    def test_cartesian_product_2x3(self):
        config = {"a": ExpandList([1, 2]), "b": ExpandList([10, 20, 30])}
        result = expand_dict(config)
        assert len(result) == 6

    def test_cartesian_product_3_dimensions(self):
        config = {
            "a": ExpandList([1, 2]),
            "b": ExpandList([3, 4]),
            "c": ExpandList([5, 6]),
        }
        result = expand_dict(config)
        assert len(result) == 8

    def test_nested_dict_with_expand(self):
        config = {"outer": {"inner": ExpandList([1, 2])}, "other": 3}
        result = expand_dict(config)
        assert len(result) == 2
        assert result[0]["outer"]["inner"] == 1
        assert result[1]["outer"]["inner"] == 2
        assert all(r["other"] == 3 for r in result)

    def test_expand_with_dict_elements(self):
        config = {"item": ExpandList([{"name": "a"}, {"name": "b"}])}
        result = expand_dict(config)
        assert len(result) == 2
        assert result[0]["item"]["name"] == "a"
        assert result[1]["item"]["name"] == "b"

    def test_bare_list_preserved_in_result(self):
        config = {"sweep": ExpandList([1, 2]), "literal": [10, 20]}
        result = expand_dict(config)
        assert len(result) == 2
        for r in result:
            assert r["literal"] == [10, 20]

    def test_deeply_nested_expand(self):
        config = {"a": {"b": {"c": ExpandList([1, 2, 3])}}}
        result = expand_dict(config)
        assert len(result) == 3
        values = [r["a"]["b"]["c"] for r in result]
        assert sorted(values) == [1, 2, 3]

    def test_expand_at_multiple_nesting_levels(self):
        config = {
            "top": ExpandList([1, 2]),
            "nested": {"deep": ExpandList([10, 20])},
        }
        result = expand_dict(config)
        assert len(result) == 4

    def test_max_combinations_guard(self):
        config = {
            "a": ExpandList(list(range(100))),
            "b": ExpandList(list(range(200))),
        }
        with pytest.raises(ValueError, match="exceeds"):
            expand_dict(config, max_combinations=100)

    def test_single_element_expand_produces_one(self):
        config = {"a": ExpandList([42]), "b": 7}
        result = expand_dict(config)
        assert len(result) == 1
        assert result[0] == {"a": 42, "b": 7}

    def test_empty_expand_produces_nothing(self):
        config = {"a": ExpandList([]), "b": 7}
        result = expand_dict(config)
        assert len(result) == 0

    def test_scalars_only_no_expansion(self):
        config = {"x": 1, "y": "hello", "z": True}
        result = expand_dict(config)
        assert result == [config]


# =========================================================================
# !expand — end-to-end YAML → expand_dict
# =========================================================================


class TestExpandEndToEnd:
    """End-to-end tests: YAML file with !expand → expand_dict."""

    def test_yaml_cartesian(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "a: !expand [1, 2]\nb: !expand [3, 4]\n",
        )
        data = load_yaml_config(str(yml))
        results = expand_dict(data)
        assert len(results) == 4
        pairs = {(r["a"], r["b"]) for r in results}
        assert pairs == {(1, 3), (1, 4), (2, 3), (2, 4)}

    def test_yaml_nested_cartesian(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "model: !expand [a, b]\ntraining:\n  lr: !expand [0.01, 0.1]\n",
        )
        data = load_yaml_config(str(yml))
        results = expand_dict(data)
        assert len(results) == 4

    def test_yaml_expand_with_literal_list(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "sweep: !expand [1, 2]\nhosts:\n  - alpha\n  - beta\n",
        )
        data = load_yaml_config(str(yml))
        results = expand_dict(data)
        assert len(results) == 2
        for r in results:
            assert r["hosts"] == ["alpha", "beta"]

    def test_yaml_three_way_sweep(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "a: !expand [1, 2]\nb: !expand [3, 4]\nc: !expand [5, 6]\n",
        )
        results = expand_dict(load_yaml_config(str(yml)))
        assert len(results) == 8

    def test_yaml_sweep_with_dict_elements(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            (
                "config: !expand\n"
                "  - lr: 0.01\n"
                "    momentum: 0.9\n"
                "  - lr: 0.1\n"
                "    momentum: 0.99\n"
            ),
        )
        data = load_yaml_config(str(yml))
        results = expand_dict(data)
        assert len(results) == 2
        assert results[0]["config"]["lr"] == 0.01
        assert results[1]["config"]["lr"] == 0.1


# =========================================================================
# !expand — integration with parse_cli_sweep (dataclass instantiation)
# =========================================================================


@frozen_dataclass
class SimpleConfig:
    lr: float = field(0.01, help="Learning rate")
    epochs: int = field(10, help="Epochs")
    name: str = field("default", help="Run name")


@frozen_dataclass
class NestedInner:
    lr: float = field(0.01)
    momentum: float = field(0.9)


@frozen_dataclass
class NestedConfig:
    training: NestedInner = field(default_factory=NestedInner)
    seed: int = field(42)


@frozen_dataclass
class ListFieldConfig:
    tags: List[str] = field(default_factory=list, help="Tags")
    count: int = field(1, help="Count")


class TestExpandWithParseCLI:
    """Test !expand with parse_cli_sweep for actual dataclass instantiation."""

    def test_sweep_produces_multiple_configs(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: !expand [0.01, 0.1]\nepochs: 5\n")
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(SimpleConfig)
        assert len(configs) == 2
        lrs = {c.lr for c in configs}
        assert lrs == {0.01, 0.1}
        assert all(c.epochs == 5 for c in configs)

    def test_sweep_cartesian_two_fields(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "lr: !expand [0.01, 0.1]\nepochs: !expand [5, 10]\n",
        )
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(SimpleConfig)
        assert len(configs) == 4
        pairs = {(c.lr, c.epochs) for c in configs}
        assert pairs == {(0.01, 5), (0.01, 10), (0.1, 5), (0.1, 10)}

    def test_sweep_with_cli_override(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: !expand [0.01, 0.1]\n")
        args = ["prog", "--config", str(yml), "--epochs", "20"]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(SimpleConfig)
        assert len(configs) == 2
        assert all(c.epochs == 20 for c in configs)

    def test_no_expand_single_config(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: 0.05\nepochs: 3\n")
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(SimpleConfig)
        assert len(configs) == 1
        assert configs[0].lr == 0.05

    def test_bare_list_on_list_field_stays_literal(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "tags:\n  - alpha\n  - beta\ncount: 5\n")
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(ListFieldConfig)
        assert len(configs) == 1
        assert configs[0].tags == ["alpha", "beta"]
        assert configs[0].count == 5

    def test_nested_expand(self, tmp_path):
        yml = _write(
            tmp_path / "c.yml",
            "training:\n  lr: !expand [0.01, 0.1]\nseed: 123\n",
        )
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(NestedConfig)
        assert len(configs) == 2
        lrs = {c.training.lr for c in configs}
        assert lrs == {0.01, 0.1}
        assert all(c.seed == 123 for c in configs)

    def test_parse_cli_args_errors_on_multiple(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: !expand [0.01, 0.1]\n")
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            with pytest.raises(ValueError, match="produced 2 configs"):
                parse_cli_args(SimpleConfig)

    def test_parse_cli_args_ok_with_single(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "lr: 0.05\nepochs: 3\n")
        args = ["prog", "--config", str(yml)]
        with patch.object(sys, "argv", args):
            config = parse_cli_args(SimpleConfig)
        assert config.lr == 0.05


# =========================================================================
# !include YAML tag — basic parsing
# =========================================================================


class TestIncludeTagParsing:
    """Tests for !include YAML tag parsing."""

    def test_include_mapping(self, tmp_path):
        _write(tmp_path / "child.yml", "model: gpt-4\ntemp: 0.7\n")
        parent = _write(
            tmp_path / "parent.yml",
            "client: !include child.yml\nretries: 3\n",
        )
        data = load_yaml_config(str(parent))
        assert data["client"] == {"model": "gpt-4", "temp": 0.7}
        assert data["retries"] == 3

    def test_include_scalar(self, tmp_path):
        _write(tmp_path / "ver.yml", '"1.2.3"\n')
        parent = _write(tmp_path / "p.yml", "version: !include ver.yml\n")
        data = load_yaml_config(str(parent))
        assert data["version"] == "1.2.3"

    def test_include_integer(self, tmp_path):
        _write(tmp_path / "num.yml", "42\n")
        parent = _write(tmp_path / "p.yml", "answer: !include num.yml\n")
        data = load_yaml_config(str(parent))
        assert data["answer"] == 42

    def test_include_list(self, tmp_path):
        _write(tmp_path / "items.yml", "- one\n- two\n- three\n")
        parent = _write(tmp_path / "p.yml", "items: !include items.yml\n")
        data = load_yaml_config(str(parent))
        assert data["items"] == ["one", "two", "three"]

    def test_include_empty_file(self, tmp_path):
        _write(tmp_path / "empty.yml", "")
        parent = _write(tmp_path / "p.yml", "data: !include empty.yml\n")
        data = load_yaml_config(str(parent))
        assert data["data"] == {}

    def test_include_boolean_file(self, tmp_path):
        _write(tmp_path / "flag.yml", "true\n")
        parent = _write(tmp_path / "p.yml", "enabled: !include flag.yml\n")
        data = load_yaml_config(str(parent))
        assert data["enabled"] is True

    def test_include_null_file(self, tmp_path):
        _write(tmp_path / "null.yml", "null\n")
        parent = _write(tmp_path / "p.yml", "val: !include null.yml\n")
        data = load_yaml_config(str(parent))
        # null → None → coerced to {} by !include handler
        assert data["val"] == {}

    def test_include_complex_mapping(self, tmp_path):
        _write(
            tmp_path / "db.yml",
            "host: localhost\nport: 5432\noptions:\n  pool_size: 10\n  timeout: 30\n",
        )
        parent = _write(tmp_path / "p.yml", "database: !include db.yml\n")
        data = load_yaml_config(str(parent))
        assert data["database"]["host"] == "localhost"
        assert data["database"]["options"]["pool_size"] == 10

    def test_multiple_includes_in_one_file(self, tmp_path):
        _write(tmp_path / "a.yml", "x: 1\n")
        _write(tmp_path / "b.yml", "y: 2\n")
        parent = _write(
            tmp_path / "p.yml",
            "first: !include a.yml\nsecond: !include b.yml\n",
        )
        data = load_yaml_config(str(parent))
        assert data["first"] == {"x": 1}
        assert data["second"] == {"y": 2}

    def test_include_same_file_twice(self, tmp_path):
        _write(tmp_path / "shared.yml", "val: 42\n")
        parent = _write(
            tmp_path / "p.yml",
            "a: !include shared.yml\nb: !include shared.yml\n",
        )
        data = load_yaml_config(str(parent))
        assert data["a"] == {"val": 42}
        assert data["b"] == {"val": 42}

    def test_include_with_sibling_keys(self, tmp_path):
        _write(tmp_path / "child.yml", "inner: true\n")
        parent = _write(
            tmp_path / "p.yml",
            "before: 1\nincluded: !include child.yml\nafter: 2\n",
        )
        data = load_yaml_config(str(parent))
        assert data["before"] == 1
        assert data["included"]["inner"] is True
        assert data["after"] == 2


# =========================================================================
# !include — path resolution
# =========================================================================


class TestIncludePathResolution:
    """Tests for !include relative and absolute path resolution."""

    def test_relative_same_directory(self, tmp_path):
        _write(tmp_path / "child.yml", "k: v\n")
        parent = _write(tmp_path / "parent.yml", "d: !include child.yml\n")
        data = load_yaml_config(str(parent))
        assert data["d"] == {"k": "v"}

    def test_relative_subdirectory(self, tmp_path):
        sub = tmp_path / "sub"
        sub.mkdir()
        _write(sub / "child.yml", "val: 42\n")
        parent = _write(tmp_path / "p.yml", "d: !include sub/child.yml\n")
        data = load_yaml_config(str(parent))
        assert data["d"] == {"val": 42}

    def test_relative_parent_directory(self, tmp_path):
        sub = tmp_path / "configs"
        sub.mkdir()
        _write(tmp_path / "shared.yml", "shared: true\n")
        parent = _write(sub / "main.yml", "d: !include ../shared.yml\n")
        data = load_yaml_config(str(parent))
        assert data["d"] == {"shared": True}

    def test_absolute_path(self, tmp_path):
        child = _write(tmp_path / "child.yml", "abs: true\n")
        parent = _write(
            tmp_path / "p.yml",
            f"d: !include {child}\n",
        )
        data = load_yaml_config(str(parent))
        assert data["d"] == {"abs": True}

    def test_nested_includes_resolve_relative_to_own_file(self, tmp_path):
        """B includes C; paths in B resolve relative to B's directory."""
        sub = tmp_path / "level1"
        sub.mkdir()
        subsub = sub / "level2"
        subsub.mkdir()

        _write(subsub / "leaf.yml", "leaf: true\n")
        _write(sub / "mid.yml", "mid: !include level2/leaf.yml\n")
        parent = _write(tmp_path / "root.yml", "top: !include level1/mid.yml\n")

        data = load_yaml_config(str(parent))
        assert data["top"]["mid"]["leaf"] is True

    def test_include_with_spaces_in_path(self, tmp_path):
        sub = tmp_path / "my configs"
        sub.mkdir()
        _write(sub / "child.yml", "val: ok\n")
        parent = _write(tmp_path / "p.yml", "d: !include 'my configs/child.yml'\n")
        data = load_yaml_config(str(parent))
        assert data["d"] == {"val": "ok"}


# =========================================================================
# !include — error handling
# =========================================================================


class TestIncludeErrors:
    """Tests for !include error conditions."""

    def test_missing_file(self, tmp_path):
        parent = _write(tmp_path / "p.yml", "x: !include nonexistent.yml\n")
        with pytest.raises(FileNotFoundError):
            load_yaml_config(str(parent))

    def test_cycle_self_include(self, tmp_path):
        f = tmp_path / "self.yml"
        f.write_text("x: !include self.yml\n")
        with pytest.raises(yaml.YAMLError, match="Circular"):
            load_yaml_config(str(f))

    def test_cycle_two_files(self, tmp_path):
        _write(tmp_path / "a.yml", "x: !include b.yml\n")
        _write(tmp_path / "b.yml", "y: !include a.yml\n")
        with pytest.raises(yaml.YAMLError, match="Circular"):
            load_yaml_config(str(tmp_path / "a.yml"))

    def test_cycle_three_files(self, tmp_path):
        _write(tmp_path / "a.yml", "x: !include b.yml\n")
        _write(tmp_path / "b.yml", "y: !include c.yml\n")
        _write(tmp_path / "c.yml", "z: !include a.yml\n")
        with pytest.raises(yaml.YAMLError, match="Circular"):
            load_yaml_config(str(tmp_path / "a.yml"))

    def test_depth_limit_exceeded(self, tmp_path):
        for i in range(_MAX_INCLUDE_DEPTH + 2):
            f = tmp_path / f"level{i}.yml"
            if i < _MAX_INCLUDE_DEPTH + 1:
                f.write_text(f"next: !include level{i+1}.yml\n")
            else:
                f.write_text("end: true\n")
        with pytest.raises(yaml.YAMLError, match="depth limit"):
            load_yaml_config(str(tmp_path / "level0.yml"))

    def test_depth_exactly_at_limit_succeeds(self, tmp_path):
        """A chain of exactly _MAX_INCLUDE_DEPTH levels should succeed."""
        for i in range(_MAX_INCLUDE_DEPTH + 1):
            f = tmp_path / f"level{i}.yml"
            if i < _MAX_INCLUDE_DEPTH:
                f.write_text(f"next: !include level{i+1}.yml\n")
            else:
                f.write_text("end: true\n")
        # Should NOT raise
        data = load_yaml_config(str(tmp_path / "level0.yml"))
        # Walk down the chain
        node = data
        for _ in range(_MAX_INCLUDE_DEPTH):
            node = node["next"]
        assert node["end"] is True

    def test_include_on_non_scalar_errors(self, tmp_path):
        parent = _write(
            tmp_path / "p.yml",
            "x: !include\n  - a.yml\n  - b.yml\n",
        )
        with pytest.raises(yaml.YAMLError, match="scalar"):
            load_yaml_config(str(parent))

    def test_include_on_mapping_node_errors(self, tmp_path):
        parent = _write(
            tmp_path / "p.yml",
            "x: !include\n  path: a.yml\n",
        )
        with pytest.raises(yaml.YAMLError, match="scalar"):
            load_yaml_config(str(parent))


# =========================================================================
# !include — nested includes
# =========================================================================


class TestIncludeNested:
    """Tests for nested and multi-level includes."""

    def test_two_levels(self, tmp_path):
        _write(tmp_path / "c.yml", "deep: true\n")
        _write(tmp_path / "b.yml", "mid: !include c.yml\n")
        parent = _write(tmp_path / "a.yml", "top: !include b.yml\n")
        data = load_yaml_config(str(parent))
        assert data["top"]["mid"]["deep"] is True

    def test_three_levels(self, tmp_path):
        _write(tmp_path / "d.yml", "val: 42\n")
        _write(tmp_path / "c.yml", "level3: !include d.yml\n")
        _write(tmp_path / "b.yml", "level2: !include c.yml\n")
        parent = _write(tmp_path / "a.yml", "level1: !include b.yml\n")
        data = load_yaml_config(str(parent))
        assert data["level1"]["level2"]["level3"]["val"] == 42

    def test_diamond_includes(self, tmp_path):
        """Two different includes both include the same leaf file (no cycle)."""
        _write(tmp_path / "leaf.yml", "shared: true\n")
        _write(tmp_path / "left.yml", "left_data: !include leaf.yml\n")
        _write(tmp_path / "right.yml", "right_data: !include leaf.yml\n")
        parent = _write(
            tmp_path / "root.yml",
            "left: !include left.yml\nright: !include right.yml\n",
        )
        data = load_yaml_config(str(parent))
        assert data["left"]["left_data"]["shared"] is True
        assert data["right"]["right_data"]["shared"] is True

    def test_fan_out_includes(self, tmp_path):
        """One file includes multiple children."""
        for i in range(5):
            _write(tmp_path / f"child{i}.yml", f"val: {i}\n")
        lines = "\n".join(f"c{i}: !include child{i}.yml" for i in range(5))
        parent = _write(tmp_path / "p.yml", lines + "\n")
        data = load_yaml_config(str(parent))
        for i in range(5):
            assert data[f"c{i}"]["val"] == i


# =========================================================================
# !include + !expand combined
# =========================================================================


class TestIncludeExpandCombined:
    """Tests for combining !include and !expand."""

    def test_include_file_with_expand(self, tmp_path):
        _write(tmp_path / "sweep.yml", "lr: !expand [0.01, 0.1]\n")
        parent = _write(
            tmp_path / "p.yml",
            "training: !include sweep.yml\nepochs: 10\n",
        )
        data = load_yaml_config(str(parent))
        assert isinstance(data["training"]["lr"], ExpandList)
        results = expand_dict(data)
        assert len(results) == 2

    def test_expand_in_parent_and_included(self, tmp_path):
        _write(tmp_path / "child.yml", "lr: !expand [0.01, 0.1]\n")
        parent = _write(
            tmp_path / "p.yml",
            "training: !include child.yml\nseed: !expand [1, 2]\n",
        )
        data = load_yaml_config(str(parent))
        results = expand_dict(data)
        assert len(results) == 4

    def test_expand_in_deeply_included_file(self, tmp_path):
        _write(tmp_path / "leaf.yml", "x: !expand [1, 2, 3]\n")
        _write(tmp_path / "mid.yml", "inner: !include leaf.yml\n")
        parent = _write(tmp_path / "root.yml", "outer: !include mid.yml\n")
        data = load_yaml_config(str(parent))
        assert isinstance(data["outer"]["inner"]["x"], ExpandList)
        results = expand_dict(data)
        assert len(results) == 3

    def test_include_and_expand_with_parse_cli_sweep(self, tmp_path):
        _write(tmp_path / "training.yml", "lr: !expand [0.01, 0.1]\nmomentum: 0.9\n")
        parent = _write(
            tmp_path / "main.yml",
            "training: !include training.yml\nseed: 42\n",
        )
        args = ["prog", "--config", str(parent)]
        with patch.object(sys, "argv", args):
            configs = parse_cli_sweep(NestedConfig)
        assert len(configs) == 2
        lrs = {c.training.lr for c in configs}
        assert lrs == {0.01, 0.1}
        assert all(c.training.momentum == 0.9 for c in configs)
        assert all(c.seed == 42 for c in configs)

    def test_include_literal_list_not_expanded(self, tmp_path):
        """Included file has a bare list → stays literal even after include."""
        _write(tmp_path / "tags.yml", "tags:\n  - a\n  - b\ncount: 5\n")
        parent = _write(tmp_path / "p.yml", "data: !include tags.yml\n")
        data = load_yaml_config(str(parent))
        assert not getattr(data["data"]["tags"], "__vidhi_expand__", False)
        results = expand_dict(data)
        assert len(results) == 1


# =========================================================================
# !include + !expand — with polymorphic configs
# =========================================================================


class CacheType:
    pass


@frozen_dataclass
class RedisCache(BasePolyConfig):
    host: str = field("localhost")
    port: int = field(6379)

    @classmethod
    def get_type(cls):
        return "redis"


@frozen_dataclass
class MemCache(BasePolyConfig):
    max_size: int = field(1000)

    @classmethod
    def get_type(cls):
        return "memcached"


@frozen_dataclass
class AppWithCache:
    name: str = field("app")
    cache: RedisCache = field(default_factory=RedisCache)


class TestIncludeWithPolymorphic:
    """Test !include with polymorphic configs."""

    def test_include_polymorphic_variant(self, tmp_path):
        _write(tmp_path / "cache.yml", "type: redis\nhost: prod.redis.io\nport: 6380\n")
        parent = _write(
            tmp_path / "app.yml",
            "name: myapp\ncache: !include cache.yml\n",
        )
        data = load_yaml_config(str(parent))
        config = create_class_from_dict(AppWithCache, data)
        assert isinstance(config.cache, RedisCache)
        assert config.cache.host == "prod.redis.io"
        assert config.cache.port == 6380


# =========================================================================
# Loader isolation
# =========================================================================


class TestLoaderIsolation:
    """Tests that the loader factory creates isolated instances."""

    def test_independent_loads_dont_share_state(self, tmp_path):
        """Two calls to load_yaml_config use independent loaders."""
        _write(tmp_path / "a.yml", "x: !expand [1, 2]\n")
        _write(tmp_path / "b.yml", "y: !expand [3, 4]\n")

        data_a = load_yaml_config(str(tmp_path / "a.yml"))
        data_b = load_yaml_config(str(tmp_path / "b.yml"))

        assert isinstance(data_a["x"], ExpandList)
        assert isinstance(data_b["y"], ExpandList)

    def test_loader_factory_returns_different_classes(self):
        L1 = _make_loader()
        L2 = _make_loader()
        assert L1 is not L2

    def test_include_stack_isolated_between_calls(self, tmp_path):
        """Include stack from one load doesn't affect another."""
        _write(tmp_path / "child.yml", "val: 1\n")
        _write(tmp_path / "p1.yml", "a: !include child.yml\n")
        _write(tmp_path / "p2.yml", "b: !include child.yml\n")

        # Both should succeed independently
        d1 = load_yaml_config(str(tmp_path / "p1.yml"))
        d2 = load_yaml_config(str(tmp_path / "p2.yml"))
        assert d1["a"]["val"] == 1
        assert d2["b"]["val"] == 1


# =========================================================================
# Edge cases
# =========================================================================


class TestEdgeCases:
    """Edge cases and unusual inputs."""

    def test_expand_with_none_element(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "x: !expand [1, null, 3]\n")
        data = load_yaml_config(str(yml))
        assert data["x"] == [1, None, 3]
        assert isinstance(data["x"], ExpandList)

    def test_expand_preserves_order(self, tmp_path):
        yml = _write(tmp_path / "c.yml", "x: !expand [c, a, b]\n")
        data = load_yaml_config(str(yml))
        assert data["x"] == ["c", "a", "b"]

    def test_json_file_no_expand_support(self, tmp_path):
        """JSON files can't use YAML tags, so no !expand."""
        f = _write(tmp_path / "c.json", '{"a": [1, 2], "b": 3}\n')
        data = load_yaml_config(str(f))
        assert data["a"] == [1, 2]
        assert not getattr(data["a"], "__vidhi_expand__", False)

    def test_include_json_file(self, tmp_path):
        """!include can reference a JSON file."""
        _write(tmp_path / "data.json", '{"key": "value"}\n')
        parent = _write(tmp_path / "p.yml", "data: !include data.json\n")
        # The !include handler opens the file with yaml.load, which can
        # parse simple JSON-like YAML. This should work for simple cases.
        data = load_yaml_config(str(parent))
        assert data["data"]["key"] == "value"

    def test_expand_with_nested_list_values(self, tmp_path):
        """!expand where each element is itself a list."""
        yml = _write(
            tmp_path / "c.yml",
            "gpus: !expand\n  - [0, 1]\n  - [2, 3]\n",
        )
        data = load_yaml_config(str(yml))
        assert isinstance(data["gpus"], ExpandList)
        assert data["gpus"] == [[0, 1], [2, 3]]
        results = expand_dict(data)
        assert len(results) == 2
        assert results[0]["gpus"] == [0, 1]
        assert results[1]["gpus"] == [2, 3]

    def test_yaml_anchors_with_expand(self, tmp_path):
        """YAML anchors work alongside !expand."""
        yml = _write(
            tmp_path / "c.yml",
            "defaults: &defaults\n  momentum: 0.9\n"
            "training:\n  <<: *defaults\n  lr: !expand [0.01, 0.1]\n",
        )
        data = load_yaml_config(str(yml))
        assert data["training"]["momentum"] == 0.9
        assert isinstance(data["training"]["lr"], ExpandList)

    def test_yaml_anchors_with_include(self, tmp_path):
        """YAML anchors in parent file work with !include."""
        _write(tmp_path / "child.yml", "extra: true\n")
        yml = _write(
            tmp_path / "p.yml",
            "defaults: &d\n  port: 8080\n"
            "server:\n  <<: *d\n  config: !include child.yml\n",
        )
        data = load_yaml_config(str(yml))
        assert data["server"]["port"] == 8080
        assert data["server"]["config"]["extra"] is True

    def test_large_expand_list(self, tmp_path):
        """!expand with many elements."""
        values = ", ".join(str(i) for i in range(50))
        yml = _write(tmp_path / "c.yml", f"x: !expand [{values}]\n")
        data = load_yaml_config(str(yml))
        assert len(data["x"]) == 50
        assert isinstance(data["x"], ExpandList)

    def test_multiline_expand(self, tmp_path):
        """!expand in block sequence style."""
        yml = _write(
            tmp_path / "c.yml",
            "models: !expand\n  - gpt-4\n  - llama-7b\n  - mistral\n",
        )
        data = load_yaml_config(str(yml))
        assert data["models"] == ["gpt-4", "llama-7b", "mistral"]
        assert isinstance(data["models"], ExpandList)
