"""Declarative base classes for typed ECM folder, register, and document models.

Usage example::

    class InvoiceFolder(ECMFolderModel):
        _internal_name_ = "InvoiceFolder"

        Title: ECMField[str]
        Year:  ECMField[int]
        Status: ECMField[str]

    # Query — field comparison returns ECMCondition, fully typed end to end.
    results = ecm.select(InvoiceFolder).where(
        InvoiceFolder.Title == "Rechnung 2024",
        InvoiceFolder.Status != "gesperrt",
    ).execute()

    # Typed field access on result instances:
    for folder in results:
        print(folder.system.id, folder.Title, folder.Year)
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from datetime import date, datetime
from enum import Enum
from typing import Any, ClassVar, dataclass_transform, get_args, get_origin

from ecmind_blue_client import SystemFields
from ecmind_blue_client.ecm.model._ecm_model_field import (
    ECMCondition,
    ECMConditionGroup,
    ECMField,
    ECMTableField,
    ECMTableList,
)
from ecmind_blue_client.ecm.parser import (
    DmsContentDocumentVariant,
    DmsContentObject,
    DmsContentObjectRowField,
    DmsContentObjectType,
    DmsContentRow,
)


@dataclass
class ECMModelArchive:
    """Archive descriptor returned as part of an ECM object's metadata."""

    type_id: int
    name: str
    osguid: str


def _serialize_value(value: Any) -> Any:
    """Convert a field value to a JSON-serializable Python type.

    Args:
        value: The raw field value from the model.

    Returns:
        The value unchanged, or an ISO-format string for ``date``/``datetime`` instances.
    """
    if isinstance(value, datetime):
        return value.isoformat()
    if isinstance(value, date):
        return value.isoformat()
    return value


@dataclass
class ECMModelRights:
    """Access rights for an ECM object, populated when ``.rights()`` is requested on a query."""

    insert: bool
    edit_metadata: bool
    read_file: bool
    edit_file: bool
    delete: bool


@dataclass
class ECMModelBaseParams:
    """Audit metadata for an ECM object, populated when ``.base_params()`` is requested on a query."""

    creator: str | None
    creation_date: date | None
    owner: str | None
    modifier: str | None
    modified_date: datetime | None
    links_count: int | None
    text_notice_count: int | None


@dataclass
class ECMModelFileProperties:
    """File metadata for a document object.

    Populated when ``.file_properties()`` is requested on the query.
    Only available on ``ECMDocumentModel`` instances.
    """

    count: int
    size: int
    extension: str
    mimetype: str
    mimetypegroup: str
    iconid: int
    documentpagecount: int


@dataclass
class ECMModelDocumentVariant:
    """A single node in the W-module document version tree.

    Populated when ``.variants()`` is requested on the query.
    Only available on ``ECMDocumentModel`` instances via ``obj.system.variants``.

    Attributes:
        doc_id: Numeric document ID of this variant.
        doc_ver: Version label string (e.g. ``"1.0"``, ``"2.1"``).
        is_active: ``True`` if this is the currently active version.
        doc_parent: Document ID of the parent variant, or ``None`` if this is the root.
        children: Direct child variants in the version tree.
    """

    doc_id: int
    doc_ver: str
    is_active: bool
    doc_parent: int | None
    children: list["ECMModelDocumentVariant"]


class ECMObjectType(Enum):
    """ECM object category — folder, register, or document."""

    FOLDER = "FOLDER"
    REGISTER = "REGISTER"
    DOCUMENT = "DOCUMENT"


