import {HitResult, LegacyReplayFrame, Replay, Score, Vector2} from "osu-classes";
import {BeatmapDecoder, BeatmapEncoder} from "osu-parsers";
import {
  Circle,
  Slider,
  Spinner,
  StandardBeatmap,
  StandardHardRock,
  StandardHitObject,
  StandardModCombination,
  StandardRuleset,
} from "osu-standard-stable";
import {Drawer} from "./Drawer";
import {Vec2} from "@osujs/math";
import {clamp, getBeatmap, getReplay} from "@/utils";
import EventEmitter from "eventemitter3";
import {toast} from "sonner";
import p5 from "p5";

export enum OsuRendererEvents {
  UPDATE = "UPDATE",
  LOAD = "LOAD",
  PLAY = "PLAY",
  TIME = "TIME",
  SPEED = "SPEED",
  SETTINGS = "SETTINGS",
}

interface Judgements {
  x: number;
  y: number;
  time: number;
  error: number;
  type: string;
}

export interface OsuRendererSettings {
  showCursorPath: boolean;
  showFutureCursorPath: boolean;
  showKeyPress: boolean;
}

export class OsuRendererBridge extends EventEmitter {
  constructor() {
    super();
  }
}

export class OsuRenderer {
  private static preempt: number;
  private static fadeIn: number;
  private static lastRender: number = Date.now();


  static playing: boolean = false;

  static event = new OsuRendererBridge();

  static judgements: Judgements[] = [];
  static judgements2: Judgements[] = [];

  static speedMultiplier = 1;
  static settings: OsuRendererSettings = {
    showCursorPath: true,
    showFutureCursorPath: false,
    showKeyPress: true,
  };

  static cvUR = 0;
  static totalMisses = 0;
  static totalThreeHundreds = 0;
  static totalHundreds = 0;
  static totalFifties = 0;

  static time: number = 0;

  static beatmap: StandardBeatmap;
  static og_beatmap: StandardBeatmap;

  static replay: Score;
  static replay2: Score | undefined;

  static og_replay_mods: StandardModCombination;
  static forceHR: boolean | undefined = undefined;

  static loaded: boolean = false;

  static pathWindow: number = 500;

  static purge() {
    this.replay = undefined as any;
    this.beatmap = undefined as any;
  }

  /**
   * Returns the cvUR of the current replay, rounded to 2 decimals.
   */
  static updateStatistics() {
    let pastJudgements = this.judgements.filter(j => j.time < this.time);
    if(pastJudgements.length === 0) {
        this.cvUR = 0;
        return;
    }

    // Calculate 300x, 100x, 50x, Misses
    this.totalMisses = pastJudgements.filter(j => j.type == "MISS").length;
    this.totalThreeHundreds = pastJudgements.filter(j => j.type == "THREE_HUNDRED").length;
    this.totalHundreds = pastJudgements.filter(j => j.type == "ONE_HUNDRED").length;
    this.totalFifties = pastJudgements.filter(j => j.type == "FIFTY").length;

    // UR is equal to the standard deviation of all the hit errors
    let hitErrors = pastJudgements.map(j => j.error);
    let mean = hitErrors.reduce((a, b) => a + b, 0) / hitErrors.length;
    let variance = hitErrors.map(e => Math.pow(e - mean, 2)).reduce((a, b) => a + b, 0) / hitErrors.length;
    let UR = Math.sqrt(variance) * 10;

    // To obtain the cvUR, we need to account for mods like DT and HT
    if(this.beatmap.mods.bitwise & 64) {
        UR /= 1.5;
    }

    if(this.beatmap.mods.bitwise & 256) {
        UR /= 0.75;
    }

    this.cvUR = Math.round(UR * 100) / 100;
  }

  static render() {
    if (!this.beatmap || !this.replay) return;

    if (this.time >= this.replay.replay!.length - 500)
      this.time = this.replay.replay!.length - 500;

    for (let i = this.beatmap.hitObjects.length - 1; i >= 0; i--) {
      this.renderObject(this.beatmap.hitObjects[i]);
    }

    if (this.playing) {
      this.setTime(this.time + ((Date.now() - this.lastRender) * this.speedMultiplier));
      this.updateStatistics();
    }

    this.lastRender = Date.now();

    this.renderPath(this.replay.replay!, Drawer.images.cursor);
    if(this.replay2) {
        this.renderPath(this.replay2.replay!, Drawer.images.cursor2);
    }

    Drawer.drawField();
  }

  static getOptions() {
    return {
      replay: this.replay,
      mods: this.replay.info.mods?.all,
      beatmap: this.og_beatmap,
    };
  }

