# Copyright (C) 2024- Davide Mollica <davide.mollica@inaf.it>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This file is part of iactsim.
#
# iactsim is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# iactsim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with iactsim.  If not, see <https://www.gnu.org/licenses/>.

from dataclasses import dataclass
import numpy as np
from tabulate import tabulate
from typing import Tuple, Optional, Any
from numpy.typing import ArrayLike

from ._sag import sag
from ._surface_misc import SurfaceType, ApertureShape, SurfaceProperties, SurfaceShape, SensorSurfaceType
from ._materials import Material, Materials

@dataclass
class SurfaceVisualProperties:
    """
    Stores visualization attributes for an optical surface.

    Parameters
    ----------
    color : tuple of float, optional
        RGB color tuple (0.0-1.0). If None, the visualizer uses default logic based on surface type.
    opacity : float, optional
        Opacity (0.0 transparent - 1.0 opaque). If None, visualizer uses default logic based on surface type.
    specular : float, optional
        Specular reflection intensity (0.0-1.0).
    wireframe : bool, optional
        If True, forces wireframe representation for this surface. Default is False.
    resolution : float, optional
        Mesh resolution in mm. If None, uses the visualizer logic based on the surface extent.
    visible : bool, optional
        Whether the surface is visible in the render. Default is True.
    """
    color: Optional[Tuple[float, float, float]] = None
    opacity: Optional[float] = None
    specular: Optional[float] = None
    wireframe: bool = False
    resolution: Optional[float] = None
    visible: bool = True

