local math = require "math"
local os = require "os"
local string = require "string"
local prce = require "rex_pcre"
local oo = require "loop.base"

local assert, error = assert, error
local type = type
local tonumber = tonumber

module("LuaOrm.Date", oo.class, package.seeall)

-- This site has a good reference on the subject
-- http://www.timeanddate.com/worldclock/timezone.html?n=163&syear=1990

-- constant(s)
local Second = 1
local Minute = 60 * Second
local Hour = 60 * Minute
local Day = 24 * Hour
local Week = 7 * Day
local Month = 31 * Day
local Year = 365 * Day
local Durations = { Y = Year, M = Month, W = Week, D = Day, h = Hour, m = Minute, s = Second }

-- Obtengo now y now como UTC. Resto y obtengo mi diferencia horaria respecto a UTC
local CurrentUtcOffset	-- The offset in seconds from local time to UTC time
local DaylightSavingsTimeEnabled
do
	local now = os.time()
	local isDst = os.date("*t", now).isdst	-- local time formatted as table, gives actual result for Daylight Savings Time (in UTC is always false)
	local nowUtc = os.time(os.date("!*t", now))
	CurrentUtcOffset = os.difftime(now, nowUtc)	-- me da el offset sin tomar en cuenta daylight savings time
	if isDst then
		CurrentUtcOffset = CurrentUtcOffset + 3600 -- (add one hour)
	end
	DaylightSavingsTimeEnabled = isDst
end

_M.__tostring = function(self)
	return self:toString()
end

local function CreateDate(year, month, day, hour, min, sec, millisec)
	year = tonumber(year)
	month = tonumber(month)
	day = tonumber(day)
	hour = tonumber(hour)
	min = tonumber(min)
	sec = tonumber(sec)
	millisec = tonumber(millisec)
	
	local date = { year = year, month = month, day = day, hour = hour, min = min, sec = sec, isdst = true}
	local localTimeDst = os.time(date)
	
	date.isdst = false
	local localTimeClean = os.time(date)
	
	--print(year, month, day, hour, min, sec, millisec)
	--print(localTimeDst, localTimeClean)
	
	local newdate = oo.new(_M)
	--newdate._time = DaylightSavingsTimeEnabled and localTimeDst or localTimeClean
	newdate._time = 0
	--in order to get the seconds since epoch in utc I need to calculate the offset without DST and the DST offset used for that date
	local utcTime = os.date("!*t", newdate._time)
	--print("os.time(utcTime)", os.time(utcTime))
	local offset = os.time(os.date("*t", os.time(utcTime))) - localTimeClean
	--print( os.time(os.date("*t", os.time(utcTime))))
	--print(offset)
	
	if newdate._time then
		newdate._time = newdate._time - offset
		newdate._milliseconds = tonumber(millisec) or 0
	else
		newdate._time = 0	-- when given an invalid date, assume the Epoch
		newdate._milliseconds = 0
	end
	--print(newdate._time)
	return newdate
end

--
-- Creates a date with the given values, that represents a date in UTC
-- (it will be adjusted using the current UTC offset before creating it)
-- Returns a new Date object.
local function dateWithValues(someValues)
	return CreateDate(someValues.year, someValues.month, someValues.day, 
		someValues.hour, someValues.min, someValues.sec, someValues.millisec or 0)
end

--
-- Creates a date with the given values, that represents a date in local time (corresponding to the machine where this code is run).
-- It will be adjusted to UTC
-- Returns a new Date object.
local function dateWithLocalValues(someValues)
	return CreateDate(someValues.year, someValues.month, someValues.day, 
		someValues.hour, someValues.min, someValues.sec, someValues.millisec or 0)
end


