/* 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/LowMassDeconvolverFullLabelling.hpp"


namespace MsXpS
{
namespace libXpertMassCore
{

/*!
\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.
*/
LowMassDeconvolverFullLabelling::LowMassDeconvolverFullLabelling(
  Parameters &parameters)
  : AbstractLowMassDeconvolver(parameters)
{
}

/*!
\brief Destructs this instance.
*/
LowMassDeconvolverFullLabelling::~LowMassDeconvolverFullLabelling()
{
}

/*!
\brief Fits the \a charge_specific_feature contained data to a theoretical
isotopic cluster.

The idea of this function implementation is that we get the \a
input_centroid_index index to an input centroided mass spectrum's centroid. We
thus get an input (observed, not theoretical) centroid m/z. We also get the
neutral mass of that centroid m/z, as stored in the \a charge_specific_feature
parameter. This neutral mass is used to compute a theoretical isotopic cluster.

Then, the centroids of the neutral theoretical isotopic cluster are iterated
into and for each one a m/z value is computed using the charge in \a
charge_specific_feature.

Then, matches are searched from the input centroided mass spectrum. When all the
possible centroids of the input mass spectrum have been checked, the matches (if
any) are the thoroughly checked for various consistency notions (like
inter-centroid distance, isotopic cluster shape correlation between the input
mass spectral data and the theoretical isotopic cluster, for example).

If the checks succeed, the \a input_centroid_index is recorded into \a
globally_used so as to avoid having to iterate into it again and true is
returned, otherwise false is returned.
*/
bool
LowMassDeconvolverFullLabelling::fitIsotopicEnvelope(
  const pappso::Trace &input_centroids,
  std::size_t input_centroid_index,
  ChargeSpecificFeature &charge_specific_feature,
  std::vector<bool> &globally_used) const
{
  const pappso::DataPoint input_candidate_centroid =
    input_centroids[input_centroid_index];

  QString processing_details_string =
    QString(
      "Received input centroid: %1 as charged feature:\n"
      "\t%2"
      "\tfor checking isotopic cluster fit (backward).\n"
      "=================================================\n\n")
      .arg(input_candidate_centroid.toString(),
           charge_specific_feature.toString());

  // 1. Get neutral mass from feature
  // ================================

  const double neutral_mass = charge_specific_feature.testedNeutralMass;

  // 2. Generate theoretical isotopic envelope using IsoSpec++
  // =========================================================

  QString message;

  // Note how we pass the intensity of the tested centroid, which will
  // be used to normalize the theoretical isotopic cluster. The monoisotopic
  // centroid of the theoretical cluster will have that intensity which will
  // ease comparison of the experimental centroids that matched with the
  // theoretical centroids.
  pappso::Trace theoretical_isotopic_cluster_for_neutral_mass =
    isotopicClusterForNeutralMass(
      neutral_mass, charge_specific_feature.testedIntensity, message);

  processing_details_string += message;

  // Sanity check
  // Ensure that the intensity parameter is accounted for in the
  // isotopic cluster calculation (hint: works fine).
  // processing_details_string +=
  //   QString(
  //     "Theoretical mono centroid intensity: %1, "
  //     "input centroid intensity: %2\n\n")
  //     .arg(theoretical_isotopic_cluster_for_neutral_mass.at(0).y)
  //     .arg(charge_specific_feature.testedIntensity);

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

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

  // Because of the rounding of the formula in the function that
  // computes the formula corresponding to the neutral mass, using
  // the averagine equivalents concept, the mono mass of the returned
  // theoretical isotopic cluster by IsoSpec++ cannot be exactly the
  // neutral mass that we used in the first place. There is a neutral
  // mass gap.

  std::vector<pappso::DataPoint>::iterator max_y_datapoint_iter =
    pappso::maxYDataPoint(theoretical_isotopic_cluster_for_neutral_mass.begin(),
                          theoretical_isotopic_cluster_for_neutral_mass.end());

  if(max_y_datapoint_iter ==
     theoretical_isotopic_cluster_for_neutral_mass.end())
    qFatal()
      << "Failed to get iterator to max Y data point of theoretical cluster.";

  processing_details_string +=
    QString("The monoisotopic centroid of the theoretical cluster: %1\n\n")
      .arg(max_y_datapoint_iter->toString());

  double neutral_mass_gap = neutral_mass - max_y_datapoint_iter->x;

  processing_details_string +=
    QString(
      "\tNeutral mass gap between input mass and cluster model's monoisotopic "
      "centroid: %1\n\n")
      .arg(neutral_mass_gap, 0, 'f', 4);

  for(pappso::DataPoint &dp : theoretical_isotopic_cluster_for_neutral_mass)
    {
      // debug_msg += QString("m/z before correction: %1\n").arg(dp.x, 0, 'f',
      // 5);
      dp.x += neutral_mass_gap;
      // debug_msg += QString("m/z after correction: %1\n").arg(dp.x, 0, 'f',
      // 5);
    }

  // qDebug() << "Finished iterating... Size:"
  //          << theoretical_isotopic_cluster_for_neutral_mass.size();

  processing_details_string +=
    QString("Neutral theoretical isotopic cluster:\n\n%1\n\n")
      .arg(theoretical_isotopic_cluster_for_neutral_mass.toString());
  // qDebug().noquote() << debug_msg;
  // debug_msg.clear();

  pappso::Trace collapsed_theoretical_isotopic_cluster =
    collapseTheoreticalIsotopicCluster(
      theoretical_isotopic_cluster_for_neutral_mass);

  processing_details_string +=
    QString(
      "After isobaric centroids merge, collapsed neutral theoretical isotopic "
      "cluster:\n\n%1\n\n")
      .arg(collapsed_theoretical_isotopic_cluster.toString());

  // We want to reduce the count of centroids in the theoretical cluster
  // so as to match the params.maxIsotopicClusterMatches value.

  pappso::Trace reduced_collapsed_theoretical_isotopic_cluster =
    getIsotopicClusterReducedToMaxMatchCount(
      collapsed_theoretical_isotopic_cluster);

  processing_details_string +=
    QString("After reduction of the theoretical isotopic cluster:\n%1\n\n")
      .arg(reduced_collapsed_theoretical_isotopic_cluster.toString());

  // We want to convert the neutral isotopic cluster centroids to
  // their m/z counterpart, using the charge:

  pappso::Trace charged_theoretical_isotopic_cluster =
    convertFromNeutralToChargedIsotopicCluster(
      reduced_collapsed_theoretical_isotopic_cluster,
      charge_specific_feature.charge);

  processing_details_string +=
    QString("Now working on the same but after converting to m/z:\n%1\n\n")
      .arg(charged_theoretical_isotopic_cluster.toString());


  // 3. Match observed peaks to theoretical m/z
  // ==========================================

  // Iterate in the theoretical cluster centroid peaks,
  // convert each one into a m/z for the charge and check if
  // there is a data point in the input centroided spectrum that has
  // a m/z matching it within tolerance.

  // Now iterating in the theoretical isotopic cluster centroids
  // that are converted into m/z with charge and then searched for
  // matches in the input spectrum centroids.

  // To store data about the match that we will need for verification later.
  std::vector<IsoMatch> matches;

  // How many isotopic centroids, starting from the first one, do we
  // want to try to match?
  const int max_isotopic_centroids_to_scan = 5;
  const double expected_spacing =
    ISOTOPIC_NEUTRON_MASS / charge_specific_feature.charge;

  for(size_t theor_cluster_binned_centroids_iter = 0;
      theor_cluster_binned_centroids_iter <
      charged_theoretical_isotopic_cluster.size();
      ++theor_cluster_binned_centroids_iter)
    {
      // Test how many matches we already have, we know having too
      // many matches introduces a serious bias in the isotopic cluster
      // shape calculations because new peaks not related to the analyte
      // at hand can introduced intensity that is not relevant to that
      // analyte isotopic cluster.
      if(static_cast<int>(matches.size()) >= m_params.maxIsotopicClusterMatches)
        {
          processing_details_string +=
            QString(
              "Already got %1 matches, breaking iteration "
              "in the theoretical cluster centroids.\n\n")
              .arg(matches.size());
          break;
        }

      const double theoretical_centroid_mz =
        charged_theoretical_isotopic_cluster
          .at(theor_cluster_binned_centroids_iter)
          .x;

      processing_details_string += QString(
                                     "Iterating in theoretical cluster "
                                     "centroid at index: %1, m/z: %2, z: %3\n")
                                     .arg(theor_cluster_binned_centroids_iter)
                                     .arg(theoretical_centroid_mz, 0, 'f', 5)
                                     .arg(charge_specific_feature.charge);

      // In the code below, the tolerance is applied to variables in the
      // m/z space, not the dalton space.
      double tolerance_in_thompson =
        ppmToDelta(theoretical_centroid_mz, m_params.ppmMassTolerance);

      processing_details_string +=
        QString(
          "\tThus searching that %1 m/z in input mass spectrum "
          "centroids (tolerance: %2):\n\n")
          .arg(theoretical_centroid_mz, 0, 'f', 5)
          .arg(tolerance_in_thompson, 0, 'f', 5);

      // qDebug() << qSetRealNumberPrecision(5)
      //          << "The tolerance in Thompson is :" <<
      //          tolerance_in_thompson;

      // Here, we should try to get rapidly to the input centroid
      // that roughly matches the searched m/z. Indeed, there might be a big
      // gap between the last scan idx and the actual m/z we are looking
      // for. Note that we want to get there from the right of the mass
      // spectrum.

      auto start_it =
        std::upper_bound(input_centroids.begin(),
                         input_centroids.end(),
                         // Searches for a DataPoint that has x strictly greater
                         // than the value below.
                         theoretical_centroid_mz + tolerance_in_thompson,
                         [](double mz, const pappso::DataPoint &dp) {
                           return mz < dp.x;
                         });

      if(start_it != input_centroids.end())
        {
          size_t scan_start_idx_by_upper_bound =
            std::distance(input_centroids.begin(), start_it);

          processing_details_string +=
            QString(
              "\tStart scanning the input centroided spectrum at index %1 with "
              "m/z value: %2\n\n")
              .arg(scan_start_idx_by_upper_bound)
              .arg(input_centroids[scan_start_idx_by_upper_bound].x, 0, 'f', 5);

          double best_delta = std::numeric_limits<double>::max();
          int best_idx      = -1;
          double delta      = 0;

          for(size_t i = scan_start_idx_by_upper_bound;; --i)
            {
              processing_details_string +=
                QString("\t\tNow testing input centroid at index %1, m/z %2\n")
                  .arg(i)
                  .arg(input_centroids[i].x, 0, 'f', 5);

              if(globally_used[i])
                {
                  processing_details_string +=
                    QString("Because index %1 - m/z %2 is used, continue.\n\n")
                      .arg(i)
                      .arg(input_centroids[i].x, 0, 'f', 5);

                  continue;
                }

              // Beware, we may have a large stretch between the
              // input_centroids[i] at i value correctly returned by
              // std::upper_bound and

              // In my centroided mass spectrum, I have:
              // 1090.556780410766805 143050.326112883194583
              // 1098.602947677612292 159456.411902638094034
              // 1099.604585075378509 238667.570989825442666
              // 1105.622639587402318 103125.403818284452427
              // 1132.640628112792911 171065.081399090908235
              // 1133.648343566894255 404157.535323876072653
              // 1134.650757751464880 599598.139151649898849

              // When searching for 1099.60458, the index obtained using
              // std::upper_bound, points to 1105.6226.

              // So, the program iterates in the input centroids starting from
              // that m/z value 1105.6226.
              //
              // The problem is that the delta computed below will yield
              // a value 1105.62263 - 1099.60458 = 6.018050
              // This is a very large gap and that is greater than
              // max_scan_window below. So we stop searching for input centroids
              // matching 1099.60458 and we iterate to the next theoretical
              // centroid. This is very bad, because we actually had the right
              // input centroid at the next iteration of the input centroids!

              // clang-format off
              
              // So this delta:
              //
              // delta = std::fabs(input_centroids[i].x - theoretical_centroid_mz);
              //
              // is actually erroneous because it does not provide a direction
              // of the gap between the searched theoretical mz and the tested
              // input centroid mz.

              // clang-format on

              // Since we iterate from right to left, this delta evaluates
              // to a positive value if we are iterating to the left of the
              // searched m/z value, which is what we want to determine.
              delta = theoretical_centroid_mz - input_centroids[i].x;

              // It makes no sense to continue iterating in the input
              // mass spectrum centroids if we are farther than 4~Th from
              // the target theoretical centroid !

              const double max_scan_window =
                expected_spacing * max_isotopic_centroids_to_scan;

              if(delta > max_scan_window)
                break;

              // Now, however, we need to have an absolute value.
              delta = std::fabs(delta);

              if(delta <= tolerance_in_thompson && delta < best_delta)
                {
                  processing_details_string +=
                    QString(
                      "\t\tFor input mass spectrum centroid m/z %1, (index "
                      "%2), MATCH "
                      "with "
                      "theoretical m/z %3, delta: %4 within tolerance: "
                      "%5\n")
                      .arg(input_centroids[i].x)
                      .arg(i)
                      .arg(theoretical_centroid_mz, 0, 'f', 5)
                      .arg(delta, 0, 'f', 5)
                      .arg(tolerance_in_thompson, 0, 'f', 4);

                  best_delta = delta;
                  best_idx   = i;
                }
              else
                {
                  processing_details_string +=
                    QString(
                      "\t\tFor input mass spectrum centroid m/z %1, (index "
                      "%2), "
                      "NO "
                      "match "
                      "with "
                      "theoretical m/z %3, delta: %4 NOT within "
                      "tolerance: %5\n")
                      .arg(input_centroids[i].x)
                      .arg(i)
                      .arg(theoretical_centroid_mz, 0, 'f', 5)
                      .arg(delta, 0, 'f', 5)
                      .arg(tolerance_in_thompson, 0, 'f', 5);
                }
            }

          if(best_idx != -1)
            {
              matches.push_back({theor_cluster_binned_centroids_iter,
                                 static_cast<std::size_t>(best_idx),
                                 input_centroids[best_idx].x,
                                 input_centroids[best_idx].y});

              processing_details_string += QString(
                                             "\t\t\tSuccess (best delta: %1). "
                                             "Ongoing match count: %2.\n\n")
                                             .arg(best_delta, 0, 'f', 5)
                                             .arg(matches.size());
            }
          else
            {
              processing_details_string += QString("\t\t\tFailure.\n\n");
            }
        }
      // End of
      // if(start_it != input_centroids.end())
      else
        {
          processing_details_string +=
            QString("\tNo candidate input m/z found.\n\n");
        }
    }
  // End of iteration in the theoretical cluster centroids.

  // Store once for all the count of matches.
  const int match_count = static_cast<int>(matches.size());

  // Important notion: if the analyte is very small (< 400 m/z , charge: 1),
  // the theoretical isotopic cluster is going to have only a limited number
  // of centroids (typically 2). It is thus impossible to use the
  // m_params.minIsotopicClusterMatches as is. For example, if the
  // theoretical isotopic cluster has only two centroids, how can we expect
  // that the number of matches be at least 3, as configured by the user ?

  int effectively_min_required_match_count =
    std::min(static_cast<int>(charged_theoretical_isotopic_cluster.size()),
             m_params.minIsotopicClusterMatches);

  if(static_cast<int>(match_count) < effectively_min_required_match_count)
    {
      processing_details_string += QString(
                                     "Found only %1 matche(s) (%2 are "
                                     "effectively required) --> rejecting.\n\n")
                                     .arg(match_count)
                                     .arg(m_params.minIsotopicClusterMatches);

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

      return false;
    }

  // Ensure matches are ordered by theoretical isotopologue index
  // (required for spacing error and correlation computations)
  std::sort(
    matches.begin(), matches.end(), [](const IsoMatch &a, const IsoMatch &b) {
      return a.theoreticalClusterIndex < b.theoreticalClusterIndex;
    });

  processing_details_string +=
    QString("Found %1 matches (ordered by isotopologue index):\n")
      .arg(match_count);

  for(const IsoMatch &iso_match : matches)
    processing_details_string += "\t" + iso_match.toString();

  processing_details_string += QString("\n\n");

  // Perform some checks about the matches
  //=======================================

  // 1. The cluster that is recreated by the matches should be faithful,
  // that is, with the right expected isotopic spacing in m/z for a given z
  // value.

  //
  // Example matches:

  // Fully labelled analytes.
  // Theoretical cluster index: 0 - Observed peak index: 159 - Observed m/z:
  // 349.21689 - Observed intensity: 3.02748e+06 Theoretical cluster index: 1 -
  // Observed peak index: 157 - Observed m/z: 348.71902 - Observed
  // intensity: 1.37948e+06 Theoretical cluster index: 2 - Observed peak index:
  // 156 - Observed m/z: 348.21709 - Observed intensity: 772821

  processing_details_string +=
    "Now checking for the uniform spacings between centroids of the "
    "matches.\n";

  std::vector<double> observed_spacings;
  observed_spacings.reserve(match_count - 1);

  for(int i = 1; i < match_count; ++i)
    {
      observed_spacings.push_back(matches[i].observedMz -
                                  matches[i - 1].observedMz);

      // Take advantage of this loop to also check that matches are
      // ordered in increasing (normal abundance analytes) or in decreasing
      // (fully heavy isotope-labelled analytes) input centroided mass spectrum
      // centroid indices:
      // Fully heavy isotope labelled analytes
      if(matches[i].observedPeakIndex >= matches[i - 1].observedPeakIndex)
        {
          processing_details_string += QString(
            "Two consecutive matches cannot have two input centroided mass "
            "spectrum indices in increasing order -> rejecting.\n\n");

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

          return false;
        }
    }

  processing_details_string +=
    QString("\tObserved spacing for matching centroids:\n");

  for(double observed_spacing : observed_spacings)
    processing_details_string +=
      QString("\t\t%1\n").arg(observed_spacing, 0, 'f', 5);

  processing_details_string += QString(
                                 "\t\tDone printing the observed spacings "
                                 "(note that the expected spacing is: "
                                 "%1 +/- 20%).\n")
                                 .arg(expected_spacing, 0, 'f', 5);

  // Important notion: if the analyte is very small (< 400 m/z , charge: 1),
  // the theoretical isotopic cluster is going to have only a limited number
  // of centroids (typically 2). It is thus impossible to use the value 2
  // for the threshold of the count of observed spacings. For example, if the
  // theoretical isotopic cluster has only two centroids, how can we expect
  // that the number of observed spacing be at least 2?

  if(static_cast<int>(observed_spacings.size()) >= match_count - 1)
    {
      // Put the median value item in the middle of the vector (no sorting
      // of the vector).
      std::nth_element(observed_spacings.begin(),
                       observed_spacings.begin() + observed_spacings.size() / 2,
                       observed_spacings.end());

      // This is the median of the inter-isotopologue spacings

      // We need to use std::abs() because the ordering of the centroids
      // makes the value computed below negative.
      double median_spacing =
        std::abs(observed_spacings[observed_spacings.size() / 2]);

      processing_details_string += QString("\tMedian of the spacings: %1... ")
                                     .arg(median_spacing, 0, 'f', 5);

      // The median of the spacings should roughly (20% margin) match the
      // expected theoretical spacing for a given charge (spacing is 1/z),
      // with z = charge_specific_feature.charge.
      if(std::abs(median_spacing - expected_spacing) > 0.2 * expected_spacing)
        {
          processing_details_string +=
            "\n\nWhen looking into the matches, the charge seems inconsistent, "
            "with non-constant spacing between the matched cluster "
            "centroids -> rejecting.\n\n";

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

          return false;
        }
      else
        {
          processing_details_string += "is fine.\n\n";
        }
    }
  else
    {
      processing_details_string += QString(
        "\n\nThe count of contiguous spacings in the matches is less than 2 -> "
        "rejecting.\n\n");

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

      return false;
    }

  // 3. Compute FitQuality
  // =====================

  FitQuality fit_quality;

  // 3a. Pearson correlation
  // -----------------------
  // The Pearson correlation checks that the observed and theoretical
  // isotopic cluster centroids are correlated by their intensities.

  Q_ASSERT(static_cast<int>(match_count) >=
           effectively_min_required_match_count);
  Q_ASSERT(static_cast<int>(match_count) <= m_params.maxIsotopicClusterMatches);

  processing_details_string +=
    "Now computing the Pearson correlation of the matched centroids "
    "(theoretical vs observed).\n\n";

  double sum_obs   = 0.0;
  double sum_theo  = 0.0;
  double sum_obs2  = 0.0;
  double sum_theo2 = 0.0;
  double sum_cross = 0.0;

  for(const auto &match : matches)
    {
      double obs_matched_intensity = match.observedIntensity;
      double theo_matched_intensity =
        charged_theoretical_isotopic_cluster[match.theoreticalClusterIndex].y;

      sum_obs   += obs_matched_intensity;
      sum_theo  += theo_matched_intensity;
      sum_obs2  += obs_matched_intensity * obs_matched_intensity;
      sum_theo2 += theo_matched_intensity * theo_matched_intensity;
      sum_cross += obs_matched_intensity * theo_matched_intensity;

      processing_details_string +=
        QString(
          "Match at input m/z %1, observed centroid intensity: %2 "
          "theoretical intensity: %3. \n")
          .arg(match.observedMz)
          .arg(obs_matched_intensity)
          .arg(theo_matched_intensity);
    }

  processing_details_string += "\n";

  processing_details_string += QString(
                                 "After iteration through all the matches:\n"
                                 "\tSum of observed intensities: %1\n"
                                 "\tSum of theoretical intensities: %2\n\n")
                                 .arg(sum_obs)
                                 .arg(sum_theo);

  const double numerator = (match_count * sum_cross) - (sum_obs * sum_theo);

  const double denominator =
    std::sqrt((match_count * sum_obs2 - sum_obs * sum_obs) *
              (match_count * sum_theo2 - sum_theo * sum_theo));

  fit_quality.pearsonCorrelation =
    (denominator > 0.0) ? numerator / denominator : 0.0;

  processing_details_string += QString("Pearson correlation value: %1... ")
                                 .arg(std::abs(fit_quality.pearsonCorrelation));

  if(fit_quality.pearsonCorrelation < m_params.minPearsonCorrelationScore)
    {
      processing_details_string +=
        QString("below min score: (%1) -> rejecting.\n\n")
          .arg(m_params.minPearsonCorrelationScore);

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

      return false;
    }
  else
    {
      processing_details_string += QString("above min score: (%1) -> fine.\n\n")
                                     .arg(m_params.minPearsonCorrelationScore);
    }

  // 3b. Spacing error in ppm
  // ------------------------

  processing_details_string +=
    "Now checking the error in the spacing between isotopologues\n";

  double spacing_error_ppm = 0.0;
  size_t count             = 0;

  for(int i = 1; i < match_count; ++i)
    {
      size_t d_theor = matches[i].theoreticalClusterIndex -
                       matches[i - 1].theoreticalClusterIndex;

      if(d_theor == 1)
        {
          double obs_mz_delta =
            matches[i].observedMz - matches[i - 1].observedMz;

          double theo_mz_delta =
            ISOTOPIC_NEUTRON_MASS / charge_specific_feature.charge;

          // '/' and '*' same precedence, evalutation by left-to-right
          // grouping std::abs(obs_delta - theo_delta) / theo_delta * 1e6;
          double delta_in_ppm = deltaToPpm(
            std::abs(std::abs(obs_mz_delta) - std::abs(theo_mz_delta)),
            std::abs(theo_mz_delta));

          spacing_error_ppm += delta_in_ppm;

          processing_details_string += QString(
                                         "\tFor isotopologues %1 and %2, "
                                         "spacing: %3 (expected spacing: %4),"
                                         "which corresponds to ppm delta: %5\n")
                                         .arg(i)
                                         .arg(i - 1)
                                         .arg(obs_mz_delta, 0, 'f', 5)
                                         .arg(theo_mz_delta, 0, 'f', 5)
                                         .arg(delta_in_ppm, 0, 'f', 5);

          ++count;
        }
    }

  fit_quality.spacingErrorPpm = (count > 0) ? spacing_error_ppm / count : 0.0;

  processing_details_string +=
    QString(
      "\tMean inter-isotopologue spacing error (ppm) relative to "
      "theoretical spacing : %1\n\n")
      .arg(fit_quality.spacingErrorPpm);

  // 3c. Explained intensity
  // -----------------------

  // This function compares the sum of the intensities in the theoretical
  // cluster with the sum of the intensities of the matched centroids.

  // Get an iterator to the match that involves the monoisotopic
  // centroid of the theoretical cluster.

  processing_details_string +=
    "Now checking the match between the theoretical and observed centroid "
    "intensities\n";

  message.clear();

  std::vector<IsoMatch>::const_iterator monoisotopic_match_iterator =
    getMonoisotopicMatchIterator(matches, message);

  if(monoisotopic_match_iterator == matches.cend())
    {
      processing_details_string +=
        QString(
          "\tFailed to get the monoisotopic (highest intensity) centroid of "
          "the cluster, with errors: %1 \n\t-> rejecting.\n\n")
          .arg(message);

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

      return false;
    }

  int monoisotopic_theoretical_cluster_index =
    monoisotopic_match_iterator->theoreticalClusterIndex;

  Q_ASSERT(monoisotopic_theoretical_cluster_index >= 0);
  Q_ASSERT(monoisotopic_theoretical_cluster_index <
           static_cast<int>(charged_theoretical_isotopic_cluster.size()));

  const double obs_mono_mz  = monoisotopic_match_iterator->observedMz;
  const double obs_mono_int = monoisotopic_match_iterator->observedIntensity;
  const double obs_mono_neutral_mass =
    mzToNeutral(obs_mono_mz, charge_specific_feature.charge);

  processing_details_string +=
    QString(
      "\tThe observed monoisotopic centroid m/z and intensity: (%1,%2).\n")
      .arg(obs_mono_mz)
      .arg(obs_mono_int);

  const double theo_mono_int =
    charged_theoretical_isotopic_cluster[monoisotopic_theoretical_cluster_index]
      .y;
  const double theo_mono_neutral_mass =
    charged_theoretical_isotopic_cluster[monoisotopic_theoretical_cluster_index]
      .x;

  processing_details_string +=
    QString(
      "\tThe theoretical monoisotopic centroid neutral mass and intensity: "
      "(%1,%2).\n")
      .arg(theo_mono_neutral_mass)
      .arg(theo_mono_int);

  if(theo_mono_int <= 0.0)
    {
      processing_details_string += QString(
        "\tTheoretical cluster mono intensity <= 0: tried to explain "
        "intensity, "
        "but failed -> rejecting.\n\n");

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

      return false;
    }

  // Note that the monoisotopic centroid of both the observed and the
  // theoretical isotopic clusters have the same intensity because the
  // theoretical isotopic cluster was computed using the intensity from the
  // tested centroid, which becomes the monoisotopic centroid of the
  // successfull matches. There is thus no need to normalize the theoretical
  // cluster centroid intensities (this is new as of 202600402).

  // Compute the sum of matched theoretical centroids intensities (only
  // the matches that we are dealing with).
  double sum_theo_intensities = 0.0;
  // Compute sum of matched observed centroids intensities
  double sum_obs_intensities  = 0.0;

  for(const IsoMatch &iso_match : matches)
    {
      double obs_int = iso_match.observedIntensity;
      double theo_int =
        charged_theoretical_isotopic_cluster[iso_match.theoreticalClusterIndex]
          .y;

      sum_theo_intensities += theo_int;
      sum_obs_intensities  += obs_int;
    }

  processing_details_string +=
    QString(
      "\tSum of the theoretical isotopic cluster intensities of the "
      "matches: %1.\n")
      .arg(sum_theo_intensities);

  processing_details_string +=
    QString("\tSum of the observed intensities of the matches: %1.\n")
      .arg(sum_obs_intensities);

  fit_quality.explainedIntensity =
    abs(sum_obs_intensities - sum_theo_intensities) / sum_theo_intensities;

  if(fit_quality.explainedIntensity > m_params.totalClusterIntensityTolerance)
    {
      processing_details_string +=
        QString(
          "\tThe difference between the obs. and theor. cluster intensities "
          "compared to "
          "the theor. intensity is beyond tolerance.\n"
          "\t\tTolerance factor: %1 -- observed ratio: %2 -> rejecting.\n\n")
          .arg(m_params.totalClusterIntensityTolerance)
          .arg(fit_quality.explainedIntensity);

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

      return false;
    }
  else
    {
      processing_details_string +=
        QString(
          "\tThe difference between the obs. and theor. cluster intensities "
          "compared to "
          "the theor. intensity is within tolerance.\n"
          "\t\tTolerance factor: %1 -- observed ratio: %2: fine.\n\n")
          .arg(m_params.totalClusterIntensityTolerance)
          .arg(fit_quality.explainedIntensity);
    }


  // 3d. Local intensity ratio sanity check
  // --------------------------------------

  // The postulate is the the monoisotopic centroid (isotopologue 0, that is
  // the highest probability centroid) and the isotopologue 1 (the
  // consecutive centroid in the matched cluster centroid) are available. In
  // this case, this test ensures that the observed input iso-0 and iso-1
  // centroids have the same intensity ratio as that computed for the
  // theoretical iso-0 and iso-1 centroids.

  Q_ASSERT(static_cast<int>(match_count) >=
           effectively_min_required_match_count);
  Q_ASSERT(static_cast<int>(match_count) <= m_params.maxIsotopicClusterMatches);

  processing_details_string +=
    "Now checking the local intensity ratios for pairs of consecutive "
    "centroids\n";

  // Do the comparisons of the relative intensity of the isotopic cluster
  // (comparing theor. and obs.). The comparison will be performed pairwise
  // theor. vs obs. on the isotopologue 0 vs isotopologue 1, isotopologue 1
  // vs isotopologue 2, and so on until the smallest isotopologue is
  // params.clusterShapeCentroidCount.

  // Check that we have enough matches to actually compute the isotopologue
  // ratios on these existing matches.

  // Here also, the notion that the match count has to be a given value depends
  // on the actual number of centroids in the theoretical isotopic cluster, and
  // thus on the number of matches.

  int effectively_required_cluster_shape_centroid_count =
    std::min(match_count, m_params.clusterShapeCentroidCount);

  // Example: if there are two matches (very small analyte) because there were
  // only two theoretical isotopic cluster centroids, then, the
  // effectively required number of centroids in the isotopic cluster
  // would need to be two.

  Q_ASSERT(static_cast<int>(match_count) >=
           effectively_required_cluster_shape_centroid_count);

  for(int iter = 0;
      iter < effectively_required_cluster_shape_centroid_count - 1;
      ++iter)
    {
      QString message;

      std::vector<IsoMatch>::const_iterator iter_isotopologue_iterator =
        getIsotopologueMatchIterator(matches, iter, message);

      processing_details_string += message + "\n";

      std::vector<IsoMatch>::const_iterator iter_next_isotopologue_iterator =
        getIsotopologueMatchIterator(matches, iter + 1, message);

      // The second call above produces the exact same message, to not repeat it
      // in the processing_details_string.

      // processing_details_string +=
      //   QString("\titer isotopologue points to iso match %1\n")
      //     .arg(iter_isotopologue_iterator->toString());
      // processing_details_string +=
      //   QString("\tnext iter isotopologue points to iso match %1\n")
      //     .arg(iter_next_isotopologue_iterator->toString());

      int isotopologue_theo_cluster_index =
        iter_isotopologue_iterator->theoreticalClusterIndex;
      int next_isotopologue_theo_cluster_index =
        iter_next_isotopologue_iterator->theoreticalClusterIndex;

      // processing_details_string +=
      //   QString(
      //     "\tThe isotopologue centroids %1 and %2 have indices in the "
      //     "theoretical cluster: %3 and %4\n")
      //     .arg(iter)
      //     .arg(iter + 1)
      //     .arg(isotopologue_theo_cluster_index)
      //     .arg(next_isotopologue_theo_cluster_index);

      if(iter_isotopologue_iterator == matches.cend())
        {
          processing_details_string += QString(
                                         "\tFailed to get iterator to match "
                                         "corresponding to isotopologue %1 -> "
                                         "rejecting.\n\n")
                                         .arg(iter);

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

          return false;
        }

      if(iter_next_isotopologue_iterator == matches.cend())
        {
          processing_details_string += QString(
                                         "\tFailed to get iterator to match "
                                         "corresponding to isotopologue %1 -> "
                                         "rejecting.\n\n")
                                         .arg(iter + 1);

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

          return false;
        }

      // Check that the returned iterator effectively correspond to
      // consecutive theoretical cluster centroids.
      if(std::abs(isotopologue_theo_cluster_index -
                  next_isotopologue_theo_cluster_index) != 1)
        {
          processing_details_string +=
            QString(
              "\tThe isotopologue centroids %1 and %2 are not consecutive in "
              "the theoretical cluster -> rejecting.\n\n")
              .arg(iter)
              .arg(iter + 1);
          // qDebug().noquote() << processing_details_string;
          logProcessingDetails(processing_details_string);

          return false;
        }

      double iter_isopologue_observed_intensity =
        iter_isotopologue_iterator->observedIntensity;
      double iter_isopologue_theoretical_intensity =
        charged_theoretical_isotopic_cluster[iter_isotopologue_iterator
                                               ->theoreticalClusterIndex]
          .y;

      double iter_next_isotopologue_observed_intensity =
        iter_next_isotopologue_iterator->observedIntensity;
      double iter_next_isopologue_theoretical_intensity =
        charged_theoretical_isotopic_cluster[iter_next_isotopologue_iterator
                                               ->theoreticalClusterIndex]
          .y;

      processing_details_string +=
        QString("\nRelative isotopologue intensity calculations:\n");

      processing_details_string +=
        QString(
          "\tObserved isotopologue %1 intensity: %2\n"
          "\tTheoretical isotopologue %1 intensity: %3\n"

          "\tObserved isotopologue %4 intensity: %5\n"
          "\tTheoretical isotopologue %4 intensity: %6\n\n")
          .arg(iter)
          .arg(iter_isopologue_observed_intensity)
          .arg(iter_isopologue_theoretical_intensity)
          .arg(iter + 1)
          .arg(iter_next_isotopologue_observed_intensity)
          .arg(iter_next_isopologue_theoretical_intensity);


      const double observed_ratio = iter_isopologue_observed_intensity /
                                    iter_next_isotopologue_observed_intensity;
      const double theoretical_ratio =
        iter_isopologue_theoretical_intensity /
        iter_next_isopologue_theoretical_intensity;

      processing_details_string +=
        QString(
          "\tObserved isotopologues %1 vs %2 intensity ratio: %3\n"
          "\tTheoretical isotopologues %1 vs %2 intensity ratio: %4\n\n")
          .arg(iter)
          .arg(iter + 1)
          .arg(observed_ratio)
          .arg(theoretical_ratio);

      processing_details_string += QString(
                                     "Tolerance over the isotopic cluster "
                                     "centroids relative intensities: %1.\n")
                                     .arg(m_params.clusterShapeTolerance);

      double deviation_ratio =
        std::abs(observed_ratio - theoretical_ratio) / theoretical_ratio;
      if(deviation_ratio > m_params.clusterShapeTolerance)
        {
          processing_details_string +=
            QString(
              "\tThe local intensity ratio between isotopes %1 and %2: "
              "[observed intensity ratio (%3) - theoretical intensity ratio "
              "(%4)] / %5 = %6 is beyond tolerance -> rejecting.\n\n")
              .arg(iter)
              .arg(iter + 1)
              .arg(observed_ratio, 0, 'f', 3)
              .arg(theoretical_ratio, 0, 'f', 3)
              .arg(m_params.clusterShapeTolerance)
              .arg(deviation_ratio);

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

          return false;
        }
      else
        {
          processing_details_string +=
            QString(
              "\tThe local intensity ratio between isotopes %1 and %2: "
              "[observed intensity ratio (%3) - theoretical intensity ratio "
              "(%4)] / %5 = %6 is within tolerance.\n\n")
              .arg(iter)
              .arg(iter + 1)
              .arg(observed_ratio, 0, 'f', 3)
              .arg(theoretical_ratio, 0, 'f', 3)
              .arg(m_params.clusterShapeTolerance)
              .arg(deviation_ratio);
        }
    }
    // End of
    // for(int iter = 0; iter < m_params.clusterShapeCentroidCount - 1;
    // ++iter)


  // 4. Update feature
  // =================
  charge_specific_feature.monoisotopicMz          = obs_mono_mz;
  charge_specific_feature.monoisotopicNeutralMass = obs_mono_neutral_mass;
  charge_specific_feature.monoisotopicIntensity   = obs_mono_int;
  charge_specific_feature.fitQuality              = fit_quality;
  charge_specific_feature.matchingIsotopicClusterQSPtr =
    QSharedPointer<pappso::Trace>::create(charged_theoretical_isotopic_cluster);

  // 5. Acceptance
  // =============

  // Consume the input centroid.
  globally_used[input_centroid_index] = true;

  // But consume also all the input centroids that were used
  // successfully to match any of the theoretical isotopic cluster
  // centroids:
  processing_details_string +=
    QString(
      "\nAll reliablity tests passed:\n"
      "\tConsume starting input centroid index %1 - m/z %2.\n"
      "\tAnd all the input spectrum indices that contributed "
      "to this cluster match:\n\n")
      .arg(input_centroid_index)
      .arg(input_centroids[input_centroid_index].x, 0, 'f', 6);

  for(const IsoMatch &iso_match : matches)
    {
      processing_details_string +=
        QString("\t\tConsume index %1 - m/z %2\n")
          .arg(iso_match.observedPeakIndex)
          .arg(input_centroids[iso_match.observedPeakIndex].x, 0, 'f', 6);

      globally_used[iso_match.observedPeakIndex] = true;
    }

  processing_details_string += QString("\n-> accepting (m/z %1, z %2).\n\n")
                                 .arg(obs_mono_mz, 0, 'f', 5)
                                 .arg(charge_specific_feature.charge);

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

  return true;
}

