#include "aja-card-manager.hpp"
#include "aja-common.hpp"
#include "aja-ui-props.hpp"
#include "aja-output.hpp"
#include "aja-routing.hpp"

#include <obs-module.h>
#include <util/platform.h>

#include <ajabase/common/timer.h>
#include <ajabase/system/systemtime.h>

#include <ajantv2/includes/ntv2card.h>
#include <ajantv2/includes/ntv2devicefeatures.h>

#include <atomic>
#include <stdlib.h>
#include <inttypes.h>

// Log AJA Output video/audio delay/sync
// #define AJA_OUTPUT_STATS
// Log AJA Output card frame buffer numbers
// #define AJA_OUTPUT_FRAME_NUMBERS

#define MATCH_OBS_FRAMERATE true

static constexpr uint32_t kNumCardFrames = 8;
static constexpr uint32_t kFrameDelay = 4;
static constexpr int64_t kDefaultStatPeriod = 2000000000;
static constexpr int64_t kAudioSyncAdjust = 500;
static constexpr int64_t kVideoSyncAdjust = 1;

static void copy_audio_data(struct audio_data *src, struct audio_data *dst, size_t size)
{
	if (src->data[0]) {
		dst->data[0] = (uint8_t *)bmemdup(src->data[0], size);
	}
}

static void free_audio_data(struct audio_data *frames)
{
	if (frames->data[0]) {
		bfree(frames->data[0]);
		frames->data[0] = NULL;
	}
	memset(frames, 0, sizeof(*frames));
}

static void copy_video_data(struct video_data *src, struct video_data *dst, size_t size)
{
	if (src->data[0]) {
		dst->data[0] = (uint8_t *)bmemdup(src->data[0], size);
	}
}

static void free_video_frame(struct video_data *frame)
{
	if (frame->data[0]) {
		bfree(frame->data[0]);
		frame->data[0] = NULL;
	}

	memset(frame, 0, sizeof(*frame));
}

static void update_sdi_transport_and_sdi_transport_4k(obs_properties_t *props, NTV2DeviceID device_id, IOSelection io,
						      NTV2VideoFormat vf)
{
	// Update SDI Transport and SDI 4K Transport selections
	obs_property_t *sdi_trx_list = obs_properties_get(props, kUIPropSDITransport.id);
	obs_property_list_clear(sdi_trx_list);
	populate_sdi_transport_list(sdi_trx_list, device_id);
	obs_property_t *sdi_4k_trx_list = obs_properties_get(props, kUIPropSDITransport4K.id);
	obs_property_list_clear(sdi_4k_trx_list);
	populate_sdi_4k_transport_list(sdi_4k_trx_list);

	bool is_sdi = aja::IsIOSelectionSDI(io);
	obs_property_set_visible(sdi_trx_list, is_sdi);
	obs_property_set_visible(sdi_4k_trx_list, is_sdi && NTV2_IS_4K_VIDEO_FORMAT(vf));
}

AJAOutput::AJAOutput(CNTV2Card *card, const std::string &cardID, const std::string &outputID, UWord deviceIndex,
		     const NTV2DeviceID deviceID)
	: mCardID{cardID},
	  mOutputID{outputID},
	  mDeviceIndex{deviceIndex},
	  mDeviceID{deviceID},
	  mAudioPlayCursor{0},
	  mAudioWriteCursor{0},
	  mAudioWrapAddress{0},
	  mAudioQueueBytes{0},
	  mAudioWriteBytes{0},
	  mAudioPlayBytes{0},
	  mNumCardFrames{0},
	  mFirstCardFrame{0},
	  mLastCardFrame{0},
	  mWriteCardFrame{0},
	  mPlayCardFrame{0},
	  mPlayCardNext{0},
	  mFrameRateNum{0},
	  mFrameRateDen{0},
	  mVideoQueueFrames{0},
	  mVideoWriteFrames{0},
	  mVideoPlayFrames{0},
	  mFirstVideoTS{0},
	  mFirstAudioTS{0},
	  mLastVideoTS{0},
	  mLastAudioTS{0},
	  mOutputDelay{0},
	  mVideoMax{0},
	  mVideoDelay{0},
	  mVideoSync{0},
	  mVideoAdjust{0},
	  mAudioMax{0},
	  mAudioDelay{0},
	  mAudioSync{0},
	  mAudioAdjust{0},
	  mLastStatTime{0},
#ifdef AJA_WRITE_DEBUG_WAV
	  mWaveWriter{nullptr},
#endif
	  mCard{card},
	  mOutputProps{DEVICE_ID_NOTFOUND},
	  mTestPattern{},
	  mIsRunning{false},
	  mAudioStarted{false},
	  mRunThread{},
	  mVideoLock{},
	  mAudioLock{},
	  mRunThreadLock{},
	  mVideoQueue{},
	  mAudioQueue{},
	  mOBSOutput{nullptr},
	  mCrosspoints{}
{
	mVideoQueue = std::make_unique<VideoQueue>();
	mAudioQueue = std::make_unique<AudioQueue>();
}

AJAOutput::~AJAOutput()
{
	if (mVideoQueue)
		mVideoQueue.reset();
	if (mAudioQueue)
		mAudioQueue.reset();
}

CNTV2Card *AJAOutput::GetCard()
{
	return mCard;
}

void AJAOutput::Initialize(const OutputProps &props)
{
	reset_frame_counts();

	// Store the address to the end of the card's audio buffer.
	mCard->GetAudioWrapAddress(mAudioWrapAddress, props.AudioSystem());

	// Specify small ring of frame buffers on the card for DMA and playback.
	// Starts at frame index corresponding to the output Channel * numFrames.
	calculate_card_frame_indices(kNumCardFrames, mCard->GetDeviceID(), props.Channel(), props.videoFormat,
				     props.pixelFormat);

	// Write black frames to the card to prevent playing garbage before the first DMA write.
	for (uint32_t i = mFirstCardFrame; i <= mLastCardFrame; i++) {
		GenerateTestPattern(props.videoFormat, props.pixelFormat, NTV2_TestPatt_Black, i);
	}

	mCard->WaitForOutputVerticalInterrupt(props.Channel());
	const auto &cardFrameRate = GetNTV2FrameRateFromVideoFormat(props.videoFormat);
	ULWord fpsNum = 0;
	ULWord fpsDen = 0;
	GetFramesPerSecond(cardFrameRate, fpsNum, fpsDen);
	mFrameRateNum = fpsNum;
	mFrameRateDen = fpsDen;
	mOutputDelay = (int64_t)kFrameDelay * 1000000 * mFrameRateDen / mFrameRateNum;
	mVideoMax = (int64_t)kVideoSyncAdjust * 1000000 * mFrameRateDen / mFrameRateNum;
	mVideoMax -= 100;
	mAudioMax = (int64_t)kAudioSyncAdjust * 1000000 / props.audioSampleRate;
	SetOutputProps(props);
}

