# Copyright (c) 2023-2025 Arista Networks, Inc.
# Use of this source code is governed by the Apache License 2.0
# that can be found in the LICENSE file.
"""Tests for anta.cli.get.commands."""

from __future__ import annotations

import filecmp
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import ANY, patch

import pytest
import requests
from cvprac.cvp_client_errors import CvpApiError

from anta.cli._main import anta
from anta.cli.utils import ExitCode

if TYPE_CHECKING:
    from click.testing import CliRunner
    from cvprac.cvp_client import CvpClient

DATA_DIR: Path = Path(__file__).parents[3].resolve() / "data"


@pytest.mark.parametrize(
    ("cvp_container", "verify_cert", "cv_token_failure", "cvp_connect_failure"),
    [
        pytest.param(None, True, False, False, id="all devices - verify cert"),
        pytest.param(None, True, True, False, id="all devices - fail SSL check"),
        pytest.param(None, False, False, False, id="all devices - do not verify cert"),
        pytest.param("custom_container", False, False, False, id="custom container"),
        pytest.param(None, False, False, True, id="cvp connect failure"),
    ],
)
def test_from_cvp(
    tmp_path: Path,
    click_runner: CliRunner,
    cvp_container: str | None,
    verify_cert: bool,
    cv_token_failure: bool,
    cvp_connect_failure: bool,
) -> None:
    # ruff: noqa: C901
    """Test `anta get from-cvp`.

    This test verifies that username and password are NOT mandatory to run this command
    """
    output: Path = tmp_path / "output.yml"
    cli_args = [
        "get",
        "from-cvp",
        "--output",
        str(output),
        "--host",
        "42.42.42.42",
        "--username",
        "anta",
        "--password",
        "anta",
    ]

    if cvp_container is not None:
        cli_args.extend(["--container", cvp_container])
    if not verify_cert:
        cli_args.extend(["--ignore-cert"])

    def mock_get_cv_token(*_args: str, **_kwargs: str) -> None:
        if cv_token_failure:
            raise requests.exceptions.SSLError

    def mock_cvp_connect(_self: CvpClient, *_args: str, **_kwargs: str) -> None:
        if cvp_connect_failure:
            raise CvpApiError(msg="mocked CvpApiError")

    # always get a token
    with (
        patch("anta.cli.get.commands.get_cv_token", autospec=True, side_effect=mock_get_cv_token),
        patch(
            "cvprac.cvp_client.CvpClient.connect",
            autospec=True,
            side_effect=mock_cvp_connect,
        ) as mocked_cvp_connect,
        patch("cvprac.cvp_client.CvpApi.get_inventory", autospec=True, return_value=[]) as mocked_get_inventory,
        patch(
            "cvprac.cvp_client.CvpApi.get_devices_in_container",
            autospec=True,
            return_value=[],
        ) as mocked_get_devices_in_container,
    ):
        result = click_runner.invoke(anta, cli_args)

    if not cvp_connect_failure and not cv_token_failure:
        assert output.exists()

    if cv_token_failure:
        assert "Authentication to CloudVison failed" in result.output
        assert result.exit_code == ExitCode.USAGE_ERROR
        return

    mocked_cvp_connect.assert_called_once()

    if cvp_connect_failure:
        assert "Error connecting to CloudVision" in result.output
        assert result.exit_code == ExitCode.USAGE_ERROR
        return

    assert "Connected to CloudVision" in result.output
    if cvp_container is not None:
        mocked_get_devices_in_container.assert_called_once_with(ANY, cvp_container)
    else:
        mocked_get_inventory.assert_called_once()
    assert result.exit_code == ExitCode.OK


def test_from_cvp_os_error(tmp_path: Path, click_runner: CliRunner, caplog: pytest.LogCaptureFixture) -> None:
    """Test from_cvp when an OSError occurs."""
    output: Path = tmp_path / "output.yml"
    cli_args = ["get", "from-cvp", "--output", str(output), "--host", "42.42.42.42", "--username", "anta", "--password", "anta"]

    with (
        patch("anta.cli.get.commands.get_cv_token", autospec=True, side_effect=None),
        patch("cvprac.cvp_client.CvpClient.connect", autospec=True, side_effect=None) as mocked_cvp_connect,
        patch("cvprac.cvp_client.CvpApi.get_inventory", autospec=True, return_value=[]) as mocked_get_inventory,
        patch("cvprac.cvp_client.CvpApi.get_devices_in_container", autospec=True, return_value=[]),
        patch("anta.cli.get.utils.Path.open", side_effect=OSError("Permission denied")),
    ):
        result = click_runner.invoke(anta, cli_args)

    mocked_cvp_connect.assert_called_once()
    mocked_get_inventory.assert_called_once()
    assert not output.exists()
    assert "Could not write inventory to path" in caplog.text
    assert result.exit_code == ExitCode.USAGE_ERROR


