"""
Declarative model implementation using Pydantic for validation.

Overview:
    - Model classes inherit from Pydantic BaseModel for validation
    - Models declare rdf_type and use custom Field descriptors
    - Fields know their predicate IRI and validate data types
    - Instances store values validated by Pydantic
    - Support for required fields and custom validators

Field Validation:
    Fields can enforce validation constraints through two mechanisms:
    1. Required fields: Use required=True to ensure field is not None
    2. Custom validators: Provide callable(s) that validate field values

    Validators should:
    - Accept the field value as the only parameter
    - Raise ValueError for validation failures
    - Be pure functions without side effects
    - Execute quickly (< 1ms recommended)

Basic Validation Example:
    >>> from typing import Annotated
    >>> from sparqlmojo import Model, LiteralField, validate_instance
    >>>
    >>> def validate_positive_age(value: int) -> None:
    ...     if value <= 0:
    ...         raise ValueError("Age must be positive")
    >>>
    >>> class Person(Model):
    ...     rdf_type = "http://schema.org/Person"
    ...     name: Annotated[str | None,
    ...         LiteralField("http://schema.org/name", required=True)] = None
    ...     age: Annotated[int | None,
    ...         LiteralField("http://schema.org/age",
    ...                     validator=validate_positive_age)] = None
    >>>
    >>> # Valid instance
    >>> person = Person(iri="http://example.org/john", name="John", age=30)
    >>> validate_instance(person)  # No exception raised
    >>>
    >>> # Missing required field
    >>> person = Person(iri="http://example.org/jane", name=None, age=25)
    >>> try:
    ...     validate_instance(person)
    ... except RequiredFieldError as e:
    ...     print(f"Error: {e}")  # "Required field 'name' is missing"
    >>>
    >>> # Invalid validator
    >>> person = Person(iri="http://example.org/bob", name="Bob", age=-5)
    >>> try:
    ...     validate_instance(person)
    ... except ValidationError as e:
    ...     print(f"Error: {e}")  # "Validation failed for 'age': Age must be positive"

Validator Composition:
    Multiple validators can be applied to a single field by passing a list.
    Validators are executed in order and all must pass.

    >>> def validate_min_length(value: str) -> None:
    ...     if len(value) < 3:
    ...         raise ValueError("Must be at least 3 characters")
    >>>
    >>> def validate_no_spaces(value: str) -> None:
    ...     if " " in value:
    ...         raise ValueError("Must not contain spaces")
    >>>
    >>> class User(Model):
    ...     rdf_type = "http://schema.org/User"
    ...     username: Annotated[str | None,
    ...         LiteralField("http://schema.org/username",
    ...                     required=True,
    ...                     validator=[validate_min_length, validate_no_spaces])] = None
    >>>
    >>> # Valid username
    >>> user = User(iri="http://example.org/alice", username="alice")
    >>> validate_instance(user)  # Passes both validators
    >>>
    >>> # Fails first validator
    >>> user = User(iri="http://example.org/ab", username="ab")
    >>> try:
    ...     validate_instance(user)
    ... except ValidationError as e:
    ...     print(f"Error: {e}")
    ...     # "Validation failed for 'username': Must be at least 3 characters"
    >>>
    >>> # Fails second validator
    >>> user = User(iri="http://example.org/alice_bob", username="alice bob")
    >>> try:
    ...     validate_instance(user)
    ... except ValidationError as e:
    ...     print(f"Error: {e}")
    ...     # "Validation failed for 'username': Must not contain spaces"

Session Integration:
    Validation is automatically triggered when adding or updating instances via Session:

    >>> from sparqlmojo import Session
    >>> session = Session()
    >>>
    >>> # Validation runs on add()
    >>> person = Person(iri="http://example.org/invalid", name=None, age=30)
    >>> try:
    ...     session.add(person)
    ... except RequiredFieldError as e:
    ...     print(f"Error: {e}")  # "Required field 'name' is missing"
    >>>
    >>> # Validation runs on update()
    >>> person = Person(iri="http://example.org/john", name="John", age=30)
    >>> person.mark_clean()
    >>> person.age = -5
    >>> try:
    ...     session.update(person)
    ... except ValidationError as e:
    ...     print(f"Error: {e}")  # "Validation failed for 'age': Age must be positive"
"""

import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import StrEnum
from typing import (
    TYPE_CHECKING,
    Annotated,
    Any,
    Callable,
    ClassVar,
    get_args,
    get_origin,
    override,
)

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic._internal._model_construction import (
    ModelMetaclass as PydanticModelMetaclass,
)

if TYPE_CHECKING:
    from rdflib import Graph, URIRef

    from ..engine.session import Session
    from .filtering import FieldFilter

# Import exceptions for type hints and potential future use
from ..exc.exceptions import UnknownFieldError
from .security import (
    DANGEROUS_PATH_KEYWORDS,
    escape_sparql_iri,
    escape_sparql_literal,
    validate_iri,
    validate_no_sparql_keywords,
    validate_predicate_iri,
)

# Predicate marker for SubjectField (which doesn't represent a real predicate)
_SUBJECT_FIELD_PREDICATE_MARKER: str = "__subject__"

# Suffix for language tag variables in SPARQL queries (e.g., "name" -> "name_lang")
LANG_VARIABLE_SUFFIX: str = "_lang"


class XSD(StrEnum):
    """XSD datatype IRIs (full IRI form to avoid PREFIX dependency).

    Members are ``str`` instances so they can be passed directly as the
    ``datatype`` argument of :class:`LiteralField`.
    """

    STRING = "http://www.w3.org/2001/XMLSchema#string"
    INTEGER = "http://www.w3.org/2001/XMLSchema#integer"
    DECIMAL = "http://www.w3.org/2001/XMLSchema#decimal"
    FLOAT = "http://www.w3.org/2001/XMLSchema#float"
    DOUBLE = "http://www.w3.org/2001/XMLSchema#double"
    BOOLEAN = "http://www.w3.org/2001/XMLSchema#boolean"
    DATE = "http://www.w3.org/2001/XMLSchema#date"
    DATETIME = "http://www.w3.org/2001/XMLSchema#dateTime"


# Convenience aliases for backward compatibility and shorter usage
XSD_STRING: str = XSD.STRING
XSD_INTEGER: str = XSD.INTEGER
XSD_DECIMAL: str = XSD.DECIMAL
XSD_FLOAT: str = XSD.FLOAT
XSD_DOUBLE: str = XSD.DOUBLE
XSD_BOOLEAN: str = XSD.BOOLEAN
XSD_DATE: str = XSD.DATE
XSD_DATETIME: str = XSD.DATETIME


# Property path validation regex pattern (private; used only by PropertyPath)
_PROPERTY_PATH_ALLOWED_CHARS_PATTERN: str = (
    r"^[a-zA-Z0-9:\/\#\-\_\.\*\+\?\|\^\/\(\)\<\>\s]+$"
)


class PropertyPath:
    """
    Represents a SPARQL property path for relationship traversal.

    Property paths allow traversing multiple relationship levels and using
    path operators like zero-or-more (*), one-or-more (+), zero-or-one (?),
    alternative paths (|), and inverse paths (^).

    **IRI Expansion**: Property paths are NOT automatically expanded by the
    session's prefix registry. You must use the exact form you want in the
    generated SPARQL query:
    - Short-form: `PropertyPath('schema:knows+')` uses prefix as-is
    - Full IRI: `PropertyPath('<http://schema.org/knows>+')` uses full IRI

    Examples:
        - 'schema:knows+' - one or more knows relationships (transitive)
        - 'org:reportsTo*' - zero or more reportsTo relationships
        - 'schema:parent|schema:guardian' - alternative paths
        - '^schema:child' - inverse path (find parents)
        - '<http://schema.org/knows>+' - full IRI with transitive operator
    """

    OPERATORS: frozenset[str] = frozenset({"^", "*", "+", "?", "|"})

    def __init__(self, path: str):
        """
        Initialize property path.

        Args:
            path: SPARQL property path string (e.g., 'schema:knows+', 'org:reportsTo*')

        Raises:
            ValueError: If path is empty or contains invalid characters
            SPARQLInjectionError: If path contains dangerous SPARQL keywords
        """
        if not path or not path.strip():
            raise ValueError("Property path cannot be empty")

        # Validate for SPARQL injection
        self._validate_property_path_security(path)

        self._path = path

    @property
    def path(self) -> str:
        """Get the property path string (read-only)."""
        return self._path

    def _validate_property_path_security(self, path: str) -> None:
        """
        Validate property path for security.

        Allows valid property path operators but rejects dangerous SPARQL keywords.

        Args:
            path: Property path string to validate

        Raises:
            ValueError: If path contains invalid characters
            SPARQLInjectionError: If path contains dangerous SPARQL keywords
        """
        self._validate_no_dangerous_keywords(path)
        self._validate_allowed_characters(path)

    def _validate_no_dangerous_keywords(self, path: str) -> None:
        """
        Check for dangerous SPARQL keywords in property path.

        Uses word boundary matching to avoid false positives with legitimate IRIs
        that contain keywords as substrings.

        Args:
            path: Property path string to validate

        Raises:
            SPARQLInjectionError: If dangerous SPARQL keywords are detected
        """
        validate_no_sparql_keywords(path, DANGEROUS_PATH_KEYWORDS, "Property path")

    def _validate_allowed_characters(self, path: str) -> None:
        """
        Validate property path character set.

        Property paths should contain:
        - IRI characters (letters, numbers, :, /, #, -, _, .)
        - Angle brackets for full IRIs (<, >)
        - Property path operators (*, +, ?, |, ^, /, (, ))
        - Limited whitespace

        Args:
            path: Property path string to validate

        Raises:
            ValueError: If path contains invalid characters
        """
        if not re.match(_PROPERTY_PATH_ALLOWED_CHARS_PATTERN, path):
            raise ValueError(
                f"Property path contains invalid characters: {path}. "
                f"Allowed: IRI characters, *, +, ?, |, ^, /, (), "
                f"and limited whitespace."
            )

    def to_sparql(self) -> str:
        """
        Return SPARQL representation of property path.

        Returns:
            SPARQL property path string ready for use in queries
        """
        return self.path

    @staticmethod
    def format_predicate(predicate: str) -> str:
        """
        Format a predicate IRI for use in a property path expression.

        This function wraps full IRIs in angle brackets while leaving
        prefixed forms (e.g., schema:knows) unchanged.

        Rules:
        - Full IRI (contains "://"): wrap in <...>
        - Already wrapped (starts with "<"): return as-is
        - Prefixed form (contains ":"): return as-is

        Args:
            predicate: The predicate IRI to format

        Returns:
            Formatted predicate string suitable for property path expressions

        Examples:
            >>> PropertyPath.format_predicate("http://schema.org/knows")
            '<http://schema.org/knows>'
            >>> PropertyPath.format_predicate("schema:knows")
            'schema:knows'
            >>> PropertyPath.format_predicate("<http://schema.org/knows>")
            '<http://schema.org/knows>'
        """
        # Already wrapped in angle brackets
        if predicate.startswith("<") and predicate.endswith(">"):
            return predicate

        # Full IRI (contains "://") - needs wrapping
        if "://" in predicate:
            escaped: str = escape_sparql_iri(predicate)
            return f"<{escaped}>"

        # Prefixed form or other - return as-is
        return predicate

    @staticmethod
    def has_operators(predicate: str) -> bool:
        """
        Check if a predicate string contains property path operators.

        Property path operators (^, *, +, ?, |) should not be wrapped in angle
        brackets. This function detects their presence to avoid double-wrapping.

        Args:
            predicate: The predicate string to check

        Returns:
            True if the predicate contains any property path operators
        """
        return any(op in predicate for op in PropertyPath.OPERATORS)


