import process from 'node:process';
import config from 'config';
import convert from 'convert';
import { differenceInYears } from 'date-fns';
import { unit } from 'mathjs';
import { MerkleJson } from 'merkle-json';
import moment from 'moment';
import { nanoid } from 'nanoid';
import omit from 'object.omit';
import unprint from 'unprint';

import { interpolateProfiles, platformsByHostname } from '../common/actors.mjs';
import { resolvePlace } from '../common/geo.mjs';
import escape from '../utils/escape-manticore.js';
import slugify from '../utils/slugify.js';
import verifyAbility from '../utils/verify-ability.js';
import { fetchCountriesByAlpha2 } from './countries.js';
import { curateEntity } from './entities.js';
import { HttpError } from './errors.js';
import { knexOwner as knex, knexManticore } from './knex.js';
import initLogger from './logger.js';
import { querySql } from './manticore.js';
import { curateMedia } from './media.js';
import redis from './redis.js';
import { curateRevision } from './revisions.js';
import { curateStash } from './stashes.js';
import { syncActors, syncScenes, syncStashes } from './sync.js';

const logger = initLogger();
const mj = new MerkleJson();

const keyMap = {
	avatar: 'avatar_media_id',
	dateOfBirth: 'date_of_birth',
	dateOfDeath: 'date_of_death',
	originCountry: 'birth_country_alpha2',
	originState: 'birth_state',
	originCity: 'birth_city',
	residenceCountry: 'residence_country_alpha2',
	residenceState: 'residence_state',
	residenceCity: 'residence_city',
	hairColor: 'hair_color',
	naturalBoobs: 'natural_boobs',
	boobsVolume: 'boobs_volume',
	boobsImplant: 'boobs_implant',
	boobsPlacement: 'boobs_placement',
	boobsIncision: 'boobs_incision',
	boobsSurgeon: 'boobs_surgeon',
	naturalButt: 'natural_butt',
	buttVolume: 'butt_volume',
	buttImplant: 'butt_implant',
	naturalLips: 'natural_lips',
	lipsVolume: 'lips_volume',
	naturalLabia: 'natural_labia',
	penisLength: 'penis_length',
	penisGirth: 'penis_girth',
	hasTattoos: 'has_tattoos',
	hasPiercings: 'has_piercings',
	isCircumcised: 'circumcised',
	allowGlobalMatch: 'allow_global_match',
};

const socialsOrder = ['onlyfans', 'fansly', 'twitter', 'instagram', 'loyalfans', 'manyvids', 'pornhub', 'linktree', null];

export function curateActor(actor, context = {}) {
	if (!actor) {
		return null;
	}

	return {
		id: actor.id,
		slug: actor.slug,
		name: actor.name,
		aliases: actor.aliases?.map((alias) => ({
			id: alias.id,
			slug: alias.slug,
			name: alias.name,
			entityId: alias.entity_id,
			entity: alias.entity,
		})) || [], // used for profile pages
		alias: actor.alias
			? {
					id: actor.alias.id,
					slug: actor.alias.slug,
					name: actor.alias.name,
				}
			: null,
		gender: actor.gender,
		age: actor.age,
		ethnicity: actor.ethnicity,
		entity: actor.entity && {
			id: actor.entity.id,
			slug: actor.entity.slug,
			name: actor.entity.name,
		},
		...Object.fromEntries(Object.entries(keyMap).map(([key, entryKey]) => [key, actor[entryKey]])),
		ageFromBirth: actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
			? differenceInYears(Date.now(), actor.date_of_birth)
			: null,
		ageAtDeath: actor.date_of_birth && actor.date_of_death && actor.date_of_birth.getFullYear() > 1
			? differenceInYears(actor.date_of_death, actor.date_of_birth)
			: null,
		ageThen: context.sceneDate && actor.date_of_birth && actor.date_of_birth.getFullYear() > 1
			? differenceInYears(context.sceneDate, actor.date_of_birth)
			: null,
		dateOfBirth: actor.date_of_birth,
		dateOfDeath: actor.date_of_death,
		bust: actor.bust,
		cup: actor.cup,
		waist: actor.waist,
		hip: actor.hip,
		height: actor.height && {
			metric: actor.height,
			imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())),
		},
		weight: actor.weight && {
			metric: actor.weight,
			imperial: Math.round(unit(actor.weight, 'kg').toNumeric('lbs')),
		},
		penisLength: actor.penis_length && {
			metric: actor.penis_length,
			imperial: Math.round(unit(actor.penis_length, 'cm').toNumeric('in')),
		},
		penisGirth: actor.penis_girth && {
			metric: actor.penis_girth,
			imperial: Math.round(unit(actor.penis_girth, 'cm').toNumeric('in')),
		},
		eyes: actor.eyes,
		tattoos: actor.tattoos,
		piercings: actor.piercings,
		origin: actor.birth_country_alpha2 && {
			country: actor.birth_country_alpha2 && {
				alpha2: actor.birth_country_alpha2,
				name: actor.birth_country_name,
				alias: actor.birth_country_alias,
			},
			city: actor.birth_city,
			state: actor.birth_state,
		},
		residence: actor.residence_country_alpha2 && {
			country: actor.residence_country_alpha2 && {
				alpha2: actor.residence_country_alpha2,
				name: actor.residence_country_name,
				alias: actor.residence_country_alias,
			},
			city: actor.residence_city,
			state: actor.residence_state,
		},
		agency: actor.agency,
		avatar: actor.avatar && curateMedia({
			...actor.avatar,
			sfw_media: actor.sfw_avatar,
			biometrics: actor.biometrics,
		}),
		allowGlobalMatch: actor.allow_global_match,
		socials: context.socials?.map((social) => ({
			id: social.id,
			url: social.url,
			platform: social.platform,
			handle: social.handle,
		})).toSorted((socialA, socialB) => {
			if (socialA.platform && !socialB.platform) {
				return -1;
			}

			if (socialB.platform && !socialA.platform) {
				return 1;
			}

			if (socialsOrder.includes(socialA.platform) && !socialsOrder.includes(socialB.platform)) {
				return -1;
			}

			if (socialsOrder.includes(socialB.platform) && !socialsOrder.includes(socialA.platform)) {
				return 1;
			}

			return socialsOrder.indexOf(socialA.platform) - socialsOrder.indexOf(socialB.platform);
		}),
		profiles: context.profiles?.map((profile) => ({
			id: profile.id,
			description: profile.description,
			descriptionHash: profile.description_hash,
			entity: curateEntity({ ...profile.entity, parent: profile.parent_entity }),
			// avatars: profile.avatars.map((avatar) => curateMedia(avatar)),
		})),
		photos: context.photos
			?.map((photo) => ({
				...curateMedia(photo),
				isAvatar: photo.id === actor.avatar?.id,
				profileIds: photo.profile_ids,
			}))
			.toSorted((photoA, photoB) => photoB.isAvatar - photoA.isAvatar),
		createdAt: actor.created_at,
		updatedAt: actor.updated_at,
		scenes: actor.scenes,
		likes: actor.stashed,
		stashes: context.stashes?.map((stash) => curateStash(stash)) || [],
		alerts: {
			only: context.alerts?.filter((alert) => alert.is_only).flatMap((alert) => alert.alert_ids) || [],
			multi: context.alerts?.filter((alert) => !alert.is_only).flatMap((alert) => alert.alert_ids) || [],
		},
		...context.append?.[actor.id],
	};
}

