'use strict';

/**
 * Production-focused discrete PID controller with modern control features:
 * - auto/manual and bumpless transfer
 * - freeze/unfreeze (hold output while optionally tracking process)
 * - derivative filtering and derivative-on-measurement/error
 * - anti-windup (clamp or back-calculation)
 * - output and integral limits
 * - output rate limiting
 * - deadband
 * - gain scheduling (array/function)
 * - feedforward and dynamic tunings at runtime
 */
class PIDController {
  constructor(options = {}) {
    const {
      kp = 1,
      ki = 0,
      kd = 0,
      sampleTime = 1000,
      derivativeFilter = 0.15,
      outputMin = Number.NEGATIVE_INFINITY,
      outputMax = Number.POSITIVE_INFINITY,
      integralMin = null,
      integralMax = null,
      derivativeOnMeasurement = true,
      setpointWeight = 1,
      derivativeWeight = 0,
      deadband = 0,
      outputRateLimitUp = Number.POSITIVE_INFINITY,
      outputRateLimitDown = Number.POSITIVE_INFINITY,
      antiWindupMode = 'clamp',
      backCalculationGain = 0,
      gainSchedule = null,
      autoMode = true,
      trackOnManual = true,
      frozen = false,
      freezeTrackMeasurement = true,
      freezeTrackError = false,
    } = options;

    this.kp = 0;
    this.ki = 0;
    this.kd = 0;

    this.setTunings({ kp, ki, kd });
    this.setSampleTime(sampleTime);
    this.setOutputLimits(outputMin, outputMax);
    this.setIntegralLimits(integralMin, integralMax);
    this.setDerivativeFilter(derivativeFilter);
    this.setSetpointWeights({ beta: setpointWeight, gamma: derivativeWeight });
    this.setDeadband(deadband);
    this.setOutputRateLimits(outputRateLimitUp, outputRateLimitDown);
    this.setAntiWindup({ mode: antiWindupMode, backCalculationGain });
    this.setGainSchedule(gainSchedule);

    this.derivativeOnMeasurement = Boolean(derivativeOnMeasurement);
    this.autoMode = Boolean(autoMode);
    this.trackOnManual = Boolean(trackOnManual);

    this.frozen = Boolean(frozen);
    this.freezeTrackMeasurement = Boolean(freezeTrackMeasurement);
    this.freezeTrackError = Boolean(freezeTrackError);

    this.reset();
  }

  setTunings({ kp = this.kp, ki = this.ki, kd = this.kd } = {}) {
    [kp, ki, kd].forEach((gain, index) => {
      if (!Number.isFinite(gain)) {
        const label = ['kp', 'ki', 'kd'][index];
        throw new TypeError(`${label} must be a finite number`);
      }
    });

    this.kp = kp;
    this.ki = ki;
    this.kd = kd;
    return this;
  }

  setSampleTime(sampleTimeMs = this.sampleTime) {
    if (!Number.isFinite(sampleTimeMs) || sampleTimeMs <= 0) {
      throw new RangeError('sampleTime must be a positive number of milliseconds');
    }

    this.sampleTime = sampleTimeMs;
    return this;
  }

  setOutputLimits(min = this.outputMin, max = this.outputMax) {
    if (!Number.isFinite(min) && min !== Number.NEGATIVE_INFINITY) {
      throw new TypeError('outputMin must be finite or -Infinity');
    }
    if (!Number.isFinite(max) && max !== Number.POSITIVE_INFINITY) {
      throw new TypeError('outputMax must be finite or Infinity');
    }
    if (min >= max) {
      throw new RangeError('outputMin must be smaller than outputMax');
    }

    this.outputMin = min;
    this.outputMax = max;
    this.lastOutput = this._clamp(this.lastOutput ?? 0, this.outputMin, this.outputMax);
    return this;
  }

  setIntegralLimits(min = this.integralMin ?? null, max = this.integralMax ?? null) {
    if (min !== null && !Number.isFinite(min)) {
      throw new TypeError('integralMin must be null or a finite number');
    }
    if (max !== null && !Number.isFinite(max)) {
      throw new TypeError('integralMax must be null or a finite number');
    }
    if (min !== null && max !== null && min > max) {
      throw new RangeError('integralMin must be smaller than integralMax');
    }

    this.integralMin = min;
    this.integralMax = max;
    this.integral = this._applyIntegralLimits(this.integral ?? 0);
    return this;
  }

