"""Tests for error resilience in proxy.py — user-friendly error messages.

Covers:
- SSH exit code 255 → "connection to GPU lost" message
- rsync "Connection refused" → helpful message
- rsync "Connection timed out" → helpful message
- rsync "No route to host" → pod terminated message
- rsync generic failure → raw error passthrough
- rsync timeout → .specterignore hint
"""

from __future__ import annotations

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

import pytest

# Patch targets — proxy.py imports at module level.
_LOAD_CONFIG = "specter_cli.proxy.load_remote_config"
_FIND_ROOT = "specter_cli.proxy.find_project_root"
_ENSURE_DIR = "specter_cli.proxy._ensure_remote_dir"
_RSYNC = "specter_cli.proxy.rsync_workspace"
_POPEN = "specter_cli.ssh_config.subprocess.Popen"
_WORKSPACE_HASH = "specter_cli.proxy.workspace_hash"
_LOAD_HASH = "specter_cli.proxy._load_cached_hash"
_SAVE_HASH = "specter_cli.proxy._save_cached_hash"

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


@pytest.fixture
def project_dir(tmp_path: Path) -> Path:
    """Create a fake project directory with .git marker."""
    proj = tmp_path / "project"
    proj.mkdir()
    (proj / ".git").mkdir()
    (proj / "train.py").write_text("print('hello')")
    return proj


class TestSSHExitCode255:
    """SSH exit code 255 = connection lost → user-friendly message."""

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value=None)
    @patch(_WORKSPACE_HASH, return_value="abc123")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_ssh_255_prints_connection_lost(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        _save_hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir
        proc = MagicMock()
        proc.returncode = 255
        proc.wait.return_value = 255
        mock_popen.return_value = proc

        with patch("specter_cli.proxy.Path.cwd", return_value=project_dir):
            rc = run_remote(["python", "train.py"])

        assert rc == 255
        captured = capsys.readouterr()
        assert "connection to GPU lost" in captured.err
        assert "specter status" in captured.err

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value=None)
    @patch(_WORKSPACE_HASH, return_value="abc123")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_non_255_exit_no_connection_message(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        _save_hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Normal exit codes (e.g. 1) should NOT print connection lost."""
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir
        proc = MagicMock()
        proc.returncode = 1
        proc.wait.return_value = 1
        mock_popen.return_value = proc

        with patch("specter_cli.proxy.Path.cwd", return_value=project_dir):
            rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "connection to GPU lost" not in captured.err

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value=None)
    @patch(_WORKSPACE_HASH, return_value="abc123")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_zero_exit_no_message(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        _save_hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Successful exit (0) should produce no error messages."""
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir
        proc = MagicMock()
        proc.returncode = 0
        proc.wait.return_value = 0
        mock_popen.return_value = proc

        with patch("specter_cli.proxy.Path.cwd", return_value=project_dir):
            rc = run_remote(["python", "train.py"])

        assert rc == 0
        captured = capsys.readouterr()
        assert captured.err == ""


class TestRsyncErrorMessages:
    """rsync failures → user-friendly messages."""

    @patch(_WORKSPACE_HASH, return_value=None)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_connection_refused(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir

        with (
            patch(
                _RSYNC,
                side_effect=subprocess.CalledProcessError(
                    1, "rsync", "", "ssh: connect to host 1.2.3.4: Connection refused"
                ),
            ),
            patch("specter_cli.proxy.Path.cwd", return_value=project_dir),
        ):
            rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "cannot reach GPU" in captured.err
        assert "connection refused" in captured.err.lower()

    @patch(_WORKSPACE_HASH, return_value=None)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_connection_timed_out(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir

        with (
            patch(
                _RSYNC,
                side_effect=subprocess.CalledProcessError(
                    1, "rsync", "", "ssh: connect to host 1.2.3.4: Connection timed out"
                ),
            ),
            patch("specter_cli.proxy.Path.cwd", return_value=project_dir),
        ):
            rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "cannot reach GPU" in captured.err

    @patch(_WORKSPACE_HASH, return_value=None)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_no_route_to_host(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir

        with (
            patch(
                _RSYNC,
                side_effect=subprocess.CalledProcessError(
                    1, "rsync", "", "ssh: connect to host 1.2.3.4: No route to host"
                ),
            ),
            patch("specter_cli.proxy.Path.cwd", return_value=project_dir),
        ):
            rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "cannot reach GPU" in captured.err
        assert "no route to host" in captured.err.lower()

    @patch(_WORKSPACE_HASH, return_value=None)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_generic_rsync_failure(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """Non-connection rsync failures should show the raw error."""
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir

        with (
            patch(
                _RSYNC,
                side_effect=subprocess.CalledProcessError(
                    23, "rsync", "", "rsync: some files vanished before transfer"
                ),
            ),
            patch("specter_cli.proxy.Path.cwd", return_value=project_dir),
        ):
            rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "workspace sync failed" in captured.err
        assert "some files vanished" in captured.err

    @patch(_WORKSPACE_HASH, return_value=None)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_rsync_timeout(
        self,
        _config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        _hash: MagicMock,
        project_dir: Path,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        """rsync timeout should suggest .specterignore."""
        from specter_cli.proxy import run_remote

        mock_find_root.return_value = project_dir

        with (
            patch(
                _RSYNC,
                side_effect=subprocess.TimeoutExpired(cmd="rsync", timeout=120),
            ),
            patch("specter_cli.proxy.Path.cwd", return_value=project_dir),
        ):
            rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "sync timed out" in captured.err
        assert ".specterignore" in captured.err


class TestNoConfigError:
    """No specter config → helpful message."""

    @patch(_LOAD_CONFIG, return_value={})
    def test_no_config_returns_1(
        self,
        _config: MagicMock,
        capsys: pytest.CaptureFixture[str],
    ) -> None:
        from specter_cli.proxy import run_remote

        rc = run_remote(["python", "train.py"])

        assert rc == 1
        captured = capsys.readouterr()
        assert "not configured" in captured.err
        assert "specter install" in captured.err
