import type {
	ComAtprotoLabelDefs,
	ComAtprotoRepoApplyWrites,
	ComAtprotoRepoStrongRef,
} from '@atcute/atproto';
import type {
	AppBskyEmbedExternal,
	AppBskyEmbedGallery,
	AppBskyEmbedImages,
	AppBskyEmbedVideo,
	AppBskyFeedPost,
} from '@atcute/bluesky';
import { type Client, ok } from '@atcute/client';
import type { $type, Blob as AtpBlob, Did, GenericUri, ResourceUri } from '@atcute/lexicons';
import * as TID from '@atcute/tid';

import { mapDefined } from '@mary/array-fns';

import type { QueryClient } from '@tanstack/react-query';

import { getPostRecord } from '#/lib/api/record-casts';
import { uploadBlob } from '#/lib/api/records';
import { prepareRichtextForPublish } from '#/lib/api/richtext';
import { isNetworkError } from '#/lib/errors';
import { createGIFDescription } from '#/lib/gif-alt-text';
import { compressImage } from '#/lib/media/composer-image';
import type { Richtext } from '#/lib/rich-text';
import { task } from '#/lib/utils/task';
import { trimText } from '#/lib/utils/text';

import { fetchResolveGifQuery, fetchResolveLinkQuery } from '#/state/queries/resolve-link';
import {
	createThreadgateRecord,
	threadgateAllowUISettingToAllowRecordValue,
} from '#/state/queries/threadgate';

import type { EmbedDraft, PostDraft, ThreadDraft } from '#/features/composer/state/composer';

import { m } from '#/paraglide/messages';

/** The authenticated clients and repo DID a publish runs against. */
export interface PostClients {
	appview: Client;
	did: Did;
	pds: Client;
}

interface PostOpts {
	thread: ThreadDraft;
	replyTo?: ResourceUri;
	langs?: string[];
}

type Write = ComAtprotoRepoApplyWrites.$input['writes'][number];

/** Assembles a post record once its rich text, embed, and reply ref have all resolved. */
const buildRecord = task(
	(
		rt: Richtext,
		embed: AppBskyFeedPost.Main['embed'],
		reply: AppBskyFeedPost.Main['reply'],
		meta: {
			createdAt: string;
			labels: $type.enforce<ComAtprotoLabelDefs.SelfLabels> | undefined;
			langs: string[] | undefined;
		},
	): AppBskyFeedPost.Main => {
		return {
			$type: 'app.bsky.feed.post',
			createdAt: meta.createdAt,
			text: rt.text,
			facets: rt.facets,
			reply,
			embed,
			langs: meta.langs,
			labels: meta.labels,
		};
	},
);

/** Derives the reply ref for the post that follows `record` in a thread. */
const chainReply = task(
	async (
		reply: AppBskyFeedPost.Main['reply'],
		record: AppBskyFeedPost.Main,
		uri: ResourceUri,
	): Promise<AppBskyFeedPost.Main['reply']> => {
		const { computeRecordCid } = await import('#/lib/api/record-cid');
		const ref: ComAtprotoRepoStrongRef.Main = {
			cid: await computeRecordCid(record),
			uri: uri,
		};

		return {
			root: reply?.root ?? ref,
			parent: ref,
		};
	},
);