/*!
\brief Generates a set of ChargeSpecificFeature instances modelled using the
\a input_centroids input centroided mass spectrum.

The implementation of this function does the following:

For each centroid in \a input_centroids, a set of ChargeSpecificFeature
instances is created by iterating in the range of acceptable ion charge values
as defined in the member Parameters datum. Since the charge of the ion below
each centroid in the input mass spectrum is not known, a neutral mass is
computed corresponding to eacb one of the values in that charge range. For
each charge level, a new ChargeSpecificFeature is allocated.

If the neutral mass that is computed is outside of the min-max mass range as
defined in the member Parameters datum, then the input centroid is skipped.

All the generated ChargeSpecificFeature instances are returned in a vector.
*/
std::vector<ChargeSpecificFeature>
LowMassDeconvolverFullLabelling::generateChargeSpecificFeatures(
  const pappso::Trace &input_centroids) const
{
  QString processing_details_string =
    QString(
      "Generating model charged features for %1 selected candidate "
      "peaks from input centroided mass spectrum.\n")
      .arg(input_centroids.size());

  // for(int iter = 0; iter < static_cast<int>(input_centroids.size()); ++iter)
  //   {
  //     processing_details_string += QString("\tIndex %1: %2\n")
  //                                    .arg(iter)
  //                                    .arg(input_centroids.at(iter).toString());
  //   }

  // qDebug().noquote() << qSetRealNumberPrecision(4) <<
  // processing_details_string;
  logProcessingDetails(processing_details_string);
  processing_details_string.clear();

  std::vector<ChargeSpecificFeature> charge_specific_features;

  charge_specific_features.reserve(input_centroids.size() * m_params.maxCharge);

  // We'll need this to document the input centroids that generated features
  // that could be matched. We won't iterate in these centroids more than once.
  std::vector<bool> globally_used(input_centroids.size(), false);

  // We want to iterate in the input centroided mass spectrum in the reverse
  // order so that we get the most intense peak first each time we encounter an
  // isotopic cluster.

  // For each centroid peak in the input data, compute a neutral mass
  // by assuming that its charge is either 1, 2 or 3 ... m_params.maxCharge.
  // Of course we do not know the charge of the ion below that centroid.

  if(!input_centroids.size())
    return charge_specific_features;

  for(int iter = input_centroids.size() - 1; iter >= 0; --iter)
    {
      if(globally_used[iter])
        continue;

      pappso::DataPoint input_centroid = input_centroids[iter];

      processing_details_string +=
        QString(
          "Generating charge-specific feature for "
          "input mass centroid at index %1 with m/z: %2\n\n")
          .arg(iter)
          .arg(input_centroid.toString());

      // After neutralizing the data point, make charged variants of it.

      // Iterate in the possible charge values in reversed order so that
      // we consume the multi-charged ions before getting to the mono-charged.
      // This way, we avoid having a real twice-charged ion being matched to
      // every other peak of a theoretical isotopic cluster generated for
      // charge 1.

      for(int z = m_params.maxCharge; z >= m_params.minCharge; --z)
        // for(int z = 1; z <= m_params.maxCharge; ++z)
        {
          // Compute neutral mass assuming that spectrum centroid was for
          // charge z.

          double neutral_mass = mzToNeutral(input_centroid.x, z);

          processing_details_string +=
            QString(
              "=================================================\n"
              "\tInput centroid m/z: %1 - assuming spectrum centroid "
              "was of charge: %2 -> neutral mass: %3\n")
              .arg(input_centroid.x, 0, 'f', 5)
              .arg(z)
              .arg(neutral_mass, 0, 'f', 5);

          // Skip masses outside allowed range
          if(neutral_mass < m_params.minMass || neutral_mass > m_params.maxMass)
            {
              processing_details_string +=
                QString(
                  "\t\tFound neutral mass that is outside accepted "
                  "range: %1, skipping it.\n")
                  .arg(neutral_mass, 0, 'f', 5);
              continue;
            }

          // Prepare a ChargeSpecificFeature to fill
          ChargeSpecificFeature charge_specific_feature;
          charge_specific_feature.charge            = z;
          charge_specific_feature.testedNeutralMass = neutral_mass;
          // Note that mz might not be the actual monoisotopic mz of the
          // isotopic cluster (if matches are to be found correct), because
          // mz might be the leftmost m/z value of an isotopic cluster
          // of an analyte fully labelled with 13C and 15N. In that case,
          // mz will actually the lightest of the isotopologues of the cluster
          // and the actual monoisotopic (highest probability centroid) will
          // be two or three m/z values on the right of the spectrum.
          charge_specific_feature.testedMz          = input_centroid.x;
          charge_specific_feature.testedIntensity   = input_centroid.y;

          processing_details_string +=
            QString(
              "\t\tGoing to check envelope fit for charged "
              "feature:\n\t\t\t%1\n")
              .arg(
                charge_specific_feature.toString(false /*with_fit_quality*/));

          // qDebug().noquote()
          //   << qSetRealNumberPrecision(4) << processing_details_string;
          logProcessingDetails(processing_details_string);
          processing_details_string.clear();

          bool accepted = fitIsotopicEnvelope(
            input_centroids, iter, charge_specific_feature, globally_used);

          if(accepted)
            {
              if(charge_specific_feature.matchingIsotopicClusterQSPtr ==
                 nullptr)
                qFatal() << "Programming error. The pointer cannot be nullptr.";

              processing_details_string += QString("---> Accepted\n\n");
              // qDebug().noquote()
              //   << qSetRealNumberPrecision(4) << processing_details_string;
              logProcessingDetails(processing_details_string);
              processing_details_string.clear();

              charge_specific_features.push_back(
                std::move(charge_specific_feature));
            }
          else
            {
              processing_details_string += QString("---> Rejected\n\n");
              // qDebug().noquote() << processing_details_string;
              logProcessingDetails(processing_details_string);
              processing_details_string.clear();
            }
        }
      // End of
      // for(int z = 1; z <= m_params.maxCharge; ++z)
      // That is, finished iterating in the accepted charge range.
    }
  // End of
  // for(const pappso::DataPoint &iter_input_candidate_peak : input_centroids)
  // That is, finished iterating in the input centroided mass spectrum.

  return charge_specific_features;
}

