local LocalizationService = game:GetService("LocalizationService")
local CorePackages = game:GetService("CorePackages")
local GetFFlagUseDateTimeType = require(CorePackages.AppTempCommon.LuaApp.Flags.GetFFlagUseDateTimeType)

--[[
	This is a Lua implementation of the DateTime API proposal. It'll eventually
	be implemented in C++ and merged into the rest of the codebase if this model
	of working with dates ends up being useful.
]]

local TimeZone = require(script.Parent.TimeZone)
local TimeUnit = require(script.Parent.TimeUnit)

local LuaDateTime = {}

local monthShortNames = {
	"Jan", "Feb", "Mar", "Apr",
	"May", "Jun", "Jul", "Aug",
	"Sep", "Oct", "Nov", "Dec"
}

local monthLongNames = {
	"January", "February", "March", "April",
	"May", "June", "July", "August",
	"September", "October", "November", "December"
}

local dayShortNames = {
	"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"
}

local dayLongNames = {
	"Sunday", "Monday", "Tuesday", "Wednesday",
	"Thursday", "Friday", "Saturday"
}

--[[
	We structure tokens like this to preserve order, since Lua associative
	arrays have no inherent order.
]]
local tokens = {
	{"YYYY", function(values)
		return tostring(values.Year)
	end},
	{"MMMM", function(values)
		return monthLongNames[values.Month]
	end},
	{"MMM", function(values)
		return monthShortNames[values.Month]
	end},
	{"MM", function(values)
		return ("%02d"):format(values.Month)
	end},
	{"M", function(values)
		return tostring(values.Month)
	end},
	{"DDDD", function(values)
		return dayLongNames[values.WeekDay]
	end},
	{"DDD", function(values)
		return dayShortNames[values.WeekDay]
	end},
	{"DD", function(values)
		return ("%02d"):format(values.Day)
	end},
	{"D", function(values)
		return tostring(values.Day)
	end},
	{"HH", function(values)
		local hour = values.Hour

		return ("%02d"):format(hour)
	end},
	{"H", function(values)
		local hour = values.Hour

		return tostring(hour)
	end},
	{"hh", function(values)
		local hour = values.Hour % 12
		if hour == 0 then
			hour = 12
		end

		return ("%02d"):format(hour)
	end},
	{"h", function(values)
		local hour = values.Hour % 12
		if hour == 0 then
			hour = 12
		end

		return tostring(hour)
	end},
	{"mm", function(values)
		return ("%02d"):format(values.Minute)
	end},
	{"m", function(values)
		return tostring(values.Minute)
	end},
	{"ss", function(values)
		return ("%02d"):format(values.Seconds)
	end},
	{"s", function(values)
		return tostring(values.Seconds)
	end},
	{"A", function(values)
		return values.Hour >= 12 and "PM" or "AM"
	end},
	{"a", function(values)
		return values.Hour >= 12 and "pm" or "am"
	end}
}

local tokenKeys = {}
for _, pair in ipairs(tokens) do
	table.insert(tokenKeys, pair[1])
end

local tokenMap = {}
for _, pair in ipairs(tokens) do
	tokenMap[pair[1]] = pair[2]
end

--[[
	What's the next token in this source?
]]
local function getToken(source, i)
	local char = source:sub(i, i)

	for _, token in ipairs(tokenKeys) do
		-- Only keep checking if the first character matches the token
		if token:sub(1, 1) == char then
			local match = source:sub(i, i + token:len() - 1)

			if match == token then
				return token
			end
		end
	end
end

--[[
	An estimate of the current time zone's offset from UTC in seconds.

	This might fail for weird timezones (UTC +/- 14), but we can fix that by
	picking a reference time that's further away from the Unix epoch.
]]
local function getTimeZoneOffset()
	local actualEpoch = 86400 + 43200
	local epoch = os.time({year = 1970, month = 1, day = 2, isdst = -1})
	if epoch then
		return actualEpoch - epoch
	else
		return 0
	end
end

-- Remove all above functions and tables when clean up GetFFlagUseDateTimeType()

