"""Backend client abstractions shared across CLI and MCP."""

from __future__ import annotations

import hashlib
from collections.abc import Sequence
from datetime import datetime, timezone
from typing import Any, Protocol, runtime_checkable
from uuid import uuid4

import httpx

from .config import BackendConfig
from .exceptions import (
    AuthenticationError,
    BackendRequestError,
    ConfigError,
    ConflictError,
    NotFoundError,
    PreconditionFailedError,
    ValidationFailure,
)
from .models import (
    ContextSearchResult,
    File,
    Item,
    ItemListResult,
    ItemMetadata,
    ItemSummary,
    Project,
    SearchResult,
)
from .request_context import get_clerk_user_id


def _unwrap_collection(payload: dict | list | None, key: str) -> list:
    if payload is None:
        return []
    if isinstance(payload, dict):
        data = payload.get(key)
        if data is None:
            return []
        if isinstance(data, list):
            return data
        raise BackendRequestError(f"Expected '{key}' to be a list payload.")
    if isinstance(payload, list):
        return payload
    raise BackendRequestError("Unexpected payload type from backend.")


@runtime_checkable
class BackendClient(Protocol):
    """Protocol describing backend interactions."""

    def list_projects(self) -> list[Project]:
        """Return available projects."""

    def get_project(self, project_key: str) -> tuple[Project, str]:
        """Fetch a single project and its ETag."""

    def create_project(self, key: str, name: str, description: str | None = None) -> Project:
        """Create a new project."""

    def delete_project(self, project_key: str, etag: str) -> None:
        """Delete an empty project (requires ETag for optimistic concurrency)."""

    def list_items(
        self, project_key: str, *, limit: int | None = None, cursor: str | None = None
    ) -> ItemListResult:
        """Return item summaries for the project with optional pagination."""

    def get_item(self, project_key: str, item_key: str, include_text: bool = False) -> Item:
        """Fetch a specific item."""

    def get_item_metadata(self, project_key: str, item_key: str) -> tuple[ItemMetadata, str]:
        """Fetch item metadata (without data field) and its ETag."""

    def patch_item(
        self,
        project_key: str,
        item_key: str,
        etag: str,
        *,
        title: str | None = None,
        mime_type: str | None = None,
        tags: Sequence[str] | None = None,
    ) -> ItemMetadata:
        """Partially update item metadata (requires at least one field)."""

    def get_item_by_content_sha256(
        self, project_key: str, content_sha256: str, content_type: str | None = None
    ) -> Item | None:
        """Look up an item by its content hash for deduplication.

        Returns the existing item if content already exists, None otherwise.
        """

    def put_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: Sequence[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        """Create or update an item."""

    def create_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: Sequence[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        """Create an item. Raises ConflictError if key already exists.

        Use this for create-only semantics where overwrites are not allowed.
        """

    def delete_item(self, project_key: str, item_key: str) -> None:
        """Delete an item."""

    def search_items(
        self,
        query: str,
        project_key: str,
        tags: Sequence[str] | None = None,
        include_pending: bool = False,
    ) -> list[SearchResult]:
        """Search items.

        Args:
            query: Search query string.
            project_key: Project to search within.
            tags: Optional tags to filter by.
            include_pending: If True, include items with processing/failed status.
        """

    def search_items_v2(
        self,
        query: str,
        project_key: str,
        tags: Sequence[str] | None = None,
        include_pending: bool = False,
    ) -> list[ContextSearchResult]:
        """Search items using the unified /search/v2 endpoint.

        Args:
            query: Search query string.
            project_key: Project to search within.
            tags: Optional tags to filter by.
            include_pending: If True, include items with processing/failed status.

        Returns:
            List of ContextSearchResult objects with guaranteed item_key values.
        """

    def list_files(self, project_key: str) -> list[File]:
        """Return files for the project."""

    def get_file(self, project_key: str, file_id: str) -> File:
        """Fetch a file and its content."""

    def download_file(self, project_key: str, file_id: str) -> bytes:
        """Download a file's binary content."""

    def upload_file(
        self,
        project_key: str,
        *,
        name: str,
        data: bytes,
        mime_type: str,
        key: str | None = None,
    ) -> File:
        """Upload a file."""

    def delete_file(self, project_key: str, file_id: str) -> None:
        """Delete a file."""

    def close(self) -> None:
        """Release any underlying resources."""
        ...


def _compute_content_sha256(data: str) -> str:
    """Compute SHA-256 hash of content for deduplication."""
    return hashlib.sha256(data.encode("utf-8")).hexdigest()


class MockBackendClient(BackendClient):
    """In-memory client used for scaffolding and tests."""

    def __init__(self) -> None:
        now = datetime.now(tz=timezone.utc)
        self._projects = [
            Project(
                key="core", name="Core Context", description="Primary project for shared data."
            ),
            Project(key="engineering", name="Engineering", description="Build and infra notes."),
        ]
        welcome_id = str(uuid4())
        runbook_id = str(uuid4())
        welcome_data = "This is a placeholder item used by the CLI and MCP scaffolds."
        runbook_data = "1. Verify alerts 2. Capture context 3. Escalate with details."
        self._items: dict[tuple[str, str], Item] = {
            ("core", "welcome"): Item(
                id=welcome_id,
                project_key="core",
                key="welcome",
                title="Welcome to Context Platform",
                data=welcome_data,
                tags=["system"],
                content_sha256=_compute_content_sha256(welcome_data),
                content_type="inline",
                created_at=now,
                updated_at=now,
            ),
            ("engineering", "runbook"): Item(
                id=runbook_id,
                project_key="engineering",
                key="runbook",
                title="On-call runbook",
                data=runbook_data,
                tags=["ops", "runbook"],
                content_sha256=_compute_content_sha256(runbook_data),
                content_type="inline",
                created_at=now,
                updated_at=now,
            ),
        }
        self._files: dict[str, File] = {}
        self._file_blobs: dict[str, bytes] = {}

    def list_projects(self) -> list[Project]:
        return list(self._projects)

    def get_project(self, project_key: str) -> tuple[Project, str]:
        for project in self._projects:
            if project.key == project_key:
                # Generate a mock ETag based on project key
                etag = f'"{project_key}-v1"'
                return project, etag
        raise NotFoundError(
            f"Project '{project_key}' not found.",
            resource_type="project",
            resource_key=project_key,
        )

    def create_project(self, key: str, name: str, description: str | None = None) -> Project:
        # Check for duplicate key
        for project in self._projects:
            if project.key == key:
                raise ConflictError(f"Project key '{key}' already exists.")
        project = Project(key=key, name=name, description=description)
        self._projects.append(project)
        return project

    def delete_project(self, project_key: str, etag: str) -> None:
        # Find the project
        project_to_delete = None
        for project in self._projects:
            if project.key == project_key:
                project_to_delete = project
                break
        if project_to_delete is None:
            raise NotFoundError(
                f"Project '{project_key}' not found.",
                resource_type="project",
                resource_key=project_key,
            )

        # Check if project has items
        for item_key in self._items:
            if item_key[0] == project_key:
                raise ConflictError("Cannot delete project with existing items or files")

        # Check if project has files
        for file in self._files.values():
            if file.project_key == project_key:
                raise ConflictError("Cannot delete project with existing items or files")

        self._projects.remove(project_to_delete)

    def list_items(
        self, project_key: str, *, limit: int | None = None, cursor: str | None = None
    ) -> ItemListResult:
        summaries: list[ItemSummary] = []
        for item in self._items.values():
            if item.project_key != project_key:
                continue
            summaries.append(
                ItemSummary(
                    project_key=item.project_key,
                    key=item.key,
                    title=item.title,
                    snippet=item.data[:120] + ("..." if len(item.data) > 120 else ""),
                    tags=item.tags,
                    processing_status=item.processing_status,
                    updated_at=item.updated_at,
                    created_at=item.created_at,
                ),
            )
        if not summaries and project_key not in {p.key for p in self._projects}:
            raise NotFoundError(
                f"Project '{project_key}' does not exist.",
                resource_type="project",
                resource_key=project_key,
            )
        return ItemListResult(items=summaries, has_more=False)

    def get_item(self, project_key: str, item_key: str, include_text: bool = False) -> Item:
        try:
            return self._items[(project_key, item_key)]
        except KeyError as exc:
            raise NotFoundError(
                f"Item '{project_key}/{item_key}' not found.",
                resource_type="item",
                resource_key=f"{project_key}/{item_key}",
            ) from exc

    def get_item_metadata(self, project_key: str, item_key: str) -> tuple[ItemMetadata, str]:
        try:
            item = self._items[(project_key, item_key)]
        except KeyError as exc:
            raise NotFoundError(
                f"Item '{project_key}/{item_key}' not found.",
                resource_type="item",
                resource_key=f"{project_key}/{item_key}",
            ) from exc
        # Generate mock ETag based on item key and version (we use content hash as proxy)
        etag = f'"{item_key}-{item.content_sha256[:8] if item.content_sha256 else "v1"}"'
        metadata = ItemMetadata(
            project_key=item.project_key,
            key=item.key,
            title=item.title,
            mime_type=item.mime_type,
            tags=item.tags,
            content_sha256=item.content_sha256,
            etag=etag,
            created_at=item.created_at,
            updated_at=item.updated_at,
        )
        return metadata, etag

    def patch_item(
        self,
        project_key: str,
        item_key: str,
        etag: str,
        *,
        title: str | None = None,
        mime_type: str | None = None,
        tags: Sequence[str] | None = None,
    ) -> ItemMetadata:
        if title is None and mime_type is None and tags is None:
            raise ValueError("At least one field (title, mime_type, or tags) must be provided")
        try:
            item = self._items[(project_key, item_key)]
        except KeyError as exc:
            raise NotFoundError(
                f"Item '{project_key}/{item_key}' not found.",
                resource_type="item",
                resource_key=f"{project_key}/{item_key}",
            ) from exc

        # Verify ETag (mock check - just ensure it looks valid)
        expected_etag = f'"{item_key}-{item.content_sha256[:8] if item.content_sha256 else "v1"}"'
        if etag != expected_etag:
            raise PreconditionFailedError("ETag mismatch")

        # Update the item in place
        now = datetime.now(tz=timezone.utc)
        new_title = title if title is not None else item.title
        new_mime_type = mime_type if mime_type is not None else item.mime_type
        new_tags = list(tags) if tags is not None else item.tags

        updated_item = Item(
            id=item.id,
            project_key=item.project_key,
            key=item.key,
            title=new_title,
            data=item.data,
            mime_type=new_mime_type,
            tags=new_tags,
            content_sha256=item.content_sha256,
            content_type=item.content_type,
            created_at=item.created_at,
            updated_at=now,
        )
        self._items[(project_key, item_key)] = updated_item

        new_etag = (
            f'"{item_key}-{item.content_sha256[:8] if item.content_sha256 else "v1"}-patched"'
        )
        return ItemMetadata(
            project_key=updated_item.project_key,
            key=updated_item.key,
            title=updated_item.title,
            mime_type=updated_item.mime_type,
            tags=updated_item.tags,
            content_sha256=updated_item.content_sha256,
            etag=new_etag,
            created_at=updated_item.created_at,
            updated_at=updated_item.updated_at,
        )

    def get_item_by_content_sha256(
        self, project_key: str, content_sha256: str, content_type: str | None = None
    ) -> Item | None:
        for item in self._items.values():
            if item.project_key != project_key or item.content_sha256 != content_sha256:
                continue
            if content_type and item.content_type != content_type:
                continue
            return item
        return None

    def put_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: Sequence[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        if project_key not in {p.key for p in self._projects}:
            raise NotFoundError(
                f"Project '{project_key}' does not exist.",
                resource_type="project",
                resource_key=project_key,
            )
        now = datetime.now(tz=timezone.utc)
        title_to_use = title or item_key
        existing = self._items.get((project_key, item_key))
        created_at = existing.created_at if existing else now
        item_id = existing.id if existing else str(uuid4())
        item = Item(
            id=item_id,
            project_key=project_key,
            key=item_key,
            title=title_to_use,
            data=data,
            mime_type=mime_type,
            tags=list(tags or []),
            content_sha256=_compute_content_sha256(data),
            content_type="inline",
            created_at=created_at,
            updated_at=now,
        )
        self._items[(project_key, item_key)] = item
        return item

    def create_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: Sequence[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        if project_key not in {p.key for p in self._projects}:
            raise NotFoundError(
                f"Project '{project_key}' does not exist.",
                resource_type="project",
                resource_key=project_key,
            )
        if (project_key, item_key) in self._items:
            raise ConflictError(f"Item '{project_key}/{item_key}' already exists.")
        now = datetime.now(tz=timezone.utc)
        title_to_use = title or item_key
        item_id = str(uuid4())
        item = Item(
            id=item_id,
            project_key=project_key,
            key=item_key,
            title=title_to_use,
            data=data,
            mime_type=mime_type,
            tags=list(tags or []),
            content_sha256=_compute_content_sha256(data),
            content_type="inline",
            created_at=now,
            updated_at=now,
        )
        self._items[(project_key, item_key)] = item
        return item

    def delete_item(self, project_key: str, item_key: str) -> None:
        if (project_key, item_key) not in self._items:
            raise NotFoundError(
                f"Item '{project_key}/{item_key}' not found.",
                resource_type="item",
                resource_key=f"{project_key}/{item_key}",
            )
        self._items.pop((project_key, item_key), None)

    def search_items(
        self,
        query: str,
        project_key: str,
        tags: Sequence[str] | None = None,
        include_pending: bool = False,
    ) -> list[SearchResult]:
        lowered = query.lower()
        results: list[SearchResult] = []
        for item in self._items.values():
            if item.project_key != project_key:
                continue
            if tags and not set(tags).issubset(set(item.tags)):
                continue
            # Filter by processing status unless include_pending is True
            if not include_pending and item.processing_status not in (None, "ready"):
                continue
            item_data = item.data or ""
            if lowered in item_data.lower() or lowered in item.title.lower():
                score = 0.9 if lowered in item.title.lower() else 0.5
                snippet = item_data[:200]
                results.append(
                    SearchResult(
                        project_key=item.project_key,
                        source_type="item",
                        item_key=item.key,
                        title=item.title,
                        score=score,
                        snippet=snippet,
                        tags=item.tags,
                        updated_at=item.updated_at,
                        processing_status=item.processing_status,
                    ),
                )
        return results

    def search_items_v2(
        self,
        query: str,
        project_key: str,
        tags: Sequence[str] | None = None,
        include_pending: bool = False,
    ) -> list[ContextSearchResult]:
        lowered = query.lower()
        results: list[ContextSearchResult] = []
        for item in self._items.values():
            if item.project_key != project_key:
                continue
            if tags and not set(tags).issubset(set(item.tags)):
                continue
            # Filter by processing status unless include_pending is True
            if not include_pending and item.processing_status not in (None, "ready"):
                continue
            if lowered in item.data.lower() or lowered in item.title.lower():
                score = 0.9 if lowered in item.title.lower() else 0.5
                snippet = item.data[:200]
                results.append(
                    ContextSearchResult(
                        project_key=item.project_key,
                        item_key=item.key,
                        title=item.title,
                        content_type="inline",
                        processing_status=item.processing_status or "ready",
                        tags=item.tags,
                        updated_at=item.updated_at,
                        score=score,
                        snippet=snippet,
                        highlight_positions=None,
                        original_filename=None,
                        size=None,
                    ),
                )
        return results

    def list_files(self, project_key: str) -> list[File]:
        return [f for f in self._files.values() if f.project_key == project_key]

    def get_file(self, project_key: str, file_id: str) -> File:
        file = self._files.get(file_id)
        if file is None or file.project_key != project_key:
            raise NotFoundError(
                f"File '{project_key}/{file_id}' not found.",
                resource_type="file",
                resource_key=f"{project_key}/{file_id}",
            )
        return file

    def download_file(self, project_key: str, file_id: str) -> bytes:
        file = self._files.get(file_id)
        if file is None or file.project_key != project_key:
            raise NotFoundError(
                f"File '{project_key}/{file_id}' not found.",
                resource_type="file",
                resource_key=f"{project_key}/{file_id}",
            )
        return self._file_blobs[file_id]

    def upload_file(
        self,
        project_key: str,
        *,
        name: str,
        data: bytes,
        mime_type: str,
        key: str | None = None,
    ) -> File:
        now = datetime.now(tz=timezone.utc)
        file_id = str(uuid4())
        checksum = hashlib.sha256(data).hexdigest()
        text = data.decode("utf-8", errors="replace")
        file = File(
            project_key=project_key,
            id=file_id,
            filename=name,
            size=len(data),
            mime_type=mime_type,
            checksum=checksum,
            storage_key=f"{project_key}/{file_id}",
            data=text,
            created_at=now,
            updated_at=now,
        )
        self._files[file_id] = file
        self._file_blobs[file_id] = data
        return file

    def delete_file(self, project_key: str, file_id: str) -> None:
        file = self._files.get(file_id)
        if file and file.project_key == project_key:
            self._files.pop(file_id, None)
            self._file_blobs.pop(file_id, None)
        else:
            raise NotFoundError(
                f"File '{project_key}/{file_id}' not found.",
                resource_type="file",
                resource_key=f"{project_key}/{file_id}",
            )

    def close(self) -> None:
        """Mock backend holds no external resources."""
        return


class HttpBackendClient(BackendClient):
    """HTTP client that talks to the FastAPI backend."""

    def __init__(
        self,
        config: BackendConfig,
        *,
        timeout: float = 10.0,
        transport: httpx.BaseTransport | None = None,
    ) -> None:
        if not config.api_key:
            raise ConfigError("API key is required for HTTP backend access.")
        headers = {
            "Accept": "application/json",
            "Authorization": f"Bearer {config.api_key}",
        }
        self._client = httpx.Client(
            base_url=config.normalized_base_url,
            timeout=timeout,
            headers=headers,
            transport=transport,
        )

    def list_projects(self) -> list[Project]:
        payload = self._request("GET", "/v1/projects")
        data = _unwrap_collection(payload, "projects")
        return [_coerce_project(entry) for entry in data]

    def get_project(self, project_key: str) -> tuple[Project, str]:
        response = self._client.request("GET", f"/v1/projects/{project_key}")
        if response.is_error:
            self._raise_for_status(response)
        payload = response.json()
        etag = response.headers.get("ETag", "")
        return _coerce_project(payload), etag

    def create_project(self, key: str, name: str, description: str | None = None) -> Project:
        body = {"key": key, "name": name, "description": description}
        payload = self._request("POST", "/v1/projects", json=body)
        return _coerce_project(payload)

    def delete_project(self, project_key: str, etag: str) -> None:
        self._request(
            "DELETE",
            f"/v1/projects/{project_key}",
            headers={"If-Match": etag},
        )

    def list_items(
        self, project_key: str, *, limit: int | None = None, cursor: str | None = None
    ) -> ItemListResult:
        params: dict[str, object] = {}
        if limit is not None:
            params["limit"] = limit
        if cursor is not None:
            params["cursor"] = cursor
        payload = self._request("GET", f"/v1/projects/{project_key}/items", params=params or None)
        data = _unwrap_collection(payload, "items")
        items = [_coerce_item_summary(project_key, entry) for entry in data]
        next_cursor = payload.get("next_cursor") if isinstance(payload, dict) else None
        has_more = payload.get("has_more", False) if isinstance(payload, dict) else False
        return ItemListResult(items=items, next_cursor=next_cursor, has_more=has_more)

    def get_item(self, project_key: str, item_key: str, include_text: bool = False) -> Item:
        params: dict[str, object] = {}
        if include_text:
            params["include_text"] = "true"
        payload = self._request(
            "GET",
            f"/v1/projects/{project_key}/items/{item_key}",
            params=params or None,
        )
        return _coerce_item(project_key, payload)

    def get_item_metadata(self, project_key: str, item_key: str) -> tuple[ItemMetadata, str]:
        response = self._client.request(
            "GET", f"/v1/projects/{project_key}/items/{item_key}/metadata"
        )
        if response.is_error:
            self._raise_for_status(response)
        payload = response.json()
        etag = response.headers.get("ETag", "")
        return _coerce_item_metadata(project_key, payload), etag

    def patch_item(
        self,
        project_key: str,
        item_key: str,
        etag: str,
        *,
        title: str | None = None,
        mime_type: str | None = None,
        tags: Sequence[str] | None = None,
    ) -> ItemMetadata:
        body: dict[str, object] = {}
        if title is not None:
            body["title"] = title
        if mime_type is not None:
            body["mime_type"] = mime_type
        if tags is not None:
            body["tags"] = list(tags)

        if not body:
            raise ValueError("At least one field (title, mime_type, or tags) must be provided")

        payload = self._request(
            "PATCH",
            f"/v1/projects/{project_key}/items/{item_key}",
            json=body,
            headers={"If-Match": etag},
        )
        return _coerce_item_metadata(project_key, payload)

    def get_item_by_content_sha256(
        self, project_key: str, content_sha256: str, content_type: str | None = None
    ) -> Item | None:
        params: dict[str, object] = {"content_sha256": content_sha256}
        if content_type:
            params["content_type"] = content_type
        payload = self._request(
            "GET",
            f"/v1/projects/{project_key}/items",
            params=params,
        )
        items = _unwrap_collection(payload, "items")
        if not items:
            return None
        return _coerce_item(project_key, items[0])

    def put_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: Sequence[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        def _build_body(resolved_title: str) -> dict[str, object]:
            return {
                "data": data,
                "title": resolved_title,
                "tags": list(tags or []),
                "mime_type": mime_type,
            }

        try:
            existing = self._request("GET", f"/v1/projects/{project_key}/items/{item_key}")
        except NotFoundError:
            resolved_title = title or item_key
            return self._create_item(project_key, item_key, _build_body(resolved_title))

        resolved_title = title or existing.get("title") or item_key
        body = _build_body(resolved_title)
        etag = existing.get("etag")
        if not etag:
            raise BackendRequestError("Backend response missing ETag for item update.")

        payload = self._request(
            "PUT",
            f"/v1/projects/{project_key}/items/{item_key}",
            json=body,
            headers={"If-Match": etag},
        )
        return _coerce_item(project_key, payload)

    def create_item(
        self,
        project_key: str,
        item_key: str,
        *,
        data: str,
        title: str | None = None,
        tags: Sequence[str] | None = None,
        mime_type: str = "text/plain",
    ) -> Item:
        body = {
            "item_key": item_key,
            "data": data,
            "title": title or item_key,
            "tags": list(tags or []),
            "mime_type": mime_type,
        }
        payload = self._request(
            "POST",
            f"/v1/projects/{project_key}/items",
            json=body,
        )
        return _coerce_item(project_key, payload)

    def delete_item(self, project_key: str, item_key: str) -> None:
        self._request("DELETE", f"/v1/projects/{project_key}/items/{item_key}")

    def search_items(
        self,
        query: str,
        project_key: str,
        tags: Sequence[str] | None = None,
        include_pending: bool = False,
    ) -> list[SearchResult]:
        params: dict[str, object] = {"q": query}
        if tags:
            params["tags"] = ",".join(tags)
        if include_pending:
            params["include_pending"] = "true"
        payload = self._request("GET", f"/v1/projects/{project_key}/search", params=params)
        data = _unwrap_collection(payload, "results")
        return [SearchResult.model_validate(entry) for entry in data]

    def search_items_v2(
        self,
        query: str,
        project_key: str,
        tags: Sequence[str] | None = None,
        include_pending: bool = False,
    ) -> list[ContextSearchResult]:
        params: dict[str, object] = {"q": query}
        if tags:
            params["tags"] = ",".join(tags)
        if include_pending:
            params["include_pending"] = "true"
        payload = self._request("GET", f"/v1/projects/{project_key}/search/v2", params=params)
        data = _unwrap_collection(payload, "results")
        return [ContextSearchResult.model_validate(entry) for entry in data]

    def list_files(self, project_key: str) -> list[File]:
        payload = self._request("GET", f"/v1/projects/{project_key}/files")
        data = _unwrap_collection(payload, "files")
        return [_coerce_file(project_key, entry) for entry in data]

    def get_file(self, project_key: str, file_id: str) -> File:
        payload = self._request("GET", f"/v1/projects/{project_key}/files/{file_id}")
        return _coerce_file(project_key, payload)

    def download_file(self, project_key: str, file_id: str) -> bytes:
        response = self._client.request(
            "GET",
            f"/v1/projects/{project_key}/files/{file_id}/download",
        )
        if response.is_error:
            self._raise_for_status(response)
        return response.content

    def upload_file(
        self,
        project_key: str,
        *,
        name: str,
        data: bytes,
        mime_type: str,
        key: str | None = None,
    ) -> File:
        files = {"uploaded": (name, data, mime_type)}
        form_data = {"key": key} if key else None
        payload = self._request(
            "POST", f"/v1/projects/{project_key}/files", files=files, data=form_data
        )
        return _coerce_file(project_key, payload)

    def delete_file(self, project_key: str, file_id: str) -> None:
        self._request("DELETE", f"/v1/projects/{project_key}/files/{file_id}")

    def _create_item(self, project_key: str, item_key: str, body: dict[str, object]) -> Item:
        payload = self._request(
            "POST",
            f"/v1/projects/{project_key}/items",
            json={"key": item_key, **body},
        )
        return _coerce_item(project_key, payload)

    def close(self) -> None:
        self._client.close()

    def __enter__(self) -> HttpBackendClient:
        return self

    def __exit__(self, *exc_info: object) -> None:
        self.close()

    def _request(self, method: str, path: str, **kwargs: object) -> dict | list | None:
        headers = dict(kwargs.pop("headers", {}) or {})
        clerk_user_id = get_clerk_user_id()
        if clerk_user_id:
            headers.setdefault("X-Clerk-User-Id", clerk_user_id)
        # Add provenance header for item creation (POST to /items or /files)
        if method.upper() == "POST" and ("/items" in path or "/files" in path):
            headers.setdefault("X-Created-Via", "cli")
        if headers:
            kwargs["headers"] = headers
        response = self._client.request(method, path, **kwargs)
        if response.status_code == 204:
            return None
        if response.is_error:
            self._raise_for_status(response)
        if not response.content:
            return None
        return response.json()

    @staticmethod
    def _raise_for_status(response: httpx.Response) -> None:
        status = response.status_code
        body = response.text
        detail = body or response.reason_phrase
        request_id = response.headers.get("x-request-id")
        request_url = str(response.request.url) if response.request else None
        response_body = body or None
        error_kwargs = {
            "status_code": status,
            "request_id": request_id,
            "response_body": response_body,
            "request_url": request_url,
        }
        if status in {401, 403}:
            raise AuthenticationError(detail, **error_kwargs)
        if status == 404:
            raise NotFoundError(detail, **error_kwargs)
        if status == 409:
            raise ConflictError(detail, **error_kwargs)
        if status == 412:
            raise PreconditionFailedError(detail, **error_kwargs)
        if status == 422:
            raise ValidationFailure(detail, **error_kwargs)
        raise BackendRequestError(f"{status}: {detail}", **error_kwargs)


def _coerce_project(entry: Any) -> Project:
    if not isinstance(entry, dict):
        raise BackendRequestError("Invalid project payload from backend.")
    key = entry.get("key")
    name = entry.get("name") or key
    if not key or not name:
        raise BackendRequestError("Project payload missing required fields.")
    payload = {
        "key": key,
        "name": name,
        "description": entry.get("description"),
    }
    return Project.model_validate(payload)


def _coerce_item_summary(project_key: str, entry: Any) -> ItemSummary:
    if not isinstance(entry, dict):
        raise BackendRequestError("Invalid item payload from backend.")
    key = _extract_item_key(entry)
    snippet = entry.get("snippet")
    if snippet is None:
        data = entry.get("data") or ""
        snippet = data[:200]
    payload = {
        "project_key": project_key,
        "key": key,
        "title": entry.get("title") or key,
        "snippet": snippet,
        "tags": entry.get("tags") or [],
        "processing_status": entry.get("processing_status"),
        "updated_at": entry.get("updated_at"),
        "created_at": entry.get("created_at"),
    }
    return ItemSummary.model_validate(payload)


def _coerce_item(project_key: str, entry: Any) -> Item:
    if not isinstance(entry, dict):
        raise BackendRequestError("Invalid item payload from backend.")
    payload = dict(entry)
    payload["project_key"] = project_key
    payload["key"] = _extract_item_key(entry)
    payload.pop("item_key", None)
    payload["content_type"] = payload.get("content_type") or "inline"
    payload.setdefault("mime_type", "text/plain")
    payload.setdefault("tags", payload.get("tags") or [])
    payload.setdefault("data", None)
    payload.setdefault("extracted_text", None)
    return Item.model_validate(payload)


def _extract_item_key(entry: dict[str, Any]) -> str:
    key = entry.get("item_key") or entry.get("key")
    if not key:
        raise BackendRequestError("Backend payload missing item key.")
    return key


def _coerce_file(project_key: str, entry: Any) -> File:
    if not isinstance(entry, dict):
        raise BackendRequestError("Invalid file payload from backend.")
    required = ["id", "filename", "size", "mime_type", "checksum", "storage_key"]
    for key in required:
        if key not in entry:
            raise BackendRequestError("File payload missing required fields.")
    payload = dict(entry)
    payload["project_key"] = project_key
    return File.model_validate(payload)


def _coerce_item_metadata(project_key: str, entry: Any) -> ItemMetadata:
    if not isinstance(entry, dict):
        raise BackendRequestError("Invalid item metadata payload from backend.")
    payload = dict(entry)
    payload["project_key"] = project_key
    payload["key"] = _extract_item_key(entry)
    payload.pop("item_key", None)
    payload.setdefault("mime_type", "text/plain")
    payload.setdefault("tags", payload.get("tags") or [])
    return ItemMetadata.model_validate(payload)
