import copy
import logging
import os
import pathlib
import pprint
import re
import shutil
import sys

import pytest
from saltfactories.utils import random_string

import salt.defaults.exitcodes
import salt.utils.files
import salt.utils.json
import salt.utils.platform
import salt.utils.yaml
import tests.conftest
import tests.support.helpers
from tests.conftest import FIPS_TESTRUN
from tests.support.runtests import RUNTIME_VARS

pytestmark = [
    pytest.mark.core_test,
    pytest.mark.windows_whitelisted,
]

log = logging.getLogger(__name__)


def test_fib(salt_call_cli):
    ret = salt_call_cli.run("test.fib", "3")
    assert ret.returncode == 0
    assert ret.data[0] == 2


def test_fib_txt_output(salt_call_cli):
    ret = salt_call_cli.run("--output=txt", "test.fib", "3")
    assert ret.returncode == 0
    assert ret.data is None
    assert (
        re.match(r"local: \(2, [0-9]{1}\.(([0-9]+)(e-([0-9]+))?)\)\s", ret.stdout)
        is not None
    )


@pytest.mark.parametrize("indent", [-1, 0, 1])
def test_json_out_indent(salt_call_cli, indent):
    ret = salt_call_cli.run("--out=json", f"--out-indent={indent}", "test.ping")
    assert ret.returncode == 0
    assert ret.data is True
    if indent == -1:
        expected_output = '{"local": true}\n'
    elif indent == 0:
        expected_output = '{\n"local": true\n}\n'
    else:
        expected_output = '{\n "local": true\n}\n'
    stdout = ret.stdout
    assert ret.stdout == expected_output


def test_local_sls_call(salt_master, salt_call_cli):
    sls_contents = """
    regular-module:
      module.run:
        - name: test.echo
        - text: hello
    """
    with salt_master.state_tree.base.temp_file("saltcalllocal.sls", sls_contents):
        ret = salt_call_cli.run(
            "--local",
            "--file-root",
            str(salt_master.state_tree.base.paths[0]),
            "state.sls",
            "saltcalllocal",
        )
        assert ret.returncode == 0
        state_run_dict = next(iter(ret.data.values()))
        assert state_run_dict["name"] == "test.echo"
        assert state_run_dict["result"] is True
        assert state_run_dict["changes"]["ret"] == "hello"


def test_local_salt_call(salt_call_cli):
    """
    This tests to make sure that salt-call does not execute the
    function twice, see https://github.com/saltstack/salt/pull/49552
    """
    with pytest.helpers.temp_file() as filename:

        ret = salt_call_cli.run(
            "--local", "state.single", "file.append", name=str(filename), text="foo"
        )
        assert ret.returncode == 0

        state_run_dict = next(iter(ret.data.values()))
        assert state_run_dict["changes"]

        # 2nd sanity check: make sure that "foo" only exists once in the file
        contents = filename.read_text()
        assert contents.count("foo") == 1, contents


@pytest.mark.skip_on_windows(reason=tests.support.helpers.PRE_PYTEST_SKIP_REASON)
def test_user_delete_kw_output(salt_call_cli):
    ret = salt_call_cli.run("-d", "user.delete", _timeout=120)
    assert ret.returncode == 0
    expected_output = "salt '*' user.delete name"
    if not salt.utils.platform.is_windows():
        expected_output += " remove=True force=True"
    assert expected_output in ret.stdout


def test_salt_documentation_too_many_arguments(salt_call_cli):
    """
    Test to see if passing additional arguments shows an error
    """
    ret = salt_call_cli.run("-d", "virtualenv.create", "/tmp/ve")
    assert ret.returncode != 0
    assert "You can only get documentation for one method at one time" in ret.stderr


def test_issue_6973_state_highstate_exit_code(salt_call_cli):
    """
    If there is no tops/master_tops or state file matches
    for this minion, salt-call should exit non-zero if invoked with
    option --retcode-passthrough
    """
    expected_comment = "No states found for this minion"
    ret = salt_call_cli.run("--retcode-passthrough", "state.highstate")
    assert ret.returncode != 0
    assert expected_comment in ret.stdout


