/**
 * @file Predict_class.js
 *
 * Permission is hereby granted to any person obtaining a copy of this software 
 * and associated documentation files (the "Software"), to use it for personal 
 * or non-commercial purposes, with the following restrictions:
 *
 * 1. **No Copying or Redistribution**: The Software or any of its parts may not 
 *    be copied, merged, distributed, sublicensed, or sold without explicit 
 *    prior written permission from the author.
 * 
 * 2. **Commercial Use**: Any use of the Software for commercial purposes requires 
 *    a valid license, obtainable only with the explicit consent of the author.
 * 
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 * FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, 
 * OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
 * SOFTWARE.
 *
 * Ownership of this code remains solely with the original author. Unauthorized 
 * use of this Software is strictly prohibited.
 *
 * @summary Class for predicting values based on a multidimensional curve.
 * @description Class for predicting values based on a multidimensional curve.
 * @module Predict_class
 * @requires EventEmitter
 * @requires ConfigUtils
 * @requires Interpolation
 * @requires Logger
 * @exports Predict
 * @version 0.1.0
 * @since 0.1.0
 * 
 * Author:
 * - Rene De Ren
 * Email:
 * - rene@thegoldenbasket.nl
 * Future Improvements:
- Add more interpolation types
- **Local Derivative (Slope)**: Instantaneous rate of change (dY/dX) at the current X. Useful for determining if the curve is ascending or descending.
- **Second Derivative (Curvature)**: Curvature (d²Y/dX²) at the current X. Indicates how quickly the slope is changing (e.g., sharp or broad peaks).
- **Distance to Nearest Local Peak or Valley**: X-distance from the current X to the closest local maximum or minimum. Useful for detecting proximity to turning points.
- **Global Statistics (Mean, Median, Std Dev)**:
  - Mean: Average of Y.
  - Median: Middle Y value (sorted).
  - Std Dev: Variability of Y. Provides insight into central tendency and spread, aiding in normalization or anomaly detection.
- **Integrated Area Under the Curve (AUC)**: Numerical integration of Y across the X-range. Useful for total sums or energy-related calculations.
- **Peak “Sharpness” or “Prominence”**: Measure of a peak's height and width relative to surrounding valleys. Important for signal processing or optimization.
- **Nearest Points Around Current X**: Data points (or interpolated values) immediately to the left and right of the current X. Useful for local interpolation or neighbor analysis.
- **Forecast / Extrapolation**: Estimated Y values outside the known X-range. Useful for exploring scenarios slightly beyond the data range (use with caution).
- **Peak Count**: Total number of local maxima in the curve. Useful for identifying all peaks and their prominence.
- **Position Relative to Mean (or Other Reference Lines)**: Distance (in percent or absolute value) of the current Y from a reference line (e.g., mean or median). Provides context relative to average or baseline levels.
- **Local Slope Trend**: Direction of the slope (up, down, or flat) at the current X. Useful for identifying trends or inflection points.
- **Local Curvature Trend**: Direction of the curvature (concave up, concave down, or flat) at the current X. Useful for identifying inflection points or turning points.
- **Local Peak-to-Valley Ratio**: Ratio of the current peak height to the nearest valley depth. Useful for identifying peak prominence or sharpness.
- ** Keep track of previous request and next request to identify slope and curvature
 */

const EventEmitter = require('events');
const Logger = require('../helper/logger.js');
const defaultConfig = require('./predictConfig.json');
const ConfigUtils = require('../helper/configUtils');
const Interpolation = require('./interpolation');

