//! Standardized part-load / seasonal rating harness.
//!
//! Re-solves a full cycle configuration at each standardized part-load point and
//! aggregates the resulting efficiencies into the seasonal metrics used to
//! *qualify* chillers and heat pumps — **IPLV/NPLV** (AHRI 550/590) and **ESEER**
//! (Eurovent). Unlike [`crate::qualify`], which holds the refrigerant regime
//! fixed and sweeps a single heat exchanger, `rate` runs the genuine coupled
//! cycle at every load point: the condensing/evaporating pressures, capacity and
//! power all emerge from the heat-exchanger ↔ secondary balance, so the reported
//! EERs — and hence the integrated value — are real simulation output, not
//! imposed design points.
//!
//! # How a part-load point is defined
//!
//! Each of the four points (100 / 75 / 50 / 25 % load) overrides a handful of
//! operating conditions on the *base* configuration:
//!
//! - `condenser_secondary_inlet_c` — condenser entering water/air temperature
//!   (the AHRI/Eurovent condenser schedule; warmer at full load).
//! - `evaporator_secondary_inlet_c` — evaporator entering fluid temperature.
//! - `condenser_secondary_mass_flow_kg_s` / `evaporator_secondary_mass_flow_kg_s`
//!   — secondary flows, if they vary with load.
//! - `compressor_speed_hz` — compressor unloading (a variable-speed drive turns
//!   the speed down to shed capacity). Combined with a `vsd_reference_speed_hz`
//!   map on the compressor this genuinely degrades part-load efficiency.
//!
//! Everything not overridden is inherited from the base config, so the four
//! points share the same machine and only the operating envelope changes.
//!
//! # Example configuration
//!
//! ```json
//! {
//!   "base_config": "chiller_r134a_emergent_pressure.json",
//!   "metric": "iplv",
//!   "points": [
//!     { "load_fraction": 1.00, "condenser_secondary_inlet_c": 29.4, "compressor_speed_hz": 50.0 },
//!     { "load_fraction": 0.75, "condenser_secondary_inlet_c": 24.4, "compressor_speed_hz": 38.0 },
//!     { "load_fraction": 0.50, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 26.0 },
//!     { "load_fraction": 0.25, "condenser_secondary_inlet_c": 18.3, "compressor_speed_hz": 14.0 }
//!   ]
//! }
//! ```

use std::path::{Path, PathBuf};

use rayon::prelude::*;
use serde::{Deserialize, Serialize};

use entropyk::rating::PartLoadStandard;

use crate::config::ScenarioConfig;
use crate::error::{CliError, CliResult};
use crate::run::{simulate_from_json, SimulationStatus};

/// Which built-in seasonal weighting standard to integrate the part-load EERs
/// into, when no explicit custom standard is supplied.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RateMetric {
    /// Integrated Part Load Value — AHRI 550/590 weighting (default).
    #[default]
    Iplv,
    /// European Seasonal Energy Efficiency Ratio — Eurovent weighting.
    Eseer,
}

impl RateMetric {
    /// The corresponding built-in [`PartLoadStandard`].
    fn standard(self) -> PartLoadStandard {
        match self {
            RateMetric::Iplv => PartLoadStandard::ahri_550_590_iplv(),
            RateMetric::Eseer => PartLoadStandard::eurovent_eseer(),
        }
    }
}

/// A single standardized part-load point: a set of operating overrides applied
/// to the base configuration before re-solving.
#[derive(Debug, Clone, Deserialize)]
pub struct RatePoint {
    /// Fraction of full load this point represents (1.0 / 0.75 / 0.50 / 0.25).
    /// Used to order the points and to weight the seasonal metric.
    pub load_fraction: f64,
    /// Condenser secondary (water/air) entering temperature [°C].
    #[serde(default)]
    pub condenser_secondary_inlet_c: Option<f64>,
    /// Evaporator secondary (water/brine) entering temperature [°C].
    #[serde(default)]
    pub evaporator_secondary_inlet_c: Option<f64>,
    /// Condenser secondary mass flow [kg/s].
    #[serde(default)]
    pub condenser_secondary_mass_flow_kg_s: Option<f64>,
    /// Evaporator secondary mass flow [kg/s].
    #[serde(default)]
    pub evaporator_secondary_mass_flow_kg_s: Option<f64>,
    /// Compressor rotational speed [Hz] (unloading via a variable-speed drive).
    #[serde(default)]
    pub compressor_speed_hz: Option<f64>,
}

