class InfiniteScroller {
	readonly getIDFromOffset: (ID: string, offset: number) => Promise<string | undefined>;
	readonly getHTMLFromID: (ID: string) => Promise<HTMLElement>;
	readonly destroyFromID: (ID: string) => Promise<boolean>;
	readonly reachesBottom: () => void;
	private readonly minDist = 2000;
	private readonly fillDist = 3000;
	private readonly maxDist = 6000;
	HTMLElements: [HTMLElement, string][] = [];
	div: HTMLDivElement | null = null;
	timeout: ReturnType<typeof setTimeout> | null = null;
	beenloaded = false;
	scrollBottom = 0;
	scrollTop = 0;
	needsupdate = true;
	averageheight = 60;
	watchtime = false;
	changePromise: Promise<boolean> | undefined;
	scollDiv!: {scrollTop: number; scrollHeight: number; clientHeight: number};

	resetVars() {
		this.scrollTop = 0;
		this.scrollBottom = 0;
		this.averageheight = 60;
		this.watchtime = false;
		this.needsupdate = true;
		this.beenloaded = false;
		this.changePromise = undefined;
		if (this.timeout) {
			clearTimeout(this.timeout);
			this.timeout = null;
		}
		for (const thing of this.HTMLElements) {
			this.destroyFromID(thing[1]);
		}
		this.HTMLElements = [];
	}
	constructor(
		getIDFromOffset: InfiniteScroller["getIDFromOffset"],
		getHTMLFromID: InfiniteScroller["getHTMLFromID"],
		destroyFromID: InfiniteScroller["destroyFromID"],
		reachesBottom: InfiniteScroller["reachesBottom"] = () => {},
	) {
		this.getIDFromOffset = getIDFromOffset;
		this.getHTMLFromID = getHTMLFromID;
		this.destroyFromID = destroyFromID;
		this.reachesBottom = reachesBottom;
	}

	async getDiv(initialId: string): Promise<HTMLDivElement> {
		if (this.div) {
			return this.div;
		}
		this.resetVars();
		const scroll = document.createElement("div");
		scroll.classList.add("scroller");
		this.div = scroll;

		this.div.addEventListener("scroll", () => {
			this.checkscroll();
			if (this.scrollBottom < 5) {
				this.scrollBottom = 5;
			}
			if (this.timeout === null) {
				this.timeout = setTimeout(this.updatestuff.bind(this), 300);
			}
			this.watchForChange();
		});

		let oldheight = 0;
		new ResizeObserver(() => {
			this.checkscroll();
			const func = this.snapBottom();
			this.updatestuff();
			const change = oldheight - scroll.offsetHeight;
			if (change > 0 && this.div) {
				this.div.scrollTop += change;
			}
			oldheight = scroll.offsetHeight;
			this.watchForChange();
			func();
		}).observe(scroll);

		new ResizeObserver(() => this.watchForChange()).observe(scroll);

		await this.firstElement(initialId);
		this.updatestuff();
		await this.watchForChange().then(() => {
			this.updatestuff();
			this.beenloaded = true;
		});

		return scroll;
	}

	checkscroll(): void {
		if (this.beenloaded && this.div && !document.body.contains(this.div)) {
			console.warn("not in document");
			this.div = null;
		}
	}

	async updatestuff(): Promise<void> {
		this.timeout = null;
		if (!this.div) return;

		this.scrollBottom = this.div.scrollHeight - this.div.scrollTop - this.div.clientHeight;
		this.averageheight = this.div.scrollHeight / this.HTMLElements.length;
		if (this.averageheight < 10) {
			this.averageheight = 60;
		}
		this.scrollTop = this.div.scrollTop;

		if (this.scrollBottom < 5 && !(await this.watchForChange())) {
			this.reachesBottom();
		}
		if (!this.scrollTop) {
			await this.watchForChange();
		}
		this.needsupdate = false;
	}

	async firstElement(id: string): Promise<void> {
		if (!this.div) return;
		const html = await this.getHTMLFromID(id);
		this.div.appendChild(html);
		this.HTMLElements.push([html, id]);
	}

	async addedBottom(): Promise<void> {
		await this.updatestuff();
		const func = this.snapBottom();
		if (this.changePromise) {
			while (this.changePromise) {
				await new Promise((res) => setTimeout(res, 30));
			}
		} else {
			await this.watchForChange();
		}
		func();
	}

	snapBottom(): () => void {
		const scrollBottom = this.scrollBottom;
		return () => {
			if (this.div && scrollBottom < 4) {
				this.div.scrollTop = this.div.scrollHeight;
			}
		};
	}
	whenFrag: (() => number)[] = [];
	private async watchForTop(already = false, fragment = new DocumentFragment()): Promise<boolean> {
		const supports = CSS.supports("overflow-anchor", "auto");
		if (!this.div) return false;
		const div = this.div;
		try {
			let again = false;
			if (this.scrollTop < (already ? this.fillDist : this.minDist)) {
				let nextid: string | undefined;
				const firstelm = this.HTMLElements.at(0);
				if (firstelm) {
					const previd = firstelm[1];
					nextid = await this.getIDFromOffset(previd, 1);
				}

				if (nextid) {
					const html = await this.getHTMLFromID(nextid);

					if (!html) {
						this.destroyFromID(nextid);
						return false;
					}
					if (!supports) {
						this.whenFrag.push(() => {
							const box = html.getBoundingClientRect();
							return box.height;
						});
					}
					again = true;
					fragment.prepend(html);
					this.HTMLElements.unshift([html, nextid]);
					this.scrollTop += this.averageheight;
				}
			}
			if (this.scrollTop > this.maxDist && this.remove) {
				const html = this.HTMLElements.shift();
				if (html) {
					let dec = 0;
					if (!supports) {
						const box = html[0].getBoundingClientRect();
						dec = box.height;
					}
					again = true;
					await this.destroyFromID(html[1]);

					div.scrollTop -= dec;
					this.scrollTop -= this.averageheight;
				}
			}
			if (again) {
				await this.watchForTop(true, fragment);
			}
			return again;
		} finally {
			if (!already) {
				if (this.div.scrollTop === 0) {
					this.scrollTop = 1;
					this.div.scrollTop = 10;
				}
				let height = 0;

				this.div.prepend(fragment);
				this.whenFrag.forEach((_) => (height += _()));
				this.div.scrollTop += height;

				this.whenFrag = [];
			}
		}
	}
	deleteId(id: string) {
		this.HTMLElements = this.HTMLElements.filter(([elm, elmid]) => {
			if (id === elmid) {
				elm.remove();
				return false;
			} else {
				return true;
			}
		});
	}

