"""Namespace class grouping ECM DMS object operations (asynchronous)."""

from typing import TYPE_CHECKING, Optional, TypeVar, overload

from ecmind_blue_client import Jobs
from ecmind_blue_client.const import StoreInCacheByIdConversion
from ecmind_blue_client.ecm._common import ECMDocumentDigest
from ecmind_blue_client.ecm._ecm_exception import ECMException, ECMNotFoundException
from ecmind_blue_client.ecm.parser import DmsContentParserHol

if TYPE_CHECKING:
    from typing import Any

    from ecmind_blue_client.ecm.asynchron._ecm_async import ECMAsync
    from ecmind_blue_client.ecm.model._ecm_model import (
        ECMDocumentModel,
        ECMFolderModel,
        ECMRegisterModel,
        _ECMModelBase,
    )
    from ecmind_blue_client.ecm.model._ecm_model_field import ECMField
    from ecmind_blue_client.ecm.model.asynchron._ecm_model_lol_query_async import ECMModelQueryLolAsync
    from ecmind_blue_client.ecm.model.asynchron._ecm_model_query_async import ECMModelQueryAsync
    from ecmind_blue_client.ecm.model.asynchron._ecm_model_upsert_async import ECMModelUpsertAsync
    from ecmind_blue_client.rpc import JobRequestFile, JobResponseFile

    _ModelT = TypeVar("_ModelT", bound=_ECMModelBase)
    _DocumentModelT = TypeVar("_DocumentModelT", bound=ECMDocumentModel)
    _RegisterModelT = TypeVar("_RegisterModelT", bound=ECMRegisterModel)
else:
    _ModelT = TypeVar("_ModelT")
    _DocumentModelT = TypeVar("_DocumentModelT")
    _RegisterModelT = TypeVar("_RegisterModelT")


