# Copyright (C) 2023 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: MIT
#
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import json
import logging
import numbers
import sys
from abc import ABC, abstractmethod
from inspect import cleandoc
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type, Union

from ansys.simai.core.data.base import ComputableDataModel, Directory
from ansys.simai.core.data.downloads import DownloadableResult
from ansys.simai.core.data.types import Identifiable, get_id_from_identifiable
from ansys.simai.core.errors import (
    InvalidArguments,
    InvalidOperationError,
    InvalidServerStateError,
)
from ansys.simai.core.utils.numerical import (
    cast_values_to_float,
    convert_axis_and_coordinate_to_plane_eq_coeffs,
)

if TYPE_CHECKING:
    from ansys.simai.core.data.predictions import Prediction
    from ansys.simai.core.data.workspaces import Workspace


logger = logging.getLogger(__name__)


class PostProcessing(ComputableDataModel, ABC):
    """Provides the local representation of a ``PostProcessing`` object.

    This is an abstract class. Depending on the postprocessing, a different implementation
    is returned. For more information, see :ref:`available_pp`.
    """

    # NOTE for developers: New postprocessings must be added to the root ``__init__.py`` file.

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._results = None
        # Set by the Directory, or else on first access
        self._prediction = None

    @property
    def parameters(self) -> Optional[Dict[str, Any]]:
        """Parameters used to run the postprocessing."""
        return self.fields["location"]

    @property
    def prediction_id(self) -> str:
        """Parent prediction's ID.

        See Also:
            - :attr:`prediction`: Get the parent prediction.
        """
        return self.fields["prediction_id"]

    @property
    def prediction(self) -> "Prediction":
        """Parent prediction.

        The parent prediction is queried if it is not already known by the current SimAPI client session.

        See Also:
            - :attr:`prediction_id`: Get the parent prediction's ID without query.
        """
        if self._prediction is None:
            if self.prediction_id in self._client.predictions._registry:
                self._prediction = self._client.predictions._registry[self.prediction_id]
            else:
                self._prediction = self._client.predictions.get(id=self.prediction_id)
        return self._prediction

    @property
    def type(self) -> str:
        """Type of postprocessing that this object represents."""
        return self._fields["type"]

    @property
    @abstractmethod
    def data(self):
        """Get the data generated by the postprocessing.

        The return type may vary depending on the postprocessing. It can be a dictionary
        or, if the data is binary, a :class:`DownloadableResult` object, which provides
        helpers to download the data into a file or in memory.
        """

    @classmethod
    def _api_name(cls) -> str:
        # Name of the postprocessing in API calls. Override if different from the class name.
        return cls.__name__

    def _get_results(self, cache=True):
        """Internal method for getting the results of this postprocessing.

        Args:
            cache:
                Whether to save results to the cache. The default is ``True``,
                which saves results to the cache. If ``False`` download
                links expire. Thus, results must be queried just before download.
        """
        if cache and self._results:
            return self._results
        res = self._client._api.get_post_processing_result(self.id)
        if cache:
            self._results = res
        return res

    def delete(self):
        """Delete the postprocessing and its result data.

        Raises:
            NotFoundError: If the postprocessing has already been deleted.
        """
        self._client._api.delete_post_processing(self.id)
        self._unregister()
        if self._prediction is not None:
            self._prediction._post_processings._delete_local_post_processing(self)


class ExportablePostProcessing(PostProcessing, ABC):
    def export(self, format: Optional[str] = "json") -> DownloadableResult:
        """Export the postprocessing results in the desired format.

        Accessing this property blocks until the data is ready.

        Args:
            format: Format to export the data in. The default is ``'json'``.
                Options are ``'csv.zip'``, ``'json'``, and ``'xlsx'``. Note that
                the ``'csv.zip'`` option exports a ZIP file containing
                multiple CSV sheets.

        Returns:
            :class:`DownloadableResult` object for downloading the exported
            data into a file or access it in memory.
        """
        self.wait()
        return DownloadableResult(
            self._client._api.post_processings_export_url(),
            self._client,
            request_method="POST",
            request_json_body={"ids": [self.id], "format": format},
        )


class GlobalCoefficients(ExportablePostProcessing):
    """Provides the representation of the global coefficients of a prediction.

    The data attribute contains a dictionary representing the global coefficients
    with its pressure and velocity components.

    This class is generated through the :meth:`PredictionPostProcessings.global_coefficients()`
    method.
    """

    @property
    def data(self) -> Dict[str, List]:
        """Dictionary containing the global coefficients, including pressure
        and velocity components.

        Accessing this property blocks until the data is ready.
        """
        self.wait()

        results = self._get_results()
        return {
            k: {**v, "data": cast_values_to_float(v["data"])}
            for k, v in results["data"]["values"].items()
        }