void AJAOutput::reset_frame_counts()
{
	mAudioQueueBytes = 0;
	mAudioWriteBytes = 0;
	mAudioPlayBytes = 0;
	mVideoQueueFrames = 0;
	mVideoWriteFrames = 0;
	mVideoPlayFrames = 0;
}

void AJAOutput::SetOBSOutput(obs_output_t *output)
{
	mOBSOutput = output;
}

obs_output_t *AJAOutput::GetOBSOutput()
{
	return mOBSOutput;
}

void AJAOutput::SetOutputProps(const OutputProps &props)
{
	mOutputProps = props;
}

OutputProps AJAOutput::GetOutputProps() const
{
	return mOutputProps;
}

void AJAOutput::CacheConnections(const NTV2XptConnections &cnx)
{
	mCrosspoints.clear();
	mCrosspoints = cnx;
}

void AJAOutput::ClearConnections()
{
	for (auto &&xpt : mCrosspoints) {
		mCard->Connect(xpt.first, NTV2_XptBlack);
	}
	mCrosspoints.clear();
}

void AJAOutput::GenerateTestPattern(NTV2VideoFormat vf, NTV2PixelFormat pf, NTV2TestPatternSelect pattern,
				    uint32_t frameNum)
{
	NTV2VideoFormat vid_fmt = vf;
	NTV2PixelFormat pix_fmt = pf;
	if (vid_fmt == NTV2_FORMAT_UNKNOWN)
		vid_fmt = NTV2_FORMAT_720p_5994;
	if (pix_fmt == NTV2_FBF_INVALID)
		pix_fmt = kDefaultAJAPixelFormat;

	NTV2FormatDesc fd(vid_fmt, pix_fmt, NTV2_VANCMODE_OFF);
	auto bufSize = fd.GetTotalRasterBytes();
	if (bufSize != mTestPattern.size()) {
		// Raster size changed, regenerate pattern
		mTestPattern.clear();
		mTestPattern.resize(bufSize);
		NTV2TestPatternGen gen;
		gen.DrawTestPattern(pattern, fd.GetRasterWidth(), fd.GetRasterHeight(), pix_fmt, mTestPattern);
	}

	if (mTestPattern.size() == 0) {
		blog(LOG_DEBUG, "AJAOutput::GenerateTestPattern: Error generating test pattern!");
		return;
	}

	if (mCard->DMAWriteFrame(frameNum, reinterpret_cast<ULWord *>(&mTestPattern.data()[0]),
				 static_cast<ULWord>(mTestPattern.size()))) {
		mCard->SetOutputFrame(mOutputProps.Channel(), frameNum);
	}
}

void AJAOutput::QueueVideoFrame(struct video_data *frame, size_t size)
{
	const std::lock_guard<std::mutex> lock(mVideoLock);

	VideoFrame vf;
	vf.frame = *frame;
	vf.frameNum = mVideoWriteFrames;
	vf.size = size;

	if (mVideoQueue->size() > kVideoQueueMaxSize) {
		auto &front = mVideoQueue->front();
		free_video_frame(&front.frame);
		mVideoQueue->pop_front();
	}

	copy_video_data(frame, &vf.frame, size);

	mVideoQueue->push_back(vf);
	mVideoQueueFrames++;
}

void AJAOutput::QueueAudioFrames(struct audio_data *frames, size_t size)
{
	const std::lock_guard<std::mutex> lock(mAudioLock);

	AudioFrames af;
	af.frames = *frames;
	af.offset = 0;
	af.size = size;

	if (mAudioQueue->size() > kAudioQueueMaxSize) {
		auto &front = mAudioQueue->front();
		free_audio_data(&front.frames);
		mAudioQueue->pop_front();
	}

	copy_audio_data(frames, &af.frames, size);

	mAudioQueue->push_back(af);
	mAudioQueueBytes += size;
}

void AJAOutput::ClearVideoQueue()
{
	const std::lock_guard<std::mutex> lock(mVideoLock);
	while (mVideoQueue->size() > 0) {
		auto &vf = mVideoQueue->front();
		free_video_frame(&vf.frame);
		mVideoQueue->pop_front();
	}
}

void AJAOutput::ClearAudioQueue()
{
	const std::lock_guard<std::mutex> lock(mAudioLock);
	while (mAudioQueue->size() > 0) {
		auto &af = mAudioQueue->front();
		free_audio_data(&af.frames);
		mAudioQueue->pop_front();
	}
}

size_t AJAOutput::VideoQueueSize()
{
	return mVideoQueue->size();
}

size_t AJAOutput::AudioQueueSize()
{
	return mAudioQueue->size();
}