class RDFFieldInfo(ABC):
    """Abstract base class for RDF field information with SPARQL formatting.

    Supports both simple predicates (IRI strings) and SPARQL property paths.
    When a PropertyPath is provided as the predicate, it is stored separately
    and the field's predicate attribute contains the SPARQL representation.

    Predicate Validation:
        String predicates are validated at field definition time. Namespace URIs
        (ending with '/' or '#') are rejected to prevent invalid SPARQL generation.
        Use complete predicate IRIs like 'http://schema.org/name' instead.
        PropertyPath objects are exempt as they have their own syntax rules.

    Example with simple predicate:
        >>> field = LiteralField("http://schema.org/name")
        >>> field.predicate
        'http://schema.org/name'
        >>> field.is_property_path()
        False

    Example with property path:
        >>> field = IRIField(PropertyPath("^<http://example.org/belongsTo>"))
        >>> field.predicate
        '^<http://example.org/belongsTo>'
        >>> field.is_property_path()
        True
        >>> field.get_property_path().to_sparql()
        '^<http://example.org/belongsTo>'
    """

    # Keyword arguments allowed for this class (excluding positional/named params).
    # Subclasses should define their own _ALLOWED_EXTRA_KWARGS to allow additional
    # kwargs. The validation logic in __init__() walks the MRO and accumulates all
    # allowed kwargs from the inheritance hierarchy, so subclasses automatically
    # inherit parent class allowances.
    #
    # Note: Named parameters declared explicitly in __init__ signatures (like
    # 'datatype', 'lang', 'range_', 'separator') are NOT subject to this validation
    # since they are consumed by the method signature before reaching **kwargs.
    # This attribute only controls what's allowed to pass through **kwargs.
    #
    # Example:
    #     class BaseField(RDFFieldInfo):
    #         _ALLOWED_EXTRA_KWARGS: frozenset[str] = frozenset({"base_param"})
    #
    #     class SubField(BaseField):
    #         _ALLOWED_EXTRA_KWARGS: frozenset[str] = frozenset({"sub_param"})
    #
    #     # SubField instances accept both "base_param" and "sub_param" via **kwargs
    _ALLOWED_EXTRA_KWARGS: frozenset[str] = frozenset()

    def __init__(
        self,
        predicate: "str | PropertyPath",
        required: bool = False,
        default: Any = None,
        validator: Callable[[Any], None] | list[Callable[[Any], None]] | None = None,
        **kwargs: Any,
    ):
        # Validate that only allowed keyword arguments are passed
        # Collect allowed kwargs from the entire class hierarchy
        allowed_kwargs: set[str] = set()
        for cls in type(self).__mro__:
            if hasattr(cls, "_ALLOWED_EXTRA_KWARGS"):
                allowed_kwargs.update(cls._ALLOWED_EXTRA_KWARGS)

        unknown_kwargs: set[str] = set(kwargs.keys()) - allowed_kwargs
        if unknown_kwargs:
            sorted_unknown: list[str] = sorted(unknown_kwargs)
            sorted_allowed: list[str] = sorted(allowed_kwargs) if allowed_kwargs else []
            allowed_msg: str = (
                f"Allowed extra kwargs: {sorted_allowed}"
                if sorted_allowed
                else "No extra keyword arguments are allowed for this field type"
            )
            raise TypeError(
                f"{type(self).__name__}() got unexpected keyword argument(s): "
                f"{sorted_unknown}. {allowed_msg}."
            )

        # Normalize validator(s) to a list
        if validator is None:
            self.validators: list[Callable[[Any], None]] = []
        elif callable(validator):
            self.validators = [validator]
        elif isinstance(validator, list):
            # Validate that all items in list are callable
            for i, v in enumerate(validator):
                if not callable(v):
                    raise TypeError(
                        f"validator[{i}] must be callable, got {type(v).__name__}"
                    )
            self.validators = list(validator)
        else:
            raise TypeError(
                f"validator must be callable or list of callables, "
                f"got {type(validator).__name__}"
            )

        # Handle PropertyPath vs string predicate
        # When a PropertyPath is provided, store it separately and use its SPARQL
        # representation as the predicate string. This enables field-level property
        # path support while maintaining backward compatibility with code that
        # expects predicate to be a string.
        self._property_path: PropertyPath | None = None
        if isinstance(predicate, PropertyPath):
            self._property_path = predicate
            self.predicate: str = predicate.to_sparql()
        else:
            self.predicate = predicate
            # Validate string predicates are not namespace-only URIs
            # (PropertyPath objects have their own syntax validation)
            validate_predicate_iri(predicate)

        self.required: bool = required
        self.field_name: str | None = None
        self.default: Any = default
        # Keep backward compatibility with single validator property
        self.validator: Callable[[Any], None] | None = (
            self.validators[0] if len(self.validators) == 1 else None
        )
        self.extra_kwargs: dict[str, Any] = kwargs

    def bind_name(self, name: str) -> None:
        """Bind the Python field name to this RDF field."""
        self.field_name = name

    def to_sparql_var(self) -> str:
        """Return the SPARQL variable name for this field."""
        return f"?{self.field_name}"

    def is_subject(self) -> bool:
        """Check if this field represents the RDF subject."""
        return getattr(self, "is_subject_field", False)

    def is_property_path(self) -> bool:
        """Check if this field uses a property path instead of a simple predicate.

        Returns:
            True if the field was defined with a PropertyPath, False otherwise.

        Example:
            >>> simple_field = LiteralField("http://schema.org/name")
            >>> simple_field.is_property_path()
            False
            >>> path_field = IRIField(PropertyPath("^<http://example.org/belongsTo>"))
            >>> path_field.is_property_path()
            True
        """
        return self._property_path is not None

    def get_property_path(self) -> PropertyPath | None:
        """Get the PropertyPath object if this field uses a property path.

        Returns:
            The PropertyPath object, or None if this field uses a simple predicate.

        Example:
            >>> field = IRIField(PropertyPath("^<http://example.org/belongsTo>"))
            >>> field.get_property_path().to_sparql()
            '^<http://example.org/belongsTo>'
        """
        return self._property_path

    @abstractmethod
    def _copy_kwargs(self) -> dict[str, Any]:
        """Return subclass-specific kwargs for copy().

        Subclasses must implement this to return a dictionary of keyword
        arguments that are specific to that subclass (e.g., datatype for
        LiteralField, lang for LangString).

        Returns:
            Dictionary of kwargs to pass to the constructor during copy().

        Raises:
            NotImplementedError: If not implemented by subclass.
        """
        raise NotImplementedError

    def copy(self) -> "RDFFieldInfo":
        """
        Create a deep copy of this field info.

        Returns:
            A new instance of the same field type with identical parameters.

        Example:
            >>> field = LiteralField("http://schema.org/name", default=None)
            >>> field.bind_name("original_name")
            >>> field_copy = field.copy()
            >>> field_copy.bind_name("copied_name")
            >>> assert field.field_name == "original_name"
            >>> assert field_copy.field_name == "copied_name"
        """
        # Use the PropertyPath object if present, otherwise use the predicate string
        predicate_arg: str | PropertyPath = (
            self._property_path if self._property_path is not None else self.predicate
        )
        return type(self)(
            predicate=predicate_arg,
            required=self.required,
            default=self.default,
            validator=self.validators,
            **self._copy_kwargs(),
            **self.extra_kwargs,
        )

    @abstractmethod
    def format_value(self, value: Any) -> str:
        """
        Format a value for SPARQL output.
        Subclasses must implement this to provide appropriate formatting.
        """
        ...

    def generate_triples(self, subject: str, predicate: str, value: Any) -> list[str]:
        """
        Generate complete SPARQL triple strings for INSERT/DELETE operations.

        This method produces fully formatted triple strings ready for use in
        SPARQL INSERT DATA or DELETE DATA statements.

        Args:
            subject: The formatted subject (e.g., "<http://example.org/person>")
            predicate: The formatted predicate (e.g., "<http://schema.org/name>")
            value: The Python value to format

        Returns:
            List of complete triple strings (e.g., ["<s> <p> \"value\" ."])
            Most field types return a single-element list, but MultiLangString
            returns multiple triples (one per language).

        Example:
            >>> field = LiteralField("http://schema.org/name")
            >>> field.generate_triples("<s>", "<p>", "Alice")
            ['<s> <p> "Alice" .']
        """
        formatted_value: str = self.format_value(value)
        return [f"{subject} {predicate} {formatted_value} ."]

    def generate_triple_tuples(
        self, subject: str, predicate: str, value: Any
    ) -> list[tuple[str, str, str]]:
        """
        Generate (subject, predicate, object) tuples for staging operations.

        Unlike generate_triples() which returns complete triple strings,
        this method returns tuples that can be stored and later assembled
        into SPARQL statements.

        Args:
            subject: The formatted subject (e.g., "<http://example.org/person>")
            predicate: The formatted predicate (e.g., "<http://schema.org/name>")
            value: The Python value to format

        Returns:
            List of (subject, predicate, object) tuples.
            Most field types return a single tuple, but MultiLangString
            returns multiple tuples (one per language).

        Example:
            >>> field = LiteralField("http://schema.org/name")
            >>> field.generate_triple_tuples("<s>", "<p>", "Alice")
            [('<s>', '<p>', '"Alice"')]
        """
        formatted_value: str = self.format_value(value)
        return [(subject, predicate, formatted_value)]

    def hydrate_value(
        self,
        rdf_value: Any,
        language: str | None = None,
        datatype: str | None = None,
    ) -> Any:
        """
        Convert an RDF value to the appropriate Python type.

        This method is used during result hydration to convert RDF values
        (from rdflib or SPARQL JSON results) into Python objects with
        proper type handling.

        Args:
            rdf_value: The RDF value to convert. Can be:
                - rdflib.Literal: Converts using toPython()
                - rdflib.URIRef: Converts to string
                - str: Returns as-is or with appropriate conversion
                - Any other type: Returns as-is
            language: Optional language tag (for language-tagged literals)
            datatype: Optional XSD datatype IRI (for typed literals)

        Returns:
            Converted Python value appropriate for this field type.
            - LiteralField: Returns toPython() result or string
            - LangString: Returns LangLiteral with preserved language tag
            - IRIField: Returns string IRI

        Example:
            >>> from rdflib import Literal
            >>> field = LiteralField("http://schema.org/name")
            >>> field.hydrate_value(Literal("Alice"))
            'Alice'
        """
        # Check if it's an rdflib Literal with toPython() method
        if hasattr(rdf_value, "toPython"):
            return rdf_value.toPython()

        # For string values, return as-is
        if isinstance(rdf_value, str):
            return rdf_value

        # Default: convert to string
        return str(rdf_value)

    def get_lang_select_expression(self, field_name: str) -> str | None:
        """
        Generate SPARQL LANG() expression for SELECT clause.

        This method generates an expression to extract the language tag
        of a literal value in a SPARQL SELECT query. Most field types
        don't have language tags, so the base implementation returns None.

        LangString overrides this to return the LANG() expression needed
        to preserve language information in query results.

        Args:
            field_name: The name of the field (used to construct variable names)

        Returns:
            SPARQL expression like "(LANG(?name) AS ?name_lang)" for fields
            that support language tags, or None for other field types.

        Example:
            >>> field = LiteralField("http://schema.org/name")
            >>> field.get_lang_select_expression("name")
            None  # LiteralField has no language tag

            >>> field = LangString("http://schema.org/name")
            >>> field.get_lang_select_expression("name")
            '(LANG(?name) AS ?name_lang)'
        """
        return None

    def supports_language_filtering(self) -> bool:
        """
        Return whether this field supports language filtering via filter_lang().

        Default implementation returns False. LangString overrides to return True,
        enabling language-based filtering in SPARQL queries.

        Returns:
            True if the field supports filter_lang(), False otherwise.

        Example:
            >>> field = LiteralField("http://schema.org/name")
            >>> field.supports_language_filtering()
            False

            >>> field = LangString("http://schema.org/name")
            >>> field.supports_language_filtering()
            True
        """
        return False

    def get_construct_pattern(self, triple: str, var_name: str) -> str:
        """
        Generate OPTIONAL pattern for SPARQL CONSTRUCT WHERE clause.

        This method produces the WHERE clause pattern for retrieving field values
        in CONSTRUCT queries. The default implementation wraps the triple in a
        simple OPTIONAL pattern.

        LangString overrides this to add FILTER(lang(?var) = "xx") when a
        specific language is configured.

        Args:
            triple: The complete triple pattern (e.g., "<s> <p> ?name .")
            var_name: The variable name used in the triple (e.g., "name")

        Returns:
            OPTIONAL pattern string for the WHERE clause.

        Example:
            >>> field = LiteralField("http://schema.org/name")
            >>> field.get_construct_pattern("<s> <p> ?name .", "name")
            'OPTIONAL { <s> <p> ?name . }'

            >>> field = LangString("http://schema.org/name", lang="en")
            >>> field.get_construct_pattern("<s> <p> ?name .", "name")
            'OPTIONAL { <s> <p> ?name . FILTER(lang(?name) = "en") }'
        """
        return f"OPTIONAL {{ {triple} }}"

    def collect_from_graph(
        self,
        graph: "Graph",
        subject: "URIRef",
        predicate: "URIRef",
        convert_fn: Callable[[Any, "RDFFieldInfo"], Any],
    ) -> Any:
        """
        Collect field value(s) from an RDF graph.

        This method retrieves values for this field from the given graph by
        iterating over triples matching the subject and predicate. The default
        implementation returns the first matching value after conversion.

        LangString overrides to filter by the configured language tag.
        MultiLangString overrides to collect all language versions into a dict.

        Args:
            graph: The rdflib Graph to query
            subject: The subject URIRef to match
            predicate: The predicate URIRef for this field
            convert_fn: Function to convert RDF values to Python types.
                       Signature: (rdf_value, field_info) -> Any

        Returns:
            The converted field value, or None if no matching triple found.
            MultiLangString returns dict[str, str] mapping language codes to text.

        Example:
            >>> from rdflib import Graph, URIRef, Literal
            >>> g = Graph()
            >>> g.add((URIRef("http://ex/s"), URIRef("http://ex/p"), Literal("hello")))
            >>> field = LiteralField("http://ex/p")
            >>> def convert(obj, f): return str(obj)
            >>> field.collect_from_graph(g, URIRef("http://ex/s"),
            ...                          URIRef("http://ex/p"), convert)
            'hello'
        """
        # Early return pattern: return first matching value immediately.
        # Base implementation for single-value fields (LiteralField, IRIField).
        # Subclasses override for different collection strategies:
        # - LangString: filters by language tag before returning
        # - MultiLangString: collects all values into a dict instead of early return
        for _, _, obj in graph.triples((subject, predicate, None)):
            return convert_fn(obj, self)
        # No matching triples found - return None to indicate missing value
        return None


