"""Tests for run.
"""
import ast
import subprocess
import sys

from datetime import timedelta
from pathlib import Path
from subprocess import CompletedProcess

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

from hypothesis import assume, given  # type: ignore

from mutatest import run
from mutatest.api import Genome, GenomeGroup, GenomeGroupTarget
from mutatest.run import BaselineTestException, Config, MutantTrialResult
from mutatest.transformers import LocIndex


RETURN_CODE_MAPPINGS = [
    (0, "SURVIVED"),
    (1, "DETECTED"),
    (2, "ERROR"),
    (3, "TIMEOUT"),
    (4, "UNKNOWN"),
]


@pytest.fixture
def binop_Add_LocIdx():
    """Binop Add LocIdx as a target for mutations."""
    end_lineno = None if sys.version_info < (3, 8) else 10
    end_col_offset = None if sys.version_info < (3, 8) else 16
    return LocIndex(
        ast_class="BinOp",
        lineno=10,
        col_offset=11,
        op_type=ast.Add,
        end_lineno=end_lineno,
        end_col_offset=end_col_offset,
    )


@pytest.fixture
def add_five_to_mult_mutant(binop_file, stdoutIO, binop_Add_LocIdx):
    """Mutant that takes add_five op ADD to MULT. Fails if mutation code does not work."""
    genome = Genome(source_file=binop_file)

    mutation_op = ast.Mult
    mutant = genome.mutate(binop_Add_LocIdx, mutation_op, write_cache=True)

    # uses the redirection for stdout to capture the value from the final output of binop_file
    with stdoutIO() as s:
        exec(mutant.mutant_code)
        assert int(s.getvalue()) == 25

    return mutant


def test_capture_output():
    """Quick utility test on capturing output for DEBUG log level 10."""
    assert run.capture_output(10) is False
    assert run.capture_output(20) is True
    assert run.capture_output(30) is True


@pytest.mark.parametrize("returncode, expected_status", RETURN_CODE_MAPPINGS)
def test_MutantTrialResult(returncode, expected_status, add_five_to_mult_mutant):
    """Test that the status property translates as expected from return-codes."""
    trial = MutantTrialResult(add_five_to_mult_mutant, returncode)
    assert trial.status == expected_status


@pytest.mark.parametrize("returncode, expected_status", RETURN_CODE_MAPPINGS)
def test_create_mutation_and_run_trial(
    returncode, expected_status, monkeypatch, binop_file, binop_Add_LocIdx
):
    """Mocked trial to ensure mutated cache files are removed after running."""
    genome = Genome(source_file=binop_file)

    mutation_op = ast.Mult

    tag = sys.implementation.cache_tag
    expected_cfile = binop_file.parent / "__pycache__" / ".".join([binop_file.stem, tag, "pyc"])

    def mock_subprocess_run(*args, **kwargs):
        return CompletedProcess(args="pytest", returncode=returncode)

    monkeypatch.setattr(subprocess, "run", mock_subprocess_run)

    trial = run.create_mutation_run_trial(
        genome=genome,
        target_idx=binop_Add_LocIdx,
        mutation_op=mutation_op,
        test_cmds=["pytest"],
        max_runtime=10,
    )

    # mutated cache files should be removed after trial run
    assert not expected_cfile.exists()
    assert trial.status == expected_status


def test_clean_trial_exception(binop_file, monkeypatch):
    """Ensure clean trial raises a BaselineTestException on non-zero returncode"""

    def mock_subprocess_run(*args, **kwargs):
        return CompletedProcess(args="pytest", returncode=1)

    monkeypatch.setattr(subprocess, "run", mock_subprocess_run)

    with pytest.raises(BaselineTestException):
        run.clean_trial(binop_file.parent, ["pytest"])


def test_clean_trial_timedelta(binop_file, monkeypatch):
    """Clean trial results in a timedelta object."""

    def mock_subprocess_run(*args, **kwargs):
        return CompletedProcess(args="pytest", returncode=0)

    monkeypatch.setattr(subprocess, "run", mock_subprocess_run)

    result = run.clean_trial(binop_file.parent, ["pytest"])
    assert isinstance(result, timedelta)


def test_generate_sample(binop_file, sorted_binop_expected_locs):
    """Sample generation from targets results in a sorted list."""
    ggrp = GenomeGroup(binop_file)
    sample = run.get_sample(ggrp, ignore_coverage=True)

    for gt in sample:
        assert gt.source_path == binop_file

    assert list(gt.loc_idx for gt in sample) == sorted_binop_expected_locs


