"""Tests for file sync optimization — workspace hash and rsync skip.

Covers:
- workspace_hash() — git-based hash computation
- _load_cached_hash() / _save_cached_hash() — hash persistence
- Skip rsync when hash matches (proxy.py integration)
- Force sync when hash differs or is missing
- Non-fatal hash failure (graceful degradation)
"""

from __future__ import annotations

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

import pytest


class TestWorkspaceHash:
    """Tests for workspace_hash() in sync.py."""

    @patch("specter_cli.sync.subprocess.run")
    def test_returns_hash_for_git_repo(self, mock_run: MagicMock) -> None:
        """In a git repo, should return a hex digest string."""
        from specter_cli.sync import workspace_hash

        mock_run.side_effect = [
            # git rev-parse HEAD
            MagicMock(returncode=0, stdout="abc123def456\n"),
            # git diff HEAD
            MagicMock(returncode=0, stdout=""),
            # git ls-files --others
            MagicMock(returncode=0, stdout=""),
        ]

        result = workspace_hash(Path("/fake/project"))

        assert result is not None
        assert len(result) == 16  # sha256 hex truncated to 16 chars
        assert all(c in "0123456789abcdef" for c in result)

    @patch("specter_cli.sync.subprocess.run")
    def test_returns_none_for_non_git_repo(self, mock_run: MagicMock) -> None:
        """Not a git repo → should return None."""
        from specter_cli.sync import workspace_hash

        mock_run.return_value = MagicMock(returncode=128, stdout="", stderr="not a git repo")

        result = workspace_hash(Path("/not/a/git/repo"))

        assert result is None

    @patch("specter_cli.sync.subprocess.run")
    def test_returns_none_on_timeout(self, mock_run: MagicMock) -> None:
        """Git timeout → should return None (non-fatal)."""
        from specter_cli.sync import workspace_hash

        mock_run.side_effect = subprocess.TimeoutExpired(cmd="git", timeout=5)

        result = workspace_hash(Path("/fake/project"))

        assert result is None

    @patch("specter_cli.sync.subprocess.run")
    def test_returns_none_on_oserror(self, mock_run: MagicMock) -> None:
        """OSError (git not found) → should return None."""
        from specter_cli.sync import workspace_hash

        mock_run.side_effect = OSError("No such file or directory: git")

        result = workspace_hash(Path("/fake/project"))

        assert result is None

    @patch("specter_cli.sync.subprocess.run")
    def test_dirty_state_changes_hash(self, mock_run: MagicMock) -> None:
        """Uncommitted changes should produce a different hash than clean state."""
        from specter_cli.sync import workspace_hash

        # Clean state.
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="abc123\n"),
            MagicMock(returncode=0, stdout=""),
            MagicMock(returncode=0, stdout=""),
        ]
        clean_hash = workspace_hash(Path("/fake/project"))

        # Dirty state.
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="abc123\n"),
            MagicMock(returncode=0, stdout="diff --git a/file.py ...\n+new line\n"),
            MagicMock(returncode=0, stdout=""),
        ]
        dirty_hash = workspace_hash(Path("/fake/project"))

        assert clean_hash is not None
        assert dirty_hash is not None
        assert clean_hash != dirty_hash

    @patch("specter_cli.sync.subprocess.run")
    def test_untracked_file_changes_hash(self, mock_run: MagicMock) -> None:
        """New untracked files should change the hash."""
        from specter_cli.sync import workspace_hash

        # No untracked files.
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="abc123\n"),
            MagicMock(returncode=0, stdout=""),
            MagicMock(returncode=0, stdout=""),
        ]
        hash_no_untracked = workspace_hash(Path("/fake/project"))

        # One untracked file.
        mock_run.side_effect = [
            MagicMock(returncode=0, stdout="abc123\n"),
            MagicMock(returncode=0, stdout=""),
            MagicMock(returncode=0, stdout="new_file.py\n"),
        ]
        hash_with_untracked = workspace_hash(Path("/fake/project"))

        assert hash_no_untracked is not None
        assert hash_with_untracked is not None
        assert hash_no_untracked != hash_with_untracked