export function sortActorsByGender(actors, context = {}) {
	if (!actors) {
		return actors;
	}

	const alphaActors = actors.sort((actorA, actorB) => actorA.name.localeCompare(actorB.name, 'en'));
	const genderActors = ['transsexual', 'female', undefined, null, 'male'].flatMap((gender) => alphaActors.filter((actor) => actor.gender === gender));

	const titleSlug = slugify(context.title);

	const titleActors = titleSlug
		? genderActors.sort((actorA, actorB) => {
				const actorASlug = actorA.slug.split('-')[0];
				const actorBSlug = actorB.slug.split('-')[0];

				if (titleSlug.includes(actorASlug) && !titleSlug.includes(actorBSlug)) {
					return -1;
				}

				if (titleSlug.includes(actorBSlug) && !titleSlug.includes(actorASlug)) {
					return 1;
				}

				return 0;
			})
		: alphaActors;

	return titleActors;
}

export async function fetchActorsById(actorIds, options = {}, reqUser) {
	const [actors, profiles, photos, socials, stashes, alerts] = await Promise.all([
		knex('actors')
			.select('actors.*')
			.whereIn('actors.id', actorIds)
			.modify((builder) => {
				if (options.order) {
					builder.orderBy(...options.order);
				}

				if (!options.shallow) {
					builder
						.select(
							'actors_meta.stashed',
							knex.raw('row_to_json(avatars) as avatar'),
							'birth_countries.alpha2 as birth_country_alpha2',
							knex.raw('COALESCE(birth_countries.alias, birth_countries.name) as birth_country_name'),
							'residence_countries.alpha2 as residence_country_alpha2',
							knex.raw('COALESCE(residence_countries.alias, residence_countries.name) as residence_country_name'),
							knex.raw('row_to_json(entities) as entity'),
							knex.raw('row_to_json(sfw_media) as sfw_avatar'),
							// knex.raw('json_agg(aliases) filter (where aliases.id is not null) as aliases'),
							knex.raw('json_agg(row_to_json(aliases)::jsonb || jsonb_build_object(\'entity\', row_to_json(alias_entities))) filter (where aliases.id is not null) as aliases'),
							knex.raw('row_to_json(aliased) as alias'),
							knex.raw('row_to_json(media_biometrics) as biometrics'),
						)
						.leftJoin('actors_meta', 'actors_meta.actor_id', 'actors.id')
						.leftJoin('actors as aliases', 'aliases.alias_for', 'actors.id')
						.leftJoin('actors as aliased', 'aliased.id', 'actors.alias_for')
						.leftJoin('entities as alias_entities', 'alias_entities.id', 'aliases.entity_id')
						.leftJoin('countries as birth_countries', 'birth_countries.alpha2', 'actors.birth_country_alpha2')
						.leftJoin('countries as residence_countries', 'residence_countries.alpha2', 'actors.residence_country_alpha2')
						.leftJoin('media as avatars', 'avatars.id', 'actors.avatar_media_id')
						.leftJoin('media as sfw_media', 'sfw_media.id', 'avatars.sfw_media_id')
						.leftJoin('media_biometrics', 'media_biometrics.media_id', 'actors.avatar_media_id')
						.leftJoin('entities', 'entities.id', 'actors.entity_id')
						.groupBy('actors.id', 'aliased.id', 'avatars.id', 'sfw_media.id', 'media_biometrics.id', 'entities.id', 'actors_meta.stashed', 'birth_countries.alpha2', 'residence_countries.alpha2');
				}
			}),
		knex('actors_profiles')
			.select(
				'actors_profiles.*',
				knex.raw('row_to_json(entities) as entity'),
				knex.raw('row_to_json(parents) as parent_entity'),
				// knex.raw('coalesce(json_agg(media) filter (where media.id is not null), \'[]\') as avatars'),
			)
			.leftJoin('actors', 'actors.id', 'actors_profiles.actor_id')
			.leftJoin('entities', 'entities.id', 'actors_profiles.entity_id')
			.leftJoin('entities as parents', 'parents.id', 'entities.parent_id')
			.leftJoin('actors_avatars', 'actors_avatars.profile_id', 'actors_profiles.id')
			// .leftJoin('media', 'media.id', 'actors_avatars.media_id')
			.whereIn('actors_profiles.actor_id', actorIds)
			.groupBy('actors_profiles.id', 'entities.id', 'parents.id'),
		knex('actors_avatars')
			.select(
				'media.*',
				// 'actors_avatars.actor_id',
				'actors_profiles.actor_id',
				knex.raw('json_agg(actors_avatars.profile_id) as profile_ids'),
				knex.raw('row_to_json(sfw_media) as sfw_media'),
			)
			.whereIn('actors_profiles.actor_id', actorIds)
			.leftJoin('media', 'media.id', 'actors_avatars.media_id')
			.leftJoin('media as sfw_media', 'sfw_media.id', 'media.sfw_media_id')
			.leftJoin('actors_profiles', 'actors_profiles.id', 'actors_avatars.profile_id')
		// .groupBy('media.id', 'sfw_media.id', 'actors_avatars.actor_id')
			.groupBy('media.id', 'sfw_media.id', 'actors_profiles.actor_id')
			.orderBy(knex.raw('max(actors_avatars.created_at)'), 'desc'),
		knex('actors_socials')
			.whereIn('actor_id', actorIds),
		reqUser && !options.shallow
			? knex('stashes_actors')
					.leftJoin('stashes', 'stashes.id', 'stashes_actors.stash_id')
					.where('stashes.user_id', reqUser.id)
					.whereIn('stashes_actors.actor_id', actorIds)
			: [],
		reqUser && !options.shallow
			? knex('alerts_users_actors')
					.where('user_id', reqUser.id)
					.whereIn('actor_id', actorIds)
			: [],
	].slice(0, options.shallow ? 1 : undefined));

	if (options.order) {
		return actors.map((actorEntry) => curateActor(actorEntry, {
			stashes: stashes?.filter((stash) => stash.actor_id === actorEntry.id),
			alerts: alerts?.filter((alert) => alert.actor_id === actorEntry.id),
			profiles: profiles?.filter((profile) => profile.actor_id === actorEntry.id),
			photos: photos?.filter((photo) => photo.actor_id === actorEntry.id),
			socials: socials?.filter((social) => social.actor_id === actorEntry.id),
			append: options.append,
		}));
	}

	const curatedActors = actorIds.map((actorId) => {
		const actor = actors.find((actorEntry) => actorEntry.id === actorId);

		if (!actor) {
			console.warn(`Can't match actor ${actorId}`);
			return null;
		}

		return curateActor(actor, {
			stashes: stashes?.filter((stash) => stash.actor_id === actor.id),
			alerts: alerts?.filter((alert) => alert.actor_id === actor.id),
			profiles: profiles?.filter((profile) => profile.actor_id === actor.id),
			photos: photos?.filter((photo) => photo.actor_id === actor.id),
			socials: socials?.filter((social) => social.actor_id === actor.id),
			append: options.append,
		});
	}).filter(Boolean);

	return curatedActors;
}