class Surface:
    """Represents an optical surface.

        Parameters
        ----------
        type : SurfaceType
            Type of surface (reflective, refractive, opaque, dummy)
        position : array-like, optional
            Position of the surface center [x, y, z], by default None
        tilt_angles : array-like, optional
            Tilt angles (counter-clockwise x-tilt, y-tilt and z_tilt) in degrees, by default no tilt.
        scattering_dispersion : float, optional
            Standard deviation of Gaussian scattering after reflection/transmission, by default 0.0
        properties : SurfaceProperties, optional
            Reflectance/transmittance properties of the surface, by default None
        material_in : material, optional
            Material of the space before the surface front side. I.e. the material in which
            photons arriving with a negative z-component of their direction vector are travelling, 
            by default standard air.
        material_out : material, optional
            Material of the space after the surface back side. I.e. the material in which
            photons arriving with a positive z-component of their direction vector are travelling, 
            by default standard air.
        visual_properties : SurfaceVisualProperties, optional
            Visual properties of the surface, by default None
        name : str, optional
            Surface name
    """

    def __init__(self,
                 surface_type: SurfaceType,
                 position: ArrayLike = np.array([0., 0., 0.]),
                 tilt_angles: ArrayLike = np.array([0., 0., 0.]),
                 scattering_dispersion: float = 0.0,
                 properties: Optional[SurfaceProperties] = None,
                 material_in: Material = Materials.AIR,
                 material_out: Material = Materials.AIR,
                 visual_properties: Optional['SurfaceVisualProperties'] = None,
                 name: Optional[str] = None):
        self.type = surface_type
        self.position = position
        self.tilt_angles = tilt_angles
        self.scattering_dispersion = scattering_dispersion
        self.properties = properties if properties is not None else SurfaceProperties()
        self.material_in = material_in
        self.material_out = material_out
        self.name = name
        self.sensor_type = SensorSurfaceType.NONE
        if visual_properties is not None:
            self.visual_properties = visual_properties
        else:
            self.visual_properties = SurfaceVisualProperties()

        self._custom_rotation_matrix = None

    @property
    def axial_position(self) -> float:
        return self._axial_position

    @axial_position.setter
    def axial_position(self, value: float):
        self._axial_position = value
        self._position[-1] = value

    @property
    def position(self) -> np.ndarray:
        return self._position

    @position.setter
    def position(self, value: np.ndarray):
        self._position = np.asarray(value)
        self._axial_position = value[-1]
    
    def get_rotation_matrix(self) -> np.ndarray:
        """
        Calculates the 3D rotation matrix to transform into the surface reference
        system from the telescope reference system.
        
        The computation is based on surface tilt angles and assumes rotations are applied in the order 
        (last first) Z @ Y @ X. This corresponds to rotating the surface around 
        the telescope X axis, then the telescope Y axis and finally the telescope Z axis.
        The rotations follow the right-hand rule (counter-clockwise).

        These angles are intended for small misalignments,
        while :py:func:`~iactsim.optics.transforms.create_surface_rotation`
        is a more robust method. Tilt angles can be bypassed if 
        a custom rotation matrix has been set using :py:meth:`set_rotation_matrix` method.
        
        Tilt angles are expected in degrees. 

        Returns
        -------
            np.ndarray: A 3x3 rotation matrix.
            The returned matrix is the transpose of the tilt sequence. It transforms from
            the telescope reference system into the surface reference system.
        
        """
        # Return the custom rotation matrix if has been defined
        if self._custom_rotation_matrix is not None:
            return self._custom_rotation_matrix.reshape(3,3)

        x_tilt_rad, y_tilt_rad, z_tilt_rad = np.deg2rad(self.tilt_angles)
        cos_x = np.cos(x_tilt_rad)
        sin_x = np.sin(x_tilt_rad)
        cos_y = np.cos(y_tilt_rad)
        sin_y = np.sin(y_tilt_rad)
        cos_z = np.cos(z_tilt_rad)
        sin_z = np.sin(z_tilt_rad)

        x_rot = np.array([
            [1.0,    0.,     0.],
            [ 0., cos_x, -sin_x],
            [ 0., sin_x,  cos_x]
        ])

        y_rot = np.array([
            [ cos_y,  0., sin_y],
            [    0.,  1.,    0.],
            [-sin_y,  0., cos_y]
        ])

        z_rot = np.array([
            [ cos_z, -sin_z, 0.],
            [ sin_z,  cos_z, 0.],
            [    0.,     0., 1.],
        ])

        return  (z_rot @ y_rot @ x_rot).T
    
    def set_rotation_matrix(self, a_matrix):
        """Set a custom rotation matrix instead of deriving it from tilt angles.
        To use tilt angles set it to ``None``.

        Parameters
        ----------
        a_matrix : np.ndarray
            3D rotation matrix to transform from telescope to surface reference frame.
        """
        self._custom_rotation_matrix = a_matrix
    
    def __repr__(self) -> str:
        """Returns a string representation of the surface object."""
        return f"{self.__class__.__name__}(half_aperture={self.type}, ...)"
    
    def _table_data(self) -> list[list[Any]]:
        """Helper function to generate data for table representation."""
        table = []
        if self.name is not None:
            table.append(["Name", self.name])
        table += [
            ["Surface Type", self.type],
            ["Position", ', '.join([f'{x:.2f}' for x in self.position])],
            ["Tilt Angles", ', '.join([f'{x:.2f}' for x in self.tilt_angles])],
            ["Scattering Sigma", self.scattering_dispersion],
            ["Properties wavelegth", "set" if self.properties.wavelength is not None else "not set"],
            ["Properties incidence angle", "set" if self.properties.incidence_angle is not None else "not set"],
            ["Transmittance value", "set" if self.properties.transmittance is not None else "not set"],
            ["Reflectance value", "set" if self.properties.reflectance is not None else "not set"],
            ["Absorption value", "set" if self.properties.absorption is not None else "not set"],
        ]
        return table

    def __str__(self) -> str:
        """Returns a string representation of the surface object formatted as a table."""
        return tabulate(self._table_data(), headers=["Attribute", "Value"], tablefmt="fancy_grid")
    
    def _repr_markdown_(self) -> str:
        """Returns a Markdown representation of the surface object."""
        return tabulate(self._table_data(), headers=["Attribute", "Value"], tablefmt="pipe")

    def _repr_html_(self) -> str:
        """Returns an HTML representation of the surface object."""
        return tabulate(self._table_data(), headers=["Attribute", "Value"], tablefmt="html")