// lock audio queue before calling
void AJAOutput::DMAAudioFromQueue(NTV2AudioSystem audioSys, uint32_t channels, uint32_t sampleRate, uint32_t sampleSize)
{
	AudioFrames &af = mAudioQueue->front();
	size_t sizeLeft = af.size - af.offset;

	if (!mFirstAudioTS)
		mFirstAudioTS = af.frames.timestamp;
	mLastAudioTS = af.frames.timestamp;

	if (sizeLeft == 0) {
		free_audio_data(&af.frames);
		mAudioQueue->pop_front();
		return;
	}

	// Get audio play cursor
	mCard->ReadAudioLastOut(mAudioPlayCursor, audioSys);

	// Calculate audio delay
	uint32_t audioPlaySamples = 0;
	uint32_t audioPlayBytes = mAudioWriteCursor - mAudioPlayCursor;
	if (mAudioPlayCursor <= mAudioWriteCursor) {
		audioPlaySamples = audioPlayBytes / (channels * sampleSize);
	} else {
		audioPlayBytes = mAudioWrapAddress - mAudioPlayCursor + mAudioWriteCursor;
		audioPlaySamples = audioPlayBytes / (channels * sampleSize);
	}
	mAudioDelay = 1000000 * (int64_t)audioPlaySamples / sampleRate;
	mAudioPlayBytes += audioPlayBytes;

	// Adjust audio sync when requested
	if (mAudioAdjust != 0) {
		if (mAudioAdjust > 0) {
			// Throw away some samples to resync audio
			uint32_t adjustSamples = (uint32_t)mAudioAdjust * sampleRate / 1000000;
			uint32_t adjustSize = adjustSamples * sampleSize * channels;
			if (adjustSize <= sizeLeft) {
				af.offset += adjustSize;
				sizeLeft -= adjustSize;
				mAudioAdjust = 0;
				blog(LOG_DEBUG, "AJAOutput::DMAAudioFromQueue: Drop %d audio samples", adjustSamples);
			} else {
				uint32_t samples = (uint32_t)sizeLeft / (sampleSize * channels);
				af.offset += sizeLeft;
				sizeLeft = 0;
				adjustSamples -= samples;
				mAudioAdjust = (int64_t)adjustSamples * 1000000 / sampleRate;
				blog(LOG_DEBUG, "AJAOutput::DMAAudioFromQueue: Drop %d audio samples", samples);
			}
		} else {
			// Add some silence to resync audio
			uint32_t adjustSamples = (uint32_t)(-mAudioAdjust) * sampleRate / 1000000;
			uint32_t adjustSize = adjustSamples * sampleSize * channels;
			uint8_t *silentBuffer = new uint8_t[adjustSize];
			memset(silentBuffer, 0, adjustSize);
			dma_audio_samples(audioSys, (uint32_t *)silentBuffer, adjustSize);
			delete[] silentBuffer;
			mAudioAdjust = 0;
			blog(LOG_DEBUG, "AJAOutput::DMAAudioFromQueue: Add %d audio samples", adjustSamples);
		}
	}

	// Write audio to the hardware ring
	if (af.frames.data[0] && sizeLeft > 0) {
		dma_audio_samples(audioSys, (uint32_t *)&af.frames.data[0][af.offset], sizeLeft);
		af.offset += sizeLeft;
	}

	// Free the audio buffer
	if (af.offset == af.size) {
		free_audio_data(&af.frames);
		mAudioQueue->pop_front();
	}
}

// lock video queue before calling
void AJAOutput::DMAVideoFromQueue()
{
	bool writeFrame = true;
	bool freeFrame = true;

	auto &vf = mVideoQueue->front();
	auto data = vf.frame.data[0];

	if (!mFirstVideoTS)
		mFirstVideoTS = vf.frame.timestamp;
	mLastVideoTS = vf.frame.timestamp;

	mVideoDelay = (((int64_t)mWriteCardFrame + (int64_t)mNumCardFrames - (int64_t)mPlayCardFrame) %
		       (int64_t)mNumCardFrames) *
		      1000000 * mFrameRateDen / mFrameRateNum;

	// Adjust video sync when requested
	if (mVideoAdjust != 0) {
		if (mVideoAdjust > 0) {
			// skip writing this frame
			writeFrame = false;
		} else {
			// write this frame twice
			freeFrame = false;
		}
		mVideoAdjust = 0;
	}

	if (writeFrame) {
		// find the next buffer
		uint32_t writeCardFrame = mWriteCardFrame + 1;
		if (writeCardFrame > mLastCardFrame)
			writeCardFrame = mFirstCardFrame;

		// use the next buffer if available
		if (writeCardFrame != mPlayCardFrame)
			mWriteCardFrame = writeCardFrame;

		mVideoWriteFrames++;

		auto result = mCard->DMAWriteFrame(mWriteCardFrame, reinterpret_cast<ULWord *>(data), (ULWord)vf.size);
		if (!result)
			blog(LOG_DEBUG, "AJAOutput::DMAVideoFromQueue: Failed to write video frame!");
	}

	if (freeFrame) {
		free_video_frame(&vf.frame);
		mVideoQueue->pop_front();
	}
}

// TODO(paulh): Keep track of framebuffer indices used on the card, between the capture
// and output plugins, so that we can optimize frame index placement in memory and
// reduce unused gaps in between channel frame indices.
void AJAOutput::calculate_card_frame_indices(uint32_t numFrames, NTV2DeviceID id, NTV2Channel channel,
					     NTV2VideoFormat vf, NTV2PixelFormat pf)
{
	ULWord channelIndex = GetIndexForNTV2Channel(channel);
	ULWord totalCardFrames = NTV2DeviceGetNumberFrameBuffers(id, GetNTV2FrameGeometryFromVideoFormat(vf), pf);
	mFirstCardFrame = channelIndex * numFrames;
	uint32_t lastFrame = mFirstCardFrame + (numFrames - 1);
	if (totalCardFrames - mFirstCardFrame > 0 && totalCardFrames - lastFrame > 0) {
		// Reserve N framebuffers in card DRAM.
		mNumCardFrames = numFrames;
		mWriteCardFrame = mFirstCardFrame;
		mLastCardFrame = lastFrame;
		mPlayCardFrame = mWriteCardFrame;
		mPlayCardNext = mPlayCardFrame + 1;
		blog(LOG_INFO, "AJA Output using %d card frames (%d-%d).", mNumCardFrames, mFirstCardFrame,
		     mLastCardFrame);
	} else {
		blog(LOG_WARNING, "AJA Output Card frames %d-%d out of bounds. %d total frames on card!",
		     mFirstCardFrame, mLastCardFrame, totalCardFrames);
	}
}

uint32_t AJAOutput::get_card_play_count()
{
	uint32_t frameCount = 0;
	NTV2Channel channel = mOutputProps.Channel();
	INTERRUPT_ENUMS interrupt = NTV2ChannelToOutputInterrupt(channel);
	bool isProgressiveTransport =
		NTV2_IS_PROGRESSIVE_STANDARD(::GetNTV2StandardFromVideoFormat(mOutputProps.videoFormat));

	if (isProgressiveTransport) {
		mCard->GetInterruptCount(interrupt, frameCount);
	} else {
		uint32_t intCount;
		uint32_t nextCount;
		NTV2FieldID fieldID;
		mCard->GetInterruptCount(interrupt, intCount);
		mCard->GetOutputFieldID(channel, fieldID);
		mCard->GetInterruptCount(interrupt, nextCount);
		if (intCount != nextCount) {
			mCard->GetInterruptCount(interrupt, intCount);
			mCard->GetOutputFieldID(channel, fieldID);
		}
		if (fieldID == NTV2_FIELD1)
			intCount--;
		frameCount = intCount / 2;
	}

	return frameCount;
}

