/**
 * Client-side degrees-of-freedom ledger for the diagram UI.
 *
 * Mirrors the solver rule `n_equations == n_unknowns` with a topology-based
 * estimate. This is intentionally a **live design aid**, not a bit-exact copy
 * of `System::dof_report()` (which requires a finalized Rust graph + CoolProp).
 *
 * Counting model (CM1.4 style):
 *   unknowns  ≈ n_mass_branches + 2 × n_edges  (+ free actuators / controls)
 *   equations ≈ Σ component residual estimates (+ control tracking residuals)
 *
 * Fix / Free discipline surfaced to the UI:
 *   - Boundary sources FIX P, h, (ṁ)
 *   - Outlet closures (SH / SC / quality) FIX a thermo state — need a FREE actuator
 *   - Emergent pressure FREES the design-point pressure pin
 *   - Controls: measure FIX paired with actuator FREE
 */

import type { Edge, Node } from "@xyflow/react";
import {
  findSecondaryLoopDofConflicts,
  isBoundaryParamFixed,
} from "./boundaryFix";
import { COMPONENT_BY_TYPE, isParamFixed, isSecondaryPort } from "./componentMeta";
import { CONTROL_NODE_TYPE, type EntropykNodeData } from "./configBuilder";

export type DofBalance = "balanced" | "over-constrained" | "under-constrained" | "empty";

export type FixFreeKind = "fixed" | "free" | "solved" | "parameter" | "risk";

export interface ParamDofTag {
  key: string;
  kind: FixFreeKind;
  label: string;
  hint: string;
}

export interface ComponentDofEstimate {
  name: string;
  type: string;
  nEquations: number;
  roles: string[];
  warnings: string[];
}

export interface DofLedger {
  nEquations: number;
  nUnknowns: number;
  balance: DofBalance;
  delta: number;
  nEdges: number;
  nBranches: number;
  nControls: number;
  components: ComponentDofEstimate[];
  diagnostics: string[];
  /** Human one-liner for the status bar. */
  summary: string;
}

function truthy(v: unknown): boolean {
  return v === true || v === "true" || v === 1 || v === "1";
}

function num(v: unknown, fallback = 0): number {
  if (typeof v === "number" && Number.isFinite(v)) return v;
  if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
  return fallback;
}

/** Edges incident to a node (by React Flow id). */
function incidentEdges(nodeId: string, edges: Edge[]): Edge[] {
  return edges.filter((e) => e.source === nodeId || e.target === nodeId);
}

/** Port handles connected on a node. */
function connectedHandles(nodeId: string, edges: Edge[]): Set<string> {
  const connected = new Set<string>();
  for (const e of edges) {
    if (e.source === nodeId && e.sourceHandle) connected.add(e.sourceHandle);
    if (e.target === nodeId && e.targetHandle) connected.add(e.targetHandle);
  }
  return connected;
}

/** Whether a HX has both secondary ports wired. */
function hasLiveSecondary(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
  const meta = COMPONENT_BY_TYPE[node.data.type];
  if (!meta?.ports.some(isSecondaryPort)) return false;
  const connected = connectedHandles(node.id, edges);
  return connected.has("secondary_inlet") && connected.has("secondary_outlet");
}

/** Generic 4-port HeatExchanger with hot + cold sides fully wired. */
function hasLiveFourPortHx(node: Node<EntropykNodeData>, edges: Edge[]): boolean {
  const connected = connectedHandles(node.id, edges);
  return (
    connected.has("hot_inlet") &&
    connected.has("hot_outlet") &&
    connected.has("cold_inlet") &&
    connected.has("cold_outlet")
  );
}

/**
 * Stream bucket for branch merging at a node.
 * HeatExchanger uses hot_* / cold_* (not secondary_*), so cold must not
 * collapse into the hot refrigerant/water branch.
 */
function streamBucket(handle: string | null | undefined): "hot" | "cold" | "sec" | "ref" {
  const h = String(handle || "").toLowerCase();
  if (!h) return "ref";
  if (isSecondaryPort(h)) return "sec";
  if (h.startsWith("cold_")) return "cold";
  if (h.startsWith("hot_")) return "hot";
  return "ref";
}

