//
//  SwiftDate
//  Parse, validate, manipulate, and display dates, time and timezones in Swift
//
//  Created by Daniele Margutti
//   - Web: https://www.danielemargutti.com
//   - Twitter: https://twitter.com/danielemargutti
//   - Mail: hello@danielemargutti.com
//
//  Copyright © 2019 Daniele Margutti. Licensed under MIT License.
//

// swiftlint:disable file_length

import Foundation

/// This defines all possible errors you can encounter parsing ISO8601 string
///
/// - eof: end of file
/// - notDigit: expected digit, value cannot be parsed as int
/// - notDouble: expected double digit, value cannot be parsed as double
/// - invalid: invalid state reached. Something in the format is not correct
public enum ISO8601ParserError: Error {
	case eof
	case notDigit
	case notDouble
	case invalid
}

fileprivate extension Int {

	/// Return `true` if current year is a leap year, `false` otherwise
	var isLeapYear: Bool {
		return ((self % 4) == 0) && (((self % 100) != 0) || ((self % 400) == 0))
	}

}

// MARK: - Internal Extension for UnicodeScalar type

internal extension UnicodeScalar {

	/// return `true` if current character is a digit (arabic), `false` otherwise
	var isDigit: Bool {
		return "0"..."9" ~= self
	}

	/// return `true` if current character is a space
	var isSpace: Bool {
		return CharacterSet.whitespaces.contains(self)
	}

}

/// This is the ISO8601 Parser class: it evaluates automatically the format of the ISO8601 date
/// and attempt to parse it in a valid `Date` object.
/// Resulting date also includes Time Zone settings and a property which allows you to inspect
/// single date components.
///
/// This work is inspired to the original ISO8601DateFormatter class written in ObjC by
/// Peter Hosey (available here https://bitbucket.org/boredzo/iso-8601-parser-unparser).
/// I've made a Swift porting and fixed some issues when parsing several ISO8601 date variants.

// swiftlint:disable type_body_length
public class ISOParser: StringToDateTransformable {

	/// Internal structure
	internal enum Weekday: Int {
		case monday = 0
		case tuesday = 1
		case wednesday = 2
		case thursday = 3
	}

	public struct Options {

		/// Time separator character. By default is `:`.
		var time_separator: ISOParser.ISOChar = ":"

		/// Strict parsing. By default is `false`.
		var strict: Bool = false

		public init(strict: Bool = false) {
			self.strict = strict
		}
	}

	/// Some typealias to make the code cleaner
	public typealias ISOString = String.UnicodeScalarView
	public typealias ISOIndex = String.UnicodeScalarView.Index
	public typealias ISOChar = UnicodeScalar
	public typealias ISOParsedDate	= (date: Date?, timezone: TimeZone?)

	/// This represent the internal parser status representation
	public struct ParsedDate {

		/// Type of date parsed
		///
		/// - monthAndDate: month and date style
		/// - week: date with week number
		/// - dateOnly: date only
		// swiftlint:disable nesting
		public enum DateStyle {
			case monthAndDate
			case week
			case dateOnly
		}

		/// Parsed year value
		var year: Int = 0

		/// Parsed month or week number
		var month_or_week:	Int = 0

		/// Parsed day value
		var day: Int = 0

		/// Parsed hour value
		var hour: Int = 0

		/// Parsed minutes value
		var minute: TimeInterval = 0.0

		/// Parsed seconds value
		var seconds: TimeInterval = 0.0

		/// Parsed nanoseconds value
		var nanoseconds:	TimeInterval = 0.0

		/// Parsed weekday number (1=monday, 7=sunday)
		/// If `nil` source string has not specs about weekday.
		var weekday: Int?

		/// Timezone parsed hour value
		var tz_hour: Int = 0

		/// Timezone parsed minute value
		var tz_minute: Int = 0

		/// Type of parsed date
		var type: DateStyle = .monthAndDate

		/// Parsed timezone object
		var timezone: TimeZone?
	}

