# Copyright (C) 2020-2022 HERE Global B.V. and its affiliate(s).
# All rights reserved.
#
# This software and other materials contain proprietary information
# controlled by HERE and are protected by applicable copyright legislation.
# Any use and utilization of this software and other materials and
# disclosure to any third parties is conditional upon having a separate
# agreement with HERE for the access, use, utilization or disclosure of this
# software. In the absence of such agreement, the use of the software is not
# allowed.

"""HERE platform catalog abstraction."""

import logging
import os
import time
import webbrowser
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Dict, Iterator, List, Mapping, Optional, Union, cast

from deprecated import deprecated
from here.platform.adapter import Adapter
from here.platform.api.aaa_authorization_api import AAAAuthorizationApi
from here.platform.api.artifact_api import ArtifactApi
from here.platform.api.base_api import BaseApi
from here.platform.api.data_blob_api import DataBlobApi
from here.platform.api.data_index_api import DataIndexApi
from here.platform.api.data_ingest_api import DataIngestApi
from here.platform.api.data_interactive_api import DataInteractiveApi
from here.platform.api.data_metadata_api import DataMetadataApi
from here.platform.api.data_object_blob_api import DataObjectBlobApi
from here.platform.api.data_publish_api import DataPublishApi
from here.platform.api.data_query_api import DataQueryApi
from here.platform.api.data_statistics_api import DataStatisticsApi
from here.platform.api.data_stream_api import DataStreamApi
from here.platform.api.data_volatile_blob_api import DataVolatileBlobApi
from here.platform.api.factory.apifactory import API, APIFactory
from here.platform.exceptions import CatalogConfigurationException, LayerConfigurationException
from here.platform.layer import (
    IndexLayer,
    InteractiveMapLayer,
    Layer,
    LayerConfiguration,
    LayerType,
    ObjectStoreLayer,
    StreamIngestion,
    StreamLayer,
    VersionedLayer,
    VolatileLayer,
)
from here.platform.model import Publication, PublicationState
from here.platform.model.version_dependency import VersionDependency
from here.platform.models import (
    AutomaticVersionDeletion,
    Coverage,
    Notifications,
    Owner,
    Replication,
)
from here.platform.partition import Partition
from here.platform.utils import JsonDictDocument

if TYPE_CHECKING:
    import pandas as pd
    from here.platform.platform import Platform

logger = logging.getLogger(__name__)


@deprecated(version="2.18", reason="Please use here.platform.model.VersionDependency")
class Dependency(VersionDependency):
    """
    This class is deprecated. Please use here.platform.model.VersionDependency.
    """

    pass


class Version(JsonDictDocument):
    """
    Represents a catalog version with its details.
    """

    @property
    def version(self) -> int:
        """The version number"""
        return int(self.json["version"])

    @property
    def dependencies(self) -> List[VersionDependency]:
        """The list of dependencies on external catalogs and their versions"""
        return [VersionDependency.from_dict(d) for d in self.json["dependencies"]]

    @property
    def shared_dependencies(self) -> List[VersionDependency]:
        """The list of dependencies on external catalogs and their versions"""
        return [VersionDependency.from_dict(d) for d in self.json["sharedDependencies"]]


class CatalogConfiguration(JsonDictDocument):
    """
    The configuration of a catalog, including its most significant properties
    """

    @property
    def id(self) -> str:
        """The ID of the catalog"""
        return str(self.json["id"])

    @property
    def hrn(self) -> str:
        """The HERE Resource Name (HRN) of the catalog"""
        return str(self.json["hrn"])

    @property
    def name(self) -> str:
        """The name of the catalog"""
        return str(self.json["name"])

    @property
    def summary(self) -> Optional[str]:
        """The summary of the catalog"""
        return self.json.get("summary")

    @property
    def description(self) -> Optional[str]:
        """A longer description of the catalog"""
        return self.json.get("description")

    @property
    def tags(self) -> List[str]:
        """List of user-defined tags applied to the catalog"""
        return [str(t) for t in self.json.get("tags", [])]

    @property
    def coverage(self) -> Optional[Coverage]:
        """The geographic area that this catalog covers"""
        return Coverage(self.json["coverage"])

    @property
    def billing_tag(self) -> Optional[List[str]]:
        """List of billing tags used for grouping billing records together for the catalog"""
        return [str(t) for t in self.json.get("billingTags", [])]

    @property
    def created(self) -> datetime:
        """Timestamp, in ISO 8601 format, when the catalog was initially created"""
        return datetime.strptime(self.json["created"], "%Y-%m-%dT%H:%M:%S.%fZ")

    @property
    def owner(self) -> Optional[Owner]:
        """The owner of the catalog"""
        return Owner(self.json["owner"])

    @property
    def replication(self) -> Optional[Replication]:
        """The replication set for the catalog"""
        return Replication(self.json["replication"])

    @property
    def automatic_version_deletion(self) -> Optional[AutomaticVersionDeletion]:
        """Specifies the number of versions to keep for the catalog"""
        return AutomaticVersionDeletion(self.json["automaticVersionDeletion"])

    @property
    def layers(self) -> List[LayerConfiguration]:
        """The layers in the catalog"""
        return [LayerConfiguration.from_dict(t) for t in self.json["layers"]]

    @property
    def version(self) -> int:
        """The version of the catalog configuration"""
        return int(self.json["version"])

    @property
    def notifications(self) -> Optional[Notifications]:
        """Indicates whether or not to notify each time the version of the catalog changes"""
        return Notifications(self.json["notifications"])