class LiteralField(RDFFieldInfo):
    """
    Field for RDF literals (strings, numbers, dates, booleans, etc.).
    Values are serialized with quotes in SPARQL.

    When ``datatype`` is set, formatted values include an explicit XSD
    datatype suffix (e.g., ``"value"^^<http://www.w3.org/2001/XMLSchema#string>``).
    """

    def __init__(
        self,
        predicate: "str | PropertyPath",
        datatype: str | None = None,
        required: bool = False,
        default: Any = None,
        validator: Callable[[Any], None] | list[Callable[[Any], None]] | None = None,
        **kwargs: Any,
    ):
        super().__init__(
            predicate=predicate,
            required=required,
            default=default,
            validator=validator,
            **kwargs,
        )
        # Validate datatype IRI to prevent injection attacks
        if datatype is not None:
            validate_iri(datatype, strict=True)
        self.datatype: str | None = datatype

    def _escape_value(self, value: Any) -> str:
        """
        Escape value for use in SPARQL literal.

        Args:
            value: Value to escape

        Returns:
            Escaped string safe for SPARQL
        """
        return escape_sparql_literal(str(value))

    @override
    def _copy_kwargs(self) -> dict[str, Any]:
        """Return datatype for copy()."""
        return {"datatype": self.datatype}

    @override
    def format_value(self, value: Any) -> str:
        """Format value as a quoted SPARQL literal with escaping."""
        escaped: str = self._escape_value(value)
        if self.datatype:
            return f'"{escaped}"^^<{self.datatype}>'
        return f'"{escaped}"'


@dataclass(frozen=True)
class LangLiteral:
    """
    A language-tagged literal value from RDF.

    This dataclass represents an RDF literal with an associated language tag.
    It is returned when querying LangString fields to preserve the language
    information from the SPARQL endpoint.

    Attributes:
        text: The string value of the literal.
        lang: The BCP 47 language tag (e.g., 'en', 'yi', 'zh-Hans'), or None
              if no language tag was present.

    Example:
        >>> literal = LangLiteral("Hello", "en")
        >>> print(literal.text)   # "Hello"
        >>> print(literal.lang)   # "en"
        >>> print(str(literal))   # "Hello"
    """

    text: str
    lang: str | None = None

    def __str__(self) -> str:
        """Return the text value when converting to string."""
        return self.text

    def __repr__(self) -> str:
        """Return a detailed representation including language tag."""
        if self.lang:
            return f"LangLiteral({self.text!r}, lang={self.lang!r})"
        return f"LangLiteral({self.text!r})"