// Perform DMA of audio samples to AJA card while taking into account wrapping around the
// ends of the card's audio buffer (size set to 4MB in aja::Routing::ConfigureOutputAudio).
void AJAOutput::dma_audio_samples(NTV2AudioSystem audioSys, uint32_t *data, size_t size)
{
	bool result = false;

	if ((mAudioWriteCursor + size) > mAudioWrapAddress) {
		const uint32_t remainingBuffer = mAudioWrapAddress - mAudioWriteCursor;

		auto audioDataRemain = reinterpret_cast<const ULWord *>((uint8_t *)(data) + remainingBuffer);

		// Incoming audio size will wrap around the end of the card audio buffer.
		// Transfer enough bytes to fill to the end of the buffer...
		if (remainingBuffer > 0) {
			result = mCard->DMAWriteAudio(audioSys, data, mAudioWriteCursor, remainingBuffer);
			if (result) {
				mAudioWriteBytes += remainingBuffer;
			} else {
				blog(LOG_DEBUG,
				     "AJAOutput::dma_audio_samples: "
				     "failed to write bytes at end of buffer (address = %d)",
				     mAudioWriteCursor);
			}
		}

		// ...transfer remaining bytes at the front of the card audio buffer.
		size_t frontRemaining = size - remainingBuffer;
		if (frontRemaining > 0) {
			result = mCard->DMAWriteAudio(audioSys, audioDataRemain, 0, (ULWord)frontRemaining);
			if (result) {
				mAudioWriteBytes += frontRemaining;
			} else {
				blog(LOG_DEBUG,
				     "AJAOutput::dma_audio_samples "
				     "failed to write bytes at front of buffer (address = %d)",
				     mAudioWriteCursor);
			}
		}

		mAudioWriteCursor = (uint32_t)size - remainingBuffer;
	} else {
		//	No wrap, so just do a linear DMA from the buffer...
		if (size > 0) {
			result = mCard->DMAWriteAudio(audioSys, data, mAudioWriteCursor, (ULWord)size);
			if (result) {
				mAudioWriteBytes += size;
			} else {
				blog(LOG_DEBUG,
				     "AJAOutput::dma_audio_samples "
				     "failed to write bytes to buffer (address = %d)",
				     mAudioWriteCursor);
			}
		}

		mAudioWriteCursor += (uint32_t)size;
	}
}

void AJAOutput::CreateThread(bool enable)
{
	const std::lock_guard<std::mutex> lock(mRunThreadLock);
	if (!mRunThread.Active()) {
		mRunThread.SetPriority(AJA_ThreadPriority_High);
		mRunThread.SetThreadName("AJA Video Output Thread");
		mRunThread.Attach(AJAOutput::OutputThread, this);
	}
	if (enable) {
		mIsRunning = true;
		mRunThread.Start();
	}
}

void AJAOutput::StopThread()
{
	const std::lock_guard<std::mutex> lock(mRunThreadLock);
	mIsRunning = false;
	if (mRunThread.Active()) {
		mRunThread.Stop();
	}
}

bool AJAOutput::ThreadRunning()
{
	return mIsRunning;
}