--
-- Builds a Date from a string representation
-- This assumes that the string represents a date in UTC
-- Returns a new Date object.
local function fromString(strDate)
	local newdate = oo.new(_M)
	-- match date part first
	local year, month, day = prce.match(strDate, [[^((?:19|20)\d\d)[- /.](0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])\s*]])
    if not year or not month or not day then
		month, day, year = prce.match(strDate, [[^(0[1-9]|1[012])[- /.](0[1-9]|[12][0-9]|3[01])[- /.]((?:19|20)\d\d)\s*]])
		if not year or not month or not day then
			day, month, year = prce.match(strDate, [[^(0[1-9]|[12][0-9]|3[01])[- /.](0[1-9]|1[012])[- /.]((?:19|20)\d\d)\s*]])
			if not year or not month or not day then 
				year, month, day = 1970, 1, 1
			end
		end
	end

	-- match hour/time part
	local hour, min, sec, millisec = prce.match(strDate, [[\s*(0[0-9]|1[0-9]|2[0-3]):([0-6][0-9]):([0-6][0-9])(?:\.(\d\d\d))?$]])
	if not hour or not min or not sec then
		hour, min, sec, millisec, zone = prce.match(strDate, [[\s(0[0-9]|1[0-9]|2[0-3]):([0-6][0-9]):([0-6][0-9])(?:\.(\d\d\d))? (a\.m\.|p\.m\.|AM|PM)$]], 1, "i") --case insensitive search
		if hour and min and sec then
			if zone then
				zone = (zone):lower()
				if (zone == "p.m." or zone == "pm") and tonumber(hour) ~= 12 then
					hour = tonumber(hour) + 12
				end
			end
		else
			hour, min, sec, millisec = 0, 0, 0, 0
		end
	end

	--[[
	local localTimeDst = os.time({ year = tonumber(year), month = tonumber(month),
						day = tonumber(day), hour = tonumber(hour),
						min = tonumber(min), sec = tonumber(sec), isdst = true})
						
	local localTimeClean = os.time({ year = tonumber(year), month = tonumber(month),
						day = tonumber(day), hour = tonumber(hour),
						min = tonumber(min), sec = tonumber(sec), isdst = false})
	local dstOffset = localTimeClean - localTimeDst
	newdate._time = DaylightSavingsTimeEnabled and localTimeDst or localTimeClean
	--in order to get the seconds since epoch in utc I need to calculate the offset without DST and the DST offset used for that date
	local utcTime = os.date("!*t", newdate._time)
	local offset = os.time(os.date("*t", os.time(utcTime))) - localTimeClean
	
	if newdate._time then
		newdate._time = newdate._time - offset
		newdate._milliseconds = tonumber(millisec) or 0
	else
		newdate._time = 0	-- when given an invalid date, assume the Epoch
		newdate._milliseconds = 0
	end
	
	return newdate
	--]]
	return CreateDate(year, month, day, hour, min, sec, millisec)
end

--
-- method to access this date time
-- This returns the number of seconds since the Epoch (1970-01-01 00:00:00 UTC)
function time(self)
	if self._time == nil then
		self._time = os.time()
	end
	return self._time
end

--
-- method to access this date time in milliseconds
-- This returns the number of seconds since the Epoch (1970-01-01 00:00:00 UTC)
function timeInMilliseconds(self)
	if self._time == nil then
		self._time = os.time()
	end
	return (self._time * 1000) + self._milliseconds
end

--
-- Returns the date as a table. The values are UTC.
function date(self)
	if not self._date then
		self._date = os.date( "!*t", self:time() )
	end
	self._date.millisec = self._milliseconds

	return self._date
end

--
-- Returns the date as a table. The date is in local time.
function dateLocal(self)
	if not self._dateLocal then
		self._dateLocal = os.date( "*t", self:time() )
	end
	self._dateLocal.milliseconds = self._milliseconds

	return self._dateLocal
end

--
-- Compares the given object with this object
-- Returns true if self is less than the compared date
function compare(self, anObject )
	return self:timeInMilliseconds() < anObject:timeInMilliseconds()
end

--
 -- Tests that self and anObject are equal (both of them dates and with the same internal value)
function equals(self, anObject )
	if oo.instanceof( anObject, _M) == true then
		if self:timeInMilliseconds() == anObject:timeInMilliseconds() then
			return true
		end
	end
	return false
