/*
 *  Copyright (c) 2024 - 2025 by Paul Hertz <ignotus@gmail.com>
 *
 *   This program is free software; you can redistribute it and/or modify
 *   it under the terms of the GNU Library 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 Library General Public License for more details.
 *
 *   You should have received a copy of the GNU Library General Public
 *   License along with this program; if not, write to the Free Software
 *   Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */

package net.paulhertz.pixelaudio.granular;

import ddf.minim.analysis.WindowFunction;
import net.paulhertz.pixelaudio.sampler.PitchPolicy;

/**
 * Core class in the PAGranularInstrumentDirector granular synthesis processing chain, provides
 * windowed granular synthesis with normalization.<br>
 * 
 * Calling chain: PAGranularInstrumentDirector -> PAGranularInstrument -> PAGranularSampler -> PAGranularVoice, <br>
 * with PABurstGranularSource handling the complexities of the granular synthesis sample by sample. 
 * 
 */
public final class PABurstGranularSource implements PASource {

    private final float[] source;
    private final int baseIndex;          // startIndices[i]
    private final int burstGrains;        // >= 1
    private final int timeHopSamples;     // >= 1 (intra-burst spacing in time)
    private final int indexHopSamples;    // >= 0 (intra-burst scan in source index)
    private final float pitchRatio;       // > 0

    // Optional per-source gain (multiplicative). Default unity.
    // If you already apply gain outside this PASource, leave at 1.
    // Not used, for now.
    private float gain = 1.0f;

    // window + grain length (authoritative)
    private int grainLength;
    private WindowFunction grainWindow = null;
    private float[] windowCurve = null;

    // note-level state (voice time origin)
    private long noteStartSample = Long.MIN_VALUE;
    private boolean noteStarted = false;

    // Scratch buffers (reused) for safe in-source OLA normalization
    private float[] wsum = new float[0];

    public PABurstGranularSource(
            float[] source,
            int baseIndex,
            int grainLengthSamples,
            int burstGrains,
            int timeHopSamples,
            int indexHopSamples,
            float pitchRatio
    ) {
        if (source == null) throw new IllegalArgumentException("source must not be null");
        this.source = source;

        this.baseIndex = Math.max(0, baseIndex);
        this.grainLength = Math.max(1, grainLengthSamples);

        this.burstGrains = Math.max(1, burstGrains);
        this.timeHopSamples = Math.max(1, timeHopSamples);
        this.indexHopSamples = Math.max(0, indexHopSamples);

        this.pitchRatio = (pitchRatio > 0f) ? pitchRatio : 1.0f;
    }

    /** Optional: set multiplicative gain applied after OLA normalization. */
    public synchronized void setGain(float gain) {
        if (Float.isFinite(gain)) this.gain = Math.max(0f, gain);
    }

    public synchronized float getGain() {
        return gain;
    }

    @Override
    public void seekTo(long absoluteSample) {
        this.noteStartSample = absoluteSample;
        this.noteStarted = true;
    }

    @Override
    public void renderBlock(long blockStart, int blockSize, float[] outL, float[] outR) {
        if (!noteStarted) return;
        if (outL == null || blockSize <= 0) return;

        final long blockEnd = blockStart + (long) blockSize;

        final float[] window =
                (windowCurve != null && windowCurve.length == grainLength)
                        ? windowCurve
                        : (grainWindow != null && grainLength > 1)
                            ? WindowCache.INSTANCE.getWindowCurve(grainWindow, grainLength)
                            : null;

        final boolean doR = (outR != null && outR != outL);

        ensureWSum(blockSize);

        // Each grain g occupies [grainStartAbs, grainEndAbs)
        for (int g = 0; g < burstGrains; g++) {
            final long grainStartAbs = noteStartSample + (long) g * (long) timeHopSamples;
            final long grainEndAbs   = grainStartAbs + (long) grainLength;

            if (grainEndAbs <= blockStart || grainStartAbs >= blockEnd) continue;

            OverlapUtil.Slice slice = OverlapUtil.computeBlockSlice(
                    blockStart, blockSize, grainStartAbs, grainEndAbs);
            if (!slice.hasOverlap) continue;

            final int i0 = slice.startIndex;
            final int i1 = slice.endIndex;

            final int grainSourceStart = baseIndex + g * indexHopSamples;

            for (int i = i0; i < i1; i++) {
                final long globalSample = blockStart + (long) i;
                final int offsetInGrain = (int) (globalSample - grainStartAbs);
                if (offsetInGrain < 0 || offsetInGrain >= grainLength) continue;

                final float srcPos =
                        (float) grainSourceStart +
                        (float) offsetInGrain * pitchRatio;

                if (srcPos < 0f || srcPos >= (source.length - 1)) continue;

                final float w = (window != null) ? window[offsetInGrain] : 1.0f;
                final float s = readLinear(source, srcPos) * w;

                outL[i] += s;
                if (doR) outR[i] += s;

                wsum[i] += w;
            }
        }

        // OLA normalization (Overlap-Add normalization, in-place)
        final float eps = 1e-12f;
        for (int i = 0; i < blockSize; i++) {
            float den = wsum[i];
            if (den > eps) {
                float inv = 1.0f / den;
                outL[i] *= inv;
                if (doR) outR[i] *= inv;
            }
        }
    }
    
    @Override
    public long lengthSamples() {
        return (long) (burstGrains - 1) * (long) timeHopSamples + (long) grainLength;
    }

    @Override
    public PitchPolicy pitchPolicy() {
        return PitchPolicy.SOURCE_GRANULAR;
    }

    @Override
    public synchronized void setGrainWindow(WindowFunction wf, int grainLenSamples) {
        this.grainWindow = wf;
        this.grainLength = Math.max(1, grainLenSamples);
        if (wf != null && this.grainLength > 1) {
            this.windowCurve = WindowCache.INSTANCE.getWindowCurve(wf, this.grainLength);
        } else {
            this.windowCurve = null; // rectangular
        }
    }

    private void ensureWSum(int blockSize) {
        if (wsum.length != blockSize) {
            wsum = new float[blockSize];
        }
        java.util.Arrays.fill(wsum, 0f);
    }
    
    /**
     * Reads interpolated value at pos in buf and returns it.
     * 
     * @param buf    the audio buffer we use to generate grains
     * @param pos    current position in the buffer
     * @return linear interpolated value at pos in buf
     */
    private static float readLinear(float[] buf, float pos) {
        int i0 = (int) pos;
        float frac = pos - i0;

        if (i0 < 0) return buf[0];
        if (i0 >= buf.length - 1) return buf[buf.length - 1];

        float a = buf[i0];
        float b = buf[i0 + 1];
        return a + frac * (b - a);
    }
}