def test_generate_sample_FileNotFoundError(binop_file, sorted_binop_expected_locs):
    """If coverage file is not found, return the targets without coverage."""
    ggrp = GenomeGroup(binop_file)
    ggrp.set_coverage(coverage_file="somethingbad")

    sample = run.get_sample(ggrp, ignore_coverage=False)
    assert list(gt.loc_idx for gt in sample) == sorted_binop_expected_locs


@pytest.mark.parametrize("popsize, nlocs, nexp", [(3, 1, 1), (3, 2, 2), (3, 5, 3)])
def test_get_mutation_sample_locations(popsize, nlocs, nexp, mock_LocIdx):
    """Test sample size draws for the mutation sample."""
    mock_src_file = Path("source.py")
    mock_sample = [GenomeGroupTarget(*i) for i in [(mock_src_file, mock_LocIdx)] * popsize]

    result = run.get_mutation_sample_locations(mock_sample, nlocs)
    assert len(result) == nexp


@pytest.mark.parametrize("nloc", [0, -1], ids=["zero", "negative integer"])
def test_get_mutation_sample_locations_ValueError(nloc, mock_LocIdx):
    """Zero and negative integer sample sizes raise a value error."""
    ggt = [GenomeGroupTarget(Path("src.py"), mock_LocIdx)]

    with pytest.raises(ValueError):
        _ = run.get_mutation_sample_locations(ggt, nloc)


def test_get_genome_group_folder_and_file(tmp_path):
    """Genome Group initialization from run using exclusions and folder/file configs."""
    f = tmp_path / "folder"
    f.mkdir()

    test_files = [
        tmp_path / "first.py",
        tmp_path / "second.py",
        tmp_path / "test_first.py",
        tmp_path / "test_second.py",
        tmp_path / "third_test.py",
        f / "third.py",
        f / "test_third.py",
    ]

    for tf in test_files:
        with open(tf, "w") as temp_py:
            temp_py.write("import this")

    config = Config(exclude_files=[tmp_path / "first.py"], filter_codes=["bn", "ix"])

    expected_keys = sorted([tmp_path / "second.py", f / "third.py"])

    # test using a folder including exclusions
    ggrp = run.get_genome_group(src_loc=tmp_path, config=config)

    assert sorted(list(ggrp.keys())) == expected_keys
    for k, g in ggrp.items():
        assert g.filter_codes == {"bn", "ix"}

    # test using only a file and empty config
    ggrp2 = run.get_genome_group(src_loc=tmp_path / "first.py", config=Config())
    assert sorted(list(ggrp2.keys())) == [tmp_path / "first.py"]
    for k, g in ggrp2.items():
        assert g.filter_codes == set()


@pytest.mark.parametrize(
    "return_code, config",
    [
        (0, Config(break_on_survival=True)),
        (1, Config(break_on_detected=True)),
        (2, Config(break_on_error=True)),
        (3, Config(break_on_timeout=True)),
        (4, Config(break_on_unknown=True)),
    ],
    ids=["survival", "detected", "err", "timeout", "unknown"],  # err, not error, for pytest output
)
def test_break_on_check(return_code, config, mock_Mutant, mock_LocIdx):
    # positive case
    mtr = MutantTrialResult(mock_Mutant, return_code)
    result = run.trial_output_check_break(mtr, config, Path("file.py"), mock_LocIdx)
    assert result

    # negative case
    # using the default Config has no break-on settings
    result = run.trial_output_check_break(mtr, Config(), Path("file.py"), mock_LocIdx)
    assert not result


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


TEXT_STRATEGY = st.text(alphabet=st.characters(blacklist_categories=("Cs", "Cc", "Po")), min_size=1)
VALID_COLORS = ["red", "green", "yellow", "blue"]


@given(TEXT_STRATEGY, TEXT_STRATEGY)
def test_colorize_output_invariant_return(o, c):
    """Property:
        1. Colorized output always returns the unmodified string for invalid entries.
    """
    assume(c not in VALID_COLORS)

    result = run.colorize_output(o, c)
    assert result == o


@pytest.mark.parametrize("color", VALID_COLORS)
@given(TEXT_STRATEGY)
def test_colorize_output_invariant_valid(color, o):
    """Property:
        1. Valid colorized output start and end with assumed terminal markers.
    """
    result = run.colorize_output(o, color)
    assert result.startswith("\x1b[")
    assert result.endswith("\x1b[0m")


####################################################################################################
# SLOW TESTS: RUN THE FULL TRIAL FUNCTION ACROSS TMP_PATH_FACTORY FILES
####################################################################################################


@pytest.fixture
def change_to_tmp(monkeypatch, tmp_path):
    """Change to temp directory for writing parallel cache files if needed."""
    monkeypatch.chdir(tmp_path)