class LangString(LiteralField):
    """
    Field for RDF language-tagged literals.
    Values are serialized with language tags in SPARQL (e.g., "Hello"@en).
    """

    def __init__(
        self,
        predicate: "str | PropertyPath",
        lang: str | None = None,
        required: bool = False,
        default: Any = None,
        validator: Callable[[Any], None] | list[Callable[[Any], None]] | None = None,
        **kwargs: Any,
    ):
        super().__init__(
            predicate=predicate,
            required=required,
            default=default,
            validator=validator,
            **kwargs,
        )
        # Validate language tag if provided
        if lang is not None:
            validate_language_tag(lang)
        self.lang: str | None = lang

    @override
    def _copy_kwargs(self) -> dict[str, Any]:
        """Return lang for copy(), merged with parent kwargs."""
        return {**super()._copy_kwargs(), "lang": self.lang}

    @override
    def format_value(self, value: Any) -> str:
        """Format value as a language-tagged SPARQL literal."""
        escaped: str = self._escape_value(value)
        if self.lang:
            return f'"{escaped}"@{self.lang}'
        return f'"{escaped}"'

    @override
    def hydrate_value(
        self,
        rdf_value: Any,
        language: str | None = None,
        datatype: str | None = None,
    ) -> LangLiteral:
        """
        Convert an RDF value to a LangLiteral preserving language tag.

        LangString fields return LangLiteral objects to preserve the
        language information from the SPARQL endpoint.

        Args:
            rdf_value: The RDF value to convert
            language: Optional language tag override
            datatype: Ignored for LangString fields

        Returns:
            LangLiteral with text and language tag preserved

        Example:
            >>> from rdflib import Literal
            >>> field = LangString("http://schema.org/name")
            >>> literal = Literal("Hello", lang="en")
            >>> field.hydrate_value(literal)
            LangLiteral('Hello', lang='en')
        """
        # Extract text value
        if hasattr(rdf_value, "toPython"):
            text: str = str(rdf_value)
        elif isinstance(rdf_value, str):
            text = rdf_value
        else:
            text = str(rdf_value)

        # Extract language tag from rdflib Literal if not provided
        lang: str | None = language
        if lang is None and hasattr(rdf_value, "language") and rdf_value.language:
            lang = str(rdf_value.language)

        return LangLiteral(text=text, lang=lang)

    @override
    def get_lang_select_expression(self, field_name: str) -> str:
        """
        Generate SPARQL LANG() expression for SELECT clause.

        LangString fields return a LANG() expression to extract the
        language tag from the literal value in query results.

        Args:
            field_name: The name of the field

        Returns:
            SPARQL expression like "(LANG(?name) AS ?name_lang)"

        Example:
            >>> field = LangString("http://schema.org/name")
            >>> field.get_lang_select_expression("name")
            '(LANG(?name) AS ?name_lang)'
        """
        lang_var: str = f"{field_name}{LANG_VARIABLE_SUFFIX}"
        return f"(LANG(?{field_name}) AS ?{lang_var})"

    @override
    def supports_language_filtering(self) -> bool:
        """
        Return True since LangString fields support language filtering.

        Returns:
            True, enabling filter_lang() to be used with this field.
        """
        return True

    @override
    def get_construct_pattern(self, triple: str, var_name: str) -> str:
        """
        Generate OPTIONAL pattern with language filter for CONSTRUCT queries.

        When a specific language is configured, adds a FILTER clause to
        restrict results to that language tag.

        Args:
            triple: The complete triple pattern
            var_name: The variable name used in the triple

        Returns:
            OPTIONAL pattern with FILTER for language-tagged fields.
        """
        if self.lang:
            return f'OPTIONAL {{ {triple} FILTER(lang(?{var_name}) = "{self.lang}") }}'
        return super().get_construct_pattern(triple, var_name)

    @override
    def collect_from_graph(
        self,
        graph: "Graph",
        subject: "URIRef",
        predicate: "URIRef",
        convert_fn: Callable[[Any, "RDFFieldInfo"], Any],
    ) -> Any:
        """
        Collect field value from graph, filtering by configured language tag.

        When a specific language is configured, only returns values with
        matching language tags. Otherwise, delegates to base implementation.

        Args:
            graph: The rdflib Graph to query
            subject: The subject URIRef to match
            predicate: The predicate URIRef for this field
            convert_fn: Function to convert RDF values to Python types

        Returns:
            The converted field value matching the language, or None if not found.
        """
        if self.lang:
            for _, _, obj in graph.triples((subject, predicate, None)):
                if hasattr(obj, "language") and obj.language == self.lang:
                    return convert_fn(obj, self)
            return None
        return super().collect_from_graph(graph, subject, predicate, convert_fn)


class MultiLangString(LiteralField):
    """
    Field for multiple language versions of a string.
    Values are expected as a dict mapping language codes to text (e.g.,
    {'en': 'Hello', 'fr': 'Bonjour'}).
    """

    def __init__(
        self,
        predicate: "str | PropertyPath",
        required: bool = False,
        default: Any = None,
        validator: Callable[[Any], None] | list[Callable[[Any], None]] | None = None,
        **kwargs: Any,
    ) -> None:
        # MultiLangString cannot have a datatype - lang tags and datatypes are
        # mutually exclusive in RDF
        if "datatype" in kwargs:
            raise ValueError(
                "MultiLangString does not support the 'datatype' parameter. "
                "Language tags and datatypes are mutually exclusive in RDF. "
                "Use LiteralField with datatype for typed literals."
            )
        super().__init__(
            predicate=predicate,
            required=required,
            default=default,
            validator=validator,
            **kwargs,
        )

    @override
    def format_value(self, value: Any) -> list[str]:  # type: ignore[override]
        """
        Format value as a list of language-tagged SPARQL literals.

        Args:
            value: Dict mapping language codes to text

        Returns:
            List of formatted SPARQL literals with language tags

        Raises:
            ValueError: If value is not a dict or is empty
        """
        if not isinstance(value, dict):
            raise ValueError(
                "MultiLangString expects a dict mapping language codes to text, got "
                f"{type(value)}"
            )

        if not value:
            raise ValueError(
                "MultiLangString requires at least one language mapping. "
                "Got empty dict."
            )

        # Validate all language tags
        for lang in value.keys():
            validate_language_tag(lang)

        # Generate list of formatted values for each language
        result: list[str] = []
        for lang, text in value.items():
            escaped: str = self._escape_value(text)
            result.append(f'"{escaped}"@{lang}')

        return result

    @override
    def generate_triples(self, subject: str, predicate: str, value: Any) -> list[str]:
        """
        Generate multiple SPARQL triple strings for each language version.

        MultiLangString values are dicts mapping language codes to text,
        so each entry generates a separate triple.

        Args:
            subject: The formatted subject (e.g., "<http://example.org/person>")
            predicate: The formatted predicate (e.g., "<http://schema.org/name>")
            value: Dict mapping language codes to text

        Returns:
            List of complete triple strings, one per language.

        Example:
            >>> field = MultiLangString("http://schema.org/name")
            >>> field.generate_triples("<http://ex/p>", "<http://schema.org/name>",
            ...                        {"en": "Hello", "fr": "Bonjour"})
            ['<http://ex/p> <http://schema.org/name> "Hello"@en .',
             '<http://ex/p> <http://schema.org/name> "Bonjour"@fr .']
        """
        formatted_values: list[str] = self.format_value(value)
        return [f"{subject} {predicate} {fv} ." for fv in formatted_values]

    @override
    def generate_triple_tuples(
        self, subject: str, predicate: str, value: Any
    ) -> list[tuple[str, str, str]]:
        """
        Generate (subject, predicate, object) tuples for each language version.

        Args:
            subject: The formatted subject
            predicate: The formatted predicate
            value: Dict mapping language codes to text

        Returns:
            List of (subject, predicate, object) tuples, one per language.
        """
        formatted_values: list[str] = self.format_value(value)
        return [(subject, predicate, fv) for fv in formatted_values]

    @override
    def collect_from_graph(
        self,
        graph: "Graph",
        subject: "URIRef",
        predicate: "URIRef",
        convert_fn: Callable[[Any, "RDFFieldInfo"], Any],
    ) -> dict[str, str] | None:
        """
        Collect all language versions from graph into a dict.

        Iterates over all triples matching the subject and predicate,
        extracting values with language tags into a dict mapping
        language codes to text.

        Args:
            graph: The rdflib Graph to query
            subject: The subject URIRef to match
            predicate: The predicate URIRef for this field
            convert_fn: Not used - values are extracted directly as strings

        Returns:
            Dict mapping language codes to text values, or None if no
            language-tagged values found.
        """
        lang_dict: dict[str, str] = {}
        for _, _, obj in graph.triples((subject, predicate, None)):
            if hasattr(obj, "language") and obj.language:
                lang_dict[str(obj.language)] = str(obj)
        return lang_dict if lang_dict else None


class IRIField(RDFFieldInfo):
    """
    Field for IRI references to other resources.
    Values are serialized with angle brackets in SPARQL.
    """

    @override
    def _copy_kwargs(self) -> dict[str, Any]:
        """Return empty dict - IRIField has no extra attributes."""
        return {}

    @override
    def format_value(self, value: Any) -> str:
        """Format value as a SPARQL IRI reference with angle brackets."""
        escaped: str = escape_sparql_iri(str(value))
        return f"<{escaped}>"