  setDerivativeFilter(value = this.derivativeFilter ?? 0) {
    if (!Number.isFinite(value) || value < 0 || value > 1) {
      throw new RangeError('derivativeFilter must be between 0 and 1');
    }

    this.derivativeFilter = value;
    return this;
  }

  setSetpointWeights({ beta = this.setpointWeight ?? 1, gamma = this.derivativeWeight ?? 0 } = {}) {
    if (!Number.isFinite(beta) || !Number.isFinite(gamma)) {
      throw new TypeError('setpoint and derivative weights must be finite numbers');
    }

    this.setpointWeight = beta;
    this.derivativeWeight = gamma;
    return this;
  }

  setDeadband(value = this.deadband ?? 0) {
    if (!Number.isFinite(value) || value < 0) {
      throw new RangeError('deadband must be a non-negative finite number');
    }

    this.deadband = value;
    return this;
  }

  setOutputRateLimits(up = this.outputRateLimitUp, down = this.outputRateLimitDown) {
    if (!Number.isFinite(up) && up !== Number.POSITIVE_INFINITY) {
      throw new TypeError('outputRateLimitUp must be finite or Infinity');
    }
    if (!Number.isFinite(down) && down !== Number.POSITIVE_INFINITY) {
      throw new TypeError('outputRateLimitDown must be finite or Infinity');
    }
    if (up <= 0 || down <= 0) {
      throw new RangeError('output rate limits must be positive values');
    }

    this.outputRateLimitUp = up;
    this.outputRateLimitDown = down;
    return this;
  }

  setAntiWindup({ mode = this.antiWindupMode ?? 'clamp', backCalculationGain = this.backCalculationGain ?? 0 } = {}) {
    const normalized = String(mode || 'clamp').trim().toLowerCase();
    if (normalized !== 'clamp' && normalized !== 'backcalc') {
      throw new RangeError('anti windup mode must be "clamp" or "backcalc"');
    }
    if (!Number.isFinite(backCalculationGain) || backCalculationGain < 0) {
      throw new RangeError('backCalculationGain must be a non-negative finite number');
    }

    this.antiWindupMode = normalized;
    this.backCalculationGain = backCalculationGain;
    return this;
  }

  /**
   * Gain schedule options:
   * - null: disabled
   * - function(input, state) => { kp, ki, kd }
   * - array: [{ min, max, kp, ki, kd }, ...]
   */
  setGainSchedule(schedule = null) {
    if (schedule == null) {
      this.gainSchedule = null;
      return this;
    }

    if (typeof schedule === 'function') {
      this.gainSchedule = schedule;
      return this;
    }

    if (!Array.isArray(schedule)) {
      throw new TypeError('gainSchedule must be null, a function, or an array');
    }

    schedule.forEach((entry, index) => {
      if (!entry || typeof entry !== 'object') {
        throw new TypeError(`gainSchedule[${index}] must be an object`);
      }
      const { min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, kp, ki, kd } = entry;
      if (!Number.isFinite(min) && min !== Number.NEGATIVE_INFINITY) {
        throw new TypeError(`gainSchedule[${index}].min must be finite or -Infinity`);
      }
      if (!Number.isFinite(max) && max !== Number.POSITIVE_INFINITY) {
        throw new TypeError(`gainSchedule[${index}].max must be finite or Infinity`);
      }
      if (min >= max) {
        throw new RangeError(`gainSchedule[${index}] min must be smaller than max`);
      }
      [kp, ki, kd].forEach((value, gainIndex) => {
        const label = ['kp', 'ki', 'kd'][gainIndex];
        if (!Number.isFinite(value)) {
          throw new TypeError(`gainSchedule[${index}].${label} must be finite`);
        }
      });
    });

    this.gainSchedule = schedule;
    return this;
  }

  setMode(mode, options = {}) {
    if (mode !== 'automatic' && mode !== 'manual') {
      throw new Error('mode must be either "automatic" or "manual"');
    }

    const nextAuto = mode === 'automatic';
    const previousAuto = this.autoMode;
    this.autoMode = nextAuto;

    if (options && Number.isFinite(options.manualOutput)) {
      this.setManualOutput(options.manualOutput);
    }

    if (!previousAuto && nextAuto) {
      this._initializeForAuto(options);
    }

    return this;
  }

  freeze(options = {}) {
    this.frozen = true;
    this.freezeTrackMeasurement = options.trackMeasurement !== false;
    this.freezeTrackError = Boolean(options.trackError);

    if (Number.isFinite(options.output)) {
      this.setManualOutput(options.output);
    }

    return this;
  }

  unfreeze() {
    this.frozen = false;
    return this;
  }

