"""
Lists and List Entries service.

Provides operations for managing lists (spreadsheets) and their entries (rows).
"""

from __future__ import annotations

import builtins
import contextlib
import re
import warnings
from collections.abc import AsyncIterator, Iterator, Sequence
from typing import TYPE_CHECKING, Any, TypeVar
from urllib.parse import urlsplit

from pydantic import BaseModel
from pydantic import ValidationError as PydanticValidationError

from ..exceptions import AffinityError
from ..filters import FilterExpression
from ..filters import parse as parse_filter
from ..models.entities import (
    AffinityList,
    FieldMetadata,
    FieldValues,
    ListCreate,
    ListEntry,
    ListEntryWithEntity,
    SavedView,
)
from ..models.pagination import (
    AsyncPageIterator,
    BatchOperationResponse,
    PageIterator,
    PaginatedResponse,
    PaginationInfo,
)
from ..models.types import (
    AnyFieldId,
    CompanyId,
    FieldType,
    ListEntryId,
    ListId,
    ListType,
    OpportunityId,
    PersonId,
    SavedViewId,
)

if TYPE_CHECKING:
    from ..clients.http import AsyncHTTPClient, HTTPClient


_LIST_SAVED_VIEWS_CURSOR_RE = re.compile(r"/lists/(?P<list_id>\\d+)/saved-views(?:/|$)")

T = TypeVar("T", bound=BaseModel)


def _safe_model_validate(model: type[T], payload: Any, *, context: str | None = None) -> T:
    label = context or getattr(model, "__name__", "response")
    try:
        return model.model_validate(payload)
    except PydanticValidationError as exc:
        raise AffinityError(
            f"Invalid API response while parsing {label}.",
            response_body=payload,
        ) from exc


def _saved_views_list_id_from_cursor(cursor: str) -> int | None:
    try:
        path = urlsplit(cursor).path or ""
    except Exception:
        return None
    m = _LIST_SAVED_VIEWS_CURSOR_RE.search(path)
    if not m:
        return None
    try:
        return int(m.group("list_id"))
    except ValueError:
        return None


