/**
 * Convert a React Flow graph (nodes + edges) into the ScenarioConfig JSON
 * expected by the Entropyk CLI / API (crates/cli/src/config.rs).
 *
 * ScenarioConfig schema:
 *   {
 *     "fluid": "R410A",
 *     "fluid_backend": "CoolProp",
 *     "circuits": [
 *       {
 *         "id": 0,
 *         "components": [ { "type": "...", "name": "...", ...params } ],
 *         "edges": [ { "from": "comp:outlet", "to": "cond:inlet" } ]
 *       }
 *     ],
 *     "thermal_couplings": [ { "hot_circuit": 0, "cold_circuit": 1, "ua": 6000, "efficiency": 0.95 } ],
 *     "solver": { "strategy": "newton", "max_iterations": 300, "tolerance": 1e-6 }
 *   }
 */

import type { Edge, Node } from "@xyflow/react";
import {
  enforceModelicaBoundaryEmit,
  findConnectedSecondaryBoundary,
  isBoundaryParamFixed,
} from "./boundaryFix";
import {
  COMPONENT_BY_TYPE,
  FIXED_FLAG_PREFIX,
  isParamFixed,
  isSecondaryPort,
  type ParamMeta,
} from "./componentMeta";

// Re-export for existing imports (PropertiesPanel, dofLedger, tests).
export { findConnectedSecondaryBoundary } from "./boundaryFix";

export interface EntropykNodeData {
  type: string; // Entropyk component type ("Condenser", ...)
  name: string; // unique name within circuit
  circuit: number;
  params: Record<string, number | string | boolean>;
  [key: string]: unknown;
}

export interface ControlConfig {
  type?: string;
  id: string;
  measure: { component: string; output: string };
  actuator: { component: string; factor: string; initial?: number; min: number; max: number };
  target: number;
  gain?: number;
  band?: number;
  smooth_eps?: number;
  objectives?: ControlObjectiveConfig[];
  alpha?: number;
}

/** Modelica Z-factor embedding (unknown + equation). Not a SaturatedController. */
export interface EmbeddingConfig {
  id: string;
  unknown: {
    component: string;
    factor: string;
    start: number;
    min: number;
    max: number;
  };
  equation: {
    component: string;
    output: string;
    value: number;
  };
}

export interface ControlObjectiveConfig {
  component: string;
  output: string;
  setpoint: number;
  gain: number;
  combine: "min" | "max";
}

export interface SubsystemTemplate {
  params?: Record<string, number | string | boolean>;
  components: Array<Record<string, unknown>>;
  edges?: Array<{ from: string; to: string }>;
  ports?: Record<string, string>;
}

export interface InstanceConfig {
  of: string;
  name: string;
  circuit?: number;
  params?: Record<string, number | string | boolean>;
}

export interface ScenarioConfig {
  /**
   * Model IR schema version. "1" is the legacy flat circuits/components/edges
   * graph; "2" adds controls/subsystems/instances/connections. The web UI emits
   * the current version so the CLI and every consumer read one unified IR.
   */
  schema_version?: string;
  name?: string;
  fluid: string;
  fluid_backend?: string;
  circuits: Array<{
    id: number;
    name?: string;
    components: Array<Record<string, unknown>>;
    edges: Array<{ from: string; to: string }>;
  }>;
  thermal_couplings?: Array<{
    hot_circuit: number;
    cold_circuit: number;
    ua: number;
    efficiency: number;
  }>;
  /** System regulation loops (EXV/injection). Mirrors CLI `controls`. */
  controls?: ControlConfig[];
  /** Modelica Z-factor embeddings (unknown + equation). Mirrors CLI `embeddings`. */
  embeddings?: EmbeddingConfig[];
  /** Reusable subsystem templates (flattened by the CLI at load time). */
  subsystems?: Record<string, SubsystemTemplate>;
  /** Template instantiations. */
  instances?: InstanceConfig[];
  /** External connections between instance ports. */
  connections?: Array<{ from: string; to: string }>;
  solver: {
    strategy: string;
    max_iterations: number;
    tolerance: number;
  };
}

/** The Model IR schema version emitted by this UI build (kept in sync with the CLI). */
export const SCHEMA_VERSION = "2";
export const CONTROL_NODE_TYPE = "SaturatedController";

/**
 * Legacy auto-calib control ids (pre-embeddings). Still purged from canvas if present.
 */
export function isAutoCalibrationControlId(id: string): boolean {
  return id.startsWith("calib_") || id.startsWith("emb_");
}

export interface BuildOptions {
  fluid?: string;
  fluidBackend?: string;
  solverStrategy?: string;
  maxIterations?: number;
  tolerance?: number;
  thermalCouplings?: Array<{
    hot_circuit: number;
    cold_circuit: number;
    ua: number;
    efficiency: number;
  }>;
  /** Steady-state control loops to co-solve (emitted verbatim into the IR). */
  controls?: ControlConfig[];
}