export async function publishThread(
	{ appview, did, pds }: PostClients,
	queryClient: QueryClient,
	opts: PostOpts,
) {
	const thread = opts.thread;

	// resolve the reply while the post record is built.
	let replyPromise: Promise<AppBskyFeedPost.Main['reply']> = opts.replyTo
		? resolveReply(appview, opts.replyTo)
		: Promise.resolve(undefined);

	let langs = opts.langs;
	if (opts.langs) {
		langs = opts.langs.slice(0, 3);
	}

	const writePromises: Promise<Write>[] = [];
	const uris: ResourceUri[] = [];

	const now = new Date();

	for (let i = 0; i < thread.posts.length; i++) {
		const draft = thread.posts[i]!;

		let labels: $type.enforce<ComAtprotoLabelDefs.SelfLabels> | undefined;
		if (draft.labels.length) {
			labels = {
				$type: 'com.atproto.label.defs#selfLabels',
				values: draft.labels.map((val) => ({ val })),
			};
		}

		const createdAt = now.toISOString();

		// give posts with the same timestamp a stable order.
		now.setMilliseconds(now.getMilliseconds() + 1);

		const rkey = TID.now();
		const uri: ResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
		uris.push(uri);

		const recordPromise = buildRecord(
			prepareRichtextForPublish(appview, trimText(draft.text)),
			resolveEmbed(appview, pds, queryClient, draft),
			replyPromise,
			{ createdAt, labels, langs },
		);
		writePromises.push(
			recordPromise.then((record) => ({
				$type: 'com.atproto.repo.applyWrites#create',
				collection: 'app.bsky.feed.post',
				rkey: rkey,
				value: record,
			})),
		);

		if (i === 0 && thread.threadgate.some((tg) => tg.type !== 'everybody')) {
			writePromises.push(
				Promise.resolve({
					$type: 'com.atproto.repo.applyWrites#create',
					collection: 'app.bsky.feed.threadgate',
					rkey: rkey,
					value: createThreadgateRecord({
						allow: threadgateAllowUISettingToAllowRecordValue(thread.threadgate),
						post: uri,
					}),
				}),
			);
		}

		if (thread.postgate.embeddingRules?.length || thread.postgate.detachedEmbeddingUris?.length) {
			writePromises.push(
				Promise.resolve({
					$type: 'com.atproto.repo.applyWrites#create',
					collection: 'app.bsky.feed.postgate',
					rkey: rkey,
					value: {
						...thread.postgate,
						$type: 'app.bsky.feed.postgate',
						createdAt: createdAt,
						post: uri,
					},
				}),
			);
		}

		// ref the current post so the next one can reply to it; skip the final post's unused ref.
		if (i < thread.posts.length - 1) {
			replyPromise = chainReply(replyPromise, recordPromise, uri);
		}
	}

	const writes = await Promise.all(writePromises);

	try {
		await ok(
			pds.post('com.atproto.repo.applyWrites', {
				input: {
					repo: did,
					validate: true,
					writes: writes,
				},
			}),
		);
	} catch (e) {
		if (isNetworkError(e)) {
			throw new Error(m['lib.upload.postFailed'](), { cause: e });
		} else {
			throw e;
		}
	}

	return { uris };
}

export class ReplyDeletedError extends Error {
	constructor() {
		super('Could not resolve reply');
	}
}

async function resolveReply(appview: Client, replyTo: ResourceUri): Promise<AppBskyFeedPost.Main['reply']> {
	const data = await ok(
		appview.get('app.bsky.feed.getPosts', {
			params: { uris: [replyTo] },
		}),
	);
	const parentPost = data.posts[0];
	if (!parentPost) {
		throw new ReplyDeletedError();
	}

	const parentRef: ComAtprotoRepoStrongRef.Main = {
		cid: parentPost.cid,
		uri: parentPost.uri,
	};
	let rootRef = parentRef;

	const parentRecord = getPostRecord(parentPost);
	if (parentRecord.reply) {
		rootRef = parentRecord.reply.root;
	}

	return {
		parent: parentRef,
		root: rootRef,
	};
}

async function resolveEmbed(
	appview: Client,
	pds: Client,
	queryClient: QueryClient,
	draft: PostDraft,
): Promise<AppBskyFeedPost.Main['embed']> {
	if (draft.embed.quote) {
		const [resolvedMedia, resolvedQuote] = await Promise.all([
			resolveMedia(appview, pds, queryClient, draft.embed),
			resolveRecord(appview, queryClient, draft.embed.quote.uri),
		]);
		if (resolvedMedia) {
			return {
				$type: 'app.bsky.embed.recordWithMedia',
				media: resolvedMedia,
				record: {
					$type: 'app.bsky.embed.record',
					record: resolvedQuote,
				},
			};
		}
		return {
			$type: 'app.bsky.embed.record',
			record: resolvedQuote,
		};
	}
	const resolvedMedia = await resolveMedia(appview, pds, queryClient, draft.embed);
	if (resolvedMedia) {
		return resolvedMedia;
	}
	if (draft.embed.link) {
		const resolvedLink = await fetchResolveLinkQuery(queryClient, appview, draft.embed.link.uri);
		if (resolvedLink.type === 'record') {
			return {
				$type: 'app.bsky.embed.record',
				record: resolvedLink.record,
			};
		}
	}
	return undefined;
}

