using System.Diagnostics.CodeAnalysis;
using System.Net;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using AngleSharp.Html.Parser;
using AsyncKeyedLock;
using EntityFramework.Exceptions.Common;
using Iceshrimp.Backend.Core.Configuration;
using Iceshrimp.Backend.Core.Database;
using Iceshrimp.Backend.Core.Database.Tables;
using Iceshrimp.Backend.Core.Extensions;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Federation.WebFinger;
using Iceshrimp.Backend.Core.Helpers;
using Iceshrimp.Backend.Core.Helpers.LibMfm.Conversion;
using Iceshrimp.Backend.Core.Middleware;
using Iceshrimp.Backend.Core.Queues;
using Iceshrimp.EntityFrameworkCore.Extensions;
using Iceshrimp.MfmSharp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;

namespace Iceshrimp.Backend.Core.Services;

public class UserService(
	IOptionsSnapshot<Config.SecuritySection> security,
	IOptions<Config.InstanceSection> instance,
	IOptionsSnapshot<Config.BackfillSection> backfillConfig,
	ILogger<UserService> logger,
	DatabaseContext db,
	ActivityPub.ActivityFetcherService fetchSvc,
	ActivityPub.ActivityRenderer activityRenderer,
	ActivityPub.ActivityDeliverService deliverSvc,
	DriveService driveSvc,
	FollowupTaskService followupTaskSvc,
	NotificationService notificationSvc,
	EmojiService emojiSvc,
	ActivityPub.MentionsResolver mentionsResolver,
	ActivityPub.UserRenderer userRenderer,
	QueueService queueSvc,
	EventService eventSvc,
	WebFingerService webFingerSvc,
	ActivityPub.FederationControlService fedCtrlSvc,
	HttpClient httpClient
) : IScopedService
{
	private static readonly AsyncKeyedLocker<string> KeyedLocker = new(o =>
	{
		o.PoolSize        = 100;
		o.PoolInitialFill = 5;
	});

	private static (string Username, string? Host) AcctToTuple(Uri acct)
	{
		if (acct.Scheme is not "acct") throw GracefulException.BadRequest($"Invalid query scheme: {acct}");
		var split = acct.AbsolutePath.Split('@');
		if (split.Length > 2) throw GracefulException.BadRequest($"Invalid query: {acct}");
		return split.Length != 2
			? (split[0], null)
			: (split[0], split[1].ToPunycodeLower());
	}

	private static (string Username, string? Host) AcctToTuple(string acct) => AcctToTuple(new Uri(acct));

	public async Task<User?> GetUserFromQueryAsync(Uri query, bool allowUrl)
	{
		if (query.Scheme is "https")
		{
			if (query.Host == instance.Value.WebDomain)
			{
				if (query.AbsolutePath.StartsWith("/users/"))
				{
					var userId = query.AbsolutePath["/users/".Length..];
					return await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == userId);
				}

				if (query.AbsolutePath.StartsWith("/@"))
				{
					var acct  = query.AbsolutePath[2..];
					var split = acct.Split('@');
					if (split.Length != 1)
						return await GetUserFromQueryAsync(new Uri($"acct:{acct}"), allowUrl);

					return await db.Users.IncludeCommonProperties()
					               .FirstOrDefaultAsync(p => p.Username == split[0].ToLower() && p.IsLocalUser);
				}

				return null;
			}

			var res = await db.Users.IncludeCommonProperties()
			                  .FirstOrDefaultAsync(p => p.Uri != null && p.Uri == query.AbsoluteUri);

			if (res != null || !allowUrl)
				return res;

			return await db.Users.IncludeCommonProperties()
			               .FirstOrDefaultAsync(p => p.UserProfile != null && p.UserProfile.Url == query.AbsoluteUri);
		}

		var tuple = AcctToTuple(query);

		if (tuple.Host == instance.Value.WebDomain || tuple.Host == instance.Value.AccountDomain)
			tuple.Host = null;

		return await db.Users
		               .IncludeCommonProperties()
		               .FirstOrDefaultAsync(p => p.UsernameLower == tuple.Username.ToLowerInvariant()
		                                         && p.Host == tuple.Host);
	}

	public async Task<User> CreateUserAsync(string uri, string acct)
	{
		logger.LogDebug("Creating user {acct} with uri {uri}", acct, uri);

		var host = AcctToTuple(acct).Host ?? throw new Exception("Host must not be null at this stage");
		if (host == instance.Value.WebDomain || host == instance.Value.AccountDomain)
			throw GracefulException.UnprocessableEntity("Refusing to create remote user on local instance domain");
		if (await fedCtrlSvc.ShouldBlockAsync(uri, host))
			throw GracefulException.UnprocessableEntity("Refusing to create user on blocked instance");

		var user = await db.Users
		                   .IncludeCommonProperties()
		                   .FirstOrDefaultAsync(p => p.Uri != null && p.Uri == uri);

		if (user != null)
		{
			// Another thread got there first
			logger.LogDebug("Actor {uri} is already known, returning existing user {id}", user.Uri, user.Id);
			return user;
		}

		var actor = await fetchSvc.FetchActorAsync(uri);
		logger.LogDebug("Got actor: {url}", actor.Url);

		actor.NormalizeAndValidate(uri);

		user = await db.Users.FirstOrDefaultAsync(p => p.UsernameLower == actor.Username!.ToLowerInvariant()
		                                               && p.Host == host);
		if (user is not null)
			throw GracefulException
				.UnprocessableEntity($"A user with acct @{user.UsernameLower}@{user.Host} already exists: {user.Uri}");

		if (actor.Id != uri)
			throw GracefulException.UnprocessableEntity("Uri doesn't match id of fetched actor");
		if (actor.PublicKey?.Id == null || actor.PublicKey?.PublicKey == null)
			throw GracefulException.UnprocessableEntity("Actor has no valid public key");

		var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType<ASEmoji>().ToList(), host);

		var fields = actor.Attachments != null
			? await actor.Attachments
			             .OfType<ASField>()
			             .Where(p => p is { Name: not null, Value: not null })
			             .Select(async p => new UserProfile.Field
			             {
				             Name = p.Name!, Value = (await MfmConverter.FromHtmlAsync(p.Value)).Mfm
			             })
			             .AwaitAllAsync()
			: null;

		var pronouns = actor.Pronouns?.Values.ToDictionary(p => p.Key, p => p.Value ?? "");

		var bio = actor.MkSummary?.ReplaceLineEndings("\n").Trim();
		if (bio == null)
		{
			var asHashtags = actor.Tags?.OfType<ASHashtag>()
			                      .Select(p => p.Name?.ToLowerInvariant().TrimStart('#'))
			                      .NotNull()
			                      .ToList()
			                 ?? [];

			bio = (await MfmConverter.FromHtmlAsync(actor.Summary, hashtags: asHashtags)).Mfm;
		}

		var tags = ResolveHashtags(MfmParser.Parse(bio), actor);

		user = new User
		{
			Id                  = IdHelpers.GenerateSnowflakeId(),
			CreatedAt           = DateTime.UtcNow,
			LastFetchedAt       = followupTaskSvc.IsBackgroundWorker.Value ? null : DateTime.UtcNow,
			DisplayName         = actor.DisplayName?.ReplaceLineEndings("\n").Trim(),
			IsLocked            = actor.IsLocked ?? false,
			IsBot               = actor.IsBot,
			Username            = actor.Username!,
			UsernameLower       = actor.Username!.ToLowerInvariant(),
			Host                = host,
			MovedToUri          = actor.MovedTo?.Link,
			AlsoKnownAs         = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList(),
			IsExplorable        = actor.IsDiscoverable ?? false,
			Inbox               = actor.Inbox?.Link,
			Outbox              = actor.Outbox?.Id,
			SharedInbox         = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id,
			FollowersUri        = actor.Followers?.Id,
			Uri                 = actor.Id,
			IsCat               = actor.IsCat ?? false,
			Featured            = actor.Featured?.Id,
			SplitDomainResolved = true,
			//TODO: FollowersCount
			//TODO: FollowingCount
			Emojis = emoji.Select(p => p.Id).ToList(),
			Tags   = tags
		};

		var profile = new UserProfile
		{
			User        = user,
			Description = bio,
			//Birthday = TODO,
			//Location = TODO,
			Fields   = fields?.ToArray() ?? [],
			UserHost = user.Host,
			Url      = actor.Url?.Link,
			Pronouns = pronouns
		};

		var publicKey = new UserPublickey
		{
			UserId = user.Id,
			KeyId  = actor.PublicKey.Id,
			KeyPem = actor.PublicKey.PublicKey.Trim()
		};

		try
		{
			await db.AddRangeAsync(user, profile, publicKey);
			await db.SaveChangesAsync();
			var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);
			await processPendingDeletes();
			user = await UpdateProfileMentionsAsync(user, actor);
			await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id});
			UpdateUserPinnedNotesInBackground(actor, user);
			_ = followupTaskSvc.ExecuteTaskAsync("UpdateInstanceUserCounter", async provider =>
			{
				var bgDb          = provider.GetRequiredService<DatabaseContext>();
				var bgInstanceSvc = provider.GetRequiredService<InstanceService>();

				var dbInstance = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(user);
				await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
				          .ExecuteUpdateAsync(p => p.SetProperty(i => i.UsersCount, i => i.UsersCount + 1));
			});
			return user;
		}
		catch (UniqueConstraintException e) when (e.ConstraintProperties is [nameof(User.Uri)])
		{
			logger.LogError("Encountered UniqueConstraintException while creating user {uri}, attempting to refetch...",
			                user.Uri);
			// another thread got there first, so we need to return the existing user
			var res = await db.Users
			                  .IncludeCommonProperties()
			                  .FirstOrDefaultAsync(p => p.Uri != null && p.Uri == user.Uri);

			// something else must have went wrong, rethrow exception
			if (res == null)
			{
				logger.LogError("Fetching user {uri} failed, rethrowing exception", user.Uri);
				throw;
			}

			logger.LogDebug("Successfully fetched user {uri}", user.Uri);

			return res;
		}
		catch (UniqueConstraintException e)
		{
			logger.LogError("Failed to insert user: Unable to satisfy unique constraint: {constraint}",
			                e.ConstraintName);
			throw;
		}
	}

	public async Task<User> UpdateUserAsync(string id)
	{
		var user = await db.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == id)
		           ?? throw new Exception("Cannot update nonexistent user");
		return await UpdateUserAsync(user, force: true);
	}

	public async Task<User> UpdateUserAsync(User user, ASActor? actor = null, bool force = false)
	{
		if (!user.NeedsUpdate && actor == null && !force) return user;
		if (user.IsLocalUser) return user;
		if (actor is { IsUnresolved: true } or { Username: null })
			actor = null; // This will trigger a fetch a couple lines down

		// Prevent multiple update jobs from running concurrently
		db.Update(user);
		var userId = user.Id;
		await db.Users.Where(u => u.Id == userId)
		        .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastFetchedAt, DateTime.UtcNow));

		var uri = user.Uri ?? throw new Exception("Encountered remote user without a Uri");
		logger.LogDebug("Updating user with uri {uri}", uri);

		actor ??= await fetchSvc.FetchActorAsync(user.Uri);
		actor.NormalizeAndValidate(uri);

		user.UserProfile ??= await db.UserProfiles.FirstOrDefaultAsync(p => p.User == user);
		user.UserProfile ??= new UserProfile { User = user };

		user.LastFetchedAt = DateTime.UtcNow; // If we don't do this we'll overwrite the value with the previous one
		user.Inbox         = actor.Inbox?.Link;
		user.Outbox        = actor.Outbox?.Id;
		user.SharedInbox   = actor.SharedInbox?.Link ?? actor.Endpoints?.SharedInbox?.Id;
		user.DisplayName   = actor.DisplayName?.ReplaceLineEndings("\n").Trim();
		user.IsLocked      = actor.IsLocked ?? false;
		user.IsBot         = actor.IsBot;
		user.MovedToUri    = actor.MovedTo?.Link;
		user.AlsoKnownAs   = actor.AlsoKnownAs?.Where(p => p.Link != null).Select(p => p.Link!).ToList();
		user.IsExplorable  = actor.IsDiscoverable ?? false;
		user.FollowersUri  = actor.Followers?.Id;
		user.IsCat         = actor.IsCat ?? false;
		user.Featured      = actor.Featured?.Id;

		var emoji = await emojiSvc.ProcessEmojiAsync(actor.Tags?.OfType<ASEmoji>().ToList(),
		                                             user.Host
		                                             ?? throw new
			                                             Exception("User host must not be null at this stage"));

		var fields = actor.Attachments != null
			? await actor.Attachments
			             .OfType<ASField>()
			             .Where(p => p is { Name: not null, Value: not null })
			             .Select(async p => new UserProfile.Field
			             {
				             Name = p.Name!, Value = (await MfmConverter.FromHtmlAsync(p.Value)).Mfm
			             })
			             .AwaitAllAsync()
			: null;

		var pronouns = actor.Pronouns?.Values.ToDictionary(p => p.Key, p => p.Value ?? "");

		user.Emojis = emoji.Select(p => p.Id).ToList();
		//TODO: FollowersCount
		//TODO: FollowingCount

		//TODO: update acct host via webfinger here

		var processPendingDeletes = await ResolveAvatarAndBannerAsync(user, actor);

		user.UserProfile.Description = actor.MkSummary?.ReplaceLineEndings("\n").Trim();
		if (user.UserProfile.Description == null)
		{
			var asHashtags = actor.Tags?.OfType<ASHashtag>()
			                      .Select(p => p.Name?.ToLowerInvariant().TrimStart('#'))
			                      .NotNull()
			                      .ToList()
			                 ?? [];

			user.UserProfile.Description = (await MfmConverter.FromHtmlAsync(actor.Summary, hashtags: asHashtags)).Mfm;
		}

		//user.UserProfile.Birthday = TODO;
		//user.UserProfile.Location = TODO;
		user.UserProfile.Fields   = fields?.ToArray() ?? [];
		user.UserProfile.UserHost = user.Host;
		user.UserProfile.Url      = actor.Url?.Link;
		user.UserProfile.Pronouns = pronouns;

		user.UserProfile.MentionsResolved = false;

		user.Tags = ResolveHashtags(MfmParser.Parse(user.UserProfile.Description), actor);
		user.Host = await UpdateUserHostAsync(user);

		db.Update(user);
		await db.SaveChangesAsync();
		await processPendingDeletes();
		user = await UpdateProfileMentionsAsync(user, actor, true);
		await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id});
		UpdateUserPinnedNotesInBackground(actor, user, true);
		return user;
	}

	public async Task<User> UpdateLocalUserAsync(User user, string? prevAvatarId, string? prevBannerId)
	{
		if (user.IsRemoteUser) throw new Exception("This method is only valid for local users");
		if (user.UserProfile == null) throw new Exception("user.UserProfile must not be null at this stage");

		user.DisplayName             = user.DisplayName?.ReplaceLineEndings("\n").Trim();
		user.UserProfile.Description = user.UserProfile.Description?.ReplaceLineEndings("\n").Trim();

		var parsedName = user.DisplayName != null ? MfmParser.Parse(user.DisplayName) : null;
		var parsedBio  = user.UserProfile.Description != null ? MfmParser.Parse(user.UserProfile.Description) : null;

		user.Tags   = parsedBio != null ? ResolveHashtags(parsedBio) : [];
		user.Emojis = [];

		if (parsedBio != null)
			user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(parsedBio)).Select(p => p.Id).ToList());
		if (parsedName != null)
			user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(parsedName)).Select(p => p.Id).ToList());

		if (user.UserProfile.Fields is { Length: > 0 } fields)
		{
			foreach (var field in fields)
				field.Name = field.Name.ReplaceLineEndings("\n").Trim();

			var input = user.UserProfile.Fields.Select(p => $"{p.Name} {p.Value}");
			var nodes = MfmParser.Parse(string.Join('\n', input));
			user.Emojis.AddRange((await emojiSvc.ResolveEmojiAsync(nodes)).Select(p => p.Id).ToList());
		}

		db.Update(user);
		db.Update(user.UserProfile);
		await db.SaveChangesAsync();

		user = await UpdateProfileMentionsAsync(user, null, wait: true);
		await queueSvc.BackgroundTaskQueue.EnqueueAsync(new ProfileFieldUpdateJobData { UserId = user.Id});

		var avatar = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == user.AvatarId);
		var banner = await db.DriveFiles.FirstOrDefaultAsync(p => p.Id == user.BannerId);

		user.Avatar = avatar;
		user.Banner = banner;

		var activity = activityRenderer.RenderUpdate(await userRenderer.RenderAsync(user));
		await deliverSvc.DeliverToFollowersAsync(activity, user, []);

		_ = followupTaskSvc.ExecuteTaskAsync("UpdateLocalUserAsync", async provider =>
		{
			var bgDriveSvc = provider.GetRequiredService<DriveService>();
			if (prevAvatarId != null && user.Avatar?.Id != prevAvatarId)
				await bgDriveSvc.RemoveFileAsync(prevAvatarId);
			if (prevBannerId != null && user.Banner?.Id != prevBannerId)
				await bgDriveSvc.RemoveFileAsync(prevBannerId);
		});

		return user;
	}

	public async Task<User> CreateLocalUserAsync(string username, string password, string? invite, bool force = false)
	{
		//TODO: invite system should allow multi-use invites & time limited invites
		if (security.Value.Registrations == Enums.Registrations.Closed && !force)
			throw new GracefulException(HttpStatusCode.Forbidden, "Registrations are disabled on this server");
		if (security.Value.Registrations == Enums.Registrations.Invite && invite == null && !force)
			throw new GracefulException(HttpStatusCode.Forbidden, "Request is missing the invite code");
		if (security.Value.Registrations == Enums.Registrations.Invite
		    && !await db.RegistrationInvites.AnyAsync(p => p.Code == invite)
		    && !force)
			throw new GracefulException(HttpStatusCode.Forbidden, "The specified invite code is invalid");
		if (!Regex.IsMatch(username, @"^\w+$"))
			throw new GracefulException(HttpStatusCode.BadRequest, "Username must only contain letters and numbers");
		if (Constants.SystemUsers.Contains(username.ToLowerInvariant()))
			throw new GracefulException(HttpStatusCode.BadRequest, "Username must not be a system user");
		if (await db.Users.AnyAsync(p => p.IsLocalUser && p.UsernameLower == username.ToLowerInvariant()))
			throw new GracefulException(HttpStatusCode.BadRequest, "User already exists");
		if (await db.UsedUsernames.AnyAsync(p => p.Username.ToLower() == username.ToLowerInvariant()))
			throw new GracefulException(HttpStatusCode.BadRequest, "Username was already used");
		if (password.Length < 8)
			throw GracefulException.BadRequest("Password must be at least 8 characters long");

		var keypair = RSA.Create(4096);
		var user = new User
		{
			Id            = IdHelpers.GenerateSnowflakeId(),
			CreatedAt     = DateTime.UtcNow,
			Username      = username,
			UsernameLower = username.ToLowerInvariant(),
			Host          = null
		};

		var userKeypair = new UserKeypair
		{
			UserId     = user.Id,
			PrivateKey = keypair.ExportPkcs8PrivateKeyPem(),
			PublicKey  = keypair.ExportSubjectPublicKeyInfoPem()
		};

		var userProfile  = new UserProfile { UserId  = user.Id };
		var userSettings = new UserSettings { UserId = user.Id, Password = AuthHelpers.HashPassword(password) };

		var usedUsername = new UsedUsername { CreatedAt = DateTime.UtcNow, Username = username.ToLowerInvariant() };

		if (security.Value.Registrations == Enums.Registrations.Invite && !force)
		{
			var ticket = await db.RegistrationInvites.FirstOrDefaultAsync(p => p.Code == invite);
			if (ticket == null)
				throw GracefulException.Forbidden("The specified invite code is invalid");
			db.Remove(ticket);
		}

		await db.AddRangeAsync(user, userKeypair, userProfile, userSettings, usedUsername);
		await db.SaveChangesAsync();

		return user;
	}

	private async Task<Func<Task>> ResolveAvatarAndBannerAsync(User user, ASActor actor)
	{
		var avatar = await driveSvc.StoreFileAsync(actor.Avatar?.Url?.Link, user, actor.Avatar?.Sensitive ?? false,
		                                           actor.Avatar?.Description, logExisting: false);
		var banner = await driveSvc.StoreFileAsync(actor.Banner?.Url?.Link, user, actor.Banner?.Sensitive ?? false,
		                                           actor.Banner?.Description, logExisting: false);

		var prevAvatarId = user.AvatarId;
		var prevBannerId = user.BannerId;

		user.Avatar = avatar;
		user.Banner = banner;

		user.AvatarBlurhash = avatar?.Blurhash;
		user.BannerBlurhash = banner?.Blurhash;

		await db.SaveChangesAsync();

		return async () =>
		{
			if (prevAvatarId != null && avatar?.Id != prevAvatarId)
				await driveSvc.RemoveFileAsync(prevAvatarId);

			if (prevBannerId != null && banner?.Id != prevBannerId)
				await driveSvc.RemoveFileAsync(prevBannerId);
		};
	}

	public async Task<UserPublickey> UpdateUserPublicKeyAsync(UserPublickey key)
	{
		var uri   = key.User.Uri ?? throw new Exception("Can't update public key of user without Uri");
		var actor = await fetchSvc.FetchActorAsync(uri);

		if (actor.PublicKey?.PublicKey == null)
			throw new Exception("Failed to update user public key: Invalid or missing public key");

		key.KeyId  = actor.PublicKey.Id;
		key.KeyPem = actor.PublicKey.PublicKey;

		db.Update(key);
		await db.SaveChangesAsync();
		return key;
	}

	public async Task<UserPublickey> UpdateUserPublicKeyAsync(User user)
	{
		var uri   = user.Uri ?? throw new Exception("Can't update public key of user without Uri");
		var actor = await fetchSvc.FetchActorAsync(uri);

		if (actor.PublicKey?.PublicKey == null)
			throw new Exception("Failed to update user public key: Invalid or missing public key");

		var key = await db.UserPublickeys.FirstOrDefaultAsync(p => p.User == user) ?? new UserPublickey { User = user };

		var insert = key.KeyId == null!;

		key.KeyId  = actor.PublicKey.Id;
		key.KeyPem = actor.PublicKey.PublicKey.Trim();

		if (insert) db.Add(key);
		else db.Update(key);
		await db.SaveChangesAsync();
		return key;
	}

	public async Task DeleteUserAsync(ASActor actor)
	{
		var user = await db.Users.FirstOrDefaultAsync(p => p.Uri == actor.Id && p.IsRemoteUser);

		if (user == null)
		{
			logger.LogDebug("User {uri} is unknown, skipping delete task", actor.Id);
			return;
		}

		await DeleteUserAsync(user);
	}

	public async Task DeleteUserAsync(User user)
	{
		await queueSvc.BackgroundTaskQueue.EnqueueAsync(new UserDeleteJobData { UserId = user.Id });
	}

	public async Task PurgeUserAsync(User user)
	{
		await queueSvc.BackgroundTaskQueue.EnqueueAsync(new UserPurgeJobData { UserId = user.Id });
	}

	public void UpdateOauthTokenMetadata(OauthToken token)
	{
		UpdateUserLastActive(token.User);

		if (token.LastActiveDate != null && token.LastActiveDate > DateTime.UtcNow - TimeSpan.FromHours(1)) return;

		_ = followupTaskSvc.ExecuteTaskAsync("UpdateOauthTokenMetadata", async provider =>
		{
			var bgDb = provider.GetRequiredService<DatabaseContext>();
			await bgDb.OauthTokens.Where(p => p.Id == token.Id)
			          .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow));
		});
	}

	public void UpdateSessionMetadata(Session session)
	{
		UpdateUserLastActive(session.User);

		if (session.LastActiveDate != null && session.LastActiveDate > DateTime.UtcNow - TimeSpan.FromMinutes(5))
			return;

		_ = followupTaskSvc.ExecuteTaskAsync("UpdateSessionMetadata", async provider =>
		{
			var bgDb = provider.GetRequiredService<DatabaseContext>();
			await bgDb.Sessions.Where(p => p.Id == session.Id)
			          .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow));
		});
	}

	private void UpdateUserLastActive(User user)
	{
		if (user.LastActiveDate != null && user.LastActiveDate > DateTime.UtcNow - TimeSpan.FromMinutes(5))
			return;

		_ = followupTaskSvc.ExecuteTaskAsync("UpdateUserLastActive", async provider =>
		{
			var bgDb = provider.GetRequiredService<DatabaseContext>();
			await bgDb.Users.Where(p => p.Id == user.Id)
			          .ExecuteUpdateAsync(p => p.SetProperty(u => u.LastActiveDate, DateTime.UtcNow));
		});
	}

	public async Task AcceptFollowRequestAsync(FollowRequest request)
	{
		if (request is { Follower.IsRemoteUser: true, RequestId: null })
			throw GracefulException.UnprocessableEntity("Cannot accept remote request without request id");

		var following = new Following
		{
			Id                  = IdHelpers.GenerateSnowflakeId(),
			CreatedAt           = DateTime.UtcNow,
			Follower            = request.Follower,
			Followee            = request.Followee,
			FollowerHost        = request.FollowerHost,
			FolloweeHost        = request.FolloweeHost,
			FollowerInbox       = request.FollowerInbox,
			FolloweeInbox       = request.FolloweeInbox,
			FollowerSharedInbox = request.FollowerSharedInbox,
			FolloweeSharedInbox = request.FolloweeSharedInbox,
			RelationshipId      = request.RelationshipId
		};

		await db.Users.Where(p => p.Id == request.Follower.Id)
		        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount, i => i.FollowingCount + 1));
		await db.Users.Where(p => p.Id == request.Followee.Id)
		        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount + 1));

		db.Remove(request);
		await db.AddAsync(following);
		await db.SaveChangesAsync();

		if (request.Follower is { IsRemoteUser: true })
		{
			_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceIncomingFollowsCounter", async provider =>
			{
				var bgDb          = provider.GetRequiredService<DatabaseContext>();
				var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
				var dbInstance    = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(request.Follower);
				await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
				          .ExecuteUpdateAsync(p => p.SetProperty(i => i.IncomingFollows, i => i.IncomingFollows + 1));
			});

			var activity = activityRenderer.RenderAccept(request.Followee, request.Follower, request.RequestId!);
			await deliverSvc.DeliverToAsync(activity, request.Followee, request.Follower);
		}
		else if (request.Followee is { IsRemoteUser: true })
		{
			_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceOutgoingFollowsCounter", async provider =>
			{
				var bgDb          = provider.GetRequiredService<DatabaseContext>();
				var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
				var dbInstance    = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(request.Followee);
				await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
				          .ExecuteUpdateAsync(p => p.SetProperty(i => i.OutgoingFollows, i => i.OutgoingFollows + 1));
			});
		}

		if (request.Followee.IsRemoteUser && request.Follower.IsLocalUser && request.Followee.FollowersCount == 0)
			UpdateUserPinnedNotesInBackground(request.Followee);

		await notificationSvc.GenerateFollowNotificationAsync(request.Follower, request.Followee);
		await notificationSvc.GenerateFollowRequestAcceptedNotificationAsync(request);

		// Clean up notifications
		await db.Notifications
		        .Where(p => p.Type == Notification.NotificationType.FollowRequestReceived
		                    && p.Notifiee == request.Followee
		                    && p.Notifier == request.Follower)
		        .ExecuteDeleteAsync();
	}

	public async Task RejectFollowRequestAsync(FollowRequest request)
	{
		if (request.FollowerHost != null)
		{
			var requestId = request.RequestId ?? throw new Exception("Cannot reject request without request id");
			var activity  = activityRenderer.RenderReject(request.Followee, request.Follower, requestId);
			await deliverSvc.DeliverToAsync(activity, request.Followee, request.Follower);
		}

		db.Remove(request);
		await db.SaveChangesAsync();

		// Clean up notifications
		await db.Notifications
		        .Where(p => ((p.Type == Notification.NotificationType.FollowRequestReceived
		                      || p.Type == Notification.NotificationType.Follow)
		                     && p.Notifiee == request.Followee
		                     && p.Notifier == request.Follower)
		                    || (p.Type == Notification.NotificationType.FollowRequestAccepted
		                        && p.Notifiee == request.Follower
		                        && p.Notifier == request.Followee))
		        .ExecuteDeleteAsync();
	}

	[SuppressMessage("ReSharper", "EntityFramework.UnsupportedServerSideFunctionCall", Justification = "Projectables")]
	public async Task FollowUserAsync(User follower, User followee, string? requestId = null)
	{
		if (follower.Id == followee.Id)
			throw GracefulException.UnprocessableEntity("You cannot follow yourself");
		if (follower.IsRemoteUser && followee.IsRemoteUser)
			throw GracefulException.UnprocessableEntity("Cannot process follow between two remote users");
		if (follower.IsSystemUser || followee.IsSystemUser)
			throw GracefulException.UnprocessableEntity("System users cannot have follow relationships");

		Guid? relationshipId = null;

		// If followee is remote, send a follow activity immediately
		if (followee.IsRemoteUser)
		{
			relationshipId = Guid.NewGuid();
			var activity = activityRenderer.RenderFollow(follower, followee, relationshipId);
			await deliverSvc.DeliverToAsync(activity, follower, followee);
		}

		// Check blocks separately for local/remote follower
		if (follower.IsRemoteUser)
		{
			if (requestId == null)
				throw GracefulException.UnprocessableEntity("Cannot process remote follow without requestId");

			if (await db.Users.AnyAsync(p => p == followee && p.IsBlocking(follower)))
			{
				var activity = activityRenderer.RenderReject(followee, follower, requestId);
				await deliverSvc.DeliverToAsync(activity, followee, follower);
				return;
			}
		}
		else
		{
			if (await db.Users.AnyAsync(p => p == followee && p.IsBlocking(follower)))
				throw GracefulException.UnprocessableEntity("You are not allowed to follow this user");
		}

		// We have to create a request instead of a follow relationship in these cases
		if (followee.IsLocked || followee.IsRemoteUser)
		{
			// We already have a pending follow request, so we want to update the request id in case it changed
			if (await db.FollowRequests.AnyAsync(p => p.Follower == follower && p.Followee == followee))
			{
				await db.FollowRequests.Where(p => p.Follower == follower && p.Followee == followee)
				        .ExecuteUpdateAsync(p => p.SetProperty(i => i.RequestId, _ => requestId));
			}
			else
			{
				// There already is an established follow relationship
				if (await db.Followings.AnyAsync(p => p.Follower == follower && p.Followee == followee))
				{
					// If the follower is remote, immediately send an accept activity, otherwise do nothing
					if (follower.IsRemoteUser)
					{
						if (requestId == null)
							throw new Exception("requestId must not be null at this stage");

						var activity = activityRenderer.RenderAccept(followee, follower, requestId);
						await deliverSvc.DeliverToAsync(activity, followee, follower);
					}
				}
				// Otherwise, create a new request and insert it into the database
				else
				{
					var autoAccept = followee.IsLocalUser
					                 && await db.Followings.AnyAsync(p => p.Follower == followee
					                                                      && p.Followee == follower
					                                                      && p.Follower.UserSettings != null
					                                                      && p.Follower.UserSettings
					                                                          .AutoAcceptFollowed);

					// Followee has auto accept enabled & is already following the follower user
					if (autoAccept)
					{
						if (follower.IsRemoteUser)
						{
							if (requestId == null)
								throw new Exception("requestId must not be null at this stage");

							var activity = activityRenderer.RenderAccept(followee, follower, requestId);
							await deliverSvc.DeliverToAsync(activity, followee, follower);
						}

						var following = new Following
						{
							Id                  = IdHelpers.GenerateSnowflakeId(),
							CreatedAt           = DateTime.UtcNow,
							Followee            = followee,
							Follower            = follower,
							FolloweeHost        = followee.Host,
							FollowerHost        = follower.Host,
							FolloweeInbox       = followee.Inbox,
							FollowerInbox       = follower.Inbox,
							FolloweeSharedInbox = followee.SharedInbox,
							FollowerSharedInbox = follower.SharedInbox
						};

						await db.AddAsync(following);
						await db.SaveChangesAsync();
						await notificationSvc.GenerateFollowNotificationAsync(follower, followee);

						await db.Users.Where(p => p.Id == follower.Id)
						        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount,
						                                               i => i.FollowingCount + 1));
						await db.Users.Where(p => p.Id == followee.Id)
						        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount,
						                                               i => i.FollowersCount + 1));

						if (follower.IsRemoteUser)
						{
							await EnqueueBackfillTaskAsync(follower);

							// @formatter:off
							_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceIncomingFollowsCounter", async provider =>
							{
								var bgDb          = provider.GetRequiredService<DatabaseContext>();
								var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
								var dbInstance    = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(follower);
								await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
								          .ExecuteUpdateAsync(p => p.SetProperty(i => i.IncomingFollows, i => i.IncomingFollows + 1));
							});
							// @formatter:on
						}

						return;
					}

					var request = new FollowRequest
					{
						Id                  = IdHelpers.GenerateSnowflakeId(),
						CreatedAt           = DateTime.UtcNow,
						RequestId           = requestId,
						Followee            = followee,
						Follower            = follower,
						FolloweeHost        = followee.Host,
						FollowerHost        = follower.Host,
						FolloweeInbox       = followee.Inbox,
						FollowerInbox       = follower.Inbox,
						FolloweeSharedInbox = followee.SharedInbox,
						FollowerSharedInbox = follower.SharedInbox,
						RelationshipId      = relationshipId
					};

					await db.AddAsync(request);
					await db.SaveChangesAsync();
					await notificationSvc.GenerateFollowRequestReceivedNotificationAsync(request);

					if (follower.IsRemoteUser) await EnqueueBackfillTaskAsync(follower);
				}
			}
		}
		// Followee is local and not locked
		else
		{
			// If there isn't an established follow relationship already, create one
			if (!await db.Followings.AnyAsync(p => p.Follower == follower && p.Followee == followee))
			{
				var following = new Following
				{
					Id                  = IdHelpers.GenerateSnowflakeId(),
					CreatedAt           = DateTime.UtcNow,
					Followee            = followee,
					Follower            = follower,
					FolloweeHost        = followee.Host,
					FollowerHost        = follower.Host,
					FolloweeInbox       = followee.Inbox,
					FollowerInbox       = follower.Inbox,
					FolloweeSharedInbox = followee.SharedInbox,
					FollowerSharedInbox = follower.SharedInbox
				};

				await db.AddAsync(following);
				await db.SaveChangesAsync();
				await notificationSvc.GenerateFollowNotificationAsync(follower, followee);

				await db.Users.Where(p => p.Id == follower.Id)
				        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount, i => i.FollowingCount + 1));
				await db.Users.Where(p => p.Id == followee.Id)
				        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount + 1));

				if (follower.IsRemoteUser)
				{
					await EnqueueBackfillTaskAsync(follower);

					_ = followupTaskSvc.ExecuteTaskAsync("IncrementInstanceIncomingFollowsCounter", async provider =>
					{
						var bgDb          = provider.GetRequiredService<DatabaseContext>();
						var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
						var dbInstance    = await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(follower);
						await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
						          .ExecuteUpdateAsync(p => p.SetProperty(i => i.IncomingFollows,
						                                                 i => i.IncomingFollows + 1));
					});
				}
			}

			// If follower is remote, send an accept activity
			if (follower.IsRemoteUser)
			{
				if (requestId == null)
					throw new Exception("requestId must not be null at this stage");

				var activity = activityRenderer.RenderAccept(followee, follower, requestId);
				await deliverSvc.DeliverToAsync(activity, followee, follower);
			}
		}
	}

	public async Task EnqueueBackfillTaskAsync(User user)
	{
		var cfg = backfillConfig.Value.User;

		// return immediately if backfilling is not enabled
		if (!cfg.Enabled) return;

		// don't try to schedule a backfill for local users
		if (user.IsLocalUser) return;

		// we don't need to backfill anyone we have followers for since we'll get their posts delivered to us
		var needBackfill = await db.Users.AnyAsync(u => u.Id == user.Id
													&& !u.Followers.Any()
													&& u.Outbox != null
													&& (u.OutboxFetchedAt == null || u.OutboxFetchedAt <= DateTime.UtcNow - cfg.RefreshAfterTimeSpan));
		if (!needBackfill) return;

		var jobData = new BackfillUserJobData
		{
			UserId = user.Id
		};

		await queueSvc.BackfillUserQueue.EnqueueAsync(jobData, mutex: $"backfill-user:{user.Id}");
	}

	/// <remarks>
	///     Make sure to call .PrecomputeRelationshipData(user) on the database query for the followee
	/// </remarks>
	public async Task RemoveFromFollowersAsync(User user, User follower)
	{
		if ((follower.PrecomputedIsFollowing ?? false) && follower.IsRemoteUser)
		{
			var activity = activityRenderer.RenderReject(userRenderer.RenderLite(user),
			                                             activityRenderer.RenderFollow(follower, user, null));
			await deliverSvc.DeliverToAsync(activity, user, follower);
		}

		if (follower.PrecomputedIsFollowing ?? false)
		{
			var followers = await db.Followings
			                        .Where(p => p.Followee == user && p.Follower == follower)
			                        .ToListAsync();

			await db.Users
			        .Where(p => p.Id == user.Id)
			        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount,
			                                               i => i.FollowersCount - followers.Count));

			await db.Users
			        .Where(p => p.Id == follower.Id)
			        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount,
			                                               i => i.FollowingCount - followers.Count));

			db.RemoveRange(followers);
			await db.SaveChangesAsync();

			if (follower.IsRemoteUser)
			{
				_ = followupTaskSvc.ExecuteTaskAsync("DecrementInstanceIncomingFollowsCounter", async provider =>
				{
					var bgDb          = provider.GetRequiredService<DatabaseContext>();
					var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
					var dbInstance =
						await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(follower);
					await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
					          .ExecuteUpdateAsync(p => p.SetProperty(i => i.IncomingFollows,
					                                                 i => i.IncomingFollows - 1));
				});
			}

			follower.PrecomputedIsFollowedBy = false;
			eventSvc.RaiseUserUnfollowed(this, follower, user);
		}
	}

	/// <remarks>
	///     Make sure to call .PrecomputeRelationshipData(user) on the database query for the followee
	/// </remarks>
	public async Task UnfollowUserAsync(User user, User followee)
	{
		if (((followee.PrecomputedIsFollowedBy ?? false) || (followee.PrecomputedIsRequestedBy ?? false))
		    && followee.IsRemoteUser)
		{
			var relationshipId = await db.Followings.Where(p => p.Follower == user && p.Followee == followee)
			                             .Select(p => p.RelationshipId)
			                             .FirstOrDefaultAsync()
			                     ?? await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee)
			                                .Select(p => p.RelationshipId)
			                                .FirstOrDefaultAsync();

			var activity = activityRenderer.RenderUnfollow(user, followee, relationshipId);
			await deliverSvc.DeliverToAsync(activity, user, followee);
		}

		if (followee.PrecomputedIsFollowedBy ?? false)
		{
			var followings = await db.Followings.Where(p => p.Follower == user && p.Followee == followee).ToListAsync();

			await db.Users.Where(p => p.Id == user.Id)
			        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowingCount,
			                                               i => i.FollowingCount - followings.Count));
			await db.Users.Where(p => p.Id == followee.Id)
			        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount,
			                                               i => i.FollowersCount - followings.Count));

			db.RemoveRange(followings);
			await db.SaveChangesAsync();

			if (followee.IsRemoteUser)
			{
				_ = followupTaskSvc.ExecuteTaskAsync("DecrementInstanceOutgoingFollowsCounter", async provider =>
				{
					var bgDb          = provider.GetRequiredService<DatabaseContext>();
					var bgInstanceSvc = provider.GetRequiredService<InstanceService>();
					var dbInstance =
						await bgInstanceSvc.GetUpdatedInstanceMetadataAsync(followee);
					await bgDb.Instances.Where(p => p.Id == dbInstance.Id)
					          .ExecuteUpdateAsync(p => p.SetProperty(i => i.OutgoingFollows,
					                                                 i => i.OutgoingFollows - 1));
				});
			}

			followee.PrecomputedIsFollowedBy = false;
			eventSvc.RaiseUserUnfollowed(this, user, followee);
		}

		if (followee.PrecomputedIsRequestedBy ?? false)
		{
			await db.FollowRequests.Where(p => p.Follower == user && p.Followee == followee).ExecuteDeleteAsync();
			followee.PrecomputedIsRequestedBy = false;
		}

		// Clean up notifications
		await db.Notifications
		        .Where(p => (p.Type == Notification.NotificationType.FollowRequestAccepted
		                     && p.Notifiee == user
		                     && p.Notifier == followee)
		                    || (p.Type == Notification.NotificationType.Follow
		                        && p.Notifiee == followee
		                        && p.Notifier == user))
		        .ExecuteDeleteAsync();

		// Clean up user list memberships
		await db.UserListMembers.Where(p => p.UserList.User == user && p.User == followee).ExecuteDeleteAsync();
	}

	[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
	private void UpdateUserPinnedNotesInBackground(ASActor actor, User user, bool force = false)
	{
		if (followupTaskSvc.IsBackgroundWorker.Value && !force) return;
		if (KeyedLocker.IsInUse($"pinnedNotes:{user.Id}")) return;
		_ = followupTaskSvc.ExecuteTaskAsync("UpdateUserPinnedNotes", async provider =>
		{
			using (await KeyedLocker.LockAsync($"pinnedNotes:{user.Id}"))
			{
				var bgDb      = provider.GetRequiredService<DatabaseContext>();
				var bgNoteSvc = provider.GetRequiredService<NoteService>();
				var bgUser    = await bgDb.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
				if (bgUser == null) return;
				await bgNoteSvc.UpdatePinnedNotesAsync(actor, bgUser);
			}
		});
	}

	[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
	private void UpdateUserPinnedNotesInBackground(User user, bool force = false)
	{
		if (user.Uri == null) return;
		if (!user.IsRemoteUser) return;
		if (followupTaskSvc.IsBackgroundWorker.Value && !force) return;
		if (KeyedLocker.IsInUse($"pinnedNotes:{user.Id}")) return;
		_ = followupTaskSvc.ExecuteTaskAsync("UpdateUserPinnedNotes", async provider =>
		{
			using (await KeyedLocker.LockAsync($"pinnedNotes:{user.Id}"))
			{
				var bgDb      = provider.GetRequiredService<DatabaseContext>();
				var bgNoteSvc = provider.GetRequiredService<NoteService>();
				var bgUser    = await bgDb.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == user.Id);
				if (bgUser == null) return;
				var bgFetch = provider.GetRequiredService<ActivityPub.ActivityFetcherService>();
				var actor   = await bgFetch.FetchActorAsync(user.Uri);
				await bgNoteSvc.UpdatePinnedNotesAsync(actor, bgUser);
			}
		});
	}

	[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataQuery", Justification = "Projectables")]
	[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
	[SuppressMessage("ReSharper", "SuggestBaseTypeForParameter", Justification = "Method only makes sense for users")]
	private async Task<User> UpdateProfileMentionsAsync(
		User user, ASActor? actor, bool force = false, bool wait = false
	)
	{
		if (followupTaskSvc.IsBackgroundWorker.Value && !force) return user;
		if (KeyedLocker.IsInUse($"profileMentions:{user.Id}")) return user;

		var success = false;

		var task = followupTaskSvc.ExecuteTaskAsync("UpdateProfileMentionsInBackground", async provider =>
		{
			using (await KeyedLocker.LockAsync($"profileMentions:{user.Id}"))
			{
				var bgDbContext = provider.GetRequiredService<DatabaseContext>();
				var bgMentionsResolver = provider.GetRequiredService<UserProfileMentionsResolver>();
				var userId = user.Id;
				var bgUser = await bgDbContext.Users.IncludeCommonProperties().FirstOrDefaultAsync(p => p.Id == userId);
				if (bgUser?.UserProfile == null) return;

				if (actor != null)
				{
					var (mentions, splitDomainMapping) =
						await bgMentionsResolver.ResolveMentionsAsync(actor, bgUser.Host);
					var fields = actor.Attachments != null
						? await actor.Attachments
						             .OfType<ASField>()
						             .Where(p => p is { Name: not null, Value: not null })
						             .Select(async p => new UserProfile.Field
						             {
							             Name  = p.Name!,
							             Value = (await MfmConverter.FromHtmlAsync(p.Value, mentions)).Mfm
						             })
						             .AwaitAllAsync()
						: null;

					var description = actor.MkSummary != null
						? mentionsResolver.ResolveMentions(actor.MkSummary, bgUser.Host, mentions, splitDomainMapping)
						: (await MfmConverter.FromHtmlAsync(actor.Summary, mentions)).Mfm;

					bgUser.UserProfile.Mentions    = mentions;
					bgUser.UserProfile.Fields      = fields?.ToArray() ?? [];
					bgUser.UserProfile.Description = description;
				}
				else
				{
					bgUser.UserProfile.Mentions =
						await bgMentionsResolver.ResolveMentionsAsync(bgUser.UserProfile.Fields,
						                                              bgUser.UserProfile.Description, bgUser.Host);
				}

				bgUser.UserProfile.MentionsResolved = true;
				bgDbContext.Update(bgUser.UserProfile);
				await bgDbContext.SaveChangesAsync();
				success = true;
			}
		});

		if (wait)
			await task;
		else
			await task.SafeWaitAsync(TimeSpan.FromMilliseconds(500));

		if (success)
			await db.ReloadEntityRecursivelyAsync(user);

		return user;
	}

	[SuppressMessage("ReSharper", "EntityFramework.NPlusOne.IncompleteDataUsage", Justification = "Same as above")]
	public async Task<User> VerifyProfileFieldsAsync(User user, string? profileUrl)
	{
		profileUrl ??= user.GetPublicUrl(instance.Value);

		foreach (var userProfileField in user.UserProfile!.Fields)
		{
			var url = userProfileField.Value;
			if (url.StartsWith('[') && url.EndsWith(')'))
			{
				var idx = url.IndexOf("](", StringComparison.Ordinal);
				if (idx != -1 && url.Length >= idx + "https://".Length)
					url = url[(idx + 2)..^1];
			}

			if (
				!url.StartsWith("https://")
				|| !Uri.TryCreate(url, UriKind.Absolute, out var uri)
				|| uri is not { Scheme: "https" }
			)
			{
				userProfileField.IsVerified = false;
				continue;
			}

			try
			{
				const int maxLength = 1_000_000;
				var       res       = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);

				if (
					res is not
					{
						IsSuccessStatusCode: true,
						Content.Headers: { ContentType.MediaType: "text/html", ContentLength: null or <= maxLength }
					}
				)
				{
					logger.LogDebug("Skipping profile field link verification of {url} for user {userId}: precondition failed",
					                uri, user.Id);

					userProfileField.IsVerified = false;
					continue;
				}

				var contentLength = res.Content.Headers.ContentLength;
				var stream = await res.Content.ReadAsStreamAsync()
				                      .ContinueWithResult(p => p.GetSafeStreamOrNullAsync(maxLength, contentLength));

				if (stream == Stream.Null) throw new Exception("Response size limit exceeded");

				var document = await new HtmlParser().ParseDocumentAsync(stream);
				var headLinks = document.Head?.Children.Where(el => el.NodeName.ToLower() == "link").ToList() ?? [];

				userProfileField.IsVerified =
					headLinks.Concat(document.Links)
					         .Any(a => (a.GetAttribute("rel")?.Contains("me")
					                    ?? false)
					                   && a.GetAttribute("href") == profileUrl
					                   || a.GetAttribute("href") == user.GetUriOrPublicUri(instance.Value));
			}
			catch (Exception e)
			{
				logger.LogDebug("Failed to verify profile field url {url} for user {userId}: {e}", uri, user.Id, e);
				userProfileField.IsVerified = false;
			}
		}

		return user;
	}

	private List<string> ResolveHashtags(IMfmNode[]? parsedText, ASActor? actor = null)
	{
		List<string> tags = [];

		if (parsedText != null)
		{
			tags = parsedText
			       .SelectMany(p => p.Children.Append(p))
			       .OfType<MfmHashtagNode>()
			       .Select(p => p.Hashtag.ToLowerInvariant())
			       .Select(p => p.Trim('#'))
			       .Distinct()
			       .ToList();
		}

		var extracted = actor?.Tags?.OfType<ASHashtag>()
		                     .Select(p => p.Name?.ToLowerInvariant())
		                     .Where(p => p != null)
		                     .Cast<string>()
		                     .Select(p => p.Trim('#'))
		                     .Distinct()
		                     .ToList();

		if (extracted != null)
			tags.AddRange(extracted);

		if (tags.Count == 0) return [];

		tags = tags.Distinct().ToList();

		_ = followupTaskSvc.ExecuteTaskAsync("UpdateHashtagsTable", async provider =>
		{
			var bgDb     = provider.GetRequiredService<DatabaseContext>();
			var existing = await bgDb.Hashtags.Where(p => tags.Contains(p.Name)).Select(p => p.Name).ToListAsync();
			var dbTags = tags.Except(existing)
			                 .Select(p => new Hashtag { Id = IdHelpers.GenerateSnowflakeId(), Name = p });
			await bgDb.UpsertRange(dbTags).On(p => p.Name).NoUpdate().RunAsync();
		});

		return tags;
	}

	public async Task MuteUserAsync(User muter, User mutee, DateTime? expiration)
	{
		mutee.PrecomputedIsMutedBy = true;

		var muting = await db.Mutings.FirstOrDefaultAsync(p => p.Muter == muter && p.Mutee == mutee);

		if (muting != null)
		{
			if (muting.ExpiresAt == expiration) return;
			muting.ExpiresAt = expiration;
			await db.SaveChangesAsync();
			if (expiration == null) return;
			var job = new MuteExpiryJobData { MuteId = muting.Id };
			await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, expiration.Value);
			return;
		}

		muting = new Muting
		{
			Id        = IdHelpers.GenerateSnowflakeId(),
			CreatedAt = DateTime.UtcNow,
			Mutee     = mutee,
			Muter     = muter,
			ExpiresAt = expiration
		};
		await db.AddAsync(muting);
		await db.SaveChangesAsync();

		eventSvc.RaiseUserMuted(this, muter, mutee);

		if (expiration != null)
		{
			var job = new MuteExpiryJobData { MuteId = muting.Id };
			await queueSvc.BackgroundTaskQueue.ScheduleAsync(job, expiration.Value);
		}
	}

	public async Task UnmuteUserAsync(User muter, User mutee)
	{
		if (!mutee.PrecomputedIsMutedBy ?? false)
			return;

		await db.Mutings.Where(p => p.Muter == muter && p.Mutee == mutee).ExecuteDeleteAsync();
		eventSvc.RaiseUserUnmuted(this, muter, mutee);

		mutee.PrecomputedIsMutedBy = false;
	}

	public async Task BlockUserAsync(User blocker, User blockee)
	{
		if (blockee.PrecomputedIsBlockedBy ?? false) return;
		blockee.PrecomputedIsBlockedBy = true;

		var blocking = new Blocking
		{
			Id        = IdHelpers.GenerateSnowflakeId(),
			CreatedAt = DateTime.UtcNow,
			Blockee   = blockee,
			Blocker   = blocker
		};

		await db.FollowRequests.Where(p => p.Follower == blockee && p.Followee == blocker).ExecuteDeleteAsync();
		await db.FollowRequests.Where(p => p.Follower == blocker && p.Followee == blockee).ExecuteDeleteAsync();

		var cnt1 = await db.Followings.Where(p => p.Follower == blockee && p.Followee == blocker).ExecuteDeleteAsync();
		var cnt2 = await db.Followings.Where(p => p.Follower == blocker && p.Followee == blockee).ExecuteDeleteAsync();

		await db.Users.Where(p => p == blocker)
		        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount - cnt1)
		                                  .SetProperty(i => i.FollowingCount, i => i.FollowingCount - cnt2));
		await db.Users.Where(p => p == blockee)
		        .ExecuteUpdateAsync(p => p.SetProperty(i => i.FollowersCount, i => i.FollowersCount - cnt2)
		                                  .SetProperty(i => i.FollowingCount, i => i.FollowingCount - cnt1));

		// clean up notifications
		await db.Notifications
		        .Where(p => ((p.Notifiee == blocker && p.Notifier == blockee)
		                     || (p.Notifiee == blockee && p.Notifier == blocker))
		                    && (p.Type == Notification.NotificationType.Follow
		                        || p.Type == Notification.NotificationType.FollowRequestAccepted
		                        || p.Type == Notification.NotificationType.FollowRequestReceived))
		        .ExecuteDeleteAsync();

		await db.AddAsync(blocking);
		await db.SaveChangesAsync();

		eventSvc.RaiseUserBlocked(this, blocker, blockee);

		if (blocker.IsLocalUser && blockee.IsRemoteUser)
		{
			var actor    = userRenderer.RenderLite(blocker);
			var obj      = userRenderer.RenderLite(blockee);
			var activity = activityRenderer.RenderBlock(actor, obj, blocking.Id);
			await deliverSvc.DeliverToAsync(activity, blocker, blockee);
		}
	}

	public async Task UnblockUserAsync(User blocker, User blockee)
	{
		if (!blockee.PrecomputedIsBlockedBy ?? false)
			return;

		blockee.PrecomputedIsBlockedBy = false;

		var blocking = await db.Blockings.FirstOrDefaultAsync(p => p.Blocker == blocker && p.Blockee == blockee);
		if (blocking == null) return;

		db.Remove(blocking);
		await db.SaveChangesAsync();

		eventSvc.RaiseUserUnblocked(this, blocker, blockee);

		if (blocker.IsLocalUser && blockee.IsRemoteUser)
		{
			var actor    = userRenderer.RenderLite(blocker);
			var obj      = userRenderer.RenderLite(blockee);
			var block    = activityRenderer.RenderBlock(actor, obj, blocking.Id);
			var activity = activityRenderer.RenderUndo(actor, block);
			await deliverSvc.DeliverToAsync(activity, blocker, blockee);
		}
	}

	public async Task AddAliasAsync(User user, User alias)
	{
		if (user.IsRemoteUser) throw GracefulException.BadRequest("Cannot add alias for remote user");
		if (user.Id == alias.Id) throw GracefulException.BadRequest("You cannot add an alias to yourself");

		user.AlsoKnownAs ??= [];
		var uri = alias.Uri ?? alias.GetPublicUri(instance.Value);

		if (!user.AlsoKnownAs.Contains(uri)) user.AlsoKnownAs.Add(uri);
		await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId);
	}

	public async Task RemoveAliasAsync(User user, string aliasUri)
	{
		if (user.IsRemoteUser) throw GracefulException.BadRequest("Cannot manage aliases for remote user");
		if (user.AlsoKnownAs is null or []) return;
		if (!user.AlsoKnownAs.Contains(aliasUri)) return;

		user.AlsoKnownAs.RemoveAll(p => p == aliasUri);
		await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId);
	}

	public async Task MoveToUserAsync(User source, User target)
	{
		if (source.IsRemoteUser) throw GracefulException.BadRequest("Cannot initiate move for remote user");
		if (source.Id == target.Id) throw GracefulException.BadRequest("You cannot migrate to yourself");

		target = await UpdateUserAsync(target, force: true);
		if (target.AlsoKnownAs is null || !target.AlsoKnownAs.Contains(source.GetPublicUri(instance.Value)))
			throw GracefulException.BadRequest("Target user has not added you as an account alias");

		var sourceUri = source.Uri ?? source.GetPublicUri(instance.Value);
		var targetUri = target.Uri ?? target.GetPublicUri(instance.Value);
		if (source.MovedToUri is not null && source.MovedToUri != targetUri)
			throw GracefulException.BadRequest("You can only initiate repeated migrations to the same target account");

		source.MovedToUri = targetUri;
		await db.SaveChangesAsync();

		var move = activityRenderer.RenderMove(userRenderer.RenderLite(source), userRenderer.RenderLite(target));
		await deliverSvc.DeliverToFollowersAsync(move, source, []);
		await MoveRelationshipsAsync(source, target, sourceUri, targetUri);
	}

	public async Task UndoMoveAsync(User user)
	{
		if (user.MovedToUri is null) return;
		user.MovedToUri = null;
		await UpdateLocalUserAsync(user, user.AvatarId, user.BannerId);
	}

	private async Task<string?> UpdateUserHostAsync(User user)
	{
		if (user.IsLocalUser || user.Uri == null || user.SplitDomainResolved)
			return user.Host;

		var res   = await webFingerSvc.ResolveAsync(user.Uri);
		var match = res?.Links.FirstOrDefault(p => p is { Rel: "self", Type: "application/activity+json" })?.Href;
		if (res == null || match != user.Uri)
		{
			logger.LogWarning("Updating split domain host failed for user {id}: uri mismatch (pass 1) - '{uri}' <> '{match}'",
			                  user.Id, user.Uri, match);
			return user.Host;
		}

		var acct = ActivityPub.UserResolver.GetAcctUri(res);
		if (acct == null)
		{
			logger.LogWarning("Updating split domain host failed for user {id}: acct was null", user.Id);
			return user.Host;
		}

		var split = acct.Split('@');
		if (split.Length != 2)
		{
			logger.LogWarning("Updating split domain host failed for user {id}: invalid acct - '{acct}'",
			                  user.Id, acct);
			return user.Host;
		}

		if (user.Host == split[1])
		{
			user.SplitDomainResolved = true;
			return user.Host;
		}

		logger.LogDebug("Updating split domain for user {id}: {host} -> {newHost}", user.Id, user.Host, split[1]);

		res   = await webFingerSvc.ResolveAsync(acct);
		match = res?.Links.FirstOrDefault(p => p is { Rel: "self", Type: "application/activity+json" })?.Href;
		if (res == null || match != user.Uri)
		{
			logger.LogWarning("Updating split domain host failed for user {id}: uri mismatch (pass 2) - '{uri}' <> '{match}'",
			                  user.Id, user.Uri, match);
			return user.Host;
		}

		if (acct != ActivityPub.UserResolver.GetAcctUri(res))
		{
			logger.LogWarning("Updating split domain host failed for user {id}: subject mismatch - '{acct}' <> '{subject}'",
			                  user.Id, acct, res.Subject.TrimStart('@'));
			return user.Host;
		}

		user.SplitDomainResolved = true;
		return split[1];
	}

	public async Task MoveRelationshipsAsync(User source, User target, string sourceUri, string targetUri)
	{
		var followers = db.Followings
		                  .Where(p => p.Followee == source && p.Follower.IsLocalUser)
		                  .Select(p => p.Follower)
		                  .AsChunkedAsyncEnumerable(50, p => p.Id, hook: p => p.PrecomputeRelationshipData(source));

		await foreach (var follower in followers)
		{
			try
			{
				if (follower.Id == target.Id) continue;

				await FollowUserAsync(follower, target);

				// We need to transfer the precomputed properties to the source user for each follower so that the unfollow method works correctly
				source.PrecomputedIsFollowedBy  = follower.PrecomputedIsFollowing;
				source.PrecomputedIsRequestedBy = follower.PrecomputedIsRequested;

				await UnfollowUserAsync(follower, source);
			}
			catch (Exception e)
			{
				logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for follower {id}: {error}",
				                  sourceUri, targetUri, follower.Id, e);
			}
		}

		var blocks = db.Blockings
		               .Where(p => p.Blockee == source)
		               .Select(p => p.Blocker)
		               .AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));

		await foreach (var blocker in blocks)
		{
			try
			{
				if (blocker.Id == target.Id) continue;

				// We need to transfer the precomputed properties to the target user for each blocker so that the block method works correctly
				target.PrecomputedIsBlockedBy = blocker.PrecomputedIsBlocking;
				await BlockUserAsync(blocker, target);
			}
			catch (Exception e)
			{
				logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for blocker {id}: {error}",
				                  sourceUri, targetUri, blocker.Id, e);
			}
		}

		var mutes = db.Mutings
		               .Where(p => p.Mutee == source && p.ExpiresAt == null)
		               .Select(p => p.Muter)
		               .AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));

		await foreach (var muter in mutes)
		{
			try
			{
				if (muter.Id == target.Id) continue;

				// We need to transfer the precomputed properties to the target user for each muter so that the block method works correctly
				target.PrecomputedIsMutedBy = muter.PrecomputedIsMuting;
				await MuteUserAsync(muter, target, null);
			}
			catch (Exception e)
			{
				logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for muter {id}: {error}",
				                  sourceUri, targetUri, muter.Id, e);
			}
		}

		if (source.IsRemoteUser || target.IsRemoteUser) return;

		var following = db.Followings
		                  .Where(p => p.Follower == source)
		                  .Select(p => p.Follower)
		                  .AsChunkedAsyncEnumerable(50, p => p.Id, hook: p => p.PrecomputeRelationshipData(source));

		await foreach (var followee in following)
		{
			try
			{
				await FollowUserAsync(target, followee);
				await UnfollowUserAsync(source, followee);
			}
			catch (Exception e)
			{
				logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for followee {id}: {error}",
				                  sourceUri, targetUri, followee.Id, e);
			}
		}

		blocks = db.Blockings
		           .Where(p => p.Blocker == source)
		           .Select(p => p.Blockee)
		           .AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));

		await foreach (var blockee in blocks)
		{
			try
			{
				if (blockee.Id == target.Id) continue;
				await BlockUserAsync(blockee, target);
			}
			catch (Exception e)
			{
				logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for blockee {id}: {error}",
				                  sourceUri, targetUri, blockee.Id, e);
			}
		}
		
		mutes = db.Mutings
		           .Where(p => p.Muter == source && p.ExpiresAt == null)
		           .Select(p => p.Mutee)
		           .AsChunkedAsyncEnumerable(50, p => p.Id, p => p.PrecomputeRelationshipData(source));

		await foreach (var mutee in mutes)
		{
			try
			{
				if (mutee.Id == target.Id) continue;
				await MuteUserAsync(mutee, target, null);
			}
			catch (Exception e)
			{
				logger.LogWarning("Failed to process move ({sourceUri} -> {targetUri}) for mutee {id}: {error}",
				                  sourceUri, targetUri, mutee.Id, e);
			}
		}
	}

	public async Task SuspendUserAsync(User user)
	{
		if (user.IsSuspended) return;
		user.IsSuspended = true;
		await db.SaveChangesAsync();
	}

	public async Task UnsuspendUserAsync(User user)
	{
		if (!user.IsSuspended) return;
		user.IsSuspended = false;
		await db.SaveChangesAsync();
	}
}