const PARAM_ALIASES: Record<string, string[]> = {
  t_set_c: ["temperature_c", "T"],
  m_flow_kg_s: ["mass_flow_kg_s", "mass_flow"],
  rh: ["relative_humidity"],
  p_set_bar: ["pressure_bar"],
  p_back_bar: ["pressure_bar"],
  dp_correlation: ["DpCorrelation", "dpCorrelation"],
};

function getParam(
  params: Record<string, number | string | boolean>,
  key: string,
): number | string | boolean | undefined {
  if (params[key] !== undefined) return params[key];
  for (const alias of PARAM_ALIASES[key] ?? []) {
    if (params[alias] !== undefined) return params[alias];
  }
  return undefined;
}

export function canonicalizeParams(
  type: string,
  params: Record<string, number | string | boolean>,
): Record<string, number | string | boolean> {
  const next = { ...params };
  if (type === "BrineSource") {
    const t = getParam(next, "t_set_c");
    const m = getParam(next, "m_flow_kg_s");
    const p = getParam(next, "p_set_bar");
    if (t !== undefined) next.t_set_c = t;
    if (m !== undefined) next.m_flow_kg_s = m;
    if (p !== undefined) next.p_set_bar = p;
  }
  if (type === "AirSource") {
    const t = getParam(next, "t_dry_c") ?? getParam(next, "t_set_c");
    const m = getParam(next, "m_flow_kg_s");
    const p = getParam(next, "p_set_bar");
    const rh = getParam(next, "rh");
    if (t !== undefined) next.t_dry_c = t;
    if (m !== undefined) next.m_flow_kg_s = m;
    if (p !== undefined) next.p_set_bar = p;
    if (rh !== undefined) next.rh = rh;
  }
  if (type === "BrineSink" || type === "AirSink" || type === "RefrigerantSink") {
    const p = getParam(next, "p_back_bar");
    if (p !== undefined) next.p_back_bar = p;
  }
  return next;
}

/**
 * Each React Flow edge carries the source/target port id on its handle.
 * The CLI expects "componentName:port" strings, so we translate handle ids
 * (which are port names like "outlet") into "name:port".
 *
 * When the handle is missing, fall back by role: sources use an outlet-like
 * port, targets an inlet-like port — never both ends as `ports[0]` (inlet),
 * which breaks pipe splice / manual wires.
 */
function edgeRef(
  node: Node<EntropykNodeData> | undefined,
  handleId: string | null | undefined,
  role: "source" | "target" = "target",
): string {
  if (!node) return "";
  const meta = COMPONENT_BY_TYPE[node.data.type];
  const ports = meta?.ports ?? [];
  if (handleId && ports.includes(handleId)) {
    return `${node.data.name}:${handleId}`;
  }
  const port =
    role === "source"
      ? ports.find((p) => /outlet|discharge|out$/i.test(p)) ??
        ports[ports.length - 1] ??
        "outlet"
      : ports.find((p) => /inlet|suction|^in$/i.test(p)) ?? ports[0] ?? "inlet";
  return `${node.data.name}:${port}`;
}

/** A boundary node (Source/Sink) supplies/absorbs a secondary stream. */
function isBoundaryNode(node: Node<EntropykNodeData> | undefined): boolean {
  return !!node && /(?:Source|Sink)$/.test(node.data.type);
}

export interface SecondaryResolution {
  /** Legacy compatibility: secondary streams are no longer reduced into hidden params. */
  overrides: Map<string, Record<string, number>>;
  /** Legacy compatibility: boundary nodes are preserved as explicit solver components. */
  absorbed: Set<string>;
}

export function resolveSecondaryStreams(
  nodes: Node<EntropykNodeData>[],
  edges: Edge[],
): SecondaryResolution {
  void nodes;
  void edges;
  return { overrides: new Map(), absorbed: new Set() };
}