end

--
-- method for hashing
function hashCode(self)
	return self:timeInMilliseconds()
end

--
-- method to access this date year
function year(self)
	return self:date().year
end

--
-- method to access this date month
function month(self)
	return self:date().month
end

--
-- method to access this date week
function week(self)
	return tonumber( self:toString( "%U" ) )
end

--
-- method to access this month number of weeks
function weeksInMonth(self)
	return self:monthEndDate():week() - self:monthDate():week() + 1
end

--
-- method to access this date day
function day(self)
	return self:date().day
end

--
-- method to access this date day of year
function dayOfYear(self)
	return self:date().yday
end

--
-- method to access this date day of week
function dayOfWeek(self)
	return self:date().wday
end

--
-- method to access this date hour
function hour(self)
	return self:date().hour
end

--
-- method to access this date minute
function minute(self)
	return self:date().min
end

--
-- method to access this date second
function second(self)
	return self:date()[ "sec" ]
end

--
-- method to access this date milliseconds
function milliseconds(self)
	return self._milliseconds
end

--
-- method to access the day date
-- Returns a new Date object
function dayDate(self)
	local someValues = { year = self:year(), month = self:month(), day = self:day(), hour = 0, min = 0, sec = 0 }
	
	return dateWithValues( someValues )
end

--
-- method to access the week date
-- Returns a new Date object
function weekDate(self)
	local someValues = { year = self:year(), month = self:month(), day = self:day() - self:dayOfWeek() + 1, hour = 0, min = 0, sec = 0 }
	
	return dateWithValues( someValues )
end

--
-- method to access the month date
-- Returns a new Date object
function monthDate(self)
	local someValues = { year = self:year(), month = self:month(), day = 1, hour = 0, min = 0, sec = 0 }
	
	return dateWithValues( someValues )
end

--
-- method to access the year date
-- Returns a new Date object
function yearDate(self)
	local someValues = { year = self:year(), month = 1, day = 1, hour = 0, min = 0, sec = 0 }
	
	return dateWithValues( someValues )
end

--
-- method to access the end of the day date
-- Returns a new Date object
function dayEndDate(self)
	local someValues = { year = self:year(), month = self:month(), day = self:day(), hour = 23, min = 59, sec = 59 , isdst = DaylightSavingsTimeEnabled}
	
	return dateWithValues( someValues )
end

--
-- method to access the end of the week date
-- Returns a new Date object
function weekEndDate(self)
	local someValues = { year = self:year(), month = self:month(), day = self:day() + 7 - self:dayOfWeek(), hour = 23, min = 59, sec = 59 }
	
	return dateWithValues( someValues )
end

--
-- method to access the end of the month date
-- Returns a new Date object
function monthEndDate(self)
	local someValues = { year = self:year(), month = self:month() + 1, day = 0, hour = 23, min = 59, sec = 59 }
	
	return dateWithValues( someValues )
end

--
-- method to access the end of the year date
-- Returns a new Date object
function yearEndDate(self)
	local someValues = { year = self:year(), month = 12, day = 31, hour = 23, min = 59, sec = 59 }
	
	return dateWithValues( someValues )
end

--
-- method to add year, month, day, hour, minute and second to a date
-- Returns a new Date object
function add( self, aYear, aMonth, aDay, anHour, aMinute, aSecond )
	local aYear = self:date()["year"] + ( aYear or 0 )
	local aMonth = self:date()["month"] + ( aMonth or 0 )
	local aDay = self:date()["day"] + ( aDay or 0 )
	local anHour = self:date()["hour"] + ( anHour or 0 )
	local aMinute = self:date()["min"] + ( aMinute or 0 )
	local aSecond = self:date()["sec"] + ( aSecond or 0 )
	local someValues = { year = aYear, month = aMonth, day = aDay, hour = anHour, min = aMinute, sec = aSecond, millisec = self._milliseconds }
	
	return dateWithValues( someValues )
end