function curateOptions(options = {}) {
	if (options.limit > 120) {
		throw new HttpError('Limit must be <= 120', 400);
	}

	return {
		page: options.page || 1,
		limit: options.limit || 30,
		aggregateCountries: true,
		requireAvatar: options.requireAvatar || false,
		order: [escape(options.order?.[0]) || 'name', escape(options.order?.[1]) || 'asc'],
	};
}

async function queryManticoreSql(filters, options, _reqUser) {
	const aggSize = config.database.manticore.maxAggregateSize;

	const sqlQuery = knexManticore.raw(`
		:query:
		OPTION
			max_matches=:maxMatches:,
			max_query_time=:maxQueryTime:
		:countriesFacet:;
		show meta;
	`, {
		query: knexManticore(filters.stashId ? 'actors_stashed' : 'actors')
			.modify((builder) => {
				if (filters.stashId) {
					builder.select(knex.raw(`
						actors.id as id,
						actors.slug,
						actors.gender as gender,
						actors.country as country,
						actors.height as height,
						actors.mass as mass,
						actors.cup as cup,
						actors.natural_boobs as natural_boobs,
						actors.date_of_birth as date_of_birth,
						actors.has_avatar as has_avatar,
						actors.scenes as scenes,
						actors.stashed as stashed,
						created_at as stashed_at,
						if(actors.date_of_birth, floor((now() - actors.date_of_birth) / 31556952), 0) as age,
						weight() as _score
					`));

					builder
						.innerJoin('actors', 'actors.id', 'actors_stashed.actor_id')
						.where('stash_id', filters.stashId);
				}
				else {
					builder.select(knex.raw('*, weight() as _score'));
				}

				if (filters.query) {
					if (filters.query.charAt(0) === '#') {
						builder.where('id', Number(escape(filters.query.slice(1))));
					}
					else {
						// manticoresearch has min_prefix_length set to 3, meaning wildcarded names under 3 characters return nu results
						builder.whereRaw(`match('@(name,aliases) :query:${filters.query.charAt(0) === '=' || filters.query.split(/\s+/).at(-1)?.length < 3 ? '' : '*'}', actors)`, { query: escape(filters.query) });
					}
				}

				if (!filters.includeAliased && !filters.stashId) {
					builder.where('alias_for', 0);
				}

				if (filters.isGlobal) {
					builder.where('entity_id', 0);
				}

				// attribute filters
				['country'].forEach((attribute) => {
					if (filters[attribute]) {
						builder.where(attribute, filters[attribute]);
					}
				});

				if (filters.gender === 'other') {
					builder.whereNull('gender');
				}
				else if (filters.gender) {
					builder.where('gender', filters.gender);
				}

				if (filters.age) {
					builder.select('if(date_of_birth, floor((now() - date_of_birth) / 31556952), 0) as age');
				}

				// range filters
				['age', 'height'].forEach((attribute) => {
					if (filters[attribute]) {
						builder
							.where(attribute, '>=', filters[attribute][0])
							.where(attribute, '<=', filters[attribute][1]);
					}
				});

				if (filters.weight) {
					// weight is a reserved keyword in manticore
					builder
						.where('mass', '>=', filters.weight[0])
						.where('mass', '<=', filters.weight[1]);
				}

				if (filters.dateOfBirth && filters.dobType === 'dateOfBirth') {
					builder.where('date_of_birth', Math.floor(filters.dateOfBirth.getTime() / 1000));
				}

				if (filters.dateOfBirth && filters.dobType === 'birthday') {
					const month = filters.dateOfBirth.getMonth() + 1;
					const day = filters.dateOfBirth.getDate();

					builder.select('month(date_of_birth) as month_of_birth, day(date_of_birth) as day_of_birth');

					builder
						.where('month_of_birth', month)
						.where('day_of_birth', day);
				}

				if (filters.cup) {
					builder.select(`regex(actors.cup, '^[${filters.cup[0]}-${filters.cup[1]}]') as cup_in_range`);
					builder.where('cup_in_range', 1);
				}

				if (typeof filters.naturalBoobs === 'boolean') {
					builder.where('natural_boobs', filters.naturalBoobs ? 2 : 1); // manticore boolean does not support null, so 0 = null, 1 = false (enhanced), 2 = true (natural)
				}

				if (filters.requireAvatar) {
					builder.where('has_avatar', 1);
				}

				if (options.order?.[0] === 'name') {
					builder.orderBy([
						{ column: 'actors.slug', order: options.order[1] },
						{ column: 'actors.entity_id', order: 'asc' },
					]);
				}
				else if (options.order?.[0] === 'likes') {
					builder.orderBy([
						{ column: 'actors.stashed', order: options.order[1] },
						{ column: 'actors.slug', order: 'asc' },
					]);
				}
				else if (options.order?.[0] === 'scenes') {
					builder.orderBy([
						{ column: 'actors.scenes', order: options.order[1] },
						{ column: 'actors.slug', order: 'asc' },
					]);
				}
				else if (options.order?.[0] === 'results') {
					builder.orderBy([
						{ column: '_score', order: options.order[1] },
						{ column: 'actors.stashed', order: 'desc' },
						{ column: 'actors.slug', order: 'asc' },
					]);
				}
				else if (options.order?.[0] === 'stashed' && filters.stashId) {
					builder.orderBy([
						{ column: 'stashed_at', order: options.order[1] },
						{ column: 'actors.slug', order: 'asc' },
					]);
				}
				else if (options.order) {
					builder.orderBy([
						{ column: `actors.${options.order[0]}`, order: options.order[1] },
						{ column: 'actors.slug', order: 'asc' },
					]);
				}
				else {
					builder.orderBy('actors.slug', 'asc');
					builder.orderBy([
						{ column: 'actors.slug', order: 'asc' },
						{ column: 'actors.entity_id', order: 'asc' },
					]);
				}
			})
			.limit(options.limit)
			.offset((options.page - 1) * options.limit)
			.toString(),
		// option threads=1 fixes actors, but drastically slows down performance, wait for fix
		countriesFacet: options.aggregateCountries ? knex.raw('facet actors.country order by count(*) desc limit :aggSize', { aggSize }) : null,
		maxMatches: config.database.manticore.maxMatches,
		maxQueryTime: config.database.manticore.maxQueryTime,
	}).toString();

	// manticore does not seem to accept table.column syntax if 'table' is primary (yet?), crude work-around
	const curatedSqlQuery = filters.stashId
		? sqlQuery
		: sqlQuery.replace(/actors\./g, '');

	if (process.env.NODE_ENV === 'development') {
		console.log(curatedSqlQuery);
	}

	const results = await querySql(curatedSqlQuery);

	const countries = results
		.find((result) => (result.columns[0].country || result.columns[0]['actors.country']) && result.columns[1]['count(*)'])
		?.data
		.map((row) => ({ key: row.country || row['actors.country'], doc_count: row['count(*)'] }))
		.filter((country) => !!country.key)
		|| [];

	const total = Number(results.at(-1).data.find((entry) => entry.Variable_name === 'total_found').Value);

	return {
		actors: results[0].data,
		total,
		aggregations: {
			countries,
		},
	};
}

