/* BEGIN software license
 *
 * MsXpertSuite - mass spectrometry software suite
 * -----------------------------------------------
 * Copyright 2009--2026 by Filippo Rusconi
 *
 * http://www.msxpertsuite.org
 *
 * This file is part of the MsXpertSuite project.
 *
 * The MsXpertSuite project is the successor of the massXpert project. This
 * project now includes various independent modules:
 *
 * - massXpert, model polymer chemistries and simulate mass spectrometric data;
 * - mineXpert, a powerful TIC chromatogram/mass spectrum viewer/miner;
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <http://www.gnu.org/licenses/>.
 *
 * END software license
 */


/////////////////////// Std includes
#include <vector>
#include <cmath>
#include <numeric>
#include <algorithm>


/////////////////////// Qt includes
#include <QList>


/////////////////////// pappsomspp includes


/////////////////////// Local includes
#include "MsXpS/libXpertMassCore/IsotopicCluster.hpp"
#include "MsXpS/libXpertMassCore/Math/Generic.hpp"
#include "MsXpS/libXpertMassCore/Math/Gaussian.hpp"

namespace MsXpS
{
namespace libXpertMassCore
{


IsotopicCluster::ChargeValidationResults::ChargeValidationResults()
  : centroidCount(0),
    mzSpacingsMean(0),
    mzSpacingsStdDev(0),
    mzSpacingsMin(0),
    mzSpacingsMax(0),
    mzSpacingsMedian(0),
    putativeCharge(0),
    validStatistics(false),
    r2(0.0),
    stdErrorOfSlope(0.0),
    chargeConfidenceIntervalLower(0.0),
    chargeConfidenceIntervalUpper(0.0),
    isChargeInsideIntegerInterval(false),
    pairwiseCoefficientOfVariation(0.0),
    physicallyValid(false),
    ppmRMSE(0.0),
    maxPpmRMSE(5.0),
    minR2(0.98),
    maxPairwiseCV(0.02),
    verdict(Verdict::AMBIGUOUS)
{
}

IsotopicCluster::ChargeValidationResults::ChargeValidationResults(
  const ChargeValidationResults &other)
  : centroidCount(other.centroidCount),
    mzSpacingsMean(other.mzSpacingsMean),
    mzSpacingsStdDev(other.mzSpacingsStdDev),
    mzSpacingsMin(other.mzSpacingsMin),
    mzSpacingsMax(other.mzSpacingsMax),
    mzSpacingsMedian(other.mzSpacingsMedian),
    putativeCharge(other.putativeCharge),
    validStatistics(other.validStatistics),
    r2(other.r2),
    stdErrorOfSlope(other.stdErrorOfSlope),
    chargeConfidenceIntervalLower(other.chargeConfidenceIntervalLower),
    chargeConfidenceIntervalUpper(other.chargeConfidenceIntervalUpper),
    isChargeInsideIntegerInterval(other.isChargeInsideIntegerInterval),
    pairwiseCoefficientOfVariation(other.pairwiseCoefficientOfVariation),
    physicallyValid(other.physicallyValid),
    ppmRMSE(other.ppmRMSE),
    maxPpmRMSE(other.maxPpmRMSE),
    minR2(other.minR2),
    maxPairwiseCV(other.maxPairwiseCV),
    verdict(other.verdict)
{
}

IsotopicCluster::ChargeValidationResults &
IsotopicCluster::ChargeValidationResults::operator=(
  const IsotopicCluster::ChargeValidationResults &other)
{
  if(&other == this)
    return *this;

  centroidCount                  = other.centroidCount;
  mzSpacingsMean                 = other.mzSpacingsMean;
  mzSpacingsStdDev               = other.mzSpacingsStdDev;
  mzSpacingsMin                  = other.mzSpacingsMin;
  mzSpacingsMax                  = other.mzSpacingsMax;
  mzSpacingsMedian               = other.mzSpacingsMedian;
  putativeCharge                 = other.putativeCharge;
  validStatistics                = other.validStatistics;
  r2                             = other.r2;
  stdErrorOfSlope                = other.stdErrorOfSlope;
  chargeConfidenceIntervalLower  = other.chargeConfidenceIntervalLower;
  chargeConfidenceIntervalUpper  = other.chargeConfidenceIntervalUpper;
  isChargeInsideIntegerInterval  = other.isChargeInsideIntegerInterval;
  pairwiseCoefficientOfVariation = other.pairwiseCoefficientOfVariation;
  physicallyValid                = other.physicallyValid;
  ppmRMSE                        = other.ppmRMSE;
  maxPpmRMSE                     = other.maxPpmRMSE;
  minR2                          = other.minR2;
  maxPairwiseCV                  = other.maxPairwiseCV;
  verdict                        = other.verdict;

  return *this;
}

void
IsotopicCluster::ChargeValidationResults::reset()
{
  centroidCount                  = 0;
  mzSpacingsMean                 = 0;
  mzSpacingsStdDev               = 0;
  mzSpacingsMin                  = 0;
  mzSpacingsMax                  = 0;
  mzSpacingsMedian               = 0;
  putativeCharge                 = 0;
  validStatistics                = false;
  r2                             = 0.0;
  stdErrorOfSlope                = 0.0;
  chargeConfidenceIntervalLower  = 0.0;
  chargeConfidenceIntervalUpper  = 0.0;
  isChargeInsideIntegerInterval  = false;
  pairwiseCoefficientOfVariation = 0.0;
  physicallyValid                = false;
  ppmRMSE                        = 0.0;
  // The three below not: they might be set by the user
  // maxPpmRMSE                     = 5.0;
  // minR2                          = 0.98;
  // maxPairwiseCV                  = 0.02;
  // The three above not: they might be set by the user
  verdict                        = Verdict::AMBIGUOUS;
}

QString
IsotopicCluster::ChargeValidationResults::toString(
  Enums::TextType text_type) const
{
  QString verdict_string;
  QString text;

  //     Purple	#800080
  //     Red	  #FF0000
  //     Orange	#FFA700
  //     Yellow	#FFF600
  //     Green	#00FF00

  if(text_type == Enums::TextType::HTML)
    {
      switch(verdict)
        {
          case Verdict::HIGH_CONF_ACCEPT:
            verdict_string = "style='color:#00FF00;'> HIGH CONFIDENCE ACCEPT";
            break;
          case Verdict::LOW_CONF_ACCEPT:
            verdict_string = "style='color:#6c6124;'> LOW CONFIDENCE ACCEPT";
            break;
          case Verdict::REJECT_NOISE_COMB:
            verdict_string = "style='color:#FFA700;'> REJECT (Noise Comb)";
            break;
          case Verdict::REJECT_PURE_NOISE:
            verdict_string = "style='color:#FF0000;'> REJECT (Pure Noise)";
            break;
          default:
            verdict_string = "style='color:#b64824;'> AMBIGUOUS";
        }

      // To ease statistics interpretation colorize this specific bit of info.
      QString charge_confidence_interval_string;

      if(isChargeInsideIntegerInterval)
        {
          charge_confidence_interval_string =
            QString(
              "<tr><td><b>Charge 95% confid.interv.:</b></td><td "
              "style='color:#00FF00;'>[%1-%2]</td></tr>")
              .arg(chargeConfidenceIntervalLower)
              .arg(chargeConfidenceIntervalUpper);
        }
      else
        {
          charge_confidence_interval_string =
            QString(
              "<tr><td><b>Charge 95% confid.interv.:</b></td><td "
              "style='color:#FF0000;'>[%1-%2]</td></tr>")
              .arg(chargeConfidenceIntervalLower)
              .arg(chargeConfidenceIntervalUpper);
        }

      QString r2_string;
      if(r2 > minR2)
        {
          r2_string = QString(
                        "<tr><td><b>R<sup>2</sup>:</b></td><td "
                        "style='color:#00FF00;'>%1</td></tr>")
                        .arg(r2);
        }
      else
        {
          r2_string = QString(
                        "<tr><td><b>R<sup>2</sup>:</b></td><td "
                        "style='color:#FF0000;'>%1</td></tr>")
                        .arg(r2);
        }

      text +=
        QString(
          // clang-format off
          "<table style='width:100%; border-spacing: 4px;'>"
          
          "<tr><td colspan='2' style='color:#4DA6FF; font-weight:bold; font-size:12px;'>Isotopic Cluster Analysis</td></tr>"
          
          "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
          
          "<tr><td><b>Centroids:</b></td><td>%1</td></tr>"
          "<tr><td><b>Candidate charge:</b></td><td>%2</td></tr>"
          "<tr><td><b>Average m/z spacing:</b></td><td style='color:#38320e;'>%3</td></tr>"
          "<tr><td><b>Median of m/z spacings:</b></td><td style='color:#38320e;'>%4</td></tr>"
          "<tr><td><b>Std dev of m/z spacings:</b></td><td style='color:#38320e;'>%5</td></tr>"

          "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
 
          "<tr><td colspan='2' style='color:#4DA6FF; font-weight:bold; font-size:12px;'>Statistical validation</td></tr>"
          "<tr><td><b>Valid:</b></td><td %6</td></tr>"

          "%7"

          "<tr><td><b>Slope std error:</b></td><td style='color:#38320e;'>%8</td></tr>"
          "<tr><td><b>Pairwise coeff. var.:</b></td><td style='color:#38320e;'>%9</td></tr>"

          "%10"

          "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
 
          "<tr><td colspan='2' style='color:#4DA6FF; font-weight:bold; font-size:12px;'>Physical validation</td></tr>"
          "<tr><td><b>Valid:</b></td><td %11</td></tr>"
          "<tr><td><b>ppm RMSE:</b></td><td style='color:#38320e;'>%12</td></tr>"

          "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
          "<tr><td><b>Verdict:</b></td><td %13</td></tr>"
          "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
          
          "</table>")
          // clang-format on
          .arg(centroidCount)
          .arg(putativeCharge)
          .arg(mzSpacingsMean)
          .arg(mzSpacingsMedian)
          .arg(mzSpacingsStdDev)
          .arg(validStatistics ? "style='color:#00FF00;'> YES"
                               : "style='color:#FF0000;'> NO")
          .arg(r2_string)
          .arg(stdErrorOfSlope)
          .arg(pairwiseCoefficientOfVariation)
          .arg(charge_confidence_interval_string)
          .arg(physicallyValid ? "style='color:#00FF00;'> YES"
                               : "style='color:#FF0000;'> NO")
          .arg(ppmRMSE)
          .arg(verdict_string);
    }
  else
    {
      switch(verdict)
        {
          case Verdict::HIGH_CONF_ACCEPT:
            verdict_string = "HIGH CONFIDENCE ACCEPT";
            break;
          case Verdict::LOW_CONF_ACCEPT:
            verdict_string = "LOW CONFIDENCE ACCEPT";
            break;
          case Verdict::REJECT_NOISE_COMB:
            verdict_string = "REJECT (Noise Comb)";
            break;
          case Verdict::REJECT_PURE_NOISE:
            verdict_string = "REJECT (Pure Noise)";
            break;
          default:
            verdict_string = "AMBIGUOUS";
        }

      text += QString(
                // clang-format off
                "Isotopic Cluster Analysis\n"
                "---------------------------"
                "Centroids: %1 | Candidate Charge: %2\n"
                "Mean Spacing: %3 | Median Spacing: %4\n"
                "Spacing Std Dev: %5\n"
                "--- Statistical Validation ---\n"
                "Valid: %6 | R2: %7 (%8)\""
                "Slope Std Err.: %9 | Pairwise Coeff. Var.: %10\n"
                "Charge 95% CI: [%11, %12] (%13)\n"
                "--- Physical Validation ---\n"
                "Valid: %14 | PPM RMSE: %15\n"
                "==========================\n"
                "FINAL VERDICT: %16")
                // clang-format on
                .arg(centroidCount)
                .arg(putativeCharge)
                .arg(mzSpacingsMean, 0, 'f', 5)
                .arg(mzSpacingsMedian, 0, 'f', 5)
                .arg(mzSpacingsStdDev)
                .arg(validStatistics ? "YES" : "NO")
                .arg(r2, 0, 'f', 4)
                .arg(r2 > minR2 ? "success" : "failure")
                .arg(stdErrorOfSlope, 0, 'e', 2)
                .arg(pairwiseCoefficientOfVariation, 0, 'f', 4)
                .arg(chargeConfidenceIntervalLower, 0, 'f', 2)
                .arg(chargeConfidenceIntervalUpper, 0, 'f', 2)
                .arg(isChargeInsideIntegerInterval ? "success" : "failure")
                .arg(physicallyValid ? "YES" : "NO")
                .arg(ppmRMSE, 0, 'f', 2)
                .arg(verdict_string);
    }

  return text;
}

// Using QMap's initializer list constructor (Qt 5.5+)
QMap<int, QString> IsotopicCluster::averageMassDeterminationMethodMap = {
  {static_cast<int>(IsotopicCluster::AverageMassDeterminationMethod::NOT_SET),
   "NOT_SET"},
  {static_cast<int>(
     IsotopicCluster::AverageMassDeterminationMethod::MZ_WEIGHTED_INTENSITIES),
   "MZ_WEIGHTED_INTENSITIES"},
  {static_cast<int>(
     IsotopicCluster::AverageMassDeterminationMethod::CLUSTER_MODE),
   "CLUSTER_MODE"},
  {static_cast<int>(IsotopicCluster::AverageMassDeterminationMethod::
                      GAUSSIAN_FIT_LEAST_SQUARES),
   "GAUSSIAN_FIT_LEAST_SQUARES"},
  {static_cast<int>(
     IsotopicCluster::AverageMassDeterminationMethod::GAUSSIAN_FIT_LEV_MARQ),
   "GAUSSIAN_FIT_LEV_MARQ"},
  {static_cast<int>(IsotopicCluster::AverageMassDeterminationMethod::LAST),
   "LAST"},
};

IsotopicCluster::IsotopicCluster(QObject *parent): QObject(parent)
{
}

IsotopicCluster::IsotopicCluster(const pappso::Trace &profile_trace,
                                 QObject *parent)
  : QObject(parent), m_profileTrace(profile_trace)
{
}

IsotopicCluster::IsotopicCluster(pappso::Trace &&profile_trace, QObject *parent)
  : QObject(parent), m_profileTrace(std::move(profile_trace))
{
}

IsotopicCluster::IsotopicCluster(const pappso::Trace &profile_trace,
                                 const pappso::Trace &centroided_trace,
                                 QObject *parent)
  : QObject(parent),
    m_profileTrace(profile_trace),
    m_centroidedTrace(centroided_trace)
{
}

IsotopicCluster::IsotopicCluster(pappso::Trace &&profile_trace,
                                 pappso::Trace &&centroided_trace,
                                 QObject *parent)
  : QObject(parent),
    m_profileTrace(std::move(profile_trace)),
    m_centroidedTrace(std::move(centroided_trace))
{
}

IsotopicCluster::IsotopicCluster(const IsotopicCluster &other, QObject *parent)
  : QObject(parent),
    m_profileTrace(other.m_profileTrace),
    m_centroidedTrace(other.m_centroidedTrace),
    m_gaussianFitTrace(other.m_gaussianFitTrace),
    m_charge(other.m_charge),
    m_avgMz(other.m_avgMz),
    m_avgMr(other.m_avgMr),
    m_chargeValidationResults(other.m_chargeValidationResults),
    m_highResPeakPickerParameters(other.m_highResPeakPickerParameters),
    m_localSignalToNoiseEstimatorParameters(
      other.m_localSignalToNoiseEstimatorParameters)
{
}

void
IsotopicCluster::setHighResPeakPickerParameters(
  const Processing::HighResPeakPickerParameters
    &high_res_peak_picker_parameters)
{
  m_highResPeakPickerParameters = high_res_peak_picker_parameters;
}

void
IsotopicCluster::setLocalSignalToNoiseEstimatorParameters(
  const Processing::LocalSignalToNoiseEstimator::Parameters
    &local_signal_to_noise_estimator_parameters)
{
  m_localSignalToNoiseEstimatorParameters =
    local_signal_to_noise_estimator_parameters;
}

void
IsotopicCluster::setProfileTrace(const pappso::Trace &profile_trace)
{
  m_profileTrace = profile_trace;
}

void
IsotopicCluster::setProfileTrace(pappso::Trace &&profile_trace)
{
  m_profileTrace = std::move(profile_trace);
}

const pappso::Trace &
IsotopicCluster::getProfileTrace() const
{
  return m_profileTrace;
}

const pappso::Trace &
IsotopicCluster::getCentroidedTrace() const
{
  return m_centroidedTrace;
}

const pappso::Trace &
IsotopicCluster::getGaussianFitTrace() const
{
  return m_gaussianFitTrace;
}

int
IsotopicCluster::extractCentroids()
{
  if(!m_profileTrace.size())
    return -1;

  Processing::HighResPeakPicker picker(m_highResPeakPickerParameters,
                                       m_localSignalToNoiseEstimatorParameters);

  QList<Processing::HighResPeakPicker::PeakRange> peak_ranges;

  picker.pick(m_profileTrace, m_centroidedTrace, peak_ranges);

  return m_centroidedTrace.size();
}

void
IsotopicCluster::setAveragine(const Averagine &averagine)
{
  m_averagine.initialize(averagine);
}

const Averagine &
IsotopicCluster::getAveragine() const
{
  return m_averagine;
}

QString
IsotopicCluster::getAvgMassDeterminationMethod(int method)
{
  return averageMassDeterminationMethodMap.value(method);
}

QString
IsotopicCluster::getAvgMassDeterminationMethod(
  AverageMassDeterminationMethod method)
{
  return getAvgMassDeterminationMethod(
    static_cast<std::underlying_type_t<AverageMassDeterminationMethod>>(
      method));
}

/*!
\brief Returns the charge computed as a rounded value of (1 / mean of m/z
spacings) in the centroids.

If the member container of centroids has less than 2 values, the computation
cannot be performed and -1 is returned, while \a statistics is left unchanged.

If the mean of the m/z spacings is not 0, then the computation can go through
and \a statistics is updated with the computed values.
*/
int
IsotopicCluster::determineCharge(
  ChargeValidationResults &charge_validation_results)
{
  m_charge = 0;

  if(!m_centroidedTrace.size())
    {
      if(!m_profileTrace.size())
        return -1;
      extractCentroids();
    }

  // At this point use the available centroids.

  // Compute the critical mean value as a local variable. If
  // that value is not 0, then, we will update the statistics param.

  // Get the m/z values of the centroided trace
  std::vector<double> mz_values = m_centroidedTrace.xValues();
  int centroid_count            = mz_values.size();

  // qDebug() << "We now have" << centroid_count
  //          << "m/z values from the centroided trace.";

  // We need at least 2 centroids to compute a m/z spacing value.
  if(centroid_count < 2)
    {
      qDebug() << "Cannot determine the charge value.";
      return m_charge;
    }

  // Now compute all the m/z spacings. The charge is the inverse of
  // the computed spacings mean value.
  QList<double> mz_spacings;
  for(int iter = 1; iter < centroid_count; ++iter)
    mz_spacings.append(mz_values.at(iter) - mz_values.at(iter - 1));

  int mz_spacings_count = mz_spacings.size();

  // qDebug() << "We could compute" << mz_spacings_count << "m/z spacing
  // values.";

  double mz_spacings_mean =
    std::accumulate(mz_spacings.begin(), mz_spacings.end(), 0.0) /
    mz_spacings_count;

  // qDebug() << "The computed m/z spacing mean value:" << mz_spacings_mean;

  if(!mz_spacings_mean)
    {
      qDebug() << "The m/z spacing mean value is 0. Cannot compute statistics.";
      return m_charge;
    }

  // Now we know we will update the statistics.

  m_chargeValidationResults.reset();

  m_chargeValidationResults.centroidCount  = centroid_count;
  m_chargeValidationResults.mzSpacingsMean = mz_spacings_mean;

  double variance =
    std::accumulate(
      mz_spacings.begin(),
      mz_spacings.end(),
      0.0,
      [this](double acc, double val) {
        return acc +
               std::pow(val - m_chargeValidationResults.mzSpacingsMean, 2);
      }) /
    mz_spacings_count;

  m_chargeValidationResults.mzSpacingsStdDev = std::sqrt(variance);

  auto [min_it, max_it] =
    std::minmax_element(mz_spacings.begin(), mz_spacings.end());
  m_chargeValidationResults.mzSpacingsMin = *min_it;
  m_chargeValidationResults.mzSpacingsMax = *max_it;

  QList<double> sorted_vals = mz_spacings;
  std::nth_element(sorted_vals.begin(),
                   sorted_vals.begin() + mz_spacings_count / 2,
                   sorted_vals.end());
  m_chargeValidationResults.mzSpacingsMedian =
    sorted_vals[mz_spacings_count / 2];


  // charge = std::round(1 / m_statistics.mzSpacingsMean);

  // Apparently, the median value is much more tolerant to
  // noise.
  double charge_as_double = 1 / m_chargeValidationResults.mzSpacingsMedian;

  m_charge = std::round(charge_as_double);

  // qDebug() << qSetRealNumberPrecision(5)
  //          << "The charge as double:" << charge_as_double
  //          << "and the charge after rounding:" << m_charge;

  charge_validation_results = m_chargeValidationResults;

  return m_charge;
}

int
IsotopicCluster::getCharge() const
{
  return m_charge;
}

void
IsotopicCluster::computeLinearRegression(const std::vector<double> &mz_values,
                                         double &slope,
                                         double &intercept,
                                         double &r2,
                                         double &se_slope)
{
  int centroid_count = mz_values.size();
  if(centroid_count < 2)
    {
      slope = intercept = r2 = se_slope = 0;
      return;
    }

  double sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
  for(int i = 0; i < centroid_count; ++i)
    {
      sumX  += i;
      sumY  += mz_values[i];
      sumXY += i * mz_values[i];
      sumX2 += i * i;
      sumY2 += mz_values[i] * mz_values[i];
    }

  double denom = (centroid_count * sumX2 - sumX * sumX);
  if(denom == 0)
    {
      slope = intercept = r2 = se_slope = 0;
      return;
    }

  slope     = (centroid_count * sumXY - sumX * sumY) / denom;
  intercept = (sumY - slope * sumX) / centroid_count;

  double ss_tot = sumY2 - (sumY * sumY) / centroid_count;
  double ss_res = 0;
  for(int i = 0; i < centroid_count; ++i)
    {
      double pred  = intercept + slope * i;
      ss_res      += std::pow(mz_values[i] - pred, 2);
    }

  r2 = (ss_tot == 0) ? 1.0 : 1.0 - (ss_res / ss_tot);

  if(centroid_count > 2)
    {
      double mse = ss_res / (centroid_count - 2);
      se_slope   = std::sqrt(mse / (sumX2 - (sumX * sumX) / centroid_count));
    }
  else
    {
      se_slope = 0; // Cannot compute SE for n=2
    }
}

double
IsotopicCluster::computePpmRMSE(const std::vector<double> &observed_mz,
                                int z_cand)
{
  if(observed_mz.empty() || z_cand == 0)
    return 1e9;

  double mono = observed_mz[0];

  // 1.003355 is the exact mass difference between 13C and 12C
  double delta = 1.003355 / z_cand;

  double sum_sq_err = 0;
  for(size_t i = 0; i < observed_mz.size(); ++i)
    {
      double theo     = mono + i * delta;
      double err_ppm  = std::abs(observed_mz[i] - theo) / theo * 1e6;
      sum_sq_err     += err_ppm * err_ppm;
    }

  return std::sqrt(sum_sq_err / observed_mz.size());
}

double
IsotopicCluster::computePairwiseCV(const std::vector<double> &mz_spacings)
{
  if(mz_spacings.size() < 2)
    return 1.0;

  double mean = std::accumulate(mz_spacings.begin(), mz_spacings.end(), 0.0) /
                mz_spacings.size();

  if(mean == 0)
    return 1.0;

  double var = 0;

  for(double spacing : mz_spacings)
    var += std::pow(spacing - mean, 2);

  var /= mz_spacings.size();

  return std::sqrt(var) / mean;
}

int
IsotopicCluster::determineChargeAdvanced(
  ChargeValidationResults &charge_validation_results)
{
  // We need to reset the values that we may modify (not those that the
  // user might have configured (see reset()).
  charge_validation_results.reset();

  m_charge = 0;

  if(!m_centroidedTrace.size())
    {
      if(!m_profileTrace.size())
        return m_charge;

      extractCentroids();
    }

  std::vector<double> mz_values           = m_centroidedTrace.xValues();
  int mz_value_count                      = mz_values.size();
  charge_validation_results.centroidCount = mz_value_count;

  if(mz_value_count < 2)
    {
      charge_validation_results.verdict =
        ChargeValidationResults::Verdict::REJECT_PURE_NOISE;
      return m_charge;
    }

  // --- Phase 1: Candidate Generation ---
  std::vector<double> mz_spacings;

  for(int iter = 1; iter < mz_value_count; ++iter)
    {
      mz_spacings.push_back(mz_values[iter] - mz_values[iter - 1]);
    }

  int mz_spacings_count = mz_spacings.size();

  charge_validation_results.mzSpacingsMean =
    std::accumulate(mz_spacings.begin(), mz_spacings.end(), 0.0) /
    mz_spacings_count;

  double variance =
    std::accumulate(
      mz_spacings.begin(),
      mz_spacings.end(),
      0.0,
      [charge_validation_results](double acc, double val) {
        return acc +
               std::pow(val - charge_validation_results.mzSpacingsMean, 2);
      }) /
    mz_spacings_count;

  charge_validation_results.mzSpacingsStdDev = std::sqrt(variance);

  std::vector<double> sorted_spacings = mz_spacings;

  std::nth_element(sorted_spacings.begin(),
                   sorted_spacings.begin() + sorted_spacings.size() / 2,
                   sorted_spacings.end());

  charge_validation_results.mzSpacingsMedian =
    sorted_spacings[sorted_spacings.size() / 2];

  charge_validation_results.putativeCharge =
    std::round(1.0 / charge_validation_results.mzSpacingsMedian);

  if(charge_validation_results.putativeCharge <= 0)
    charge_validation_results.putativeCharge = 1; // Fallback

  // --- Phase 2: Statistical Validation ---
  if(mz_value_count >= 4)
    {
      double slope, intercept, r2, se_slope;
      computeLinearRegression(mz_values, slope, intercept, r2, se_slope);

      charge_validation_results.r2              = r2;
      charge_validation_results.stdErrorOfSlope = se_slope;

      // Calculate 95% Confidence Interval for charge
      bool bounds_valid = false;

      if(slope > 0 && se_slope >= 0)
        {
          double denom_lower =
            slope + 1.96 * se_slope; // Upper bound of spacing
          double denom_upper =
            slope - 1.96 * se_slope; // Lower bound of spacing

          // Ensure we don't divide by zero or negative numbers
          if(denom_lower > 0 && denom_upper > 0)
            {
              // Use the exact 13C-12C mass difference (ISOTOPIC_NEUTRON_MASS)
              // instead of 1.0!

              charge_validation_results.chargeConfidenceIntervalLower =
                ISOTOPIC_NEUTRON_MASS / denom_lower;
              charge_validation_results.chargeConfidenceIntervalUpper =
                ISOTOPIC_NEUTRON_MASS / denom_upper;

              bounds_valid = true;
            }
        }

      charge_validation_results.isChargeInsideIntegerInterval = false;
      bool is_narrow_enough                                   = false;

      if(bounds_valid)
        {
          // 1. Check the width of the Confidence Interval
          double ci_width =
            charge_validation_results.chargeConfidenceIntervalUpper -
            charge_validation_results.chargeConfidenceIntervalLower;
          is_narrow_enough = (ci_width <= 1.0);

          // 2. Check if the CI is unambiguously assigned to ONE charge state.
          // In real-world MS, the measured spacing is rarely exactly the
          // theoretical spacing due to centroiding jitter, space-charge
          // effects, or slight calibration errors. This means the calculated
          // charge is almost never exactly an integer (e.g., it might be 30.03
          // instead of 30.00).
          //
          // If we demand that the 95% CI strictly *contains* the integer, we
          // will reject perfectly good data (like the observed [30.02, 30.04]
          // interval on synthetic mass spectrum of telokin charged 30+).
          //
          // Instead, we check if the CI is completely contained within the
          // +/- 0.5 "basin of attraction" of the nearest integer.

          double mean_charge         = ISOTOPIC_NEUTRON_MASS / slope;
          int nearest_integer_charge = std::round(mean_charge);

          double basin_lower = nearest_integer_charge - 0.5;
          double basin_upper = nearest_integer_charge + 0.5;

          charge_validation_results.isChargeInsideIntegerInterval =
            (charge_validation_results.chargeConfidenceIntervalLower >=
             basin_lower) &&
            (charge_validation_results.chargeConfidenceIntervalUpper <=
             basin_upper);

          // qDebug() << "CI Width:" << ci_width
          //          << "| Is narrow enough:" << is_narrow_enough
          //          << "| Mean charge:" << mean_charge
          //          << "| Nearest integer:" << nearest_integer_charge
          //          << "| Basin: [" << basin_lower << ", " << basin_upper <<
          //          "]"
          //          << "| CI: ["
          //          << charge_validation_results.chargeConfidenceIntervalLower
          //          << ", "
          //          << charge_validation_results.chargeConfidenceIntervalUpper
          //          << "]"
          //          << "| Is unambiguous:"
          //          <<
          //          charge_validation_results.isChargeInsideIntegerInterval;

          // We use 'in_inside_the_mz_basin' as the final statistical check
          charge_validation_results.validStatistics =
            (charge_validation_results.r2 > charge_validation_results.minR2) &&
            bounds_valid && is_narrow_enough &&
            charge_validation_results.isChargeInsideIntegerInterval;
        }
      else
        {
          qDebug() << "Confidence interval bounds could not be calculated (SE "
                      "too large).";
          charge_validation_results.validStatistics = false;
        }

      // Final Statistical Validation Decision
      charge_validation_results.validStatistics =
        (charge_validation_results.r2 > charge_validation_results.minR2) &&
        bounds_valid && is_narrow_enough &&
        charge_validation_results.isChargeInsideIntegerInterval;

      // qDebug() << "The valid statistics:"
      //          << (charge_validation_results.r2 >
      //              charge_validation_results.minR2);
      // qDebug() << "The valid statistics:" << bounds_valid;
      // qDebug() << "The valid statistics:" << is_narrow_enough;
      // qDebug() << "The valid statistics:"
      //          << charge_validation_results.isChargeInsideIntegerInterval;

      // qDebug() << "The valid statistics:"
      //          << charge_validation_results.validStatistics;
    }
  else
    {
      // n <= 3: Use Pairwise Statistics
      charge_validation_results.pairwiseCoefficientOfVariation =
        computePairwiseCV(mz_spacings);

      charge_validation_results.validStatistics =
        (charge_validation_results.pairwiseCoefficientOfVariation <
         charge_validation_results.maxPairwiseCV);

      // qDebug() << "The valid statistics:"
      //          << charge_validation_results.validStatistics;
    }

  // --- Phase 3: Physical Validation ---

  charge_validation_results.ppmRMSE =
    computePpmRMSE(mz_values, charge_validation_results.putativeCharge);

  charge_validation_results.physicallyValid =
    (charge_validation_results.ppmRMSE < charge_validation_results.maxPpmRMSE);

  // --- Phase 4: Decision Matrix ---
  if(charge_validation_results.validStatistics &&
     charge_validation_results.physicallyValid)
    {
      charge_validation_results.verdict =
        ChargeValidationResults::Verdict::HIGH_CONF_ACCEPT;
    }
  else if(!charge_validation_results.validStatistics &&
          charge_validation_results.physicallyValid)
    {
      charge_validation_results.verdict =
        ChargeValidationResults::Verdict::LOW_CONF_ACCEPT;
    }
  else if(charge_validation_results.validStatistics &&
          !charge_validation_results.physicallyValid)
    {
      charge_validation_results.verdict =
        ChargeValidationResults::Verdict::REJECT_NOISE_COMB;
    }
  else
    {
      charge_validation_results.verdict =
        ChargeValidationResults::Verdict::REJECT_PURE_NOISE;
    }

  m_charge = charge_validation_results.putativeCharge;

  // We store everything because we may need to write a string.
  m_chargeValidationResults = charge_validation_results;

  return m_charge;
}

std::tuple<double, double, double>
IsotopicCluster::computelinearLeastSquaresGaussianForCentroids()
{

  if(!m_centroidedTrace.size())
    return std::tuple{0, 0, 0};

  std::tuple<double, double, double> result_tuple =
    Math::Gaussian::linearLeastSquaresFit(m_centroidedTrace,
                                          m_gaussianFitTrace);

  // qDebug() << "The Gaussian fit gave - mu (avg mass):"
  //          << std::get<0>(result_tuple)
  //          << " - intensity:" << std::get<1>(result_tuple)
  //          << " - FWHM:" << std::get<2>(result_tuple);

  return result_tuple;
}

std::tuple<double, double, double, double>
IsotopicCluster::computeLevenbergMarquardtGaussianForCentroids()
{
  std::tuple<double, double, double, double> levenberg_marquardt_fit =
    Math::Gaussian::levenbergMarquardtFit(m_centroidedTrace,
                                          m_gaussianFitTrace);

  return levenberg_marquardt_fit;
}

double
IsotopicCluster::determineAvgMz(
  AverageMassDeterminationMethod determination_method)
{
  // The member datum can never be set to NOT_SET
  Q_ASSERT(m_avgMassDeterminationMethod !=
           AverageMassDeterminationMethod::NOT_SET);

  if(determination_method != AverageMassDeterminationMethod::NOT_SET)
    {
      m_avgMassDeterminationMethod = determination_method;
    }

  if(m_avgMassDeterminationMethod ==
     AverageMassDeterminationMethod::MZ_WEIGHTED_INTENSITIES)
    {
      return determineAvgMzWeightedIntensities();
    }
  if(m_avgMassDeterminationMethod ==
     AverageMassDeterminationMethod::CLUSTER_MODE)
    {
      return determineAvgMzClusterMode();
    }
  if(m_avgMassDeterminationMethod ==
       AverageMassDeterminationMethod::GAUSSIAN_FIT_LEAST_SQUARES ||
     m_avgMassDeterminationMethod ==
       AverageMassDeterminationMethod::GAUSSIAN_FIT_LEV_MARQ)
    {
      return determineAvgMzGaussianFit(m_avgMassDeterminationMethod);
    }

  m_avgMassDeterminationMethod = AverageMassDeterminationMethod::NOT_SET;
  m_avgMz                      = 0.0;
  return m_avgMz;
}

double
IsotopicCluster::determineAvgMzWeightedIntensities()
{
  // qDebug() << "";

  // This is the weighted center of mass computation.

  std::tuple<double, double, double, double> results_tuple =
    Math::Generic::weightedCenterOfMass(m_centroidedTrace);

  m_avgMz = std::get<0>(results_tuple);

  return m_avgMz;
}

double
IsotopicCluster::determineAvgMzClusterMode()
{
  if(!m_charge)
    return 0.0;

  // The average m/z of an isotopic cluster is the mode of the cluster, that is,
  // the m/z value of the highest intensity peak in that cluster.

  m_avgMz = m_centroidedTrace.maxYDataPoint().x;

  // qDebug() << qSetRealNumberPrecision(10) << "Returning m_avgMz:" << m_avgMz;

  return m_avgMz;
}

double
IsotopicCluster::determineAvgMzGaussianFit(
  AverageMassDeterminationMethod method)
{
  // qDebug() << "";

  if(m_centroidedTrace.size() < 4)
    {
      m_avgMz = 0.0;

      return m_avgMz;
    }

  if(method == AverageMassDeterminationMethod::GAUSSIAN_FIT_LEAST_SQUARES)
    {

      std::tuple<double, double, double> gaussian_results_tuple =
        Math::Gaussian::linearLeastSquaresFit(m_centroidedTrace,
                                              m_gaussianFitTrace);

      // The Avg m/z
      m_avgMz = std::get<0>(gaussian_results_tuple);

      qDebug() << qSetRealNumberPrecision(10)
               << "Returning m_avgMz:" << m_avgMz;
    }
  else if(method == AverageMassDeterminationMethod::GAUSSIAN_FIT_LEV_MARQ)
    {
      std::tuple<double, double, double, double> gaussian_results_tuple =
        Math::Gaussian::levenbergMarquardtFit(m_centroidedTrace,
                                              m_gaussianFitTrace);

      // The Avg m/z
      m_avgMz = std::get<0>(gaussian_results_tuple);

      qDebug() << qSetRealNumberPrecision(10)
               << "Returning m_avgMz:" << m_avgMz;
    }
  else
    qFatal() << "Programming error. Should not have reached this point.";

  return m_avgMz;
}

double
IsotopicCluster::getAvgMz()
{
  return m_avgMz;
}

double
IsotopicCluster::determineAvgMr(
  AverageMassDeterminationMethod determination_method)
{
  // The member datum can never be set to NOT_SET
  Q_ASSERT(m_avgMassDeterminationMethod !=
           AverageMassDeterminationMethod::NOT_SET);

  if(!m_avgMz)
    {
      // Simplest, we set the method and calculate avg m/z ex nihilo

      if(determination_method != AverageMassDeterminationMethod::NOT_SET)
        {
          // Use the method that was last used (if any) otherwise
          // it should be the default one anyways (see Q_ASSERT above).
          // Thus no change is needed.
          m_avgMassDeterminationMethod = determination_method;
        }

      determineAvgMz(m_avgMassDeterminationMethod);

      if(!m_avgMz)
        {
          m_avgMr = 0.0;
          return m_avgMr;
        }
    }

  // At this point, either we have computed m_avgMz right above, or it was
  // computed in a previous call to estimateAvgMz().

  if(m_avgMassDeterminationMethod == determination_method ||
     determination_method == AverageMassDeterminationMethod::NOT_SET)
    {
      // Either the user calls the function with the same method as a previous
      // call, or there is not setting and we consider that the previous setting
      // is the one we want to use. So just get to the average Mr.

      return computeAvgMr();
    }

  // The last possibility is that the requested calculation method here
  // conflicts with the previous method. Best is to set m_avgMz to 0.0 and
  // recall the function. This way, we go back to square start with the
  // determination method passed as param. If it is NOT_SET then that will be
  // handled in the function called below (which is this function, first
  // condition with m_avgMz == 0).

  qCritical() << "Attention, first estimation method for average m/z is "
                 "not consistent with asked estimation method for average Mr.";

  m_avgMz = 0.0;

  return determineAvgMr(determination_method);
}

double
IsotopicCluster::computeAvgMr()
{
  if(!m_avgMz)
    {
      determineAvgMz(m_avgMassDeterminationMethod);

      if(!m_avgMz)
        {
          m_avgMr = 0.0;
          return m_avgMr;
        }
    }

  // m_avgMr = (m_avgMz * m_charge) - (m_charge * PROTON_MASS);
  // Factorize:-)
  m_avgMr = m_charge * (m_avgMz - PROTON_MASS);

  return m_avgMr;
}

double
IsotopicCluster::getAvgMr()
{
  return m_avgMr;
}

double
IsotopicCluster::estimateMonoMz(
  AverageMassDeterminationMethod determination_method)
{
  // Sanity check.
  Q_ASSERT(m_avgMassDeterminationMethod !=
           AverageMassDeterminationMethod::NOT_SET);

  if(!m_charge)
    {
      m_monoMz = 0.0;
      m_monoMr = 0.0;
      return m_monoMr;
    }

  // This estimation needs to be performed once the mono Mr has been estimated.

  if(!m_monoMr)
    {
      estimateMonoMr(determination_method);

      if(!m_monoMr)
        {
          m_monoMz = 0.0;
          m_monoMr = 0.0;
          return m_monoMr;
        }
    }

  // Because we do have m_monoMz, we just need to convert that to a m/z
  // using charge.

  m_monoMz = (m_monoMr + (m_charge * PROTON_MASS)) / m_charge;
  return m_monoMz;
}

double
IsotopicCluster::estimateMonoMz(const Averagine &averagine)
{
  estimateMonoMr(averagine);

  if(!m_monoMr)
    return 0.0;

  m_monoMz = (m_monoMr + (m_charge * PROTON_MASS)) / m_charge;

  return m_monoMz;
}

double
IsotopicCluster::estimateMonoMr(
  AverageMassDeterminationMethod determination_method)
{
  // Sanity check.
  Q_ASSERT(m_avgMassDeterminationMethod !=
           AverageMassDeterminationMethod::NOT_SET);

  AverageMassDeterminationMethod local_determination_method =
    determination_method;

  // The monoisotopic Mr leverages the average m/z & Mr values that need to
  // have been determined using a compatible determination method.
  if(local_determination_method == AverageMassDeterminationMethod::NOT_SET)
    {
      // The parameter has the default value. Use the member datum.

      local_determination_method = m_avgMassDeterminationMethod;
    }

  if(!m_avgMz || !m_avgMr)
    {
      // We first have to determine the average masses. Use the proper method.

      determineAvgMz(local_determination_method);

      if(!m_avgMz || !m_avgMr)
        {
          m_monoMz = m_monoMr = 0.0;
          return m_monoMr;
        }
    }

  // Good, we have the average Mr. Use it!

  if(local_determination_method == AverageMassDeterminationMethod::CLUSTER_MODE)
    return estimateClusterModeMonoMr();

  return estimateMonoMr(m_averagine);
}

double
IsotopicCluster::estimateMonoMr(const Averagine &averagine)
{
  if(!averagine.isValid())
    {
      m_monoMr = 0.0;
      m_monoMz = 0.0;
      return m_monoMr;
    }

  m_averagine.initialize(averagine);

  if(!m_avgMz || !m_avgMr)
    {
      determineAvgMr(m_avgMassDeterminationMethod);

      if(!m_avgMz || !m_avgMr)
        {
          m_monoMr = 0.0;
          return m_monoMr;
        }
    }

  bool ok  = false;
  m_monoMr = m_averagine.modelMonoMass(m_avgMr, ok);

  if(!ok)
    return 0.0;

  return m_monoMr;
}

double
IsotopicCluster::estimateClusterModeMonoMr()
{
  // qDebug() << "";

  // We need to check that the data we already have and that we need
  // are compatible with the cluster mode determination of the
  // average mass.

  if(!m_avgMz || !m_avgMr ||
     m_avgMassDeterminationMethod !=
       AverageMassDeterminationMethod::CLUSTER_MODE)
    {
      determineAvgMr(AverageMassDeterminationMethod::CLUSTER_MODE);

      if(!m_avgMr)
        {
          m_monoMz = 0.0;
          m_monoMr = 0.0;
          return m_monoMr;
        }
    }

  // Now that we know we have the average Mr computed using the cluster mode,
  // we can estimate the monoisotopic Mr.

  // Now do the actual mono Mr estimation using the Cluster Mode method.

  double first_approx_mono_mr = m_avgMr * 0.999316;

  // double second_approx_mono_mr = m_avgMr - n * 1.003
  // where n is the smallest integer such that M2 ≥ M1

  // Seed the while loop with n = 0
  int n = 0;

  while(m_avgMr - (n * 1.003) >= first_approx_mono_mr)
    ++n;

  // qDebug() << "Found n to be:" << n;

  double second_approx_mono_mr = m_avgMr - n * 1.003;

  // If there is in the cluster data a centroid which has a
  // Mr value <= second_approx_mono_mr + 1, then
  // m_monoMr = second_approx_mono_mr + 1.003
  // else
  // m_monoMr = second_approx_mono_mr

  double right_margin = second_approx_mono_mr + 1;

  n = m_centroidedTrace.size() - 1;

  double last_valid_centroid_mr = 0;

  while(n >= 0)
    {
      if(m_centroidedTrace.at(n).x <= right_margin &&
         m_centroidedTrace.at(n).x >= second_approx_mono_mr)
        {
          last_valid_centroid_mr = m_centroidedTrace.at(n).x;
          break;
        }

      --n;
    }

  if(last_valid_centroid_mr)
    {
      m_monoMr = second_approx_mono_mr + 1.003;
    }
  else
    {
      m_monoMr = second_approx_mono_mr;
    }

  return m_monoMr;
}

QString
IsotopicCluster::toString(OutputStringElements output_string_elements,
                          Enums::TextType text_type) const
{
  QString text;

  if(static_cast<bool>(output_string_elements &
                       OutputStringElements::CHARGE_VALIDATION_RESULTS))
    text += m_chargeValidationResults.toString(text_type);

  if(static_cast<bool>(output_string_elements &
                       OutputStringElements::INPUT_ISOTOPIC_CLUSTER))
    text += m_profileTrace.toString();

  if(static_cast<bool>(output_string_elements &
                       OutputStringElements::OUTPUT_ISOTOPIC_CLUSTER))
    text += m_centroidedTrace.toString();

  if(static_cast<bool>(output_string_elements &
                       OutputStringElements::MASS_ESTIMATIONS))
    {

      if(text_type == Enums::TextType::TEXT)
        {
          text += QString(
                    "Avg m/z: %1 - mass (Mr): %2\n"
                    "Mono m/z: %4 - mass (Mr): %4")
                    .arg(m_avgMz, 0, 'f', 5)
                    .arg(m_avgMr, 0, 'f', 5)
                    .arg(m_monoMz, 0, 'f', 5)
                    .arg(m_monoMr, 0, 'f', 5);
        }
      else if(text_type == Enums::TextType::HTML)
        {

          text +=
            QString(
              // clang-format off
              "<table style='width:100%; border-spacing: 4px;'>"
              
              "<tr><td colspan='2' style='color:#4DA6FF; font-weight:bold; font-size:12px;'>Modelled masses</td></tr>"
              
              "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
              
              "<tr><td><b>Avg m/z:</b></td><td>%1</td></tr>"
              "<tr><td><b>Avg Mr:</b></td><td>%2</td></tr>"

              "<tr><td><b>Mono m/z:</b></td><td>%3</td></tr>"
              "<tr><td><b>Mono Mr:</b></td><td>%4</td></tr>"
              
              "<tr><td colspan='2'><hr style='border:1px solid #444;'/></td></tr>"
              
              "</table>")
              // clang-format on
              .arg(m_avgMz, 0, 'f', 5)
              .arg(m_avgMr, 0, 'f', 5)
              .arg(m_monoMz, 0, 'f', 5)
              .arg(m_monoMr, 0, 'f', 5);
        }
    }

  return text;
}

} // namespace libXpertMassCore
} // namespace MsXpS
