// End-to-end test for the level-armed hysteresis (shifted ramp) cycle.
// Drives a full fill→arm→drain cycle through the same code path the
// dashboard exercises (manual Q_IN / Q_OUT + tick), and asserts the
// hold-then-ramp output behaviour.
//
// Run with:  node --test test/integration/shifted-ramp-end-to-end.test.js

const test = require('node:test');
const assert = require('node:assert/strict');

const PumpingStation = require('../../src/specificClass');

const SURFACE_AREA = 10;        // basin volume / height = 50/5
const TICK_MS = 1000;           // simulate 1 s per tick

function makeConfig() {
  return {
    general: {
      name: 'TestPS',
      id: 'ps-e2e',
      unit: 'm3/h',
      logging: { enabled: false, logLevel: 'error' },
      flowThreshold: 1e-4,
    },
    functionality: {
      softwareType: 'pumpingStation',
      role: 'stationcontroller',
      positionVsParent: 'atEquipment',
    },
    basin: {
      volume: 50, height: 5,
      inflowLevel: 3, outflowLevel: 0.2, overflowLevel: 4.5,
      inletPipeDiameter: 0.4, outletPipeDiameter: 0.3,
    },
    hydraulics: { refHeight: 'NAP', basinBottomRef: 0, minHeightBasedOn: 'outlet' },
    control: {
      mode: 'levelbased',
      allowedModes: new Set(['levelbased', 'manual']),
      levelbased: {
        // holdLevel pins the ramp foot at 3 to preserve the original geometry
        // (up curve 0 %@3 → 100 %@4). New default would put the foot at
        // startLevel=2; this test specifically exercises shifted-ramp arming
        // behaviour, not the ramp-foot semantic itself.
        minLevel: 1, startLevel: 2, holdLevel: 3, maxLevel: 4,
        curveType: 'linear', logCurveFactor: 9,
        enableShiftedRamp: true, shiftLevel: 3.5, shiftArmPercent: 80,
      },
    },
    safety: {
      enableDryRunProtection: false, enableOverfillProtection: false,
      dryRunThresholdPercent: 2, highVolumeSafetyThresholdPercent: 98,
      overfillThresholdPercent: 98, timeleftToFullOrEmptyThresholdSeconds: 0,
    },
  };
}

// machineGroups is a registry-backed getter (declareChildGetter) — inject
// the fake MGC via the real child-registration handshake so the registry
// stays the source of truth across configure() and tick().
function registerMockGroup(ps, id, demands) {
  const mock = {
    config: {
      general: { id, name: id },
      functionality: { softwareType: 'machinegroup', positionVsParent: 'atEquipment' },
      asset: { category: 'controller' },
    },
    measurements: {
      emitter: { on: () => {} },
      setChildId: () => {}, setChildName: () => {}, setParentRef: () => {},
    },
    handleInput: async (_src, d) => { demands.push(d); },
    turnOffAllMachines: () => {},
  };
  ps.childRegistrationUtils.registerChild(mock, 'atEquipment');
  return mock;
}

// Build a PS with a fake MGC that captures every demand sent to it,
// and a clock we control so _updatePredictedVolume integrates over a
// known dt regardless of wall-clock.
function buildHarness() {
  const ps = new PumpingStation(makeConfig());
  const demands = [];
  registerMockGroup(ps, 'mgc1', demands);
  // Seed level at startLevel so the run begins idle.
  ps.calibratePredictedLevel(2.0);
  // Override Date.now via a controllable clock that advances `step()`.
  let now = ps._predictedFlowState.lastTimestamp || 0;
  ps._fakeNow = () => now;
  ps._fakeAdvance = (ms) => { now += ms; };
  // Patch global Date.now JUST inside the scope of these tests.
  const realNow = Date.now;
  Date.now = ps._fakeNow;
  // Restore on completion.
  ps._restore = () => { Date.now = realNow; };
  return { ps, demands };
}

async function step(ps, qIn, qOut) {
  // Apply the manual Q_IN / Q_OUT (mirroring the dashboard's q_in / q_out
  // topic handlers in nodeClass.js), advance time, then tick once.
  if (Number.isFinite(qIn))  ps.setManualInflow(qIn,  Date.now(), 'm3/s');
  if (Number.isFinite(qOut)) ps.setManualOutflow(qOut, Date.now(), 'm3/s');
  ps._fakeAdvance(TICK_MS);
  ps.tick();
}

function levelOf(ps) {
  return ps.measurements.type('level').variant('predicted').position('atequipment').getCurrentValue('m');
}

