--[=[
@c Date
@t ui
@mt mem
@op seconds number
@op microseconds number
@d Represents a single moment in time and provides utilities for converting to
and from different date and time formats. Although microsecond precision is available,
most formats are implemented with only second precision.
]=]

local class = require('class')
local constants = require('constants')
local Time = require('utils/Time')

local abs, modf, fmod, floor = math.abs, math.modf, math.fmod, math.floor
local format = string.format
local date, time, difftime = os.date, os.time, os.difftime
local isInstance = class.isInstance

local MS_PER_S = constants.MS_PER_S
local US_PER_MS = constants.US_PER_MS
local US_PER_S = US_PER_MS * MS_PER_S

local DISCORD_EPOCH = constants.DISCORD_EPOCH

local months = {
	Jan = 1, Feb = 2, Mar = 3, Apr = 4, May = 5, Jun = 6,
	Jul = 7, Aug = 8, Sep = 9, Oct = 10, Nov = 11, Dec = 12
}

local function offset() -- difference between *t and !*t
	return difftime(time(), time(date('!*t'))) ---@diagnostic disable-line: param-type-mismatch
end

local Date = class('Date')

local function check(self, other)
	if not isInstance(self, Date) or not isInstance(other, Date) then
		return error('Cannot perform operation with non-Date object', 2)
	end
end

function Date:__init(seconds, micro)

	local f
	seconds = tonumber(seconds)
	if seconds then
		seconds, f = modf(seconds)
	else
		seconds = time()
	end

	micro = tonumber(micro)
	if micro then
		seconds = seconds + modf(micro / US_PER_S)
		micro = fmod(micro, US_PER_S)
	else
		micro = 0
	end

	if f and f > 0 then
		micro = micro + US_PER_S * f
	end

	self._s = seconds
	self._us = floor(micro + 0.5)

end

function Date:__tostring()
	return 'Date: ' .. self:toString()
end

--[=[
@m toString
@op fmt string
@r string
@d Returns a string from this Date object via Lua's `os.date`.
If no format string is provided, the default is '%a %b %d %Y %T GMT%z (%Z)'.
]=]
function Date:toString(fmt)
	if not fmt or fmt == '*t' or fmt == '!*t' then
		fmt = '%a %b %d %Y %T GMT%z (%Z)'
	end
	return date(fmt, self._s)
end

function Date:__eq(other) check(self, other)
	return self._s == other._s and self._us == other._us
end

function Date:__lt(other) check(self, other)
	return self:toMicroseconds() < other:toMicroseconds()
end

function Date:__le(other) check(self, other)
	return self:toMicroseconds() <= other:toMicroseconds()
end

function Date:__add(other)
	if not isInstance(self, Date) then
		self, other = other, self
	end
	if not isInstance(other, Time) then
		return error('Cannot perform operation with non-Time object')
	end
	return Date(self:toSeconds() + other:toSeconds())
end

function Date:__sub(other)
	if isInstance(self, Date) then
		if isInstance(other, Date) then
			return Time(abs(self:toMilliseconds() - other:toMilliseconds()))
		elseif isInstance(other, Time) then
			return Date(self:toSeconds() - other:toSeconds())
		else
			return error('Cannot perform operation with non-Date/Time object')
		end
	else
		return error('Cannot perform operation with non-Date object')
	end
end

--[=[
@m parseISO
@t static
@p str string
@r number
@r number
@d Converts an ISO 8601 string into a Unix time in seconds. For compatibility
with Discord's timestamp format, microseconds are also provided as a second
return value.
]=]
function Date.parseISO(str)
	local year, month, day, hour, min, sec, other = str:match(
		'(%d+)-(%d+)-(%d+).(%d+):(%d+):(%d+)(.*)'
	)
	other = other:match('%.%d+')
	return Date.parseTableUTC {
		day = day, month = month, year = year,
		hour = hour, min = min, sec = sec, isdst = false,
	}, other and other * US_PER_S or 0
end

--[=[
@m parseHeader
@t static
@p str string
@r number
@d Converts an RFC 2822 string (an HTTP Date header) into a Unix time in seconds.
]=]
function Date.parseHeader(str)
	local day, month, year, hour, min, sec = str:match(
		'%a+, (%d+) (%a+) (%d+) (%d+):(%d+):(%d+) GMT'
	)
	return Date.parseTableUTC {
		day = day, month = months[month], year = year,
		hour = hour, min = min, sec = sec, isdst = false,
	}
end

--[=[
@m parseSnowflake
@t static
@p id string
@r number
@d Converts a Discord Snowflake ID into a Unix time in seconds. Additional
decimal points may be present, though only the first 3 (milliseconds) should be
considered accurate.
]=]
function Date.parseSnowflake(id)
	return (id / 2^22 + DISCORD_EPOCH) / MS_PER_S
end

--[=[
@m parseTable
@t static
@op tbl table
@r number
@d Interprets a Lua date table as a local time and converts it to a Unix time in
seconds. Equivalent to `os.time(tbl)`.
]=]
function Date.parseTable(tbl)
	return time(tbl)
end

--[=[
@m parseTableUTC
@t static
@op tbl table
@r number
@d Interprets a Lua date table as a UTC time and converts it to a Unix time in
seconds. Equivalent to `os.time(tbl)` with a correction for UTC.
]=]
function Date.parseTableUTC(tbl)
	return time(tbl) + offset()
end

--[=[
@m parseDiscordTimestamp
@p str string
@r number
@r string
@d Interprets a Discord timestamp format string `<t:seconds:style>`,
returns Unix time in seconds and the style if one was present.
]=]
function Date.parseDiscordTimestamp(str)
	local t, s = string.match(str, '<t:(%d+):?(%a?)>')
	return tonumber(t), s
