/*
 * Copyright (C) 2022 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/>.
 */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import {
  AfterViewInit,
  Component,
  ContentChild,
  ElementRef,
  EventEmitter,
  Input,
  OnChanges,
  OnDestroy,
  OnInit,
  Output,
  QueryList,
  SimpleChanges,
  TemplateRef,
  ViewChild,
  ViewChildren,
  ViewContainerRef,
} from '@angular/core';
import Swiper from 'swiper';
import {materialManualFade} from '../../../../animation/material-motion';
import {zip} from '@openstapps/collection-utils';

export interface SlideContext {
  $implicit: number;
}

/**
 * Wait for specified amount of time
 */
async function wait(ms?: number) {
  await new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * This is an infinite version of the swiper
 *
 * The basic principle it works on is
 * 1. The user can never swiper further than the amount of visible slides
 * 2. Only out of view slides are re-initialized
 */
@Component({
  selector: 'infinite-swiper',
  templateUrl: 'infinite-swiper.html',
  styleUrls: ['infinite-swiper.scss'],
  animations: [materialManualFade],
})
export class InfiniteSwiperComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges {
  @Input() controller?: InfiniteSwiperComponent;

  @Input() slidesPerView = 5;

  virtualIndex = 0;

  @ContentChild(TemplateRef) userSlideTemplateRef: TemplateRef<SlideContext>;

  @Output() indexChange = new EventEmitter<number>();

  @Output() indexChangeStart = new EventEmitter<number>();

  slidesArray: number[];

  @ViewChild('swiper', {static: true})
  swiperElement: ElementRef<HTMLDivElement>;

  @ViewChildren('slideContainers', {read: ViewContainerRef})
  slideContainers: QueryList<ViewContainerRef>;

  swiper: Swiper;

  visibilityState: 'in' | 'out' = 'in';

  private preventControllerCallback = false;

  ngOnInit() {
    this.createSwiper();
  }

  ngAfterViewInit() {
    this.initSwiper();
  }

  ngOnDestroy() {
    this.swiper.destroy();
    this.clearSlides();
  }

  async ngOnChanges(changes: SimpleChanges) {
    if ('slidesPerView' in changes) {
      const change = changes.slidesPerView;
      if (change.isFirstChange()) return;

      // little bit of a cheesy trick just to reinitialize
      // everything... But you know, it works just fine.
      // And how often are you realistically going to
      // resize your window.
      this.visibilityState = 'out';
      await wait(250);

      this.ngOnDestroy();
      this.createSwiper();
      await wait();
      this.initSwiper();

      this.visibilityState = 'in';
    }
  }

  createSwiper() {
    this.resetSlides();

    // I have absolutely no clue why two results are returned here.
    // Probably a bug, so be on the lookout if you get odd errors
    const [swiper] = new Swiper('.swiper', {
      // TODO: evaluate if the controller has decent performance, some time in the future
      // modules: [Controller],
      slidesPerView: this.slidesPerView,
      initialSlide: this.slidesPerView,
      init: false,
    }) as unknown as [Swiper, Swiper];
    this.swiper = swiper;
  }

  initSwiper() {
    this.swiper.init(this.swiperElement.nativeElement);
    // SwiperJS controller still has some performance issues unfortunately...
    // So unfortunately we are kind of forced to use a workaround :/
    // TODO: evaluate if the controller has decent performance, some time in the future
    /*setTimeout(() => {
      this.swiper.controller.control = this.controller?.swiper;
    });*/

    this.shiftSlides();

    this.swiper.on('activeIndexChange', () => {
      if (!this.preventControllerCallback) {
        this.controller?.controllerSlideTo(this.swiper.activeIndex);
      }
    });

    this.swiper.on('slideChangeTransitionEnd', () => {
      this.shiftSlides(this.swiper.activeIndex);
      this.indexChange.emit(this.virtualIndex);
      this.preventControllerCallback = false;
    });

    this.swiper.on('slideChangeTransitionStart', swiper => {
      this.indexChangeStart.emit(this.virtualIndex + swiper.activeIndex - swiper.previousIndex);
    });
  }

  clearSlides() {
    for (const container of this.slideContainers) {
      while (container.length > 0) {
        container.remove();
      }
    }
  }

  pageForward() {
    this.swiper.slideTo(this.slidesPerView * 2);
  }

  pageBackwards() {
    this.swiper.slideTo(0);
  }

  /**
   * This method is require to not cause a callback loop
   * when the controller slides
   */
  private async controllerSlideTo(index: number) {
    // TODO: prevent virtual index falling out of sync
    this.preventControllerCallback = true;
    this.swiper.slideTo(index);
    await wait(400);
    if (this.controller && this.virtualIndex !== this.controller.virtualIndex) {
      console.warn(
        `Virtual indices fell out of sync ${this.virtualIndex} : ${this.controller.virtualIndex}, correcting...`,
      );
      await this.controller.goToIndex(this.virtualIndex, false);
    }
  }

  async goToIndex(index: number, runCallbacks = true) {
    if (runCallbacks) {
      this.controller?.goToIndex(index, false);
    }

    this.visibilityState = 'out';

    await wait(250);

    this.virtualIndex = index;
    this.clearSlides();
    this.shiftSlides();

    this.visibilityState = 'in';
  }

  shiftSlides(activeIndex = this.slidesPerView) {
    const delta = this.slidesPerView - activeIndex;
    const deltaAmount = Math.abs(delta);
    const direction = delta > 0;
    this.virtualIndex -= delta;
    const containers = this.slideContainers.toArray();

    const slides = containers.map(it => (it.length > 0 ? it.detach(0) : undefined));

    // delete slides that are going to be dropped
    for (const slide of [...slides].splice(direction ? -deltaAmount : 0, deltaAmount)) {
      slide?.destroy();
    }

    // reuse existing slides
    const newElements: undefined[] = Array.from({length: deltaAmount});
    const shiftedSlides = direction
      ? [...newElements, ...slides.slice(0, -deltaAmount)]
      : [...slides.slice(deltaAmount), ...newElements];

    for (const [i, [container, element]] of zip(containers, shiftedSlides).entries()) {
      // TODO: we should be able to skip this... In theory.
      while (container!.length > 0) {
        console.warn('Slide container is not empty after detach!');
        container!.remove();
      }

      if (element) {
        container!.insert(element);
      } else {
        container!.createEmbeddedView(this.userSlideTemplateRef, {
          $implicit: this.virtualIndex + (i - this.slidesPerView),
        });
      }
    }

    this.swiper.slideTo(this.slidesPerView, 0, false);
  }

  resetSlides() {
    this.slidesArray = Array.from({length: this.slidesPerView * 3}).map((_, i) => i);
  }
}
