import logging
from collections import OrderedDict
from pathlib import Path

import pytest

import salt.loader
import salt.pillar
import salt.utils.cache
from tests.support.mock import MagicMock, patch


@pytest.mark.parametrize(
    "envs",
    (
        ["a", "b", "c"],
        ["c", "b", "a"],
        ["b", "a", "c"],
    ),
)
def test_pillar_envs_order(envs, temp_salt_minion, tmp_path):
    opts = temp_salt_minion.config.copy()
    # Stop using OrderedDict once we drop Py3.5 support
    opts["pillar_roots"] = OrderedDict()
    for envname in envs:
        opts["pillar_roots"][envname] = [str(tmp_path / envname)]
    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(
        opts,
        grains,
        temp_salt_minion.id,
        "base",
    )
    # The base environment is always present and as the first environment name
    assert pillar._get_envs() == ["base"] + envs


def test_pillar_get_tops_should_not_error_when_merging_strategy_is_none_and_no_pillarenv(
    temp_salt_minion,
):
    opts = temp_salt_minion.config.copy()
    opts["pillarenv"] = None
    opts["pillar_source_merging_strategy"] = "none"
    pillar = salt.pillar.Pillar(
        opts=opts,
        grains=salt.loader.grains(opts),
        minion_id=temp_salt_minion.id,
        saltenv="base",
    )
    tops, errors = pillar.get_tops()
    assert not errors


def test_dynamic_pillarenv():
    opts = {
        "optimization_order": [0, 1, 2],
        "renderer": "json",
        "renderer_blacklist": [],
        "renderer_whitelist": [],
        "state_top": "",
        "pillar_roots": {
            "__env__": ["/srv/pillar/__env__"],
            "base": ["/srv/pillar/base"],
            "test": ["/srv/pillar/__env__"],
        },
        "file_roots": {"base": ["/srv/salt/base"], "__env__": ["/srv/salt/__env__"]},
        "extension_modules": "",
        "fileserver_backend": "roots",
        "cachedir": "",
    }
    pillar = salt.pillar.Pillar(opts, {}, "mocked-minion", "base", pillarenv="dev")
    assert pillar.opts["pillar_roots"] == {
        "base": ["/srv/pillar/base"],
        "dev": ["/srv/pillar/dev"],
        "test": ["/srv/pillar/__env__"],
    }


def test_ignored_dynamic_pillarenv():
    opts = {
        "optimization_order": [0, 1, 2],
        "renderer": "json",
        "renderer_blacklist": [],
        "renderer_whitelist": [],
        "state_top": "",
        "pillar_roots": {
            "__env__": ["/srv/pillar/__env__"],
            "base": ["/srv/pillar/base"],
        },
        "file_roots": {"base": ["/srv/salt/base"], "dev": ["/svr/salt/dev"]},
        "extension_modules": "",
        "fileserver_backend": "roots",
        "cachedir": "",
    }
    pillar = salt.pillar.Pillar(opts, {}, "mocked-minion", "base", pillarenv="base")
    assert pillar.opts["pillar_roots"] == {"base": ["/srv/pillar/base"]}


@pytest.mark.parametrize(
    "env",
    ("base", "something-else", "cool_path_123", "__env__"),
)
def test_pillar_envs_path_substitution(env, temp_salt_minion, tmp_path):
    """
    Test pillar access to a dynamic path using __env__
    """
    opts = temp_salt_minion.config.copy()

    if env == "__env__":
        # __env__ saltenv will pass "dynamic" as saltenv and
        # expect to be routed to the "dynamic" directory
        actual_env = "dynamic"
        leaf_dir = actual_env
    else:
        # any other saltenv will pass saltenv normally and
        # expect to be routed to a static "__env__" directory
        actual_env = env
        leaf_dir = "__env__"

    expected = {actual_env: [str(tmp_path / leaf_dir)]}

    # Stop using OrderedDict once we drop Py3.5 support
    opts["pillar_roots"] = OrderedDict()
    opts["pillar_roots"][env] = [str(tmp_path / leaf_dir)]
    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(
        opts,
        grains,
        temp_salt_minion.id,
        actual_env,
    )

    # The __env__ string in the path has been substituted for the actual env
    assert pillar.opts["pillar_roots"] == expected


