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

import argparse

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

import hypothesis.strategies as st
import pytest

from freezegun import freeze_time
from hypothesis import given

import mutatest.cli

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


class MockArgs(NamedTuple):
    """Container for mocks of the cli arguments."""

    blacklist: Optional[List[str]]
    exclude: Optional[List[str]]
    mode: Optional[str]
    nlocations: Optional[int]
    output: Optional[Path]
    rseed: Optional[int]
    src: Optional[Path]
    testcmds: Optional[List[str]]
    whitelist: Optional[List[str]]
    exception: Optional[int]
    debug: Optional[bool]
    nocov: Optional[bool]
    parallel: Optional[bool]
    timeout_factor: Optional[int]


@pytest.fixture
def mock_args(tmp_path, binop_file):
    """Basic fixture with default settings using existing binop_file fixture."""
    return MockArgs(
        blacklist=[],
        exclude=["__init__.py"],
        mode="s",
        nlocations=10,
        output=tmp_path / "mock_mutation_report.rst",
        rseed=314,
        src=binop_file,
        testcmds=["pytest"],
        whitelist=[],
        exception=None,
        debug=False,
        nocov=True,
        parallel=False,
        timeout_factor=2,
    )


@pytest.fixture()
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}"])
