"""WindUp configuration models."""

from __future__ import annotations

import datetime as dt
import json
import logging
from pathlib import Path
from typing import Annotated

import numpy as np
import pandas as pd
import yaml
from pydantic import BaseModel, BeforeValidator, Field, model_validator

from wind_up.backporting import strict_zip
from wind_up.constants import OUTPUT_DIR
from wind_up.yaml_loader import Loader, construct_include

logger = logging.getLogger(__name__)


class PlotConfig(BaseModel):
    """Plot configuration model."""

    show_plots: bool = Field(default=False, description="Show plots in interactive window")
    save_plots: bool = Field(default=True, description="Save plots to file")
    skip_per_turbine_plots: bool = Field(default=False, description="If True skip per turbine plots")
    plots_dir: Path = Field(description="Directory to save plots to")


class TurbineType(BaseModel):
    """Turbine type model."""

    turbine_type: str = Field(
        description="Turbine type description",
        min_length=2,
    )
    rotor_diameter_m: float = Field(description="Rotor diameter in meters", gt=0, examples=[100])
    rated_power_kw: float = Field(description="Rated power in kW", gt=0, examples=[3000])
    cutout_ws_mps: float = Field(default=25, description="Cutout wind speed in m/s", gt=0, examples=[25])
    normal_operation_pitch_range: tuple[float, float] = Field(
        description="Normal operation pitch range in degrees",
        examples=[[-10, 40]],
    )
    normal_operation_genrpm_range: tuple[float, float] = Field(
        description="Normal operation generator rpm range",
        examples=[[800, 1600]],
    )
    rpm_v_pw_margin_factor: float = Field(
        default=0.05,
        description="Factor to multiply max RPM by to get rpm margin for rpm vs power filtering",
        examples=[0.05, 0.1],
    )
    pitch_to_stall: bool = Field(
        default=False,
        description="If False then pitch filter will eliminate data below the threshold rather than above",
    )


def _validate_lat(value: float | None) -> float:
    v = np.nan if value is None else value
    min_value = -90
    max_value = 90
    if v < min_value or v > max_value:
        err_msg = f"Latitude {v} is out of range"
        raise ValueError(err_msg)
    return v


def _validate_lon(value: float | None) -> float:
    v = np.nan if value is None else value
    min_value = -180
    max_value = 180
    if v < min_value or v > max_value:
        err_msg = f"Latitude {v} is out of range"
        raise ValueError(err_msg)
    return v


class Turbine(BaseModel):
    """Turbine model."""

    name: str = Field(description="Turbine name", min_length=2)
    turbine_type: TurbineType
    latitude: Annotated[float, BeforeValidator(_validate_lat)] = np.nan
    longitude: Annotated[float, BeforeValidator(_validate_lon)] = np.nan

    def get_latlongs(self: Turbine) -> list[tuple[float, float]]:
        """Get the latitude and longitude of the turbine."""
        return [(self.latitude, self.longitude)]


class MastOrLidar(BaseModel):
    """Mast or LiDAR model."""

    name: str = Field(description="Object name", min_length=2)
    latitude: float = Field(default=np.nan, ge=-90, le=90)
    longitude: float = Field(default=np.nan, ge=-180, le=180)
    data_file_name: str = Field(
        min_length=2,
        description="Name of data timeseries file",
    )
    wind_speed_column: str = Field(min_length=2, description="Name of wind speed column in data timeseries file")
    wind_direction_column: str = Field(
        min_length=2,
        description="Name of wind direction column in data timeseries file",
    )


class Asset(BaseModel):
    """Asset model."""

    name: str = Field(description="Asset Name", min_length=2, examples=["Kelmarsh"])
    wtgs: list[Turbine]
    masts_and_lidars: list[MastOrLidar] = Field(description="list of mast and LiDAR objects, if any", default=[])


