from datetime import timedelta
from typing import Callable

import numpy as np
from vsm_datascience_common import constants
from vsm_datascience_common.cycle_database_connection.db_utils import get_cycles_collection
from vsm_datascience_common.cycles.sequences import get_timestamps, get_values

from utils.smoothing import get_curve_composition


def get_day_of_week_encoded(cycle: dict, shift: int = 0) -> dict:
    timestamps = get_timestamps(cycle)
    weekdays = np.array([x.weekday() for x in timestamps])
    sine_encoded = np.sin(weekdays * (2 * np.pi / 7))
    cosine_encoded = np.cos(weekdays * (2 * np.pi / 7))
    return {
        "sine": sine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None],
        "cosine": cosine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None]
    }


def get_hour_of_day_encoded(cycle: dict, shift: int = 0) -> dict:
    timestamps = get_timestamps(cycle)
    hours = np.array([x.hour for x in timestamps])
    sine_encoded = np.sin(hours * (2 * np.pi / 24))
    cosine_encoded = np.cos(hours * (2 * np.pi / 24))
    return {
        "sine": sine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None],
        "cosine": cosine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None]
    }


def get_month_of_year_encoded(cycle: dict, shift: int = 0) -> dict:
    timestamps = get_timestamps(cycle)
    months = np.array([x.month for x in timestamps])
    sine_encoded = np.sin(months * (2 * np.pi / 12))
    cosine_encoded = np.cos(months * (2 * np.pi / 12))
    return {
        "sine": sine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None],
        "cosine": cosine_encoded[shift if shift > 0 else 0:shift if shift < 0 else None]
    }


def get_hours_from_start(cycle: dict, shift: int = 0) -> np.ndarray:
    timestamps = get_timestamps(cycle)
    return np.arange(len(timestamps))[shift if shift > 0 else 0:shift if shift < 0 else None]


def get_temperature(cycle: dict, shift: int = 0) -> np.ndarray:
    return get_values(cycle)[shift if shift > 0 else 0:shift if shift < 0 else None]


def get_fertility_curve(cycle_length: int,
                        ov_index: int,
                        fertility_base_curve: np.ndarray | list,
                        base_curve_offset: int) -> np.ndarray:
    fert_prob = np.zeros((cycle_length,))
    if ov_index + base_curve_offset < 0:
        length_in_cycle = len(fertility_base_curve) + ov_index + base_curve_offset
        fert_prob[:length_in_cycle] = fertility_base_curve[-length_in_cycle:]
    elif ov_index + base_curve_offset + len(fertility_base_curve) > cycle_length:
        length_in_cycle = cycle_length - ov_index - base_curve_offset
        fert_prob[ov_index + base_curve_offset:] = fertility_base_curve[:length_in_cycle]
    else:
        fert_prob[ov_index + base_curve_offset:
                  ov_index + base_curve_offset + len(fertility_base_curve)] = fertility_base_curve
    return fert_prob


def get_fertility_probability_base_curve() -> (np.ndarray, int):
    """
    This function returns the base curve for the fertility probability as well as the offset to the ovulation day in measurements
    :return: numpy array with the base curve resampled to the number of measurements per day
    """
    fertilization_chance_curve = [0, 0.07, 0.12, 0.25, 0.3, 0.18, 0]
    offset_to_ov = -6

    new_indices = np.arange(len(fertilization_chance_curve) * constants.MEASUREMENTS_PER_DAY)
    fertility_chance_curve_resampled = np.interp(new_indices,
                                                 np.linspace(0, len(new_indices), num=len(fertilization_chance_curve)),
                                                 fertilization_chance_curve)
    ov_offset_resampled = offset_to_ov * constants.MEASUREMENTS_PER_DAY
    return fertility_chance_curve_resampled, ov_offset_resampled


