import {MusicPartManagerIterator} from "../MusicalScore/MusicParts/MusicPartManagerIterator";
import {MusicPartManager} from "../MusicalScore/MusicParts/MusicPartManager";
import {VoiceEntry} from "../MusicalScore/VoiceData/VoiceEntry";
import {VexFlowStaffEntry} from "../MusicalScore/Graphical/VexFlow/VexFlowStaffEntry";
import {MusicSystem} from "../MusicalScore/Graphical/MusicSystem";
import {OpenSheetMusicDisplay} from "./OpenSheetMusicDisplay";
import {GraphicalMusicSheet} from "../MusicalScore/Graphical/GraphicalMusicSheet";
import {Instrument} from "../MusicalScore/Instrument";
import {Note} from "../MusicalScore/VoiceData/Note";
import {Fraction} from "../Common/DataObjects/Fraction";
import { EngravingRules } from "../MusicalScore/Graphical/EngravingRules";
import { SourceMeasure } from "../MusicalScore/VoiceData/SourceMeasure";
import { StaffLine } from "../MusicalScore/Graphical/StaffLine";
import { GraphicalMeasure } from "../MusicalScore/Graphical/GraphicalMeasure";
import { VexFlowMeasure } from "../MusicalScore/Graphical/VexFlow/VexFlowMeasure";
import { CursorOptions } from "./OSMDOptions";
import { BoundingBox, GraphicalStaffEntry } from "../MusicalScore";
import { IPlaybackListener } from "../Common/Interfaces/IPlaybackListener";
import { CursorPosChangedData } from "../Common/DataObjects/CursorPosChangedData";
import { PointF2D } from "../Common/DataObjects";

/**
 * A cursor which can iterate through the music sheet.
 */
export class Cursor implements IPlaybackListener {
  constructor(container: HTMLElement, openSheetMusicDisplay: OpenSheetMusicDisplay, cursorOptions: CursorOptions) {
    this.container = container;
    this.openSheetMusicDisplay = openSheetMusicDisplay;
    this.rules = this.openSheetMusicDisplay.EngravingRules;
    this.cursorOptions = cursorOptions;

    // set cursor id
    // TODO add this for the OSMD object as well and refactor this into a util method?
    let id: number = 0;
    this.cursorElementId = "cursorImg-0";
    // find unique cursor id in document
    while (document.getElementById(this.cursorElementId)) {
      id++;
      this.cursorElementId = `cursorImg-${id}`;
    }

    const curs: HTMLElement = document.createElement("img");
    curs.id = this.cursorElementId;
    curs.style.position = "absolute";
    if (this.cursorOptions.follow === true) {
      curs.style.zIndex = "-1";
    } else {
      curs.style.zIndex = "-2";
    }
    this.cursorElement = <HTMLImageElement>curs;
    this.container.appendChild(curs);
  }
  public cursorPositionChanged(timestamp: Fraction, data: CursorPosChangedData): void {
    // if (this.iterator.CurrentEnrolledTimestamp.lt(timestamp)) {
    //   this.iterator.moveToNext();
    //   while (this.iterator.CurrentEnrolledTimestamp.lt(timestamp)) {
    //     this.iterator.moveToNext();
    //   }
    // } else if (this.iterator.CurrentEnrolledTimestamp.gt(timestamp)) {
    //   this.iterator = new MusicPartManagerIterator(this.manager.MusicSheet, timestamp);
    // }

    this.updateWithTimestamp(data.PredictedPosition);
  }
  public pauseOccurred(o: object): void {
    // throw new Error("Method not implemented.");
  }
  public selectionEndReached(o: object): void {
    // throw new Error("Method not implemented.");
  }
  public resetOccurred(o: object): void {
    this.reset();
  }
  public notesPlaybackEventOccurred(o: object): void {
    // throw new Error("Method not implemented.");
  }

