"""
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()
        self.schema_registry: SchemaRegistry = schema_registry or SchemaRegistry(
            endpoint=endpoint
        )
        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)
            if primary_graph:
                self.wrapper.addDefaultGraph(primary_graph)
            # 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)
            if primary_graph:
                self.write_wrapper.addDefaultGraph(primary_graph)
            # 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.

        Args:
            graph_uri: URI of the primary graph
        """
        self.primary_graph = graph_uri
        # Note: SPARQLWrapper doesn't support clearing default graphs,
        # so we just update our internal state
        # The wrapper will use the new primary graph for subsequent queries

    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."""
        return Query(model_cls, session=self)

    # -------------------------
    # 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 = []