void AJAOutput::OutputThread(AJAThread *thread, void *ctx)
{
	UNUSED_PARAMETER(thread);

	AJAOutput *ajaOutput = static_cast<AJAOutput *>(ctx);
	if (!ajaOutput) {
		blog(LOG_ERROR, "AJAOutput::OutputThread: AJA Output instance is null!");
		return;
	}

	CNTV2Card *card = ajaOutput->GetCard();
	if (!card) {
		blog(LOG_ERROR, "AJAOutput::OutputThread: Card instance is null!");
		return;
	}

	const auto &props = ajaOutput->GetOutputProps();
	const auto &audioSystem = props.AudioSystem();
	uint64_t videoPlayLast = ajaOutput->get_card_play_count();
	uint32_t audioSyncSlowCount = 0;
	uint32_t audioSyncFastCount = 0;
	uint32_t audioSyncCountMax = 3;
	uint32_t videoSyncSlowCount = 0;
	uint32_t videoSyncFastCount = 0;
	uint32_t videoSyncCountMax = 3;
	int64_t audioSyncSlowSum = 0;
	int64_t audioSyncFastSum = 0;

	// thread loop
	while (ajaOutput->ThreadRunning()) {
		// Wait for preroll
		if (!ajaOutput->mAudioStarted && (ajaOutput->mAudioDelay > ajaOutput->mOutputDelay)) {
			card->StartAudioOutput(audioSystem, false);
			ajaOutput->mAudioStarted = true;
			blog(LOG_DEBUG, "AJAOutput::OutputThread: Audio Preroll complete");
		}

		// Check if a vsync occurred
		uint32_t frameCount = ajaOutput->get_card_play_count();
		if (frameCount > videoPlayLast) {
			videoPlayLast = frameCount;
			ajaOutput->mPlayCardFrame = ajaOutput->mPlayCardNext;

			if (ajaOutput->mPlayCardFrame != ajaOutput->mWriteCardFrame) {
				uint32_t playCardNext = ajaOutput->mPlayCardFrame + 1;
				if (playCardNext > ajaOutput->mLastCardFrame)
					playCardNext = ajaOutput->mFirstCardFrame;

				if (playCardNext != ajaOutput->mWriteCardFrame) {
					ajaOutput->mPlayCardNext = playCardNext;
					// Increment the play frame
					ajaOutput->mCard->SetOutputFrame(ajaOutput->mOutputProps.Channel(),
									 ajaOutput->mPlayCardNext);
					ajaOutput->mVideoPlayFrames++;
				}
			}
		}

		// Audio DMA
		{
			const std::lock_guard<std::mutex> lock(ajaOutput->mAudioLock);
			while (ajaOutput->AudioQueueSize() > 0) {
				ajaOutput->DMAAudioFromQueue(audioSystem, props.audioNumChannels, props.audioSampleRate,
							     props.audioSampleSize);
			}
		}

		// Video DMA
		{
			const std::lock_guard<std::mutex> lock(ajaOutput->mVideoLock);
			while (ajaOutput->VideoQueueSize() > 0) {
				ajaOutput->DMAVideoFromQueue();
			}
		}

		// Get current time and audio play cursor
		int64_t curTime = (int64_t)os_gettime_ns();
		card->ReadAudioLastOut(ajaOutput->mAudioPlayCursor, audioSystem);

		if ((curTime - ajaOutput->mLastStatTime) > kDefaultStatPeriod) {
			ajaOutput->mLastStatTime = curTime;

			if (ajaOutput->mAudioStarted) {
				// Calculate audio sync delay
				ajaOutput->mAudioSync = ajaOutput->mAudioDelay - ajaOutput->mOutputDelay;

				if (ajaOutput->mAudioSync > ajaOutput->mAudioMax) {
					audioSyncSlowCount++;
					audioSyncSlowSum += ajaOutput->mAudioSync;
					if (audioSyncSlowCount >= audioSyncCountMax) {
						ajaOutput->mAudioAdjust = audioSyncSlowSum / audioSyncCountMax;
						audioSyncSlowCount = 0;
						audioSyncSlowSum = 0;
					}
				} else {
					audioSyncSlowCount = 0;
					audioSyncSlowSum = 0;
				}
				if (ajaOutput->mAudioSync < -ajaOutput->mAudioMax) {
					audioSyncFastCount++;
					audioSyncFastSum += ajaOutput->mAudioSync;
					if (audioSyncFastCount >= audioSyncCountMax) {
						ajaOutput->mAudioAdjust = audioSyncFastSum / audioSyncCountMax;
						audioSyncFastCount = 0;
						audioSyncFastSum = 0;
					}
				} else {
					audioSyncFastCount = 0;
					audioSyncFastSum = 0;
				}
			}

			// calculate video sync delay
			if (ajaOutput->mVideoDelay > 0) {
				ajaOutput->mVideoSync = ajaOutput->mVideoDelay - ajaOutput->mOutputDelay;

				if (ajaOutput->mVideoSync > ajaOutput->mVideoMax) {
					videoSyncSlowCount++;
					if (videoSyncSlowCount > videoSyncCountMax) {
						ajaOutput->mVideoAdjust = 1;
					}
				} else {
					videoSyncSlowCount = 0;
				}
				if (ajaOutput->mVideoSync < -ajaOutput->mVideoMax) {
					videoSyncFastCount++;
					if (videoSyncFastCount > videoSyncCountMax) {
						ajaOutput->mVideoAdjust = -1;
					}
				} else {
					videoSyncFastCount = 0;
				}
			}

#ifdef AJA_OUTPUT_STATS
			blog(LOG_INFO,
			     "AJAOutput::OutputThread: od %li  vd %li  vs %li  vm %li  ad %li  as %li  am %li",
			     ajaOutput->mOutputDelay, ajaOutput->mVideoDelay, ajaOutput->mVideoSync,
			     ajaOutput->mVideoMax, ajaOutput->mAudioDelay, ajaOutput->mAudioSync, ajaOutput->mAudioMax);
#endif
		}
#ifdef AJA_OUTPUT_FRAME_NUMBERS
		blog(LOG_INFO, "AJAOutput::OutputThread: dma: %d play: %d next: %d", ajaOutput->mWriteCardFrame,
		     ajaOutput->mPlayCardFrame, ajaOutput->mPlayCardNext);
#endif
		os_sleep_ms(1);
	}

	ajaOutput->mAudioStarted = false;

	// Log total number of queued/written/played video frames and audio samples
	uint32_t audioSize = props.audioNumChannels / props.audioSampleSize;
	if (audioSize > 0) {
		blog(LOG_INFO,
		     "AJAOutput::OutputThread: Thread stopped\n[Video] qf: %" PRIu64 " wf: %" PRIu64 " pf: %" PRIu64
		     "\n[Audio] qs: %" PRIu64 " ws: %" PRIu64 " ps: %" PRIu64,
		     ajaOutput->mVideoQueueFrames, ajaOutput->mVideoWriteFrames, ajaOutput->mVideoPlayFrames,
		     ajaOutput->mAudioQueueBytes / audioSize, ajaOutput->mAudioWriteBytes / audioSize,
		     ajaOutput->mAudioPlayBytes / audioSize);
	}
}

void populate_output_device_list(obs_property_t *list)
{
	obs_property_list_clear(list);

	auto &cardManager = aja::CardManager::Instance();
	cardManager.EnumerateCards();
	for (auto &iter : cardManager.GetCardEntries()) {
		if (!iter.second)
			continue;

		CNTV2Card *card = iter.second->GetCard();
		if (!card)
			continue;

		NTV2DeviceID deviceID = card->GetDeviceID();

		//TODO(paulh): Add support for analog I/O
		// w/ NTV2DeviceGetNumAnalogVideoOutputs(cardEntry.deviceID)
		if (NTV2DeviceGetNumVideoOutputs(deviceID) > 0 || NTV2DeviceGetNumHDMIVideoOutputs(deviceID) > 0) {

			obs_property_list_add_string(list, iter.second->GetDisplayName().c_str(),
						     iter.second->GetCardID().c_str());
		}
	}
}