export async function fetchActors(filters, rawOptions, reqUser) {
	const options = curateOptions(rawOptions);
	const result = await queryManticoreSql(filters, options, reqUser);

	// console.log('result', result);

	const actorIds = result.actors.map((actor) => Number(actor.id));
	const [actors, countries] = await Promise.all([
		fetchActorsById(actorIds, {}, reqUser),
		fetchCountriesByAlpha2(result.aggregations.countries.map((bucket) => bucket.key)),
	]);

	return {
		actors,
		countries,
		total: result.total,
		limit: options.limit,
	};
}

function curateActorEntry(actor, context) {
	return {
		name: actor.name,
		slug: slugify(actor.name),
		entry_id: nanoid(), // allows for manual creation of multiple actors with the same name
		gender: actor.gender,
		allow_global_match: actor.allowGlobalMatch,
		comment: context?.comment,
	};
}

function getBaseActor(actor) {
	return Object.fromEntries(Object.entries(actor).map(([key, values]) => {
		if ([
			'scenes',
			'likes',
			'stashes',
			'profiles',
		].includes(key)) {
			return null;
		}

		if ([
			'socials',
		].includes(key)) {
			return [key, values];
		}

		if (values?.id) {
			return [key, values.id];
		}

		if (values?.metric) {
			return [key, values.metric];
		}

		if (Array.isArray(values)) {
			return [key, values.map((value) => value?.hash || value?.id || value)];
		}

		return [key, values];
	}).filter(Boolean));
}

