import contextlib
import pathlib
import re
from contextlib import ExitStack as does_not_raise
from dataclasses import replace
from typing import ContextManager

import pytest
from _pytest.monkeypatch import MonkeyPatch
from click.testing import CliRunner

from pyink import (
    Mode,
    NothingChanged,
    QuoteStyle,
    format_cell,
    format_file_contents,
    format_file_in_place,
    main,
)
from pyink.handle_ipynb_magics import jupyter_dependencies_are_installed
from tests.util import DATA_DIR, get_case_path, read_jupyter_notebook

with contextlib.suppress(ModuleNotFoundError):
    import IPython
pytestmark = pytest.mark.jupyter
pytest.importorskip("IPython", reason="IPython is an optional dependency")
pytest.importorskip("tokenize_rt", reason="tokenize-rt is an optional dependency")

JUPYTER_MODE = Mode(is_ipynb=True)
PYINK_JUPYTER_MODE = Mode(is_ipynb=True, pyink_indentation=2, pyink_ipynb_indentation=2)

EMPTY_CONFIG = DATA_DIR / "empty_pyproject.toml"
PYINK_OVERRIDE_CONFIG = DATA_DIR / "pyink_configs" / "overrides.toml"

runner = CliRunner()


def test_noop() -> None:
    src = 'foo = "a"'
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


@pytest.mark.parametrize("fast", [True, False])
def test_trailing_semicolon(fast: bool) -> None:
    src = 'foo = "a" ;'
    result = format_cell(src, fast=fast, mode=JUPYTER_MODE)
    expected = 'foo = "a";'
    assert result == expected


def test_trailing_semicolon_with_comment() -> None:
    src = 'foo = "a" ;  # bar'
    result = format_cell(src, fast=True, mode=JUPYTER_MODE)
    expected = 'foo = "a";  # bar'
    assert result == expected


def test_trailing_semicolon_with_comment_on_next_line() -> None:
    src = "import pyink;\n\n# this is a comment"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_trailing_semicolon_indented() -> None:
    src = "with foo:\n    plot_bar();"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_trailing_semicolon_noop() -> None:
    src = 'foo = "a";'
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


@pytest.mark.parametrize(
    "mode",
    [
        pytest.param(JUPYTER_MODE, id="default mode"),
        pytest.param(
            replace(JUPYTER_MODE, python_cell_magics={"cust1", "cust2"}),
            id="custom cell magics mode",
        ),
    ],
)
def test_cell_magic(mode: Mode) -> None:
    src = "%%time\nfoo =bar"
    result = format_cell(src, fast=True, mode=mode)
    expected = "%%time\nfoo = bar"
    assert result == expected


def test_cell_magic_noop() -> None:
    src = "%%time\n2 + 2"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


@pytest.mark.parametrize(
    "mode",
    [
        pytest.param(JUPYTER_MODE, id="default mode"),
        pytest.param(
            replace(JUPYTER_MODE, python_cell_magics={"cust1", "cust2"}),
            id="custom cell magics mode",
        ),
    ],
)
@pytest.mark.parametrize(
    "src, expected",
    (
        pytest.param("ls =!ls", "ls = !ls", id="System assignment"),
        pytest.param("!ls\n'foo'", '!ls\n"foo"', id="System call"),
        pytest.param("!!ls\n'foo'", '!!ls\n"foo"', id="Other system call"),
        pytest.param("?str\n'foo'", '?str\n"foo"', id="Help"),
        pytest.param("??str\n'foo'", '??str\n"foo"', id="Other help"),
        pytest.param(
            "%matplotlib inline\n'foo'",
            '%matplotlib inline\n"foo"',
            id="Line magic with argument",
        ),
        pytest.param("%time\n'foo'", '%time\n"foo"', id="Line magic without argument"),
        pytest.param(
            "env =  %env var", "env = %env var", id="Assignment to environment variable"
        ),
        pytest.param("env =  %env", "env = %env", id="Assignment to magic"),
    ),
)
def test_magic(src: str, expected: str, mode: Mode) -> None:
    result = format_cell(src, fast=True, mode=mode)
    assert result == expected


