"""Tests for .swival/ write-race fixes (issue #7).

Validates per-context scratch directory isolation, shared-state locking,
and graceful handling of vanished temp files.
"""

import os
import threading
from pathlib import Path
from unittest.mock import patch

import pytest

from swival.todo import TodoState
from swival.tools import (
    _delete_file,
    _read_file,
    _save_large_output,
    _cleanup_trash,
    SWIVAL_DIR,
)


# ---------------------------------------------------------------------------
# 1. Multi-context todo isolation
# ---------------------------------------------------------------------------


class TestTodoIsolation:
    def test_two_contexts_do_not_interfere(self, tmp_path):
        """Two TodoStates with different todo_dir write independent lists."""
        dir_a = tmp_path / "ctx_a"
        dir_b = tmp_path / "ctx_b"
        dir_a.mkdir()
        dir_b.mkdir()

        todo_a = TodoState(notes_dir=str(tmp_path), todo_dir=str(dir_a))
        todo_b = TodoState(notes_dir=str(tmp_path), todo_dir=str(dir_b))

        todo_a.process({"action": "add", "tasks": "task-A1"})
        todo_a.process({"action": "add", "tasks": "task-A2"})
        todo_b.process({"action": "add", "tasks": "task-B1"})

        assert len(todo_a.items) == 2
        assert len(todo_b.items) == 1

        # Verify files are separate
        file_a = dir_a / "todo.md"
        file_b = dir_b / "todo.md"
        assert file_a.exists()
        assert file_b.exists()
        assert "task-A1" in file_a.read_text()
        assert "task-B1" in file_b.read_text()
        assert "task-B1" not in file_a.read_text()

    def test_concurrent_adds_isolated(self, tmp_path):
        """Concurrent adds to separate todo_dirs don't corrupt either list."""
        n_items = 20
        dir_a = tmp_path / "ctx_a"
        dir_b = tmp_path / "ctx_b"
        dir_a.mkdir()
        dir_b.mkdir()

        todo_a = TodoState(notes_dir=str(tmp_path), todo_dir=str(dir_a))
        todo_b = TodoState(notes_dir=str(tmp_path), todo_dir=str(dir_b))

        errors = []

        def add_items(todo, prefix, count):
            try:
                for i in range(count):
                    todo.process({"action": "add", "tasks": f"{prefix}-{i}"})
            except Exception as e:
                errors.append(e)

        t1 = threading.Thread(target=add_items, args=(todo_a, "A", n_items))
        t2 = threading.Thread(target=add_items, args=(todo_b, "B", n_items))
        t1.start()
        t2.start()
        t1.join()
        t2.join()

        assert not errors
        assert len(todo_a.items) == n_items
        assert len(todo_b.items) == n_items
        assert all(item.text.startswith("A-") for item in todo_a.items)
        assert all(item.text.startswith("B-") for item in todo_b.items)

    def test_default_notes_dir_unchanged(self, tmp_path):
        """When todo_dir is None, TodoState uses notes_dir/.swival/todo.md."""
        todo = TodoState(notes_dir=str(tmp_path))
        todo.process({"action": "add", "tasks": "hello"})
        assert (tmp_path / ".swival" / "todo.md").exists()

    def test_reset_clears_todo_dir_file(self, tmp_path):
        """reset() removes todo.md from todo_dir, not just notes_dir/.swival/."""
        ctx_dir = tmp_path / "ctx"
        ctx_dir.mkdir()
        todo = TodoState(notes_dir=str(tmp_path), todo_dir=str(ctx_dir))
        todo.process({"action": "add", "tasks": "item1"})
        assert (ctx_dir / "todo.md").exists()

        todo.reset()
        assert not (ctx_dir / "todo.md").exists()
        assert len(todo.items) == 0


# ---------------------------------------------------------------------------
# 2. Multi-context cmd_output isolation
# ---------------------------------------------------------------------------