class ECMDmsAsync:
    """Namespace for asynchronous ECM DMS object operations.

    Accessed via ``ECMAsync.dms``. Groups all methods that operate on typed ECM
    objects (folders, registers, documents): select, insert, update, delete,
    upsert, get, files, and get_object_type_by_id.
    """

    def __init__(self, ecm: "ECMAsync") -> None:
        """Initialise the namespace with a reference to the owning ECMAsync instance.

        Args:
            ecm: The ``ECMAsync`` instance that owns this namespace.
        """
        self._ecm = ecm

    def select(self, model_class: type[_ModelT]) -> "ECMModelQueryAsync[_ModelT]":
        """Return a type-safe query builder for a declarative ECM model class.

        Builds and executes a HOL query based on the model's identifier and the
        conditions added via ``where()``. Results are returned as typed model
        instances instead of raw object representations.

        Args:
            model_class: A subclass of ``ECMFolderModel``, ``ECMRegisterModel``,
                or ``ECMDocumentModel`` that defines the object type and its fields.

        Returns:
            An ``ECMModelQueryAsync`` instance for chaining conditions and executing.

        Example:
            ::

                class MeinOrdner(ECMFolderModel):
                    _internal_name_ = "MeinOrdner"
                    Title: str
                    Status: str

                results = await ecm.dms.select(MeinOrdner).where(
                    MeinOrdner.Title == "gesuchter Ordner",
                    MeinOrdner["dynamicField"] != "gesperrt",
                ).execute()
        """
        from ecmind_blue_client.ecm.model.asynchron._ecm_model_query_async import (  # pylint: disable=import-outside-toplevel
            ECMModelQueryAsync,
        )

        return ECMModelQueryAsync(self._ecm, model_class)

    def select_lol(self, model_class: type[_ModelT]) -> "ECMModelQueryLolAsync[_ModelT]":
        """Return a LOL query builder for a declarative ECM model class.

        Builds and executes a LOL (Linear Object List) query.  LOL queries are typically
        **faster** than HOL for large, flat result sets because the server returns a
        compact column-oriented rowset rather than a per-object element tree.

        **Limitations compared to** ``select()`` **(HOL):**

        - ``file_properties()`` / ``obj.system.file_properties`` is **not available**.
          Partial file metadata is accessible only via system field columns:
          ``OBJECT_FILESIZE`` (size), ``OBJECT_COUNT`` (count),
          ``OBJECT_DOCPAGECOUNT`` (page count).
        - ``base_params()`` is **not available** as a structured object.
          Audit metadata is only present when the corresponding system fields are
          declared via ``_ecm_system_fields_`` on the model.
        - ``variants()`` is not supported.
        - ``icons()`` has no effect (server ignores the flag in LOL mode).
        - ``with_children()`` / ``with_parents()`` (HOL hierarchy) are not available.
        - Table field values are untyped raw strings; ``row_id`` is always ``None``.

        Args:
            model_class: A subclass of ``ECMFolderModel``, ``ECMRegisterModel``,
                or ``ECMDocumentModel`` that defines the object type and its fields.

        Returns:
            An ``ECMModelQueryLolAsync`` instance for chaining conditions and executing.

        Example:
            ::

                results = await ecm.dms.select_lol(MeinOrdner).where(
                    MeinOrdner.Title == "gesuchter Ordner",
                ).execute()
        """
        from ecmind_blue_client.ecm.model.asynchron._ecm_model_lol_query_async import (  # pylint: disable=import-outside-toplevel
            ECMModelQueryLolAsync,
        )

        return ECMModelQueryLolAsync(self._ecm, model_class)

    def upsert(
        self,
        model: _ModelT,
        folder_id: int | None = None,
        register_id: int | None = None,
        *,
        check_mandatory: bool = True,
    ) -> "ECMModelUpsertAsync[_ModelT]":
        """Return a fluent upsert builder for a declarative ECM model instance.

        Executes ``dms.XMLImport`` on the server: searches for an existing object using the
        conditions configured via ``search()`` and then inserts or updates depending on the
        number of results and the configured actions.

        Args:
            model: A populated model instance (subclass of ``ECMFolderModel``,
                ``ECMRegisterModel``, or ``ECMDocumentModel``) whose field values are written.
            folder_id: Optional ID of the folder to place the object in. Only valid for
                ``ECMRegisterModel`` and ``ECMDocumentModel``.
            register_id: Optional ID of the register to file the document in
                (``ECMDocumentModel``) or of the parent register (``ECMRegisterModel``).
            check_mandatory: If ``True`` (default), validates that all ``mandatory=True``
                ``ECMField`` descriptors have a non-``None`` value when ``execute()`` is
                called. If ``False``, skips client-side validation and adds
                ``CHECKOBLIGATION=0`` to the server request.

        Returns:
            An ``ECMModelUpsertAsync`` builder for chaining ``.search()``, ``.action0()``,
            ``.action1()``, ``.action_multiple()``, and ``.execute()``.

        Example:
            ::

                object_id, object_type_id, hits, action = await (
                    ecm.dms.upsert(InvoiceFolder(Title="Rechnung 2024", Year=2024))
                    .search(InvoiceFolder.Title == "Rechnung 2024")
                    .action0("INSERT")
                    .action1("UPDATE")
                    .execute()
                )
        """
        from ecmind_blue_client.ecm.model.asynchron._ecm_model_upsert_async import (  # pylint: disable=import-outside-toplevel
            ECMModelUpsertAsync,
        )

        return ECMModelUpsertAsync(self._ecm, model, folder_id=folder_id, register_id=register_id, check_mandatory=check_mandatory)

    async def delete(
        self,
        model: "_ECMModelBase | int",
        object_type: "int | str | None" = None,
        *,
        hard_delete: bool = False,
        delete_cascading: bool = False,
    ) -> None:
        """Delete an existing ECM object using ``dms.XMLDelete``.

        The object to delete can be identified either by a model instance or by a plain
        integer object ID combined with a type identifier.

        Args:
            model: The object to delete — one of:

                * ``ECMFolderModel``, ``ECMRegisterModel``, or ``ECMDocumentModel`` instance —
                  ``id`` must be set (i.e. loaded from the server or assigned manually).
                * ``int`` — plain numeric object ID; ``object_type`` must also be provided.
            object_type: Only required when ``model`` is a plain ``int``. Identifies the
                object type in the XML:

                * ``int`` — numeric type ID.
                * ``str`` — internal name of the object type.
            hard_delete: If ``True``, adds ``HARDDELETE=1`` to the ``Options`` parameter,
                which permanently removes the object without moving it to the recycle bin.
            delete_cascading: If ``True``, adds ``DELETECASCADING=1`` to the ``Options``
                parameter, which also deletes all child objects recursively.

        Raises:
            ValueError: If ``model`` is an ``int`` and ``object_type`` is ``None``.
            ValueError: If ``model`` is a model instance with ``id`` set to ``None``.
        """
        if isinstance(model, int):
            if object_type is None:
                raise ValueError("object_type (type_id or internal_name) must be provided when model is a plain integer")
            obj_id: int = model
            obj_identifier: int | str = object_type
        else:
            if model._id_ is None:  # pylint: disable=protected-access
                raise ValueError(
                    f"Cannot delete {type(model).__name__}: model has no id. "
                    "Load the object from the server first or set the id manually."
                )
            obj_id = model._id_  # pylint: disable=protected-access
            obj_identifier = model._ecm_identifier()  # pylint: disable=protected-access
        kwargs: dict = {"Flags": 0, "XML": self._ecm._build_delete_xml(obj_id, obj_identifier)}  # pylint: disable=protected-access
        options = []
        if hard_delete:
            options.append("HARDDELETE=1")
        if delete_cascading:
            options.append("DELETECASCADING=1")
        if options:
            kwargs["Options"] = ";".join(options)
        await self._ecm.execute(Jobs.DMS_XMLDELETE, **kwargs)

    async def move(
        self,
        model: "ECMDocumentModel | ECMRegisterModel | int",
        object_type: "int | str | None" = None,
        *,
        folder_id: "int | ECMFolderModel | None" = None,
        register_id: "int | ECMRegisterModel | None" = None,
        register_type: int | None = None,
        wf_to_user_tray: bool = False,
    ) -> None:
        """Move a document or register to a new location using ``dms.XMLMove``.

        The object to move can be identified by a model instance, a plain integer object ID
        combined with a type identifier, or an integer object ID combined with an internal name.
        Target folder and register can be given as model instances or plain integer IDs.

        Moving a register also moves all its child objects to the new location.

        When ``wf_to_user_tray=True``, the document is moved from the workflow tray to
        the user tray. In that case no location attributes (``folder_id``, ``register_id``)
        should be provided.

        Args:
            model: The object to move — one of:

                * ``ECMDocumentModel`` or ``ECMRegisterModel`` instance — ``id`` must be set.
                * ``int`` — plain numeric object ID; ``object_type`` must also be provided.
            object_type: Only required when ``model`` is a plain ``int``. Identifies the
                ``<ObjectType>`` element in the XML:

                * ``int`` — numeric type ID (e.g. from ``get_object_type_by_id()``).
                * ``str`` — internal name of the object type (e.g. ``"word_documents"``).
            folder_id: Target folder — a numeric ID or an ``ECMFolderModel`` instance whose
                ``id`` is used. Required unless ``wf_to_user_tray=True``.
            register_id: Target register — a numeric ID or an ``ECMRegisterModel`` instance
                whose ``id`` is used. Pass ``-1`` to file directly in the folder without a
                parent register. When an ``ECMRegisterModel`` instance is passed and
                ``register_type`` is not set, the type is taken from the model class's
                ``_type_id_`` attribute if available.
            register_type: Type ID of the target register. Pass ``-1`` together with
                ``register_id=-1`` when targeting a folder directly (matching the server's
                expected XML convention for register moves). Inferred automatically when
                ``register_id`` is a model instance whose class defines ``_type_id_``.
            wf_to_user_tray: If ``True``, adds ``WFTOUSERTRAY=1`` to the ``Options``
                parameter and omits location attributes from the XML. The document must
                currently reside in the workflow tray.

        Raises:
            ValueError: If ``model`` is an ``int`` and ``object_type`` is ``None``.
            ValueError: If ``model`` is a model instance with ``id`` set to ``None``.
        """
        from ecmind_blue_client.ecm.model._ecm_model import _ECMModelBase  # pylint: disable=import-outside-toplevel

        if isinstance(model, int):
            if object_type is None:
                raise ValueError("object_type (type_id or internal_name) must be provided when model is a plain integer")
            obj_id: int = model
            obj_identifier: int | str = object_type
        else:
            if model._id_ is None:  # pylint: disable=protected-access
                raise ValueError(
                    f"Cannot move {type(model).__name__}: model has no id. "
                    "Load the object from the server first or set the id manually."
                )
            obj_id = model._id_  # pylint: disable=protected-access
            obj_identifier = model._ecm_identifier()  # pylint: disable=protected-access

        _folder_id = folder_id if isinstance(folder_id, int) or folder_id is None else folder_id._id_  # pylint: disable=protected-access
        if isinstance(register_id, _ECMModelBase):
            _register_id: int | None = register_id._id_  # pylint: disable=protected-access
            if register_type is None:
                _register_type: int | None = type(register_id)._type_id_  # pylint: disable=protected-access
            else:
                _register_type = register_type
        else:
            _register_id = register_id
            _register_type = register_type

        if wf_to_user_tray:
            xml = self._ecm._build_move_xml(obj_id, obj_identifier)  # pylint: disable=protected-access
        else:
            xml = self._ecm._build_move_xml(  # pylint: disable=protected-access
                obj_id, obj_identifier, folder_id=_folder_id, register_id=_register_id, register_type=_register_type
            )
        options: list[str] = []
        if wf_to_user_tray:
            options.append("WFTOUSERTRAY=1")
        kwargs: dict = {"Flags": 0, "XML": xml}
        if options:
            kwargs["Options"] = ";".join(options)
        await self._ecm.execute(Jobs.DMS_XMLMOVE, **kwargs)

    async def copy(
        self,
        model: "ECMFolderModel | ECMDocumentModel | ECMRegisterModel | int",
        object_type: "int | str | None" = None,
        *,
        folder_id: "int | ECMFolderModel | None" = None,
        register_id: "int | ECMRegisterModel | None" = None,
        register_type: int | None = None,
        copy_cascading: bool = False,
        link_document: bool = False,
    ) -> None:
        """Copy a folder, register, or document to a new location using ``dms.XMLCopy``.

        When copying a register or folder, ``copy_cascading=True`` additionally copies all
        contained child objects recursively.

        When copying a document, ``link_document=True`` creates a second location (link) for
        the same index record rather than a fully independent copy. Source and target location
        must differ for a linked copy.

        The object to copy can be identified by a model instance, a plain integer object ID
        combined with a type identifier, or an integer object ID combined with an internal name.
        Target folder and register can be given as model instances or plain integer IDs.

        Args:
            model: The object to copy — one of:

                * ``ECMFolderModel``, ``ECMRegisterModel``, or ``ECMDocumentModel`` instance —
                  ``id`` must be set.
                * ``int`` — plain numeric object ID; ``object_type`` must also be provided.
            object_type: Only required when ``model`` is a plain ``int``. Identifies the
                ``<ObjectType>`` element in the XML:

                * ``int`` — numeric type ID (e.g. from ``get_object_type_by_id()``).
                * ``str`` — internal name of the object type (e.g. ``"word_documents"``).
            folder_id: Target folder — a numeric ID or an ``ECMFolderModel`` instance whose
                ``id`` is used. Required for all copy operations.
            register_id: Target register — a numeric ID or an ``ECMRegisterModel`` instance
                whose ``id`` is used. Pass ``-1`` to copy directly into the folder without a
                parent register. When an ``ECMRegisterModel`` instance is passed and
                ``register_type`` is not set, the type is taken from the model class's
                ``_type_id_`` attribute if available.
            register_type: Type ID of the target register. Pass ``-1`` together with
                ``register_id=-1`` when targeting a folder directly. Inferred automatically
                when ``register_id`` is a model instance whose class defines ``_type_id_``.
            copy_cascading: If ``True``, adds ``COPYCASCADING=1`` to the ``Options``
                parameter. When copying a register or folder, all contained child objects are
                also copied recursively.
            link_document: If ``True``, adds ``LINKDOCUMENT=1`` to the ``Options``
                parameter. The document receives a second location (link) instead of being
                fully duplicated. Only applicable when copying a document.

        Raises:
            ValueError: If ``model`` is an ``int`` and ``object_type`` is ``None``.
            ValueError: If ``model`` is a model instance with ``id`` set to ``None``.
        """
        from ecmind_blue_client.ecm.model._ecm_model import _ECMModelBase  # pylint: disable=import-outside-toplevel

        if isinstance(model, int):
            if object_type is None:
                raise ValueError("object_type (type_id or internal_name) must be provided when model is a plain integer")
            obj_id: int = model
            obj_identifier: int | str = object_type
        else:
            if model._id_ is None:  # pylint: disable=protected-access
                raise ValueError(
                    f"Cannot copy {type(model).__name__}: model has no id. "
                    "Load the object from the server first or set the id manually."
                )
            obj_id = model._id_  # pylint: disable=protected-access
            obj_identifier = model._ecm_identifier()  # pylint: disable=protected-access

        _folder_id = folder_id if isinstance(folder_id, int) or folder_id is None else folder_id._id_  # pylint: disable=protected-access
        if isinstance(register_id, _ECMModelBase):
            _register_id: int | None = register_id._id_  # pylint: disable=protected-access
            if register_type is None:
                _register_type: int | None = type(register_id)._type_id_  # pylint: disable=protected-access
            else:
                _register_type = register_type
        else:
            _register_id = register_id
            _register_type = register_type

        # XMLCopy uses the same XML format as XMLMove: object_id + target location attributes.
        xml = self._ecm._build_move_xml(  # pylint: disable=protected-access
            obj_id, obj_identifier, folder_id=_folder_id, register_id=_register_id, register_type=_register_type
        )
        options: list[str] = []
        if copy_cascading:
            options.append("COPYCASCADING=1")
        if link_document:
            options.append("LINKDOCUMENT=1")
        kwargs: dict = {"Flags": 0, "XML": xml}
        if options:
            kwargs["Options"] = ";".join(options)
        await self._ecm.execute(Jobs.DMS_XMLCOPY, **kwargs)

    @overload
    async def insert(
        self,
        model: "ECMDocumentModel",
        files: "list[JobRequestFile] | None" = ...,
        *,
        folder_id: "int | ECMFolderModel | None" = ...,
        register_id: "int | ECMRegisterModel | None" = ...,
        check_mandatory: bool = ...,
    ) -> tuple[int, int]: ...

    @overload
    async def insert(
        self,
        model: "ECMRegisterModel",
        files: None = ...,
        *,
        folder_id: "int | ECMFolderModel | None" = ...,
        register_id: "int | ECMRegisterModel | None" = ...,
        check_mandatory: bool = ...,
    ) -> tuple[int, int]: ...

    @overload
    async def insert(self, model: "ECMFolderModel", files: None = ..., *, check_mandatory: bool = ...) -> tuple[int, int]: ...

    async def insert(
        self,
        model: "_ECMModelBase",
        files: "list[JobRequestFile] | None" = None,
        *,
        folder_id: "int | _ECMModelBase | None" = None,
        register_id: "int | _ECMModelBase | None" = None,
        check_mandatory: bool = True,
    ) -> tuple[int, int]:
        """Insert a new ECM object using ``dms.XMLInsert``.

        Serialises all field values from ``model`` into a DMSData XML document and
        submits it to the server. The model instance is not mutated.

        Args:
            model: A populated model instance (subclass of ``ECMFolderModel``,
                ``ECMRegisterModel``, or ``ECMDocumentModel``).
            files: Optional list of files to attach to the new document. Only allowed
                for ``ECMDocumentModel`` instances; raises ``TypeError`` for folder
                or register models.
            folder_id: Optional folder to place the object in — either a numeric ID or an
                ``ECMFolderModel`` instance whose ``id`` is used. Only valid for
                ``ECMRegisterModel`` and ``ECMDocumentModel``.
            register_id: Optional register to file the document in (``ECMDocumentModel``) or
                parent register (``ECMRegisterModel``) — either a numeric ID or an
                ``ECMRegisterModel`` instance whose ``id`` is used.
            check_mandatory: If ``True`` (default), validates that all ``mandatory=True``
                ``ECMField`` descriptors have a non-``None`` value and raises ``ValueError``
                if any are missing. If ``False``, skips client-side validation and adds
                ``CHECKOBLIGATION=0`` to the server request so the server also skips its own
                obligation check.

        Returns:
            A ``(object_id, object_type_id)`` tuple of the newly created object.

        Raises:
            ECMException: If the server reports a failed insert (``ObjectID == -1``).
            TypeError: If ``files`` are provided for a non-document model.
            ValueError: If ``check_mandatory=True`` and required fields are missing.
        """
        from ecmind_blue_client.ecm.model._ecm_model import (  # pylint: disable=import-outside-toplevel
            ECMDocumentModel,
        )

        if check_mandatory:
            self._ecm._check_required_fields(model)  # pylint: disable=protected-access
        if files is not None and not isinstance(model, ECMDocumentModel):
            raise TypeError(f"files can only be provided for ECMDocumentModel instances, got {type(model).__name__}")
        _folder_id = folder_id if isinstance(folder_id, int) or folder_id is None else folder_id._id_  # pylint: disable=protected-access
        _register_id = register_id if isinstance(register_id, int) or register_id is None else register_id._id_  # pylint: disable=protected-access
        xml = self._ecm._build_insert_xml(model, folder_id=_folder_id, register_id=_register_id)  # pylint: disable=protected-access
        kwargs: dict = {"Flags": 0, "XML": xml}
        if not check_mandatory:
            kwargs["Options"] = "CHECKOBLIGATION=0"
        result = await self._ecm.execute(Jobs.DMS_XMLINSERT, files=files, **kwargs)
        object_id = result.get("ObjectID", int)
        object_type_id = result.get("ObjectType", int)
        if object_id < 0:
            raise ECMException(f"Insert failed for model {type(model).__name__}")
        return object_id, object_type_id

    @overload
    async def insert_and_get(
        self,
        model: "_DocumentModelT",
        files: "list[JobRequestFile] | None" = ...,
        *,
        folder_id: "int | ECMFolderModel | None" = ...,
        register_id: "int | ECMRegisterModel | None" = ...,
        check_mandatory: bool = ...,
        rights: bool = ...,
        base_params: bool = ...,
        file_properties: bool = ...,
        variants: bool = ...,
        use_result_list: bool = ...,
        fields: "list[str | ECMField[Any]] | None" = ...,
    ) -> "_DocumentModelT": ...

    @overload
    async def insert_and_get(
        self,
        model: "_RegisterModelT",
        files: None = ...,
        *,
        folder_id: "int | ECMFolderModel | None" = ...,
        register_id: "int | ECMRegisterModel | None" = ...,
        check_mandatory: bool = ...,
        rights: bool = ...,
        base_params: bool = ...,
        file_properties: bool = ...,
        variants: bool = ...,
        use_result_list: bool = ...,
        fields: "list[str | ECMField[Any]] | None" = ...,
    ) -> "_RegisterModelT": ...

    @overload
    async def insert_and_get(
        self,
        model: _ModelT,
        files: None = ...,
        *,
        check_mandatory: bool = ...,
        rights: bool = ...,
        base_params: bool = ...,
        file_properties: bool = ...,
        variants: bool = ...,
        use_result_list: bool = ...,
        fields: "list[str | ECMField[Any]] | None" = ...,
    ) -> _ModelT: ...

    async def insert_and_get(
        self,
        model: "_ECMModelBase",
        files: "list[JobRequestFile] | None" = None,
        *,
        folder_id: "int | _ECMModelBase | None" = None,
        register_id: "int | _ECMModelBase | None" = None,
        check_mandatory: bool = True,
        rights: bool = False,
        base_params: bool = False,
        file_properties: bool = False,
        variants: bool = False,
        use_result_list: bool = False,
        fields: "list[str | ECMField[Any]] | None" = None,
    ) -> "_ECMModelBase":
        """Insert a new ECM object and immediately fetch the created instance.

        Combines ``insert()`` and ``get()`` into a single call for the common pattern
        of needing the fully populated server-side object after creation.

        Args:
            model: A populated model instance (subclass of ``ECMFolderModel``,
                ``ECMRegisterModel``, or ``ECMDocumentModel``).
            files: Optional list of files to attach to the new document. Only allowed
                for ``ECMDocumentModel`` instances; raises ``TypeError`` for folder
                or register models.
            folder_id: Optional ID of the folder to place the object in. Only valid for
                ``ECMRegisterModel`` and ``ECMDocumentModel``.
            register_id: Optional ID of the register to file the document in
                (``ECMDocumentModel``) or of the parent register (``ECMRegisterModel``).
            check_mandatory: Forwarded to ``insert()``. See ``insert()`` for details.
            rights: If ``True``, requests access rights data (populates ``obj.system.rights``).
            base_params: If ``True``, requests audit metadata (populates ``obj.system.base_params``).
            file_properties: If ``True``, requests file metadata for documents
                (populates ``obj.system.file_properties``).
            variants: If ``True``, requests W-module document variant data
                (populates ``obj.system.variants``).  Ignored when ``use_result_list=True``.
            use_result_list: Forwarded to ``get()``. See ``get()`` for details.
                Defaults to ``False``.
            fields: Forwarded to ``get()``. See ``get()`` for details.

        Returns:
            A typed model instance populated with the server's field values.

        Raises:
            ECMException: If the server reports a failed insert (``ObjectID == -1``).
            TypeError: If ``files`` are provided for a non-document model.
            ValueError: If ``check_mandatory=True`` and required fields are missing.
        """
        object_id, object_type_id = await self.insert(  # type: ignore[call-overload]
            model, files=files, folder_id=folder_id, register_id=register_id, check_mandatory=check_mandatory  # type: ignore[arg-type]
        )
        return await self.get(  # type: ignore[return-value]
            type(model), object_id, object_type_id,
            rights=rights, base_params=base_params, file_properties=file_properties, variants=variants,
            use_result_list=use_result_list, fields=fields,
        )

    @overload
    async def update(
        self,
        model: "ECMDocumentModel",
        files: "list[JobRequestFile] | None" = ...,
        replace_files: bool = ...,
        force: bool = ...,
        *,
        check_mandatory: bool = ...,
    ) -> None: ...

    @overload
    async def update(
        self,
        model: "ECMFolderModel | ECMRegisterModel",
        files: None = ...,
        replace_files: bool = ...,
        force: bool = ...,
        *,
        check_mandatory: bool = ...,
    ) -> None: ...

    async def update(
        self,
        model: "_ECMModelBase",
        files: "list[JobRequestFile] | None" = None,
        replace_files: bool = True,
        force: bool = False,
        *,
        check_mandatory: bool = True,
    ) -> None:
        """Update an existing ECM object using ``dms.XMLUpdate``.

        Serialises all field values from ``model`` into a DMSData XML document and
        submits it to the server. The model must have a valid ``id``.

        By default the update is skipped when neither scalar fields nor table fields
        have changed and no files are attached. Pass ``force=True`` to send the request
        regardless. When table rows have been removed the ``REPLACETABLEFIELDS=1`` option
        is added automatically.

        Args:
            model: A model instance loaded from the server (subclass of ``ECMFolderModel``,
                ``ECMRegisterModel``, or ``ECMDocumentModel``). Must have ``id`` set.
            files: Optional list of replacement or additional files for the document. Only
                allowed for ``ECMDocumentModel`` instances; raises ``TypeError`` for folder
                or register models.
            replace_files: If ``True`` (default), uploaded files replace the existing document
                files (``REPLACEFILES=1``). If ``False``, files are appended to the existing
                ones. Only relevant for ``ECMDocumentModel`` when ``files`` is provided.
            force: If ``True``, always execute the update even when no changes are detected.
                Defaults to ``False``.
            check_mandatory: If ``True`` (default), validates that all ``mandatory=True``
                ``ECMField`` descriptors have a non-``None`` value and raises ``ValueError``
                if any are missing. If ``False``, skips client-side validation and adds
                ``CHECKOBLIGATION=0`` to the server request.

        Raises:
            ValueError: If ``model.id`` is ``None`` or required fields are missing.
            TypeError: If ``files`` are provided for a non-document model.
        """
        from ecmind_blue_client.ecm.model._ecm_model import (  # pylint: disable=import-outside-toplevel
            ECMDocumentModel,
        )

        if check_mandatory:
            self._ecm._check_required_fields(model)  # pylint: disable=protected-access
        if files is not None and not isinstance(model, ECMDocumentModel):
            raise TypeError(f"files can only be provided for ECMDocumentModel instances, got {type(model).__name__}")
        if not force and not model.system.is_modified and files is None:
            return
        options: list[str] = []
        if replace_files and isinstance(model, ECMDocumentModel) and files is not None:
            options.append("REPLACEFILES=1")
        if model.system.has_removed_table_rows:
            options.append("REPLACETABLEFIELDS=1")
        if not check_mandatory:
            options.append("CHECKOBLIGATION=0")
        kwargs: dict = {"Flags": 0, "XML": self._ecm._build_update_xml(model)}  # pylint: disable=protected-access
        if options:
            kwargs["Options"] = ";".join(options)
        await self._ecm.execute(Jobs.DMS_XMLUPDATE, files=files, **kwargs)

    @overload
    async def update_and_get(
        self,
        model: "_DocumentModelT",
        files: "list[JobRequestFile] | None" = ...,
        replace_files: bool = ...,
        force: bool = ...,
        *,
        check_mandatory: bool = ...,
        rights: bool = ...,
        base_params: bool = ...,
        file_properties: bool = ...,
        variants: bool = ...,
        use_result_list: bool = ...,
        fields: "list[str | ECMField[Any]] | None" = ...,
    ) -> "_DocumentModelT": ...

    @overload
    async def update_and_get(
        self,
        model: _ModelT,
        files: None = ...,
        replace_files: bool = ...,
        force: bool = ...,
        *,
        check_mandatory: bool = ...,
        rights: bool = ...,
        base_params: bool = ...,
        file_properties: bool = ...,
        variants: bool = ...,
        use_result_list: bool = ...,
        fields: "list[str | ECMField[Any]] | None" = ...,
    ) -> _ModelT: ...

    async def update_and_get(
        self,
        model: "_ECMModelBase",
        files: "list[JobRequestFile] | None" = None,
        replace_files: bool = True,
        force: bool = False,
        *,
        check_mandatory: bool = True,
        rights: bool = False,
        base_params: bool = False,
        file_properties: bool = False,
        variants: bool = False,
        use_result_list: bool = False,
        fields: "list[str | ECMField[Any]] | None" = None,
    ) -> "_ECMModelBase":
        """Update an existing ECM object and immediately fetch the updated instance.

        Combines ``update()`` and ``get()`` into a single call for the common pattern
        of needing the fully populated server-side object after modification. The update
        is skipped if no changes are detected and ``force=False``; in that case ``get()``
        is still called to return the current server state.

        Args:
            model: A model instance loaded from the server (subclass of ``ECMFolderModel``,
                ``ECMRegisterModel``, or ``ECMDocumentModel``). Must have ``id`` set.
            files: Optional list of replacement or additional files for the document. Only
                allowed for ``ECMDocumentModel`` instances; raises ``TypeError`` for folder
                or register models.
            replace_files: If ``True`` (default), uploaded files replace the existing document
                files (``REPLACEFILES=1``). If ``False``, files are appended to the existing
                ones. Only relevant for ``ECMDocumentModel`` when ``files`` is provided.
            force: If ``True``, always execute the update even when no changes are detected.
                Defaults to ``False``.
            check_mandatory: Forwarded to ``update()``. See ``update()`` for details.
            rights: If ``True``, requests access rights data (populates ``obj.system.rights``).
            base_params: If ``True``, requests audit metadata (populates ``obj.system.base_params``).
            file_properties: If ``True``, requests file metadata for documents
                (populates ``obj.system.file_properties``).
            variants: If ``True``, requests W-module document variant data
                (populates ``obj.system.variants``).  Ignored when ``use_result_list=True``.
            use_result_list: Forwarded to ``get()``. See ``get()`` for details.
                Defaults to ``False``.
            fields: Forwarded to ``get()``. See ``get()`` for details.

        Returns:
            A typed model instance populated with the server's updated field values.

        Raises:
            ValueError: If ``model.id`` is ``None`` or required fields are missing.
            TypeError: If ``files`` are provided for a non-document model.
        """
        await self.update(
            model, files=files, replace_files=replace_files, force=force, check_mandatory=check_mandatory  # type: ignore[call-overload]
        )
        object_id = model._id_  # pylint: disable=protected-access
        if object_id is None:
            raise ValueError("model.id must not be None")
        return await self.get(  # type: ignore[return-value]
            type(model), object_id,
            rights=rights, base_params=base_params, file_properties=file_properties, variants=variants,
            use_result_list=use_result_list, fields=fields,
        )

    async def get(
        self,
        model_class: type[_ModelT],
        object_id: int,
        object_type_id: Optional[int] = None,
        *,
        rights: bool = False,
        base_params: bool = False,
        file_properties: bool = False,
        variants: bool = False,
        use_result_list: bool = False,
        fields: "list[str | ECMField[Any]] | None" = None,
    ) -> _ModelT:
        """Fetch a single ECM object by its ID.

        By default uses ``dms.GetObjectDetails`` (direct ID lookup).  Pass
        ``use_result_list=True`` to use ``dms.GetResultList`` (HOL query) instead,
        which additionally populates document location fields
        (``system.folder_id``, ``system.register_id``).

        .. note::
            ``use_result_list=True`` requires the object to have a location in the
            archive (i.e. filed inside a folder/register).  Objects without a location
            — such as stand-alone or unarchived objects — cannot be found via
            ``GetResultList`` and will raise ``ECMNotFoundException``.
            ``variants`` is supported with both modes.

        System fields with ``use_result_list=False`` (default): ``dms.GetObjectDetails``
        supports most system fields via the ``SystemFields`` parameter, but document
        location fields (``SDSTA_ID``, ``SDREG_ID``, ``SDREG_TYPE``) are excluded
        because the server generates a single-table query that cannot resolve the
        document-location link table.

        Args:
            model_class: A subclass of ``ECMFolderModel``, ``ECMRegisterModel``,
                or ``ECMDocumentModel`` defining the object type and its fields.
            object_id: The numeric ECM object ID to retrieve.
            object_type_id: Optional numeric object type ID.  Only used in
                ``GetObjectDetails`` mode; ignored when ``use_result_list=True``.
                If omitted in default mode, the server resolves the type automatically.
            rights: If ``True``, requests access rights data (populates ``obj.system.rights``).
            base_params: If ``True``, requests audit metadata (populates ``obj.system.base_params``).
            file_properties: If ``True``, requests file metadata for documents
                (populates ``obj.system.file_properties``).
            variants: If ``True``, requests W-module document variant data
                (populates ``obj.system.variants``).  Supported in both modes.
            use_result_list: If ``True``, fetches via ``dms.GetResultList`` (HOL query),
                which populates all system fields including document location.
                Defaults to ``False``.
            fields: Optional list of field names or ``ECMField`` descriptors to retrieve.
                Only effective when ``use_result_list=True``; silently ignored otherwise.
                ``None`` (default) returns all fields (``field_schema="ALL"``).
                When provided, only the listed fields plus system and sort fields are
                fetched (``field_schema="MIN"``).

        Returns:
            A typed model instance populated with the server's field values.

        Raises:
            ECMNotFoundException: If no object with ``object_id`` exists on the server.
        """
        if use_result_list:
            from ecmind_blue_client.ecm.model.asynchron._ecm_model_query_async import (  # pylint: disable=import-outside-toplevel
                ECMModelQueryAsync,
            )

            query: ECMModelQueryAsync[_ModelT] = ECMModelQueryAsync(self._ecm, model_class)
            query = query.where(model_class["OBJECT_ID"] == object_id)  # type: ignore[assignment]
            if rights:
                query = query.rights()  # type: ignore[assignment]
            if base_params:
                query = query.base_params()  # type: ignore[assignment]
            if file_properties:
                query = query.file_properties()  # type: ignore[assignment]
            if variants:
                query = query.variants()  # type: ignore[assignment]
            if fields:
                query = query.fields(*fields)  # type: ignore[assignment]
            results = await query.execute()
            if not results:
                raise ECMNotFoundException(f"Object with id {object_id} not found")
            return results[0]

        kwargs: dict = {"Flags": 16, "ObjectID": object_id}
        if object_type_id is not None:
            kwargs["ObjectType"] = object_type_id
        if rights:
            kwargs["Rights"] = 1
        if base_params:
            kwargs["BaseParams"] = 1
        if file_properties:
            kwargs["FileInfo"] = 1
        if variants:
            kwargs["Variants"] = 1

        sys_field_names: list[str] = []
        seen: set[str] = set()
        for klass in model_class.__mro__:
            klass_sys_fields = klass.__dict__.get("_ecm_system_fields_get_details_") or klass.__dict__.get("_ecm_system_fields_", ())
            for sf in klass_sys_fields:
                if sf.name not in seen:
                    sys_field_names.append(sf.name)
                    seen.add(sf.name)
        if sys_field_names:
            kwargs["SystemFields"] = ";".join(sys_field_names)

        result = await self._ecm.execute(Jobs.DMS_GETOBJECTDETAILS, **kwargs)
        dms_content = DmsContentParserHol(result.files[0])
        expected_name = model_class._internal_name_  # pylint: disable=protected-access
        for ot in dms_content.object_types():
            for obj in ot.objects():
                if expected_name is not None and ot.internal_name != expected_name:
                    raise ValueError(
                        f"Model {model_class.__name__} expects internal_name "
                        f"'{expected_name}' but server returned '{ot.internal_name}'"
                    )
                return model_class._from_dms_parsed(ot, obj)  # type: ignore[return-value]  # pylint: disable=protected-access

        raise ECMNotFoundException(f"Object with id {object_id} not found")

    async def files(
        self,
        model: "ECMDocumentModel | int",
        *,
        flags: int = 1,
        convert: StoreInCacheByIdConversion = StoreInCacheByIdConversion.NONE,
        when_cold_then_tiff: bool = False,
        add_annotations: bool = False,
    ) -> "list[JobResponseFile]":
        """Download the files of a document object via ``std.StoreInCacheById``.

        Args:
            model: Either an ``ECMDocumentModel`` instance (its ``id`` is used) or a plain
                integer document object ID.
            flags: Transfer flags controlling which files are returned.

                * ``0`` — document files and the DIA file are transferred.
                * ``1`` — only the document files themselves (default).
                * ``2`` — only the DIA file of the object.
            convert: Optional file conversion applied before transfer.

                * ``StoreInCacheByIdConversion.NONE`` — no conversion (default).
                * ``StoreInCacheByIdConversion.PDF`` — documents with main type 1, 2, 3, or 4
                  are converted to PDF.
                * ``StoreInCacheByIdConversion.MULTIPAGE_TIFF`` — TIFF documents with main type
                  2 or 3 are merged into a single multi-page TIFF.
            when_cold_then_tiff: If ``True``, ASCII COLD files are returned in TIFF format.
                Only evaluated when ``convert`` is ``NONE``.
            add_annotations: If ``True``, image annotations are burned into the returned files.

        Returns:
            A list of ``JobResponseFile`` objects representing the transferred files.

        Raises:
            ValueError: If ``model`` is an ``ECMDocumentModel`` instance with ``id`` set to
                ``None``.
        """
        object_id = model if isinstance(model, int) else model._id_  # pylint: disable=protected-access
        if object_id is None:
            raise ValueError("model.id must not be None")
        kwargs: dict = {"Flags": flags, "dwObjectID": object_id, "Convert": convert.value}
        if when_cold_then_tiff:
            kwargs["WhenCOLDThenTIFF"] = 1
        if add_annotations:
            kwargs["AddAnnotations"] = 1
        result = await self._ecm.execute(Jobs.STD_STOREINCACHEBYID, **kwargs)
        return result.files

    async def get_object_type_by_id(self, object_id: int) -> int:
        """Resolve the object type ID for a given ECM object ID.

        Args:
            object_id: The numeric ID of the ECM object to look up.

        Returns:
            The numeric object type ID associated with ``object_id``.

        Raises:
            ECMNotFoundException: If no object with ``object_id`` exists on the server.
        """
        result = await self._ecm.execute(Jobs.DMS_GETOBJECTTYPEBYID, Flags=0, ObjectID=object_id)
        object_type_id = result.get("ObjectType", int)

        if object_type_id < 0:
            raise ECMNotFoundException(f"Object type id for id {object_id} not found")

        return object_type_id

    async def digest(
        self,
        model: "ECMDocumentModel | int",
        object_type_id: int | None = None,
        *,
        calculate: bool = False,
    ) -> ECMDocumentDigest:
        """Return the digest of a document object via ``std.GetDocumentDigest``.

        Args:
            model: Either an ``ECMDocumentModel`` instance (its ``id`` is used) or a plain
                integer document object ID.
            object_type_id: The numeric object type ID. If ``None``, the type is resolved
                automatically via ``get_object_type_by_id()``.
            calculate: If ``True``, the server calculates the digest from the actual document
                files (``dwFlags=1``) and also returns ``local_digest``. If ``False``
                (default), the digest is read from the database (``dwFlags=0``) and
                ``local_digest`` is ``None``.

        Returns:
            An ``ECMDocumentDigest`` with ``table_digest`` always populated and
            ``local_digest`` set only when ``calculate=True``.

        Raises:
            ValueError: If ``model`` is an ``ECMDocumentModel`` instance with ``id`` set
                to ``None``.
            ECMNotFoundException: If ``object_type_id`` is ``None`` and no object with
                the given ID exists on the server.
        """
        object_id = model if isinstance(model, int) else model._id_  # pylint: disable=protected-access
        if object_id is None:
            raise ValueError("model.id must not be None")
        if object_type_id is None:
            object_type_id = await self.get_object_type_by_id(object_id)
        result = await self._ecm.execute(
            Jobs.STD_GETDOCUMENTDIGEST,
            dwObjectID=object_id,
            dwObjectType=object_type_id,
            dwFlags=1 if calculate else 0,
        )
        table_digest = result.get("TableDigest", str)
        local_digest = result.get("LocalDigest", str) if calculate else None
        return ECMDocumentDigest(table_digest=table_digest, local_digest=local_digest)