  isFrozen() {
    return this.frozen;
  }

  setManualOutput(value) {
    this._assertNumeric('manual output', value);
    this.lastOutput = this._clamp(value, this.outputMin, this.outputMax);
    return this.lastOutput;
  }

  reset(state = {}) {
    const {
      integral = 0,
      lastOutput = 0,
      timestamp = null,
      prevMeasurement = null,
      prevError = null,
      prevDerivativeInput = null,
      derivativeState = 0,
    } = state;

    this.integral = this._applyIntegralLimits(Number.isFinite(integral) ? integral : 0);
    this.prevError = Number.isFinite(prevError) ? prevError : null;
    this.prevMeasurement = Number.isFinite(prevMeasurement) ? prevMeasurement : null;
    this.prevDerivativeInput = Number.isFinite(prevDerivativeInput) ? prevDerivativeInput : null;
    this.lastOutput = this._clamp(
      Number.isFinite(lastOutput) ? lastOutput : 0,
      this.outputMin ?? Number.NEGATIVE_INFINITY,
      this.outputMax ?? Number.POSITIVE_INFINITY
    );
    this.lastTimestamp = Number.isFinite(timestamp) ? timestamp : null;
    this.derivativeState = Number.isFinite(derivativeState) ? derivativeState : 0;

    return this;
  }

  update(setpoint, measurement, timestamp = Date.now(), options = {}) {
    if (timestamp && typeof timestamp === 'object' && options && Object.keys(options).length === 0) {
      options = timestamp;
      timestamp = Date.now();
    }

    this._assertNumeric('setpoint', setpoint);
    this._assertNumeric('measurement', measurement);
    this._assertNumeric('timestamp', timestamp);

    const opts = options || {};

    if (opts.tunings && typeof opts.tunings === 'object') {
      this.setTunings(opts.tunings);
    }

    if (Number.isFinite(opts.gainInput)) {
      this._applyGainSchedule(opts.gainInput, { setpoint, measurement, timestamp });
    }

    if (typeof opts.setMode === 'string') {
      this.setMode(opts.setMode, opts);
    }

    if (opts.freeze === true) this.freeze(opts);
    if (opts.unfreeze === true) this.unfreeze();

    if (Number.isFinite(opts.manualOutput)) {
      this.setManualOutput(opts.manualOutput);
    }

    const feedForward = Number.isFinite(opts.feedForward) ? opts.feedForward : 0;
    const force = Boolean(opts.force);

    const error = setpoint - measurement;

    if (!this.autoMode) {
      if (this.trackOnManual) {
        this._trackProcessState(setpoint, measurement, error, timestamp);
      }
      return this.lastOutput;
    }

    if (this.frozen) {
      if (this.freezeTrackMeasurement || this.freezeTrackError) {
        this._trackProcessState(setpoint, measurement, error, timestamp, {
          trackMeasurement: this.freezeTrackMeasurement,
          trackError: this.freezeTrackError,
        });
      }
      return this.lastOutput;
    }

    if (!force && this.lastTimestamp !== null && (timestamp - this.lastTimestamp) < this.sampleTime) {
      return this.lastOutput;
    }

    const elapsedMs = this.lastTimestamp === null ? this.sampleTime : (timestamp - this.lastTimestamp);
    const dtSeconds = Math.max(elapsedMs / 1000, Number.EPSILON);

    const inDeadband = Math.abs(error) <= this.deadband;
    if (inDeadband) {
      this.prevError = error;
      this.prevMeasurement = measurement;
      this.prevDerivativeInput = this.derivativeOnMeasurement
        ? measurement
        : ((this.derivativeWeight * setpoint) - measurement);
      this.lastTimestamp = timestamp;
      return this.lastOutput;
    }

    const effectiveError = error;

    const pInput = (this.setpointWeight * setpoint) - measurement;
    const pTerm = this.kp * pInput;

    const derivativeRaw = this._computeDerivative({ setpoint, measurement, error, dtSeconds });
    this.derivativeState = this.derivativeFilter === 0
      ? derivativeRaw
      : this.derivativeState + (derivativeRaw - this.derivativeState) * (1 - this.derivativeFilter);

    const dTerm = this.kd * this.derivativeState;

    const nextIntegral = this._applyIntegralLimits(this.integral + (effectiveError * dtSeconds));
    let unclampedOutput = pTerm + (this.ki * nextIntegral) + dTerm + feedForward;
    let clampedOutput = this._clamp(unclampedOutput, this.outputMin, this.outputMax);

    if (this.antiWindupMode === 'backcalc' && this.ki !== 0 && this.backCalculationGain > 0) {
      const correctedIntegral = nextIntegral + ((clampedOutput - unclampedOutput) * this.backCalculationGain * dtSeconds);
      this.integral = this._applyIntegralLimits(correctedIntegral);
    } else {
      const saturatingHigh = clampedOutput >= this.outputMax && effectiveError > 0;
      const saturatingLow = clampedOutput <= this.outputMin && effectiveError < 0;
      this.integral = (saturatingHigh || saturatingLow) ? this.integral : nextIntegral;
    }

    let output = pTerm + (this.ki * this.integral) + dTerm + feedForward;
    output = this._clamp(output, this.outputMin, this.outputMax);

    if (this.lastTimestamp !== null) {
      output = this._applyRateLimit(output, this.lastOutput, dtSeconds);
    }

    if (Number.isFinite(opts.trackingOutput)) {
      this._trackIntegralToOutput(opts.trackingOutput, { pTerm, dTerm, feedForward });
      output = this._clamp(opts.trackingOutput, this.outputMin, this.outputMax);
    }

    this.lastOutput = output;
    this.prevError = error;
    this.prevMeasurement = measurement;
    this.prevDerivativeInput = this.derivativeOnMeasurement
      ? measurement
      : ((this.derivativeWeight * setpoint) - measurement);
    this.lastTimestamp = timestamp;

    return this.lastOutput;
  }

