using Cryville.Crtr.Browsing;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;

namespace Cryville.Crtr.Extensions.osu {
#pragma warning disable IDE1006
	public class osuChartConverter : ResourceConverter {
#pragma warning restore IDE1006
		static readonly string[] SUPPORTED_FORMATS = { ".osu" };
		const double OFFSET = 0.07;

		public override string[] GetSupportedFormats() {
			return SUPPORTED_FORMATS;
		}

		public override IEnumerable<Resource> ConvertFrom(FileInfo file) {
			List<Resource> result = new List<Resource>();
			var meta = new ChartMeta { song = new SongMetaInfo() };
			var group = new Chart.Group() {
				tracks = new List<Chart.Track>(),
				notes = new List<Chart.Note>(),
				motions = new List<Chart.Motion>(),
			};
			var chart = new Chart {
				format = 2,
				time = new BeatTime(-4, 0, 1),
				sigs = new List<Chart.Signature>(),
				sounds = new List<Chart.Sound>(),
				motions = new List<Chart.Motion>(),
				groups = new List<Chart.Group> { group },
			};
			var diff = new DifficultyInfo();
			var evs = new List<osuEvent>();

			bool ftc = false;
			using (var reader = new StreamReader(file.FullName, Encoding.UTF8)) {
				Section section = Section.General;
				int version;
				bool flag = false;
				string line;
				while ((line = reader.ReadLine()) != null) {
					if (!flag) {
						if (line.StartsWith("osu file format v")) {
							version = int.Parse(line.Substring(17), CultureInfo.InvariantCulture);
							if (version > 14) throw new NotSupportedException("osu! chart format version too high");
							else if (version < 5) throw new NotImplementedException("osu! chart format version too low"); // TODO apply offset
						}
						else throw new NotSupportedException("Unrecognized osu! chart format");
						flag = true;
					}
					if (ShouldSkipLine(line)) continue;
					if (section != Section.Metadata) line = StripComments(line);
					line = line.TrimEnd();
					if (line.StartsWith('[') && line.EndsWith(']')) {
						Enum.TryParse(line.Substring(1, line.Length - 2), out section);
						continue;
					}
					ParseLine(meta, chart, diff, evs, ref ftc, section, line);
				}
			}

			if (!ftc) throw new InvalidOperationException("Unconvertible chart: no timing point is present in this beatmap");
			result.Add(new RawChartResource(string.Format("{0} - {1}", meta.song.name, meta.name), chart, meta));
			var evc = evs.Count;
			for (int i = 0; i < evc; i++) if (evs[i].IsLong) evs.Add(new osuEvent.EndEvent(evs[i]));
			evs.Sort();

			var longevs = new Dictionary<osuEvent, ChartEvent>();
			Chart.Sound bgmEv = null;
			TimeTimingModel tm = null;
			foreach (var ev in evs) {
				if (tm != null) tm.ForwardTo(ev.StartTime / 1e3);
				if (ev is osuEvent.Audio) {
					var tev = (osuEvent.Audio)ev;
					chart.sounds.Add(bgmEv = new Chart.Sound { time = new BeatTime(0, 0, 1), id = meta.song.name });
					result.Add(new SongResource(meta.song.name, new FileInfo(Path.Combine(file.DirectoryName, tev.AudioFile))));
				}
				else if (ev is osuEvent.Background) {
					meta.cover = ((osuEvent.Background)ev).FileName;
				}
				else if (ev is osuEvent.EffectPoint) {
					var tev = (osuEvent.EffectPoint)ev;
					group.motions.Add(new Chart.Motion { motion = string.Format(CultureInfo.InvariantCulture, "svm:{0}", tev.ScrollSpeed) });
				}
				else if (ev is osuEvent.EndEvent) {
					if (tm == null) throw new InvalidOperationException("Unconvertible chart: timed event before first timing point");
					var tev = (osuEvent.EndEvent)ev;
					longevs[tev.Original].endtime = tm.FractionalBeatTime;
				}
				else if (ev is osuEvent.HOMania) {
					if (tm == null) throw new InvalidOperationException("Unconvertible chart: timed event before first timing point");
					var tev = (osuEvent.HOMania)ev;
					var rn = new Chart.Note {
						time = tm.FractionalBeatTime,
						motions = new List<Chart.Motion> {
							new Chart.Motion{ motion = string.Format(CultureInfo.InvariantCulture, "track:{0}", (int)(tev.X * diff.CircleSize / 512)) }
						},
					};
					group.notes.Add(rn);
					if (tev.IsLong) longevs.Add(tev, rn);
				}
				else if (ev is osuEvent.TimingChange) {
					var tev = (osuEvent.TimingChange)ev;
					if (tm == null) {
						tm = new TimeTimingModel(tev.StartTime / 1e3);
						bgmEv.offset = (float)(tev.StartTime / 1e3 + OFFSET);
					}
					tm.BeatLength = tev.BeatLength / 1e3;
					chart.sigs.Add(new Chart.Signature {
						time = tm.FractionalBeatTime,
						tempo = (float)tm.BPM,
					});
				}
				else throw new NotSupportedException("Unsupported event detected");
			}
			var endbeat = tm.FractionalBeatTime;
			endbeat.b += 4;
			chart.endtime = endbeat;
			meta.length = (float)tm.Time;
			meta.note_count = group.notes.Count;
			return result;
		}

