import type { FileUpload } from "@remix-run/form-data-parser";
import { parseFormData as parseMultipartFormData } from "@remix-run/form-data-parser";
import type { Namespace, TFunction } from "i18next";
import type { Ok, Result } from "neverthrow";
import type { Params, UIMatch } from "react-router";
import { data, redirect } from "react-router";
import type { z } from "zod";
import type { navItems } from "~/components/layout/nav-items";
import { ServerConfig } from "~/config.server";
import { logger } from "./logger";
import { currentRequestPathname } from "./request-context.server";

export function notFoundIfFalsy<T>(value: T | null | undefined): T {
	if (!value) throw new Response(null, { status: 404 });

	return value;
}

export function notFoundIfNullLike<T>(value: T | null | undefined): T {
	if (value === null || value === undefined)
		throw new Response(null, { status: 404 });

	return value;
}

export function unauthorizedIfFalsy<T>(value: T | null | undefined): T {
	if (!value) throw new Response(null, { status: 401 });

	return value;
}

/** Throws a HTTP 403 (Forbidden) response, ending execution of the loader/action early */
export function forbidden() {
	throw new Response(null, { status: 403 });
}

export function badRequestIfFalsy<T>(value: T | null | undefined): T {
	if (!value) {
		throw new Response(null, { status: 400 });
	}

	return value;
}

export function parseSearchParams<T extends z.ZodTypeAny>({
	request,
	schema,
}: {
	request: Request;
	schema: T;
}): z.infer<T> {
	const url = new URL(request.url);
	const searchParams = Object.fromEntries(url.searchParams);

	try {
		return schema.parse(searchParams);
	} catch (e) {
		logger.error("Error parsing search params", e);

		throw errorToastRedirect("Validation failed");
	}
}

/**
 * If the requested `page` exceeds `pagesCount`, throws a redirect to the last
 * available page (preserving other search params). `pagesCount` is normalized
 * to a minimum of 1 so empty result sets stay on page 1.
 */
export function redirectIfPageOutOfBounds({
	url,
	page,
	pagesCount,
}: {
	url: URL;
	page: number;
	pagesCount: number;
}): void {
	const safePagesCount = Math.max(1, pagesCount);
	if (page <= safePagesCount) return;

	const searchParams = new URLSearchParams(url.searchParams);
	searchParams.set("page", String(safePagesCount));
	throw redirect(`${url.pathname}?${searchParams.toString()}`);
}

export function parseSafeSearchParams<T extends z.ZodTypeAny>({
	request,
	schema,
}: {
	request: Request;
	schema: T;
}) {
	const url = new URL(request.url);
	return schema.safeParse(Object.fromEntries(url.searchParams));
}

/**
 * Parse formData of a request with the given schema. Throws HTTP 400 response if fails.
 *
 * When using SendouForm, use parseFormData from /app/form/parse.server.ts instead.
 * */
export async function parseRequestPayload<T extends z.ZodTypeAny>({
	request,
	schema,
}: {
	request: Request;
	schema: T;
}): Promise<z.infer<T>> {
	const formDataObj =
		request.headers.get("Content-Type") === "application/json"
			? await request.json()
			: formDataToObject(await request.formData());
	try {
		return await schema.parseAsync(formDataObj);
	} catch (e) {
		logger.error("Error parsing request payload", e);

		throw errorToastRedirect("Validation failed");
	}
}

/**
 * @deprecated - use parseFormData from /app/form/parse.server.ts (with SendouForm) or parseRequestPayload (without SendouForm)
 *
 * Parse formData with the given schema. Throws a request to show an error toast if it fails.
 */
export async function parseFormData<T extends z.ZodTypeAny>({
	formData,
	schema,
}: {
	formData: FormData;
	schema: T;
}): Promise<z.infer<T>> {
	const formDataObj = formDataToObject(formData);
	try {
		return await schema.parseAsync(formDataObj);
	} catch (e) {
		logger.error("Error parsing form data", e);

		throw errorToastRedirect("Validation failed");
	}
}

/** Parse params with the given schema. Throws HTTP 404 response if fails. */
export function parseParams<T extends z.ZodTypeAny>({
	params,
	schema,
}: {
	params: Params<string>;
	schema: T;
}): z.infer<T> {
	const parsed = schema.safeParse(params);
	if (!parsed.success) {
		throw new Response(null, { status: 404 });
	}

	return parsed.data;
}

/** Parse JSON body with the given schema. Throws HTTP 400 response if fails. */
export async function parseBody<T extends z.ZodTypeAny>({
	request,
	schema,
}: {
	request: Request;
	schema: T;
}): Promise<z.infer<T>> {
	const parsed = schema.safeParse(await request.json());
	if (!parsed.success) {
		throw new Response(null, { status: 400 });
	}

	return parsed.data;
}

export async function safeParseRequestFormData<T extends z.ZodTypeAny>({
	request,
	schema,
}: {
	request: Request;
	schema: T;
}): Promise<
	{ success: true; data: z.infer<T> } | { success: false; errors: string[] }
> {
	const parsed = schema.safeParse(formDataToObject(await request.formData()));

	// this implementation is somewhat redundant but it's the only way I got types to work nice
	if (!parsed.success) {
		return {
			success: false,
			errors: parsed.error.issues.map(
				(issue: { message: string }) => issue.message,
			),
		};
	}

	return {
		success: true,
		data: parsed.data,
	};
}