class ECMModelSystem:
    """System metadata namespace for ECM model instances.

    Accessed via ``model.system``. Exposes server-populated metadata that is not
    part of the user-defined index fields: object ID, type name, access rights,
    audit parameters, and change-tracking helpers.
    """

    __slots__ = ("_model",)

    def __init__(self, model: "_ECMModelBase") -> None:
        self._model = model

    @property
    def id(self) -> int:
        """The numeric ECM object ID.

        Raises:
            ValueError: If the object has not been saved to the server yet.
        """
        if self._model._id_ is None:  # pylint: disable=protected-access
            raise ValueError("Object id is not available if object not saved")
        return self._model._id_  # pylint: disable=protected-access

    @property
    def name(self) -> str | None:
        """The display name of this object's type."""
        return self._model._name_  # pylint: disable=protected-access

    @property
    def rights(self) -> ECMModelRights:
        """Access rights for this object.

        Raises:
            ValueError: If not requested via ``.rights()`` on the query.
        """
        result = self._model._rights_  # pylint: disable=protected-access
        if result is None:
            raise ValueError(
                "Rights were not included in the server response. "
                "Request them by calling .rights() on the query: "
                "ecm.select(Model).rights().where(...).stream()"
            )
        return result

    @property
    def base_params(self) -> ECMModelBaseParams:
        """Audit metadata (creator, timestamps, etc.) for this object.

        Raises:
            ValueError: If not requested via ``.base_params()`` on the query.
        """
        result = self._model._base_params_  # pylint: disable=protected-access
        if result is None:
            raise ValueError(
                "Base params were not included in the server response. "
                "Request them by calling .base_params() on the query: "
                "ecm.select(Model).base_params().where(...).stream()"
            )
        return result

    @property
    def is_modified(self) -> bool:
        """True if any field or table field has changed since loading from the server."""
        m = self._model
        if m._raw_fields_ != m._original_fields_:  # pylint: disable=protected-access
            return True
        for name, rows in m._raw_table_fields_.items():  # pylint: disable=protected-access
            if len(rows) != m._original_table_row_counts_.get(name, 0):  # pylint: disable=protected-access
                return True
            if any(row.is_modified for row in rows if isinstance(row, ECMTableRowModel)):
                return True
        return False

    @property
    def has_removed_table_rows(self) -> bool:
        """True if any table field has fewer rows than when the object was loaded from the server.

        Used to determine whether ``REPLACETABLEFIELDS=1`` must be set on an update request,
        since the server only appends rows by default and cannot delete individual rows without
        a full replacement.
        """
        m = self._model
        return any(
            len(rows) < m._original_table_row_counts_.get(name, 0)  # pylint: disable=protected-access
            for name, rows in m._raw_table_fields_.items()  # pylint: disable=protected-access
        )

    @property
    def modified_fields(self) -> dict[str, Any]:
        """Current values of all scalar fields that differ from their server-loaded value."""
        m = self._model
        return {  # pylint: disable=protected-access
            name: value
            for name, value in m._raw_fields_.items()
            if value != m._original_fields_.get(name)
        }

    @property
    def modified_table_fields(self) -> dict[str, list]:
        """Table fields where rows have been added, removed, or modified since loading."""
        m = self._model
        result: dict[str, list] = {}
        for name, rows in m._raw_table_fields_.items():  # pylint: disable=protected-access
            original_count = m._original_table_row_counts_.get(name, 0)  # pylint: disable=protected-access
            row_changed = any(row.is_modified for row in rows if isinstance(row, ECMTableRowModel))
            if len(rows) != original_count or row_changed:
                result[name] = rows
        return result

    @property
    def owner_guid(self) -> str | None:
        """The GUID of the owner of this object (``OBJECT_USERGUID``)."""
        return self._model._raw_fields_.get("OBJECT_USERGUID")  # pylint: disable=protected-access

    @property
    def deleted_at(self) -> datetime | None:
        """The date and time at which this object was deleted (``OBJECT_DELETED``).

        The raw value is a Unix timestamp (int). Returns ``None`` if the object has not been deleted.
        """
        raw = self._model._raw_fields_.get("OBJECT_DELETED")  # pylint: disable=protected-access
        if not raw:
            return None
        return datetime.fromtimestamp(int(raw))

    @property
    def creation_date(self) -> date | None:
        """The date on which this object was created (``OBJECT_CRDATE``)."""
        return self._model._raw_fields_.get("OBJECT_CRDATE")  # pylint: disable=protected-access

    @property
    def creator(self) -> str | None:
        """The username of the user who created this object (``OBJECT_CRID``)."""
        return self._model._raw_fields_.get("OBJECT_CRID")  # pylint: disable=protected-access

    @property
    def last_modifier(self) -> str | None:
        """The username of the user who last modified this object (``OBJECT_MODIFYUSER``)."""
        return self._model._raw_fields_.get("OBJECT_MODIFYUSER")  # pylint: disable=protected-access

    @property
    def last_modified(self) -> datetime | None:
        """The date and time of the last modification to this object (``OBJECT_MODIFYTIME``)."""
        return self._model._raw_fields_.get("OBJECT_MODIFYTIME")  # pylint: disable=protected-access

    @property
    def creation_time(self) -> datetime | None:
        """The date and time at which this object was created (``OBJECT_TIME``)."""
        return self._model._raw_fields_.get("OBJECT_TIME")  # pylint: disable=protected-access

    def is_field_modified(self, field: "str | ECMField") -> bool:
        """Return ``True`` if the field value has changed since loading.

        Args:
            field: ECM internal field name or ``ECMField`` descriptor.
        """
        m = self._model
        name = field.internal_name if isinstance(field, ECMField) else field  # pylint: disable=protected-access
        return m._raw_fields_.get(name) != m._original_fields_.get(name)  # pylint: disable=protected-access


class ECMRegisterModelSystem(ECMModelSystem):
    """System metadata namespace for ``ECMRegisterModel`` instances.

    Extends ``ECMModelSystem`` with register-specific server-populated properties.
    """

    @property
    def folder_id(self) -> int | None:
        """The ID of the folder this register belongs to (``REG_STAID``)."""
        return self._model._raw_fields_.get("REG_STAID")  # pylint: disable=protected-access

    @property
    def parent_register_id(self) -> int | None:
        """The ID of the parent register, or ``None`` if this is a root register (``REG_PARID``)."""
        val = self._model._raw_fields_.get("REG_PARID")  # pylint: disable=protected-access
        return val if val else None


