"""Unit tests for the split filesystem tools (read/write/AI)."""

import pytest

from navi.tools.filesystem import (
    FileAiTool,
    FilesystemReadTool,
    FilesystemWriteTool,
    _check_path,
)


class TestCheckPath:
    @pytest.fixture(autouse=True)
    def _allow_all(self, monkeypatch):
        import navi.tools._filesystem.base as _base
        import navi.tools._filesystem.helpers as _helpers
        from navi.config import Settings

        new = Settings(fs_allowed_paths="*")
        for mod in (_helpers, _base):
            monkeypatch.setattr(mod, "settings", new)

    def test_resolves_relative(self):
        p = _check_path(".")
        assert p is not None
        assert p.is_dir()

    def test_expands_tilde(self):
        p = _check_path("~")
        assert p is not None

    def test_rejects_empty(self):
        assert _check_path("") is None

    def test_restricted_paths(self, monkeypatch, tmp_path):
        import navi.tools._filesystem.helpers as _helpers
        from navi.config import Settings

        allowed = tmp_path / "allowed"
        allowed.mkdir()
        blocked = tmp_path / "blocked"
        blocked.mkdir()
        new = Settings(fs_allowed_paths=str(allowed))
        monkeypatch.setattr(_helpers, "settings", new)
        assert _check_path(str(allowed / "file.txt")) is not None
        assert _check_path(str(blocked / "file.txt")) is None


class TestCheckPathSandbox:
    """Non-admin paths live in the sandbox or in the current session directory."""

    @pytest.fixture(autouse=True)
    def _areas(self, monkeypatch, tmp_path):
        import navi.session_files as _sf_mod
        from navi.config import Settings
        from navi.tools._internal.base import current_session_id

        monkeypatch.setattr(
            _sf_mod,
            "settings",
            Settings(session_files_dir=str(tmp_path / "sessions"), fs_allowed_paths="*"),
        )
        monkeypatch.chdir(tmp_path)
        self.root = tmp_path
        token = current_session_id.set("sess 1")
        yield
        current_session_id.reset(token)

    def test_absolute_inside_sandbox_allowed(self):
        target = self.root / "user_data" / "7" / "notes.txt"
        checked = _check_path(str(target), user_id="7", role="user")
        assert checked == target.resolve()

    def test_absolute_inside_session_dir_allowed(self):
        session = self.root / "sessions" / "sess 1"
        session.mkdir(parents=True)
        target = session / "upload.csv"
        target.write_text("a,b\n")
        checked = _check_path(str(target), user_id="7", role="user")
        assert checked == target.resolve()

    def test_absolute_outside_both_denied(self):
        outside = self.root / "elsewhere.txt"
        outside.write_text("x")
        assert _check_path(str(outside), user_id="7", role="user") is None

    def test_other_session_dir_denied(self):
        other = self.root / "sessions" / "sess 2"
        other.mkdir(parents=True)
        target = other / "leaked.txt"
        target.write_text("x")
        assert _check_path(str(target), user_id="7", role="user") is None

    def test_relative_still_resolves_into_sandbox(self):
        checked = _check_path("notes.txt", user_id="7", role="user")
        assert checked == (self.root / "user_data" / "7" / "notes.txt").resolve()

    def test_relative_dotdot_escape_denied(self):
        (self.root / "elsewhere.txt").write_text("x")
        assert _check_path("../elsewhere.txt", user_id="7", role="user") is None

    def test_relative_symlink_escape_denied(self):
        outside = self.root / "elsewhere.txt"
        outside.write_text("x")
        sandbox = self.root / "user_data" / "7"
        sandbox.mkdir(parents=True, exist_ok=True)
        (sandbox / "link.txt").symlink_to(outside)
        assert _check_path("link.txt", user_id="7", role="user") is None


@pytest.fixture
def allow_all(monkeypatch):
    import navi.tools._filesystem.base as _base
    import navi.tools._filesystem.helpers as _helpers
    from navi.config import Settings

    new = Settings(fs_allowed_paths="*")
    for mod in (_helpers, _base):
        monkeypatch.setattr(mod, "settings", new)


