//! Probe — zero-residual measurement tap on a connection line.
//!
//! Per the calibration redesign (see
//! `_bmad-output/implementation-artifacts/calibration-probe-node-plain-embedding.md`),
//! **every** calibration measurement lives on a `Probe` node the user drops
//! onto a wire. The calibrated z-factor (`z_ua`, `z_dp`, `z_flow`, ...) on a
//! component is freed, and a `Probe` elsewhere on the graph supplies the
//! matching measurement via its [`Component::measure_output`] override.
//!
//! ## Design
//!
//! - **Zero residual, zero Jacobian** — `n_equations() == 0`. The Probe is a
//!   measurement tap, not a physical element. The calibration `Constraint`
//!   (one residual, one unknown z-factor) closes the system.
//! - **Two ports `inlet`/`outlet`** — same typestate pattern as `Pipe`/`Pump`/
//!   `Node`. The Probe splices into an edge A→B (becomes A→Probe→B) exactly
//!   like `Pipe` does in `apps/web/src/lib/edgeInsert.ts`, so the UI drop-on-
//!   wire interaction reuses `insertOnEdge`.
//! - **Reads live edge state** via [`Component::set_system_context`]. The
//!   Probe stores the `(ṁ, P, h)` triples for both its incident edges (the
//!   two halves of the spliced wire) and uses them in `measure_output`.
//!
//! ## Measure kinds
//!
//! [`ProbeMeasure`] is the exhaustive list the user named. SST/SDT/DGT/DSH/
//! SH/SC all derive from `(P, T)` on the same edge via CoolProp — the user
//! picks the semantic kind for clarity, the math is P/T/Tsat:
//!
//! | Kind | Formula |
//! |------|---------|
//! | [`Sst`](ProbeMeasure::Sst) / [`Sdt`](ProbeMeasure::Sdt) | `Tsat(P_edge)` |
//! | [`Dgt`](ProbeMeasure::Dgt) / [`T`](ProbeMeasure::T) | `T_edge` |
//! | [`Dsh`](ProbeMeasure::Dsh) / [`Sh`](ProbeMeasure::Sh) | `T_edge − Tsat(P_edge)` |
//! | [`Sc`](ProbeMeasure::Sc) | `Tsat(P_edge) − T_edge` |
//! | [`P`](ProbeMeasure::P) | `P_edge` |
//! | [`MassFlow`](ProbeMeasure::MassFlow) | `ṁ_edge` |
//! | [`Enthalpy`](ProbeMeasure::Enthalpy) | `h_edge` |
//! | [`Capacity`](ProbeMeasure::Capacity) | `ṁ_edge · |h_inlet − h_outlet|` |
//!
//! `Tsat(P)` reuses the `tsat_k` pattern from
//! `crates/components/src/heat_exchanger/bphx_exchanger.rs:543-559` (clamps
//! the query pressure into the detected saturation domain via
//! `sat_domain::clamp_to_saturation_domain`).

use crate::heat_exchanger::sat_domain;
use crate::port::{Connected, Disconnected, Port};
use crate::state_machine::StateManageable;
use crate::{
    CircuitId, Component, ComponentError, ConnectedPort, JacobianBuilder, MeasuredOutput,
    OperationalState, ResidualVector, StateSlice,
};
use entropyk_core::{CalibIndices, MassFlow, Power};
use entropyk_fluids::{FluidBackend, FluidId as BackendFluidId, FluidState, Property, Quality};
use std::marker::PhantomData;
use std::sync::Arc;

/// Semantic quantity a [`Probe`] extracts from its edge.
///
/// Exhaustive list per the user mandate (see module docs). SST/SDT and DSH/SH
/// share formulas by design — the user picks the label that matches the
/// physical sensor location (suction vs discharge).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProbeMeasure {
    /// Saturated suction temperature = `Tsat(P_edge)`.
    Sst,
    /// Saturated discharge temperature = `Tsat(P_edge)` (same formula, label only).
    Sdt,
    /// Discharge gas temperature = `T_edge` (raw temperature at the probe).
    Dgt,
    /// Discharge superheat = `T_edge − Tsat(P_edge)`.
    Dsh,
    /// (Suction) superheat = `T_edge − Tsat(P_edge)` (same formula as Dsh).
    Sh,
    /// Subcooling = `Tsat(P_edge) − T_edge`.
    Sc,
    /// Raw temperature = `T_edge`.
    T,
    /// Raw pressure = `P_edge`.
    P,
    /// Mass flow rate = `ṁ_edge`.
    MassFlow,
    /// Heat duty = `ṁ · |Δh|` across the probe (see [`Probe`] module docs).
    Capacity,
    /// Raw specific enthalpy = `h_edge`.
    Enthalpy,
}