function hasOrificeKv(p: Record<string, unknown>): boolean {
  const kv = p.orifice_kv;
  if (kv === undefined || kv === null || kv === "") return false;
  const n = typeof kv === "number" ? kv : Number(kv);
  return Number.isFinite(n) && n > 0;
}

/** Fixed EXV orifice → compressor drops displacement ṁ law (CLI meter_mass_flow_via_exv). */
function circuitHasFixedOrificeExv(nodes: Node<EntropykNodeData>[]): boolean {
  return nodes.some((n) => {
    const t = n.data.type;
    if (t !== "IsenthalpicExpansionValve" && t !== "EXV") return false;
    const p = n.data.params ?? {};
    if (!hasOrificeKv(p)) return false;
    const openMeta = COMPONENT_BY_TYPE[t]?.params.find((x) => x.key === "opening");
    return !openMeta || isParamFixed(p, openMeta);
  });
}

/**
 * Estimate component residual count from type + params + wiring.
 * Conservative and documented; prefer over-counting diagnostics over silent under-count.
 */
export function estimateComponentEquations(
  node: Node<EntropykNodeData>,
  edges: Edge[],
  allNodes: Node<EntropykNodeData>[],
): ComponentDofEstimate {
  const type = String(node.data?.type || node.data?.kind || "");
  const name = String(node.data?.name || node.id || "unnamed");
  const p = node.data.params ?? {};
  const roles: string[] = [];
  const warnings: string[] = [];
  let n = 0;

  const liveSec = hasLiveSecondary(node, edges);
  const emergent = truthy(p.emergent_pressure);
  const skipP = truthy(p.skip_pressure_eq);

  switch (type) {
    case "IsentropicCompressor":
    case "Compressor":
      // series branch: ṁ law + h_dis (mass residual dropped when same-branch).
      // Fixed EXV orifice meters ṁ → compressor energy-only (same-branch).
      if (circuitHasFixedOrificeExv(allNodes)) {
        n = 1;
        roles.push("energy[discharge]");
      } else {
        n = 2;
        roles.push("mass_or_volume_flow", "energy[discharge]");
      }
      break;
    case "ScrewEconomizerCompressor":
      n = 3;
      roles.push("mass", "energy[discharge]", "economizer");
      break;
    case "CentrifugalCompressor":
      n = 2;
      roles.push("mass", "energy[discharge]");
      break;
    case "CapillaryTube":
      n = 2;
      roles.push("mass", "energy[isenthalpic]");
      break;
    case "IsenthalpicExpansionValve":
    case "EXV":
    case "ExpansionValve":
      // emergent: isenthalpic only (1); fixed-P: +P_evap pin (2); orifice: +flow residual
      n = emergent ? 1 : 2;
      roles.push("energy[isenthalpic]");
      if (!emergent) roles.push("boundary[P_evap]");
      if (hasOrificeKv(p)) {
        n += 1;
        roles.push("actuator[orifice]");
      }
      break;
    case "Condenser":
    case "CondenserCoil":
    case "MchxCondenserCoil":
    case "FinCoilCondenser":
    case "FloodedCondenser":
    case "AirCooledCondenser": {
      n = skipP ? 1 : 2; // ΔP + energy
      roles.push(skipP ? "energy[refrigerant]" : "momentum[refrigerant]", "energy[refrigerant]");
      if (emergent) {
        n += 1;
        roles.push("outlet_closure[subcooling]");
      }
      if (liveSec) {
        // Isobaric secondary P + energy (same-branch mass dropped) — Modelica
        // MassFlowSource_T Free P needs HX to propagate sink P to source edge.
        n += 2;
        roles.push("momentum[secondary]", "energy[secondary]");
      } else {
        warnings.push(
          "No live secondary ports — system-mode coupling needs BrineSource→secondary_in→secondary_out→BrineSink",
        );
      }
      if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
        n += 1;
        roles.push("actuator[fan_head_pressure]");
      }
      break;
    }
    case "Evaporator":
    case "EvaporatorCoil": {
      n = skipP ? 1 : 2;
      roles.push(skipP ? "energy[refrigerant]" : "momentum[refrigerant]", "energy[refrigerant]");
      const superheatReg = truthy(p.superheat_regulated);
      if (emergent && !superheatReg) {
        n += 1;
        roles.push("outlet_closure[superheat]");
      }
      if (superheatReg) {
        roles.push("superheat_regulated(drop SH residual)");
        const hasCtrl = allNodes.some(
          (n) =>
            n.data.type === CONTROL_NODE_TYPE &&
            String(n.data.params?.measure_component ?? "") === name,
        );
        if (!hasCtrl) {
          warnings.push(
            "superheat_regulated drops the SH residual — pair with a SaturatedController (EXV opening)",
          );
        }
      }
      if (liveSec) {
        n += 2;
        roles.push("momentum[secondary]", "energy[secondary]");
      } else {
        warnings.push(
          "No live secondary ports — connect a water/brine loop for real-machine energy balance",
        );
      }
      break;
    }
    case "FloodedEvaporator": {
      // ΔP + energy + outlet closure (saturated vapor default, or quality if enabled)
      n = 3;
      roles.push("momentum[refrigerant]", "energy[refrigerant]");
      if (truthy(p.quality_control)) {
        roles.push("outlet_closure[quality]");
        warnings.push(
          "quality_control uses q_target instead of saturated-vapor suction closure — pair with free actuator if this over-constrains controls",
        );
      } else {
        roles.push("outlet_closure[saturated_vapor]");
      }
      if (liveSec) {
        n += 2;
        roles.push("momentum[secondary]", "energy[secondary]");
      } else {
        warnings.push(
          "Flooded system mode requires live secondary edges; scalar secondary_* fields are rating-only",
        );
      }
      break;
    }
    case "BrineSource":
    case "AirSource":
    case "RefrigerantSource": {
      const meta = COMPONENT_BY_TYPE[type];
      const pMeta = meta?.params.find((x) => x.key === "p_set_bar");
      const tMeta = meta?.params.find((x) => x.key === "t_set_c" || x.key === "t_dry_c");
      const mMeta = meta?.params.find((x) => x.key === "m_flow_kg_s");
      const fixP = !pMeta?.fixable || isBoundaryParamFixed(p, pMeta);
      const fixT = !tMeta?.fixable || isBoundaryParamFixed(p, tMeta);
      const fixM = !mMeta?.fixable || isBoundaryParamFixed(p, mMeta);
      n = 0;
      if (fixP) {
        n += 1;
        roles.push("boundary[P]");
      }
      if (fixT) {
        n += 1;
        roles.push("boundary[h]");
      }
      if (
        fixM &&
        p.m_flow_kg_s !== undefined &&
        p.m_flow_kg_s !== "" &&
        num(p.m_flow_kg_s) > 0
      ) {
        n += 1;
        roles.push("boundary[m]");
      }
      break;
    }
    case "BrineSink":
    case "AirSink":
    case "RefrigerantSink": {
      const meta = COMPONENT_BY_TYPE[type];
      const pMeta = meta?.params.find((x) => x.key === "p_back_bar");
      const tMeta = meta?.params.find((x) => x.key === "t_set_c" || x.key === "t_back_c");
      const fixP = !pMeta?.fixable || isBoundaryParamFixed(p, pMeta);
      n = 0;
      if (fixP) {
        n += 1;
        roles.push("boundary[P]");
      }
      const tKey = type === "AirSink" ? "t_back_c" : "t_set_c";
      const tFixed = tMeta
        ? isBoundaryParamFixed(p, tMeta)
        : p[tKey] !== undefined && p[tKey] !== "";
      if (tFixed && p[tKey] !== undefined && p[tKey] !== "") {
        n += 1;
        roles.push("boundary[h]");
      }
      break;
    }
    case "Anchor":
      n = 2;
      roles.push("continuity[P]", "continuity[h]");
      if (p.constraint || p.superheat_k || p.quality || p.t_set_k || p.p_set_pa) {
        n += 1;
        roles.push("outlet_closure[spec]");
        warnings.push("Anchor constraint FIX consumes one DoF — free something elsewhere");
      }
      break;
    case "HeatExchanger":
      // Server 4-port HX contributes 4 residuals when both streams are live
      // (matches System::dof_report for hx_air_water_4port).
      if (hasLiveFourPortHx(node, edges)) {
        n = 4;
        roles.push(
          "momentum[hot]",
          "energy[hot]",
          "momentum[cold]",
          "energy[cold]",
        );
      } else {
        n = 2;
        roles.push("energy[side_a]", "energy[side_b]");
        warnings.push(
          "HeatExchanger needs hot_in/out + cold_in/out wired for a 4-eq system residual",
        );
      }
      break;
    case "BphxEvaporator":
    case "BphxCondenser":
    case "Economizer":
    case "FreeCoolingExchanger": {
      // Live secondary → 4-port energy/momentum; emergent_pressure adds SH/SC closure.
      n = liveSec ? 4 : 2;
      roles.push(
        ...(liveSec
          ? ["momentum[primary]", "energy[primary]", "momentum[secondary]", "energy[secondary]"]
          : ["energy[side_a]", "energy[side_b]"]),
      );
      if (!liveSec) {
        warnings.push(
          "No live secondary ports — wire Source→secondary_in→secondary_out→Sink",
        );
      }
      if (
        (type === "BphxEvaporator" || type === "BphxCondenser") &&
        truthy(p.emergent_pressure)
      ) {
        n += 1;
        roles.push(
          type === "BphxCondenser"
            ? "outlet_closure[subcooling]"
            : "outlet_closure[superheat]",
        );
      }
      break;
    }
    case "Pump":
    case "Fan":
      n = 2;
      roles.push("momentum", "energy");
      break;
    case "Pipe":
    case "RefrigerantPipe":
    case "WaterPipe":
    case "AirDuct":
    case "PipeWater":
    case "PipeAir":
    case "ReversingValve":
    case "BypassValve": {
      // Unwired 2-port parts must not inflate the ledger — an orphan pipe
      // (+2 eqs, +0 edges) hard-blocks Simulate as "over-constrained".
      const degree = incidentEdges(node.id, edges).length;
      if (degree < 2) {
        n = 0;
        roles.push("unconnected");
        warnings.push(
          degree === 0
            ? "not connected — drop on a matching wire or connect inlet+outlet"
            : "partially connected (need inlet + outlet)",
        );
      } else {
        n = 2;
        roles.push("momentum", "energy");
      }
      break;
    }
    case "Drum":
      n = 4;
      roles.push("mass", "energy", "level_or_volume", "outlet");
      break;
    case "ThermalLoad":
    case "HeatSource":
      n = 2;
      roles.push("mass", "energy");
      break;
    case "FlowSplitter":
    case "FlowMerger":
      n = 2;
      roles.push("mass", "pressure");
      break;
    case "Placeholder":
      n = Math.max(0, Math.floor(num(p.n_equations, 2)));
      roles.push(`placeholder×${n}`);
      break;
    case CONTROL_NODE_TYPE:
      // Controller residuals are counted at system level (2 per loop).
      n = 0;
      roles.push("control(system-level)");
      break;
    default:
      n = 2;
      roles.push("unspecified×2");
      warnings.push(`No DoF template for type '${type}' — assumed 2 equations`);
  }

  // Series refrigerant/secondary: same-branch drops mass residual — already assumed
  // for 2-port chain components above.

  return { name, type, nEquations: n, roles, warnings };
}