def test_pillar_get_cache_disk(temp_salt_minion, caplog):
    # create faked path for cache
    with pytest.helpers.temp_directory() as temp_path:
        opts = temp_salt_minion.config.copy()
        tmp_cachefile = Path(str(temp_path)) / "pillar" / f"{temp_salt_minion.id}.p"
        tmp_cachefile.parent.mkdir(parents=True)
        tmp_cachefile.touch()
        assert tmp_cachefile.exists()

        opts["pillarenv"] = None
        opts["pillar_cache"] = True
        opts["minion_data_cache"] = False
        opts["cachedir"] = str(temp_path)

        caplog.at_level(logging.DEBUG)
        pillar = salt.pillar.PillarCache(
            opts=opts,
            grains=salt.loader.grains(opts),
            minion_id=temp_salt_minion.id,
            saltenv="base",
        )
        fresh_pillar = pillar.compile_pillar()
        assert "Unpack failed: incomplete input" not in caplog.messages
        assert fresh_pillar == {}


def test_pillar_fetch_pillar_override_skipped(temp_salt_minion, caplog):
    with pytest.helpers.temp_directory() as temp_path:
        opts = temp_salt_minion.config.copy()
        tmp_cachefile = Path(str(temp_path)) / "pillar" / f"{temp_salt_minion.id}.p"
        tmp_cachefile.parent.mkdir(parents=True)
        tmp_cachefile.touch()
        assert tmp_cachefile.exists()

        opts = temp_salt_minion.config.copy()
        opts["pillarenv"] = None
        opts["pillar_cache"] = True
        opts["minion_data_cache"] = False
        opts["cachedir"] = str(temp_path)

        pillar_override = {"inline_pillar": True}

        caplog.at_level(logging.DEBUG)
        pillar = salt.pillar.PillarCache(
            opts=opts,
            grains=salt.loader.grains(opts),
            minion_id=temp_salt_minion.id,
            saltenv="base",
            pillar_override=pillar_override,
        )

        fresh_pillar = pillar.compile_pillar()
        assert "inline_pillar" in fresh_pillar

        pillar_cache = pillar.cache.fetch("pillar", f"{temp_salt_minion.id}:base")
        assert "inline_pillar" not in pillar_cache


def test_remote_pillar_timeout(temp_salt_minion, tmp_path):
    opts = temp_salt_minion.config.copy()
    opts["master_uri"] = "tcp://127.0.0.1:12323"
    grains = salt.loader.grains(opts)
    pillar = salt.pillar.RemotePillar(
        opts,
        grains,
        temp_salt_minion.id,
        "base",
    )
    mock = MagicMock()
    mock.side_effect = salt.exceptions.SaltReqTimeoutError()
    pillar.channel.crypted_transfer_decode_dictentry = mock
    msg = r"^Pillar timed out after \d{1,4} seconds$"
    with pytest.raises(salt.exceptions.SaltClientError):
        pillar.compile_pillar()


def test_ext_pillar_dunder_in_modules_in_pillar(temp_salt_minion):
    """
    Test that ext_pillar is available in __pillar__ inside execution modules
    during pillar render when using ext_pillar_first=true
    """
    opts = temp_salt_minion.config.copy()
    opts["ext_pillar_first"] = True

    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(opts, grains, temp_salt_minion.id, "base")
    # Pillar should be empty to start with
    assert pillar.functions.pack["__pillar__"] == {}

    ext_value = {"ext": "some ext value"}
    pil_value = {"pillar": "some pillar value"}
    with patch.object(pillar, "ext_pillar", return_value=(ext_value, [])):
        with patch.object(pillar, "render_pillar", return_value=(pil_value, [])):
            compiled = pillar.compile_pillar()
            assert compiled == dict(**ext_value, **pil_value)

    # Loader should pack the opts pillar dict from the ext_pillar() call
    # and the rendered pillar data
    assert pillar.functions.pack["__pillar__"] == dict(**ext_value, **pil_value)

    # Ensure a module function can access the pillar data
    assert pillar.functions["pillar.get"]("ext") == "some ext value"
    assert pillar.functions["pillar.get"]("pillar") == "some pillar value"