class ECMDocumentModelSystem(ECMModelSystem):
    """System metadata namespace for ``ECMDocumentModel`` instances.

    Extends ``ECMModelSystem`` with document-specific server-populated properties.
    """

    @property
    def folder_id(self) -> int | None:
        """The ID of the folder this document belongs to (``SDSTA_ID``)."""
        return self._model._raw_fields_.get("SDSTA_ID")  # pylint: disable=protected-access

    @property
    def register_id(self) -> int | None:
        """The ID of the register this document is stored in (``SDREG_ID``)."""
        return self._model._raw_fields_.get("SDREG_ID")  # pylint: disable=protected-access

    @property
    def register_type_id(self) -> int | None:
        """The object type ID of the register this document belongs to (``SDREG_TYPE``)."""
        return self._model._raw_fields_.get("SDREG_TYPE")  # pylint: disable=protected-access

    @property
    def foreign_id(self) -> str | None:
        """The external (foreign) ID string for cross-system referencing (``OBJECT_FOREIGNID``)."""
        return self._model._raw_fields_.get("OBJECT_FOREIGNID")  # pylint: disable=protected-access

    @property
    def lock_user(self) -> str | None:
        """The name of the user who currently holds a lock on this document (``OBJECT_LOCKUSER``).

        Returns ``None`` if the document is not locked or the field was not requested.
        """
        return self._model._raw_fields_.get("OBJECT_LOCKUSER")  # pylint: disable=protected-access

    @property
    def archivist(self) -> str | None:
        """The username of the archivist responsible for this document (``OBJECT_AVID``)."""
        return self._model._raw_fields_.get("OBJECT_AVID")  # pylint: disable=protected-access

    @property
    def archiving_date(self) -> date | None:
        """The date on which this document was archived (``OBJECT_AVDATE``)."""
        return self._model._raw_fields_.get("OBJECT_AVDATE")  # pylint: disable=protected-access

    @property
    def file_properties(self) -> ECMModelFileProperties:
        """File metadata (count, size, MIME type, page count) for this document.

        Raises:
            ValueError: If not requested via ``.file_properties()`` on the query.
        """
        result = self._model._file_properties_  # pylint: disable=protected-access
        if result is None:
            raise ValueError(
                "File properties were not included in the server response. "
                "Request them by calling .file_properties() on the query: "
                "ecm.select(Model).file_properties().where(...).stream()"
            )
        return result

    @property
    def variants(self) -> list[ECMModelDocumentVariant]:
        """Document variants (W-module version tree) for this document.

        Returns:
            List of ``DmsContentDocumentVariant`` instances; may be empty if the document
            has no variants.

        Raises:
            ValueError: If not requested via ``.variants()`` on the query.
        """
        result = self._model._variants_  # pylint: disable=protected-access
        if result is None:
            raise ValueError(
                "Variants were not included in the server response. "
                "Request them by calling .variants() on the query: "
                "ecm.select(Model).variants().where(...).stream()"
            )
        return result


@dataclass_transform(eq_default=False, kw_only_default=True, field_specifiers=(ECMField, ECMTableField))
class ECMModelMeta(type):
    """Metaclass for ECM model classes.

    Converts ``ECMField[T]`` and plain-type annotations into ``ECMField`` descriptors.
    Enables class-level item access for undeclared fields: ``MyFolder["dynamicField"] == "value"``.
    The ``@dataclass_transform`` decorator instructs type checkers to synthesise a typed
    ``__init__`` from field annotations, enabling IDE code completion on model subclasses.
    """

    def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict, **kwargs) -> type:
        annotations: dict[str, Any] = namespace.get("__annotations__", {})
        for attr_name, attr_type in annotations.items():
            # Skip private names, ClassVar annotations, and explicitly assigned values.
            if attr_name.startswith("_"):
                continue
            if attr_name in namespace:
                # For ECMTableField instances without a row type (e.g. declared as
                # ``ECMTableField(default_factory=list)`` for @dataclass_transform compatibility),
                # back-fill _row_type from the generic annotation so the descriptor knows
                # which row class to instantiate when parsing server responses.
                existing = namespace[attr_name]
                if isinstance(existing, ECMTableField) and existing._row_type is None:  # pylint: disable=protected-access
                    origin = get_origin(attr_type)
                    if origin is ECMTableField or attr_type is ECMTableField:
                        args = get_args(attr_type)
                        existing._row_type = args[0] if args else None  # pylint: disable=protected-access
                continue
            origin = get_origin(attr_type)
            if origin is ECMField or attr_type is ECMField:
                # ECMField[T] annotation: extract T and create descriptor.
                args = get_args(attr_type)
                field_type: type = args[0] if args else object
                namespace[attr_name] = ECMField(field_type)
            elif origin is ECMTableField or attr_type is ECMTableField:
                # ECMTableField[RowT] annotation: extract RowT and create descriptor.
                args = get_args(attr_type)
                row_type = args[0] if args else None
                namespace[attr_name] = ECMTableField(row_type)
            elif isinstance(attr_type, type):
                # Plain type annotation like str / int — wrap for runtime compat.
                namespace[attr_name] = ECMField(attr_type)
            # Other annotation forms (string forward refs, etc.) are left as-is.
        return super().__new__(mcs, name, bases, namespace, **kwargs)

    def __getitem__(cls, internal_name: str) -> ECMField:
        """Return an ``ECMField`` proxy for a field not declared as a class attribute.

        Args:
            internal_name: The ECM internal field name.

        Returns:
            An ``ECMField`` for building query conditions.
        """
        identifier = getattr(cls, "_internal_name_", None) or getattr(cls, "_type_id_", None)
        return ECMField(str, internal_name, identifier)


