"""Tests for pip dual-forward: pip runs locally AND forwards to remote.

Covers the pip-forward feature where:
1. pip commands are forwarded to the remote venv first via SSH
2. The same pip command runs locally for IDE support
3. Exit code is 0 if either side succeeded (remote install matters for execution)
4. Remote failures are non-fatal warnings

Tests for ``pip_forward()`` in ``deps.py`` and the wrapper exit code logic.
"""

from __future__ import annotations

import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from specter_cli.deps import pip_forward

_SUBPROCESS_RUN = "specter_cli.deps.subprocess.run"
_SSH_BASE = "specter_cli.deps.ssh_base_args"
_LOAD_CONFIG = "specter_cli.config.load_remote_config"

_SAMPLE_CONFIG = {
    "host": "1.2.3.4",
    "user": "root",
    "ssh_port": 22,
    "remote_venv": "~/.specter/venv",
}


class TestPipForward:
    """Tests for deps.pip_forward() — SSH pip to remote venv."""

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_forwards_install_to_remote_venv(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """pip install numpy should SSH to remote venv's pip."""
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        rc = pip_forward(["/path/to/.venv/bin/pip", "install", "numpy"])

        assert rc == 0
        mock_run.assert_called_once()
        cmd = mock_run.call_args[0][0]
        ssh_cmd_str = " ".join(cmd)
        assert "~/.specter/venv/bin/pip install numpy" in ssh_cmd_str

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_skips_local_pip_script_path(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """First arg (pip script path) should be stripped."""
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        pip_forward(["/home/user/.venv/bin/pip", "install", "requests==2.31.0"])

        cmd = mock_run.call_args[0][0]
        ssh_cmd_str = " ".join(cmd)
        # Should NOT contain the local pip path.
        assert "/home/user/.venv/bin/pip" not in ssh_cmd_str
        # Should contain the pip args.
        assert "install requests==2.31.0" in ssh_cmd_str

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_forwards_uninstall(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """pip uninstall should also be forwarded."""
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        rc = pip_forward(["/path/to/bin/pip", "uninstall", "numpy", "-y"])

        assert rc == 0
        cmd = mock_run.call_args[0][0]
        ssh_cmd_str = " ".join(cmd)
        assert "uninstall numpy -y" in ssh_cmd_str

    @patch(_LOAD_CONFIG, return_value={})
    def test_no_config_returns_1(self, _config: MagicMock) -> None:
        """No active session → return 1 silently."""
        rc = pip_forward(["install", "numpy"])

        assert rc == 1

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_remote_failure_returns_nonzero(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """Remote pip failure returns non-zero exit code."""
        mock_run.return_value = MagicMock(
            returncode=1, stdout="", stderr="ERROR: No matching distribution"
        )

        rc = pip_forward(["/path/to/bin/pip", "install", "nonexistent-pkg"])

        assert rc == 1

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_timeout_returns_1(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """SSH timeout returns 1."""
        mock_run.side_effect = subprocess.TimeoutExpired(cmd="ssh", timeout=600)

        rc = pip_forward(["/path/to/bin/pip", "install", "torch"])

        assert rc == 1

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_uses_custom_remote_venv(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """Should use remote_venv from config."""
        _config.return_value = {**_SAMPLE_CONFIG, "remote_venv": "/opt/custom-venv"}
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        pip_forward(["/path/to/bin/pip", "install", "flask"])

        cmd = mock_run.call_args[0][0]
        ssh_cmd_str = " ".join(cmd)
        assert "/opt/custom-venv/bin/pip install flask" in ssh_cmd_str

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_passes_port_and_key(
        self, _config: MagicMock, mock_run: MagicMock, mock_ssh_base: MagicMock
    ) -> None:
        """Should pass SSH port and key from config."""
        _config.return_value = {**_SAMPLE_CONFIG, "ssh_port": 2222, "key": "/tmp/id_rsa"}
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        pip_forward(["/path/to/bin/pip", "install", "numpy"])

        mock_ssh_base.assert_called_once_with("1.2.3.4", "root", port=2222, key_path="/tmp/id_rsa")

    def test_empty_args_returns_zero(self) -> None:
        """No pip args → return 0."""
        rc = pip_forward([])

        assert rc == 0

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_args_without_path_prefix(
        self, _config: MagicMock, mock_run: MagicMock, _ssh_base: MagicMock
    ) -> None:
        """Args without a path prefix should be passed through directly."""
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        pip_forward(["install", "numpy"])

        cmd = mock_run.call_args[0][0]
        ssh_cmd_str = " ".join(cmd)
        assert "install numpy" in ssh_cmd_str


class TestPipForwardMain:
    """Tests for the --pip-forward CLI entry point in main()."""

    @patch("specter_cli.deps.pip_forward", return_value=0)
    def test_pip_forward_flag(self, mock_forward: MagicMock) -> None:
        """--pip-forward should call pip_forward() and exit."""
        import sys

        with patch.object(sys, "argv", ["deps", "--pip-forward", "install", "numpy"]):
            with pytest.raises(SystemExit) as exc_info:
                from specter_cli.deps import main

                main()

            assert exc_info.value.code == 0
            mock_forward.assert_called_once_with(["install", "numpy"])

    @patch("specter_cli.deps.pip_forward", return_value=1)
    def test_pip_forward_nonzero_exit(self, mock_forward: MagicMock) -> None:
        """Non-zero remote exit should propagate."""
        import sys

        with patch.object(sys, "argv", ["deps", "--pip-forward", "install", "bad-pkg"]):
            with pytest.raises(SystemExit) as exc_info:
                from specter_cli.deps import main

                main()

            assert exc_info.value.code == 1


class TestVersionedPythonRedirect:
    """Tests for python3.X redirect so pip shebangs route through wrapper.

    Standard venvs use the versioned binary (python3.12) in pip's shebang
    line.  patch_venv() must redirect python3.X → python (the wrapper) so
    the shebang chain resolves: python3.12 → python → wrapper → intercept pip.
    """

    def _make_venv(self, tmp_path: Path) -> Path:
        """Create a minimal fake venv with python, python3 → python, python3.12 → python."""
        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")
        # Versioned binary — this is what pip's shebang references.
        python312 = bin_dir / "python3.12"
        python312.symlink_to("python")
        return tmp_path

    def test_patch_redirects_versioned_python(self, tmp_path: Path) -> None:
        """After patch_venv(), python3.12 should be a symlink to python (wrapper)."""
        from specter_cli.venv import patch_venv

        venv = self._make_venv(tmp_path)
        bin_dir = venv / "bin"

        patch_venv(venv, "h100")

        python312 = bin_dir / "python3.12"
        assert python312.is_symlink()
        assert str(python312.readlink()) == "python"

    def test_patch_backs_up_versioned_python(self, tmp_path: Path) -> None:
        """After patch_venv(), python3.12.specter-original should exist."""
        from specter_cli.venv import patch_venv

        venv = self._make_venv(tmp_path)
        bin_dir = venv / "bin"

        patch_venv(venv, "h100")

        backup = bin_dir / "python3.12.specter-original"
        assert backup.exists(), "versioned backup should exist"

    def test_restore_restores_versioned_python(self, tmp_path: Path) -> None:
        """After restore, python3.12 should be restored from backup."""
        from specter_cli.venv import patch_venv, restore_venv

        venv = self._make_venv(tmp_path)
        bin_dir = venv / "bin"

        patch_venv(venv, "h100")
        restore_venv(venv)

        python312 = bin_dir / "python3.12"
        assert python312.exists(), "python3.12 should be restored"
        # Backup should be gone (renamed back to original).
        backup = bin_dir / "python3.12.specter-original"
        assert not backup.exists(), "backup should be consumed by restore"

    def test_patch_restore_patch_cycle_no_recursion(self, tmp_path: Path) -> None:
        """patch → restore → patch should leave wrapper chain intact.

        The key invariant: python.specter-original (REAL_PYTHON in the wrapper)
        must be the real binary, not the wrapper.  python3.12.specter-original
        is allowed to be a symlink to "python" because that's the original
        venv layout — nobody executes the versioned backup directly.
        """
        from specter_cli.venv import patch_venv, restore_venv

        venv = self._make_venv(tmp_path)
        bin_dir = venv / "bin"

        patch_venv(venv, "h100")
        restore_venv(venv)
        patch_venv(venv, "a100")

        # python3.12 should point to python (the wrapper) for pip interception.
        python312 = bin_dir / "python3.12"
        assert python312.is_symlink()
        assert str(python312.readlink()) == "python"

        # python.specter-original (REAL_PYTHON) must be the real binary.
        real_python = bin_dir / "python.specter-original"
        assert real_python.exists()
        assert not real_python.is_symlink(), "REAL_PYTHON must be real binary, not symlink"
        content = real_python.read_text()
        assert "#!/bin/bash" not in content, "REAL_PYTHON must not be the wrapper"

        # python (the wrapper) must be a bash script.
        wrapper = bin_dir / "python"
        assert "#!/bin/bash" in wrapper.read_text()

        # Versioned backup should exist for restore.
        backup = bin_dir / "python3.12.specter-original"
        assert backup.exists(), "versioned backup must exist for restore"

    def test_multiple_versioned_binaries(self, tmp_path: Path) -> None:
        """patch_venv should handle multiple versioned pythons (3.11, 3.12)."""
        from specter_cli.venv import patch_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")
        (bin_dir / "python3.11").symlink_to("python")
        (bin_dir / "python3.12").symlink_to("python")

        patch_venv(tmp_path, "h100")

        for name in ("python3.11", "python3.12"):
            p = bin_dir / name
            assert p.is_symlink(), f"{name} should be symlink after patch"
            assert str(p.readlink()) == "python", f"{name} should point to python"
            backup = bin_dir / f"{name}.specter-original"
            assert backup.exists(), f"{name} backup should exist"

    def test_no_versioned_binary_is_fine(self, tmp_path: Path) -> None:
        """patch_venv should work even if no python3.X exists."""
        from specter_cli.venv import patch_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")

        # Should not raise — just skip versioned redirect.
        patch_venv(tmp_path, "h100")

        assert (bin_dir / "python").exists()
        assert (tmp_path / ".specter.json").exists()

    def test_pip_shebang_chain_resolves_through_wrapper(self, tmp_path: Path) -> None:
        """Simulate pip's shebang: python3.12 → python (wrapper) → intercept."""
        from specter_cli.venv import patch_venv

        venv = self._make_venv(tmp_path)
        bin_dir = venv / "bin"

        # Simulate pip with versioned shebang.
        pip_bin = bin_dir / "pip"
        pip_bin.write_text(f"#!{bin_dir}/python3.12\nimport pip\npip.main()\n")
        pip_bin.chmod(0o755)

        patch_venv(venv, "h100")

        # python3.12 now points to python (the wrapper).
        python312 = bin_dir / "python3.12"
        assert python312.is_symlink()
        target = str(python312.readlink())
        assert target == "python", "python3.12 should point to wrapper"

        # And python IS the wrapper (bash script).
        wrapper_content = (bin_dir / "python").read_text()
        assert "#!/bin/bash" in wrapper_content
        assert "specter" in wrapper_content.lower()


class TestShebangCompatSymlink:
    """Tests for the compat symlink that fixes entry point shebangs."""

    def test_restore_leaves_compat_symlink(self, tmp_path: Path) -> None:
        """After restore_venv(), python.specter-original should be a symlink to python."""
        from specter_cli.venv import patch_venv, restore_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")

        patch_venv(tmp_path, "h100")
        restore_venv(tmp_path)

        compat = bin_dir / "python.specter-original"
        assert compat.is_symlink(), "compat symlink should exist after restore"
        assert str(compat.readlink()) == "python"

    def test_entry_point_shebang_resolves_after_restore(self, tmp_path: Path) -> None:
        """Entry points with python.specter-original shebang should resolve."""
        from specter_cli.venv import patch_venv, restore_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")

        patch_venv(tmp_path, "h100")

        # Simulate pip creating entry point with backup python shebang.
        entry_point = bin_dir / "my-tool"
        entry_point.write_text(f"#!{bin_dir}/python.specter-original\nprint('hello')\n")
        entry_point.chmod(0o755)

        restore_venv(tmp_path)

        # The compat symlink makes the shebang resolve.
        compat = bin_dir / "python.specter-original"
        assert compat.exists(), "compat symlink must exist for entry points to work"

    def test_patch_removes_compat_symlink(self, tmp_path: Path) -> None:
        """patch_venv() should remove compat symlink before backing up."""
        from specter_cli.venv import patch_venv, restore_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")

        # Patch → restore → patch cycle.
        patch_venv(tmp_path, "h100")
        restore_venv(tmp_path)
        patch_venv(tmp_path, "a100")

        # After re-patch, python.specter-original should be the real binary,
        # NOT a symlink to python (the wrapper).
        backup = bin_dir / "python.specter-original"
        assert backup.exists()
        assert not backup.is_symlink(), "backup should be real file, not compat symlink"

    def test_no_infinite_recursion_after_restore_repatch(self, tmp_path: Path) -> None:
        """After restore → re-patch, the wrapper must NOT point to itself."""
        from specter_cli.venv import patch_venv, restore_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n# real python\n")
        python_bin.chmod(0o755)
        python3_bin = bin_dir / "python3"
        python3_bin.symlink_to("python")

        # Full cycle: patch → restore → patch.
        patch_venv(tmp_path, "h100")
        restore_venv(tmp_path)
        patch_venv(tmp_path, "a100")

        # python.specter-original should NOT be a symlink to python.
        backup = bin_dir / "python.specter-original"
        if backup.is_symlink():
            target = str(backup.readlink())
            assert target != "python", "backup must not point to the wrapper"
        else:
            content = backup.read_text()
            assert "#!/bin/bash" not in content, "backup should be original python, not wrapper"

    def test_restore_without_backup_no_symlink(self, tmp_path: Path) -> None:
        """If venv was never patched, restore_venv should not create a symlink."""
        from specter_cli.venv import restore_venv

        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python_bin = bin_dir / "python"
        python_bin.write_text("#!/usr/bin/env python3\n")
        python_bin.chmod(0o755)

        result = restore_venv(tmp_path)

        assert result is False
        compat = bin_dir / "python.specter-original"
        assert not compat.exists(), "no symlink should be created if nothing was restored"
