"""Tests for dependency synchronisation (specter_cli.deps).

Verifies pip freeze → rsync → remote pip install flow with mocked
subprocess calls and SSH infrastructure.
"""

from __future__ import annotations

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

import pytest

from specter_cli.deps import (
    REMOTE_VENV,
    _pip_freeze,
    check_python_version,
    create_remote_venv,
    get_local_python_version,
    get_remote_python_version,
    sync_deps,
)

_SUBPROCESS_RUN = "specter_cli.deps.subprocess.run"
_RSYNC_SSH = "specter_cli.deps.rsync_ssh_args"
_SSH_BASE = "specter_cli.deps.ssh_base_args"


@pytest.fixture
def fake_venv(tmp_path: Path) -> Path:
    """Create a minimal fake venv with python and python.specter-original."""
    bin_dir = tmp_path / "bin"
    bin_dir.mkdir()
    # Create the backed-up python binary.
    original = bin_dir / "python.specter-original"
    original.write_text("#!/bin/bash\n")
    original.chmod(0o755)
    # Also create the regular python (wrapper).
    python = bin_dir / "python"
    python.write_text("#!/bin/bash\n# wrapper\n")
    python.chmod(0o755)
    return tmp_path


class TestPipFreeze:
    """_pip_freeze uses python.specter-original when available."""

    @patch(_SUBPROCESS_RUN)
    def test_uses_specter_original(self, mock_run: MagicMock, fake_venv: Path) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="torch==2.3.0\n")

        result = _pip_freeze(fake_venv)

        assert result == "torch==2.3.0\n"
        cmd = mock_run.call_args[0][0]
        assert "python.specter-original" in cmd[0]

    @patch(_SUBPROCESS_RUN)
    def test_falls_back_to_python(self, mock_run: MagicMock, tmp_path: Path) -> None:
        # Venv without specter-original — uses regular python.
        bin_dir = tmp_path / "bin"
        bin_dir.mkdir()
        python = bin_dir / "python"
        python.write_text("#!/bin/bash\n")
        python.chmod(0o755)

        mock_run.return_value = MagicMock(returncode=0, stdout="requests==2.31.0\n")

        result = _pip_freeze(tmp_path)

        assert result == "requests==2.31.0\n"
        cmd = mock_run.call_args[0][0]
        assert cmd[0].endswith("/python")

    @patch(_SUBPROCESS_RUN)
    def test_returns_empty_on_failure(self, mock_run: MagicMock, fake_venv: Path) -> None:
        mock_run.return_value = MagicMock(returncode=1, stderr="error", stdout="")

        result = _pip_freeze(fake_venv)

        assert result == ""


