#### Based on libsamplerate
####
#### Copyright (c) 2002-2021, Erik de Castro Lopo <erikd@mega-nerd.com>
#### Copyright (c) 2024, Remilia Scarlet <remilia@posteo.jp>
#### All rights reserved.
####
#### This code is released under 2-clause BSD license. Please see the
#### file at : https://github.com/libsndfile/libsamplerate/blob/master/COPYING

module RemiAudio::Resampler
  # Used to process a block of audio data using `#process`.
  private class Data
    # The input audio data samples.
    property dataIn : Slice(Float32)

    # Where processed audio samples will be stored.
    property dataOut : Slice(Float32)

    # The number of frames of `#dataIn` to process.
    property inputFrames : Int64 = 0i64

    # The maximum number of frames to store in `#dataOut`.
    property outputFrames : Int64 = 0i64

    # The number of frames from `dataIn` that were actually used during
    # processing.  This is set by the
    # `RemiAudio::Resampler::Resampler#process` method.
    getter inputFramesUsed : Int64 = 0i64
    protected setter inputFramesUsed : Int64

    # The actual number of frames written to `dataOutn` during processing.
    # This is set by the `RemiAudio::Resampler::Resampler#process` method.
    getter outputFramesGen : Int64 = 0i64
    protected setter outputFramesGen : Int64

    # When `true`, then all of the input data was used, otherwise some of it
    # went unused.  This is set by the
    # `RemiAudio::Resampler::Resampler#process` method.
    getter? endOfInput : Bool = false
    protected setter endOfInput : Bool

    # The resampling ratio (`targetRate / sourceRate`).
    getter srcRatio : Float64 = 0.0

    # Creates a new `Data` instance that will be used to convert audio data
    # from *dataIn* and store the results into *dataOut*.
    def initialize(@dataIn : Slice(Float32), @dataOut : Slice(Float32))
    end

    protected def initialize
      @dataIn = Slice(Float32).empty
      @dataOut = Slice(Float32).empty
    end

    # Sets the ratio used for resampling (`targetRate / sourceRate`).
    def srcRatio=(value : Float64) : Nil
      unless RemiAudio::Resampler.validRatio?(value)
        raise BadRatioError.new("Invalid ratio")
      end
      @srcRatio = value
    end

    # Resets this instance to its initial state.
    @[AlwaysInline]
    protected def reset : Nil
      @inputFrames = 0i64
      @outputFrames = 0i64
      @inputFramesUsed = 0i64
      @outputFramesGen = 0i64
      @endOfInput = false
      @srcRatio = 0.0
    end
  end

  # Base class for all resamplers.
  abstract class Resampler
    @lastRatio : Float64 = 0.0
    @lastPosition : Float64 = 0.0
    @dataToProcess : Data?

    # The number of channels this resampler works on.
    getter channels : Int32

    @[AlwaysInline]
    protected def fmodOne(x : Float64) : Float64
      res : Float64 = x - x.round
      if res < 0.0
        res + 1.0
      else
        res
      end
    end

    # Keep the compiler happy
    private def initialize(@channels : Int32)
      raise "What?"
    end

    # Sets the ratio of the resampler.  This can be changed between calls to
    # process/read data and the library will try to smoothly transition between
    # the conversion ratio of the last call and the conversion ratio of the
    # current call.
    #
    # If the user wants to bypass this smooth transition and achieve a step
    # response in the conversion ratio, the `ratio=` method can be used to set
    # the starting conversion ratio of the next call to process/read data.
    #
    # This will raise a `BadRatioError` if the ratio is out of range.
    def ratio=(value : Float64) : Nil
      unless RemiAudio::Resampler.validRatio?(value)
        raise BadRatioError.new("Invalid ratio")
      end

      @lastRatio = value
    end

    # Resets the resampler.
    def reset : Nil
      @lastPosition = 0.0
      @lastRatio = 0.0
    end

    protected abstract def variProcess(data : Data) : Nil

    protected def constProcess(data : Data) : Nil
      variProcess(data)
    end

    # Resamples the audio data in *data*.
    protected def process(data : Data) : Nil
      data.inputFrames = 0 if data.inputFrames < 0
      data.outputFrames = 0 if data.outputFrames < 0

      # Check for data overlap
      inStart = data.dataIn.to_unsafe.address
      inEnd = (data.dataIn.to_unsafe + (data.inputFrames * @channels)).address
      outStart = data.dataOut.to_unsafe.address
      outEnd = (data.dataOut.to_unsafe + (data.outputFrames * @channels)).address

      if inStart == outStart
        raise Error.new("Input and output data are the same")
      elsif inStart < outStart
        if inEnd > outStart
          raise Error.new("Input and output data overlaps in memory")
        end
      elsif outEnd > inStart
        raise Error.new("Output and input data overlaps in memory")
      end

      # Set the input and output counts to zero.
      data.inputFramesUsed = 0
      data.outputFramesGen = 0

      # Special case for when last_ratio has not been set.
      @lastRatio = data.srcRatio if @lastRatio < (1.0 / SRC_MAX_RATIO)

      # Now process
      if (@lastRatio - data.srcRatio).abs < 1e-15
        constProcess(data)
      else
        variProcess(data)
      end
    end

    @[AlwaysInline]
    protected def dataToProcess : Data
      if ret = @dataToProcess
        ret.reset
        ret
      else
        @dataToProcess = Data.new
      end
    end

    # Resamples the audio data in *source*, writing the results into *dest*,
    # using the given ratio (`targetRate / sourceRate`).
    #
    # Note that this method is not implemented in `CallbackResampler` classes
    # and will raise a `NotImplementedError` if you attempt to use it with one.
    def process(source : Array(Float32)|Slice(Float32), dest : Array(Float32)|Slice(Float32),
                ratio : Float64) : Tuple(Int64, Int64, Bool)
      data = dataToProcess

      case source
      in Slice(Float32) then data.dataIn = source
      in Array(Float32) then data.dataIn = Slice(Float32).new(source.to_unsafe, source.size)
      end

      case dest
      in Slice(Float32) then data.dataOut = dest
      in Array(Float32) then data.dataOut = Slice(Float32).new(dest.to_unsafe, dest.size)
      end

      data.inputFrames = source.size.tdiv(@channels).to_i64!
      data.outputFrames = dest.size.tdiv(@channels).to_i64!
      data.srcRatio = ratio
      process(data)
      {data.inputFramesUsed, data.outputFramesGen, data.endOfInput?}
    end

    # :ditto:
    def process(source : Array(Float64)|Slice(Float64), dest : Array(Float64)|Slice(Float64),
                ratio : Float64) : Tuple(Int64, Int64, Bool)
      src32 = Slice(Float32).new(source.size) do |idx|
        source.unsafe_fetch(idx).to_f32!
      end

      dest32 = Slice(Float32).new(dest.size, 0.0f32)

      ret = process(src32, dest32, ratio)
      dest32.each_with_index do |smp, idx|
        dest[idx] = smp.to_f64!
      end
      ret
    end
  end

  # The `CallbackResampler` mixin is used in `Resampler` subclasses that call a
  # callback function to read source audio data as-needed.
  module CallbackResampler
    # The method called when new input data is needed.
    getter callbackFunc : CallbackProc
    protected property savedFrames : Int64 = 0i64
    protected property savedData : Slice(Float32) = Slice(Float32).empty
    @data : Data = Data.new
    @tempFloat32Buf : Slice(Float32)?

    protected abstract def processData : Nil

    @[AlwaysInline]
    def tempFloat32Buf(size : Int) : Slice(Float32)
      if (ret = @tempFloat32Buf) && ret.size == size
        ret
      else
        @tempFloat32Buf = Slice(Float32).new(size, 0.0f32)
      end
    end

    # Reads audio data into *data* at the given ratio, returning the actual
    # number of samples that were read into *data*.
    #
    # Note that the returned value is samples _per channel_.
    def read(ratio : Float64, data : Array(Float32)|Slice(Float32)) : Int64
      return 0i64 if data.empty?

      frames = data.size.tdiv(@channels)
      @data.reset
      @data.srcRatio = ratio
      @data.dataOut = case data
                      in Array(Float32)
                        Slice(Float32).new(data.to_unsafe, data.size)
                      in Slice(Float32)
                        data
                      end
      @data.outputFrames = frames.to_i64!
      @data.dataIn = @savedData
      @data.inputFrames = @savedFrames

      outputFramesGen : Int64 = 0i64
      while outputFramesGen < frames
        if @data.inputFrames == 0
          @data.dataIn, @data.inputFrames = @callbackFunc.call
          if @data.inputFrames == 0
            @data.endOfInput = true
          end
        end

        # Now call process function.
        processData

        newIdx = @data.inputFramesUsed * @channels
        if newIdx < @data.dataIn.size
          @data.dataIn = @data.dataIn[newIdx..]
        else
          @data.dataIn = Slice(Float32).empty
        end
        @data.inputFrames -= @data.inputFramesUsed

        newIdx = @data.outputFramesGen * @channels
        if newIdx < @data.dataOut.size
          @data.dataOut = @data.dataOut[newIdx..]
        else
          @data.dataOut = Slice(Float32).empty
        end
        @data.outputFrames -= @data.outputFramesGen

        outputFramesGen += @data.outputFramesGen

        break if @data.endOfInput? && @data.outputFramesGen == 0
      end

      @savedData = @data.dataIn
      @savedFrames = @data.inputFrames

      outputFramesGen
    end

    # :ditto:
    def read(ratio : Float64, data : Array(Float64)|Slice(Float64)) : Int64
      return 0i64 if data.empty?
      buf = tempFloat32Buf(data.size)
      ret = read(ratio, buf)
      buf.each_with_index do |smp, idx|
        data[idx] = smp.to_f64!
      end
      ret
    end
  end
end