@pytest.mark.parametrize(
    "src",
    (
        "%%bash\n2+2",
        "%%html --isolated\n2+2",
        "%%writefile e.txt\n  meh\n meh",
    ),
)
def test_non_python_magics(src: str) -> None:
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


@pytest.mark.skipif(
    IPython.version_info < (8, 3),
    reason="Change in how TransformerManager transforms this input",
)
def test_set_input() -> None:
    src = "a = b??"
    expected = "??b"
    result = format_cell(src, fast=True, mode=JUPYTER_MODE)
    assert result == expected


def test_input_already_contains_transformed_magic() -> None:
    src = '%time foo()\nget_ipython().run_cell_magic("time", "", "foo()\\n")'
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_magic_noop() -> None:
    src = "ls = !ls"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_cell_magic_with_magic() -> None:
    src = "%%timeit -n1\nls =!ls"
    result = format_cell(src, fast=True, mode=JUPYTER_MODE)
    expected = "%%timeit -n1\nls = !ls"
    assert result == expected


@pytest.mark.parametrize(
    "src, expected",
    (
        ("\n\n\n%time \n\n", "%time"),
        ("  \n\t\n%%timeit -n4 \t \nx=2  \n\r\n", "%%timeit -n4\nx = 2"),
        (
            "  \t\n\n%%capture \nx=2 \n%config \n\n%env\n\t  \n \n\n",
            "%%capture\nx = 2\n%config\n\n%env",
        ),
    ),
)
def test_cell_magic_with_empty_lines(src: str, expected: str) -> None:
    result = format_cell(src, fast=True, mode=JUPYTER_MODE)
    assert result == expected


@pytest.mark.parametrize(
    "mode, expected_output, expectation",
    [
        pytest.param(
            JUPYTER_MODE,
            "%%custom_python_magic -n1 -n2\nx=2",
            pytest.raises(NothingChanged),
            id="No change when cell magic not registered",
        ),
        pytest.param(
            replace(JUPYTER_MODE, python_cell_magics={"cust1", "cust2"}),
            "%%custom_python_magic -n1 -n2\nx=2",
            pytest.raises(NothingChanged),
            id="No change when other cell magics registered",
        ),
        pytest.param(
            replace(JUPYTER_MODE, python_cell_magics={"custom_python_magic", "cust1"}),
            "%%custom_python_magic -n1 -n2\nx = 2",
            does_not_raise(),
            id="Correctly change when cell magic registered",
        ),
    ],
)
def test_cell_magic_with_custom_python_magic(
    mode: Mode, expected_output: str, expectation: ContextManager[object]
) -> None:
    with expectation:
        result = format_cell(
            "%%custom_python_magic -n1 -n2\nx=2",
            fast=True,
            mode=mode,
        )
        assert result == expected_output


@pytest.mark.parametrize(
    "src",
    (
        "   %%custom_magic \nx=2",
        "\n\n%%custom_magic\nx=2",
        "# comment\n%%custom_magic\nx=2",
        "\n  \n # comment with %%time\n\t\n %%custom_magic # comment \nx=2",
    ),
)
def test_cell_magic_with_custom_python_magic_after_spaces_and_comments_noop(
    src: str,
) -> None:
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_cell_magic_with_forced_single_quoted_strings() -> None:
    src = "%time"
    mode = replace(JUPYTER_MODE, quote_style=QuoteStyle.SINGLE)
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=mode)


def test_cell_magic_nested() -> None:
    src = "%%time\n%%time\n2+2"
    result = format_cell(src, fast=True, mode=JUPYTER_MODE)
    expected = "%%time\n%%time\n2 + 2"
    assert result == expected


def test_cell_magic_with_magic_noop() -> None:
    src = "%%t -n1\nls = !ls"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_automagic() -> None:
    src = "pip install black"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_multiline_magic() -> None:
    src = "%time 1 + \\\n2"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_multiline_no_magic() -> None:
    src = "1 + \\\n2"
    result = format_cell(src, fast=True, mode=JUPYTER_MODE)
    expected = "1 + 2"
    assert result == expected