class ECMTableRowModel(metaclass=ECMModelMeta):
    """Base class for typed ECM table row definitions.

    Declare column attributes using ``ECMField[T]`` annotations. Row instances
    are created automatically when loading a model that contains an ``ECMTableField``.
    """

    _row_id_: int | None = None
    _raw_fields_: dict[str, Any] = {}
    _original_fields_: dict[str, Any] = {}

    @classmethod
    def _from_parser_row(cls, row: dict[str, DmsContentObjectRowField]) -> ECMTableRowModel:
        """Create a row instance from a HOL parser row dict.

        Args:
            row: ``{column_name: DmsContentObjectRowField}`` from the parser.

        Returns:
            Row instance with field values and ``row_id`` populated.
        """
        instance = cls.__new__(cls)
        raw: dict[str, Any] = {}
        row_id: int | None = None
        for col_name, row_field in row.items():
            raw[col_name] = row_field.value()
            row_id = row_field.row_id
        instance._row_id_ = row_id
        instance._raw_fields_ = raw
        instance._original_fields_ = dict(raw)
        return instance

    @classmethod
    def _from_lol_raw_dict(cls, raw: dict[str, str]) -> ECMTableRowModel:
        """Create a row instance from a LOL table field row dict.

        Values are raw strings exactly as returned by the server — no type conversion is
        applied.  ``row_id`` is not available in LOL table rows and is set to ``None``.

        Args:
            raw: ``{column_name: raw_string_value}`` from
                ``DmsContentLolRowTableField.rows``.

        Returns:
            Row instance with ``_raw_fields_`` populated from the raw dict.
        """
        instance = cls.__new__(cls)
        instance._row_id_ = None
        instance._raw_fields_ = dict(raw)
        instance._original_fields_ = dict(raw)
        return instance

    @property
    def row_id(self) -> int | None:
        """The numeric row ID assigned by the server."""
        return self._row_id_

    @property
    def is_modified(self) -> bool:
        """True if any column value has changed since the row was loaded."""
        return self._raw_fields_ != self._original_fields_

    def is_field_modified(self, field: "str | ECMField") -> bool:
        """Return ``True`` if the column value has changed since loading.

        Args:
            field: Column name or ``ECMField`` descriptor.
        """
        name = field.internal_name if isinstance(field, ECMField) else field
        return self._raw_fields_.get(name) != self._original_fields_.get(name)

    def to_dict(self) -> dict[str, Any]:
        """Return all column values as a plain dictionary.

        ``date`` values are converted to ISO-format strings (``YYYY-MM-DD``).
        ``datetime`` values are converted to ISO-format strings (``YYYY-MM-DDTHH:MM:SS``).

        Returns:
            Dict mapping ECM internal column names to their Python values.
        """
        return {k: _serialize_value(v) for k, v in self._raw_fields_.items()}

    def __getitem__(self, name: str) -> Any:
        return self._raw_fields_.get(name)

    def __setitem__(self, name: str, value: Any) -> None:
        self._raw_fields_[name] = value

    def __str__(self) -> str:
        return "| " + " | ".join(str(v) for v in self._raw_fields_.values()) + " |"

    def __repr__(self) -> str:
        return f"<{type(self).__name__} row_id={self._row_id_} fields={list(self._raw_fields_.keys())}>"


def _variant_from_parsed(v: "DmsContentDocumentVariant") -> ECMModelDocumentVariant:
    """Recursively convert a parser ``DmsContentDocumentVariant`` into ``ECMModelDocumentVariant``.

    Args:
        v: Parser-layer variant node.

    Returns:
        Model-layer dataclass with the same data and recursively converted children.
    """
    parent_id = v.doc_parent
    return ECMModelDocumentVariant(
        doc_id=v.doc_id,
        doc_ver=v.doc_ver,
        is_active=v.is_active,
        doc_parent=parent_id if parent_id != -1 else None,
        children=[_variant_from_parsed(child) for child in v.variants()],
    )


