import inspect
import io
import logging
import sys
from pathlib import Path
from unittest.mock import Mock
from uuid import uuid4

import pytest
from fastjsonschema import JsonSchemaValueException

from validate_pyproject import cli, errors, plugins


class TestHelp:
    def test_list_default_plugins(self, capsys):
        with pytest.raises(SystemExit):
            cli.main(["--help"])
        captured = capsys.readouterr()
        assert "setuptools" in captured.out
        assert "distutils" in captured.out

    def test_no_plugins(self, capsys):
        with pytest.raises(SystemExit):
            cli.parse_args(["--help"], plugins=[])
        captured = capsys.readouterr()
        assert "setuptools" not in captured.out
        assert "distutils" not in captured.out

    def test_custom_plugins(self, capsys):
        fake_plugin = plugins.PluginWrapper("my42", lambda _: {})
        with pytest.raises(SystemExit):
            cli.parse_args(["--help"], plugins=[fake_plugin])
        captured = capsys.readouterr()
        assert "my42" in captured.out


def parse_args(args):
    plg = plugins.list_from_entry_points()
    return cli.parse_args(args, plg)


simple_example = """\
[project]
name = "myproj"
version = "0"

[tool.setuptools]
zip-safe = false
packages = {find = {}}
"""


def write_example(dir_path, *, name="pyproject.toml", _text=simple_example):
    path = Path(dir_path, name)
    path.write_text(_text, "UTF-8")
    return path


def write_invalid_example(dir_path, *, name="pyproject.toml"):
    text = simple_example.replace("zip-safe = false", "zip-safe = { hello = 'world' }")
    return write_example(dir_path, name=name, _text=text)


@pytest.fixture
def valid_example(tmp_path):
    return write_example(tmp_path)


@pytest.fixture
def invalid_example(tmp_path):
    return write_invalid_example(tmp_path)


class TestEnable:
    TOOLS = ("setuptools", "distutils")

    @pytest.mark.parametrize("tool", TOOLS)
    def test_parse(self, valid_example, tool):
        params = parse_args([str(valid_example), "-E", tool])
        assert len(params.plugins) == 1
        assert params.plugins[0].tool == tool

        # Meta test:
        schema = params.plugins[0].schema
        if tool == "setuptools":
            assert "zip-safe" in schema["properties"]
            assert schema["properties"]["zip-safe"]["type"] == "boolean"

    def test_valid(self, valid_example):
        assert cli.main([str(valid_example), "-E", "setuptools"]) == 0

    def test_invalid(self, invalid_example):
        print(invalid_example.read_text())
        with pytest.raises(JsonSchemaValueException):
            cli.run([str(invalid_example), "-E", "setuptools"])

    def test_invalid_not_enabled(self, invalid_example):
        # When the plugin is not enabled, the validator should ignore the tool
        assert cli.main([str(invalid_example), "-E", "distutils"]) == 0


class TestDisable:
    TOOLS = ("setuptools", "distutils")

    @pytest.mark.parametrize(("tool", "other_tool"), zip(TOOLS, reversed(TOOLS)))
    def test_parse(self, valid_example, tool, other_tool):
        all_plugins = parse_args([str(valid_example), "-D", tool]).plugins
        our_plugins = [p for p in all_plugins if p.id.startswith("validate_pyproject")]
        assert len(our_plugins) == 1
        assert our_plugins[0].tool == other_tool

    def test_valid(self, valid_example):
        assert cli.run([str(valid_example), "-D", "distutils"]) == 0

    def test_invalid(self, invalid_example):
        print(invalid_example.read_text())
        with pytest.raises(JsonSchemaValueException):
            cli.run([str(invalid_example), "-D", "distutils"])

    def test_invalid_disabled(self, invalid_example):
        # When the plugin is disabled, the validator should ignore the tool
        assert cli.main([str(invalid_example), "-D", "setuptools"]) == 0


class TestInput:
    def test_inform_user_about_stdin(self, monkeypatch):
        print_mock = Mock()
        fake_stdin = io.StringIO('[project]\nname="test"\nversion="0.42"\n')
        with monkeypatch.context() as ctx:
            ctx.setattr("validate_pyproject.cli._STDIN", fake_stdin)
            ctx.setattr("sys.argv", ["validate-pyproject"])
            ctx.setattr("builtins.print", print_mock)
            cli.run()
        calls = print_mock.call_args_list
        assert any("input via `stdin`" in str(args[0]) for args, _kwargs in calls)


class TestOutput:
    def test_valid(self, capsys, valid_example):
        cli.main([str(valid_example)])
        captured = capsys.readouterr()
        assert "valid" in captured.out.lower()

    def test_invalid(self, caplog, invalid_example):
        caplog.set_level(logging.DEBUG)
        with pytest.raises(SystemExit):
            cli.main([str(invalid_example)])
        captured = caplog.text.lower()
        assert "`tool.setuptools.zip-safe` must be boolean" in captured
        assert "offending rule" in captured
        assert "given value" in captured
        assert '"type": "boolean"' in captured


def test_multiple_files(tmp_path, capsys):
    N = 3

    valid_files = [
        write_example(tmp_path, name=f"valid-pyproject{i}.toml") for i in range(N)
    ]
    cli.run(map(str, valid_files))
    captured = capsys.readouterr().out.lower()
    number_valid = captured.count("valid file:")
    assert number_valid == N

    invalid_files = [
        write_invalid_example(tmp_path, name=f"invalid-pyproject{i}.toml")
        for i in range(N + 3)
    ]
    with pytest.raises(SystemExit):
        cli.main(map(str, valid_files + invalid_files))

    repl = str(uuid4())
    captured = capsys.readouterr().out.lower()
    captured = captured.replace("invalid file:", repl)
    number_invalid = captured.count(repl)
    number_valid = captured.count("valid file:")
    captured = captured.replace(repl, "invalid file:")
    assert number_valid == N
    assert number_invalid == N + 3


def test_missing_toolname(tmp_path):
    example = write_example(tmp_path, name="valid-pyproject.toml")
    with pytest.raises(
        errors.URLMissingTool,
        match=r"Correct form is '--tool <tool-name>=http://json\.schemastore\.org/poetry\.toml', with an optional",
    ):
        cli.run(["--tool=http://json.schemastore.org/poetry.toml", str(example)])


def test_bad_url(tmp_path):
    example = write_example(tmp_path, name="valid-pyproject.toml")
    with pytest.raises(ValueError, match="URL must start with 'http:' or 'https:'"):
        cli.run(
            ["--tool", "poetry=file://json.schemastore.org/poetry.toml", str(example)]
        )


def test_bad_extra_url(tmp_path):
    example = write_example(tmp_path, name="valid-pyproject.toml")
    with pytest.raises(ValueError, match="URL must start with 'http:' or 'https:'"):
        cli.run(["--tool", "=file://json.schemastore.org/poetry.toml", str(example)])


@pytest.mark.skipif(sys.version_info[:2] < (3, 11), reason="requires 3.11+")
def test_parser_is_tomllib():
    """Make sure Python >= 3.11 uses tomllib instead of tomli"""
    module_name = inspect.getmodule(cli.tomllib.loads).__name__
    assert module_name.startswith("tomllib")