class Predict {
  constructor(config = {}) {

    // Capture share-source BEFORE config validation strips it (ConfigUtils
    // mutates the input config to drop unknown keys, which would remove
    // shareInputsFrom because it's not in predictConfig.json's schema).
    // Detach on a shallow clone so validateSchema doesn't see the key at all
    // — leaving it on the input would emit a `[interpolation] Unknown key
    // 'shareInputsFrom'` warning per group-predictor on every curve refresh.
    const _sharedSource = (config && config.shareInputsFrom instanceof Predict)
      ? config.shareInputsFrom
      : null;
    let _initConfig = config;
    if (_initConfig && 'shareInputsFrom' in _initConfig) {
      _initConfig = { ..._initConfig };
      delete _initConfig.shareInputsFrom;
    }

    // Initialize dependencies
    this.emitter = new EventEmitter();  // Own EventEmitter
    this.configUtils = new ConfigUtils(defaultConfig);
    this.config = this.configUtils.initConfig(_initConfig);

    // Init after config is set
    this.logger = new Logger(this.config.general.logging.enabled,this.config.general.logging.logLevel, this.config.general.name);
    this.interpolation = new Interpolation(this.config.interpolation);

    // Input and state
    this.inputCurve = {};  
    this.currentF = 0;
    this.currentX = 0;
    this.outputY = 0;

    // Curves and Splines
    this.normalizedCurve = {};
    this.calculatedCurve = {};
    this.fCurve = {};
    this.currentFxyCurve = {};
    this.normalizedSplines = {};
    this.fSplines = {};
    this.currentFxySplines = {};

    // Stored min/max values
    this.xValues = {};
    this.fValues = {};
    this.yValues = {};
    this.currentFxyXMin = 0;
    this.currentFxyXMax = 0;
    this.currentFxyYMin = 0;
    this.currentFxyYMax = 0;

    // From config
    this.normMin = this.config.normalization.parameters.min;
    this.normMax = this.config.normalization.parameters.max;
    this.calculationPoints = this.config.normalization.parameters.curvePoints;
    this.interpolationType = this.config.interpolation.type;

    // Load curve if provided.
    //   shareInputsFrom: an existing Predict instance whose pre-built input
    //   curves and splines we adopt by reference. Used to create a parallel
    //   "view" of the same source curves (e.g. an MGC group-scope predict
    //   that mirrors a pump's individual predict). Per-instance state —
    //   currentF / currentX / currentFxyCurve / currentFxySplines /
    //   currentFxyY/X Min/Max / outputY — stays freshly initialised so the
    //   two views have independent operating points. Curve mutations on the
    //   source via updateCurve() are propagated through the source's
    //   "curveUpdated" emitter (see updateCurve below).
    if (_sharedSource) {
      this._adoptInputsFrom(_sharedSource);
      this._sharedInputsSource = _sharedSource;
      this._sharedInputsHandler = (newCurve) => {
        this._adoptInputsFrom(this._sharedInputsSource);
        // Keep our currentF in range; constrain re-uses the new fValues.
        this.fDimension = this.constrain(this.currentF, this.fValues.min, this.fValues.max);
      };
      this._sharedInputsSource.emitter.on('curveUpdated', this._sharedInputsHandler);
      // Initialise our own operating point to the source's min, same as
      // the standard buildAllFxyCurves flow does at end of curve load.
      this.fDimension = this.fValues.min;
    } else if (config.curve) {
      this.inputCurveData = config.curve;
    } else {
      this.logger.warn("No curve data provided. Please set curve data using setCurveData method. Using default");
      this.inputCurveData = this.config.curve;
    }

  }

  // Adopt another Predict's input curves and splines by reference. Used by
  // the shareInputsFrom constructor option and by the curveUpdated emitter
  // handler to re-sync after the source's curves change. Does NOT touch
  // per-instance state (currentF, currentX, currentFxy* etc.).
  //
  // Also copies the scalar parameters (calculationPoints, normMin/Max,
  // interpolationType) so the clone uses the SAME pointsCount the source
  // built fSplines with — otherwise buildSingleFxyCurve can iterate past
  // the end of the shared fSplines.
  _adoptInputsFrom(source) {
    this.inputCurve        = source.inputCurve;
    this.normalizedCurve   = source.normalizedCurve;
    this.calculatedCurve   = source.calculatedCurve;
    this.fCurve            = source.fCurve;
    this.fSplines          = source.fSplines;
    this.normalizedSplines = source.normalizedSplines;
    this.xValues           = source.xValues;
    this.fValues           = source.fValues;
    this.yValues           = source.yValues;
    this.calculationPoints = source.calculationPoints;
    this.normMin           = source.normMin;
    this.normMax           = source.normMax;
    this.interpolationType = source.interpolationType;
  }
  