@pytest.mark.parametrize(
    ("ansible_inventory", "ansible_group", "expected_exit", "expected_log"),
    [
        pytest.param("ansible_inventory.yml", None, ExitCode.OK, None, id="no group"),
        pytest.param("ansible_inventory.yml", "ATD_LEAFS", ExitCode.OK, None, id="group found"),
        pytest.param(
            "ansible_inventory.yml",
            "DUMMY",
            ExitCode.USAGE_ERROR,
            "Group DUMMY not found in Ansible inventory",
            id="group not found",
        ),
        pytest.param(
            "empty_ansible_inventory.yml",
            None,
            ExitCode.USAGE_ERROR,
            "is empty",
            id="empty inventory",
        ),
    ],
)
def test_from_ansible(
    tmp_path: Path,
    click_runner: CliRunner,
    ansible_inventory: Path,
    ansible_group: str | None,
    expected_exit: int,
    expected_log: str | None,
) -> None:
    """Test `anta get from-ansible`.

    This test verifies:
    * the parsing of an ansible-inventory
    * the ansible_group functionaliy

    The output path is ALWAYS set to a non existing file.
    """
    output: Path = tmp_path / "output.yml"
    ansible_inventory_path = DATA_DIR / ansible_inventory
    # Init cli_args
    cli_args = [
        "get",
        "from-ansible",
        "--output",
        str(output),
        "--ansible-inventory",
        str(ansible_inventory_path),
    ]

    # Set --ansible-group
    if ansible_group is not None:
        cli_args.extend(["--ansible-group", ansible_group])

    result = click_runner.invoke(anta, cli_args)

    assert result.exit_code == expected_exit

    if expected_exit != ExitCode.OK:
        assert expected_log
        assert expected_log in result.output
    else:
        assert output.exists()
        # TODO: check size of generated inventory to validate the group functionality!


@pytest.mark.parametrize(
    ("env_set", "overwrite", "is_tty", "prompt", "expected_exit", "expected_log"),
    [
        pytest.param(
            True,
            False,
            True,
            "y",
            ExitCode.OK,
            "",
            id="no-overwrite-tty-init-prompt-yes",
        ),
        pytest.param(
            True,
            False,
            True,
            "N",
            ExitCode.INTERNAL_ERROR,
            "Aborted",
            id="no-overwrite-tty-init-prompt-no",
        ),
        pytest.param(
            True,
            False,
            False,
            None,
            ExitCode.USAGE_ERROR,
            "Conversion aborted since destination file is not empty (not running in interactive TTY)",
            id="no-overwrite-no-tty-init",
        ),
        pytest.param(False, False, True, None, ExitCode.OK, "", id="no-overwrite-tty-no-init"),
        pytest.param(False, False, False, None, ExitCode.OK, "", id="no-overwrite-no-tty-no-init"),
        pytest.param(True, True, True, None, ExitCode.OK, "", id="overwrite-tty-init"),
        pytest.param(True, True, False, None, ExitCode.OK, "", id="overwrite-no-tty-init"),
        pytest.param(False, True, True, None, ExitCode.OK, "", id="overwrite-tty-no-init"),
        pytest.param(False, True, False, None, ExitCode.OK, "", id="overwrite-no-tty-no-init"),
    ],
)
def test_from_ansible_overwrite(
    tmp_path: Path,
    click_runner: CliRunner,
    temp_env: dict[str, str | None],
    env_set: bool,
    overwrite: bool,
    is_tty: bool,
    prompt: str | None,
    expected_exit: int,
    expected_log: str | None,
) -> None:
    """Test `anta get from-ansible` overwrite mechanism.

    The test uses a static ansible-inventory and output as these are tested in other functions

    This test verifies:
    * that overwrite is working as expected with or without init data in the target file
    * that when the target file is not empty and a tty is present, the user is prompt with confirmation
    * Check the behavior when the prompt is filled

    The initial content of the ANTA inventory is set using init_anta_inventory, if it is None, no inventory is set.

    * With overwrite True, the expectation is that the from-ansible command succeeds
    * With no init (init_anta_inventory == None), the expectation is also that command succeeds
    """
    ansible_inventory_path = DATA_DIR / "ansible_inventory.yml"
    expected_anta_inventory_path = DATA_DIR / "expected_anta_inventory.yml"
    tmp_output = tmp_path / "output.yml"
    cli_args = [
        "get",
        "from-ansible",
        "--ansible-inventory",
        str(ansible_inventory_path),
    ]

    if env_set:
        tmp_inv = Path(str(temp_env["ANTA_INVENTORY"]))
    else:
        temp_env["ANTA_INVENTORY"] = None
        tmp_inv = tmp_output
        cli_args.extend(["--output", str(tmp_inv)])
    if overwrite:
        cli_args.append("--overwrite")

    # Verify initial content is different
    if tmp_inv.exists():
        assert not filecmp.cmp(tmp_inv, expected_anta_inventory_path)

    with patch("sys.stdin.isatty", return_value=is_tty):
        result = click_runner.invoke(anta, cli_args, env=temp_env, input=prompt)

    assert result.exit_code == expected_exit
    if expected_exit == ExitCode.OK:
        assert filecmp.cmp(tmp_inv, expected_anta_inventory_path)
    elif expected_exit == ExitCode.INTERNAL_ERROR:
        assert expected_log
        assert expected_log in result.output