@pytest.mark.slow
@pytest.mark.parametrize(
    "bos, bod, exp_trials", [(False, False, 6), (True, True, 1), (False, True, 1)]
)
@pytest.mark.parametrize("parallel", [False, True])
def test_run_mutation_trials_good_binop(
    bos, bod, exp_trials, parallel, single_binop_file_with_good_test, change_to_tmp
):
    """Slow test to run detection trials on a simple mutation on a binop.

    Based on fixture, there is one Add operation, with 6 substitutions e.g.
    sub, div, mult, pow, mod, floordiv, therefore, 6 total trials are expected for a full run
    and 1 trial is expected when break on detected is used.

    Args:
        bos: break on survival
        bod: break on detection
        exp_trials: number of expected trials
        single_binop_file_with_good_test: fixture for single op with a good test
    """
    if sys.version_info < (3, 8) and parallel:
        pytest.skip("Under version 3.8 will not run parallel tests.")

    test_cmds = f"pytest {single_binop_file_with_good_test.test_file.resolve()}".split()

    config = Config(
        n_locations=100, break_on_survival=bos, break_on_detected=bod, multi_processing=parallel
    )

    results_summary = run.run_mutation_trials(
        single_binop_file_with_good_test.src_file.resolve(), test_cmds=test_cmds, config=config
    )

    assert len(results_summary.results) == exp_trials

    # in all trials the status should be detected
    for mutant_trial in results_summary.results:
        assert mutant_trial.return_code == 1
        assert mutant_trial.status == "DETECTED"


@pytest.mark.slow
@pytest.mark.parametrize(
    "bos, bod, exp_trials", [(False, False, 6), (True, True, 1), (True, False, 1)]
)
@pytest.mark.parametrize("parallel", [False, True])
def test_run_mutation_trials_bad_binop(
    bos, bod, exp_trials, parallel, single_binop_file_with_bad_test, change_to_tmp
):
    """Slow test to run detection trials on a simple mutation on a binop.

    Based on fixture, there is one Add operation, with 6 substitutions e.g.
    sub, div, mult, pow, mod, floordiv, therefore, 6 total trials are expected for a full run
    and 1 trial is expected when break on detected is used.

    Args:
        bos: break on survival
        bod: break on detection
        exp_trials: number of expected trials
        single_binop_file_with_good_test: fixture for single op with a good test
    """
    if sys.version_info < (3, 8) and parallel:
        pytest.skip("Under version 3.8 will not run parallel tests.")

    test_cmds = f"pytest {single_binop_file_with_bad_test.test_file.resolve()}".split()

    config = Config(
        n_locations=100, break_on_survival=bos, break_on_detected=bod, multi_processing=parallel
    )

    results_summary = run.run_mutation_trials(
        single_binop_file_with_bad_test.src_file.resolve(), test_cmds=test_cmds, config=config
    )

    assert len(results_summary.results) == exp_trials

    # in all trials the status should be survivors
    for mutant_trial in results_summary.results:
        assert mutant_trial.return_code == 0
        assert mutant_trial.status == "SURVIVED"


@pytest.mark.slow
@pytest.mark.parametrize("bot, exp_timeout_trials", [(False, 3), (True, 2)])
def test_run_mutation_trials_timeout(bot, exp_timeout_trials, sleep_timeout):
    """Slow test to run detection trials on a simple mutation on a binop.

    Based on fixture, there are 2 substitutions e.g. and one if statement:
    one of these changes will cause the sleep function to be executed
    resulting in a Timeout. In total there are 6 total mutations, 3 of which will timeout.

    Args:
        bot: break on timeout
        exp_trials: number of expected trials
        sleep_timeout: fixture for single op with a timeout test
    """

    test_cmds = f"pytest {sleep_timeout.test_file.resolve()}".split()
    max_runtime = 1  # manually set to keep the timeout time reasonable

    config = Config(
        n_locations=100,
        break_on_survival=False,
        break_on_detected=False,
        break_on_timeout=bot,
        max_runtime=max_runtime,
    )

    results_summary = run.run_mutation_trials(
        sleep_timeout.src_file.resolve(), test_cmds=test_cmds, config=config
    )

    # in all trials the status should be survivors or timeouts
    for mutant_trial in results_summary.results:
        assert mutant_trial.return_code in {0, 3}
        if mutant_trial.return_code == 0:
            assert mutant_trial.status == "SURVIVED"
        else:
            assert mutant_trial.status == "TIMEOUT"

    timeout_results = [
        mutant_trial for mutant_trial in results_summary.results if mutant_trial.status == "TIMEOUT"
    ]

    # It's possible the timeout will exceed in CI, rare but seen on Windows
    # Assumed to be an IO thing or shared fixture problem in multiple environments
    # Generally, these are expected to be equal
    assert len(timeout_results) >= exp_timeout_trials