"""Tests for ContextHubBackend with mocked langsmith.Client."""

from __future__ import annotations

from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch

import pytest
from langsmith.schemas import AgentEntry, FileEntry, SkillEntry
from langsmith.utils import LangSmithAPIError, LangSmithNotFoundError

from deepagents.backends import CompositeBackend, ContextHubBackend, FilesystemBackend

if TYPE_CHECKING:
    from pathlib import Path

_COMMIT_HASH = "abcd1234" * 8  # 64-char hex
_COMMIT_URL = "https://host/hub/-/test-agent:ef567890"


def _make_backend(
    **files: Any,
) -> tuple[ContextHubBackend, MagicMock]:
    """Build a ContextHubBackend with a mocked langsmith.Client.

    `files` pre-populates the `pull_agent` return as `AgentContext.files`.
    Entry values can be `FileEntry`/`AgentEntry`/`SkillEntry` instances.
    """
    mock_client = MagicMock()
    context = SimpleNamespace(
        commit_id="00000000-0000-0000-0000-000000000000",
        commit_hash=_COMMIT_HASH,
        files=files,
    )
    mock_client.pull_agent.return_value = context
    mock_client.push_agent.return_value = _COMMIT_URL
    backend = ContextHubBackend("-/test-agent", client=mock_client)
    return backend, mock_client


def test_read_returns_content() -> None:
    backend, _ = _make_backend(**{"AGENTS.md": FileEntry(type="file", content="# hi\nworld")})
    result = backend.read("/AGENTS.md")
    assert result.error is None
    assert result.file_data is not None
    assert result.file_data["content"] == "# hi\nworld"


def test_read_missing_returns_not_found() -> None:
    backend, _ = _make_backend()
    result = backend.read("/missing.md")
    assert result.error == "File '/missing.md' not found"
    assert result.file_data is None


def test_read_slices_by_offset_limit() -> None:
    backend, _ = _make_backend(**{"a.md": FileEntry(type="file", content="1\n2\n3\n4\n5")})
    result = backend.read("/a.md", offset=1, limit=2)
    assert result.error is None
    assert result.file_data is not None
    assert result.file_data["content"] == "2\n3\n"


def test_pull_runs_only_once_for_multiple_reads() -> None:
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="a")})
    backend.read("/a.md")
    backend.read("/a.md")
    backend.ls("/")
    assert mock_client.pull_agent.call_count == 1


def test_pull_404_treated_as_empty_repo() -> None:
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = LangSmithNotFoundError("not found")
    backend = ContextHubBackend("-/new-agent", client=mock_client)

    result = backend.read("/any.md")
    assert result.error == "File '/any.md' not found"  # empty cache, not a hub error


def test_pull_non_404_failure_surfaces_as_error() -> None:
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = LangSmithAPIError("hub 5xx")
    backend = ContextHubBackend("-/x", client=mock_client)

    result = backend.read("/anything")
    assert result.error is not None
    assert "Hub unavailable" in result.error
    assert "hub 5xx" in result.error


def test_pull_non_langsmith_failure_propagates() -> None:
    """Unexpected runtime errors should bubble up instead of being masked."""
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = RuntimeError("boom")
    backend = ContextHubBackend("-/x", client=mock_client)

    with pytest.raises(RuntimeError, match="boom"):
        backend.read("/anything")


def test_has_prior_commits_false_for_missing_repo() -> None:
    """A repo that 404s on pull has never been committed to."""
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = LangSmithNotFoundError("not found")
    backend = ContextHubBackend("-/fresh", client=mock_client)

    assert backend.has_prior_commits() is False


def test_has_prior_commits_true_for_existing_repo() -> None:
    """An existing repo with a commit hash should report prior commits."""
    backend, _ = _make_backend(**{"a.md": FileEntry(type="file", content="a")})
    assert backend.has_prior_commits() is True


