/* BEGIN software license
 *
 * MsXpertSuite - mass spectrometry software suite
 * -----------------------------------------------
 * Copyright (C) 2009--2026 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
 */


/////////////////////// stdlib includes


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


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


/////////////////////// Local includes
#include "MsXpS/libXpertMassCore/AbstractLowMassDeconvolver.hpp"
#include "MsXpS/libXpertMassCore/IsotopicClusterGenerator.hpp"

namespace MsXpS
{
namespace libXpertMassCore
{

/*!
\struct MsXpS::libXpertMassCore::AbstractLowMassDeconvolver::Parameters
\inmodule libXpertMassCore
\ingroup XpertMassCoreMassCalculations
\inheaderfile IsotopicData.hpp

\brief The AbstractLowMassDeconvolver::Parameters struct encapsulates all the
parameters needed to configure the low mass deconvolving process.
*/

/*!
\variable
MsXpS::libXpertMassCore::AbstractLowMassDeconvolver::Parameters::minCharge

\brief The minimum charge deconvolved mass peaks may have.
*/

/*!
\variable
MsXpS::libXpertMassCore::AbstractLowMassDeconvolver::Parameters::maxCharge

\brief The maximum charge deconvolved mass peaks may have.
*/

/*!
\variable
MsXpS::libXpertMassCore::AbstractLowMassDeconvolver::Parameters::minMass

\brief The minimum mass deconvolved mass peaks may have.
*/

/*!
\variable
MsXpS::libXpertMassCore::AbstractLowMassDeconvolver::Parameters::maxMass

\brief The maximum mass deconvolved mass peaks may have.
*/

/*!
\brief Constructs a AbstractLowMassDeconvolver::Parameters empty instance.
*/
AbstractLowMassDeconvolver::Parameters::Parameters()
{
}

/*!
\brief Constructs a AbstractLowMassDeconvolver::Parameters instance with all the
parameters.

\list

\li \a min_charge: minimum charge of the deconvolved peak
\li \a max_charge: maximum charge of the deconvolved peak
\li \a min_mass: minimum mass of the deconvolved peak
\li \a max_mass: maximum mass of the deconvolved peak
\li \a ppm_mass_tolerance: tolerance (in parts-per-million) for the mass matches
\li \a min_intensity: minimum intensity of the deconvolved mass peak
\li \a min_isotopic_cluster_matches: minimum count of matches to be obtained
\li \a max_isotopic_cluster_matches: maximum count of matches that are monitored
\li \a cluster_shape_centroid_count: the number of cluster centroids used to
compute the relative intensity ratios;
\li \a isotopic_data_file_path: file path to the isotopic data file
\li \a processing_details_log_file_path: file path to which the processing
details are logged
\li \a averagine_formula_string: formula of the Averagine describing the average
composition of the analytes
\li \a explained_intensity_threshold: threshold below which matches between
theoretical data and input mass spectral data are rejected
\li \a relative_cluster_shape_tolerance: tolerance above which matches are
rejected
\li \a min_pearson_correlation_score: threshold below which matches are rejected

\endlist

\a isotopic_data_file_path cannot be empty
and must point to an existing and valid file. The isotopic data are
loaded from file and a pointer to these is stored in the object.

\a averagine_formula_string must contain a valid formula, as it is validated
against the isotopic data. The mass of the averagine formula (neutral formula)
is calculated and stored in the object.

Any failure with these assumptions triggers a qFatal() call.

 */
AbstractLowMassDeconvolver::Parameters::Parameters(
  int min_charge,
  int max_charge,
  double min_mass,
  double max_mass,
  double ppm_mass_tolerance,
  double min_intensity,
  int min_isotopic_cluster_matches,
  int max_isotopic_cluster_matches,
  int cluster_shape_centroid_count,
  const Averagine &averagine,
  bool full_heavy_isotope_labelling,
  const QString &processing_details_log_file_path,
  double explained_intensity_threshold,
  double relative_cluster_shape_tolerance,
  double min_pearson_correlation_score)
  : minCharge(min_charge),
    maxCharge(max_charge),
    minMass(min_mass),
    maxMass(max_mass),
    ppmMassTolerance(ppm_mass_tolerance),
    minIntensity(min_intensity),
    minIsotopicClusterMatches(min_isotopic_cluster_matches),
    maxIsotopicClusterMatches(max_isotopic_cluster_matches),
    clusterShapeCentroidCount(cluster_shape_centroid_count),
    averagine(averagine),
    fullHeavyIsotopeLabelling(full_heavy_isotope_labelling),
    processingDetailsLogFilePath(processing_details_log_file_path),
    totalClusterIntensityTolerance(explained_intensity_threshold),
    clusterShapeTolerance(relative_cluster_shape_tolerance),
    minPearsonCorrelationScore(min_pearson_correlation_score)
{
  qDebug() << "In the all-args constructor.";

  if(!averagine.isValid())
    qFatal() << "The Averagine instance is not valid.";

  qDebug() << "Finished constructing the parameters:" << toString();
}

/*!
\brief Constructs an instance using \a other as a template.
*/
AbstractLowMassDeconvolver::Parameters::Parameters(const Parameters &other)
{
  // qDebug() << "In the copy constructor, other:" << other.toString();

  minCharge                 = other.minCharge;
  maxCharge                 = other.maxCharge;
  minMass                   = other.minMass;
  maxMass                   = other.maxMass;
  ppmMassTolerance          = other.ppmMassTolerance;
  minIntensity              = other.minIntensity;
  minIsotopicClusterMatches = other.minIsotopicClusterMatches;
  maxIsotopicClusterMatches = other.maxIsotopicClusterMatches;
  clusterShapeCentroidCount = other.clusterShapeCentroidCount;

  // qDebug() << "Other averagine:" << other.averagine.toString();

  averagine.initialize(other.averagine);
  if(!averagine.isValid())
    qFatal() << "The Averagine instance is not valid.";

  fullHeavyIsotopeLabelling      = other.fullHeavyIsotopeLabelling;
  processingDetailsLogFilePath   = other.processingDetailsLogFilePath;
  totalClusterIntensityTolerance = other.totalClusterIntensityTolerance;
  clusterShapeTolerance          = other.clusterShapeTolerance;
  minPearsonCorrelationScore     = other.minPearsonCorrelationScore;

  // qDebug() << "The Averagine constructed instance:" << toString();
}

/*!
\brief Initializes this instance using \a other as a template.

Returns a reference to this instance.
*/
AbstractLowMassDeconvolver::Parameters &
AbstractLowMassDeconvolver::Parameters::operator=(const Parameters &other)
{
  // qDebug() << "In the operator=(), other:" << other.toString();

  if(&other == this)
    return *this;

  minCharge             = other.minCharge;
  maxCharge             = other.maxCharge;
  minMass               = other.minMass;
  maxMass               = other.maxMass;
  ppmMassTolerance      = other.ppmMassTolerance;
  minIntensity          = other.minIntensity;
  minIsotopicClusterMatches    = other.minIsotopicClusterMatches;
  maxIsotopicClusterMatches    = other.maxIsotopicClusterMatches;
  clusterShapeCentroidCount    = other.clusterShapeCentroidCount;

  // qDebug() << "Other averagine:" << other.averagine.toString();

  averagine.initialize(other.averagine);
  if(!averagine.isValid())
    qFatal() << "The Averagine instance is not valid.";

  fullHeavyIsotopeLabelling      = other.fullHeavyIsotopeLabelling;
  processingDetailsLogFilePath   = other.processingDetailsLogFilePath;
  totalClusterIntensityTolerance = other.totalClusterIntensityTolerance;
  clusterShapeTolerance = other.clusterShapeTolerance;
  minPearsonCorrelationScore   = other.minPearsonCorrelationScore;

  return *this;
}

/*!
\brief Initializes this instance using the parameters

\list

\li \a min_charge: minimum charge of the deconvolved peak
\li \a max_charge: maximum charge of the deconvolved peak
\li \a min_mass: minimum mass of the deconvolved peak
\li \a max_mass: maximum mass of the deconvolved peak
\li \a ppm_mass_tolerance: tolerance (in parts-per-million) for the mass matches
\li \a min_intensity: minimum intensity of the deconvolved mass peak
\li \a min_isotopic_cluster_matches: the minimum count of isotopic cluster
matches
\li \a max_isotopic_cluster_matches: the maximum count of isotopic cluster
matches
\li \a isotopic_data_file_path: file path to the isotopic data file
\li \a processing_details_log_file_path: file path to which the processing
details are logged
\li \a averagine_formula_string: formula of the Averagine describing the average
composition of the analytes
\li \a explained_intensity_threshold: threshold below which matches between
theoretical data and input mass spectral data are rejected
\li \a relative_cluster_shape_tolerance: tolerance above which matches are
rejected
\li \a min_pearson_correlation_score: threshold below which matches are rejected

\endlist

\a isotopic_data_file_path cannot be empty
and must point to an existing and valid file. The isotopic data are
loaded from file and a pointer to these is stored in the object.

\a averagine_formula_string must contain a valid formula, as it is validated
against the isotopic data. The mass of the averagine formula (neutral formula)
is calculated and stored in the object.

Any failure with these assumptions triggers a qFatal() call.
*/
void
AbstractLowMassDeconvolver::Parameters::initialize(
  int min_charge,
  int max_charge,
  double min_mass,
  double max_mass,
  double ppm_mass_tolerance,
  double min_intensity,
  int min_isotopic_cluster_matches,
  int max_isotopic_cluster_matches,
  int cluster_shape_centroid_count,
  const Averagine &the_averagine,
  bool full_heavy_isotope_labelling,
  const QString &processing_details_log_file_path,
  double explained_intensity_threshold,
  int relative_cluster_shape_tolerance,
  double min_pearson_correlation_score)
{
  minCharge             = min_charge;
  maxCharge             = max_charge;
  minMass               = min_mass;
  maxMass               = max_mass;
  ppmMassTolerance      = ppm_mass_tolerance;
  minIntensity          = min_intensity;
  minIsotopicClusterMatches    = min_isotopic_cluster_matches;
  maxIsotopicClusterMatches    = max_isotopic_cluster_matches;
  clusterShapeCentroidCount    = cluster_shape_centroid_count;

  averagine.initialize(the_averagine);
  if(!averagine.isValid())
    qFatal() << "The Averagine instance is not valid.";

  fullHeavyIsotopeLabelling      = full_heavy_isotope_labelling;
  processingDetailsLogFilePath   = processing_details_log_file_path;
  totalClusterIntensityTolerance = explained_intensity_threshold;
  clusterShapeTolerance = relative_cluster_shape_tolerance;
  minPearsonCorrelationScore   = min_pearson_correlation_score;
}

/*!
\brief Crafts a text string describing the parameters and returns it.
*/
QString
AbstractLowMassDeconvolver::Parameters::toString() const
{
  QString text;
  text +=
    QString(
      "\nmin charge: %1 - "
      "max charge: %2 - "
      "min mass: %3 - "
      "max mass: %4 - "
      "mass tolerance (ppm): %5 - "
      "min intensity: %6 - "
      "min isotopic cluster centroid matches: %7 - "
      "max isotopic cluster centroid matches: %8 - "
      "cluster shape centroid count: %9 - "
      "full heavy isotope labelling: %10 - "
      "averagine isotopic data file path: %11 - "
      "averagine formula: '%12' - "
      "averagine mono mass: %13 - "
      "averagine avg mass: %14 - "
      "explained intensity threshold: %15 - "
      "min fit percentage for isotopic cluster shape: %16 - "
      "min score: %17\n")
      .arg(minCharge)
      .arg(maxCharge)
      .arg(minMass)
      .arg(maxMass)
      .arg(ppmMassTolerance, 0, 'f', 5)
      .arg(minIntensity)
      .arg(minIsotopicClusterMatches)
      .arg(maxIsotopicClusterMatches)
      .arg(clusterShapeCentroidCount)
      .arg(fullHeavyIsotopeLabelling ? "yes" : "no")
      .arg(averagine.getIsotopicData()->getFilePath())
      .arg(averagine.getFormulaCstRef().getActionFormula(true /*with_title*/))
      .arg(averagine.getMonoMass(), 0, 'f', 5)
      .arg(averagine.getAvgMass(), 0, 'f', 5)
      .arg(totalClusterIntensityTolerance, 0, 'f', 2)
      .arg(clusterShapeTolerance)
      .arg(minPearsonCorrelationScore);
  return text;
}

/*!
\brief Constructs a LowMassDeconvolver instance using \a parameters.

The \a parameters structure members are used to initialize the isotopic data and
the averagine formula. Any failure to do so triggers qFatal() calls.
*/
AbstractLowMassDeconvolver::AbstractLowMassDeconvolver(Parameters &parameters)
  : m_params(parameters)
{
  // qDebug() << "Got parameters:" << m_params.toString();

  if(!m_params.averagine.isValid())
    qFatal() << "The averagine is not valid.";

  // In this constructor we truncate the file.
  if(!m_params.processingDetailsLogFilePath.isEmpty())
    {
      // The QFile needs to be alive as long as the QTextStream is!
      mp_logFile = new QFile(m_params.processingDetailsLogFilePath);
      if(mp_logFile == nullptr)
        qFatal() << "Failed to create a new log file instance.";

      if(mp_logFile->open(QIODevice::WriteOnly | QIODevice::Truncate |
                          QIODevice::Text))
        {
          mp_logTextStream = new QTextStream(mp_logFile);
          *mp_logTextStream << "This is a processing details log file.\n\n";
          mp_logTextStream->flush();
        }
      else
        qFatal() << "Failed to open the log file for writing.";
    }

  QString processing_details_string =
    QString("LowMassDeconvolve parameters: %1\n\n").arg(m_params.toString());

  // qDebug() << processing_details_string;
  logProcessingDetails(processing_details_string);
}

/*!
\brief Destructs this instance.
*/
AbstractLowMassDeconvolver::~AbstractLowMassDeconvolver()
{
  // First destroy the stream!
  if(mp_logTextStream != nullptr)
    {
      // I think the flush is performed automatically upon destruction of the
      // stream, but anyways.
      mp_logTextStream->flush();
      delete mp_logTextStream;
    }

  // Next destroy the file!
  if(mp_logFile != nullptr)
    delete mp_logFile;
}

/*!
\brief Creates an isotopic cluster for \a formula and returns it as a
pappso::Trace.
*/
pappso::Trace
AbstractLowMassDeconvolver::isotopicClusterForFormula(const QString &formula,
                                                      double intensity) const
{
  IsotopicClusterGenerator isotopic_cluster_generator(
    std::const_pointer_cast<IsotopicData>(
      m_params.averagine.getIsotopicData()));

  isotopic_cluster_generator.setIsotopicDataType(
    IsotopicDataType::LIBRARY_CONFIG);
  isotopic_cluster_generator.setMaxSummedProbability(0.95);
  isotopic_cluster_generator.setNormalizationIntensity(intensity);
  isotopic_cluster_generator.setSortType(pappso::Enums::SortType::x);
  isotopic_cluster_generator.setSortOrder(pappso::Enums::SortOrder::ascending);

  FormulaChargePair formula_charge_pair(formula, 0);

  isotopic_cluster_generator.setFormulaChargePair(formula_charge_pair);

  std::size_t count = isotopic_cluster_generator.run();

  std::vector<IsotopicClusterChargePair> isotopic_cluster_charge_pairs =
    isotopic_cluster_generator.getIsotopicClusterChargePairs();

  Q_ASSERT(count == isotopic_cluster_charge_pairs.size());

  return *isotopic_cluster_charge_pairs.at(0).first;
}

/*!
\brief Creates an isotopic cluster for \a neutral_mass.

First the neutral mass is used to establish a corresponding averagine-based
formula that is then used to create the isotopic cluster, that is returned
as a pappso::Trace object.
*/
pappso::Trace
AbstractLowMassDeconvolver::isotopicClusterForNeutralMass(
  double neutral_mass, double intensity, QString &message) const
{
  bool ok;
  QString averagine_based_estimated_formula =
    m_params.averagine.elementalComposition(
      Enums::MassType::MONO, neutral_mass, false /*as_integer_indices*/, ok);

  if(averagine_based_estimated_formula.isEmpty())
    qFatal() << "Programming error. The elemental composition is empty.";

  message = QString("The averagine-based formula for neutral mass %1 is %2 - ")
              .arg(neutral_mass, 0, 'f', 5)
              .arg(averagine_based_estimated_formula);

  Formula averagine_formula(averagine_based_estimated_formula);

  ErrorList error_list;
  ok = averagine_formula.validate(m_params.averagine.getIsotopicData(),
                                  true /*store*/,
                                  true /*reset*/,
                                  &error_list);

  if(!ok)
    qFatal() << "Programming error. The averagine-based elemental composition "
                "formula failed to validate successfully.";

  ok = false;

  double mono_mass_for_formula = 0;
  double avg_mass_for_formula  = 0;

  averagine_formula.accountMasses(ok,
                                  m_params.averagine.getIsotopicData(),
                                  mono_mass_for_formula,
                                  avg_mass_for_formula,
                                  1 /*times*/);

  if(!ok)
    qFatal() << "Programming error. Failed to compute masses for the formula.";

  message += QString("The calculated mono mass for the formula: %1\n\n")
               .arg(mono_mass_for_formula, 0, 'f', 5);

  qDebug() << message;

  pappso::Trace centroids =
    isotopicClusterForFormula(averagine_based_estimated_formula, intensity);

  if(!centroids.size())
    qFatal() << "Programming error. The returned centroids are empty.";

  return centroids;
}

/*!
\brief Converts the highly detailed (with hyperfine isotopic structure
centroids) \a theoretical_cluster isotopic cluster into a cluster where very
close peaks are merged (which is what is called the collapse). The new isotopic
cluster is returned as a vector of BinnedIsotopicCentroid instances.
*/
pappso::Trace
AbstractLowMassDeconvolver::collapseTheoreticalIsotopicCluster(
  const pappso::Trace &theoretical_cluster) const
{
  // This only works on neutral masses!

  Q_ASSERT(!theoretical_cluster.empty());

  // Get an iterator to the monoisotopic data point (highest intensity).
  pappso::Trace::const_iterator max_y_datapoint_iterator =
    pappso::maxYDataPoint(theoretical_cluster.begin(),
                          theoretical_cluster.end());

  if(max_y_datapoint_iterator == theoretical_cluster.end())
    qFatal() << "Programming error. Failed to get iterator to max Y datapoint "
                "in isotopic cluster.";

  const double mono_mass = max_y_datapoint_iterator->x;

  // isotopeIndex → (sum_mass, sum_intensity)
  std::map<int, std::pair<double, double>> bins;

  for(const pappso::DataPoint &dp : theoretical_cluster)
    {
      const double mass      = dp.x;
      const double intensity = dp.y;

      if(intensity <= 0.0)
        continue;

      const int isotope_index = static_cast<int>(
        std::llround((mass - mono_mass) / ISOTOPIC_NEUTRON_MASS));

      auto &bin   = bins[isotope_index];
      bin.first  += mass * intensity; // weighted mass sum
      bin.second += intensity;        // intensity sum
    }

  std::vector<BinnedIsotopicCentroid> result;
  result.reserve(bins.size());

  for(const auto &[iso_idx, sums] : bins)
    {
      const double weighted_mass = sums.first;
      const double total_int     = sums.second;

      if(total_int <= 0.0)
        continue;

      result.push_back(
        BinnedIsotopicCentroid{iso_idx, weighted_mass / total_int, total_int});
    }

  // Ensure strict ordering by isotope index
  std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) {
    return a.isotopeIndex < b.isotopeIndex;
  });