class _ECMModelBase(metaclass=ECMModelMeta):
    """Shared base for all typed ECM model classes.

    Provides field storage, change tracking, and common properties (``id``, ``name``,
    ``rights``, ``base_params``). Subclasses must set ``_internal_name_`` or ``_type_id_``.
    """

    _internal_name_: ClassVar[str | None] = None
    _type_id_: ClassVar[int | None] = None
    _ecm_system_fields_: ClassVar[tuple[SystemFields, ...]] = (
        SystemFields.OBJECT_CRDATE,
        SystemFields.OBJECT_CRID,
        SystemFields.OBJECT_MODIFYUSER,
        SystemFields.OBJECT_MODIFYTIME,
        SystemFields.OBJECT_TIME,
        SystemFields.OBJECT_USERGUID,
        SystemFields.OBJECT_DELETED,
    )
    _id_: int | None = None
    _name_: str | None = None
    _rights_: ECMModelRights | None = None
    _base_params_: ECMModelBaseParams | None = None
    _file_properties_: ECMModelFileProperties | None = None
    _variants_: list[ECMModelDocumentVariant] | None = None
    _raw_fields_: dict[str, Any] = {}
    _original_fields_: dict[str, Any] = {}
    _raw_table_fields_: dict[str, list] = {}
    _original_table_row_counts_: dict[str, int] = {}
    extra_fields: dict[str, Any] | None = None
    extra_table_fields: dict[str, list] | None = None

    def __init__(self, **kwargs: Any) -> None:
        """Initialise a new model instance with field values as keyword arguments.

        Scalar fields (``ECMField``) and undeclared fields are stored in ``_raw_fields_``.
        Table fields (``ECMTableField``) accept a list of dicts or ``ECMTableRowModel``
        instances and are stored in ``_raw_table_fields_``. All declared table fields not
        present in ``kwargs`` are initialised as empty lists.

        Args:
            **kwargs: Field values keyed by ECM internal field name.
            extra_fields: Optional dict of additional scalar field values that are not
                declared as ``ECMField`` attributes. Merged into the instance's field
                storage after all typed fields have been processed.
            extra_table_fields: Optional dict of table field values for fields that are not
                declared as ``ECMTableField`` attributes. Each value must be a list of dicts
                mapping column names to values. Useful when using dynamically created model
                classes (e.g. via ``make_document_model``).
        """
        extra_fields: dict[str, Any] = kwargs.pop("extra_fields", None) or {}
        extra_table_fields: dict[str, list] = kwargs.pop("extra_table_fields", None) or {}
        field_values: dict[str, Any] = {}
        raw_table_fields: dict[str, list] = {}

        for klass in type(self).__mro__:
            for attr_name, attr_val in klass.__dict__.items():
                if isinstance(attr_val, ECMTableField) and attr_name not in raw_table_fields:
                    raw_table_fields[attr_name] = ECMTableList()

        for key, value in kwargs.items():
            descriptor = None
            for klass in type(self).__mro__:
                if key in klass.__dict__:
                    descriptor = klass.__dict__[key]
                    break

            if isinstance(descriptor, ECMTableField):
                row_type: type[ECMTableRowModel] = (
                    descriptor._row_type if descriptor._row_type is not None else ECMTableRowModel
                )  # pylint: disable=protected-access
                rows: ECMTableList = ECMTableList()
                if value is not None:
                    for item in value:
                        if isinstance(item, ECMTableRowModel):
                            rows.append(item)
                        else:
                            row_instance = row_type.__new__(row_type)
                            row_instance._row_id_ = None  # pylint: disable=protected-access
                            row_instance._raw_fields_ = dict(item)  # pylint: disable=protected-access
                            row_instance._original_fields_ = {}  # pylint: disable=protected-access
                            rows.append(row_instance)
                raw_table_fields[descriptor.internal_name] = rows
            elif isinstance(descriptor, ECMField):
                field_values[descriptor.internal_name] = value
            else:
                field_values[key] = value

        field_values.update(extra_fields)

        for tf_name, rows_data in extra_table_fields.items():
            rows = ECMTableList()
            for item in rows_data:
                if isinstance(item, ECMTableRowModel):
                    rows.append(item)
                else:
                    row_instance = ECMTableRowModel.__new__(ECMTableRowModel)
                    row_instance._row_id_ = None  # pylint: disable=protected-access
                    row_instance._raw_fields_ = dict(item)  # pylint: disable=protected-access
                    row_instance._original_fields_ = {}  # pylint: disable=protected-access
                    rows.append(row_instance)
            raw_table_fields[tf_name] = rows

        self._id_ = None
        self._name_ = None
        self._rights_ = None
        self._base_params_ = None
        self._file_properties_ = None
        self._variants_ = None
        self._raw_fields_ = field_values
        self._original_fields_ = {}
        self._raw_table_fields_ = raw_table_fields
        self._original_table_row_counts_ = {name: 0 for name in raw_table_fields}

    def __setattr__(self, name: str, value: Any) -> None:
        object.__setattr__(self, name, value)

    # __class_getitem__ teaches Pyright that Class["fieldName"] returns ECMField,
    # not a generic type argument.  At runtime the metaclass __getitem__ is called
    # first, but Pyright uses __class_getitem__ for static analysis.
    @classmethod
    def __class_getitem__(cls, internal_name: str) -> ECMField:
        """Return an ``ECMField`` proxy for an undeclared field.

        Args:
            internal_name: The ECM internal field name.

        Returns:
            An ``ECMField`` for building query conditions.
        """
        identifier = cls._internal_name_ or cls._type_id_
        return ECMField(str, internal_name, identifier)

    @classmethod
    def _ecm_identifier(cls) -> str | int:
        """Return ``_type_id_`` if set, otherwise ``_internal_name_``.

        Raises:
            ValueError: If neither ``_internal_name_`` nor ``_type_id_`` is set.
        """
        if cls._type_id_ is not None:
            return cls._type_id_
        if cls._internal_name_ is not None:
            return cls._internal_name_
        raise ValueError(f"{cls.__name__} must define _internal_name_ or _type_id_")

    @classmethod
    def _from_dms_parsed(cls, object_type: DmsContentObjectType, obj: DmsContentObject) -> _ECMModelBase:  # pylint: disable=too-many-locals
        """Create a model instance from HOL parser objects.

        Args:
            object_type: The ``DmsContentObjectType`` from the HOL parser.
            obj: The ``DmsContentObject`` from the HOL parser.

        Returns:
            Model instance with all field values populated.
        """
        instance = cls.__new__(cls)
        raw_fields: dict[str, Any] = {}

        instance._id_ = obj.id
        instance._name_ = object_type.name
        if obj.rights:
            instance._rights_ = ECMModelRights(
                obj.rights.object_inserts,
                obj.rights.modify_index,
                obj.rights.export_object,
                obj.rights.modify_object,
                obj.rights.delete_object,
            )
        if obj.base_params:
            instance._base_params_ = ECMModelBaseParams(
                creator=obj.base_params.creator,
                creation_date=obj.base_params.creation_date,
                owner=obj.base_params.owner,
                modifier=obj.base_params.modifier,
                modified_date=obj.base_params.modified,
                links_count=obj.base_params.links,
                text_notice_count=obj.base_params.txt_notice_count,
            )
        try:
            fp = obj.file_properties
            instance._file_properties_ = ECMModelFileProperties(
                count=fp.count,
                size=fp.size,
                extension=fp.extension,
                mimetype=fp.mimetype,
                mimetypegroup=fp.mimetypegroup,
                iconid=fp.iconid,
                documentpagecount=fp.documentpagecount,
            )
        except ValueError:
            instance._file_properties_ = None

        raw_variants = obj.document_variants
        instance._variants_ = [_variant_from_parsed(v) for v in raw_variants] if raw_variants is not None else None

        for field_name, field in obj.fields.items():
            raw_fields[field_name] = field.value()
        instance._raw_fields_ = raw_fields
        instance._original_fields_ = dict(raw_fields)

        raw_table_fields: dict[str, list] = {}
        original_counts: dict[str, int] = {}
        for tf_name, tf in obj.table_fields.items():
            descriptor = getattr(type(instance), tf_name, None)
            row_type: type[ECMTableRowModel] = (
                descriptor._row_type  # pylint: disable=protected-access
                if isinstance(descriptor, ECMTableField) and descriptor._row_type is not None  # pylint: disable=protected-access
                else ECMTableRowModel  # pylint: disable=protected-access
            )
            rows: ECMTableList = ECMTableList(row_type._from_parser_row(row) for row in tf.rows)  # pylint: disable=protected-access
            raw_table_fields[tf_name] = rows
            original_counts[tf_name] = len(rows)
        for klass in type(instance).__mro__:
            for attr_name, attr_val in klass.__dict__.items():
                if isinstance(attr_val, ECMTableField) and attr_name not in raw_table_fields:
                    raw_table_fields[attr_name] = ECMTableList()
                    original_counts[attr_name] = 0

        instance._raw_table_fields_ = raw_table_fields
        instance._original_table_row_counts_ = original_counts

        return instance

    @classmethod
    def _from_lol_row(cls, object_type: DmsContentObjectType, row: DmsContentRow, *, request_rights: bool = False) -> _ECMModelBase:
        """Create a model instance from a LOL parser row.

        LOL rows do not carry ``<BaseParams>``, ``<FileProperties>``, or
        ``<DocumentVariants>`` elements.  Audit metadata (creator, modifier, dates) is
        only available when the corresponding system fields (``OBJECT_CRID``,
        ``OBJECT_MODIFYUSER``, etc.) are declared via ``_ecm_system_fields_`` on the
        model and will then appear in ``_raw_fields_``, **not** in
        ``system.base_params``.  Table field values are untyped raw strings and
        ``row.row_id`` is always ``None``.

        Args:
            object_type: The ``DmsContentObjectTypeLol`` from the LOL parser.
            row: The ``DmsContentRow`` from the LOL parser.
            request_rights: If ``True``, reads ``row.rights`` and populates
                ``_rights_``.

        Returns:
            Model instance with field values populated.
        """
        instance = cls.__new__(cls)
        raw_fields: dict[str, Any] = {}

        instance._id_ = row.id
        instance._name_ = object_type.name
        instance._base_params_ = None
        instance._file_properties_ = None
        instance._variants_ = None

        if request_rights:
            try:
                r = row.rights
                instance._rights_ = ECMModelRights(
                    r.object_inserts,
                    r.modify_index,
                    r.export_object,
                    r.modify_object,
                    r.delete_object,
                )
            except ValueError:
                instance._rights_ = None
        else:
            instance._rights_ = None

        for field_name, field in row.fields.items():
            raw_fields[field_name] = field.value()
        instance._raw_fields_ = raw_fields
        instance._original_fields_ = dict(raw_fields)

        raw_table_fields: dict[str, list] = {}
        original_counts: dict[str, int] = {}
        for tf_name, tf in row.table_fields.items():
            descriptor = getattr(type(instance), tf_name, None)
            row_type: type[ECMTableRowModel] = (
                descriptor._row_type  # pylint: disable=protected-access
                if isinstance(descriptor, ECMTableField) and descriptor._row_type is not None  # pylint: disable=protected-access
                else ECMTableRowModel
            )
            rows: ECMTableList = ECMTableList(row_type._from_lol_raw_dict(raw_row) for raw_row in tf.rows)  # pylint: disable=protected-access
            raw_table_fields[tf_name] = rows
            original_counts[tf_name] = len(rows)
        for klass in type(instance).__mro__:
            for attr_name, attr_val in klass.__dict__.items():
                if isinstance(attr_val, ECMTableField) and attr_name not in raw_table_fields:
                    raw_table_fields[attr_name] = ECMTableList()
                    original_counts[attr_name] = 0

        instance._raw_table_fields_ = raw_table_fields
        instance._original_table_row_counts_ = original_counts

        return instance

    @property
    def system(self) -> ECMModelSystem:
        """System metadata namespace for this object.

        Returns:
            An ``ECMModelSystem`` instance exposing server-populated metadata such as ``id``,
            ``name``, ``rights``, ``base_params``, and change-tracking helpers.
        """
        return ECMModelSystem(self)

    def to_dict(self) -> dict[str, Any]:
        """Return all field and table-field values as a plain dictionary.

        The returned dict has the following structure::

            {
                "id":   123,
                "type": "InvoiceFolder",
                "fields": {
                    "Title": "Rechnung 2024",
                    "Year":  2024,
                },
                "table_fields": {
                    "Positions": [
                        {"Amount": 100.0, "Description": "Service"},
                    ],
                },
            }

        ``date`` values are converted to ISO-format strings (``YYYY-MM-DD``).
        ``datetime`` values are converted to ISO-format strings (``YYYY-MM-DDTHH:MM:SS``).

        Returns:
            A JSON-serializable dict representation of the model instance.
        """
        return {
            "id": self._id_,
            "type": type(self).__name__,
            "fields": {k: _serialize_value(v) for k, v in self._raw_fields_.items()},
            "table_fields": {
                tf_name: [row.to_dict() for row in rows]
                for tf_name, rows in self._raw_table_fields_.items()
            },
        }

    def to_json(self, **kwargs: Any) -> str:
        """Serialise the model instance to a JSON string.

        Args:
            **kwargs: Additional keyword arguments forwarded to ``json.dumps``
                (e.g. ``indent=2``).

        Returns:
            A JSON string representation of the model instance.
        """
        return json.dumps(self.to_dict(), **kwargs)

    def __getitem__(self, internal_name: str) -> Any:
        return self._raw_fields_.get(internal_name)

    def __setitem__(self, internal_name: str, value: Any) -> None:
        self._raw_fields_[internal_name] = value

    def __str__(self) -> str:
        return self.to_json(indent=2)

    def __repr__(self) -> str:
        return f"<{type(self).__name__} id={self._id_} fields={list(self._raw_fields_.keys())}>"