function getDeltas(edits, baseActor, options) {
	return Promise.all(Object.entries(edits).map(async ([key, value]) => {
		if (baseActor[key] === value || typeof value === 'undefined') {
			return null;
		}

		if (key === 'originCountry' && edits.originPlace) {
			// place overrides country
			return null;
		}

		if (key === 'residenceCountry' && edits.residencePlace) {
			// place overrides country
			return null;
		}

		if (['originPlace', 'residencePlace'].includes(key)) {
			if (!value && !baseActor[key]) {
				// don't pollute deltas if value is already unset
				return null;
			}

			if (!value) {
				return [
					// { key: key.includes('origin') ? 'originCountry' : 'residenceCountry', value: null },
					{ key: key.includes('origin') ? 'originState' : 'residenceState', value: null },
					{ key: key.includes('origin') ? 'originCity' : 'residenceCity', value: null },
				];
			}

			const resolvedLocation = await resolvePlace(value, {
				knex,
				redis,
				logger,
				slugify,
				unprint,
			}, {
				userAgent: 'contact via https://traxxx.me/',
			});

			if (!resolvedLocation) {
				throw new Error(`Failed to resolve ${key} ${value}`);
			}

			const countryKey = key.includes('origin') ? 'originCountry' : 'residenceCountry';

			if (!resolvedLocation.country) {
				return null;
			}

			return [
				{
					key: countryKey,
					value: resolvedLocation.country,
					comment: edits[countryKey] && edits[countryKey] !== resolvedLocation.country
						? `${countryKey} overridden by resolved ${key}`
						: null,
				},
				{
					key: key.includes('origin') ? 'originState' : 'residenceState',
					value: resolvedLocation.state || null, // explicitly unset to prevent outcomes like Los Angeles, Greenland
				},
				{
					key: key.includes('origin') ? 'originCity' : 'residenceCity',
					value: resolvedLocation.city || null,
				},
			];
		}

		if (key === 'socials') {
			const convertedSocials = curateSocials(value);

			const convertedUrls = value
				.filter((social) => social.url && !convertedSocials.some((convertedSocial) => convertedSocial.url === social.url))
				.map((social) => social.url);

			const conversionComment = convertedUrls.length > 0
				? `curated URLs ${convertedUrls.join(', ')} as social handles`
				: null;

			return {
				key,
				value: convertedSocials,
				comment: conversionComment,
			};
		}

		if (['cup', 'bust', 'waist', 'hip'].includes(key)) {
			const convertedValue = convertFigure(key, value, options.figureUnits);

			const conversionComment = !value || convertedValue === value
				? null
				: `${key} converted from ${value} ${options.figureUnits?.toUpperCase() || 'US'} to ${convertedValue} US`;

			return {
				key,
				value: convertedValue,
				comment: conversionComment,
			};
		}

		if (['height'].includes(key)) {
			const convertedValue = convertHeight(value, options.sizeUnits);

			if (baseActor[key] === convertedValue) {
				return null;
			}

			const conversionComment = !value || convertedValue === value
				? null
				: `${key} converted from ${value[0]} in ${value[1]} ft to ${convertedValue} cm`;

			return {
				key,
				value: convertedValue,
				comment: conversionComment,
			};
		}

		if (['weight'].includes(key)) {
			const convertedValue = convertWeight(value, options.sizeUnits);

			if (baseActor[key] === convertedValue) {
				return null;
			}

			const conversionComment = !value || convertedValue === value
				? null
				: `${key} converted from ${value} lbs to ${convertedValue} kg`;

			return {
				key,
				value: convertedValue,
				comment: conversionComment,
			};
		}

		if (['penisLength', 'penisGirth'].includes(key) && options.penisUnits === 'imperial') {
			const convertedValue = Math.round(convert(value, 'inches').to('cm'));

			if (baseActor[key] === convertedValue) {
				return null;
			}

			return {
				key,
				value: convertedValue,
				comment: `${key} converted from ${value} inches to ${convertedValue} cm`,
			};
		}

		if (Array.isArray(value)) {
			const valueSet = new Set(value);
			const baseSet = new Set(baseActor[key]);

			if (valueSet.size === baseSet.size && baseActor[key].every((id) => valueSet.has(id))) {
				return null;
			}

			return { key, value: Array.from(valueSet) };
		}

		return { key, value };
	})).then((rawDeltas) => rawDeltas.flat().filter(Boolean));
}

export async function createActor(newActor, context, reqUser) {
	if (!reqUser || reqUser.role === 'user') {
		throw new HttpError('You are not permitted to create actors', 403);
	}

	const curatedActorEntry = curateActorEntry(newActor, context);
	const [actorEntry] = await knex('actors').insert(curatedActorEntry).returning('*');

	await syncActors([actorEntry.id]);

	return curateActor(actorEntry);
}

