// Level-based control strategy.
//
// Ported from basin-docs `_controlLevelBased` into the refactored
// strategy module. Concerns kept here:
//   1. minLevel hard-stop (unconditional MGC shutdown).
//   2. stopLevel Schmitt-trigger hysteresis — pumps stay engaged
//      through the dead band [stopLevel, startLevel] emitting a small
//      keep-alive demand so MGC keeps a single pump draining the basin.
//   3. Up-curve mapping — level mapped to demand 0..100 % across
//      [max(startLevel, inflowLevel), maxLevel] using linear or log shape.
//      Foot at startLevel when startLevel > inflowLevel allows buffering
//      in the upstream sewer above the gravity-feed point.
//   4. Shifted-ramp hysteresis — when the up-curve crosses
//      shiftArmPercent the strategy ARMS; on the next filling→draining
//      flip it captures the up-curve value as `hold`; while draining
//      the output stays at `hold` until level falls to shiftLevel, then
//      ramps `hold → 0 %` over [shiftLevel, startLevel]. Disarms when
//      level reaches startLevel.
//
// Hysteresis flags live on the host (specificClass instance) — the
// strategy reads/writes via ctx.host so the same flags survive across
// ticks regardless of how often the context view is rebuilt.

// Apply the configured curve shape to a normalized x in [0, 1].
// Linear by default; log when curveType is 'log'.
function _curveShape(x, levelbased) {
  const { curveType = 'linear', logCurveFactor = 9 } = levelbased || {};
  const clamped = Math.max(0, Math.min(1, x));
  if (curveType === 'log') {
    const factor = Number.isFinite(Number(logCurveFactor)) && Number(logCurveFactor) > 0
      ? Number(logCurveFactor) : 9;
    return Math.log1p(factor * clamped) / Math.log1p(factor);
  }
  return clamped;
}

// Map level to demand % across [rampFoot, rampTop]. Returns 0 below the
// foot, 100 above the top. Curve type controlled by levelbased.curveType.
function _scaleLevelToFlowPercent(level, rampFoot, rampTop, levelbased) {
  if (!Number.isFinite(level) || !Number.isFinite(rampFoot) || !Number.isFinite(rampTop)) return 0;
  if (rampTop <= rampFoot) return level >= rampTop ? 100 : 0;
  if (level <= rampFoot) return 0;
  if (level >= rampTop) return 100;
  const x = (level - rampFoot) / (rampTop - rampFoot);
  return 100 * _curveShape(x, levelbased);
}

async function _applyMachineGroupLevelControl(machineGroups, percentControl, logger) {
  if (!machineGroups || Object.keys(machineGroups).length === 0) return;
  // The caller (run() below) already gated turn-off via the minLevel
  // hard-stop, stopLevel falling-edge, and the rising-edge engagement gate.
  // By the time we get here, pumps should be running — `0 %` is the engaged
  // "min flow" floor (MGC.setDemand interpolates 0 → dt.flow.min), NOT a
  // soft turn-off. Forward unconditionally.
  const forward = (group) => {
    if (typeof group.setDemand !== 'function') {
      logger?.error?.(`Group "${group.config?.general?.name}" missing setDemand — refusing to call handleInput with a percent value`);
      return Promise.resolve();
    }
    return Promise.resolve(group.setDemand(percentControl, '%')).catch((err) => {
      logger?.error?.(`Failed to send level control to group "${group.config?.general?.name}": ${err && err.message}`);
    });
  };
  await Promise.all(Object.values(machineGroups).map(forward));
}

async function _applyMachineLevelControl(machines, percentControl, logger) {
  const filtered = Object.values(machines).filter((machine) => {
    const pos = machine?.config?.functionality?.positionVsParent;
    return (pos === 'downstream' || pos === 'atequipment');
  });
  if (!filtered.length) return;

  const perMachine = percentControl / filtered.length;
  for (const machine of filtered) {
    try {
      await machine.handleInput('parent', 'execSequence', 'startup');
      await machine.handleInput('parent', 'execMovement', perMachine);
    } catch (err) {
      logger?.error?.(`Failed to start machine "${machine.config?.general?.name}": ${err.message}`);
    }
  }
}