class ECMFolderModel(_ECMModelBase):
    """Base class for typed folder models.

    Declare field attributes using ``ECMField[T]`` annotations. The metaclass
    automatically creates ``ECMField`` descriptors::

        class CustomerFolder(ECMFolderModel):
            _internal_name_ = "CustomerFolder"

            CustomerName:   ECMField[str]
            CustomerNumber: ECMField[str]
            Region:         ECMField[str]
    """


class ECMRegisterModel(_ECMModelBase):
    """Base class for typed register models.

    Declare field attributes using ``ECMField[T]`` annotations::

        class YearRegister(ECMRegisterModel):
            _internal_name_ = "YearRegister"

            Year:        ECMField[int]
            Description: ECMField[str]
    """

    _ecm_system_fields_: ClassVar[tuple[SystemFields, ...]] = (
        SystemFields.REG_STAID,
        SystemFields.REG_PARID,
    )

    @property
    def system(self) -> ECMRegisterModelSystem:
        """System metadata namespace for this register object.

        Returns:
            An ``ECMRegisterModelSystem`` instance additionally exposing ``folder_id``
            and ``parent_register_id``.
        """
        return ECMRegisterModelSystem(self)


class ECMDocumentModel(_ECMModelBase):
    """Base class for typed document models.

    Declare field attributes using ``ECMField[T]`` annotations::

        class InvoiceDocument(ECMDocumentModel):
            _internal_name_ = "InvoiceDocument"
            InvoiceNumber: ECMField[str]
            Amount:        ECMField[float]
            DueDate:       ECMField[str]
    """

    _ecm_system_fields_: ClassVar[tuple[SystemFields, ...]] = (
        SystemFields.SDSTA_ID,
        SystemFields.SDREG_ID,
        SystemFields.SDREG_TYPE,
        SystemFields.OBJECT_FOREIGNID,
        SystemFields.OBJECT_LOCKUSER,
        SystemFields.OBJECT_AVID,
        SystemFields.OBJECT_AVDATE,
    )
    # SDSTA_ID, SDREG_ID, SDREG_TYPE live in a separate document-location link
    # table on the server.  dms.GetObjectDetails generates a single-table query
    # and therefore cannot bind those column references — requesting them causes
    # a SQL error.  This tuple lists only the subset that GetObjectDetails
    # actually supports.  Used by ECMDmsSync/ECMDmsAsync.get() instead of the
    # full _ecm_system_fields_.
    _ecm_system_fields_get_details_: ClassVar[tuple[SystemFields, ...]] = (
        SystemFields.OBJECT_FOREIGNID,
        SystemFields.OBJECT_LOCKUSER,
        SystemFields.OBJECT_AVID,
        SystemFields.OBJECT_AVDATE,
    )

    @property
    def system(self) -> ECMDocumentModelSystem:
        """System metadata namespace for this document object.

        Returns:
            An ``ECMDocumentModelSystem`` instance additionally exposing ``folder_id``,
            ``register_id``, ``register_type_id``, and ``file_properties``.
        """
        return ECMDocumentModelSystem(self)