def test_has_prior_commits_flips_after_first_write() -> None:
    """A fresh repo flips from no-prior-commits to has-prior-commits after a write."""
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = LangSmithNotFoundError("not found")
    mock_client.push_agent.return_value = _COMMIT_URL
    backend = ContextHubBackend("-/fresh", client=mock_client)

    assert backend.has_prior_commits() is False
    backend.write("/seed.md", "hello")
    assert backend.has_prior_commits() is True


def test_write_commits_file() -> None:
    backend, mock_client = _make_backend()
    result = backend.write("/notes.md", "# hi")

    assert result.error is None
    assert result.path == "/notes.md"
    mock_client.push_agent.assert_called_once()
    call = mock_client.push_agent.call_args
    assert call.args[0] == "-/test-agent"
    files_arg = call.kwargs["files"]
    assert "notes.md" in files_arg
    assert files_arg["notes.md"].content == "# hi"
    assert files_arg["notes.md"].type == "file"


def test_write_sends_parent_commit_from_pull() -> None:
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="a")})
    backend.read("/a.md")  # prime cache to populate commit_hash
    backend.write("/b.md", "b")

    call = mock_client.push_agent.call_args
    assert call.kwargs["parent_commit"] == _COMMIT_HASH


def test_write_updates_commit_hash_from_url() -> None:
    backend, mock_client = _make_backend()
    backend.write("/a.md", "a")
    backend.write("/b.md", "b")

    second_call = mock_client.push_agent.call_args_list[1]
    assert second_call.kwargs["parent_commit"] == "ef567890"


def test_write_updates_cache_after_commit() -> None:
    backend, _ = _make_backend()
    backend.write("/a.md", "hello")

    result = backend.read("/a.md")
    assert result.error is None
    assert result.file_data is not None
    assert result.file_data["content"] == "hello"


def test_write_sibling_of_linked_entry_allowed() -> None:
    backend, mock_client = _make_backend(**{"skills/code-reviewer": SkillEntry(type="skill", repo_handle="code-reviewer")})
    result = backend.write("/skills/code-reviewer.md", "sibling")
    assert result.error is None
    mock_client.push_agent.assert_called_once()


def test_commit_failure_invalidates_cache() -> None:
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="a")})
    mock_client.push_agent.side_effect = LangSmithAPIError("500")

    result = backend.write("/b.md", "b")
    assert result.error is not None
    assert "Hub unavailable" in result.error

    # Next read should trigger a re-pull because cache was invalidated.
    backend.read("/a.md")
    assert mock_client.pull_agent.call_count == 2


def test_edit_replaces_single_occurrence() -> None:
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="hello world")})
    result = backend.edit("/a.md", "world", "earth")

    assert result.error is None
    assert result.occurrences == 1
    call = mock_client.push_agent.call_args
    assert call.kwargs["files"]["a.md"].content == "hello earth"


def test_edit_file_not_found() -> None:
    backend, _ = _make_backend()
    result = backend.edit("/missing.md", "x", "y")
    assert result.error is not None
    assert "not found" in result.error


def test_edit_ambiguous_match_without_replace_all() -> None:
    backend, _ = _make_backend(**{"a.md": FileEntry(type="file", content="x x x")})
    result = backend.edit("/a.md", "x", "y")
    assert result.error is not None
    assert "appears" in result.error.lower() or "3 times" in result.error


def test_edit_replace_all() -> None:
    backend, _ = _make_backend(**{"a.md": FileEntry(type="file", content="x x x")})
    result = backend.edit("/a.md", "x", "y", replace_all=True)
    assert result.error is None
    assert result.occurrences == 3


def test_ls_flat_repo() -> None:
    backend, _ = _make_backend(
        **{
            "AGENTS.md": FileEntry(type="file", content="a"),
            "tools.json": FileEntry(type="file", content="{}"),
        }
    )
    result = backend.ls("/")
    assert result.entries is not None
    paths = sorted(e["path"] for e in result.entries)
    assert paths == ["/AGENTS.md", "/tools.json"]


