const fs = require("fs");
const path = require("path");
const archiver = require("archiver");
const { log } = require("./renderer");
const { TimingPoint } = require("./timingpoint");
const { BreakPeriod } = require("./breakperiod");
const { HitObject, HitCircle, Slider, Spinner, HoldNote } = require("./hitobject");
class OsuFile {
  /**
   * Reads an .osu file asynchronously.
   * @param {string} osupath
   * @returns {Promise<OsuFile>}
   */
  static fromFile(osupath) {
    return new Promise((resolve, reject) => {
      const filename = path.parse(osupath).base;
      const dirs = path.dirname(osupath).split(path.sep);
      const dirname = dirs.pop();
      const songsDirectory = path.join(...dirs);
      fs.readFile(osupath, (err, data) => {
        if (err)
          return reject(err);
        resolve(new OsuFile(data, filename, dirname, songsDirectory));
      });
    });
  }
  /**
   * Constucts a new OsuFile instance.
   * @param {Buffer} data
   * @param {string} filename
   * @param {string} dirname
   * @param {string} songsDirectory
   */
  constructor(data, filename, dirname, songsDirectory) {
    if (data)
      this.lines = data.toString("UTF-8").split("\n");
    this.filename = filename;
    this.dirname = dirname;
    this.songsDirectory = songsDirectory;
  }
  getVersion() {
    return parseInt(this.lines[0].match(/\d+/)[0]);
  }
  setVersion(version) {
    this.lines[0] = `osu file format v${version}`;
  }
  /**
   * Gets a property from an osu file.
   * @param {string} name the name of the property
   * @param {string=} defaultValue the default value
   */
  getProperty(name, defaultValue) {
    let line = this.lines.find(e => e.startsWith(name));
    return line ? line.slice(line.indexOf(":") + 1).trim() : defaultValue;
  }
  /**
   * Sets a property of an osu file.
   * @param {string} data the contents of the .osu file
   * @param {string} name the name of the property
   */
  setProperty(name, value) {
    let index = this.lines.findIndex(e => e.startsWith(name));
    if (index == -1)
      return -1;
    this.lines[index] = `${this.lines[index].slice(0, this.lines[index].indexOf(":"))}: ${value}`;
  }

  appendToDiffName(postfix) {
    this.filename = `${this.filename.substring(0, this.filename.lastIndexOf("]"))} ${postfix}].osu`;
  }

  getBreakEvents() {
    let eventsIndex = this.lines.findIndex(e => e.startsWith("//Break Periods"));
    let eventsEndIndex = this.lines.findIndex(e => e.startsWith("//Storyboard Layer 0 (Background)"));
    return this.lines
      .filter((e, i) => (eventsIndex < i && i < eventsEndIndex))
      .filter(e => e.trim() !== "")
      .map(e => BreakPeriod.fromString(e));
  }

  setBreakEvents(breakEvents) {
    let eventsIndex = this.lines.findIndex(e => e.startsWith("//Break Periods"));
    let eventsEndIndex = this.lines.findIndex(e => e.startsWith("//Storyboard Layer 0 (Background)"));
    this.lines.splice(
      eventsIndex + 1,
      eventsEndIndex - eventsIndex - 1,
      ...(breakEvents
        .sort((a, b) => (a.startTime - b.startTime))
        .map(e => e.toString())),
      "",
    );
  }

  getBreakEventAt(time) {
    return this.getBreakEvents()
      .reverse()
      .find(e => e.startTime <= time);
  }

  getTimingPoints() {
    let timingPointsIndex = this.lines.findIndex(e => e.startsWith("[TimingPoints]"));
    let timingPointsEndIndex = this.lines.findIndex((e, i) => i > timingPointsIndex && e.startsWith("["));
    return this.lines
      .filter((e, i) => (timingPointsIndex < i && i < timingPointsEndIndex))
      .filter(e => e.trim() !== "")
      .map(e => TimingPoint.fromString(e));
  }

  setTimingPoints(timingPoints) {
    let timingPointsIndex = this.lines.findIndex(e => e.startsWith("[TimingPoints]"));
    let timingPointsEndIndex = this.lines.findIndex((e, i) => i > timingPointsIndex && e.startsWith("["));
    this.lines.splice(
      timingPointsIndex + 1,
      timingPointsEndIndex - timingPointsIndex - 1,
      ...(timingPoints
        .sort((a, b) => (a.offset - b.offset))
        .map(e => e.toString())),
      "",
    );
  }