class AsphericalSurface(Surface):
    """Represents an aspherical optical surface.

        Parameters
        ----------
        half_aperture : float
            Half-aperture of the surface.
        curvature : float
            Surface curvature (1/radius of curvature)
        conic_constant : float
            Conic constant
        aspheric_coefficients : array-like
            Array of aspheric coefficients
        central_hole_half_aperture : float, optional
            Half-aperture of the central hole (0.0 if no hole), by default 0.0
        aperture_shape : ApertureShape, optional
            Shape of the aperture (circular, hexagonal or rectangular), by default ApertureShape.CIRCULAR
        central_hole_shape : ApertureShape, optional
            Shape of the central hole (circular, hexagonal or rectangular), by default ApertureShape.CIRCULAR
        is_fresnel : bool, optional
            Whether the surface is an ideal Fresnel lens/mirror, by default False
        surface_type : SurfaceType, optional
            Type of surface (reflective, refractive, opaque, dummy), by default SurfaceType.REFLECTIVE
        position : array-like, optional
            Position of the surface center [x, y, z], by default None
        tilt_angles : array-like, optional
            Tilt angles (counter-clockwise x-tilt, y-tilt and z_tilt) in degrees, by default no tilt.
        scattering_dispersion : float, optional
            Standard deviation of Gaussian scattering after reflection/transmission, by default 0.0
        properties : SurfaceProperties, optional
            Reflectance/transmittance properties of the surface, by default None
        material_in : material, optional
            Material of the space before the surface front side. I.e. the material in which
            photons arriving with a negative z-component of their direction vector are travelling, 
            by default standard air.
        material_out : material, optional
            Material of the space after the surface back side. I.e. the material in which
            photons arriving with a positive z-component of their direction vector are travelling, 
            by default standard air.
        name : str, optional
            Surface name
    """

    def __init__(self,
                 half_aperture: float,
                 curvature: float,
                 conic_constant: float,
                 aspheric_coefficients: ArrayLike,
                 central_hole_half_aperture: float = 0.0,
                 aperture_shape: ApertureShape = ApertureShape.CIRCULAR,
                 central_hole_shape: ApertureShape = ApertureShape.CIRCULAR,
                 is_fresnel: bool = False,
                 surface_type: SurfaceType = SurfaceType.REFLECTIVE,
                 position: ArrayLike = np.array([0., 0., 0.]),
                 tilt_angles: ArrayLike = np.array([0., 0., 0.]),
                 scattering_dispersion: float = 0.0,
                 properties: Optional[SurfaceProperties] = None,
                 material_in: Material = Materials.AIR,
                 material_out: Material = Materials.AIR,
                 name: Optional[str] = None):
        super().__init__(
            surface_type=surface_type,
            position=position,
            tilt_angles=tilt_angles,
            scattering_dispersion=scattering_dispersion,
            properties=properties,
            material_in=material_in,
            material_out=material_out,
            name=name
        )
        self.half_aperture = half_aperture
        self.curvature = curvature
        self.conic_constant = conic_constant
        self.aspheric_coefficients = np.asarray(aspheric_coefficients)
        self.central_hole_half_aperture = central_hole_half_aperture
        self.central_hole_shape = central_hole_shape
        self.aperture_shape = aperture_shape
        self.is_fresnel = is_fresnel
        self.offset = np.array([0.,0.])
        """Treat the surface as a segment centered at these aperture coordinates."""
        self._shape = SurfaceShape.ASPHERICAL

    def _validate(self):
        """Validates the internal consistency of the surface parameters."""
        name_str = "-".join([self.name,""]) if self.name is not None else ""
        if not self.half_aperture > 0:
            raise ValueError("".join([name_str, "Half-aperture must be positive."]))
        if not self.central_hole_half_aperture >= 0:
            raise ValueError("".join([name_str, "Half-aperture of the central hole cannot be negative."]))
        if not self.central_hole_half_aperture < self.half_aperture:
            raise ValueError("".join([name_str, "Half-aperture of the central must be less than surface half-aperture."]))
        if not self.aspheric_coefficients.ndim == 1:
            raise ValueError("".join([name_str, "Aspheric coefficients must be a 1D array."]))

    @property
    def aspheric_coefficients(self) -> np.ndarray:
        return self._aspheric_coefficients

    @aspheric_coefficients.setter
    def aspheric_coefficients(self, value: np.ndarray):
        if not isinstance(value, np.ndarray):
            value = np.asarray(value, dtype=np.float64)
        if len(value) > 10:
            raise(ValueError("Aspheric surfaces can be defined by a maximum of 10 aspheric coefficients."))
        self._aspheric_coefficients = value

    def sagitta(self, r: np.ndarray) -> np.ndarray:
        """Calculate the sagitta of the aspheric surface at a given radial distance in the surface refrence frame.

        Parameters
        ----------
        r : np.ndarray, list or scalar
            Radial distance(s) from the optical axis.

        Returns
        -------
        np.ndarray
            Sagitta value(s) at the given radial distance(s).
        """

        return sag(r, self.curvature, self.conic_constant, self.aspheric_coefficients, 0., self.is_fresnel)

    def get_surface_normal_vector(self, x: float, y: float) -> np.ndarray:
        """Calculate the upward-going normal unit vector at a given surface point (x,y) in the surface reference frame.

        Parameters
        ----------
        x : float
            x coordinate of the point.
        y : float
            y coordinate of the point.

        Returns
        -------
        np.ndarray
            Normal unit vector.
        """
        n = np.array([0,0,1.])

        # Flat
        if (np.abs(self.curvature) < 1e-10): 
            return n
        
        r2 = x*x+y*y

        # Aspheric component
        tot_aspher = 0.
        for i in range(len(self.aspheric_coefficients)):
            tot_aspher += 2 * (i+1) * self.aspheric_coefficients[i] * np.power(r2, i)

        arg_sqrt = 1-(1.0 + self.conic_constant) * self.curvature*self.curvature * r2
        if arg_sqrt < 0:
            raise(ValueError('Surface not well defined at the given point.'))
        
        fact = self.curvature / np.sqrt(arg_sqrt) + tot_aspher

        n[0] = - fact * x
        n[1] = - fact * y
        norm = np.sqrt(n[0]*n[0]+n[1]*n[1]+1.)

        return n/norm
    
    def _table_data(self) -> list[list[Any]]:
        """Helper function to generate data for table representation."""
        table = []
        if self.name is not None:
            table.append(["Name", self.name])
        table += [
            ["Half-Aperture", f'{self.half_aperture:.2f}'],
            ["Curvature", f'{self.curvature:.9f}'],
            ["Conic Constant", f'{self.conic_constant:.2f}'],
            ["# Aspheric Coefficients", len(self.aspheric_coefficients)],
            ["Central Hole Half-Aperture", f'{self.central_hole_half_aperture:.2f}'],
            ["Aperture Shape", self.aperture_shape],
            ["Central Hole Shape", self.central_hole_shape],
            ["Is Fresnel", self.is_fresnel],
            ["Surface Type", self.type],
            ["Position", ', '.join([f'{x:.2f}' for x in self.position])],
            ["Tilt Angles", ', '.join([f'{x:.2f}' for x in self.tilt_angles])],
            ["Scattering Sigma", f'{self.scattering_dispersion:.3f}'],
            ["Properties wavelegth", "set" if self.properties.wavelength is not None else "not set"],
            ["Properties incidence angle", "set" if self.properties.incidence_angle is not None else "not set"],
            ["Transmittance value", "set" if self.properties.transmittance is not None else "not set"],
            ["Reflectance value", "set" if self.properties.reflectance is not None else "not set"],
            ["Absorption value", "set" if self.properties.absorption is not None else "not set"],
        ]
        return table