	/// Source generation calendar.
	private var srcCalendar = Calendars.gregorian.toCalendar()

	/// Source raw parsed values
	private var date = ParsedDate()

	/// Source string represented as unicode scalars
	private var string: ISOString

	/// Current position of the parser in source string.
	/// Initially is equal to `string.startIndex`
	private var cIdx: ISOIndex

	/// Just a shortcut to the last index in source string
	private var eIdx: ISOIndex

	/// Lenght of the string
	private var length: Int

	/// Number of hyphens characters found before any value
	/// Consequential "-" are used to define implicit values in dates.
	private var hyphens:	Int = 0

	/// Private date components used for default values
	private var now_cmps:	DateComponents

	/// Configuration used for parser
	private var options: ISOParser.Options

	/// Date components parsed
	private(set) var date_components: DateComponents?

	/// Parsed date
	private(set) var parsedDate: Date?

	/// Parsed timezone
	private(set) var parsedTimeZone: TimeZone?

	/// Date adjusted at parsed timezone
	private var dateInTimezone: Date? {
		get {
			srcCalendar.timeZone = date.timezone ?? TimeZone(identifier: "UTC")!
			return srcCalendar.date(from: date_components!)
		}
	}

	/// Initialize a new parser with a source ISO8601 string to parse
	/// Parsing is done during initialization; any exception is reported
	/// before allocating.
	///
	/// - Parameters:
	///   - src: source ISO8601 string
	///   - config: configuration used for parsing
	/// - Throws: throw an `ISO8601Error` if parsing operation fails

	public init?(_ src: String, options: ISOParser.Options? = nil) {
		let src_trimmed = src.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
		guard src_trimmed.count > 0 else {
			return nil
		}
		string = src_trimmed.unicodeScalars
		length = src_trimmed.count
		cIdx = string.startIndex
		eIdx = string.endIndex
		self.options = (options ?? ISOParser.Options())
		self.now_cmps = srcCalendar.dateComponents([.year, .month, .day], from: Date())

		var idx = cIdx
		while idx < eIdx {
			if string[idx] == "-" { hyphens += 1 } else { break }
			idx = string.index(after: idx)
		}

		do {
			try parse()
		} catch {
			return nil
		}
	}

	// MARK: - Internal Parser