--[[
	Create a DateTime with the given values in UTC.

	All values are optional!
]]
function LuaDateTime.new(year, month, day, hour, minute, seconds, milliseconds)
	if GetFFlagUseDateTimeType() then
		local self = {}
		self.dateTime = DateTime.fromUniversalTime(
			year or 1970,
			month or 1,
			day or 1,
			hour or 0,
			minute or 0,
			seconds or 0,
			milliseconds or 0)
		setmetatable(self, LuaDateTime)
		return self
	end

	local tzOffset = getTimeZoneOffset()
	local timestamp = os.time({
		year = year or 1970,
		month = month or 1,
		day = day or 1,
		hour = hour or 0,
		min = minute or 0,
		sec = seconds or 0,
		isdst = -1
	})
	if timestamp == nil then
		timestamp = 0
	end

	if seconds then
		local subseconds = seconds - math.floor(seconds)
		timestamp = timestamp + subseconds
	end

	return LuaDateTime.fromUnixTimestamp(timestamp + tzOffset)
end

--[[
	Create a DateTime representing now.
]]
function LuaDateTime.now()
	if GetFFlagUseDateTimeType() then
		local self = {}
		self.dateTime = DateTime.now()
		setmetatable(self, LuaDateTime)
		return self
	end

	return LuaDateTime.fromUnixTimestamp(os.time())
end

--[[
	Create a Datetime from the given Unix timestamp.

	Limited to the range [0, 2^32) when GetFFlagUseDateTimeType() is off, which lets us represent
	dates out to about 2038.

	When GetFFlagUseDateTimeType() is on, year range is 1400-9999, and timestamp range is between
	first second of year 1400 to the last second of year 9999.
]]
function LuaDateTime.fromUnixTimestamp(timestamp)
	assert(type(timestamp) == "number", "Invalid argument #1 to fromUnixTimestamp, expected number.")

	if GetFFlagUseDateTimeType() then
		local self = {}
		self.dateTime = DateTime.fromUnixTimestampMillis(timestamp*1000)
		setmetatable(self, LuaDateTime)
		return self
	end

	local self = {}

	self.value = timestamp

	setmetatable(self, LuaDateTime)

	return self
end

--[[
	Attempt to create a DateTime from an ISO 8601 date-time string.

	Will return nil on failure and output a warning to a console denoting what
	went wrong. This can probably turned into a second return value if we need
	to handle that data programmatically.
]]
function LuaDateTime.fromIsoDate(isoDate)
	assert(type(isoDate) == "string", "Invalid argument #1 to DateTime.fromIsoDate, expected string.")

	if GetFFlagUseDateTimeType() then
		local self = {}
		self.dateTime = DateTime.fromIsoDate(isoDate)
		setmetatable(self, LuaDateTime)
		return self
	end

	local datePattern = "^(%d+)%-(%d+)%-(%d+)" -- 0000-00-00
	local timePattern = "T(%d+):(%d+):(%d+%.?%d*)" -- T00:00:00
	local utcPattern = "Z$"
	local timeZonePattern = "([+-]%d+):(%d+)$" -- either Z or +/- followed by "00:00"

	local timezone = 0
	local values = {1970, 1, 1, 0, 0, 0}
	local year, month, day = isoDate:match(datePattern)

	if not year then
		warn(("Invalid ISO 8601 date: %q"):format(isoDate))
		return nil
	end

	values[1] = tonumber(year)
	values[2] = tonumber(month)
	values[3] = tonumber(day)

	local hour, minute, seconds = isoDate:match(timePattern)

	if hour then
		values[4] = tonumber(hour)
		values[5] = tonumber(minute)
		values[6] = tonumber(seconds)

		local isUtc = isoDate:match(utcPattern)

		if not isUtc then
			local offsetHours, offsetMinutes = isoDate:match(timeZonePattern)

			if not offsetHours then
				local offsetTotal = getTimeZoneOffset()
				offsetHours = offsetTotal / 3600
				offsetMinutes = 0

				warn(("Invalid time zone in ISO 8601 date: %q -- falling back to local time"):format(isoDate))
			end

			timezone = 3600 * tonumber(offsetHours) + 60 * tonumber(offsetMinutes)
		end
	end

	local date = LuaDateTime.new(unpack(values))
	date.value = date.value - timezone

	return date