def test_cell_magic_with_invalid_body() -> None:
    src = "%%time\nif True"
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_empty_cell() -> None:
    src = ""
    with pytest.raises(NothingChanged):
        format_cell(src, fast=True, mode=JUPYTER_MODE)


def test_entire_notebook_empty_metadata() -> None:
    content = read_jupyter_notebook("jupyter", "notebook_empty_metadata")
    result = format_file_contents(content, fast=True, mode=JUPYTER_MODE)
    expected = (
        "{\n"
        ' "cells": [\n'
        "  {\n"
        '   "cell_type": "code",\n'
        '   "execution_count": null,\n'
        '   "metadata": {\n'
        '    "tags": []\n'
        "   },\n"
        '   "outputs": [],\n'
        '   "source": [\n'
        '    "%%time\\n",\n'
        '    "\\n",\n'
        '    "print(\\"foo\\")"\n'
        "   ]\n"
        "  },\n"
        "  {\n"
        '   "cell_type": "code",\n'
        '   "execution_count": null,\n'
        '   "metadata": {},\n'
        '   "outputs": [],\n'
        '   "source": []\n'
        "  }\n"
        " ],\n"
        ' "metadata": {},\n'
        ' "nbformat": 4,\n'
        ' "nbformat_minor": 4\n'
        "}\n"
    )
    assert result == expected


def test_entire_notebook_trailing_newline() -> None:
    content = read_jupyter_notebook("jupyter", "notebook_trailing_newline")
    result = format_file_contents(content, fast=True, mode=JUPYTER_MODE)
    expected = (
        "{\n"
        ' "cells": [\n'
        "  {\n"
        '   "cell_type": "code",\n'
        '   "execution_count": null,\n'
        '   "metadata": {\n'
        '    "tags": []\n'
        "   },\n"
        '   "outputs": [],\n'
        '   "source": [\n'
        '    "%%time\\n",\n'
        '    "\\n",\n'
        '    "print(\\"foo\\")"\n'
        "   ]\n"
        "  },\n"
        "  {\n"
        '   "cell_type": "code",\n'
        '   "execution_count": null,\n'
        '   "metadata": {},\n'
        '   "outputs": [],\n'
        '   "source": []\n'
        "  }\n"
        " ],\n"
        ' "metadata": {\n'
        '  "interpreter": {\n'
        '   "hash": "e758f3098b5b55f4d87fe30bbdc1367f20f246b483f96267ee70e6c40cb185d8"\n'  # noqa:B950
        "  },\n"
        '  "kernelspec": {\n'
        '   "display_name": "Python 3.8.10 64-bit (\'black\': venv)",\n'
        '   "name": "python3"\n'
        "  },\n"
        '  "language_info": {\n'
        '   "name": "python",\n'
        '   "version": ""\n'
        "  }\n"
        " },\n"
        ' "nbformat": 4,\n'
        ' "nbformat_minor": 4\n'
        "}\n"
    )
    assert result == expected


def test_entire_notebook_no_trailing_newline() -> None:
    content = read_jupyter_notebook("jupyter", "notebook_no_trailing_newline")
    result = format_file_contents(content, fast=True, mode=JUPYTER_MODE)
    expected = (
        "{\n"
        ' "cells": [\n'
        "  {\n"
        '   "cell_type": "code",\n'
        '   "execution_count": null,\n'
        '   "metadata": {\n'
        '    "tags": []\n'
        "   },\n"
        '   "outputs": [],\n'
        '   "source": [\n'
        '    "%%time\\n",\n'
        '    "\\n",\n'
        '    "print(\\"foo\\")"\n'
        "   ]\n"
        "  },\n"
        "  {\n"
        '   "cell_type": "code",\n'
        '   "execution_count": null,\n'
        '   "metadata": {},\n'
        '   "outputs": [],\n'
        '   "source": []\n'
        "  }\n"
        " ],\n"
        ' "metadata": {\n'
        '  "interpreter": {\n'
        '   "hash": "e758f3098b5b55f4d87fe30bbdc1367f20f246b483f96267ee70e6c40cb185d8"\n'  # noqa: B950
        "  },\n"
        '  "kernelspec": {\n'
        '   "display_name": "Python 3.8.10 64-bit (\'black\': venv)",\n'
        '   "name": "python3"\n'
        "  },\n"
        '  "language_info": {\n'
        '   "name": "python",\n'
        '   "version": ""\n'
        "  }\n"
        " },\n"
        ' "nbformat": 4,\n'
        ' "nbformat_minor": 4\n'
        "}"
    )
    assert result == expected


