# coding: utf-8
from __future__ import annotations

import os
import random
import shutil
from collections import namedtuple
from typing import Callable, Dict, List, NamedTuple, Optional, Tuple, Union
from uuid import UUID

import numpy as np
from tqdm import tqdm

import supervisely.imaging.image as sly_image
import supervisely.pointcloud.pointcloud as sly_pointcloud
from supervisely._utils import batched
from supervisely.api.api import Api
from supervisely.api.module_api import ApiField
from supervisely.api.pointcloud.pointcloud_api import PointcloudInfo
from supervisely.collection.key_indexed_collection import KeyIndexedCollection
from supervisely.io.fs import (
    copy_file,
    dir_exists,
    ensure_base_path,
    file_exists,
    get_file_name,
    get_file_name_with_ext,
    list_files,
    remove_dir,
    silent_remove,
    touch,
)
from supervisely.io.json import dump_json_file, load_json_file
from supervisely.pointcloud_annotation.constants import OBJECT_KEY
from supervisely.pointcloud_annotation.pointcloud_annotation import PointcloudAnnotation
from supervisely.project.project import Dataset, OpenMode
from supervisely.project.project import read_single_project as read_project_wrapper
from supervisely.project.project_meta import ProjectMeta
from supervisely.project.project_type import ProjectType
from supervisely.project.video_project import VideoDataset, VideoProject
from supervisely.sly_logger import logger
from supervisely.task.progress import Progress, tqdm_sly
from supervisely.video_annotation.key_id_map import KeyIdMap


class PointcloudItemPaths(NamedTuple):
    #: :class:`str`: Full pointcloud file path of item
    pointcloud_path: str

    #: :class:`str`: Path to related images directory of item
    related_images_dir: str

    #: :class:`str`: Full annotation file path of item
    ann_path: str


class PointcloudItemInfo(NamedTuple):
    #: :class:`str`: Item's dataset name
    dataset_name: str

    #: :class:`str`: Item name
    name: str

    #: :class:`str`: Full pointcloud file path of item
    pointcloud_path: str

    #: :class:`str`: Path to related images directory of item
    related_images_dir: str

    #: :class:`str`: Full annotation file path of item
    ann_path: str


