import {
  AfterViewInit,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  computed,
  DestroyRef,
  ElementRef,
  EventEmitter,
  HostListener,
  inject,
  OnDestroy,
  OnInit,
  signal,
  Signal,
  viewChild
} from '@angular/core';
import {AsyncPipe, NgClass, NgStyle, PercentPipe} from '@angular/common';
import {ActivatedRoute, Router} from '@angular/router';
import {
  BehaviorSubject,
  debounceTime,
  distinctUntilChanged,
  filter,
  forkJoin,
  fromEvent,
  map,
  merge,
  Observable,
  ReplaySubject,
  Subject,
  tap
} from 'rxjs';
import {ChangeContext, LabelType, NgxSliderModule, Options} from '@angular-slider/ngx-slider';
import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms';
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {Stack} from 'src/app/shared/data-structures/stack';
import {UtilityService} from 'src/app/shared/_services/utility.service';
import {LibraryType} from 'src/app/_models/library/library';
import {MangaFormat} from 'src/app/_models/manga-format';
import {PageSplitOption} from 'src/app/_models/preferences/page-split-option';
import {ReaderMode} from 'src/app/_models/preferences/reader-mode';
import {ReadingDirection} from 'src/app/_models/preferences/reading-direction';
import {ScalingOption} from 'src/app/_models/preferences/scaling-option';
import {AccountService} from 'src/app/_services/account.service';
import {MemberService} from 'src/app/_services/member.service';
import {NavService} from 'src/app/_services/nav.service';
import {ReaderService} from 'src/app/_services/reader.service';
import {LayoutMode} from '../../_models/layout-mode';
import {FITTING_OPTION, PAGING_DIRECTION} from '../../_models/reader-enums';
import {ReaderSetting} from '../../_models/reader-setting';
import {MangaReaderService} from '../../_service/manga-reader.service';
import {CanvasRendererComponent} from '../canvas-renderer/canvas-renderer.component';
import {DoubleRendererComponent} from '../double-renderer/double-renderer.component';
import {DoubleReverseRendererComponent} from '../double-reverse-renderer/double-reverse-renderer.component';
import {SingleRendererComponent} from '../single-renderer/single-renderer.component';
import {ChapterInfo} from '../../_models/chapter-info';
import {DoubleNoCoverRendererComponent} from '../double-renderer-no-cover/double-no-cover-renderer.component';
import {SwipeEvent} from 'src/app/ng-swipe/ag-swipe.core';
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {FullscreenIconPipe} from '../../../_pipes/fullscreen-icon.pipe';
import {ReaderModeIconPipe} from '../../../_pipes/reader-mode-icon.pipe';
import {FittingIconPipe} from '../../../_pipes/fitting-icon.pipe';
import {InfiniteScrollerComponent} from '../infinite-scroller/infinite-scroller.component';
import {SwipeDirective} from '../../../ng-swipe/ng-swipe.directive';
import {LoadingComponent} from '../../../shared/loading/loading.component';
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {shareReplay} from "rxjs/operators";
import {DblClickDirective} from "../../../_directives/dbl-click.directive";
import {
  layoutModes,
  pageSplitOptions,
  ReadingProfile,
  ReadingProfileKind
} from "../../../_models/preferences/reading-profiles";
import {ReadingProfileService} from "../../../_services/reading-profile.service";
import {ConfirmService} from "../../../shared/confirm.service";
import {PageBookmark} from "../../../_models/readers/page-bookmark";
import {KeyBindEvent, KeyBindService} from "../../../_services/key-bind.service";
import {KeyBindTarget} from "../../../_models/preferences/preferences";
import {EntityTitleService} from "../../../_services/entity-title.service";


const PREFETCH_PAGES = 10;

const CHAPTER_ID_NOT_FETCHED = -2;
const CHAPTER_ID_DOESNT_EXIST = -1;

const OVERLAY_AUTO_CLOSE_TIME = 3000;
const CLICK_OVERLAY_TIMEOUT = 3000;

enum ChapterInfoPosition {
  Previous = 0,
  Current = 1,
  Next = 2
}

enum KeyDirection {
  Right = 0,
  Left = 1,
  Up = 2,
  Down = 3
}

const KEYBIND_TARGETS = [
  {keyBindTarget: KeyBindTarget.PageLeft, description: 'prev-page'},
  {keyBindTarget: KeyBindTarget.PageRight, description: 'next-page'},
  {keyBindTarget: KeyBindTarget.GoTo, description: 'go-to'},
  {keyBindTarget: KeyBindTarget.ToggleFullScreen},
  {keyBindTarget: KeyBindTarget.ToggleMenu},
  {keyBindTarget: KeyBindTarget.OpenHelp},
  {keyBindTarget: KeyBindTarget.Escape},
  {keyBindTarget: KeyBindTarget.BookmarkPage, description: 'bookmark'},
  {keyBindTarget: KeyBindTarget.OffsetDoublePage, description: 'offset-double-page'},
  {keyBindTarget: KeyBindTarget.PreviousChapter, description: 'previous-chapter'},
  {keyBindTarget: KeyBindTarget.NextChapter, description: 'next-chapter'},
  {keyBindTarget: KeyBindTarget.FirstPage, description: 'first-page'},
  {keyBindTarget: KeyBindTarget.LastPage, description: 'last-page'},
  {keyBindTarget: KeyBindTarget.NavigateToSettings, description: 'navigate-to-settings'},
  {key: translate('shortcuts-modal.double-click'), description: 'bookmark'},
];