-- method to compute an approximate interval between two dates
function interval(self, aDate, aType )
	local aDuration = Durations[ aType ] or Second
	local anInterval = math.floor( math.abs( self:time() - aDate:time() ) / aDuration )
	
	if self:time() > aDate:time() then
		anInterval = -anInterval
	end
	
	return anInterval
end

-- as per "Frequently Asked Questions about Calendars Version 2.7"
-- "2.16.1 Is there a formula for calculating the Julian day number?"
-- http://www.tondering.dk/claus/cal/node3.html#SECTION003161000000000000000

function julianDay(self)
	local floor = math.floor
	local a = floor( ( 14 - self:month() ) / 12 )
	local y = self:year() + 4800 - a
	local m = self:month() + ( 12 * a ) - 3
	local aDay = self:day() + floor( ( ( 153 * m ) + 2 ) / 5 ) + ( 365 * y ) + floor( y / 4 ) - floor( y / 100 ) + floor( y / 400 ) - 32045
	
	return aDay
end

-- Returns a new Date object from a given Julian day
function dateWithJulianDay(self, aJulianDay )
	local floor = math.floor
	local a = aJulianDay + 32044
	local b = floor( ( ( 4 * a ) + 3 ) / 146097 )
	local c = a - floor( ( 146097 * b ) / 4 )
	local d = floor( ( ( 4 * c ) + 3 ) / 1461 )
	local e = c - floor( ( 1461 * d ) / 4 )
	local m = floor( ( 5 * e + 2 ) / 153 )
	local aDay = e - floor( ( 153 * m + 2 ) / 5 ) + 1
	local aMonth = m + 3 - ( 12 * floor( m / 10 ) )
	local aYear = ( 100 * b ) + d - 4800 + floor( m / 10 )
	local someValues = { year = aYear, month = aMonth, day = aDay, hour = 0, min = 0, sec = 0 }

	return dateWithValues( someValues )
end

--
-- Returns the UTC date formatted according to @aFormat or %Y-%m-%d %H:%M:%S (plus .milliseconds)
function toString(self, aFormat)
	local custom = aFormat
	aFormat = aFormat or "!%Y-%m-%d %H:%M:%S"
	if aFormat:sub(1, 1) ~= "!" then
		aFormat = "!" .. aFormat
	end
	local date = os.date( aFormat, self:time() )
	if custom then
		return date
	end
	return date .. (".%03d"):format(self._milliseconds)
end

--
-- Returns the local date formatted according to @aFormat or %Y-%m-%d %H:%M:%S (plus .milliseconds)
function toLocalTimeString(self, aFormat)
	local custom = aFormat
	aFormat = aFormat or "%Y-%m-%d %H:%M:%S"
	if aFormat:sub(1, 1) == "!" then
		aFormat = aFormat:sub(2)
	end

	local date = os.date( aFormat, self:time() )
	if custom then
		return date
	end
	return date .. (".%03d"):format(self._milliseconds)
end

--
-- Returns the UTC offset of a given date. Calculates the DST value at that time
function utcOffset(self)
	local date = self:date()
	date.isdst = true
	local localTimeDst = os.time(date)
	date.isdst = false
	local localTimeClean = os.time(date)
	
	local dstValue = localTimeDst - localTimeClean
	
	local now = self._time
	local isDst = os.date("*t", now).isdst	-- local time formatted as table, gives actual result for Daylight Savings Time (in UTC is always false)
	local nowUtc = os.time(os.date("!*t", now))
	local offset = os.difftime(now, nowUtc)	-- me da el offset sin tomar en cuenta daylight savings time
	if isDst then
		return offset - dstValue
	end
	return offset
end