class TestCmdOutputIsolation:
    def test_scratch_dir_used_when_set(self, tmp_path):
        """_save_large_output writes to scratch_dir, not base_dir/.swival/."""
        scratch = tmp_path / "scratch"
        scratch.mkdir()
        output = "x" * 50_000

        result = _save_large_output(output, str(tmp_path), scratch_dir=str(scratch))

        # File should be in scratch, not in .swival/
        assert list(scratch.glob("cmd_output_*.txt"))
        assert not list((tmp_path / SWIVAL_DIR).glob("cmd_output_*.txt"))
        assert "cmd_output_" in result

    def test_returned_path_points_to_scratch_dir(self, tmp_path):
        """The path returned by _save_large_output is resolvable via read_file."""
        scratch = tmp_path / ".swival" / "contexts" / "abc123"
        scratch.mkdir(parents=True)
        output = "payload " * 10_000

        result = _save_large_output(output, str(tmp_path), scratch_dir=str(scratch))

        # Extract the path from the result message
        for line in result.splitlines():
            if "saved to:" in line.lower() or "saved to" in line.lower():
                rel_path = line.split(":")[-1].strip()
                break
        else:
            pytest.fail(f"No 'saved to' path in result: {result}")

        # The path should resolve to the actual file
        full_path = tmp_path / rel_path
        assert full_path.exists(), (
            f"File not found at {full_path} (rel_path={rel_path})"
        )
        assert full_path.read_text().startswith("payload ")

    def test_default_without_scratch_dir(self, tmp_path):
        """Without scratch_dir, falls back to base_dir/.swival/."""
        output = "x" * 50_000
        _save_large_output(output, str(tmp_path))

        assert list((tmp_path / SWIVAL_DIR).glob("cmd_output_*.txt"))

    def test_two_contexts_write_separately(self, tmp_path):
        """Two contexts with different scratch dirs don't see each other's files."""
        scratch_a = tmp_path / "ctx_a"
        scratch_b = tmp_path / "ctx_b"
        scratch_a.mkdir()
        scratch_b.mkdir()
        output = "y" * 50_000

        _save_large_output(output, str(tmp_path), scratch_dir=str(scratch_a))
        _save_large_output(output, str(tmp_path), scratch_dir=str(scratch_b))

        files_a = list(scratch_a.glob("cmd_output_*.txt"))
        files_b = list(scratch_b.glob("cmd_output_*.txt"))
        assert len(files_a) == 1
        assert len(files_b) == 1
        assert files_a[0].name != files_b[0].name


# ---------------------------------------------------------------------------
# 3. Vanished cmd_output read
# ---------------------------------------------------------------------------


class TestVanishedCmdOutput:
    def test_read_deleted_file_returns_error(self, tmp_path):
        """Reading a file that vanishes between existence check and open."""
        target = tmp_path / "cmd_output_abc123.txt"
        target.write_text("hello")

        # Patch open to simulate the file vanishing after the existence check
        original_open = open

        def vanishing_open(path, *args, **kwargs):
            if "cmd_output_abc123" in str(path):
                target.unlink(missing_ok=True)
                raise FileNotFoundError(f"No such file: {path}")
            return original_open(path, *args, **kwargs)

        with patch("builtins.open", side_effect=vanishing_open):
            result = _read_file(str(target), str(tmp_path), offset=1, limit=100)

        assert result.startswith("error:")

    def test_read_vanishes_after_binary_check(self, tmp_path):
        """File vanishes between the binary check and read_text()."""
        target = tmp_path / "cmd_output_late.txt"
        target.write_text("hello world")

        original_read_text = Path.read_text

        def vanishing_read_text(self_path, *args, **kwargs):
            if "cmd_output_late" in str(self_path):
                target.unlink(missing_ok=True)
                raise FileNotFoundError(f"No such file: {self_path}")
            return original_read_text(self_path, *args, **kwargs)

        with patch.object(Path, "read_text", vanishing_read_text):
            result = _read_file(str(target), str(tmp_path), offset=1, limit=100)

        assert result.startswith("error:")
        assert "removed after check" in result

    def test_read_nonexistent_returns_error(self, tmp_path):
        """Reading a file that doesn't exist returns a clean error."""
        result = _read_file(
            str(tmp_path / "cmd_output_gone.txt"),
            str(tmp_path),
            offset=1,
            limit=100,
        )
        assert result.startswith("error:")