def test_entire_notebook_with_pyink_overrides() -> None:
    content = read_jupyter_notebook("pyink_configs", "example")
    result = format_file_contents(content, fast=True, mode=PYINK_JUPYTER_MODE)
    expected = (
        "{\n"
        '  "cells": [\n'
        "    {\n"
        '      "cell_type": "markdown",\n'
        '      "metadata": {},\n'
        '      "source": [\n'
        '        "### Unformatted notebook"\n'
        "      ]\n"
        "    },\n"
        "    {\n"
        '      "cell_type": "code",\n'
        '      "execution_count": null,\n'
        '      "metadata": {},\n'
        '      "outputs": [],\n'
        '      "source": [\n'
        '        "%%time\\n",\n'
        '        "\\n",\n'
        '        "a = 1\\n",\n'
        '        "if a == 1:\\n",\n'
        '        "    print(\\"\\")"\n'
        "      ]\n"
        "    }\n"
        "  ],\n"
        '  "metadata": {\n'
        '    "language_info": {\n'
        '      "name": "python"\n'
        "    }\n"
        "  },\n"
        '  "nbformat": 4,\n'
        '  "nbformat_minor": 5\n'
        "}\n"
    )
    assert result == expected


def test_entire_notebook_without_changes() -> None:
    content = read_jupyter_notebook("jupyter", "notebook_without_changes")
    with pytest.raises(NothingChanged):
        format_file_contents(content, fast=True, mode=JUPYTER_MODE)


def test_non_python_notebook() -> None:
    content = read_jupyter_notebook("jupyter", "non_python_notebook")

    with pytest.raises(NothingChanged):
        format_file_contents(content, fast=True, mode=JUPYTER_MODE)


def test_empty_string() -> None:
    with pytest.raises(NothingChanged):
        format_file_contents("", fast=True, mode=JUPYTER_MODE)


def test_unparseable_notebook() -> None:
    path = get_case_path("jupyter", "notebook_which_cant_be_parsed.ipynb")
    msg = rf"File '{re.escape(str(path))}' cannot be parsed as valid Jupyter notebook\."
    with pytest.raises(ValueError, match=msg):
        format_file_in_place(path, fast=True, mode=JUPYTER_MODE)


def test_ipynb_diff_with_change() -> None:
    result = runner.invoke(
        main,
        [
            str(get_case_path("jupyter", "notebook_trailing_newline.ipynb")),
            "--diff",
            f"--config={EMPTY_CONFIG}",
        ],
    )
    expected = "@@ -1,3 +1,3 @@\n %%time\n \n-print('foo')\n+print(\"foo\")\n"
    assert expected in result.output


def test_ipynb_diff_with_no_change() -> None:
    result = runner.invoke(
        main,
        [
            str(get_case_path("jupyter", "notebook_without_changes.ipynb")),
            "--diff",
            f"--config={EMPTY_CONFIG}",
        ],
    )
    expected = "1 file would be left unchanged."
    assert expected in result.output


def test_ipynb_diff_with_pyink_overrides() -> None:
    result = runner.invoke(
        main,
        [
            str(get_case_path("pyink_configs", "example.ipynb")),
            "--diff",
            f"--config={PYINK_OVERRIDE_CONFIG}",
        ],
    )
    expected = """00:00:cell_1
@@ -1,6 +1,5 @@
- %%time
+%%time
 
-a=1
-if a  ==1:
-    print("")
-
+a = 1
+if a == 1:
+  print("")"""
    assert expected in result.output