  getTimingPointAt(time) {
    let timingPoint = this.getTimingPoints()
      .reverse()
      .find(e => Math.floor(e.offset) <= time);

    if (timingPoint === undefined) {
      timingPoint = this.getTimingPoints()[0];
    }

    return timingPoint;
  }

  getBPMPointAt(time) {
    let bpmPoint = this.getTimingPoints()
      .filter(timingPoint => timingPoint.uninherited == 1)
      .reverse()
      .find(e => Math.floor(e.offset) <= time);

    if (bpmPoint === undefined) {
      bpmPoint = this.getTimingPoints()[0];
    }

    return bpmPoint;
  }

  getMainBPM() {
    let bpms = new Map();
    this.getTimingPoints().filter(point => point.msPerBeat > 0)
      .forEach((point, i, arr) => {
        let endTime = Infinity;
        if (i + 1 >= arr.length) {
          endTime = this.getHitObjects().pop().time;
        } else {
          endTime = arr[i + 1].offset;
        }
        let duration = endTime - point.offset;
        if (!bpms.has(point.msPerBeat)) {
          bpms.set(point.msPerBeat, 0);
        }
        bpms.set(point.msPerBeat, bpms.get(point.msPerBeat) + duration);
      });
    let mainBpm = 60000 / [...bpms.entries()]
      .reduce(([mainMsPerBeat, maxCount], [msPerBeat, count]) => {
        return count > maxCount ? [msPerBeat, count] : [mainMsPerBeat, maxCount];
      }, [0, 0])[0];
    return mainBpm;
  }

  getHitObjects() {
    let hitObjectsIndex = this.lines.findIndex(e => e.startsWith("[HitObjects]"));
    let hitObjectsEndIndex = this.lines.length;
    return this.lines
      .filter((e, i) => (hitObjectsIndex < i && i < hitObjectsEndIndex))
      .filter(e => e.trim() !== "")
      .map(e => HitObject.fromString(e));
  }

  /**
   * @param {HitObject[]} hitObjects
   */
  setHitObjects(hitObjects) {
    let hitObjectsIndex = this.lines.findIndex(e => e.startsWith("[HitObjects]"));
    let hitObjectsEndIndex = this.lines.length;
    this.lines.splice(
      hitObjectsIndex + 1,
      hitObjectsEndIndex - hitObjectsIndex - 1,
      ...(hitObjects
        .sort((a, b) => (a.time - b.time))
        .map(e => e.toString())),
      ""
    );
  }

  getComboAt(time) {
    const sliderMultiplier = parseFloat(this.getProperty("SliderMultiplier", "1.4"));
    const sliderTickRate = parseFloat(this.getProperty("SliderTickRate", "1"));
    const hitObjects = this.getHitObjects().filter(e => e.time < time);
    let combo = 0;
    for (const hitObject of hitObjects) {
      if (hitObject instanceof HitCircle) {
        combo += 1;
      } else if (hitObject instanceof Slider) {
        let svMultiplier = 1.0;
        let timingPoint = this.getTimingPointAt(hitObject.time);
        if (timingPoint.msPerBeat < 0) svMultiplier = -100.0 / timingPoint.msPerBeat;
        const epsilon = 0.1;
        let pixelsPerBeat = 0;
        if (this.getVersion() < 8) {
          pixelsPerBeat = sliderMultiplier * 100.0;
        } else {
          pixelsPerBeat = sliderMultiplier * 100.0 * svMultiplier;
        }
        let numBeats = hitObject.pixelLength * hitObject.repeat / pixelsPerBeat;
        let ticks = Math.ceil((numBeats - epsilon) / hitObject.repeat * sliderTickRate) - 1;
        ticks = Math.max(0, ticks);
        combo += ticks * hitObject.repeat;
        combo += hitObject.repeat;
        combo += 1;
      } else if (hitObject instanceof Spinner) {
        combo += 1;
      }
    }
    return combo;
  }


  toString() {
    return this.lines.join("\n");
  }

  generateOsu() {
    return new Promise((resolve, reject) => {
      const filename = path.join(this.songsDirectory, this.dirname, this.filename);
      fs.writeFile(filename, this.toString(), (err) => {
        if (err)
          return reject(err);
        resolve();
      });
    });
  }
  generateOsz(archiveCallback) {
    let oszFileName = path.join(this.songsDirectory, `${this.dirname}.osz`);
    let output = fs.createWriteStream(oszFileName);
    let archive = archiver("zip", {
      zlib: { level: 0 } // Sets the compression level.
    });
    archive.on("error", function (err) {
      throw err;
    });
    archive.pipe(output);
    if (archiveCallback)
      archiveCallback(archive);
    archive.append(this.toString(), {
      name: this.filename
    });
    return archive.finalize();
  }
  clone() {
    const copy = new OsuFile(null, this.filename, this.dirname, this.songsDirectory);
    copy.lines = this.lines.slice();
    return copy;
  }
}
exports.OsuFile = OsuFile;