class SurfaceEvolution(ExportablePostProcessing):
    """Provides the representation of the ``SurfaceEvolution`` object.

    This class is generated through :meth:`PredictionPostProcessings.surface_evolution()`
    """

    @property
    def data(self) -> DownloadableResult:
        """:class:`DownloadableResult` object that allows access to the
        ``SurfaceEvolution`` JSON data, both directly in memory any by downloading it
        into a file.

        Accessing this property blocks until the data is ready.
        """
        self.wait()
        results = self._get_results(cache=False)
        return DownloadableResult(results["data"]["resources"]["json"], self._client)

    def as_dict(self) -> Dict[str, Any]:
        """Download the SurfaceEvolution JSON data and load it as a Python dictionary.

        Accessing this help method blocks until the data is ready.
        """
        return json.load(self.data.in_memory())

    @classmethod
    def _api_name(cls) -> str:
        # Name of the postprocessing in API calls. Overriding because the endpoint is SurfaceEvol not SurfaceEvolution
        return "SurfaceEvol"


class Slice(PostProcessing):
    """Provides a representation of a slice from the prediction in PNG or VTP format.

    This class is generated through the :meth:`PredictionPostProcessings.slice` method.
    """

    @property
    def data(self) -> DownloadableResult:
        """:class:`DownloadableResult` object that allows
        access to slice data, both directly in memory
        and by downloading it into a file.

        Accessing this property blocks until the data is ready.

        Returns:
            A :class:`DownloadableResult`
        """
        self.wait()
        results = self._get_results(cache=False)
        return DownloadableResult(
            results["data"]["resources"][self.parameters.get("output_format", "png")],
            self._client,
        )


class _PostProcessingVTKExport(PostProcessing, ABC):
    """Provides the representation of the result of the prediction in a format of the VTK family."""

    @property
    def data(self) -> DownloadableResult:
        """:class:`DownloadableResult` object that allows
        access to the VTK data, either directly in memory
        or by downloading it into a file.

        Accessing this property blocks until the data is ready.
        """
        self.wait()
        results = self._get_results(cache=False)
        return DownloadableResult(results["data"]["resources"]["vtk"], self._client)


class VolumeVTU(_PostProcessingVTKExport):
    """Provides for exporting the volume of the prediction in VTU format.

    This class is generated through the :meth:`PredictionPostProcessings.volume_vtu()` method.
    """


class SurfaceVTP(_PostProcessingVTKExport):
    """Exports the surface of the prediction in VTP format associating all data with cells.

    This class is generated through the :meth:`~PredictionPostProcessings.surface_vtp()` method.
    """


class SurfaceVTPTDLocation(_PostProcessingVTKExport):
    """Exports the surface of the prediction in VTP format keeping the original data association.

    This class is generated through the :meth:`~PredictionPostProcessings.surface_vtp_td_location()` method.
    """


class CustomVolumePointCloud(PostProcessing):
    """Provides a representation of a CustomVolumePointCloud post-processing.

    This class is generated through the :meth:`~PredictionPostProcessings.custom_volume_point_cloud()` method.
    """

    @property
    def data(self) -> DownloadableResult:
        """:class:`DownloadableResult` object that allows access to the custom volume VTP
        either directly in memory or by download it into a file.

        Accessing this property blocks until the data is ready
        """
        self.wait()
        results = self._get_results(cache=False)
        return DownloadableResult(results["data"]["resources"]["vtp"], self._client)