	/// Private parsing function
	///
	/// - Throws: throw an `ISO8601Error` if parsing operation fails
	@discardableResult
	private func parse() throws -> ISOParsedDate {

		// PARSE DATE

		if current() == "T" {
			// There is no date here, only a time.
			// Set the date to now; then we'll parse the time.
			next()
			guard current()?.isDigit ?? false else {
				throw ISO8601ParserError.invalid
			}

			date.year = now_cmps.year!
			date.month_or_week = now_cmps.month!
			date.day = now_cmps.day!
		} else {
			moveUntil(is: "-")
			let is_time_only = (string.contains("T") == false && string.contains(":") && !string.contains("-"))

			if is_time_only == false {
				var (num_digits, segment) = try read_int()
				switch num_digits {
				case 0:		try parse_digits_0(num_digits, &segment)
				case 8:		try parse_digits_8(num_digits, &segment)
				case 6:		try parse_digits_6(num_digits, &segment)
				case 4:		try parse_digits_4(num_digits, &segment)
				case 5:		try parse_digits_5(num_digits, &segment)
				case 1:		try parse_digits_1(num_digits, &segment)
				case 2:		try parse_digits_2(num_digits, &segment)
				case 7:		try parse_digits_7(num_digits, &segment) //YYYY DDD (ordinal date)
				case 3:		try parse_digits_3(num_digits, &segment) //--DDD (ordinal date, implicit year)
				default:	throw ISO8601ParserError.invalid
				}
			} else {
				date.year = now_cmps.year!
				date.month_or_week = now_cmps.month!
				date.day = now_cmps.day!
			}
		}

		var hasTime = false
		if current()?.isSpace ?? false || current() == "T" {
			hasTime = true
			next()
		}

		// PARSE TIME

		if current()?.isDigit ?? false == true {
			let time_sep = options.time_separator
			let hasTimeSeparator = string.contains(time_sep)

			date.hour = try read_int(2).value

			if hasTimeSeparator == false && hasTime {
				date.minute = TimeInterval(try read_int(2).value)
			} else if current() == time_sep {
				next()

				if time_sep == "," || time_sep == "." {
					//We can't do fractional minutes when '.' is the segment separator.
					//Only allow whole minutes and whole seconds.
					date.minute = TimeInterval(try read_int(2).value)
					if current() == time_sep {
						next()
						date.seconds = TimeInterval(try read_int(2).value)
					}
				} else {
					//Allow a fractional minute.
					//If we don't get a fraction, look for a seconds segment.
					//Otherwise, the fraction of a minute is the seconds.
					date.minute = try read_double().value

					if current() != ":" {
						var int_part: Double = 0.0
						var frac_part: Double = 0.0
						frac_part = modf(date.minute, &int_part)
						date.minute = int_part
						date.seconds = frac_part
						if date.seconds > Double.ulpOfOne {
							// Convert fraction (e.g. .5) into seconds (e.g. 30).
							date.seconds *= 60
						} else if current() == time_sep {
							next()
							//	date.seconds = try read_double().value
							let value = try modf(read_double().value)
							date.nanoseconds = TimeInterval(round(value.1 * 1000) * 1_000_000)
							date.seconds = TimeInterval(value.0)
						}
					} else {
						// fractional minutes
						next()
						let value = try modf(read_double().value)
						date.nanoseconds = TimeInterval(round(value.1 * 1000) * 1_000_000)
						date.seconds = TimeInterval(value.0)
					}
				}
			}

			if options.strict == false {
				if cIdx != eIdx && current()?.isSpace ?? false == true {
					next()
				}
			}

			if cIdx != eIdx {
				switch current() {
				case "Z":
					date.timezone = TimeZone(abbreviation: "UTC")

				case "+", "-":
					let is_negative = current() == "-"
					next()
					if current()?.isDigit ?? false == true {
						//Read hour offset.
						date.tz_hour = try read_int(2).value
						if is_negative == true { date.tz_hour = -date.tz_hour }

						// Optional separator
						if current() == time_sep {
							next()
						}

						if current()?.isDigit ?? false {
							// Read minute offset
							date.tz_minute = try read_int(2).value
							if is_negative == true { date.tz_minute = -date.tz_minute }
						}

						let timezone_offset = (date.tz_hour * 3600) + (date.tz_minute * 60)
						date.timezone = TimeZone(secondsFromGMT: timezone_offset)
					}
				default:
					break
				}
			}
		}

		date_components = DateComponents()
		date_components!.year = date.year
		date_components!.day = date.day
		date_components!.hour = date.hour
		date_components!.minute = Int(date.minute)
		date_components!.second = Int(date.seconds)
		date_components!.nanosecond = Int(date.nanoseconds)

		switch date.type {
		case .monthAndDate:
			date_components!.month = date.month_or_week
		case .week:
			//Adapted from <http://personal.ecu.edu/mccartyr/ISOwdALG.txt>.
			//This works by converting the week date into an ordinal date, then letting the next case handle it.
			let prevYear = date.year - 1
			let YY = prevYear % 100
			let prevC = prevYear - YY
			let prevG = YY + YY / 4
			let isLeapYear = (((prevC / 100) % 4) * 5)
			let jan1Weekday = ((isLeapYear + prevG) % 7)

			var day = ((8 - jan1Weekday) + (7 * (jan1Weekday > Weekday.thursday.rawValue ? 1 : 0)))
			day += (date.day - 1) + (7 * (date.month_or_week - 2))

			if let weekday = date.weekday {
				//date_components!.weekday = weekday
				date_components!.day = day + weekday
			} else {
				date_components!.day = day
			}
		case .dateOnly: //An "ordinal date".
			break

		}

		//cfg.calendar.timeZone = date.timezone ?? TimeZone(identifier: "UTC")!
		//parsedDate = cfg.calendar.date(from: date_components!)

		let tz = date.timezone ?? TimeZone(identifier: "UTC")!
		parsedTimeZone = tz
		srcCalendar.timeZone = tz
		parsedDate = srcCalendar.date(from: date_components!)

		return (parsedDate, parsedTimeZone)
	}

