import type { NumberArray } from 'cheminfo-types';

import { matrixCholeskySolver } from '../matrix/index.ts';
import { addWeights } from '../utils/addWeights.ts';
import { createSystemMatrix } from '../utils/createSystemMatrix.ts';
import type {
  CalculateAdaptiveWeightsOptions,
  WeightsAndControlPoints,
} from '../utils/index.ts';
import { calculateAdaptiveWeights } from '../utils/index.ts';

import { xEnsureFloat64 } from './xEnsureFloat64.ts';
import { xMultiply } from './xMultiply.ts';

export interface XWhittakerSmootherOptions extends CalculateAdaptiveWeightsOptions {
  /**
   * Factor of weights matrix in -> [I + lambda D'D]z = x
   * @default 100
   */
  lambda?: number;
  /**
   * Maximum number of iterations for the baseline refinement process.
   * @default 100
   */
  maxIterations?: number;

  /**
   * Tolerance for convergence. The process stops if the change in baseline is less than this value.
   * @default 1e-6
   */
  tolerance?: number;

  /**
   * Learning rate for weight updates.
   * @default 0.5
   */
  learningRate?: number;
  /**
   * Solver algorithm to use for the smoothing routine.
   * - `'cholesky'` — build the full (symmetric) system and solve using a
   *   Cholesky decomposition. Generally more robust for small/medium arrays
   *   and when a matrix factorization is acceptable.
   * - `'thomas'` — use a tridiagonal formulation and solve with the Thomas
   *   algorithm (specialised tridiagonal solver). Lower memory usage and
   *   faster for large inputs when the system is tridiagonal.
   * @default 'thomas'
   */
  algorithm?: 'cholesky' | 'thomas';
}

export function xWhittakerSmoother(
  yData: NumberArray,
  options: XWhittakerSmootherOptions = {},
) {
  const { algorithm = 'thomas', ...restOptions } = options;

  if (algorithm === 'thomas') {
    return whittakerByThomas(yData, restOptions);
  }

  return whittakerByCholesky(yData, restOptions);
}

/**
 * @deprecated Use xWhittakerSmoother instead.
 * TODO: Remove in next major version.
 */
export const xWhitakerSmoother = xWhittakerSmoother;

/**
 * Computes the baseline points for the given data using an iterative smoothing algorithm.
 * @param yData - the input data array.
 * @param options - the options for baseline computation.
 * @returns the computed baseline points.
 */
function whittakerByCholesky(
  yData: NumberArray,
  options: XWhittakerSmootherOptions = {},
) {
  const {
    lambda = 100,
    maxIterations = 100,
    tolerance = 1e-6,
    factorStd = 3,
    learningRate = 0.5,
  } = options;

  const size = yData.length;

  const { controlPoints, weights } = getWeightsAndControlPoints(yData, options);
  const prevBaseline: Float64Array = new Float64Array(size);

  let iteration = 0;
  let delta = Infinity;
  let baseline = xEnsureFloat64(yData);
  const { upperTriangularNonZeros, permutationEncodedArray } =
    createSystemMatrix(size, lambda);
  while (iteration < maxIterations && delta > tolerance) {
    const { leftHandSide, rightHandSide } = addWeights(
      upperTriangularNonZeros,
      yData,
      weights,
    );

    const cho = matrixCholeskySolver(
      leftHandSide,
      size,
      permutationEncodedArray,
    );

    if (!cho) {
      return baseline;
    }

    const newBaseline = cho(rightHandSide);

    //weights is updated inplace
    calculateAdaptiveWeights(yData, newBaseline, weights, {
      controlPoints,
      learningRate,
      factorStd,
    });

    delta = calculateDelta(newBaseline, prevBaseline, size);
    prevBaseline.set(newBaseline);
    baseline = xEnsureFloat64(newBaseline);
    iteration++;
  }

  return baseline;
}

/**
 * Calculates the delta between the current and previous baseline.
 * @param baseline - the current baseline array.
 * @param prevBaseline - the previous baseline array.
 * @param n - the length of the arrays.
 * @returns the calculated delta value.
 */
function calculateDelta(
  baseline: NumberArray,
  prevBaseline: NumberArray,
  n: number,
): number {
  let sum = 0;
  for (let i = 0; i < n; i++) {
    sum += (baseline[i] - prevBaseline[i]) ** 2;
  }
  return Math.sqrt(sum / n);
}

/**
 * Retrieves the control points and weights for the given data, the weights are modified multiplication of controlPoints if it exists.
 * @param y - the input data array.
 * @param options - the options for control points and weights.
 * @returns the control points and modified weights.
 */