  static getCurrentDifficulty() {
    return {
      AR: OsuRenderer.og_beatmap?.difficulty?.approachRate,
      CS: OsuRenderer.og_beatmap?.difficulty?.circleSize,
      OD: OsuRenderer.og_beatmap?.difficulty?.overallDifficulty,
    };
  }

  static setMetadata({ AR, CS, OD }: { AR: number; CS: number; OD: number }) {
    this.og_beatmap.difficulty.approachRate = AR;
    this.og_beatmap.difficulty.circleSize = CS;
    this.og_beatmap.difficulty.overallDifficulty = OD;
    const tempClone = this.og_beatmap.clone();
    let sendMods = this.replay.info.mods as StandardModCombination;
    const hasHardrock = sendMods.all.some((e) => e instanceof StandardHardRock);

    if (this.forceHR !== undefined) {
      if (hasHardrock !== this.forceHR) {
        this.replay.replay?.frames.forEach((frame) => {
          const f = frame as LegacyReplayFrame;
          f.position = new Vector2(f.position.x, 384 - f.position.y);
        });
      }
      if (this.forceHR) {
        if (!hasHardrock) {
          //@ts-ignore
          sendMods._mods.push(new StandardHardRock());
        }
      } else {
        //@ts-ignore
        sendMods._mods = sendMods._mods.filter(
          //@ts-ignore
          (e) => !(e instanceof StandardHardRock)
        );
      }
    }

    this.beatmap = this.recreateBeatmap(tempClone, sendMods);
    // GameplayAnalyzer.createBucket(this.replay, this.beatmap);

    let fadeIn: number;
    let preempt: number;
    if (this.beatmap!.difficulty.approachRate <= 5) {
      fadeIn = 800 + (400 * (5 - this.beatmap!.difficulty.approachRate)) / 5;
      preempt = 1200 + (600 * (5 - this.beatmap!.difficulty.approachRate)) / 5;
    } else {
      fadeIn = 800 - (500 * (this.beatmap!.difficulty.approachRate - 5)) / 5;
      preempt = 1200 - (750 * (this.beatmap!.difficulty.approachRate - 5)) / 5;
    }
    this.preempt = preempt;
    this.fadeIn = fadeIn;
    this.event.emit(OsuRendererEvents.UPDATE);
  }

  static setPlaying(state: boolean) {
    this.playing = state;
    this.event.emit(OsuRendererEvents.PLAY);
  }

  static refreshMetadata() {
    OsuRenderer.setMetadata({
      AR: OsuRenderer.og_beatmap.difficulty.approachRate,
      CS: OsuRenderer.og_beatmap.difficulty.circleSize,
      OD: OsuRenderer.og_beatmap.difficulty.overallDifficulty,
    });
  }

  static getApiUrl(): string {
    return document.location.hostname === "localhost"
      ? `http://localhost:8080`
      : `https://nise.moe/api`;
  }

  static async loadReplayPairFromUrl(replayId1: number, replayId2: number) {
    OsuRenderer.purge();

    const apiUrl = `${this.getApiUrl()}/pair/${replayId1}/${replayId2}/replay`;

    const response = await fetch(apiUrl, {
      headers: {
        'X-NISE-REPLAY': '20240303'
      }
    });

    let data;

    if (!response.ok) {
      toast.error("Failed to load replay :(");
      return Promise.reject();
    } else {
      data = await response.json();
    }

    const { beatmap, replay1, replay2, mods, judgements1, judgements2 } = data;

    // Load replays
    const i_replay1 = await getReplay(replay1);
    i_replay1.info.id = replayId1;
    i_replay1.info.rawMods = mods;
    i_replay1.info.mods = new StandardModCombination(mods);

    const i_replay2 = await getReplay(replay2);
    i_replay2.info.id = replayId2;
    i_replay2.info.rawMods = mods;
    i_replay2.info.mods = new StandardModCombination(mods);

    const i_beatmap = await getBeatmap(beatmap, i_replay1);

    OsuRenderer.setPairOptions(i_beatmap, i_replay1, i_replay2, judgements1, judgements2);
    this.event.emit(OsuRendererEvents.LOAD);
  }

  static async loadReplayFromUrl(replayId: number) {
    OsuRenderer.purge();

    const url = `${this.getApiUrl()}/score/${replayId}/replay`;

    const response = await fetch(url, {
      headers: {
        'X-NISE-REPLAY': '20240303'
      }
    });

    let data;

    if (!response.ok) {
      toast.error("Failed to load replay :(");
      return Promise.reject();
    } else {
      data = await response.json();
    }

    const { beatmap, replay, mods, judgements } = data;

    const i_replay = await getReplay(replay);
    i_replay.info.id = replayId;
    i_replay.info.rawMods = mods;
    i_replay.info.mods = new StandardModCombination(mods);

    const i_beatmap = await getBeatmap(beatmap, i_replay);

    OsuRenderer.setOptions(i_beatmap, i_replay, judgements);
    this.event.emit(OsuRendererEvents.LOAD);
  }