class ObjectPropertyField(IRIField):
    """
    Field for object properties linking to other RDF resources.

    Inherits from IRIField to automatically handle IRI formatting in SPARQL
    queries. This ensures filter operations produce correct IRI syntax
    (``<http://...>``) rather than string literals (``"http://..."``), which is
    essential for SPARQL query correctness when filtering on object properties.

    The inheritance relationship also simplifies type checking: any code that
    checks ``isinstance(field, IRIField)`` will correctly identify
    ObjectPropertyField as an IRI-valued field, enabling consistent handling
    across all IRI field types.

    The range_ parameter specifies the RDF class (rdfs:range) that objects
    of this property should have. For example, if 'knows' links Person to Person,
    range_="http://schema.org/Person". This enables:

    - Type validation in future implementations
    - Automatic type constraints in SPARQL queries
    - Schema documentation and introspection

    Currently range_ is stored for future use but not enforced.

    Example:
        >>> class Person(Model):
        ...     rdf_type = "http://schema.org/Person"
        ...     knows: Annotated[str | None, ObjectPropertyField(
        ...         "http://schema.org/knows",
        ...         range_="http://schema.org/Person"
        ...     )] = None
        >>>
        >>> # Filtering produces correct IRI syntax
        >>> query = session.query(Person).filter_by(
        ...     knows="http://example.org/bob"
        ... )
        >>> # Generated SPARQL: FILTER(?knows = <http://example.org/bob>)
    """

    def __init__(
        self,
        predicate: "str | PropertyPath",
        range_: str | None = None,
        required: bool = False,
        default: Any = None,
        validator: Callable[[Any], None] | list[Callable[[Any], None]] | None = None,
        **kwargs: Any,
    ):
        super().__init__(
            predicate=predicate,
            required=required,
            default=default,
            validator=validator,
            **kwargs,
        )
        self.range: str | None = range_

    @override
    def _copy_kwargs(self) -> dict[str, Any]:
        """Return range_ for copy(), merged with parent kwargs."""
        return {**super()._copy_kwargs(), "range_": self.range}

    # format_value() is inherited from IRIField


class SubjectField(IRIField):
    """
    Field that maps to the RDF subject (the ?s variable in SPARQL).

    Inherits from IRIField to automatically handle IRI formatting in SPARQL
    queries. This ensures that when filtering by subject IRI, the value is
    correctly formatted with angle brackets (``<http://...>``) rather than
    as a string literal. The inheritance also enables consistent type checking:
    ``isinstance(field, IRIField)`` correctly identifies SubjectField as an
    IRI-valued field.

    Unlike other fields, SubjectField doesn't represent a predicate relationship.
    It represents the subject IRI itself, allowing you to filter and access
    the subject IRI as a named field in your model.

    This is particularly useful for modeling property relationships (e.g., rdfs:label)
    where you need to query for ``?subject <property> ?value`` patterns and filter
    by the subject.

    Usage:
        >>> class Label(Model):
        ...     rdf_type = None  # Property relationship, not a typed entity
        ...     entity_iri: Annotated[str, SubjectField()]
        ...     text: Annotated[str, LangString("rdfs:label")]
        >>>
        >>> # Filter by subject IRI - uses VALUES clause for efficiency
        >>> labels = session.query(Label).filter_by(
        ...     entity_iri="http://www.wikidata.org/entity/Q682"
        ... ).all()

    Note:
        - Each model can have at most one SubjectField
        - SubjectField does not generate a triple pattern in SPARQL
        - Values are populated from the ?s binding in query results
        - When used with filter_by(), generates a VALUES clause rather than FILTER
          for better query performance

    See Also:
        - :class:`LiteralList`, :class:`LangStringList`, :class:`IRIList` for
          collection fields that aggregate multiple values into lists
    """

    # SubjectField allows 'description' as an extra kwarg for documentation
    _ALLOWED_EXTRA_KWARGS: frozenset[str] = frozenset({"description"})

    def __init__(
        self,
        *,
        required: bool = False,
        default: Any = None,
        validator: Callable[[Any], None] | list[Callable[[Any], None]] | None = None,
        description: str | None = None,
        **kwargs: Any,
    ):
        """
        Initialize a SubjectField.

        Args:
            required: Whether this field is required (default: False)
            default: Default value if not provided (default: None)
            validator: Optional validation function(s)
            description: Human-readable description of this field
            **kwargs: Additional keyword arguments passed to parent
        """
        # SubjectField has no predicate - it IS the subject
        # Use special marker that won't conflict with real predicates
        super().__init__(
            predicate=_SUBJECT_FIELD_PREDICATE_MARKER,
            required=required,
            default=default,
            validator=validator,
            description=description,
            **kwargs,
        )
        self.is_subject_field: bool = True

    @override
    def _copy_kwargs(self) -> dict[str, Any]:
        """Return empty dict - SubjectField has no extra copy kwargs.

        Note: description is stored in extra_kwargs and handled separately.
        """
        return {}

    @override
    def copy(self) -> "SubjectField":
        """Create a deep copy of this SubjectField.

        Overrides base implementation because SubjectField's __init__
        doesn't accept a predicate parameter (it's keyword-only).
        """
        return type(self)(
            required=self.required,
            default=self.default,
            validator=self.validators,
            **self.extra_kwargs,
        )

    # format_value() is inherited from IRIField


# =============================================================================
# Collection Field Types
# =============================================================================

# Default separator for GROUP_CONCAT aggregation
# Uses three ASCII Unit Separator characters (\u001F) which are:
# 1. Invisible in output (easier to work with than visible separators)
# 2. Guaranteed not to appear in natural text (control character)
# 3. Used in sequence of three to further reduce collision risk
DEFAULT_COLLECTION_SEPARATOR: str = "\u001f\u001f\u001f"

# Separator between value and metadata within collection items
# Uses ASCII Record Separator (\u001E) to combine value + separator + metadata
# Used by LangStringList (text + lang) and TypedLiteralList (value + datatype)
# This ensures value and metadata stay paired during GROUP_CONCAT aggregation
VALUE_METADATA_SEPARATOR: str = "\u001e"

# Aliases for backwards compatibility and semantic clarity
LANG_VALUE_SEPARATOR: str = VALUE_METADATA_SEPARATOR
DATATYPE_VALUE_SEPARATOR: str = VALUE_METADATA_SEPARATOR


def _escape_sparql_string(value: str) -> str:
    """
    Escape a string value for use in SPARQL query.

    Handles backslashes and double quotes which have special meaning in
    SPARQL string literals.

    Args:
        value: String value to escape

    Returns:
        Escaped string safe for use in SPARQL literals
    """
    return value.replace("\\", "\\\\").replace('"', '\\"')


@dataclass(frozen=True)
class TypedLiteral:
    """
    A typed literal value from RDF with its XSD datatype.

    This dataclass represents an RDF literal with its associated datatype IRI.
    Values are **automatically converted** to appropriate Python types based on
    the XSD datatype during hydration.

    Type Conversion Mapping:
        - xsd:integer    → int
        - xsd:decimal    → decimal.Decimal
        - xsd:float      → float
        - xsd:double     → float
        - xsd:boolean    → bool
        - xsd:date       → datetime.date
        - xsd:dateTime   → datetime.datetime
        - Unknown types  → str (original string value preserved)

    Attributes:
        value: The converted Python value (NOT always a string - see mapping above)
        datatype: The XSD datatype IRI (e.g., "http://www.w3.org/2001/XMLSchema#integer"),
                  or None if no datatype was present in the RDF data.

    Example:
        >>> literal = TypedLiteral(42, "http://www.w3.org/2001/XMLSchema#integer")
        >>> print(literal.value)        # 42
        >>> print(type(literal.value))  # <class 'int'>
        >>> print(literal.datatype)     # "http://www.w3.org/2001/XMLSchema#integer"

        >>> # Unknown datatypes preserve the string value
        >>> custom = TypedLiteral("abc", "http://example.org/customType")
        >>> print(type(custom.value))   # <class 'str'>
    """

    value: Any
    datatype: str | None = None

    def __str__(self) -> str:
        """Return the value as string when converting to string."""
        return str(self.value)

    def __repr__(self) -> str:
        """Return a detailed representation including datatype."""
        if self.datatype:
            return f"TypedLiteral({self.value!r}, datatype={self.datatype!r})"
        return f"TypedLiteral({self.value!r})"


# Suffix for GROUP_CONCAT result variables (e.g., "labels" -> "labels_concat")
COLLECTION_CONCAT_SUFFIX: str = "_concat"

# Suffix for SAMPLE() result variables in collection queries
# Avoids SPARQL variable scope conflicts (e.g., "description" -> "description_sample")
SAMPLE_VARIABLE_SUFFIX: str = "_sample"


class CollectionFieldMixin:
    """
    Mixin marking fields that aggregate multiple RDF values into Python lists.

    Collection fields use SPARQL's GROUP_CONCAT function to concatenate all
    values of a multi-valued property into a single string, which is then
    split back into a list during hydration.

    This mixin provides the `is_collection` marker and configurable `separator`
    for GROUP_CONCAT. Subclasses inherit from both this mixin and a concrete
    field type (LiteralField, LangString, or IRIField).

    Attributes:
        is_collection: Always True, marks this as a collection field
        separator: String separator for GROUP_CONCAT (default: triple unit separator)

    Note:
        This class is not meant to be instantiated directly. Use the concrete
        collection field classes: :class:`LiteralList`, :class:`LangStringList`,
        or :class:`IRIList`.

    Example:
        >>> class EntityLabels(Model):
        ...     rdf_type = None
        ...     entity_iri: Annotated[str, SubjectField()]
        ...     labels: Annotated[list[LangLiteral], LangStringList("rdfs:label")]
        >>>
        >>> result = session.query(EntityLabels).filter_by(entity_iri="wd:Q682").one()
        >>> len(result.labels)  # All labels aggregated into a single list
        200
    """

    is_collection: bool = True
    separator: str = DEFAULT_COLLECTION_SEPARATOR

    def __init__(
        self,
        predicate: "str | PropertyPath",
        separator: str = DEFAULT_COLLECTION_SEPARATOR,
        **kwargs: Any,
    ) -> None:
        """
        Initialize a collection field.

        Args:
            predicate: The RDF predicate IRI or PropertyPath for this field
            separator: String used for GROUP_CONCAT (default: triple unit separator)
            **kwargs: Additional arguments passed to parent field class
        """
        self.separator = separator
        super().__init__(predicate, **kwargs)  # type: ignore[call-arg]

    def _copy_kwargs(self) -> dict[str, Any]:
        """Return separator for copy(), merged with parent kwargs."""
        return {**super()._copy_kwargs(), "separator": self.separator}  # type: ignore[misc]

    def get_group_concat_expr(
        self, inner_var: str, concat_var: str, escaped_sep: str
    ) -> str:
        """
        Generate the GROUP_CONCAT expression for this collection field.

        Subclasses override this to customize how values are aggregated.
        The base implementation simply concatenates raw values.

        Args:
            inner_var: Variable name for inner values (e.g., "labels_inner")
            concat_var: Variable name for concatenated result (e.g., "labels_concat")
            escaped_sep: SPARQL-escaped separator string

        Returns:
            GROUP_CONCAT expression string (without leading SELECT keyword)
        """
        return (
            f'(GROUP_CONCAT(?{inner_var}; separator="{escaped_sep}") AS ?{concat_var})'
        )

    def hydrate_values(
        self,
        values: list[str],
        convert_datatype: "Callable[[str, str], Any] | None" = None,
    ) -> list[Any]:
        """
        Convert raw string values from GROUP_CONCAT into typed Python objects.

        Subclasses override this to customize how values are parsed and converted.
        The base implementation returns values as-is (list of strings).

        Args:
            values: List of string values from split GROUP_CONCAT result
            convert_datatype: Optional callback to convert typed literals.
                Signature: (raw_value: str, datatype_iri: str) -> Any

        Returns:
            List of typed Python objects (strings, LangLiteral, TypedLiteral, etc.)
        """
        return values