async function setCS(osuFile, cs) {
  let difficulty = osuFile.getProperty("Version");
  let circleSize = osuFile.getProperty("CircleSize");
  if (parseFloat(circleSize) === cs) {
    throw new Error(`CS is already ${cs}!`);
  }
  // Required to fix older file formats
  if (!Number.isInteger(cs) && osuFile.getVersion() < 13)
    osuFile.setVersion(13);
  osuFile.setProperty("Version", `${difficulty} CS${cs}`);
  osuFile.setProperty("BeatmapID", 0);
  osuFile.setProperty("CircleSize", cs);
  osuFile.appendToDiffName(`CS${cs}`);
  return osuFile;
}
exports.setCS = setCS;

async function setAR(osuFile, ar) {
  let difficulty = osuFile.getProperty("Version");
  let approachRate = osuFile.getProperty("ApproachRate");
  if (!approachRate) {
    // For older map without AR, insert AR after OD.
    let odIndex = osuFile.lines.findIndex(e => e.startsWith("OverallDifficulty"));
    osuFile.lines.splice(odIndex + 1, 0, `ApproachRate:-1`);
    approachRate = -1;
  }
  if (parseFloat(approachRate) === ar) {
    throw new Error(`AR is already ${ar}!`);
  }
  // Required to fix older file formats
  if (!Number.isInteger(ar) && osuFile.getVersion() < 13)
    osuFile.setVersion(13);
  osuFile.setProperty("Version", `${difficulty} AR${ar}`);
  osuFile.setProperty("BeatmapID", 0);
  osuFile.setProperty("ApproachRate", ar);
  osuFile.appendToDiffName(`AR${ar}`);
  return osuFile;
}
exports.setAR = setAR;

async function setOD(osuFile, od) {
  let difficulty = osuFile.getProperty("Version");
  let overallDifficulty = osuFile.getProperty("OverallDifficulty");
  if (parseFloat(overallDifficulty) === od) {
    throw new Error(`OD is already ${od}!`);
  }
  // Required to fix older file formats
  if (!Number.isInteger(od) && osuFile.getVersion() < 13)
    osuFile.setVersion(13);
  osuFile.setProperty("Version", `${difficulty} OD${od}`);
  osuFile.setProperty("BeatmapID", 0);
  osuFile.setProperty("OverallDifficulty", od);
  osuFile.appendToDiffName(`OD${od}`);
  return osuFile;
}
exports.setOD = setOD;

async function setHP(osuFile, hp) {
  let difficulty = osuFile.getProperty("Version");
  let hpDrainRate = osuFile.getProperty("HPDrainRate");
  if (parseFloat(hpDrainRate) === hp) {
    throw new Error(`HP is already ${hp}!`);
  }
  // Required to fix older file formats
  if (!Number.isInteger(hp) && osuFile.getVersion() < 13)
    osuFile.setVersion(13);
  osuFile.setProperty("Version", `${difficulty} HP${hp}`);
  osuFile.setProperty("BeatmapID", 0);
  osuFile.setProperty("HPDrainRate", hp);
  osuFile.appendToDiffName(`HP${hp}`);
  return osuFile;
}
exports.setHP = setHP;

/**
 * Scales the timing of entries in the osu file to the rate.
 * @param {OsuFile} osuFile
 * @param {number} rate
 */
async function setRate(osuFile, rate) {
  let difficulty = osuFile.getProperty("Version");
  let previewTime = parseInt(osuFile.getProperty("PreviewTime"));
  let sliderMultiplier = parseFloat(osuFile.getProperty("SliderMultiplier"));
  osuFile.setProperty("Version", `${difficulty} ${rate}x`);
  osuFile.setProperty("PreviewTime", Math.round(previewTime / rate));
  osuFile.setProperty("BeatmapID", 0);
  let breaksIndex = osuFile.lines.findIndex(e => e.startsWith("//Break Periods"));
  let breaksEndIndex = osuFile.lines.findIndex(e => e.startsWith("//Storyboard Layer 0"));

  osuFile.lines = osuFile.lines.map((l, index) => {
    if (l.trim() !== "") {
      // is a break
      if ((index > breaksIndex && index < breaksEndIndex)) {
        let [n, start, end] = l.split(",");
        return [n, Math.round(parseInt(start) / rate), Math.round(parseInt(end) / rate)].join(",");
      }
    }
    return l;
  });

  let timingPoints = osuFile.getTimingPoints();
  for (let point of timingPoints) {
    if (point.msPerBeat > 0) {
      point.msPerBeat = point.msPerBeat / rate;
    }
    point.offset = Math.round(point.offset / rate);
  };
  osuFile.setTimingPoints(timingPoints);

  let hitObjects = osuFile.getHitObjects();
  for (let object of hitObjects) {
    if (object instanceof Spinner || object instanceof HoldNote) {
      object.endTime = Math.round(object.endTime / rate);
    }
    object.time = Math.round(object.time / rate);
  }
  osuFile.setHitObjects(hitObjects);

  osuFile.appendToDiffName(`${rate}x`);
  return osuFile;
}
exports.setRate = setRate;