@pytest.mark.parametrize(
    ("module", "test_name", "short", "count", "expected_output", "expected_exit_code"),
    [
        pytest.param(
            None,
            None,
            False,
            False,
            "VerifyAcctConsoleMethods",
            ExitCode.OK,
            id="Get all tests",
        ),
        pytest.param(
            "anta.tests.aaa",
            None,
            False,
            False,
            "VerifyAcctConsoleMethods",
            ExitCode.OK,
            id="Get tests, filter on module",
        ),
        pytest.param(
            None,
            "VerifyNTPAssociations",
            False,
            False,
            "VerifyNTPAssociations",
            ExitCode.OK,
            id="Get tests, filter on exact test name",
        ),
        pytest.param(
            None,
            "VerifyNTP",
            False,
            False,
            "anta.tests.system",
            ExitCode.OK,
            id="Get tests, filter on included test name",
        ),
        pytest.param(
            None,
            "VerifyNTP",
            True,
            False,
            "VerifyNTPAssociations",
            ExitCode.OK,
            id="Get tests --short",
        ),
        pytest.param(
            "unknown_module",
            None,
            True,
            False,
            "Module `unknown_module` was not found!",
            ExitCode.USAGE_ERROR,
            id="Get tests wrong module",
        ),
        pytest.param(
            "unknown_module.unknown",
            None,
            True,
            False,
            "Module `unknown_module.unknown` was not found!",
            ExitCode.USAGE_ERROR,
            id="Get tests wrong submodule",
        ),
        pytest.param(
            ".unknown_module",
            None,
            True,
            False,
            "`--module <module>` option does not support relative imports",
            ExitCode.USAGE_ERROR,
            id="Use relative module name",
        ),
        pytest.param(
            None,
            "VerifySomething",
            True,
            False,
            "No test 'VerifySomething' found in 'anta.tests'",
            ExitCode.OK,
            id="Get tests wrong test name",
        ),
        pytest.param(
            "anta.tests.aaa",
            "VerifyNTP",
            True,
            False,
            "No test 'VerifyNTP' found in 'anta.tests.aaa'",
            ExitCode.OK,
            id="Get tests test exists but not in module",
        ),
        pytest.param(
            "anta.tests.system",
            "VerifyNTPAssociations",
            False,
            True,
            "There is 1 test available in 'anta.tests.system'.",
            ExitCode.OK,
            id="Get single test count",
        ),
        pytest.param(
            "anta.tests.stun",
            None,
            False,
            True,
            "There are 3 tests available in 'anta.tests.stun'",
            ExitCode.OK,
            id="Get multiple test count",
        ),
    ],
)
def test_get_tests(
    click_runner: CliRunner, module: str | None, test_name: str | None, *, short: bool, count: bool, expected_output: str, expected_exit_code: str
) -> None:
    """Test `anta get tests`."""
    cli_args = [
        "get",
        "tests",
    ]
    if module is not None:
        cli_args.extend(["--module", module])

    if test_name is not None:
        cli_args.extend(["--test", test_name])

    if short:
        cli_args.append("--short")

    if count:
        cli_args.append("--count")

    result = click_runner.invoke(anta, cli_args)

    assert result.exit_code == expected_exit_code
    assert expected_output in result.output


def test_get_tests_local_module(click_runner: CliRunner) -> None:
    """Test injecting CWD in sys.

    The test overwrite CWD to return this file parents and local_module is located there.
    """
    cli_args = ["get", "tests", "--module", "local_module"]

    cwd = Path.cwd()
    local_module_parent_path = Path(__file__).parent
    with patch("anta.cli.get.utils.Path.cwd", return_value=local_module_parent_path):
        result = click_runner.invoke(anta, cli_args)

    assert result.exit_code == ExitCode.OK

    # In the rare case where people would be running `pytest .` in this directory
    if cwd != local_module_parent_path:
        assert "injecting CWD in PYTHONPATH and retrying..." in result.output
    assert "No test found in 'local_module'" in result.output


