# coding=utf-8
from doctest import REPORT_NDIFF, ELLIPSIS, DocTestParser as BaseDocTestParser
from pathlib import Path

import pytest
from testfixtures import compare

from sybil.document import Document, PythonDocStringDocument
from sybil.evaluators.doctest import (
    KEEP_TRAILING_WHITESPACE,
    NUMBER,
    float_approx_equal,
    OutputChecker,
)
from sybil.example import SybilFailure
from sybil.parsers.abstract import DocTestStringParser
from sybil.parsers.rest import DocTestParser, DocTestDirectiveParser
from .helpers import sample_path, parse, FUNCTIONAL_TEST_DIR


def test_pass():
    examples, namespace = parse('doctest.txt', DocTestParser(), expected=5)
    examples[0].evaluate()
    assert namespace['y'] == 1
    examples[1].evaluate()
    assert namespace['y'] == 1
    examples[2].evaluate()
    assert namespace['x'] == [1, 2, 3]
    examples[3].evaluate()
    assert namespace['y'] == 2
    examples[4].evaluate()
    assert namespace['y'] == 2


def test_fail():
    path = sample_path('doctest_fail.txt')
    examples, namespace = parse('doctest_fail.txt', DocTestParser(), expected=2)
    with pytest.raises(SybilFailure) as excinfo:
        examples[0].evaluate()
    # Note on line numbers: This test shows how the example's line number is correct
    # however, doctest doesn't do the line padding trick Sybil does with codeblocks,
    # so the line number will never be correct, it's always 1.
    compare(
        str(excinfo.value),
        expected=(
            f"Example at {path}, line 1, column 1 did not evaluate as expected:\n"
            "Expected:\n"
            "    Not my output\n"
            "Got:\n"
            "    where's my output?\n"
        ),
    )
    with pytest.raises(SybilFailure) as excinfo:
        examples[1].evaluate()
    actual = excinfo.value.result
    assert actual.startswith('Exception raised:')
    assert actual.endswith('Exception: boom!\n')


def test_fail_with_options():
    parser = DocTestParser(optionflags=REPORT_NDIFF | ELLIPSIS)
    examples, namespace = parse('doctest_fail.txt', parser, expected=2)
    with pytest.raises(SybilFailure) as excinfo:
        examples[0].evaluate()
    assert excinfo.value.result == (
        "Differences (ndiff with -expected +actual):\n"
        "    - Not my output\n"
        "    + where's my output?\n"
    )


def test_literals():
    parser = DocTestParser()
    examples, _ = parse('doctest_literals.txt', parser, expected=5)
    for example in examples:
        example.evaluate()


def test_min_indent():
    examples, _ = parse('doctest_min_indent.txt', DocTestParser(), expected=1)
    examples[0].evaluate()


def test_tabs():
    path = sample_path('doctest_tabs.txt')
    parser = DocTestParser()
    with pytest.raises(ValueError):
        Document.parse(path, parser)


def test_irrelevant_tabs():
    examples, _ = parse('doctest_irrelevant_tabs.txt', DocTestParser(), expected=1)
    examples[0].evaluate()


def test_unicode():
    examples, _ = parse('doctest_unicode.txt', DocTestParser(), expected=1)
    examples[0].evaluate()


def test_directive():
    path = sample_path('doctest_directive.txt')
    examples, _ = parse('doctest_directive.txt', DocTestDirectiveParser(), expected=3)
    examples[0].evaluate()
    with pytest.raises(SybilFailure) as excinfo:
        examples[1].evaluate()
    compare(
        str(excinfo.value),
        expected=(
            f"Example at {path}, line 13, column 1 did not evaluate as expected:\n"
            "Expected:\n"
            "    Unexpected!\n"
            "Got:\n"
            "    2\n"
        ),
    )
    with pytest.raises(SybilFailure) as excinfo:
        examples[2].evaluate()
    actual = excinfo.value.result
    assert actual.startswith('Exception raised:')
    assert actual.endswith('Exception: boom!\n')


def test_directive_with_options():
    path = sample_path('doctest_directive.txt')
    parser = DocTestDirectiveParser(optionflags=REPORT_NDIFF | ELLIPSIS)
    examples, namespace = parse('doctest_directive.txt', parser, expected=3)
    with pytest.raises(SybilFailure) as excinfo:
        examples[1].evaluate()
    compare(
        str(excinfo.value),
        expected=(
            f"Example at {path}, line 13, column 1 did not evaluate as expected:\n"
            "Differences (ndiff with -expected +actual):\n"
            "    - Unexpected!\n"
            "    + 2\n"
        ),
    )