  getState() {
    return {
      kp: this.kp,
      ki: this.ki,
      kd: this.kd,
      sampleTime: this.sampleTime,
      outputLimits: { min: this.outputMin, max: this.outputMax },
      integralLimits: { min: this.integralMin, max: this.integralMax },
      derivativeFilter: this.derivativeFilter,
      derivativeOnMeasurement: this.derivativeOnMeasurement,
      setpointWeight: this.setpointWeight,
      derivativeWeight: this.derivativeWeight,
      deadband: this.deadband,
      outputRateLimits: { up: this.outputRateLimitUp, down: this.outputRateLimitDown },
      antiWindupMode: this.antiWindupMode,
      backCalculationGain: this.backCalculationGain,
      autoMode: this.autoMode,
      frozen: this.frozen,
      integral: this.integral,
      derivativeState: this.derivativeState,
      lastOutput: this.lastOutput,
      lastTimestamp: this.lastTimestamp,
    };
  }

  getLastOutput() {
    return this.lastOutput;
  }

  _initializeForAuto(options = {}) {
    const setpoint = Number.isFinite(options.setpoint) ? options.setpoint : null;
    const measurement = Number.isFinite(options.measurement) ? options.measurement : null;
    const timestamp = Number.isFinite(options.timestamp) ? options.timestamp : Date.now();

    if (measurement !== null) {
      this.prevMeasurement = measurement;
    }
    if (setpoint !== null && measurement !== null) {
      this.prevError = setpoint - measurement;
      this.prevDerivativeInput = this.derivativeOnMeasurement
        ? measurement
        : ((this.derivativeWeight * setpoint) - measurement);
    }

    this.lastTimestamp = timestamp;

    if (this.ki !== 0 && setpoint !== null && measurement !== null) {
      const pTerm = this.kp * ((this.setpointWeight * setpoint) - measurement);
      const dTerm = this.kd * this.derivativeState;
      const trackedIntegral = (this.lastOutput - pTerm - dTerm) / this.ki;
      this.integral = this._applyIntegralLimits(Number.isFinite(trackedIntegral) ? trackedIntegral : this.integral);
    }
  }

  _trackProcessState(setpoint, measurement, error, timestamp, tracking = {}) {
    const trackMeasurement = tracking.trackMeasurement !== false;
    const trackError = Boolean(tracking.trackError);

    if (trackMeasurement) {
      this.prevMeasurement = measurement;
      this.prevDerivativeInput = this.derivativeOnMeasurement
        ? measurement
        : ((this.derivativeWeight * setpoint) - measurement);
    }

    if (trackError) {
      this.prevError = error;
    }

    this.lastTimestamp = timestamp;
  }

  _trackIntegralToOutput(trackingOutput, terms) {
    if (this.ki === 0) return;
    const { pTerm, dTerm, feedForward } = terms;
    const targetIntegral = (trackingOutput - pTerm - dTerm - feedForward) / this.ki;
    if (Number.isFinite(targetIntegral)) {
      this.integral = this._applyIntegralLimits(targetIntegral);
    }
  }