  static setPairOptions(beatmap: StandardBeatmap, replay1: Score, replay2: Score, judgements1: Judgements[], judgements2: Judgements[]) {
    this.judgements = judgements1;
    this.judgements2 = judgements2;
    this.forceHR = undefined;
    this.replay = replay1;
    this.replay2 = replay2;
    this.beatmap = beatmap;
    this.og_beatmap = beatmap.clone();
    this.og_replay_mods = replay1.info.mods?.clone() as StandardModCombination;
    this.setMetadata({
      AR: this.beatmap.difficulty.approachRate,
      CS: this.beatmap.difficulty.circleSize,
      OD: this.beatmap.difficulty.overallDifficulty,
    });
  }

  static setOptions(beatmap: StandardBeatmap, replay: Score, judgements: Judgements[] ) {
    this.judgements = judgements;
    this.forceHR = undefined;
    this.replay = replay;
    this.beatmap = beatmap;
    this.og_beatmap = beatmap.clone();
    this.og_replay_mods = replay.info.mods?.clone() as StandardModCombination;
    this.setMetadata({
      AR: this.beatmap.difficulty.approachRate,
      CS: this.beatmap.difficulty.circleSize,
      OD: this.beatmap.difficulty.overallDifficulty,
    });
  }

  static setTime(time: number) {
    this.time = clamp(time, 0, OsuRenderer.replay.replay?.length || 0);
    this.event.emit(OsuRendererEvents.TIME);
  }

  static setSpeed(speed: number) {
    this.speedMultiplier = speed;
    this.event.emit(OsuRendererEvents.SPEED);
  }

  private static renderObject(hitObject: StandardHitObject) {
    if (hitObject instanceof Circle) {
      this.renderCircle(hitObject);
    }

    if (hitObject instanceof Slider) {
      this.renderSlider(hitObject);
    }

    if (hitObject instanceof Spinner) {
      // TODO
      // this.renderSpinner(hitObject);
    }
  }

  private static calculateEffects(hitObject: StandardHitObject) {
    let vEndTime = hitObject.startTime;

    if (hitObject instanceof Spinner) {
      vEndTime = hitObject.endTime + 25;
    }

    const fadeOut = Math.max(
      0.0,
      (this.time - vEndTime) / hitObject.hitWindows.windowFor(HitResult.Meh)
    );

    let opacity = Math.max(
      0.0,
      Math.min(
        1.0,
        Math.min(
          1.0,
          (this.time - hitObject.startTime + this.preempt) / this.fadeIn
        ) - fadeOut
      )
    );

    const arScale = Math.max(
      1,
      ((hitObject.startTime - this.time) / this.preempt) * 3.0 + 1.0
    );

    let visible =
      this.time > hitObject.startTime - this.preempt &&
      this.time < vEndTime + hitObject.hitWindows.windowFor(HitResult.Meh);

    return {
      opacity,
      arScale,
      visible,
    };
  }

  private static calculateSliderEffects(hitObject: Slider) {
    let sliderCircleEndTime = hitObject.startTime;
    let vEndTime = hitObject.endTime + 25;

    const sliderCircleFadeOut = Math.max(
        0.0,
        (this.time - sliderCircleEndTime) / 375
    );

    const fadeOut = Math.max(
        0.0,
        (this.time - vEndTime) / hitObject.hitWindows.windowFor(HitResult.Meh)
    );

    let sliderCircleOpacity = Math.max(
        0.0,
        Math.min(
            1.0,
            Math.min(
                1.0,
                (this.time - hitObject.startTime + this.preempt) / this.fadeIn
            ) - sliderCircleFadeOut
        )
    );

    let opacity = Math.max(
        0.0,
        Math.min(
            1.0,
            Math.min(
                1.0,
                (this.time - hitObject.startTime + this.preempt) / this.fadeIn
            ) - fadeOut
        )
    );

    const arScale = Math.max(
        1,
        ((hitObject.startTime - this.time) / this.preempt) * 3.0 + 1.0
    );

    let visible =
        this.time > hitObject.startTime - this.preempt &&
        this.time < vEndTime + hitObject.hitWindows.windowFor(HitResult.Meh);

    if (this.time > hitObject.endTime) {
      opacity -= (this.time - hitObject.endTime) / 25;
    }

    return {
      opacity,
      sliderCircleOpacity,
      arScale,
      visible,
    };
  }