  private container: HTMLElement;
  public cursorElement: HTMLImageElement;
  /** a unique id of the cursor's HTMLElement in the document.
   * Should be constant between re-renders and backend changes,
   * but different between different OSMD objects on the same page.
   */
  public cursorElementId: string;
  private openSheetMusicDisplay: OpenSheetMusicDisplay;
  private rules: EngravingRules;
  private manager: MusicPartManager;
  public iterator: MusicPartManagerIterator;
  private graphic: GraphicalMusicSheet;
  public hidden: boolean = false;
  public currentPageNumber: number = 1;
  private cursorOptions: CursorOptions;

  /** Initialize the cursor. Necessary before using functions like show() and next(). */
  public init(manager: MusicPartManager, graphic: GraphicalMusicSheet): void {
    this.manager = manager;
    this.graphic = graphic;
    this.reset();
    this.hidden = false;
  }

  /**
   * Make the cursor visible
   */
  public show(): void {
    this.hidden = false;
    //this.resetIterator(); // TODO maybe not here? though setting measure range to draw, rerendering, then handling cursor show is difficult
    this.update();
  }

  public resetIterator(): void {
    if (!this.openSheetMusicDisplay.Sheet || !this.openSheetMusicDisplay.Sheet.SourceMeasures) { // just a safety measure
      console.log("OSMD.Cursor.resetIterator(): sheet or measures were null/undefined.");
      return;
    }

    // set selection start, so that when there's MinMeasureToDraw set, the cursor starts there right away instead of at measure 1
    const lastSheetMeasureIndex: number = this.openSheetMusicDisplay.Sheet.SourceMeasures.length - 1; // last measure in data model
    let startMeasureIndex: number = this.rules.MinMeasureToDrawIndex;
    startMeasureIndex = Math.min(startMeasureIndex, lastSheetMeasureIndex);
    let endMeasureIndex: number = this.rules.MaxMeasureToDrawIndex;
    endMeasureIndex = Math.min(endMeasureIndex, lastSheetMeasureIndex);

    const updateSelectionStart: boolean = this.openSheetMusicDisplay.Sheet && (
      !this.openSheetMusicDisplay.Sheet.SelectionStart ||
      this.openSheetMusicDisplay.Sheet.SelectionStart.WholeValue < startMeasureIndex) &&
      this.openSheetMusicDisplay.Sheet.SourceMeasures.length > startMeasureIndex;
    if (updateSelectionStart) {
      this.openSheetMusicDisplay.Sheet.SelectionStart = this.openSheetMusicDisplay.Sheet.SourceMeasures[startMeasureIndex].AbsoluteTimestamp;
    }
    if (this.openSheetMusicDisplay.Sheet && this.openSheetMusicDisplay.Sheet.SourceMeasures.length > endMeasureIndex) {
      const lastMeasure: SourceMeasure = this.openSheetMusicDisplay.Sheet.SourceMeasures[endMeasureIndex];
      this.openSheetMusicDisplay.Sheet.SelectionEnd = Fraction.plus(lastMeasure.AbsoluteTimestamp, lastMeasure.Duration);
    }

    this.iterator = this.manager.getIterator();
  }

  private getStaffEntryFromVoiceEntry(voiceEntry: VoiceEntry): VexFlowStaffEntry {
    const measureIndex: number = voiceEntry.ParentSourceStaffEntry.VerticalContainerParent.ParentMeasure.measureListIndex;
    const staffIndex: number = voiceEntry.ParentSourceStaffEntry.ParentStaff.idInMusicSheet;
    return <VexFlowStaffEntry>this.graphic.findGraphicalStaffEntryFromMeasureList(staffIndex, measureIndex, voiceEntry.ParentSourceStaffEntry);
  }