class FlatSurface(AsphericalSurface):
    """Represents a flat optical surface.

        Parameters
        ----------
        half_aperture : float
            Half-aperture of the surface.
        central_hole_half_aperture : float, optional
            Half-aperture of the central hole (0.0 if no hole), by default 0.0
        aperture_shape : ApertureShape, optional
            Shape of the aperture (circular, hexagonal or rectangular), by default ApertureShape.CIRCULAR
        central_hole_shape : ApertureShape, optional
            Shape of the central hole (circular, hexagonal or rectangular), by default ApertureShape.CIRCULAR
        surface_type : SurfaceType, optional
            Type of surface (reflective, refractive, opaque, dummy), by default SurfaceType.REFLECTIVE
        position : array-like, optional
            Position of the surface center [x, y, z], by default None
        tilt_angles : array-like, optional
            Tilt angles (counter-clockwise x-tilt, y-tilt and z_tilt) in degrees, by default no tilt.
        scattering_dispersion : float, optional
            Standard deviation of Gaussian scattering after reflection/transmission, by default 0.0
        properties : SurfaceProperties, optional
            Reflectance/transmittance properties of the surface, by default None
        material_in : material, optional
            Material of the space before the surface front side. I.e. the material in which
            photons arriving with a negative z-component of their direction vector are travelling, 
            by default standard air.
        material_out : material, optional
            Material of the space after the surface back side. I.e. the material in which
            photons arriving with a positive z-component of their direction vector are travelling, 
            by default standard air.
        name : str, optional
            Surface name
    """

    def __init__(self,
                 half_aperture: float,
                 central_hole_half_aperture: float = 0.0,
                 aperture_shape: ApertureShape = ApertureShape.CIRCULAR,
                 central_hole_shape: ApertureShape = ApertureShape.CIRCULAR,
                 surface_type: SurfaceType = SurfaceType.REFLECTIVE,
                 position: ArrayLike = np.array([0., 0., 0.]),
                 tilt_angles: ArrayLike = np.array([0., 0., 0.]),
                 scattering_dispersion: float = 0.0,
                 properties: Optional[SurfaceProperties] = None,
                 material_in: Material = Materials.AIR,
                 material_out: Material = Materials.AIR,
                 name: Optional[str] = None):
        super().__init__(
            half_aperture=half_aperture,
            curvature=0,
            conic_constant=0,
            aspheric_coefficients=[0.],
            central_hole_half_aperture=central_hole_half_aperture,
            aperture_shape=aperture_shape,
            central_hole_shape=central_hole_shape,
            is_fresnel=False,
            surface_type=surface_type,
            position=position,
            tilt_angles=tilt_angles,
            scattering_dispersion=scattering_dispersion,
            properties=properties,
            material_in=material_in,
            material_out=material_out,
            name=name
        )
        self._shape = SurfaceShape.FLAT