		void ParseLine(ChartMeta meta, Chart chart, DifficultyInfo diff, List<osuEvent> evs, ref bool ftc, Section section, string line) {
			switch (section) {
				case Section.General: HandleGeneral(meta, chart, evs, line); return;
				case Section.Metadata: HandleMetadata(meta, line); return;
				case Section.Difficulty: HandleDifficulty(diff, line); return;
				case Section.Events: HandleEvent(evs, line); return;
				case Section.TimingPoints: HandleTimingPoint(chart, evs, ref ftc, line); return;
				case Section.HitObjects: HandleHitObject(evs, line); return;
			}
		}

		void HandleGeneral(ChartMeta meta, Chart chart, List<osuEvent> evs, string line) {
			var pair = SplitKeyVal(line);
			switch (pair.Key) {
				case @"AudioFilename":
					evs.Add(new osuEvent.Audio { StartTime = double.NegativeInfinity, AudioFile = pair.Value });
					break;
				case @"Mode":
					int rulesetID = int.Parse(pair.Value, CultureInfo.InvariantCulture);
					var ruleset = "osu!";
					switch (rulesetID) {
						case 0: /*ruleset += "standard";*/ throw new NotImplementedException("osu!standard mode is not supported yet");
						case 1: /*ruleset += "taiko";*/ throw new NotImplementedException("osu!taiko mode is not supported yet");
						case 2: /*ruleset += "catch";*/ throw new NotImplementedException("osu!catch mode is not supported yet");
						case 3: ruleset += "mania"; break;
					}
					meta.ruleset = chart.ruleset = ruleset;
					break;
			}
		}

		void HandleMetadata(ChartMeta meta, string line) {
			var pair = SplitKeyVal(line);
			switch (pair.Key) {
				case @"Title": if (meta.song.name == null) meta.song.name = pair.Value; break;
				case @"TitleUnicode": meta.song.name = pair.Value; break;
				case @"Artist": if (meta.song.author == null) meta.song.author = pair.Value; break;
				case @"ArtistUnicode": meta.song.author = pair.Value; break;
				case @"Creator": meta.author = pair.Value; break;
				case @"Version": meta.name = pair.Value; break;
			}
		}

		void HandleDifficulty(DifficultyInfo diff, string line) {
			var pair = SplitKeyVal(line);
			switch (pair.Key) {
				case @"CircleSize":
					diff.CircleSize = float.Parse(pair.Value, CultureInfo.InvariantCulture);
					break;
				case @"SliderMultiplier":
					diff.SliderMultiplier = double.Parse(pair.Value, CultureInfo.InvariantCulture);
					break;
			}
		}