def make_folder_model(internal_name: str) -> type[ECMFolderModel]:
    """Create an ``ECMFolderModel`` subclass at runtime.

    Field values are passed as ``extra_fields`` or as keyword arguments when
    instantiating the returned class. No typed field descriptors are declared.

    Args:
        internal_name: The ECM internal object type name (``_internal_name_``).

    Returns:
        A new ``ECMFolderModel`` subclass bound to ``internal_name``.

    Example:
        ::

            InvoiceFolder = make_folder_model("InvoiceFolder")
            folder = InvoiceFolder(Title="Rechnung 2024", Year=2024)
            ecm.insert(folder)
    """
    return ECMModelMeta(internal_name, (ECMFolderModel,), {"_internal_name_": internal_name})  # type: ignore[return-value]


def make_register_model(internal_name: str) -> type[ECMRegisterModel]:
    """Create an ``ECMRegisterModel`` subclass at runtime.

    Field values are passed as ``extra_fields`` or as keyword arguments when
    instantiating the returned class. No typed field descriptors are declared.

    Args:
        internal_name: The ECM internal object type name (``_internal_name_``).

    Returns:
        A new ``ECMRegisterModel`` subclass bound to ``internal_name``.

    Example:
        ::

            YearRegister = make_register_model("YearRegister")
            register = YearRegister(Year=2024)
            ecm.insert(register, folder_id=folder.id)
    """
    return ECMModelMeta(internal_name, (ECMRegisterModel,), {"_internal_name_": internal_name})  # type: ignore[return-value]