async function resolveMedia(
	appview: Client,
	pds: Client,
	queryClient: QueryClient,
	embedDraft: EmbedDraft,
): Promise<
	| $type.enforce<AppBskyEmbedExternal.Main>
	| $type.enforce<AppBskyEmbedGallery.Main>
	| $type.enforce<AppBskyEmbedImages.Main>
	| $type.enforce<AppBskyEmbedVideo.Main>
	| undefined
> {
	if (embedDraft.media?.type === 'images') {
		const imagesDraft = embedDraft.media.images;
		const images: AppBskyEmbedImages.Image[] = await Promise.all(
			imagesDraft.map(async (image) => {
				const { blob, width, height } = await compressImage(image);
				return {
					alt: trimText(image.alt),
					aspectRatio: { height, width },
					image: await uploadBlob(pds, blob),
				};
			}),
		);
		return {
			$type: 'app.bsky.embed.images',
			images,
		};
	}
	if (embedDraft.media?.type === 'gallery') {
		const imagesDraft = embedDraft.media.images;
		const items: $type.enforce<AppBskyEmbedGallery.Image>[] = await Promise.all(
			imagesDraft.map(async (image) => {
				const { blob, width, height } = await compressImage(image);
				return {
					$type: 'app.bsky.embed.gallery#image',
					alt: trimText(image.alt),
					aspectRatio: { height, width },
					image: await uploadBlob(pds, blob),
				};
			}),
		);
		return {
			$type: 'app.bsky.embed.gallery',
			items,
		};
	}
	if (embedDraft.media?.type === 'video' && embedDraft.media.video.status === 'done') {
		const videoDraft = embedDraft.media.video;
		const captions = await Promise.all(
			mapDefined(videoDraft.captions, (caption) => {
				if (caption.lang === '') {
					return;
				}

				return uploadBlob(pds, caption.file, 'text/vtt').then((file) => ({ file, lang: caption.lang }));
			}),
		);

		// lexicon numbers must be floats
		const width = Math.round(videoDraft.asset.width);
		const height = Math.round(videoDraft.asset.height);

		// aspect ratio values must be >0 - better to leave as unset otherwise
		// posting will fail if aspect ratio is set to 0
		const aspectRatio = width > 0 && height > 0 ? { height, width } : undefined;

		return {
			$type: 'app.bsky.embed.video',
			alt: trimText(videoDraft.altText) || undefined,
			aspectRatio,
			captions: captions.length === 0 ? undefined : captions,
			presentation: videoDraft.asset.mimeType === 'image/gif' ? 'gif' : 'default',
			video: videoDraft.pendingPublish.blobRef,
		};
	}
	if (embedDraft.media?.type === 'gif') {
		const gifDraft = embedDraft.media;
		const resolvedGif = await fetchResolveGifQuery(queryClient, gifDraft.gif);
		let blob: AtpBlob | undefined;
		if (resolvedGif.thumb) {
			blob = await uploadBlob(pds, resolvedGif.thumb.source.blob);
		}
		return {
			$type: 'app.bsky.embed.external',
			external: {
				description: createGIFDescription(resolvedGif.title, gifDraft.alt),
				thumb: blob,
				title: resolvedGif.title,
				// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- `resolveGif` builds this from the provider's absolute url
				uri: resolvedGif.uri as GenericUri,
			},
		};
	}
	if (embedDraft.link) {
		const resolvedLink = await fetchResolveLinkQuery(queryClient, appview, embedDraft.link.uri);
		if (resolvedLink.type === 'external') {
			let blob: AtpBlob | undefined;
			if (resolvedLink.thumb) {
				blob = await uploadBlob(pds, resolvedLink.thumb.source.blob);
			}
			return {
				$type: 'app.bsky.embed.external',
				external: {
					associatedRefs: resolvedLink.associatedRefs,
					description: resolvedLink.description,
					thumb: blob,
					title: resolvedLink.title,
					// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- link cards are only offered for `http(s)://` autolinks
					uri: resolvedLink.uri as GenericUri,
				},
			};
		}
	}
	return undefined;
}

async function resolveRecord(
	appview: Client,
	queryClient: QueryClient,
	uri: string,
): Promise<ComAtprotoRepoStrongRef.Main> {
	const resolvedLink = await fetchResolveLinkQuery(queryClient, appview, uri);
	if (resolvedLink.type !== 'record') {
		throw Error(m['lib.error.uriNotRecord']());
	}
	return resolvedLink.record;
}