		void HandleEvent(List<osuEvent> evs, string line) {
			string[] split = line.Split(',');
			if (!Enum.TryParse(split[0], out LegacyEventType type))
				throw new InvalidDataException($@"Unknown event type: {split[0]}");
			switch (type) {
				case LegacyEventType.Sprite:
					if (evs.Count == 0 || !(evs[evs.Count - 1] is osuEvent.Background))
						evs.Add(new osuEvent.Background { StartTime = double.NegativeInfinity, FileName = CleanFilename(split[3]) });
					break;
				case LegacyEventType.Background:
					evs.Add(new osuEvent.Background { StartTime = double.NegativeInfinity, FileName = CleanFilename(split[2]) });
					break;
			}
		}
		enum LegacyEventType {
			Background = 0,
			Video = 1,
			Break = 2,
			Colour = 3,
			Sprite = 4,
			Sample = 5,
			Animation = 6
		}

		void HandleTimingPoint(Chart chart, List<osuEvent> evs, ref bool ftc, string line) {
			string[] split = line.Split(',');

			double time = double.Parse(split[0].Trim(), CultureInfo.InvariantCulture)/* + offset*/;

			// beatLength is allowed to be NaN to handle an edge case in which some beatmaps use NaN slider velocity to disable slider tick generation (see LegacyDifficultyControlPoint).
			double beatLength = double.Parse(split[1].Trim(), CultureInfo.InvariantCulture);

			// If beatLength is NaN, speedMultiplier should still be 1 because all comparisons against NaN are false.
			double speedMultiplier = beatLength < 0 ? 100.0 / -beatLength : 1;

			int timeSignature = 4;
			if (split.Length >= 3)
				timeSignature = split[2][0] == '0' ? 4 : int.Parse(split[2], CultureInfo.InvariantCulture);

			bool timingChange = true;
			if (split.Length >= 7)
				timingChange = split[6][0] == '1';

			//bool omitFirstBarSignature = false;

			//if (split.Length >= 8) {
			//	int effectFlags = int.Parse(split[7], CultureInfo.InvariantCulture);
			//	omitFirstBarSignature = (effectFlags & 0x8) != 0;
			//}

			if (timingChange) {
				if (double.IsNaN(beatLength))
					throw new InvalidDataException("Beat length cannot be NaN in a timing control point");
				var ev = new osuEvent.TimingChange { StartTime = time, BeatLength = beatLength, TimeSignature = timeSignature };
				if (!ftc) {
					ftc = true;
					ev.StartTime = time % beatLength - beatLength;
				}
				evs.Add(ev);
			}

			// osu!taiko and osu!mania use effect points rather than difficulty points for scroll speed adjustments.
			if (chart.ruleset == "osu!taiko" || chart.ruleset == "osu!mania")
				evs.Add(new osuEvent.EffectPoint { StartTime = time, ScrollSpeed = speedMultiplier });
		}

