import { createContext, createEffect, createMemo, createSignal, onCleanup, useContext, type Accessor, type JSX } from 'solid-js';
import { getSharedCaches, clearSharedCaches } from '@atproto-ui/core';

type QueryKey = readonly unknown[];
type QueryKeyInput = QueryKey | unknown[];
type QueryFn<T> = () => Promise<T> | T;
type QueryUpdater<T> = T | ((old: T | undefined) => T | undefined);

interface QueryOptions<T> {
	queryKey: QueryKeyInput;
	queryFn: QueryFn<T>;
	enabled?: boolean;
	staleTime?: number;
	placeholderData?: unknown;
	refetchOnWindowFocus?: boolean;
}

interface QueryEntry<T = unknown> {
	key: QueryKey;
	keyHash: string;
	data?: T;
	error?: Error;
	updatedAt: number;
	promise?: Promise<T>;
	listeners: Set<() => void>;
	queryFn?: QueryFn<T>;
	staleTime: number;
	invalidated: boolean;
}

export interface QueryResult<T = unknown> {
	readonly data: T | undefined;
	readonly error: Error | undefined;
	readonly isLoading: boolean;
	readonly isFetching: boolean;
	readonly isPending: boolean;
	readonly isError: boolean;
	readonly isSuccess: boolean;
	readonly status: 'pending' | 'error' | 'success';
	refetch: () => Promise<T | undefined>;
}

const UNTANGLED_QUERY_DID = 'did:web:untangled.local';
const UNTANGLED_QUERY_COLLECTION = 'app.untangled.query';

const queryKeyHash = (key: QueryKeyInput): string => JSON.stringify(key);

const atprotoQueryCache = () => getSharedCaches().recordCache;

const queryKeyStartsWith = (candidate: QueryKey, prefix?: QueryKeyInput): boolean => {
	if (!prefix) return true;
	if (prefix.length > candidate.length) return false;
	return prefix.every((part, index) => Object.is(part, candidate[index]));
};

const toError = (error: unknown): Error => error instanceof Error ? error : new Error(String(error));

export class QueryClient {
	private entries = new Map<string, QueryEntry>();
	private defaultStaleTime: number;

	constructor(options?: { defaultOptions?: { queries?: { staleTime?: number; retry?: number | boolean } } }) {
		this.defaultStaleTime = options?.defaultOptions?.queries?.staleTime ?? 30_000;
	}

	getOrCreateEntry<T>(key: QueryKeyInput, staleTime?: number): QueryEntry<T> {
		const normalizedKey = [...key];
		const hash = queryKeyHash(normalizedKey);
		let entry = this.entries.get(hash) as QueryEntry<T> | undefined;
		if (!entry) {
			const cached = atprotoQueryCache().get<{
				data: T;
				updatedAt: number;
			}>(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, hash);
			entry = {
				key: normalizedKey,
				keyHash: hash,
				data: cached?.data,
				updatedAt: cached?.updatedAt ?? 0,
				listeners: new Set(),
				staleTime: staleTime ?? this.defaultStaleTime,
				invalidated: false,
			};
			this.entries.set(hash, entry);
		} else if (staleTime !== undefined) {
			entry.staleTime = staleTime;
		}
		return entry;
	}

	subscribe(entry: QueryEntry, listener: () => void): () => void {
		entry.listeners.add(listener);
		return () => entry.listeners.delete(listener);
	}

	private notify(entry: QueryEntry) {
		for (const listener of entry.listeners) listener();
	}

	private isFresh(entry: QueryEntry): boolean {
		return !entry.invalidated && entry.data !== undefined && Date.now() - entry.updatedAt < entry.staleTime;
	}

	async fetchQuery<T>(options: { queryKey: QueryKeyInput; queryFn: QueryFn<T>; staleTime?: number }): Promise<T> {
		const entry = this.getOrCreateEntry<T>(options.queryKey, options.staleTime);
		entry.queryFn = options.queryFn;
		if (this.isFresh(entry)) return entry.data as T;
		return this.fetchEntry(entry, options.queryFn);
	}

	async ensureQueryData<T>(options: { queryKey: QueryKeyInput; queryFn: QueryFn<T>; staleTime?: number }): Promise<T> {
		const entry = this.getOrCreateEntry<T>(options.queryKey, options.staleTime);
		entry.queryFn = options.queryFn;
		if (entry.data !== undefined && !entry.invalidated) return entry.data as T;
		return this.fetchEntry(entry, options.queryFn);
	}

	prefetchQuery<T>(options: { queryKey: QueryKeyInput; queryFn: QueryFn<T>; staleTime?: number }): Promise<void> {
		return this.fetchQuery(options).then(() => undefined, () => undefined);
	}

	async refetchQueries(filter: { queryKey?: QueryKeyInput } = {}): Promise<void> {
		await Promise.all(this.matchingEntries(filter.queryKey).map((entry) => {
			if (!entry.queryFn) return undefined;
			return this.fetchEntry(entry, entry.queryFn).then(() => undefined, () => undefined);
		}));
	}

	async invalidateQueries(filter: { queryKey?: QueryKeyInput; refetchType?: 'none' | string } = {}): Promise<void> {
		const entries = this.matchingEntries(filter.queryKey);
		for (const entry of entries) {
			entry.invalidated = true;
			this.notify(entry);
		}
		if (filter.refetchType === 'none') return;
		await this.refetchQueries(filter);
	}