class PredictionPostProcessings:
    """Acts as the namespace inside :py:class:`~ansys.simai.core.data.predictions.Prediction` objects.

    This class allows you to analyze the results of a prediction.

    It can be accessed from any prediction object through its
    :attr:`~ansys.simai.core.data.predictions.Prediction.post` property:

    Example:
        .. code-block:: python

            sim = simai.predictions.get("<prediction_id>")
            # Run the global coefficients
            coefs = sim.post.global_coefficients()
    """

    def __init__(self, prediction: "Prediction"):
        self._client = prediction._client
        self.prediction = prediction
        # _post_processings contains, for each Type,
        # a Dict of params -> postProcess
        self._post_processings: Dict[Type[PostProcessing], Dict[frozenset, PostProcessing]] = {}

    def global_coefficients(self, run: bool = True) -> Optional[GlobalCoefficients]:
        """Compute or get the global coefficients of the prediction.

        This is a non-blocking method. It returns the ``GlobalCoefficients``
        object without waiting. This object may not have data right away
        if the computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        Computation is launched only on first call of this method.
        Subsequent calls do not relaunch it.

        Args:
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            ``GlobalCoefficients`` object that eventually contains
            the global coefficients with its pressure and velocity components.
            Returns ``None`` if ``run=False`` and the postprocessing does not exist.
        """
        return self._get_or_run(GlobalCoefficients, {}, run)

    def surface_evolution(
        self, axis: str, delta: float, run: bool = True
    ) -> Optional[SurfaceEvolution]:
        """Compute or get the SurfaceEvolution for specific parameters.

        This is a non-blocking method. It returns the ``SurfaceEvolution``
        object without waiting. This object may not have data right away
        if computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        The computation is launched only on first call of this method
        with a specific set of parameters. Subsequent calls with the
        same parameters do not relaunch it.

        Args:
            axis: Axis to compute the surface evolution for.
            delta: Increment of the abscissa in meters.
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            ``SurfaceEvolution`` that allows access to the values.
            Returns ``None`` if ``run=False`` and the postprocessing does not exist.
        """
        if axis not in ["x", "y", "z"]:
            raise TypeError("Axis must be x, y, or z.")
        if not isinstance(delta, numbers.Number) or not (delta > 0):
            raise TypeError(f"Delta must be a positive number (got: {delta}).")
        return self._get_or_run(SurfaceEvolution, {"axis": axis, "delta": delta}, run)

    def slice(
        self, axis: str, coordinate: float, format: str = "png", run: bool = True
    ) -> Optional[Slice]:
        """Compute or get a slice for specific plane parameters.

        This is a non-blocking method. It returns the ``Slice``
        object without waiting. This object may not have data right away
        if computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        The computation is launched only on first call of this method
        with a specific set of parameters. Subsequent calls with the same
        parameters do not relaunch it.

        The slice is in the NPZ format.

        Args:
            axis: Axis to slice.
            coordinate: Coordinate along the given axis to slice at.
            format: Format of the output. The default is ``'png'``. Options
                are ``'png'`` and ``'vtp'``.
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            ``Slice`` object that allows downloading the binary data.
            Returns ``None`` if ``run=False`` and the postprocessing does not exist.

        Example:
            Make a slice and open it in a new window using the `Pillow <https://pypi.org/project/pillow/>`_
            library.

            .. code-block:: python

                import ansys.simai.core
                from PIL import Image

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                slice_data = prediction.post.slice("x", 50).data.in_memory()
                slice = Image.open(slice_data)
                slice.show()

        """
        if axis not in ["x", "y", "z"]:
            raise InvalidArguments(f"{axis} is not a valid axis. It should be x, y, or z.")
        if format not in ["png", "vtp"]:
            raise InvalidArguments(f"{format} is not a valid format. It should be png or vtp.")
        plane = convert_axis_and_coordinate_to_plane_eq_coeffs(axis, coordinate)
        return self._get_or_run(Slice, {"plane": plane, "output_format": format}, run)

    def surface_vtp(self, run: bool = True) -> Optional[SurfaceVTP]:
        """Compute or get the result of the prediction's surface in VTP format.

        This method associates all data with cells; if a variable is originally
        associated with points in the sample, it would be now associated with cells.

        It is a non-blocking method. It returns the ``PostProcessingVTP``
        object without waiting. This object may not have data right away
        if the computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        The computation is launched only on first call of this method.
        Subsequent calls do not relaunch it.

        Args:
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            :class:`SurfaceVTP` object that allows downloading the binary data.
            Returns ``None`` if ``run=False`` and the postprocessing does not exist.

        Examples:
            Run and download a surface VTP with data associated with cells.

            .. code-block:: python

                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                surface_vtp = prediction.post.surface_vtp().data.download("/tmp/simai.vtp")

            Run a surface VTP with data association on cells, and open a plot using PyVista.

            .. code-block:: python

                import ansys.simai.core
                import pyvista
                import tempfile

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                surface_vtp_data = prediction.post.surface_vtp().data
                # I don't want to save the file locally but pyvista doesn't read file-objects
                # Using temporary file as a workaround but a real path can be used instead
                with tempfile.NamedTemporaryFile(suffix=".vtp") as temp_vtp_file:
                    surface_vtp_data.download(temp_vtp_file.name)
                    surface_vtp = pyvista.read(temp_vtp_file.name)
                    surface_vtp.plot()
        """
        return self._get_or_run(SurfaceVTP, {}, run)

    def surface_vtp_td_location(self, run: bool = True) -> Optional[SurfaceVTPTDLocation]:
        """Compute or get the result of the prediction's surface in VTP format .

        This method keeps the original data association as they are in the sample.

        It is a non-blocking method. It returns the ``PostProcessingVTP``
        object without waiting. This object may not have data right away
        if the computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        The computation is launched only on first call of this method.
        Subsequent calls do not relaunch it.

        Args:
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            :class:`SurfaceVTPTDLocation` object that allows downloading the binary data.
            Returns ``None`` if ``run=False`` and the postprocessing does not exist.

        Examples:
            Run and download a surface VTP with the original data association.

            .. code-block:: python

                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                surface_vtp = prediction.post.surface_vtp_td_location().data.download("/tmp/simai.vtp")

            Run a surface VTP with the original data association, and open a plot using PyVista.

            .. code-block:: python

                import ansys.simai.core
                import pyvista
                import tempfile

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                surface_vtp_data = prediction.post.surface_vtp_td_location().data
                # I don't want to save the file locally but pyvista doesn't read file-objects
                # Using temporary file as a workaround but a real path can be used instead
                with tempfile.NamedTemporaryFile(suffix=".vtp") as temp_vtp_file:
                    surface_vtp_data.download(temp_vtp_file.name)
                    surface_vtp = pyvista.read(temp_vtp_file.name)
                    surface_vtp.plot()
        """
        return self._get_or_run(SurfaceVTPTDLocation, {}, run)

    def volume_vtu(self, run: bool = True) -> Optional[VolumeVTU]:
        """Compute or get the result of the prediction's volume in VTU format.

        This is a non-blocking method. It returns the ``PostProcessingVTU``
        object without waiting. This object may not have data right away
        if the computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        The computation is launched only on first call of this method.
        Subsequent calls do not relaunch it.

        Args:
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            :class:`VolumeVTU` object that allows downloading the binary data.

        Examples:
            Run and download a volume VTU

            .. code-block:: python

                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                volume_vtu = prediction.post.volume_vtu().data.download("/tmp/simai.vtu")


            Run a volume VTU and open a plot using PyVista.

            .. code-block:: python

                import ansys.simai.core
                import pyvista
                import tempfile

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                volume_vtu_data = prediction.post.volume_vtu().data
                # I don't want to save the file locally but pyvista doesn't read file-objects
                # Using temporary file as a workaround but a real path can be used instead
                with tempfile.NamedTemporaryFile(suffix=".vtu") as temp_vtu_file:
                    volume_vtu_data.download(temp_vtu_file.name)
                    volume_vtu = pyvista.read(temp_vtu_file.name)
                    volume_vtu.plot()
        """
        return self._get_or_run(VolumeVTU, {}, run)

    def custom_volume_point_cloud(self, run: bool = True) -> Optional[CustomVolumePointCloud]:
        """Compute or get the result of a predicted volume depending on the geometry's point cloud.

        This is a non-blocking method. It returns the :class:`CustomVolumePointCloud`
        object without waiting. This object may not have data right away
        if the computation is still in progress. Data is filled
        asynchronously once the computation is finished.
        The state of computation can be monitored with the ``is_ready`` flag
        or waited upon with the ``wait()`` method.

        The computation is launched only on first call of this method.
        Subsequent calls do not relaunch it.

        Args:
            run: Boolean indicating whether to compute or get the postprocessing.
                The default is ``True``. If ``False``, the postprocessing is not
                computed, and ``None`` is returned if it does not exist yet.

        Returns:
            :class:`CustomVolumePointCloud` object that allows downloading the binary data.

        Examples:
            Run and download a custom volume point cloud

            .. code-block:: python

                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                prediction.geometry.upload_point_cloud("/data/my-point-cloud.vtp")
                custom_volume = prediction.post.custom_volume_point_cloud().data.download(
                    "/tmp/simai.vtp"
                )
        """
        if self.prediction.geometry.point_cloud is None:
            raise InvalidOperationError(
                """No point cloud file is attached to the geometry.
Attach the required file to enable CustomVolumePointCloud postprocessing."""
            )
        return self._get_or_run(CustomVolumePointCloud, {}, run)

    @property
    def _local_post_processings(self):
        """Postprocessings launched by the local session, which are waited upon."""
        local_post_processings = []
        for pp_by_type in self._post_processings.values():
            local_post_processings.extend(pp_by_type.values())
        return local_post_processings

    def _delete_local_post_processing(self, post_processing: PostProcessing):
        params = post_processing.fields.get("location", {})
        params_frozen = frozenset(params.items())
        del self._post_processings[post_processing.__class__][params_frozen]

    def _get_or_run(
        self, pp_class: Type[PostProcessing], params: Dict[str, Any], run: bool
    ) -> Optional[PostProcessing]:
        """Get the existing postprocessing or run one if it doesn't exist yet.

        Args:
            pp_class: Type of postprocessing.
            params: Parameters of the postprocessing.
            run: Boolean indicating whether to compute or get the postprocessing.
                If ``False``, this method only gets an existing postprocessing.

        This is a non-blocking method. It runs (if not already run orrunning) the postprocessing
        of given type with the given parameters. If ``run=False``, if a postprocessing already
        exits, it gets it.
        """
        # FIXME frozenset(params.items()) works as long as there are no
        # collision between params (axis and delta for surface evolution, param for slice)
        # but will be broken if a new type of postprocessings can have
        # two params with the same value.
        params_frozen = frozenset(params.items())
        # If a postprocessing of this type and with those params already exists locally, return it
        if pp_class in self._post_processings and params_frozen in self._post_processings[pp_class]:
            return self._post_processings[pp_class][params_frozen]
        if run:
            api_response = self._client._api.run_post_processing(
                self.prediction.id, pp_class._api_name(), params
            )
        else:
            api_response = self._client._api.get_post_processings_for_prediction(
                self.prediction.id, pp_class._api_name(), params
            )
            # The API is supposed to return a list of one element (because we filtered for the specific params).
            # Check that it's the case and extract that element from the list.
            if len(api_response) == 0:
                return None
            elif len(api_response) > 1:
                raise InvalidServerStateError(
                    cleandoc(
                        f"""
                        Multiple postprocessings were found when only one should be found.
                        {[pp["id"] for pp in api_response]}
                        """
                    )
                )
            else:
                api_response = api_response[0]

        post_processing = self._client._post_processing_directory._model_from(
            data=api_response, pp_class=pp_class, prediction=self.prediction
        )
        if pp_class not in self._post_processings:
            self._post_processings[pp_class] = {}
        self._post_processings[pp_class][params_frozen] = post_processing
        for location, warning_message in post_processing.fields.get("warnings", {}).items():
            logger.warning(f"{location}: {warning_message}")
        return post_processing

    def list(
        self,
        post_processing_type: Optional[Type[PostProcessing]] = None,
    ) -> List[PostProcessing]:
        """List the postprocessings associated with the prediction.

        See :func:`post_processings.list<ansys.simai.core.data.post_processings.PostProcessingDirectory.list>`
        """
        return self._client.post_processings.list(
            post_processing_type=post_processing_type,
            prediction=self.prediction,
        )