bool aja_output_device_changed(void *data, obs_properties_t *props, obs_property_t *list, obs_data_t *settings)
{
	UNUSED_PARAMETER(data);

	blog(LOG_DEBUG, "AJA Output Device Changed");

	populate_output_device_list(list);

	const char *cardID = obs_data_get_string(settings, kUIPropDevice.id);
	if (!cardID || !cardID[0])
		return false;

	const char *outputID = obs_data_get_string(settings, kUIPropAJAOutputID.id);
	auto &cardManager = aja::CardManager::Instance();
	cardManager.EnumerateCards();
	auto cardEntry = cardManager.GetCardEntry(cardID);
	if (!cardEntry) {
		blog(LOG_ERROR, "aja_output_device_changed: Card Entry not found for %s", cardID);
		return false;
	}

	CNTV2Card *card = cardEntry->GetCard();
	if (!card) {
		blog(LOG_ERROR, "aja_output_device_changed: Card instance is null!");
		return false;
	}

	obs_property_t *io_select_list = obs_properties_get(props, kUIPropOutput.id);
	obs_property_t *vid_fmt_list = obs_properties_get(props, kUIPropVideoFormatSelect.id);
	obs_property_t *pix_fmt_list = obs_properties_get(props, kUIPropPixelFormatSelect.id);

	const NTV2DeviceID deviceID = cardEntry->GetDeviceID();
	populate_io_selection_output_list(cardID, outputID, deviceID, io_select_list);

	// If Channel 1 is actively in use, filter the video format list to only
	// show video formats within the same framerate family. If Channel 1 is
	// not active we just go ahead and try to set all framestores to the same video format.
	// This is because Channel 1's clock rate will govern the card's Free Run clock.
	NTV2VideoFormat videoFormatChannel1 = NTV2_FORMAT_UNKNOWN;
	if (!cardEntry->ChannelReady(NTV2_CHANNEL1, outputID)) {
		card->GetVideoFormat(videoFormatChannel1, NTV2_CHANNEL1);
	}

	obs_property_list_clear(vid_fmt_list);
	populate_video_format_list(deviceID, vid_fmt_list, videoFormatChannel1, false, MATCH_OBS_FRAMERATE);

	obs_property_list_clear(pix_fmt_list);
	populate_pixel_format_list(deviceID, {kDefaultAJAPixelFormat, NTV2_FBF_24BIT_BGR}, pix_fmt_list);

	IOSelection io_select = static_cast<IOSelection>(obs_data_get_int(settings, kUIPropOutput.id));

	update_sdi_transport_and_sdi_transport_4k(
		props, cardEntry->GetDeviceID(), io_select,
		static_cast<NTV2VideoFormat>(obs_data_get_int(settings, kUIPropVideoFormatSelect.id)));

	return true;
}

bool aja_output_dest_changed(obs_properties_t *props, obs_property_t *list, obs_data_t *settings)
{
	blog(LOG_DEBUG, "AJA Output Dest Changed");

	const char *cardID = obs_data_get_string(settings, kUIPropDevice.id);
	if (!cardID || !cardID[0])
		return false;

	auto &cardManager = aja::CardManager::Instance();
	auto cardEntry = cardManager.GetCardEntry(cardID);
	if (!cardEntry) {
		blog(LOG_DEBUG, "aja_output_dest_changed: Card Entry not found for %s", cardID);
		return false;
	}

	bool itemFound = false;
	const long long dest = obs_data_get_int(settings, kUIPropOutput.id);
	const size_t itemCount = obs_property_list_item_count(list);
	for (size_t i = 0; i < itemCount; i++) {
		const long long itemDest = obs_property_list_item_int(list, i);
		if (dest == itemDest) {
			itemFound = true;
			break;
		}
	}
	if (!itemFound) {
		obs_property_list_insert_int(list, 0, "", dest);
		obs_property_list_item_disable(list, 0, true);
		return true;
	}

	// Revert to "Select..." if desired IOSelection is already in use
	auto io_select = static_cast<IOSelection>(obs_data_get_int(settings, kUIPropOutput.id));
	for (size_t i = 0; i < obs_property_list_item_count(list); i++) {
		auto io_item = static_cast<IOSelection>(obs_property_list_item_int(list, i));
		if (io_item == io_select && obs_property_list_item_disabled(list, i)) {
			obs_data_set_int(settings, kUIPropOutput.id, static_cast<long long>(IOSelection::Invalid));
			blog(LOG_WARNING, "aja_output_dest_changed: IOSelection %s is already in use",
			     aja::IOSelectionToString(io_select).c_str());
			return false;
		}
	}

	update_sdi_transport_and_sdi_transport_4k(
		props, cardEntry->GetDeviceID(), io_select,
		static_cast<NTV2VideoFormat>(obs_data_get_int(settings, kUIPropVideoFormatSelect.id)));

	return true;
}

static void aja_output_destroy(void *data)
{
	blog(LOG_DEBUG, "AJA Output Destroy");

	auto ajaOutput = (AJAOutput *)data;
	if (!ajaOutput) {
		blog(LOG_ERROR, "aja_output_destroy: Plugin instance is null!");
		return;
	}

#ifdef AJA_WRITE_DEBUG_WAV
	if (ajaOutput->mWaveWriter) {
		ajaOutput->mWaveWriter->close();
		delete ajaOutput->mWaveWriter;
		ajaOutput->mWaveWriter = nullptr;
	}
#endif
	ajaOutput->StopThread();
	ajaOutput->ClearVideoQueue();
	ajaOutput->ClearAudioQueue();
	delete ajaOutput;
	ajaOutput = nullptr;
}