impl ProbeMeasure {
    /// Parses a measure-kind string from the JSON config (case-insensitive).
    /// Accepts the canonical names plus a few common aliases.
    pub fn parse(s: &str) -> Result<Self, String> {
        let lower = s.trim().to_ascii_lowercase();
        let m = match lower.as_str() {
            "sst" => ProbeMeasure::Sst,
            "sdt" => ProbeMeasure::Sdt,
            "dgt" => ProbeMeasure::Dgt,
            "dsh" => ProbeMeasure::Dsh,
            "sh" | "superheat" => ProbeMeasure::Sh,
            "sc" | "subcooling" => ProbeMeasure::Sc,
            "t" | "temperature" => ProbeMeasure::T,
            "p" | "pressure" => ProbeMeasure::P,
            "massflow" | "mass_flow" | "mass flow" | "massflowrate" | "mass_flow_rate" => {
                ProbeMeasure::MassFlow
            }
            "capacity" | "heatduty" | "heat_transfer_rate" | "heatrate" | "duty" => {
                ProbeMeasure::Capacity
            }
            "enthalpy" | "h" => ProbeMeasure::Enthalpy,
            other => {
                return Err(format!(
                    "unknown Probe measure '{other}' (expected one of: SST, SDT, DGT, DSH, SH, SC, T, P, MassFlow, Capacity, Enthalpy)"
                ))
            }
        };
        Ok(m)
    }

    /// Returns the canonical JSON name for this measure (round-trips through `parse`).
    pub fn as_str(&self) -> &'static str {
        match self {
            ProbeMeasure::Sst => "SST",
            ProbeMeasure::Sdt => "SDT",
            ProbeMeasure::Dgt => "DGT",
            ProbeMeasure::Dsh => "DSH",
            ProbeMeasure::Sh => "SH",
            ProbeMeasure::Sc => "SC",
            ProbeMeasure::T => "T",
            ProbeMeasure::P => "P",
            ProbeMeasure::MassFlow => "MassFlow",
            ProbeMeasure::Capacity => "Capacity",
            ProbeMeasure::Enthalpy => "Enthalpy",
        }
    }

    /// Maps this measure to the solver-side [`MeasuredOutput`] the calibration
    /// constraint will ask for. SST/SDT → `SaturationTemperature`; DSH/SH →
    /// `Superheat`; SC → `Subcooling`; DGT/T → `Temperature`; P → `Pressure`;
    /// MassFlow → `MassFlowRate`; Capacity → `Capacity`; Enthalpy →
    /// `HeatTransferRate` is NOT right, so Enthalpy returns `Temperature` as a
    /// placeholder (the Probe still serves it via `measure_output(Temperature)`
    /// returning T, and Enthalpy is read directly via the measure-output hook
    /// below — see [`Probe::measure_output`] which honours the configured kind).
    pub fn to_measured_output(&self) -> MeasuredOutput {
        match self {
            ProbeMeasure::Sst | ProbeMeasure::Sdt => MeasuredOutput::SaturationTemperature,
            ProbeMeasure::Dsh | ProbeMeasure::Sh => MeasuredOutput::Superheat,
            ProbeMeasure::Sc => MeasuredOutput::Subcooling,
            ProbeMeasure::Dgt | ProbeMeasure::T => MeasuredOutput::Temperature,
            ProbeMeasure::P => MeasuredOutput::Pressure,
            ProbeMeasure::MassFlow => MeasuredOutput::MassFlowRate,
            ProbeMeasure::Capacity => MeasuredOutput::Capacity,
            // No dedicated Enthalpy variant in MeasuredOutput — collapse onto
            // HeatTransferRate so the constraint resolves; measure_output
            // detects the configured Enthalpy kind and returns h_edge.
            ProbeMeasure::Enthalpy => MeasuredOutput::HeatTransferRate,
        }
    }
}

/// A zero-residual measurement tap on a connection line.
///
/// See the module docs for the calibration redesign context.
///
/// Like `Pipe` and `Pump`, `Probe` uses a typestate (`Disconnected` →
/// `Connected`) so the compiler enforces port connection before the component
/// is added to a system.
#[derive(Clone)]
pub struct Probe<State> {
    measure: ProbeMeasure,
    fluid_id_str: String,
    port_inlet: Port<State>,
    port_outlet: Port<State>,
    fluid_backend: Option<Arc<dyn FluidBackend>>,
    circuit_id: CircuitId,
    operational_state: OperationalState,
    calib_indices: CalibIndices,
    // Live edge-state indices populated by `set_system_context`.
    inlet_m_idx: Option<usize>,
    inlet_p_idx: Option<usize>,
    inlet_h_idx: Option<usize>,
    outlet_m_idx: Option<usize>,
    outlet_p_idx: Option<usize>,
    outlet_h_idx: Option<usize>,
    _state: PhantomData<State>,
}

