"""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
import pytest

from hypothesis import assume, given

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
