import numpy as np
from astropy.nddata import StdDevUncertainty
from ndcube import NDCube
from scipy.ndimage import binary_dilation

from punchbowl.data.units import split_ccd_array
from punchbowl.prefect import punch_task
from punchbowl.util import inpaint_nans


def dn_to_photons(data_array: np.ndarray, gain_bottom: float = 4.9, gain_top: float = 4.9) -> np.ndarray:
    """Convert an input array from DN to photon count."""
    gain = split_ccd_array(data_array.shape, gain_bottom, gain_top)
    return data_array * gain


def compute_noise(
        data: np.ndarray,
        bias_level: float = 100,
        dark_level: float = 55.81,
        gain_bottom: float = 4.9,
        gain_top: float = 4.9,
        read_noise_level: float = 17,
        bitrate_signal: int = 16) -> tuple[np.ndarray, np.ndarray]:
    """
    Generate noise based on an input data array, with specified noise parameters.

    Parameters
    ----------
    data
        input data array (n x n)
    bias_level
        ccd bias level
    dark_level
        ccd dark level
    gain_bottom
        ccd gain (bottom side of CCD)
    gain_top
        ccd gain (top side of CCD)
    read_noise_level
        ccd read noise level
    bitrate_signal
        desired ccd data bit level

    Returns
    -------
    data : np.ndarray
        clipped data with the bias level added
    noise : np.ndarray
        computed noise array corresponding to input data and ccd/noise parameters

    """
    data = data.copy().astype("long")

    gain = split_ccd_array(data.shape, gain_bottom, gain_top)

    # Photon / shot noise generation
    data_photon = data * gain  # DN to photoelectrons
    sigma_photon = np.sqrt(data_photon)  # Converting sigma of this
    sigma = sigma_photon / gain  # Converting back to DN
    noise_photon = np.random.normal(scale=sigma)

    # Add bias level and clip pixels to avoid overflow
    data = np.clip(data + bias_level, 0, 2 ** bitrate_signal - 1)

    # Dark noise generation
    noise_level = dark_level * gain
    noise_dark = np.random.poisson(lam=noise_level, size=data.shape) / gain

    # Read noise generation
    noise_read = np.random.normal(scale=read_noise_level, size=data.shape)
    noise_read = noise_read / gain  # Convert back to DN

    # And then add noise terms directly
    return data, noise_photon + noise_dark + noise_read


def compute_uncertainty(data_array: np.ndarray,
                        dark_level: float = 55.81,
                        gain_bottom: float = 4.9,
                        gain_top: float = 4.9,
                        read_noise_level: float = 17,
                        ) -> np.ndarray:
    """With an input data array compute a corresponding uncertainty array."""
    # Convert this photon count to a shot noise
    data = data_array.copy()

    gain = split_ccd_array(data.shape, gain_bottom, gain_top)

    # Photon / shot noise generation
    data_photon = data * gain  # DN to photoelectrons
    sigma_photon = np.sqrt(data_photon)  # Converting sigma of this
    noise_photon = sigma_photon / gain  # Converting back to DN

    # Dark noise generation
    noise_dark = dark_level

    # Read noise generation
    noise_read = read_noise_level / gain  # Convert back to DN

    # And then add noise terms directly
    return np.sqrt(noise_photon**2 + noise_dark**2 + noise_read**2)


def fill_saturated_pixels(data_object: NDCube,
                          saturated_pixels: np.ndarray,
                          row_threshold: int = 300) -> NDCube:
    """
    Flag saturated pixels with neighborhood and flag in the uncertainty layer.

    Parameters
    ----------
    data_object : NDCube
        input data cube
    saturated_pixels : np.ndarray
        mask where saturated pixels are True
    row_threshold : int
        if this many pixels are flagged as saturated in a row, the whole row is filled with neighborhood values

    Returns
    -------
    NDCube
        a cleaned NDCube with saturated pixels filled

    """
    if saturated_pixels is not None:
        saturated_pixels = binary_dilation(saturated_pixels, iterations=1)
        row_counts = np.sum(saturated_pixels, axis=1)
        saturated_pixels[row_counts > row_threshold, :] = True

        data_object.uncertainty.array[saturated_pixels] = np.inf
        image = data_object.data
        image[saturated_pixels] = np.nan
        filled_data = inpaint_nans(image, kernel_size=5)
        data_object.data[...] = filled_data[...]
        data_object.mask = saturated_pixels
    else:
        data_object.mask = np.zeros_like(data_object.data, dtype=bool)
    return data_object


@punch_task
def update_initial_uncertainty_task(data_object: NDCube,
                                    dark_level: float = 55.81,
                                    gain_bottom: float = 4.9,
                                    gain_top: float = 4.9,
                                    read_noise_level: float = 17,
                                    bitrate_signal: int = 16,
                                    saturated_pixels: np.ndarray | None = None) -> NDCube:
    """Prefect task to compute initial uncertainty."""
    uncertainty_array = compute_uncertainty(data_object.data,
                                            dark_level=dark_level,
                                            gain_bottom=gain_bottom,
                                            gain_top=gain_top,
                                            read_noise_level=read_noise_level,
                                            )
    data_object.uncertainty = StdDevUncertainty(uncertainty_array)

    fill_saturated_pixels(data_object, saturated_pixels)

    data_object.meta.history.add_now("LEVEL1-initial_uncertainty", "Initial uncertainty computed with:")
    data_object.meta.history.add_now("LEVEL1-initial_uncertainty", f"dark_level={dark_level}")
    data_object.meta.history.add_now("LEVEL1-initial_uncertainty", f"gain_bottom={gain_bottom}")
    data_object.meta.history.add_now("LEVEL1-initial_uncertainty", f"gain_top={gain_top}")
    data_object.meta.history.add_now("LEVEL1-initial_uncertainty", f"read_noise_level={read_noise_level}")
    data_object.meta.history.add_now("LEVEL1-initial_uncertainty", f"bitrate_signal={bitrate_signal}")
    return data_object
