/*
 * Copyright (C) 2023 StApps
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, version 3.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program. If not, see <https://www.gnu.org/licenses/>.
 */

import {CdkVirtualForOf, CdkVirtualScrollViewport, VirtualScrollStrategy} from '@angular/cdk/scrolling';
import {BehaviorSubject, Subject, Subscription, takeUntil, timer} from 'rxjs';
import {debounceTime, distinctUntilChanged, tap} from 'rxjs/operators';
import {SCThingType} from '@openstapps/core';

export class ScThingListItemVirtualScrollStrategy implements VirtualScrollStrategy {
  private viewport?: CdkVirtualScrollViewport;

  private virtualForOf?: CdkVirtualForOf<unknown>;

  private index$ = new Subject<number>();

  private heights = new Map<unknown, number | undefined>();

  private groupHeights = new Map<unknown, number | undefined>();

  private offsets: number[] = [];

  private totalHeight = 0;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private _items?: readonly unknown[];

  private _groups?: readonly unknown[];

  /**
   * We use this to track loadMore
   */
  private currentLength = 0;

  private dataStreamSubscription?: Subscription;

  private mutationObserver?: MutationObserver;

  approximateItemHeight = 67;

  approximateGroupSizes: Map<unknown, number> = new Map([[SCThingType.AcademicEvent, 139]]);

  buffer = 4000;

  gap = 8;

  itemRenderTimeout = 1000;

  heightUpdateDebounceTime = 25;

  heightSetDebounceTime = 100;

  loadMore = new Subject<void>();

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  trackGroupBy: (value: any) => unknown = it => it.type;

  attach(viewport: CdkVirtualScrollViewport): void {
    this.viewport = viewport;
    // @ts-expect-error private property
    this.virtualForOf = viewport._forOf;
    this.dataStreamSubscription = this.virtualForOf?.dataStream.subscribe(items => {
      this.items = items;
    });

    this.mutationObserver = new MutationObserver(() => {
      const renderedItems = this.renderedItems;
      if (!renderedItems) {
        this.mutationObserver?.disconnect();
        return this.onPrematureDisconnect();
      }

      this.intersectionObserver?.disconnect();
      this.intersectionObserver = new IntersectionObserver(this.observeIntersection.bind(this), {
        rootMargin: `${this.buffer - 64}px`,
        threshold: 1,
      });

      for (const node of renderedItems) {
        const [item, group] = this.getItemByNode(node, renderedItems);

        if (this.heights.has(item)) {
          node.style.height = `${this.getHeight(item, group)}px`;
        } else {
          this.intersectionObserver.observe(node);
        }
      }
    });

    const contentWrapper = this.contentWrapper;
    if (!contentWrapper) return this.onPrematureDisconnect();
    this.mutationObserver.observe(contentWrapper, {childList: true, subtree: true});

    this.setTotalContentSize();
  }

  detach(): void {
    this.index$.complete();
    this.dataStreamSubscription?.unsubscribe();
    this.mutationObserver?.disconnect();
    delete this.viewport;
  }

  private getHeight(item: unknown, group: unknown) {
    return (
      this.heights.get(item) ||
      this.groupHeights.get(group) ||
      this.approximateGroupSizes.get(group) ||
      this.approximateItemHeight
    );
  }

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private set items(value: readonly unknown[]) {
    const trackBy = this.virtualForOf?.cdkVirtualForTrackBy;
    const tracks = value.map((it, i) => (trackBy ? trackBy(i, it) : it));
    if (
      this._items &&
      tracks.length === this._items.length &&
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      tracks.every((it, i) => it === this._items![i])
    )
      return;

    this._items = tracks;
    this._groups = value.map(this.trackGroupBy);
    this.currentLength = tracks.length;

    this.updateHeights();
  }

  scrolledIndexChange = this.index$.pipe(distinctUntilChanged());

  private getOffsetFromIndex(index: number): number {
    return this.offsets[index];
  }

  private getIndexFromOffset(offset: number): number {
    return Math.max(
      0,
      this.offsets.findIndex((it, i, array) => it >= offset || i === array.length - 1),
    );
  }