class Toggle(BaseModel):
    """Toggle model."""

    name: str = Field(description="Name of toggle signal to use in plots", min_length=2, default="toggle")
    toggle_file_per_turbine: bool = Field(
        description="Is there one toggle timeseries file per turbine, or one for the asset?",
    )
    toggle_filename: str = Field(
        min_length=2,
        description="Name of toggle timeseries file, can use {wtg} if toggle_file_per_turbine is True",
    )
    detrend_data_selection: str = Field(
        description="Method for selecting directional detrending data",
        default="do_not_use_toggle_test",
        examples=["do_not_use_toggle_test", "use_toggle_off_data"],
    )
    pairing_filter_method: str = Field(
        description="Method to use for pairing filter",
        examples=["none", "one_to_one", "any_within_timedelta"],
        default="none",
    )
    pairing_filter_timedelta_seconds: int = Field(
        description="Time delta in seconds to use for pairing filter",
        default=50 * 60,
    )
    toggle_change_settling_filter_seconds: int = Field(
        description="Time delta in seconds filter out after a toggle state change",
        default=10 * 60,
    )


class PrePost(BaseModel):
    """PrePost model."""

    pre_first_dt_utc_start: dt.datetime = Field(
        description="First time to use in pre-upgrade analysis, UTC Start format",
    )
    pre_last_dt_utc_start: dt.datetime = Field(
        description="Last time to use in pre-upgrade analysis, UTC Start format",
    )
    post_first_dt_utc_start: dt.datetime = Field(
        description="First time to use in post-upgrade analysis, UTC Start format",
    )
    post_last_dt_utc_start: dt.datetime = Field(
        description="Last time to use in post-upgrade analysis, UTC Start format",
    )