/**
 * Estimate mass-flow branches: connected components of edges that share a series
 * path (1-in/1-out through non-junction nodes). Approximate with undirected edge
 * connectivity partitioned by fluid "kind" (secondary vs refrigerant) via handles.
 */
function estimateBranches(nodes: Node<EntropykNodeData>[], edges: Edge[]): number {
  if (edges.length === 0) return 0;

  // Union-Find over edges: two edges share a branch if they meet at a 1-in/1-out
  // component on matching stream (both secondary or both primary).
  const parent = new Map<string, string>();
  const find = (x: string): string => {
    let p = parent.get(x) ?? x;
    while (parent.get(p) && parent.get(p) !== p) p = parent.get(p)!;
    parent.set(x, p);
    return p;
  };
  const unite = (a: string, b: string) => {
    const ra = find(a);
    const rb = find(b);
    if (ra !== rb) parent.set(ra, rb);
  };
  for (const e of edges) parent.set(e.id, e.id);

  const nodeById = new Map(nodes.map((n) => [n.id, n]));

  for (const n of nodes) {
    if (n.data.type === CONTROL_NODE_TYPE) continue;
    const inc = incidentEdges(n.id, edges);
    // Group incident edges by stream class (ref / hot / cold / secondary).
    const buckets = new Map<string, Edge[]>();
    for (const e of inc) {
      const handle = e.source === n.id ? e.sourceHandle : e.targetHandle;
      const key = streamBucket(handle);
      const list = buckets.get(key) ?? [];
      list.push(e);
      buckets.set(key, list);
    }
    // Series: exactly 2 edges on a stream → same ṁ branch
    for (const group of buckets.values()) {
      if (group.length === 2) unite(group[0].id, group[1].id);
    }

    void nodeById;
  }

  const roots = new Set<string>();
  for (const e of edges) roots.add(find(e.id));
  return roots.size;
}