class PointcloudDataset(VideoDataset):
    #: :class:`str`: Items data directory name
    item_dir_name = "pointcloud"

    #: :class:`str`: Annotations directory name
    ann_dir_name = "ann"

    #: :class:`str`: Items info directory name
    item_info_dir_name = "pointcloud_info"

    #: :class:`str`: Related images directory name
    related_images_dir_name = "related_images"

    #: :class:`str`: Segmentation masks directory name
    seg_dir_name = None

    annotation_class = PointcloudAnnotation
    item_info_type = PointcloudInfo

    @property
    def img_dir(self) -> str:
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Property 'img_dir' is not supported for {type(self).__name__} object."
        )

    @property
    def img_info_dir(self):
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Property 'img_info_dir' is not supported for {type(self).__name__} object."
        )

    @property
    def seg_dir(self):
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Property 'seg_dir' is not supported for {type(self).__name__} object."
        )

    def get_img_path(self, item_name: str) -> str:
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Method 'get_img_path(item_name)' is not supported for {type(self).__name__} object."
        )

    def get_img_info_path(self, img_name: str) -> str:
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Method 'get_img_info_path(item_name)' is not supported for {type(self).__name__} object."
        )

    def get_image_info(self, item_name: str) -> None:
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Method 'get_image_info(item_name)' is not supported for {type(self).__name__} object."
        )

    def get_seg_path(self, item_name: str) -> str:
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Method 'get_seg_path(item_name)' is not supported for {type(self).__name__} object."
        )

    @property
    def pointcloud_dir(self) -> str:
        return self.item_dir

    @property
    def pointcloud_info_dir(self) -> str:
        return self.item_info_dir

    @staticmethod
    def _has_valid_ext(path: str) -> bool:
        return sly_pointcloud.has_valid_ext(path)

    def _get_empty_annotaion(self, item_name):
        return self.annotation_class()

    def get_pointcloud_path(self, item_name: str) -> str:
        """
        Path to the given pointcloud.

        :param item_name: Pointcloud name
        :type item_name: :class:`str`
        :return: Path to the given pointcloud
        :rtype: :class:`str`
        :raises: :class:`RuntimeError` if item not found in the project
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            dataset_path = "/home/admin/work/supervisely/projects/ptc_project/ds0"
            ds = sly.PointcloudDataset(dataset_path, sly.OpenMode.READ)

            ds.get_pointcloud_path("PTC_0748")
            # Output: RuntimeError: Item IMG_0748 not found in the project.

            ds.get_pointcloud_path("PTC_0748.pcd")
            # Output: '/home/admin/work/supervisely/projects/ptc_project/ds0/pointcloud/PTC_0748.pcd'
        """
        return super().get_item_path(item_name)

    def get_pointcloud_info(self, item_name: str) -> PointcloudInfo:
        """
        Information for Pointcloud with given name.

        :param item_name: Pointcloud name.
        :type item_name: str
        :return: Pointcloud with information for the given Dataset
        :rtype: :class:`NamedTuple`
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            dataset_path = "/home/admin/work/supervisely/projects/ptc_project/ds0"
            ds = sly.PointcloudDataset(dataset_path, sly.OpenMode.READ)

            info = ds.get_pointcloud_info("IMG_0748.pcd")
        """
        return self.get_item_info(item_name)

    def get_ann_path(self, item_name: str) -> str:
        """
        Path to the given annotation.

        :param item_name: PointcloudAnnotation name.
        :type item_name: str
        :return: Path to the given annotation
        :rtype: :class:`str`
        :raises: :class:`RuntimeError` if item not found in the project
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            dataset_path = "/home/admin/work/supervisely/projects/ptc_project/ds0"
            ds = sly.PointcloudDataset(dataset_path, sly.OpenMode.READ)

            ds.get_ann_path("PTC_0748")
            # Output: RuntimeError: Item PTC_0748 not found in the project.

            ds.get_ann_path("PTC_0748.pcd")
            # Output: '/home/admin/work/supervisely/projects/ptc_project/ds0/ann/IMG_0748.pcd.json'
        """
        return super().get_ann_path(item_name)

    def delete_item(self, item_name: str) -> bool:
        """
        Delete pointcloud, annotation, pointcloud info and related images from PointcloudDataset.

        :param item_name: Item name.
        :type item_name: :class:`str`
        :return: True if successful, otherwise False
        :rtype: :class:`bool`
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            dataset_path = "/home/admin/work/supervisely/projects/ptc_project/ds0"
            ds = sly.PointcloudDataset(dataset_path, sly.OpenMode.READ)

            result = dataset.delete_item("PTC_0748.pcd")
            # Output: True
        """
        if self.item_exists(item_name):
            data_path, rel_images_dir, ann_path = self.get_item_paths(item_name)
            img_info_path = self.get_pointcloud_info_path(item_name)
            silent_remove(data_path)
            silent_remove(ann_path)
            silent_remove(img_info_path)
            remove_dir(rel_images_dir)
            self._item_to_ann.pop(item_name)
            return True
        return False

    def add_item_file(
        self,
        item_name: str,
        item_path: str,
        ann: Optional[Union[PointcloudAnnotation, str]] = None,
        _validate_item: Optional[bool] = True,
        _use_hardlink: Optional[bool] = False,
        item_info: Optional[Union[PointcloudInfo, Dict, str]] = None,
    ) -> None:
        """
        Adds given item file to dataset items directory, and adds given annotation to dataset
        annotations directory. if ann is None, creates empty annotation file.

        :param item_name: Item name.
        :type item_name: :class:`str`
        :param item_path: Path to the item.
        :type item_path: :class:`str`
        :param ann: PointcloudAnnotation object or path to annotation json file.
        :type ann: :class:`PointcloudAnnotation<supervisely.pointcloud_annotation.pointcloud_annotation.PointcloudAnnotation>` or :class:`str`, optional
        :param _validate_item: Checks input files format.
        :type _validate_item: :class:`bool`, optional
        :param _use_hardlink: If True creates a hardlink pointing to src named dst, otherwise don't.
        :type _use_hardlink: :class:`bool`, optional
        :param item_info: PointcloudInfo object or PointcloudInfo object converted to dict or path to item info json file for copying to dataset item info directory.
        :type item_info: :class:`PointcloudInfo<supervisely.api.pointcloud.pointcloud_api.PointcloudInfo>` or :class:`dict` or :class:`str`, optional
        :return: None
        :rtype: NoneType
        :raises: :class:`RuntimeError` if item_name already exists in dataset or item name has unsupported extension.
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            dataset_path = "/home/admin/work/supervisely/projects/ptc_project/ds0"
            ds = sly.PointcloudDataset(dataset_path, sly.OpenMode.READ)

            ann = "/home/admin/work/supervisely/projects/ptc_project/ds0/ann/PTC_8888.pcd.json"
            ds.add_item_file("PTC_8888.pcd", "/home/admin/work/supervisely/projects/ptc_project/ds0/pointcloud/PTC_8888.pcd", ann=ann)
            print(ds.item_exists("PTC_8888.pcd"))
            # Output: True
        """
        return super().add_item_file(
            item_name=item_name,
            item_path=item_path,
            ann=ann,
            _validate_item=_validate_item,
            _use_hardlink=_use_hardlink,
            item_info=item_info,
        )

    def add_item_np(
        self,
        item_name: str,
        pointcloud: np.ndarray,
        ann: Optional[Union[PointcloudAnnotation, str]] = None,
        item_info: Optional[NamedTuple] = None,
    ) -> None:
        """
        Adds given numpy array as a pointcloud to dataset items directory, and adds given annotation to dataset ann directory. if ann is None, creates empty annotation file.

        :param item_name: Item name.
        :type item_name: str
        :param pointcloud: numpy Pointcloud array [N, 3], in (X, Y, Z) format.
        :type pointcloud: np.ndarray
        :param ann: PointcloudAnnotation object or path to annotation .json file.
        :type ann: PointcloudAnnotation or str, optional
        :param item_info: NamedTuple PointcloudItemInfo containing information about Pointcloud.
        :type item_info: NamedTuple, optional
        :return: None
        :rtype: :class:`NoneType`
        :raises: :class:`Exception` if item_name already exists in dataset or item name has unsupported extension
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            dataset_path = "/home/admin/work/supervisely/projects/ptc_project/ds0"
            ds = sly.PointcloudDataset(dataset_path, sly.OpenMode.READ)

            pointcloud_path = "/home/admin/Pointclouds/ptc0.pcd"
            img_np = sly.image.read(img_path)
            ds.add_item_np("IMG_050.jpeg", img_np)
        """
        # TODO: is it ok that names of params differs from base function?
        # TODO: check this function
        self._add_pointcloud_np(item_name, pointcloud)
        self._add_ann_by_type(item_name, ann)
        self._add_item_info(item_name, item_info)

    def add_item_raw_bytes(self, item_name, item_raw_bytes, ann=None, img_info=None):
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Method 'add_item_raw_bytes()' is not supported for {type(self).__name__} object."
        )

    def _add_item_raw_bytes(self, item_name, item_raw_bytes):
        """
        Not available for PointcloudDataset class object.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Method '_add_item_raw_bytes()' is not supported for {type(self).__name__} object."
        )

    def _add_pointcloud_np(self, item_name, pointcloud):
        if pointcloud is None:
            return
        self._check_add_item_name(item_name)
        dst_img_path = os.path.join(self.pointcloud_dir, item_name)
        sly_pointcloud.write(dst_img_path, pointcloud)

    def get_classes_stats(
        self,
        project_meta: Optional[ProjectMeta] = None,
        return_objects_count: Optional[bool] = True,
        return_figures_count: Optional[bool] = True,
        return_items_count: Optional[bool] = True,
    ):
        if project_meta is None:
            project = PointcloudProject(self.project_dir, OpenMode.READ)
            project_meta = project.meta
        class_items = {}
        class_objects = {}
        class_figures = {}
        for obj_class in project_meta.obj_classes:
            class_items[obj_class.name] = 0
            class_objects[obj_class.name] = 0
            class_figures[obj_class.name] = 0
        objects_calculated = False
        for item_name in self:
            item_ann = self.get_ann(item_name, project_meta)
            item_class = {}
            if not objects_calculated:
                for ann_obj in item_ann.objects:
                    class_objects[ann_obj.obj_class.name] += 1
                objects_calculated = True
            for ptc_figure in item_ann.figures:
                class_figures[ptc_figure.parent_object.obj_class.name] += 1
                item_class[ptc_figure.parent_object.obj_class.name] = True
            for obj_class in project_meta.obj_classes:
                if obj_class.name in item_class.keys():
                    class_items[obj_class.name] += 1

        result = {}
        if return_items_count:
            result["items_count"] = class_items
        if return_objects_count:
            result["objects_count"] = class_objects
        if return_figures_count:
            result["figures_count"] = class_figures
        return result

    def _validate_added_item_or_die(self, item_path):
        # Make sure we actually received a valid pointcloud file, clean it up and fail if not so.
        try:
            sly_pointcloud.validate_format(item_path)
        except (sly_pointcloud.UnsupportedPointcloudFormat, sly_pointcloud.PointcloudReadException):
            os.remove(item_path)
            raise

    def get_related_images_path(self, item_name: str) -> str:
        item_name_temp = item_name.replace(".", "_")
        rimg_dir = os.path.join(self.directory, self.related_images_dir_name, item_name_temp)
        return rimg_dir

    def get_item_paths(self, item_name: str) -> PointcloudItemPaths:
        return PointcloudItemPaths(
            pointcloud_path=self.get_pointcloud_path(item_name),
            related_images_dir=self.get_related_images_path(item_name),
            ann_path=self.get_ann_path(item_name),
        )

    # def validate_figure_bounds(self,)

    def get_ann(
        self, item_name, project_meta: ProjectMeta, key_id_map: Optional[KeyIdMap] = None
    ) -> PointcloudAnnotation:
        """
        Read pointcloud annotation of item from json.

        :param item_name: Pointcloud name.
        :type item_name: str
        :param project_meta: Project Meta.
        :type project_meta: ProjectMeta
        :param key_id_map: KeyIdMap object.
        :type key_id_map: KeyIdMap, optional
        :return: PointcloudAnnotation object
        :rtype: :class:`PointcloudAnnotation<supervisely.PointcloudAnnotation>`
        :raises: :class:`RuntimeError` if item not found in the project
        :Usage example:

         .. code-block:: python

            import supervisely as sly

            project_path = "/home/admin/work/supervisely/projects/pointcloud_project"
            project = sly.PointcloudProject(project_path, sly.OpenMode.READ)

            ds = project.datasets.get('ds1')

            annotation = ds.get_ann("PTC_0056")
            # Output: RuntimeError: Item PTC_0056 not found in the project.

            annotation = ds.get_ann("PTC_0056.pcd")
            print(type(annotation).__name__)
            # Output: PointcloudAnnotation
        """
        ann_path = self.get_ann_path(item_name)
        return PointcloudAnnotation.load_json_file(ann_path, project_meta, key_id_map)

    def get_related_images(self, item_name: str) -> List[Tuple[str, Dict]]:
        results = []
        path = self.get_related_images_path(item_name)
        if dir_exists(path):
            files = list_files(path, sly_image.SUPPORTED_IMG_EXTS)
            for file in files:
                img_meta_path = os.path.join(path, get_file_name_with_ext(file) + ".json")
                img_meta = {}
                if file_exists(img_meta_path):
                    img_meta = load_json_file(img_meta_path)
                    if img_meta[ApiField.NAME] != get_file_name_with_ext(file):
                        raise RuntimeError("Wrong format: name field contains wrong image path")
                results.append((file, img_meta))
        return results

    def get_pointcloud_info_path(self, item_name: str) -> str:
        return self.get_item_info_path(item_name)

    def get_related_image_figures(self, item_name: str, img_name: str) -> List[Dict]:
        """
        Get figures from related image with given name.

        :param item_name: Name of the item in the dataset.
        :type item_name: str
        :param img_name: Related image name.
        :type img_name: str
        :return: List of figures from related image.
        :rtype: List[Dict]
        :raises: :class:`RuntimeError` if item not found in the project
        """
        results = []
        path = self.get_related_images_path(item_name)
        if dir_exists(path):
            files = list_files(path, sly_image.SUPPORTED_IMG_EXTS)
            for file in files:
                if get_file_name_with_ext(file) != img_name:
                    continue
                fig_path = os.path.join(path, get_file_name_with_ext(file) + ".figures.json")
                if file_exists(fig_path):
                    figs = load_json_file(fig_path)
                    results.append(figs)
        return results


class PointcloudProject(VideoProject):
    """
    PointcloudProject is a parent directory for pointcloud datasets. PointcloudProject object is immutable.

    :param directory: Path to pointcloud project directory.
    :type directory: :class:`str`
    :param mode: Determines working mode for the given project.
    :type mode: :class:`OpenMode<supervisely.project.project.OpenMode>`
    :Usage example:

     .. code-block:: python

        import supervisely as sly
        project_path = "/home/admin/work/supervisely/projects/ptc_project"
        project = sly.PointcloudProject(project_path, sly.OpenMode.READ)
    """

    dataset_class = PointcloudDataset

    class DatasetDict(KeyIndexedCollection):
        item_type = PointcloudDataset

    @classmethod
    def read_single(cls, dir) -> PointcloudProject:
        return read_project_wrapper(dir, cls)

    def get_classes_stats(
        self,
        dataset_names: Optional[List[str]] = None,
        return_objects_count: Optional[bool] = True,
        return_figures_count: Optional[bool] = True,
        return_items_count: Optional[bool] = True,
    ):
        return super(PointcloudProject, self).get_classes_stats(
            dataset_names, return_objects_count, return_figures_count, return_items_count
        )

    @property
    def type(self) -> str:
        """
        Project type.

        :return: Project type.
        :rtype: :class:`str`
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            project = sly.PointcloudProject("/home/admin/work/supervisely/projects/pointclouds", sly.OpenMode.READ)
            print(project.type)
            # Output: 'point_clouds'
        """
        return ProjectType.POINT_CLOUDS.value

    @staticmethod
    def get_train_val_splits_by_count(
        project_dir: str, train_count: int, val_count: int
    ) -> Tuple[List[PointcloudItemInfo], List[PointcloudItemInfo]]:
        """
        Get train and val items information from project by given train and val counts.

        :param project_dir: Path to project directory.
        :type project_dir: str
        :param train_count: Number of train items.
        :type train_count: int
        :param val_count: Number of val items.
        :type val_count: int
        :raises: :class:`ValueError` if total_count != train_count + val_count
        :return: Tuple with lists of train items information and val items information
        :rtype: :class:`Tuple[List[PointcloudItemInfo], List[PointcloudItemInfo]]`
        :Usage example:

         .. code-block:: python

            from supervisely.project.pointcloud_project import PointcloudProject
            project_path = "/home/admin/work/supervisely/projects/pointcloud_project"
            project = PointcloudProject(project_path, sly.OpenMode.READ)
            train_count = 4
            val_count = 2
            train_items, val_items = project.get_train_val_splits_by_count(project_path, train_count, val_count)
        """

        def _list_items_for_splits(project) -> List[PointcloudItemInfo]:
            items = []
            for dataset in project.datasets:
                for item_name in dataset:
                    items.append(
                        PointcloudItemInfo(
                            dataset_name=dataset.name,
                            name=item_name,
                            pointcloud_path=dataset.get_pointcloud_path(item_name),
                            related_images_dir=dataset.get_related_images_path(item_name),
                            ann_path=dataset.get_ann_path(item_name),
                        )
                    )
            return items

        project = PointcloudProject(project_dir, OpenMode.READ)
        if project.total_items != train_count + val_count:
            raise ValueError("total_count != train_count + val_count")
        all_items = _list_items_for_splits(project)
        random.shuffle(all_items)
        train_items = all_items[:train_count]
        val_items = all_items[train_count:]
        return train_items, val_items

    @staticmethod
    def get_train_val_splits_by_tag(
        project_dir: str,
        train_tag_name: str,
        val_tag_name: str,
        untagged: Optional[str] = "ignore",
    ) -> Tuple[List[PointcloudItemInfo], List[PointcloudItemInfo]]:
        """
        Get train and val items information from project by given train and val tags names.

        :param project_dir: Path to project directory.
        :type project_dir: str
        :param train_tag_name: Train tag name.
        :type train_tag_name: str
        :param val_tag_name: Val tag name.
        :type val_tag_name: str
        :param untagged: Actions in case of absence of train_tag_name and val_tag_name in project.
        :type untagged: str, optional
        :raises: :class:`ValueError` if untagged not in ["ignore", "train", "val"]
        :return: Tuple with lists of train items information and val items information
        :rtype: :class:`Tuple[List[PointcloudItemInfo], List[PointcloudItemInfo]]`
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            project_path = "/home/admin/work/supervisely/projects/pointcloud_project"
            project = sly.PointcloudProject(project_path, sly.OpenMode.READ)
            train_tag_name = 'train'
            val_tag_name = 'val'
            train_items, val_items = project.get_train_val_splits_by_tag(project_path, train_tag_name, val_tag_name)
        """
        untagged_actions = ["ignore", "train", "val"]
        if untagged not in untagged_actions:
            raise ValueError(
                f"Unknown untagged action {untagged}. Should be one of {untagged_actions}"
            )
        project = PointcloudProject(project_dir, OpenMode.READ)
        train_items = []
        val_items = []
        for dataset in project.datasets:
            for item_name in dataset:
                item_paths = dataset.get_item_paths(item_name)
                info = PointcloudItemInfo(
                    dataset_name=dataset.name,
                    name=item_name,
                    pointcloud_path=item_paths.pointcloud_path,
                    related_images_dir=item_paths.related_images_dir,
                    ann_path=item_paths.ann_path,
                )

                ann = PointcloudAnnotation.load_json_file(item_paths.ann_path, project.meta)
                if ann.tags.get(train_tag_name) is not None:
                    train_items.append(info)
                if ann.tags.get(val_tag_name) is not None:
                    val_items.append(info)
                if ann.tags.get(train_tag_name) is None and ann.tags.get(val_tag_name) is None:
                    # untagged item
                    if untagged == "ignore":
                        continue
                    elif untagged == "train":
                        train_items.append(info)
                    elif untagged == "val":
                        val_items.append(info)
        return train_items, val_items

    @staticmethod
    def get_train_val_splits_by_dataset(
        project_dir: str, train_datasets: List[str], val_datasets: List[str]
    ) -> Tuple[List[PointcloudItemInfo], List[PointcloudItemInfo]]:
        """
        Get train and val items information from project by given train and val datasets names.

        :param project_dir: Path to project directory.
        :type project_dir: str
        :param train_datasets: List of train datasets names.
        :type train_datasets: List[str]
        :param val_datasets: List of val datasets names.
        :type val_datasets: List[str]
        :raises: :class:`KeyError` if dataset name not found in project
        :return: Tuple with lists of train items information and val items information
        :rtype: :class:`Tuple[List[PointcloudItemInfo], List[PointcloudItemInfo]]`
        :Usage example:

         .. code-block:: python

            import supervisely as sly
            project_path = "/home/admin/work/supervisely/projects/pointcloud_project"
            project = sly.PointcloudProject(project_path, sly.OpenMode.READ)
            train_datasets = ['ds1', 'ds2']
            val_datasets = ['ds3', 'ds4']
            train_items, val_items = project.get_train_val_splits_by_dataset(project_path, train_datasets, val_datasets)
        """

        def _add_items_to_list(project, datasets_names, items_list):
            for dataset_name in datasets_names:
                dataset = project.datasets.get(dataset_name)
                if dataset is None:
                    raise KeyError(f"Dataset '{dataset_name}' not found")
                for item_name in dataset:
                    item_paths = dataset.get_item_paths(item_name)
                    info = PointcloudItemInfo(
                        dataset_name=dataset.name,
                        name=item_name,
                        pointcloud_path=item_paths.pointcloud_path,
                        related_images_dir=item_paths.related_images_dir,
                        ann_path=item_paths.ann_path,
                    )
                    items_list.append(info)

        project = PointcloudProject(project_dir, OpenMode.READ)
        train_items = []
        _add_items_to_list(project, train_datasets, train_items)
        val_items = []
        _add_items_to_list(project, val_datasets, val_items)
        return train_items, val_items

    @staticmethod
    def get_train_val_splits_by_collections(
        project_dir: str,
        train_collections: List[int],
        val_collections: List[int],
        project_id: int,
        api: Api,
    ) -> None:
        """
        Not available for PointcloudProject class.
        :raises: :class:`NotImplementedError` in all cases.
        """
        raise NotImplementedError(
            f"Static method 'get_train_val_splits_by_collections()' is not supported for PointcloudProject class now."
        )

    @staticmethod
    def download(
        api: Api,
        project_id: int,
        dest_dir: str,
        dataset_ids: Optional[List[int]] = None,
        download_pointclouds: Optional[bool] = True,
        download_related_images: Optional[bool] = True,
        download_pointclouds_info: Optional[bool] = False,
        batch_size: Optional[int] = 10,
        log_progress: bool = True,
        progress_cb: Optional[Union[tqdm, Callable]] = None,
        **kwargs,
    ) -> PointcloudProject:
        """
        Download pointcloud project from Supervisely to the given directory.

        :param api: Supervisely API address and token.
        :type api: sly.Api
        :param project_id: Supervisely downloadable project ID.
        :type project_id: int
        :param dest_dir: Destination directory.
        :type dest_dir: str
        :param dataset_ids: Dataset IDs.
        :type dataset_ids: List[int], optional
        :param download_pointclouds: Download pointcloud data files or not.
        :type download_pointclouds: bool, optional
        :param download_related_images: Download related images or not.
        :type download_related_images: bool, optional
        :param download_pointclouds_info: Download pointcloud info .json files or not.
        :type download_pointclouds_info: bool, optional
        :param batch_size: The number of images in the batch when they are loaded to a host.
        :type batch_size: int, optional
        :param log_progress: Show uploading progress bar.
        :type log_progress: bool
        :param progress_cb: Function for tracking download progress.
        :type progress_cb: tqdm or callable, optional
        :return: None
        :rtype: NoneType
        :Usage example:

        .. code-block:: python

                import supervisely as sly

                # Local destination Pointcloud Project folder
                save_directory = "/home/admin/work/supervisely/source/ptc_project"

                # Obtain server address and your api_token from environment variables
                # Edit those values if you run this notebook on your own PC
                address = os.environ['SERVER_ADDRESS']
                token = os.environ['API_TOKEN']

                # Initialize API object
                api = sly.Api(address, token)
                project_id = 8888

                # Download Project
                sly.PointcloudProject.download(api, project_id, save_directory)
                project_fs = sly.PointcloudProject(save_directory, sly.OpenMode.READ)
        """
        download_pointcloud_project(
            api=api,
            project_id=project_id,
            dest_dir=dest_dir,
            dataset_ids=dataset_ids,
            download_items=download_pointclouds,
            download_related_images=download_related_images,
            download_pointclouds_info=download_pointclouds_info,
            batch_size=batch_size,
            log_progress=log_progress,
            progress_cb=progress_cb,
        )

    @staticmethod
    def upload(
        directory: str,
        api: Api,
        workspace_id: int,
        project_name: Optional[str] = None,
        log_progress: bool = True,
        progress_cb: Optional[Union[tqdm, Callable]] = None,
    ) -> Tuple[int, str]:
        """
        Uploads pointcloud project to Supervisely from the given directory.

        :param directory: Path to project directory.
        :type directory: str
        :param api: Supervisely API address and token.
        :type api: sly.Api
        :param workspace_id: Workspace ID, where project will be uploaded.
        :type workspace_id: int
        :param project_name: Name of the project in Supervisely. Can be changed if project with the same name is already exists.
        :type project_name: str, optional
        :param log_progress: Show uploading progress bar.
        :type log_progress: bool
        :param progress_cb: Function for tracking download progress.
        :type progress_cb: tqdm or callable, optional
        :return: Project ID and name. It is recommended to check that returned project name coincides with provided project name.
        :rtype: int, str
        :Usage example:

        .. code-block:: python

            import supervisely as sly

            # Local folder with Pointcloud Project
            project_directory = "/home/admin/work/supervisely/source/ptc_project"

            # Obtain server address and your api_token from environment variables
            # Edit those values if you run this notebook on your own PC
            address = os.environ['SERVER_ADDRESS']
            token = os.environ['API_TOKEN']

            # Initialize API object
            api = sly.Api(address, token)

            # Upload Pointcloud Project
            project_id, project_name = sly.PointcloudProject.upload(
                project_directory,
                api,
                workspace_id=45,
                project_name="My Pointcloud Project"
            )
        """
        return upload_pointcloud_project(
            directory=directory,
            api=api,
            workspace_id=workspace_id,
            project_name=project_name,
            log_progress=log_progress,
            progress_cb=progress_cb,
        )

    @staticmethod
    async def download_async(*args, **kwargs):
        raise NotImplementedError(
            f"Static method 'download_async()' is not supported for PointcloudProject class now."
        )


def download_pointcloud_project(
    api: Api,
    project_id: int,
    dest_dir: str,
    dataset_ids: Optional[List[int]] = None,
    download_items: Optional[bool] = True,
    download_related_images: Optional[bool] = True,
    download_pointclouds_info: Optional[bool] = False,
    batch_size: Optional[int] = 10,
    log_progress: bool = True,
    progress_cb: Optional[Union[tqdm, Callable]] = None,
) -> None:
    """
    Download pointcloud project to the local directory.

    :param api: Supervisely API address and token.
    :type api: Api
    :param project_id: Project ID to download.
    :type project_id: int
    :param dest_dir: Destination path to local directory.
    :type dest_dir: str
    :param dataset_ids: Specified list of Dataset IDs which will be downloaded. Datasets could be downloaded from different projects but with the same data type.
    :type dataset_ids: list(int), optional
    :param download_items: Include pointcloud items in the download.
    :type download_items: bool, optional
    :param download_related_images: Include related context images of a pointcloud project in the download.
    :type download_related_images: bool, optional
    :param download_pointclouds_info: Include pointclouds info in the download.
    :type download_pointclouds_info: bool, optional
    :param batch_size: Size of a downloading batch.
    :type batch_size: int, optional
    :param log_progress: Show downloading logs in the output.
    :type log_progress: bool
    :param progress_cb: Function for tracking download progress.
    :type progress_cb: tqdm or callable, optional

    :return: None.
    :rtype: NoneType
    :Usage example:

     .. code-block:: python

        import os
        from dotenv import load_dotenv

        from tqdm import tqdm
        import supervisely as sly

        # Load secrets and create API object from .env file (recommended)
        # Learn more here: https://developer.supervisely.com/getting-started/basics-of-authentication
        if sly.is_development():
            load_dotenv(os.path.expanduser("~/supervisely.env"))
        api = sly.Api.from_env()

        # Pass values into the API constructor (optional, not recommended)
        # api = sly.Api(server_address="https://app.supervisely.com", token="4r47N...xaTatb")

        dest_dir = 'your/local/dest/dir'

        # Download pointcloud project
        project_id = 19542
        project_info = api.project.get_info_by_id(project_id)
        num_pointclouds = project_info.items_count

        p = tqdm(
            desc="Downloading pointcloud project",
            total=num_pointclouds,
        )
        sly.download_pointcloud_project(
            api,
            project_id,
            dest_dir,
            progress_cb=p,
        )
    """
    key_id_map = KeyIdMap()

    project_fs = PointcloudProject(dest_dir, OpenMode.CREATE)

    meta = ProjectMeta.from_json(api.project.get_meta(project_id))
    project_fs.set_meta(meta)

    if progress_cb is not None:
        log_progress = False

    filter_fn = lambda ds: True
    if dataset_ids is not None:
        filter_fn = lambda ds: ds.id in dataset_ids

    for parents, dataset in api.dataset.tree(project_id):
        if not filter_fn(dataset):
            continue
        dataset_path = None
        if parents:
            dataset_path = "/datasets/".join(parents + [dataset.name])
        dataset_fs: PointcloudDataset = project_fs.create_dataset(
            ds_name=dataset.name, ds_path=dataset_path
        )
        pointclouds = api.pointcloud.get_list(dataset.id)

        ds_progress = progress_cb
        if log_progress:
            ds_progress = tqdm_sly(
                desc="Downloading clouds from: {!r}".format(dataset.name),
                total=len(pointclouds),
            )
        for batch in batched(pointclouds, batch_size=batch_size):
            pointcloud_ids = [pointcloud_info.id for pointcloud_info in batch]
            pointcloud_names = [pointcloud_info.name for pointcloud_info in batch]

            ann_jsons = api.pointcloud.annotation.download_bulk(dataset.id, pointcloud_ids)
            if download_related_images:
                try:
                    rimgs = api.pointcloud.get_list_related_images_batch(dataset.id, pointcloud_ids)
                    if len(rimgs) > 0:
                        rimg_ids = [rimg[ApiField.ID] for rimg in rimgs]
                        batch_rimg_figures = api.image.figure.download(
                            dataset_id=dataset.id, image_ids=rimg_ids
                        )
                    else:
                        batch_rimg_figures = []
                except Exception as e:
                    logger.info(
                        "INFO FOR DEBUGGING",
                        extra={
                            "project_id": project_id,
                            "dataset_id": dataset.id,
                            "pointcloud_ids": pointcloud_ids,
                        },
                    )
                    raise e

            for pointcloud_id, pointcloud_name, pointcloud_info, ann_json in zip(
                pointcloud_ids, pointcloud_names, batch, ann_jsons
            ):
                if pointcloud_name != ann_json[ApiField.NAME]:
                    raise RuntimeError("Error in api.video.annotation.download_batch: broken order")

                pointcloud_file_path = dataset_fs.generate_item_path(pointcloud_name)
                if download_items:
                    try:
                        api.pointcloud.download_path(pointcloud_id, pointcloud_file_path)
                    except Exception as e:
                        logger.info(
                            "INFO FOR DEBUGGING",
                            extra={
                                "project_id": project_id,
                                "dataset_id": dataset.id,
                                "pointcloud_id": pointcloud_id,
                                "pointcloud_name": pointcloud_name,
                                "pointcloud_file_path": pointcloud_file_path,
                            },
                        )
                        raise e
                else:
                    touch(pointcloud_file_path)

                pointcloud_file_path = pointcloud_file_path if download_items else None
                pointcloud_info = pointcloud_info._asdict() if download_pointclouds_info else None
                try:
                    pointcloud_ann = PointcloudAnnotation.from_json(
                        ann_json, project_fs.meta, key_id_map
                    )
                except Exception as e:
                    logger.info(
                        "INFO FOR DEBUGGING",
                        extra={
                            "project_id": project_id,
                            "dataset_id": dataset.id,
                            "pointcloud_id": pointcloud_id,
                            "pointcloud_name": pointcloud_name,
                            "ann_json": ann_json,
                        },
                    )
                    raise e

                if download_related_images:
                    related_images_path = dataset_fs.get_related_images_path(pointcloud_name)
                    try:
                        related_images = api.pointcloud.get_list_related_images(pointcloud_id)
                    except Exception as e:
                        logger.info(
                            "INFO FOR DEBUGGING",
                            extra={
                                "project_id": project_id,
                                "dataset_id": dataset.id,
                                "pointcloud_id": pointcloud_id,
                                "pointcloud_name": pointcloud_name,
                            },
                        )
                        raise e
                    for rimage_info in related_images:
                        name = rimage_info[ApiField.NAME]

                        if not sly_image.has_valid_ext(name):
                            new_name = get_file_name(name)  # to fix cases like .png.json
                            if sly_image.has_valid_ext(new_name):
                                name = new_name
                                rimage_info[ApiField.NAME] = name
                            else:
                                raise RuntimeError(
                                    "Something wrong with photo context filenames.\
                                                    Please, contact support"
                                )

                        rimage_id = rimage_info[ApiField.ID]

                        path_img = os.path.join(related_images_path, name)
                        path_json = os.path.join(related_images_path, name + ".json")
                        path_figures = os.path.join(related_images_path, name + ".figures.json")

                        try:
                            api.pointcloud.download_related_image(rimage_id, path_img)
                        except Exception as e:
                            logger.info(
                                "INFO FOR DEBUGGING",
                                extra={
                                    "project_id": project_id,
                                    "dataset_id": dataset.id,
                                    "pointcloud_id": pointcloud_id,
                                    "pointcloud_name": pointcloud_name,
                                    "rimage_id": rimage_id,
                                    "path_img": path_img,
                                },
                            )
                            raise e
                        dump_json_file(rimage_info, path_json)

                        try:
                            if rimage_id in batch_rimg_figures:
                                rimg_figures = batch_rimg_figures[rimage_id]
                                rimg_figures_json = []
                                for fig in rimg_figures:
                                    fig_json = fig.to_json()
                                    if ApiField.OBJECT_ID in fig_json:
                                        fig_json[OBJECT_KEY] = str(
                                            key_id_map.get_object_key(fig_json[ApiField.OBJECT_ID])
                                        )
                                        fig_json.pop(ApiField.OBJECT_ID, None)
                                        rimg_figures_json.append(fig_json)
                                    else:
                                        raise RuntimeError(f"Figure {fig} has no object id")

                                dump_json_file(rimg_figures_json, path_figures)
                        except Exception as e:
                            logger.info(
                                "INFO FOR DEBUGGING",
                                extra={
                                    "project_id": project_id,
                                    "dataset_id": dataset.id,
                                    "pointcloud_id": pointcloud_id,
                                    "pointcloud_name": pointcloud_name,
                                    "rimage_id": rimage_id,
                                    "path_figures": path_figures,
                                },
                            )
                            raise e

                try:
                    dataset_fs.add_item_file(
                        pointcloud_name,
                        pointcloud_file_path,
                        ann=pointcloud_ann,
                        _validate_item=False,
                        item_info=pointcloud_info,
                    )
                except Exception as e:
                    logger.info(
                        "INFO FOR DEBUGGING",
                        extra={
                            "project_id": project_id,
                            "dataset_id": dataset.id,
                            "pointcloud_id": pointcloud_id,
                            "pointcloud_name": pointcloud_name,
                            "pointcloud_file_path": pointcloud_file_path,
                            "item_info": pointcloud_info,
                        },
                    )
                    raise e
                if log_progress or progress_cb is not None:
                    ds_progress(1)

    project_fs.set_key_id_map(key_id_map)


def upload_pointcloud_project(
    directory: str,
    api: Api,
    workspace_id: int,
    project_name: Optional[str] = None,
    log_progress: bool = True,
    progress_cb: Optional[Union[tqdm, Callable]] = None,
) -> Tuple[int, str]:
    project_fs = PointcloudProject.read_single(directory)
    if project_name is None:
        project_name = project_fs.name

    if api.project.exists(workspace_id, project_name):
        project_name = api.project.get_free_name(workspace_id, project_name)

    project = api.project.create(workspace_id, project_name, ProjectType.POINT_CLOUDS)
    api.project.update_meta(project.id, project_fs.meta.to_json())

    if progress_cb is not None:
        log_progress = False

    key_id_map = KeyIdMap()
    for dataset_fs in project_fs:
        dataset = api.dataset.create(project.id, dataset_fs.name, change_name_if_conflict=True)

        ds_progress = progress_cb
        if log_progress:
            ds_progress = tqdm_sly(
                desc="Uploading pointclouds to {!r}".format(dataset.name),
                total=len(dataset_fs),
            )

        for item_name in dataset_fs:
            item_path, related_images_dir, ann_path = dataset_fs.get_item_paths(item_name)
            related_items = dataset_fs.get_related_images(item_name)

            try:
                _, meta = related_items[0]
                timestamp = meta[ApiField.META]["timestamp"]
                item_meta = {"timestamp": timestamp}
            except (KeyError, IndexError):
                item_meta = {}

            try:
                pointcloud = api.pointcloud.upload_path(dataset.id, item_name, item_path, item_meta)
            except Exception as e:
                logger.info(
                    "INFO FOR DEBUGGING",
                    extra={
                        "project_id": project.id,
                        "dataset_id": dataset.id,
                        "item_name": item_name,
                        "item_path": item_path,
                        "item_meta": item_meta,
                    },
                )
                raise e

            # validate_item_annotation
            ann_json = load_json_file(ann_path)
            try:
                ann = PointcloudAnnotation.from_json(ann_json, project_fs.meta)
            except Exception as e:
                logger.info(
                    "INFO FOR DEBUGGING",
                    extra={
                        "project_id": project.id,
                        "dataset_id": dataset.id,
                        "pointcloud_id": pointcloud.id,
                        "pointcloud_name": pointcloud.name,
                        "ann_json": ann_json,
                    },
                )
                raise e

            try:
                # ignore existing key_id_map because the new objects will be created
                api.pointcloud.annotation.append(pointcloud.id, ann, key_id_map)
            except Exception as e:
                logger.info(
                    "INFO FOR DEBUGGING",
                    extra={
                        "project_id": project.id,
                        "dataset_id": dataset.id,
                        "pointcloud_id": pointcloud.id,
                        "pointcloud_name": pointcloud.name,
                        "ann": ann.to_json(),
                    },
                )
                raise e

            # upload related_images if exist
            if len(related_items) != 0:
                rimg_infos = []
                camera_names = []
                rimg_figures = {}
                for img_ind, (img_path, meta_json) in enumerate(related_items):
                    try:
                        img = api.pointcloud.upload_related_image(img_path)
                    except Exception as e:
                        logger.info(
                            "INFO FOR DEBUGGING",
                            extra={
                                "project_id": project.id,
                                "dataset_id": dataset.id,
                                "pointcloud_id": pointcloud.id,
                                "pointcloud_name": pointcloud.name,
                                "img_path": img_path,
                            },
                        )
                        raise e
                    if "deviceId" not in meta_json[ApiField.META].keys():
                        camera_names.append(f"CAM_{str(img_ind).zfill(2)}")
                    else:
                        camera_names.append(meta_json[ApiField.META]["deviceId"])
                    rimg_infos.append(
                        {
                            ApiField.ENTITY_ID: pointcloud.id,
                            ApiField.NAME: meta_json[ApiField.NAME],
                            ApiField.HASH: img,
                            ApiField.META: meta_json[ApiField.META],
                        }
                    )
                    img_name = meta_json[ApiField.NAME]
                    fig_json_path = os.path.join(related_images_dir, img_name + ".figures.json")
                    if os.path.exists(fig_json_path):
                        try:
                            rimg_figures[img] = load_json_file(fig_json_path)
                        except Exception as e:
                            logger.info(
                                "INFO FOR DEBUGGING",
                                extra={
                                    "project_id": project.id,
                                    "dataset_id": dataset.id,
                                    "pointcloud_id": pointcloud.id,
                                    "pointcloud_name": pointcloud.name,
                                    "fig_json_path": fig_json_path,
                                },
                            )
                            raise e

                try:
                    uploaded_rimgs = api.pointcloud.add_related_images(rimg_infos, camera_names)
                    # build mapping hash->id
                    pcl_to_hash_to_id = {}
                    for info, uploaded in zip(rimg_infos, uploaded_rimgs):
                        img_hash = info.get(ApiField.HASH)
                        img_id = (
                            uploaded.get(ApiField.ID)
                            if isinstance(uploaded, dict)
                            else getattr(uploaded, "id", None)
                        )
                        if img_hash is not None and img_id is not None:
                            pcl_to_hash_to_id[img_hash] = img_id
                except Exception as e:
                    logger.info(
                        "INFO FOR DEBUGGING",
                        extra={
                            "project_id": project.id,
                            "dataset_id": dataset.id,
                            "pointcloud_id": pointcloud.id,
                            "pointcloud_name": pointcloud.name,
                            "rimg_infos": rimg_infos,
                            "camera_names": camera_names,
                        },
                    )
                    raise e

                # upload figures
                if len(rimg_figures) > 0:
                    hash_to_ids = pcl_to_hash_to_id
                    figures_payload = []
                    for img_hash, figs_json in rimg_figures.items():
                        if img_hash not in hash_to_ids:
                            continue
                        img_id = hash_to_ids[img_hash]
                        for fig in figs_json:
                            try:
                                fig[ApiField.ENTITY_ID] = img_id
                                fig[ApiField.DATASET_ID] = dataset.id
                                fig[ApiField.PROJECT_ID] = project.id
                                fig[ApiField.OBJECT_ID] = key_id_map.get_object_id(
                                    UUID(fig[OBJECT_KEY])
                                )
                            except Exception as e:
                                logger.debug(
                                    f"Failed to process figure json for img_hash={img_hash}: {repr(e)}"
                                )
                                continue

                        figures_payload.extend(figs_json)

                    if len(figures_payload) > 0:
                        try:
                            api.image.figure.create_bulk(
                                figures_json=figures_payload, dataset_id=dataset.id
                            )
                        except Exception as e:
                            logger.debug(f"Failed to upload figures for related images: {repr(e)}")

            if ds_progress:
                ds_progress(1)

    return project.id, project_name