@tests.support.helpers.PRE_PYTEST_SKIP
def test_issue_15074_output_file_append(salt_call_cli):

    with pytest.helpers.temp_file(name="issue-15074") as output_file_append:
        ret = salt_call_cli.run(
            "--output-file", str(output_file_append), "test.versions"
        )
        assert ret.returncode == 0

        first_run_output = output_file_append.read_text()

        assert first_run_output

        ret = salt_call_cli.run(
            "--output-file",
            str(output_file_append),
            "--output-file-append",
            "test.versions",
        )
        assert ret.returncode == 0

        second_run_output = output_file_append.read_text()

        assert second_run_output

        assert second_run_output == first_run_output + first_run_output


@tests.support.helpers.PRE_PYTEST_SKIP
def test_issue_14979_output_file_permissions(salt_call_cli):
    with pytest.helpers.temp_file(name="issue-14979") as output_file:
        with salt.utils.files.set_umask(0o077):
            # Let's create an initial output file with some data
            ret = salt_call_cli.run("--output-file", str(output_file), "--grains")
            assert ret.returncode == 0
            try:
                stat1 = output_file.stat()
            except OSError:
                pytest.fail(f"Failed to generate output file {output_file}")

            # Let's change umask
            os.umask(0o777)  # pylint: disable=blacklisted-function

            ret = salt_call_cli.run(
                "--output-file", str(output_file), "--output-file-append", "--grains"
            )
            assert ret.returncode == 0
            stat2 = output_file.stat()
            assert stat1.st_mode == stat2.st_mode
            # Data was appeneded to file
            assert stat1.st_size < stat2.st_size

            # Let's remove the output file
            output_file.unlink()

            # Not appending data
            ret = salt_call_cli.run("--output-file", str(output_file), "--grains")
            assert ret.returncode == 0
            try:
                stat3 = output_file.stat()
            except OSError:
                pytest.fail(f"Failed to generate output file {output_file}")
            # Mode must have changed since we're creating a new log file
            assert stat1.st_mode != stat3.st_mode


@pytest.mark.skip_on_windows(reason="This test does not apply on Win")
def test_42116_cli_pillar_override(salt_call_cli):
    ret = salt_call_cli.run(
        "state.apply",
        "issue-42116-cli-pillar-override",
        pillar={"myhost": "localhost"},
    )
    state_run_dict = next(iter(ret.data.values()))
    assert state_run_dict["changes"]
    assert (
        state_run_dict["comment"] == 'Command "ping -c 2 localhost" run'
    ), "CLI pillar override not found in pillar data. State Run Dictionary:\n{}".format(
        pprint.pformat(state_run_dict)
    )


def test_pillar_items_masterless(salt_minion, salt_call_cli):
    """
    Test to ensure we get expected output
    from pillar.items with salt-call
    """
    top_file = """
    base:
      '{}':
        - basic
    """.format(
        salt_minion.id
    )
    basic_pillar_file = """
    monty: python
    knights:
      - Lancelot
      - Galahad
      - Bedevere
      - Robin
    """
    top_tempfile = salt_minion.pillar_tree.base.temp_file("top.sls", top_file)
    basic_tempfile = salt_minion.pillar_tree.base.temp_file(
        "basic.sls", basic_pillar_file
    )

    with top_tempfile, basic_tempfile:
        ret = salt_call_cli.run("--local", "pillar.items")
        assert ret.returncode == 0
        assert "knights" in ret.data
        assert sorted(ret.data["knights"]) == sorted(
            ["Lancelot", "Galahad", "Bedevere", "Robin"]
        )
        assert "monty" in ret.data
        assert ret.data["monty"] == "python"


def test_masterless_highstate(salt_minion, salt_call_cli, tmp_path):
    """
    test state.highstate in masterless mode
    """
    top_sls = """
    base:
      '*':
        - core
        """

    testfile = tmp_path / "testfile"
    core_state = """
    {}:
      file:
        - managed
        - source: salt://testfile
        - makedirs: true
        """.format(
        testfile
    )

    expected_id = str(testfile)

    with salt_minion.state_tree.base.temp_file(
        "top.sls", top_sls
    ), salt_minion.state_tree.base.temp_file("core.sls", core_state):
        ret = salt_call_cli.run("--local", "state.highstate")
        assert ret.returncode == 0
        state_run_dict = next(iter(ret.data.values()))
        assert state_run_dict["result"] is True
        assert state_run_dict["__id__"] == expected_id