export function buildScenarioConfig(
  nodes: Node<EntropykNodeData>[],
  edges: Edge[],
  options: BuildOptions = {},
): ScenarioConfig {
  const nodeById = new Map<string, Node<EntropykNodeData>>();
  for (const n of nodes) nodeById.set(n.id, n);

  // Separate ModuleInstance nodes from flat atomic components
  const instances: InstanceConfig[] = [];
  const connections: Array<{ from: string; to: string }> = [];

  const moduleNodes = nodes.filter((n) => n.data.type === "ModuleInstance");
  moduleNodes.forEach((n) => {
    const moduleName = String(n.data.params.module_name ?? "");
    if (!moduleName) return;

    const params: Record<string, number | string | boolean> = {};
    for (const [k, v] of Object.entries(n.data.params)) {
      if (k !== "module_name" && k !== "module_ports" && typeof v !== "undefined") {
        params[k] = v;
      }
    }

    instances.push({
      of: moduleName,
      name: n.data.name,
      circuit: n.data.circuit ?? 0,
      ...(Object.keys(params).length > 0 ? { params } : {}),
    });
  });

  // Group non-module component nodes by circuit id.
  const circuitsMap = new Map<number, Node<EntropykNodeData>[]>();
  for (const n of nodes) {
    if (n.data.type === "ModuleInstance") continue;
    const c = n.data?.circuit ?? 0;
    if (!circuitsMap.has(c)) circuitsMap.set(c, []);
    circuitsMap.get(c)!.push(n);
  }

  // Ensure circuit 0 exists if empty
  if (circuitsMap.size === 0) {
    circuitsMap.set(0, []);
  }

  const circuits = Array.from(circuitsMap.entries())
    .sort(([a], [b]) => a - b)
    .map(([circuitId, cNodes]) => {
      // Stable name order — React Flow node array order changes on drag/select.
      const components = cNodes
        .filter((n) => n.data.type !== CONTROL_NODE_TYPE)
        .slice()
        .sort((a, b) => a.data.name.localeCompare(b.data.name))
        .map((n) => {
          const { type, name, params } = n.data;
          const canonicalParams = canonicalizeParams(type, params);
          const cleaned = stripUiOnlyParams(type, canonicalParams);
          return { type, name, ...cleaned } as Record<string, unknown>;
        });

      // Pure circuit edges (neither endpoint is a ModuleInstance)
      const circuitEdges = edges.filter((e) => {
        const s = nodeById.get(e.source);
        const t = nodeById.get(e.target);
        if (!s || !t) return false;
        if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") return false;
        if (s.data.circuit !== circuitId || t.data.circuit !== circuitId) return false;
        return true;
      });

      const edgeConfigs = circuitEdges.map((e) => ({
        from: edgeRef(nodeById.get(e.source), e.sourceHandle, "source"),
        to: edgeRef(nodeById.get(e.target), e.targetHandle, "target"),
      }));

      enforceModelicaBoundaryEmit(components, cNodes, circuitEdges);

      return { id: circuitId, name: `Circuit ${circuitId}`, components, edges: edgeConfigs };
    });

  // Collect edges connected to ModuleInstances as `connections`
  edges.forEach((e) => {
    const s = nodeById.get(e.source);
    const t = nodeById.get(e.target);
    if (!s || !t) return;
    if (s.data.type === "ModuleInstance" || t.data.type === "ModuleInstance") {
      const fromRef =
        s.data.type === "ModuleInstance"
          ? `${s.data.name}.${e.sourceHandle ?? "outlet"}`
          : edgeRef(s, e.sourceHandle, "source");

      const toRef =
        t.data.type === "ModuleInstance"
          ? `${t.data.name}.${e.targetHandle ?? "inlet"}`
          : edgeRef(t, e.targetHandle, "target");

      connections.push({ from: fromRef, to: toRef });
    }
  });

  // Explicit Advanced-palette regulation controllers only (EXV/injection).
  // Fixed/Free Z-factors → embeddings[] (never controls[]/SaturatedController).
  const nodeControls = nodes
    .filter(
      (node) =>
        node.data.type === CONTROL_NODE_TYPE &&
        !isAutoCalibrationControlId(node.data.name),
    )
    .map(controlNodeToConfig);
  const controls = mergeControls(options.controls ?? [], nodeControls);
  const embeddings = buildModelEmbeddings(nodes, edges);

  return {
    schema_version: SCHEMA_VERSION,
    fluid: options.fluid || "R410A",
    fluid_backend: options.fluidBackend || "CoolProp",
    circuits,
    ...(instances.length > 0 ? { instances } : {}),
    ...(connections.length > 0 ? { connections } : {}),
    thermal_couplings: options.thermalCouplings || [],
    ...(controls.length > 0 ? { controls } : {}),
    ...(embeddings.length > 0 ? { embeddings } : {}),
    solver: {
      strategy: options.solverStrategy || "newton",
      max_iterations: options.maxIterations ?? 300,
      tolerance: options.tolerance ?? 1e-6,
    },
  };
}

/**
 * Remove UI-only keys before sending to the CLI:
 * - `__fixed_*` Fixed checkbox flags
 * - measure-only targets (e.g. Probe `target`) — they become control setpoints
 * - emit Modelica-style `fix_pressure` / `fix_temperature` / `fix_mass_flow`
 * - BPHX only: when Z_UA is Free, omit absolute `ua` override (geometry = UA_nominal)
 * - fill missing *required* params from catalogue defaults (e.g. FloodedEvaporator.ua)
 */