  // Improved function to get a local peak in an array by starting in the middle.
  // It also handles the case of a tie by preferring the left side (arbitrary choice)
  // when array[start] == leftValue or array[start] == rightValue.
  getLocalPeak(array) {
    if (!Array.isArray(array) || array.length === 0) {
      return { peak: null, peakIndex: -1 };
    }
  
    let left = 0;
    let right = array.length - 1;
  
    while (left <= right) {
      const mid = Math.floor((left + right) / 2);
      
      // Safely retrieve left/right neighbor values (use -Infinity if out of bounds)
      const leftVal = mid - 1 >= 0 ? array[mid - 1] : -Infinity;
      const rightVal = mid + 1 < array.length ? array[mid + 1] : -Infinity;
      const currentVal = array[mid];
  
      // Check if mid is a local peak
      if (currentVal >= leftVal && currentVal >= rightVal) {
        return { peak: currentVal, peakIndex: mid };
      }
      
      // If left neighbor is bigger, move left
      if (leftVal > currentVal) {
        right = mid - 1;
      } 
      // Otherwise, move right
      else {
        left = mid + 1;
      }
    }
  
    // If no local peak is found
    return { peak: null, peakIndex: -1 };
  }

  // Function what uses the peak in the y array to return the yPeak, x value and its procentual value
  getPosXofYpeak(curve) {
    
    //find index of y peak
    const { peak , peakIndex } = this.getLocalPeak(curve.y);

    // Guard against invalid peakIndex (e.g. empty array returns -1)
    if (peakIndex < 0 || peakIndex >= curve.x.length) {
      return { yPeak: null, x: null, xProcent: null };
    }

    // scale the x value to procentual value
    const yPeak = peak;
    const x = curve.x[peakIndex];
    const xMin = Math.min(...curve.x);
    const xMax = Math.max(...curve.x);
    const xProcent = (x - xMin) / (xMax - xMin) * 100;

    return { yPeak, x, xProcent };
  }

  calcRelativePositionToPeak(curve , outputY) {
    
    //find y peak
    const { peak } = this.getLocalPeak(curve.y);

    if ( peak === null ) {
      this.logger.warn("No peak found in curve");
      return -1;
    }

    // Calculate the "peak-only" percentage:
    //    - Distance from peak, relative to peak itself
    //    - 0% => outputY == peak,  100% => outputY == 0 (if peak != 0)
    let peakOnlyPercentage;
    const distanceFromPeak = Math.abs(peak - outputY);
    if (peak === 0) {
      // If peak is 0, then the concept of "peak-only" percentage is tricky.
      // If outputY is also 0 => 0%, otherwise => Infinity.
      peakOnlyPercentage = distanceFromPeak === 0 ? 0 : Number.POSITIVE_INFINITY;
    } else {
      peakOnlyPercentage = (distanceFromPeak / peak) * 100;
    }

    // Calculate the range-based percentage:
    //  - Range = [yMin, peak]
    //  - 0% => outputY == peak,  100% => outputY == yMin
    const yMin = Math.min(...curve.y);
    let rangeBasedPercentage = -1;

    // If peak <= yMin, there is no vertical range for normalization
    if (peak > yMin) {
      const distanceFromPeakRange = peak - outputY; // Not absolute
      const totalRange = peak - yMin;
      rangeBasedPercentage = (distanceFromPeakRange / totalRange) * 100;

      // Optionally clamp to [0, 100] if outputY goes out of bounds
      rangeBasedPercentage = Math.max(0, Math.min(100, rangeBasedPercentage));
    }

    return {
      peakOnlyPercentage: Math.round(peakOnlyPercentage * 100) / 100,
      rangeBasedPercentage: Math.round(rangeBasedPercentage * 100) / 100
    };

  }

