// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

export interface SpectrumFrame {
  bins: readonly number[] | ArrayBufferView;
  center_hz: number;
  sample_rate: number;
}

export interface InterferenceLevels { aci?: number; cci?: number }
export type BandwidthLimits = readonly [defaultHz: number, minHz: number, maxHz: number, stepHz: number];

function clampPercent(value: unknown): number {
  const numeric = Number(value) || 0;
  return Math.max(0, Math.min(100, numeric)) / 100;
}

export function estimateOccupiedBandwidth(
  data: SpectrumFrame | null,
  centerHz: number,
  mode: string,
  limits: BandwidthLimits,
  interference: InterferenceLevels = {},
): number | null {
  if (!data || !Array.isArray(data.bins) && !ArrayBuffer.isView(data.bins)
    || !Number.isFinite(centerHz)) return null;

  const bins = Array.from(data.bins as ArrayLike<number>);
  if (bins.length < 3) return null;
  const maxIdx = bins.length - 1;
  const hzPerBin = data.sample_rate / maxIdx;
  const fullLoHz = data.center_hz - data.sample_rate / 2;
  const centerIdx = Math.max(
    1,
    Math.min(maxIdx - 1, Math.round(((centerHz - fullLoHz) / data.sample_rate) * maxIdx)),
  );
  const normalizedMode = mode.toUpperCase();
  const [defaultBw, minBw, maxBw, stepBw] = limits;
  const oneSided = ["USB", "DIG", "CW"].includes(normalizedMode)
    ? 1 : ["LSB", "CWR"].includes(normalizedMode) ? -1 : 0;
  const isWfm = normalizedMode === "WFM";
  const smoothRadius = isWfm ? 3 : 1;
  const smoothed = bins.map((_, index) => {
    let sum = 0;
    let count = 0;
    for (let adjacent = Math.max(0, index - smoothRadius);
      adjacent <= Math.min(maxIdx, index + smoothRadius); adjacent += 1) {
      sum += bins[adjacent] ?? 0;
      count += 1;
    }
    return sum / count;
  });
  const sorted = [...bins].sort((left, right) => left - right);
  const noise = sorted[Math.floor(sorted.length * 0.2)] ?? -Infinity;
  const maxSpanBins = Math.max(2, Math.ceil(maxBw / hzPerBin));
  const searchHalfBins = oneSided === 0 ? Math.ceil(maxSpanBins / 2) : maxSpanBins;
  const searchLo = Math.max(1, centerIdx - (oneSided > 0 ? 2 : searchHalfBins));
  const searchHi = Math.min(maxIdx - 1, centerIdx + (oneSided < 0 ? 2 : searchHalfBins));
  let peak = -Infinity;
  for (let index = searchLo; index <= searchHi; index += 1) {
    peak = Math.max(peak, smoothed[index] ?? -Infinity);
  }
  const snr = peak - noise;
  if (!Number.isFinite(snr) || snr < (isWfm ? 5 : 4)) return isWfm ? minBw : defaultBw;

  const threshold = noise + Math.max(3, Math.min(isWfm ? 6 : 10, snr * (isWfm ? 0.18 : 0.28)));
  const allowedGap = Math.max(isWfm ? 4 : 2, Math.ceil((isWfm ? 12_000 : stepBw) / hzPerBin));
  const occupiedExtent = (direction: -1 | 1, limitBins: number): number => {
    let lastOccupied = centerIdx;
    let gap = 0;
    for (let offset = 0; offset <= limitBins; offset += 1) {
      const index = centerIdx + direction * offset;
      if (index <= 0 || index >= maxIdx) break;
      if ((smoothed[index] ?? -Infinity) >= threshold) {
        lastOccupied = index;
        gap = 0;
      } else if (++gap > allowedGap) break;
    }
    return Math.abs(lastOccupied - centerIdx) * hzPerBin;
  };

  let rawBw = oneSided !== 0
    ? occupiedExtent(oneSided, maxSpanBins)
    : 2 * Math.max(occupiedExtent(-1, searchHalfBins), occupiedExtent(1, searchHalfBins));
  rawBw *= isWfm ? 1.08 : 1.12;
  if (isWfm) {
    const aci = clampPercent(interference.aci);
    const cci = clampPercent(interference.cci);
    const aciCap = maxBw - (maxBw - minBw) * aci;
    const cciFloor = minBw + (defaultBw - minBw) * 0.65;
    const cciCap = maxBw - (maxBw - cciFloor) * cci;
    rawBw = Math.min(rawBw, aciCap, cciCap);
  }
  const clamped = Math.max(minBw, Math.min(maxBw, rawBw));
  return Math.max(stepBw, Math.round(clamped / stepBw) * stepBw);
}