@pytest.mark.parametrize(
    ("module", "test_name", "catalog", "expected_output", "expected_exit_code"),
    [
        pytest.param(
            None,
            None,
            None,
            "VerifyAcctConsoleMethods",
            ExitCode.OK,
            id="Get all commands",
        ),
        pytest.param(
            "anta.tests.aaa",
            None,
            None,
            "VerifyAcctConsoleMethods",
            ExitCode.OK,
            id="Get commands, filter on module",
        ),
        pytest.param(
            None,
            "VerifyNTPAssociations",
            None,
            "VerifyNTPAssociations",
            ExitCode.OK,
            id="Get commands, filter on exact test name",
        ),
        pytest.param(
            None,
            "VerifyNTP",
            None,
            "anta.tests.system",
            ExitCode.OK,
            id="Get commands, filter on included test name",
        ),
        pytest.param(
            "unknown_module",
            None,
            None,
            "Module `unknown_module` was not found!",
            ExitCode.USAGE_ERROR,
            id="Get commands wrong module",
        ),
        pytest.param(
            "unknown_module.unknown",
            None,
            None,
            "Module `unknown_module.unknown` was not found!",
            ExitCode.USAGE_ERROR,
            id="Get commands wrong submodule",
        ),
        pytest.param(
            ".unknown_module",
            None,
            None,
            "`--module <module>` option does not support relative imports",
            ExitCode.USAGE_ERROR,
            id="Use relative module name",
        ),
        pytest.param(
            None,
            "VerifySomething",
            None,
            "No test 'VerifySomething' found in 'anta.tests'",
            ExitCode.OK,
            id="Get commands wrong test name",
        ),
        pytest.param(
            "anta.tests.aaa",
            "VerifyNTP",
            None,
            "No test 'VerifyNTP' found in 'anta.tests.aaa'",
            ExitCode.OK,
            id="Get commands test exists but not in module",
        ),
        pytest.param(
            None,
            None,
            DATA_DIR / "test_catalog.yml",
            "VerifyEOSVersion",
            ExitCode.OK,
            id="Get all commands from catalog",
        ),
        pytest.param(
            "anta.tests.aaa",
            None,
            DATA_DIR / "test_catalog.yml",
            "No test found in 'anta.tests.aaa' for catalog '",  # partial match as no test from aaa in the catalog
            ExitCode.OK,
            id="Get all commands from module in catalog",
        ),
        pytest.param(
            None,
            None,
            DATA_DIR / "non_existing_catalog.yml",
            "Invalid value for '--catalog'",
            ExitCode.USAGE_ERROR,
            id="Catalog does not exist",
        ),
        # TODO: catalog format JSON
    ],
)
def test_get_commands(
    click_runner: CliRunner, module: str | None, test_name: str | None, catalog: str | None, expected_output: str, expected_exit_code: str
) -> None:
    """Test `anta get commands`."""
    cli_args = [
        "get",
        "commands",
    ]
    if module is not None:
        cli_args.extend(["--module", module])

    if test_name is not None:
        cli_args.extend(["--test", test_name])

    if catalog is not None:
        cli_args.extend(["--catalog", catalog])

    # Make sure to disable any ANTA_CATALOG env that could be set and pollute the test
    result = click_runner.invoke(anta, cli_args, env={"ANTA_CATALOG": None})

    assert result.exit_code == expected_exit_code
    assert expected_output in result.output


@pytest.mark.parametrize(
    ("module", "test_name", "catalog", "expected_count"),
    [
        pytest.param(
            "anta.tests.aaa",
            None,
            None,
            4,
            id="Get unique commands, filter on module",
        ),
        pytest.param(
            None,
            "VerifyNTPAssociations",
            None,
            1,
            id="Get unique commands, filter on exact test name",
        ),
        pytest.param(
            None,
            "VerifyNTP",
            None,
            2,
            id="Get unique commands, filter on included test name",
        ),
        pytest.param(
            None,
            None,
            DATA_DIR / "test_catalog.yml",
            1,
            id="Get all unique commands from catalog",
        ),
    ],
)
def test_get_commands_unique(click_runner: CliRunner, module: str | None, test_name: str | None, catalog: str | None, expected_count: int) -> None:
    """Test `anta get commands`."""
    cli_args = [
        "get",
        "commands",
    ]
    if module is not None:
        cli_args.extend(["--module", module])

    if test_name is not None:
        cli_args.extend(["--test", test_name])

    if catalog is not None:
        cli_args.extend(["--catalog", catalog])

    cli_args.extend(["--unique"])

    # Make sure to disable any ANTA_CATALOG env that could be set and pollute the test
    result = click_runner.invoke(anta, cli_args, env={"ANTA_CATALOG": None})

    assert expected_count == len(result.output.splitlines())
