"""
Session using:
- SPARQLWrapper for querying endpoints
- rdflib.Graph for parsing RDF results (CONSTRUCT/DESCRIBE)

Responsibilities:
- query(model)
- execute_sparql(...)
- get(model, iri)
- add(model_instance)
- commit()
"""

# Import for type hints - avoid circular import at runtime
import logging
from enum import StrEnum
from typing import TYPE_CHECKING, Any, Dict, Literal, Sequence, TypeVar, overload

from rdflib import Graph, URIRef
from SPARQLWrapper import JSON, TURTLE, SPARQLWrapper

from ..exc.exceptions import (
    IRIError,
    QueryError,
    UnknownFieldError,
    ValidationError,
)
from ..orm.compiler import Query
from ..orm.model import IRIField, SubjectField, validate_instance
from ..orm.prefix_registry import PrefixRegistry
from ..orm.schema_registry import SchemaRegistry
from ..orm.security import escape_sparql_iri

if TYPE_CHECKING:
    from ..orm.model import Model, RDFFieldInfo

# TypeVar for generic model typing
T = TypeVar("T", bound="Model")

# Configure module logger
logger = logging.getLogger(__name__)


class QueryMethod(StrEnum):
    """HTTP method for SPARQL SELECT queries."""

    POST = "POST"
    GET = "GET"


def _validate_instance_for_update(instance: "Model") -> None:
    """Validate instance can be updated."""
    if not instance.subject_iri:
        raise ValidationError(
            "Instance must have a subject IRI to update. "
            "Set the SubjectField value in your model instance."
        )


def _validate_instance_has_iri(instance: "Model", operation: str) -> None:
    """Validate that an instance has an IRI for the given operation.

    Args:
        instance: Model instance to validate
        operation: Operation name for error message (e.g., 'add', 'update', 'delete')

    Raises:
        ValidationError: If instance has no IRI
    """
    if not instance.subject_iri:
        logger.error(f"Attempted to {operation} instance without IRI")
        raise ValidationError(
            f"Instance must have a subject IRI to {operation}. "
            f"Set the SubjectField value in your model instance."
        )


def _build_update_triples(
    instance: "Model", changes: dict[str, tuple[Any, Any]]
) -> tuple[list[str], list[str]]:
    """Build DELETE and INSERT triples for changed fields."""
    deletes: list[str] = []
    inserts: list[str] = []

    # IRI should be validated before calling this function
    if instance.subject_iri is None:
        raise ValidationError("Instance must have an IRI")

    for field_name, (old_val, new_val) in changes.items():
        field: "RDFFieldInfo" = instance.__class__._rdf_fields[field_name]
        subject: str = f"<{escape_sparql_iri(instance.subject_iri)}>"
        predicate: str = f"<{escape_sparql_iri(field.predicate)}>"

        # Use polymorphic generate_triples() - handles single/multi-value fields
        if old_val is not None:
            deletes.extend(field.generate_triples(subject, predicate, old_val))

        if new_val is not None:
            inserts.extend(field.generate_triples(subject, predicate, new_val))

    return deletes, inserts