end

--[[
	Format our current date using a formatting string. Look at the DateTime
	proposal to see information about the different formatting tokens.
	Generally, they try to resemble LDML and/or Moment.js-style formatting.

	The time zone parameter is optional and defaults to the current time zone,
	TimeZone.Current.
]]
function LuaDateTime:Format(formatString, tz, localeId)
	assert(type(formatString) == "string", "Invalid argument #1 to Format, expected string.")

	if GetFFlagUseDateTimeType() then
		tz = tz or TimeZone.Current
		localeId = localeId or LocalizationService.RobloxLocaleId

		if tz == TimeZone.UTC then
			return self.dateTime:FormatUniversalTime(formatString, localeId)
		elseif tz == TimeZone.Current then
			return self.dateTime:FormatLocalTime(formatString, localeId)
		else
			error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
		end
	end

	tz = tz or TimeZone.Current

	local values = self:GetValues(tz)

	local buffer = {}

	local i = 1
	while i <= formatString:len() do
		local char = formatString:sub(i, i)
		local token = getToken(formatString, i)

		if token then
			table.insert(buffer, tokenMap[token](values))
			i = i + token:len()
		elseif char == "[" then
			-- Crawl forward until the next ] and interpret that text literally
			local j = i
			while j <= formatString:len() do
				j = j + 1

				if formatString:sub(j, j) == "]" then
					break
				end
			end

			table.insert(buffer, formatString:sub(i + 1, j - 1))
			i = j + 1
		else
			table.insert(buffer, char)
			i = i + 1
		end
	end

	local result = table.concat(buffer)

	return result
end

--[[
	Get a table of values representing the date-time in the given timezone.

	The time zone parameter is optional and defaults to the current zime zone,
	TimeZone.Current.

	When GetFFlagUseDateTimeType() is true, table would include
	{Year, Month, Day, Hour, Minute, Second, Millisecond}
]]
function LuaDateTime:GetValues(tz)
	if GetFFlagUseDateTimeType() then
		tz = tz or TimeZone.Current

		if tz == TimeZone.UTC then
			return self.dateTime:ToUniversalTime()
		elseif tz == TimeZone.Current then
			return self.dateTime:ToLocalTime()
		else
			error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
		end
	end

	tz = tz or TimeZone.Current

	local reference

	if tz == TimeZone.Current then
		reference = os.date("*t", self.value)
	elseif tz == TimeZone.UTC then
		reference = os.date("!*t", self.value)
	end

	if not reference then
		error(("Invalid TimeZone \"%s\""):format(tostring(tz)), 2)
	end

	return {
		Year = reference.year,
		Month = reference.month,
		Day = reference.day,
		Hour = reference.hour,
		Minute = reference.min,
		Seconds = reference.sec,
		WeekDay = reference.wday
	}
end

--[[
	Recover a Unix timestamp representing the DateTime's value.
]]
function LuaDateTime:GetUnixTimestamp()
	if GetFFlagUseDateTimeType() then
		if self.dateTime:ToUniversalTime().Millisecond > 0 then
			return self.dateTime.UnixTimestamp + (self.dateTime.UnixTimestampMillis % 1000)/1000
		else
			return self.dateTime.UnixTimestamp
		end
	end

	return self.value
end

--[[
	Format the DateTime as an ISO 8601 date string with time attached.

	Always formats the time as UTC. There generally aren't many reasons to
	generate an ISO 8601 date in another time zone.
]]
function LuaDateTime:GetIsoDate()
	if GetFFlagUseDateTimeType() then
		return self.dateTime:ToIsoDate()
	end

	return self:Format("YYYY-MM-DD[T]HH:mm:ss[Z]", TimeZone.UTC)
end