export function stripUiOnlyParams(
  type: string,
  params: Record<string, number | string | boolean>,
): Record<string, number | string | boolean> {
  const meta = COMPONENT_BY_TYPE[type];
  const measureOnly = new Set(
    (meta?.params ?? [])
      .filter((p) => p.measureOutput && !p.actuatorFactor)
      .map((p) => p.key),
  );
  const out: Record<string, number | string | boolean> = {};
  for (const [k, v] of Object.entries(params)) {
    if (k.startsWith(FIXED_FLAG_PREFIX)) continue;
    if (measureOnly.has(k)) continue;
    // Omit blank optional numerics (e.g. unset orifice_kv).
    if (v === "" || v === null || v === undefined) continue;
    out[k] = v;
  }

  // Free z_ua on BPHX: drop absolute `ua` override (geometry supplies UA_nominal).
  // Legacy Condenser / Evaporator / Flooded*: keep `ua` — constructor nominal.
  const zUaMeta = meta?.params.find((p) => p.key === "z_ua" && p.actuatorFactor === "z_ua");
  const bphxUaIsOverride =
    type === "BphxCondenser" || type === "BphxEvaporator" || type === "BphxExchanger";
  if (bphxUaIsOverride && zUaMeta && !isParamFixed(params, zUaMeta)) {
    delete out.ua;
  }

  // Required catalogue defaults (safety net when the canvas field was cleared).
  for (const p of meta?.params ?? []) {
    if (!p.required || p.default === undefined) continue;
    if (bphxUaIsOverride && p.key === "ua") continue;
    const cur = out[p.key];
    if (cur === undefined || cur === "") {
      out[p.key] = p.default;
    }
  }

  return applyExvFixSemantics(type, applyBoundaryFixSemantics(type, out, params), params);
}

const EXV_TYPES = new Set(["IsenthalpicExpansionValve", "EXV"]);

/**
 * Emit `fix_opening` when orifice_kv is set so the CLI can choose fixed vs
 * free orifice (never infer orifice from opening alone).
 */
export function applyExvFixSemantics(
  type: string,
  cleaned: Record<string, number | string | boolean>,
  rawParams: Record<string, number | string | boolean>,
): Record<string, number | string | boolean> {
  if (!EXV_TYPES.has(type)) return cleaned;
  const kv = cleaned.orifice_kv;
  const kvNum = typeof kv === "number" ? kv : Number(kv);
  if (!Number.isFinite(kvNum) || kvNum <= 0) {
    const out: Record<string, number | string | boolean> = { ...cleaned };
    delete out.orifice_kv;
    delete out.fix_opening;
    return out;
  }
  const meta = COMPONENT_BY_TYPE[type];
  const openMeta = meta?.params.find((p) => p.key === "opening");
  const out: Record<string, number | string | boolean> = { ...cleaned, orifice_kv: kvNum };
  if (openMeta?.fixable) {
    out.fix_opening = isParamFixed(rawParams, openMeta);
  } else {
    out.fix_opening = true;
  }
  return out;
}

const BOUNDARY_FIX_TYPES = new Set([
  "BrineSource",
  "BrineSink",
  "AirSource",
  "AirSink",
]);

/**
 * Translate UI Fixed checkboxes into CLI `fix_*` flags for boundary nodes.
 * Free sink temperatures omit the Dirichlet key so legacy configs stay valid.
 */
export function applyBoundaryFixSemantics(
  type: string,
  cleaned: Record<string, number | string | boolean>,
  rawParams: Record<string, number | string | boolean>,
): Record<string, number | string | boolean> {
  if (!BOUNDARY_FIX_TYPES.has(type)) return cleaned;
  const meta = COMPONENT_BY_TYPE[type];
  if (!meta) return cleaned;
  const out = { ...cleaned };

  const pMeta = meta.params.find((p) => p.key === "p_set_bar" || p.key === "p_back_bar");
  const tMeta = meta.params.find(
    (p) => p.key === "t_set_c" || p.key === "t_dry_c" || p.key === "t_back_c",
  );
  const mMeta = meta.params.find((p) => p.key === "m_flow_kg_s");

  if (pMeta?.fixable) {
    out.fix_pressure = isBoundaryParamFixed(rawParams, pMeta);
  }
  if (tMeta?.fixable) {
    const fixedT = isBoundaryParamFixed(rawParams, tMeta);
    out.fix_temperature = fixedT;
    // Free T on sinks: omit the setpoint so CLI does not impose h (legacy presence rule).
    if (!fixedT && (type === "BrineSink" || type === "AirSink")) {
      delete out.t_set_c;
      delete out.t_back_c;
    }
  }
  if (mMeta?.fixable) {
    out.fix_mass_flow = isBoundaryParamFixed(rawParams, mMeta);
  }

  delete out.delta_t_k;

  return out;
}