@pytest.mark.skip_on_windows
def test_syslog_file_not_found(salt_minion, salt_call_cli, tmp_path):
    """
    test when log_file is set to a syslog file that does not exist
    """
    config_dir = tmp_path / "log_file_incorrect"
    config_dir.mkdir()
    with pytest.helpers.change_cwd(str(config_dir)):
        minion_config = copy.deepcopy(salt_minion.config)
        minion_config["log_file"] = "file:///dev/doesnotexist"
        with salt.utils.files.fopen(str(config_dir / "minion"), "w") as fh_:
            fh_.write(salt.utils.yaml.dump(minion_config, default_flow_style=False))
        ret = salt_call_cli.run(
            f"--config-dir={config_dir}",
            "--log-level=debug",
            "cmd.run",
            "echo foo",
        )
        if sys.version_info >= (3, 5, 4):
            assert ret.returncode == 0
            assert (
                "[WARNING ] The log_file does not exist. Logging not setup correctly or"
                " syslog service not started." in ret.stderr
            )
            assert ret.data == "foo", ret
        else:
            assert ret.returncode == salt.defaults.exitcodes.EX_UNAVAILABLE
            assert "Failed to setup the Syslog logging handler" in ret.stderr


@tests.support.helpers.PRE_PYTEST_SKIP
@pytest.mark.skip_on_windows
def test_return(salt_call_cli, salt_run_cli):
    command = "echo returnTOmaster"
    ret = salt_call_cli.run("cmd.run", command)
    assert ret.returncode == 0
    assert ret.data == "returnTOmaster"

    ret = salt_run_cli.run("jobs.list_jobs")
    assert ret.returncode == 0
    jid = target = None
    for jid, details in ret.data.items():
        if command in details["Arguments"]:
            target = details["Target"]
            break

    ret = salt_run_cli.run("jobs.lookup_jid", jid, _timeout=60)
    assert ret.returncode == 0
    assert target in ret.data
    assert ret.data[target] == "returnTOmaster"


def test_exit_status_unknown_argument(salt_call_cli):
    """
    Ensure correct exit status when an unknown argument is passed to salt CLI.
    """
    ret = salt_call_cli.run("--unknown-argument")
    assert ret.returncode == salt.defaults.exitcodes.EX_USAGE, ret
    assert "Usage" in ret.stderr
    assert "no such option: --unknown-argument" in ret.stderr


def test_exit_status_correct_usage(salt_call_cli):
    """
    Ensure correct exit status when salt CLI starts correctly.

    """
    ret = salt_call_cli.run("test.true")
    assert ret.returncode == salt.defaults.exitcodes.EX_OK, ret


def test_context_retcode_salt_call(salt_call_cli):
    """
    Test that a nonzero retcode set in the context dunder will cause the
    salt CLI to set a nonzero retcode.
    """
    # Test salt-call, making sure to also confirm the behavior of
    # retcode_passthrough.
    ret = salt_call_cli.run("test.retcode", "0")
    assert ret.returncode == 0, ret
    ret = salt_call_cli.run("test.retcode", "42")
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret
    ret = salt_call_cli.run("--retcode-passthrough", "test.retcode", "42")
    assert ret.returncode == 42, ret

    # Test a state run that exits with one or more failures
    ret = salt_call_cli.run("state.single", "test.fail_without_changes", "foo")
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret
    ret = salt_call_cli.run(
        "--retcode-passthrough", "state.single", "test.fail_without_changes", "foo"
    )
    assert ret.returncode == salt.defaults.exitcodes.EX_STATE_FAILURE, ret

    # Test a state compiler error
    ret = salt_call_cli.run("state.apply", "thisslsfiledoesnotexist")
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret
    ret = salt_call_cli.run(
        "--retcode-passthrough", "state.apply", "thisslsfiledoesnotexist"
    )
    assert ret.returncode == salt.defaults.exitcodes.EX_STATE_COMPILER_ERROR, ret