	private func parse_digits_3(_ num_digits: Int, _ segment: inout Int) throws {
		//Technically, the standard only allows one hyphen. But it says that two hyphens is the logical implementation, and one was dropped for brevity. So I have chosen to allow the missing hyphen.
		if hyphens < 1 || (hyphens > 2 && options.strict == false) {
			throw ISO8601ParserError.invalid
		}

		date.day = segment
		date.year = now_cmps.year!
		date.type = .dateOnly
		if options.strict == true && (date.day > (365 + (date.year.isLeapYear ? 1 : 0))) {
			throw ISO8601ParserError.invalid
		}
	}

	private func parse_digits_7(_ num_digits: Int, _ segment: inout Int) throws {
		guard hyphens == 0 else { throw ISO8601ParserError.invalid }

		date.day = segment % 1000
		date.year = segment / 1000
		date.type = .dateOnly
		if options.strict == true && (date.day > (365 + (date.year.isLeapYear ? 1 : 0))) {
			throw ISO8601ParserError.invalid
		}
	}

	private func parse_digits_2(_ num_digits: Int, _ segment: inout Int) throws {

		func parse_hyphens_3(_ num_digits: Int, _ segment: inout Int) throws {
			date.year = now_cmps.year!
			date.month_or_week = now_cmps.month!
			date.day = segment
		}

		func parse_hyphens_2(_ num_digits: Int, _ segment: inout Int) throws {
			date.year = now_cmps.year!
			date.month_or_week = segment
			if current() == "-" {
				next()
				date.day = try read_int(2).value
			} else {
				date.day = 1
			}
		}

		func parse_hyphens_1(_ num_digits: Int, _ segment: inout Int) throws {
			let current_year = now_cmps.year!
			let current_century = (current_year % 100)
			date.year = segment + (current_year - current_century)
			if num_digits == 1 { // implied decade
				date.year += current_century - (current_year % 10)
			}

			if current() == "-" {
				next()
				if current() == "W" {
					next()
					date.type = .week
				}
				date.month_or_week = try read_int(2).value

				if current() == "-" {
					next()
					if date.type == .week {
						// weekday number
						let weekday = try read_int().value
						if weekday > 7 {
							throw ISO8601ParserError.invalid
						}
						date.weekday = weekday
					} else {
						date.day = try read_int().value
						if date.day == 0 {
							date.day = 1
						}
						if date.month_or_week == 0 {
							date.month_or_week = 1
						}
					}
				} else {
					date.day = 1
				}
			} else {
				date.month_or_week = 1
				date.day = 1
			}
		}

		func parse_hyphens_0(_ num_digits: Int, _ segment: inout Int) throws {
			if current() == "-" {
				// Implicit century
				date.year = now_cmps.year!
				date.year -= (date.year % 100)
				date.year += segment

				next()
				if current() == "W" {
					try parseWeekAndDay()
				} else if current()?.isDigit ?? false == false {
					try centuryOnly(&segment)
				} else {
					// Get month and/or date.
					let (v_count, v_seg) = try read_int()
					switch v_count {
					case 4: // YY-MMDD
						date.day = v_seg % 100
						date.month_or_week = v_seg / 100
					case 1: // YY-M; YY-M-DD (extension)
						if options.strict == true {
							throw ISO8601ParserError.invalid
						}
					case 2: // YY-MM; YY-MM-DD
						date.month_or_week = v_seg
						if current() == "-" {
							next()
							if current()?.isDigit ?? false == true {
								date.day = try read_int(2).value
							} else {
								date.day = 1
							}
						} else {
							date.day = 1
						}
					case 3: // Ordinal date
						date.day = v_seg
						date.type = .dateOnly
					default:
						break
					}
				}
			} else if current() == "W" {
				date.year = now_cmps.year!
				date.year -= (date.year % 100)
				date.year += segment

				try parseWeekAndDay()
			} else {
				try centuryOnly(&segment)
			}
		}

		switch hyphens {
		case 0:		try parse_hyphens_0(num_digits, &segment)
		case 1:		try parse_hyphens_1(num_digits, &segment) //-YY; -YY-MM (implicit century)
		case 2:		try parse_hyphens_2(num_digits, &segment) //--MM; --MM-DD
		case 3:		try parse_hyphens_3(num_digits, &segment) //---DD
		default:	throw ISO8601ParserError.invalid
		}
	}