class TestHashCachePersistence:
    """Tests for _load_cached_hash() and _save_cached_hash()."""

    def test_save_and_load_round_trip(self, tmp_path: Path) -> None:
        """Saved hash should be loadable."""
        from specter_cli.sync import _load_cached_hash, _save_cached_hash

        _save_cached_hash(tmp_path, "deadbeef12345678")
        loaded = _load_cached_hash(tmp_path)

        assert loaded == "deadbeef12345678"

    def test_load_no_cache_returns_none(self, tmp_path: Path) -> None:
        """No cache file → should return None."""
        from specter_cli.sync import _load_cached_hash

        result = _load_cached_hash(tmp_path)

        assert result is None

    def test_save_overwrites_existing(self, tmp_path: Path) -> None:
        """Saving a new hash should overwrite the old one."""
        from specter_cli.sync import _load_cached_hash, _save_cached_hash

        _save_cached_hash(tmp_path, "old_hash_1234567")
        _save_cached_hash(tmp_path, "new_hash_9876543")

        assert _load_cached_hash(tmp_path) == "new_hash_9876543"

    def test_cache_file_location(self, tmp_path: Path) -> None:
        """Cache file should be .specter-sync-hash in the project root."""
        from specter_cli.sync import _save_cached_hash

        _save_cached_hash(tmp_path, "test_hash")

        cache_file = tmp_path / ".specter-sync-hash"
        assert cache_file.exists()
        assert cache_file.read_text().strip() == "test_hash"

    def test_load_handles_corrupt_file(self, tmp_path: Path) -> None:
        """If cache file is corrupt, load should not crash."""
        from specter_cli.sync import _load_cached_hash

        cache = tmp_path / ".specter-sync-hash"
        cache.write_text("")

        # Empty file → should return empty string (which won't match any real hash).
        result = _load_cached_hash(tmp_path)
        assert result == "" or result is not None  # Should not raise


# Shared config for integration tests.
_INTEGRATION_CONFIG = {
    "host": "1.2.3.4",
    "user": "root",
    "ssh_port": 22,
    "workspace": "~/.specter/workspace",
    "remote_venv": "~/.specter/venv",
}


class TestSyncSkipIntegration:
    """Tests for the rsync skip logic in proxy.py run_remote()."""

    _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"

    @pytest.fixture
    def project_dir(self, tmp_path: Path) -> Path:
        proj = tmp_path / "project"
        proj.mkdir()
        (proj / ".git").mkdir()
        (proj / "train.py").write_text("print('hello')")
        return proj

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value="same_hash")
    @patch(_WORKSPACE_HASH, return_value="same_hash")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG)
    def test_skip_rsync_when_hash_matches(
        self,
        mock_config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        mock_rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        _save_hash: MagicMock,
        project_dir: Path,
    ) -> None:
        """When hash matches cached, rsync should be skipped entirely."""
        from specter_cli.proxy import run_remote

        mock_config.return_value = _INTEGRATION_CONFIG
        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):
            run_remote(["python", "train.py"])

        # rsync should NOT have been called.
        mock_rsync.assert_not_called()

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value="old_hash")
    @patch(_WORKSPACE_HASH, return_value="new_hash")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG)
    def test_sync_when_hash_differs(
        self,
        mock_config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        mock_rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        mock_save_hash: MagicMock,
        project_dir: Path,
    ) -> None:
        """When hash differs from cached, rsync should run."""
        from specter_cli.proxy import run_remote

        mock_config.return_value = _INTEGRATION_CONFIG
        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):
            run_remote(["python", "train.py"])

        # rsync SHOULD have been called.
        mock_rsync.assert_called_once()
        # New hash should be saved after successful sync.
        mock_save_hash.assert_called_once_with(project_dir, "new_hash")

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value=None)
    @patch(_WORKSPACE_HASH, return_value="first_hash")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG)
    def test_sync_on_first_run_no_cache(
        self,
        mock_config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        mock_rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        mock_save_hash: MagicMock,
        project_dir: Path,
    ) -> None:
        """First run (no cache) should always sync."""
        from specter_cli.proxy import run_remote

        mock_config.return_value = _INTEGRATION_CONFIG
        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):
            run_remote(["python", "train.py"])

        mock_rsync.assert_called_once()
        mock_save_hash.assert_called_once_with(project_dir, "first_hash")

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value="some_hash")
    @patch(_WORKSPACE_HASH, return_value=None)
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG)
    def test_sync_when_hash_returns_none(
        self,
        mock_config: MagicMock,
        mock_find_root: MagicMock,
        _ensure_dir: MagicMock,
        mock_rsync: MagicMock,
        mock_popen: MagicMock,
        _hash: MagicMock,
        _load_hash: MagicMock,
        _save_hash: MagicMock,
        project_dir: Path,
    ) -> None:
        """If workspace_hash returns None (not git, or error), should always sync."""
        from specter_cli.proxy import run_remote

        mock_config.return_value = _INTEGRATION_CONFIG
        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):
            run_remote(["python", "train.py"])

        # Hash is None → can't skip → must sync.
        mock_rsync.assert_called_once()
        # Should NOT save None hash.
        _save_hash.assert_not_called()

    @patch(_SAVE_HASH)
    @patch(_LOAD_HASH, return_value="same_hash")
    @patch(_WORKSPACE_HASH, return_value="same_hash")
    @patch(_POPEN)
    @patch(_RSYNC)
    @patch(_ENSURE_DIR)
    @patch(_FIND_ROOT)
    @patch(_LOAD_CONFIG)
    def test_skip_sync_still_runs_ssh(
        self,
        mock_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,
    ) -> None:
        """Even when sync is skipped, the SSH command should still execute."""
        from specter_cli.proxy import run_remote

        mock_config.return_value = _INTEGRATION_CONFIG
        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
        mock_popen.assert_called_once()