    // Function to retrieve current curve including the interpolated active point
  retrieveActiveCurve(){

    // Retreive y values
    const yValues = this.currentFxyCurve[this.fDimension].y;
    // Retreive normalized x values
    const xValues = this.denormalizeXvals( this.currentFxyCurve[this.fDimension].x );

    //check what the current x value is
    const currentX = this.currentX;

    //check current y Output value 
    const outputY = this.outputY;

    //find where the current x value should be in the xValues array
    const index = xValues.findIndex((x) => x > currentX);

    // push the yOutput value in the yValues array between the current x value
    yValues.splice(index, 0, outputY);
    xValues.splice(index, 0, currentX);

    return { xValues, yValues };
  }

  set fDimension(newF) {
    
        if (newF < this.fValues.min || newF > this.fValues.max) {
          this.logger.warn(`New f =${newF} is constrained to fit between min=${this.fValues.min} and max=${this.fValues.max}`);
          newF = this.constrain(newF,this.fValues.min,this.fValues.max); 
        }

      if (newF in this.calculatedCurve) {
        this.currentFxyCurve[newF] = this.calculatedCurve[newF];
        this.currentFxySplines = this.normalizedSplines;
      } else {
        this.currentFxyCurve = this.buildSingleFxyCurve(
          this.fSplines, 
          this.calculatedCurve, 
          newF, 
          this.calculationPoints
        );
        this.currentFxySplines = this.buildXySplines(this.currentFxyCurve, this.interpolationType);
      }

      const yArray = this.currentFxyCurve[newF].y;
      this.currentFxyYMin = Math.min(...yArray);
      this.currentFxyYMax = Math.max(...yArray);

      this.calculateFxyXRange(newF);

      this.currentF = newF;
      this.logger.debug(`Calculating new yValue using X= ${this.currentX}`);

      // Recalculate output y based on currentX 
      this.y(this.currentX);

  }

  get fDimension() {
    return this.currentF;
  }

  // Function to predict Y value based on X value
  y(x) {

      // Clamp value before normalization
      if (x > this.currentFxyXMax) x = this.currentFxyXMax;
      if (x < this.currentFxyXMin) x = this.currentFxyXMin;

      //keep track of current x value
      this.currentX = x;

      this.logger.debug(`Interpolating x using input=${x} , currentFxyXmin=${this.currentFxyXMin}, currentFxyXMax=${this.currentFxyXMax}, normMin=${this.normMin}, normMax=${this.normMax} `);
      
      const normalizedX = this.interpolation.interpolate_lin_single_point(
        x,
        this.currentFxyXMin,
        this.currentFxyXMax,
        this.normMin,
        this.normMax
      );

      this.logger.debug(`Calculating new Y value using ${normalizedX}`);

      this.outputY = this.currentFxySplines[this.fDimension].interpolate(normalizedX);
      
      return this.outputY;

  }

  set yOutput(y) {
    this.outputY = y;
    //by emitting this one output we dont have to use the entire class
    this.emitter.emit('yOutput', this.outputY);
  }

  get yOutput() {
    return this.outputY;
  }

  set inputCurveData(curve) {
    try {
      this.inputCurve = curve;
      this.buildAllFxyCurves(curve);
    } catch (error) {
      this.logger.error(`Curve validation failed: ${error.message}`);
      this.inputCurve = null; // Reset curve data if validation fails
    }
  }

  get inputCurveData() {
    return this.inputCurve;
  }

  updateCurve(curve) {

    this.logger.info("Updating curve data");
    // update config with new curve data merged with existing config
    const newConfig = {...this.config, curve: curve};
    this.config = this.configUtils.updateConfig(newConfig);

    const validatedCurve = this.config.curve;
    this.inputCurve = validatedCurve;

    this.buildAllFxyCurves(validatedCurve);

    // Notify shared-input clones (see shareInputsFrom in the constructor).
    // They re-adopt our inputs and clamp their own operating point.
    this.emitter.emit('curveUpdated', validatedCurve);
  }

  constrain(value,min,max) {
    return Math.min(Math.max(value, min), max);
  }