  public updateWithTimestamp(timestamp: Fraction): void {
    const sheetTimestamp: Fraction = this.manager.absoluteEnrolledToSheetTimestamp(timestamp);

    const values: [number, MusicSystem, GraphicalStaffEntry] = this.graphic.calculateXPositionFromTimestamp(sheetTimestamp);
    const x: number = values[0];
    const currentSystem: MusicSystem = values[1];
    this.updateCurrentPageFromSystem(currentSystem);
    const previousStaffEntry: GraphicalStaffEntry = values[2];

    const topMeasureNumber: number = Math.max(previousStaffEntry.parentMeasure.MeasureNumber, 1);
    // we have to find the top measure, otherwise the cursor with type 3 "jumps around" between vertical measures
    let topMeasure: GraphicalMeasure;
    for (const measure of this.graphic.MeasureList[topMeasureNumber - 1]) {
      if (measure) {
        topMeasure = measure;
        break;
      }
    }

    const points: [PointF2D, PointF2D] = this.graphic.calculateCursorPoints(x, currentSystem);
    const y: number = points[0].y;
    const height: number = points[1].y - y;

    this.updateWidthAndStyle(topMeasure.PositionAndShape, x, y, height);

    if (this.openSheetMusicDisplay.FollowCursor) {
      const diff: number = this.cursorElement.getBoundingClientRect().top;
      this.cursorElement.scrollIntoView({behavior: diff < 1000 ? "smooth" : "auto", block: "center"});
    }
    // Show cursor
    // // Old cursor: this.graphic.Cursors.push(cursor);
    this.cursorElement.style.display = "";
  }

  public update(): void {
    if (this.hidden || this.hidden === undefined || this.hidden === null) {
      return;
    }
    this.updateCurrentPage(); // attach cursor to new page DOM if necessary

    // this.graphic?.Cursors?.length = 0;
    const iterator: MusicPartManagerIterator = this.iterator;
    // TODO when measure draw range (drawUpToMeasureNumber) was changed, next/update can fail to move cursor. but of course it can be reset before.

    const voiceEntries: VoiceEntry[] = iterator.CurrentVisibleVoiceEntries();
    if (iterator.EndReached || !iterator.CurrentVoiceEntries || voiceEntries.length === 0) {
      return;
    }
    let x: number = 0, y: number = 0, height: number = 0;
    let musicSystem: MusicSystem;
    if (iterator.CurrentMeasure.isReducedToMultiRest) {
      const multiRestGMeasure: GraphicalMeasure = this.graphic.findGraphicalMeasure(iterator.CurrentMeasureIndex, 0);
      const totalRestMeasures: number = multiRestGMeasure.parentSourceMeasure.multipleRestMeasures;
      const currentRestMeasureNumber: number = iterator.CurrentMeasure.multipleRestMeasureNumber;
      const progressRatio: number = currentRestMeasureNumber / (totalRestMeasures + 1);
      const effectiveWidth: number = multiRestGMeasure.PositionAndShape.Size.width - (multiRestGMeasure as VexFlowMeasure).beginInstructionsWidth;
      x = multiRestGMeasure.PositionAndShape.AbsolutePosition.x + (multiRestGMeasure as VexFlowMeasure).beginInstructionsWidth + progressRatio * effectiveWidth;

      musicSystem = multiRestGMeasure.ParentMusicSystem;
    } else {
          // get all staff entries inside the current voice entry
          const gseArr: VexFlowStaffEntry[] = voiceEntries.map(ve => this.getStaffEntryFromVoiceEntry(ve));
          // sort them by x position and take the leftmost entry
          const gse: VexFlowStaffEntry =
                gseArr.sort((a, b) => a?.PositionAndShape?.AbsolutePosition?.x <= b?.PositionAndShape?.AbsolutePosition?.x ? -1 : 1 )[0];
          x = gse.PositionAndShape.AbsolutePosition.x;
          musicSystem = gse.parentMeasure.ParentMusicSystem;

          // debug: change color of notes under cursor (needs re-render)
          // for (const gve of gse.graphicalVoiceEntries) {
          //   for (const note of gve.notes) {
          //     note.sourceNote.NoteheadColor = "#0000FF";
          //   }
          // }
    }
    if (!musicSystem) {
      return;
    }

    // y is common for both multirest and non-multirest, given the MusicSystem
    y = musicSystem.PositionAndShape.AbsolutePosition.y + musicSystem.StaffLines[0].PositionAndShape.RelativePosition.y;
    const bottomStaffline: StaffLine = musicSystem.StaffLines[musicSystem.StaffLines.length - 1];
    const endY: number = musicSystem.PositionAndShape.AbsolutePosition.y +
    bottomStaffline.PositionAndShape.RelativePosition.y + bottomStaffline.StaffHeight;
    height = endY - y;

    // Update the graphical cursor
    const measurePositionAndShape: BoundingBox = this.graphic.findGraphicalMeasure(iterator.CurrentMeasureIndex, 0).PositionAndShape;
    this.updateWidthAndStyle(measurePositionAndShape, x, y, height);

    if (this.openSheetMusicDisplay.FollowCursor) {
      if (!this.openSheetMusicDisplay.EngravingRules.RenderSingleHorizontalStaffline) {
        const diff: number = this.cursorElement.getBoundingClientRect().top;
        this.cursorElement.scrollIntoView({behavior: diff < 1000 ? "smooth" : "auto", block: "center"});
      } else {
        this.cursorElement.scrollIntoView({behavior: "smooth", inline: "center"});
      }
    }
    // Show cursor
    // // Old cursor: this.graphic.Cursors.push(cursor);
    this.cursorElement.style.display = "";
  }