/** Count free actuators implied by the diagram (orifice, fan head-pressure, controls). */
function estimateExtraUnknowns(nodes: Node<EntropykNodeData>[]): {
  freeActuators: number;
  saturatedPairs: number;
  controlDiagnostics: string[];
} {
  let freeActuators = 0;
  let saturatedPairs = 0;
  const controlDiagnostics: string[] = [];

  for (const n of nodes) {
    const p = n.data.params ?? {};
    if (
      (n.data.type === "IsenthalpicExpansionValve" || n.data.type === "EXV") &&
      hasOrificeKv(p)
    ) {
      const openMeta = COMPONENT_BY_TYPE[n.data.type]?.params.find((x) => x.key === "opening");
      // Free opening only — Fixed opening is a parameter (not an unknown).
      if (openMeta && !isParamFixed(p, openMeta)) {
        freeActuators += 1;
      }
    }
    if (p.fan_head_pressure_target_c !== undefined && p.fan_head_pressure_target_c !== "") {
      freeActuators += 1;
    }
    if (n.data.type === CONTROL_NODE_TYPE) {
      saturatedPairs += 1; // (u, x) + 2 residuals → neutral if paired
    }
  }

  // Orphan quality_control without free actuator
  for (const n of nodes) {
    if (n.data.type === "FloodedEvaporator" && truthy(n.data.params?.quality_control)) {
      if (freeActuators + saturatedPairs === 0) {
        controlDiagnostics.push(
          `${n.data.name}: quality_control FIX without free actuator → over-constrained risk`,
        );
      }
    }
  }

  return { freeActuators, saturatedPairs, controlDiagnostics };
}