	async watchForBottom(already = false, fragment = new DocumentFragment()): Promise<boolean> {
		let func: Function | undefined;
		if (!already) func = this.snapBottom();
		if (!this.div) return false;
		try {
			let again = false;
			const scrollBottom = this.scrollBottom;
			if (scrollBottom < (already ? this.fillDist : this.minDist)) {
				let nextid: string | undefined;
				const lastelm = this.HTMLElements.at(-1);
				if (lastelm) {
					const previd = lastelm[1];
					nextid = await this.getIDFromOffset(previd, -1);
				}
				if (nextid) {
					again = true;
					const html = await this.getHTMLFromID(nextid);
					fragment.appendChild(html);
					this.HTMLElements.push([html, nextid]);
					this.scrollBottom += this.averageheight;
				}
			}
			if (scrollBottom > this.maxDist && this.remove) {
				const html = this.HTMLElements.pop();
				if (html) {
					await this.destroyFromID(html[1]);
					this.scrollBottom -= this.averageheight;
					again = true;
				}
			}
			if (again) {
				await this.watchForBottom(true, fragment);
			}
			return again;
		} finally {
			if (!already) {
				this.div.append(fragment);
				if (func) {
					func();
				}
			}
		}
	}

	async watchForChange(stop = false): Promise<boolean> {
		if (!this.remove) return false;
		if (stop == true) {
			let prom = this.changePromise;
			while (this.changePromise) {
				prom = this.changePromise;
				await this.changePromise;
				if (prom === this.changePromise) {
					this.changePromise = undefined;
					break;
				}
			}
		}
		if (this.changePromise) {
			this.watchtime = true;
			return await this.changePromise;
		} else {
			this.watchtime = false;
		}

		this.changePromise = new Promise<boolean>(async (res) => {
			try {
				if (!this.div) {
					res(false);
				}
				const out = (await Promise.allSettled([this.watchForTop(), this.watchForBottom()])) as {
					value: boolean;
				}[];
				const changed = out[0].value || out[1].value;
				if (this.timeout === null && changed) {
					this.timeout = setTimeout(this.updatestuff.bind(this), 300);
				}
				res(Boolean(changed));
			} catch (e) {
				console.error(e);
				res(false);
			} finally {
				if (stop === true) {
					this.changePromise = undefined;
					return;
				}
				setTimeout(() => {
					this.changePromise = undefined;
					if (this.watchtime) {
						this.watchForChange();
					}
				}, 300);
			}
		});

		return await this.changePromise;
	}
	remove = true;
	async focus(id: string, flash = true, sec = false): Promise<void> {
		let element: HTMLElement | undefined;
		for (const thing of this.HTMLElements) {
			if (thing[1] === id) {
				element = thing[0];
			}
		}
		if (sec && element && document.contains(element)) {
			if (flash) {
				element.scrollIntoView({
					behavior: "smooth",
					inline: "center",
					block: "center",
				});
				await new Promise((resolve) => {
					setTimeout(resolve, 1000);
				});
				element.classList.remove("jumped");
				await new Promise((resolve) => {
					setTimeout(resolve, 100);
				});
				element.classList.add("jumped");
			} else {
				element.scrollIntoView({
					block: "center",
				});
			}
		} else if (!sec) {
			this.resetVars();
			//TODO may be a redundant loop, not 100% sure :P
			for (const thing of this.HTMLElements) {
				await this.destroyFromID(thing[1]);
			}
			this.HTMLElements = [];
			await this.firstElement(id);
			this.changePromise = new Promise<boolean>(async (resolve) => {
				try {
					await this.updatestuff();
					this.remove = false;
					await Promise.all([this.watchForBottom(), this.watchForTop()]);

					await new Promise<void>((res) => queueMicrotask(res));
					await this.focus(id, !element && flash, true);
					this.remove = true;
					//TODO figure out why this fixes it and fix it for real :P
					await new Promise(requestAnimationFrame);
					await new Promise(requestAnimationFrame);
					this.changePromise = undefined;
				} finally {
					resolve(true);
				}
			});
			await this.changePromise;
		} else {
			console.warn("elm not exist");
		}
	}

	async delete(): Promise<void> {
		if (this.div) {
			this.div.remove();
			this.div = null;
		}
		this.resetVars();
		try {
			for (const thing of this.HTMLElements) {
				await this.destroyFromID(thing[1]);
			}
		} catch (e) {
			console.error(e);
		}
		this.HTMLElements = [];
		if (this.timeout) {
			clearTimeout(this.timeout);
		}
	}
}

export {InfiniteScroller};