class ListService:
    """
    Service for managing lists.

    Lists are spreadsheet-like collections of people, companies, or opportunities.
    """

    def __init__(self, client: HTTPClient):
        self._client = client
        self._resolve_cache: dict[tuple[str, ListType | None], AffinityList | None] = {}

    def entries(self, list_id: ListId) -> ListEntryService:
        """
        Get a ListEntryService for a specific list.

        This is the explicit path for retrieving "full row" data via list entries.
        """
        return ListEntryService(self._client, list_id)

    # =========================================================================
    # List Operations (V2 for read, V1 for write)
    # =========================================================================

    def list(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[AffinityList]:
        """
        Get all lists accessible to you.

        Args:
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).

        Returns:
            Paginated list of lists (without field metadata)
        """
        if cursor is not None:
            if limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            data = self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            data = self._client.get("/lists", params=params or None)

        return PaginatedResponse[AffinityList](
            data=[
                _safe_model_validate(AffinityList, list_item) for list_item in data.get("data", [])
            ],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    def pages(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> Iterator[PaginatedResponse[AffinityList]]:
        """
        Iterate list pages (not items), yielding `PaginatedResponse[AffinityList]`.

        This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
        """
        if cursor is not None and limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        page = self.list(limit=limit) if cursor is None else self.list(cursor=cursor)
        while True:
            yield page
            if not page.has_next:
                return
            next_cursor = page.next_cursor
            if next_cursor is None or next_cursor == requested_cursor:
                return
            requested_cursor = next_cursor
            page = self.list(cursor=next_cursor)

    def all(self) -> Iterator[AffinityList]:
        """Iterate through all accessible lists."""

        def fetch_page(next_url: str | None) -> PaginatedResponse[AffinityList]:
            if next_url:
                data = self._client.get_url(next_url)
                return PaginatedResponse[AffinityList](
                    data=[
                        _safe_model_validate(AffinityList, list_item)
                        for list_item in data.get("data", [])
                    ],
                    pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
                )
            return self.list()

        return PageIterator(fetch_page)

    def iter(self) -> Iterator[AffinityList]:
        """
        Auto-paginate all lists.

        Alias for `all()` (FR-006 public contract).
        """
        return self.all()

    def get(self, list_id: ListId) -> AffinityList:
        """
        Get a single list by ID.

        Includes field metadata for the list.

        Note: Uses V1 API because V2's listSize field is undocumented and
        returns incorrect values (often 0 for non-empty lists).
        """
        data = self._client.get(f"/lists/{list_id}", v1=True)
        return _safe_model_validate(AffinityList, data)

    def resolve(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> AffinityList | None:
        """
        Find a single list by name (optionally filtered by type).

        Notes:
        - This iterates list pages client-side (the API does not expose a list-search endpoint).
        - Results are cached in-memory on this service instance. If you call this frequently,
          reuse the client, or persist the resolved `ListId` in your own configuration.

        If multiple matches exist, returns the first match in server-provided order.
        """
        key = (name.lower(), list_type)
        if key in self._resolve_cache:
            return self._resolve_cache[key]

        for item in self.all():
            if item.name.lower() == name.lower() and (list_type is None or item.type == list_type):
                self._resolve_cache[key] = item
                return item

        self._resolve_cache[key] = None
        return None

    def resolve_all(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> builtins.list[AffinityList]:
        """
        Find all lists matching a name (optionally filtered by type).

        Notes:
        - This iterates list pages client-side (the API does not expose a list-search endpoint).
        - Unlike `resolve()`, this does not cache results.
        """
        matches: builtins.list[AffinityList] = []
        name_lower = name.lower()
        for item in self.all():
            if item.name.lower() != name_lower:
                continue
            if list_type is not None and item.type != list_type:
                continue
            matches.append(item)
        return matches

    def create(self, data: ListCreate) -> AffinityList:
        """
        Create a new list.

        Uses V1 API.
        """
        payload = data.model_dump(mode="json", exclude_none=True, exclude_unset=True)
        if not data.additional_permissions:
            payload.pop("additional_permissions", None)

        result = self._client.post("/lists", json=payload, v1=True)

        # Invalidate cache
        if self._client.cache:
            self._client.cache.invalidate_prefix("list")
        self._resolve_cache.clear()

        return _safe_model_validate(AffinityList, result)

    # =========================================================================
    # Field Operations
    # =========================================================================

    def get_fields(
        self,
        list_id: ListId,
        *,
        field_types: Sequence[FieldType] | None = None,
    ) -> builtins.list[FieldMetadata]:
        """
        Get fields (columns) for a list.

        Includes list-specific, global, enriched, and relationship intelligence fields.
        Cached for performance.
        """
        params: dict[str, Any] = {}
        if field_types:
            params["fieldTypes"] = [field_type.value for field_type in field_types]

        data = self._client.get(
            f"/lists/{list_id}/fields",
            params=params or None,
            cache_key=f"list_{list_id}_fields:{','.join(field_types or [])}",
            cache_ttl=300,
        )

        return [_safe_model_validate(FieldMetadata, f) for f in data.get("data", [])]

    # =========================================================================
    # Saved View Operations
    # =========================================================================

    def get_saved_views(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[SavedView]:
        """
        Get saved views for a list.

        Args:
            list_id: List id for the initial request.
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).
        """
        if cursor is not None:
            if limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            cursor_list_id = _saved_views_list_id_from_cursor(cursor)
            if cursor_list_id is not None and int(list_id) != cursor_list_id:
                raise ValueError(
                    f"Cursor does not match list_id: cursor is for list {cursor_list_id}, "
                    f"requested list_id is {int(list_id)}"
                )
            data = self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            data = self._client.get(f"/lists/{list_id}/saved-views", params=params or None)

        return PaginatedResponse[SavedView](
            data=[_safe_model_validate(SavedView, v) for v in data.get("data", [])],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    def saved_views_pages(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> Iterator[PaginatedResponse[SavedView]]:
        """Iterate saved view pages, yielding `PaginatedResponse[SavedView]`."""
        if cursor is not None and limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        page = (
            self.get_saved_views(list_id, limit=limit)
            if cursor is None
            else self.get_saved_views(list_id, cursor=cursor)
        )
        while True:
            yield page
            if not page.has_next:
                return
            next_cursor = page.next_cursor
            if next_cursor is None or next_cursor == requested_cursor:
                return
            requested_cursor = next_cursor
            page = self.get_saved_views(list_id, cursor=next_cursor)

    def saved_views_all(self, list_id: ListId) -> Iterator[SavedView]:
        """Iterate all saved views for a list."""
        for page in self.saved_views_pages(list_id):
            yield from page.data

    def get_saved_view(self, list_id: ListId, view_id: SavedViewId) -> SavedView:
        """Get a single saved view."""
        data = self._client.get(f"/lists/{list_id}/saved-views/{view_id}")
        return _safe_model_validate(SavedView, data)


def _entry_to_filter_dict(entry: ListEntryWithEntity) -> dict[str, Any]:
    """
    Convert a ListEntryWithEntity to a dict for client-side filter matching.

    Extracts field values by name from the entity's fields_raw (V2 API format).
    This allows FilterExpression.matches() to evaluate against field values.
    """
    result: dict[str, Any] = {}

    # Extract field values from entity.fields_raw (V2 API format)
    if entry.entity is not None:
        fields_raw = getattr(entry.entity, "fields_raw", None)
        if isinstance(fields_raw, builtins.list):
            for field_obj in fields_raw:
                if isinstance(field_obj, dict):
                    field_name = field_obj.get("name")
                    if field_name:
                        value_wrapper = field_obj.get("value")
                        if isinstance(value_wrapper, dict):
                            data = value_wrapper.get("data")
                            # For ranked-dropdown/dropdown, extract text value
                            if isinstance(data, dict) and "text" in data:
                                result[field_name] = data["text"]
                            else:
                                result[field_name] = data
                        else:
                            result[field_name] = value_wrapper

    # Also add basic entity properties for filtering
    if entry.entity is not None:
        if hasattr(entry.entity, "name"):
            result["name"] = entry.entity.name
        if hasattr(entry.entity, "domain"):
            result["domain"] = entry.entity.domain
        if hasattr(entry.entity, "primary_email"):
            result["primary_email"] = entry.entity.primary_email

    return result


# Warning message for client-side filtering
_CLIENT_SIDE_FILTER_WARNING = (
    "The Affinity V2 API does not support server-side filtering on list entries. "
    "Filtering is being applied client-side after fetching data. "
    "For large lists, consider using saved views instead (--saved-view)."
)


class ListEntryService:
    """
    Service for managing list entries (rows).

    List entries connect entities (people, companies, opportunities) to lists
    and hold list-specific field values.
    """

    def __init__(self, client: HTTPClient, list_id: ListId):
        self._client = client
        self._list_id = list_id

    def _all_entity_list_entries_v2(self, path: str) -> builtins.list[ListEntry]:
        """
        Fetch all list entries for a single entity across all lists (V2 API).

        Used for list membership helpers to avoid enumerating an entire list.
        """
        entries: builtins.list[ListEntry] = []
        data = self._client.get(path)

        while True:
            entries.extend(_safe_model_validate(ListEntry, item) for item in data.get("data", []))
            pagination = _safe_model_validate(PaginationInfo, data.get("pagination", {}))
            if not pagination.next_cursor:
                break
            data = self._client.get_url(pagination.next_cursor)

        return entries

    # =========================================================================
    # Read Operations (V2 API)
    # =========================================================================

    def list(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[ListEntryWithEntity]:
        """
        Get list entries with entity data and field values.

        Args:
            field_ids: Specific field IDs to include
            field_types: Field types to include
            filter: Filter expression (applied client-side; API doesn't support it)
            limit: Maximum results per page
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).

        Returns:
            Paginated list entries with entity data

        Note:
            The Affinity V2 API does not support server-side filtering on list entries.
            When a filter is provided, it is applied client-side after fetching data.
            For large lists, consider using saved views for server-side filtering.
        """
        # Parse filter expression if provided
        filter_expr: FilterExpression | None = None
        if filter is not None:
            if isinstance(filter, str):
                # Treat whitespace-only strings as no filter
                stripped = filter.strip()
                if stripped:
                    with contextlib.suppress(ValueError):
                        filter_expr = parse_filter(stripped)
            else:
                filter_expr = filter
            # Emit warning about client-side filtering (only if we have an actual filter)
            if filter_expr is not None:
                warnings.warn(_CLIENT_SIDE_FILTER_WARNING, UserWarning, stacklevel=2)

        if cursor is not None:
            if field_ids or field_types or filter_expr is not None or limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            data = self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if field_ids:
                params["fieldIds"] = [str(field_id) for field_id in field_ids]
            if field_types:
                params["fieldTypes"] = [field_type.value for field_type in field_types]
            # NOTE: filter is NOT sent to API - it doesn't support filtering
            if limit is not None:
                params["limit"] = limit

            data = self._client.get(
                f"/lists/{self._list_id}/list-entries",
                params=params or None,
            )

        # Parse entries
        entries = [_safe_model_validate(ListEntryWithEntity, e) for e in data.get("data", [])]

        # Apply client-side filtering if filter was provided
        if filter_expr is not None:
            entries = [e for e in entries if filter_expr.matches(_entry_to_filter_dict(e))]

        return PaginatedResponse[ListEntryWithEntity](
            data=entries,
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    def pages(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> Iterator[PaginatedResponse[ListEntryWithEntity]]:
        """Iterate list-entry pages, yielding `PaginatedResponse[ListEntryWithEntity]`."""
        if cursor is not None and (
            field_ids or field_types or filter is not None or limit is not None
        ):
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        page = (
            self.list(field_ids=field_ids, field_types=field_types, filter=filter, limit=limit)
            if cursor is None
            else self.list(cursor=cursor)
        )
        while True:
            yield page
            if not page.has_next:
                return
            next_cursor = page.next_cursor
            if next_cursor is None or next_cursor == requested_cursor:
                return
            requested_cursor = next_cursor
            page = self.list(cursor=next_cursor)

    def all(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
    ) -> Iterator[ListEntryWithEntity]:
        """
        Iterate through all list entries with automatic pagination.

        Note:
            The Affinity V2 API does not support server-side filtering on list entries.
            When a filter is provided, it is applied client-side after fetching all data.
            For large lists, consider using saved views for server-side filtering.
        """
        # Parse filter once for all pages
        filter_expr: FilterExpression | None = None
        if filter is not None:
            if isinstance(filter, str):
                # Treat whitespace-only strings as no filter
                stripped = filter.strip()
                if stripped:
                    with contextlib.suppress(ValueError):
                        filter_expr = parse_filter(stripped)
            else:
                filter_expr = filter
            # Emit warning once for the entire iteration (only if we have an actual filter)
            if filter_expr is not None:
                warnings.warn(_CLIENT_SIDE_FILTER_WARNING, UserWarning, stacklevel=2)

        def fetch_page(next_url: str | None) -> PaginatedResponse[ListEntryWithEntity]:
            if next_url:
                data = self._client.get_url(next_url)
                entries = [
                    _safe_model_validate(ListEntryWithEntity, e) for e in data.get("data", [])
                ]
                return PaginatedResponse[ListEntryWithEntity](
                    data=entries,
                    pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
                )
            # First page - don't pass filter to list() to avoid duplicate warnings
            return self.list(
                field_ids=field_ids,
                field_types=field_types,
            )

        # Iterate through all pages, applying filter if provided
        for entry in PageIterator(fetch_page):
            if filter_expr is None or filter_expr.matches(_entry_to_filter_dict(entry)):
                yield entry

    def iter(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
    ) -> Iterator[ListEntryWithEntity]:
        """
        Auto-paginate all list entries.

        Alias for `all()` (FR-006 public contract).
        """
        return self.all(field_ids=field_ids, field_types=field_types, filter=filter)

    def get(self, entry_id: ListEntryId) -> ListEntryWithEntity:
        """Get a single list entry by ID."""
        data = self._client.get(f"/lists/{self._list_id}/list-entries/{entry_id}")
        return _safe_model_validate(ListEntryWithEntity, data)

    def from_saved_view(
        self,
        view_id: SavedViewId,
        *,
        limit: int | None = None,
    ) -> PaginatedResponse[ListEntryWithEntity]:
        """
        Get list entries from a saved view.

        Respects the view's field selection and filters (but not sorts).
        """
        params: dict[str, Any] = {}
        if limit:
            params["limit"] = limit

        data = self._client.get(
            f"/lists/{self._list_id}/saved-views/{view_id}/list-entries",
            params=params or None,
        )

        return PaginatedResponse[ListEntryWithEntity](
            data=[_safe_model_validate(ListEntryWithEntity, e) for e in data.get("data", [])],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    # =========================================================================
    # Write Operations (V1 API for create/delete, V2 for field updates)
    # =========================================================================

    # -------------------------------------------------------------------------
    # Membership helpers (V2 for read, V1 for insert)
    # -------------------------------------------------------------------------

    def find_person(self, person_id: PersonId) -> ListEntry | None:
        """
        Return the first list entry for this person on this list (or None).

        Notes:
        - Affinity lists can contain duplicates. This returns the first match in
          the server-provided order. Use `find_all_person()` to surface all matches.
        """
        entries = self.find_all_person(person_id)
        return entries[0] if entries else None

    def find_all_person(self, person_id: PersonId) -> builtins.list[ListEntry]:
        """
        Return all list entries for this person on this list (may be empty).

        Affinity allows the same entity to appear multiple times on a list.
        """
        all_entries = self._all_entity_list_entries_v2(f"/persons/{person_id}/list-entries")
        return [entry for entry in all_entries if entry.list_id == self._list_id]

    def ensure_person(
        self,
        person_id: PersonId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """
        Ensure a person is on this list (idempotent by default).

        Returns:
            The first existing list entry if present; otherwise creates a new one.

        Notes:
        - This method performs an existence check to avoid accidental duplicates.
          To intentionally create duplicates, call `add_person()` directly.
        """
        existing = self.find_person(person_id)
        if existing is not None:
            return existing
        return self.add_person(person_id, creator_id=creator_id)

    def find_company(self, company_id: CompanyId) -> ListEntry | None:
        """
        Return the first list entry for this company on this list (or None).

        Notes:
        - Affinity lists can contain duplicates. This returns the first match in
          the server-provided order. Use `find_all_company()` to surface all matches.
        """
        entries = self.find_all_company(company_id)
        return entries[0] if entries else None

    def find_all_company(self, company_id: CompanyId) -> builtins.list[ListEntry]:
        """
        Return all list entries for this company on this list (may be empty).

        Affinity allows the same entity to appear multiple times on a list.
        """
        all_entries = self._all_entity_list_entries_v2(f"/companies/{company_id}/list-entries")
        return [entry for entry in all_entries if entry.list_id == self._list_id]

    def ensure_company(
        self,
        company_id: CompanyId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """
        Ensure a company is on this list (idempotent by default).

        Returns:
            The first existing list entry if present; otherwise creates a new one.

        Notes:
        - This method performs an existence check to avoid accidental duplicates.
          To intentionally create duplicates, call `add_company()` directly.
        """
        existing = self.find_company(company_id)
        if existing is not None:
            return existing
        return self.add_company(company_id, creator_id=creator_id)

    def add_person(
        self,
        person_id: PersonId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Add a person to this list."""
        return self._create_entry(int(person_id), creator_id)

    def add_company(
        self,
        company_id: CompanyId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Add a company to this list."""
        return self._create_entry(int(company_id), creator_id)

    def add_opportunity(
        self,
        opportunity_id: OpportunityId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Add an opportunity to this list."""
        return self._create_entry(int(opportunity_id), creator_id)

    def _create_entry(
        self,
        entity_id: int,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Internal method to create a list entry."""
        payload: dict[str, Any] = {"entity_id": entity_id}
        if creator_id:
            payload["creator_id"] = creator_id

        result = self._client.post(
            f"/lists/{self._list_id}/list-entries",
            json=payload,
            v1=True,
        )

        return _safe_model_validate(ListEntry, result)

    def delete(self, entry_id: ListEntryId) -> bool:
        """
        Remove a list entry (row) from the list.

        Note: This only removes the entry from the list, not the entity itself.
        """
        result = self._client.delete(
            f"/lists/{self._list_id}/list-entries/{entry_id}",
            v1=True,
        )
        return bool(result.get("success", False))

    # =========================================================================
    # Field Value Operations (V2 API)
    # =========================================================================

    def get_field_values(
        self,
        entry_id: ListEntryId,
    ) -> FieldValues:
        """Get all field values for a list entry."""
        data = self._client.get(f"/lists/{self._list_id}/list-entries/{entry_id}/fields")
        values = data.get("data", {})
        if isinstance(values, dict):
            return _safe_model_validate(FieldValues, values)
        return _safe_model_validate(FieldValues, {})

    def get_field_value(
        self,
        entry_id: ListEntryId,
        field_id: AnyFieldId,
    ) -> Any:
        """Get a single field value."""
        data = self._client.get(f"/lists/{self._list_id}/list-entries/{entry_id}/fields/{field_id}")
        return data.get("value")

    def update_field_value(
        self,
        entry_id: ListEntryId,
        field_id: AnyFieldId,
        value: Any,
    ) -> FieldValues:
        """
        Update a single field value on a list entry.

        Args:
            entry_id: The list entry
            field_id: The field to update
            value: New value (type depends on field type)

        Returns:
            Updated field value data
        """
        result = self._client.post(
            f"/lists/{self._list_id}/list-entries/{entry_id}/fields/{field_id}",
            json={"value": value},
        )
        return _safe_model_validate(FieldValues, result)

    def batch_update_fields(
        self,
        entry_id: ListEntryId,
        updates: dict[AnyFieldId, Any],
    ) -> BatchOperationResponse:
        """
        Update multiple field values at once.

        More efficient than individual updates for multiple fields.

        Args:
            entry_id: The list entry
            updates: Dict mapping field IDs to new values

        Returns:
            Batch operation response with success/failure per field
        """
        operations = [
            {"fieldId": str(field_id), "value": value} for field_id, value in updates.items()
        ]

        result = self._client.patch(
            f"/lists/{self._list_id}/list-entries/{entry_id}/fields",
            json={"operations": operations},
        )

        return _safe_model_validate(BatchOperationResponse, result)


class AsyncListService:
    """Async list operations (TR-009)."""

    def __init__(self, client: AsyncHTTPClient):
        self._client = client
        self._resolve_cache: dict[tuple[str, ListType | None], AffinityList | None] = {}

    def entries(self, list_id: ListId) -> AsyncListEntryService:
        """
        Get an AsyncListEntryService for a specific list.

        This is the explicit path for retrieving "full row" data via list entries.
        """
        return AsyncListEntryService(self._client, list_id)

    async def list(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[AffinityList]:
        """
        Get all lists accessible to you.

        Args:
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).

        Returns:
            Paginated list of lists (without field metadata)
        """
        if cursor is not None:
            if limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            data = await self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            data = await self._client.get("/lists", params=params or None)
        return PaginatedResponse[AffinityList](
            data=[
                _safe_model_validate(AffinityList, list_item) for list_item in data.get("data", [])
            ],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    async def pages(
        self,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> AsyncIterator[PaginatedResponse[AffinityList]]:
        """
        Iterate list pages (not items), yielding `PaginatedResponse[AffinityList]`.

        This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
        """
        if cursor is not None and limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        page = await self.list(limit=limit) if cursor is None else await self.list(cursor=cursor)
        while True:
            yield page
            if not page.has_next:
                return
            next_cursor = page.next_cursor
            if next_cursor is None or next_cursor == requested_cursor:
                return
            requested_cursor = next_cursor
            page = await self.list(cursor=next_cursor)

    def all(self) -> AsyncIterator[AffinityList]:
        """Iterate through all accessible lists."""

        async def fetch_page(next_url: str | None) -> PaginatedResponse[AffinityList]:
            if next_url:
                data = await self._client.get_url(next_url)
                return PaginatedResponse[AffinityList](
                    data=[
                        _safe_model_validate(AffinityList, list_item)
                        for list_item in data.get("data", [])
                    ],
                    pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
                )
            return await self.list()

        return AsyncPageIterator(fetch_page)

    def iter(self) -> AsyncIterator[AffinityList]:
        """
        Auto-paginate all lists.

        Alias for `all()` (FR-006 public contract).
        """
        return self.all()

    # =========================================================================
    # Saved View Operations
    # =========================================================================

    async def get_saved_views(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[SavedView]:
        """
        Get saved views for a list.

        Args:
            list_id: List id for the initial request.
            limit: Maximum results per page.
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).
        """
        if cursor is not None:
            if limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            cursor_list_id = _saved_views_list_id_from_cursor(cursor)
            if cursor_list_id is not None and int(list_id) != cursor_list_id:
                raise ValueError(
                    f"Cursor does not match list_id: cursor is for list {cursor_list_id}, "
                    f"requested list_id is {int(list_id)}"
                )
            data = await self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if limit is not None:
                params["limit"] = limit
            data = await self._client.get(f"/lists/{list_id}/saved-views", params=params or None)

        return PaginatedResponse[SavedView](
            data=[_safe_model_validate(SavedView, v) for v in data.get("data", [])],
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    async def saved_views_pages(
        self,
        list_id: ListId,
        *,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> AsyncIterator[PaginatedResponse[SavedView]]:
        """Iterate saved view pages, yielding `PaginatedResponse[SavedView]`."""
        if cursor is not None and limit is not None:
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        page = (
            await self.get_saved_views(list_id, limit=limit)
            if cursor is None
            else await self.get_saved_views(list_id, cursor=cursor)
        )
        while True:
            yield page
            if not page.has_next:
                return
            next_cursor = page.next_cursor
            if next_cursor is None or next_cursor == requested_cursor:
                return
            requested_cursor = next_cursor
            page = await self.get_saved_views(list_id, cursor=next_cursor)

    async def saved_views_all(self, list_id: ListId) -> AsyncIterator[SavedView]:
        """Iterate all saved views for a list."""
        async for page in self.saved_views_pages(list_id):
            for item in page.data:
                yield item

    async def get_saved_view(self, list_id: ListId, view_id: SavedViewId) -> SavedView:
        """Get a single saved view."""
        data = await self._client.get(f"/lists/{list_id}/saved-views/{view_id}")
        return _safe_model_validate(SavedView, data)

    async def get(self, list_id: ListId) -> AffinityList:
        """
        Get a single list by ID.

        Includes field metadata for the list.

        Note: Uses V1 API because V2's listSize field is undocumented and
        returns incorrect values (often 0 for non-empty lists).
        """
        data = await self._client.get(f"/lists/{list_id}", v1=True)
        return _safe_model_validate(AffinityList, data)

    async def resolve(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> AffinityList | None:
        """
        Find a single list by name (optionally filtered by type).

        Notes:
        - This iterates list pages client-side (the API does not expose a list-search endpoint).
        - Results are cached in-memory on this service instance. If you call this frequently,
          reuse the client, or persist the resolved `ListId` in your own configuration.

        If multiple matches exist, returns the first match in server-provided order.
        """
        key = (name.lower(), list_type)
        if key in self._resolve_cache:
            return self._resolve_cache[key]

        async for item in self.all():
            if item.name.lower() == name.lower() and (list_type is None or item.type == list_type):
                self._resolve_cache[key] = item
                return item

        self._resolve_cache[key] = None
        return None

    async def resolve_all(
        self,
        *,
        name: str,
        list_type: ListType | None = None,
    ) -> builtins.list[AffinityList]:
        """
        Find all lists matching a name (optionally filtered by type).

        Notes:
        - This iterates list pages client-side (the API does not expose a list-search endpoint).
        - Unlike `resolve()`, this does not cache results.
        """
        matches: builtins.list[AffinityList] = []
        name_lower = name.lower()
        async for item in self.all():
            if item.name.lower() != name_lower:
                continue
            if list_type is not None and item.type != list_type:
                continue
            matches.append(item)
        return matches

    # =========================================================================
    # Write Operations (V1 API)
    # =========================================================================

    async def create(self, data: ListCreate) -> AffinityList:
        """
        Create a new list.

        Uses V1 API.
        """
        payload = data.model_dump(mode="json", exclude_none=True, exclude_unset=True)
        if not data.additional_permissions:
            payload.pop("additional_permissions", None)

        result = await self._client.post("/lists", json=payload, v1=True)

        if self._client.cache:
            self._client.cache.invalidate_prefix("list")
        self._resolve_cache.clear()

        return _safe_model_validate(AffinityList, result)

    # =========================================================================
    # Field Operations
    # =========================================================================

    async def get_fields(
        self,
        list_id: ListId,
        *,
        field_types: Sequence[FieldType] | None = None,
    ) -> builtins.list[FieldMetadata]:
        """
        Get fields (columns) for a list.

        Includes list-specific, global, enriched, and relationship intelligence fields.
        Cached for performance.
        """
        params: dict[str, Any] = {}
        if field_types:
            params["fieldTypes"] = [field_type.value for field_type in field_types]

        data = await self._client.get(
            f"/lists/{list_id}/fields",
            params=params or None,
            cache_key=f"list_{list_id}_fields:{','.join(field_types or [])}",
            cache_ttl=300,
        )

        return [_safe_model_validate(FieldMetadata, f) for f in data.get("data", [])]


class AsyncListEntryService:
    """Async list entry operations (TR-009)."""

    def __init__(self, client: AsyncHTTPClient, list_id: ListId):
        self._client = client
        self._list_id = list_id

    async def _all_entity_list_entries_v2(self, path: str) -> builtins.list[ListEntry]:
        """
        Fetch all list entries for a single entity across all lists (V2 API).

        Used for list membership helpers to avoid enumerating an entire list.
        """
        entries: builtins.list[ListEntry] = []
        data = await self._client.get(path)

        while True:
            entries.extend(_safe_model_validate(ListEntry, item) for item in data.get("data", []))
            pagination = _safe_model_validate(PaginationInfo, data.get("pagination", {}))
            if not pagination.next_cursor:
                break
            data = await self._client.get_url(pagination.next_cursor)

        return entries

    async def list(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> PaginatedResponse[ListEntryWithEntity]:
        """
        Get list entries with entity data and field values.

        Args:
            field_ids: Specific field IDs to include
            field_types: Field types to include
            filter: Filter expression (applied client-side; API doesn't support it)
            limit: Maximum results per page
            cursor: Cursor to resume pagination (opaque; obtained from prior responses).

        Returns:
            Paginated list entries with entity data

        Note:
            The Affinity V2 API does not support server-side filtering on list entries.
            When a filter is provided, it is applied client-side after fetching data.
            For large lists, consider using saved views for server-side filtering.
        """
        # Parse filter expression if provided
        filter_expr: FilterExpression | None = None
        if filter is not None:
            if isinstance(filter, str):
                # Treat whitespace-only strings as no filter
                stripped = filter.strip()
                if stripped:
                    with contextlib.suppress(ValueError):
                        filter_expr = parse_filter(stripped)
            else:
                filter_expr = filter
            # Emit warning about client-side filtering (only if we have an actual filter)
            if filter_expr is not None:
                warnings.warn(_CLIENT_SIDE_FILTER_WARNING, UserWarning, stacklevel=2)

        if cursor is not None:
            if field_ids or field_types or filter_expr is not None or limit is not None:
                raise ValueError(
                    "Cannot combine 'cursor' with other parameters; cursor encodes all query "
                    "context. Start a new pagination sequence without a cursor to change "
                    "parameters."
                )
            data = await self._client.get_url(cursor)
        else:
            if limit is not None and limit <= 0:
                raise ValueError("'limit' must be > 0")
            params: dict[str, Any] = {}
            if field_ids:
                params["fieldIds"] = [str(field_id) for field_id in field_ids]
            if field_types:
                params["fieldTypes"] = [field_type.value for field_type in field_types]
            # NOTE: filter is NOT sent to API - it doesn't support filtering
            if limit is not None:
                params["limit"] = limit

            data = await self._client.get(
                f"/lists/{self._list_id}/list-entries",
                params=params or None,
            )

        # Parse entries
        entries = [_safe_model_validate(ListEntryWithEntity, e) for e in data.get("data", [])]

        # Apply client-side filtering if filter was provided
        if filter_expr is not None:
            entries = [e for e in entries if filter_expr.matches(_entry_to_filter_dict(e))]

        return PaginatedResponse[ListEntryWithEntity](
            data=entries,
            pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
        )

    async def pages(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> AsyncIterator[PaginatedResponse[ListEntryWithEntity]]:
        """Iterate list-entry pages, yielding `PaginatedResponse[ListEntryWithEntity]`."""
        if cursor is not None and (
            field_ids or field_types or filter is not None or limit is not None
        ):
            raise ValueError(
                "Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
                "Start a new pagination sequence without a cursor to change parameters."
            )
        requested_cursor = cursor
        page = (
            await self.list(
                field_ids=field_ids, field_types=field_types, filter=filter, limit=limit
            )
            if cursor is None
            else await self.list(cursor=cursor)
        )
        while True:
            yield page
            if not page.has_next:
                return
            next_cursor = page.next_cursor
            if next_cursor is None or next_cursor == requested_cursor:
                return
            requested_cursor = next_cursor
            page = await self.list(cursor=next_cursor)

    async def all(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
    ) -> AsyncIterator[ListEntryWithEntity]:
        """
        Iterate through all list entries with automatic pagination.

        Note:
            The Affinity V2 API does not support server-side filtering on list entries.
            When a filter is provided, it is applied client-side after fetching all data.
            For large lists, consider using saved views for server-side filtering.
        """
        # Parse filter once for all pages
        filter_expr: FilterExpression | None = None
        if filter is not None:
            if isinstance(filter, str):
                # Treat whitespace-only strings as no filter
                stripped = filter.strip()
                if stripped:
                    with contextlib.suppress(ValueError):
                        filter_expr = parse_filter(stripped)
            else:
                filter_expr = filter
            # Emit warning once for the entire iteration (only if we have an actual filter)
            if filter_expr is not None:
                warnings.warn(_CLIENT_SIDE_FILTER_WARNING, UserWarning, stacklevel=2)

        async def fetch_page(next_url: str | None) -> PaginatedResponse[ListEntryWithEntity]:
            if next_url:
                data = await self._client.get_url(next_url)
                entries = [
                    _safe_model_validate(ListEntryWithEntity, e) for e in data.get("data", [])
                ]
                return PaginatedResponse[ListEntryWithEntity](
                    data=entries,
                    pagination=_safe_model_validate(PaginationInfo, data.get("pagination", {})),
                )
            # First page - don't pass filter to list() to avoid duplicate warnings
            return await self.list(
                field_ids=field_ids,
                field_types=field_types,
            )

        # Iterate through all pages, applying filter if provided
        async for entry in AsyncPageIterator(fetch_page):
            if filter_expr is None or filter_expr.matches(_entry_to_filter_dict(entry)):
                yield entry

    async def iter(
        self,
        *,
        field_ids: Sequence[AnyFieldId] | None = None,
        field_types: Sequence[FieldType] | None = None,
        filter: str | FilterExpression | None = None,
    ) -> AsyncIterator[ListEntryWithEntity]:
        """
        Auto-paginate all list entries.

        Alias for `all()` (FR-006 public contract).
        """
        async for entry in self.all(field_ids=field_ids, field_types=field_types, filter=filter):
            yield entry

    # -------------------------------------------------------------------------
    # Membership helpers (V2 for read only)
    # -------------------------------------------------------------------------

    async def find_person(self, person_id: PersonId) -> ListEntry | None:
        """
        Return the first list entry for this person on this list (or None).

        Notes:
        - Affinity lists can contain duplicates. This returns the first match in
          the server-provided order. Use `find_all_person()` to surface all matches.
        """
        entries = await self.find_all_person(person_id)
        return entries[0] if entries else None

    async def find_all_person(self, person_id: PersonId) -> builtins.list[ListEntry]:
        """
        Return all list entries for this person on this list (may be empty).

        Affinity allows the same entity to appear multiple times on a list.
        """
        all_entries = await self._all_entity_list_entries_v2(f"/persons/{person_id}/list-entries")
        return [entry for entry in all_entries if entry.list_id == self._list_id]

    async def find_company(self, company_id: CompanyId) -> ListEntry | None:
        """
        Return the first list entry for this company on this list (or None).

        Notes:
        - Affinity lists can contain duplicates. This returns the first match in
          the server-provided order. Use `find_all_company()` to surface all matches.
        """
        entries = await self.find_all_company(company_id)
        return entries[0] if entries else None

    async def find_all_company(self, company_id: CompanyId) -> builtins.list[ListEntry]:
        """
        Return all list entries for this company on this list (may be empty).

        Affinity allows the same entity to appear multiple times on a list.
        """
        all_entries = await self._all_entity_list_entries_v2(
            f"/companies/{company_id}/list-entries"
        )
        return [entry for entry in all_entries if entry.list_id == self._list_id]

    async def ensure_person(
        self,
        person_id: PersonId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """
        Ensure a person is on this list (idempotent by default).

        Returns:
            The first existing list entry if present; otherwise creates a new one.

        Notes:
        - This method performs an existence check to avoid accidental duplicates.
          To intentionally create duplicates, call `add_person()` directly.
        """
        existing = await self.find_person(person_id)
        if existing is not None:
            return existing
        return await self.add_person(person_id, creator_id=creator_id)

    async def ensure_company(
        self,
        company_id: CompanyId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """
        Ensure a company is on this list (idempotent by default).

        Returns:
            The first existing list entry if present; otherwise creates a new one.

        Notes:
        - This method performs an existence check to avoid accidental duplicates.
          To intentionally create duplicates, call `add_company()` directly.
        """
        existing = await self.find_company(company_id)
        if existing is not None:
            return existing
        return await self.add_company(company_id, creator_id=creator_id)

    async def add_person(
        self,
        person_id: PersonId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Add a person to this list."""
        return await self._create_entry(int(person_id), creator_id)

    async def add_company(
        self,
        company_id: CompanyId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Add a company to this list."""
        return await self._create_entry(int(company_id), creator_id)

    async def add_opportunity(
        self,
        opportunity_id: OpportunityId,
        *,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Add an opportunity to this list."""
        return await self._create_entry(int(opportunity_id), creator_id)

    async def _create_entry(
        self,
        entity_id: int,
        creator_id: int | None = None,
    ) -> ListEntry:
        """Internal method to create a list entry."""
        payload: dict[str, Any] = {"entity_id": entity_id}
        if creator_id:
            payload["creator_id"] = creator_id

        result = await self._client.post(
            f"/lists/{self._list_id}/list-entries",
            json=payload,
            v1=True,
        )

        return _safe_model_validate(ListEntry, result)

    async def delete(self, entry_id: ListEntryId) -> bool:
        """
        Remove a list entry (row) from the list.

        Note: This only removes the entry from the list, not the entity itself.
        """
        result = await self._client.delete(
            f"/lists/{self._list_id}/list-entries/{entry_id}",
            v1=True,
        )
        return bool(result.get("success", False))

    # =========================================================================
    # Field Value Operations (V2 API)
    # =========================================================================

    async def get_field_values(
        self,
        entry_id: ListEntryId,
    ) -> FieldValues:
        """Get all field values for a list entry."""
        data = await self._client.get(f"/lists/{self._list_id}/list-entries/{entry_id}/fields")
        values = data.get("data", {})
        if isinstance(values, dict):
            return _safe_model_validate(FieldValues, values)
        return _safe_model_validate(FieldValues, {})

    async def get_field_value(
        self,
        entry_id: ListEntryId,
        field_id: AnyFieldId,
    ) -> Any:
        """Get a single field value."""
        data = await self._client.get(
            f"/lists/{self._list_id}/list-entries/{entry_id}/fields/{field_id}"
        )
        return data.get("value")

    async def update_field_value(
        self,
        entry_id: ListEntryId,
        field_id: AnyFieldId,
        value: Any,
    ) -> FieldValues:
        """
        Update a single field value on a list entry.

        Args:
            entry_id: The list entry
            field_id: The field to update
            value: New value (type depends on field type)

        Returns:
            Updated field value data
        """
        result = await self._client.post(
            f"/lists/{self._list_id}/list-entries/{entry_id}/fields/{field_id}",
            json={"value": value},
        )
        return _safe_model_validate(FieldValues, result)

    async def batch_update_fields(
        self,
        entry_id: ListEntryId,
        updates: dict[AnyFieldId, Any],
    ) -> BatchOperationResponse:
        """
        Update multiple field values at once.

        More efficient than individual updates for multiple fields.

        Args:
            entry_id: The list entry
            updates: Dict mapping field IDs to new values

        Returns:
            Batch operation response with success/failure per field
        """
        operations = [
            {"fieldId": str(field_id), "value": value} for field_id, value in updates.items()
        ]

        result = await self._client.patch(
            f"/lists/{self._list_id}/list-entries/{entry_id}/fields",
            json={"operations": operations},
        )

        return _safe_model_validate(BatchOperationResponse, result)