# ---------------------------------------------------------------------------
# 4. Trash cleanup serialization
# ---------------------------------------------------------------------------


class TestTrashCleanupSerialization:
    def _populate_trash(self, tmp_path, count=10, file_size=1024):
        """Create trash entries for testing."""
        trash_root = tmp_path / SWIVAL_DIR / "trash"
        trash_root.mkdir(parents=True, exist_ok=True)
        for i in range(count):
            entry_dir = trash_root / f"entry_{i:04d}"
            entry_dir.mkdir()
            (entry_dir / "file.txt").write_text("x" * file_size)
        return trash_root

    def test_concurrent_cleanup_no_errors(self, tmp_path):
        """Multiple threads running _cleanup_trash don't raise errors."""
        self._populate_trash(tmp_path, count=20, file_size=512)
        errors = []

        def run_cleanup():
            try:
                _cleanup_trash(str(tmp_path))
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=run_cleanup) for _ in range(5)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors

    def test_concurrent_deletes_no_errors(self, tmp_path):
        """Multiple threads deleting files via _delete_file don't raise."""
        files = []
        for i in range(10):
            f = tmp_path / f"file_{i}.txt"
            f.write_text(f"content {i}")
            files.append(f)

        errors = []

        def delete_one(path):
            try:
                _delete_file(str(path), str(tmp_path))
            except Exception as e:
                errors.append(e)

        threads = [threading.Thread(target=delete_one, args=(f,)) for f in files]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors
        # All files should be trashed
        for f in files:
            assert not f.exists()


# ---------------------------------------------------------------------------
# 5. History append under contention
# ---------------------------------------------------------------------------


class TestHistoryContention:
    def test_concurrent_appends_no_corruption(self, tmp_path):
        """Concurrent history appends produce valid entries without corruption."""
        from swival.agent import append_history

        n_threads = 5
        n_writes = 10
        errors = []

        def write_entries(thread_id):
            try:
                for i in range(n_writes):
                    append_history(
                        str(tmp_path),
                        f"question-{thread_id}-{i}",
                        f"answer-{thread_id}-{i}",
                        diagnostics=False,
                    )
            except Exception as e:
                errors.append(e)

        threads = [
            threading.Thread(target=write_entries, args=(tid,))
            for tid in range(n_threads)
        ]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert not errors

        history_path = tmp_path / SWIVAL_DIR / "HISTORY.md"
        assert history_path.exists()
        content = history_path.read_text()

        # Every entry should be present
        for tid in range(n_threads):
            for i in range(n_writes):
                assert f"answer-{tid}-{i}" in content

    def test_capacity_respected(self, tmp_path):
        """History stops growing beyond MAX_HISTORY_SIZE."""
        from swival.agent import append_history, MAX_HISTORY_SIZE

        # Write until capacity
        for i in range(2000):
            append_history(
                str(tmp_path),
                f"q{i}",
                "a" * 500,
                diagnostics=False,
            )
            history_path = tmp_path / SWIVAL_DIR / "HISTORY.md"
            if (
                history_path.exists()
                and history_path.stat().st_size >= MAX_HISTORY_SIZE
            ):
                break

        # One more write should be skipped
        append_history(str(tmp_path), "extra", "should not appear", diagnostics=False)
        size_after = history_path.stat().st_size

        # Allow tolerance of one entry (the lock makes the check atomic,
        # but the entry that pushed us over the cap is already written)
        assert size_after <= MAX_HISTORY_SIZE + 1024