def test_directive_with_inline_options():
    name = 'doctest_inline_options.txt'
    path = sample_path(name)
    parser = DocTestParser(optionflags=ELLIPSIS)
    examples, namespace = parse(name, parser, expected=3)
    # ELLIPSIS active from options:
    examples[0].evaluate()
    # ELLIPSIS deactivated by comment:
    with pytest.raises(SybilFailure) as excinfo:
        examples[1].evaluate()
    compare(
        str(excinfo.value),
        expected=(
            f"Example at {path}, line 4, column 1 did not evaluate as expected:\n"
            "Expected:\n"
            "    '...'\n"
            "Got:\n"
            "    'b'\n"
        ),
    )
    # SKIP activated by comment:
    examples[2].evaluate()


@pytest.mark.parametrize(
    "text",
    [
        "1.",
        "+1.",
        "-1.",
        ".1",
        "+.1",
        "-.1",
        "0.1",
        "+0.1",
        "-0.1",
        "1e5",
        "+1e5",
        "1e+5",
        "+1e+5",
        "1e-5",
        "+1e-5",
        "-1e-5",
        "1.2e3",
        "-1.2e-3",
    ],
)
def test_number_re_matches(text: str) -> None:
    m = OutputChecker._number_re.match(text)
    assert m is not None
    assert float_approx_equal(m.group(), text)


@pytest.mark.parametrize(
    "text",
    [
        "1",
        "abc",
    ],
)
def test_number_re_no_matches(text: str) -> None:
    assert OutputChecker._number_re.match(text) is None


def test_number_option():
    parser = DocTestParser(optionflags=NUMBER)
    examples, namespace = parse('doctest_number.txt', parser, expected=23)
    for example in examples:
        example.evaluate()


def test_number_option_failures():
    parser = DocTestParser(optionflags=NUMBER)
    examples, namespace = parse('doctest_number_failures.txt', parser, expected=11)
    for example in examples:
        with pytest.raises(SybilFailure):
            example.evaluate()


def test_trailing_whitespace_ignored_by_default():
    path = sample_path('doctest_trailing_whitespace.txt')
    examples, _ = parse('doctest_trailing_whitespace.txt', DocTestParser(), expected=4)
    examples[0].evaluate()
    examples[1].evaluate()
    with pytest.raises(SybilFailure) as excinfo:
        examples[2].evaluate()
    compare(
        str(excinfo.value),
        expected=(
            f"Example at {path}, line 11, column 1 did not evaluate as expected:\n"
            "Expected:\n"
            "    comparing:\n"
            "Got:\n"
            "    comparing: \n"
        ),
    )
    with pytest.raises(SybilFailure) as excinfo:
        examples[3].evaluate()
    compare(
        str(excinfo.value),
        expected=(
            f"Example at {path}, line 16, column 1 did not evaluate as expected:\n"
            "Expected:\n"
            "    one\n"
            "    two\n"
            "Got:\n"
            "    one\n"
            "    three\n"
        ),
    )


def test_keep_trailing_whitespace_option():
    parser = DocTestParser(optionflags=KEEP_TRAILING_WHITESPACE)
    examples, _ = parse('doctest_trailing_whitespace.txt', parser, expected=4)
    for example in examples:
        with pytest.raises(SybilFailure):
            example.evaluate()


def test_output_checker_trailing_whitespace():
    checker = OutputChecker()
    assert checker.check_output('foo\n', 'foo \n', 0)
    assert checker.check_output('foo \n', 'foo\n', 0)
    assert checker.check_output('foo\tbar\n', 'foo\tbar\t\n', 0)
    assert not checker.check_output('foo\n', 'foo \n', KEEP_TRAILING_WHITESPACE)
    assert not checker.check_output('foo \n', 'foo\n', KEEP_TRAILING_WHITESPACE)