	private func parse_digits_1(_ num_digits: Int, _ segment: inout Int) throws {
		if options.strict == true {
			// Two digits only - never just one.
			guard hyphens == 1 else { throw ISO8601ParserError.invalid }
			if current() == "-" {
				next()
			}
			next()
			guard current() == "W" else { throw ISO8601ParserError.invalid }

			date.year = now_cmps.year!
			date.year -= (date.year % 10)
			date.year += segment
		} else {
			try parse_digits_2(num_digits, &segment)
		}
	}

	private func parse_digits_5(_ num_digits: Int, _ segment: inout Int) throws {
		guard hyphens == 0 else { throw ISO8601ParserError.invalid }
		// YYDDD
		date.year = now_cmps.year!
		date.year -= (date.year % 100)
		date.year += segment / 1000

		date.day = segment % 1000
		date.type = .dateOnly
	}

	private func parse_digits_4(_ num_digits: Int, _ segment: inout Int) throws {

		func parse_hyphens_0(_ num_digits: Int, _ segment: inout Int) throws {
			date.year = segment
			if current() == "-" {
				next()
			}

			if current()?.isDigit ?? false == false {
				if current() == "W" {
					try parseWeekAndDay()
				} else {
					date.month_or_week = 1
					date.day = 1
				}
			} else {
				let (v_num, v_seg) = try read_int()
				switch v_num {
				case 4: // MMDD
					date.day = v_seg % 100
					date.month_or_week = v_seg / 100
				case 2: // MM
					date.month_or_week = v_seg

					if current() == "-" {
						next()
					}
					if current()?.isDigit ?? false == false {
						date.day = 1
					} else {
						date.day = try read_int().value
					}
				case 3: // DDD
					date.day = v_seg % 1000
					date.type = .dateOnly
					if options.strict == true && (date.day > 365 + (date.year.isLeapYear ? 1 : 0)) {
						throw ISO8601ParserError.invalid
					}
				default:
					throw ISO8601ParserError.invalid
				}
			}
		}

		func parse_hyphens_1(_ num_digits: Int, _ segment: inout Int) throws {
			date.month_or_week = segment % 100
			date.year = segment / 100

			if current() == "-" {
				next()
			}
			if current()?.isDigit ?? false == false {
				date.day = 1
			} else {
				date.day = try read_int().value
			}
		}

		func parse_hyphens_2(_ num_digits: Int, _ segment: inout Int) throws {
			date.day = segment % 100
			date.month_or_week = segment / 100
			date.year = now_cmps.year!
		}

		switch hyphens {
		case 0:		try parse_hyphens_0(num_digits, &segment) // YYYY
		case 1:		try parse_hyphens_1(num_digits, &segment) // YYMM
		case 2:		try parse_hyphens_2(num_digits, &segment) // MMDD
		default:	throw ISO8601ParserError.invalid
		}

	}

