import os
import sys

from alembic import command
from alembic import util
from alembic.script import write_hooks
from alembic.testing import assert_raises_message
from alembic.testing import combinations
from alembic.testing import eq_
from alembic.testing import mock
from alembic.testing import TestBase
from alembic.testing.env import _get_staging_directory
from alembic.testing.env import _no_sql_testing_config
from alembic.testing.env import clear_staging_env
from alembic.testing.env import staging_env
from alembic.util import compat


class HookTest(TestBase):
    def test_register(self):
        @write_hooks.register("my_writer")
        def my_writer(path, config):
            return path

        assert "my_writer" in write_hooks._registry

    def test_invoke(self):
        my_formatter = mock.Mock()
        write_hooks.register("my_writer")(my_formatter)

        write_hooks._invoke("my_writer", "/some/path", {"option": 1})

        my_formatter.assert_called_once_with("/some/path", {"option": 1})


class RunHookTest(TestBase):
    def setUp(self):
        self.env = staging_env()

    def tearDown(self):
        clear_staging_env()

    def test_generic(self):
        hook1 = mock.Mock()
        hook2 = mock.Mock()

        write_hooks.register("hook1")(hook1)
        write_hooks.register("hook2")(hook2)

        self.cfg = _no_sql_testing_config(
            directives=(
                "\n[post_write_hooks]\n"
                "hooks=hook1,hook2\n"
                "hook1.type=hook1\n"
                "hook1.arg1=foo\n"
                "hook2.type=hook2\n"
                "hook2.arg1=bar\n"
            )
        )

        rev = command.revision(self.cfg, message="x")

        eq_(
            hook1.mock_calls,
            [
                mock.call(
                    rev.path,
                    {"type": "hook1", "arg1": "foo", "_hook_name": "hook1"},
                )
            ],
        )
        eq_(
            hook2.mock_calls,
            [
                mock.call(
                    rev.path,
                    {"type": "hook2", "arg1": "bar", "_hook_name": "hook2"},
                )
            ],
        )

    def test_empty_section(self):
        self.cfg = _no_sql_testing_config(
            directives=("\n[post_write_hooks]\n")
        )

        command.revision(self.cfg, message="x")

    def test_no_section(self):
        self.cfg = _no_sql_testing_config(directives="")

        command.revision(self.cfg, message="x")

    def test_empty_hooks(self):
        self.cfg = _no_sql_testing_config(
            directives=("\n[post_write_hooks]\n" "hooks=\n")
        )

        command.revision(self.cfg, message="x")

    def test_no_type(self):
        self.cfg = _no_sql_testing_config(
            directives=(
                "\n[post_write_hooks]\n" "hooks=foo\n" "foo.bar=somebar\n"
            )
        )

        assert_raises_message(
            util.CommandError,
            "Key foo.type is required for post write hook 'foo'",
            command.revision,
            self.cfg,
            message="x",
        )

    def test_console_scripts_entrypoint_missing(self):
        self.cfg = _no_sql_testing_config(
            directives=(
                "\n[post_write_hooks]\n"
                "hooks=black\n"
                "black.type=console_scripts\n"
            )
        )
        assert_raises_message(
            util.CommandError,
            "Key black.entrypoint is required for post write hook 'black'",
            command.revision,
            self.cfg,
            message="x",
        )

    def _run_black_with_config(
        self, input_config, expected_additional_arguments_fn, cwd=None
    ):
        self.cfg = _no_sql_testing_config(directives=input_config)

        retVal = [
            compat.EntryPoint(
                name="black",
                value="black.foo:patched_main",
                group="console_scripts",
            ),
            compat.EntryPoint(
                name="alembic",
                value="alembic.config:main",
                group="console_scripts",
            ),
        ]

        importlib_metadata_get = mock.Mock(return_value=retVal)
        with (
            mock.patch(
                "alembic.util.compat.importlib_metadata_get",
                importlib_metadata_get,
            ),
            mock.patch(
                "alembic.script.write_hooks.subprocess"
            ) as mock_subprocess,
        ):
            rev = command.revision(self.cfg, message="x")

        eq_(importlib_metadata_get.mock_calls, [mock.call("console_scripts")])
        eq_(
            mock_subprocess.mock_calls,
            [
                mock.call.run(
                    [
                        sys.executable,
                        "-c",
                        "import black.foo; black.foo.patched_main()",
                    ]
                    + expected_additional_arguments_fn(rev.path),
                    cwd=cwd,
                )
            ],
        )

    def test_console_scripts(self):
        input_config = """
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = -l 79
        """

        def expected_additional_arguments_fn(rev_path):
            return [rev_path, "-l", "79"]

        self._run_black_with_config(
            input_config, expected_additional_arguments_fn
        )

    @combinations(True, False)
    def test_filename_interpolation(self, posix):
        input_config = """
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = arg1 REVISION_SCRIPT_FILENAME 'multi-word arg' \
    --flag1='REVISION_SCRIPT_FILENAME'
        """

        def expected_additional_arguments_fn(rev_path):
            if compat.is_posix:
                return [
                    "arg1",
                    rev_path,
                    "multi-word arg",
                    "--flag1=" + rev_path,
                ]
            else:
                return [
                    "arg1",
                    rev_path,
                    "'multi-word arg'",
                    "--flag1='%s'" % rev_path,
                ]

        with mock.patch("alembic.util.compat.is_posix", posix):
            self._run_black_with_config(
                input_config, expected_additional_arguments_fn
            )

    def test_path_in_config(self):
        input_config = """
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.options = arg1 REVISION_SCRIPT_FILENAME --config %(here)s/pyproject.toml
        """

        def expected_additional_arguments_fn(rev_path):
            return [
                "arg1",
                rev_path,
                "--config",
                os.path.abspath(_get_staging_directory()) + "/pyproject.toml",
            ]

        self._run_black_with_config(
            input_config, expected_additional_arguments_fn
        )

    def test_black_with_cwd(self):
        input_config = """
[post_write_hooks]
hooks = black
black.type = console_scripts
black.entrypoint = black
black.cwd = /path/to/cwd
        """

        def expected_additional_arguments_fn(rev_path):
            return [rev_path]

        self._run_black_with_config(
            input_config, expected_additional_arguments_fn, cwd="/path/to/cwd"
        )

    def test_exec_executable_missing(self):
        self.cfg = _no_sql_testing_config(
            directives=(
                "\n[post_write_hooks]\n" "hooks=ruff\n" "ruff.type=exec\n"
            )
        )
        assert_raises_message(
            util.CommandError,
            "Key ruff.executable is required for post write hook 'ruff'",
            command.revision,
            self.cfg,
            message="x",
        )

    def _run_ruff_with_config(
        self,
        input_config,
        expected_additional_arguments_fn,
        executable="ruff",
        cwd=None,
    ):
        self.cfg = _no_sql_testing_config(directives=input_config)

        with mock.patch(
            "alembic.script.write_hooks.subprocess"
        ) as mock_subprocess:
            rev = command.revision(self.cfg, message="x")

        eq_(
            mock_subprocess.mock_calls,
            [
                mock.call.run(
                    [
                        executable,
                    ]
                    + expected_additional_arguments_fn(rev.path),
                    cwd=cwd,
                )
            ],
        )

    def test_exec_with_path_search(self):
        input_config = """
[post_write_hooks]
hooks = ruff
ruff.type = exec
ruff.executable = ruff
ruff.options = check --fix
        """

        def expected_additional_arguments_fn(rev_path):
            return [rev_path, "check", "--fix"]

        self._run_ruff_with_config(
            input_config, expected_additional_arguments_fn
        )

    def test_exec_with_full_pathname(self):
        input_config = """
[post_write_hooks]
hooks = ruff
ruff.type = exec
ruff.executable = %(here)s/.venv/bin/ruff
ruff.options = check --fix
        """

        def expected_additional_arguments_fn(rev_path):
            return [rev_path, "check", "--fix"]

        self._run_ruff_with_config(
            input_config,
            expected_additional_arguments_fn,
            executable=os.path.abspath(_get_staging_directory())
            + "/.venv/bin/ruff",
        )

    @combinations(True, False)
    def test_exec_with_filename_interpolation(self, posix):
        input_config = """
[post_write_hooks]
hooks = ruff
ruff.type = exec
ruff.executable = ruff
ruff.options = arg1 REVISION_SCRIPT_FILENAME 'multi-word arg' \
    --flag1='REVISION_SCRIPT_FILENAME'
        """

        def expected_additional_arguments_fn(rev_path):
            if compat.is_posix:
                return [
                    "arg1",
                    rev_path,
                    "multi-word arg",
                    "--flag1=" + rev_path,
                ]
            else:
                return [
                    "arg1",
                    rev_path,
                    "'multi-word arg'",
                    "--flag1='%s'" % rev_path,
                ]

        with mock.patch("alembic.util.compat.is_posix", posix):
            self._run_ruff_with_config(
                input_config, expected_additional_arguments_fn
            )

    def test_exec_with_path_in_config(self):
        input_config = """
[post_write_hooks]
hooks = ruff
ruff.type = exec
ruff.executable = ruff
ruff.options = arg1 REVISION_SCRIPT_FILENAME --config %(here)s/pyproject.toml
        """

        def expected_additional_arguments_fn(rev_path):
            return [
                "arg1",
                rev_path,
                "--config",
                os.path.abspath(_get_staging_directory()) + "/pyproject.toml",
            ]

        self._run_ruff_with_config(
            input_config, expected_additional_arguments_fn
        )

    def test_exec_with_cwd(self):
        input_config = """
[post_write_hooks]
hooks = ruff
ruff.type = exec
ruff.executable = ruff
ruff.cwd = /path/to/cwd
        """

        def expected_additional_arguments_fn(rev_path):
            return [rev_path]

        self._run_ruff_with_config(
            input_config, expected_additional_arguments_fn, cwd="/path/to/cwd"
        )