export function formDataToObject(formData: FormData) {
	const result: Record<string, string | string[]> = {};

	for (const [key, value] of formData.entries()) {
		const newValue = String(value);
		const existingValue = result[key];

		if (Array.isArray(existingValue)) {
			existingValue.push(newValue);
		} else if (typeof existingValue === "string") {
			result[key] = [existingValue, newValue];
		} else {
			result[key] = newValue;
		}
	}

	return result;
}

const LOHI_TOKEN_HEADER_NAME = "Lohi-Token";

/** Some endpoints can only be accessed with an auth token. Used by Lohi bot and cron jobs. */
export function canAccessLohiEndpoint(request: Request) {
	return request.headers.get(LOHI_TOKEN_HEADER_NAME) === ServerConfig.lohiToken;
}

function errorToastRedirect(message: string) {
	return redirect(`${currentRequestPathname() ?? ""}?__error=${message}`);
}

/** Asserts condition is truthy. Throws a redirect triggering an error toast with given message otherwise.  */
export function errorToastIfFalsy(
	condition: any,
	message: string,
): asserts condition {
	if (condition) return;

	throw errorToastRedirect(message);
}

/**
 * To be used in loader or action function. Asserts that the provided `Result` value is an `Ok` variant of the `neverthrow` library.
 *
 * If the value is an `Err`, shows an error toast to the user with the error message. The function will stop execution by throwing a redirect meaning it is safe to operate on the value after this function call.
 */
export function errorToastIfErr<T, E extends string>(
	value: Result<T, E>,
): asserts value is Ok<T, never> {
	if (value.isErr()) {
		throw errorToastRedirect(value.error);
	}
}

/** Throws a redirect triggering an error toast with given message.  */
export function errorToast(message: string) {
	throw errorToastRedirect(message);
}

export function successToast(message: string) {
	return redirect(`${currentRequestPathname() ?? ""}?__success=${message}`);
}

export function successToastWithRedirect({
	message,
	url,
}: {
	message: string;
	url: string;
}) {
	return redirect(`${url}?__success=${message}`);
}

export type ActionError = { field: string; msg: string; isError: true };

export function actionError<T extends z.ZodTypeAny>({
	msg,
	field,
}: {
	msg: string;
	field: (keyof z.infer<T> & string) | `${keyof z.infer<T> & string}.root`;
}): ActionError {
	return { msg, field, isError: true };
}

export type Breadcrumb =
	| {
			imgPath: string;
			type: "IMAGE";
			href: string;
			text?: string;
			/** Seed for the identicon shown if `imgPath` fails to load. */
			identiconInput?: string;
	  }
	| { text: string; type: "TEXT"; href: string };

/**
 * Our custom type for route handles - the keys are defined by us or
 * libraries that parse them.
 *
 * Can be set per route using `export const handle: SendouRouteHandle = { };`
 * Can be accessed for all currently active routes via the `useMatches()` hook.
 */
export type SendouRouteHandle = {
	/** The i18n translation files used for this route, via remix-i18next */
	i18n?: Namespace;

	/**
	 * A function that returns the breadcrumb text that should be displayed in
	 * the <Breadcrumb> component
	 */
	breadcrumb?: (args: {
		match: UIMatch;
		t: TFunction<"common", undefined>;
	}) => Breadcrumb | Array<Breadcrumb> | undefined;

	/** The name of a navItem that is active on this route. See nav-items.ts */
	navItemName?: (typeof navItems)[number]["name"];

	/**
	 * When `true`, the shared `<Main>` rendered by a parent layout (e.g. the
	 * tournament layout) fills the whole content area instead of the page
	 * max-width, while the page content stays centered at the normal width.
	 * Lets a descendant (e.g. the bracket) break out and grow wider than the
	 * page when it needs to.
	 */
	mainBreakout?: boolean;
};

/** Caches the loader response with "private" Cache-Control meaning that CDN won't cache the response.
 * To be used when the response is different for each user. This is especially useful when the response
 * is prefetched on link hover.
 */
export function privatelyCachedJson<T>(dataValue: T) {
	return data(dataValue, {
		headers: { "Cache-Control": "private, max-age=5" },
	});
}

const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024;

type FileUploadHandler = (
	fileUpload: FileUpload,
) => Promise<string | null | undefined>;
type ParseFormDataOptions = { maxFileSize?: number };

export function safeParseMultipartFormData(
	request: Request,
	uploadHandler?: FileUploadHandler,
): Promise<FormData>;
export function safeParseMultipartFormData(
	request: Request,
	options?: ParseFormDataOptions,
	uploadHandler?: FileUploadHandler,
): Promise<FormData>;
export async function safeParseMultipartFormData(
	request: Request,
	optionsOrHandler?: ParseFormDataOptions | FileUploadHandler,
	uploadHandler?: FileUploadHandler,
): Promise<FormData> {
	const maxFileSize =
		typeof optionsOrHandler === "object" && optionsOrHandler?.maxFileSize
			? optionsOrHandler.maxFileSize
			: DEFAULT_MAX_FILE_SIZE_BYTES;

	try {
		if (typeof optionsOrHandler === "function") {
			return await parseMultipartFormData(request, optionsOrHandler);
		}
		return await parseMultipartFormData(
			request,
			optionsOrHandler,
			uploadHandler,
		);
	} catch (err) {
		if (
			err instanceof Error &&
			(err.name === "MaxFileSizeExceededError" ||
				(err.cause instanceof Error &&
					err.cause.name === "MaxFileSizeExceededError"))
		) {
			throw errorToastRedirect(
				`File size exceeds maximum allowed size of ${maxFileSize / 1024 / 1024}MB`,
			);
		}
		throw err;
	}
}