class SphericalSurface(AsphericalSurface):
    """Represents a spherical optical surface.

        Parameters
        ----------
        half_aperture : float
            Half-aperture of the surface.
        curvature : float
            Surface curvature (1/radius of curvature)
        central_hole_half_aperture : float, optional
            Half-aperture of the central hole (0.0 if no hole), by default 0.0
        aperture_shape : ApertureShape, optional
            Shape of the aperture (circular, hexagonal or rectangular), by default ApertureShape.CIRCULAR
        central_hole_shape : ApertureShape, optional
            Shape of the central hole (circular, hexagonal or rectangular), by default ApertureShape.CIRCULAR
        is_fresnel : bool, optional
            Whether the surface is an ideal Fresnel lens/mirror, by default False
        surface_type : SurfaceType, optional
            Type of surface (reflective, refractive, opaque, dummy), by default SurfaceType.REFLECTIVE
        position : array-like, optional
            Position of the surface center [x, y, z], by default None
        tilt_angles : array-like, optional
            Tilt angles (counter-clockwise x-tilt, y-tilt and z_tilt) in degrees, by default no tilt.
        scattering_dispersion : float, optional
            Standard deviation of Gaussian scattering after reflection/transmission, by default 0.
        properties : SurfaceProperties, optional
            Reflectance/transmittance properties of the surface, by default None
        material_in : material, optional
            Material of the space before the surface front side. I.e. the material in which
            photons arriving with a negative z-component of their direction vector are travelling, 
            by default standard air.
        material_out : material, optional
            Material of the space after the surface back side. I.e. the material in which
            photons arriving with a positive z-component of their direction vector are travelling, 
            by default standard air.
        name : str, optional
            Surface name
    """

    def __init__(self,
                 half_aperture: float,
                 curvature: float,
                 central_hole_half_aperture: float = 0.,
                 aperture_shape: ApertureShape = ApertureShape.CIRCULAR,
                 central_hole_shape: ApertureShape = ApertureShape.CIRCULAR,
                 is_fresnel: bool = False,
                 surface_type: SurfaceType = SurfaceType.REFLECTIVE,
                 position: ArrayLike = np.array([0., 0., 0.]),
                 tilt_angles: ArrayLike = np.array([0., 0., 0.]),
                 scattering_dispersion: float = 0.,
                 properties: Optional[SurfaceProperties] = None,
                 material_in: Material = Materials.AIR,
                 material_out: Material = Materials.AIR,
                 name: Optional[str] = None):
        super().__init__(
            half_aperture=half_aperture,
            curvature=curvature,
            conic_constant=0,
            aspheric_coefficients=[0.],
            central_hole_half_aperture=central_hole_half_aperture,
            aperture_shape=aperture_shape,
            central_hole_shape=central_hole_shape,
            is_fresnel=is_fresnel,
            surface_type=surface_type,
            position=position,
            tilt_angles=tilt_angles,
            scattering_dispersion=scattering_dispersion,
            properties=properties,
            material_in=material_in,
            material_out=material_out,
            name=name
        )
        self._shape = SurfaceShape.SPHERICAL