  private static renderCircle(hitObject: Circle) {
    if (hitObject.startTime > this.time + 10000) return;
    const { arScale, opacity, visible } = this.calculateEffects(hitObject);

    if (!visible) return;
    Drawer.beginDrawing();

    Drawer.setDrawingOpacity(opacity);

    Drawer.drawApproachCircle(
      hitObject.stackedStartPosition,
      hitObject.radius,
      arScale
    );
    Drawer.drawHitCircle(
      hitObject.stackedStartPosition,
      hitObject.radius,
      hitObject.currentComboIndex
    );

    // if (GameplayAnalyzer.renderJudgements[hitObject.startTime]) {
    //   Drawer.setDrawingOpacity(opacity / 2);
    //
    //   Drawer.drawCircleJudgement(
    //     hitObject.stackedStartPosition,
    //     hitObject.radius,
    //     GameplayAnalyzer.renderJudgements[hitObject.startTime]
    //   );
    // }
    Drawer.endDrawing();
    return arScale;
  }

  private static renderSlider(hitObject: Slider) {
    if (hitObject.endTime > this.time + 10000) return;

    const { arScale, opacity, sliderCircleOpacity, visible } = this.calculateSliderEffects(hitObject);

    if (!visible) return;
    Drawer.beginDrawing();
    Drawer.setDrawingOpacity(opacity * 0.8);

    Drawer.drawSliderBody(
      hitObject.stackedStartPosition,
      hitObject.path.path,
      hitObject.radius
    );
    Drawer.drawApproachCircle(
      hitObject.stackedStartPosition,
      hitObject.radius,
      arScale
    );
    Drawer.setDrawingOpacity(sliderCircleOpacity);
    Drawer.drawHitCircle(
      hitObject.stackedStartPosition,
      hitObject.radius,
      hitObject.currentComboIndex
    );
    Drawer.setDrawingOpacity(opacity * 0.8);
    let progress = (this.time - hitObject.startTime) / hitObject.duration;
    let position = hitObject.stackedStartPosition.add(
      hitObject.path.curvePositionAt(progress, hitObject.repeats + 1)
    );

    if (hitObject.repeats == 0) {
      position = hitObject.stackedStartPosition.add(
        hitObject.path.positionAt(progress)
      );
    }

    let sliderPos = new Vector2(position.x, position.y);

    if (this.time > hitObject.startTime && this.time < hitObject.endTime) {
      Drawer.drawSliderFollowPoint(sliderPos, hitObject.radius);
    }

    Drawer.endDrawing();
    return arScale;
  }

  private static renderPath(replay: Replay, cursorImage: p5.Image) {
    const frames = replay.frames as LegacyReplayFrame[];
    const renderFrames: {
      position: Vector2;
      time: number;
      button: {
        mouseLeft1: boolean;
        mouseLeft2: boolean;
        mouseRight1: boolean;
        mouseRight2: boolean;
      };
    }[] = [];
    let lastFrame: LegacyReplayFrame | undefined;
    let cursorPushed: any = false;
    for (const frame of frames) {
      if (frame.startTime > this.time) {
        if (!cursorPushed)
          cursorPushed = {
            position: this.interpolateReplayPosition(
              lastFrame ? lastFrame : frame,
              frame,
              this.time
            ),
            button: {
              mouseLeft1: frame.mouseLeft1,
              mouseLeft2: frame.mouseLeft2,
              mouseRight1: frame.mouseRight1,
              mouseRight2: frame.mouseRight2,
            },
          };
        if (frame.startTime > this.time + this.pathWindow) break;
      }
      if (frame.startTime < this.time - this.pathWindow) {
        continue;
      }
      lastFrame = frame;
      renderFrames.push({
        position: frame.position,
        time: frame.startTime,
        button: {
          mouseLeft1: frame.mouseLeft1,
          mouseLeft2: frame.mouseLeft2,
          mouseRight1: frame.mouseRight1,
          mouseRight2: frame.mouseRight2,
        },
      });
    }

    Drawer.drawCursorPath(cursorImage, renderFrames, cursorPushed);

    return cursorPushed;
  }

  private static interpolateReplayPosition(
    fA: LegacyReplayFrame,
    fB: LegacyReplayFrame,
    time: number
  ): Vector2 {
    if (fB.startTime === fA.startTime) {
      return fA.position;
    } else {
      const p = (time - fA.startTime) / (fB.startTime - fA.startTime);
      const { x, y } = Vec2.interpolate(fA.position, fB.position, p);
      return new Vector2(x, y);
    }
  }

  private static recreateBeatmap(
    beatmap: StandardBeatmap,
    mods: StandardModCombination
  ) {
    const beatmapDecoder = new BeatmapDecoder();
    const beatmapEncoder = new BeatmapEncoder();
    const ruleset = new StandardRuleset();

    const bp = beatmapDecoder.decodeFromString(
      beatmapEncoder.encodeToString(beatmap)
    );
    return ruleset.applyToBeatmapWithMods(bp, mods as StandardModCombination);
  }

}