class Catalog:
    """HERE platform catalog abstraction."""

    def __init__(
        self,
        hrn: str,
        platform: "Platform",
        adapter: Optional[Adapter] = None,
        billing_tag: Optional[str] = None,
    ):
        """
        Instantiate catalog given its HRN, API instances, and schema registry.

        :param hrn: the HERE Resource Name of the catalog
        :param platform: a Platform instance
        :param adapter: the ``Adapter`` to transform data between different representations
          ``None`` to use the default adapter of the platform.
        :param billing_tag: A string to represent a grouping of billing records.
        """
        self.hrn = hrn
        self.platform = platform
        self.adapter: Adapter = adapter or self.platform.adapter
        self._data_config_api = self.platform.data_config_api  # type: ignore
        self._base_api: BaseApi = self.platform.base_api
        self.billing_tag: Optional[str] = billing_tag
        self._resource_apis = self.platform.lookup_api.get_resource_api_list(hrn)

        self._api_factory: APIFactory = self.platform.api_factory

        # These are lazy-loaded
        self._details: Optional[dict] = None
        self._layer_details: Optional[Dict[str, dict]] = None
        self._configuration: Optional[CatalogConfiguration] = None
        self._layer_configuration: Optional[Dict[str, LayerConfiguration]] = None

    @property
    def _data_blob_api(self) -> DataBlobApi:
        """
        Lazy loads DataBlob API.

        :return: DataBlobApi instance.
        """
        return cast(DataBlobApi, self._api_factory.get_api(API.DATA_BLOB, self.hrn))

    @property
    def _data_volatile_blob_api(self) -> DataVolatileBlobApi:
        """
        Lazy loads DataVolatileBlob API.

        :return: DataVolatileBlobApi instance.
        """
        return cast(
            DataVolatileBlobApi, self._api_factory.get_api(API.DATA_VOLATILE_BLOB, self.hrn)
        )

    @property
    def _data_metadata_api(self) -> DataMetadataApi:
        """
        Lazy loads DataMetadata API.

        :return: DataMetadataApi instance.
        """
        return cast(DataMetadataApi, self._api_factory.get_api(API.DATA_METADATA, self.hrn))

    @property
    def _data_publish_api(self) -> DataPublishApi:
        """
        Lazy loads DataPublish API.

        :return: DataPublishApi instance.
        """
        return cast(DataPublishApi, self._api_factory.get_api(API.DATA_PUBLISH, self.hrn))

    @property
    def _data_query_api(self) -> DataQueryApi:
        """
        Lazy loads DataQuery API.

        :return: DataQueryApi instance.
        """
        return cast(DataQueryApi, self._api_factory.get_api(API.DATA_QUERY, self.hrn))

    @property
    def _data_index_api(self) -> DataIndexApi:
        """
        Lazy loads DataIndex API.

        :return: DataIndexApi instance.
        """
        return cast(DataIndexApi, self._api_factory.get_api(API.DATA_INDEX, self.hrn))

    @property
    def _data_stream_api(self) -> DataStreamApi:
        """
        Lazy loads DataStream API.

        :return: DataStreamApi instance.
        """
        return cast(DataStreamApi, self._api_factory.get_api(API.DATA_STREAM, self.hrn))

    @property
    def _artifact_api(self) -> ArtifactApi:
        """
        Lazy loads Artifact API.

        :return: ArtifactApi instance.
        """
        return cast(ArtifactApi, self._api_factory.get_api(API.ARTIFACT, self.hrn))

    @property
    def _data_ingest_api(self) -> DataIngestApi:
        """
        Lazy loads DataIngest API.

        This API is not implemented on the Local Data Service.

        :return: DataIngestApi instance
        """
        return cast(DataIngestApi, self._api_factory.get_api(API.DATA_INGEST, self.hrn))

    @property
    def _data_interactive_api(self) -> DataInteractiveApi:
        """
        Lazy loads DataInteractive API.

        This API is not implemented on the Local Data Service.

        :return: DataInteractiveApi instance
        """
        return cast(DataInteractiveApi, self._api_factory.get_api(API.DATA_INTERACTIVE, self.hrn))

    @property
    def _data_object_blob_api(self) -> DataObjectBlobApi:
        """
        Lazy loads DataObjectBlobApi API.

        This API is not implemented on the Local Data Service.

        :return: DataObjectBlobApi instance
        """
        return cast(DataObjectBlobApi, self._api_factory.get_api(API.DATA_OBJECT_BLOB, self.hrn))

    @property
    def _data_statistics_api(self) -> DataStatisticsApi:
        """
        Lazy loads DataStatisticsApi API.

        This API is not implemented on the Local Data Service.

        :return: DataStatisticsApi instance
        """
        return cast(DataStatisticsApi, self._api_factory.get_api(API.DATA_STATISTICS, self.hrn))

    @property
    def _aaa_auth_api(self) -> AAAAuthorizationApi:
        """
        Returns parent platform's AAAAuthorization instance.

        This API is not implemented on the Local Data Service.

        :return: AAAAuthorization instance
        """
        return self.platform.aaa_auth_api  # type: ignore

    def _ensure_catalog_config_loaded(self):
        if not self._details:
            json_config = self._data_config_api.get_catalog_details(catalog_hrn=self.hrn)
            self._details = json_config  # TODO: remove
            self._layer_details = {
                layer["id"]: layer for layer in json_config["layers"]
            }  # TODO: remove
            self._configuration = CatalogConfiguration.from_dict(json_config)
            self._layer_configuration = {
                layer["id"]: LayerConfiguration.from_dict(layer) for layer in json_config["layers"]
            }

    def _invalidate_catalog_config(self):
        self._details = None
        self._layer_details = None
        self._configuration = None
        self._layer_configuration = None

    def lookup_apis(self, region: Optional[str] = None) -> dict:
        """
        Lookup implemented APIs for the given HRN.

        :param region: an Optional param to look up a specific region for a given resource
        :return: dictionary with API descriptions for the HRN
        """
        res: dict = self.platform.lookup_api.get_resource_api_list(hrn=self.hrn, region=region)
        return res

    @property
    def configuration(self) -> CatalogConfiguration:
        """The configuration of the catalog"""
        self._ensure_catalog_config_loaded()
        assert self._configuration is not None
        return self._configuration

    def get_details(self) -> dict:
        """
        Fetch and buffer catalog details from the platform.

        :return: catalog details
        """
        self._ensure_catalog_config_loaded()
        assert self._details is not None
        return self._details

    def has_layer(self, layer_id: str) -> bool:
        """
        Check if a layer ID exists in the catalog.

        :param layer_id: layer ID in the catalog.
        :return: a boolean value indicating if a layer exists
        """
        self._ensure_catalog_config_loaded()
        assert self._layer_details is not None
        return layer_id in self._layer_details

    def first_version(self) -> Optional[int]:
        """
        Return first version of the catalog available on the platform.

        :return: first version number of the catalog if present, ``None`` otherwise
        """
        return self._data_metadata_api.get_minimum_version(billing_tag=self.billing_tag)

    def latest_version(self) -> Optional[int]:
        """
        Return latest version of the catalog available on the platform.

        :return: latest version number of the catalog if present, ``None`` otherwise
        """
        return self._data_metadata_api.get_latest_version(billing_tag=self.billing_tag)

    def list_versions(
        self, start: Optional[int] = None, end: Optional[int] = None
    ) -> List[Version]:
        """
        Return list of all catalog versions available on the platform, with one dict each.

        :param start: An int to represent start version of catalog, ``None`` if unspecified
        :param end: An int to represent end version of catalog, ``None`` if unspecified
        :return: list of all catalog versions beginning at ``start + 1``and continuing
                through (and including) ``end``
        """
        if start is None:
            start = -1
        if end is None:
            end = self.latest_version()
        if end is None or start >= end:
            return []
        limit = 1000
        versions = []
        for i in range(start, end, limit):
            list_versions_resp = self._data_metadata_api.list_versions(
                i, i + limit if i + limit < end else end, billing_tag=self.billing_tag
            )
            if "versions" in list_versions_resp:
                versions.extend([Version(version) for version in list_versions_resp["versions"]])
        return versions

    def get_version(self, version: int) -> Version:
        """
        Return details of a single version which is specified.

        :param version: An int to specify the specific version
        for which catalog details are needed
        :return: details of the version specified
        :raises ValueError:The value of the version is not an integer
        or when the value is greater than the latest catalog version or less than 0
        """
        if type(version) == int and version >= 0:
            start = version - 1
            end = version
            latest = self.latest_version()
        else:
            raise ValueError(
                f"Invalid Version value -> {version} ,expected positive integer value."
            )
        if latest is None:
            latest = -1
        if start >= latest:
            raise ValueError(f"Version {version} does not exist.")
        get_version_resp = self._data_metadata_api.list_versions(
            start, end, billing_tag=self.billing_tag
        )
        return Version(get_version_resp["versions"][0])

    def get_layers_version(self, catalog_version: int) -> Dict[str, int]:
        """
        Return list of all available layers and layer version
        for a given catalog version. Layer version is always
        less or equal than the catalog version and it represent
        the version when the layer was last updated.

        :param catalog_version: An int to represent a catalog version
        :return: all available layers with their version
        """
        layer_versions_resp = self._data_metadata_api.get_layers_version(
            catalog_version, billing_tag=self.billing_tag
        )
        if "layerVersions" in layer_versions_resp:
            return {lv["layer"]: lv["version"] for lv in layer_versions_resp["layerVersions"]}
        else:
            return {}

    def compatible_versions(
        self, catalog_dependencies: dict, limit: Optional[int] = 1
    ) -> Iterator[List[Version]]:
        """
        Return compatible versions iterator which returns list of Versions.
        Each Version object contains version and shared_dependencies attribute.
        :param catalog_dependencies: The catalog dependencies we want to search for
        :param limit: number of records in api response.
        :yields: List of class: `Version` objects.
        """

        is_next = True
        url = None

        while is_next:
            resp_json = self._data_metadata_api.compatible_versions(
                catalog_dependencies, limit, next=url
            )
            url = resp_json.get("next")
            is_next = True if url else False
            yield [Version(version) for version in resp_json["versions"]]

    def _update_layer_in_catalog(self, catalog_details, layer_id: str, layers: list) -> Layer:
        """
        Updates the catalog with :param:`catalog_details` and :param:`layers`.
        :param catalog_details: Details of catalog.
        :param layer_id: id of layer that needs to be added or updated in the catalog
        :param layers: list of layers
        :returns: object of added layer
        """
        assert any([layer["id"] == layer_id for layer in layers])
        self.platform.update_catalog(
            self.hrn,
            layers=layers,
        )
        updated_catalog = self.platform.get_catalog(catalog_details["hrn"])
        return updated_catalog.get_layer(layer_id)

    def add_layer(
        self,
        id: str,
        layer_type: str,
        name: str,
        summary: str,
        description: str,
        content_type: str,
        **details,
    ) -> Layer:
        """
        Adds a layer to the catalog and returns the object of the created layer.

        :param id: ID of the layer to be added
        :param layer_type: layer_type of the layer to be added
        :param name: name of the layer to be added
        :param summary: summary of the layer to be added
        :param description: description of the layer to be added
        :param content_type: content_type of the layer to be added
        :param details: other details of the layer to be added
        :return: the object of added layer
        :raises CatalogConfigurationException: if the catalog already
            contains a layer with ID :param:`id`
        """
        details["id"] = id
        details["layerType"] = layer_type
        details["name"] = name
        details["summary"] = summary
        details["description"] = description
        details["contentType"] = content_type
        if self.has_layer(details["id"]):
            raise CatalogConfigurationException(
                "Layer {} already exists in catalog {}".format(
                    details["id"], self.configuration.json["name"]
                )
            )
        catalog_details = self.configuration.json
        layers = catalog_details["layers"]
        layers.append(details)
        return self._update_layer_in_catalog(catalog_details, details["id"], layers)

    def update_layer(
        self,
        layer_id: str,
        layer_type: str,
        name: str,
        summary: str,
        description: str,
        content_type: str,
        **details,
    ):
        """
        Updates the layer with :param:`layer_id` with given layer details.

        :param layer_id: ID of the layer to be updated
        :param layer_type: layer_type of the layer to be updated
        :param name: name of the layer to be updated
        :param summary: summary of the layer to be updated
        :param description: description of the layer to be updated
        :param content_type: content_type of the layer to be updated
        :param details: other details of the layer to be updated
        :raises CatalogConfigurationException: if the catalog has no layer
            with the given ID :param:`layer_id`
        """
        details["id"] = id
        details["layerType"] = layer_type
        details["name"] = name
        details["summary"] = summary
        details["description"] = description
        details["contentType"] = content_type
        if not self.has_layer(layer_id):
            raise CatalogConfigurationException(
                f"Layer {layer_id} does not exist in catalog {self.configuration.json['name']}"
            )
        catalog_details = self.configuration.json
        layers = catalog_details["layers"]
        for i in range(0, len(layers)):
            if layers[i]["id"] == layer_id:
                details["id"] = layer_id
                layers[i] = details
                break
        self._update_layer_in_catalog(catalog_details, layer_id, layers)

    def get_layer(self, layer_id: str) -> Layer:
        """
        Create and return a :class:`Layer` object for the given layer ID.

        :param layer_id: layer ID
        :return: the layer requested
        :raises ValueError: Unknown layer id
        """
        self._ensure_catalog_config_loaded()
        assert self._layer_details is not None
        if layer_id in self._layer_details:
            layer_type = self._layer_details[layer_id]["layerType"]
            layer_type_lower = layer_type.lower()
            if layer_type_lower == LayerType.VERSIONED.name.lower():
                return VersionedLayer(layer_id=layer_id, catalog=self)
            elif layer_type_lower == LayerType.VOLATILE.name.lower():
                return VolatileLayer(layer_id=layer_id, catalog=self)
            elif layer_type_lower == LayerType.STREAM.name.lower():
                return StreamLayer(layer_id=layer_id, catalog=self)
            elif layer_type_lower == LayerType.INDEX.name.lower():
                return IndexLayer(layer_id=layer_id, catalog=self)
            elif layer_type_lower == LayerType.INTERACTIVEMAP.name.lower():
                return InteractiveMapLayer(layer_id=layer_id, catalog=self)
            elif layer_type_lower == LayerType.OBJECTSTORE.name.lower():
                return ObjectStoreLayer(layer_id=layer_id, catalog=self)
            else:
                return Layer(layer_id=layer_id, catalog=self)
        raise ValueError(f"Unknown layer id {layer_id}")

    def list_layers(self) -> List[Layer]:
        """
        Return a list of all the layers present in the catalog.

        :return: a list of layers
        """
        self._ensure_catalog_config_loaded()
        assert self._layer_details is not None
        return [self.get_layer(id) for id in self._layer_details]

    def open_in_portal(self):
        """Open the catalog page on the HERE platform portal."""
        portal_url = self.platform.platform_config.portal_url
        if portal_url is None:
            raise ValueError("here_platform_portal_url is not present in configuration.")
        webbrowser.open_new(f"{portal_url}/data/{self.hrn}")

    def modify_layer(self, layer_id: str, **details):
        """
        Modify the catalog layer details.

        :param layer_id: the identifier of the layer
        :param details: Details of the catalog to be modified

        The documentation for the API endpoint used can be found here:
        |update_layer|

        .. |update_layer| raw:: html

          <a href="https://developer.here.com/documentation/data-api/api-reference-config.html" target="_blank">Update a catalog layer</a>  # noqa E501

        Usage::

          >>> from here.platform import Platform
          >>> platform = Platform()
          >>> catalog = platform.get_catalog("hrn:here:data::olp-here:example") # doctest: +SKIP
          >>> catalog.modify_layer('layer-id', name='updated-name', description='updated-description') # doctest: +SKIP
        """
        if not details:
            return
        response = self._data_config_api.patch_layer(self.hrn, layer_id=layer_id, data=details)
        status_response, complete = self._data_config_api.get_catalog_status(response["href"])
        while not complete:
            logger.debug(f"status polling wait {self.platform._polling_wait} sec.")
            time.sleep(self.platform._polling_wait)
            status_response, complete = self._data_config_api.get_catalog_status(response["href"])
        logger.info(f"Layer update for hrn: {self.hrn} finished successfully.")
        self._invalidate_catalog_config()

    def delete_layer(self, layer_id: str):
        """Delete the catalog layer. Supported layer-types: volatile, stream, index.

        :param layer_id: the identifier of the layer
        :raises CatalogConfigurationException: in case the catalog configuration is not accepted
        :raises LayerConfigurationException: in case the layer configuration is not accepted

        Usage::

          >>> from here.platform import Platform
          >>> platform = Platform()
          >>> catalog = platform.get_catalog("hrn:here:data::olp-here:example") # doctest: +SKIP
          >>> catalog.delete_layer("layer-id") # doctest: +SKIP
        """
        response = self._data_config_api.delete_layer(self.hrn, layer_id)
        while True:
            logger.debug(f"status polling wait {self.platform._polling_wait} sec.")
            time.sleep(self.platform._polling_wait)
            status_response, complete = self._data_config_api.get_catalog_status(
                response["href"], billing_tag=self.billing_tag
            )
            if "status" in status_response and status_response["status"] == "pending":
                continue
            if "status" in status_response and status_response["status"] == "failure":
                raise CatalogConfigurationException(status_response)
            if "layers" in status_response:
                for layer in status_response["layers"]:
                    if layer["id"] == layer_id:
                        raise LayerConfigurationException(status_response)
                break
            if complete:
                raise LayerConfigurationException("Completed without layers response.")
        logger.info(f"Layer deletion for hrn: {self.hrn} finished successfully.")
        self._invalidate_catalog_config()

    def init_publication(
        self,
        layers: Union[List[str], List[Layer]],
        dependencies: Optional[List[VersionDependency]] = [],
    ) -> Publication:
        """
        Initialize a new publication, indicating which layers will be affected.

        Pass the returned ``Publication`` to the functions that write to the layers.
        Call `Publication.complete()` to complete the work.

        Dependencies with other catalogs and relative versions can be published together with
        the data. Submitting a new publication that affects one or more versioned layer
        determines the creation of a new catalog version with the dependencies specified.

        :param layers: which layers are affected by the publication
        :param dependencies: list of dependencies to be use in case a new version is created
        :return: a new publication valid for the layers specified  # noqa
        """
        layers_list = [self.get_layer(x) if isinstance(x, str) else x for x in layers]
        publication = Publication(self, layers=layers_list, dependencies=dependencies)
        return publication

    def retrieve_publication(self, publication_id: str) -> Publication:
        """
        Retrieves an existing publication based on its ID.

        :param publication_id: the ID for an already active publication.
        :return: a publication for the ID.
        :raises ValueError: the publication isn't in an initialized (or open) state or a layer from
          the publication isn't present in the current catalog.
        """
        publication_details = self._data_publish_api.get_publication(
            publication_id, self.billing_tag
        )
        state = PublicationState(publication_details["details"]["state"])
        if state != PublicationState.INITIALIZED:
            raise ValueError(
                f"Publication ID {publication_id} has an invalid state: {state.value}"
            )

        layer_ids: List[str] = publication_details.get("layerIds", [])
        layers = [self.get_layer(layer_id) for layer_id in layer_ids]

        version_dependencies: List[Dict[str, Union[bool, int, str]]] = publication_details.get(
            "versionDependencies", []
        )
        if version_dependencies:
            dependencies: Optional[List[VersionDependency]] = [
                VersionDependency.from_dict(depend) for depend in version_dependencies
            ]
        else:
            dependencies = None

        catalog_version = cast(Optional[int], publication_details.get("catalogVersion"))
        return Publication(self, layers, dependencies, (publication_id, catalog_version))

    def write_versioned_layers(
        self,
        layers_write_info: Mapping[str, Mapping[Union[str, int], Union[str, Path, bytes]]],
        version_dependencies: Optional[List[VersionDependency]] = None,
        encode: bool = False,
        adapter: Optional[Adapter] = None,
    ):
        """
        Write data to versioned layers of a catalog, creating one single new version.

        Data for each layer must be encoded according to the content type configured for the layer.

        :param layers_write_info: a nested dict containing layer_ids, partition_ids and
          encoded data in the form of bytes or file names or paths which will be uploaded
        :param version_dependencies: A list of version dependencies
        :param encode: whether to encode the data or upload raw bytes
        :param adapter: the ``Adapter`` to transform the input data.
          ``None`` to use the default adapter of the catalog.
        :raises ValueError: in case one the layers specified is of the wrong type
        """

        layer_ids: List[str] = list(layers_write_info.keys())
        publication = self.init_publication(
            layers=layer_ids,
            dependencies=version_dependencies,
        )
        for layer_id, data in layers_write_info.items():
            layer = self.get_layer(layer_id=layer_id)
            if not isinstance(layer, VersionedLayer):
                raise ValueError(f"Layer {layer.id} is not a versioned layer")
            layer.write_partitions(
                publication=publication, data=data, encode=encode, adapter=adapter
            )
        publication.complete()

    def write_stream_layers(
        self,
        layers_write_info: Mapping[str, Mapping[Union[str, int], Union[str, Path, bytes]]],
        timestamp: Optional[int] = None,
        encode: bool = False,
        inline_data_limit: int = 819200,
        adapter: Optional[Adapter] = None,
    ):
        """
        Write data to stream layers of a catalog.

        Data for each layer must be encoded according to the content type configured for the layer.

        :param layers_write_info: a nested dict containing layer_ids, partition_ids and
            encoded data in the form of bytes or file names or paths which will be uploaded.
        :param timestamp: optional timestamp for all the messages:
            in milliseconds since Unix epoch (1970-01-01T00:00:00 UTC)
        :param inline_data_limit: threshold data size in bytes to decide if
            inline stream data field should be populated, if data size
            is less than the inline_data_limit then the data would
            be added to StreamPartition.data field or else blob would
            be uploaded and its data_handle will be added to
            StreamPartition.data_handle field.
            Default is 819200 bytes.
        :param encode: whether to encode the data or upload raw bytes
        :param adapter: the ``Adapter`` to transform the input data.
          ``None`` to use the default adapter of the catalog.
        :raises ValueError: in case one the layers specified is of the wrong type
        """

        # publication is not needed anymore for writing stream layer
        # publication = self.init_publication(layers=layer_ids)
        for layer_id, data in layers_write_info.items():
            layer = self.get_layer(layer_id=layer_id)
            if not isinstance(layer, StreamLayer):
                raise ValueError(f"Layer {layer.id} is not a stream layer")
            layer.write_stream(
                data=data,
                encode=encode,
                timestamp=timestamp,
                inline_data_limit=inline_data_limit,
                adapter=adapter,
            )
        # publication.complete()

    def write_volatile_layers(
        self,
        layers_write_info: Mapping[str, Mapping[Union[str, int], Union[str, Path, bytes]]],
    ):
        """
        Write data to volatile layers of a catalog.

        Data for each layer must be encoded according to the content type configured for the layer.

        :param layers_write_info: a nested dict containing layer_ids, partition_ids and
          encoded data in the form of bytes or file names or paths which will be uploaded.
        :raises ValueError: in case one the layers specified is of the wrong type
        """

        layer_ids: List[str] = list(layers_write_info.keys())
        publication = self.init_publication(layers=layer_ids)
        for layer_id, data in layers_write_info.items():
            layer = self.get_layer(layer_id=layer_id)
            if not isinstance(layer, VolatileLayer):
                raise ValueError(f"Layer {layer.id} is not a volatile layer")
            layer.write_partitions(publication=publication, data=data, encode=False)
        publication.complete()

    def ingest_sdii(
        self,
        layer_id: str,
        partition_id: Union[str, int],
        path_or_data: Union[str, Path, bytes],
        checksum: Optional[str] = None,
        trace_id: Optional[str] = None,
    ) -> StreamIngestion:
        """
        Ingest sensor data in SDII format in a stream layer.

        :param layer_id: the id of the stream layer
        :param partition_id: A unique id to specify the partition
        :param path_or_data: Data to be uploaded in the partition. It must be less than 20 MB
        :param checksum: A base64 encoded SHA-256 hash of the data
        :param trace_id: A unique id to track your request and identify the message in a catalog
        :returns: Trace and message IDs returned by the service
        :raises ValueError: Data size is greater than the permissible limit of 20 MB

        Example::

        >>> from here.platform import Platform
        >>> platform = Platform()
        >>> catalog = platform.get_catalog("catalog_hrn") # doctest: +SKIP
        >>> layer_id = "layer_id" # doctest: ´+SKIP
        >>> partition_id = "partition_id" # doctest: +SKIP
        >>> data = b'Some valid data in SDII format' # doctest: +SKIP
        >>> catalog.ingest_sdii(layer_id, partition_id, data) # doctest: +SKIP
        """
        if isinstance(path_or_data, bytes):
            data_size = len(path_or_data)
        else:
            if not Path(path_or_data).is_file():
                raise ValueError(f"File: {path_or_data} does not exist.")

            data_size = os.path.getsize(path_or_data)

        if data_size > 20 * 1024 * 1024:
            raise ValueError(
                "ingest_sdii function supports writing SDII data to"
                " stream layer up to 20 MB. For larger data sets, use"
                " write_stream_layer functionality."
            )

        partition_id = str(partition_id)
        if isinstance(path_or_data, bytes):
            data = path_or_data
        else:
            with Partition.get_data_handler(path_or_data) as data_handler:
                data = data_handler.read()

        response = self._data_ingest_api.ingest_sdii(
            layer_id=layer_id,
            body=data,
            checksum_header=checksum,
            traceid_header=trace_id,
            message_key_header=partition_id,
            billing_tag=self.billing_tag,
        )

        return StreamIngestion(response)

    def write_index_layer(
        self,
        layer: Union[str, Layer],
        path_or_data: Union[str, Path, bytes, "pd.DataFrame"],
        timestamp: Optional[int] = None,
        fields: Dict[str, Union[str, int, bool]] = {},
        additional_metadata: Dict[str, str] = {},
        encode: bool = True,
        adapter: Optional[Adapter] = None,
        **kwargs,
    ):
        """
        Write data to an index layer.

        :param layer: the layer or ID of the layer
        :param path_or_data: a file name or path or bytes with encoded content.
          Alternatively, DataFrame is supported only for layers with content-type
          application/x-parquet when using a ``GeoPandasAdapter``.
        :param timestamp: timestamp
        :param fields: a dict representing the fields of index record for data being uploaded
        :param additional_metadata: a dict of additional metadata about data being uploaded
        :param encode: whether to encode the data through an adapter or store raw bytes
        :param adapter: the ``Adapter`` to transform the input data.
          ``None`` to use the default adapter of the catalog.
        :param kwargs: adapter-specific, please consult the documentation
          of the specific adapter to for the parameters and types it supports
        :raises ValueError: in case the specified layer is of the wrong type

        Example::

        >>> from here.platform import Platform
        >>> platform = Platform()
        >>> catalog = platform.get_catalog(hrn="hrn:here:data::olp-here:write-function-test")
        >>> layer_id = "sample_index_layer"
        >>> file_path = "~/sample_data.parquet"
        >>> fields = {"ingestionTime": 1114, "partition_id": 343433}
        >>> catalog.write_index_layer(layer_id, path_or_data=file_path, # doctest: +SKIP
        >>>                           fields=fields, encode=False) # doctest: +SKIP
        """

        layer = layer if isinstance(layer, Layer) else self.get_layer(layer)
        if not isinstance(layer, IndexLayer):
            raise ValueError(f"Layer {layer.id} is not an index layer")

        layer.write_single_partition(
            data=path_or_data,
            timestamp=timestamp,
            fields=fields,
            additional_metadata=additional_metadata,
            encode=encode,
            adapter=adapter,
            **kwargs,
        )

    def grant_access(self, entity_type: str, entity_id: str, action: str):
        """
        Grants access to a catalog to an entity.

        :param entity_type: Type of entity app, user or group.
        :param entity_id: a unique identifier for the given entity type.
        :param action: an action type to grant access - read, write, manage.
        """
        self._aaa_auth_api.add_grant(
            resource_hrn=self.hrn,
            entity_id=entity_id,
            entity_type=entity_type,
            action_id=f"{action}Resource",
        )

    def revoke_access(self, entity_type, entity_id, action):
        """
        Revokes access to a catalog from an entity.

        :param entity_type: Type of entity app, user or group.
        :param entity_id: a unique identifier for the given entity type.
        :param action: an action type to grant access - read, write, manage.
        """
        self._aaa_auth_api.remove_grant(
            resource_hrn=self.hrn,
            entity_id=entity_id,
            entity_type=entity_type,
            action_id=f"{action}Resource",
        )

    def share(self, entity_type: str, entity_id: str):
        """
        Share a catalog with an entity.

        :param entity_type: Type of entity app, user or a group.
        :param entity_id: a unique identifier for the given entity type.
        """
        self._aaa_auth_api.share_authorization(
            resource_hrn=self.hrn, entity_type=entity_type, entity_id=entity_id
        )