def test_salt_call_error(salt_call_cli):
    """
    Test that we return the expected retcode when a minion function raises
    an exception.
    """
    ret = salt_call_cli.run("test.raise_exception", "TypeError")
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret

    ret = salt_call_cli.run(
        "test.raise_exception", "salt.exceptions.CommandNotFoundError"
    )
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret

    ret = salt_call_cli.run(
        "test.raise_exception", "salt.exceptions.CommandExecutionError"
    )
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret

    ret = salt_call_cli.run(
        "test.raise_exception", "salt.exceptions.SaltInvocationError"
    )
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret

    ret = salt_call_cli.run(
        "test.raise_exception",
        "OSError",
        "2",
        "No such file or directory",
        "/tmp/foo.txt",
    )
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret

    ret = salt_call_cli.run("test.echo", "{foo: bar, result: False}")
    assert ret.returncode == salt.defaults.exitcodes.EX_GENERIC, ret


def test_local_salt_call_no_function_no_retcode(salt_call_cli):
    """
    This tests ensures that when salt-call --local is called
    with a module but without a function the return code is 1
    and we receive the docs for all module functions.

    Also ensure we don't get an exception.
    """
    ret = salt_call_cli.run("--local", "test")
    assert ret.returncode == 1
    assert ret.data
    assert "test" in ret.data
    assert ret.data["test"] == "'test' is not available."
    assert "test.echo" in ret.data


@pytest.fixture
def master_id_alt():
    master_id = random_string("master-")
    yield master_id


@pytest.fixture
def minion_id_alt():
    master_id = random_string("minion-")
    yield master_id


@pytest.fixture
def salt_master_alt(salt_factories, tmp_path, master_id_alt):
    """
    A running salt-master fixture
    """
    root_dir = salt_factories.get_root_dir_for_daemon(master_id_alt)
    conf_dir = root_dir / "conf"
    conf_dir.mkdir(exist_ok=True)
    extension_modules_path = str(root_dir / "extension_modules")
    if not os.path.exists(extension_modules_path):
        shutil.copytree(
            os.path.join(RUNTIME_VARS.FILES, "extension_modules"),
            extension_modules_path,
        )
    cache = pathlib.Path(extension_modules_path) / "cache"
    cache.mkdir()
    localfs = cache / "localfs.py"
    localfs.write_text(
        tests.support.helpers.dedent(
            """
        from salt.exceptions import SaltClientError
        def store(bank, key, data): # , cachedir):
            raise SaltClientError("TEST")
        """
        )
    )
    factory = salt_factories.salt_master_daemon(
        master_id_alt,
        defaults={
            "root_dir": str(root_dir),
            "extension_modules": extension_modules_path,
            "auto_accept": True,
        },
        overrides={
            "fips_mode": FIPS_TESTRUN,
            "publish_signing_algorithm": (
                "PKCS1v15-SHA224" if FIPS_TESTRUN else "PKCS1v15-SHA1"
            ),
        },
    )
    with factory.pillar_tree.base.temp_file("cve_2024_37088.sls", "foobar: bang"):
        with factory.state_tree.base.temp_file(
            "cve_2024_37088.sls",
            """
            # cvs_2024_37088.sls
            {{%- set var = salt ['pillar.get']('foobar', 'state default') %}}

            test:
              file.managed:
                - name: {0}
                - contents: {{{{ var }}}}
            """.format(
                tmp_path / "cve_2024_37088.txt"
            ),
        ):
            with factory.started():
                yield factory


@pytest.fixture
def salt_call_alt(salt_master_alt, minion_id_alt):
    minion_factory = salt_master_alt.salt_minion_daemon(
        minion_id_alt,
        overrides={
            "fips_mode": tests.conftest.FIPS_TESTRUN,
            "encryption_algorithm": (
                "OAEP-SHA224" if tests.conftest.FIPS_TESTRUN else "OAEP-SHA1"
            ),
            "signing_algorithm": (
                "PKCS1v15-SHA224" if tests.conftest.FIPS_TESTRUN else "PKCS1v15-SHA1"
            ),
        },
    )
    return minion_factory.salt_call_cli()


def test_cve_2024_37088(salt_master_alt, salt_call_alt, caplog):
    with caplog.at_level(logging.ERROR):
        ret = salt_call_alt.run("state.sls", "cve_2024_37088")
        assert ret.returncode == 1
        assert ret.data is None
        assert "Got a bad pillar from master, type str, expecting dict" in caplog.text