def get_fertility_probability(cycle: dict, shift: int = 0) -> np.ndarray:
    # check, if biphasic
    if "classification_results" in cycle and "results" in cycle["classification_results"][0]:
        if cycle["classification_results"][0]["results"]["predicted_class"] != "biphasic":
            return np.zeros((len(get_values(cycle)) - abs(shift, )))
    else:
        return np.zeros((len(get_values(cycle)) - abs(shift, )))

    if "ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]:
        try:
            ov_timestamp = cycle["ov_detection_results"][0]["results"]["ovulation_timestamp"]
            timestamps = get_timestamps(cycle)
            ov_index = np.where(timestamps >= ov_timestamp)[0][0]
            fertility_chance_curve_resampled, ov_offset_resampled = get_fertility_probability_base_curve()
            return get_fertility_curve(len(get_values(cycle)), ov_index, fertility_chance_curve_resampled,
                                       ov_offset_resampled)[shift if shift > 0 else 0:shift if shift < 0 else None]
        except:
            return np.zeros((len(get_values(cycle)) - abs(shift, )))
    else:
        return np.zeros((len(get_values(cycle)) - abs(shift, )))


def get_ov_over_probability(cycle: dict, shift: int = 0) -> np.ndarray:
    """
    Get the ovulation over probability for a cycle
    :param cycle: cycle data
    :param shift: shift to apply
    :return: ovulation over probability
    """
    if "ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]:
        try:
            ov_timestamp = cycle["ov_detection_results"][0]["results"]["ovulation_timestamp"]
            timestamps = get_timestamps(cycle)
            ov_index = np.where(timestamps > ov_timestamp)[0][0]
            ov_over_probability = np.zeros(len(get_values(cycle)))
            ov_over_probability[ov_index:] = 1
            return ov_over_probability[shift if shift > 0 else 0:shift if shift < 0 else None]
        except:
            return np.zeros(len(get_values(cycle)))[shift if shift > 0 else 0:shift if shift < 0 else None]
    else:
        return np.zeros(len(get_values(cycle)))[shift if shift > 0 else 0:shift if shift < 0 else None]


def get_days_relative_to_ov(cycle: dict) -> np.ndarray:
    """
    Get the time in days until the next OV event.
    """
    timestamps = get_timestamps(cycle)
    if ("ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]) and (
            "classification_results" in cycle and "results" in cycle["classification_results"][0] and
            cycle["classification_results"][0]["results"]["predicted_class"] == "biphasic"):
        try:
            ov_timestamp = cycle["ov_detection_results"][0]["results"]["ovulation_timestamp"]
            ov_index = np.where(timestamps > ov_timestamp)[0][0]
            ov_timestamp = timestamps[ov_index]
            days_until_ov = np.empty(len(timestamps))
            for i in range(len(timestamps)):
                current_day = timestamps[i]
                day_diff = ov_timestamp - current_day
                if day_diff.days < -1:
                    pass
                # days_until_ov[i] = max(day_diff.days -1)
                # use - days, as days relative to event
                days_until_ov[i] = - day_diff.days

            return np.array(days_until_ov)
        except:
            pass

    return np.full((len(timestamps),), np.nan)


def get_ov_day(cycle: dict) -> np.ndarray:
    """
    Get the ovulation day for a cycle
    :param cycle: cycle data
    :return: ovulation day
    """
    values = get_values(cycle)
    seq_len = len(values)
    is_biphasic = "classification_results" in cycle and "results" in cycle["classification_results"][0] and \
                  cycle["classification_results"][0]["results"]["predicted_class"] == "biphasic"
    has_ov_results = "ov_detection_results" in cycle and "results" in cycle["ov_detection_results"][0]
    if is_biphasic and has_ov_results:
        try:
            ov_day = cycle["ov_detection_results"][0]["results"]["ovulation_day"]
            return np.full((seq_len,), ov_day)
        except:
            pass

    return np.full((seq_len,), np.nan)