class WindUpConfig(BaseModel):
    """WindUpConfig model.

    This model is used to store the configuration for a WindUp analysis. The attributes form the basis for the analysis.
    """

    assessment_name: str = Field(
        min_length=2,
        description="Name used for assessment output folder",
    )
    timebase_s: int = Field(
        default=10 * 60,
        description="Timebase in seconds for SCADA data, other data is converted to this timebase",
    )
    ignore_turbine_anemometer_data: bool = Field(
        default=False,
        description="If true do not use turbine anemometer data for anything",
    )
    require_test_wake_free: bool = Field(
        default=False,
        description="Remove data where the test turbine has any upwind turbines",
    )
    require_ref_wake_free: bool = Field(
        default=False,
        description="Remove data where the reference has any upwind turbines",
    )
    detrend_min_hours: int = Field(
        default=24,
        description="Minimum number of hours to use in directional detrending",
    )
    ref_wd_filter: list[float] | None = Field(
        default=None,
        description="Wind direction filter for reference data; only data within this range is used",
        examples=[[195, 241]],
    )
    ref_hod_filter: list[float] | None = Field(
        default=None,
        description="Hour of day filter for reference data; only data within this range is used",
        examples=[[18, 6]],
    )
    filter_all_test_wtgs_together: bool = Field(
        description="If True any row filtered for one test wtg is filtered for all test wtgs. "
        "Recommended for wake steering analysis.",
        default=False,
    )
    use_lt_distribution: bool = Field(
        description="If True the long term distribution is calculated and used for uplift calculation", default=True
    )
    use_test_wtg_lt_distribution: bool = Field(
        description="If True the test turbine's data is used for the long term distribution, "
        "otherwise the whole wind farm is used",
        default=False,
    )
    out_dir: Path = Field(description="Directory to save assessment output to")
    test_wtgs: list[Turbine] = Field(description="List of test Turbine ids")
    ref_wtgs: list[Turbine] = Field(default=[], description="List of reference Turbine ids")
    ref_super_wtgs: list[str] = Field(
        default=[],
        description="List of reference super Turbine ids",
    )
    non_wtg_ref_names: list[str] = Field(
        default=[],
        description="List of non turbine references",
    )
    upgrade_first_dt_utc_start: dt.datetime = Field(
        description="First time when test turbine is upgraded, UTC Start format",
    )
    analysis_last_dt_utc_start: dt.datetime = Field(
        description="Last time to use in upgrade analysis, UTC Start format",
    )
    analysis_first_dt_utc_start: dt.datetime = Field(
        description="First time to use in upgrade analysis, UTC Start format",
    )
    lt_first_dt_utc_start: dt.datetime = Field(
        description="First time to use in long term analysis, UTC Start format",
    )
    lt_last_dt_utc_start: dt.datetime = Field(
        description="Last time to use in long term analysis, UTC Start format",
    )
    detrend_first_dt_utc_start: dt.datetime = Field(
        description="First time to use in detrend analysis, UTC Start format",
    )
    detrend_last_dt_utc_start: dt.datetime = Field(
        description="Last time to use in detrend analysis, UTC Start format",
    )
    years_offset_for_pre_period: int | None = Field(
        description="How many years to go back in time to begin pre upgrade dataset",
        ge=0,
        examples=[None, 1],
        default=None,
    )
    years_for_lt_distribution: int = Field(
        description="How many years to use in long term analysis",
        ge=0,
        examples=[3, 5],
    )
    years_for_detrend: int = Field(
        description="How many years to use in directional detrending",
        ge=0,
        examples=[1, 2],
    )
    ws_bin_width: float = Field(description="Wind speed bin width in m/s", gt=0, examples=[0.5, 1])
    bootstrap_runs_override: int | None = Field(
        description="Number of bootstrap runs to use, if None then calculated",
        ge=0,
        examples=[None, 1000],
        default=None,
    )
    reanalysis_method: str = Field(
        default="node_with_best_ws_corr",
        description="Method to use for reanalysis (e.g. ERA5) selection",
        examples=["node_with_best_ws_corr"],
    )
    missing_scada_data_fields: list[str] = Field(
        default=[],
        description="List of SCADA fields not available for this asset",
        examples=[["YawAngleMin", "YawAngleMax"]],
    )
    asset: Asset
    exclusion_periods_utc: list[tuple[str, dt.datetime, dt.datetime]] = Field(
        default=[],
        description="Turbine id or 'ALL', first UTC timestamp to exclude, last UTC timestamp to exclude",
        examples=[
            "ALL",
            dt.datetime(2021, 9, 21, 12, 40, tzinfo=dt.timezone.utc),
            dt.datetime(2021, 9, 29, 18, 10, tzinfo=dt.timezone.utc),
        ],
    )
    yaw_data_exclusions_utc: list[tuple[str, dt.datetime, dt.datetime]] = Field(
        default=[],
        description="Turbine id or 'ALL', first UTC start format timestamp to exclude yaw data,"
        " last UTC timestamp to exclude yaw data",
        examples=[
            "ALL",
            dt.datetime(2021, 9, 21, 12, 40, tzinfo=dt.timezone.utc),
            dt.datetime(2021, 9, 29, 18, 10, tzinfo=dt.timezone.utc),
        ],
    )
    optimize_northing_corrections: bool = Field(
        default=False,
        description="run northing correction optimization",
    )
    northing_corrections_utc: list[tuple[str, dt.datetime, float]] = Field(
        default=[],
        description="Turbine id, first UTC timestamp to correct, correction to add to yaw data",
        examples=["SMV1", dt.datetime(2021, 9, 21, 12, 40, tzinfo=dt.timezone.utc), 15],
    )
    toggle: Toggle | None = None
    prepost: PrePost | None = None
    clip_rated_power_pp: bool = Field(default=True, description="Clip rated power in power performance analysis")
    use_rated_invalid_bins: bool = Field(default=False, description="Use rated power bins which have been filled in")

    @model_validator(mode="after")
    def _check_years_offset_for_pre_period(self: WindUpConfig) -> WindUpConfig:
        if self.toggle is None and self.years_offset_for_pre_period is None:
            msg = "toggle is None and years_offset_for_pre_period is None"
            raise ValueError(msg)
        return self

    @model_validator(mode="after")
    def _check_first_datetime_before_last(self: WindUpConfig) -> WindUpConfig:
        if self.upgrade_first_dt_utc_start >= self.analysis_last_dt_utc_start:
            msg = "upgrade_first_datetime must be before last_useable_datetime"
            raise ValueError(msg)
        return self

    @model_validator(mode="after")
    def _check_non_wtg_ref_names(self: WindUpConfig) -> WindUpConfig:
        for non_wtg_ref in self.non_wtg_ref_names:
            if non_wtg_ref == "reanalysis":
                if len(self.reanalysis_method) < 1:
                    msg = "reanalysis is in non_wtg_ref_names but reanalysis_method is not valid"
                    raise ValueError(msg)
            elif len([x for x in self.asset.masts_and_lidars if x.name == non_wtg_ref]) != 1:
                msg = f"there is not exactly 1 non_wtg_ref {non_wtg_ref} in cfg.asset.masts_and_lidars"
                raise ValueError(msg)
        return self

    @model_validator(mode="after")
    def print_summary(self: WindUpConfig) -> WindUpConfig:
        """Print a summary of the WindUpConfig."""
        logger.info(f"loaded WindUpConfig assessment_name: {self.assessment_name}")
        dt_fmt = "%Y-%m-%d %H:%M"
        if self.toggle is not None:
            dt_rng_start = self.analysis_first_dt_utc_start.strftime(dt_fmt)
            dt_rng_end = (self.analysis_last_dt_utc_start + pd.Timedelta(seconds=self.timebase_s)).strftime(dt_fmt)
            logger.info(
                f"toggle analysis period (UTC): {dt_rng_start} to {dt_rng_end}",
            )
        elif self.prepost is not None:
            dt_rng_start = self.prepost.pre_first_dt_utc_start.strftime(dt_fmt)
            dt_rng_end = (self.prepost.pre_last_dt_utc_start + pd.Timedelta(seconds=self.timebase_s)).strftime(dt_fmt)
            logger.info(
                f"pre analysis period (UTC): {dt_rng_start} to {dt_rng_end}",
            )
            dt_rng_start = self.prepost.post_first_dt_utc_start.strftime(dt_fmt)
            dt_rng_end = (self.prepost.post_last_dt_utc_start + pd.Timedelta(seconds=self.timebase_s)).strftime(dt_fmt)
            logger.info(
                f"post analysis period (UTC): {dt_rng_start} to {dt_rng_end}",
            )
        else:
            msg = "toggle and prepost are both set to None"
            raise RuntimeError(msg)
        dt_rng_start = self.lt_first_dt_utc_start.strftime(dt_fmt)
        dt_rng_end = (self.lt_last_dt_utc_start + pd.Timedelta(seconds=self.timebase_s)).strftime(dt_fmt)
        logger.info(f"long term period (UTC): {dt_rng_start} to {dt_rng_end}")
        dt_rng_start = self.detrend_first_dt_utc_start.strftime(dt_fmt)
        dt_rng_end = (self.detrend_last_dt_utc_start + pd.Timedelta(seconds=self.timebase_s)).strftime(dt_fmt)
        logger.info(f"detrend period (UTC): {dt_rng_start} to {dt_rng_end}")
        return self

    @classmethod
    def from_yaml(cls, file_path: Path) -> "WindUpConfig":  # noqa ANN102
        """Load WindUpConfig from a yaml file.

        :param file_path: Path to yaml file
        :return: WindUpConfig
        """
        yaml.add_constructor("!include", construct_include, Loader)
        with Path.open(file_path) as f:
            cfg_dct = yaml.load(f, Loader)  # noqa S506

        cfg_dct["out_dir"] = OUTPUT_DIR / cfg_dct["assessment_name"]

        # ensure datetimes are UTC aware
        cfg_dct["upgrade_first_dt_utc_start"] = pd.Timestamp(cfg_dct["upgrade_first_dt_utc_start"], tz="UTC")
        cfg_dct["analysis_last_dt_utc_start"] = pd.Timestamp(cfg_dct["analysis_last_dt_utc_start"], tz="UTC")
        if cfg_dct.get("exclusion_periods_utc", None) is not None:
            for x in cfg_dct["exclusion_periods_utc"]:
                x[1] = pd.Timestamp(x[1], tz="UTC")
                x[2] = pd.Timestamp(x[2], tz="UTC")
        if cfg_dct.get("yaw_data_exclusions_utc", None) is not None:
            for x in cfg_dct["yaw_data_exclusions_utc"]:
                x[1] = pd.Timestamp(x[1], tz="UTC")
                x[2] = pd.Timestamp(x[2], tz="UTC")
        if cfg_dct.get("northing_corrections_utc", None) is not None:
            for x in cfg_dct["northing_corrections_utc"]:
                x[1] = pd.Timestamp(x[1], tz="UTC")

        # calculate analysis and long term datetimes
        test_is_toggle = cfg_dct.get("toggle", None) is not None
        if test_is_toggle:
            cfg_dct["analysis_first_dt_utc_start"] = cfg_dct["upgrade_first_dt_utc_start"]
        else:
            pre_post_dict = {
                "post_first_dt_utc_start": cfg_dct["upgrade_first_dt_utc_start"],
                "post_last_dt_utc_start": cfg_dct["analysis_last_dt_utc_start"],
                "pre_first_dt_utc_start": cfg_dct["upgrade_first_dt_utc_start"]
                - pd.DateOffset(years=cfg_dct["years_offset_for_pre_period"]),
                "pre_last_dt_utc_start": cfg_dct["analysis_last_dt_utc_start"]
                - pd.DateOffset(years=cfg_dct["years_offset_for_pre_period"]),
            }
            cfg_dct["prepost"] = PrePost.model_validate(pre_post_dict)
            cfg_dct["analysis_first_dt_utc_start"] = cfg_dct["prepost"].pre_first_dt_utc_start
        cfg_dct["lt_last_dt_utc_start"] = (
            cfg_dct["upgrade_first_dt_utc_start"] - dt.timedelta(days=7)  # go back 1 week for buffer before toggling
            if test_is_toggle
            else cfg_dct["prepost"].pre_last_dt_utc_start
        )
        cfg_dct["lt_first_dt_utc_start"] = cfg_dct["lt_last_dt_utc_start"] - pd.DateOffset(
            years=cfg_dct["years_for_lt_distribution"]
        )
        cfg_dct["detrend_last_dt_utc_start"] = cfg_dct["lt_last_dt_utc_start"]
        if test_is_toggle and cfg_dct["toggle"].get("detrend_data_selection", None) == "use_toggle_off_data":
            cfg_dct["detrend_last_dt_utc_start"] = cfg_dct["analysis_last_dt_utc_start"]
        cfg_dct["detrend_first_dt_utc_start"] = cfg_dct["detrend_last_dt_utc_start"] - pd.DateOffset(
            years=cfg_dct["years_for_detrend"]
        )

        # check each test_wtg exists in asset.wtgs
        for x in cfg_dct["test_wtgs"]:
            if x not in cfg_dct["asset"]["wtgs"]:
                msg = f"test_wtg {x} does not exist in asset.wtgs"
                raise ValueError(msg)

        # if ref_wtgs exist, check each ref_wtg exists in asset.wtgs
        if cfg_dct.get("ref_wtgs", None) is not None:
            for x in cfg_dct["ref_wtgs"]:
                if x not in cfg_dct["asset"]["wtgs"]:
                    msg = f"ref_wtg {x} does not exist in asset.wtgs"
                    raise ValueError(msg)

        # change lists of strings into list of turbines
        if len(cfg_dct["asset"]["turbine_types"]) == 1:
            cfg_dct["asset"]["turbine_types"] = cfg_dct["asset"]["turbine_types"] * len(
                cfg_dct["asset"]["wtgs"],
            )
        tt_list = [TurbineType.model_validate(tt) for tt in cfg_dct["asset"]["turbine_types"]]
        cfg_dct["asset"]["wtgs"] = [
            Turbine(name=x, turbine_type=tt) for x, tt in strict_zip(cfg_dct["asset"]["wtgs"], tt_list)
        ]
        test_wtg_list = []
        for x in cfg_dct["test_wtgs"]:
            for wtg in cfg_dct["asset"]["wtgs"]:
                if x == wtg.name:
                    test_wtg_list.append(wtg)
                    continue
        cfg_dct["test_wtgs"] = test_wtg_list
        if cfg_dct.get("ref_wtgs", None) is not None:
            ref_wtg_list = []
            for x in cfg_dct["ref_wtgs"]:
                for wtg in cfg_dct["asset"]["wtgs"]:
                    if x == wtg.name:
                        ref_wtg_list.append(wtg)
                        continue
            cfg_dct["ref_wtgs"] = ref_wtg_list
        elif cfg_dct.get("non_wtg_ref_names", None) is not None:
            if len(cfg_dct["non_wtg_ref_names"]) < 1:
                msg = "ref_wtgs or non_wtg_ref_names must have more than one element"
                raise ValueError(msg)
        else:
            msg = "ref_wtgs or non_wtg_ref_names must be specified"
            raise ValueError(msg)
        return WindUpConfig.model_validate(cfg_dct)

    def save_json(self, file_path: Path | None = None) -> None:
        """Save WindUpConfig to a json file.

        :param file_path: Path to json file
        :return: None
        """
        file_path = file_path or (self.out_dir / f"config_{self.assessment_name}.json")
        if not file_path.name.endswith(".json"):
            err_msg = "file_path must end with .json"
            raise ValueError(err_msg)
        if not file_path.parent.exists():
            file_path.parent.mkdir(parents=True, exist_ok=True)
        with file_path.open(mode="w") as f:
            json_data = json.loads(self.model_dump_json().replace("NaN", "null"))  # required to serialize NaN
            json.dump(json_data, f, indent=4)
        logger.info(f"Saved WindUpConfig to {file_path}")

    def get_max_rated_power(self: WindUpConfig) -> float:
        """Get the maximum rated power out of all turbine types on the wind farm asset."""
        return max(x.turbine_type.rated_power_kw for x in self.asset.wtgs)

    def list_unique_turbine_types(self: WindUpConfig) -> list[TurbineType]:
        """List unique turbine types of the wind farm asset."""
        unique_names = sorted({x.turbine_type.turbine_type for x in self.asset.wtgs})
        unique_turbine_types = []
        for name in unique_names:
            unique_turbine_types.append(
                next(x.turbine_type for x in self.asset.wtgs if x.turbine_type.turbine_type == name),
            )
        return unique_turbine_types

    def list_turbine_ids_of_type(self: WindUpConfig, ttype: TurbineType) -> list[str]:
        """List turbine ids of a specific turbine type.

        :param ttype: TurbineType
        :return: list of turbine ids
        """
        return [x.name for x in self.asset.wtgs if x.turbine_type == ttype]

    def get_normal_operation_genrpm_range(self: WindUpConfig, ttype: TurbineType) -> tuple[float, float]:
        """Get the normal operation generator rpm range for turbine type.

        :param ttype: TurbineType
        :return: tuple of generator rpm range
        """
        return next(x.turbine_type.normal_operation_genrpm_range for x in self.asset.wtgs if x.turbine_type == ttype)

    def get_normal_operation_pitch_range(self: WindUpConfig, ttype: TurbineType) -> tuple[float, float]:
        """Get the normal operation pitch range for turbine type.

        :param ttype: TurbineType
        :return: tuple of pitch range
        """
        return next(x.turbine_type.normal_operation_pitch_range for x in self.asset.wtgs if x.turbine_type == ttype)