	setQueryData<T>(queryKey: QueryKeyInput, updater: QueryUpdater<T>): T | undefined {
		const entry = this.getOrCreateEntry<T>(queryKey);
		const next = typeof updater === 'function'
			? (updater as (old: T | undefined) => T | undefined)(entry.data as T | undefined)
			: updater;
		entry.data = next;
		entry.error = undefined;
		entry.updatedAt = Date.now();
		entry.invalidated = false;
		if (next !== undefined) {
			atprotoQueryCache().set(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, entry.keyHash, {
				data: next,
				updatedAt: entry.updatedAt,
			});
		}
		this.notify(entry);
		return next;
	}

	setQueriesData<T>(filter: { queryKey?: QueryKeyInput }, updater: QueryUpdater<T>): Array<[QueryKey, T | undefined]> {
		return this.matchingEntries(filter.queryKey).map((entry) => [
			entry.key,
			this.setQueryData<T>(entry.key, updater),
		]);
	}

	getQueryData<T>(queryKey: QueryKeyInput): T | undefined {
		return this.entries.get(queryKeyHash(queryKey))?.data as T | undefined;
	}

	clear(): void {
		this.entries.clear();
		clearSharedCaches();
	}

	private matchingEntries(prefix?: QueryKeyInput): QueryEntry[] {
		return [...this.entries.values()].filter((entry) => queryKeyStartsWith(entry.key, prefix));
	}

	private fetchEntry<T>(entry: QueryEntry<T>, queryFn: QueryFn<T>): Promise<T> {
		if (entry.promise) return entry.promise;
		entry.queryFn = queryFn;
		const promise = Promise.resolve()
			.then(queryFn)
			.then((data) => {
				entry.data = data;
				entry.error = undefined;
				entry.updatedAt = Date.now();
				entry.invalidated = false;
				atprotoQueryCache().set(UNTANGLED_QUERY_DID, UNTANGLED_QUERY_COLLECTION, entry.keyHash, {
					data,
					updatedAt: entry.updatedAt,
				});
				return data;
			})
			.catch((error) => {
				entry.error = toError(error);
				throw entry.error;
			})
			.finally(() => {
				entry.promise = undefined;
				this.notify(entry);
			});
		entry.promise = promise;
		this.notify(entry);
		return promise;
	}
}

const AtprotoQueryContext = createContext<QueryClient>();
export const defaultQueryClient = new QueryClient();

export function QueryClientProvider(props: { client?: QueryClient; children: JSX.Element }) {
	return (
		<AtprotoQueryContext.Provider value={props.client ?? defaultQueryClient}>
			{props.children}
		</AtprotoQueryContext.Provider>
	);
}

export function useQueryClient(): QueryClient {
	return useContext(AtprotoQueryContext) ?? defaultQueryClient;
}

const accessOptions = <T,>(options: QueryOptions<T> | Accessor<QueryOptions<T>>): QueryOptions<T> =>
	typeof options === 'function' ? (options as Accessor<QueryOptions<T>>)() : options;

export function createQuery<T = unknown>(options: QueryOptions<T> | Accessor<QueryOptions<T>>): QueryResult<T> {
	const client = useQueryClient();
	const getOptions = createMemo(() => accessOptions(options));
	const [version, setVersion] = createSignal(0);
	const [fetching, setFetching] = createSignal(false);
	let activeEntry: QueryEntry<T> | undefined;
	let unsubscribe: (() => void) | undefined;
	let token = 0;

	const bump = () => setVersion((value) => value + 1);

	createEffect(() => {
		const opts = getOptions();
		const enabled = opts.enabled ?? true;
		const key = opts.queryKey;
		const entry = client.getOrCreateEntry<T>(key, opts.staleTime);
		entry.queryFn = opts.queryFn;
		activeEntry = entry;
		unsubscribe?.();
		unsubscribe = client.subscribe(entry, bump);
		bump();

		const runToken = ++token;
		if (!enabled) {
			setFetching(false);
			return;
		}
		if (entry.data !== undefined && !entry.invalidated && Date.now() - entry.updatedAt < entry.staleTime) {
			setFetching(false);
			return;
		}
		setFetching(true);
		client.fetchQuery({ queryKey: key, queryFn: opts.queryFn, staleTime: opts.staleTime })
			.catch(() => undefined)
			.finally(() => {
				if (runToken === token) setFetching(false);
			});
	});

	onCleanup(() => unsubscribe?.());

	const entry = () => {
		version();
		return activeEntry;
	};

	return {
		get data() { return entry()?.data as T | undefined; },
		get error() { return entry()?.error; },
		get isFetching() { return fetching() || !!entry()?.promise; },
		get isLoading() { return (fetching() || !!entry()?.promise) && entry()?.data === undefined; },
		get isPending() { return this.isLoading; },
		get isError() { return !!entry()?.error; },
		get isSuccess() { return entry()?.data !== undefined && !entry()?.error; },
		get status() { return this.isError ? 'error' : this.isSuccess ? 'success' : 'pending'; },
		refetch: async () => {
			const opts = getOptions();
			setFetching(true);
			try {
				return await client.fetchQuery({ ...opts, staleTime: -1 });
			} finally {
				setFetching(false);
			}
		},
	};
}

export const keepPreviousData = Symbol('keepPreviousData');

export { getSharedCaches };