def test_ls_surfaces_virtual_directories() -> None:
    backend, _ = _make_backend(
        **{
            "AGENTS.md": FileEntry(type="file", content="a"),
            "memories/day1.md": FileEntry(type="file", content="m1"),
            "memories/day2.md": FileEntry(type="file", content="m2"),
        }
    )
    result = backend.ls("/")
    assert result.entries is not None

    paths = {e["path"]: e for e in result.entries}
    assert "/AGENTS.md" in paths
    assert paths["/AGENTS.md"]["is_dir"] is False
    assert "/memories" in paths
    assert paths["/memories"]["is_dir"] is True


def test_ls_nested_path() -> None:
    backend, _ = _make_backend(
        **{
            "memories/a.md": FileEntry(type="file", content="x"),
            "memories/b.md": FileEntry(type="file", content="y"),
        }
    )
    result = backend.ls("/memories")
    assert result.entries is not None
    paths = sorted(e["path"] for e in result.entries)
    assert paths == ["/memories/a.md", "/memories/b.md"]


def test_ls_surfaces_pull_error() -> None:
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = LangSmithAPIError("5xx")
    backend = ContextHubBackend("-/x", client=mock_client)

    result = backend.ls("/")
    assert result.error is not None
    assert "Hub unavailable" in result.error


def test_grep_finds_matches() -> None:
    backend, _ = _make_backend(
        **{
            "a.md": FileEntry(type="file", content="hello\nworld\n"),
            "b.md": FileEntry(type="file", content="nothing"),
        }
    )
    result = backend.grep("hello")
    assert result.error is None
    assert result.matches is not None
    assert len(result.matches) == 1
    assert result.matches[0]["path"] == "/a.md"
    assert result.matches[0]["line"] == 1


def test_grep_with_path_prefix() -> None:
    backend, _ = _make_backend(
        **{
            "memories/a.md": FileEntry(type="file", content="hello"),
            "AGENTS.md": FileEntry(type="file", content="hello"),
        }
    )
    result = backend.grep("hello", path="/memories")
    assert result.matches is not None
    paths = {m["path"] for m in result.matches}
    assert paths == {"/memories/a.md"}


def test_grep_with_glob_filter() -> None:
    """`grep` honors the glob filter via the precompiled, hoisted matcher."""
    backend, _ = _make_backend(
        **{
            "src/main.py": FileEntry(type="file", content="import os"),
            "src/notes.md": FileEntry(type="file", content="import os"),
        }
    )
    result = backend.grep("import", glob="*.py")
    assert result.matches is not None
    paths = {m["path"] for m in result.matches}
    assert paths == {"/src/main.py"}


def test_grep_glob_matches_full_path_not_basename() -> None:
    """A directory-qualified glob matches against the full path, not the basename.

    Context Hub matches the glob against the whole file path, so `src/*.py`
    selects the nested file but excludes a same-named top-level file. A
    basename-only matcher would select neither, so this pins the intended
    full-path semantics.
    """
    backend, _ = _make_backend(
        **{
            "src/main.py": FileEntry(type="file", content="import os"),
            "main.py": FileEntry(type="file", content="import os"),
        }
    )
    result = backend.grep("import", glob="src/*.py")
    assert result.matches is not None
    paths = {m["path"] for m in result.matches}
    assert paths == {"/src/main.py"}


def test_grep_glob_does_not_brace_expand() -> None:
    """Context Hub uses `fnmatch`, which treats braces literally.

    Unlike the wcmatch-backed in-memory grep helpers (which enable brace
    expansion), `*.{py,md}` here matches a file literally named `x.{py,md}`
    rather than expanding to `*.py` or `*.md`. This locks in the intended
    asymmetry between the two grep paths.
    """
    backend, _ = _make_backend(
        **{
            "a.py": FileEntry(type="file", content="hit"),
            "b.md": FileEntry(type="file", content="hit"),
            "literal.{py,md}": FileEntry(type="file", content="hit"),
        }
    )
    result = backend.grep("hit", glob="*.{py,md}")
    assert result.matches is not None
    paths = {m["path"] for m in result.matches}
    assert paths == {"/literal.{py,md}"}