class CylindricalSurface(Surface):
    """Represents a cylindrical optical surface.

        Parameters
        ----------
        radius : float
            Radius of the cylinder.
        height : float
            Height of the cylinder.
        has_top : bool, optional
            Whether the cylinder has a top cap, by default True.
        has_bottom : bool, optional
            Whether the cylinder has a bottom cap, by default True.
        position : array-like, optional
            Position of cylinder center [x, y, z] (half-height on the cylinder axis), by default None.
        tilt_angles : array-like, optional
            Tilt angles (counter-clockwise x-tilt, y-tilt and z_tilt) in degrees, by default no tilt.
        surface_type : SurfaceType, optional
            Type of surface (opaque or sensitive), by default SurfaceType.OPAQUE.
        name : str, optional
            Surface name
    """

    def __init__(self,
                 radius: float,
                 height: float,
                 has_top: bool = True,
                 has_bottom: bool = True,
                 position: ArrayLike = np.array([0., 0., 0.]),
                 tilt_angles: ArrayLike = np.array([0., 0., 0.]),
                 surface_type: SurfaceType = SurfaceType.OPAQUE,
                 name: Optional[str] = None):

        scattering_dispersion = 0.
        properties = None
        material_in = Materials.AIR
        material_out = Materials.AIR
        super().__init__(
            surface_type=surface_type,
            position=position,
            tilt_angles=tilt_angles,
            scattering_dispersion=scattering_dispersion,
            properties=properties,
            material_in=material_in,
            material_out=material_out,
            name=name
        )
        self.radius = radius
        self.height = height
        self.top = has_top
        self.bottom = has_bottom
        self._shape = SurfaceShape.CYLINDRICAL
    
    def _validate(self):
        """Validates the internal consistency of the surface parameters."""
        name_str = "-".join([self.name,""]) if self.name is not None else ""
        if not self.radius > 0:
            raise ValueError("".join([name_str, "Cylinder radius must be positive."]))
        if not self.height > 0:
            raise ValueError("".join([name_str, "Cylinder height must be positive."]))
    
    def _table_data(self) -> list[list[Any]]:
        """Helper function to generate data for table representation."""
        table = []
        if self.name is not None:
            table.append(["Name", self.name])
        table += [
            ["Radius", f'{self.radius:.2f}'],
            ["Height", f'{self.height:.2f}'],
            ["Has top", self.top],
            ["Has bottom", self.bottom],
            ["Surface Type", self.type],
            ["Position", ', '.join([f'{x:.2f}' for x in self.position])],
            ["Tilt Angles", ', '.join([f'{x:.2f}' for x in self.tilt_angles])],
        ]
        return table