class PostProcessingDirectory(Directory[PostProcessing]):
    _data_model = PostProcessing

    @classmethod
    def _data_model_for_type_name(cls, pp_type_name: str) -> Optional[Type[PostProcessing]]:
        return getattr(sys.modules[__name__], pp_type_name, None)

    def _model_from(
        self,
        data: dict,
        pp_class: Optional[Type[PostProcessing]] = None,
        prediction: "Prediction" = None,
    ) -> PostProcessing:
        if pp_class is not None:
            constructor = pp_class
        else:
            constructor = self._data_model_for_type_name(data["type"])
        if not constructor:
            raise ValueError(f"""Received unknown postprocessing type {data["type"]}.""")
        post_processing = super()._model_from(data, data_model=constructor)
        post_processing._prediction = prediction
        return post_processing

    @property
    def info(self) -> List[Dict[str, Any]]:
        """Dictionary containing information about the available postprocessings and their parameters.

        Example:
            .. code-block:: python

                from pprint import pprint
                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                post_processing_info = simai.post_processings.info
                pprint(post_processing_info)
        """
        return self._client.current_workspace.model_manifest.post_processings

    def get(self, id: str) -> PostProcessing:
        """Get a specific postprocessing object from the server.

        Args:
            id: ID of the postprocessing.

        Returns:
            :py:class:`PostProcessing` with the given ID if it exists.

        Raises:
            NotFoundError: No postprocessing with the given ID exists.
        """
        data = self._client._api.get_post_processing_result(id)
        return self._model_from(data)

    def list(
        self,
        post_processing_type: Optional[Type[PostProcessing]] = None,
        prediction: Optional[Identifiable["Prediction"]] = None,
        workspace: Optional[Identifiable["Workspace"]] = None,
    ) -> List[PostProcessing]:
        """List the postprocessings in the current workspace or associated with a prediction.

        Optionally you can choose to list only postprocessings of a specific type.
        For the name of the available postprocessings, see :ref:`available_pp`.

        Args:
            post_processing_type: Type of postprocessing to list.
            prediction: ID or :class:`model <.predictions.Prediction>` of a prediction.
                If a value is specified, only postprocessings associated with this prediction
                are returned.
            workspace: ID or :class:`model <.workspaces.Workspace>` of a workspace.
                If a value is specified, only postprocessings associated with this workspace
                are returned.

        Raises:
            NotFoundError: Postprocessing type, prediction ID or workspace ID are incorrect.
            InvalidArguments: If both prediction and workspace are specified.
            InvalidClientStateError: Neither prediction nor workspace are defined, default
                workspace is not set.

        Example:
            .. code-block:: python

                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                post_processings = simai.post_processings.list(
                    ansys.simai.core.SurfaceEvolution, prediction.id
                )
        """
        pp_type_str = post_processing_type._api_name() if post_processing_type else None
        if workspace and prediction:
            raise InvalidArguments("Only one of Workspace or Prediction can be specified")
        if prediction:
            prediction_id = get_id_from_identifiable(prediction, required=False)
            post_processings = self._client._api.get_post_processings_for_prediction(
                prediction_id, pp_type_str
            )
        else:
            if workspace:
                workspace_id = get_id_from_identifiable(workspace, required=False)
            else:
                workspace_id = self._client.current_workspace.id
            post_processings = self._client._api.get_post_processings_in_workspace(
                workspace_id, pp_type_str
            )
        return list(map(self._model_from, post_processings))

    def run(
        self,
        post_processing_type: Union[str, Type[PostProcessing]],
        prediction: Identifiable["Prediction"],
        parameters: Optional[Dict[str, Any]] = None,
        **kwargs,
    ) -> PostProcessing:
        """Run a postprocessing on a prediction.

        For the name and the parameters expected by the postprocessings,
        see :ref:`available_pp` and :ref:`pp_methods`. Note that the case
        of the class names must be respected.

        Args:
            post_processing_type: Type of postprocessing to run as a string
                or as the class itself.
            prediction: ID or :class:`model <.predictions.Prediction>` of the prediction
                to run the postprocessing for.
            parameters: Parameters to apply to the postprocessing, if needed.
                Alternatively, parameters can be passed as kwargs.
            **kwargs: Unpacked parameters for the postprocessing.

        Examples:
            .. code-block:: python

                import ansys.simai.core

                simai = ansys.simai.core.from_config()
                prediction = simai.predictions.list()[0]
                simai.post_processings.run(
                    ansys.simai.core.Slice, prediction, {"axis": "x", coordinate: 50}
                )

            Using kwargs:

            .. code-block:: python

                simai.post_processings.run(ansys.simai.core.Slice, prediction, axis="x", coordinate=50)
        """
        if isinstance(post_processing_type, str):
            post_processing_type = getattr(
                sys.modules[__name__], post_processing_type, post_processing_type
            )

        if isinstance(post_processing_type, type) and issubclass(
            post_processing_type, PostProcessing
        ):
            pp_class = post_processing_type
        else:
            raise InvalidArguments(
                cleandoc(
                    f""""{post_processing_type}" is not a valid postprocessing type.
                    You can find the available postprocessings by accessing the
                    ``.post_processings.info`` attribute of your SimAI client.
                    """
                )
            )
        prediction = self._client.predictions.get(get_id_from_identifiable(prediction))
        if not parameters:
            parameters = {}
        parameters.update(**kwargs)
        return prediction.post._get_or_run(pp_class, parameters, True)

    def delete(self, post_processing: Identifiable[PostProcessing]):
        """Delete a postprocessing.

        Args:
            post_processing: ID or :class:`model <PostProcessing>` of the postprocessing.
        """
        # FIXME?: This won't update the post_processings of the prediction's PredictionPostProcessings if any.
        # Doing so would require an extra call to get the prediction info and I'm not sure there's really a point
        self._client._api.delete_post_processing(get_id_from_identifiable(post_processing))