/*!
\brief Returns an iterator to the IsoMatch instance that corresponds to the
match having the highest probability among all the matches in \a matches.

The rationale is that it is not always true that the monoisotopic match is the
first match corresponding to the first peak in an isotopic cluster. Indeed,
when working with fully labelled analytes using stable heavy isotopes of
carbon and nitrogen, the isotopic cluster shape is inverted with the
monoisotopic cluster centroid being the rightmost centroid of the cluster.

This function does the right thing in any situation: return the first centroid
when the data involved natural abundance isotopes and the last one when the
data involved stable heavy isotope full labelling of the analytes.
*/
std::vector<IsoMatch>::const_iterator
LowMassDeconvolverFullLabelling::getMonoisotopicMatchIterator(
  const std::vector<IsoMatch> &matches, QString &message) const
{
  // The monoisotopic match is the match that corresponds to the most probable
  // observed centroid (that is, the one having the highest intensity).

  return getIsotopologueMatchIterator(matches, 0, message);
}

/*!
\brief Returns an iterator to the IsoMatch instance in \a matches found
to be the isotopologue \a isotopologue of the isotopic cluster.

Since we are handling fully labeled C13/N15 analytes, and that these
analytes are of a low mass, the monoisotopic centroid has to be both:

- the one of greater m/z value
- the one of greater intensity (the smallest-intensity
  peaks right of the most intense peak that are due to the hyperfine isotopic
  structure have been removed by getIsotopicClusterReducedToMaxMatchCount()).

Isotopologue 0 is the monoisotopic centroid, isotopologue 1 is the centroid that
is found left of isotopologue 0, isotopologue 2 is the centroid that is
found left of isotopologue 1.
*/
std::vector<IsoMatch>::const_iterator
LowMassDeconvolverFullLabelling::getIsotopologueMatchIterator(
  const std::vector<IsoMatch> &matches,
  int isotopologue,
  QString &message) const
{
  // The iso matches are ordered in the vector in the m/z (and input centroid
  // index) decreasing order because we are working with fully labelled
  // analytes.
  message += "The isotopic cluster matches are: \n";

  for(const IsoMatch &iso_match : matches)
    message += "\t" + iso_match.toString();

  std::vector<IsoMatch>::const_iterator the_iterator_const = matches.cbegin();
  std::vector<IsoMatch>::const_iterator the_end_iterator_const = matches.cend();

  if(the_iterator_const == the_end_iterator_const)
    {
      message += "\nThere are no matches.\n";
      // qDebug() << message;
      return the_end_iterator_const;
    }

  if(isotopologue >= static_cast<int>(matches.size()))
    {
      message +=
        QString(
          "\nThe 'isotopologue' index (%1) is too large vs matches count.\n")
          .arg(isotopologue);
      return the_end_iterator_const;
    }

  // The isotopologue 0 is the highest probability isotopologue. The
  // isotopologue 1 is the one that is next to it. If the cluster is of natural
  // abundance isotopes, that isotopologue will be next to the first to the
  // right, otherwise it will be next to the first to the left.

  // The isotopic cluster matches are:
  //
  // clang-format off
  // Theoretical cluster index: 0 - Observed peak index: 5921 - Observed m/z: 1317.73197 - Observed intensity: 2.8724e+06
  // Theoretical cluster index: 1 - Observed peak index: 5917 - Observed m/z: 1316.73060 - Observed intensity: 1.55154e+06
  // Theoretical cluster index: 2 - Observed peak index: 5911 - Observed m/z: 1315.72142 - Observed intensity: 931520
  // clang-format on

  // Start by checking what is the most intense peak (should be the right-most
  // centroid of the matches, that is the match with the greatest m/z value).
  // This is true because we work on low mass analytes.

  the_iterator_const = matches.cbegin();

  double highest_intensity = 0;
  std::vector<IsoMatch>::const_iterator max_intensity_iterator_const =
    the_end_iterator_const;

  while(the_iterator_const != the_end_iterator_const)
    {
      if(the_iterator_const->observedIntensity > highest_intensity)
        {
          highest_intensity            = the_iterator_const->observedIntensity;
          max_intensity_iterator_const = the_iterator_const;
        }

      ++the_iterator_const;
    }

  message += QString("The max intensity iterator points to match: %1\n")
               .arg(max_intensity_iterator_const->toString());
  // qDebug() << message;

  // Continue by checking what is the right-most m/z peak (should be the most
  // intense centroid of the matches).

  the_iterator_const = matches.cbegin();

  double highest_mz = 0;
  std::vector<IsoMatch>::const_iterator max_mz_iterator_const =
    the_end_iterator_const;

  while(the_iterator_const != the_end_iterator_const)
    {
      if(the_iterator_const->observedMz > highest_mz)
        {
          highest_mz            = the_iterator_const->observedMz;
          max_mz_iterator_const = the_iterator_const;
        }

      ++the_iterator_const;
    }

  message += QString("The max m/z iterator points to match: %1\n")
               .arg(max_mz_iterator_const->toString());
  // qDebug() << message;

  // Both iterators should point to the same match.

  if(max_intensity_iterator_const != max_mz_iterator_const)
    {
      message += QString(
        "The max intensity iterator and the max m/z iterator point to "
        "different matches: error.\n");
      // qDebug() << message;
      return the_end_iterator_const;
    }

  // At this point we have identified the monoisotopic centroid!
  // Return the iterator to which the isotopologue arg is added (see the matches
  // above). If isotopologue is 0 (that is, we want the monoisotopologue), then
  // return the unchanged found iterator. Otherwise, increment the iterator by
  // the value of the isotopologue that is requested.
  return max_intensity_iterator_const + isotopologue;
}