class TestSyncDeps:
    """sync_deps: pip freeze → rsync → remote pip install."""

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_RSYNC_SSH, return_value="ssh -o ControlMaster=auto")
    @patch(_SUBPROCESS_RUN)
    def test_happy_path(
        self,
        mock_run: MagicMock,
        _rsync_ssh: MagicMock,
        _ssh_base: MagicMock,
        fake_venv: Path,
    ) -> None:
        # pip freeze returns packages.
        mock_run.side_effect = [
            # pip freeze
            MagicMock(returncode=0, stdout="torch==2.3.0\nrequests==2.31.0\n"),
            # rsync
            MagicMock(returncode=0),
            # remote pip install
            MagicMock(returncode=0, stderr=""),
        ]

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
            port=22,
        )

        assert result is True
        assert mock_run.call_count == 3

        # Verify rsync call includes the requirements file.
        rsync_call = mock_run.call_args_list[1]
        rsync_cmd = rsync_call[0][0]
        assert rsync_cmd[0] == "rsync"
        assert any("specter-requirements.txt" in arg for arg in rsync_cmd)

        # Verify SSH pip install call.
        ssh_call = mock_run.call_args_list[2]
        ssh_cmd = ssh_call[0][0]
        assert ssh_cmd[0] == "ssh"
        assert "~/.specter/venv/bin/pip install -q -r /tmp/specter-requirements.txt" in " ".join(
            ssh_cmd
        )

    @patch(_SUBPROCESS_RUN)
    def test_empty_freeze_returns_false(self, mock_run: MagicMock, fake_venv: Path) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="\n")

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
        )

        assert result is False
        # Only pip freeze was called — no rsync or SSH.
        assert mock_run.call_count == 1

    @patch(_SUBPROCESS_RUN)
    def test_filters_specter_host(self, mock_run: MagicMock, fake_venv: Path) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="specter-host==0.1.0\n")

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
        )

        assert result is False

    @patch(_SUBPROCESS_RUN)
    def test_filters_specter_host_underscore(self, mock_run: MagicMock, fake_venv: Path) -> None:
        """pip may output specter_host (underscore) instead of specter-host."""
        mock_run.return_value = MagicMock(returncode=0, stdout="specter_host==0.1.0\n")

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
        )

        assert result is False

    @patch(_SUBPROCESS_RUN)
    def test_filters_specter_host_file_path(self, mock_run: MagicMock, fake_venv: Path) -> None:
        """pip freeze outputs '@ file:///' for local path installs."""
        mock_run.return_value = MagicMock(
            returncode=0,
            stdout="specter-host @ file:///home/ubuntu/specter-cli\n",
        )

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
        )

        assert result is False

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_RSYNC_SSH, return_value="ssh -o ControlMaster=auto")
    @patch(_SUBPROCESS_RUN)
    def test_remote_pip_failure_still_returns_true(
        self,
        mock_run: MagicMock,
        _rsync_ssh: MagicMock,
        _ssh_base: MagicMock,
        fake_venv: Path,
    ) -> None:
        """Remote pip install failure is a warning, not an error."""
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="torch==2.3.0\n"),
            MagicMock(returncode=0),  # rsync OK
            MagicMock(returncode=1, stderr="ERROR: Could not find version"),
        ]

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
        )

        # Still returns True — deps were sent, some just failed.
        assert result is True

    @patch(_SUBPROCESS_RUN)
    def test_pip_freeze_failure_returns_false(self, mock_run: MagicMock, fake_venv: Path) -> None:
        mock_run.return_value = MagicMock(returncode=1, stderr="error", stdout="")

        result = sync_deps(
            fake_venv,
            host="1.2.3.4",
            user="root",
        )

        assert result is False

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_RSYNC_SSH, return_value="ssh -o ControlMaster=auto")
    @patch(_SUBPROCESS_RUN)
    def test_custom_port_and_key(
        self,
        mock_run: MagicMock,
        mock_rsync_ssh: MagicMock,
        mock_ssh_base: MagicMock,
        fake_venv: Path,
    ) -> None:
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="numpy==1.26.0\n"),
            MagicMock(returncode=0),
            MagicMock(returncode=0, stderr=""),
        ]

        sync_deps(
            fake_venv,
            host="5.6.7.8",
            user="ubuntu",
            port=2222,
            key_path="/home/user/.ssh/id_rsa",
        )

        mock_rsync_ssh.assert_called_once_with(port=2222, key_path="/home/user/.ssh/id_rsa")
        mock_ssh_base.assert_called_once_with(
            "5.6.7.8", "ubuntu", port=2222, key_path="/home/user/.ssh/id_rsa"
        )

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_RSYNC_SSH, return_value="ssh -o ControlMaster=auto")
    @patch(_SUBPROCESS_RUN)
    def test_cleans_up_temp_file(
        self,
        mock_run: MagicMock,
        _rsync_ssh: MagicMock,
        _ssh_base: MagicMock,
        fake_venv: Path,
    ) -> None:
        """Temp requirements file is cleaned up even on failure."""
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="torch==2.3.0\n"),
            Exception("rsync failed"),
        ]

        with pytest.raises(Exception, match="rsync failed"):
            sync_deps(fake_venv, host="1.2.3.4", user="root")

        # Temp file should have been cleaned up by the finally block.
        # We can't easily check this directly, but the test verifies
        # the finally block runs by not leaving the test hanging.


class TestSyncDepsMain:
    """deps.main() — entry point for the wrapper's pip hook."""

    @patch("specter_cli.deps.sync_deps", return_value=True)
    @patch(
        "specter_cli.config.load_remote_config",
        return_value={
            "host": "1.2.3.4",
            "user": "root",
            "ssh_port": 22,
        },
    )
    @patch.dict("os.environ", {"VIRTUAL_ENV": "/home/user/.venv"})
    @patch("specter_cli.deps.Path")
    def test_main_syncs_when_configured(
        self,
        mock_path_cls: MagicMock,
        _config: MagicMock,
        mock_sync: MagicMock,
    ) -> None:
        from specter_cli.deps import main

        mock_path_instance = MagicMock()
        mock_path_instance.is_dir.return_value = True
        mock_path_cls.return_value = mock_path_instance

        main()

        mock_sync.assert_called_once()

    @patch("specter_cli.deps.sync_deps")
    @patch("specter_cli.config.load_remote_config", return_value={})
    def test_main_no_config_skips(
        self,
        _config: MagicMock,
        mock_sync: MagicMock,
    ) -> None:
        from specter_cli.deps import main

        main()

        mock_sync.assert_not_called()

    @patch("specter_cli.deps.sync_deps")
    @patch(
        "specter_cli.config.load_remote_config",
        return_value={"host": "1.2.3.4", "user": "root"},
    )
    @patch.dict("os.environ", {}, clear=True)
    def test_main_no_venv_skips(
        self,
        _config: MagicMock,
        mock_sync: MagicMock,
    ) -> None:
        from specter_cli.deps import main

        main()

        mock_sync.assert_not_called()

    @patch("specter_cli.deps.sync_deps", side_effect=Exception("SSH failed"))
    @patch(
        "specter_cli.config.load_remote_config",
        return_value={
            "host": "1.2.3.4",
            "user": "root",
            "ssh_port": 22,
        },
    )
    @patch.dict("os.environ", {"VIRTUAL_ENV": "/home/user/.venv"})
    @patch("specter_cli.deps.Path")
    def test_main_handles_errors_gracefully(
        self,
        mock_path_cls: MagicMock,
        _config: MagicMock,
        _sync: MagicMock,
    ) -> None:
        """sync failure should not crash — just print a warning."""
        from specter_cli.deps import main

        mock_path_instance = MagicMock()
        mock_path_instance.is_dir.return_value = True
        mock_path_cls.return_value = mock_path_instance

        # Should not raise — errors are caught and logged.
        main()


