"""Integration tests for ContextHubBackend against a real LangSmith Hub.

Skipped unless `LANGSMITH_API_KEY` is set. Each test fixture creates a
uniquely-named throwaway agent repo and deletes it on teardown, so these
tests are safe to run against a real tenant.
"""

from __future__ import annotations

import logging
import os
import uuid
from typing import TYPE_CHECKING, Any
from unittest.mock import patch

import pytest
from langsmith import Client

from deepagents.backends import ContextHubBackend

if TYPE_CHECKING:
    from collections.abc import Iterator

pytestmark = pytest.mark.skipif(
    not os.environ.get("LANGSMITH_API_KEY"),
    reason="LANGSMITH_API_KEY not set; skipping Context Hub integration tests.",
)

logger = logging.getLogger(__name__)


@pytest.fixture
def identifier() -> str:
    """Unique throwaway agent-repo handle under the current tenant."""
    return f"-/deepagents-ctx-hub-test-{uuid.uuid4().hex[:12]}"


@pytest.fixture
def backend(identifier: str) -> Iterator[ContextHubBackend]:
    """Build a ContextHubBackend and delete the underlying repo on teardown."""
    client = Client()
    yield ContextHubBackend(identifier, client=client)

    try:
        client.delete_agent(identifier)
    except Exception:  # noqa: BLE001
        logger.warning("Failed to delete test repo %r", identifier, exc_info=True)


def test_lazy_create_on_first_write(backend: ContextHubBackend) -> None:
    """Missing paths should read as not found, and writes should round-trip."""
    missing = backend.read("/notes.md")
    assert missing.error is not None

    write = backend.write("/notes.md", "# hi")
    assert write.error is None
    assert write.path == "/notes.md"

    read = backend.read("/notes.md")
    assert read.error is None
    assert read.file_data is not None
    assert read.file_data["content"] == "# hi"


def test_round_trip_with_ls_grep_glob_edit(backend: ContextHubBackend) -> None:
    """CRUD + search operations should behave consistently."""
    assert backend.write("/a.md", "hello\nworld").error is None
    assert backend.write("/b.md", "hello again").error is None
    assert backend.write("/notes/day1.md", "first note").error is None

    ls_root = backend.ls("/")
    assert ls_root.entries is not None
    root_paths = {entry["path"] for entry in ls_root.entries}
    assert "/a.md" in root_paths
    assert "/b.md" in root_paths
    assert any(path.rstrip("/") == "/notes" for path in root_paths)

    ls_nested = backend.ls("/notes")
    assert ls_nested.entries is not None
    assert {entry["path"] for entry in ls_nested.entries} == {"/notes/day1.md"}

    grep = backend.grep("hello")
    assert grep.matches is not None
    assert {match["path"] for match in grep.matches} == {"/a.md", "/b.md"}

    glob = backend.glob("*.md")
    assert glob.matches is not None
    glob_paths = {match["path"] for match in glob.matches}
    assert "/a.md" in glob_paths
    assert "/b.md" in glob_paths

    edit = backend.edit("/a.md", "world", "earth")
    assert edit.error is None
    assert edit.occurrences == 1

    updated = backend.read("/a.md")
    assert updated.error is None
    assert updated.file_data is not None
    assert "earth" in updated.file_data["content"]


def test_download_files_round_trip(backend: ContextHubBackend) -> None:
    """Download should return content for existing files and errors for missing paths."""
    assert backend.write("/blob.txt", "payload").error is None

    responses = backend.download_files(["/blob.txt", "/missing.txt"])
    assert len(responses) == 2
    assert responses[0].path == "/blob.txt"
    assert responses[0].content == b"payload"
    assert responses[0].error is None
    assert responses[1].path == "/missing.txt"
    assert responses[1].error is not None


def test_upload_files_round_trip(backend: ContextHubBackend) -> None:
    """Upload should support partial success and persist valid UTF-8 files."""
    responses = backend.upload_files(
        [
            ("/u1.md", b"one"),
            ("/u2.md", b"two"),
            ("/bad.bin", b"\x80\xff"),
        ]
    )
    assert responses[0].error is None
    assert responses[1].error is None
    assert responses[2].error == "invalid_path"

    first = backend.read("/u1.md")
    assert first.file_data is not None
    assert first.file_data["content"] == "one"

    second = backend.read("/u2.md")
    assert second.file_data is not None
    assert second.file_data["content"] == "two"


def test_persists_across_backend_instances(backend: ContextHubBackend, identifier: str) -> None:
    """A fresh ContextHubBackend on the same identifier sees prior writes."""
    assert backend.write("/persist.md", "original").error is None

    second = ContextHubBackend(identifier, client=Client())
    result = second.read("/persist.md")
    assert result.error is None
    assert result.file_data is not None
    assert result.file_data["content"] == "original"


def test_parent_commit_conflict_surfaces_error(backend: ContextHubBackend, identifier: str) -> None:
    """Concurrent writes against a stale parent_commit should be rejected."""
    assert backend.write("/shared.md", "v0").error is None

    stale = ContextHubBackend(identifier, client=Client())
    stale.read("/shared.md")  # prime stale's commit_hash with current state

    # `backend` advances the repo.
    assert backend.write("/shared.md", "v1").error is None

    # `stale` now has an outdated parent_commit; server rejects.
    result = stale.write("/other.md", "should-fail")
    assert result.error is not None
    assert "Hub unavailable" in result.error


def test_upload_files_produces_single_commit(identifier: str) -> None:
    """Batch upload of N files should make exactly one `push_agent` call.

    Unit tests assert this with a fully-mocked client; this test wraps a
    real `langsmith.Client` so we get the same guarantee against the
    actual Hub API surface, and additionally confirms the resulting commit
    persists every file in one shot.
    """
    real_client = Client()
    push_calls: list[dict[str, Any]] = []
    original_push = type(real_client).push_agent

    def spy_push(self, identifier: str, **kwargs: Any) -> str:
        push_calls.append({"identifier": identifier, **kwargs})
        return original_push(self, identifier, **kwargs)

    backend = ContextHubBackend(identifier, client=real_client)
    try:
        with patch.object(type(real_client), "push_agent", spy_push):
            responses = backend.upload_files(
                [
                    ("/batch/a.md", b"alpha"),
                    ("/batch/b.md", b"beta"),
                    ("/batch/c.md", b"gamma"),
                    ("/batch/d.md", b"delta"),
                ]
            )
        assert all(r.error is None for r in responses), responses
        assert len(push_calls) == 1, f"expected one push_agent call, got {len(push_calls)}"
        assert set(push_calls[0]["files"].keys()) == {
            "batch/a.md",
            "batch/b.md",
            "batch/c.md",
            "batch/d.md",
        }

        # Confirm the commit actually landed and contains all four files.
        pulled = Client().pull_agent(identifier)
        pulled_paths = set(pulled.files.keys())
        assert {"batch/a.md", "batch/b.md", "batch/c.md", "batch/d.md"} <= pulled_paths
    finally:
        try:
            Client().delete_agent(identifier)
        except Exception:  # noqa: BLE001
            logger.warning("Failed to delete test repo %r", identifier, exc_info=True)