  public updateWidthAndStyle(measurePositionAndShape: BoundingBox, x: number, y: number, height: number): void {
    const cursorElement: HTMLImageElement = this.cursorElement;
    let newWidth: number = 0;
    switch (this.cursorOptions.type) {
      case 1:
        cursorElement.style.top = (y * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.style.left = ((x - 1.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = 5 * this.openSheetMusicDisplay.zoom;
        break;
      case 2:
        cursorElement.style.top = ((y-2.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.style.left = (x * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.height = (1.5 * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = 5 * this.openSheetMusicDisplay.zoom;
        break;
      case 3:
        cursorElement.style.top = measurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.style.left = measurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = measurePositionAndShape.Size.width * 10 * this.openSheetMusicDisplay.zoom;
        break;
      case 4:
        cursorElement.style.top = measurePositionAndShape.AbsolutePosition.y * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.style.left = measurePositionAndShape.AbsolutePosition.x * 10.0 * this.openSheetMusicDisplay.zoom +"px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = (x-measurePositionAndShape.AbsolutePosition.x) * 10 * this.openSheetMusicDisplay.zoom;
        break;
        default:
        cursorElement.style.top = (y * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.style.left = ((x - 1.5) * 10.0 * this.openSheetMusicDisplay.zoom) + "px";
        cursorElement.height = (height * 10.0 * this.openSheetMusicDisplay.zoom);
        newWidth = 3 * 10.0 * this.openSheetMusicDisplay.zoom;
        break;
    }

    if (newWidth !== cursorElement.width) {
      cursorElement.width = newWidth;
      this.updateStyle(newWidth, this.cursorOptions);
    }
  }

  /**
   * Hide the cursor
   */
  public hide(): void {
    // Hide the actual cursor element
    this.cursorElement.style.display = "none";
    //this.graphic.Cursors.length = 0;
    // Forcing the sheet to re-render is not necessary anymore
    //if (!this.hidden) {
    //    this.openSheetMusicDisplay.render();
    //}
    this.hidden = true;
  }

  /**
   * Go to next entry
   */
  public next(): void {
    this.iterator.moveToNextVisibleVoiceEntry(false); // moveToNext() would not skip notes in hidden (visible = false) parts
    this.update();
  }

  /**
   * reset cursor to start
   */
  public reset(): void {
    this.resetIterator();
    //this.iterator.moveToNext();
    const iterTmp: MusicPartManagerIterator = this.manager.getIterator(this.graphic.ParentMusicSheet.SelectionStart);
    this.updateWithTimestamp(iterTmp.CurrentEnrolledTimestamp);
  }

  private updateStyle(width: number, cursorOptions: CursorOptions = undefined): void {
    if (cursorOptions !== undefined) {
      this.cursorOptions = cursorOptions;
    }
    // Create a dummy canvas to generate the gradient for the cursor
    // FIXME This approach needs to be improved
    const c: HTMLCanvasElement = document.createElement("canvas");
    c.width = this.cursorElement.width;
    c.height = 1;
    const ctx: CanvasRenderingContext2D = c.getContext("2d");
    ctx.globalAlpha = this.cursorOptions.alpha;
    // Generate the gradient
    const gradient: CanvasGradient = ctx.createLinearGradient(0, 0, this.cursorElement.width, 0);
    switch (this.cursorOptions.type) {
      case 1:
      case 2:
      case 3:
      case 4:
        gradient.addColorStop(1, this.cursorOptions.color);
        break;
      default:
        gradient.addColorStop(0, "white"); // it was: "transparent"
        gradient.addColorStop(0.2, this.cursorOptions.color);
        gradient.addColorStop(0.8, this.cursorOptions.color);
        gradient.addColorStop(1, "white"); // it was: "transparent"
      break;
    }
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, width, 1);
    // Set the actual image
    this.cursorElement.src = c.toDataURL("image/png");
  }

  public get Iterator(): MusicPartManagerIterator {
    return this.iterator;
  }

  public get Hidden(): boolean {
    return this.hidden;
  }

  /** returns voices under the current Cursor position. Without instrument argument, all voices are returned. */
  public VoicesUnderCursor(instrument?: Instrument): VoiceEntry[] {
    return this.iterator.CurrentVisibleVoiceEntries(instrument);
  }

  public NotesUnderCursor(instrument?: Instrument): Note[] {
    const voiceEntries: VoiceEntry[]  = this.VoicesUnderCursor(instrument);
    const notes: Note[] = [];
    voiceEntries.forEach(voiceEntry => {
      notes.push.apply(notes, voiceEntry.Notes);
    });
    return notes;
  }

  /** Check if there was a change in current page, and attach cursor element to the corresponding HTMLElement (div).
   *  This is only necessary if using PageFormat (multiple pages).
   */
  public updateCurrentPage(): number {
    const timestamp: Fraction = this.iterator.currentTimeStamp;
    for (const page of this.graphic.MusicPages) {
      const lastSystemTimestamp: Fraction = page.MusicSystems.last().GetSystemsLastTimeStamp();
      if (lastSystemTimestamp.gt(timestamp)) {
        // gt: the last timestamp of the last system is equal to the first of the next page,
        //   so we do need to use gt, not gte here.
        const newPageNumber: number = page.PageNumber;
        if (newPageNumber !== this.currentPageNumber) {
          this.container.removeChild(this.cursorElement);
          this.container = document.getElementById("osmdCanvasPage" + newPageNumber);
          this.container.appendChild(this.cursorElement);
          // TODO maybe store this.pageCurrentlyAttachedTo, though right now it isn't necessary
          // alternative to remove/append:
          // this.openSheetMusicDisplay.enableOrDisableCursor(true);
        }
        return this.currentPageNumber = newPageNumber;
      }
    }
    return 1;
  }

  public updateCurrentPageFromSystem(system: MusicSystem): number {
    if (system !== undefined) {
      const newPageNumber: number = system.Parent.PageNumber;
      if (newPageNumber !== this.currentPageNumber) {
        this.container.removeChild(this.cursorElement);
        this.container = document.getElementById("osmdCanvasPage" + newPageNumber);
        this.container.appendChild(this.cursorElement);
        // TODO maybe store this.pageCurrentlyAttachedTo, though right now it isn't necessary
        // alternative to remove/append:
        // this.openSheetMusicDisplay.enableOrDisableCursor(true);
      }
      return this.currentPageNumber = newPageNumber;
    }
    return 1;
  }
}