def test_pillar_opts_in_dunder_pillar(temp_salt_minion):
    """
    Test that pillar_opts=True correctly includes master config in __pillar__
    during the pillar rendering process.
    """
    opts = temp_salt_minion.config.copy()
    opts["pillar_opts"] = True
    # Ensure some master config is present
    opts["master_key"] = "master_secret"

    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(opts, grains, temp_salt_minion.id, "base")

    pil_value = {"pillar_key": "pillar_value"}
    with patch.object(pillar, "render_pillar", return_value=(pil_value, [])):
        compiled = pillar.compile_pillar()
        # Compiled pillar should include master opts nested under "master" when pillar_opts=True
        assert "master" in compiled
        assert compiled["master"]["master_key"] == "master_secret"
        assert compiled["pillar_key"] == "pillar_value"

    # The loader pack should also contain the master opts
    assert "master" in pillar.functions.pack["__pillar__"]
    assert (
        pillar.functions.pack["__pillar__"]["master"]["master_key"] == "master_secret"
    )
    assert pillar.functions["pillar.get"]("master:master_key") == "master_secret"


def test_ssh_merge_pillar_in_dunder_pillar(temp_salt_minion):
    """
    Test that ssh_merge_pillar correctly merges extra pillar data from opts["pillar"] into __pillar__
    """
    opts = temp_salt_minion.config.copy()
    opts["ssh_merge_pillar"] = True
    opts["pillar"] = {"ssh_key": "ssh_value"}

    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(opts, grains, temp_salt_minion.id, "base")

    pil_value = {"normal_key": "normal_value"}
    with patch.object(pillar, "render_pillar", return_value=(pil_value, [])):
        compiled = pillar.compile_pillar()
        assert compiled["ssh_key"] == "ssh_value"
        assert compiled["normal_key"] == "normal_value"

    # The loader pack should contain the merged SSH pillar
    assert pillar.functions.pack["__pillar__"]["ssh_key"] == "ssh_value"
    assert pillar.functions["pillar.get"]("ssh_key") == "ssh_value"


def test_decrypt_pillar_in_dunder_pillar(temp_salt_minion):
    """
    Test that __pillar__ contains decrypted values if decryption is performed.
    """
    opts = temp_salt_minion.config.copy()

    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(opts, grains, temp_salt_minion.id, "base")

    # Mock encrypted and decrypted data
    encrypted_pil = {"secret": "encrypted_value"}
    decrypted_pil = {"secret": "decrypted_value"}

    with patch.object(pillar, "render_pillar", return_value=(encrypted_pil, [])):
        # Mock the decrypt_pillar method of the Pillar class
        with patch.object(
            pillar,
            "decrypt_pillar",
            side_effect=lambda p: p.update(decrypted_pil) or [],
        ):
            compiled = pillar.compile_pillar()
            assert compiled["secret"] == "decrypted_value"

    # The loader pack should contain the decrypted pillar
    assert pillar.functions.pack["__pillar__"]["secret"] == "decrypted_value"
    assert pillar.functions["pillar.get"]("secret") == "decrypted_value"


def test_ext_pillar_after_dunder_pillar(temp_salt_minion):
    """
    Test that ext_pillar is correctly merged into __pillar__ when ext_pillar_first=False
    """
    opts = temp_salt_minion.config.copy()
    opts["ext_pillar_first"] = False

    grains = salt.loader.grains(opts)
    pillar = salt.pillar.Pillar(opts, grains, temp_salt_minion.id, "base")

    pil_value = {"normal": "value"}
    ext_value = {"ext": "value"}

    with patch.object(pillar, "render_pillar", return_value=(pil_value, [])):
        with patch.object(pillar, "ext_pillar", return_value=(ext_value, [])):
            compiled = pillar.compile_pillar()
            # Verify both are present in the final result
            assert compiled["normal"] == "value"
            assert compiled["ext"] == "value"

    # Loader should have both
    assert pillar.functions.pack["__pillar__"]["normal"] == "value"
    assert pillar.functions.pack["__pillar__"]["ext"] == "value"
    assert pillar.functions["pillar.get"]("ext") == "value"
    assert pillar.functions["pillar.get"]("normal") == "value"