  _applyGainSchedule(input, state) {
    if (!this.gainSchedule) return;

    if (typeof this.gainSchedule === 'function') {
      const tunings = this.gainSchedule(input, this.getState(), state);
      if (tunings && typeof tunings === 'object') {
        this.setTunings(tunings);
      }
      return;
    }

    const matched = this.gainSchedule.find((entry) => input >= entry.min && input < entry.max);
    if (matched) {
      this.setTunings({ kp: matched.kp, ki: matched.ki, kd: matched.kd });
    }
  }

  _computeDerivative({ setpoint, measurement, error, dtSeconds }) {
    if (!(dtSeconds > 0) || !Number.isFinite(dtSeconds)) {
      return 0;
    }

    if (this.derivativeOnMeasurement) {
      if (this.prevMeasurement === null) return 0;
      return -(measurement - this.prevMeasurement) / dtSeconds;
    }

    const derivativeInput = (this.derivativeWeight * setpoint) - measurement;
    if (this.prevDerivativeInput === null) return 0;
    const derivativeFromInput = (derivativeInput - this.prevDerivativeInput) / dtSeconds;

    if (Number.isFinite(derivativeFromInput)) {
      return derivativeFromInput;
    }

    if (this.prevError === null) return 0;
    return (error - this.prevError) / dtSeconds;
  }

  _applyRateLimit(nextOutput, previousOutput, dtSeconds) {
    const maxRise = Number.isFinite(this.outputRateLimitUp)
      ? this.outputRateLimitUp * dtSeconds
      : Number.POSITIVE_INFINITY;
    const maxFall = Number.isFinite(this.outputRateLimitDown)
      ? this.outputRateLimitDown * dtSeconds
      : Number.POSITIVE_INFINITY;

    const lower = previousOutput - maxFall;
    const upper = previousOutput + maxRise;
    return this._clamp(nextOutput, lower, upper);
  }

  _applyIntegralLimits(value) {
    if (!Number.isFinite(value)) {
      return 0;
    }

    let result = value;
    if (this.integralMin !== null && result < this.integralMin) {
      result = this.integralMin;
    }
    if (this.integralMax !== null && result > this.integralMax) {
      result = this.integralMax;
    }
    return result;
  }

  _assertNumeric(label, value) {
    if (!Number.isFinite(value)) {
      throw new TypeError(`${label} must be a finite number`);
    }
  }

  _clamp(value, min, max) {
    if (value < min) return min;
    if (value > max) return max;
    return value;
  }
}

/**
 * Cascade PID utility:
 * - primary PID controls the outer variable
 * - primary output becomes setpoint for secondary PID
 */
class CascadePIDController {
  constructor(options = {}) {
    const {
      primary = {},
      secondary = {},
    } = options;

    this.primary = primary instanceof PIDController ? primary : new PIDController(primary);
    this.secondary = secondary instanceof PIDController ? secondary : new PIDController(secondary);
  }

  update({
    setpoint,
    primaryMeasurement,
    secondaryMeasurement,
    timestamp = Date.now(),
    primaryOptions = {},
    secondaryOptions = {},
  } = {}) {
    if (!Number.isFinite(setpoint)) {
      throw new TypeError('setpoint must be a finite number');
    }
    if (!Number.isFinite(primaryMeasurement)) {
      throw new TypeError('primaryMeasurement must be a finite number');
    }
    if (!Number.isFinite(secondaryMeasurement)) {
      throw new TypeError('secondaryMeasurement must be a finite number');
    }

    const secondarySetpoint = this.primary.update(setpoint, primaryMeasurement, timestamp, primaryOptions);
    const controlOutput = this.secondary.update(secondarySetpoint, secondaryMeasurement, timestamp, secondaryOptions);

    return {
      primaryOutput: secondarySetpoint,
      secondaryOutput: controlOutput,
      state: this.getState(),
    };
  }

  setMode(mode, options = {}) {
    this.primary.setMode(mode, options.primary || options);
    this.secondary.setMode(mode, options.secondary || options);
    return this;
  }

  freeze(options = {}) {
    this.primary.freeze(options.primary || options);
    this.secondary.freeze(options.secondary || options);
    return this;
  }

  unfreeze() {
    this.primary.unfreeze();
    this.secondary.unfreeze();
    return this;
  }

  reset(state = {}) {
    this.primary.reset(state.primary || {});
    this.secondary.reset(state.secondary || {});
    return this;
  }

  getState() {
    return {
      primary: this.primary.getState(),
      secondary: this.secondary.getState(),
    };
  }
}

module.exports = {
  PIDController,
  CascadePIDController,
};