pappso::Trace
LowMassDeconvolverFullLabelling::getIsotopicClusterReducedToMaxMatchCount(
  const pappso::Trace &isotopic_cluster) const
{
  // We get a cluster with a number of centroids. But we do not want to have too
  // many centroids, because that harms the matching process with the input
  // centroided mass spectrum, due to the increased possibility that a new
  // cluster is convoluted with the initial one.

  pappso::Trace::const_iterator max_y_datapoint_iter =
    pappso::maxYDataPoint(isotopic_cluster.begin(), isotopic_cluster.end());

  if(max_y_datapoint_iter == isotopic_cluster.end())
    qFatal()
      << "Failed to get iterator to max Y data point of theoretical cluster.";

  int max_y_datapoint_index =
    std::distance(isotopic_cluster.begin(), max_y_datapoint_iter);

  pappso::Trace reduced_trace;

  int last_datapoint_index =
    max_y_datapoint_index - m_params.maxIsotopicClusterMatches;

  while(max_y_datapoint_index >= 0 &&
        max_y_datapoint_index > last_datapoint_index)
    {
      reduced_trace.append(
        pappso::DataPoint(isotopic_cluster.at(max_y_datapoint_index)));
      --max_y_datapoint_index;
    }

      return reduced_trace;
}

/*!
\brief Performs the deconvolution of the input centroided mass spectrum \a
input_centroids.

Returns a container of DeconvolutedFeature instances corresponding to the found
neutral masses along with supporting ions data.
*/
std::vector<DeconvolutedFeature>
LowMassDeconvolverFullLabelling::deconvolute(
  const pappso::Trace &input_centroids) const
{
  // 1. Filter peaks (intensity, mass)
  // 2. Detect charge-specific isotopic candidates
  // 3. Fit theoretical envelopes (IsoSpec++)
  // 4. Keep ChargeSpecificFeature candidates
  // 5. Group by neutral mass
  // 6. Build DeconvolutedFeature objects

  auto input_candidate_centroids = selectCandidatePeaks(input_centroids);
  auto charge_specific_features =
    generateChargeSpecificFeatures(input_candidate_centroids);
  auto deconvoluted_features = groupByNeutralMass(charge_specific_features);
  return deconvoluted_features;
}

} // namespace libXpertMassCore
} // namespace MsXpS