export async function mergeActors(targetActorId, sourceActorIds, comment, reqUser) {
	if (!verifyAbility(reqUser, 'actor', 'merge')) {
		throw new HttpError('You are not permitted to merge actors', 403);
	}

	if (sourceActorIds.includes(targetActorId)) {
		throw new HttpError('Cannot merge actor profile into itself', 400);
	}

	const [targetActorEntry, sourceActorEntries] = await Promise.all([
		knex('actors')
			.where('id', targetActorId)
			.whereNull('entity_id')
			.whereNull('alias_for')
			.first(),
		knex('actors')
			.whereIn('id', sourceActorIds),
	]);

	if (!targetActorEntry) {
		throw new HttpError('Target actor not found', 404);
	}

	if (sourceActorEntries.length < sourceActorIds.length) {
		throw new HttpError('Source actor not found', 404);
	}

	const [[targetActor], sourceActors] = await Promise.all([
		fetchActorsById([targetActorEntry.id], {
			reqUser,
			includeAssets: true,
			includePartOf: true,
		}),
		fetchActorsById(sourceActorEntries.map((sourceActorEntry) => sourceActorEntry.id), {
			reqUser,
			includeAssets: true,
			includePartOf: true,
		}),
	]);

	const trx = await knex.transaction();

	let mergedProfiles = [];
	let mergedSceneActors = [];
	let existingSceneActors = [];
	let duplicateSourceActors = [];
	let mergedActorStashes = [];

	try {
		const [existingProfiles, sourceProfiles] = await Promise.all([
			trx('actors_profiles')
				.where('actor_id', targetActorId),
			trx('actors_profiles')
				.whereIn('actor_id', sourceActorIds),
			trx('actors')
				.update('alias_for', targetActorId)
				.whereIn('id', sourceActorIds)
				.returning(['id', 'alias_for']),
			trx('actors_avatars')
				.update('actor_id', targetActorId)
				.whereIn('actor_id', sourceActorIds),
		]);

		// multiple source actors may provide profiles for the same entity, but we can only assign one to the target actor; prefer the newest
		const newestSourceProfileMap = Object.fromEntries(sourceProfiles
			.toSorted((profileA, profileB) => profileA.updated_at - profileB.updated_at)
			.map((profile) => [profile.entity_id, profile.id]));

		const duplicateSourceProfiles = sourceProfiles.filter((profile) => newestSourceProfileMap[profile.entity_id] && newestSourceProfileMap[profile.entity_id] !== profile.id);

		// assign source actor profiles to target actor, unless a profile for that entity is already present
		mergedProfiles = await trx('actors_profiles')
			.update('actor_id', targetActorId)
			.whereIn('actor_id', sourceActorIds)
			.whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_id))
			.whereNotIn('id', duplicateSourceProfiles.map((profile) => profile.id))
			.returning(['id', 'actor_id']);

		// find releases that have more than one source actor assigned
		duplicateSourceActors = await trx('releases_actors')
			.select('release_id', knex.raw('array_agg(actor_id) as actor_ids'))
			.whereIn('actor_id', sourceActorIds)
			.groupBy('release_id')
			.having(trx.raw('COUNT(DISTINCT actor_id) > 1'));

		if (duplicateSourceActors.length > 0) {
			// some scenes have multiple source actors assigned, which will cause a conflict after merging; we will need to remove all but one
			await trx('releases_actors')
				.whereIn('release_id', duplicateSourceActors.map((sceneActor) => sceneActor.release_id))
				.whereIn('actor_id', duplicateSourceActors.flatMap((sceneActor) => sceneActor.actor_ids.slice(1)))
				.delete();
		}

		// find scenes that already have target actor assigned
		existingSceneActors = await trx('releases_actors')
			.where('actor_id', targetActorId);

		// delete release source actors for scenes that already have the target actor assigned
		await trx('releases_actors')
			.whereIn('release_id', existingSceneActors.map((sceneActor) => sceneActor.release_id))
			.whereIn('actor_id', sourceActorIds)
			.delete();

		// alias release source actors to target actors
		mergedSceneActors = await trx('releases_actors')
			.update({
				actor_id: targetActorId,
				alias_id: knex.raw('actor_id'),
			})
			.whereIn('actor_id', sourceActorIds)
			.returning('release_id');

		await trx('releases_tags')
			.update('actor_id', targetActorId)
			.whereIn('actor_id', sourceActorIds);

		const [targetActorStashes, sourceActorStashes] = await Promise.all([
			trx('stashes_actors')
				.where('actor_id', targetActorId),
			trx('stashes_actors')
				.whereIn('actor_id', sourceActorIds),
		]);

		// remove source actors from stashes that already contain target actor
		await trx('stashes_actors')
			.whereIn('stash_id', targetActorStashes.map((stash) => stash.stash_id))
			.whereIn('actor_id', sourceActorIds)
			.delete();

		// find stashes that have more than one source actor assigned
		const duplicateStashActors = await trx('stashes_actors')
			.select('stash_id', knex.raw('array_agg(actor_id order by created_at) as actor_ids'))
			.whereIn('actor_id', sourceActorStashes.map((actorStash) => actorStash.actor_id))
			.groupBy('stash_id')
			.having(trx.raw('COUNT(DISTINCT actor_id) > 1'));

		if (duplicateStashActors.length > 0) {
			// some stashes have multiple source actors assigned, which will cause a conflict after merging; we will need to remove all but one
			await trx('stashes_actors')
				.whereIn('stash_id', duplicateStashActors.map((actorStash) => actorStash.stash_id))
				.whereIn('actor_id', duplicateStashActors.flatMap((actorStash) => actorStash.actor_ids.slice(1)))
				.delete();
		}

		// we update an existing entry instead of creating a new one, so the original stash date is preserved
		mergedActorStashes = await trx('stashes_actors')
			.update('actor_id', targetActorId)
			.whereIn('actor_id', sourceActorIds)
			.returning('stash_id');

		const baseTargetActor = getBaseActor(targetActor);

		const targetDeltas = sourceActors.map((sourceActor) => ({
			key: 'mergeSource',
			actorId: sourceActor.id,
			actorName: sourceActor.name,
			profileIds: sourceProfiles
				.filter((sourceProfile) => sourceProfile.actor_id === sourceActor.id)
				.map((sourceProfile) => sourceProfile.id),
		}));

		await trx('actors_revisions')
			.insert({
				actor_id: targetActor.id,
				user_id: reqUser.id,
				base: baseTargetActor,
				deltas: JSON.stringify(targetDeltas),
				hash: mj.hash({
					base: baseTargetActor,
					deltas: targetDeltas,
				}),
				comment,
				reviewed_by: reqUser.id,
				reviewed_at: knex.fn.now(),
				applied_at: knex.fn.now(),
				approved: true,
			});

		await trx('actors_revisions')
			.insert(sourceActors.map((sourceActor) => {
				const baseSourceActor = getBaseActor(sourceActor);

				const deltas = [{
					key: 'mergeTarget',
					actorId: targetActorId,
					actorName: targetActor.name,
					profileIds: sourceProfiles
						.filter((sourceProfile) => sourceProfile.actor_id === sourceActor.id)
						.map((sourceProfile) => sourceProfile.id),
				}];

				return {
					actor_id: sourceActor.id,
					user_id: reqUser.id,
					base: baseSourceActor,
					deltas: JSON.stringify(deltas),
					hash: mj.hash({
						base: baseSourceActor,
						deltas: targetDeltas,
					}),
					comment,
					reviewed_by: reqUser.id,
					reviewed_at: knex.fn.now(),
					applied_at: knex.fn.now(),
					approved: true,
				};
			}));

		await trx.commit();
	}
	catch (error) {
		await trx.rollback();

		throw error;
	}

	await interpolateProfiles([targetActorId, ...sourceActorIds], {
		knex,
		logger,
		moment,
		slugify,
		omit,
	}, { refreshView: false });

	await Promise.all([
		syncScenes([
			...mergedSceneActors.map((sceneActor) => sceneActor.release_id),
			...existingSceneActors.map((sceneActor) => sceneActor.release_id),
			...duplicateSourceActors.map((sceneActor) => sceneActor.release_id),
		]),
		syncActors([targetActorId, ...sourceActorIds]),
		syncStashes('actor', [targetActorId, ...sourceActorIds]),
	]);

	return {
		scenes: mergedSceneActors.length,
		profiles: mergedProfiles.length,
		stashes: mergedActorStashes.length,
	};
}