	private func parse_digits_6(_ num_digits: Int, _ segment: inout Int) throws {
		// YYMMDD (implicit century)
		guard hyphens == 0 else {
			throw ISO8601ParserError.invalid
		}

		date.day = segment % 100
		segment /= 100
		date.month_or_week = segment % 100
		date.year = now_cmps.year!
		date.year -= (date.year % 100)
		date.year += (segment / 100)
	}

	private func parse_digits_8(_ num_digits: Int, _ segment: inout Int) throws {
		// YYYY MM DD
		guard hyphens == 0 else {
			throw ISO8601ParserError.invalid
		}

		date.day = segment % 100
		segment /= 100
		date.month_or_week = segment % 100
		date.year = segment / 100
	}

	private func parse_digits_0(_ num_digits: Int, _ segment: inout Int) throws {
		guard current() == "W" else {
			throw ISO8601ParserError.invalid
		}

		if seek(1) == "-" && isDigit(seek(2)) &&
			((hyphens == 1 || hyphens == 2) && options.strict == false) {

			date.year = now_cmps.year!
			date.month_or_week = 1
			next(2)
			try parseDayAfterWeek()
		} else if hyphens == 1 {
			date.year = now_cmps.year!
			if current() == "W" {
				next()
				date.month_or_week = try read_int(2).value
				date.type = .week
				try parseWeekday()
			} else {
				try parseDayAfterWeek()
			}
		} else {
			throw ISO8601ParserError.invalid
		}
	}

	private func parseWeekday() throws {
		if current() == "-" {
			next()
		}
		let weekday = try read_int().value
		if weekday > 7 {
			throw ISO8601ParserError.invalid
		}
		date.type = .week
		date.weekday = weekday
	}

	private func parseWeekAndDay() throws {
		next()
		if current()?.isDigit ?? false == false {
			//Not really a week-based date; just a year followed by '-W'.
			guard options.strict == false else {
				throw ISO8601ParserError.invalid
			}
			date.month_or_week = 1
			date.day = 1
		} else {
			date.month_or_week = try read_int(2).value
			try parseWeekday()
		}
	}

	private func parseDayAfterWeek() throws {
		date.day = current()?.isDigit ?? false == true ? try read_int(2).value : 1
		date.type = .week
	}

	private func centuryOnly(_ segment: inout Int) throws {
		date.year = segment * 100 + now_cmps.year! % 100
		date.month_or_week = 1
		date.day = 1
	}

	/// Return `true` if given character is a char
	///
	/// - Parameter char: char to evaluate
	/// - Returns: `true` if char is a digit, `false` otherwise
	private func isDigit(_ char: UnicodeScalar?) -> Bool {
		guard let char = char else { return false }
		return char.isDigit
	}

	// MARK: - Scanner internal functions

	/// Get the value at specified offset from current scanner position without
	/// moving the current scanner's index.
	///
	/// - Parameter offset: offset to move
	/// - Returns: char at given position, `nil` if not found
	@discardableResult
	public func seek(_ offset: Int = 1) -> ISOChar? {
		let move_idx = string.index(cIdx, offsetBy: offset)
		guard move_idx < eIdx else {
			return nil
		}
		return string[move_idx]
	}

	/// Return the char at the current position of the scanner
	///
	/// - Parameter next: if `true` return the current char and move to the next position
	/// - Returns: the char sat the current position of the scanner
	@discardableResult
	public func current(_ next: Bool = false) -> ISOChar? {
		guard cIdx != eIdx else { return nil }
		let current = string[cIdx]
		if next == true { cIdx = string.index(after: cIdx) }
		return current
	}

	/// Move by `offset` characters the index of the scanner and return the char at the current
	/// position. If EOF is reached `nil` is returned.
	///
	/// - Parameter offset: offset value (use negative number to move backwards)
	/// - Returns: character at the current position.
	@discardableResult
	private func next(_ offset: Int = 1) -> ISOChar? {
		let next = string.index(cIdx, offsetBy: offset)
		guard next < eIdx else {
			return nil
		}
		cIdx = next
		return string[cIdx]
	}