/**
 * Splits the hit objects of a file by bookmarks.
 * @param {OsuFile} osuFile
 */
async function splitByBookmarks(osuFile) {
  let difficulty = osuFile.getProperty("Version");
  if (!difficulty.includes("(Split)")) {
    throw new Error("The map is not marked for split.");
  }
  difficulty = `${difficulty.split("(Split)")[0].trim()}`;
  const bookmarks = (osuFile.getProperty("Bookmarks") || "")
    .split(",")
    .filter(e => (e.trim().length > 0))
    .map(e => parseInt(e));
  if (bookmarks.length == 0) {
    throw new Error("No bookmarks set!.");
  }
  const sections = [0, ...bookmarks.sort((a, b) => (a - b)), Infinity];
  const files = [];
  for (let i = 2; i < sections.length; i++) {
    const start = sections[i - 1] - 1;
    const sectionFile = osuFile.clone();
    sectionFile.setProperty("Version", `${difficulty} (${i}/${sections.length - 1})`);
    sectionFile.setProperty("BeatmapID", 0);
    sectionFile.setProperty("Countdown", 3);
    sectionFile.appendToDiffName(i);
    sectionFile.setHitObjects(sectionFile.getHitObjects()
      .filter(({ time }) => (start <= time)));
    files.push(sectionFile);
  }
  return files;
}
exports.splitByBookmarks = splitByBookmarks;

/**
 * Adds invisible spinners of combo (or buzz slider) to the beginning of the osu file.
 * @param {OsuFile} osuFile
 * @param {number} combo
 * @param {boolean} useBuzzSlider
 * @param {number} revPerSecond
 */