test('shifted ramp e2e: arm → hold → ramp-down → disarm', async () => {
  const { ps } = buildHarness();
  try {
    // ─── PHASE A: fill from start (2.0) up past the arm point ──────────
    // Q_IN = 0.05 m3/s, Q_OUT = 0 → net = 0.05 m3/s. Level rises by
    // 0.05/SURFACE_AREA = 0.005 m per second.
    let armedAt = null;
    for (let i = 0; i < 600 && levelOf(ps) < 3.95; i++) {
      await step(ps, 0.05, 0);
      if (!armedAt && ps._shiftArmed) armedAt = { level: levelOf(ps), pct: ps.percControl };
    }
    assert.ok(armedAt, 'shift should arm during fill');
    // Should arm right around level=3.8 (up curve = 80 %). Allow ±0.05 m
    // jitter for time-discretization.
    assert.ok(Math.abs(armedAt.level - 3.8) < 0.05,
      `expected arm near level=3.8, got ${armedAt.level}`);
    assert.ok(armedAt.pct >= 80 - 1e-6,
      `at arm point output should be ≥ shiftArmPercent, got ${armedAt.pct}`);

    // While still filling and armed, output should track the up curve
    // (not jump to 100 %). At level ~ 3.95, up curve = 95 %.
    const fillingPct = ps.percControl;
    assert.ok(fillingPct < 100 + 1e-6 && fillingPct >= 80 - 1e-6,
      `filling-armed output should still be on up curve, got ${fillingPct}`);
    // No hold captured yet (still filling).
    assert.equal(ps._shiftHoldValue, null);

    // ─── PHASE B: flip to draining ─────────────────────────────────────
    // First drain tick captures the hold. We need direction='draining' as
    // determined by _selectBestNetFlow → so q_in - q_out must be negative
    // by more than the dead-band (1e-4).
    await step(ps, 0, 0.05);                     // net = -0.05
    assert.equal(ps.state.direction, 'draining');
    // Hold captured = up curve at the level when direction flipped. The
    // captured value is recorded BEFORE this drain tick lowered the level
    // further, so it should match the last filling tick's output (within
    // the per-tick step size 0.5 % ~ 0.005 m × 100 / 1 m).
    assert.ok(ps._shiftHoldValue >= 80 - 1e-6,
      `hold should be at least the arm threshold, got ${ps._shiftHoldValue}`);
    const hold = ps._shiftHoldValue;

    // ─── PHASE C: drain while level still ≥ shiftLevel — output HELD ───
    // Drain until level just above shiftLevel=3.5. Output stays = hold.
    let held = true;
    for (let i = 0; i < 200 && levelOf(ps) > 3.51; i++) {
      await step(ps, 0, 0.05);
      if (Math.abs(ps.percControl - hold) > 1e-6) { held = false; break; }
    }
    assert.ok(held, 'output should HOLD at the captured value while level > shiftLevel');
    assert.ok(Math.abs(ps.percControl - hold) < 1e-6,
      `still expected hold=${hold}, got ${ps.percControl}`);

    // ─── PHASE D: drain past shiftLevel — output ramps hold→0 ──────────
    // Drain until clearly below shiftLevel (level ≤ 3.45). Output should drop.
    while (levelOf(ps) > 3.45) await step(ps, 0, 0.05);
    const justBelow = ps.percControl;
    assert.ok(justBelow < hold,
      `output should start dropping below shiftLevel, got ${justBelow} vs hold ${hold}`);
    // Ramp midpoint: level=2.75 (midway in [2, 3.5]). Output ≈ hold × 0.5.
    while (levelOf(ps) > 2.78 && levelOf(ps) > 2.0) await step(ps, 0, 0.05);
    const mid = ps.percControl;
    assert.ok(Math.abs(mid - hold * 0.5) < hold * 0.05,
      `at level≈2.75 expected ≈ hold/2 (${hold * 0.5}), got ${mid}`);

    // ─── PHASE E: level drops to startLevel — DISARM, output 0 ─────────
    while (levelOf(ps) > 1.95) await step(ps, 0, 0.05);
    assert.equal(ps._shiftArmed, false, 'should disarm when level reaches startLevel');
    assert.equal(ps._shiftHoldValue, null);
    assert.equal(ps.percControl, 0);
  } finally {
    ps._restore();
  }
});

test('shifted ramp e2e: bounce — fill, drain a bit, refill, drain — captures fresh hold', async () => {
  const { ps } = buildHarness();
  try {
    // Fill to arm + some headroom.
    while (levelOf(ps) < 3.85) await step(ps, 0.05, 0);
    assert.equal(ps._shiftArmed, true);

    // First drain transition → hold #1.
    await step(ps, 0, 0.05);
    const hold1 = ps._shiftHoldValue;
    assert.ok(hold1 >= 80 - 1e-6);

    // Drain a tiny bit (level still > shiftLevel) → output stays at hold1.
    for (let i = 0; i < 5; i++) await step(ps, 0, 0.05);
    assert.ok(Math.abs(ps.percControl - hold1) < 1e-6);

    // Flip back to filling at higher rate; up curve resumes; hold cleared.
    await step(ps, 0.05, 0);
    assert.equal(ps._shiftHoldValue, null);
    assert.equal(ps._shiftArmed, true, 'should stay armed across the bounce');

    // Fill higher than before (output goes higher).
    while (levelOf(ps) < 3.95) await step(ps, 0.05, 0);
    const fillingPct = ps.percControl;
    assert.ok(fillingPct > hold1, `bounce should rise above first hold; got ${fillingPct} vs ${hold1}`);

    // Drain again → fresh hold #2 = current up curve %.
    await step(ps, 0, 0.05);
    const hold2 = ps._shiftHoldValue;
    assert.ok(hold2 > hold1, `second hold (${hold2}) should be > first (${hold1})`);
  } finally {
    ps._restore();
  }
});
