"""Tests for the cli module.
"""

import argparse

from datetime import timedelta
from pathlib import Path
from textwrap import dedent
from typing import List, NamedTuple

import hypothesis.strategies as st  # type: ignore
import pytest

from freezegun import freeze_time
from hypothesis import given  # type: ignore

import mutatest.cli

from mutatest import cli
from mutatest.cli import RunMode, SurvivingMutantException, TrialTimes


@pytest.fixture(scope="module")
def mock_TrialTimes():
    """Mock Trial Time fixture for the CLI."""
    return TrialTimes(
        clean_trial_1=timedelta(days=0, seconds=6, microseconds=0),
        clean_trial_2=timedelta(days=0, seconds=6, microseconds=0),
        mutation_trials=timedelta(days=0, seconds=6, microseconds=0),
    )


@pytest.mark.parametrize(
    "mode, bod, bos, boe, bou",
    [
        ("f", False, False, True, True),
        ("s", False, True, True, True),
        ("d", True, False, True, True),
        ("sd", True, True, True, True),
        ("x", False, False, True, True),  # invalid entry defaults to same as 'f'
    ],
)
def test_RunMode(mode, bod, bos, boe, bou):
    """Various run mode configurations based onv v0.1.0 settings."""
    result = RunMode(mode)

    assert result.break_on_detection == bod
    assert result.break_on_survival == bos
    assert result.break_on_error == boe
    assert result.break_on_unknown == bou


def test_get_src_location_pkg(monkeypatch):
    """Mock a multiple package scenario, only the first one is used."""

    def mock_find_packages(*args, **kwargs):
        return ["srcdir", "secondsrcdir"]

    # because I use: from setuptools import find_packages
    # therefore the mock of the imported instance
    monkeypatch.setattr(mutatest.cli, "find_packages", mock_find_packages)

    result = cli.get_src_location()
    assert result.name == "srcdir"


def test_get_src_location_error(monkeypatch):
    """Mock a missing package scenario, FileNotFoundError is raised."""

    def mock_find_packages(*args, **kwargs):
        return []

    # because I use: from setuptools import find_packages
    # therefore the mock of the imported instance
    monkeypatch.setattr(mutatest.cli, "find_packages", mock_find_packages)

    with pytest.raises(FileNotFoundError):
        _ = cli.get_src_location()


def test_get_src_location_missing_file(monkeypatch):
    """If a missing file is passed an exception is raised."""

    with pytest.raises(FileNotFoundError):
        _ = cli.get_src_location(Path("/tmp/filethatdoesnotexist/sdf/asdf/23rjsdfu.py"))


def test_get_src_location_file(monkeypatch, binop_file):
    """If an existing file is passed it is returned without modification."""
    result = cli.get_src_location(binop_file)
    assert result.resolve() == binop_file.resolve()


class MockOpSet(NamedTuple):
    category: str


EXPECTED_CATEGORIES = ["a", "b", "c", "d", "e"]


@pytest.fixture
def mock_get_compatible_sets(monkeypatch):
    """Mock for compatible operations to return basic list of single letter values."""

    def mock_comp_sets(*args, **kwargs):
        categories = EXPECTED_CATEGORIES
        return [MockOpSet(c) for c in categories]

    monkeypatch.setattr(mutatest.cli.transformers, "get_compatible_operation_sets", mock_comp_sets)


def test_selected_categories_empty_lists(mock_get_compatible_sets):
    """Empty lists should be the full set."""
    result = cli.selected_categories([], [])
    assert sorted(result) == sorted(EXPECTED_CATEGORIES)


def test_selected_categories_wlist(mock_get_compatible_sets):
    """Whitelisted categories are only selections."""
    wl = ["a", "b"]
    result = cli.selected_categories(wl, [])
    assert sorted(result) == sorted(wl)


def test_selected_categories_blist(mock_get_compatible_sets):
    """Blacklisted categories are the inverse selection."""
    bl = ["a", "b", "c"]
    result = cli.selected_categories([], bl)
    assert sorted(result) == sorted(["d", "e"])


def test_selected_categories_wblist(mock_get_compatible_sets):
    """Mixing white/black list results in the differentiated set."""
    wl = ["a", "b"]
    bl = ["a"]
    result = cli.selected_categories(wl, bl)
    assert result == ["b"]


def test_selected_categories_wblist_long(mock_get_compatible_sets):
    """Mixing white/black list results in the differentiated set if blist is longer."""
    wl = ["a", "b"]
    bl = ["a", "d", "e"]
    result = cli.selected_categories(wl, bl)
    assert result == ["b"]


def test_exception_raised(mock_trial_results):
    """Mock trials results should have 1 survivor"""
    with pytest.raises(SurvivingMutantException):
        cli.exception_processing(1, mock_trial_results)