async function addCombo(osuFile, combo, useBuzzSlider, revPerSecond) {
  osuFile.setProperty("Version", `${osuFile.getProperty("Version")} +${combo}x`);
  osuFile.setProperty("BeatmapID", 0);
  osuFile.setProperty("Countdown", 3);
  osuFile.appendToDiffName(`+${combo}x`);
 
  if (combo <= 1) {
    return osuFile;
  }

  const waitTime = 800;
  let hitObjects = osuFile.getHitObjects();
  let firstObject = hitObjects[0];

  // Remove unnecessary break periods that might cause bugs
  // Find the last break with startTime less than firstObject.time. That startTime value assign to 0. Remove every other break period that goes before that.

  let breakEvent = osuFile.getBreakEventAt(firstObject.time);
  if (breakEvent !== undefined) {
    let breakEvents = osuFile.getBreakEvents()
        .filter(e => e.startTime > breakEvent.startTime);

      breakEvent.startTime = 0;
      breakEvents.push(breakEvent);

      osuFile.setBreakEvents(breakEvents);
  }

  // Add the combo object (either buzz slider or spinners)

  if (useBuzzSlider) {
      let repeatCount = combo - 1;
      let sliderLength = 1000 * (parseFloat(repeatCount) / revPerSecond);
      let sliderWaitTime = sliderLength + waitTime;

      let sliderTime = firstObject.time - sliderWaitTime;
      let BPMPoint = osuFile.getBPMPointAt(sliderTime);
      let beatCount = Math.floor((sliderTime - BPMPoint.offset) / BPMPoint.msPerBeat);
      sliderTime = Math.round(BPMPoint.offset + (BPMPoint.msPerBeat * beatCount));
      if (sliderTime < 0) {
        throw new Error("The first bookmark is too early on the timeline!");
      }

      let timingPoint = osuFile.getTimingPointAt(sliderTime);

      let timingSM = (timingPoint.uninherited == 0) ? timingPoint.msPerBeat : -100.0;
      let sliderMultiplier = parseFloat(osuFile.getProperty("SliderMultiplier"));
      let pixelLength = (100.0 / revPerSecond) * sliderMultiplier * (-100.0 / timingSM) * (1000.0 / BPMPoint.msPerBeat);

      hitObjects.push(new Slider(firstObject.x, firstObject.y, sliderTime, 2, 0, 'L', [`${firstObject.x}:${(firstObject.y + 1)}`], repeatCount, pixelLength));
  }
  else {
    let spinnerTime = Math.round(firstObject.time - Math.max(combo * 5.5, waitTime));
    if (spinnerTime < 0) {
      throw new Error("The first bookmark is too early on the timeline!");
    }

    let spinners = [];
    for (let i = 0; i < combo; i++) {
      hitObjects.push(new Spinner(256, 192, spinnerTime, 12, 0, spinnerTime, "0:0:0:0:"));
    }
  }
  
  osuFile.setHitObjects(hitObjects);

  let comboObject = osuFile.getHitObjects()[0];
  let timingPoints = osuFile.getTimingPoints();

  // -------------------------------------
  // Make the combo object silent.

  // Create a silent timing point at the combo object.
  // If the first timing point is AFTER the combo object (which could only be uninherited) -> add one right on that object
  let lastTimingPoint = osuFile.getTimingPointAt(comboObject.time);
  if (lastTimingPoint.offset > comboObject.time) {
    let redTimingPoint = lastTimingPoint.clone();
    redTimingPoint.offset = comboObject.time;

    timingPoints.push(redTimingPoint);
  }
  else if (lastTimingPoint.offset < comboObject.time) {
    let silentPoint = lastTimingPoint.clone();
    silentPoint.offset = comboObject.time;

    if (lastTimingPoint.uninherited) {
      silentPoint.msPerBeat = -100;
      silentPoint.uninherited = 0;
    }

    timingPoints.push(silentPoint);
  }

  // Create a timing point at the first main object.
  lastTimingPoint = osuFile.getTimingPointAt(firstObject.time);
  if (lastTimingPoint.offset !== firstObject.time) {
      let startPoint = lastTimingPoint.clone();
      startPoint.offset = firstObject.time;

      if (lastTimingPoint.uninherited) {
        startPoint.msPerBeat = -100;
        startPoint.uninherited = 0;
      }

      timingPoints.push(startPoint);
  }

  // Make the timing points between the combo object and the first object silent.
  for (let timingPoint of timingPoints) {
    if ((timingPoint.offset >= comboObject.time) && (timingPoint.offset < firstObject.time)) {
      timingPoint.volume = 0;
    }
  }

  osuFile.setTimingPoints(timingPoints)
  
  return osuFile;
}
exports.addCombo = addCombo;

/**
 * Removes SVs from an osu file.
 * @param {OsuFile} osuFile
 */
async function removeSVs(osuFile) {
  if (osuFile.getProperty("Version").includes("No SVs")) {
    throw new Error("Map already has no SVs!");
  }
  osuFile.setProperty("Version", `${osuFile.getProperty("Version")} No SVs`);
  osuFile.setProperty("BeatmapID", 0);

  let mainBpm = osuFile.getMainBPM();
  let currentBpm = mainBpm;

  let timingPoints = osuFile.getTimingPoints();
  timingPoints.forEach((point) => {
    const bpm = 60000 / point.msPerBeat;
    if (point.msPerBeat < 0) {
      point.msPerBeat = -100 * currentBpm / mainBpm;
    }
    else {
      currentBpm = bpm;
      let newPoint = point.clone()
      newPoint.uninherited = 0;
      newPoint.msPerBeat = -100 * currentBpm / mainBpm;
      timingPoints.push(newPoint);
    }
  });
  osuFile.setTimingPoints(timingPoints);
  osuFile.appendToDiffName("No SVs");
  return osuFile;
}
exports.removeSVs = removeSVs;

/**
 * Replaces mania hold notes with hit circles in an osu file.
 * @param {OsuFile} osuFile
 */
async function removeLNs(osuFile) {
  if (osuFile.getProperty("Version").includes("No LNs")) {
    throw new Error("Map already has no LNs!");
  }
  osuFile.setProperty("Version", `${osuFile.getProperty("Version")} No LNs`);
  osuFile.setProperty("BeatmapID", 0);
  let hitObjectsIndex = osuFile.lines.findIndex(e => e.startsWith("[HitObjects]"));
  osuFile.setHitObjects(osuFile.getHitObjects().map(object => {
    if (object instanceof HoldNote) {
      let { x, y, time, type, hitSound, extras } = object;
      return new HitCircle(x, y, time, type & ~128 | 1, hitSound, extras);
    }
    return object
  }));
  osuFile.appendToDiffName("No LNs");
  return osuFile;
}
exports.removeLNs = removeLNs;