def test_grep_invalid_regex() -> None:
    backend, _ = _make_backend()
    result = backend.grep("[unclosed")
    assert result.error is not None
    assert "Invalid regex" in result.error


def test_glob_matches_pattern() -> None:
    backend, _ = _make_backend(
        **{
            "a.md": FileEntry(type="file", content="x"),
            "b.txt": FileEntry(type="file", content="y"),
            "c.md": FileEntry(type="file", content="z"),
        }
    )
    result = backend.glob("*.md")
    assert result.matches is not None
    paths = sorted(m["path"] for m in result.matches)
    assert paths == ["/a.md", "/c.md"]


def test_upload_text_file_succeeds() -> None:
    backend, mock_client = _make_backend()
    responses = backend.upload_files([("/note.md", b"hello")])
    assert len(responses) == 1
    assert responses[0].error is None
    mock_client.push_agent.assert_called_once()


def test_upload_binary_rejected() -> None:
    backend, _ = _make_backend()
    responses = backend.upload_files([("/x.bin", b"\x80\x81\xff")])
    assert responses[0].error == "invalid_path"


def test_upload_partial_success() -> None:
    backend, _ = _make_backend()
    responses = backend.upload_files([("/ok.md", b"hello"), ("/bad.bin", b"\x80")])
    assert len(responses) == 2
    assert responses[0].error is None
    assert responses[1].error == "invalid_path"


def test_upload_multiple_files_uses_single_commit() -> None:
    """Batch upload of N text files must produce one push_agent call, not N."""
    backend, mock_client = _make_backend()
    responses = backend.upload_files(
        [
            ("/a.md", b"alpha"),
            ("/b.md", b"beta"),
            ("/nested/c.md", b"gamma"),
        ]
    )
    assert all(r.error is None for r in responses)

    mock_client.push_agent.assert_called_once()
    call = mock_client.push_agent.call_args
    files_payload = call.kwargs["files"]
    assert set(files_payload.keys()) == {"a.md", "b.md", "nested/c.md"}
    assert files_payload["a.md"].content == "alpha"
    assert files_payload["b.md"].content == "beta"
    assert files_payload["nested/c.md"].content == "gamma"


def test_upload_partial_success_only_commits_valid_files() -> None:
    """Mixed valid/invalid input still flushes valid files in a single commit."""
    backend, mock_client = _make_backend()
    responses = backend.upload_files(
        [
            ("/ok.md", b"hello"),
            ("/bad.bin", b"\x80"),
            ("/also-ok.md", b"world"),
        ]
    )
    assert responses[0].error is None
    assert responses[1].error == "invalid_path"
    assert responses[2].error is None

    mock_client.push_agent.assert_called_once()
    files_payload = mock_client.push_agent.call_args.kwargs["files"]
    assert set(files_payload.keys()) == {"ok.md", "also-ok.md"}


def test_upload_failure_propagates_to_all_pending_paths() -> None:
    """If the batch commit fails, every otherwise-valid file gets the hub error."""
    backend, mock_client = _make_backend()
    mock_client.push_agent.side_effect = LangSmithAPIError("503")

    responses = backend.upload_files([("/a.md", b"alpha"), ("/b.md", b"beta"), ("/bad.bin", b"\x80")])
    assert "Hub unavailable" in (responses[0].error or "")
    assert "Hub unavailable" in (responses[1].error or "")
    assert responses[2].error == "invalid_path"
    # Single batch call attempted, not three.
    mock_client.push_agent.assert_called_once()