def test_exception_not_raised(mock_trial_results):
    """Mock trials results should have 1 survivor"""
    cli.exception_processing(5, mock_trial_results)


@freeze_time("2019-01-01")
def test_main(monkeypatch, mock_args, mock_results_summary):
    """As of v0.1.0, if the report structure changes this will need to be updated."""
    expected_final_report = dedent(
        """\
        Mutatest diagnostic summary
        ===========================
         - Source location: {src_loc}
         - Test commands: ['pytest']
         - Mode: s
         - Excluded files: ['__init__.py']
         - N locations input: 10
         - Random seed: 314

        Random sample details
        ---------------------
         - Total locations mutated: 4
         - Total locations identified: 4
         - Location sample coverage: 100.00 %


        Running time details
        --------------------
         - Clean trial 1 run time: 0:00:01.000002
         - Clean trial 2 run time: 0:00:01.000002
         - Mutation trials total run time: 0:00:06

        Overall mutation trial summary
        ==============================
         - SURVIVED: 1
         - DETECTED: 1
         - ERROR: 1
         - TIMEOUT: 1
         - UNKNOWN: 1
         - TOTAL RUNS: 5
         - RUN DATETIME: 2019-01-01 00:00:00


        Mutations by result status
        ==========================


        SURVIVED
        --------
         - src.py: (l: 1, c: 2) - mutation from <class '_ast.Add'> to <class '_ast.Mult'>


        TIMEOUT
        -------
         - src.py: (l: 1, c: 2) - mutation from <class '_ast.Add'> to <class '_ast.Mult'>


        DETECTED
        --------
         - src.py: (l: 1, c: 2) - mutation from <class '_ast.Add'> to <class '_ast.Mult'>


        ERROR
        -----
         - src.py: (l: 1, c: 2) - mutation from <class '_ast.Add'> to <class '_ast.Mult'>


        UNKNOWN
        -------
         - src.py: (l: 1, c: 2) - mutation from <class '_ast.Add'> to <class '_ast.Mult'>"""
    ).format_map({"src_loc": mock_args.src.resolve()})

    def mock_clean_trial(*args, **kwargs):
        return timedelta(days=0, seconds=1, microseconds=2)

    def mock_run_mutation_trials(*args, **kwargs):
        return mock_results_summary

    def mock_cli_args(*args, **kwargs):
        return mock_args

    monkeypatch.setattr(mutatest.cli.run, "clean_trial", mock_clean_trial)
    monkeypatch.setattr(mutatest.cli.run, "run_mutation_trials", mock_run_mutation_trials)

    monkeypatch.delenv("SOURCE_DATE_EPOCH", raising=False)
    monkeypatch.setattr(mutatest.cli, "cli_args", mock_cli_args)

    cli.cli_main()

    with open(mock_args.output, "r") as f:
        results = f.read()
        assert results == expected_final_report


def test_expected_arg_attrs():
    """With an empty list we should always get args with the specified attributes."""
    args = cli.cli_args([])
    expected_args = [
        "exclude",
        "mode",
        "nlocations",
        "output",
        "rseed",
        "src",
        "testcmds",
        "debug",
        "nocov",
    ]
    for e in expected_args:
        assert hasattr(args, e)


@pytest.fixture
def mock_parser():
    """Mock parser."""
    parser = argparse.ArgumentParser(prog="mock_parser", description=("Mock parser"))
    parser.add_argument("-e", "--exclude", action="append", default=[], help="Append")
    parser.add_argument("-b", "--blacklist", nargs="*", default=[], help="Nargs")
    parser.add_argument("--debug", action="store_true", help="Store True.")

    return parser


def test_get_parser_actions(mock_parser):
    """Parser action types based on basic inputs."""
    expected_actions = {
        "-h": "--help",
        "-e": "--exclude",
        "-b": "--blacklist",
        "--debug": "--debug",
    }
    expected_types = {
        argparse._HelpAction: ["help"],
        argparse._AppendAction: ["exclude"],
        argparse._StoreAction: ["blacklist"],
        argparse._StoreTrueAction: ["debug"],
    }

    parser_actions = cli.get_parser_actions(mock_parser)
    assert parser_actions.actions == expected_actions
    assert parser_actions.action_types == expected_types


class MockINI(NamedTuple):
    """Container for the Mock ini config."""

    ini_file: Path
    args: List[str]


@pytest.fixture
def mock_ini_file(tmp_path):
    """Basic ini file with mutatest configuration."""
    ini_contents = dedent(
        """\
    [mutatest]
    blacklist = nc su ix
    exclude =
        mutatest/__init__.py
        mutatest/_devtools.py
    mode = sd
    rseed = 567
    testcmds = pytest -m 'not slow'
    debug = no
    nocov = no
    """
    )

    ini_file = tmp_path / "testing.ini"
    with open(ini_file, "w") as fstream:
        fstream.write(ini_contents)

    default_args = [
        "--blacklist",
        "nc",
        "su",
        "ix",
        "--exclude",
        "mutatest/__init__.py",
        "--exclude",
        "mutatest/_devtools.py",
        "--mode",
        "sd",
        "--rseed",
        "567",
        "--testcmds",
        "pytest -m 'not slow'",
    ]

    return MockINI(ini_file, default_args)