/**
 * Build Modelica `embeddings[]`: Free Z-factor (unknown) + Fixed Probe (equation).
 *
 * Pairing (free factor → Probe Fixed physical params):
 *   z_ua    → Tsat, Tsh
 *   z_dp    → P
 *   z_flow  → Capacity
 *   f_w     → T
 *   z_power → Capacity
 *   z_etav  → Capacity
 *   opening → Tsh
 *
 * Never emits SaturatedController / controls[].
 */
export function buildModelEmbeddings(
  nodes: Node<EntropykNodeData>[],
  edges: Edge[] = [],
): EmbeddingConfig[] {
  type ProbeMeasure = {
    nodeName: string;
    kind: string;
    output: string;
    target: number;
    factorCompat: readonly string[];
  };
  type FreeAct = {
    factor: string;
    initial: number;
    min: number;
    max: number;
    key: string;
  };

  const embeddings: EmbeddingConfig[] = [];
  const probeMeasures: ProbeMeasure[] = [];
  const freeActsByComponent = new Map<
    string,
    { nodeName: string; node: Node<EntropykNodeData>; acts: FreeAct[] }
  >();

  for (const node of nodes) {
    if (node.data.type === CONTROL_NODE_TYPE) continue;
    const meta = COMPONENT_BY_TYPE[node.data.type];
    if (!meta) continue;
    const params = node.data.params ?? {};

    if (node.data.type === "Probe") {
      let emitted = 0;
      for (const p of meta.params) {
        if (!p.fixable || !p.measureOutput) continue;
        if (!isParamFixed(params, p)) continue;
        const raw = params[p.key];
        const n = typeof raw === "number" ? raw : Number(raw);
        if (!Number.isFinite(n)) continue;
        const kind = PROBE_PARAM_KIND[p.key] ?? p.key;
        let target = measureSetpointSi(p, n);
        if (p.measureOutput === "pressure" && (p.unit ?? "").toLowerCase() === "bar") {
          target = n * 1e5;
        }
        probeMeasures.push({
          nodeName: node.data.name,
          kind,
          output: p.measureOutput === "auto" ? "temperature" : p.measureOutput,
          target,
          factorCompat: FACTOR_COMPATIBILITY[kind] ?? [],
        });
        emitted += 1;
      }
      if (emitted === 0) {
        const legacy = legacyProbeMeasure(params);
        if (legacy) {
          probeMeasures.push({
            nodeName: node.data.name,
            kind: legacy.kind,
            output: legacy.output,
            target: legacy.target,
            factorCompat: FACTOR_COMPATIBILITY[legacy.kind] ?? [],
          });
        }
      }
      continue;
    }

    const freeActs: FreeAct[] = [];
    for (const p of meta.params) {
      if (!p.fixable) continue;
      const fixed = isParamFixed(params, p);
      const raw = params[p.key];

      if (p.actuatorFactor && !fixed) {
        const n = typeof raw === "number" ? raw : Number(raw);
        // Keep the UI value as Newton start (do not rewrite 1.0 → 0.3 — that
        // pushed Free Z into a fragile basin and amplified HashMap-order flakes).
        const initial = Number.isFinite(n) ? n : 1.0;
        freeActs.push({
          factor: p.actuatorFactor,
          initial,
          min: p.freeMin ?? 0.1,
          max: p.freeMax ?? 3.0,
          key: p.key,
        });
      }
    }

    if (freeActs.length > 0) {
      freeActs.sort((a, b) => a.factor.localeCompare(b.factor));
      freeActsByComponent.set(node.data.name, { nodeName: node.data.name, node, acts: freeActs });
    }
  }

  const adjacentProbes = new Map<string, Set<string>>();
  const probeNodeIds = new Set(nodes.filter((n) => n.data.type === "Probe").map((n) => n.id));
  for (const edge of edges) {
    const endpoints = [edge.source, edge.target];
    for (const endpoint of endpoints) {
      if (!endpoint) continue;
      for (const other of endpoints) {
        if (other && other !== endpoint && probeNodeIds.has(other)) {
          const probeName = nodes.find((n) => n.id === other)?.data.name;
          if (probeName) {
            const endpointName = nodes.find((n) => n.id === endpoint)?.data.name;
            if (endpointName) {
              if (!adjacentProbes.has(endpointName)) {
                adjacentProbes.set(endpointName, new Set());
              }
              adjacentProbes.get(endpointName)!.add(probeName);
            }
          }
        }
      }
    }
  }

  const findProbeFor = (
    component: string,
    factor: string,
    usedKeys: Set<string>,
  ): ProbeMeasure | undefined => {
    const compatible = probeMeasures.filter(
      (m) => m.factorCompat.includes(factor) && !usedKeys.has(`${m.nodeName}::${m.kind}`),
    );
    if (compatible.length === 0) return undefined;
    const adjacent = adjacentProbes.get(component);
    const adjacentMatch = adjacent
      ? compatible.find((m) => adjacent.has(m.nodeName))
      : undefined;
    const chosen = adjacentMatch ?? compatible[0];
    usedKeys.add(`${chosen.nodeName}::${chosen.kind}`);
    return chosen;
  };

  const usedKeys = new Set<string>();
  const freeEntries = [...freeActsByComponent.values()].sort((a, b) =>
    a.nodeName.localeCompare(b.nodeName),
  );
  for (const { nodeName, acts } of freeEntries) {
    for (const act of acts) {
      const probe = findProbeFor(nodeName, act.factor, usedKeys);
      if (probe) {
        embeddings.push({
          id: `emb_${nodeName}_${act.factor}`,
          unknown: {
            component: nodeName,
            factor: act.factor,
            start: act.initial,
            min: act.min,
            max: act.max,
          },
          equation: {
            component: probe.nodeName,
            output: probe.output,
            value: probe.target,
          },
        });
      }
    }
  }

  embeddings.sort((a, b) => a.id.localeCompare(b.id));
  return embeddings;
}