impl<State> std::fmt::Debug for Probe<State> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Probe")
            .field("measure", &self.measure)
            .field("fluid", &self.fluid_id_str)
            .field("has_backend", &self.fluid_backend.is_some())
            .field("circuit_id", &self.circuit_id)
            .field("operational_state", &self.operational_state)
            .finish()
    }
}

impl Probe<Disconnected> {
    /// Creates a new disconnected Probe.
    ///
    /// `fluid` is used for `Tsat`/`SH`/`SC` derivation via the configured
    /// fluid backend. The backend itself is injected later by
    /// [`Component::set_fluid_backend_from_builder`] when the system is built.
    pub fn new(
        measure: ProbeMeasure,
        fluid: impl Into<String>,
        port_inlet: Port<Disconnected>,
        port_outlet: Port<Disconnected>,
    ) -> Self {
        Self {
            measure,
            fluid_id_str: fluid.into(),
            port_inlet,
            port_outlet,
            fluid_backend: None,
            circuit_id: CircuitId::default(),
            operational_state: OperationalState::default(),
            calib_indices: CalibIndices::default(),
            inlet_m_idx: None,
            inlet_p_idx: None,
            inlet_h_idx: None,
            outlet_m_idx: None,
            outlet_p_idx: None,
            outlet_h_idx: None,
            _state: PhantomData,
        }
    }

    /// Attaches a fluid backend for property queries (Tsat, T from P,h).
    pub fn with_fluid_backend(mut self, backend: Arc<dyn FluidBackend>) -> Self {
        self.fluid_backend = Some(backend);
        self
    }

    /// Connects the Probe to its inlet and outlet ports, transitioning to
    /// `Probe<Connected>`. Mirrors `Pipe::connect` / `Pump::connect`.
    pub fn connect(
        self,
        inlet: Port<Disconnected>,
        outlet: Port<Disconnected>,
    ) -> Result<Probe<Connected>, ComponentError> {
        let (p_in, _) = self
            .port_inlet
            .connect(inlet)
            .map_err(|e| ComponentError::InvalidState(e.to_string()))?;
        let (p_out, _) = self
            .port_outlet
            .connect(outlet)
            .map_err(|e| ComponentError::InvalidState(e.to_string()))?;
        Ok(Probe {
            measure: self.measure,
            fluid_id_str: self.fluid_id_str,
            port_inlet: p_in,
            port_outlet: p_out,
            fluid_backend: self.fluid_backend,
            circuit_id: self.circuit_id,
            operational_state: self.operational_state,
            calib_indices: self.calib_indices,
            inlet_m_idx: self.inlet_m_idx,
            inlet_p_idx: self.inlet_p_idx,
            inlet_h_idx: self.inlet_h_idx,
            outlet_m_idx: self.outlet_m_idx,
            outlet_p_idx: self.outlet_p_idx,
            outlet_h_idx: self.outlet_h_idx,
            _state: PhantomData,
        })
    }
}

impl Probe<Connected> {
    /// Returns the configured measure kind.
    pub fn measure(&self) -> ProbeMeasure {
        self.measure
    }

    /// Returns the fluid identifier string the Probe derives properties for.
    pub fn fluid_id_str(&self) -> &str {
        &self.fluid_id_str
    }

    /// Returns the inlet port.
    pub fn port_inlet(&self) -> &Port<Connected> {
        &self.port_inlet
    }

    /// Returns the outlet port.
    pub fn port_outlet(&self) -> &Port<Connected> {
        &self.port_outlet
    }

    /// Returns both ports as a slice.
    pub fn get_ports_slice(&self) -> [&Port<Connected>; 2] {
        [&self.port_inlet, &self.port_outlet]
    }