def get_is_biphasic(cycle: dict) -> np.ndarray:
    """
    Get the is_biphasic flag for a cycle
    :param cycle: cycle data
    :return: is_biphasic flag
    """
    values = get_values(cycle)
    seq_len = len(values)
    is_biphasic = "classification_results" in cycle and "results" in cycle["classification_results"][0] and \
                  cycle["classification_results"][0]["results"]["predicted_class"] == "biphasic"
    if is_biphasic:
        return np.full((seq_len,), 1)

    return np.full((seq_len,), 0)


def get_curve_composition_as_features(cycle: dict, shift: int = 0) -> dict:
    composition = get_curve_composition(get_values(cycle))
    return {
        "trend": composition[0][shift if shift > 0 else 0:shift if shift < 0 else None],
        "seasonal": composition[1][shift if shift > 0 else 0:shift if shift < 0 else None],
        "residual": composition[2][shift if shift > 0 else 0:shift if shift < 0 else None],
        "smoothed": composition[3][shift if shift > 0 else 0:shift if shift < 0 else None]
    }


def get_rolling_average_with_padding(cycle: dict,
                                     shift: int = 0,
                                     rolling_average_window_length: int = constants.MEASUREMENTS_PER_DAY) -> np.ndarray:
    """
    Get the rolling average of the temperature values with padding, so that the rolling average has the same length as the
    original temperature values
    Args:
        cycle (dict): cycle data
        shift (int): shift to apply
        rolling_average_window_length (int): window length for the rolling average, default is one day (288 measurements)

    Returns:
        np.ndarray: rolling average of the temperature values with padding

    """

    values = get_values(cycle)
    values_padded = np.concatenate(
        [np.full((constants.MEASUREMENTS_PER_DAY,), np.mean(values[:constants.MEASUREMENTS_PER_DAY])), values])
    rolling_average = np.convolve(values_padded, np.ones(rolling_average_window_length) / rolling_average_window_length,
                                  mode="valid")[-len(values):]
    return rolling_average[shift if shift > 0 else 0:shift if shift < 0 else None]


def get_window_fn(cycle: dict,
                  window_size: int,
                  fn: Callable) -> np.ndarray:
    """
    Apply a window function to a time series.
    :param cycle: cycle data
    :param window_size: window size
    :return: array with minimum value in window for each value in input array
    """
    values = get_values(cycle)
    values_padded = np.concatenate([np.full(window_size - 1, values[0]), values])

    windows = np.lib.stride_tricks.sliding_window_view(values_padded, window_shape=window_size)
    return fn(windows)


def get_cycle_length_stats(cycle: dict) -> dict:
    user_id = cycle["user_id"]
    cycle_length = len(get_values(cycle))
    cycles_dates = list(get_cycles_collection().find({
        "user_id": user_id, "ends_at": {"$exists": True},
        "starts_at": {"$lt": cycle["starts_at"]},
    },
        {"starts_at": 1, "ends_at": 1}))
    if len(cycles_dates) == 0:
        # use the average cycle length as fallback
        average_cycle_length = 37.13
        cycle_length_std = 12.42
    else:
        cycle_lengths = [x["ends_at"] - x["starts_at"] for x in cycles_dates]
        average_cycle_length = np.mean(cycle_lengths).total_seconds() / (60 * 60 * 24)
        cycle_length_std = np.std([x.total_seconds() for x in cycle_lengths]) / (60 * 60 * 24)

    return {
        "average_cycle_length": np.full((cycle_length,), average_cycle_length),
        "cycle_length_std": np.full((cycle_length,), cycle_length_std),
    }


def get_average_ovulation_day(cycle: dict) -> float:
    user_id = cycle["user_id"]
    cycle_length = len(get_values(cycle))
    cycles_dates = list(
        get_cycles_collection().find({
            "user_id": user_id,
            "ov_detection_results.0.results": {"$exists": True},
            "starts_at": {"$lt": cycle["starts_at"]},
        },
            {"ov_detection_results": 1}))
    if len(cycles_dates) == 0:
        # use the average ovulation day as fallback
        average_ovulation_day = 18.9
    else:
        ovulation_days = [x["ov_detection_results"][0]["results"]["ovulation_day"] for x in cycles_dates]
        average_ovulation_day = np.mean(ovulation_days)
    return np.full((cycle_length,), average_ovulation_day)