end

--[=[
@m fromISO
@t static
@p str string
@r Date
@d Constructs a new Date object from an ISO 8601 string. Equivalent to
`Date(Date.parseISO(str))`.
]=]
function Date.fromISO(str)
	return Date(Date.parseISO(str))
end

--[=[
@m fromHeader
@t static
@p str string
@r Date
@d Constructs a new Date object from an RFC 2822 string. Equivalent to
`Date(Date.parseHeader(str))`.
]=]
function Date.fromHeader(str)
	return Date(Date.parseHeader(str))
end

--[=[
@m fromSnowflake
@t static
@p id string
@r Date
@d Constructs a new Date object from a Discord/Twitter Snowflake ID. Equivalent to
`Date(Date.parseSnowflake(id))`.
]=]
function Date.fromSnowflake(id)
	return Date(Date.parseSnowflake(id))
end

--[=[
@m fromTable
@t static
@p tbl table
@r Date
@d Constructs a new Date object from a Lua date table interpreted as a local time.
Equivalent to `Date(Date.parseTable(tbl))`.
]=]
function Date.fromTable(tbl)
	return Date(Date.parseTable(tbl))
end

--[=[
@m fromTableUTC
@t static
@p tbl table
@r Date
@d Constructs a new Date object from a Lua date table interpreted as a UTC time.
Equivalent to `Date(Date.parseTableUTC(tbl))`.
]=]
function Date.fromTableUTC(tbl)
	return Date(Date.parseTableUTC(tbl))
end

--[=[
@m fromSeconds
@t static
@p s number
@r Date
@d Constructs a new Date object from a Unix time in seconds.
]=]
function Date.fromSeconds(s)
	return Date(s)
end

--[=[
@m fromMilliseconds
@t static
@p ms number
@r Date
@d Constructs a new Date object from a Unix time in milliseconds.
]=]
function Date.fromMilliseconds(ms)
	return Date(ms / MS_PER_S)
end

--[=[
@m fromMicroseconds
@t static
@p us number
@r Date
@d Constructs a new Date object from a Unix time in microseconds.
]=]
function Date.fromMicroseconds(us)
	return Date(0, us)
end

--[=[
@m fromDiscordTimestamp
@t static
@p str string
@r Date
@d Constructs a new Date object from the Discord timestamp format `<t:seconds:style>`.
]=]
function Date.fromDiscordTimestamp(str)
	return Date((Date.parseDiscordTimestamp(str)))
end

--[=[
@m toISO
@op sep string
@op tz string
@r string
@d Returns an ISO 8601 string that represents the stored date and time.
If `sep` and `tz` are both provided, then they are used as a custom separator
and timezone; otherwise, `T` is used for the separator and `+00:00` is used for
the timezone, plus microseconds if available.
]=]
function Date:toISO(sep, tz)
	if sep and tz then
		local ret = date('!%F%%s%T%%s', self._s)
		return format(ret, sep, tz)
	else
		if self._us == 0 then
			return date('!%FT%T', self._s) .. '+00:00'
		else
			return date('!%FT%T', self._s) .. format('.%06i+00:00', self._us)
		end
	end
end

--[=[
@m toHeader
@r string
@d Returns an RFC 2822 string that represents the stored date and time.
]=]
function Date:toHeader()
	return date('!%a, %d %b %Y %T GMT', self._s)
end

--[=[
@m toSnowflake
@r string
@d Returns a synthetic Discord Snowflake ID based on the stored date and time.
Due to the lack of native 64-bit support, the result may lack precision.
In other words, `Date.fromSnowflake(id):toSnowflake() == id` may be `false`.
]=]
function Date:toSnowflake()
	local n = (self:toMilliseconds() - DISCORD_EPOCH) * 2^22
	return format('%f', n):match('%d*')
end

--[=[
@m toTable
@r table
@d Returns a Lua date table that represents the stored date and time as a local
time. Equivalent to `os.date('*t', s)` where `s` is the Unix time in seconds.
]=]
function Date:toTable()
	return date('*t', self._s)
end

--[=[
@m toTableUTC
@r table
@d Returns a Lua date table that represents the stored date and time as a UTC
time. Equivalent to `os.date('!*t', s)` where `s` is the Unix time in seconds.
]=]
function Date:toTableUTC()
	return date('!*t', self._s)
end

--[=[
@m toSeconds
@r number
@d Returns a Unix time in seconds that represents the stored date and time.
]=]
function Date:toSeconds()
	return self._s + self._us / US_PER_S
end

--[=[
@m toMilliseconds
@r number
@d Returns a Unix time in milliseconds that represents the stored date and time.
]=]
function Date:toMilliseconds()
	return self._s * MS_PER_S + self._us / US_PER_MS
end

--[=[
@m toMicroseconds
@r number
@d Returns a Unix time in microseconds that represents the stored date and time.
]=]
function Date:toMicroseconds()
	return self._s * US_PER_S + self._us
end

--[=[
@m toDiscordTimestamp
@op style string
@r string
@d Returns the date converted to the Discord timestamp format `<t:seconds:style>`.
]=]
function Date:toDiscordTimestamp(style)
	local t = floor(self:toSeconds())
	if style then
		return string.format('<t:%d:%s>', t, style)
	else
		return string.format('<t:%d>', t)
	end
end

--[=[
@m toParts
@r number
@r number
@d Returns the seconds and microseconds that are stored in the date object.
]=]
function Date:toParts()
	return self._s, self._us
end

return Date