static void *aja_output_create(obs_data_t *settings, obs_output_t *output)
{
	blog(LOG_INFO, "Creating AJA Output...");

	const char *cardID = obs_data_get_string(settings, kUIPropDevice.id);
	if (!cardID || !cardID[0])
		return nullptr;

	const char *outputID = obs_data_get_string(settings, kUIPropAJAOutputID.id);

	auto &cardManager = aja::CardManager::Instance();
	auto cardEntry = cardManager.GetCardEntry(cardID);
	if (!cardEntry) {
		blog(LOG_ERROR, "aja_output_create: Card Entry not found for %s", cardID);
		return nullptr;
	}

	CNTV2Card *card = cardEntry->GetCard();
	if (!card) {
		blog(LOG_ERROR, "aja_output_create: Card instance is null for %s", cardID);
		return nullptr;
	}

	NTV2DeviceID deviceID = card->GetDeviceID();
	OutputProps outputProps(deviceID);
	outputProps.ioSelect = static_cast<IOSelection>(obs_data_get_int(settings, kUIPropOutput.id));
	outputProps.videoFormat = static_cast<NTV2VideoFormat>(obs_data_get_int(settings, kUIPropVideoFormatSelect.id));
	outputProps.pixelFormat = static_cast<NTV2PixelFormat>(obs_data_get_int(settings, kUIPropPixelFormatSelect.id));
	outputProps.sdiTransport = static_cast<SDITransport>(obs_data_get_int(settings, kUIPropSDITransport.id));
	outputProps.sdi4kTransport = static_cast<SDITransport4K>(obs_data_get_int(settings, kUIPropSDITransport4K.id));
	outputProps.audioNumChannels = kDefaultAudioChannels;
	outputProps.audioSampleSize = kDefaultAudioSampleSize;
	outputProps.audioSampleRate = kDefaultAudioSampleRate;

	if (outputProps.ioSelect == IOSelection::Invalid) {
		blog(LOG_DEBUG, "aja_output_create: Select a valid AJA Output IOSelection!");
		return nullptr;
	}
	if (outputProps.videoFormat == NTV2_FORMAT_UNKNOWN || outputProps.pixelFormat == NTV2_FBF_INVALID) {
		blog(LOG_ERROR, "aja_output_create: Select a valid video and/or pixel format!");
		return nullptr;
	}

	const std::string &ioSelectStr = aja::IOSelectionToString(outputProps.ioSelect);

	NTV2OutputDestinations outputDests;
	aja::IOSelectionToOutputDests(outputProps.ioSelect, outputDests);
	if (outputDests.empty()) {
		blog(LOG_ERROR, "No Output Destinations found for IOSelection %s!", ioSelectStr.c_str());
		return nullptr;
	}
	outputProps.outputDest = *outputDests.begin();

	if (!cardEntry->AcquireOutputSelection(outputProps.ioSelect, deviceID, outputID)) {
		blog(LOG_ERROR, "aja_output_create: Error acquiring IOSelection %s for card ID %s", ioSelectStr.c_str(),
		     cardID);
		return nullptr;
	}

	auto ajaOutput = new AJAOutput(card, cardID, outputID, (UWord)cardEntry->GetCardIndex(), deviceID);
	ajaOutput->Initialize(outputProps);
	ajaOutput->ClearVideoQueue();
	ajaOutput->ClearAudioQueue();
	ajaOutput->SetOBSOutput(output);
	ajaOutput->CreateThread(true);

#ifdef AJA_WRITE_DEBUG_WAV
	AJAWavWriterAudioFormat wavFormat;
	wavFormat.channelCount = outputProps.AudioChannels();
	wavFormat.sampleRate = outputProps.audioSampleRate;
	wavFormat.sampleSize = outputProps.AudioSize();
	ajaOutput->mWaveWriter = new AJAWavWriter("obs_aja_output.wav", wavFormat);
	ajaOutput->mWaveWriter->open();
#endif

	blog(LOG_INFO, "AJA Output created!");

	return ajaOutput;
}

static void aja_output_update(void *data, obs_data_t *settings)
{
	UNUSED_PARAMETER(data);
	UNUSED_PARAMETER(settings);
	blog(LOG_INFO, "AJA Output Update...");
}

static bool aja_output_start(void *data)
{
	blog(LOG_INFO, "Starting AJA Output...");

	auto ajaOutput = (AJAOutput *)data;
	if (!ajaOutput) {
		blog(LOG_ERROR, "aja_output_start: Plugin instance is null!");
		return false;
	}

	const std::string &cardID = ajaOutput->mCardID;
	auto &cardManager = aja::CardManager::Instance();
	cardManager.EnumerateCards();
	auto cardEntry = cardManager.GetCardEntry(cardID);
	if (!cardEntry) {
		blog(LOG_DEBUG, "aja_io_selection_changed: Card Entry not found for %s", cardID.c_str());
		return false;
	}

	CNTV2Card *card = ajaOutput->GetCard();
	if (!card) {
		blog(LOG_ERROR, "aja_output_start: Card instance is null!");
		return false;
	}

	auto outputProps = ajaOutput->GetOutputProps();
	auto audioSystem = outputProps.AudioSystem();
	auto outputDest = outputProps.outputDest;
	auto videoFormat = outputProps.videoFormat;
	auto pixelFormat = outputProps.pixelFormat;

	blog(LOG_INFO, "Output Dest: %s | Audio System: %s | Video Format: %s | Pixel Format: %s",
	     NTV2OutputDestinationToString(outputDest, true).c_str(),
	     NTV2AudioSystemToString(audioSystem, true).c_str(), NTV2VideoFormatToString(videoFormat, false).c_str(),
	     NTV2FrameBufferFormatToString(pixelFormat, true).c_str());

	const NTV2DeviceID deviceID = card->GetDeviceID();

	if (GetIndexForNTV2Channel(outputProps.Channel()) > 0) {
		auto numFramestores = aja::CardNumFramestores(deviceID);
		for (UWord i = 0; i < numFramestores; i++) {
			auto channel = GetNTV2ChannelForIndex(i);
			if (cardEntry->ChannelReady(channel, ajaOutput->mOutputID)) {
				card->SetVideoFormat(videoFormat, false, false, channel);
				card->SetRegisterWriteMode(NTV2_REGWRITE_SYNCTOFRAME, channel);
				card->SetFrameBufferFormat(channel, pixelFormat);
			}
		}
	}

	// Configures crosspoint routing on AJA card
	ajaOutput->ClearConnections();
	NTV2XptConnections xpt_cnx;
	if (!aja::Routing::ConfigureOutputRoute(outputProps, NTV2_MODE_DISPLAY, card, xpt_cnx)) {
		blog(LOG_ERROR, "aja_output_start: Error configuring output route!");
		return false;
	}
	ajaOutput->CacheConnections(xpt_cnx);
	aja::Routing::ConfigureOutputAudio(outputProps, card);

	const auto &formatDesc = outputProps.FormatDesc();
	struct video_scale_info scaler = {};
	scaler.format = aja::AJAPixelFormatToOBSVideoFormat(pixelFormat);
	scaler.width = formatDesc.GetRasterWidth();
	scaler.height = formatDesc.GetRasterHeight();
	// TODO(paulh): Find out what these scaler params actually do.
	// The colors are off when outputting the frames that OBS sends us.
	// but simply changing these values doesn't seem to have any effect.
	scaler.colorspace = VIDEO_CS_709;
	scaler.range = VIDEO_RANGE_PARTIAL;

	obs_output_set_video_conversion(ajaOutput->GetOBSOutput(), &scaler);

	struct audio_convert_info conversion = {};
	conversion.format = outputProps.AudioFormat();
	conversion.speakers = outputProps.SpeakerLayout();
	conversion.samples_per_sec = outputProps.audioSampleRate;

	obs_output_set_audio_conversion(ajaOutput->GetOBSOutput(), &conversion);

	if (!obs_output_begin_data_capture(ajaOutput->GetOBSOutput(), 0)) {
		blog(LOG_ERROR, "aja_output_start: Begin OBS data capture failed!");
		return false;
	}

	blog(LOG_INFO, "AJA Output started!");

	return true;
}