	/// Read from the current scanner index and parse the value as Int.
	///
	/// - Parameter max_count: number of characters to move. If nil scanners continues until a non
	///   digit value is encountered.
	/// - Returns: parsed value
	/// - Throws: throw an exception if parser fails
	@discardableResult
	private func read_int(_ max_count: Int? = nil) throws -> (count: Int, value: Int) {
		var move_idx = cIdx
		var count = 0
		while move_idx < eIdx {
			if let max = max_count, count >= max { break }
			if string[move_idx].isDigit == false { break }
			count += 1
			move_idx = string.index(after: move_idx)
		}

		let raw_value = String(string[cIdx..<move_idx])
		if raw_value == "" {
			return (count, 0)
		}
		guard let value = Int(raw_value) else {
			throw ISO8601ParserError.notDigit
		}

		cIdx = move_idx
		return (count, value)
	}

	/// Read from the current scanner index and parse the value as Double.
	/// If parser fails an exception is throw.
	/// Unit separator can be `-` or `,`.
	///
	/// - Returns: double value
	/// - Throws: throw an exception if parser fails
	@discardableResult
	private func read_double() throws -> (count: Int, value: Double) {
		var move_idx = cIdx
		var count = 0
		var fractional_start = false
		while move_idx < eIdx {
			let char = string[move_idx]
			if char == "." || char == "," {
				if fractional_start == true { throw ISO8601ParserError.notDouble } else { fractional_start = true }
			} else {
				if char.isDigit == false { break }
			}
			count += 1
			move_idx = string.index(after: move_idx)
		}

		let raw_value = String(string[cIdx..<move_idx]).replacingOccurrences(of: ",", with: ".")
		if raw_value == "" {
			return (count, 0.0)
		}
		guard let value = Double(raw_value) else {
			throw ISO8601ParserError.notDouble
		}
		cIdx = move_idx
		return (count, value)
	}

	/// Move the current scanner index to the next position until the current char of the scanner
	/// is the given `char` value.
	///
	/// - Parameter char: char
	/// - Returns: the number of characters passed
	@discardableResult
	private func moveUntil(is char: UnicodeScalar) -> Int {
		var move_idx = cIdx
		var count = 0
		while move_idx < eIdx {
			guard string[move_idx] == char else { break }
			move_idx = string.index(after: move_idx)
			count += 1
		}
		cIdx = move_idx
		return count
	}

	/// Move the current scanner index to the next position until passed `char` value is
	/// encountered or `eof` is reached.
	///
	/// - Parameter char: char
	/// - Returns: the number of characters passed
	@discardableResult
	private func moveUntil(isNot char: UnicodeScalar) -> Int {
		var move_idx = cIdx
		var count = 0
		while move_idx < eIdx {
			guard string[move_idx] != char else { break }
			move_idx = string.index(after: move_idx)
			count += 1
		}
		cIdx = move_idx
		return count
	}

	/// Return a date parsed from a valid ISO8601 string
	///
	/// - Parameter string: source string
	/// - Returns: a valid `Date` object or `nil` if date cannot be parsed
	public static func date(from string: String) -> ISOParsedDate? {
		guard let parser = ISOParser(string) else {
			return nil
		}
		return (parser.parsedDate, parser.parsedTimeZone)
	}

	public static func parse(_ string: String, region: Region?, options: Any?) -> DateInRegion? {
		let formatOptions = options as? ISOParser.Options
		guard let parser = ISOParser(string, options: formatOptions),
			let date = parser.parsedDate else {
			return nil
		}
		let parsedRegion = Region(calendar: region?.calendar ?? Region.ISO.calendar,
								  zone: (region?.timeZone ?? parser.parsedTimeZone ?? Region.ISO.timeZone),
								  locale: region?.locale ?? Region.ISO.locale)
		return DateInRegion(date, region: parsedRegion)
	}

}