# ---------------------------------------------------------------------------
# 6. Context ID sanitization
# ---------------------------------------------------------------------------


class TestContextIdSanitization:
    def test_traversal_context_id_stays_inside_swival(self, tmp_path):
        """A malicious contextId cannot escape .swival/contexts/."""
        from swival.a2a_server import A2aServer

        session_kwargs = {"base_dir": str(tmp_path), "provider": "generic"}
        server = A2aServer(session_kwargs=session_kwargs, host="127.0.0.1", port=0)

        # Simulate creating a session with a path-traversal contextId
        malicious_id = "../../../../tmp/pwn"
        session = server._create_session(malicious_id)

        scratch = Path(session.scratch_dir)
        # scratch_dir must resolve inside base_dir/.swival/contexts/
        assert scratch.resolve().is_relative_to(
            (tmp_path / ".swival" / "contexts").resolve()
        ), f"scratch_dir escaped: {scratch}"

    def test_different_ids_get_different_dirs(self, tmp_path):
        """Different contextIds produce different scratch directories."""
        from swival.a2a_server import A2aServer

        session_kwargs = {"base_dir": str(tmp_path), "provider": "generic"}
        server = A2aServer(session_kwargs=session_kwargs, host="127.0.0.1", port=0)

        s1 = server._create_session("ctx-1")
        s2 = server._create_session("ctx-2")
        assert s1.scratch_dir != s2.scratch_dir


# ---------------------------------------------------------------------------
# 7. Lock fallback paths
# ---------------------------------------------------------------------------


class TestLockFallbacks:
    def test_trash_lock_mkdir_fails_still_executes_body(self, tmp_path):
        """_trash_lock falls back to no-op when mkdir raises OSError."""
        from swival.tools import _trash_lock

        executed = False
        with patch.object(Path, "mkdir", side_effect=OSError("permission denied")):
            with _trash_lock(str(tmp_path)):
                executed = True
        assert executed

    def test_trash_lock_no_fcntl_still_executes_body(self, tmp_path):
        """_trash_lock falls back to no-op when fcntl is unavailable."""
        import builtins

        from swival.tools import _trash_lock

        original_import = builtins.__import__

        def fake_import(name, *args, **kwargs):
            if name == "fcntl":
                raise ImportError("no fcntl on Windows")
            return original_import(name, *args, **kwargs)

        executed = False
        with patch.object(builtins, "__import__", side_effect=fake_import):
            with _trash_lock(str(tmp_path)):
                executed = True
        assert executed

    def test_trash_delete_works_when_lock_file_fails(self, tmp_path):
        """_delete_file succeeds even when the lock file can't be created.

        The lock gracefully degrades; the delete still moves the file to trash.
        """
        target = tmp_path / "doomed.txt"
        target.write_text("bye")

        import swival.tools as tools_mod

        original_os_open = os.open

        def fail_lock_open(path, *args, **kwargs):
            if ".lock" in str(path):
                raise OSError("cannot create lock file")
            return original_os_open(path, *args, **kwargs)

        with patch.object(tools_mod.os, "open", side_effect=fail_lock_open):
            result = _delete_file(str(target), str(tmp_path))

        assert result.startswith("Trashed")
        assert not target.exists()

    def test_append_history_no_fcntl(self, tmp_path):
        """append_history writes correctly when fcntl is unavailable."""
        import builtins

        from swival.agent import append_history

        original_import = builtins.__import__

        def fake_import(name, *args, **kwargs):
            if name == "fcntl":
                raise ImportError("no fcntl on Windows")
            return original_import(name, *args, **kwargs)

        with patch.object(builtins, "__import__", side_effect=fake_import):
            append_history(
                str(tmp_path),
                "test-question",
                "test-answer",
                diagnostics=False,
            )

        history_path = tmp_path / SWIVAL_DIR / "HISTORY.md"
        assert history_path.exists()
        content = history_path.read_text()
        assert "test-answer" in content
        assert "test-question" in content