class Session:
    """
    SPARQL endpoint session with graph management.

    RECOMMENDED USAGE PATTERN:
    - Create separate Session instances for different graph types
    - Use primary_graph for the main graph each session manages
    - Use graph_uri parameters for method-level overrides when needed

    Example:
        # RECOMMENDED: Session per graph type
        people_session = Session(
            endpoint="http://localhost:3030/ds/sparql",
            primary_graph="http://example.org/people"
        )
        products_session = Session(
            endpoint="http://localhost:3030/ds/sparql",
            primary_graph="http://example.org/products"
        )

        # Each session automatically uses its primary graph
        people_session.add(person)      # Goes to people graph
        products_session.add(product)   # Goes to products graph
    """

    def __init__(
        self,
        endpoint: str | None = None,
        primary_graph: str | None = None,
        named_graphs: list[str] | None = None,
        username: str | None = None,
        password: str | None = None,
        write_endpoint: str | None = None,
        max_batch_size: int = 1000,
        prefix_registry: PrefixRegistry | None = None,
        schema_registry: SchemaRegistry | None = None,
        query_method: QueryMethod = QueryMethod.POST,
    ) -> None:
        self.endpoint: str | None = endpoint
        self.write_endpoint: str | None = write_endpoint
        self.primary_graph: str | None = primary_graph
        self.named_graphs: list[str] = named_graphs or []
        self.username: str | None = username
        self.password: str | None = password
        self.max_batch_size: int = max_batch_size
        self.prefix_registry: PrefixRegistry = prefix_registry or PrefixRegistry()
        # Session.named_graphs are FROM NAMED (for GRAPH patterns in user queries),
        # but SchemaRegistry needs them as default graphs (FROM) because its
        # CONSTRUCT query uses a bare WHERE clause without GRAPH keywords.
        self.schema_registry: SchemaRegistry = schema_registry or SchemaRegistry(
            endpoint=endpoint,
            prefix_registry=self.prefix_registry,
            username=username,
            password=password,
            default_graphs=named_graphs,
        )
        self.query_method: QueryMethod = query_method
        self._pending_inserts: list[tuple[tuple[str, str, str], str | None]] = []
        self._pending_updates: list[tuple[list[str], list[str], str | None]] = []
        self._pending_deletes: list[tuple[str, str | None]] = []
        self._pending_field_deletes: list[tuple[tuple[str, str, str], str | None]] = []
        self.wrapper: SPARQLWrapper | None
        self.write_wrapper: SPARQLWrapper | None

        if endpoint:
            self.wrapper = SPARQLWrapper(endpoint)
            if username and password:
                self.wrapper.setHTTPAuth("BASIC")
                self.wrapper.setCredentials(username, password)
            # Add named graphs
            for graph_uri in self.named_graphs:
                self.wrapper.addNamedGraph(graph_uri)
        else:
            self.wrapper = None

        # Setup separate write wrapper if write_endpoint is provided
        if write_endpoint:
            self.write_wrapper = SPARQLWrapper(write_endpoint)
            if username and password:
                self.write_wrapper.setHTTPAuth("BASIC")
                self.write_wrapper.setCredentials(username, password)
            # Add named graphs to write wrapper
            for graph_uri in self.named_graphs:
                self.write_wrapper.addNamedGraph(graph_uri)
        else:
            # If no separate write endpoint, use the same wrapper for both
            self.write_wrapper = self.wrapper

    # -------------------------
    # Graph management
    # -------------------------
    def add_named_graph(self, graph_uri: str) -> None:
        """Add a named graph to the session's SPARQL wrappers.

        Args:
            graph_uri: URI of the named graph to add
        """
        if self.wrapper:
            self.wrapper.addNamedGraph(graph_uri)
        if self.write_wrapper and self.write_wrapper is not self.wrapper:
            self.write_wrapper.addNamedGraph(graph_uri)
        if graph_uri not in self.named_graphs:
            self.named_graphs.append(graph_uri)

    def set_primary_graph(self, graph_uri: str) -> None:
        """Set the primary graph for the session.

        Updates the session's primary graph. Queries created after this call
        will include the new URI as FROM and FROM NAMED clauses in the
        compiled SPARQL text.

        Args:
            graph_uri: URI of the primary graph
        """
        self.primary_graph = graph_uri

    def clear_named_graphs(self) -> None:
        """Clear all named graphs from the session."""
        # Note: SPARQLWrapper doesn't support clearing named graphs,
        # so we just update our internal state
        # For a real implementation, we would need to create a new wrapper
        self.named_graphs = []

    # -------------------------
    # Prefix management
    # -------------------------
    def register_prefix(self, prefix: str, namespace: str) -> None:
        """Register a new prefix mapping.

        Args:
            prefix: The prefix (e.g., 'schema')
            namespace: The namespace URI (e.g., 'http://schema.org/')

        Raises:
            ValueError: If prefix contains invalid characters or namespace is invalid
        """
        self.prefix_registry.register_prefix(prefix, namespace)

    def expand_iri(self, short_iri: str) -> str:
        """Expand a short-form IRI to full IRI using registered prefixes.

        Args:
            short_iri: Short-form IRI (e.g., 'schema:Person')

        Returns:
            Expanded IRI (e.g., 'http://schema.org/Person')

        Raises:
            ValueError: If short_iri format is invalid
        """
        return self.prefix_registry.expand(short_iri)

    def contract_iri(self, full_iri: str) -> str | None:
        """Contract a full IRI to short-form using registered prefixes.

        Args:
            full_iri: Full IRI (e.g., 'http://schema.org/Person')

        Returns:
            Short-form IRI if prefix found (e.g., 'schema:Person'),
            None if no matching prefix found
        """
        return self.prefix_registry.contract(full_iri)

    def get_prefix_declarations(self) -> str:
        """Generate SPARQL PREFIX declarations for all registered prefixes.

        Returns:
            SPARQL PREFIX declarations as string
        """
        return self.prefix_registry.get_prefix_declarations()

    def get_prefixes(self) -> Dict[str, str]:
        """Get all registered prefixes.

        Returns:
            Dictionary of prefix to namespace mappings
        """
        return self.prefix_registry.get_prefixes()

    def clear_prefixes(self) -> None:
        """Clear all registered prefixes."""
        self.prefix_registry.clear()

    # -------------------------
    # Query builder
    # -------------------------
    def query(self, model_cls: type[T]) -> Query[T]:
        """Create query bound to this session.

        Args:
            model_cls: Model class to query. If registered subclasses exist,
                the query is automatically polymorphic (dispatches to subclasses
                via a VALUES ?type clause).
        """
        q: Query[T] = Query(model_cls, session=self)
        if self.primary_graph:
            q = q.from_graph(self.primary_graph)
        return q

    # -------------------------
    # SPARQL execution
    # -------------------------
    @overload
    def execute_sparql(
        self, sparql: str, construct: Literal[True], update: bool = False
    ) -> str | bytes | None:
        """Execute CONSTRUCT/DESCRIBE query returning Turtle format."""
        ...

    @overload
    def execute_sparql(
        self, sparql: str, construct: bool = False, update: Literal[True] = ...
    ) -> Any | None:
        """Execute UPDATE query (INSERT/DELETE)."""
        ...

    @overload
    def execute_sparql(
        self,
        sparql: str,
        construct: Literal[False] = False,
        update: Literal[False] = False,
    ) -> dict[str, Any] | None:
        """Execute SELECT query returning JSON format."""
        ...

    def execute_sparql(
        self, sparql: str, construct: bool = False, update: bool = False
    ) -> Any | None:
        """Executes SPARQL via SPARQLWrapper.

        Args:
            sparql: The SPARQL query string
            construct: If True, expect CONSTRUCT/DESCRIBE results (Turtle format)
            update: If True, this is an UPDATE operation (INSERT/DELETE) requiring POST

        Raises:
            QueryError: If SPARQL query execution fails
        """
        # Select appropriate wrapper based on operation type
        wrapper: SPARQLWrapper | None
        if update:
            wrapper = self.write_wrapper
        else:
            wrapper = self.wrapper

        if not wrapper:
            logger.error("No SPARQL endpoint configured. Query would be:\n%s", sparql)
            return None

        try:
            wrapper.setQuery(sparql)

            if update:
                wrapper.setMethod("POST")
                # UPDATE queries don't return data
                return wrapper.query()
            elif construct:
                wrapper.setReturnFormat(TURTLE)
            else:
                # SELECT query - apply configured method
                wrapper.setReturnFormat(JSON)
                if self.query_method == QueryMethod.POST:
                    wrapper.setMethod("POST")
                    wrapper.setRequestMethod("postdirectly")

            return wrapper.query().convert()
        except Exception as e:
            logger.error("SPARQL query failed: %s\nQuery was:\n%s", str(e), sparql)
            raise QueryError(f"SPARQL query failed: {e}", query=sparql) from e

    # -------------------------
    # Get a single subject
    # -------------------------
    @staticmethod
    def _convert_rdf_value(rdf_object: Any, field: "RDFFieldInfo") -> Any:
        """
        Convert an RDF object to a Python value based on field type.

        Uses the polymorphic hydrate_value() method on the field to handle
        type-specific conversion (e.g., LangString returns LangLiteral).

        Args:
            rdf_object: The RDF object from rdflib (Literal, URIRef, etc.)
            field: The RDFFieldInfo instance describing the field

        Returns:
            Python value appropriate for the field type
        """
        # Use polymorphic hydrate_value() - handles both literals and IRIs,
        # and field-type-specific conversions (e.g., LangString -> LangLiteral)
        return field.hydrate_value(rdf_object)

    def _parse_rdf_data(self, data: Any) -> Graph:
        """Parse RDF data into rdflib Graph.

        Args:
            data: RDF data in Turtle format (str or bytes)

        Returns:
            Parsed rdflib Graph object
        """
        g: Graph = Graph()

        if isinstance(data, (str, bytes)):
            g.parse(data=data, format="turtle")
        else:
            decoded: str = (
                data.decode("utf-8") if isinstance(data, bytes) else str(data)
            )
            g.parse(data=decoded, format="turtle")

        return g

    def _populate_fields(self, inst: "Model", graph: Graph, subj_node: URIRef) -> None:
        """Populate model instance fields from RDF graph.

        Args:
            inst: Model instance to populate
            graph: RDF graph containing field data
            subj_node: Subject URI node to query

        Raises:
            IRIError: If predicate IRI format is invalid
        """
        for name, field in inst.__class__._rdf_fields.items():
            # Skip SubjectField - populated from subject IRI, not predicates
            if isinstance(field, SubjectField):
                continue

            try:
                pred: URIRef = URIRef(field.predicate)
            except (ValueError, TypeError) as e:
                raise IRIError(field.predicate, f"Invalid IRI format: {e}")

            # Use polymorphic method to collect values from graph
            # LangString filters by language tag, MultiLangString collects all into dict
            value: Any = field.collect_from_graph(
                graph, subj_node, pred, self._convert_rdf_value
            )
            if value is not None:
                setattr(inst, name, value)

    def _count_populated_fields(self, instance: "Model") -> int:
        """Count number of populated fields in model instance.

        Args:
            instance: Model instance to count fields for

        Returns:
            Number of fields with non-None values (excluding SubjectField)
        """
        return len(
            [
                f
                for f, field_info in instance.__class__._rdf_fields.items()
                if getattr(instance, f, None) is not None
                and not isinstance(field_info, SubjectField)
            ]
        )

    def _build_construct_query(self, model_cls: "type[Model]", iri: str) -> str:
        """Build CONSTRUCT query for retrieving instance data.

        Args:
            model_cls: The model class to query
            iri: The IRI of the instance to retrieve

        Returns:
            SPARQL CONSTRUCT query string
        """
        s: str = f"<{escape_sparql_iri(iri)}>"

        # Filter out SubjectField from field iteration
        # SubjectField: doesn't have a real predicate (represents the subject itself)
        regular_fields: dict[str, "RDFFieldInfo"] = {
            name: field
            for name, field in model_cls._rdf_fields.items()
            if not isinstance(field, SubjectField)
        }

        # Build CONSTRUCT triples
        construct_triples: list[str] = [
            f"{s} <{escape_sparql_iri(field.predicate)}> ?{name} ."
            for name, field in regular_fields.items()
        ]

        # Build WHERE patterns with OPTIONAL and language filters
        where_patterns: list[str] = []
        for name, field in regular_fields.items():
            pred: str = f"<{escape_sparql_iri(field.predicate)}>"
            triple: str = f"{s} {pred} ?{name} ."
            pattern: str = field.get_construct_pattern(triple, name)
            where_patterns.append(pattern)

        construct_block: str = "\n          ".join(construct_triples)
        where_block: str = "\n          ".join(where_patterns)

        return f"""
        CONSTRUCT {{
          {construct_block}
        }}
        WHERE {{
          {where_block}
        }}
        """

    def _parse_rdf_response(
        self, data: Any | None, model_cls: type[T], iri: str
    ) -> T | None:
        """Parse RDF response into model instance.

        Args:
            data: Raw RDF data from SPARQL endpoint (Turtle), or None
            model_cls: The model class to instantiate
            iri: The IRI of the instance

        Returns:
            Model instance with fields populated from RDF data, or None if
            entity not found (data is None or graph is empty)

        Raises:
            ValueError: If model has no SubjectField declared
        """
        if not model_cls._subject_field_name:
            raise ValueError(
                f"Model {model_cls.__name__} has no SubjectField declared. "
                "Add a SubjectField to your model, e.g.: "
                "iri: Annotated[str, SubjectField()]"
            )

        # No data means no endpoint configured or entity not found
        if data is None:
            return None

        graph: Graph = self._parse_rdf_data(data)

        # Empty graph means entity doesn't exist in the triplestore
        if len(graph) == 0:
            return None

        # Create instance with SubjectField set to the IRI
        inst: T = model_cls(**{model_cls._subject_field_name: iri})
        self._populate_fields(inst, graph, URIRef(iri))

        return inst

    def get(self, model_cls: type[T], iri: str) -> T | None:
        """Retrieves all known triples about a subject.

        Flow: query → parse → populate → count → return

        Loads fields into a model instance from the SPARQL endpoint.

        Args:
            model_cls: The model class to instantiate
            iri: The IRI of the instance to retrieve

        Returns:
            Model instance with fields populated from SPARQL endpoint,
            or None if entity not found
        """
        logger.debug("Retrieving %s instance: %s", model_cls.__name__, iri)

        q: str = self._build_construct_query(model_cls, iri)
        data: Any | None = self.execute_sparql(q, construct=True)

        result_instance: T | None = self._parse_rdf_response(data, model_cls, iri)

        if result_instance is None:
            logger.debug("%s with IRI %s not found", model_cls.__name__, iri)
            return None

        result_instance.mark_clean()

        field_count: int = self._count_populated_fields(result_instance)
        logger.debug(
            "Retrieved %s with %d populated fields", model_cls.__name__, field_count
        )

        return result_instance

    # -------------------------
    # Insert data
    # -------------------------
    def _stage_instance_for_insert(
        self, instance: "Model", graph_uri: str | None = None
    ) -> None:
        """Stage a single instance for insertion (internal method without validation).

        Args:
            instance: Model instance to stage (must already be validated)
            graph_uri: Optional graph URI for GRAPH clause in INSERT
        """
        logger.debug(
            "Staging %s for insertion: %s",
            instance.__class__.__name__,
            instance.subject_iri,
        )

        # Validate and escape subject IRI
        s: str = f"<{escape_sparql_iri(instance.subject_iri)}>"  # type: ignore[arg-type]

        field_count: int = 0
        for name, field in instance.__class__._rdf_fields.items():
            # Skip SubjectField - represents the subject, not a predicate-object
            if isinstance(field, SubjectField):
                continue

            val: Any = getattr(instance, name, None)
            if val is not None:
                # Expand short-form predicate IRI to full IRI
                expanded_predicate: str = instance.expand_iri(field.predicate, self)
                # Validate and escape predicate IRI
                p: str = f"<{escape_sparql_iri(expanded_predicate)}>"

                # For IRIField, expand short-form value IRIs (e.g., "schema:Book")
                # to full IRIs before formatting
                if isinstance(field, IRIField) and isinstance(val, str):
                    val = instance.expand_iri(val, self)

                # Use polymorphic generate_triple_tuples() - handles both single and
                # multi-value fields (e.g., MultiLangString returns multiple tuples)
                tuples: list[tuple[str, str, str]] = field.generate_triple_tuples(
                    s, p, val
                )
                for triple_tuple in tuples:
                    self._pending_inserts.append((triple_tuple, graph_uri))
                    field_count += 1

        logger.debug("Staged %d triples for %s", field_count, instance.subject_iri)

    def add(self, instance: "Model", graph_uri: str | None = None) -> None:
        """Stages triples to insert via INSERT DATA with injection protection.

        Security:
        - Validates and escapes IRIs to prevent injection
        - Uses secure value formatting that escapes string literals
        - Detects and rejects SPARQL keywords in user-provided values
        - Provides protection against SPARQL injection attacks

        Args:
            instance: Model instance to add
            graph_uri: Optional graph URI for GRAPH clause in INSERT

        Raises:
            ValidationError: If instance has no IRI or validation fails
            SPARQLSecurityError: If potential injection is detected in values
        """
        _validate_instance_has_iri(instance, "add")

        # Validate required fields and custom validators
        validate_instance(instance)

        # Use explicit graph_uri parameter or None (session default will be used)
        self._stage_instance_for_insert(instance, graph_uri)

    def add_all(
        self, instances: Sequence["Model"], graph_uri: str | None = None
    ) -> None:
        """Add multiple instances in batch.

        This method stages multiple instances for insertion in a single operation.
        It's more efficient than calling add() multiple times as it reduces
        the overhead of individual method calls.

        Args:
            instances: List of Model instances to add
            graph_uri: Optional graph URI for GRAPH clause in INSERT

        Raises:
            ValidationError: If any instance has no IRI
            SPARQLSecurityError: If potential injection is detected in values
        """
        if not instances:
            logger.debug("No instances provided to add_all()")
            return

        logger.debug("Staging %d instances for batch insertion", len(instances))

        # Validate all instances first before processing any
        for instance in instances:
            _validate_instance_has_iri(instance, "add")

        # Process all instances after validation using the internal staging method
        for instance in instances:
            # Use explicit graph_uri parameter or None (session default will be used)
            self._stage_instance_for_insert(instance, graph_uri)

        logger.info(
            "Staged %d instances with %d total triples for batch insertion",
            len(instances),
            len(self._pending_inserts),
        )

    def _stage_instance_for_update(
        self, instance: "Model", graph_uri: str | None = None
    ) -> None:
        """Stage a single instance for update (internal method without validation).

        Args:
            instance: Model instance to stage (must already be validated and dirty)
            graph_uri: Optional graph URI for GRAPH clause in UPDATE
        """
        changes: dict[str, tuple[Any, Any]] = instance.get_changes()
        deletes: list[str]
        inserts: list[str]
        deletes, inserts = _build_update_triples(instance, changes)

        # Always include graph_uri for consistency (can be None for session default)
        self._pending_updates.append((deletes, inserts, graph_uri))

    def update(self, instance: "Model", graph_uri: str | None = None) -> None:
        """
        Stages an UPDATE operation for the given instance.

        Only updates fields that have been modified (dirty tracking).
        Uses DELETE/INSERT pattern to update existing triples.

        Args:
            instance: Model instance to update
            graph_uri: Optional graph URI for GRAPH clause in UPDATE

        Raises:
            ValidationError: If instance has no IRI or validation fails
        """
        _validate_instance_for_update(instance)

        # Validate required fields and custom validators
        validate_instance(instance)

        if not instance.is_dirty():
            return

        # Use explicit graph_uri parameter or None (session default will be used)
        self._stage_instance_for_update(instance, graph_uri)

    def update_all(self, instances: Sequence["Model"]) -> None:
        """Update multiple instances in batch.

        This method stages multiple instances for update in a single operation.
        It's more efficient than calling update() multiple times as it reduces
        the overhead of individual method calls.

        Only updates fields that have been modified (dirty tracking).
        Uses DELETE/INSERT pattern to update existing triples.

        Args:
            instances: List of Model instances to update

        Raises:
            ValidationError: If any instance has no IRI
        """
        if not instances:
            logger.debug("No instances provided to update_all()")
            return

        logger.debug("Staging %d instances for batch update", len(instances))

        for instance in instances:
            _validate_instance_has_iri(instance, "update")

            if not instance.is_dirty():
                logger.debug(
                    "Instance %s is not dirty, skipping update", instance.subject_iri
                )
                continue

            self._stage_instance_for_update(instance)

        logger.info("Staged %d instances for batch update", len(instances))

    def delete(
        self,
        instance: "Model",
        fields: list[str] | None = None,
        graph_uri: str | None = None,
    ) -> None:
        """
        Stage deletion of entity or specific fields.

        **Important:** This method provides flexible deletion options:
        - With `fields=None`: Deletes ALL triples for the entity
        - With `fields=[...]`: Deletes ONLY specified field triples

        Args:
            instance: Model instance to delete (must have IRI)
            fields: Optional list of field names to delete. If None,
                deletes entire entity (all triples with subject IRI).
                If provided, deletes only the specified field triples.
            graph_uri: Optional graph URI for GRAPH clause in DELETE

        Raises:
            ValidationError: If instance has no IRI
            UnknownFieldError: If a specified field doesn't exist on the model

        Examples:
            # Delete entire entity (all triples with subject IRI)
            session.delete(person)
            # Result: DELETE WHERE { <person_iri> ?p ?o }

            # Delete specific fields only (partial deletion)
            session.delete(person, fields=['age', 'email'])
            # Result: DELETE DATA { <person_iri> <age_predicate> "25" .
            #          <person_iri> <email_predicate> "test@example.com" . }
        """
        _validate_instance_has_iri(instance, "delete")
        subject: str = f"<{escape_sparql_iri(instance.subject_iri)}>"  # type: ignore[arg-type]

        # Use explicit graph_uri parameter or None (session default will be used)
        if fields is None:
            # Full entity deletion - delete all triples with this subject
            pattern: str = f"{subject} ?p ?o"
            self._pending_deletes.append((pattern, graph_uri))
        else:
            # Field deletion - validate fields and build DELETE DATA triples
            for field_name in fields:
                if field_name not in instance.__class__._rdf_fields:
                    raise UnknownFieldError(instance.__class__, field_name)

                field: "RDFFieldInfo" = instance.__class__._rdf_fields[field_name]
                predicate: str = f"<{escape_sparql_iri(field.predicate)}>"

                # Get current value to build DELETE DATA statement
                current_value: Any = getattr(instance, field_name, None)
                if current_value is not None:
                    # Use polymorphic generate_triple_tuples() - handles both single
                    # and multi-value fields (e.g., MultiLangString returns multiple)
                    tuples: list[tuple[str, str, str]] = field.generate_triple_tuples(
                        subject, predicate, current_value
                    )
                    for triple_tuple in tuples:
                        self._pending_field_deletes.append((triple_tuple, graph_uri))

    def delete_all(self, instances: Sequence["Model"]) -> None:
        """Delete multiple instances in batch (full entity deletion only).

        **Important:** This method ALWAYS performs full entity deletion.
        For partial field deletion, use delete() with fields parameter.

        This method stages multiple instances for deletion in a single operation.
        It's more efficient than calling delete() multiple times as it reduces
        the overhead of individual method calls.

        Args:
            instances: List of Model instances to delete (full entity deletion)

        Raises:
            ValidationError: If any instance has no IRI

        Examples:
            # Delete multiple entities completely (all triples for each entity)
            session.delete_all([person1, person2, person3])
            # Result: Multiple DELETE WHERE { <iri> ?p ?o } queries

            # For partial deletion (specific fields only), use delete():
            session.delete(person, fields=['age', 'email'])  # Not batchable
        """
        if not instances:
            logger.debug("No instances provided to delete_all()")
            return

        logger.debug("Staging %d instances for batch deletion", len(instances))

        # Validate all instances first before processing any
        for instance in instances:
            _validate_instance_has_iri(instance, "delete")

        # Process all instances after validation
        for instance in instances:
            subject: str = f"<{escape_sparql_iri(instance.subject_iri)}>"  # type: ignore[arg-type]
            # Full entity deletion - delete all triples with this subject
            pattern: str = f"{subject} ?p ?o"
            self._pending_deletes.append((pattern, None))

        logger.info("Staged %d instances for batch deletion", len(instances))

    def _execute_chunked_inserts(
        self, triples: list[tuple[tuple[str, str, str], str | None]]
    ) -> None:
        """Execute INSERT DATA operations in chunks for large batches.

        Args:
            triples: List of (triple, graph_uri) tuples to insert
        """
        if not triples:
            return

        # For inserts, we want to chunk at a reasonable size to avoid
        # queries that are too large. The chunk_size refers to number of triples.
        chunk_size: int = self.max_batch_size

        # Group triples by graph URI to execute them in the same GRAPH clause
        graph_groups: dict[str | None, list[tuple[str, str, str]]] = {}
        for triple, graph_uri in triples:
            if graph_uri not in graph_groups:
                graph_groups[graph_uri] = []
            graph_groups[graph_uri].append(triple)

        # Process each graph group separately
        for graph_uri, graph_triples in graph_groups.items():
            # Process triples in chunks
            for i in range(0, len(graph_triples), chunk_size):
                chunk: list[tuple[str, str, str]] = graph_triples[i : i + chunk_size]
                chunk_triples_str: str = "\n  ".join(
                    f"{s} {p} {o} ." for (s, p, o) in chunk
                )

                chunk_query: str
                if graph_uri:
                    chunk_query = f"""
                    INSERT DATA {{
                      GRAPH <{escape_sparql_iri(graph_uri)}> {{
                        {chunk_triples_str}
                      }}
                    }}
                    """
                else:
                    chunk_query = f"""
                    INSERT DATA {{
                      {chunk_triples_str}
                    }}
                    """

                logger.debug(
                    "Executing INSERT DATA chunk %d-%d of %d for graph %s",
                    i + 1,
                    min(i + chunk_size, len(graph_triples)),
                    len(graph_triples),
                    graph_uri or "default",
                )
                self.execute_sparql(chunk_query, construct=False, update=True)

    def _execute_chunked_field_deletes(
        self, triples: list[tuple[tuple[str, str, str], str | None]]
    ) -> None:
        """Execute DELETE DATA operations for field deletes in chunks.

        Args:
            triples: List of (triple, graph_uri) tuples to delete
        """
        if not triples:
            return

        # Group triples by graph URI to execute them in the same GRAPH clause
        graph_groups: dict[str | None, list[tuple[str, str, str]]] = {}
        for triple, graph_uri in triples:
            if graph_uri not in graph_groups:
                graph_groups[graph_uri] = []
            graph_groups[graph_uri].append(triple)

        # Process each graph group separately
        for graph_uri, graph_triples in graph_groups.items():
            # Process in chunks if batch is large
            chunk_size: int = self.max_batch_size
            total_triples: int = len(graph_triples)

            for i in range(0, total_triples, chunk_size):
                chunk: list[tuple[str, str, str]] = graph_triples[i : i + chunk_size]
                delete_triples: str = "\n  ".join(
                    f"{s} {p} {o} ." for (s, p, o) in chunk
                )

                field_delete_query: str
                if graph_uri:
                    field_delete_query = f"""
                    DELETE DATA {{
                      GRAPH <{escape_sparql_iri(graph_uri)}> {{
                        {delete_triples}
                      }}
                    }}
                    """
                else:
                    field_delete_query = f"""
                    DELETE DATA {{
                      {delete_triples}
                    }}
                    """

                logger.debug(
                    "Executing DELETE DATA chunk %d-%d of %d for graph %s",
                    i + 1,
                    min(i + chunk_size, total_triples),
                    total_triples,
                    graph_uri or "default",
                )
                self.execute_sparql(field_delete_query, construct=False, update=True)

    def commit(self) -> None:
        """Execute all pending operations: deletes, updates, inserts."""

        total_triples: int = len(self._pending_inserts)
        if self._pending_updates:
            # Handle both old (deletes, inserts) and new (deletes, inserts, graph_uri)
            # formats
            for update_item in self._pending_updates:
                if len(update_item) == 3:
                    # New format: (deletes, inserts, graph_uri)
                    _, inserts, _ = update_item
                    total_triples += len(inserts)
                else:
                    # Old format: (deletes, inserts)
                    _, inserts = update_item
                    total_triples += len(inserts)

        # Also count deletes
        if self._pending_deletes:
            total_triples += len(self._pending_deletes)
        if self._pending_field_deletes:
            total_triples += len(self._pending_field_deletes)

        if total_triples == 0:
            logger.debug("No pending operations to commit")
            return

        logger.info("Committing %d triples to endpoint", total_triples)

        # 1. Execute full entity deletes first
        if self._pending_deletes:
            # DELETE WHERE operations are efficient even for large batches
            for pattern, graph_uri in self._pending_deletes:
                delete_query: str
                if graph_uri:
                    delete_query = f"""
                    WITH <{escape_sparql_iri(graph_uri)}>
                    DELETE WHERE {{
                      {pattern} .
                    }}
                    """
                else:
                    delete_query = f"""
                    DELETE WHERE {{
                      {pattern} .
                    }}
                    """
                self.execute_sparql(delete_query, construct=False, update=True)
            self._pending_deletes = []

        # 2. Execute field deletes with chunking for large batches
        if self._pending_field_deletes:
            self._execute_chunked_field_deletes(self._pending_field_deletes)
            self._pending_field_deletes = []

        # 3. Execute updates
        for update_item in self._pending_updates:
            if len(update_item) == 3:
                # New format: (deletes, inserts, graph_uri)
                deletes, inserts, graph_uri = update_item
                delete_block: str = "\n  ".join(deletes) if deletes else ""
                insert_block: str = "\n  ".join(inserts)

                if graph_uri:
                    if delete_block:
                        update_query = f"""
                        WITH <{escape_sparql_iri(graph_uri)}>
                        DELETE {{
                          {delete_block}
                        }} INSERT {{
                          {insert_block}
                        }} WHERE {{
                          {delete_block}
                        }}
                        """
                    else:
                        update_query = f"""
                        INSERT DATA {{
                          GRAPH <{escape_sparql_iri(graph_uri)}> {{
                            {insert_block}
                          }}
                        }}
                        """
                else:
                    if delete_block:
                        update_query = f"""
                        DELETE DATA {{
                          {delete_block}
                        }} ;
                        INSERT DATA {{
                          {insert_block}
                        }}
                        """
                    else:
                        update_query = f"""
                        INSERT DATA {{
                          {insert_block}
                        }}
                        """
            else:
                # Old format: (deletes, inserts)
                deletes, inserts = update_item
                delete_block_old: str = "\n  ".join(deletes) if deletes else ""
                insert_block_old: str = "\n  ".join(inserts)

                if delete_block_old:
                    update_query = f"""
                    DELETE DATA {{
                      {delete_block_old}
                    }} ;
                    INSERT DATA {{
                      {insert_block_old}
                    }}
                    """
                else:
                    update_query = f"""
                    INSERT DATA {{
                      {insert_block_old}
                    }}
                    """

            self.execute_sparql(update_query, construct=False, update=True)

        self._pending_updates = []

        # 4. Execute inserts with chunking for large batches
        if self._pending_inserts:
            self._execute_chunked_inserts(self._pending_inserts)
            logger.info("Successfully committed %d triples", total_triples)
            self._pending_inserts = []