/// Rating configuration (JSON).
///
/// The seasonal weighting standard is selected — in order of precedence — by an
/// inline [`standard`](Self::standard) object, a [`standard_file`](Self::standard_file)
/// path (a JSON [`PartLoadStandard`]), a [`standard_name`](Self::standard_name)
/// built-in id (`"iplv"`, `"nplv"`, `"eseer"`), or finally the
/// [`metric`](Self::metric) enum (default IPLV). Supplying a custom standard lets
/// you track a revised norm without recompiling.
#[derive(Debug, Clone, Deserialize)]
pub struct RateConfig {
    /// Path to the base cycle configuration (a `run` scenario file). Resolved
    /// relative to the rating config file's directory when not absolute.
    pub base_config: PathBuf,
    /// Built-in seasonal metric to compute: `"iplv"` (default) or `"eseer"`.
    /// Ignored when a more specific standard selector below is present.
    #[serde(default)]
    pub metric: RateMetric,
    /// Built-in standard id (e.g. `"iplv"`, `"nplv"`, `"eseer"`). Overrides
    /// [`metric`](Self::metric) when set.
    #[serde(default)]
    pub standard_name: Option<String>,
    /// Path to a custom [`PartLoadStandard`] JSON file, resolved relative to this
    /// config's directory. Overrides [`standard_name`](Self::standard_name).
    #[serde(default)]
    pub standard_file: Option<PathBuf>,
    /// Inline custom [`PartLoadStandard`]. Highest precedence.
    #[serde(default)]
    pub standard: Option<PartLoadStandard>,
    /// The standardized part-load points (typically four: 100/75/50/25 %).
    pub points: Vec<RatePoint>,
}

impl RateConfig {
    /// Resolve the effective seasonal weighting standard, honouring the
    /// precedence `standard` > `standard_file` > `standard_name` > `metric`.
    ///
    /// `config_dir` is used to resolve a relative `standard_file` path.
    pub fn resolve_standard(&self, config_dir: Option<&Path>) -> CliResult<PartLoadStandard> {
        let standard = if let Some(inline) = &self.standard {
            inline.clone()
        } else if let Some(file) = &self.standard_file {
            let path = if file.is_absolute() {
                file.clone()
            } else {
                config_dir
                    .map(|d| d.join(file))
                    .unwrap_or_else(|| file.clone())
            };
            let raw = std::fs::read_to_string(&path).map_err(|e| {
                CliError::Config(format!(
                    "Failed to read standard file {}: {}",
                    path.display(),
                    e
                ))
            })?;
            serde_json::from_str(&raw)
                .map_err(|e| CliError::Config(format!("Invalid standard file: {}", e)))?
        } else if let Some(name) = &self.standard_name {
            PartLoadStandard::builtin(name).ok_or_else(|| {
                CliError::Config(format!(
                    "Unknown built-in standard '{}'. Known ids: {}",
                    name,
                    PartLoadStandard::builtin_ids().join(", ")
                ))
            })?
        } else {
            self.metric.standard()
        };
        standard
            .validate()
            .map_err(|e| CliError::Config(format!("Invalid rating standard: {}", e)))?;
        Ok(standard)
    }
}