def test_upload_duplicate_path_keeps_last_write() -> None:
    """If the same path appears twice in a batch, the later content wins."""
    backend, mock_client = _make_backend()
    responses = backend.upload_files([("/dup.md", b"first"), ("/dup.md", b"second")])
    assert all(r.error is None for r in responses)

    mock_client.push_agent.assert_called_once()
    files_payload = mock_client.push_agent.call_args.kwargs["files"]
    assert files_payload["dup.md"].content == "second"


def test_single_file_write_still_uses_one_commit() -> None:
    """Refactor regression: per-file write() must still issue exactly one commit."""
    backend, mock_client = _make_backend()
    backend.write("/note.md", "hi")
    mock_client.push_agent.assert_called_once()
    files_payload = mock_client.push_agent.call_args.kwargs["files"]
    assert set(files_payload.keys()) == {"note.md"}


def test_download_existing_file() -> None:
    backend, _ = _make_backend(**{"a.md": FileEntry(type="file", content="hi")})
    responses = backend.download_files(["/a.md"])
    assert len(responses) == 1
    assert responses[0].content == b"hi"
    assert responses[0].error is None


def test_download_missing_file() -> None:
    backend, _ = _make_backend()
    responses = backend.download_files(["/nope.md"])
    assert responses[0].error == "file_not_found"


def test_download_propagates_pull_failure() -> None:
    mock_client = MagicMock()
    mock_client.pull_agent.side_effect = LangSmithAPIError("5xx")
    backend = ContextHubBackend("-/x", client=mock_client)

    responses = backend.download_files(["/a.md"])
    assert len(responses) == 1
    assert responses[0].error is not None
    assert "Hub unavailable" in responses[0].error


def test_get_linked_entries_returns_repo_handles() -> None:
    backend, _ = _make_backend(
        **{
            "skills/reviewer": SkillEntry(type="skill", repo_handle="reviewer"),
            "subagents/planner": AgentEntry(type="agent", repo_handle="planner"),
            "AGENTS.md": FileEntry(type="file", content="a"),
        }
    )
    entries = backend.get_linked_entries()
    assert entries == {"skills/reviewer": "reviewer", "subagents/planner": "planner"}


def test_server_expanded_linked_files_are_readable() -> None:
    """Files expanded by the server under a linked skill path should be readable."""
    backend, _ = _make_backend(
        **{
            "skills/s": SkillEntry(type="skill", repo_handle="s"),
            "skills/s/skill.md": FileEntry(type="file", content="expanded"),
        }
    )
    result = backend.read("/skills/s/skill.md")
    assert result.error is None
    assert result.file_data is not None
    assert result.file_data["content"] == "expanded"


def test_default_client_constructed_when_not_provided() -> None:
    """Verify the default Client() path is exercised (even though we don't call it)."""
    with patch("deepagents.backends.context_hub.Client") as mock_client_cls:
        ContextHubBackend("-/x")
        mock_client_cls.assert_called_once_with()


def test_composite_backend_routes_prefix_correctly(tmp_path: Path) -> None:
    """Paths through a CompositeBackend route reach the hub with the prefix stripped.

    Wires a ContextHubBackend under `/memories/` and verifies write/read/ls/
    grep/glob round-trip: writes to `/memories/x` land at hub key `x`,
    reads come back with the route prefix restored, and writes outside the
    route go to the default backend.
    """
    hub_backend, mock_client = _make_backend()
    default_backend = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=True)
    composite = CompositeBackend(
        default=default_backend,
        routes={"/memories/": hub_backend},
    )

    # Write under the routed prefix — should reach hub as "notes.md" (stripped).
    composite.write("/memories/notes.md", "hello hub")
    call = mock_client.push_agent.call_args
    assert "notes.md" in call.kwargs["files"]
    assert call.kwargs["files"]["notes.md"].content == "hello hub"

    # Read through the composite returns the hub value.
    read = composite.read("/memories/notes.md")
    assert read.error is None
    assert read.file_data is not None
    assert read.file_data["content"] == "hello hub"

    # Write outside the route goes to the default backend, hub is untouched.
    composite.write("/fs-only.txt", "default side")
    assert mock_client.push_agent.call_count == 1
    assert (tmp_path / "fs-only.txt").read_text() == "default side"

    # ls on the route root yields paths WITH the /memories/ prefix restored.
    ls_mem = composite.ls("/memories/")
    assert ls_mem.entries is not None
    assert any(e["path"] == "/memories/notes.md" for e in ls_mem.entries)

    # grep scoped to the route finds the hub-backed file.
    grep_mem = composite.grep("hello", path="/memories")
    assert grep_mem.matches is not None
    assert any(m["path"] == "/memories/notes.md" for m in grep_mem.matches)

    # glob scoped to the route finds the hub-backed file.
    glob_mem = composite.glob("*.md", path="/memories")
    assert glob_mem.matches is not None
    assert any(m["path"] == "/memories/notes.md" for m in glob_mem.matches)