export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
	const limit = filters.limit || 50;
	const page = filters.page || 1;

	const revisions = await knex('actors_revisions')
		.select(
			'actors_revisions.*',
			'users.username as username',
			'reviewers.username as reviewer_username',
		)
		.leftJoin('users', 'users.id', 'actors_revisions.user_id')
		.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
		.modify((builder) => {
			if (!['admin', 'editor'].includes(reqUser?.role) && !filters.userId && !filters.actorId) {
				builder.where('user_id', reqUser.id);
			}

			if (filters.userId) {
				if (!['admin', 'editor'].includes(reqUser?.role) && filters.userId !== reqUser.id) {
					throw new HttpError('You are not permitted to view revisions from other users.', 403);
				}

				builder.where('actors_revisions.user_id', filters.userId);
			}

			if (revisionId) {
				builder.where('actors_revisions.id', revisionId);
				return;
			}

			if (filters.actorId) {
				builder.where('actors_revisions.actor_id', filters.actorId);
			}

			if (filters.isFinalized === false) {
				builder.whereNull('approved');
			}

			if (filters.isFinalized === true) {
				builder.whereNotNull('approved');
			}
		})
		.orderBy('created_at', 'desc')
		.limit(limit)
		.offset((page - 1) * limit);

	const avatarIds = Array.from(new Set(revisions.flatMap((revision) => [revision.base.avatar, revision.deltas.find((delta) => delta.key === 'avatar')?.value]))).filter(Boolean);
	const avatarEntries = await knex('media').whereIn('id', avatarIds);
	const avatars = avatarEntries.map((avatar) => curateMedia(avatar));

	const curatedRevisions = revisions.map((revision) => curateRevision(revision));

	return {
		revisions: curatedRevisions,
		revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
		avatars,
	};
}

async function applyActorValueDelta(profileId, delta, trx) {
	await knex('actors_profiles')
		.where('id', profileId)
		.update(keyMap[delta.key] || delta.key, delta.value)
		.transacting(trx);
}

async function applyActorDirectDelta(actorId, delta, trx) {
	await knex('actors')
		.where('id', actorId)
		.update(keyMap[delta.key] || delta.key, delta.value)
		.modify((builder) => {
			if (delta.key === 'name') {
				builder.update('slug', slugify(delta.value));
			}
		})
		.transacting(trx);
}

async function applyActorSocialsDelta(actorId, delta, trx) {
	await knex('actors_socials')
		.where('actor_id', actorId)
		.delete()
		.transacting(trx);

	await knex('actors_socials')
		.insert(delta.value.map((social) => ({
			actor_id: actorId,
			platform: social.platform,
			handle: social.handle,
			url: social.url,
			verified_at: knex.fn.now(), // manual add implies verification
		})))
		.transacting(trx);
}

async function fetchMainProfile(actorId, wasCreated = false) {
	const profileEntry = await knex('actors_profiles')
		.where('actor_id', actorId)
		.where('entity_id', null)
		.first();

	if (profileEntry) {
		return profileEntry;
	}

	if (wasCreated) {
		throw new HttpError('Failed to find or create main profile', 404);
	}

	await knex('actors_profiles').insert({
		actor_id: actorId,
		entity_id: null,
	});

	return fetchMainProfile(actorId, true);
}

async function applyActorRevision(revisionIds, reqUser) {
	const revisions = await knex('actors_revisions')
		.whereIn('id', revisionIds)
		.whereNull('applied_at'); // should not re-apply revision that was already applied

	await revisions.reduce(async (chain, revision) => {
		await chain;

		const mainProfile = await fetchMainProfile(revision.actor_id);

		await knex.transaction(async (trx) => {
			await Promise.all(revision.deltas.map(async (delta) => {
				if ([
					'gender',
					'avatar',
					'dateOfBirth',
					'dateOfDeath',
					'originCountry',
					'originState',
					'originCity',
					'residenceCountry',
					'residenceState',
					'residenceCity',
					'ethnicity',
					'height',
					'weight',
					'bust',
					'cup',
					'waist',
					'hip',
					'naturalBoobs',
					'boobsVolume',
					'boobsImplant',
					'boobsPlacement',
					'boobsIncision',
					'boobsSurgeon',
					'naturalButt',
					'buttVolume',
					'buttImplant',
					'naturalLips',
					'lipsVolume',
					'naturalLabia',
					'penisLength',
					'penisGirth',
					'isCircumcised',
					'hairColor',
					'eyes',
					'hasTattoos',
					'tattoos',
					'hasPiercings',
					'piercings',
					'agency',
				].includes(delta.key)) {
					return applyActorValueDelta(mainProfile.id, delta, trx);
				}

				if (delta.key === 'socials') {
					return applyActorSocialsDelta(revision.actor_id, delta, trx);
				}

				if (['name', 'allowGlobalMatch'].includes(delta.key) && verifyAbility(reqUser, 'actor', 'update')) {
					return applyActorDirectDelta(revision.actor_id, delta, trx);
				}

				return null;
			}));

			await knex('actors_revisions')
				.transacting(trx)
				.where('id', revision.id)
				.update('applied_at', knex.fn.now());

			// await trx.commit();
		}).catch(async (error) => {
			logger.error(`Failed to apply revision ${revision.id} on actor ${revision.actor_id}: ${error.message}`);
		});
	}, Promise.resolve());

	const actorIds = Array.from(new Set(revisions.map((revision) => revision.actor_id)));

	await interpolateProfiles(actorIds, {
		knex,
		logger,
		moment,
		slugify,
		omit,
	}, { refreshView: false });

	await syncActors(actorIds);
}