def test_cache_isnt_written_if_no_jupyter_deps_single(
    monkeypatch: MonkeyPatch, tmp_path: pathlib.Path
) -> None:
    # Check that the cache isn't written to if Jupyter dependencies aren't installed.
    jupyter_dependencies_are_installed.cache_clear()
    nb = get_case_path("jupyter", "notebook_trailing_newline.ipynb")
    tmp_nb = tmp_path / "notebook.ipynb"
    tmp_nb.write_bytes(nb.read_bytes())
    monkeypatch.setattr("pyink.jupyter_dependencies_are_installed", lambda warn: False)
    result = runner.invoke(
        main, [str(tmp_path / "notebook.ipynb"), f"--config={EMPTY_CONFIG}"]
    )
    assert "No Python files are present to be formatted. Nothing to do" in result.output
    jupyter_dependencies_are_installed.cache_clear()
    monkeypatch.setattr("pyink.jupyter_dependencies_are_installed", lambda warn: True)
    result = runner.invoke(
        main, [str(tmp_path / "notebook.ipynb"), f"--config={EMPTY_CONFIG}"]
    )
    assert "reformatted" in result.output


def test_cache_isnt_written_if_no_jupyter_deps_dir(
    monkeypatch: MonkeyPatch, tmp_path: pathlib.Path
) -> None:
    # Check that the cache isn't written to if Jupyter dependencies aren't installed.
    jupyter_dependencies_are_installed.cache_clear()
    nb = get_case_path("jupyter", "notebook_trailing_newline.ipynb")
    tmp_nb = tmp_path / "notebook.ipynb"
    tmp_nb.write_bytes(nb.read_bytes())
    monkeypatch.setattr(
        "pyink.files.jupyter_dependencies_are_installed", lambda warn: False
    )
    result = runner.invoke(main, [str(tmp_path), f"--config={EMPTY_CONFIG}"])
    assert "No Python files are present to be formatted. Nothing to do" in result.output
    jupyter_dependencies_are_installed.cache_clear()
    monkeypatch.setattr(
        "pyink.files.jupyter_dependencies_are_installed", lambda warn: True
    )
    result = runner.invoke(main, [str(tmp_path), f"--config={EMPTY_CONFIG}"])
    assert "reformatted" in result.output


def test_ipynb_flag(tmp_path: pathlib.Path) -> None:
    nb = get_case_path("jupyter", "notebook_trailing_newline.ipynb")
    tmp_nb = tmp_path / "notebook.a_file_extension_which_is_definitely_not_ipynb"
    tmp_nb.write_bytes(nb.read_bytes())
    result = runner.invoke(
        main,
        [
            str(tmp_nb),
            "--diff",
            "--ipynb",
            f"--config={EMPTY_CONFIG}",
        ],
    )
    expected = "@@ -1,3 +1,3 @@\n %%time\n \n-print('foo')\n+print(\"foo\")\n"
    assert expected in result.output


def test_ipynb_and_pyi_flags() -> None:
    nb = get_case_path("jupyter", "notebook_trailing_newline.ipynb")
    result = runner.invoke(
        main,
        [
            str(nb),
            "--pyi",
            "--ipynb",
            "--diff",
            f"--config={EMPTY_CONFIG}",
        ],
    )
    assert isinstance(result.exception, SystemExit)
    expected = "Cannot pass both `pyi` and `ipynb` flags!\n"
    assert result.output == expected


def test_unable_to_replace_magics(monkeypatch: MonkeyPatch) -> None:
    src = "%%time\na = 'foo'"
    monkeypatch.setattr("pyink.handle_ipynb_magics.TOKEN_HEX", lambda _: "foo")
    with pytest.raises(
        AssertionError, match="Black was not able to replace IPython magic"
    ):
        format_cell(src, fast=True, mode=JUPYTER_MODE)
