#### RemiAudio
#### Copyright (C) 2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or modify it
#### under the terms of the GNU Affero 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 Affero General Public
#### License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.
require "./port-lib"

module RemiAudio::Drivers::PortAudio
  # :nodoc:
  module PaMappable(T)
    def initialize(ptr : T)
      {% begin %}
        {% for ivar in @type.instance_vars %}
          @{{ivar.id}} = {% if ivar.type.resolve < Enum %}
                               {{ivar.type.id}}.new(ptr.value.{{ivar.id}}.value)
          {% elsif ivar.type == String %}
            String.new(ptr.value.{{ivar.id}})
          {% else %}
            ptr.value.{{ivar.id}}
          {% end %}
        {% end %}
      {% end %}
    end
  end

  # :nodoc:
  protected def self.raiseIfError(&)
    err = yield
    if err.is_a?(Number)
      if err >= -10000 && err <= -9972
        raise PaError.new("#{LibPortAudio::PaError.new(err)}", err.to_i64)
      else
        return err
      end
    elsif err != LibPortAudio::PaError::NoError
      raise PaError.new("PortAudio error: #{err}", err.to_i64)
    end
    0
  end

  class PaError < AudioDeviceError
    getter errCode : Int64 = 0

    def initialize(@message : String?)
    end

    def initialize(@message : String?, @errCode)
    end
  end

  enum HostApiType
    InDevelopment = 0
    DirectSound
    Mme
    Asio
    SoundManager
    CoreAudio
    Oss
    Alsa
    Al
    BeOs
    Wdmks
    Jack
    Wasapi
    AudioScienceHpi

    # Returns the runtine host API index associated with the `HostApiType`.
    def index : Int
      PortAudio.raiseIfError do
        LibPortAudio.Pa_HostApiTypeIdToHostApiIndex(self.value)
      end
    end
  end

  @[Flags]
  enum SampleFormat : UInt64
    Float32 = 0x00000001
    Int32   = 0x00000002
    Int24   = 0x00000004
    Int16   = 0x00000008
    Int8    = 0x00000010
    UInt8   = 0x00000020
    #CustomFormat = 0x00010000    # Not supported
    #NonInterleaved = 0x80000000  # Not supported

    # Retrieves the size of a single sample for this `SampleFormat`.
    def sampleSize : Int
      LibPortAudio.Pa_GetSampleSize(self.value)
    end
  end

  alias SampleRate = Int8|Int16|Int32|Int64|Int128|
                     UInt8|UInt16|UInt32|UInt64|UInt128|
                     Float32|Float64
  alias SampleBuffer = Slice(Float32)|Slice(Int32)|Slice(Int16)|Slice(Int8)|Slice(UInt8)|
                       Array(Float32)|Array(Int32)|Array(Int16)|Array(Int8)|Array(UInt8)

  alias StreamCallbackTimeInfo = LibPortAudio::StreamCallbackTimeInfo
  alias StreamCallbackFlags = LibPortAudio::StreamCallbackFlags
  alias PaCallback = LibPortAudio::PaCallback

  @[Flags]
  enum StreamFlags
    NoFlag = 0
    ClipOff = 0x00000001
    DitherOff = 0x00000002
  end

  struct HostErrorInfo
    include PaMappable(LibPortAudio::PHostErrorInfo)
    getter hostApiTypeID : HostApiType
    getter errorCode : Int64
    getter errorText : String
  end

  struct DeviceInfo
    include PaMappable(LibPortAudio::PDeviceInfo)
    getter structVersion : Int32
    getter name : String
    getter hostApi : Int32
    getter maxInputChannels : Int32
    getter maxOutputChannels : Int32
    getter defaultLowInputLatency : Float64
    getter defaultLowOutputLatency : Float64
    getter defaultHighInputLatency : Float64
    getter defaultHighOutputLatency : Float64
    getter defaultSampleRate : Float64
  end

  struct StreamParameters
    property device : Int32 = 0
    property channelCount : Int32 = 0
    property sampleFormat : SampleFormat = SampleFormat::Float32
    property suggestedLatency : Float64 = 0.0

    def initialize
    end

    def initialize(@device : Int32, @channelCount : Int32, @sampleFormat : SampleFormat, @suggestedLatency : Float64)
    end

    protected def toForeign : LibPortAudio::StreamParameters
      ret = LibPortAudio::StreamParameters.new
      ret.device = @device
      ret.channelCount = @channelCount
      ret.sampleFormat = @sampleFormat.value
      ret.suggestedLatency = @suggestedLatency
      ret.hostApiSpecificStreamInfo = nil
      ret
    end

    protected def toForeignPtr : Pointer(LibPortAudio::StreamParameters)
      ret = Pointer(LibPortAudio::StreamParameters).malloc(1)
      ret.value.device = @device
      ret.value.channelCount = @channelCount
      ret.value.sampleFormat = @sampleFormat.value
      ret.value.suggestedLatency = @suggestedLatency
      ret.value.hostApiSpecificStreamInfo = nil
      ret
    end

    def self.checkFormatSupported(inputParams : StreamParameters?, outputParams : StreamParameters?,
                                  sampleRate : SampleRate) : Bool
      finput = inputParams ? inputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null
      foutput = outputParams ? outputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null

      ret = PortAudio.raiseIfError do
        LibPortAudio.Pa_IsFormatSupported(finput, foutput, sampleRate.to_f64)
      end

      ret == LibPortAudio::FORMAT_IS_SUPPORTED
    end

    def self.isFormatSupported?(inputParams : StreamParameters?, outputParams : StreamParameters?,
                                sampleRate : SampleRate) : Bool
      # ameba:disable Style/RedundantBegin
      begin
        self.checkFormatSupported(inputParams, outputParams, sampleRate)
      rescue PaError
        false
      end
    end

    def self.isInputFormatSupported?(params : StreamParameters, sampleRate : SampleRate) : Bool
      self.isFormatSupported?(params, nil, sampleRate)
    end

    def self.isOutputFormatSupported?(params : StreamParameters, sampleRate : SampleRate) : Bool
      self.isFormatSupported?(nil, params, sampleRate)
    end
  end

  struct HostApiInfo
    include PaMappable(LibPortAudio::PHostApiInfo)
    getter structVersion : Int32
    getter typeId : HostApiType
    getter name : String
    getter deviceCount : Int32
    getter defaultInputDevice : Int32
    getter defaultOutputDevice : Int32
  end

  def self.getLastHostErrorInfo : HostErrorInfo
    HostErrorInfo.new(LibPortAudio.Pa_GetLastHostErrorInfo())
  end

  def self.version : Int
    LibPortAudio.Pa_GetVersion()
  end

  def self.versionStr : String
    String.new(LibPortAudio.Pa_GetVersionText())
  end

  def self.deviceCount : Int
    PortAudio.raiseIfError { LibPortAudio.Pa_GetDeviceCount() }.not_nil!
  end

  def self.defaultOutputDevice : Int
    PortAudio.raiseIfError { LibPortAudio.Pa_GetDefaultOutputDevice() }.not_nil!
  end

  def self.getDeviceInfo(device : Int) : DeviceInfo
    DeviceInfo.new(LibPortAudio.Pa_GetDeviceInfo(device))
  end

  def self.defaultInputDevice() : Int
    PortAudio.raiseIfError { LibPortAudio.Pa_GetDefaultInputDevice() }.not_nil!
  end

  def self.hostApiInfo(apiIndex : Int) : HostApiInfo
    HostApiInfo.new(LibPortAudio.Pa_GetHostApiInfo(apiIndex))
  end

  def self.init
    PortAudio.raiseIfError { LibPortAudio.Pa_Initialize() }
  end

  def self.terminate
    PortAudio.raiseIfError { LibPortAudio.Pa_Terminate() }
  end

  def self.defaultHostApi : Int
    PortAudio.raiseIfError { LibPortAudio.Pa_GetDefaultHostApi() }
  end

  def self.hostApiDeviceIndexToDeviceIndex(hostApi : Int, hostApiDeviceIndex : Int) : Int
    PortAudio.raiseIfError { LibPortAudio.Pa_HostApiDeviceIndexToDeviceIndex(hostApi, hostApiDeviceIndex) }
  end

  def self.sleep(msec : Int64) : Nil
    LibPortAudio.Pa_Sleep(msec)
  end

  # Initializes PortAudio using `PortAudio.start`, then yields.  This
  # ensures that `PortAudio.terminate` is called before this returns.
  def self.withPA(&) : Nil
    init
    yield
  ensure
    terminate
  end

  struct StreamInfo
    include PaMappable(LibPortAudio::PStreamInfo)
    getter structVersion : Int32
    getter inputLatency : Float64
    getter outputLatency : Float64
    getter sampleRate : Float64
  end

  class PaStream
    @handle : LibPortAudio::PPaStream = LibPortAudio::PPaStream.null
    getter inputSampleFormat : SampleFormat?
    getter inputChannels : Int32?
    getter outputSampleFormat : SampleFormat?
    getter outputChannels : Int32?
    getter framesPerBuffer : UInt64

    protected def initialize(@handle, @inputSampleFormat, @inputChannels, @outputSampleFormat, @outputChannels,
                             @framesPerBuffer)
    end

    protected def initialize(@inputSampleFormat, @inputChannels, @outputSampleFormat, @outputChannels,
                             @framesPerBuffer)
    end

    protected def handle : LibPortAudio::PPaStream
      @handle
    end

    def self.openDefaultStream(numInput : Int?, numOutput : Int?, sampleFormat : SampleFormat,
                               sampleRate : SampleRate, framesPerBuffer : Int) : PaStream
      ret : LibPortAudio::PPaStream = LibPortAudio::PPaStream.null

      PortAudio.raiseIfError do
        LibPortAudio.Pa_OpenDefaultStream(pointerof(ret), numInput || 0, numOutput || 0,
                                          sampleFormat.value,
                                          sampleRate.to_f64, framesPerBuffer.to_u64, nil, nil)
      end

      PaStream.new(ret, sampleFormat,
                   (numInput.nil? ? nil : (numInput == 0 ? nil : numInput.to_i32)),
                   sampleFormat,
                   (numOutput.nil? ? nil : (numOutput == 0 ? nil : numOutput.to_i32)),
                   framesPerBuffer.to_u64)
    end


    def self.openDefaultStream(numInput : Int?, numOutput : Int?, sampleFormat : SampleFormat,
                               sampleRate : SampleRate, framesPerBuffer : Int, callback : PaCallback,
                               userData : Pointer(Void)? = nil) : PaStream
      ret : LibPortAudio::PPaStream = LibPortAudio::PPaStream.null

      PortAudio.raiseIfError do
        LibPortAudio.Pa_OpenDefaultStream(pointerof(ret), numInput || 0, numOutput || 0,
                                          sampleFormat.value,
                                          sampleRate.to_f64, framesPerBuffer.to_u64,
                                          callback, userData)
      end

      PaStream.new(ret, sampleFormat,
                   (numInput.nil? ? nil : (numInput == 0 ? nil : numInput)),
                   sampleFormat,
                   (numOutput.nil? ? nil : (numOutput == 0 ? nil : numOutput)),
                   framesPerBuffer.to_u64)
    end

    def self.openDefaultStream(numInput : Int?, numOutput : Int?, sampleFormat : SampleFormat,
                               sampleRate : SampleRate, framesPerBuffer : Int,
                               userData : Pointer(Void)?, &block : PaCallback) : PaStream
      openDefaultStream(numInput, numOutput, sampleFormat, sampleRate, framesPerBuffer, block, userData)
    end

    def self.openStream(inputParams : StreamParameters?, outputParams : StreamParameters?,
                        sampleRate : SampleRate, framesPerBuffer : Int,
                        streamFlags : StreamFlags) : PaStream
      ret = uninitialized LibPortAudio::PaStream
      finput = inputParams ? inputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null
      foutput = outputParams ? outputParams.toForeignPtr : Pointer(LibPortAudio::StreamParameters).null

      PortAudio.raiseIfError do
        LibPortAudio.Pa_OpenStream(out ret, finput, foutput, sampleRate.to_f64,
                                       framesPerBuffer.to_u64,
                                       LibPortAudio::StreamFlags.new(streamFlags.value), nil, nil)
      end

      input = finput.nil? ? nil : inputParams.new(finput)
      output = foutput.nil? ? nil : outputParams.new(foutput)
      PaStream.new(pointerof(ret),
                   input.nil? ? nil : input.sampleFormat,
                   input.nil? ? nil : input.channelCount,
                   output.nil? ? nil : output.sampleFormat,
                   output.nil? ? nil : output.channelCount,
                   framesPerBuffer)
    end

    # Opens the default audio stream using the given options and starts it with
    # `#start`.  This then yields the stream.  This ensures that the stream is
    # both stopped with `#stop` and closed with `#close` before returning.
    def self.withDefaultStream(numInput : Int?, numOutput : Int?,
                               *, sampleFormat : SampleFormat = SampleFormat::Float32,
                               sampleRate : SampleRate = 44100, framesPerBuffer : Int = 1024, &) : Nil
      strm = self.openDefaultStream(numInput, numOutput, sampleFormat, sampleRate, framesPerBuffer.to_u64)
      begin
        strm.start
        yield strm
      ensure
        strm.stop
        strm.close
      end
    end

    # Opens an audio stream using the given options and starts it with `#start`.
    # This then yields the stream.  This ensures that the stream is both stopped
    # with `#stop` and closed with `#close` before returning.
    def self.withStream(inputParams : StreamParameters?, outputParams : StreamParameters?,
                        *, sampleRate : SampleRate = 44100, framesPerBuffer : Int = 1024,
                        streamFlags : StreamFlags = StreamFlags::NoFlag, &) : Nil
      strm = self.openStream(inputParams, outputParams, sampleRate, framesPerBuffer.to_u64, streamFlags)
      begin
        strm.start
        yield strm
      ensure
        strm.stop
        strm.close
      end
    end

    # Commences audio processing
    def start : Nil
      PortAudio.raiseIfError { LibPortAudio.Pa_StartStream(@handle) }
    end

    # Terminates audio processing immediately without waiting for pending
    # buffers to complete.
    def abort : Nil
      PortAudio.raiseIfError { LibPortAudio.Pa_AbortStream(@handle) }
    end

    # Terminates audio processing. It will wait until all pending audio buffers
    # have been played before returning.
    def stop : Nil
      if @handle != LibPortAudio::PPaStream.null
        PortAudio.raiseIfError { LibPortAudio.Pa_StopStream(@handle) }
      end
    end

    def close : Nil
      PortAudio.raiseIfError { LibPortAudio.Pa_CloseStream(@handle) }
      @handle = LibPortAudio::PPaStream.null
    end

    def stopped? : Bool
      case PortAudio.raiseIfError { LibPortAudio.Pa_IsStreamStopped(@handle) }
      when 0 then false
      when 1 then true
      else raise "Unexpected PulseAudio error"
      end
    end

    def active? : Bool
      case PortAudio.raiseIfError { LibPortAudio.Pa_IsStreamActive(@handle) }
      when 0 then false
      when 1 then true
      else raise "Unexpected PulseAudio error"
      end
    end

    def time : Float64
      LibPortAudio.Pa_GetStreamTime(@handle)
    end

    def writeAvailable : Int64
      PortAudio.raiseIfError { LibPortAudio.Pa_GetStreamWriteAvailable(@handle) }
    end

    def readAvailable : Int64
      PortAudio.raiseIfError { LibPortAudio.Pa_GetStreamReadAvailable(@handle) }
    end

    private macro defineReadFn(dataType, sampleType)
      # Attempts to read samples from an input stream into the destination.  The
      # method doesn't return until the entire buffer has been filled - this may
      # involve waiting for the operating system to supply the data.
      #
      # The size of the destination must be less than or equal to the number of
      # frames per buffer multiplied by the channel count.
      #
      # On success, this returns `true`.  If input data was discarded by PortAudio
      # after the previous call and before this call, this returns `false`.
      # Otherwise this will raise a `PaError` if another error occurs.
      def read(dest : {{dataType}}) : Bool
        unless @inputSampleFormat == {{sampleType}}
          raise PaError.new("Attempt to read #{ {{sampleType}} } samples from an stream that does not provide #{ {{sampleType}} } input")
        end

        unless @inputChannels > 0
          raise PaError.new("Attempt to read samples from a stream with no input channels")
        end

        unless dest.size < (@framesPerBuffer * @inputChannels)
          raise PaError.new("Attempt to read samples into a slice that is not large enough")
        end

        begin
          PortAudio.raiseIfError do
            LibPortAudio.Pa_ReadStream(@handle, dest.to_unsafe, @framesPerBuffer)
          end
          true
        rescue err : PaError
          if err.errCode == LibPortAudio::PaError::InputOverflowed
            return false
          else
            raise err
          end
        end
      end

      # Attempts to read samples from an input stream.  The method doesn't
      # return until one entire buffer has been filled - this may involve
      # waiting for the operating system to supply the data. The size of
      # returned data equal to the number of frames per buffer multiplied by the
      # channel count.
      #
      # On success, this returns the batch of data.  Otherwise this will raise a
      # `PaError` if another error occurs.
      def read(destType : {{dataType}}.class) : {{dataType}}
        dest = {{dataType}}.new(@framesPerBuffer * @inputChannels, 0)
        read(dest)
        dest
      end
    end

    defineReadFn(Slice(Float32), SampleFormat::Float32)
    defineReadFn(Array(Float32), SampleFormat::Float32)
    defineReadFn(Slice(Int32), SampleFormat::Int32)
    defineReadFn(Array(Int32), SampleFormat::Int32)
    defineReadFn(Slice(Int32), SampleFormat::Int24)
    defineReadFn(Array(Int32), SampleFormat::Int24)
    defineReadFn(Slice(Int16), SampleFormat::Int16)
    defineReadFn(Array(Int16), SampleFormat::Int16)
    defineReadFn(Slice(Int8), SampleFormat::Int8)
    defineReadFn(Array(Int8), SampleFormat::Int8)
    defineReadFn(Slice(UInt8), SampleFormat::UInt8)
    defineReadFn(Array(UInt8), SampleFormat::UInt8)

    private macro defineSeparationFn(dataType, innerType)
      # A utility function that separates interleaved data into individual
      # channels.
      def self.separateArray(channelCount : Int, data : {{dataType}}) : Array({{innerType}})
        if data.size != channelCount * @framesPerBuffer
          raise PaError.new("Data of size #{data.size} cannot be separated into #{channelCount} channel#{channelCount == 1 ? "" : "s"} when the frames per buffer is #{framesPerBuffer}")
        end

        ret = [] of Array({{innerType}})

        channelCount.times do |chan|
          ret << Array({{innerType}}).new(@framesPerBuffer.size) do |frame|
            data[(frame * channelCount) + channel]
          end
        end

        ret
      end
    end

    defineSeparationFn(Slice(Float32), Float32)
    defineSeparationFn(Slice(Int32), Int32)
    defineSeparationFn(Slice(Int16), Int16)
    defineSeparationFn(Slice(Int8), Int8)
    defineSeparationFn(Slice(UInt8), UInt8)
    defineSeparationFn(Array(Float32), Float32)
    defineSeparationFn(Array(Int32), Int32)
    defineSeparationFn(Array(Int16), Int16)
    defineSeparationFn(Array(Int8), Int8)
    defineSeparationFn(Array(UInt8), UInt8)

    private macro defineMergeFn(dataType)
      # A utility function that merges individual channels into interleaved
      # data.
      def self.mergeArrays(channelCount : Int, *data : {{dataType}}) : {{dataType}}
        if data.size != channelCount * @framesPerBuffer
          raise PaError.new("Data of size #{data.size} cannot be separated into #{channelCount} channel#{channelCount == 1 ? "" : "s"} when the frames per buffer is #{framesPerBuffer}")
        end

        frame = 0
        channel = 0
        {{dataType}}.new(channelCount * @framesPerBuffer) do |_|
          ret = data[channel][frame]
          frame += 1
          if frame == @framesPerBuffer
            frame = 0
            channel += 1
          end
          ret
        end
      end
    end

    defineMergeFn(Slice(Float32))
    defineMergeFn(Slice(Int32))
    defineMergeFn(Slice(Int16))
    defineMergeFn(Slice(Int8))
    defineMergeFn(Slice(UInt8))
    defineMergeFn(Array(Float32))
    defineMergeFn(Array(Int32))
    defineMergeFn(Array(Int16))
    defineMergeFn(Array(Int8))
    defineMergeFn(Array(UInt8))

    private macro defineWriteFn(dataType, sampleType)
      # Writes sample data to an output stream.  This function doesn't return
      # until the entire buffer has been consumed - this may involve waiting for
      # the operating system to consume the data. The size of the data must be
      # equal to the frames per buffer multiplied by the number of output
      # channels.
      #
      # This returns `self`.
      def <<(data : {{dataType}}) : PaStream
        unless @outputSampleFormat == {{sampleType}}
          raise PaError.new("Cannot write #{ {{sampleType}} } data to a stream that does not accept #{ {{sampleType}} } data")
        end

        if data.size != (@outputChannels.not_nil! * @framesPerBuffer)
          raise PaError.new("Not enough data to write to to the stream (need #{@outputChannels.not_nil! * @framesPerBuffer}, got #{data.size})")
        end

        PortAudio.raiseIfError do
          LibPortAudio.Pa_WriteStream(@handle, data.to_unsafe, @framesPerBuffer)
        end

        self
      end
    end

    defineWriteFn(Slice(Float32), SampleFormat::Float32)
    defineWriteFn(Array(Float32), SampleFormat::Float32)
    defineWriteFn(Slice(Int32), SampleFormat::Int32)
    defineWriteFn(Array(Int32), SampleFormat::Int32)
    defineWriteFn(Slice(Int32), SampleFormat::Int24)
    defineWriteFn(Array(Int32), SampleFormat::Int24)
    defineWriteFn(Slice(Int16), SampleFormat::Int16)
    defineWriteFn(Array(Int16), SampleFormat::Int16)
    defineWriteFn(Slice(Int8), SampleFormat::Int8)
    defineWriteFn(Array(Int8), SampleFormat::Int8)
    defineWriteFn(Slice(UInt8), SampleFormat::UInt8)
    defineWriteFn(Array(UInt8), SampleFormat::UInt8)

    def info : StreamInfo
      StreamInfo.new(LibPortAudio.Pa_GetStreamInfo(@handle))
    end
  end
end