@pytest.mark.parametrize(
    ("want", "got", "optionflags", "matches"),
    [
        pytest.param(
            'AssertionError: ...\nNot equal\n',
            'AssertionError: \nNot equal\n',
            ELLIPSIS,
            True,
            id='space-then-ellipsis-at-eol-matches-stripped-actual',
        ),
        pytest.param(
            'Mismatched elements: 1 / 3 (33.3%)...\nMax difference: 0.5\n',
            'Mismatched elements: 1 / 3 (33.3%)\nMax difference: 0.5\n',
            ELLIPSIS,
            True,
            id='ellipsis-abutting-text-at-eol',
        ),
        pytest.param(
            'Mismatched elements: 1 / 3 (33.3%)...\nMax difference: 0.5\n',
            'Mismatched elements: 1 / 3 (33.3%) \nMax difference: 0.5\n',
            ELLIPSIS,
            True,
            id='ellipsis-abutting-text-at-eol-with-trailing-whitespace-in-actual',
        ),
        pytest.param(
            'foo ...\n',
            'foo bar\n',
            ELLIPSIS,
            True,
            id='space-then-ellipsis-still-matches-real-content',
        ),
        pytest.param(
            'foo ...\n',
            'foo ...\n',
            0,
            True,
            id='literal-dots-without-ellipsis-flag-match-exactly',
        ),
        pytest.param(
            'foo ...\n',
            'foo bar\n',
            0,
            False,
            id='literal-dots-without-ellipsis-flag-are-not-a-wildcard',
        ),
        pytest.param(
            'AssertionError: ...\nNot equal\n',
            'AssertionError: \nNot equal\n',
            ELLIPSIS | KEEP_TRAILING_WHITESPACE,
            True,
            id='keep-flag-restores-space-matching-trailing-whitespace',
        ),
        pytest.param(
            'AssertionError: ...\n',
            'AssertionError:\n',
            ELLIPSIS | KEEP_TRAILING_WHITESPACE,
            False,
            id='keep-flag-requires-space-before-ellipsis-to-be-present',
        ),
    ],
)
def test_output_checker_ellipsis_and_trailing_whitespace(
    want: str, got: str, optionflags: int, matches: bool
) -> None:
    assert OutputChecker().check_output(want, got, optionflags) is matches


# Number of doctests that can't be parsed in a file when looking at the whole file source:
ROOT = Path(FUNCTIONAL_TEST_DIR)
UNPARSEABLE = {
    ROOT / 'package_and_docs' / 'src' / 'parent' / 'child' / 'module_b.py': 1,
}
MINIMUM_EXPECTED_DOCTESTS = 9


def test_sybil_example_count(all_python_files):
    parser = DocTestStringParser()

    seen_examples_from_source = 0
    seen_examples_from_docstrings = 0

    for path, source in all_python_files:
        seen_examples_from_source += len(tuple(parser(source, path)))
        for start, end, docstring in PythonDocStringDocument.extract_docstrings(source):
            seen_examples_from_docstrings += len(tuple(parser(docstring, path)))

    assert seen_examples_from_source > MINIMUM_EXPECTED_DOCTESTS, seen_examples_from_source
    assert seen_examples_from_docstrings > MINIMUM_EXPECTED_DOCTESTS, seen_examples_from_docstrings
    assert seen_examples_from_docstrings == seen_examples_from_source


def check_sybil_against_doctest(path, text):
    problems = []
    name = str(path)
    regions = list(DocTestStringParser()(text, path))
    sybil_examples = [region.parsed for region in regions]
    doctest_examples = BaseDocTestParser().get_examples(text, name)
    problems.append(
        compare(
            expected=doctest_examples, actual=sybil_examples, raises=False, show_whitespace=True
        )
    )
    for region in regions:
        example = region.parsed
        region_source = text[region.start : region.end]
        for name in 'source', 'want':
            expected = getattr(example, name)
            if name == 'source':
                # doctest.DocTestParser REPL examples are pre-processed into python
                # source to execute, so do similar here but note this is pretty fragile:
                expected_source = region_source.replace('>>>', '').replace('    ... ', '')
            else:
                expected_source = region_source
            if expected not in expected_source:  # pragma: no cover - Only on failures!
                problems.append(f'{region}:{name}\n{expected!r} not in {expected_source!r}')
    problems = [problem for problem in problems if problem]
    if problems:  # pragma: no cover - Only on failures!
        raise AssertionError('doctests not correctly extracted:\n\n' + '\n\n'.join(problems))


def test_all_docstest_examples_extracted_from_source_correctly(python_file):
    path, source = python_file
    if path in UNPARSEABLE:
        return
    check_sybil_against_doctest(path, source)


def test_all_docstest_examples_extracted_from_docstrings_correctly(python_file):
    path, source = python_file
    for start, end, docstring in PythonDocStringDocument.extract_docstrings(source):
        check_sybil_against_doctest(path, docstring)