  buildAllFxyCurves(curve) {

    let globalMinY = Infinity;
    let globalMaxY = -Infinity;

    for (const fKey of Object.keys(curve)) {
      const f = Number(fKey);
      this.xValues[f] = {
        min: Math.min(...curve[f].x),
        max: Math.max(...curve[f].x),
      };

      const fMinY = Math.min(...curve[f].y);
      const fMaxY = Math.max(...curve[f].y);

      if (fMinY < globalMinY) globalMinY = fMinY;
      if (fMaxY > globalMaxY) globalMaxY = fMaxY;

      // Normalize curves
      this.normalizedCurve[f] = this.normalizeCurve(curve[f], this.normMin, this.normMax);
      
    }

    this.normalizedSplines = this.buildXySplines(this.normalizedCurve, this.interpolationType);

    // Build calculated curves (same #points across all f)
    for (const f of Object.keys(this.normalizedCurve)) {
      this.calculatedCurve[f] = this.buildCalculatedCurve(this.normalizedSplines, f, this.calculationPoints);
    }

    this.fCurve = this.buildFCurve(this.calculatedCurve, this.calculationPoints);
    this.fSplines = this.buildFSplines(this.fCurve, this.interpolationType);

    const fKeys = Object.keys(curve).map(Number);
    this.fValues.min = Math.min(...fKeys);
    this.fValues.max = Math.max(...fKeys);

    this.yValues.lowest = globalMinY;
    this.yValues.highest = globalMaxY;

    // Set initial fDimension to min
    this.fDimension = this.fValues.min;
    this.logger.debug(` !!!   Initial fDimension set to ${this.fValues.min}`);
  }

  normalizeVal(val, normMin, normMax) {
    return this.interpolation.interpolate_lin_single_point(val, normMin, normMax, 1, this.calculationPoints);
  }

  normalizeCurve(curve, normMin, normMax) {
    return {
      x: this.interpolation.interpolate_lin_curve_points(curve.x, normMin, normMax),
      y: curve.y,
    };
  }

  denormalizeXvals(xValues) {
    // Retrieve the normalized x-array from the current Fxy curve
    const normalizedX = xValues;
  
    // Map each normalized x to its denormalized value
    const denormalizedX = normalizedX.map(nx => {
      return this.interpolation.interpolate_lin_single_point(
        nx,
        this.normMin,
        this.normMax,
        this.currentFxyXMin,
        this.currentFxyXMax
      );
    });
  
    // Return a new object with denormalized x and the original y array
    return denormalizedX;
  }

  // interpolate input x value to denormalized x value
  denormalizeX(x) {
    return this.interpolation.interpolate_lin_single_point(
      x,
      this.normMin,
      this.normMax,
      this.currentFxyXMin,
      this.currentFxyXMax
    );
  }

  buildCalculatedCurve(splines, f, pointsCount) {
    const cCurve = { x: [], y: [] };
    for (let i = 1; i <= pointsCount; i++) {
      const nx = this.interpolation.interpolate_lin_single_point(i, 1, pointsCount, this.normMin, this.normMax);
      cCurve.x.push(nx);
      cCurve.y.push(splines[f].interpolate(nx));
    }
    return cCurve;
  }

  buildFCurve(curve, pointsCount) {
    const fCurve = {};
    for (let i = 0; i < pointsCount; i++) {
      fCurve[i] = { x: [], y: [] };
    }

    for (let i = 0; i < pointsCount; i++) {
      for (const [f, val] of Object.entries(curve)) {
        fCurve[i].x.push(Number(f));
        fCurve[i].y.push(val.y[i]);
      }
    }
    return fCurve;
  }

  buildFSplines(fCurve, type) {
    const fSplines = {};
    for (const i of Object.keys(fCurve)) {
      fSplines[i] = this.loadSpline(fCurve[i], type);
    }
    return fSplines;
  }

  buildSingleFxyCurve(fSplines, cCurve, f, pointsCount) {
    const singleCurve = { [f]: { x: [], y: [] } };
    const keys = Object.keys(cCurve);
    const firstKey = keys[0];

    for (let i = 0; i < pointsCount; i++) {
      singleCurve[f].x.push(cCurve[firstKey].x[i]);
      singleCurve[f].y.push(fSplines[i].interpolate(f));
    }

    return singleCurve;
  }