@Component({
    selector: 'app-manga-reader',
    templateUrl: './manga-reader.component.html',
    styleUrls: ['./manga-reader.component.scss'],
    changeDetection: ChangeDetectionStrategy.OnPush,
    providers: [MangaReaderService],
  imports: [NgStyle, LoadingComponent, SwipeDirective, CanvasRendererComponent, SingleRendererComponent,
    DoubleRendererComponent, DoubleReverseRendererComponent, DoubleNoCoverRendererComponent, InfiniteScrollerComponent,
    NgxSliderModule, ReactiveFormsModule, FittingIconPipe, ReaderModeIconPipe,
    FullscreenIconPipe, TranslocoDirective, PercentPipe, NgClass, AsyncPipe, DblClickDirective, NgbTooltip]
})
export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {


  readonly reader = viewChild.required<ElementRef>('reader');
  readonly readingArea = viewChild.required<ElementRef>('readingArea');
  readonly canvas = viewChild<ElementRef>('content');

  readonly canvasRenderer = viewChild(CanvasRendererComponent);
  readonly singleRenderer = viewChild(SingleRendererComponent);
  readonly doubleRenderer = viewChild(DoubleRendererComponent);
  readonly doubleReverseRenderer = viewChild(DoubleReverseRendererComponent);
  readonly doubleNoCoverRenderer = viewChild(DoubleNoCoverRendererComponent);

  readonly imageElement = computed(() =>
    this.singleRenderer()?.imageElement()
  ?? this.doubleRenderer()?.imageElement()
  ?? this.doubleReverseRenderer()?.imageElement()
  ?? this.doubleNoCoverRenderer()?.imageElement()
  ?? this.canvasRenderer()?.canvas());

  private readonly destroyRef = inject(DestroyRef);
  private readonly route = inject(ActivatedRoute);
  private readonly router = inject(Router);
  private readonly accountService = inject(AccountService);
  private readonly formBuilder = inject(FormBuilder);
  private readonly navService = inject(NavService);
  private readonly memberService = inject(MemberService);
  private readonly cdRef = inject(ChangeDetectorRef);
  private readonly toastr = inject(ToastrService);
  private readonly readingProfileService = inject(ReadingProfileService);
  private readonly confirmService = inject(ConfirmService);
  protected readonly readerService = inject(ReaderService);
  protected readonly utilityService = inject(UtilityService);
  protected readonly mangaReaderService = inject(MangaReaderService);
  private readonly keyBindService = inject(KeyBindService);
  private readonly entityTitleService = inject(EntityTitleService);

  protected readonly KeyDirection = KeyDirection;
  protected readonly ReaderMode = ReaderMode;
  protected readonly LayoutMode = LayoutMode;
  protected readonly ReadingDirection = ReadingDirection;
  protected readonly Math = Math;

  libraryId!: number;
  seriesId!: number;
  volumeId!: number;
  chapterId!: number;
  chapterInfo = signal<ChapterInfo | null>(null);
  /**
   * Reading List id. Defaults to -1.
   */
  readingListId: number = CHAPTER_ID_DOESNT_EXIST;

  /**
   * If this is true, no progress will be saved.
   */
  incognitoMode: boolean = false;
  /**
   * If this is true, we are reading a bookmark. ChapterId will be 0. There is no continuous reading. Progress is not saved. Bookmark control is removed.
   */
  bookmarkMode = signal<boolean>(false);

  /**
   * If this is true, chapters will be fetched in the order of a reading list, rather than natural series order.
   */
  readingListMode: boolean = false;
  /**
   * The current page. UI will show this number + 1.
   */
  pageNum = 0;
  /**
   * Total pages in the given Chapter
   */
  maxPages = 1;
  totalSeriesPages = 0;
  totalSeriesPagesRead = 0;
  readingProfile!: ReadingProfile;
  /**
   * The reading profile itself, unless readingProfile is implicit
   */
  parentReadingProfile: ReadingProfile | null = null;
  generalSettingsForm!: FormGroup;

  readingDirection = ReadingDirection.LeftToRight;
  scalingOption = ScalingOption.FitToHeight;
  pageSplitOption = PageSplitOption.FitSplit;

  isFullscreen: boolean = false;
  autoCloseMenu: boolean = true;

  readerMode: ReaderMode = ReaderMode.LeftRight;
  readerModeSubject = new BehaviorSubject(this.readerMode);
  readerMode$: Observable<ReaderMode> = this.readerModeSubject.asObservable();

  pagingDirection: PAGING_DIRECTION = PAGING_DIRECTION.FORWARD;
  pagingDirectionSubject: Subject<PAGING_DIRECTION> = new BehaviorSubject(this.pagingDirection);
  pagingDirection$: Observable<PAGING_DIRECTION> = this.pagingDirectionSubject.asObservable();


  pageSplitOptionsTranslated = pageSplitOptions.map(this.translatePrefOptions);
  layoutModesTranslated = layoutModes.map(this.translatePrefOptions);

  isLoading = true;
  hasBookmarkRights = computed(() => this.accountService.hasBookmarkRole() || this.accountService.hasAdminRole());


  getPageFn!: (pageNum: number) => HTMLImageElement;


  /**
   * Used to render a page on the canvas or in the image tag. This Image element is prefetched by the cachedImages buffer.
   * @remarks Used for rendering to screen.
   */
  canvasImage = new Image();

  /**
   * A circular array of size PREFETCH_PAGES. Maintains prefetched Images around the current page to load from to avoid loading animation.
   * @see CircularArray
   */
  cachedImages: Array<HTMLImageElement> = [];
  /**
   * A stack of the chapter ids we come across during continuous reading mode. When we traverse a boundary, we use this to avoid extra API calls.
   * @see Stack
   */
  continuousChaptersStack: Stack<number> = new Stack();

  continuousChapterInfos: Array<ChapterInfo | undefined> = [undefined, undefined, undefined];

  /**
   * An event emitter when a page change occurs. Used solely by the webtoon reader.
   */
  goToPageEvent!: BehaviorSubject<number>; // Renderer interaction

   /**
   * An event emitter when a bookmark on a page change occurs. Used solely by the webtoon reader.
   */
  showBookmarkEffectEvent: ReplaySubject<number> = new ReplaySubject<number>();
  showBookmarkEffect$: Observable<number> = this.showBookmarkEffectEvent.asObservable();
   /**
   * An event emitter when fullscreen mode is toggled. Used solely by the webtoon reader.
   */
  fullscreenEvent: ReplaySubject<boolean> = new ReplaySubject<boolean>();
  /**
   * If the menu is open/visible.
   */
  menuOpen = false;
  /**
   * If the prev page allows a page change to occur.
   */
  prevPageDisabled = false;
  /**
   * If the next page allows a page change to occur.
   */
  nextPageDisabled = false;
  pageOptions: Options = {
    floor: 0,
    ceil: 0,
    step: 1,
    boundPointerLabels: true,
    showSelectionBar: true,
    translate: (_: number, label: LabelType) => {
      if (label == LabelType.Floor) {
        return 1 + '';
      } else if (label === LabelType.Ceil) {
        return this.maxPages + '';
      }
      return (this.pageNum + 1) + '';
    },
    animate: false
  };
  refreshSlider: EventEmitter<void> = new EventEmitter<void>();

  /**
   * Used to store the Series name for UI
   */
  title: string = '';
  /**
   * Timeout id for auto-closing menu overlay
   */
  menuTimeout: any;
  /**
   * If the click overlay is rendered on screen
   */
  showClickOverlay: boolean = false;
  private showClickOverlaySubject: ReplaySubject<boolean> = new ReplaySubject();
  showClickOverlay$ = this.showClickOverlaySubject.asObservable();
  /**
   * Next Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking).
   */
  nextChapterId: number = CHAPTER_ID_NOT_FETCHED;
  /**
   * Previous Chapter Id. This is not guaranteed to be a valid ChapterId. Prefetched on page load (non-blocking).
   */
  prevChapterId: number = CHAPTER_ID_NOT_FETCHED;
  /**
   * Is there a next chapter. If not, this will disable UI controls.
   */
  nextChapterDisabled: boolean = false;
  /**
   * Is there a previous chapter. If not, this will disable UI controls.
   */
  prevChapterDisabled: boolean = false;
  /**
   * Has the next chapter been prefetched. Prefetched means the backend will cache the files.
   */
  nextChapterPrefetched: boolean = false;
  /**
   * Has the previous chapter been prefetched. Prefetched means the backend will cache the files.
   */
  prevChapterPrefetched: boolean = false;
  /**
   * If extended settings area is visible. Blocks auto-closing of menu.
   */
  settingsOpen: boolean = false;
  /**
   * A map of bookmarked pages to chapterTitle. Used for O(1) lookup time if a page is bookmarked or not.
   */
  bookmarks: {[key: string]: string} = {};
  /**
   * Library Type used for rendering chapter or issue
   */
  libraryType: LibraryType = LibraryType.Manga;
  /**
   * Used for webtoon reader. When loading pages or data, this will disable the reader
   */
  inSetup: boolean = true;
  /**
   * If we render 2 pages at once or 1
   */
  layoutMode: LayoutMode = LayoutMode.Single;
  /**
   * Background color for canvas/reader. User configured.
   */
  backgroundColor: string = '#FFFFFF';

  /**
   * This is here as absolute layout requires us to calculate a negative right property for the right pagination when there is overflow. This is calculated on scroll.
   */
  rightPaginationOffset = 0;

  /**
   * Previous amount of scroll left. Used for swipe to paginate functionality.
   */
  prevScrollLeft = 0;
  /**
   * Previous amount of scroll top. Used for swipe to paginate functionality.
   */
  prevScrollTop = 0;

  prevIsHorizontalScrollLeft = true;
  prevIsVerticalScrollLeft = true;

  /**
   * Has the user scrolled to the far right side. This is used for swipe to next page and must ensure user is at end of scroll then on next swipe, will move pages.
   */
  hasHitRightScroll = false;
  /**
   * Has the user scrolled once for the current page
   */
  hasScrolledX: boolean = false;
  /**
   * Has the user scrolled once in the Y axis for the current page
   */
  hasScrolledY: boolean = false;
  /**
   * Has the user scrolled to far left size. This doesn't include starting from no scroll
   */
  hasHitZeroScroll: boolean = false;
  /**
   * Has the user scrolled to the far top of the screen
   */
  hasHitZeroTopScroll: boolean = false;
  /**
   * Has the user scrolled to the far bottom of the screen
   */
  hasHitBottomTopScroll: boolean = false;

  /**
   * Show and log debug information
   */
  debugMode: boolean = false;
  /**
   * Width override label for manual width control
  */
  widthOverrideLabel$ : Observable<string> = new Observable<string>();
  /**
   * If bookmark mode, the title of the bookmark chapter, else the normal chapter title
   */
  chapterTitleLabel: Signal<string>;

  // Renderer interaction
  readerSettings$!: Observable<ReaderSetting>;
  private currentImage: Subject<HTMLImageElement | null> = new ReplaySubject(1);
  currentImage$: Observable<HTMLImageElement | null> = this.currentImage.asObservable().pipe(
    shareReplay({refCount: true, bufferSize: 2})
  );

  private pageNumSubject: Subject<{pageNum: number, maxPages: number}> = new ReplaySubject();
  pageNum$: Observable<{pageNum: number, maxPages: number}> = this.pageNumSubject.asObservable();

  getPageUrl = (pageNum: number, chapterId: number = this.chapterId) => {
    const imageKey = this.accountService.currentUserImageAuthKey();
    if (this.bookmarkMode()) return this.readerService.getBookmarkPageUrl(this.seriesId, imageKey!, pageNum);
    return this.readerService.getPageUrl(chapterId, pageNum);
  }

  get CurrentPageBookmarked() {
    return this.bookmarks.hasOwnProperty(this.pageNum);
  }

  get WindowWidth() {
    return this.readingArea()?.nativeElement.scrollWidth + 'px';
  }

  get ImageHeight() {
    if (this.FittingOption !== FITTING_OPTION.HEIGHT) {
      return this.mangaReaderService.getPageDimensions(this.pageNum)?.height  + 'px';
    }

    return this.readingArea()?.nativeElement?.clientHeight + 'px';
  }

  // This is for the pagination area
  get MaxHeight() {
    return '100dvh';
  }

  get RightPaginationOffset() {
    if (this.readerMode === ReaderMode.LeftRight && this.FittingOption !== FITTING_OPTION.WIDTH) {
      return (this.readingArea()?.nativeElement?.scrollLeft || 0) * -1;
    }
    return 0;
  }

  get SplitIconClass() {
    if (this.mangaReaderService.isSplitLeftToRight(this.pageSplitOption)) {
      return 'left-side';
    } else if (this.mangaReaderService.isNoSplit(this.pageSplitOption)) {
      return 'none';
    }
    return 'right-side';
  }

  get FittingOption() { return this.generalSettingsForm?.get('fittingOption')?.value || FITTING_OPTION.HEIGHT; }
  get ReadingAreaWidth() {
    const readingArea = this.readingArea();
    return readingArea?.nativeElement.scrollWidth - readingArea?.nativeElement.clientWidth;
  }

  get ReadingAreaHeight() {
    const readingArea = this.readingArea();
    return readingArea?.nativeElement.scrollHeight - readingArea?.nativeElement.clientHeight;
  }


  constructor() {
    this.navService.hideNavBar();
    this.navService.hideSideNav();
    this.cdRef.markForCheck();

    this.chapterTitleLabel = computed(() => {
      const chapterInfo = this.chapterInfo();
      const bookmarkMode = this.bookmarkMode();

      if (bookmarkMode) {
        // TODO: This currently doesn't work as bookmark reader doesn't have the page/chapter binding in bookmark info, we'd need a new API
        const hasKey = (this.bookmarks ?? {}).hasOwnProperty(this.pageNum);
        if (!hasKey) return translate('manga-reader.bookmarks-title');
        return this.bookmarks[this.pageNum];
      }

      return chapterInfo?.chapterTitle || chapterInfo?.subtitle || '';
    });

    this.keyBindService.registerListener(
      this.destroyRef,
      async (e) => {
        switch (e.target) {
          case KeyBindTarget.Escape:
            if (this.menuOpen) {
              this.toggleMenu();
              return;
            }
            this.closeReader();
            break;
          case KeyBindTarget.PageLeft:
            this.handlePageLeft();
            break;
          case KeyBindTarget.PageRight:
            this.handlePageRight();
            break;
          case KeyBindTarget.PageUp:
            this.handlePageUp(e);
            break;
          case KeyBindTarget.PageDown:
            this.handlePageDown(e);
            break;
          case KeyBindTarget.ToggleFullScreen:
            this.toggleFullscreen();
            break;
          case KeyBindTarget.BookmarkPage:
            this.bookmarkPage();
            break;
          case KeyBindTarget.OffsetDoublePage:
            this.togglePageOffset();
            break;
          case KeyBindTarget.GoTo:
            const goToPageNum = await this.promptForPage();
            if (goToPageNum === null) { return; }
            this.goToPage(parseInt(goToPageNum.trim(), 10));
            break;
          case KeyBindTarget.ToggleMenu:
            this.toggleMenu();
            break;
          case KeyBindTarget.OpenHelp:
            this.openShortcutModal();
            break;
          case KeyBindTarget.NextChapter:
            this.loadNextChapter();
            break;
          case KeyBindTarget.PreviousChapter:
            this.loadPrevChapter();
            break;
          case KeyBindTarget.FirstPage:
            this.goToPage(0);
            break;
          case KeyBindTarget.LastPage:
            this.goToPage(this.maxPages);
            break;
          case KeyBindTarget.NavigateToSettings:
            this.toggleMenu();
            this.settingsOpen = !this.settingsOpen;
            this.cdRef.markForCheck();
            break;
        }
      },
      KEYBIND_TARGETS.filter(k => !!k.keyBindTarget).map(k => k.keyBindTarget as KeyBindTarget),
    );
  }

  ngOnInit(): void {
    const libraryId = this.route.snapshot.paramMap.get('libraryId');
    const seriesId = this.route.snapshot.paramMap.get('seriesId');
    const chapterId = this.route.snapshot.paramMap.get('chapterId');
    if (libraryId === null || seriesId === null || chapterId === null) {
      this.router.navigateByUrl('/home');
      return;
    }

    this.getPageFn = this.getPage.bind(this);

    this.libraryId = parseInt(libraryId, 10);
    this.seriesId = parseInt(seriesId, 10);
    this.chapterId = parseInt(chapterId, 10);
    this.incognitoMode = this.route.snapshot.queryParamMap.get('incognitoMode') === 'true';
    this.bookmarkMode.set(this.route.snapshot.queryParamMap.get('bookmarkMode') === 'true');

    this.route.data.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(data => {
      this.readingProfile = data['readingProfile'];
      if (this.readingProfile == null) {
        this.router.navigateByUrl('/home');
        return;
      }
      // Requires seriesId to be set
      this.setupReaderSettings();
      this.cdRef.markForCheck();
    });

    const readingListId = this.route.snapshot.queryParamMap.get('readingListId');
    if (readingListId != null) {
      this.readingListMode = true;
      this.readingListId = parseInt(readingListId, 10);
    }

    this.continuousChaptersStack.push(this.chapterId);

    this.memberService.hasReadingProgress(this.libraryId).subscribe(progress => {
      if (!progress) {
        this.toggleMenu();
        this.toastr.info(translate('manga-reader.first-time-reading-manga'));
      }
    });

    this.init(true);

    // Update implicit reading profile while changing settings
    this.generalSettingsForm.valueChanges.pipe(
      debounceTime(300),
      distinctUntilChanged(),
      takeUntilDestroyed(this.destroyRef),
      map(_ => this.packReadingProfile()),
      distinctUntilChanged(),
      tap(newProfile => {
        this.readingProfileService.updateImplicit(this.libraryId, this.seriesId, newProfile).subscribe({
          next: updatedProfile => {
            this.readingProfile = updatedProfile;
            this.cdRef.markForCheck();
          },
          error: err => {
            console.error(err);
          }
        })
      })
    ).subscribe();

    this.currentImage$.pipe(
      filter(() => this.readerMode !== ReaderMode.Webtoon),
      filter(img => !!img),
      tap(() => {
        this.imageElement()?.nativeElement?.focus();
      }),
    ).subscribe();
  }

  ngAfterViewInit() {
    fromEvent(this.readingArea().nativeElement, 'scroll').pipe(debounceTime(20), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
      if (this.readerMode === ReaderMode.Webtoon) return;
      if (this.readerMode === ReaderMode.LeftRight && this.FittingOption === FITTING_OPTION.HEIGHT) {
        this.rightPaginationOffset = (this.readingArea().nativeElement.scrollLeft) * -1;
        this.cdRef.markForCheck();
        return;
      }
      this.rightPaginationOffset = 0;
      this.cdRef.markForCheck();
    });

    fromEvent(this.readingArea().nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
      this.prevScrollLeft = this.readingArea()?.nativeElement?.scrollLeft || 0;
      this.prevScrollTop = this.readingArea()?.nativeElement?.scrollTop || 0;
      this.hasScrolledX = true;
      this.hasScrolledY = true;
    });
  }

  ngOnDestroy() {
    this.readerService.resetOverrideStyles();
    this.navService.showNavBar();
    this.navService.showSideNav();
    this.showBookmarkEffectEvent.complete();
    if (this.goToPageEvent !== undefined) this.goToPageEvent.complete();

    this.readerService.disableWakeLock();
  }

  private handlePageUp(e: KeyBindEvent) {
    if (this.readerMode === ReaderMode.UpDown && this.checkIfPaginationAllowed(KeyDirection.Up)) {
      this.prevPage();
    }

    if (this.readerMode === ReaderMode.Webtoon) {
      e.triggered = false;
    }

  }

  private handlePageLeft() {
    if (this.readerMode === ReaderMode.LeftRight && this.checkIfPaginationAllowed(KeyDirection.Left)) {
      this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
    }
  }

  private handlePageDown(e: KeyBindEvent) {
    if (this.readerMode === ReaderMode.UpDown && this.checkIfPaginationAllowed(KeyDirection.Down)) {
      this.nextPage();
    }

    if (this.readerMode === ReaderMode.Webtoon) {
      e.triggered = false;
    }
  }

  private handlePageRight() {
    if (this.readerMode === ReaderMode.LeftRight && this.checkIfPaginationAllowed(KeyDirection.Right)) {
      this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
    }
  }

  @HostListener('window:resize', ['$event'])
  @HostListener('window:orientationchange', ['$event'])
  onResize(event: Event) {
    this.disableDoubleRendererIfScreenTooSmall();
  }

  setupReaderSettings() {

    if (this.readingProfile.kind === ReadingProfileKind.Implicit) {
      this.readingProfileService.getForSeries(this.libraryId, this.seriesId, true).subscribe(parent => {
        this.parentReadingProfile = parent;
        this.cdRef.markForCheck();
      })
    } else {
      this.parentReadingProfile = this.readingProfile;
    }

    this.readingDirection = this.readingProfile.readingDirection;
    this.scalingOption = this.readingProfile.scalingOption;
    this.pageSplitOption = this.readingProfile.pageSplitOption;
    this.autoCloseMenu = this.readingProfile.autoCloseMenu;
    this.readerMode = this.readingProfile.readerMode;
    this.layoutMode = this.readingProfile.layoutMode || LayoutMode.Single;
    this.backgroundColor = this.readingProfile.backgroundColor || '#000000';
    this.readerService.setOverrideStyles(this.backgroundColor);

    this.generalSettingsForm = this.formBuilder.nonNullable.group({
      autoCloseMenu: new FormControl(this.autoCloseMenu),
      pageSplitOption: new FormControl(this.pageSplitOption),
      fittingOption: new FormControl(this.mangaReaderService.translateScalingOption(this.scalingOption)),
      widthSlider: new FormControl(this.readingProfile.widthOverride ?? 'none'),
      layoutMode: new FormControl(this.layoutMode),
      darkness: new FormControl(100),
      emulateBook: new FormControl(this.readingProfile.emulateBook),
      swipeToPaginate: new FormControl(this.readingProfile.swipeToPaginate),
      pageOffset: new FormControl(false),
      readingDirection: this.readingDirection,
    });

    this.readerModeSubject.next(this.readerMode);
    this.pagingDirectionSubject.next(this.pagingDirection);

    // We need a mergeMap when page changes
    this.readerSettings$ = merge(this.generalSettingsForm.valueChanges, this.pagingDirection$, this.readerMode$).pipe(
      map(_ => this.createReaderSettingsUpdate()),
      takeUntilDestroyed(this.destroyRef),
    );

    this.updateForm();

    this.pagingDirection$.pipe(
      distinctUntilChanged(),
      tap(dir => {
        this.pagingDirection = dir;
        this.cdRef.markForCheck();
      }),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(() => {});

    this.readerMode$.pipe(
      distinctUntilChanged(),
      tap(mode => {
        this.readerMode = mode;
        this.disableDoubleRendererIfScreenTooSmall();
        this.cdRef.markForCheck();
      }),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(() => {});

    this.setupWidthOverrideTriggers();

    this.generalSettingsForm.get('layoutMode')?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(val => {

      const changeOccurred = parseInt(val, 10) !== this.layoutMode;
      this.layoutMode = parseInt(val, 10);

      if (this.layoutMode === LayoutMode.Single) {
        this.generalSettingsForm.get('pageSplitOption')?.setValue(this.readingProfile!.pageSplitOption);
        this.generalSettingsForm.get('pageSplitOption')?.enable();
        this.generalSettingsForm.get('widthSlider')?.enable();
        this.generalSettingsForm.get('fittingOption')?.enable();
        this.generalSettingsForm.get('emulateBook')?.enable();
        this.generalSettingsForm.get('pageOffset')?.disable();
      } else {
        this.generalSettingsForm.get('pageSplitOption')?.setValue(PageSplitOption.NoSplit);
        this.generalSettingsForm.get('pageSplitOption')?.disable();
        this.generalSettingsForm.get('widthSlider')?.disable();
        this.generalSettingsForm.get('fittingOption')?.setValue(this.mangaReaderService.translateScalingOption(ScalingOption.FitToHeight));
        this.generalSettingsForm.get('fittingOption')?.disable();
        this.generalSettingsForm.get('emulateBook')?.enable();
        this.generalSettingsForm.get('pageOffset')?.enable();
      }
      this.cdRef.markForCheck();

      // Re-render the current page when we switch layouts
      if (changeOccurred) {
        this.setPageNum(this.adjustPagesForDoubleRenderer(this.pageNum));
        this.loadPage();
      }
    });

    this.generalSettingsForm.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
      this.autoCloseMenu = this.generalSettingsForm.get('autoCloseMenu')?.value;
      this.pageSplitOption = parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10);
      const pageOffset = this.generalSettingsForm.get('pageOffset')?.value;
      if (pageOffset !== undefined) {
        this.mangaReaderService.setPageOffset(pageOffset);
        const adjustedPage = this.mangaReaderService.adjustForDoubleReader(this.pageNum);
        this.pageNumSubject.next({pageNum: adjustedPage, maxPages: this.maxPages});
        this.pageNum = adjustedPage;
        this.goToPage(this.pageNum);
      }

      const needsSplitting = this.mangaReaderService.isWidePage(this.readerService.imageUrlToPageNum(this.canvasImage.src));
      // If we need to split on a menu change, then we need to re-render.
      if (needsSplitting) {
        // If we need to re-render, to ensure things layout properly, let's update paging direction & reset render
        this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
        this.canvasRenderer()?.reset();
        this.loadPage();
      }
    });

    this.cdRef.markForCheck();
  }

  /**
   * Width override is only valid under the following conditions:
   *   Image Scaling is Width
   *   Reader Mode is Webtoon
   *
   *   In all other cases, the form will be disabled and set to 0 which indicates default/off state.
   */
  setupWidthOverrideTriggers() {
    const widthOverrideControl = this.generalSettingsForm.get('widthSlider')!;

    const enableWidthOverride = () => {
      widthOverrideControl.enable();
    };

    const disableWidthOverride = () => {
      widthOverrideControl.setValue(0);
      widthOverrideControl.disable();
    };

    const handleControlChanges = () => {
      const fitting = this.generalSettingsForm.get('fittingOption')?.value;
      const splitting = this.generalSettingsForm.get('pageSplitOption')?.value;

      if ((PageSplitOption.FitSplit == splitting && FITTING_OPTION.WIDTH == fitting) || this.readerMode === ReaderMode.Webtoon) {
        enableWidthOverride();
      } else {
        disableWidthOverride();
      }
    };

    // Reader mode changes
    this.readerModeSubject.asObservable()
      .pipe(
        filter(v => v === ReaderMode.Webtoon),
        tap(enableWidthOverride),
        takeUntilDestroyed(this.destroyRef)
      )
      .subscribe();

    // Page split option changes
    this.generalSettingsForm.get('pageSplitOption')?.valueChanges.pipe(
      distinctUntilChanged(),
      tap(handleControlChanges),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe();

    // Fitting option changes
    this.generalSettingsForm.get('fittingOption')?.valueChanges.pipe(
      tap(handleControlChanges),
      takeUntilDestroyed(this.destroyRef)
    ).subscribe();

    // Set the default override to 0
    //widthOverrideControl.setValue(0);

    //send the current width override value to the label
    this.widthOverrideLabel$ = this.readerSettings$?.pipe(
      map(values => parseInt(values.widthSlider)),
      map(widthOverride => (isNaN(widthOverride)) ? '' : widthOverride + '%'),
      takeUntilDestroyed(this.destroyRef)
    );
  }

  createReaderSettingsUpdate() {
    return {
      pageSplit: parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10),
      fitting: (this.generalSettingsForm.get('fittingOption')?.value as FITTING_OPTION),
      widthSlider: this.generalSettingsForm.get('widthSlider')?.value,
      layoutMode: this.layoutMode,
      darkness: parseInt(this.generalSettingsForm.get('darkness')?.value + '', 10) || 100,
      pagingDirection: this.pagingDirection,
      readerMode: this.readerMode,
      emulateBook: this.generalSettingsForm.get('emulateBook')?.value,
    };
  }

  // If we are in double mode, we need to check if our current page is on a right edge or not, if so adjust by decrementing by 1
  adjustPagesForDoubleRenderer(pageNum: number) {
    if (pageNum === this.maxPages - 1) return pageNum;
    if (this.readerMode !== ReaderMode.Webtoon && this.layoutMode !== LayoutMode.Single) {
      return this.mangaReaderService.adjustForDoubleReader(pageNum);
    }
    return pageNum;
  }

  switchToWebtoonReaderIfPagesLikelyWebtoon() {
    if (this.readerMode === ReaderMode.Webtoon) return;
    if (!this.readingProfile!.allowAutomaticWebtoonReaderDetection) return;

    if (this.mangaReaderService.shouldBeWebtoonMode()) {
      this.readerMode = ReaderMode.Webtoon;
      this.toastr.info(translate('toasts.webtoon-override'));
      this.readerModeSubject.next(this.readerMode);
      this.cdRef.markForCheck();
    }
  }

  disableDoubleRendererIfScreenTooSmall() {
    if (window.innerWidth > window.innerHeight) {
      if (this.generalSettingsForm.get('layoutMode')?.disabled) {
        this.generalSettingsForm.get('layoutMode')?.enable();
      }
      this.cdRef.markForCheck();
      return;
    }
    if (this.layoutMode === LayoutMode.Single || this.readerMode === ReaderMode.Webtoon) return;

    this.generalSettingsForm.get('layoutMode')?.setValue(LayoutMode.Single);
    this.generalSettingsForm.get('layoutMode')?.disable();
    this.toastr.info(translate('manga-reader.layout-mode-switched'));
    this.cdRef.markForCheck();
  }

  /**
   * Gets a page from cache else gets a brand new Image
   * @param pageNum Page Number to load
   * @param forceNew Forces to fetch a new image
   * @param chapterId ChapterId to fetch page from. Defaults to current chapterId. Not used when in bookmark mode
   * @returns HTMLImageElement | undefined
   */
   getPage(pageNum: number, chapterId: number = this.chapterId, forceNew: boolean = false) {

    let img: HTMLImageElement | undefined;
    if (this.bookmarkMode()) img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum);
    else img = this.cachedImages.find(img => this.readerService.imageUrlToPageNum(img.src) === pageNum
      && (this.readerService.imageUrlToChapterId(img.src) == chapterId || this.readerService.imageUrlToChapterId(img.src) === -1)
    );

    //console.log('Requesting page ', pageNum, ' found page: ', img, ' and app is requesting new image? ', forceNew);
    if (!img || forceNew) {
      img = new Image();
      img.src = this.getPageUrl(pageNum, chapterId);
      img.onload = (evt) => {
        this.currentImage.next(img!);
        this.cdRef.markForCheck();
      }
    }

    return img;
  }



  isHorizontalScrollLeft() {
    const scrollLeft = this.readingArea()?.nativeElement?.scrollLeft || 0;
    // if scrollLeft is 0 and this.ReadingAreaWidth is 0, then there is no scroll needed
    // if they equal each other, it means we are at the end of the scroll area
    if (scrollLeft === 0 && this.ReadingAreaWidth === 0) return false;
    if (scrollLeft === this.ReadingAreaWidth) return false;
    return scrollLeft < this.ReadingAreaWidth;
  }

  isVerticalScrollLeft() {
    const scrollTop = this.readingArea()?.nativeElement?.scrollTop || 0;
    return scrollTop < this.ReadingAreaHeight;
  }

  /**
   * Is there any room to scroll in the direction we are giving? If so, return false. Otherwise, return true.
   * @param direction
   * @returns
   */
  checkIfPaginationAllowed(direction: KeyDirection) {
    const readingArea = this.readingArea();
    if (readingArea === undefined || readingArea.nativeElement === undefined) return true;

    const scrollLeft = readingArea?.nativeElement?.scrollLeft || 0;
    const scrollTop = readingArea?.nativeElement?.scrollTop || 0;

    switch (direction) {
      case KeyDirection.Right:
        if (this.prevIsHorizontalScrollLeft && !this.isHorizontalScrollLeft()) { return true; }
        this.prevIsHorizontalScrollLeft = this.isHorizontalScrollLeft();

        if (this.isHorizontalScrollLeft()) {
          return false;
        }
        break;
      case KeyDirection.Left:
        this.prevIsHorizontalScrollLeft = this.isHorizontalScrollLeft();
        if (scrollLeft > 0 || this.prevScrollLeft > 0) {
          return false;
        }
        break;
      case KeyDirection.Up:
        this.prevIsVerticalScrollLeft = this.isVerticalScrollLeft();
        if (scrollTop > 0|| this.prevScrollTop > 0) {
          return false;
        }
        break;
      case KeyDirection.Down:
        if (this.prevIsVerticalScrollLeft && !this.isVerticalScrollLeft()) { return true; }
        this.prevIsVerticalScrollLeft = this.isVerticalScrollLeft();

        if (this.isVerticalScrollLeft()) {
          return false;
        }
        break;
    }

    return true;
  }



  init(firstLoad: boolean) {
    this.nextChapterId = CHAPTER_ID_NOT_FETCHED;
    this.prevChapterId = CHAPTER_ID_NOT_FETCHED;
    this.nextChapterDisabled = false;
    this.prevChapterDisabled = false;
    this.nextChapterPrefetched = false;
    this.pageNum = 0;
    this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
    this.inSetup = true;
    this.canvasImage.src = '';
    this.cdRef.markForCheck();

    this.cachedImages = [];
    for (let i = 0; i < PREFETCH_PAGES; i++) {
      this.cachedImages.push(new Image());
    }

    if (this.goToPageEvent) {
      // There was a bug where goToPage was emitting old values into infinite scroller between chapter loads. We explicitly clear it out between loads,
      // and we use a BehaviourSubject to ensure only latest value is sent
      this.goToPageEvent.complete();
    }

    if (this.bookmarkMode()) {
      this.readerService.getBookmarkInfo(this.seriesId).subscribe(bookmarkInfo => {
        this.setPageNum(0);
        this.title = bookmarkInfo.seriesName;
        this.libraryType = bookmarkInfo.libraryType;
        this.maxPages = bookmarkInfo.pages;
        this.mangaReaderService.load(bookmarkInfo);

        // Due to change detection rules in Angular, we need to re-create the options object to apply the change
        const newOptions: Options = Object.assign({}, this.pageOptions);
        newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
        this.pageOptions = newOptions;
        this.inSetup = false;
        this.cdRef.markForCheck();

        this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);

        this.render();
      });

      setTimeout(() => {
        this.readerService.enableWakeLock(this.reader().nativeElement);
      }, 1000);
      return;
    }

    forkJoin({
      progress: this.readerService.getProgress(this.chapterId),
      chapterInfo: this.readerService.getChapterInfo(this.chapterId, true),
      bookmarks: this.readerService.getBookmarks(this.chapterId),
    }).subscribe(results => {
      if (this.readingListMode && (results.chapterInfo.seriesFormat === MangaFormat.EPUB || results.chapterInfo.seriesFormat === MangaFormat.PDF)) {
        // Redirect to the book reader.
        const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId);
        this.router.navigate(this.readerService.getNavigationArray(results.chapterInfo.libraryId, results.chapterInfo.seriesId, this.chapterId, results.chapterInfo.seriesFormat), {queryParams: params});
        return;
      }

      this.chapterInfo.set(results.chapterInfo);

      this.mangaReaderService.load(results.chapterInfo);

      this.continuousChapterInfos[ChapterInfoPosition.Current] = results.chapterInfo;
      this.volumeId = results.chapterInfo.volumeId;
      this.maxPages = results.chapterInfo.pages;


      let page = results.progress.pageNum;

      // When a chapter is completed, we store the last page (maxPages - 1) as progress maxPages
      // We need to correct for this when using it pageNum again. See setPageNum method for the correction logic
      if (page === this.maxPages) {
        page--;
      }

      if (page > this.maxPages) {
        page = !firstLoad ? 0 : this.maxPages - 1;
      }

      page = this.adjustPagesForDoubleRenderer(page);

      this.totalSeriesPages = results.chapterInfo.seriesTotalPages;
      this.totalSeriesPagesRead = results.chapterInfo.seriesTotalPagesRead - page;

      this.setPageNum(page); // first call
      this.goToPageEvent = new BehaviorSubject<number>(this.pageNum);

      // Due to change detection rules in Angular, we need to re-create the options object to apply the change
      const newOptions: Options = Object.assign({}, this.pageOptions);
      newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything.
      this.pageOptions = newOptions;

      this.libraryType = results.chapterInfo.libraryType;
      this.title = results.chapterInfo.title;

      this.inSetup = false;

      this.disableDoubleRendererIfScreenTooSmall();
      this.switchToWebtoonReaderIfPagesLikelyWebtoon();
      this.cdRef.markForCheck();

      // From bookmarks, create map of pages to make lookup time O(1)
      this.setupBookmarks(results.bookmarks);


      this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).subscribe(chapterId => {
        this.nextChapterId = chapterId;
        if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
          this.nextChapterDisabled = true;
          this.cdRef.markForCheck();
        } else {
          // Fetch the first page of next chapter
          this.getPage(0, this.nextChapterId);

        }
      });
      this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).subscribe(chapterId => {
        this.prevChapterId = chapterId;
        if (chapterId === CHAPTER_ID_DOESNT_EXIST || chapterId === this.chapterId) {
          this.prevChapterDisabled = true;
          this.cdRef.markForCheck();
        } else {
          // Fetch the last page of prev chapter
          this.getPage(1000000, this.prevChapterId);
        }
      });

      this.render();
    }, () => {
      setTimeout(() => {
        this.closeReader();
      }, 200);
    });
  }

  private setupBookmarks(bookmarks: PageBookmark[]) {
    this.bookmarks = {};
    bookmarks.forEach(bookmark => {
      this.bookmarks[bookmark.page] = bookmark.chapterTitle ?? '';
    });
    this.cdRef.markForCheck();
  }

  closeReader() {
    this.readerService.closeShortCutModal();
    this.readerService.closeReader(this.libraryId, this.seriesId, this.chapterId, this.readingListMode, this.readingListId);
  }

  render() {
    if (this.readerMode === ReaderMode.Webtoon) {
      this.isLoading = false;
      this.cdRef.markForCheck();
    } else {
      this.loadPage();
    }
  }

  cancelMenuCloseTimer() {
    if (this.menuTimeout) {
      clearTimeout(this.menuTimeout);
    }
  }

  /**
   * Whenever the menu is interacted with, restart the timer. However, if the settings menu is open, don't restart, just cancel the timeout.
   */
  resetMenuCloseTimer() {
    if (this.menuTimeout) {
      clearTimeout(this.menuTimeout);
      if (!this.settingsOpen && this.autoCloseMenu) {
        this.startMenuCloseTimer();
      }
    }
  }

  startMenuCloseTimer() {
    if (!this.autoCloseMenu) { return; }

    this.menuTimeout = setTimeout(() => {
      this.toggleMenu();
    }, OVERLAY_AUTO_CLOSE_TIME);
  }

  toggleMenu() {
    this.menuOpen = !this.menuOpen;
    this.cdRef.markForCheck();

    if (this.menuTimeout) {
      clearTimeout(this.menuTimeout);
    }

    if (this.menuOpen && !this.settingsOpen) {
      this.startMenuCloseTimer();
    } else {
      this.showClickOverlay = false;
      this.settingsOpen = false;
      this.cdRef.markForCheck();
    }
  }

  resetSwipeModifiers() {
    this.prevScrollLeft = 0;
    this.prevScrollTop = 0;
    this.hasScrolledX = false;
    this.hasScrolledY = false;
    this.hasHitRightScroll = false;
    this.hasHitZeroScroll = false;
    this.hasHitBottomTopScroll = false;
    this.hasHitZeroTopScroll = false;
  }

  /**
   * This executes BEFORE fromEvent('scroll')
   * @returns
   * @param _
   */
  onSwipeMove(_: SwipeEvent) {
    this.prevScrollLeft = this.readingArea()?.nativeElement?.scrollLeft || 0;
    this.prevScrollTop = this.readingArea()?.nativeElement?.scrollTop || 0
  }

  triggerSwipePagination(direction: KeyDirection) {
    if (!this.generalSettingsForm.get('swipeToPaginate')?.value) return;

    switch(direction) {
      case KeyDirection.Down:
        this.nextPage();
        break;
      case KeyDirection.Right:
        this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage() : this.prevPage();
        break;
      case KeyDirection.Up:
        this.prevPage();
        break;
      case KeyDirection.Left:
          this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage() : this.nextPage();
          break;
    }

  }

  onSwipeEnd(event: SwipeEvent) {
    // Positive number means swiping right/down, negative means left
    switch (this.readerMode) {
      case ReaderMode.Webtoon: break;
      case ReaderMode.LeftRight:
        {
          if (event.direction !== 'x') return;
          const scrollLeft = this.readingArea()?.nativeElement?.scrollLeft || 0;
          const direction = event.distance < 0 ? KeyDirection.Right : KeyDirection.Left;
          if (!this.checkIfPaginationAllowed(direction)) {
            return;
          }


          // We just came from a swipe where pagination was required, and we are now at the end of the swipe, so make the user do it once more
          if (direction === KeyDirection.Right) {
            this.hasHitZeroScroll = false;
            if (scrollLeft === 0 && this.ReadingAreaWidth === 0) {
              this.triggerSwipePagination(direction);
              return;
            }
            if (!this.hasHitRightScroll && this.checkIfPaginationAllowed(direction)) {
              this.hasHitRightScroll = true;
              return;
            }
          } else if (direction === KeyDirection.Left) {
            this.hasHitRightScroll = false;

            // If we have not scrolled then let the user page back
            if (scrollLeft === 0 && this.prevScrollLeft === 0) {
              if (!this.hasScrolledX || this.hasHitZeroScroll) {
                this.triggerSwipePagination(direction);
                return;
              }
              this.hasHitZeroScroll = true;
              return;
            }
          }

          if (!this.hasHitRightScroll) {
            return;
          }

          this.triggerSwipePagination(direction);
          break;
        }
      case ReaderMode.UpDown:
        {
          if (event.direction !== 'y') return;
          const direction = event.distance < 0 ? KeyDirection.Down : KeyDirection.Up;
          const scrollTop = this.readingArea()?.nativeElement?.scrollTop || 0;
          if (!this.checkIfPaginationAllowed(direction)) return;


          if (direction === KeyDirection.Down) {
            this.hasHitZeroTopScroll = false;
            if (!this.hasHitBottomTopScroll && this.checkIfPaginationAllowed(direction)) {
              this.hasHitBottomTopScroll = true;
              return;
            }
          } else if (direction === KeyDirection.Up) {
            this.hasHitBottomTopScroll = false;

            // If we have not scrolled then let the user page back
            if (scrollTop === 0 && this.prevScrollTop === 0) {
              if (!this.hasScrolledY || this.hasHitZeroTopScroll) {
                this.triggerSwipePagination(direction);
                return;
              }
              this.hasHitZeroTopScroll = true;
              return;
            }
          }

          if (!this.hasHitBottomTopScroll) {
            return;
          }

          this.triggerSwipePagination(direction);
          break;
        }
    }
  }

  /**
   * Calls the correct prev- or nextPage method based on the direction, readingDirection, and readerMode
   *
   * readingDirection is ignored when readerMode is Webtoon or UpDown
   *
   * KeyDirection.Right: right or bottom click
   * KeyDirection.Left:  left or top click
   * @param event
   * @param direction
   */
  handlePageChange(event: any, direction: KeyDirection) {
    // Webtoons and UpDown reading mode should not take ReadingDirection into account
    if (this.readerMode === ReaderMode.Webtoon || this.readerMode === ReaderMode.UpDown) {
      if (direction === KeyDirection.Right) {
        this.nextPage(event);
      } else {
        this.prevPage(event);
      }
      return;
    }

    if (direction === KeyDirection.Right) {
      this.readingDirection === ReadingDirection.LeftToRight ? this.nextPage(event) : this.prevPage(event);
    } else if (direction === KeyDirection.Left) {
      this.readingDirection === ReadingDirection.LeftToRight ? this.prevPage(event) : this.nextPage(event);
    }
  }

  nextPage(event?: any) {
    if (event) {
      event.stopPropagation();
      event.preventDefault();
    }

    this.resetSwipeModifiers();

    this.isLoading = true;
    this.cdRef.markForCheck();

    this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);

    const pageAmount = Math.max(this.canvasRenderer()!.getPageAmount(PAGING_DIRECTION.FORWARD), this.singleRenderer()!.getPageAmount(PAGING_DIRECTION.FORWARD),
                                this.doubleRenderer()!.getPageAmount(PAGING_DIRECTION.FORWARD),
                                this.doubleReverseRenderer()!.getPageAmount(PAGING_DIRECTION.FORWARD),
                                this.doubleNoCoverRenderer()!.getPageAmount(PAGING_DIRECTION.FORWARD)
                              );
    // If we are on last page with split mode, we need to be able to progress, hence why we check if we could move backwards or not
    const isSplitRendering = [PageSplitOption.SplitRightToLeft, PageSplitOption.SplitRightToLeft].includes(parseInt(this.generalSettingsForm.get('pageSplitOption')?.value, 10));
    const notInSplit = this.canvasRenderer()!.getPageAmount(PAGING_DIRECTION.BACKWARDS) === 0;
    const isASpread = this.mangaReaderService.isWidePage(this.pageNum);


    if ((this.pageNum + pageAmount >= this.maxPages && (!isASpread || !isSplitRendering || notInSplit))) {
      // Move to next volume/chapter automatically
      this.loadNextChapter();
      return;
    }

    this.setPageNum(this.pageNum + pageAmount);
    this.loadPage();
  }

  prevPage(event?: any) {
    if (event) {
      event.stopPropagation();
      event.preventDefault();
    }

    this.resetSwipeModifiers();

    this.isLoading = true;
    this.cdRef.markForCheck();

    this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);


    const pageAmount = this.readerMode === ReaderMode.Webtoon ? 1 : Math.max(this.canvasRenderer()!.getPageAmount(PAGING_DIRECTION.BACKWARDS),
                                this.singleRenderer()!.getPageAmount(PAGING_DIRECTION.BACKWARDS),
                                this.doubleRenderer()!.getPageAmount(PAGING_DIRECTION.BACKWARDS),
                                this.doubleNoCoverRenderer()!.getPageAmount(PAGING_DIRECTION.BACKWARDS),
                                this.doubleReverseRenderer()!.getPageAmount(PAGING_DIRECTION.BACKWARDS)
                              );

    const notInSplit = this.readerMode === ReaderMode.Webtoon ? true : this.canvasRenderer()!.shouldMovePrev();

    if ((this.pageNum - 1 < 0 && notInSplit)) {
      // Move to next volume/chapter automatically
      this.loadPrevChapter();
      return;
    }

    this.setPageNum(this.pageNum - pageAmount);
    this.loadPage();
  }

  /**
   * Sets canvasImage's src to current page, but first attempts to use a pre-fetched image
   */
  setCanvasImage() {
    if (this.cachedImages === undefined) return;

    this.canvasImage = this.getPage(this.pageNum, this.chapterId, this.layoutMode !== LayoutMode.Single);
    if (!this.canvasImage.complete) {
      this.canvasImage.addEventListener('load', () => {
        this.currentImage.next(this.canvasImage);
      }, false);
    } else {
      this.currentImage.next(this.canvasImage);
    }


    this.cdRef.markForCheck();
  }


  loadNextChapter(saveMaxProgress: boolean = false) {
    if (this.nextPageDisabled || this.nextChapterDisabled || this.bookmarkMode()) {
      this.toastr.info(translate('manga-reader.no-next-chapter'));
      this.isLoading = false;
      this.cdRef.markForCheck();
      return;
     }


    if (saveMaxProgress && !this.incognitoMode) {
      this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.maxPages + 1).subscribe();
    }

    if (this.nextChapterId === CHAPTER_ID_NOT_FETCHED || this.nextChapterId === this.chapterId) {
      this.readerService.getNextChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).subscribe(chapterId => {
        this.nextChapterId = chapterId;
        this.loadChapter(chapterId, 'Next');
      });
    } else {
      this.loadChapter(this.nextChapterId, 'Next');
    }
  }

  loadPrevChapter() {
    if (this.prevPageDisabled || this.prevChapterDisabled || this.bookmarkMode()) {
      this.toastr.info(translate('manga-reader.no-prev-chapter'));
      this.isLoading = false;
      this.cdRef.markForCheck();
      return;
    }
    this.continuousChaptersStack.pop();
    const prevChapter = this.continuousChaptersStack.peek();
    if (prevChapter != this.chapterId) {
      if (prevChapter !== undefined) {
        this.chapterId = prevChapter;
        this.init(false);
        return;
      }
    }

    if (this.prevChapterId === CHAPTER_ID_NOT_FETCHED || this.prevChapterId === this.chapterId) {
      this.readerService.getPrevChapter(this.seriesId, this.volumeId, this.chapterId, this.readingListId).subscribe(chapterId => {
        this.prevChapterId = chapterId;
        this.loadChapter(chapterId, 'Prev');
      });
    } else {
      this.loadChapter(this.prevChapterId, 'Prev');
    }
  }

  loadChapter(chapterId: number, direction: 'Next' | 'Prev') {
    if (chapterId > 0) {
      this.isLoading = true;
      this.cdRef.markForCheck();

      this.chapterId = chapterId;
      this.continuousChaptersStack.push(chapterId);
      // Load chapter Id onto route but don't reload
      const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
      window.history.replaceState({}, '', newRoute);
      this.init(false);
      const msg = translate(direction === 'Next' ? 'toasts.load-next-chapter' : 'toasts.load-prev-chapter', {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
      this.toastr.info(msg, '', {timeOut: 3000});
    } else {
      // This will only happen if no actual chapter can be found
      const msg = translate(direction === 'Next' ? 'toasts.no-next-chapter' : 'toasts.no-prev-chapter',
        {entity: this.entityTitleService.formatChapterName(this.libraryType).toLowerCase()});
      this.toastr.warning(msg);
      this.isLoading = false;
      if (direction === 'Prev') {
        this.prevPageDisabled = true;
      } else {
        this.nextPageDisabled = true;
      }
      this.cdRef.markForCheck();
    }
  }



  renderPage() {
    const page = [this.canvasImage];

    this.canvasRenderer()?.renderPage(page);
    this.singleRenderer()?.renderPage(page);
    this.doubleRenderer()?.renderPage(page);
    this.doubleNoCoverRenderer()?.renderPage(page);
    this.doubleReverseRenderer()?.renderPage(page);

    // Originally this was only for fit to height, but when swiping was introduced, it made more sense to do it always to reset to the same view
    this.readingArea().nativeElement.scroll(0,0);

    this.isLoading = false;
    this.cdRef.markForCheck();
  }

  /**
   * Maintains an array of images (that are requested from backend) around the user's current page. This allows for quick loading (seamless to user)
   * and also maintains page info (wide image, etc.) due to onload event.
   */
  prefetch() {
    // NOTE: This doesn't allow for any directionality
    // NOTE: This doesn't maintain 1 image behind at all times
    for(let i = 0; i <= PREFETCH_PAGES - 3; i++) {
      let numOffset = this.pageNum + i;

      if (numOffset > this.maxPages - 1) {
        break;
      }

      const index = (numOffset % this.cachedImages.length + this.cachedImages.length) % this.cachedImages.length;
      const cachedImagePageNum = this.readerService.imageUrlToPageNum(this.cachedImages[index].src);
      if (cachedImagePageNum !== numOffset) {
        this.cachedImages[index] = this.getPage(numOffset, this.chapterId);
      }
    }
  }


  /**
   * This is responsible for setting up the image variables. This will be moved out to different renderers
   */
  loadPage() {
    if (this.readerMode === ReaderMode.Webtoon) return;

    this.isLoading = true;
    this.setCanvasImage();
    this.cdRef.markForCheck();

    this.renderPage();
    this.isLoading = false;
    this.cdRef.markForCheck();

    this.prefetch();
  }

  setReadingDirection() {
    if (this.readingDirection === ReadingDirection.LeftToRight) {
      this.readingDirection = ReadingDirection.RightToLeft;
    } else {
      this.readingDirection = ReadingDirection.LeftToRight;
    }

    // Manually update the form to keep the reading profiel in sync
    this.generalSettingsForm.get('readingDirection')?.setValue(this.readingDirection);

    if (this.menuOpen && this.readingProfile!.showScreenHints) {
      this.showClickOverlay = true;
      this.showClickOverlaySubject.next(true);
      setTimeout(() => {
        this.showClickOverlay = false;
        this.showClickOverlaySubject.next(false);
      }, CLICK_OVERLAY_TIMEOUT);
    }
  }


  sliderDragUpdate(context: ChangeContext) {
    // This will update the value for value except when in webtoon due to how the webtoon reader
    // responds to page changes
    if (this.readerMode !== ReaderMode.Webtoon) {
      this.setPageNum(context.value);
    }
  }

  sliderPageUpdate(context: ChangeContext) {
    const page = context.value;

    if (page > this.pageNum) {
      this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
    } else {
      this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);
    }

    this.setPageNum(this.adjustPagesForDoubleRenderer(page));
    this.refreshSlider.emit();
    this.goToPageEvent.next(this.pageNum);
    this.render();
  }

  setPageNum(pageNum: number) {
    const clampedPageNum = Math.max(Math.min(pageNum, this.maxPages - 1), 0);
    const isSamePage = clampedPageNum === this.pageNum;

    this.pageNum = clampedPageNum;
    this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
    this.cdRef.markForCheck();

    if (this.pageNum >= this.maxPages - 10) {
      // Tell server to cache the next chapter
      if (this.nextChapterId > 0 && !this.nextChapterPrefetched) {
        this.readerService.getChapterInfo(this.nextChapterId).subscribe(res => {
          this.continuousChapterInfos[ChapterInfoPosition.Next] = res;
          this.nextChapterPrefetched = true;
          this.prefetchStartOfChapter(this.nextChapterId, PAGING_DIRECTION.FORWARD);
        });
      }
    } else if (this.pageNum <= 10) {
      if (this.prevChapterId > 0 && !this.prevChapterPrefetched) {
        this.readerService.getChapterInfo(this.prevChapterId).subscribe(res => {
          this.continuousChapterInfos[ChapterInfoPosition.Previous] = res;
          this.prevChapterPrefetched = true;
          this.prefetchStartOfChapter(this.prevChapterId, PAGING_DIRECTION.BACKWARDS);
        });
      }
    }

    // Due to the fact that we start at image 0, but page 1, we need the last page to have progress as page + 1 to be completed
    let tempPageNum = this.pageNum;
    if (this.pageNum == this.maxPages - 1 && this.pagingDirection === PAGING_DIRECTION.FORWARD) {
      tempPageNum = this.pageNum + 1;
    }

    // We need to avoid calling this on first load (except if the chapter only has one page)
    if (!this.incognitoMode && !this.bookmarkMode() && (!this.inSetup || this.maxPages === 1)) {
      if (isSamePage) {
        //console.log('Same page, dropping request: ', this.pageNum)
        return;
      }
      this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, tempPageNum).subscribe();
    }
  }

  /**
   * Loads the first 5 images (throwaway cache) from the given chapterId
   * @param chapterId
   * @param direction Used to indicate if the chapter is behind or ahead of current chapter
   */
  prefetchStartOfChapter(chapterId: number, direction: PAGING_DIRECTION) {
    let pages = [];

    if (direction === PAGING_DIRECTION.BACKWARDS) {
      if (this.continuousChapterInfos[ChapterInfoPosition.Previous] === undefined) return;
      const n = this.continuousChapterInfos[ChapterInfoPosition.Previous]!.pages;
      // Ensure we only load up to 5 pages backward
      pages = Array.from({ length: Math.min(n + 1, 5) }, (v, k) => n - k);
    } else {
      pages = [0, 1, 2, 3, 4];
    }

    const images = [];
    pages.forEach((_, i: number) => {
      const img = new Image();
      img.src = this.getPageUrl(i, chapterId);
      images.push(img)
    });
  }

  goToPage(pageNum: number) {
    let page = pageNum;

    if (page === undefined || this.pageNum === page) { return; }

    if (page > this.maxPages) {
      page = this.maxPages;
    } else if (page < 0) {
      page = 0;
    }

    if (!(page === 0 || page === this.maxPages - 1)) {
      page -= 1;
    }

    if (page > this.pageNum) {
      this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
    } else {
      this.pagingDirectionSubject.next(PAGING_DIRECTION.BACKWARDS);
    }

    this.setPageNum(this.adjustPagesForDoubleRenderer(page));
    this.goToPageEvent.next(page);
    this.render();
  }

  // This is menu code
  clickOverlayClass(side: 'right' | 'left') {
    if (!this.showClickOverlay) {
      return '';
    }

    if (this.readingDirection === ReadingDirection.LeftToRight) {
      return side === 'right' ? 'highlight' : 'highlight-2';
    }
    return side === 'right' ? 'highlight-2' : 'highlight';
  }

  // This is menu only code
  async promptForPage() {
    // const question = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages});
    // const goToPageNum = window.prompt(question, '');

    const promptConfig = {...this.confirmService.defaultPrompt};
    promptConfig.header = translate('book-reader.go-to-page');
    promptConfig.content = translate('book-reader.go-to-page-prompt', {totalPages: this.maxPages});

    const goToPageNum = await this.confirmService.prompt(undefined, promptConfig);

    if (goToPageNum === null || goToPageNum.trim().length === 0) { return null; }
    return goToPageNum;
  }

  // This is menu only code
  toggleFullscreen() {
      this.readerService.toggleFullscreen(this.reader().nativeElement, () => {
        this.isFullscreen = true;
        this.fullscreenEvent.next(true);
        this.render();
      });
  }

  togglePageOffset() {
    if (this.layoutMode === LayoutMode.Single || this.readerMode === ReaderMode.Webtoon) {
      return;
    }

    const currentValue = this.generalSettingsForm.get('pageOffset')?.value;
    this.generalSettingsForm.patchValue({pageOffset: !currentValue});
  }

  // This is menu only code
  toggleReaderMode() {
    switch(this.readerMode) {
      case ReaderMode.LeftRight:
        this.pagingDirectionSubject.next(PAGING_DIRECTION.FORWARD);
        this.readerModeSubject.next(ReaderMode.UpDown);
        break;
      case ReaderMode.UpDown:
        this.readerModeSubject.next(ReaderMode.Webtoon);
        break;
      case ReaderMode.Webtoon:
        this.readerModeSubject.next(ReaderMode.LeftRight);
        break;
    }

    // We must set this here because loadPage from render doesn't call if we aren't page splitting
    if (this.readerMode !== ReaderMode.Webtoon) {
      this.canvasImage = this.getPage(this.pageNum);
      this.currentImage.next(this.canvasImage);
      this.pageNumSubject.next({pageNum: this.pageNum, maxPages: this.maxPages});
      //this.isLoading = true;
      this.cdRef.detectChanges(); // Must use detectChanges to ensure ViewChildren get updated again
    }

    this.updateForm();

    this.render();
  }

  // This is menu only code
  updateForm() {
    if ( this.readerMode === ReaderMode.Webtoon) {
      this.generalSettingsForm.get('pageSplitOption')?.disable()
      this.generalSettingsForm.get('fittingOption')?.disable()
      this.generalSettingsForm.get('layoutMode')?.disable();
      this.generalSettingsForm.get('pageOffset')?.disable();
    } else {
      this.generalSettingsForm.get('fittingOption')?.enable()
      this.generalSettingsForm.get('pageSplitOption')?.enable();
      this.generalSettingsForm.get('layoutMode')?.enable();

      if (this.layoutMode !== LayoutMode.Single) {
        this.generalSettingsForm.get('pageSplitOption')?.disable();
        this.generalSettingsForm.get('fittingOption')?.disable();
        this.generalSettingsForm.get('pageOffset')?.enable();
      } else {
        this.generalSettingsForm.get('pageOffset')?.disable();
      }
    }
    this.cdRef.markForCheck();
  }

  handleWebtoonPageChange(updatedPageNum: number) {
    this.setPageNum(updatedPageNum);
  }

  /**
   * Bookmarks the current page for the chapter
   */
  bookmarkPage(event: Event | undefined = undefined) {
    if (event) {
      event.stopPropagation();
      event.preventDefault();
    }
    if (this.bookmarkMode()) return;
    if (!(this.accountService.hasBookmarkRole() || this.accountService.hasAdminRole())) return;

    const pageNum = this.pageNum;
    // if canvasRenderer and doubleRenderer is undefined, then we are in webtoon mode
    const canvasRenderer = this.canvasRenderer();
    const doubleRenderer = this.doubleRenderer();
    const isDouble = canvasRenderer !== undefined && doubleRenderer !== undefined && Math.max(canvasRenderer.getBookmarkPageCount(), this.singleRenderer()!.getBookmarkPageCount(),
      doubleRenderer.getBookmarkPageCount(), this.doubleReverseRenderer()!.getBookmarkPageCount(), this.doubleNoCoverRenderer()!.getBookmarkPageCount()) > 1;

    if (this.CurrentPageBookmarked) {
      const apis = [this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];

      if (isDouble) {
        apis.push(this.readerService.unbookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
      }

      forkJoin(apis).subscribe(() => {
        delete this.bookmarks[pageNum];

        if (isDouble) delete this.bookmarks[pageNum + 1];

        this.cdRef.detectChanges();
      });

    } else {
      const apis = [this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum)];

      if (isDouble) {
        apis.push(this.readerService.bookmark(this.seriesId, this.volumeId, this.chapterId, pageNum + 1));
      }

      forkJoin(apis).subscribe(() => {
        this.bookmarks[pageNum] = this.chapterInfo()?.chapterTitle ?? '';

        if (isDouble) this.bookmarks[pageNum + 1] = this.chapterInfo()?.chapterTitle ?? '';

        this.cdRef.detectChanges();
      });
    }

    // Show an effect on the image to show that it was bookmarked
    this.showBookmarkEffectEvent.next(pageNum);
  }

  // This is menu only code
  /**
   * Turns off Incognito mode. This can only happen once if the user clicks the icon. This will modify URL state
   */
  turnOffIncognito() {
    this.incognitoMode = false;
    const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId);
    window.history.replaceState({}, '', newRoute);
    this.toastr.info(translate('toasts.incognito-off'));
    if (!this.bookmarkMode()) {
      this.readerService.saveProgress(this.libraryId, this.seriesId, this.volumeId, this.chapterId, this.pageNum).subscribe(() => {/* No operation */});
    }
  }
  // menu only code
  updateParentPref() {
    if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
      return;
    }

    this.readingProfileService.updateParentProfile(this.libraryId, this.seriesId, this.packReadingProfile()).subscribe(newProfile => {
      this.readingProfile = newProfile;
      this.toastr.success(translate('manga-reader.reading-profile-updated'));
      this.cdRef.markForCheck();
    });
  }

  createNewProfileFromImplicit() {
    if (this.readingProfile.kind !== ReadingProfileKind.Implicit) {
      return;
    }

    this.readingProfileService.promoteProfile(this.readingProfile.id).subscribe(newProfile => {
      this.readingProfile = newProfile;
      this.parentReadingProfile = newProfile; // Profile is no longer implicit
      this.toastr.success(translate("manga-reader.reading-profile-promoted"));
      this.cdRef.markForCheck();
    });
  }

  openShortcutModal() {
    this.readerService.openShortcutModal(KEYBIND_TARGETS);
  }

  translatePrefOptions(o: {text: string, value: any}) {
    const d = {...o};
    d.text = translate('preferences.' + o.text);
    return d;
  }

  private packReadingProfile(): ReadingProfile {
    const modelSettings = this.generalSettingsForm.getRawValue();
    const data = {...this.readingProfile!};

    data.layoutMode = parseInt(modelSettings.layoutMode, 10);
    data.readerMode = this.readerMode;
    data.autoCloseMenu = this.autoCloseMenu;
    data.readingDirection = this.readingDirection;
    data.emulateBook = modelSettings.emulateBook;
    data.swipeToPaginate = modelSettings.swipeToPaginate;
    data.pageSplitOption = parseInt(modelSettings.pageSplitOption, 10);
    data.widthOverride = modelSettings.widthSlider === 'none' ? null : modelSettings.widthSlider;

    return data;
  }

  protected readonly ReadingProfileKind = ReadingProfileKind;
}