  private updateHeights() {
    if (!this._items) return;
    const heights = this._items.map((it, i) => this.getHeight(it, this._groups![i]) + this.gap);
    this.offsets = Array.from({length: heights.length}, () => 0);
    this.totalHeight = heights.reduce((a, b, index) => (this.offsets[index + 1] = a + b), 0) + this.gap;

    this.setTotalContentSize();
    this.updateRenderedRange();
  }

  private updateRenderedRange() {
    if (!this.viewport) return;
    const offset = this.viewport.measureScrollOffset('top');
    const viewportSize = this.viewport.getViewportSize();
    const firstVisibleIndex = Math.max(0, this.getIndexFromOffset(offset) - 1);
    const range = {
      start: this.getIndexFromOffset(Math.max(0, offset - this.buffer)),
      end: this.getIndexFromOffset(offset + viewportSize + this.buffer),
    };
    const {start, end} = this.viewport.getRenderedRange();
    if (range.start === start && range.end === end) return;

    if (this.currentLength !== 0 && range.end === this.currentLength) {
      this.currentLength++;
      this.loadMore.next();
    }

    this.viewport.setRenderedRange(range);
    this.viewport.setRenderedContentOffset(this.getOffsetFromIndex(range.start), 'to-start');

    this.index$.next(firstVisibleIndex);
  }

  private setTotalContentSize() {
    this.viewport?.setTotalContentSize(this.totalHeight);
    // @ts-expect-error TODO
    this.viewport?._measureViewportSize();
  }

  observeIntersection(entries: IntersectionObserverEntry[], observer: IntersectionObserver) {
    const renderedItems = this.renderedItems;
    if (!renderedItems) return this.onPrematureDisconnect();

    const update = new Subject<void>();
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;

      const outerNode = entry.target as HTMLElement;
      const [item] = this.getItemByNode(outerNode, renderedItems);
      const node = outerNode.firstChild! as HTMLElement;

      const height = new BehaviorSubject(node.offsetHeight);
      const resizeObserver = new ResizeObserver(() => {
        const renderedItems = this.renderedItems;
        if (!renderedItems) {
          resizeObserver.disconnect();
          return this.onPrematureDisconnect();
        }
        const [newItem] = this.getItemByNode(node, renderedItems);
        if (newItem !== item) {
          this.heights.delete(item);
          resizeObserver.disconnect();
          return;
        }
        height.next(node.offsetHeight);
      });
      resizeObserver.observe(node);
      height
        .pipe(
          distinctUntilChanged(),
          debounceTime(this.heightSetDebounceTime),
          takeUntil(timer(this.itemRenderTimeout)),
          tap({complete: () => resizeObserver.disconnect()}),
        )
        .subscribe(height => {
          this.heights.set(item, height);
          outerNode.style.height = `${height}px`;
          update.next();
        });

      observer.unobserve(node);
    }
    update
      .pipe(debounceTime(this.heightUpdateDebounceTime), takeUntil(timer(this.itemRenderTimeout)))
      .subscribe(() => {
        this.updateHeights();
      });
  }

  intersectionObserver?: IntersectionObserver;

  get contentWrapper() {
    return this.viewport?._contentWrapper.nativeElement;
  }

  get renderedItems() {
    const contentWrapper = this.contentWrapper;
    return contentWrapper
      ? // eslint-disable-next-line unicorn/prefer-spread
        (Array.from(contentWrapper.children).filter(it => it instanceof HTMLElement) as HTMLElement[])
      : undefined;
  }

  getItemByNode(node: HTMLElement, renderedItems: HTMLElement[]) {
    const {start} = this.viewport!.getRenderedRange();
    const index = renderedItems.indexOf(node) + start;
    return [this._items![index], this._groups![index]];
  }

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  onContentRendered(): void {}

  onContentScrolled() {
    this.updateRenderedRange();
  }

  onPrematureDisconnect() {
    console.warn('Virtual Scroll strategy was disconnected unexpectedly', new Error('foo').stack);
  }

  onDataLengthChanged(): void {
    this.setTotalContentSize();
  }

  // eslint-disable-next-line @typescript-eslint/no-empty-function
  onRenderedOffsetChanged(): void {}

  scrollToIndex(index: number, behavior: ScrollBehavior): void {
    this.viewport?.scrollToOffset(this.getOffsetFromIndex(index), behavior);
  }
}
