# Copyright 2015-present MongoDB, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tools for representing raw BSON documents.

Inserting and Retrieving RawBSONDocuments
=========================================

Example: Moving a document between different databases/collections

.. doctest::

  >>> import bson
  >>> from pymongo import MongoClient
  >>> from bson.raw_bson import RawBSONDocument
  >>> client = MongoClient(document_class=RawBSONDocument)
  >>> client.drop_database("db")
  >>> client.drop_database("replica_db")
  >>> db = client.db
  >>> result = db.test.insert_many(
  ...     [{"_id": 1, "a": 1}, {"_id": 2, "b": 1}, {"_id": 3, "c": 1}, {"_id": 4, "d": 1}]
  ... )
  >>> replica_db = client.replica_db
  >>> for doc in db.test.find():
  ...     print(f"raw document: {doc.raw}")
  ...     print(f"decoded document: {bson.decode(doc.raw)}")
  ...     result = replica_db.test.insert_one(doc)
  ...
  raw document: b'...'
  decoded document: {'_id': 1, 'a': 1}
  raw document: b'...'
  decoded document: {'_id': 2, 'b': 1}
  raw document: b'...'
  decoded document: {'_id': 3, 'c': 1}
  raw document: b'...'
  decoded document: {'_id': 4, 'd': 1}

For use cases like moving documents across different databases or writing binary
blobs to disk, using raw BSON documents provides better speed and avoids the
overhead of decoding or encoding BSON.
"""
from __future__ import annotations

from typing import Any, ItemsView, Iterator, Mapping, Optional

from bson import _get_object_size, _raw_to_dict
from bson.codec_options import _RAW_BSON_DOCUMENT_MARKER, CodecOptions
from bson.codec_options import DEFAULT_CODEC_OPTIONS as DEFAULT


def _inflate_bson(
    bson_bytes: bytes, codec_options: CodecOptions[RawBSONDocument], raw_array: bool = False
) -> dict[str, Any]:
    """Inflates the top level fields of a BSON document.

    :param bson_bytes: the BSON bytes that compose this document
    :param codec_options: An instance of
        :class:`~bson.codec_options.CodecOptions` whose ``document_class``
        must be :class:`RawBSONDocument`.
    """
    return _raw_to_dict(bson_bytes, 4, len(bson_bytes) - 1, codec_options, {}, raw_array=raw_array)


class RawBSONDocument(Mapping[str, Any]):
    """Representation for a MongoDB document that provides access to the raw
    BSON bytes that compose it.

    Only when a field is accessed or modified within the document does
    RawBSONDocument decode its bytes.
    """

    __slots__ = ("__raw", "__inflated_doc", "__codec_options")
    _type_marker = _RAW_BSON_DOCUMENT_MARKER
    __codec_options: CodecOptions[RawBSONDocument]

    def __init__(
        self, bson_bytes: bytes, codec_options: Optional[CodecOptions[RawBSONDocument]] = None
    ) -> None:
        """Create a new :class:`RawBSONDocument`

        :class:`RawBSONDocument` is a representation of a BSON document that
        provides access to the underlying raw BSON bytes. Only when a field is
        accessed or modified within the document does RawBSONDocument decode
        its bytes.

        :class:`RawBSONDocument` implements the ``Mapping`` abstract base
        class from the standard library so it can be used like a read-only
        ``dict``::

            >>> from bson import encode
            >>> raw_doc = RawBSONDocument(encode({'_id': 'my_doc'}))
            >>> raw_doc.raw
            b'...'
            >>> raw_doc['_id']
            'my_doc'

        :param bson_bytes: the BSON bytes that compose this document
        :param codec_options: An instance of
            :class:`~bson.codec_options.CodecOptions` whose ``document_class``
            must be :class:`RawBSONDocument`. The default is
            :attr:`DEFAULT_RAW_BSON_OPTIONS`.

        .. versionchanged:: 3.8
          :class:`RawBSONDocument` now validates that the ``bson_bytes``
          passed in represent a single bson document.

        .. versionchanged:: 3.5
          If a :class:`~bson.codec_options.CodecOptions` is passed in, its
          `document_class` must be :class:`RawBSONDocument`.
        """
        self.__raw = bson_bytes
        self.__inflated_doc: Optional[Mapping[str, Any]] = None
        # Can't default codec_options to DEFAULT_RAW_BSON_OPTIONS in signature,
        # it refers to this class RawBSONDocument.
        if codec_options is None:
            codec_options = DEFAULT_RAW_BSON_OPTIONS
        elif not issubclass(codec_options.document_class, RawBSONDocument):
            raise TypeError(
                "RawBSONDocument cannot use CodecOptions with document "
                f"class {codec_options.document_class}"
            )
        self.__codec_options = codec_options
        # Validate the bson object size.
        _get_object_size(bson_bytes, 0, len(bson_bytes))

    @property
    def raw(self) -> bytes:
        """The raw BSON bytes composing this document."""
        return self.__raw

    def items(self) -> ItemsView[str, Any]:
        """Lazily decode and iterate elements in this document."""
        return self.__inflated.items()

    @property
    def __inflated(self) -> Mapping[str, Any]:
        if self.__inflated_doc is None:
            # We already validated the object's size when this document was
            # created, so no need to do that again.
            self.__inflated_doc = self._inflate_bson(self.__raw, self.__codec_options)
        return self.__inflated_doc

    @staticmethod
    def _inflate_bson(
        bson_bytes: bytes, codec_options: CodecOptions[RawBSONDocument]
    ) -> Mapping[str, Any]:
        return _inflate_bson(bson_bytes, codec_options)

    def __getitem__(self, item: str) -> Any:
        return self.__inflated[item]

    def __iter__(self) -> Iterator[str]:
        return iter(self.__inflated)

    def __len__(self) -> int:
        return len(self.__inflated)

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, RawBSONDocument):
            return self.__raw == other.raw
        return NotImplemented

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}({self.raw!r}, codec_options={self.__codec_options!r})"


class _RawArrayBSONDocument(RawBSONDocument):
    """A RawBSONDocument that only expands sub-documents and arrays when accessed."""

    @staticmethod
    def _inflate_bson(
        bson_bytes: bytes, codec_options: CodecOptions[RawBSONDocument]
    ) -> Mapping[str, Any]:
        return _inflate_bson(bson_bytes, codec_options, raw_array=True)


DEFAULT_RAW_BSON_OPTIONS: CodecOptions[RawBSONDocument] = DEFAULT.with_options(
    document_class=RawBSONDocument
)
_RAW_ARRAY_BSON_OPTIONS: CodecOptions[_RawArrayBSONDocument] = DEFAULT.with_options(
    document_class=_RawArrayBSONDocument
)
"""The default :class:`~bson.codec_options.CodecOptions` for
:class:`RawBSONDocument`.
"""