@pytest.fixture
def reader():
    return FilesystemReadTool()


@pytest.fixture
def writer():
    return FilesystemWriteTool()


class TestFilesystemRead:
    async def test_read_file(self, reader, tmp_path, allow_all):
        f = tmp_path / "hello.txt"
        f.write_text("world")
        result = await reader.execute({"action": "read", "path": str(f)})
        assert result.success
        assert "world" in result.output

    async def test_read_missing(self, reader, tmp_path, allow_all):
        f = tmp_path / "missing.txt"
        result = await reader.execute({"action": "read", "path": str(f)})
        assert not result.success
        assert "not found" in result.output.lower()

    async def test_read_directory_rejected(self, reader, tmp_path, allow_all):
        result = await reader.execute({"action": "read", "path": str(tmp_path)})
        assert not result.success
        assert result.error == "is_directory"
        assert "list" in result.output

    async def test_read_pages_large_file(self, reader, tmp_path, allow_all):
        f = tmp_path / "big.txt"
        f.write_text("\n".join(f"line {i}" for i in range(1, 801)))
        result = await reader.execute({"action": "read", "path": str(f)})
        assert result.success
        assert "lines 1–500 of 800" in result.output
        assert "[more: call read with offset=501]" in result.output

    async def test_read_numbered_false_has_no_footer(self, reader, tmp_path, allow_all):
        f = tmp_path / "big.txt"
        f.write_text("\n".join(f"line {i}" for i in range(1, 801)))
        result = await reader.execute(
            {"action": "read", "path": str(f), "numbered": False}
        )
        assert result.success
        assert "line 1\n" in result.output
        assert "[more:" not in result.output

    async def test_read_binary_rejected(self, reader, tmp_path, allow_all):
        f = tmp_path / "bin.dat"
        f.write_bytes(b"abc\x00def")
        result = await reader.execute({"action": "read", "path": str(f)})
        assert not result.success
        assert result.error == "binary_file"

    async def test_list_dir(self, reader, tmp_path, allow_all):
        (tmp_path / "a.txt").write_text("a")
        (tmp_path / "b.txt").write_text("b")
        result = await reader.execute({"action": "list", "path": str(tmp_path)})
        assert result.success
        assert "a.txt" in result.output
        assert "b.txt" in result.output

    async def test_exists_true_and_false(self, reader, tmp_path, allow_all):
        f = tmp_path / "exists.txt"
        f.write_text("")
        assert (await reader.execute({"action": "exists", "path": str(f)})).output == "true"
        assert (
            await reader.execute({"action": "exists", "path": str(tmp_path / "no.txt")})
        ).output == "false"

    async def test_info_file(self, reader, tmp_path, allow_all):
        f = tmp_path / "info.txt"
        f.write_text("content")
        result = await reader.execute({"action": "info", "path": str(f)})
        assert result.success
        assert "info.txt" in result.output

    async def test_find(self, reader, tmp_path, allow_all):
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "a.py").write_text("")
        (tmp_path / "src" / "b.txt").write_text("")
        result = await reader.execute(
            {"action": "find", "path": str(tmp_path), "pattern": "*.py"}
        )
        assert result.success
        assert "src/a.py" in result.output
        assert "b.txt" not in result.output

    async def test_find_up(self, reader, tmp_path, allow_all):
        (tmp_path / "pyproject.toml").write_text("")
        nested = tmp_path / "a" / "b"
        nested.mkdir(parents=True)
        result = await reader.execute({"action": "find_up", "path": str(nested), "name": "pyproject.toml"})
        assert result.success
        assert str(tmp_path / "pyproject.toml") in result.output

    async def test_grep_file(self, reader, tmp_path, allow_all):
        f = tmp_path / "grep.txt"
        f.write_text("apple\nbanana\napricot\n")
        result = await reader.execute({"action": "grep", "path": str(f), "pattern": "ap"})
        assert result.success
        assert "apple" in result.output
        assert "apricot" in result.output
        assert "banana" not in result.output

    async def test_grep_regex(self, reader, tmp_path, allow_all):
        f = tmp_path / "grep.txt"
        f.write_text("cat 123\ndog 456\ncat 789\n")
        result = await reader.execute(
            {"action": "grep", "path": str(f), "pattern": r"cat\s+\d+", "regex": True}
        )
        assert result.success
        assert "cat 123" in result.output
        assert "cat 789" in result.output
        assert "dog 456" not in result.output

    async def test_grep_no_match(self, reader, tmp_path, allow_all):
        f = tmp_path / "grep.txt"
        f.write_text("hello\nworld\n")
        result = await reader.execute({"action": "grep", "path": str(f), "pattern": "xyz"})
        assert result.success
        assert "No matches" in result.output
        assert "regex=true" in result.output

    async def test_grep_missing_pattern(self, reader, tmp_path, allow_all):
        f = tmp_path / "grep.txt"
        f.write_text("hello\n")
        result = await reader.execute({"action": "grep", "path": str(f)})
        assert not result.success
        assert "pattern" in result.output.lower()

    async def test_grep_skips_ignored_dirs_by_default(self, reader, tmp_path, allow_all):
        (tmp_path / "node_modules").mkdir()
        (tmp_path / "node_modules" / "x.js").write_text("needle")
        (tmp_path / "src").mkdir()
        (tmp_path / "src" / "x.py").write_text("needle")
        result = await reader.execute({"action": "grep", "path": str(tmp_path), "pattern": "needle"})
        assert "src/x.py" in result.output
        assert "node_modules" not in result.output

        result = await reader.execute(
            {"action": "grep", "path": str(tmp_path), "pattern": "needle", "include_ignored": True}
        )
        assert "node_modules/x.js" in result.output

    async def test_grep_context(self, reader, tmp_path, allow_all):
        f = tmp_path / "grep.txt"
        f.write_text("before\nneedle\nafter\n")
        result = await reader.execute(
            {"action": "grep", "path": str(f), "pattern": "needle", "context": 1}
        )
        assert "grep.txt:2: needle" in result.output
        assert "grep.txt-1- before" in result.output
        assert "grep.txt-3- after" in result.output

    async def test_diff_identical(self, reader, tmp_path, allow_all):
        a = tmp_path / "a.txt"
        a.write_text("same\ncontent\n")
        b = tmp_path / "b.txt"
        b.write_text("same\ncontent\n")
        result = await reader.execute({"action": "diff", "path": str(a), "other": str(b)})
        assert result.success
        assert "identical" in result.output.lower()

    async def test_diff_different(self, reader, tmp_path, allow_all):
        a = tmp_path / "a.txt"
        a.write_text("line1\nline2\n")
        b = tmp_path / "b.txt"
        b.write_text("line1\nmodified\n")
        result = await reader.execute({"action": "diff", "path": str(a), "other": str(b)})
        assert result.success
        assert "---" in result.output
        assert "+++" in result.output
        assert "modified" in result.output

    async def test_diff_includes_real_line_numbers(self, reader, tmp_path, allow_all):
        a = tmp_path / "a.txt"
        a.write_text("line1\nline2\n")
        b = tmp_path / "b.txt"
        b.write_text("line1\nmodified\n")
        result = await reader.execute({"action": "diff", "path": str(a), "other": str(b)})
        assert "  1│ line1" in result.output
        assert "- 2│ line2" in result.output
        assert "+ 2│ modified" in result.output
        assert "@@ -1,2 +1,2 @@" in result.output

    async def test_diff_missing_other(self, reader, tmp_path, allow_all):
        a = tmp_path / "a.txt"
        a.write_text("data")
        result = await reader.execute({"action": "diff", "path": str(a)})
        assert not result.success
        assert "other" in result.output.lower()

    async def test_diff_directory_rejected(self, reader, tmp_path, allow_all):
        a = tmp_path / "a.txt"
        a.write_text("data")
        d = tmp_path / "dir"
        d.mkdir()
        result = await reader.execute({"action": "diff", "path": str(a), "other": str(d)})
        assert not result.success
        assert "directories" in result.output.lower()

    async def test_read_action_redirects_write(self, reader, tmp_path, allow_all):
        result = await reader.execute(
            {"action": "write", "path": str(tmp_path / "x.txt"), "content": "x"}
        )
        assert not result.success
        assert result.error == "wrong_tool"
        assert "filesystem_write" in result.output