/// Result of re-solving one part-load point.
#[derive(Debug, Clone, Serialize)]
pub struct RatePointResult {
    /// Fraction of full load.
    pub load_fraction: f64,
    /// Solver status string ("converged", "non_converged", "error", ...).
    pub status: String,
    /// Energy efficiency ratio (cooling COP) at this point, if converged.
    pub eer: Option<f64>,
    /// Cooling capacity [kW], if available.
    pub q_cooling_kw: Option<f64>,
    /// Power input [kW], if available.
    pub power_kw: Option<f64>,
    /// Error message when the point failed to solve.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Full rating report.
#[derive(Debug, Clone, Serialize)]
pub struct RateReport {
    /// Base configuration path used for every point.
    pub base_config: String,
    /// Name of the seasonal standard applied (e.g. "AHRI 550/590 IPLV").
    pub metric: String,
    /// Citation / provenance of the standard's coefficients.
    pub standard_reference: String,
    /// One result row per part-load point (ordered by descending load fraction).
    pub points: Vec<RatePointResult>,
    /// Integrated seasonal value, `None` if a required load point failed.
    pub integrated_value: Option<f64>,
}

/// Applies a part-load point's overrides to a clone of the base configuration.
///
/// The overrides are written into the flattened `params` map of every matching
/// component so that [`crate::run`] picks them up exactly as if the user had
/// authored them in the JSON. Overriding *all* condensers/evaporators/compressors
/// keeps single-circuit chillers (the common case) simple while remaining
/// well-defined for multi-instance machines.
fn apply_overrides(base: &ScenarioConfig, point: &RatePoint) -> ScenarioConfig {
    let mut config = base.clone();
    for circuit in &mut config.circuits {
        for comp in &mut circuit.components {
            match comp.component_type.as_str() {
                "BrineSource" | "AirSource" => {
                    let name_lower = comp.name.to_lowercase();
                    let is_condenser = name_lower.contains("cond");
                    let is_evaporator = name_lower.contains("evap");
                    let temp_key = if comp.component_type == "BrineSource" {
                        "t_set_c"
                    } else {
                        "t_dry_c"
                    };
                    if is_condenser {
                        if let Some(t) = point.condenser_secondary_inlet_c {
                            comp.params.insert(temp_key.into(), json_num(t));
                        }
                        if let Some(m) = point.condenser_secondary_mass_flow_kg_s {
                            comp.params.insert("m_flow_kg_s".into(), json_num(m));
                        }
                    }
                    if is_evaporator {
                        if let Some(t) = point.evaporator_secondary_inlet_c {
                            comp.params.insert(temp_key.into(), json_num(t));
                        }
                        if let Some(m) = point.evaporator_secondary_mass_flow_kg_s {
                            comp.params.insert("m_flow_kg_s".into(), json_num(m));
                        }
                    }
                }
                "IsentropicCompressor" | "Compressor" => {
                    if let Some(hz) = point.compressor_speed_hz {
                        comp.params.insert("speed_hz".into(), json_num(hz));
                    }
                }
                _ => {}
            }
        }
    }
    config
}

/// Builds a JSON number value, falling back to null for non-finite inputs.
fn json_num(x: f64) -> serde_json::Value {
    serde_json::Number::from_f64(x)
        .map(serde_json::Value::Number)
        .unwrap_or(serde_json::Value::Null)
}

/// Re-solves a single part-load point and extracts its EER.
fn solve_point(base: &ScenarioConfig, point: &RatePoint) -> RatePointResult {
    let config = apply_overrides(base, point);
    let json = match serde_json::to_string(&config) {
        Ok(j) => j,
        Err(e) => {
            return RatePointResult {
                load_fraction: point.load_fraction,
                status: "error".to_string(),
                eer: None,
                q_cooling_kw: None,
                power_kw: None,
                error: Some(format!("Failed to serialize overridden config: {}", e)),
            }
        }
    };

    match simulate_from_json(&json) {
        Ok(result) => {
            let status = status_label(&result.status);
            let perf = result.performance.as_ref();
            RatePointResult {
                load_fraction: point.load_fraction,
                status,
                eer: perf.and_then(|p| p.cop),
                q_cooling_kw: perf.and_then(|p| p.q_cooling_kw),
                power_kw: perf.and_then(|p| p.compressor_power_kw),
                error: result.error,
            }
        }
        Err(e) => RatePointResult {
            load_fraction: point.load_fraction,
            status: "error".to_string(),
            eer: None,
            q_cooling_kw: None,
            power_kw: None,
            error: Some(format!("{}", e)),
        },
    }
}

fn status_label(status: &SimulationStatus) -> String {
    match status {
        SimulationStatus::Converged => "converged",
        SimulationStatus::Timeout => "timeout",
        SimulationStatus::NonConverged => "non_converged",
        SimulationStatus::Error => "error",
    }
    .to_string()
}

/// Tolerance for matching a part-load point to a standard's load fraction.
const LOAD_FRACTION_MATCH_TOL: f64 = 0.02;

/// Integrates the part-load EERs into the seasonal value defined by `standard`.
///
/// The standard drives everything: for each of its `load_fractions` (in order)
/// the nearest part-load point (within [`LOAD_FRACTION_MATCH_TOL`]) supplies the
/// EER, and the weighted sum is formed with the standard's `weights`. Returns
/// `None` unless every load point of the standard is matched by a converged point
/// with a finite EER — so a revised standard with a different number of points or
/// different fractions works with no code change, provided the config supplies
/// matching points.
fn integrate(standard: &PartLoadStandard, points: &[RatePointResult]) -> Option<f64> {
    let mut efficiencies = Vec::with_capacity(standard.load_fractions.len());
    for &target in &standard.load_fractions {
        let best = points
            .iter()
            .filter(|p| p.eer.map(|v| v.is_finite()).unwrap_or(false))
            .min_by(|a, b| {
                (a.load_fraction - target)
                    .abs()
                    .partial_cmp(&(b.load_fraction - target).abs())
                    .unwrap_or(std::cmp::Ordering::Equal)
            })?;
        if (best.load_fraction - target).abs() > LOAD_FRACTION_MATCH_TOL {
            return None;
        }
        efficiencies.push(best.eer?);
    }
    standard.integrate(&efficiencies).ok()
}

/// Runs the full rating: re-solves every part-load point (in parallel) and
/// aggregates the seasonal metric defined by `standard`.
pub fn rate(
    config: &RateConfig,
    base: &ScenarioConfig,
    standard: &PartLoadStandard,
) -> CliResult<RateReport> {
    if config.points.is_empty() {
        return Err(CliError::Config(
            "rate config must define at least one part-load point".to_string(),
        ));
    }

    // Each point is an independent solve → evaluate them in parallel.
    let mut points: Vec<RatePointResult> = config
        .points
        .par_iter()
        .map(|p| solve_point(base, p))
        .collect();
    points.sort_by(|a, b| {
        b.load_fraction
            .partial_cmp(&a.load_fraction)
            .unwrap_or(std::cmp::Ordering::Equal)
    });

    let integrated_value = integrate(standard, &points);

    Ok(RateReport {
        base_config: config.base_config.display().to_string(),
        metric: standard.name.clone(),
        standard_reference: standard.reference.clone(),
        points,
        integrated_value,
    })
}

/// Loads a rating config from a JSON file, resolves and loads the referenced base
/// scenario, resolves the seasonal standard, runs the rating, and optionally
/// writes the JSON report.
pub fn run_rate(config_path: &Path, output: Option<&Path>) -> CliResult<RateReport> {
    let raw = std::fs::read_to_string(config_path).map_err(|e| {
        CliError::Config(format!("Failed to read {}: {}", config_path.display(), e))
    })?;
    let config: RateConfig = serde_json::from_str(&raw)
        .map_err(|e| CliError::Config(format!("Invalid rate config: {}", e)))?;

    let config_dir = config_path.parent();

    // Resolve base_config relative to the rating file's directory when needed.
    let base_path = if config.base_config.is_absolute() {
        config.base_config.clone()
    } else {
        config_dir
            .map(|d| d.join(&config.base_config))
            .unwrap_or_else(|| config.base_config.clone())
    };
    let base = ScenarioConfig::from_file(&base_path)?;

    let standard = config.resolve_standard(config_dir)?;

    let report = rate(&config, &base, &standard)?;

    if let Some(out) = output {
        let json = serde_json::to_string_pretty(&report)
            .map_err(|e| CliError::Simulation(format!("Failed to serialize report: {}", e)))?;
        std::fs::write(out, json)
            .map_err(|e| CliError::Config(format!("Failed to write {}: {}", out.display(), e)))?;
    }

    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn base_config() -> ScenarioConfig {
        let json = r#"
        {
            "fluid": "R134a",
            "circuits": [{
                "id": 0,
                "components": [
                    { "type": "Condenser", "name": "cond", "ua": 766.0, "secondary_fluid": "Water" },
                    { "type": "Evaporator", "name": "evap", "ua": 1468.0, "secondary_fluid": "Water" },
                    { "type": "IsentropicCompressor", "name": "comp",
                      "isentropic_efficiency": 0.7, "t_cond_k": 318.15, "t_evap_k": 278.15,
                      "superheat_k": 5.0, "speed_hz": 50.0 },
                    { "type": "BrineSource", "name": "cond_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 30.0, "m_flow_kg_s": 0.36 },
                    { "type": "BrineSink", "name": "cond_water_out", "fluid": "Water", "p_back_bar": 2.0 },
                    { "type": "BrineSource", "name": "evap_water_in", "fluid": "Water", "p_set_bar": 2.0, "t_set_c": 12.0, "m_flow_kg_s": 0.48 },
                    { "type": "BrineSink", "name": "evap_water_out", "fluid": "Water", "p_back_bar": 2.0 }
                ],
                "edges": []
            }],
            "solver": { "strategy": "fallback", "max_iterations": 200, "tolerance": 1e-6 }
        }"#;
        ScenarioConfig::from_json(json).unwrap()
    }

    #[test]
    fn test_apply_overrides_sets_component_params() {
        let base = base_config();
        let point = RatePoint {
            load_fraction: 0.5,
            condenser_secondary_inlet_c: Some(18.3),
            evaporator_secondary_inlet_c: Some(10.0),
            condenser_secondary_mass_flow_kg_s: None,
            evaporator_secondary_mass_flow_kg_s: None,
            compressor_speed_hz: Some(25.0),
        };
        let cfg = apply_overrides(&base, &point);
        let comps = &cfg.circuits[0].components;
        let cond_src = comps.iter().find(|c| c.name == "cond_water_in").unwrap();
        let evap_src = comps.iter().find(|c| c.name == "evap_water_in").unwrap();
        let comp = comps
            .iter()
            .find(|c| c.component_type == "IsentropicCompressor")
            .unwrap();
        assert_eq!(cond_src.params.get("t_set_c").unwrap().as_f64(), Some(18.3));
        assert_eq!(evap_src.params.get("t_set_c").unwrap().as_f64(), Some(10.0));
        assert_eq!(comp.params.get("speed_hz").unwrap().as_f64(), Some(25.0));
        // Untouched fields keep their base value.
        assert_eq!(
            cond_src.params.get("m_flow_kg_s").unwrap().as_f64(),
            Some(0.36)
        );
    }

    #[test]
    fn test_apply_overrides_does_not_mutate_base() {
        let base = base_config();
        let point = RatePoint {
            load_fraction: 1.0,
            condenser_secondary_inlet_c: Some(40.0),
            evaporator_secondary_inlet_c: None,
            condenser_secondary_mass_flow_kg_s: None,
            evaporator_secondary_mass_flow_kg_s: None,
            compressor_speed_hz: None,
        };
        let _ = apply_overrides(&base, &point);
        let cond_src = base.circuits[0]
            .components
            .iter()
            .find(|c| c.name == "cond_water_in")
            .unwrap();
        assert_eq!(
            cond_src.params.get("t_set_c").unwrap().as_f64(),
            Some(30.0),
            "base config must be left unmodified"
        );
    }

    #[test]
    fn test_integrate_iplv_matches_weighted_formula() {
        let points = vec![
            mk_point(1.00, 4.0),
            mk_point(0.75, 5.0),
            mk_point(0.50, 6.0),
            mk_point(0.25, 5.5),
        ];
        let std = PartLoadStandard::ahri_550_590_iplv();
        let value = integrate(&std, &points).unwrap();
        let expected = 0.01 * 4.0 + 0.42 * 5.0 + 0.45 * 6.0 + 0.12 * 5.5;
        assert!((value - expected).abs() < 1e-12);
    }

    #[test]
    fn test_integrate_eseer_uses_eurovent_weights() {
        let points = vec![
            mk_point(1.00, 3.0),
            mk_point(0.75, 4.0),
            mk_point(0.50, 5.0),
            mk_point(0.25, 4.5),
        ];
        let std = PartLoadStandard::eurovent_eseer();
        let value = integrate(&std, &points).unwrap();
        let expected = 0.03 * 3.0 + 0.33 * 4.0 + 0.41 * 5.0 + 0.23 * 4.5;
        assert!((value - expected).abs() < 1e-12);
    }

    #[test]
    fn test_integrate_orders_points_by_load_fraction() {
        // Points supplied out of order must still map correctly to 100/75/50/25.
        let points = vec![
            mk_point(0.25, 5.5),
            mk_point(1.00, 4.0),
            mk_point(0.50, 6.0),
            mk_point(0.75, 5.0),
        ];
        let std = PartLoadStandard::ahri_550_590_iplv();
        let value = integrate(&std, &points).unwrap();
        let expected = 0.01 * 4.0 + 0.42 * 5.0 + 0.45 * 6.0 + 0.12 * 5.5;
        assert!((value - expected).abs() < 1e-12);
    }

    #[test]
    fn test_integrate_with_custom_standard() {
        // A custom standard (different fractions/weights) drives the aggregation
        // with no code change — matched to the supplied points by nearest fraction.
        let std = PartLoadStandard::new(
            "Custom SEER",
            "illustrative",
            vec![1.0, 0.74, 0.47, 0.21],
            vec![0.03, 0.27, 0.41, 0.29],
        )
        .unwrap();
        let points = vec![
            mk_point(1.00, 3.0),
            mk_point(0.74, 4.0),
            mk_point(0.47, 5.0),
            mk_point(0.21, 4.5),
        ];
        let value = integrate(&std, &points).unwrap();
        let expected = 0.03 * 3.0 + 0.27 * 4.0 + 0.41 * 5.0 + 0.29 * 4.5;
        assert!((value - expected).abs() < 1e-12);
    }

    #[test]
    fn test_integrate_none_when_point_missing_or_failed() {
        let std = PartLoadStandard::ahri_550_590_iplv();
        // Only three points → a standard fraction (0.25) has no match within tol.
        let three = vec![mk_point(1.0, 4.0), mk_point(0.75, 5.0), mk_point(0.5, 6.0)];
        assert!(integrate(&std, &three).is_none());

        // Four points but one failed to produce an EER.
        let mut four = vec![
            mk_point(1.0, 4.0),
            mk_point(0.75, 5.0),
            mk_point(0.5, 6.0),
            mk_point(0.25, 5.5),
        ];
        four[2].eer = None;
        assert!(integrate(&std, &four).is_none());
    }

    #[test]
    fn test_resolve_standard_precedence() {
        // Inline standard wins over everything.
        let inline = PartLoadStandard::new("inline", "", vec![1.0, 0.5], vec![0.5, 0.5]).unwrap();
        let cfg = RateConfig {
            base_config: PathBuf::from("x.json"),
            metric: RateMetric::Eseer,
            standard_name: Some("eseer".to_string()),
            standard_file: None,
            standard: Some(inline.clone()),
            points: vec![],
        };
        assert_eq!(cfg.resolve_standard(None).unwrap().name, "inline");

        // standard_name selects a built-in over the metric enum.
        let cfg2 = RateConfig {
            standard: None,
            standard_name: Some("eseer".to_string()),
            metric: RateMetric::Iplv,
            ..cfg.clone()
        };
        assert_eq!(
            cfg2.resolve_standard(None).unwrap().weights,
            entropyk::rating::ESEER_WEIGHTS.to_vec()
        );

        // Falls back to the metric enum (default IPLV) when nothing else is set.
        let cfg3 = RateConfig {
            standard: None,
            standard_name: None,
            metric: RateMetric::Iplv,
            ..cfg.clone()
        };
        assert_eq!(
            cfg3.resolve_standard(None).unwrap().weights,
            entropyk::rating::IPLV_WEIGHTS.to_vec()
        );

        // Unknown built-in id is a clear error.
        let cfg4 = RateConfig {
            standard: None,
            standard_name: Some("nope".to_string()),
            ..cfg
        };
        assert!(cfg4.resolve_standard(None).is_err());
    }

    fn mk_point(load: f64, eer: f64) -> RatePointResult {
        RatePointResult {
            load_fraction: load,
            status: "converged".to_string(),
            eer: Some(eer),
            q_cooling_kw: Some(eer * 2.0),
            power_kw: Some(2.0),
            error: None,
        }
    }
}
