import type { JsonValue } from "type-fest";


export type QueryParams = Record<string, string | number | boolean | undefined | string[]>;

/**
 *  Create a query string ("?param=value") from an object {[key]: value}.
 *  Undefined valued are ignored, and an empty string is returned if all values are undefined.
 */
export function queryString(params: QueryParams): string {
	const searchParams = new URLSearchParams();
	for (const [key, value] of Object.entries(params)) {
		if (value !== undefined) {
			if (Array.isArray(value)) {
				for (const val of value) {
					searchParams.append(key, String(val));
				}
			} else {
				searchParams.set(key, String(value));
			}
		}
	}

	const searchParamsStr = searchParams.toString();
	return searchParamsStr ? `?${searchParamsStr}` : "";
}

interface HttpResponseBase<T> {
	/** set to true if the call was aborted by the User */
	aborted?:    boolean;
	/** set to true if the call resulted in an error */
	isError:     boolean;
	/** the parsed server response, whether the call ended up in an error or not */
	payload:     T;
	/** a clone of the raw Response object returned by fetch, in case it is needed for some edge cases */
	rawResponse: Response | undefined;
	/** the request status code */
	statusCode:  number;
	/** Parsed links in Link header */
	links?:      Record<string, string>;
}

interface HttpResponseError<T> extends HttpResponseBase<T> {
	aborted: boolean;
	error:   string;
	isError: true;
}
interface HttpResponseSuccess<T> extends HttpResponseBase<T> {
	isError: false;
	payload: T;
}

export type HttpResponse<SuccessType, ErrorType = unknown> =
	| HttpResponseSuccess<SuccessType>
	| HttpResponseError<ErrorType>;


export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
type ResponseType = "blob" | "json" | "text";

interface SendOptions<D> {
	/** the data sent to the server */
	data?:         D;
	/** the request headers */
	headers?:      Record<string, string>;
	/**
	 * determines how the server response will be parsed (as JSON, text, or a blob)
	 * @default "json"
	 */
	responseType?: ResponseType;
	/**
	 * The AbortSignal interface represents a signal object that allows you to communicate with a
	 * DOM request (such as a fetch request) and abort it if required via an AbortController object.
	 * read more at: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
	 */
	signal?:       AbortSignal;
	credentials?:  RequestCredentials;
}


async function getResponseContent(res: Response, type?: ResponseType): Promise<Blob | JsonValue | undefined> {
	try {
		if (res.headers.get("content-type")?.includes("json")) {
			return (await res.json()) as JsonValue;
		}
		if (type === "blob") {
			return await res.blob();
		}
		return await res.text();
	} catch (err) {
		return undefined;
	}
}

/**
 * Handle fetch calls, parse the server response, capture every possible error,
 * and returns standardized {@link HttpResponse} objects in every scenario
 */
export async function httpSend<T, D = Record<string, unknown> | File>(
	method: HttpMethod,
	path: string,
	sendOptions: SendOptions<D> = {}
): Promise<HttpResponse<T>> {
	try {
		const headers = {
			...sendOptions.headers,
			...(sendOptions.responseType === "json"
				? { Accept: "application/json" }
				: sendOptions.responseType === "text"
					? { Accept: "text/plain" }
					: {}),
		};
		const res = await fetch(path, {
			body:
				sendOptions.data instanceof File
					? sendOptions.data
					: sendOptions.data
						? JSON.stringify(sendOptions.data)
						: undefined,
			headers,
			method,
			...(sendOptions.signal ? { signal: sendOptions.signal } : {}),
			...(sendOptions.credentials ? { credentials: sendOptions.credentials } : {}),
		});

		const rawResponse = res.clone();

		if (!res.ok) {
			let error = `${res.status} ${res.statusText}`;

			const payload = await getResponseContent(res);
			// In case we get a detailed JSON error message from the backend - which we should in any of the following cases:
			// - When hitting /api/... endpoints
			// - When using header X-Requested-With: XMLHttpRequest
			// - When using header Content-Type: application/json
			if (typeof payload === "object" && payload) {
				if ("message" in payload && typeof payload.message === "string") {
					error = payload.message;
				} else if ("error" in payload && typeof payload.error === "string") {
					error = payload.error;
				}
			}

			return {
				aborted:    false,
				error,
				isError:    true,
				payload,
				rawResponse,
				statusCode: res.status,
			};
		}

		const payload = await getResponseContent(res, sendOptions.responseType);
		const links = res.headers.get("Link") ? parseLinkHeader(res.headers.get("Link")!) : undefined;
		return payload !== undefined
			? {
				isError:    false,
				payload:    payload as T,
				rawResponse,
				statusCode: res.status,
				links,
			}
			: {
				aborted:    false,
				error:      sendOptions.responseType === "json" ? "Error parsing JSON" : "Error parsing server response",
				isError:    true,
				payload,
				rawResponse,
				statusCode: res.status,
				links,
			};
	} catch (e) {
		return {
			aborted:     e instanceof DOMException && e.name === "AbortError",
			error:       (e instanceof TypeError || e instanceof DOMException) && e.message ? e.message : "Failed to fetch",
			isError:     true,
			payload:     undefined,
			rawResponse: undefined,
			statusCode:  0,
		};
	}
}

type GetOptions = Omit<SendOptions<unknown>, "data">;

/**
 * Helper function to easily and safely make GET calls
 */
export function httpGet<T>(path: string, opts: GetOptions = {}): Promise<HttpResponse<T>> {
	return httpSend<T>("GET", path, { ...opts });
}

export function parseLinkHeader(header: string): Record<string, string> {
	const regex = /<(https?:[/][/][^>]+)>;\s+rel="([^"]+)"/g;

	return Object.fromEntries([...header.matchAll(regex)].map(([_, url, rel]) => [rel, url]));
}


/// A not-that-great throttling function
export function throttle<T extends unknown[]>(callback: (...rest: T) => unknown, limit: number): (...rest: T) => void {
	let last: number;
	/// setTimeout can return different types on browser or node
	let deferTimer: ReturnType<typeof setTimeout>;

	return function (...rest) {
		const now = Date.now();
		if (last && now < last + limit) {
			clearTimeout(deferTimer);
			deferTimer = setTimeout(function () {
				last = now;
				callback(...rest);
			}, limit);
		} else {
			last = now;
			callback(...rest);
		}
	};
}