/**
 * Build a full client-side DoF ledger for the current diagram.
 */
export function computeDofLedger(
  nodes: Node<EntropykNodeData>[],
  edges: Edge[],
): DofLedger {
  if (nodes.length === 0) {
    return {
      nEquations: 0,
      nUnknowns: 0,
      balance: "empty",
      delta: 0,
      nEdges: 0,
      nBranches: 0,
      nControls: 0,
      components: [],
      diagnostics: [],
      summary: "No components — empty system",
    };
  }

  const physNodes = nodes.filter((n) => n.data.type !== CONTROL_NODE_TYPE);
  const components = physNodes.map((n) => estimateComponentEquations(n, edges, nodes));
  let nEquations = components.reduce((s, c) => s + c.nEquations, 0);

  const nBranches = estimateBranches(nodes, edges);
  const nEdges = edges.length;
  // unknowns: ṁ per branch + (P,h) per edge
  let nUnknowns = nBranches + 2 * nEdges;

  const { freeActuators, saturatedPairs, controlDiagnostics } = estimateExtraUnknowns(nodes);
  nUnknowns += freeActuators + 2 * saturatedPairs;
  nEquations += 2 * saturatedPairs; // saturated PI: +2 residuals per loop

  const nControls = nodes.filter((n) => n.data.type === CONTROL_NODE_TYPE).length;

  const diagnostics: string[] = [...controlDiagnostics];
  for (const c of components) {
    for (const w of c.warnings) diagnostics.push(`${c.name}: ${w}`);
  }

  const delta = nEquations - nUnknowns;
  let balance: DofBalance;
  if (nEquations === nUnknowns) balance = "balanced";
  else if (nEquations > nUnknowns) balance = "over-constrained";
  else balance = "under-constrained";

  for (const c of findSecondaryLoopDofConflicts(physNodes, edges)) {
    diagnostics.push(c.message);
  }

  if (balance === "over-constrained") {
    diagnostics.unshift(
      `Over-constrained by ${delta}: remove a FIX (outlet closure / quality / boundary) or FREE an actuator`,
    );
  } else if (balance === "under-constrained") {
    diagnostics.unshift(
      `Under-constrained by ${-delta}: add a residual (emergent closure, boundary) or remove a free unknown`,
    );
  }

  const summary =
    balance === "balanced"
      ? `DoF balanced · ${nEquations} eqs = ${nUnknowns} unk`
      : balance === "over-constrained"
        ? `DoF OVER · ${nEquations} eqs > ${nUnknowns} unk (+${delta})`
        : `DoF UNDER · ${nEquations} eqs < ${nUnknowns} unk (${delta})`;

  return {
    nEquations,
    nUnknowns,
    balance,
    delta,
    nEdges,
    nBranches,
    nControls,
    components,
    diagnostics,
    summary,
  };
}