function _pickVariant(measurements, type, variants, position, unit) {
  for (const variant of variants) {
    const val = measurements.type(type).variant(variant).position(position).getCurrentValue(unit);
    if (!Number.isFinite(val)) continue;
    return val;
  }
  return null;
}

async function run(ctx, controlState, direction) {
  const { measurements, config, logger, machineGroups, basin, levelVariants, host } = ctx;
  const cfg = config.control.levelbased || {};
  const { startLevel, minLevel, maxLevel } = cfg;
  const levelUnit = measurements.getUnit('level');

  const variants = levelVariants || ['measured', 'predicted'];
  const level = _pickVariant(measurements, 'level', variants, 'atequipment', levelUnit);
  if (level == null) {
    logger?.warn?.('No valid level found');
    return;
  }

  // 1. minLevel hard-stop — unconditional MGC shutdown.
  if (level < minLevel) {
    controlState.percControl = 0;
    if (host) {
      host._shiftHoldValue = null;
      host._shiftArmed = false;
      host._stopHystRunning = false;
      host._lastDirection = direction;
    }
    Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
    return;
  }

  // 2. stopLevel hysteresis (Schmitt trigger).
  // Requires an explicit positive stopLevel — configManager merges null
  // defaults to 0 otherwise, which would activate the hysteresis on every
  // config that omitted it.
  const stopLvl = Number(cfg.stopLevel);
  const stopThresholdActive = cfg.stopLevel != null && Number.isFinite(stopLvl)
    && stopLvl > 0 && stopLvl < maxLevel;
  if (stopThresholdActive && level <= stopLvl) {
    controlState.percControl = 0;
    if (host) {
      host._stopHystRunning = false;
      host._shiftArmed = false;
      host._shiftHoldValue = null;
      host._lastDirection = direction;
    }
    Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
    return;
  }
  if (host) {
    if (stopThresholdActive) {
      if (!host._stopHystRunning && level >= startLevel) host._stopHystRunning = true;
    } else {
      host._stopHystRunning = level >= startLevel;
    }
  }

  // 3. Engagement gate. Pumps stay OFF until level rises through startLevel
  //    for the first time (rising-edge); once engaged they stay on until
  //    level drops through stopLevel (falling-edge — handled by case 2).
  //    Without an explicit stopLevel the gate collapses to `level >= startLevel`.
  //    Moved out of the percentControl path so 0 % can mean "engaged at
  //    min flow" instead of "stopped". Disengagement also clears the
  //    shifted-ramp hysteresis so it doesn't survive a stop/start cycle.
  const isEngaged = host ? host._stopHystRunning : (level >= startLevel);
  if (!isEngaged) {
    controlState.percControl = 0;
    if (host) {
      host._shiftArmed = false;
      host._shiftHoldValue = null;
      host._lastDirection = direction;
    }
    Object.values(machineGroups || {}).forEach((group) => group.turnOffAllMachines());
    return;
  }

  // 4. Up-curve mapping. Foot = holdLevel (defaults to startLevel; operators
  //    can raise it to introduce a hold band [startLevel, holdLevel] where
  //    pumps run at min flow before the ramp begins). `inflowLevel` does NOT
  //    shape the curve — it's basin geometry, not a control setpoint.
  //    Explicit null/undefined check first so `Number(null) === 0` doesn't
  //    silently put the ramp foot at the basin floor.
  const rawHold = cfg.holdLevel;
  const holdLevel = (rawHold != null && Number.isFinite(Number(rawHold)))
    ? Number(rawHold) : startLevel;
  const rampFoot = Math.max(startLevel, holdLevel);
  const upPct = _scaleLevelToFlowPercent(level, rampFoot, maxLevel, cfg);

  // 5. Shifted-ramp arming.
  if (host) {
    if (cfg.enableShiftedRamp) {
      const armPct = Number.isFinite(cfg.shiftArmPercent) ? cfg.shiftArmPercent : 95;
      if (!host._shiftArmed && upPct >= armPct) {
        host._shiftArmed = true;
        logger?.debug?.(`Shift armed: upPct=${upPct} >= ${armPct}`);
      }
    } else {
      host._shiftArmed = false;
    }
    if (level <= startLevel) {
      host._shiftArmed = false;
      host._shiftHoldValue = null;
    }
    // Capture hold on filling→draining transition while armed.
    if (cfg.enableShiftedRamp && host._shiftArmed) {
      if (host._lastDirection !== 'draining' && direction === 'draining') {
        host._shiftHoldValue = upPct;
        logger?.debug?.(`Shift hold captured: ${upPct} % at level=${level}`);
      } else if (direction === 'filling') {
        // Returning to filling clears any captured hold; the next drain
        // transition will recapture from the up curve.
        host._shiftHoldValue = null;
      }
    }
    if (direction === 'filling' || direction === 'draining') {
      host._lastDirection = direction;
    }
  }

  // Compute output.
  const shiftArmed = !!host?._shiftArmed;
  const shiftHold = host?._shiftHoldValue;
  const inDrainingHold = cfg.enableShiftedRamp && shiftArmed
    && direction === 'draining' && shiftHold != null;

  let percControl;
  if (!inDrainingHold) {
    if (level < rampFoot) {
      // Engaged (we passed the gate above) but below the ramp foot. Two
      // sub-cases:
      //   (a) Inside the configurable hold band [startLevel, holdLevel] —
      //       emit 0 %, which MGC's setDemand interpolates to flow.min.
      //   (b) Inside the falling-edge keep-alive band [stopLevel, startLevel]
      //       — emit deadZoneKeepAlivePercent (default 1 %) so MGC keeps
      //       at least one pump turning rather than dispatching a clean min.
      if (stopThresholdActive && level < startLevel) {
        const keepAlive = Number.isFinite(Number(cfg.deadZoneKeepAlivePercent))
          ? Number(cfg.deadZoneKeepAlivePercent) : 1;
        percControl = Math.max(0, keepAlive);
      } else {
        percControl = 0;
      }
    } else {
      percControl = Math.max(0, upPct);
    }
  } else {
    const hold = shiftHold;
    const shift = cfg.shiftLevel;
    if (!Number.isFinite(shift) || shift <= startLevel) {
      // Bad config — fall back to up curve.
      percControl = Math.max(0, upPct);
    } else if (level >= shift) {
      percControl = hold;
    } else if (level > startLevel) {
      // Ramp [shift, hold] → [start, 0] using the same curve shape.
      const x = (level - startLevel) / (shift - startLevel);
      percControl = Math.max(0, hold * _curveShape(x, cfg));
    } else {
      percControl = 0;
    }
  }

  controlState.percControl = percControl;
  logger?.debug?.(
    `Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}`
  );

  // We are past every off-gate, so the station is engaged and the computed
  // demand is meant to drive pumps. If no machine group is registered the
  // demand has nowhere to go and the pumps stay silent — the signature of a
  // dropped Port 2 parent↔group registration (e.g. after a partial redeploy
  // that recreated this node). Warn once until a group reappears so the
  // failure isn't invisible.
  const groupCount = machineGroups ? Object.keys(machineGroups).length : 0;
  if (groupCount === 0) {
    if (host && !host._warnedNoMachineGroup) {
      logger?.warn?.(
        `Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — `
        + `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; `
        + `redeploy/restart fully to re-run the Port 2 registration handshake.`
      );
      host._warnedNoMachineGroup = true;
    }
  } else if (host) {
    host._warnedNoMachineGroup = false;
  }

  await _applyMachineGroupLevelControl(machineGroups, percControl, logger);
}

module.exports = {
  name: 'levelbased',
  run,
  _scaleLevelToFlowPercent,
  _curveShape,
  _applyMachineGroupLevelControl,
  _applyMachineLevelControl,
};