class TestCreateRemoteVenv:
    """create_remote_venv creates a venv on the remote pod via SSH."""

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_happy_path(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        result = create_remote_venv(host="1.2.3.4", user="root")

        mock_run.assert_called_once()
        cmd = mock_run.call_args[0][0]
        ssh_cmd_str = " ".join(cmd)
        assert "python3 -m venv --system-site-packages ~/.specter/venv" in ssh_cmd_str
        assert result == REMOTE_VENV

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_failure_raises(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="venv failed")

        with pytest.raises(subprocess.CalledProcessError):
            create_remote_venv(host="1.2.3.4", user="root")

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_custom_venv_path(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        result = create_remote_venv(host="1.2.3.4", user="root", venv_path="/opt/custom-venv")

        cmd = mock_run.call_args[0][0]
        assert "/opt/custom-venv" in " ".join(cmd)
        assert result == "/opt/custom-venv"

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_custom_port_and_key(self, mock_run: MagicMock, mock_ssh_base: MagicMock) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        create_remote_venv(host="5.6.7.8", user="ubuntu", port=2222, key_path="/tmp/key")

        mock_ssh_base.assert_called_once_with("5.6.7.8", "ubuntu", port=2222, key_path="/tmp/key")

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_tilde_not_quoted(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        """Tilde in venv path should not be quoted — remote shell expands it."""
        mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")

        create_remote_venv(host="1.2.3.4", user="root")

        cmd = mock_run.call_args[0][0]
        # The venv path appears as the last part of the SSH command string.
        ssh_remote_cmd = cmd[-1]
        assert "~/.specter/venv" in ssh_remote_cmd
        assert "'" not in ssh_remote_cmd  # Not quoted


class TestGetLocalPythonVersion:
    """get_local_python_version returns 'major.minor' from sys.version_info."""

    def test_returns_major_minor(self) -> None:
        import sys

        expected = f"{sys.version_info.major}.{sys.version_info.minor}"
        assert get_local_python_version() == expected

    @patch("specter_cli.deps.sys")
    def test_format(self, mock_sys: MagicMock) -> None:
        mock_sys.version_info.major = 3
        mock_sys.version_info.minor = 12
        assert get_local_python_version() == "3.12"


class TestGetRemotePythonVersion:
    """get_remote_python_version SSHs to remote and returns version string."""

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_returns_version_on_success(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="3.12\n")

        result = get_remote_python_version(host="1.2.3.4", user="root")

        assert result == "3.12"

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_returns_none_on_failure(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")

        result = get_remote_python_version(host="1.2.3.4", user="root")

        assert result is None

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_returns_none_on_timeout(self, mock_run: MagicMock, _ssh_base: MagicMock) -> None:
        import subprocess

        mock_run.side_effect = subprocess.TimeoutExpired(cmd="ssh", timeout=30)

        result = get_remote_python_version(host="1.2.3.4", user="root")

        assert result is None

    @patch(_SSH_BASE, return_value=["-o", "StrictHostKeyChecking=no", "root@1.2.3.4"])
    @patch(_SUBPROCESS_RUN)
    def test_passes_custom_port_and_key(
        self, mock_run: MagicMock, mock_ssh_base: MagicMock
    ) -> None:
        mock_run.return_value = MagicMock(returncode=0, stdout="3.11\n")

        get_remote_python_version(host="5.6.7.8", user="ubuntu", port=2222, key_path="/tmp/key")

        mock_ssh_base.assert_called_once_with("5.6.7.8", "ubuntu", port=2222, key_path="/tmp/key")


class TestCheckPythonVersion:
    """check_python_version compares local and remote versions."""

    @patch("specter_cli.deps.get_remote_python_version", return_value="3.12")
    @patch("specter_cli.deps.get_local_python_version", return_value="3.12")
    def test_matching_versions(self, _local: MagicMock, _remote: MagicMock) -> None:
        local, remote = check_python_version(host="1.2.3.4", user="root")

        assert local == "3.12"
        assert remote == "3.12"

    @patch("specter_cli.deps.get_remote_python_version", return_value="3.12")
    @patch("specter_cli.deps.get_local_python_version", return_value="3.11")
    def test_mismatched_versions(self, _local: MagicMock, _remote: MagicMock) -> None:
        local, remote = check_python_version(host="1.2.3.4", user="root")

        assert local == "3.11"
        assert remote == "3.12"

    @patch("specter_cli.deps.get_remote_python_version", return_value=None)
    @patch("specter_cli.deps.get_local_python_version", return_value="3.12")
    def test_remote_unavailable(self, _local: MagicMock, _remote: MagicMock) -> None:
        local, remote = check_python_version(host="1.2.3.4", user="root")

        assert local == "3.12"
        assert remote is None