class SipmTileSurface(FlatSurface):
    """Represents a SiPM tile of NxN pixels.

        Parameters
        ----------
        pixels_per_side : int
            Number of pixels per side.
        pixel_active_side : float
            Side length of the active area of a single pixel.
        pixels_separation : float
            Separation between pixels.
        border_to_active_area : float
            Width of the border from the edge of the tile to the active area.
        microcell_size : float
            Size of a pixel micro-cell in mm (e.g. 0.075 for 75um ucells).
        position : array-like, optional
            Position of the surface center [x, y, z], by default None
        tilt_angles : array-like, optional
            Tilt angles (counter-clockwise x-tilt, y-tilt and z_tilt) in degrees, by default no tilt.
        scattering_dispersion : float, optional
            Standard deviation of Gaussian scattering after reflection/transmission, by default 0.0
        properties : SurfaceProperties, optional
            Reflectance/transmittance properties of the surface, by default None
        material_in : material, optional
            Material of the space before the surface front side. I.e. the material in which
            photons arriving with a negative z-component of their direction vector are travelling, 
            by default standard air.
        material_out : material, optional
            Material of the space after the surface back side. I.e. the material in which
            photons arriving with a positive z-component of their direction vector are travelling, 
            by default standard air.
        name : str, optional
            Surface name
        
        Notes
        -----
        Tile layout::

              y       pixels_separation
              ^    ________ <--> ________
              |   |        |    |        |
              |   |        |    |        |
              |   |        |    |        |
              |   |________|    |________|
              |<->               <------>
              | |            pixel_active_side
              | border_to_active_area
            (0,0)---------> x

    """

    def __init__(self,
                 pixels_per_side: int,
                 pixel_active_side: float,
                 pixels_separation: float,
                 border_to_active_area: float,
                 microcell_size: float = 0.0,
                 position: ArrayLike = np.array([0., 0., 0.]),
                 tilt_angles: ArrayLike = np.array([0., 0., 0.]),
                 scattering_dispersion: float = 0.0,
                 surface_type: SurfaceType = SurfaceType.SENSITIVE,
                 properties: Optional[SurfaceProperties] = None,
                 material_in: Material = Materials.AIR,
                 material_out: Material = Materials.AIR,
                 name: Optional[str] = None
                ):
        
        # Compute tile size
        tile_side = pixels_per_side * pixel_active_side + (pixels_per_side - 1) * pixels_separation
        half_aperture = 0.5 * tile_side + border_to_active_area

        super().__init__(
            half_aperture = half_aperture,
            central_hole_half_aperture = 0.0,
            aperture_shape = ApertureShape.SQUARE,
            surface_type = SurfaceType.SENSITIVE_BACK,
            position = position,
            tilt_angles = tilt_angles,
            scattering_dispersion = scattering_dispersion,
            properties = properties,
            material_in = material_in,
            material_out = material_out,
            name = name
        )
        self.type = surface_type
        self._pixel_active_side = pixel_active_side
        self._pixels_separation = pixels_separation
        self._border_to_active_area = border_to_active_area
        self._pixels_per_side = pixels_per_side
        self._n_pixels = pixels_per_side * pixels_per_side

        # By deafult no ucells
        self._n_ucells_per_side = 0
        self.microcell_size = microcell_size

        self.sensor_type = SensorSurfaceType.SIPM_TILE
        self._shape = SurfaceShape.FLAT
        self.sensor_id = 0
        self._first_pixel_id = None
    
    @property
    def type(self) -> SurfaceType:
        return self._type

    @type.setter
    def type(self, value: SurfaceType):
        sensitive_list = [
            SurfaceType.SENSITIVE_BACK,
            SurfaceType.SENSITIVE_FRONT,
            SurfaceType.SENSITIVE,
            SurfaceType.REFLECTIVE_SENSITIVE,
            SurfaceType.REFLECTIVE_SENSITIVE_FRONT,
            SurfaceType.REFLECTIVE_SENSITIVE_BACK,
        ]
        if value not in sensitive_list:
            raise(ValueError("SipmTileSurface can be only a sensitive surface."))
        self._type = value
    
    @property
    def pixels_per_side(self) -> int:
        return self._pixels_per_side

    @pixels_per_side.setter
    def pixels_per_side(self, value: int):
        self._pixels_per_side = value
        self._n_pixels = value * value
        self._update_size()
    
    @property
    def pixel_active_side(self) -> float:
        return self._pixel_active_side
    
    @pixel_active_side.setter
    def pixel_active_side(self, value: float):
        self._pixel_active_side = value
        self._update_size()
    
    @property
    def microcell_size(self):
        return self._microcell_size
    
    @microcell_size.setter
    def microcell_size(self, value: float):
        self._microcell_size = value
        if value > 0:
            ucelss_per_side = np.floor(self.pixel_active_side / value)
            self._n_ucells_per_side = int(ucelss_per_side)
            self._pixel_active_side = ucelss_per_side * value
            self._update_size()
            self._inv_ucell_size = 1. / value
        else:
            self._n_ucells_per_side = 0
            self._inv_ucell_size = 0.

    @property
    def pixels_separation(self) -> float:
        return self._pixels_separation
    
    @pixels_separation.setter
    def pixels_separation(self, value: float):
        self._pixels_separation = value
        self._update_size()
    
    @property
    def border_to_active_area(self) -> float:
        return self._border_to_active_area
    
    @border_to_active_area.setter
    def border_to_active_area(self, value: float):
        self._border_to_active_area = value
        self._update_size()
    
    def _update_size(self):
        tile_side = self.pixels_per_side * self.pixel_active_side + (self.pixels_per_side - 1) * self.pixels_separation
        self.half_aperture = 0.5 * tile_side + self.border_to_active_area