/** @deprecated Use {@link buildModelEmbeddings}. Kept for transitional tests. */
export function buildFixedFreeCalibrationControls(
  nodes: Node<EntropykNodeData>[],
  edges: Edge[] = [],
): ControlConfig[] {
  return buildModelEmbeddings(nodes, edges).map((emb) => ({
    type: "SaturatedController",
    id: emb.id,
    measure: { component: emb.equation.component, output: emb.equation.output },
    actuator: {
      component: emb.unknown.component,
      factor: emb.unknown.factor,
      initial: emb.unknown.start,
      min: emb.unknown.min,
      max: emb.unknown.max,
    },
    target: emb.equation.value,
    gain: -0.5,
    band: 2.0,
  }));
}

/** Probe param key → semantic kind for pairing. */
export const PROBE_PARAM_KIND: Record<string, string> = {
  t_c: "T",
  tsat_c: "Tsat",
  p_bar: "P",
  x: "X",
  tsh_k: "Tsh",
  capacity_w: "Capacity",
};

/**
 * Legacy Probe shape: `{ measure: "SST", target: 5.9, __fixed_target: true }`.
 * Absolute temperatures that look like °C (< 200) are converted to K.
 */
function legacyProbeMeasure(params: Record<string, unknown>): {
  kind: string;
  output: string;
  target: number;
} | null {
  const fixedFlag = params.__fixed_target;
  // defaultFixed was false for legacy target — require explicit Fixed ON
  if (fixedFlag !== true && fixedFlag !== "true") return null;
  const kindRaw = params.measure;
  const kind = typeof kindRaw === "string" ? kindRaw : "";
  if (!kind) return null;
  const raw = params.target;
  const n = typeof raw === "number" ? raw : Number(raw);
  if (!Number.isFinite(n)) return null;

  const output = legacyProbeOutput(kind);
  let target = n;
  // SST/SDT/T/DGT: UI historically stored °C in `target` without a unit.
  if (
    output === "saturationTemperature" ||
    output === "temperature"
  ) {
    if (n > -100 && n < 200) {
      target = n + 273.15;
    }
  }
  // Pressure: bar → Pa when value looks like bar
  if (output === "pressure" && n > 0 && n < 200) {
    target = n * 1e5;
  }
  return { kind, output, target };
}

function legacyProbeOutput(kind: string): string {
  switch (kind) {
    case "SST":
    case "SDT":
      return "saturationTemperature";
    case "SH":
    case "DSH":
      return "superheat";
    case "SC":
      return "subcooling";
    case "DGT":
    case "T":
      return "temperature";
    case "P":
      return "pressure";
    case "MassFlow":
      return "massFlowRate";
    case "Capacity":
      return "capacity";
    default:
      return "temperature";
  }
}

/**
 * Probe physical kind → free factors it can calibrate (Modelica Fixed ↔ Free).
 * Shared by emit (`buildModelEmbeddings`) and UI aides (`calibAssist`).
 */
export const FACTOR_COMPATIBILITY: Record<string, readonly string[]> = {
  Tsat: ["z_ua"],
  Tsh: ["z_ua", "opening"],
  T: ["f_w"],
  P: ["z_dp"],
  Capacity: ["z_flow", "z_power", "z_etav"],
  X: [],
  // Legacy kind names (older modules / tests)
  SST: ["z_ua"],
  SDT: ["z_ua"],
  SH: ["z_ua", "opening"],
  SC: ["z_ua"],
  DGT: ["f_w"],
  DSH: ["f_w"],
  MassFlow: ["z_flow", "z_etav"],
};

/** Convert UI measure value to SI expected by the solver (temps → K). */
function measureSetpointSi(meta: ParamMeta, value: number): number {
  const unit = (meta.unit ?? "").toLowerCase();
  if (unit === "°c" || unit === "c" || meta.key.endsWith("_c")) {
    return value + 273.15;
  }
  return value;
}