class LiteralList(CollectionFieldMixin, LiteralField):
    """
    Collection field for aggregating multiple literal values into a list.

    This field uses GROUP_CONCAT in SPARQL to aggregate all values of a
    multi-valued literal property, returning them as a Python list of strings.

    Usage:
        class Document(Model):
            rdf_type = "http://example.org/Document"
            keywords: Annotated[list[str], LiteralList("http://purl.org/dc/terms/subject")]

        # Returns a single Document with all keywords in a list
        doc = session.query(Document).filter_by(iri="http://example.org/doc1").one()
        print(doc.keywords)  # ["python", "sparql", "rdf"]

    Args:
        predicate: The RDF predicate IRI for the literal values
        separator: Custom separator for GROUP_CONCAT (default: triple unit separator)
        **kwargs: Additional arguments passed to LiteralField

    Note:
        Empty collections are returned as empty lists [], not None.

    See Also:
        TypedLiteralList: Use instead when you need to preserve XSD datatype
            information (e.g., distinguishing integers from decimals). TypedLiteralList
            returns list[TypedLiteral] with both the converted Python value and
            the datatype IRI. LiteralList is simpler when you only need string values.
    """

    pass


class LangStringList(CollectionFieldMixin, LangString):
    """
    Collection field for aggregating multiple language-tagged literals into a list.

    This field uses GROUP_CONCAT in SPARQL to aggregate all values of a
    multi-valued language-tagged property (like rdfs:label), returning them
    as a Python list of :class:`LangLiteral` objects that preserve language tags.

    Usage:
        class EntityLabels(Model):
            rdf_type = None
            entity_iri: Annotated[str, SubjectField()]
            labels: Annotated[list[LangLiteral], LangStringList("rdfs:label")]

        # Returns all labels for an entity in a single list
        result = session.query(EntityLabels).filter_by(
            entity_iri="http://www.wikidata.org/entity/Q682"
        ).one()

        for label in result.labels:
            print(f"{label.text} ({label.lang})")
        # "Berlin" (en)
        # "Berlin" (de)
        # "Berlín" (es)

    Args:
        predicate: The RDF predicate IRI for the language-tagged literals
        separator: Custom separator for GROUP_CONCAT (default: triple unit separator)
        **kwargs: Additional arguments passed to LangString

    Note:
        - Language tags are preserved via a parallel GROUP_CONCAT on LANG()
        - Empty collections are returned as empty lists [], not None
    """

    def get_group_concat_expr(
        self, inner_var: str, concat_var: str, escaped_sep: str
    ) -> str:
        """
        Generate GROUP_CONCAT that combines value and language tag.

        Format: "text1<RS>lang1<SEP>text2<RS>lang2" where RS=record separator.
        This ensures value-language pairs stay together during aggregation.
        """
        escaped_lang_sep: str = _escape_sparql_string(VALUE_METADATA_SEPARATOR)
        return (
            f"(GROUP_CONCAT("
            f'CONCAT(STR(?{inner_var}), "{escaped_lang_sep}", '
            f'COALESCE(LANG(?{inner_var}), "")); '
            f'separator="{escaped_sep}") AS ?{concat_var})'
        )

    def hydrate_values(
        self,
        values: list[str],
        convert_datatype: Callable[[str, str], Any] | None = None,
    ) -> list[LangLiteral]:
        """
        Parse combined value+language format into LangLiteral objects.

        Values are in format "text<RS>lang" where RS is record separator.
        """
        result: list[LangLiteral] = []
        for combined in values:
            if VALUE_METADATA_SEPARATOR in combined:
                text: str
                lang_str: str
                text, lang_str = combined.split(VALUE_METADATA_SEPARATOR, 1)
                lang: str | None = lang_str if lang_str else None
            else:
                text = combined
                lang = None
            result.append(LangLiteral(text=text, lang=lang))
        return result


class IRIList(CollectionFieldMixin, IRIField):
    """
    Collection field for aggregating multiple IRI references into a list.

    This field uses GROUP_CONCAT in SPARQL to aggregate all values of a
    multi-valued object property or IRI field, returning them as a Python
    list of IRI strings.

    Usage:
        class Person(Model):
            rdf_type = "http://schema.org/Person"
            friends: Annotated[list[str], IRIList("http://schema.org/knows")]

        # Returns a single Person with all friend IRIs in a list
        person = session.query(Person).filter_by(iri="http://example.org/alice").one()
        print(person.friends)
        # ["http://example.org/bob", "http://example.org/carol"]

    Args:
        predicate: The RDF predicate IRI for the object property
        separator: Custom separator for GROUP_CONCAT (default: triple unit separator)
        **kwargs: Additional arguments passed to IRIField

    Note:
        Empty collections are returned as empty lists [], not None.
    """

    pass


class TypedLiteralList(CollectionFieldMixin, LiteralField):
    """
    Collection field for aggregating typed literals while preserving XSD datatypes.

    Unlike LiteralList which returns plain strings, TypedLiteralList preserves
    the XSD datatype IRI alongside each value, enabling proper Python type
    conversion (int, float, Decimal, bool, date, datetime).

    Returns list[TypedLiteral] where each item contains:
    - value: The converted Python value (int, float, Decimal, str, etc.)
    - datatype: The XSD datatype IRI or None if no datatype was present

    Usage:
        class Document(Model):
            rdf_type = "http://example.org/Document"
            page_counts: Annotated[
                list[TypedLiteral] | None,
                TypedLiteralList("http://example.org/pageCount")
            ] = None

        # Returns TypedLiteral objects with preserved datatypes
        doc = session.query(Document).filter_by(iri="http://example.org/doc1").one()
        for pc in doc.page_counts:
            print(f"{pc.value} ({pc.datatype})")
            # 42 (http://www.w3.org/2001/XMLSchema#integer)

    Args:
        predicate: The RDF predicate IRI for the typed literal values
        separator: Custom separator for GROUP_CONCAT (default: triple unit separator)
        **kwargs: Additional arguments passed to LiteralField

    See Also:
        LiteralList: Use instead when you only need string values and don't care
            about XSD datatypes. LiteralList returns list[str] which is simpler
            to work with for plain text values like tags or keywords.

    Note:
        - Empty collections are returned as empty lists [], not None
        - Values are automatically converted to Python types based on XSD datatype
        - Unknown datatypes result in string values with the datatype preserved
    """

    def get_group_concat_expr(
        self, inner_var: str, concat_var: str, escaped_sep: str
    ) -> str:
        """
        Generate GROUP_CONCAT that combines value and datatype IRI.

        Format: "value1<GS>datatype1<SEP>value2<GS>datatype2" where GS=group separator.
        This ensures value-datatype pairs stay together during aggregation.
        """
        escaped_dtype_sep: str = _escape_sparql_string(VALUE_METADATA_SEPARATOR)
        return (
            f"(GROUP_CONCAT("
            f'CONCAT(STR(?{inner_var}), "{escaped_dtype_sep}", '
            f'COALESCE(STR(DATATYPE(?{inner_var})), "")); '
            f'separator="{escaped_sep}") AS ?{concat_var})'
        )

    def hydrate_values(
        self,
        values: list[str],
        convert_datatype: Callable[[str, str], Any] | None = None,
    ) -> list[TypedLiteral]:
        """
        Parse combined value+datatype format into TypedLiteral objects.

        Values are in format "value<GS>datatype" where GS is group separator.
        Uses convert_datatype callback to convert raw values to Python types.
        """
        result: list[TypedLiteral] = []
        for combined in values:
            if VALUE_METADATA_SEPARATOR in combined:
                raw_value: str
                datatype_iri: str
                raw_value, datatype_iri = combined.split(VALUE_METADATA_SEPARATOR, 1)
                datatype: str | None = datatype_iri if datatype_iri else None
            else:
                raw_value = combined
                datatype = None

            # Convert to Python type if callback provided and datatype present
            if convert_datatype and datatype:
                converted: Any = convert_datatype(raw_value, datatype)
            else:
                converted = raw_value

            result.append(TypedLiteral(value=converted, datatype=datatype))
        return result


def is_collection_field(field_info: RDFFieldInfo) -> bool:
    """
    Check if a field is a collection field.

    Collection fields aggregate multiple RDF values into Python lists using
    GROUP_CONCAT in the generated SPARQL query.

    Args:
        field_info: RDF field information object to check

    Returns:
        True if the field is a collection field (LiteralList, LangStringList,
        or IRIList), False otherwise.

    Example:
        >>> from sparqlmojo import LiteralField, LiteralList, is_collection_field
        >>> is_collection_field(LiteralField("http://example.org/name"))
        False
        >>> is_collection_field(LiteralList("http://example.org/keywords"))
        True
    """
    return getattr(field_info, "is_collection", False)


