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

from __future__ import annotations
from typing import TypeVar, Any, Generic, Sequence, Iterator
import warnings
import copy
import functools
import math

import numpy as _np
import pandas as _pd

from ..util import DPError, ElementType, floating, is_floating, is_integer, is_realnum, realnum
from ..array_base import PrivArrayBase
from ..alignment import assert_privacy_axis
from ..prisoner import Prisoner, SensitiveInt, SensitiveFloat
from ..realexpr import RealExpr, _max as dmax
from ..accountants import Accountant
from ..numpy import PrivNDArray, SensitiveNDArray, NDArrayDomain
from .. import egrpc
from .domain import Domain, BoolDomain, RealDomain, CategoryDomain, sum_sensitivity

T = TypeVar("T")

# to avoid TypeError: type 'Series' is not subscriptable
# class PrivSeries(PrivArrayBase[_pd.Series[T]]):
@egrpc.remoteclass
class PrivSeries(Generic[T], PrivArrayBase[_pd.Series]):  # type: ignore[type-arg]
    """Private Series.

    Each value in this series object should have a one-to-one relationship with an individual (event-/row-/item-level DP).
    Therefore, the number of values is treated as a sensitive value.
    """
    _domain        : Domain
    _is_user_key   : bool
    _user_max_freq : RealExpr

    def __init__(self,
                 data           : Any,
                 domain         : Domain,
                 distance       : RealExpr,
                 is_user_key    : bool                         = False,
                 user_max_freq  : RealExpr                     = RealExpr.INF,
                 *,
                 parents        : Sequence[PrivArrayBase[Any]] = [],
                 accountant     : Accountant[Any] | None       = None,
                 keep_alignment : bool                         = False,
                 ):
        self._domain = domain
        self._is_user_key = is_user_key
        self._user_max_freq = user_max_freq
        ser = _pd.Series(data)
        super().__init__(value          = ser,
                         distance       = distance,
                         privacy_axis   = 0,
                         parents        = parents,
                         accountant     = accountant,
                         keep_alignment = keep_alignment)

    def _is_eldp(self) -> bool:
        return not self._is_user_key

    def _is_uldp(self) -> bool:
        return self._is_user_key

    def _assert_not_uldp(self) -> None:
        if self._is_uldp():
            raise DPError("This operation is not permitted for user Series.")

    def _eldp_distance(self) -> RealExpr:
        if self._is_eldp():
            return self._distance
        else:
            return self._distance * self._user_max_freq

    def _get_dummy_ser(self, n_rows: int = 3) -> _pd.Series[str]:
        index = list(range(n_rows)) + ['...']
        dummy_data = ['***' for _ in range(n_rows)] + ['...']
        # TODO: dtype becomes 'object'
        return _pd.Series(dummy_data, index=index, name=self.name)

    def __str__(self) -> str:
        with _pd.option_context('display.show_dimensions', False):
            return self._get_dummy_ser().__str__().replace("dtype: object", f"dtype: {self.domain.dtype}")

    def __repr__(self) -> str:
        with _pd.option_context('display.show_dimensions', False):
            return self._get_dummy_ser().__repr__().replace("dtype: object", f"dtype: {self.domain.dtype}")

    def __len__(self) -> int:
        # We cannot return Prisoner() here because len() must be an integer value
        raise DPError("len(ser) is not supported. Use ser.shape[0] or ser.size instead.")

    @egrpc.multimethod
    def __getitem__(self, key: PrivSeries[bool]) -> PrivSeries[T]:
        assert_privacy_axis(self, key)
        return PrivSeries[T](data           = self._value.__getitem__(key._value),
                             domain         = self.domain,
                             distance       = self._distance,
                             is_user_key    = self._is_user_key,
                             user_max_freq  = self._user_max_freq,
                             parents        = [self],
                             keep_alignment = False)

    @egrpc.multimethod
    def __setitem__(self, key: PrivSeries[bool], value: ElementType) -> None:
        assert_privacy_axis(self, key)
        self._assert_not_uldp()
        self._value[key._value] = value

    @egrpc.multimethod
    def __eq__(self, other: PrivSeries[ElementType]) -> PrivSeries[bool]:
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value == other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__eq__.register
    def _(self, other: ElementType) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value == other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __ne__(self, other: PrivSeries[ElementType]) -> PrivSeries[bool]:
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value != other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__ne__.register
    def _(self, other: ElementType) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value != other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __lt__(self, other: PrivSeries[ElementType]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value < other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__lt__.register
    def _(self, other: ElementType) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value < other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __le__(self, other: PrivSeries[ElementType]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value <= other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__le__.register
    def _(self, other: ElementType) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value <= other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __gt__(self, other: PrivSeries[ElementType]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value > other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__gt__.register
    def _(self, other: ElementType) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value > other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __ge__(self, other: PrivSeries[ElementType]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value >= other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__ge__.register
    def _(self, other: ElementType) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value >= other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __and__(self, other: PrivSeries[bool]) -> PrivSeries[bool]:
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value & other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__and__.register
    def _(self, other: bool) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value & other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __rand__(self, other: PrivSeries[bool]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = other._value & self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__rand__.register
    def _(self, other: bool) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = other & self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __or__(self, other: PrivSeries[bool]) -> PrivSeries[bool]:
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value | other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__or__.register
    def _(self, other: bool) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value | other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __ror__(self, other: PrivSeries[bool]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = other._value | self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__ror__.register
    def _(self, other: bool) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = other | self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __xor__(self, other: PrivSeries[bool]) -> PrivSeries[bool]:
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = self._value ^ other._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__xor__.register
    def _(self, other: bool) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = self._value ^ other,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.multimethod
    def __rxor__(self, other: PrivSeries[bool]) -> PrivSeries[bool]: # type: ignore
        assert_privacy_axis(self, other)
        self._assert_not_uldp()
        other._assert_not_uldp()
        return PrivSeries[bool](data           = other._value ^ self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self, other],
                                keep_alignment = True)

    @__rxor__.register
    def _(self, other: bool) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = other ^ self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.method
    def __invert__(self) -> PrivSeries[bool]:
        self._assert_not_uldp()
        return PrivSeries[bool](data           = ~self._value,
                                domain         = BoolDomain(),
                                distance       = self._distance,
                                parents        = [self],
                                keep_alignment = True)

    @egrpc.property
    def shape(self) -> tuple[SensitiveInt]:
        nrows = SensitiveInt(value=self._value.shape[0], distance=self._eldp_distance(), parents=[self])
        return (nrows,)

    @egrpc.property
    def size(self) -> SensitiveInt:
        return SensitiveInt(value=self._value.size, distance=self._eldp_distance(), parents=[self])

    # FIXME
    @property
    def dtype(self) -> Any:
        return self._value.dtype

    @egrpc.property
    def name(self) -> ElementType | None:
        return self._value.name  # type: ignore[return-value]

    @egrpc.property
    def domain(self) -> Domain:
        return self._domain

    @egrpc.property
    def is_user_key(self) -> bool:
        return self._is_user_key

    @egrpc.property
    def user_max_freq(self) -> int | None:
        return int(self._user_max_freq.max()) if not self._user_max_freq.is_inf() else None

    @egrpc.method
    def to_numpy(self, copy: bool | None = None) -> PrivNDArray:
        self._assert_not_uldp()

        if copy is None or not copy:
            raise NotImplementedError("`copy` must be True in privjail to_numpy().")

        if not isinstance(self.domain, RealDomain):
            raise DPError("Series must have RealDomain to be converted to NDArray.")

        array = self._value.to_numpy(dtype=float, copy=copy)

        lower, upper = self.domain.range
        lower_abs = _np.abs(lower) if lower is not None else math.inf
        upper_abs = _np.abs(upper) if upper is not None else math.inf
        l1_norm_max = max(lower_abs, upper_abs)

        new_domain = NDArrayDomain(norm_type="l1", norm_bound=l1_norm_max if l1_norm_max != math.inf else None)
        return PrivNDArray(value          = array,
                           distance       = self._distance,
                           privacy_axis   = 0,
                           domain         = new_domain,
                           parents        = [self],
                           keep_alignment = True)

    @property
    def values(self) -> PrivNDArray:
        return self.to_numpy(copy=True)

    # TODO: add test
    @egrpc.method
    def head(self, n: int = 5) -> PrivSeries[T]:
        self._assert_not_uldp()
        return PrivSeries[T](data           = self._value.head(n),
                             domain         = self.domain,
                             distance       = self._distance * 2,
                             parents        = [self],
                             keep_alignment = False)

    # TODO: add test
    @egrpc.method
    def tail(self, n: int = 5) -> PrivSeries[T]:
        self._assert_not_uldp()
        return PrivSeries[T](data           = self._value.tail(n),
                             domain         = self.domain,
                             distance       = self._distance * 2,
                             parents        = [self],
                             keep_alignment = False)

    # TODO: add test
    @egrpc.method
    def sort_values(self, *, ascending: bool = True) -> PrivSeries[T]:
        ser = self._value.sort_values(ascending=ascending, kind="stable")
        return PrivSeries[T](data           = ser,
                             domain         = self.domain,
                             distance       = self._distance,
                             is_user_key    = self._is_user_key,
                             user_max_freq  = self._user_max_freq,
                             parents        = [self],
                             keep_alignment = False)

    @egrpc.method
    def replace(self,
                to_replace : ElementType | None = None,
                value      : ElementType | None = None,
                ) -> PrivSeries[T]:
        self._assert_not_uldp()

        if (not is_realnum(to_replace)) or (not is_realnum(value)):
            # TODO: consider string and category dtype
            raise NotImplementedError

        if self.domain.dtype == "int64" and _np.isnan(value):
            new_domain = copy.copy(self.domain)
            new_domain.dtype = "Int64"

        elif isinstance(self.domain, RealDomain):
            a, b = self.domain.range
            if (a is None or a <= to_replace) and (b is None or to_replace <= b):
                new_a = min(a, value) if a is not None else None # type: ignore[type-var]
                new_b = max(b, value) if b is not None else None # type: ignore[type-var]

                new_domain = copy.copy(self.domain)
                new_domain.range = (new_a, new_b)

            else:
                new_domain = self.domain

        else:
            new_domain = self.domain

        ser = self._value.replace(to_replace, value) # type: ignore[arg-type]
        return PrivSeries[T](data           = ser,
                             domain         = new_domain,
                             distance       = self._distance,
                             parents        = [self],
                             keep_alignment = True)

    @egrpc.method
    def dropna(self, *, ignore_index: bool = False) -> PrivSeries[T]:
        if ignore_index:
            raise DPError("`ignore_index` must be False. Index cannot be reindexed with positions.")

        if self.domain.dtype == "Int64":
            new_domain = copy.copy(self.domain)
            new_domain.dtype = "int64"
        else:
            new_domain = self.domain

        return PrivSeries[T](data           = self._value.dropna(),
                             domain         = new_domain,
                             distance       = self._distance,
                             is_user_key    = self._is_user_key,
                             user_max_freq  = self._user_max_freq,
                             parents        = [self],
                             keep_alignment = True)

    @egrpc.method
    def clip(self,
             lower : realnum | None = None,
             upper : realnum | None = None,
             ) -> PrivSeries[T]:
        self._assert_not_uldp()

        if not isinstance(self.domain, RealDomain):
            raise TypeError("Domain must be real numbers.")

        new_domain = copy.copy(self.domain)
        a, b = self.domain.range
        new_a = a if lower is None else lower if a is None else max(a, lower) # type: ignore[type-var]
        new_b = b if upper is None else upper if b is None else min(b, upper) # type: ignore[type-var]
        new_domain.range = (new_a, new_b)

        ser = self._value.clip(lower, upper) # type: ignore[arg-type]
        return PrivSeries[T](data           = ser,
                             domain         = new_domain,
                             distance       = self._distance,
                             parents        = [self],
                             keep_alignment = True)

    @egrpc.method
    def sum(self) -> SensitiveInt | SensitiveFloat:
        self._assert_not_uldp()

        new_distance = self._distance * sum_sensitivity(self.domain)

        s = self._value.sum()

        if self.domain.dtype in ["int64", "Int64"]:
            return SensitiveInt(s, new_distance, parents=[self])
        elif self.domain.dtype in ["float64", "Float64"]:
            return SensitiveFloat(s, new_distance, parents=[self])
        else:
            raise ValueError

    def mean(self, eps: float) -> float:
        eps_each = eps / 2
        return self.sum().reveal(eps=eps_each) / self.shape[0].reveal(eps=eps_each)

    @egrpc.method
    def value_counts(self,
                     normalize : bool       = False,
                     sort      : bool       = True,
                     ascending : bool       = False,
                     bins      : int | None = None,
                     dropna    : bool       = True,
                     ) -> SensitiveSeries[int]:
        self._assert_not_uldp()

        if normalize:
            # TODO: what is the sensitivity?
            raise NotImplementedError

        if bins is not None:
            # TODO: support continuous values
            raise NotImplementedError

        if sort:
            raise DPError("The `sort` argument must be False.")

        if isinstance(self.domain, CategoryDomain):
            values = list(self.domain.categories)
        else:
            raise DPError("Series for value_counts() must be of a categorical type")

        if not dropna and not any(_np.isnan(values)): # type: ignore
            # TODO: consider handling for pd.NA
            warnings.warn("Counts for NaN will be dropped from the result because NaN is not included in `values`", UserWarning)

        counts = self._value.value_counts(normalize, sort, ascending, bins, dropna)

        # Select only the specified values and fill non-existent counts with 0
        counts = counts.reindex(values, fill_value=0)

        return SensitiveSeries[int](data                = counts,
                                    distance_group_axes = None,
                                    distance            = self._distance,
                                    parents             = [self])

@egrpc.remoteclass
class SensitiveSeries(Generic[T], Prisoner[_pd.Series]): # type: ignore[type-arg]
    _distance_group_axes   : tuple[int, ...] | None
    _partitioned_distances : list[RealExpr] | None
    _distributed_ser       : _pd.Series[Any] | None

    def __init__(self,
                 data                  : Any,
                 distance_group_axes   : tuple[int, ...] | None    = None,
                 distance              : RealExpr | None           = None,
                 partitioned_distances : Sequence[RealExpr] | None = None,
                 index                 : Any                       = None,
                 name                  : ElementType | None        = None,
                 *,
                 parents               : Sequence[Prisoner[Any]]   = [],
                 accountant            : Accountant[Any] | None    = None,
                 distributed_ser       : _pd.Series[Any] | None    = None,
                 ):
        self._distance_group_axes = tuple(distance_group_axes) if distance_group_axes is not None else None
        self._partitioned_distances = list(partitioned_distances) if partitioned_distances is not None else None
        self._distributed_ser = distributed_ser

        ser = _pd.Series(data, index=index, name=name)

        if self._distance_group_axes is None:
            if distance is None:
                raise ValueError("`distance` must be specified when distance_group_axes is None.")
            if self._partitioned_distances is not None:
                raise ValueError("`partitioned_distances` must not be provided when distance_group_axes is None.")

        elif self._distance_group_axes == (0,):
            if self._partitioned_distances is None:
                raise ValueError("`partitioned_distances` is required when distance_group_axes=(0,).")
            if len(ser) != len(self._partitioned_distances):
                raise ValueError("`partitioned_distances` length must match the series length.")

            distance = sum(self._partitioned_distances, start=RealExpr(0))

        else:
            raise ValueError("Unsupported distance_group_axes for SensitiveSeries.")

        super().__init__(value=ser, distance=distance, parents=parents, accountant=accountant)

        if self._distance_group_axes is None and self._distributed_ser is not None:
            self._partitioned_distances = [value._distance for value in self._distributed_ser]

    def _distance_of(self, key: ElementType) -> RealExpr:
        components = self._ensure_partitioned_distances()
        idx = self.index.get_loc(key)
        assert isinstance(idx, int)
        return components[idx]

    def _wrap_sensitive_value(self,
                              value      : ElementType,
                              distance   : RealExpr,
                              parents    : Sequence[Prisoner[Any]],
                              accountant : Accountant[Any] | None = None,
                              ) -> SensitiveInt | SensitiveFloat:
        if self._value.dtype in ["int64", "Int64"]:
            assert is_integer(value)
            return SensitiveInt(value, distance=distance, parents=parents, accountant=accountant)
        elif self._value.dtype in ["float64", "Float64"]:
            assert is_floating(value)
            return SensitiveFloat(value, distance=distance, parents=parents, accountant=accountant)
        else:
            raise Exception

    def _ensure_partitioned_distances(self) -> list[RealExpr]:
        if self._distance_group_axes is None:
            if self._partitioned_distances is None:
                self._partitioned_distances = self._distance.create_exclusive_children(len(self))

        elif self._distance_group_axes == (0,):
            assert self._partitioned_distances is not None

        else:
            raise ValueError("Unsupported distance_group_axes for SensitiveSeries.")

        assert self._partitioned_distances is not None
        return self._partitioned_distances

    def _get_distributed_ser(self) -> _pd.Series[Any]:
        assert self._distance_group_axes is None

        if self._distributed_ser is None:
            effective_max_distance = float(self._distance.max())
            if effective_max_distance != 1.0:
                raise DPError("Parallel composition requires adjacent databases (max_distance=1)")

            distances = self._ensure_partitioned_distances()

            child_accountants = self.accountant.create_parallel_accountants(len(distances))
            data = [self._wrap_sensitive_value(v, distance=d, parents=[self], accountant=child_accountant)
                    for v, d, child_accountant in zip(self._value, distances, child_accountants)]

            self._distributed_ser = _pd.Series(data, index=self.index, name=self.name)

        return self._distributed_ser

    def _get_dummy_ser(self) -> _pd.Series[str]:
        dummy_data = ['***' for _ in self.index]
        # TODO: dtype becomes 'object'
        return _pd.Series(dummy_data, index=self.index, name=self.name)

    def __str__(self) -> str:
        with _pd.option_context('display.show_dimensions', False):
            # return self._get_dummy_ser().__str__().replace("dtype: object", f"dtype: {self.domain.dtype}")
            return self._get_dummy_ser().__str__()

    def __repr__(self) -> str:
        with _pd.option_context('display.show_dimensions', False):
            # return self._get_dummy_ser().__repr__().replace("dtype: object", f"dtype: {self.domain.dtype}")
            return self._get_dummy_ser().__repr__()

    def __len__(self) -> int:
        return self.shape[0]

    def __iter__(self) -> Iterator[SensitiveInt | SensitiveFloat]:
        return iter([self[idx] for idx in self.index])

    @egrpc.multimethod
    def __getitem__(self, key: ElementType) -> SensitiveInt | SensitiveFloat:
        if self._distance_group_axes is None:
            return copy.copy(self._get_distributed_ser().loc[key]) # type: ignore
        elif self._distance_group_axes == (0,):
            return self._wrap_sensitive_value(self._value[key], distance=self._distance_of(key), parents=[self]) # type: ignore
        else:
            raise ValueError("Unsupported distance_group_axes for SensitiveSeries.")

    @egrpc.property
    def index(self) -> _pd.Index:  # type: ignore
        return self._value.index

    @egrpc.property
    def name(self) -> ElementType | None:
        return self._value.name  # type: ignore[return-value]

    @egrpc.property
    def shape(self) -> tuple[int]:
        return self._value.shape

    @egrpc.property
    def size(self) -> int:
        return self._value.size

    @egrpc.method
    def to_numpy(self, copy: bool | None = None) -> SensitiveNDArray:
        if copy is None or not copy:
            raise NotImplementedError("`copy` must be True in privjail to_numpy().")

        try:
            array = self._value.to_numpy(dtype=float, copy=copy)
        except (TypeError, ValueError) as exc:
            raise DPError("Series must have RealDomain to be converted to NDArray.") from exc

        return SensitiveNDArray(value     = array,
                                distance  = self._distance,
                                norm_type = "l1",
                                parents   = [self])

    @property
    def values(self) -> SensitiveNDArray:
        return self.to_numpy(copy=True)

    @egrpc.method
    def max(self) -> SensitiveInt | SensitiveFloat:
        # TODO: args?
        if self._distance_group_axes == (0,):
            components = self._ensure_partitioned_distances()
            distance = functools.reduce(dmax, components)
        else:
            distance = self._distance

        return self._wrap_sensitive_value(self._value.max(), distance=distance, parents=[self])

    @egrpc.method
    def min(self) -> SensitiveInt | SensitiveFloat:
        # TODO: args?
        if self._distance_group_axes == (0,):
            components = self._ensure_partitioned_distances()
            distance = functools.reduce(dmax, components)
        else:
            distance = self._distance

        return self._wrap_sensitive_value(self._value.min(), distance=distance, parents=[self])

    def reveal(self,
               *,
               eps   : floating | None = None,
               delta : floating | None = None,
               rho   : floating | None = None,
               scale : floating | None = None,
               mech  : str             = "laplace",
               ) -> _pd.Series: # type: ignore
        if mech == "laplace":
            from ..mechanism import laplace_mechanism
            return laplace_mechanism(self,
                                     eps   = float(eps)   if eps   is not None else None,
                                     scale = float(scale) if scale is not None else None)
        elif mech == "gaussian":
            from ..mechanism import gaussian_mechanism
            return gaussian_mechanism(self,
                                      eps   = float(eps)   if eps   is not None else None,
                                      delta = float(delta) if delta is not None else None,
                                      rho   = float(rho)   if rho   is not None else None,
                                      scale = float(scale) if scale is not None else None)
        else:
            raise ValueError(f"Unknown DP mechanism: '{mech}'")