    /// \(T_{\mathrm{sat}}(P)\) [K] for the configured fluid, domain-clamped.
    /// Mirrors `BphxExchanger::tsat_k` (`bphx_exchanger.rs:544-559`).
    fn tsat_k(&self, p_pa: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed(
                "Probe: FluidBackend required for Tsat/SH/SC derivation".into(),
            )
        })?;
        let p_pa =
            sat_domain::clamp_to_saturation_domain(backend, &self.fluid_id_str, p_pa).unwrap_or(p_pa);
        backend
            .property(
                BackendFluidId::new(&self.fluid_id_str),
                Property::Temperature,
                FluidState::from_px(
                    entropyk_core::Pressure::from_pascals(p_pa),
                    Quality::new(0.5),
                ),
            )
            .map_err(ComponentError::from_fluid_error)
    }

    /// Temperature at the probe edge from `(P, h)` via the fluid backend.
    fn temperature_k(&self, p_pa: f64, h_j_kg: f64) -> Result<f64, ComponentError> {
        let backend = self.fluid_backend.as_ref().ok_or_else(|| {
            ComponentError::CalculationFailed(
                "Probe: FluidBackend required for temperature derivation".into(),
            )
        })?;
        let t = backend
            .property(
                BackendFluidId::new(&self.fluid_id_str),
                Property::Temperature,
                FluidState::from_ph(
                    entropyk_core::Pressure::from_pascals(p_pa),
                    entropyk_core::Enthalpy::from_joules_per_kg(h_j_kg),
                ),
            )
            .map_err(ComponentError::from_fluid_error)?;
        if !t.is_finite() || t <= 0.0 {
            return Err(ComponentError::CalculationFailed(format!(
                "Probe: non-physical temperature {t} for P={p_pa} Pa, h={h_j_kg} J/kg"
            )));
        }
        Ok(t)
    }

    /// Reads `(ṁ, P, h)` of the inlet edge from the global state, or `None`
    /// when the indices have not been wired yet.
    fn inlet_state(&self, state: &StateSlice) -> Option<(f64, f64, f64)> {
        let (m, p, h) = (
            self.inlet_m_idx?,
            self.inlet_p_idx?,
            self.inlet_h_idx?,
        );
        if m >= state.len() || p >= state.len() || h >= state.len() {
            return None;
        }
        Some((state[m], state[p], state[h]))
    }

    /// Reads the outlet-edge `(P, h)` from the global state, or `None`.
    fn outlet_state(&self, state: &StateSlice) -> Option<(f64, f64)> {
        let (p, h) = (self.outlet_p_idx?, self.outlet_h_idx?);
        if p >= state.len() || h >= state.len() {
            return None;
        }
        Some((state[p], state[h]))
    }
}

impl Component for Probe<Connected> {
    fn set_system_context(
        &mut self,
        _state_offset: usize,
        external_edge_state_indices: &[(usize, usize, usize)],
    ) {
        // The Probe is a 2-port splice; layout mirrors Pipe/Pump:
        // [0] = incoming edge (upstream→probe), [1] = outgoing edge (probe→downstream).
        // The Probe measures at its location; for SST/SDT/DGT/DSH/SH/SC/T/P we
        // use the inlet edge (the edge the user dropped the probe onto). For
        // Capacity we use both edges (ṁ·|Δh| across the splice).
        if let Some(&(m, p, h)) = external_edge_state_indices.first() {
            self.inlet_m_idx = Some(m);
            self.inlet_p_idx = Some(p);
            self.inlet_h_idx = Some(h);
        }
        if let Some(&(m, p, h)) = external_edge_state_indices.get(1) {
            self.outlet_m_idx = Some(m);
            self.outlet_p_idx = Some(p);
            self.outlet_h_idx = Some(h);
        }
    }

    fn compute_residuals(
        &self,
        state: &StateSlice,
        residuals: &mut ResidualVector,
    ) -> Result<(), ComponentError> {
        // Two topology residuals any 2-port splice must impose to keep the
        // DoF balance closed (mirrors `Anchor` and edge-coupled `Pipe`):
        //   r0 = P_out − P_in   (zero-resistance pressure pass-through)
        //   r1 = h_out − h_in   (adiabatic enthalpy pass-through)
        // These are NOT calibration residuals — they are the continuity that
        // makes the Probe a transparent tap. The calibration `Constraint`
        // (measure − target) supplies the only calibration-related residual.
        if residuals.len() < 2 {
            return Err(ComponentError::InvalidResidualDimensions {
                expected: 2,
                actual: residuals.len(),
            });
        }
        let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
            self.inlet_p_idx,
            self.inlet_h_idx,
            self.outlet_p_idx,
            self.outlet_h_idx,
        ) {
            (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
            _ => {
                return Err(ComponentError::InvalidState(
                    "Probe requires live inlet and outlet edge state indices".to_string(),
                ));
            }
        };
        let max_idx = in_p_idx.max(in_h_idx).max(out_p_idx).max(out_h_idx);
        if max_idx >= state.len() {
            return Err(ComponentError::InvalidStateDimensions {
                expected: max_idx + 1,
                actual: state.len(),
            });
        }
        residuals[0] = state[out_p_idx] - state[in_p_idx];
        residuals[1] = state[out_h_idx] - state[in_h_idx];
        Ok(())
    }