function controlNodeToConfig(node: Node<EntropykNodeData>): ControlConfig {
  const p = node.data.params;
  const cfg: ControlConfig = {
    type: "SaturatedController",
    id: node.data.name,
    measure: {
      component: stringParam(p.measure_component, "comp"),
      output: stringParam(p.measure_output, "temperature"),
    },
    actuator: {
      component: stringParam(p.actuator_component, "comp"),
      factor: stringParam(p.actuator_factor, "injection"),
      initial: numberParam(p.initial, 0.15),
      min: numberParam(p.min, 0.0),
      max: numberParam(p.max, 0.3),
    },
    target: numberParam(p.target, 330.0),
    gain: numberParam(p.gain, -0.5),
    band: numberParam(p.band, 5.0),
  };
  if (typeof p.smooth_eps === "number" && Number.isFinite(p.smooth_eps)) {
    cfg.smooth_eps = p.smooth_eps;
  }
  const objectives = parseControlObjectives(p.objectives_json);
  if (objectives.length > 0) cfg.objectives = objectives;
  if (typeof p.alpha === "number" && Number.isFinite(p.alpha) && p.alpha > 0) {
    cfg.alpha = p.alpha;
  }
  return cfg;
}

export function parseControlObjectives(value: unknown): ControlObjectiveConfig[] {
  if (typeof value !== "string" || value.trim() === "") return [];
  try {
    const parsed: unknown = JSON.parse(value);
    if (!Array.isArray(parsed)) return [];
    return parsed.flatMap((objective): ControlObjectiveConfig[] => {
      if (!objective || typeof objective !== "object") return [];
      const candidate = objective as Record<string, unknown>;
      if (
        typeof candidate.component !== "string" ||
        typeof candidate.output !== "string" ||
        typeof candidate.setpoint !== "number" ||
        !Number.isFinite(candidate.setpoint) ||
        typeof candidate.gain !== "number" ||
        !Number.isFinite(candidate.gain) ||
        (candidate.combine !== "min" && candidate.combine !== "max")
      ) {
        return [];
      }
      return [{
        component: candidate.component,
        output: candidate.output,
        setpoint: candidate.setpoint,
        gain: candidate.gain,
        combine: candidate.combine,
      }];
    });
  } catch {
    return [];
  }
}

function mergeControls(base: ControlConfig[], fromNodes: ControlConfig[]): ControlConfig[] {
  const merged = new Map<string, ControlConfig>();
  for (const control of base) merged.set(control.id, control);
  for (const control of fromNodes) merged.set(control.id, control);
  return Array.from(merged.values());
}

function stringParam(value: unknown, fallback: string): string {
  return typeof value === "string" && value.trim() ? value : fallback;
}

function numberParam(value: unknown, fallback: number): number {
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}