def test_read_config_key_error(mock_ini_file):
    """Ensure KeyError is raised if missing section from config file."""
    with pytest.raises(KeyError):
        _ = cli.read_ini_config(mock_ini_file.ini_file, sections=["missing"])


@pytest.mark.parametrize("section", ["mutatest", "tool:mutatest"])
def test_read_setup_cfg_missing_mutatest_ini(tmp_path, section, monkeypatch):
    """Setup.cfg will support both [mutatest] and [tool:mutatest] sections."""
    ini_contents = dedent(
        f"""\
    [{section}]
    whitelist = nc su ix"""
    )

    expected = ["nc", "su", "ix"]

    with open(tmp_path / "setup.cfg", "w") as fstream:
        fstream.write(ini_contents)

    monkeypatch.chdir(tmp_path)
    result = cli.cli_args([])
    print(result.__dict__)

    assert len(result.whitelist) == 3
    for r, e in zip(result.whitelist, expected):
        assert r == e


@pytest.mark.parametrize("section", ["mutatest", "tool:mutatest"])
def test_search_file_order_bad_key_mutatest_ini(tmp_path, section, monkeypatch):
    """Ensuring the search hierarchy works, if the mutatest.ini is configured without the
    required [mutatest] key, the setup.cfg is searched next for each key type.
    """
    f1 = "[mypy]\nmval=123"
    f2 = f"[isort]\nival=456\n\n[{section}]\nmode=sd"

    write_order = ["mutatest.ini", "setup.cfg"]

    for fp, contents in zip(write_order, [f1, f2]):
        with open(tmp_path / fp, "w") as fstream:
            fstream.write(contents)

    monkeypatch.chdir(tmp_path)
    result = cli.cli_args([])

    assert result.mode == "sd"


def test_read_ini_config_keys(mock_ini_file):
    """Ensure the keys align to the mock from reading the file."""
    section = cli.read_ini_config(mock_ini_file.ini_file)
    expected_keys = ["blacklist", "exclude", "mode", "rseed", "testcmds", "debug", "nocov"]
    result = [k for k in section.keys()]
    assert result == expected_keys


def test_parse_ini_config_with_cli_empty(mock_ini_file):
    """With default empty args the ini file should be the only values"""
    config = cli.read_ini_config(mock_ini_file.ini_file)
    parser = cli.cli_parser()
    result = cli.parse_ini_config_with_cli(parser, config, [])
    assert result == mock_ini_file.args


def test_parse_ini_config_with_cli_overrides(mock_ini_file):
    """Input from the CLI will override the values from the ini file."""
    override = ["--blacklist", "aa", "-m", "s", "-r", "314", "--debug"]
    expected = [
        "--blacklist",
        "aa",
        "--mode",
        "s",
        "--rseed",
        "314",
        "--debug",
        "--exclude",
        "mutatest/__init__.py",
        "--exclude",
        "mutatest/_devtools.py",
        "--testcmds",
        "pytest -m 'not slow'",
    ]
    config = cli.read_ini_config(mock_ini_file.ini_file)
    parser = cli.cli_parser()
    result = cli.parse_ini_config_with_cli(parser, config, override)
    assert result == expected


####################################################################################################
# PROPERTY TESTS
####################################################################################################


# no arguments, so no given assumption
def test_cli_epilog_invariant():
    """Property:
        1. cli-epilog always returns a string value for screen printing
    """
    result = cli.cli_epilog()
    assert isinstance(result, str)
    assert len(result) > 1


@given(st.integers(), st.integers())
def test_cli_summary_report_invariant(mock_args, mock_TrialTimes, lm, li):
    """Property:
        1. cli_summary report returns a valid string without errors given any set of integers for
        locs_mutated and locs_identified.
    """

    results = cli.cli_summary_report(
        src_loc=Path("file.py"),
        args=mock_args,
        locs_mutated=lm,
        locs_identified=li,
        runtimes=mock_TrialTimes,
    )

    assert isinstance(results, str)
    assert len(results) > 1


@pytest.mark.parametrize("n", ["--nlocations", "-n", "-rseed", "-r"])
@given(st.integers(max_value=-1))
def test_syserror_negative_n_and_rseed(n, i):
    """Property:
        1. Given a negative n-value a SystemExit is raised.
    """
    with pytest.raises(SystemExit):
        _ = cli.cli_args([n, f"{i}"])