function getWeightsAndControlPoints(
  y: NumberArray,
  options: WeightsAndControlPoints = {},
): { weights: NumberArray; controlPoints?: NumberArray } {
  const { controlPoints } = options;
  const { weights = Float64Array.from({ length: y.length }).fill(1) } = options;

  if (controlPoints && controlPoints.length !== y.length) {
    throw new RangeError('controlPoints should match the length with X');
  } else if (weights.length !== y.length) {
    throw new RangeError('weights should match the length with X');
  }

  return {
    weights: controlPoints ? xMultiply(weights, controlPoints) : weights,
    controlPoints,
  };
}

function whittakerByThomas(
  yData: NumberArray,
  options: XWhittakerSmootherOptions = {},
) {
  const {
    lambda = 100,
    maxIterations = 100,
    tolerance = 1e-6,
    factorStd = 3,
    learningRate = 0.5,
  } = options;

  const n = yData.length;
  const y = xEnsureFloat64(yData);
  const { controlPoints, weights } = getWeightsAndControlPoints(yData, options);

  const prevBaseline = new Float64Array(n);
  let baseline = xEnsureFloat64(yData);

  // Precompute base diagonal and constant off-diagonals (−lambda)
  const baseDiag = new Float64Array(n);
  if (n === 1) {
    baseDiag[0] = lambda;
  } else {
    baseDiag[0] = lambda;
    for (let i = 1; i < n - 1; i++) baseDiag[i] = 2 * lambda;
    baseDiag[n - 1] = lambda;
  }
  const lower = new Float64Array(Math.max(0, n - 1));
  const upper = new Float64Array(Math.max(0, n - 1));
  for (let i = 0; i < lower.length; i++) {
    lower[i] = -lambda;
    upper[i] = -lambda;
  }

  const main = new Float64Array(n);
  const rhs = new Float64Array(n);
  const solution = new Float64Array(n);
  const workC = new Float64Array(Math.max(0, n - 1));
  const workD = new Float64Array(n);

  let iteration = 0;
  let delta = Infinity;

  /**
   * Iterative Whittaker smoothing implementation using the Thomas algorithm
   * (tridiagonal solver). The method constructs the system
   * [W + lambda * D'D] z = W * y where D is the second-difference operator
   * and W are adaptive weights. At each iteration the tridiagonal system is
   * solved with the Thomas algorithm and the weights are updated using
   * `calculateAdaptiveWeights` until convergence.
   *
   * Use this algorithm by passing `algorithm: 'thomas'` to `xWhittakerSmoother`.
   * It is more memory-efficient for large inputs because it avoids forming
   * the full banded matrix, but both implementations aim to produce the same
   * baseline result.
   * @param yData - input signal/spectrum
   * @param options - smoothing options (supports `lambda`, `maxIterations`,
   *                  `tolerance`, `factorStd`, `learningRate`, and optional
   *                  `controlPoints` / `weights` inherited from
   *                  `CalculateAdaptiveWeightsOptions`).
   * @returns baseline as a `Float64Array`.
   */

  while (iteration < maxIterations && delta > tolerance) {
    // Build main diagonal = baseDiag + weights, and RHS = weights * y
    for (let i = 0; i < n; i++) {
      main[i] = baseDiag[i] + weights[i];
      rhs[i] = weights[i] * y[i];
    }

    // Solve tridiagonal system in-place into `solution`
    solveTridiagonalFloat64(lower, main, upper, rhs, solution, workC, workD);

    calculateAdaptiveWeights(y, solution, weights, {
      controlPoints,
      learningRate,
      factorStd,
    });

    delta = calculateDelta(solution, prevBaseline, n);
    prevBaseline.set(solution);
    baseline = xEnsureFloat64(solution);
    iteration++;
  }

  return baseline;
}

function solveTridiagonalFloat64(
  lower: Float64Array,
  diag: Float64Array,
  upper: Float64Array,
  rhs: Float64Array,
  out?: Float64Array,
  cp?: Float64Array,
  dp?: Float64Array,
): Float64Array {
  const n = diag.length;
  const x = out ?? new Float64Array(n);
  if (n === 0) return x;
  if (n === 1) {
    x[0] = rhs[0] / diag[0];
    return x;
  }
  const cprime = cp ?? new Float64Array(n - 1);
  const dprime = dp ?? new Float64Array(n);

  let denom = diag[0];
  cprime[0] = upper[0] / denom;
  dprime[0] = rhs[0] / denom;

  for (let i = 1; i < n - 1; i++) {
    denom = diag[i] - lower[i - 1] * cprime[i - 1];
    cprime[i] = upper[i] / denom;
    dprime[i] = (rhs[i] - lower[i - 1] * dprime[i - 1]) / denom;
  }

  denom = diag[n - 1] - lower[n - 2] * cprime[n - 2];
  dprime[n - 1] = (rhs[n - 1] - lower[n - 2] * dprime[n - 2]) / denom;

  x[n - 1] = dprime[n - 1];
  for (let i = n - 2; i >= 0; i--) {
    x[i] = dprime[i] - cprime[i] * x[i + 1];
  }
  return x;
}