  pappso::Trace collapsed_isotopic_cluster;

  for(const BinnedIsotopicCentroid &centroid : result)
    collapsed_isotopic_cluster.append(
      pappso::DataPoint(centroid.neutralMass, centroid.intensity));

  return collapsed_isotopic_cluster;
}

/*!
\brief Iterates in the \a input_centroids input centroided mass spectrum
and filters all the data points that have their m/z or intensity value
outside of configured ranges (as defined in the member Parameters datum).

Returns a new filtered pappso::Trace.
*/
pappso::Trace
AbstractLowMassDeconvolver::selectCandidatePeaks(
  const pappso::Trace &input_centroids) const
{
  std::vector<pappso::DataPoint> candidates;
  candidates.reserve(input_centroids.size()); // reserve max possible size

  for(const pappso::DataPoint &dp : input_centroids)
    {
      // Skip peaks outside mass bounds
      if(dp.x < m_params.minMass || dp.x > m_params.maxMass)
        continue;

      // Skip peaks below intensity threshold
      if(dp.y < m_params.minIntensity)
        continue;

      candidates.push_back(dp);
    }

  return candidates;
}

/*!
\brief Iterates in the each ChargeSpecificFeature in \a
charge_specific_features and check if multiple items in it correspond to the
same neutral analyte.

The notion here is that a given analyte might be found as ions of different
charges and the input centroids might thus have matched theoretical isotopic
clusters generated from different ChargeSpecificFeature instances of the same
analyte. Each time a ChargeSpecificFeature instance is iterated into, it
triggers either the allocation of a new DeconvolutedFeature instance if no
previous instance was found for the corresponding neutral mass, or the
update of an existing instance if an instance already existed with the same
neutral mass.

The created DeconvolutedFeature instances (neutral mass along with supporting
ions) are returned in a vector.
*/
std::vector<DeconvolutedFeature>
AbstractLowMassDeconvolver::groupByNeutralMass(
  const std::vector<ChargeSpecificFeature> &charge_specific_features) const
{
  QString processing_details_string =
    "\n\nNow grouping ions by neutral mass.\n\n";

  std::vector<DeconvolutedFeature> deconvoluted_features;

  processing_details_string +=
    QString("Found %1 charge-specific features (ions).\n")
      .arg(charge_specific_features.size());

  for(const ChargeSpecificFeature &charge_specific_feature :
      charge_specific_features)
    {
      // Sanity check, there must be the matching isotopic cluster.
      if(charge_specific_feature.matchingIsotopicClusterQSPtr == nullptr)
        qFatal() << "Programming error. The pointer cannot be nullptr.";

      bool merged = false;

      // qDebug().noquote() << qSetRealNumberPrecision(5)
      //                    << "Now iterating into charge specific feature:"
      //                    << charge_specific_feature.toString();

      for(DeconvolutedFeature &iter_feature : deconvoluted_features)
        {
          // qDebug().noquote()
          //   << "At least one deconvoluted feature existed already, "
          //      "iterating into one:"
          //   << iter_feature.toString(true /*with_supporting_ions*/);

          double reference_mass = iter_feature.neutralMass;

          // delta_mass = | observed − reference |
          double delta_mass = std::abs(
            charge_specific_feature.monoisotopicNeutralMass - reference_mass);

          double delta_ppm = deltaToPpm(delta_mass, reference_mass);

          // qDebug().noquote()
          //   << qSetRealNumberPrecision(5) << "Delta mass:" << delta_mass
          //   << "reference mass:" << reference_mass
          //   << " - delta_ppm:" << delta_ppm
          //   << " - params tolerance (ppm):" << m_params.ppmMassTolerance
          //   << " - Merging only if delta_ppm is less than "
          //      "params tolerance (ppm)";

          if(delta_ppm <= m_params.ppmMassTolerance)
            {
              // Merge into existing feature.
              // qDebug() << "Merge new charge-specific feature into an
              // existing
              // "
              //             "deconvoluted feature.";

              SupportingIon ion;
              ion.charge         = charge_specific_feature.charge;
              ion.monoisotopicMz = charge_specific_feature.monoisotopicMz;
              ion.intensity = charge_specific_feature.monoisotopicIntensity;

              // Store a pointer to the matching theoretical isotopic cluster
              // so that the user of this class will be able to craft
              // a synthetic mass spectrum matching the observed mass spectrum
              // signal, which will help when double-checking the deconvolution
              // of border case isotopic cluster shapes.
              ion.matchingIsotopicClusterQSPtr =
                charge_specific_feature.matchingIsotopicClusterQSPtr;

              iter_feature.supportingIons.push_back(ion);

              // Add the intensity to the sum of intensities of all the
              // supporting ions for a given neutral mass.
              iter_feature.intensity +=
                charge_specific_feature.monoisotopicIntensity;

              // Aggregate FitQuality params.
              iter_feature.fitQuality.pearsonCorrelation =
                std::max(iter_feature.fitQuality.pearsonCorrelation,
                         charge_specific_feature.fitQuality.pearsonCorrelation);

              iter_feature.fitQuality.spacingErrorPpm =
                (iter_feature.fitQuality.spacingErrorPpm *
                   (iter_feature.intensity -
                    charge_specific_feature.monoisotopicIntensity) +
                 charge_specific_feature.fitQuality.spacingErrorPpm *
                   charge_specific_feature.monoisotopicIntensity) /
                iter_feature.intensity;

              iter_feature.fitQuality.explainedIntensity =
                (iter_feature.fitQuality.explainedIntensity *
                   (iter_feature.intensity -
                    charge_specific_feature.monoisotopicIntensity) +
                 charge_specific_feature.fitQuality.explainedIntensity *
                   charge_specific_feature.monoisotopicIntensity) /
                iter_feature.intensity;

              merged = true;
              break;
            }
        }

      if(!merged)
        {
          // Start new feature from scratch.

          processing_details_string +=
            QString(
              "Creating final new deconvoluted feature with neutral mass: "
              "%1.\n")
              .arg(charge_specific_feature.testedNeutralMass, 0, 'f', 5);

          DeconvolutedFeature feature;
          feature.neutralMass = charge_specific_feature.monoisotopicNeutralMass;
          feature.intensity   = charge_specific_feature.monoisotopicIntensity;
          feature.fitQuality  = charge_specific_feature.fitQuality;

          SupportingIon ion;
          ion.charge         = charge_specific_feature.charge;
          ion.monoisotopicMz = charge_specific_feature.monoisotopicMz;
          ion.intensity      = charge_specific_feature.monoisotopicIntensity;

          // Store a pointer to the matching theoretical isotopic cluster
          // so that the user of this class will be able to craft
          // a synthetic mass spectrum matching the observed mass spectrum
          // signal, which will help when double-checking the deconvolution
          // of border case isotopic cluster shapes.
          ion.matchingIsotopicClusterQSPtr =
            charge_specific_feature.matchingIsotopicClusterQSPtr;

          feature.supportingIons.push_back(ion);

          deconvoluted_features.push_back(std::move(feature));
        }
    }

  if(deconvoluted_features.size())
    {
      processing_details_string += QString("\n%1 deconvoluted feature(s):\n\n")
                                     .arg(deconvoluted_features.size());

      for(const DeconvolutedFeature &deconvoluted_feature :
          deconvoluted_features)
        {
          processing_details_string += QString("----->\n%1\n")
                                         .arg(deconvoluted_feature.toString(
                                           true /*with_supporting_ions*/));
          processing_details_string += "<-----\n\n";
        }
    }

  // qDebug().noquote() << processing_details_string;
  logProcessingDetails(processing_details_string);

  return deconvoluted_features;
}

pappso::Trace
AbstractLowMassDeconvolver::convertFromNeutralToChargedIsotopicCluster(
  const pappso::Trace &neutral_isotopic_cluster, int charge) const
{
  pappso::Trace new_charged_isotopic_cluster;

  for(const pappso::DataPoint &dp : neutral_isotopic_cluster)
    new_charged_isotopic_cluster.append(
      pappso::DataPoint(neutralToMz(dp.x, charge), dp.y));

  return new_charged_isotopic_cluster;
}


} // namespace libXpertMassCore
} // namespace MsXpS