  buildXySplines(curves, type) {
    const xySplines = {};
    for (const f of Object.keys(curves)) {
      xySplines[f] = this.loadSpline(curves[f], type);
    }
    return xySplines;
  }

  loadSpline(curve, type) {
    const splineObj = new Interpolation();
    splineObj.load_spline(curve.x, curve.y, type);
    return splineObj;
  }

  calculateFxyXRange(value) {

    const keys = Object.keys(this.inputCurve).map(Number).sort((a, b) => a - b);
    for (let i = 0; i < keys.length; i++) {
      const cur = keys[i];
      const next = keys[i + 1];

      if (value === cur) {        
        this.currentFxyXMin = this.xValues[cur].min;
        this.currentFxyXMax = this.xValues[cur].max;
        return;
      }

      if (next && value > cur && value < next) {
        this.currentFxyXMin = this.interpolation.interpolate_lin_single_point(
          value, cur, next, this.xValues[cur].min, this.xValues[next].min
        );
        this.currentFxyXMax = this.interpolation.interpolate_lin_single_point(
          value, cur, next, this.xValues[cur].max, this.xValues[next].max
        );
        return;
      }
    }
  }

  getOutput() {
    return {
      x: this.currentX,
      y: this.yOutput,
      f: this.currentF,
      yOutputPosVsPeak: {
        peakOnlyPercentage: this.calcRelativePositionToPeak(this.currentFxyCurve[this.fDimension], this.outputY).peakOnlyPercentage,
        rangeBasedPercentage: this.calcRelativePositionToPeak(this.currentFxyCurve[this.fDimension], this.outputY).rangeBasedPercentage
      },
      posXyPeak: this.getPosXofYpeak(this.currentFxyCurve[this.fDimension]),
      xRange: { min: this.currentFxyXMin, max: this.currentFxyXMax },
      yRange: { min: this.currentFxyYMin, max: this.currentFxyYMax },
    };
  }
}

module.exports = Predict;

/*
// Example usage
let example = 
{ 
  0:  
  { 
      x:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 
      y:[5, 15, 25, 35, 45, 55, 45, 35, 25, 15],
  },
  100: 
  { 
    x:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 
    y:[50, 150, 250, 350, 450, 550, 450, 350, 250, 150],
  }
}

//set curve data in config
let config = {curve:example};

var predict = new Predict(config=config);

console.log(" showing curve data");
console.log(predict.inputCurveData);

console.log(" showing config data");
console.log(predict.config);

// specify dimension f if there is no dim f then specify 0 as example 2
console.log(" showing config data");
console.log(predict.config);

console.log(`lowest y value ever seen : ${predict.yValues.lowest}`);
console.log(`higehst y value ever seen : ${predict.yValues.highest}`);

predict.fDimension = 0;

console.log(`default x : ${predict.currentX}`);
console.log(`min x : ${predict.currentFxyXMin} , max x : ${predict.currentFxyXMax} for f : ${predict.fDimension}`);
console.log(`min y : ${predict.currentFxyYMin} , max y : ${predict.currentFxyYMax} for f : ${predict.fDimension}`);
console.log(`Y prediction is= ${predict.outputY} @ f : ${predict.fDimension} `);

// specify x value to predict y
const yVal = predict.y(x=0);
console.log(`For x : ${predict.currentX} is the predicted value ${yVal} @ f : ${predict.fDimension} `);
console.log(predict.retrieveActiveCurve());
const peak = predict.getLocalPeak(predict.currentFxyCurve[predict.fDimension].y);

console.log(predict.getPosXofYpeak(predict.currentFxyCurve[predict.fDimension]));

const { peakOnlyPercentage, rangeBasedPercentage } = predict.calcRelativePositionToPeak(predict.currentFxyCurve[predict.fDimension], predict.outputY);
console.log(`Peak-only percentage: ${peakOnlyPercentage}%, Range-based percentage: ${rangeBasedPercentage}%`);


//*/