def validate_language_tag(lang: str) -> None:
    """
    Validate BCP 47 language tag format.

    Args:
        lang: Language tag to validate (e.g., 'en', 'en-US', 'zh-Hans')

    Raises:
        ValueError: If language tag is invalid

    BCP 47 format (simplified):
        - Primary language subtag: 2-3 lowercase letters (e.g., 'en', 'fra')
        - Optional script subtag: 4 letters, first uppercase (e.g., 'Hans', 'Latn')
        - Optional region subtag: 2 uppercase letters or 3 digits (e.g., 'US', '419')

    Examples:
        - 'en' - English
        - 'en-US' - English (United States)
        - 'zh-Hans' - Chinese (Simplified script)
        - 'zh-Hans-CN' - Chinese (Simplified, China)
    """
    if not lang or not lang.strip():
        raise ValueError("Language tag cannot be empty")

    # BCP 47 simplified pattern:
    # - Primary: 2-3 lowercase letters
    # - Optional script: hyphen + 4 letters (first capital)
    # - Optional region: hyphen + 2 uppercase letters OR 3 digits
    pattern = r"^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$"

    if not re.match(pattern, lang):
        raise ValueError(
            f"Invalid language tag '{lang}'. Expected BCP 47 format "
            f"(e.g., 'en', 'en-US', 'zh-Hans', 'zh-Hans-CN')"
        )


def validate_instance(instance: "Model") -> None:
    """
    Validate all fields on a model instance.

    Checks:
    - Required fields are present and not None
    - Custom validators pass for fields that have them

    Args:
        instance: Model instance to validate

    Raises:
        ValidationError: If validation fails with detailed error messages
        RequiredFieldError: If required field is missing

    Example:
        >>> class Person(Model):
        ...     name: Annotated[str,
        ...         LiteralField("http://schema.org/name", required=True)]
        ...     age: Annotated[int,
        ...         LiteralField("http://schema.org/age",
        ...                     validator=lambda x: x >= 0)]
        >>>
        >>> person = Person(name="John", age=-5)
        >>> validate_instance(person)  # Raises ValidationError for negative age
    """
    from ..exc.exceptions import RequiredFieldError, ValidationError

    errors: list[str] = []

    # Get all RDF fields for this model class
    rdf_fields = getattr(instance.__class__, "_rdf_fields", {})

    for field_name, field in rdf_fields.items():
        # Get field value once for both checks
        field_value = getattr(instance, field_name, None)

        # Check required fields
        if field.required and field_value is None:
            errors.append(f"Required field '{field_name}' is missing")

        # Check custom validators (only if value is present)
        if field.validators and field_value is not None:
            for validator_func in field.validators:
                try:
                    validator_func(field_value)
                except ValueError as e:
                    # Expected validation errors
                    errors.append(f"Validation failed for '{field_name}': {e}")
                except Exception as e:
                    # Unexpected errors from buggy validators - re-raise with context
                    from ..exc.exceptions import ValidationError

                    raise ValidationError(
                        f"Validator function for '{field_name}' raised "
                        f"unexpected error: {type(e).__name__}: {e}"
                    ) from e

    if errors:
        # Use RequiredFieldError for single required field error
        if len(errors) == 1 and "Required field" in errors[0]:
            raise RequiredFieldError(errors[0])
        # Use ValidationError for multiple errors or validator errors
        else:
            raise ValidationError("\n".join(errors))


def _register_subject_field(
    cls_name: str, field_name: str, subject_field_name: str | None
) -> str:
    """
    Register a SubjectField and validate uniqueness.

    Args:
        cls_name: Name of the model class being defined
        field_name: Name of the field being registered
        subject_field_name: Name of previously registered SubjectField, if any

    Returns:
        The field_name (for assignment to subject_field_name variable)

    Raises:
        ValueError: If multiple SubjectFields are declared
    """
    if subject_field_name is not None:
        raise ValueError(
            f"Model {cls_name} cannot have multiple "
            f"SubjectFields: {subject_field_name}, {field_name}"
        )
    return field_name


class ModelMetaclass(PydanticModelMetaclass):
    """
    Custom metaclass that extends Pydantic's ModelMetaclass.

    Enables field-level filtering syntax: Person.name == "Alice"

    By intercepting class attribute access, this metaclass returns FieldFilter
    objects for RDF fields, allowing intuitive filter expressions like:
        query.filter(Person.name == "Alice")
        query.filter(Product.price > 100)
    """

    def __getattr__(cls, name: str) -> "FieldFilter":  # noqa: N805
        """
        Intercept attribute access on Model classes.

        Returns FieldFilter for RDF fields, enabling syntax like:
            Person.name == "Alice"

        For collection fields (LiteralList, IRIList, etc.), returns
        CollectionFieldFilter which has contains() for membership checks.

        Args:
            name: Attribute name being accessed

        Returns:
            FieldFilter for regular fields, CollectionFieldFilter for collections

        Raises:
            AttributeError: If attribute is not an RDF field
        """
        # Check if this is an RDF field
        rdf_fields: dict[str, RDFFieldInfo] = getattr(cls, "_rdf_fields", {})
        if name in rdf_fields:
            field_info: RDFFieldInfo = rdf_fields[name]

            # Return appropriate filter type based on field type
            if is_collection_field(field_info):
                from .filtering import CollectionFieldFilter

                return CollectionFieldFilter(name)
            else:
                from .filtering import FieldFilter

                return FieldFilter(name)

        # Not an RDF field - raise AttributeError
        raise AttributeError(f"type object '{cls.__name__}' has no attribute '{name}'")