def get_ovulation_std(cycle: dict) -> float:
    user_id = cycle["user_id"]
    cycle_length = len(get_values(cycle))
    cycles_dates = list(
        get_cycles_collection().find({
            "user_id": user_id,
            "ov_detection_results.0.results": {"$exists": True},
            "starts_at": {"$lt": cycle["starts_at"]},
        },
            {"ov_detection_results": 1}))
    if len(cycles_dates) == 0:
        # use the average ovulation std as fallback
        ovulation_std = 4.07
    else:
        ovulation_days = [x["ov_detection_results"][0]["results"]["ovulation_day"] for x in cycles_dates]
        ovulation_std = np.std(ovulation_days)
    return np.full((cycle_length,), ovulation_std)


def get_biphasic_fraction(cycle: dict) -> float:
    user_id = cycle["user_id"]
    cycle_length = len(get_values(cycle))
    cycle_dates = list(
        get_cycles_collection().find({
            "user_id": user_id,
            "classification_results.0.results": {"$exists": True},
            "starts_at": {"$lt": cycle["starts_at"]},
        },
            {"classification_results": 1}))
    if len(cycle_dates) == 0:
        # use the average cycle length as fallback
        biphasic_fraction = 0.9449
    else:
        biphasic = [x for x in cycle_dates if
                    x["classification_results"][0]["results"]["predicted_class"] == "biphasic"]
        biphasic_fraction = len(biphasic) / len(cycle_dates)
    return np.full((cycle_length,), biphasic_fraction)


def get_num_cycles(cycle: dict) -> int:
    user_id = cycle["user_id"]
    cycle_length = len(get_values(cycle))
    cycles_dates = list(get_cycles_collection().find({"user_id": user_id,
                                                      "ends_at": {"$exists": True},
                                                      "starts_at": {"$lt": cycle["starts_at"]}
                                                      },
                                                     {"starts_at": 1, "ends_at": 1}))
    num_cycles = len(cycles_dates)
    return np.full((cycle_length,), num_cycles)


def get_average_temperatures(cycle: dict) -> dict:
    user_id = cycle["user_id"]
    cycle_length = len(get_values(cycle))
    cycles_dates = list(
        get_cycles_collection().find({"user_id": user_id, "ov_detection_results.0.results": {"$exists": True}},
                                     {"starts_at": 1, "ov_detection_results": 1, "measurements": 1}))

    if len(cycles_dates) == 0:
        return {
            "pre_ov_temperatures": np.full((cycle_length,), 37.07),
            "post_ov_temperatures": np.full((cycle_length,), 37.37),
        }

    pre_ov_temperatures = []
    post_ov_temperatures = []
    for x in cycles_dates:
        ov_detection = x["ov_detection_results"][0]["results"]
        values = get_values(x)
        timestamps = get_timestamps(x)
        cutoff_day = x["starts_at"] + timedelta(days=ov_detection["ovulation_day"])
        cutoff_index = np.where(np.array(timestamps) >= cutoff_day)[0][0]
        pre_ov_temperatures.append(values[:cutoff_index])
        post_ov_temperatures.append(values[cutoff_index:])

    pre_ov_temperatures = np.concatenate(pre_ov_temperatures)
    post_ov_temperatures = np.concatenate(post_ov_temperatures)

    pre_ov_temperatures = np.mean(pre_ov_temperatures)
    post_ov_temperatures = np.mean(post_ov_temperatures)

    return {
        "pre_ov_temperatures": np.full((cycle_length,), pre_ov_temperatures),
        "post_ov_temperatures": np.full((cycle_length,), post_ov_temperatures),
    }