class TestFilesystemWrite:
    async def test_write_file(self, writer, tmp_path, allow_all):
        f = tmp_path / "write.txt"
        result = await writer.execute({"action": "write", "path": str(f), "content": "data"})
        assert result.success
        assert f.read_text() == "data"

    async def test_write_empty_overwrite_needs_force(self, writer, tmp_path, allow_all):
        f = tmp_path / "write.txt"
        f.write_text("data")
        result = await writer.execute({"action": "write", "path": str(f), "content": ""})
        assert not result.success
        assert result.error == "empty_overwrite"
        assert f.read_text() == "data"

    async def test_write_shrink_warns(self, writer, tmp_path, allow_all):
        f = tmp_path / "write.txt"
        f.write_text("\n".join("x" for _ in range(10)))
        result = await writer.execute({"action": "write", "path": str(f), "content": "x\n"})
        assert result.success
        assert "much shorter" in result.output

    async def test_append_file(self, writer, tmp_path, allow_all):
        f = tmp_path / "append.txt"
        f.write_text("first")
        result = await writer.execute(
            {"action": "append", "path": str(f), "content": "-second"}
        )
        assert result.success
        assert f.read_text() == "first-second"

    async def test_edit_replaces_exact_unique_text(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha\nbeta\ngamma\n")
        result = await writer.execute(
            {"action": "edit", "path": str(f), "old": "beta\n", "new": "BETA\n"}
        )
        assert result.success
        assert f.read_text() == "alpha\nBETA\ngamma\n"
        assert "replaced" in result.output
        assert "2: BETA" in result.output
        assert "- 2│ beta" in result.metadata["diff"]
        assert "+ 2│ BETA" in result.metadata["diff"]

    async def test_edit_requires_old_text(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha")
        result = await writer.execute({"action": "edit", "path": str(f), "new": "beta"})
        assert not result.success
        assert result.error == "missing_old"
        assert f.read_text() == "alpha"

    async def test_edit_rejects_empty_old_text(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha")
        result = await writer.execute(
            {"action": "edit", "path": str(f), "old": "", "new": "beta"}
        )
        assert not result.success
        assert result.error == "empty_old"
        assert f.read_text() == "alpha"

    async def test_edit_rejects_missing_match(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha\nbeta\n")
        result = await writer.execute(
            {"action": "edit", "path": str(f), "old": "betta", "new": "gamma"}
        )
        assert not result.success
        assert result.error == "old_not_found"
        assert "Closest lines" in result.output
        assert f.read_text() == "alpha\nbeta\n"

    async def test_edit_detects_numbered_copy(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha\n")
        result = await writer.execute(
            {"action": "edit", "path": str(f), "old": "1: alpha", "new": "beta"}
        )
        assert not result.success
        assert result.error == "old_not_found"
        assert "line-number column" in result.output

    async def test_edit_rejects_non_unique_match(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha beta beta")
        result = await writer.execute(
            {"action": "edit", "path": str(f), "old": "beta", "new": "BETA"}
        )
        assert not result.success
        assert result.error == "old_not_unique"
        assert "lines: 1" in result.output
        assert f.read_text() == "alpha beta beta"

    async def test_edit_replace_all(self, writer, tmp_path, allow_all):
        f = tmp_path / "edit.txt"
        f.write_text("alpha beta beta")
        result = await writer.execute(
            {"action": "edit", "path": str(f), "old": "beta", "new": "BETA", "replace_all": True}
        )
        assert result.success
        assert f.read_text() == "alpha BETA BETA"

    async def test_edit_lines(self, writer, tmp_path, allow_all):
        f = tmp_path / "lines.txt"
        f.write_text("a\nb\nc\n")
        result = await writer.execute(
            {
                "action": "edit_lines",
                "path": str(f),
                "operations": [{"op": "replace", "start": 2, "end": 2, "content": "B"}],
            }
        )
        assert result.success
        assert f.read_text() == "a\nB\nc\n"
        assert "Applied 1 operation" in result.output

    async def test_edit_lines_overlap_rejected(self, writer, tmp_path, allow_all):
        f = tmp_path / "lines.txt"
        f.write_text("a\nb\nc\n")
        result = await writer.execute(
            {
                "action": "edit_lines",
                "path": str(f),
                "operations": [
                    {"op": "replace", "start": 1, "end": 2, "content": "x"},
                    {"op": "replace", "start": 2, "end": 3, "content": "y"},
                ],
            }
        )
        assert not result.success
        assert result.error == "invalid_ops"
        assert "overlaps" in result.output

    async def test_insert(self, writer, tmp_path, allow_all):
        f = tmp_path / "insert.txt"
        f.write_text("a\nc\n")
        result = await writer.execute(
            {"action": "insert", "path": str(f), "after": 1, "content": "b"}
        )
        assert result.success
        assert f.read_text() == "a\nb\nc\n"

    async def test_copy_file(self, writer, tmp_path, allow_all):
        src = tmp_path / "src.txt"
        src.write_text("copy me")
        dst = tmp_path / "dst.txt"
        result = await writer.execute(
            {"action": "copy", "path": str(src), "destination": str(dst)}
        )
        assert result.success
        assert dst.read_text() == "copy me"

    async def test_copy_refuses_existing_without_overwrite(self, writer, tmp_path, allow_all):
        src = tmp_path / "src.txt"
        src.write_text("new")
        dst = tmp_path / "dst.txt"
        dst.write_text("old")
        result = await writer.execute(
            {"action": "copy", "path": str(src), "destination": str(dst)}
        )
        assert not result.success
        assert result.error == "destination_exists"
        assert dst.read_text() == "old"

    async def test_copy_overwrite(self, writer, tmp_path, allow_all):
        src = tmp_path / "src.txt"
        src.write_text("new")
        dst = tmp_path / "dst.txt"
        dst.write_text("old")
        result = await writer.execute(
            {"action": "copy", "path": str(src), "destination": str(dst), "overwrite": True}
        )
        assert result.success
        assert dst.read_text() == "new"

    async def test_copy_missing_source(self, writer, tmp_path, allow_all):
        result = await writer.execute(
            {"action": "copy", "path": str(tmp_path / "missing.txt"), "destination": str(tmp_path / "dst.txt")}
        )
        assert not result.success
        assert "not found" in result.output.lower()

    async def test_move(self, writer, tmp_path, allow_all):
        src = tmp_path / "a.txt"
        src.write_text("x")
        dst = tmp_path / "dir" / "b.txt"
        result = await writer.execute(
            {"action": "move", "path": str(src), "destination": str(dst)}
        )
        assert result.success
        assert not src.exists()
        assert dst.read_text() == "x"

    async def test_delete_file(self, writer, tmp_path, allow_all):
        f = tmp_path / "del.txt"
        f.write_text("bye")
        result = await writer.execute({"action": "delete", "path": str(f)})
        assert result.success
        assert not f.exists()

    async def test_delete_non_empty_dir_guard(self, writer, tmp_path, allow_all):
        d = tmp_path / "d"
        d.mkdir()
        (d / "x.txt").write_text("x")
        result = await writer.execute({"action": "delete", "path": str(d)})
        assert not result.success
        assert result.error == "directory_not_empty"
        assert d.exists()

    async def test_delete_recursive(self, writer, tmp_path, allow_all):
        d = tmp_path / "d"
        d.mkdir()
        (d / "x.txt").write_text("x")
        result = await writer.execute({"action": "delete", "path": str(d), "recursive": True})
        assert result.success
        assert not d.exists()

    async def test_mkdir_parents(self, writer, tmp_path, allow_all):
        d = tmp_path / "a" / "b" / "c"
        result = await writer.execute({"action": "mkdir", "path": str(d)})
        assert result.success
        assert d.is_dir()

    async def test_write_action_redirects_read(self, writer, tmp_path, allow_all):
        result = await writer.execute({"action": "grep", "path": str(tmp_path), "pattern": "x"})
        assert not result.success
        assert result.error == "wrong_tool"
        assert "filesystem_read" in result.output

    async def test_smart_edit_redirects_to_file_ai(self, writer, tmp_path, allow_all):
        result = await writer.execute(
            {"action": "smart_edit", "path": str(tmp_path / "x.txt"), "instruction": "x"}
        )
        assert not result.success
        assert result.error == "wrong_tool"
        assert "file_ai" in result.output


class _FakeAI:
    def __init__(self, answer="answer", ops=None):
        self.answer = answer
        self.ops = ops or []
        self.ask_calls = []
        self.ask_json_calls = []

    async def ask(self, system, user):
        self.ask_calls.append((system, user))
        return self.answer

    async def ask_json(self, system, user):
        self.ask_json_calls.append((system, user))
        return self.ops


class TestFileAi:
    async def test_without_ai_helper(self, tmp_path, allow_all):
        f = tmp_path / "x.txt"
        f.write_text("hello")
        result = await FileAiTool().execute(
            {"action": "ask", "path": str(f), "question": "what?"}
        )
        assert not result.success
        assert result.error == "no_ai_helper"

    async def test_ask(self, tmp_path, allow_all):
        f = tmp_path / "x.txt"
        f.write_text("hello")
        ai = _FakeAI(answer="it says hello")
        result = await FileAiTool(ai_helper=ai).execute(
            {"action": "ask", "path": str(f), "question": "what?"}
        )
        assert result.success
        assert result.output == "it says hello"
        assert "hello" in ai.ask_calls[0][1]

    async def test_edit(self, tmp_path, allow_all):
        f = tmp_path / "x.txt"
        f.write_text("alpha\nbeta\n")
        ai = _FakeAI(ops=[{"op": "replace", "start": 2, "end": 2, "content": "BETA"}])
        result = await FileAiTool(ai_helper=ai).execute(
            {"action": "edit", "path": str(f), "instruction": "uppercase beta"}
        )
        assert result.success
        assert f.read_text() == "alpha\nBETA\n"
        assert "Applied 1 operation" in result.output

    async def test_deterministic_action_redirects(self, tmp_path, allow_all):
        ai = _FakeAI()
        result = await FileAiTool(ai_helper=ai).execute(
            {"action": "read", "path": str(tmp_path / "x.txt")}
        )
        assert not result.success
        assert result.error == "wrong_tool"
        assert "filesystem_read" in result.output


class TestNumberedDiff:
    def test_unified_diff_has_no_blank_lines_between_content_lines(self, tmp_path):
        from pathlib import Path

        from navi.tools.filesystem import _unified_diff

        out = _unified_diff(
            ["import os", "OLD = 1", "", "def f():"],
            ["import os", "NEW = 2", "", "def f():"],
            Path("f.py"),
        )
        assert "\n\n" not in out
        assert "  1│ import os" in out
        assert "- 2│ OLD = 1" in out
        assert "+ 2│ NEW = 2" in out
        assert "  3│  " in out or "  3│ " in out

    def test_number_diff_strips_trailing_newline_from_content(self):
        from navi.tools.filesystem import _number_diff

        diff_lines = [
            "--- a/f.py\n",
            "+++ b/f.py\n",
            "@@ -1,3 +1,3 @@",
            " import os\n",
            "-OLD = 1\n",
            "+NEW = 2\n",
            " def f():\n",
        ]
        out = "\n".join(_number_diff(diff_lines))
        assert "\n\n" not in out
        assert out.endswith("def f():")
        assert "  1│ import os" in out
        assert "- 2│ OLD = 1" in out
        assert "+ 2│ NEW = 2" in out

    def test_number_diff_noop_for_lines_without_trailing_newline(self):
        from navi.tools.filesystem import _number_diff

        diff_lines = [
            "--- a/f.py",
            "+++ b/f.py",
            "@@ -1,3 +1,3 @@",
            " import os",
            "-OLD = 1",
            "+NEW = 2",
            " def f():",
        ]
        out = "\n".join(_number_diff(diff_lines))
        assert "\n\n" not in out
        assert out.endswith("def f():")
        assert "  1│ import os" in out
        assert "- 2│ OLD = 1" in out
        assert "+ 2│ NEW = 2" in out