class Model(BaseModel, metaclass=ModelMetaclass):
    """
    Base class for RDF models with Pydantic validation.

    Models should declare:
    - rdf_type: The RDF class IRI (as ClassVar)
    - Fields using LiteralField, IRIField, or ObjectPropertyField with type annotations

    Features:
        - Automatic IRI validation (can be disabled with validate_iris = False)
        - Dirty tracking for field modifications
        - SPARQL triple pattern generation
        - Pydantic type validation and coercion
        - Field-level validation with required fields and custom validators

    Field Validation:
        Fields support required enforcement and custom validators.
        See module-level documentation for detailed validation examples including:
        - Required field enforcement
        - Custom validator functions
        - Validator composition (multiple validators per field)
        - Session integration (automatic validation on add/update)

    Raises:
        UnknownFieldError: When unknown fields are provided
        IRIError: When IRI validation fails (if validate_iris is True)
        RequiredFieldError: When required fields are missing
        ValidationError: When custom validators fail

    Example:
        >>> class Person(Model):
        ...     rdf_type = "http://schema.org/Person"
        ...     name: Annotated[str, LiteralField("http://schema.org/name")]
        ...     age: Annotated[int, LiteralField("http://schema.org/age")]

        >>> # Valid IRI - works fine
        >>> person = Person(iri="http://example.org/john", name="John", age=30)

        >>> # Invalid IRI - raises IRIError
        >>> try:
        ...     person = Person(iri="invalid iri with spaces", name="John", age=30)
        ... except IRIError as e:
        ...     print(f"Validation failed: {e}")

        >>> # Disable validation for performance
        >>> class FastPerson(Model):
        ...     rdf_type = "http://schema.org/Person"
        ...     name: Annotated[str, LiteralField("http://schema.org/name")]
        ...     validate_iris = False  # Disable IRI validation

        >>> person = FastPerson(iri="any string works now", name="John")
    """

    model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")

    # Class variables
    validate_iris: ClassVar[bool] = True
    rdf_type: ClassVar[str | None] = None
    _rdf_fields: ClassVar[dict[str, RDFFieldInfo]] = {}
    _subject_field_name: ClassVar[str | None] = None

    iri: str | None = None

    @classmethod
    def _setup_pydantic_field(cls, field_name: str, field_info: RDFFieldInfo) -> None:
        """
        Set up Pydantic Field attribute for a field.

        Args:
            field_name: Name of the field to set up
            field_info: RDF field information containing default and kwargs

        This ensures proper default values are set instead of FieldFilter objects.
        """
        setattr(
            cls,
            field_name,
            Field(
                default=field_info.default,
                **field_info.extra_kwargs,
            ),
        )

    @classmethod
    def _inherit_parent_fields(
        cls,
        rdf_fields: dict[str, RDFFieldInfo],
        subject_field_name: str | None,
        annotations: dict[str, Any],
    ) -> tuple[dict[str, RDFFieldInfo], str | None]:
        """
        Inherit RDF fields from parent Model classes.

        Args:
            rdf_fields: Dictionary to populate with inherited fields
            subject_field_name: Current SubjectField name (if any)
            annotations: Annotations defined on this class (to avoid
                inheriting overridden fields)

        Returns:
            Tuple of (updated rdf_fields dict, updated subject_field_name)

        This method walks the MRO to collect fields from parent Model classes,
        avoiding duplication and respecting field overrides in child classes.
        """
        for base_class in cls.__mro__:
            # Skip current class (process separately) and base Model class
            if base_class == cls or base_class == Model:
                continue

            # Check if base class is a Model subclass and has RDF fields
            if issubclass(base_class, Model) and hasattr(base_class, "_rdf_fields"):
                # Inherit all RDF fields from parent
                for field_name, field_info in base_class._rdf_fields.items():
                    # Only inherit if not already processed and not overridden in child
                    if field_name not in rdf_fields and field_name not in annotations:
                        # Create a copy of the field info to avoid mutation issues
                        field_copy = field_info.copy()
                        field_copy.bind_name(field_name)
                        rdf_fields[field_name] = field_copy

                        # Set up Pydantic Field attribute for inherited field
                        cls._setup_pydantic_field(field_name, field_copy)

                        # Check if this is a SubjectField
                        if isinstance(field_copy, SubjectField):
                            subject_field_name = _register_subject_field(
                                cls.__name__, field_name, subject_field_name
                            )

        return rdf_fields, subject_field_name

    @classmethod
    def _process_class_annotations(
        cls,
        rdf_fields: dict[str, RDFFieldInfo],
        subject_field_name: str | None,
        annotations: dict[str, Any],
    ) -> tuple[dict[str, RDFFieldInfo], str | None]:
        """
        Process fields defined directly on this class via annotations.

        Args:
            rdf_fields: Dictionary to populate with class fields
            subject_field_name: Current SubjectField name (if any)
            annotations: Type annotations defined on this class

        Returns:
            Tuple of (updated rdf_fields dict, updated subject_field_name)

        This method extracts RDFFieldInfo from Annotated type hints and
        registers them with the appropriate metadata.
        """
        for field_name in annotations:
            # Check if field descriptor is in Annotated type metadata
            annotation = annotations[field_name]
            if get_origin(annotation) is Annotated:
                # Extract metadata from Annotated type
                metadata = get_args(annotation)
                if len(metadata) >= 2:
                    # The second argument should be the field descriptor
                    field_descriptor = metadata[1]
                    if isinstance(field_descriptor, RDFFieldInfo):
                        field_descriptor.bind_name(field_name)

                        # Check if this is a SubjectField
                        if isinstance(field_descriptor, SubjectField):
                            subject_field_name = _register_subject_field(
                                cls.__name__, field_name, subject_field_name
                            )

                        rdf_fields[field_name] = field_descriptor
                        cls._setup_pydantic_field(field_name, field_descriptor)

        return rdf_fields, subject_field_name

    def __init_subclass__(cls, **kwargs: Any) -> None:
        """
        Extract RDF field metadata when subclass is created.

        This method processes both inherited fields from parent Model classes
        and fields defined directly on the subclass. Inherited fields are copied
        to avoid mutation issues, and Pydantic Field descriptors are set up to
        ensure proper default values.

        Field Inheritance Behavior:
        - Fields from parent Model classes are inherited automatically
        - Child classes can override parent fields by redefining them
        - Inherited fields maintain their predicates, defaults, and validators
        - SubjectFields are inherited and validated (only one per class hierarchy)

        Example:
            >>> class Person(Model):
            ...     name: Annotated[str | None, LiteralField(..., default=None)]
            >>>
            >>> class Employee(Person):
            ...     employee_id: Annotated[str | None, LiteralField(..., default=None)]
            >>>
            >>> emp = Employee(name="Alice")
            >>> emp.name  # "Alice" (set value)
            >>> emp.employee_id  # None (inherited default)
        """
        super().__init_subclass__(**kwargs)

        rdf_fields: dict[str, RDFFieldInfo] = {}
        subject_field_name: str | None = None
        annotations = getattr(cls, "__annotations__", {})

        # Inherit fields from parent classes
        rdf_fields, subject_field_name = cls._inherit_parent_fields(
            rdf_fields, subject_field_name, annotations
        )

        # Process fields defined on this class
        rdf_fields, subject_field_name = cls._process_class_annotations(
            rdf_fields, subject_field_name, annotations
        )

        cls._rdf_fields = rdf_fields
        cls._subject_field_name = subject_field_name

    @model_validator(mode="before")
    @classmethod
    def check_unknown_fields(cls, data: Any) -> Any:
        """Validate that all provided fields are known RDF fields."""
        if isinstance(data, dict):
            rdf_fields = getattr(cls, "_rdf_fields", {})
            for field_name in data.keys():
                if field_name not in rdf_fields and field_name != "iri":
                    raise UnknownFieldError(cls, field_name)
        return data

    @field_validator("iri")
    @classmethod
    def validate_iri_field(cls, v: str | None) -> str | None:
        """Validate IRI format if validation is enabled."""
        if v is not None and cls.validate_iris:
            validate_iri(v)
        return v

    def __init__(self, **data: Any) -> None:
        """Initialize model instance with dirty tracking."""
        # Pydantic validation happens automatically before this
        super().__init__(**data)

        # Initialize dirty tracking
        self._original_data: dict[str, Any] = {}
        self._dirty_fields: set[str] = set()

        # Store original state of all fields
        rdf_fields = getattr(self.__class__, "_rdf_fields", {})
        for field_name in rdf_fields.keys():
            if hasattr(self, field_name):
                # Get value from __dict__ to avoid triggering metaclass
                value = self.__dict__.get(field_name)
                self._original_data[field_name] = value
            else:
                # Field not set on instance, use default from field info
                field_info = rdf_fields[field_name]
                self._original_data[field_name] = field_info.default

    @classmethod
    def _get_field_filter(cls, field_name: str) -> "FieldFilter":
        """
        Get a FieldFilter object for the specified field.

        This method enables field-level filtering syntax like:
            Person.name == "Alice"

        Args:
            field_name: Name of the field to filter on

        Returns:
            FieldFilter object for the specified field

        Raises:
            UnknownFieldError: If the field doesn't exist in the model
        """
        from .filtering import FieldFilter

        # Check if field exists in model
        rdf_fields: dict[str, RDFFieldInfo] = getattr(cls, "_rdf_fields", {})
        if field_name not in rdf_fields:
            raise UnknownFieldError(cls, field_name)

        return FieldFilter(field_name)

    @classmethod
    def _field_accessor(cls, name: str) -> "FieldFilter":
        """
        Class method to access fields for filtering.

        This method enables the intuitive filtering syntax:
            Person.name == "Alice"

        Args:
            name: Attribute name being accessed

        Returns:
            FieldFilter object for the specified field

        Raises:
            AttributeError: If the attribute is not a known RDF field
        """
        try:
            return cls._get_field_filter(name)
        except UnknownFieldError:
            # If not a field, raise standard AttributeError
            raise AttributeError(
                f"type object '{cls.__name__}' has no attribute '{name}'"
            )

    def __setattr__(self, key: str, value: Any) -> None:
        """
        Override attribute setting to track changes for dirty tracking.

        Args:
            key: Attribute name
            value: New value
        """
        # Handle special attributes and private attributes normally
        if key in ("_original_data", "_dirty_fields") or key.startswith("_"):
            object.__setattr__(self, key, value)
            return

        # Check if this is an RDF field
        rdf_fields = getattr(self.__class__, "_rdf_fields", {})
        if key in rdf_fields:
            # Check if value has actually changed
            current_value = getattr(self, key, None)
            if current_value != value:
                # Mark as dirty if not already marked
                if key not in self._dirty_fields:
                    self._dirty_fields.add(key)
            # Set the value
            object.__setattr__(self, key, value)
        else:
            # For non-RDF fields, use normal attribute setting
            object.__setattr__(self, key, value)

    def mark_clean(self) -> None:
        """Mark all fields as clean (not modified)."""
        # Update original data to current values
        rdf_fields = getattr(self.__class__, "_rdf_fields", {})
        for field_name in rdf_fields.keys():
            if hasattr(self, field_name):
                self._original_data[field_name] = getattr(self, field_name)
        self._dirty_fields.clear()

    def is_dirty(self) -> bool:
        """Check if any fields have been modified."""
        return len(self._dirty_fields) > 0

    def get_changes(self) -> dict[str, tuple[Any, Any]]:
        """Get dictionary of changed fields with (old, new) values."""
        changes: dict[str, tuple[Any, Any]] = {}
        for field_name in self._dirty_fields:
            old_value = self._original_data.get(field_name)
            new_value = getattr(self, field_name, None)
            changes[field_name] = (old_value, new_value)
        return changes

    def triple_patterns(
        self, subject_var: str = "?s", session: "Session | None" = None
    ) -> list[str]:
        """
        Generate SPARQL triple patterns for this model instance.

        Args:
            subject_var: The SPARQL variable to use for the subject
            session: Optional session with prefix registry for IRI expansion

        Returns:
            List of SPARQL triple pattern strings

        Raises:
            AttributeError: If required RDF fields are missing from the model class
        """
        clauses: list[str] = []

        # Type triple
        if self.rdf_type:
            cls_iri: str = (
                self.rdf_type
                if isinstance(self.rdf_type, str)
                else self.rdf_type.predicate
            )
            # Expand short-form IRI if session is provided
            if session:
                cls_iri = self.expand_iri(cls_iri, session)
            clauses.append(f"{subject_var} a {cls_iri} .")

        # Field triples
        rdf_fields = getattr(self.__class__, "_rdf_fields", {})
        for field_name, field_info in rdf_fields.items():
            value = getattr(self, field_name, None)
            var: str = f"?{field_name}"

            # Expand predicate IRI if session is provided
            predicate: str = field_info.predicate
            if session:
                predicate = self.expand_iri(predicate, session)

            if value is not None:
                # Use polymorphic generate_triples() - handles both single and
                # multi-value fields (e.g., MultiLangString returns multiple triples)
                triples: list[str] = field_info.generate_triples(
                    subject_var, predicate, value
                )
                clauses.extend(triples)
            else:
                # Optional fetch pattern
                clauses.append(f"OPTIONAL {{ {subject_var} {predicate} {var} . }}")

        return clauses

    def expand_iri(self, iri: str, session: "Session | None" = None) -> str:
        """Expand a short-form IRI using session's prefix registry if available.

        Args:
            iri: The IRI to expand (may be short-form like 'schema:Person')
            session: Optional session with prefix registry

        Returns:
            Expanded IRI (full IRI)
        """
        if not iri:
            return iri

        # If session is provided and has expand_iri method, use it
        if session and hasattr(session, "expand_iri"):
            try:
                return session.expand_iri(iri)
            except (ValueError, AttributeError):
                # If expansion fails, return original IRI
                return iri

        # No session or expansion failed, return original
        return iri

    @classmethod
    def subject_var(cls) -> str:
        """Return the default SPARQL subject variable."""
        return "?s"