    fn jacobian_entries(
        &self,
        _state: &StateSlice,
        jacobian: &mut JacobianBuilder,
    ) -> Result<(), ComponentError> {
        // ∂r0/∂P_out = +1, ∂r0/∂P_in = −1 ; ∂r1/∂h_out = +1, ∂r1/∂h_in = −1
        let (in_p_idx, in_h_idx, out_p_idx, out_h_idx) = match (
            self.inlet_p_idx,
            self.inlet_h_idx,
            self.outlet_p_idx,
            self.outlet_h_idx,
        ) {
            (Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
            _ => {
                return Err(ComponentError::InvalidState(
                    "Probe Jacobian requires live inlet and outlet edge state indices".to_string(),
                ));
            }
        };
        jacobian.add_entry(0, out_p_idx, 1.0);
        jacobian.add_entry(0, in_p_idx, -1.0);
        jacobian.add_entry(1, out_h_idx, 1.0);
        jacobian.add_entry(1, in_h_idx, -1.0);
        Ok(())
    }

    fn equation_roles(&self) -> Vec<crate::EquationRole> {
        vec![
            crate::EquationRole::Continuity { quantity: "P" },
            crate::EquationRole::Continuity { quantity: "h" },
        ]
    }

    fn n_equations(&self) -> usize {
        // 2 topology-continuity residuals (P, h) — see `compute_residuals`.
        // NOT a calibration residual: the calibration Constraint supplies that.
        2
    }

    fn get_ports(&self) -> &[ConnectedPort] {
        &[]
    }

    fn port_names(&self) -> Vec<String> {
        vec!["inlet".to_string(), "outlet".to_string()]
    }

    fn flow_paths(&self) -> Vec<(usize, usize)> {
        // Single series path through the tap.
        vec![(0, 1)]
    }

    fn port_mass_flows(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<MassFlow>, ComponentError> {
        let m_in = match self.inlet_m_idx {
            Some(idx) if idx < state.len() => state[idx],
            _ => 0.0,
        };
        let m_out = match self.outlet_m_idx {
            Some(idx) if idx < state.len() => state[idx],
            _ => m_in,
        };
        Ok(vec![
            MassFlow::from_kg_per_s(m_in),
            MassFlow::from_kg_per_s(-m_out),
        ])
    }

    fn port_enthalpies(
        &self,
        state: &StateSlice,
    ) -> Result<Vec<entropyk_core::Enthalpy>, ComponentError> {
        let h_in = match self.inlet_h_idx {
            Some(idx) if idx < state.len() => state[idx],
            _ => self.port_inlet.enthalpy().to_joules_per_kg(),
        };
        let h_out = match self.outlet_h_idx {
            Some(idx) if idx < state.len() => state[idx],
            _ => self.port_outlet.enthalpy().to_joules_per_kg(),
        };
        Ok(vec![
            entropyk_core::Enthalpy::from_joules_per_kg(h_in),
            entropyk_core::Enthalpy::from_joules_per_kg(h_out),
        ])
    }

    fn set_calib_indices(&mut self, indices: CalibIndices) {
        // A Probe owns no z-factors; the slot is kept only for trait
        // compatibility with the System finalize path.
        self.calib_indices = indices;
    }

    fn set_fluid_backend_from_builder(
        &mut self,
        backend: Arc<dyn FluidBackend>,
    ) {
        if self.fluid_backend.is_none() {
            self.fluid_backend = Some(Arc::clone(&backend));
        }
    }

    fn energy_transfers(&self, _state: &StateSlice) -> Option<(Power, Power)> {
        // A measurement tap is adiabatic.
        Some((Power::from_watts(0.0), Power::from_watts(0.0)))
    }

    fn measure_output(&self, kind: MeasuredOutput, state: &StateSlice) -> Option<f64> {
        // Resolve which edge we measure on. SST/SDT use the inlet edge's
        // pressure; DGT/T/P/MassFlow/Enthalpy also use the inlet edge (the
        // physical sensor location). For DSH/SH/SC we need (P, T) on the same
        // edge. For Capacity we use ṁ and |Δh| across the splice.
        let (m_in, p_in, h_in) = self.inlet_state(state)?;
        // When the configured measure is Enthalpy, return h_edge regardless of
        // the `kind` the constraint asks for (we map Enthalpy →
        // HeatTransferRate at the routing layer because MeasuredOutput has no
        // Enthalpy variant).
        if self.measure == ProbeMeasure::Enthalpy {
            return Some(h_in);
        }

        match kind {
            MeasuredOutput::SaturationTemperature => Some(self.tsat_k(p_in).ok()?),
            MeasuredOutput::Pressure => Some(p_in),
            MeasuredOutput::MassFlowRate => Some(m_in.abs()),
            MeasuredOutput::Temperature => {
                // DGT/T → raw temperature at the probe. Falls back to None
                // when the backend is unavailable or returns a non-physical T.
                self.temperature_k(p_in, h_in).ok()
            }
            MeasuredOutput::Superheat | MeasuredOutput::Subcooling => {
                let tsat = self.tsat_k(p_in).ok()?;
                let t = self.temperature_k(p_in, h_in).ok()?;
                if !t.is_finite() || t <= 0.0 {
                    return None;
                }
                match kind {
                    MeasuredOutput::Superheat => Some(t - tsat),
                    _ => Some(tsat - t),
                }
            }
            MeasuredOutput::Capacity | MeasuredOutput::HeatTransferRate => {
                // Capacity = ṁ · |Δh| across the probe splice. Uses both edges
                // when available; if the outlet index isn't wired (defensive),
                // falls back to zero duty.
                if let Some((_, h_out)) = self.outlet_state(state) {
                    let dh = (h_in - h_out).abs();
                    let q = m_in.abs() * dh;
                    if q.is_finite() {
                        return Some(q);
                    }
                }
                Some(0.0)
            }
        }
    }

    fn signature(&self) -> String {
        format!(
            "Probe(measure={}, fluid={}, circuit={})",
            self.measure.as_str(),
            self.fluid_id_str,
            self.circuit_id.0
        )
    }

    fn to_params(&self) -> crate::ComponentParams {
        crate::ComponentParams::new("Probe")
            .with_param("measure", self.measure.as_str().to_string())
            .with_param("fluid", self.fluid_id_str.clone())
            .with_param("circuitId", self.circuit_id.0)
    }
}

impl StateManageable for Probe<Connected> {
    fn state(&self) -> OperationalState {
        self.operational_state
    }

    fn set_state(&mut self, state: OperationalState) -> Result<(), ComponentError> {
        if self.operational_state.can_transition_to(state) {
            self.operational_state = state;
            Ok(())
        } else {
            Err(ComponentError::InvalidStateTransition {
                from: self.operational_state,
                to: state,
                reason: "Transition not allowed".to_string(),
            })
        }
    }

    fn can_transition_to(&self, target: OperationalState) -> bool {
        self.operational_state.can_transition_to(target)
    }

    fn circuit_id(&self) -> &CircuitId {
        &self.circuit_id
    }

    fn set_circuit_id(&mut self, circuit_id: CircuitId) {
        self.circuit_id = circuit_id;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::port::FluidId;
    use entropyk_core::{Enthalpy, Pressure};
    use entropyk_fluids::TestBackend;

    fn build_probe(measure: ProbeMeasure) -> Probe<Connected> {
        let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
        let mk = || {
            Port::new(
                FluidId::new("R134a"),
                Pressure::from_bar(3.0),
                Enthalpy::from_joules_per_kg(400_000.0),
            )
        };
        let probe_disc = Probe::new(measure, "R134a", mk(), mk()).with_fluid_backend(backend);
        // The connect() call needs a second pair of ports (mirrors Pipe/Pump
        // CLI idiom — both pairs carry identical nominal values).
        probe_disc.connect(mk(), mk()).expect("probe connect")
    }

    /// Helper: equip a Probe with live edge indices pointing at a synthetic
    /// state vector `[ṁ_in, P_in, h_in, ṁ_out, P_out, h_out]`.
    fn wire_state_indices(probe: &mut Probe<Connected>) {
        probe.inlet_m_idx = Some(0);
        probe.inlet_p_idx = Some(1);
        probe.inlet_h_idx = Some(2);
        probe.outlet_m_idx = Some(3);
        probe.outlet_p_idx = Some(4);
        probe.outlet_h_idx = Some(5);
    }

    #[test]
    fn probe_imposes_continuity_only() {
        // The Probe is a zero-resistance tap: it contributes 2 topology
        // residuals (P_out = P_in, h_out = h_in) so the spliced edge stays
        // DoF-balanced. It does NOT contribute a calibration residual —
        // the calibration Constraint (measure − target) supplies that.
        let probe = build_probe(ProbeMeasure::Sh);
        assert_eq!(probe.n_equations(), 2);
        let roles = probe.equation_roles();
        assert_eq!(roles.len(), 2);

        // Continuity residuals: inlet edge state (ṁ=0.1, P=5e5, h=300e3),
        // outlet edge state (ṁ=0.1, P=5e5, h=300e3) → r0 = 0, r1 = 0.
        let mut probe = build_probe(ProbeMeasure::Sh);
        wire_state_indices(&mut probe);
        let state = vec![0.1, 5.0e5, 300_000.0, 0.1, 5.0e5, 300_000.0];
        let mut residuals = vec![0.0; 2];
        probe
            .compute_residuals(&state, &mut residuals)
            .expect("residuals compute");
        assert!(residuals[0].abs() < 1e-9, "P continuity: {}", residuals[0]);
        assert!(residuals[1].abs() < 1e-9, "h continuity: {}", residuals[1]);

        // Mismatched inlet/outlet → non-zero continuity residuals.
        let state2 = vec![0.1, 5.0e5, 300_000.0, 0.1, 4.0e5, 290_000.0];
        probe
            .compute_residuals(&state2, &mut residuals)
            .expect("residuals compute");
        assert!((residuals[0] - (-1.0e5)).abs() < 1e-3);
        assert!((residuals[1] - (-10_000.0)).abs() < 1e-6);

        // Jacobian: ∂r0/∂P_out=+1, ∂r0/∂P_in=−1, ∂r1/∂h_out=+1, ∂r1/∂h_in=−1.
        let mut jac = JacobianBuilder::new();
        probe
            .jacobian_entries(&state, &mut jac)
            .expect("jacobian computes");
        let entries = jac.entries();
        assert!(entries.contains(&(0, 4, 1.0)), "missing ∂r0/∂P_out");
        assert!(entries.contains(&(0, 1, -1.0)), "missing ∂r0/∂P_in");
        assert!(entries.contains(&(1, 5, 1.0)), "missing ∂r1/∂h_out");
        assert!(entries.contains(&(1, 2, -1.0)), "missing ∂r1/∂h_in");
    }

    #[test]
    fn probe_port_names() {
        let probe = build_probe(ProbeMeasure::Sh);
        assert_eq!(probe.port_names(), vec!["inlet", "outlet"]);
        assert_eq!(probe.flow_paths(), vec![(0, 1)]);
    }

    #[test]
    fn probe_measure_parse_round_trip() {
        for kind in [
            ProbeMeasure::Sst,
            ProbeMeasure::Sdt,
            ProbeMeasure::Dgt,
            ProbeMeasure::Dsh,
            ProbeMeasure::Sh,
            ProbeMeasure::Sc,
            ProbeMeasure::T,
            ProbeMeasure::P,
            ProbeMeasure::MassFlow,
            ProbeMeasure::Capacity,
            ProbeMeasure::Enthalpy,
        ] {
            let parsed = ProbeMeasure::parse(kind.as_str()).expect("parses");
            assert_eq!(parsed, kind, "round-trip for {:?}", kind);
        }
    }

    #[test]
    fn probe_measure_parse_rejects_unknown() {
        assert!(ProbeMeasure::parse("NOPE").is_err());
        assert!(ProbeMeasure::parse("").is_err());
    }

    #[test]
    fn probe_measure_parse_accepts_aliases() {
        assert_eq!(
            ProbeMeasure::parse("superheat").unwrap(),
            ProbeMeasure::Sh
        );
        assert_eq!(
            ProbeMeasure::parse("mass_flow_rate").unwrap(),
            ProbeMeasure::MassFlow
        );
        assert_eq!(
            ProbeMeasure::parse("subcooling").unwrap(),
            ProbeMeasure::Sc
        );
    }

    #[test]
    fn probe_returns_none_without_state_indices() {
        // No `set_system_context` call → indices are None → measure_output
        // must return None gracefully instead of panicking.
        let probe = build_probe(ProbeMeasure::Sh);
        let state = vec![0.0; 6];
        assert!(probe
            .measure_output(MeasuredOutput::Superheat, &state)
            .is_none());
        assert!(probe
            .measure_output(MeasuredOutput::Pressure, &state)
            .is_none());
    }

    /// Sanity-check the Tsat derivation against TestBackend's R134a table at a
    /// known pressure. R134a at 3 bar saturated → Tsat ≈ 273.4 K on TestBackend
    /// (the table is approximate; we just assert the value is in a sane band).
    #[test]
    fn probe_saturation_temperature_in_sane_band() {
        let mut probe = build_probe(ProbeMeasure::Sst);
        wire_state_indices(&mut probe);
        // 3 bar, h whatever — Tsat only uses P.
        let state = vec![0.05, 3.0e5, 400_000.0, 0.05, 3.0e5, 400_000.0];
        let tsat = probe
            .measure_output(MeasuredOutput::SaturationTemperature, &state)
            .expect("Tsat must resolve");
        assert!(
            (250.0..=320.0).contains(&tsat),
            "Tsat out of sane band: {tsat}"
        );
    }

    #[test]
    fn probe_pressure_and_mass_flow_passthrough() {
        let mut probe = build_probe(ProbeMeasure::P);
        wire_state_indices(&mut probe);
        let state = vec![0.42, 5.0e5, 250_000.0, 0.42, 5.0e5, 250_000.0];
        let p = probe
            .measure_output(MeasuredOutput::Pressure, &state)
            .unwrap();
        assert!((p - 5.0e5).abs() < 1e-6);
        probe.measure = ProbeMeasure::MassFlow;
        let m = probe
            .measure_output(MeasuredOutput::MassFlowRate, &state)
            .unwrap();
        assert!((m - 0.42).abs() < 1e-9);
    }

    #[test]
    fn probe_enthalpy_kind_returns_h_edge() {
        let mut probe = build_probe(ProbeMeasure::Enthalpy);
        wire_state_indices(&mut probe);
        let state = vec![0.1, 3.0e5, 412_300.0, 0.1, 3.0e5, 412_300.0];
        // Enthalpy maps to HeatTransferRate in to_measured_output, but the
        // measure_output override detects the configured Enthalpy kind and
        // returns h_edge regardless.
        let h = probe
            .measure_output(MeasuredOutput::HeatTransferRate, &state)
            .unwrap();
        assert!((h - 412_300.0).abs() < 1e-6);
    }

    #[test]
    fn probe_capacity_is_mass_flow_times_delta_h() {
        let mut probe = build_probe(ProbeMeasure::Capacity);
        wire_state_indices(&mut probe);
        // Inlet h = 420 kJ/kg, outlet h = 400 kJ/kg, ṁ = 0.5 kg/s → Q = 10 kW.
        let state = vec![0.5, 3.0e5, 420_000.0, 0.5, 3.0e5, 400_000.0];
        let q = probe
            .measure_output(MeasuredOutput::Capacity, &state)
            .unwrap();
        assert!((q - 10_000.0).abs() < 1e-6, "expected 10 kW, got {q}");
    }

    #[test]
    fn probe_superheat_subcooling_signs() {
        let backend: Arc<dyn FluidBackend> = Arc::new(TestBackend::new());
        let mk = || {
            Port::new(
                FluidId::new("R134a"),
                Pressure::from_bar(3.0),
                Enthalpy::from_joules_per_kg(400_000.0),
            )
        };
        // Probe with SH measure: should be T_edge − Tsat(P_edge).
        let mut sh_probe = Probe::new(ProbeMeasure::Sh, "R134a", mk(), mk())
            .with_fluid_backend(Arc::clone(&backend))
            .connect(mk(), mk())
            .unwrap();
        wire_state_indices(&mut sh_probe);
        let tsat = sh_probe.tsat_k(3.0e5).expect("tsat resolves");
        // Pick h that gives a known T via TestBackend. We can't know T without
        // querying, so just assert SH and SC are negatives of each other for
        // the same (P, h).
        let state = vec![0.1, 3.0e5, 430_000.0, 0.1, 3.0e5, 430_000.0];
        let sh = sh_probe
            .measure_output(MeasuredOutput::Superheat, &state)
            .expect("SH resolves");

        let mut sc_probe = Probe::new(ProbeMeasure::Sc, "R134a", mk(), mk())
            .with_fluid_backend(Arc::clone(&backend))
            .connect(mk(), mk())
            .unwrap();
        wire_state_indices(&mut sc_probe);
        let sc = sc_probe
            .measure_output(MeasuredOutput::Subcooling, &state)
            .expect("SC resolves");
        assert!(
            (sh + sc).abs() < 1e-6,
            "SH and SC must be opposite signs for same (P,h): SH={sh}, SC={sc}, tsat={tsat}"
        );
    }

    #[test]
    fn probe_to_measured_output_mapping_is_exhaustive() {
        // Every variant maps to a non-panic MeasuredOutput.
        for kind in [
            ProbeMeasure::Sst,
            ProbeMeasure::Sdt,
            ProbeMeasure::Dgt,
            ProbeMeasure::Dsh,
            ProbeMeasure::Sh,
            ProbeMeasure::Sc,
            ProbeMeasure::T,
            ProbeMeasure::P,
            ProbeMeasure::MassFlow,
            ProbeMeasure::Capacity,
            ProbeMeasure::Enthalpy,
        ] {
            let _ = kind.to_measured_output();
        }
    }

    #[test]
    fn probe_signature_and_params_include_measure_and_fluid() {
        let probe = build_probe(ProbeMeasure::Sst);
        let sig = probe.signature();
        assert!(sig.contains("SST"), "signature: {sig}");
        assert!(sig.contains("R134a"));
        let params = probe.to_params();
        assert_eq!(params.component_type, "Probe");
    }
}