--
-- Returns the date adjusted to the given timezone formatted according to @aFormat or %Y-%m-%d %H:%M:%S
-- @timezone a table with fields offset (an integer), minutes (an integer) and dst (a boolean)
function toDifferentTimezoneString(self, timezone, aFormat)
	-- the time, which is in UTC, is adjusted to the given timezone, and then adjusted again using the local timezone 
	-- because the os.date call below will adjust it back. So, the equation will result in:
	-- resulting_time = utc_time + desired_timezone_offset + local_timezone (at that time) - local_timezone (at that time)
	local custom = aFormat
	aFormat = aFormat or "%Y-%m-%d %H:%M:%S"
	if aFormat:sub(1, 1) == "!" then
		aFormat = aFormat:sub(2)
	end
	local offset, minutes, dst = assert(timezone.offset, "missing 'offset' field"), timezone.minutes or 0, timezone.dst or false 
	
	if offset < 0 and minutes > 0 then
		minutes = minutes * -1
	end
	if dst then
		offset = offset + 1
	end
	
	local timeZoneDate = self:add(0, 0, 0, offset, minutes)
	timeZoneDate = timeZoneDate:add(0, 0, 0, 0, 0, -self:utcOffset())

	local date = os.date( aFormat, timeZoneDate:time() )
	if custom then
		return date
	end
	return date .. (".%03d"):format(timeZoneDate._milliseconds)
end

--
-- Returns the date adjusted to the given timezone. The value returned is a fake UTC date.
-- @timezone a table with fields offset (an integer), minutes (an integer) and dst (a boolean)
-- @return a new Date object
function toDifferentTimezone(self, timezone)
	local offset, minutes, dst = assert(timezone.offset), timezone.minutes or 0, timezone.dst or false 
	
	if offset < 0 and minutes > 0 then
		minutes = minutes * -1
	end
	if dst then
		offset = offset + 1
	end
	return self:add(0, 0, 0, 0, 0, (offset * 60 + minutes) * 60)
end

--
-- Returns a new UTC date constructed from a string that contains a date in a given timezone.
-- @value a string with a date in canonical format
-- @timezone a table with fields offset (an integer), minutes (an integer) and dst (a boolean)
-- @return a new Date object
function newFromDifferentTimezone(self, value, timezone)
	-- for instance in Uruguay with DST, "20:00:00" will become "22:00:00 UTC"
	local d = fromString(value)	-- d holds a fake UTC date (20:00:00 UTC)
	local offset, minutes, dst = assert(timezone.offset, "missing 'offset' field"), timezone.minutes or 0, timezone.dst or false 
	
	-- adjust the sign of the minutes if the offset is negative
	if offset < 0 and minutes > 0 then
		minutes = minutes * -1
	end
	if dst then
		offset = offset + 1
	end
	-- now adjust the date to obtain the real UTC date from the local one
	local timeZoneDate = d:add(0, 0, 0, -offset, -minutes)
	return timeZoneDate
end

-- method to access the local time zone offset (it was already computed)
function timeZoneOffset()
	return CurrentUtcOffset
end

-- returns true or false indicating is DST is enabled or not
function isDstEnabled()
	return DaylightSavingsTimeEnabled
end

-- 
-- Handles construction of dates when doing LuaOrm.Date(arg)
-- @arg can be a string with a date in UTC (i.e. 2009-05-23 13:54:12) or a table (i.e. { year=2009, month=5, day=23, hour=13, min=54, sec=12, millisec=078})
-- If @arg is nil then it returns the current time
function __init(class, arg)
	if not arg then
		local newInstance = oo.rawnew(class)
		newInstance._milliseconds = 0
		return newInstance
	end
	local type_arg = type(arg)
	if type_arg == "string" then
		return fromString(arg)
	elseif type_arg == "table" then
		if oo.instanceof( arg, _M) == true then
			-- just copy the time value
			local newInstance = oo.rawnew(class)
			newInstance._time = arg:time()
			newInstance._milliseconds = arg:milliseconds()
			return newInstance
		elseif arg.islocal then
			return dateWithLocalValues(arg)
		else
			return dateWithValues(arg)
		end
	elseif type_arg == "number" then
		local newInstance = oo.rawnew(class)
		newInstance._time = math.abs(arg)
		newInstance._milliseconds = 0
		return newInstance
	else
		error("argument must be a string or a table")
	end
end