/**
 * Classify a parameter as fixed / free / solved / plain for the properties panel.
 */
export function classifyParamDof(
  type: string,
  key: string,
  params: Record<string, number | string | boolean>,
  opts?: { hasLiveSecondary?: boolean; hasControl?: boolean },
): ParamDofTag | null {
  const emergent = truthy(params.emergent_pressure);
  const hasLive = opts?.hasLiveSecondary ?? false;

  // Shared patterns
  if (key === "ua" || key === "z_ua" || key === "z_dp") {
    return {
      key,
      kind: "parameter",
      label: "param",
      hint: "Model parameter (not a Newton unknown unless inverse-calibrated)",
    };
  }

  if (
    key === "t_sat_k" ||
    key === "t_cond_k" ||
    key === "t_evap_k"
  ) {
    if (emergent) {
      return {
        key,
        kind: "solved",
        label: "seed",
        hint: "Initialization / design guess only — pressure is SOLVED (emergent)",
      };
    }
    return {
      key,
      kind: "fixed",
      label: "FIX",
      hint: "Imposes a design-point pressure (fixed-P mode). Prefer emergent_pressure for real machines.",
    };
  }

  if (key === "emergent_pressure") {
    return {
      key,
      kind: "free",
      label: "FREE P",
      hint: "When ON, condensing/evaporating pressure is free (solved from secondary balance)",
    };
  }

  if (key === "superheat_k" || key === "subcooling_k") {
    if (key === "superheat_k" && truthy(params.superheat_regulated)) {
      return {
        key,
        kind: "solved",
        label: "target",
        hint: "Superheat residual dropped — closed by EXV controller (FREE opening)",
      };
    }
    if (emergent) {
      return {
        key,
        kind: "fixed",
        label: "FIX",
        hint: "Outlet closure residual (+1 eq). Consumes one DoF; OK when pressure is free.",
      };
    }
    return {
      key,
      kind: "parameter",
      label: "param",
      hint: "Used with fixed-pressure mode",
    };
  }

  if (key === "superheat_regulated") {
    return {
      key,
      kind: opts?.hasControl ? "free" : "risk",
      label: opts?.hasControl ? "pair OK" : "needs FREE",
      hint: "Drops SH residual (−1 eq). Must pair with SaturatedController → EXV opening.",
    };
  }

  if (key === "quality_control") {
    return {
      key,
      kind: truthy(params.quality_control) ? "risk" : "parameter",
      label: truthy(params.quality_control) ? "FIX +1" : "off",
      hint: "When ON, adds quality residual. Pair with free actuator or leave OFF for suction models.",
    };
  }

  if (key === "target_quality") {
    return {
      key,
      kind: truthy(params.quality_control) ? "fixed" : "parameter",
      label: truthy(params.quality_control) ? "FIX set" : "unused",
      hint: "Only active if quality_control is ON (costs +1 DoF)",
    };
  }

  if (
    key.startsWith("secondary_inlet_temp") ||
    key.startsWith("secondary_mass_flow") ||
    key.startsWith("secondary_capacity") ||
    key === "secondary_cp_j_per_kgk"
  ) {
    if (hasLive) {
      return {
        key,
        kind: "parameter",
        label: "rating",
        hint: "Live secondary edges present — scalars are rating-only (ignored in system residual path)",
      };
    }
    return {
      key,
      kind: "risk",
      label: "scalar",
      hint: "Scalar secondary is NOT a water-loop unknown. Wire BrineSource/Sink to secondary ports for real machines.",
    };
  }

  if (
    type.endsWith("Source") &&
    (key === "t_set_c" || key === "p_set_bar" || key === "m_flow_kg_s" || key === "t_dry_c")
  ) {
    const meta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === key);
    const fixed = !meta?.fixable || isBoundaryParamFixed(params, meta);
    return {
      key,
      kind: fixed ? "fixed" : "free",
      label: fixed ? "FIX" : "FREE",
      hint: fixed
        ? "Boundary Dirichlet — machine input (correct place to fix T/ṁ/P)"
        : "Free at source — Modelica Boundary_pT; pair with Fixed T_out on Sink",
    };
  }

  if (type.endsWith("Sink") && (key === "p_back_bar" || key === "t_set_c" || key === "t_back_c")) {
    const meta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === key);
    const fixed = meta ? isBoundaryParamFixed(params, meta) : params[key] !== undefined;
    return {
      key,
      kind: fixed ? "fixed" : "free",
      label: fixed ? "FIX" : "FREE",
      hint:
        key === "p_back_bar"
          ? fixed
            ? "Back-pressure fixed"
            : "Free back-pressure — unusual; ensure another P anchor exists"
          : fixed
            ? "Fixed T_out — consumes DoF (pair with Free ṁ on Source)"
            : "T_out free (solved from energy)",
    };
  }

  if (key === "opening" && (type === "IsenthalpicExpansionValve" || type === "EXV")) {
    if (!hasOrificeKv(params)) {
      return {
        key,
        kind: "parameter",
        label: "ignored",
        hint: "Sans orifice_kv, opening n’entre pas dans les résidus — ajoute Kv pour un débit physique",
      };
    }
    const openMeta = COMPONENT_BY_TYPE[type]?.params.find((x) => x.key === "opening");
    const fixed = !openMeta || isParamFixed(params, openMeta);
    return {
      key,
      kind: fixed ? "fixed" : "free",
      label: fixed ? "FIX" : "FREE",
      hint: fixed
        ? "Opening Fixed — orifice ṁ = Kv·opening·√(2ρΔP); le compresseur lâche la loi de ṁ"
        : "Opening FREE — inconnue solver (régulation / SaturatedController)",
    };
  }

  if (key === "opening" || key === "fan_speed" || key === "speed_hz") {
    return {
      key,
      kind: "free",
      label: "FREE?",
      hint: "Actuator candidate — free when co-solved by a control loop / free actuator residual",
    };
  }

  return null;
}