		void HandleHitObject(List<osuEvent> evs, string line) {
			string[] split = line.Split(',');

			int posx = (int)float.Parse(split[0], CultureInfo.InvariantCulture);
			// int posy = (int)float.Parse(split[1], CultureInfo.InvariantCulture);

			double startTime = double.Parse(split[2], CultureInfo.InvariantCulture)/* + Offset*/;

			LegacyHitObjectType type = (LegacyHitObjectType)int.Parse(split[3], CultureInfo.InvariantCulture);

			// int comboOffset = (int)(type & LegacyHitObjectType.ComboOffset) >> 4;
			type &= ~LegacyHitObjectType.ComboOffset;

			// bool combo = type.HasFlag(LegacyHitObjectType.NewCombo);
			type &= ~LegacyHitObjectType.NewCombo;

			osuEvent.HitObject result;

			if (type.HasFlag(LegacyHitObjectType.Circle)) {
				result = new osuEvent.HOManiaHit { X = posx };
			}
			else if (type.HasFlag(LegacyHitObjectType.Hold)) {
				double endTime = Math.Max(startTime, double.Parse(split[2], CultureInfo.InvariantCulture));
				if (split.Length > 5 && !string.IsNullOrEmpty(split[5])) {
					string[] ss = split[5].Split(':');
					endTime = Math.Max(startTime, double.Parse(ss[0], CultureInfo.InvariantCulture));
				}
				result = new osuEvent.HOManiaHold { X = posx, EndTime = endTime };
			}
			else throw new NotSupportedException(string.Format("Hit objects of type {0} is not supported yet", type));

			if (result == null) throw new InvalidDataException($"Unknown hit object type: {split[3]}");

			result.StartTime = startTime;

			if (result != null) evs.Add(result);
		}
		[Flags]
		enum LegacyHitObjectType {
			Circle = 1,
			Slider = 1 << 1,
			NewCombo = 1 << 2,
			Spinner = 1 << 3,
			ComboOffset = (1 << 4) | (1 << 5) | (1 << 6),
			Hold = 1 << 7
		}

		static bool ShouldSkipLine(string line) => string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("//", StringComparison.Ordinal)
			|| line.StartsWith(' ') || line.StartsWith('_');
		protected string StripComments(string line) {
			int index = line.IndexOf("//");
			if (index > 0) return line.Substring(0, index);
			return line;
		}
		KeyValuePair<string, string> SplitKeyVal(string line, char separator = ':', bool shouldTrim = true) {
			string[] split = line.Split(separator, 2);
			if (shouldTrim) {
				for (int i = 0; i < split.Length; i++)
					split[i] = split[i].Trim();
			}
			return new KeyValuePair<string, string> (
				split[0],
				split.Length > 1 ? split[1] : string.Empty
			);
		}
		static string CleanFilename(string path) => path.Replace(@"\\", @"\").Trim('"');

		enum Section {
			General,
			Editor,
			Metadata,
			Difficulty,
			Events,
			TimingPoints,
			Colours,
			HitObjects,
			Variables,
			Fonts,
			CatchTheBeat,
			Mania,
		}

		class DifficultyInfo {
			public float CircleSize { get; internal set; }
			public double SliderMultiplier { get; set; }
		}
#pragma warning disable IDE1006 // Naming Styles
		abstract class osuEvent : IComparable<osuEvent> {
			public virtual double StartTime { get; set; }
			public virtual double EndTime { get; set; }
			public bool IsLong { get { return EndTime - StartTime > 0 && EndTime > 0; } }
			public abstract int Priority { get; }
			public int CompareTo(osuEvent other) {
				var c = StartTime.CompareTo(other.StartTime);
				if (c != 0) return c;
				return Priority.CompareTo(other.Priority);
			}
			public class EndEvent : osuEvent {
				public osuEvent Original;
				public EndEvent(osuEvent ev) { if (!ev.IsLong) throw new ArgumentException("Event is not long"); Original = ev; }
				public override double StartTime { get { return Original.EndTime; } }
				public override double EndTime { get { return 0; } }
				public override int Priority { get { return Original.Priority - 1; } }
			}
			public class Audio : osuEvent {
				public string AudioFile { get; set; }
				public override int Priority { get { return 0; } }
			}
			public class TimingChange : osuEvent {
				public double BeatLength { get; set; }
				public int TimeSignature { get; set; }
				public override int Priority { get { return -4; } }
			}
			public class EffectPoint : osuEvent {
				public double ScrollSpeed { get; set; }
				public override int Priority { get { return -2; } }
			}
			public class Background : osuEvent {
				public string FileName { get; set; }
				public override int Priority { get { return 0; } }
			}
			public class HitObject : osuEvent {
				public sealed override int Priority { get { return 0; } }
			}
			public class HOMania : HitObject {
				public float X { get; set; }
			}
			public class HOManiaHit : HOMania { }
			public class HOManiaHold : HOMania { }
		}
#pragma warning restore IDE1006 // Naming Styles
	}
}