-- Used by IsSame
-- Remove when clean up GetFFlagUseDateTimeType()
local descendingGranularityUnits = {
	{
		unit = TimeUnit.Years,
		key = "Year"
	},
	{
		unit = TimeUnit.Months,
		key = "Month"
	},
	{
		unit = TimeUnit.Days,
		key = "Day"
	},
	{
		unit = TimeUnit.Hours,
		key = "Hour"
	},
	{
		unit = TimeUnit.Minutes,
		key = "Minute"
	},
	{
		unit = TimeUnit.Seconds,
		key = "Seconds"
	}
}

--[[
	Checks whether two DateTime values are the same, given a granularity and
	timezone value.

	Granularity defaults to seconds and time zone defaults to the current local
	time zone.

	Remove when clean up GetFFlagUseDateTimeType()
]]
function LuaDateTime:IsSame(other, granularity, timezone)
	granularity = granularity or TimeUnit.Seconds
	timezone = timezone or TimeZone.Current

	local selfUnix = self:GetUnixTimestamp()
	local otherUnix = other:GetUnixTimestamp()

	if selfUnix == otherUnix then
		return true
	end

	local selfValues = self:GetValues(timezone)
	local otherValues = other:GetValues(timezone)

	-- Week logic is special
	if granularity == TimeUnit.Weeks then
		local diff = math.abs(selfUnix - otherUnix)
		local diffDays = diff / (60 * 60 * 24)

		-- Two dates separated by 7 or more whole days are never in the same week
		if diffDays >= 7 then
			return false
		end

		-- Two dates separated by less than 7 days will be sorted monotonically
		-- if they're in the same week
		-- TODO: Use start-of-week value to shift WeekDay for locale
		if selfUnix > otherUnix then
			return selfValues.WeekDay >= otherValues.WeekDay
		else
			return selfValues.WeekDay <= otherValues.WeekDay
		end
	end

	for _, unit in ipairs(descendingGranularityUnits) do
		local selfValue = selfValues[unit.key]
		local otherValue = otherValues[unit.key]

		if selfValue ~= otherValue then
			return false
		end

		if unit.unit == granularity then
			break
		end
	end

	return true
end

--[[
	Get a human-readable timestamp relative to the given epoch, which defaults
	to now. The format of the time is contextual to how far away the times are.
]]
function LuaDateTime:GetLongRelativeTime(epoch, timezone, localeId)
	if GetFFlagUseDateTimeType() then
		-- Not relative time format for now, will do that later in DateTime v2
		return self:Format("lll", timezone, localeId)
	end

	timezone = timezone or TimeZone.Current
	epoch = epoch or LuaDateTime.now()

	if self:IsSame(epoch, TimeUnit.Days, timezone) then
		return self:Format("h:mm A", timezone)
	elseif self:IsSame(epoch, TimeUnit.Weeks, timezone) then
		return self:Format("DDD | h:mm A", timezone)
	elseif self:IsSame(epoch, TimeUnit.Years, timezone) then
		return self:Format("MMM D | h:mm A", timezone)
	else
		return self:Format("MMM D, YYYY | h:mm A", timezone)
	end
end

--[[
	Get a human-readable timestamp relative to the given epoch, which defaults
	to now. The format of the time is contextual to how far away the times are.
]]
function LuaDateTime:GetShortRelativeTime(epoch, timezone, localeId)
	timezone = timezone or TimeZone.Current

	if GetFFlagUseDateTimeType() then
		-- Not relative time format for now, will do that later in DateTime v2
		return self:Format("ll", timezone, localeId)
	end

	epoch = epoch or LuaDateTime.now()

	if self:IsSame(epoch, TimeUnit.Days, timezone) then
		return self:Format("h:mm A", timezone)
	elseif self:IsSame(epoch, TimeUnit.Weeks, timezone) then
		return self:Format("DDD", timezone)
	elseif self:IsSame(epoch, TimeUnit.Years, timezone) then
		return self:Format("MMM D", timezone)
	else
		return self:Format("MMM D, YYYY", timezone)
	end
end

LuaDateTime.__index = LuaDateTime

return LuaDateTime