export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
	if (!reqUser || reqUser.role === 'user') {
		throw new HttpError('You are not permitted to approve revisions', 403);
	}

	if (typeof isApproved !== 'boolean') {
		throw new HttpError('You must either approve or reject the revision', 400);
	}

	const updated = await knex('actors_revisions')
		.where('id', revisionId)
		.whereNull('approved') // don't rerun reviewed revision, must be forked into new revision instead
		.whereNull('applied_at')
		.update({
			approved: isApproved,
			reviewed_at: knex.fn.now(),
			reviewed_by: reqUser.id,
			feedback,
		});

	if (updated === 0) {
		throw new HttpError('This revision was already reviewed', 409);
	}

	if (isApproved) {
		await applyActorRevision([revisionId], reqUser);
	}
}

const cupConversions = {
	us: ['AA', 'A', 'B', 'C', 'D', ['DD', 'E'], ['DDD', 'F'], 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'], // United States
	uk: ['AA', 'A', 'B', 'C', 'D', 'DD', 'E', 'F', 'FF', 'G', 'GG', 'H', 'HH', 'J', 'JJ', 'K', 'KK'], // United Kingdom
	eu: ['AA', 'A', 'B', 'C', 'D', 'E', 'F', 'G', ' H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P'], // Europe
	jp: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q'], // Japan
};

cupConversions.fr = cupConversions.eu; // France
cupConversions.it = cupConversions.uk; // Italy
cupConversions.au = cupConversions.uk; // Australia

// bra band sizes
const bustConversions = {
	us: [28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56],
	eu: [60, 65, 70, 75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130],
	fr: [75, 80, 85, 90, 95, 100, 105, 110, 115, 120, 125, 130, 135, 140, 145],
	it: [0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13],
	au: [6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34],
};

bustConversions.uk = bustConversions.us;
bustConversions.jp = bustConversions.eu;

const conversions = {
	cup: cupConversions,
	bust: bustConversions,
};

// to US
function convertFigure(domain = 'cup', rawValue, units) {
	if (!rawValue) {
		return null;
	}

	const value = typeof rawValue === 'string'
		? rawValue.toUpperCase()
		: Number(rawValue);

	if (!units || !cupConversions[units] || units === 'us') {
		return value;
	}

	if (!conversions[domain]) {
		if (['us', 'uk'].includes(units)) {
			return value; // should already be in inches
		}

		return Math.round(convert(value, 'cm').to('inches'));
	}

	if (Number.isNaN(value)) {
		return value;
	}

	const valueIndex = conversions[domain][units].findIndex((chartValue) => (Array.isArray(chartValue) // US uses both DD and E, and DDD and F
		? chartValue.includes(value)
		: value === chartValue));

	const usValue = Array.isArray(conversions[domain].us[valueIndex])
		? conversions[domain].us[valueIndex][0]
		: conversions[domain].us[valueIndex];

	return usValue;
}

function convertHeight(height, units) {
	if (units === 'metric' || !Array.isArray(height)) {
		return Number(height) || null;
	}

	if (height.length !== 2) {
		return null;
	}

	// 12 inches in a foot
	return Math.round(((height[0] * 12) + height[1]) * 2.54);
}

function convertWeight(weight, units) {
	if (units === 'imperial') {
		return Math.round(unit(weight, 'lbs').toNumeric('kg'));
	}

	return Number(weight) || null;
}

function curateSocials(socials) {
	return socials.map((social) => {
		if (!social.handle && !social.url) {
			throw new Error('No social handle or website URL specified');
		}

		if (social.handle && !social.platform) {
			throw new Error('No platform specified for social handle');
		}

		if (social.handle && social.platform && /[\w-]+/.test(social.handle) && /[a-z]+/i.test(social.platform)) {
			return {
				platform: social.platform.toLowerCase(),
				handle: social.handle,
			};
		}

		if (social.url) {
			const { hostname, pathname } = new URL(social.url);
			const platform = platformsByHostname[hostname];

			if (platform) {
				const handle = pathname.match(new RegExp(platform.pathname.replace('{handle}', '([\\w-]+)')))?.[1];

				if (handle) {
					return {
						platform: platform.platform,
						handle,
					};
				}
			}

			return {
				url: social.url,
			};
		}

		throw new Error('Invalid social');
	}).filter(Boolean);
}

export async function createActorRevision(actorId, {
	edits,
	comment,
	apply,
	...options
}, reqUser) {
	const [
		[actor],
		openRevisions,
	] = await Promise.all([
		fetchActorsById([actorId], {
			reqUser,
			includeAssets: true,
			includePartOf: true,
		}),
		knex('actors_revisions')
			.where('user_id', reqUser.id)
			.whereNull('approved'),
	]);

	if (!actor) {
		throw new HttpError(`No actor with ID ${actorId} found to update`, 404);
	}

	if (openRevisions.length >= config.revisions.unapprovedLimit && reqUser.role !== 'admin') {
		throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429);
	}

	const baseActor = getBaseActor(actor);
	const deltas = await getDeltas(edits, baseActor, options);

	const deltaComments = deltas.map((delta) => delta.comment);
	const curatedComment = [comment, ...deltaComments].filter(Boolean).join(' | ');

	if (deltas.length === 0) {
		throw new HttpError('No effective changes provided', 400);
	}

	const [revisionEntry] = await knex('actors_revisions')
		.insert({
			user_id: reqUser.id,
			actor_id: actor.id,
			base: JSON.stringify(baseActor),
			deltas: JSON.stringify(deltas),
			hash: mj.hash({
				base: baseActor,
				deltas,
			}),
			comment: curatedComment,
		})
		.returning('id');

	if (['admin', 'editor'].includes(reqUser.role) && apply) {
		// don't keep the editor waiting for the revision to apply
		reviewActorRevision(revisionEntry.id, true, {}, reqUser);
	}
}