/** Validate the built config — returns a list of human-readable issues. */
export function validateConfig(nodes: Node<EntropykNodeData>[], edges: Edge[]): string[] {
  const issues: string[] = [];

  if (nodes.length === 0) {
    issues.push("Add at least one component.");
  }

  // Each circuit must have at least one component.
  const circuits = new Set(nodes.map((n) => n.data?.circuit ?? 0));
  for (const c of circuits) {
    const cNodes = nodes.filter((n) => (n.data?.circuit ?? 0) === c);
    if (cNodes.length === 0) issues.push(`Circuit ${c} is empty.`);
  }

  // Duplicate names within a circuit.
  for (const c of circuits) {
    const names = nodes
      .filter((n) => (n.data?.circuit ?? 0) === c)
      .map((n) => n.data.name);
    const dupes = names.filter((n, i) => names.indexOf(n) !== i);
    if (dupes.length > 0) issues.push(`Duplicate component name(s) in circuit ${c}: ${[...new Set(dupes)].join(", ")}`);
  }

  // Required params present.
  for (const n of nodes) {
    const meta = COMPONENT_BY_TYPE[n.data.type];
    if (!meta) {
      issues.push(`Unknown component type "${n.data.type}".`);
      continue;
    }
    for (const p of meta.params) {
      const params = canonicalizeParams(n.data.type, n.data.params);
      const supplied = params[p.key] !== undefined && params[p.key] !== "";
      const fromSecondary = secondaryParamSuppliedByConnection(n, p.key, nodes, edges);
      // Free fixable params are not required (value is only an initial hint).
      const needFixed = BOUNDARY_FIX_TYPES.has(n.data.type)
        ? isBoundaryParamFixed(n.data.params, p)
        : !p.fixable || isParamFixed(n.data.params, p);
      if (p.required && needFixed && !supplied && !fromSecondary) {
        issues.push(`${n.data.name}: required parameter "${p.label}" is missing.`);
      }
    }

    // Dual-mode HX secondary:
    //   system  → both secondary_inlet + secondary_outlet wired (live edges)
    //   rating  → scalar T_sec + C_sec (or ṁ·cp) without live edges
    if (meta.ports.some(isSecondaryPort)) {
      const connected = new Set<string>();
      for (const e of edges) {
        if (e.source === n.id && e.sourceHandle) connected.add(e.sourceHandle);
        if (e.target === n.id && e.targetHandle) connected.add(e.targetHandle);
      }
      const hasIn = connected.has("secondary_inlet");
      const hasOut = connected.has("secondary_outlet");
      const liveOk = hasIn && hasOut;
      const params = canonicalizeParams(n.data.type, n.data.params);
      const ratingOk = hasRatingSecondaryScalars(params);
      if (!liveOk && !ratingOk) {
        issues.push(
          `${n.data.name}: secondary incomplete — wire secondary_inlet + secondary_outlet ` +
            `(system mode) OR set rating scalars (secondary_inlet_temp_c + mass flow/cp).`,
        );
      } else if ((hasIn || hasOut) && !liveOk) {
        issues.push(
          `${n.data.name}: secondary ports partial (need both secondary_inlet and secondary_outlet).`,
        );
      }
    }

    if (
      n.data.type === "FloodedEvaporator" &&
      (n.data.params?.quality_control === true || n.data.params?.quality_control === "true")
    ) {
      issues.push(
        `${n.data.name}: quality_control=true adds +1 FIX residual — free an actuator (EXV/level) ` +
          `or leave it off for compressor suction models.`,
      );
    }
  }

  // Modelica balanced model: parameter(fixed=false) is an unknown → needs an
  // equation. Fixed Probe value supplies that equation. Free without equation
  // was silently ignored (still a constant) — reject it.
  const balancedUnknowns = new Set(
    buildModelEmbeddings(nodes, edges).map(
      (e) => `${e.unknown.component}::${e.unknown.factor}`,
    ),
  );
  for (const n of nodes) {
    if (n.data.type === CONTROL_NODE_TYPE) continue;
    const meta = COMPONENT_BY_TYPE[n.data.type];
    if (!meta) continue;
    const params = n.data.params ?? {};
    for (const p of meta.params) {
      if (!p.actuatorFactor || !p.fixable) continue;
      if (isParamFixed(params, p)) continue;
      const key = `${n.data.name}::${p.actuatorFactor}`;
      if (!balancedUnknowns.has(key)) {
        issues.push(
          `${n.data.name}: ${p.label} is Free (unknown) without an equation — ` +
            `Modelica: parameter(fixed=false) needs a Fixed Probe value on the line, ` +
            `or leave ${p.label} Fixed.`,
        );
      }
    }
  }

  return issues;
}

function secondaryParamSuppliedByConnection(
  node: Node<EntropykNodeData>,
  key: string,
  nodes: Node<EntropykNodeData>[],
  edges: Edge[],
): boolean {
  if (key !== "secondary_inlet_temp_c" && key !== "secondary_mass_flow_kg_s") return false;
  const sourceEdge = edges.find((edge) => edge.target === node.id && edge.targetHandle === "secondary_inlet");
  if (!sourceEdge) return false;
  const source = nodes.find((candidate) => candidate.id === sourceEdge.source);
  if (!source || !isBoundaryNode(source)) return false;
  const params = canonicalizeParams(source.data.type, source.data.params);
  if (key === "secondary_inlet_temp_c") return params.t_set_c !== undefined || params.t_dry_c !== undefined;
  return params.m_flow_kg_s !== undefined;
}

/**
 * Rating-mode secondary stream is complete when T_sec,in and a positive capacity
 * rate are available: either C_sec directly, or ṁ·cp (with default cp assumed if
 * only mass flow is set — matches CLI `parse_secondary_stream` defaults).
 */
function hasRatingSecondaryScalars(
  params: Record<string, number | string | boolean | undefined>,
): boolean {
  const t =
    numParam(params.secondary_inlet_temp_c) ?? numParam(params.secondary_inlet_temp_k);
  if (t === undefined) return false;

  const cDirect = numParam(params.secondary_capacity_rate_w_per_k);
  if (cDirect !== undefined && cDirect > 0) return true;

  const m = numParam(params.secondary_mass_flow_kg_s);
  if (m === undefined || m <= 0) return false;
  const cp = numParam(params.secondary_cp_j_per_kgk);
  // CLI supplies a fluid-dependent default cp when only mass flow is given.
  return cp === undefined || cp > 0;
}

function numParam(v: number | string | boolean | undefined): number | undefined {
  if (typeof v === "number" && Number.isFinite(v)) return v;
  if (typeof v === "string" && v.trim() !== "") {
    const n = Number(v);
    if (Number.isFinite(n)) return n;
  }
  return undefined;
}
