#import <Cocoa/Cocoa.h>
#import <ScriptingBridge/ScriptingBridge.h>
#import "syphon-framework/Syphon.h"
#include <obs-module.h>
#include <AvailabilityMacros.h>

#define LOG(level, message, ...)                                    \
	blog(level, "%s: " message, obs_source_get_name(s->source), \
	     ##__VA_ARGS__)

struct syphon {
	SYPHON_CLIENT_UNIQUE_CLASS_NAME *client;
	IOSurfaceRef ref;

	gs_samplerstate_t *sampler;
	gs_effect_t *effect;
	gs_vertbuffer_t *vertbuffer;
	gs_texture_t *tex;
	uint32_t width, height;
	bool crop;
	CGRect crop_rect;
	bool allow_transparency;

	obs_source_t *source;

	bool active;
	bool uuid_changed;
	id new_server_listener;
	id retire_listener;

	NSString *app_name;
	NSString *name;
	NSString *uuid;
};
typedef struct syphon *syphon_t;

static inline void update_properties(syphon_t s)
{
	obs_source_update_properties(s->source);
}

@interface OBSSyphonKVObserver : NSObject
- (void)observeValueForKeyPath:(NSString *)keyPath
		      ofObject:(id)object
			change:(NSDictionary *)change
		       context:(void *)context;
@end

@implementation OBSSyphonKVObserver
- (void)observeValueForKeyPath:(NSString *)keyPath
		      ofObject:(id)object
			change:(NSDictionary *)change
		       context:(void *)context
{
	syphon_t s = context;
	if (!s)
		return;

	if (!change[NSKeyValueChangeNewKey])
		return;

	update_properties(s);
}
@end

static const char *syphon_get_name(void *unused __attribute((unused)))
{
	return obs_module_text("Syphon");
}

static void stop_client(syphon_t s)
{
	obs_enter_graphics();

	if (s->client) {
		[s->client stop];
	}

	if (s->tex) {
		gs_texture_destroy(s->tex);
		s->tex = NULL;
	}

	if (s->ref) {
		IOSurfaceDecrementUseCount(s->ref);
		CFRelease(s->ref);
		s->ref = NULL;
	}

	s->width = 0;
	s->height = 0;

	obs_leave_graphics();
}

static inline NSDictionary *find_by_uuid(NSArray *arr, NSString *uuid)
{
	for (NSDictionary *dict in arr) {
		if ([dict[SyphonServerDescriptionUUIDKey] isEqual:uuid])
			return dict;
	}

	return nil;
}

static inline void check_version(syphon_t s, NSDictionary *desc)
{
	extern const NSString *SyphonServerDescriptionDictionaryVersionKey;

	NSNumber *version = desc[SyphonServerDescriptionDictionaryVersionKey];
	if (!version)
		return LOG(LOG_WARNING, "Server description does not contain "
					"VersionKey");

	if (version.unsignedIntValue > 0)
		LOG(LOG_WARNING,
		    "Got server description version %d, "
		    "expected 0",
		    version.unsignedIntValue);
}

static inline void check_description(syphon_t s, NSDictionary *desc)
{
	extern const NSString *SyphonSurfaceType;
	extern const NSString *SyphonSurfaceTypeIOSurface;
	extern const NSString *SyphonServerDescriptionSurfacesKey;

	NSArray *surfaces = desc[SyphonServerDescriptionSurfacesKey];
	if (!surfaces)
		return LOG(LOG_WARNING, "Server description does not contain "
					"SyphonServerDescriptionSurfacesKey");

	if (!surfaces.count)
		return LOG(LOG_WARNING, "Server description contains empty "
					"SyphonServerDescriptionSurfacesKey");

	for (NSDictionary *surface in surfaces) {
		NSString *type = surface[SyphonSurfaceType];
		if (type && [type isEqual:SyphonSurfaceTypeIOSurface])
			return;
	}

	NSString *surfaces_string = [NSString stringWithFormat:@"%@", surfaces];
	LOG(LOG_WARNING,
	    "SyphonSurfaces does not contain"
	    "'SyphonSurfaceTypeIOSurface': %s",
	    surfaces_string.UTF8String);
}

static inline void handle_new_frame(syphon_t s,
				    SYPHON_CLIENT_UNIQUE_CLASS_NAME *client)
{
	IOSurfaceRef ref = [client IOSurface];

	if (!ref)
		return;

	if (ref == s->ref) {
		CFRelease(ref);
		return;
	}

	IOSurfaceIncrementUseCount(ref);

	obs_enter_graphics();
	if (s->ref) {
		gs_texture_destroy(s->tex);
		IOSurfaceDecrementUseCount(s->ref);
		CFRelease(s->ref);
	}

	s->ref = ref;
	s->tex = gs_texture_create_from_iosurface(s->ref);
	s->width = gs_texture_get_width(s->tex);
	s->height = gs_texture_get_height(s->tex);
	obs_leave_graphics();
}

static void create_client(syphon_t s)
{
	stop_client(s);

	if (!s->app_name.length && !s->name.length && !s->uuid.length)
		return;

	SyphonServerDirectory *ssd = [SyphonServerDirectory sharedDirectory];
	NSArray *servers = [ssd serversMatchingName:s->name
					    appName:s->app_name];
	if (!servers.count)
		return;

	NSDictionary *desc = find_by_uuid(servers, s->uuid);
	if (!desc) {
		desc = servers[0];
		if (![s->uuid isEqualToString:
				      desc[SyphonServerDescriptionUUIDKey]]) {
			s->uuid_changed = true;
		}
	}

	check_version(s, desc);
	check_description(s, desc);

	s->client = [[SYPHON_CLIENT_UNIQUE_CLASS_NAME alloc]
		initWithServerDescription:desc
				  options:nil
			  newFrameHandler:^(
				  SYPHON_CLIENT_UNIQUE_CLASS_NAME *client) {
				  handle_new_frame(s, client);
			  }];

	s->active = true;
}

static inline bool load_syphon_settings(syphon_t s, obs_data_t *settings)
{
	NSString *app_name = @(obs_data_get_string(settings, "app_name"));
	NSString *name = @(obs_data_get_string(settings, "name"));
	bool equal_names = [app_name isEqual:s->app_name] &&
			   [name isEqual:s->name];
	if (s->uuid_changed && equal_names)
		return false;

	NSString *uuid = @(obs_data_get_string(settings, "uuid"));
	if ([uuid isEqual:s->uuid] && equal_names)
		return false;

	s->app_name = app_name;
	s->name = name;
	s->uuid = uuid;
	s->uuid_changed = false;
	return true;
}

static inline void update_from_announce(syphon_t s, NSDictionary *info)
{
	if (s->active)
		return;

	if (!info)
		return;

	NSString *app_name = info[SyphonServerDescriptionAppNameKey];
	NSString *name = info[SyphonServerDescriptionNameKey];
	NSString *uuid = info[SyphonServerDescriptionUUIDKey];

	if (![uuid isEqual:s->uuid] &&
	    !([app_name isEqual:s->app_name] && [name isEqual:s->name]))
		return;

	s->app_name = app_name;
	s->name = name;
	if (![s->uuid isEqualToString:uuid]) {
		s->uuid = uuid;
		s->uuid_changed = true;
	}

	create_client(s);
}

static inline void handle_announce(syphon_t s, NSNotification *note)
{
	if (!note)
		return;

	update_from_announce(s, note.object);
	update_properties(s);
}

static inline void update_from_retire(syphon_t s, NSDictionary *info)
{
	if (!info)
		return;

	NSString *uuid = info[SyphonServerDescriptionUUIDKey];
	if (!uuid)
		return;

	if (![uuid isEqual:s->uuid])
		return;

	s->active = false;
}

static inline void handle_retire(syphon_t s, NSNotification *note)
{
	if (!note)
		return;

	update_from_retire(s, note.object);
	update_properties(s);
}

static inline gs_vertbuffer_t *create_vertbuffer()
{
	struct gs_vb_data *vb_data = gs_vbdata_create();
	vb_data->num = 4;
	vb_data->points = bzalloc(sizeof(struct vec3) * 4);
	if (!vb_data->points)
		return NULL;

	vb_data->num_tex = 1;
	vb_data->tvarray = bzalloc(sizeof(struct gs_tvertarray));
	if (!vb_data->tvarray)
		goto fail_tvarray;

	vb_data->tvarray[0].width = 2;
	vb_data->tvarray[0].array = bzalloc(sizeof(struct vec2) * 4);
	if (!vb_data->tvarray[0].array)
		goto fail_array;

	gs_vertbuffer_t *vbuff = gs_vertexbuffer_create(vb_data, GS_DYNAMIC);
	if (vbuff)
		return vbuff;

	bfree(vb_data->tvarray[0].array);
fail_array:
	bfree(vb_data->tvarray);
fail_tvarray:
	bfree(vb_data->points);

	return NULL;
}

static inline bool init_obs_graphics_objects(syphon_t s)
{
	struct gs_sampler_info info = {
		.filter = GS_FILTER_LINEAR,
		.address_u = GS_ADDRESS_CLAMP,
		.address_v = GS_ADDRESS_CLAMP,
		.address_w = GS_ADDRESS_CLAMP,
		.max_anisotropy = 1,
	};

	obs_enter_graphics();
	s->sampler = gs_samplerstate_create(&info);
	s->vertbuffer = create_vertbuffer();
	obs_leave_graphics();

	s->effect = obs_get_base_effect(OBS_EFFECT_DEFAULT_RECT);

	return s->sampler != NULL && s->vertbuffer != NULL && s->effect != NULL;
}

static inline bool create_syphon_listeners(syphon_t s)
{
	NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
	s->new_server_listener = [nc
		addObserverForName:SyphonServerAnnounceNotification
			    object:nil
			     queue:[NSOperationQueue mainQueue]
			usingBlock:^(NSNotification *note) {
				handle_announce(s, note);
			}];

	s->retire_listener = [nc
		addObserverForName:SyphonServerRetireNotification
			    object:nil
			     queue:[NSOperationQueue mainQueue]
			usingBlock:^(NSNotification *note) {
				handle_retire(s, note);
			}];

	return s->new_server_listener != nil && s->retire_listener != nil;
}

static inline void load_crop(syphon_t s, obs_data_t *settings)
{
	s->crop = obs_data_get_bool(settings, "crop");

#define LOAD_CROP(x) s->crop_rect.x = obs_data_get_double(settings, "crop." #x)
	LOAD_CROP(origin.x);
	LOAD_CROP(origin.y);
	LOAD_CROP(size.width);
	LOAD_CROP(size.height);
#undef LOAD_CROP
}

static inline void syphon_destroy_internal(syphon_t s);

static void *syphon_create_internal(obs_data_t *settings, obs_source_t *source)
{
	syphon_t s = bzalloc(sizeof(struct syphon));
	if (!s)
		return s;

	s->source = source;

	if (!init_obs_graphics_objects(s)) {
		syphon_destroy_internal(s);
		return NULL;
	}

	if (!load_syphon_settings(s, settings)) {
		syphon_destroy_internal(s);
		return NULL;
	}

	if (!create_syphon_listeners(s)) {
		syphon_destroy_internal(s);
		return NULL;
	}

	create_client(s);

	load_crop(s, settings);

	s->allow_transparency =
		obs_data_get_bool(settings, "allow_transparency");

	return s;
}

static void *syphon_create(obs_data_t *settings, obs_source_t *source)
{
	@autoreleasepool {
		return syphon_create_internal(settings, source);
	}
}

static inline void stop_listener(id listener)
{
	if (!listener)
		return;

	NSNotificationCenter *nc = [NSNotificationCenter defaultCenter];
	[nc removeObserver:listener];
}

static inline void syphon_destroy_internal(syphon_t s)
{
	stop_listener(s->new_server_listener);
	stop_listener(s->retire_listener);

	NSWorkspace *ws = [NSWorkspace sharedWorkspace];

	obs_enter_graphics();
	stop_client(s);

	if (s->sampler)
		gs_samplerstate_destroy(s->sampler);
	if (s->vertbuffer)
		gs_vertexbuffer_destroy(s->vertbuffer);
	obs_leave_graphics();

	bfree(s);
}

static void syphon_destroy(void *data)
{
	@autoreleasepool {
		syphon_destroy_internal(data);
	}
}

static inline NSString *get_string(obs_data_t *settings, const char *name)
{
	if (!settings)
		return nil;

	return @(obs_data_get_string(settings, name));
}

static inline void update_strings_from_context(syphon_t s, obs_data_t *settings,
					       NSString **app, NSString **name,
					       NSString **uuid)
{
	if (!s || !s->uuid_changed)
		return;

	s->uuid_changed = false;
	*app = s->app_name;
	*name = s->name;
	*uuid = s->uuid;

	obs_data_set_string(settings, "app_name", s->app_name.UTF8String);
	obs_data_set_string(settings, "name", s->name.UTF8String);
	obs_data_set_string(settings, "uuid", s->uuid.UTF8String);
}

static inline void add_servers(syphon_t s, obs_property_t *list,
			       obs_data_t *settings)
{
	bool found_current = settings == NULL;

	NSString *set_app = get_string(settings, "app_name");
	NSString *set_name = get_string(settings, "name");
	NSString *set_uuid = get_string(settings, "uuid");

	update_strings_from_context(s, settings, &set_app, &set_name,
				    &set_uuid);

	obs_property_list_add_string(list, "", "");
	NSArray *arr = [[SyphonServerDirectory sharedDirectory] servers];
	for (NSDictionary *server in arr) {
		NSString *app = server[SyphonServerDescriptionAppNameKey];
		NSString *name = server[SyphonServerDescriptionNameKey];
		NSString *uuid = server[SyphonServerDescriptionUUIDKey];
		NSString *serv =
			[NSString stringWithFormat:@"[%@] %@", app, name];

		obs_property_list_add_string(list, serv.UTF8String,
					     uuid.UTF8String);

		if (!found_current)
			found_current = [uuid isEqual:set_uuid];
	}

	if (found_current || !set_uuid.length || !set_app.length)
		return;

	NSString *serv =
		[NSString stringWithFormat:@"[%@] %@", set_app, set_name];
	size_t idx = obs_property_list_add_string(list, serv.UTF8String,
						  set_uuid.UTF8String);
	obs_property_list_item_disable(list, idx, true);
}

static bool servers_changed(obs_properties_t *props, obs_property_t *list,
			    obs_data_t *settings)
{
	@autoreleasepool {
		obs_property_list_clear(list);
		add_servers(obs_properties_get_param(props), list, settings);
		return true;
	}
}

static bool update_crop(obs_properties_t *props, obs_property_t *prop,
			obs_data_t *settings)
{
	bool enabled = obs_data_get_bool(settings, "crop");

#define LOAD_CROP(x)                                  \
	prop = obs_properties_get(props, "crop." #x); \
	obs_property_set_enabled(prop, enabled);
	LOAD_CROP(origin.x);
	LOAD_CROP(origin.y);
	LOAD_CROP(size.width);
	LOAD_CROP(size.height);
#undef LOAD_CROP

	return true;
}

static void show_syphon_license_internal(void)
{
	char *path = obs_module_file("syphon_license.txt");
	if (!path)
		return;

	NSWorkspace *ws = [NSWorkspace sharedWorkspace];

#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 110000
	if (@available(macOS 11.0, *)) {
		NSURL *url = [NSURL
			URLWithString:
				[@"file://"
					stringByAppendingString:
						[NSString
							stringWithCString:path
								 encoding:NSUTF8StringEncoding]]];
		[ws openURL:url];
	} else {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
		[ws openFile:@(path)];
#pragma clang diagnostic pop
	}
#else
	[ws openFile:@(path)];
#endif
	bfree(path);
}

static bool show_syphon_license(obs_properties_t *props, obs_property_t *prop,
				void *data)
{
	UNUSED_PARAMETER(props);
	UNUSED_PARAMETER(prop);
	UNUSED_PARAMETER(data);

	@autoreleasepool {
		show_syphon_license_internal();
		return false;
	}
}

static void syphon_release(void *param)
{
	if (!param)
		return;

	obs_source_release(((syphon_t)param)->source);
}

static inline obs_properties_t *syphon_properties_internal(syphon_t s)
{
	if (s && obs_source_get_ref(s->source) == NULL) {
		s = NULL;
	}

	obs_properties_t *props =
		obs_properties_create_param(s, syphon_release);

	obs_property_t *list = obs_properties_add_list(
		props, "uuid", obs_module_text("Source"), OBS_COMBO_TYPE_LIST,
		OBS_COMBO_FORMAT_STRING);
	obs_property_set_modified_callback(list, servers_changed);

	obs_properties_add_bool(props, "allow_transparency",
				obs_module_text("AllowTransparency"));

	obs_property_t *crop =
		obs_properties_add_bool(props, "crop", obs_module_text("Crop"));
	obs_property_set_modified_callback(crop, update_crop);

#define LOAD_CROP(x)                                                      \
	obs_properties_add_float(props, "crop." #x,                       \
				 obs_module_text("Crop." #x), 0., 4096.f, \
				 .5f);
	LOAD_CROP(origin.x);
	LOAD_CROP(origin.y);
	LOAD_CROP(size.width);
	LOAD_CROP(size.height);
#undef LOAD_CROP

	obs_properties_add_button(props, "syphon license",
				  obs_module_text("SyphonLicense"),
				  show_syphon_license);

	return props;
}

static obs_properties_t *syphon_properties(void *data)
{
	@autoreleasepool {
		return syphon_properties_internal(data);
	}
}

static inline void syphon_save_internal(syphon_t s, obs_data_t *settings)
{
	if (!s->uuid_changed)
		return;

	obs_data_set_string(settings, "app_name", s->app_name.UTF8String);
	obs_data_set_string(settings, "name", s->name.UTF8String);
	obs_data_set_string(settings, "uuid", s->uuid.UTF8String);
}

static void syphon_save(void *data, obs_data_t *settings)
{
	@autoreleasepool {
		syphon_save_internal(data, settings);
	}
}

static inline void build_sprite(struct gs_vb_data *data, float fcx, float fcy,
				float start_u, float end_u, float start_v,
				float end_v)
{
	struct vec2 *tvarray = data->tvarray[0].array;

	vec3_set(data->points + 1, fcx, 0.0f, 0.0f);
	vec3_set(data->points + 2, 0.0f, fcy, 0.0f);
	vec3_set(data->points + 3, fcx, fcy, 0.0f);
	vec2_set(tvarray, start_u, start_v);
	vec2_set(tvarray + 1, end_u, start_v);
	vec2_set(tvarray + 2, start_u, end_v);
	vec2_set(tvarray + 3, end_u, end_v);
}

static inline void build_sprite_rect(struct gs_vb_data *data, float origin_x,
				     float origin_y, float end_x, float end_y)
{
	build_sprite(data, fabs(end_x - origin_x), fabs(end_y - origin_y),
		     origin_x, end_x, origin_y, end_y);
}

static void syphon_video_tick(void *data, float seconds)
{
	syphon_t s = data;

	if (!s->tex)
		return;

	static const CGRect null_crop = {{0.f}};
	const CGRect *crop = &null_crop;
	if (s->crop)
		crop = &s->crop_rect;

	obs_enter_graphics();
	build_sprite_rect(gs_vertexbuffer_get_data(s->vertbuffer),
			  crop->origin.x, s->height - crop->origin.y,
			  s->width - crop->size.width, crop->size.height);
	obs_leave_graphics();
}

static void syphon_video_render(void *data, gs_effect_t *effect)
{
	UNUSED_PARAMETER(effect);

	syphon_t s = data;

	if (!s->tex)
		return;

	gs_vertexbuffer_flush(s->vertbuffer);
	gs_load_vertexbuffer(s->vertbuffer);
	gs_load_indexbuffer(NULL);
	gs_load_samplerstate(s->sampler, 0);
	const char *tech_name = s->allow_transparency ? "Draw" : "DrawOpaque";
	gs_technique_t *tech = gs_effect_get_technique(s->effect, tech_name);
	gs_effect_set_texture(gs_effect_get_param_by_name(s->effect, "image"),
			      s->tex);
	gs_technique_begin(tech);
	gs_technique_begin_pass(tech, 0);

	gs_draw(GS_TRISTRIP, 0, 4);

	gs_technique_end_pass(tech);
	gs_technique_end(tech);
}

static uint32_t syphon_get_width(void *data)
{
	syphon_t s = (syphon_t)data;
	if (!s->crop)
		return s->width;
	int32_t width =
		s->width - s->crop_rect.origin.x - s->crop_rect.size.width;
	return MAX(0, width);
}

static uint32_t syphon_get_height(void *data)
{
	syphon_t s = (syphon_t)data;
	if (!s->crop)
		return s->height;
	int32_t height =
		s->height - s->crop_rect.origin.y - s->crop_rect.size.height;
	return MAX(0, height);
}

static inline bool update_syphon(syphon_t s, obs_data_t *settings)
{
	NSArray *arr = [[SyphonServerDirectory sharedDirectory] servers];

	if (!load_syphon_settings(s, settings))
		return false;

	NSDictionary *dict = find_by_uuid(arr, s->uuid);
	if (dict) {
		NSString *app = dict[SyphonServerDescriptionAppNameKey];
		NSString *name = dict[SyphonServerDescriptionNameKey];
		obs_data_set_string(settings, "app_name", app.UTF8String);
		obs_data_set_string(settings, "name", name.UTF8String);
		load_syphon_settings(s, settings);

	} else if (!dict && !s->uuid.length) {
		obs_data_set_string(settings, "app_name", "");
		obs_data_set_string(settings, "name", "");
		load_syphon_settings(s, settings);
	}

	return true;
}

static void syphon_update_internal(syphon_t s, obs_data_t *settings)
{
	s->allow_transparency =
		obs_data_get_bool(settings, "allow_transparency");

	load_crop(s, settings);

	if (update_syphon(s, settings))
		create_client(s);
}

static void syphon_update(void *data, obs_data_t *settings)
{
	@autoreleasepool {
		syphon_update_internal(data, settings);
	}
}

struct obs_source_info syphon_info = {
	.id = "syphon-input",
	.type = OBS_SOURCE_TYPE_INPUT,
	.output_flags = OBS_SOURCE_VIDEO | OBS_SOURCE_CUSTOM_DRAW |
			OBS_SOURCE_DO_NOT_DUPLICATE,
	.get_name = syphon_get_name,
	.create = syphon_create,
	.destroy = syphon_destroy,
	.video_render = syphon_video_render,
	.video_tick = syphon_video_tick,
	.get_properties = syphon_properties,
	.get_width = syphon_get_width,
	.get_height = syphon_get_height,
	.update = syphon_update,
	.save = syphon_save,
	.icon_type = OBS_ICON_TYPE_GAME_CAPTURE,
};