def make_document_model(internal_name: str) -> type[ECMDocumentModel]:
    """Create an ``ECMDocumentModel`` subclass at runtime.

    Field values are passed as ``extra_fields`` or as keyword arguments when
    instantiating the returned class. No typed field descriptors are declared.

    Args:
        internal_name: The ECM internal object type name (``_internal_name_``).

    Returns:
        A new ``ECMDocumentModel`` subclass bound to ``internal_name``.

    Example:
        ::

            InvoiceDoc = make_document_model("InvoiceDoc")
            doc = InvoiceDoc(InvoiceNumber="2024-001", Amount=199.99)
            ecm.insert(doc, folder_id=folder.id, register_id=register.id)
    """
    return ECMModelMeta(internal_name, (ECMDocumentModel,), {"_internal_name_": internal_name})  # type: ignore[return-value]


# Re-export for convenience so users only need to import from _ecm_model.
__all__ = [
    "ECMDocumentModelSystem",
    "ECMFolderModel",
    "ECMRegisterModel",
    "ECMDocumentModel",
    "ECMModelFileProperties",
    "ECMModelSystem",
    "ECMRegisterModelSystem",
    "ECMTableRowModel",
    "ECMField",
    "ECMTableField",
    "ECMCondition",
    "ECMConditionGroup",
    "make_folder_model",
    "make_register_model",
    "make_document_model",
]