def test_delete_commits_none_entry() -> None:
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="bye")})
    result = backend.delete("/a.md")

    assert result.error is None
    assert result.path == "/a.md"
    mock_client.push_agent.assert_called_once()
    call = mock_client.push_agent.call_args
    files_arg = call.kwargs["files"]
    # A None entry is the deletion marker.
    assert "a.md" in files_arg
    assert files_arg["a.md"] is None
    assert call.kwargs["parent_commit"] == _COMMIT_HASH


def test_delete_directory_commits_all_nested_entries() -> None:
    backend, mock_client = _make_backend(
        **{
            "work/a.md": FileEntry(type="file", content="a"),
            "work/sub/b.md": FileEntry(type="file", content="b"),
            "keep.md": FileEntry(type="file", content="k"),
        }
    )
    result = backend.delete("/work")

    assert result.error is None
    assert result.path == "/work"
    files_arg = mock_client.push_agent.call_args.kwargs["files"]
    # Every nested entry under /work is marked for deletion...
    assert files_arg["work/a.md"] is None
    assert files_arg["work/sub/b.md"] is None
    # ...and the sibling outside the subtree is left alone.
    assert "keep.md" not in files_arg


def test_delete_missing_returns_not_found() -> None:
    backend, mock_client = _make_backend()
    result = backend.delete("/ghost.md")

    assert result.path is None
    assert result.error is not None
    assert "not found" in result.error
    mock_client.push_agent.assert_not_called()


def test_delete_updates_cache_after_commit() -> None:
    backend, _ = _make_backend(**{"a.md": FileEntry(type="file", content="bye")})
    assert backend.read("/a.md").error is None

    backend.delete("/a.md")

    # File is gone from the cache; no re-pull needed.
    assert backend.read("/a.md").error is not None


def test_delete_failure_invalidates_cache() -> None:
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="a")})
    mock_client.push_agent.side_effect = LangSmithAPIError("500")

    result = backend.delete("/a.md")
    assert result.error is not None
    assert "Hub unavailable" in result.error

    backend.read("/a.md")
    assert mock_client.pull_agent.call_count == 2


async def test_adelete_commits_none_entry() -> None:
    """Async delete (protocol default `asyncio.to_thread`) behaves like sync."""
    backend, mock_client = _make_backend(**{"a.md": FileEntry(type="file", content="bye")})
    result = await backend.adelete("/a.md")

    assert result.error is None
    assert result.path == "/a.md"
    mock_client.push_agent.assert_called_once()
    files_arg = mock_client.push_agent.call_args.kwargs["files"]
    # A None entry is the deletion marker, same as the sync path.
    assert files_arg["a.md"] is None
    # Cache was updated, so a follow-up read finds the file gone without re-pulling.
    assert backend.read("/a.md").error is not None


async def test_adelete_missing_returns_not_found() -> None:
    backend, mock_client = _make_backend()
    result = await backend.adelete("/ghost.md")

    assert result.path is None
    assert result.error is not None
    assert "not found" in result.error
    mock_client.push_agent.assert_not_called()