static void aja_output_stop(void *data, uint64_t ts)
{
	UNUSED_PARAMETER(ts);

	blog(LOG_INFO, "Stopping AJA Output...");

	auto ajaOutput = (AJAOutput *)data;
	if (!ajaOutput) {
		blog(LOG_ERROR, "aja_output_stop: Plugin instance is null!");
		return;
	}
	const std::string &cardID = ajaOutput->mCardID;
	auto &cardManager = aja::CardManager::Instance();
	cardManager.EnumerateCards();
	auto cardEntry = cardManager.GetCardEntry(cardID);
	if (!cardEntry) {
		blog(LOG_ERROR, "aja_output_stop: Card Entry not found for %s", cardID.c_str());
		return;
	}
	CNTV2Card *card = ajaOutput->GetCard();
	if (!card) {
		blog(LOG_ERROR, "aja_output_stop: Card instance is null!");
		return;
	}

	auto outputProps = ajaOutput->GetOutputProps();
	if (!cardEntry->ReleaseOutputSelection(outputProps.ioSelect, card->GetDeviceID(), ajaOutput->mOutputID)) {
		blog(LOG_WARNING, "aja_output_stop: Error releasing IOSelection %s from card ID %s",
		     aja::IOSelectionToString(outputProps.ioSelect).c_str(), cardID.c_str());
	}

	ajaOutput->GenerateTestPattern(outputProps.videoFormat, outputProps.pixelFormat, NTV2_TestPatt_Black,
				       ajaOutput->mWriteCardFrame);

	obs_output_end_data_capture(ajaOutput->GetOBSOutput());
	auto audioSystem = outputProps.AudioSystem();
	card->StopAudioOutput(audioSystem);
	ajaOutput->ClearConnections();

	blog(LOG_INFO, "AJA Output stopped.");
}

static void aja_output_raw_video(void *data, struct video_data *frame)
{
	auto ajaOutput = (AJAOutput *)data;
	if (!ajaOutput)
		return;

	auto outputProps = ajaOutput->GetOutputProps();
	auto rasterBytes = outputProps.FormatDesc().GetTotalRasterBytes();
	ajaOutput->QueueVideoFrame(frame, rasterBytes);
}

static void aja_output_raw_audio(void *data, struct audio_data *frames)
{
	auto ajaOutput = (AJAOutput *)data;
	if (!ajaOutput)
		return;

	auto outputProps = ajaOutput->GetOutputProps();
	auto audioSize = outputProps.AudioSize();
	auto audioBytes = static_cast<ULWord>(frames->frames * audioSize);
	ajaOutput->QueueAudioFrames(frames, audioBytes);
}

static obs_properties_t *aja_output_get_properties(void *data)
{
	obs_properties_t *props = obs_properties_create();
	obs_property_t *device_list = obs_properties_add_list(props, kUIPropDevice.id,
							      obs_module_text(kUIPropDevice.text), OBS_COMBO_TYPE_LIST,
							      OBS_COMBO_FORMAT_STRING);
	obs_property_t *output_list = obs_properties_add_list(props, kUIPropOutput.id,
							      obs_module_text(kUIPropOutput.text), OBS_COMBO_TYPE_LIST,
							      OBS_COMBO_FORMAT_INT);
	obs_property_t *vid_fmt_list = obs_properties_add_list(props, kUIPropVideoFormatSelect.id,
							       obs_module_text(kUIPropVideoFormatSelect.text),
							       OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT);
	obs_properties_add_list(props, kUIPropPixelFormatSelect.id, obs_module_text(kUIPropPixelFormatSelect.text),
				OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT);
	obs_properties_add_list(props, kUIPropSDITransport.id, obs_module_text(kUIPropSDITransport.text),
				OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT);
	obs_properties_add_list(props, kUIPropSDITransport.id, obs_module_text(kUIPropSDITransport.text),
				OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT);
	obs_properties_add_list(props, kUIPropSDITransport4K.id, obs_module_text(kUIPropSDITransport4K.text),
				OBS_COMBO_TYPE_LIST, OBS_COMBO_FORMAT_INT);
	obs_properties_add_bool(props, kUIPropAutoStartOutput.id, obs_module_text(kUIPropAutoStartOutput.text));

	obs_property_set_modified_callback(vid_fmt_list, aja_video_format_changed);
	obs_property_set_modified_callback(output_list, aja_output_dest_changed);
	obs_property_set_modified_callback2(device_list, aja_output_device_changed, data);
	return props;
}

static const char *aja_output_get_name(void *)
{
	return obs_module_text(kUIPropOutputModule.text);
}

static void aja_output_defaults(obs_data_t *settings)
{
	obs_data_set_default_int(settings, kUIPropOutput.id, static_cast<long long>(IOSelection::Invalid));
	obs_data_set_default_int(settings, kUIPropVideoFormatSelect.id, static_cast<long long>(kDefaultAJAVideoFormat));
	obs_data_set_default_int(settings, kUIPropPixelFormatSelect.id, static_cast<long long>(kDefaultAJAPixelFormat));
	obs_data_set_default_int(settings, kUIPropSDITransport.id, static_cast<long long>(kDefaultAJASDITransport));
	obs_data_set_default_int(settings, kUIPropSDITransport4K.id, static_cast<long long>(kDefaultAJASDITransport4K));
}

void register_aja_output_info()
{
	struct obs_output_info aja_output_info = {};

	aja_output_info.id = kUIPropOutputModule.id;
	aja_output_info.flags = OBS_OUTPUT_AV;
	aja_output_info.get_name = aja_output_get_name;
	aja_output_info.create = aja_output_create;
	aja_output_info.destroy = aja_output_destroy;
	aja_output_info.start = aja_output_start;
	aja_output_info.stop = aja_output_stop;
	aja_output_info.raw_video = aja_output_raw_video;
	aja_output_info.raw_audio = aja_output_raw_audio;
	aja_output_info.update = aja_output_update;
	aja_output_info.get_defaults = aja_output_defaults;
	aja_output_info.get_properties = aja_output_get_properties;
	obs_register_output(&aja_output_info);
}
