{-# LANGUAGE OverloadedStrings #-}

{-|
Module: Data.Time.LocalTime.TimeZone.Detect
Portability: POSIX

Exposes utilities derived from the excellent ZoneDetect library <https://github.com/BertoldVdb/ZoneDetect>.
To use this module, you need to obtain database files from the aforementioned library's server.

Additionally, if you have a local time, latitude and longitude, we use the timezone-series and timezone-olson
packages to help determine the equivalent UTC instant to that point in geography and time.

The only relevant binding to ZoneDetect is 'lookupTimeZoneName', richer information that plays neatly
with the notion of 'TimeZone' could be derived, but I didn't have a personal need for that yet.

Functions to open, close, and work with an open database (from a valid file) are provided.
-}

module Data.Time.LocalTime.TimeZone.Detect 
    ( TimeZoneName
    , TimeZoneDatabase
    -- for managing the database info pointer:
    , openTimeZoneDatabase
    , closeTimeZoneDatabase
    , withTimeZoneDatabase
    -- timezone lookup:
    , lookupTimeZoneName
    , lookupTimeZoneNameFromFile
    -- (local time, place) -> UTC "instant"
    , timeAtPointToUTC
    , timeAtPointToUTCFromFile
    -- (local time, timezone name) -> UTC "instant"
    , timeInTimeZoneToUTC
) where

import Foreign.ZoneDetect
import Foreign.C.String (peekCAString, withCAString)
import Foreign (Ptr, nullPtr)
import Data.Time
import Data.Time.LocalTime.TimeZone.Olson
import Data.Time.LocalTime.TimeZone.Series
import System.IO.Unsafe (unsafePerformIO)
import Control.Monad.IO.Class (MonadIO(liftIO))
import Control.Monad.Fail (MonadFail, fail)
import Prelude hiding (fail)
import Control.Exception (bracket)

-- | Alias for clarity, timezones are path-like strings that follow the IANA conventions
-- documented here:
-- <https://data.iana.org/time-zones/tz-link.html>
-- and here:
-- <https://en.wikipedia.org/wiki/Tz_database#Names_of_time_zones>
type TimeZoneName = FilePath

-- | A reference to a timezone database.
type TimeZoneDatabase = Ptr ZoneDetectInfo

-- | Given a timezone database, latitude and longitude, try to determine the
-- timezone name. Follow the instructions in the C library's repository
-- to obtain timezone database files (<https://github.com/BertoldVdb/ZoneDetect/tree/05567e367576d7f3efa00083b7661a01e43dc8ca/database>)
-- Once in possesion of said files, the lookup looks as follows:
-- 
-- >>> db <- openTimeZoneDatabase "./test/tz_db/timezone21.bin" 
-- >>> let tz = lookupTimeZoneName db 40.7831 (-73.9712) :: Maybe TimeZoneName
-- Just "America/New_York"
-- >>> closeTimeZoneDatabase db
-- 
-- An invalid database pointer, or invalid coordinates, will cause the given monad to `fail`.
-- Note that we follow the original library's pattern of obtaining the `TimeZoneDatabase`
-- separately (which could prove advantageous in e.g a web server.) If you're doing
-- a one-off lookup, or are okay with the IO cost of opening the DB file, see
-- `lookupTimeZoneNameFromFile`.
lookupTimeZoneName :: MonadFail m => TimeZoneDatabase -> Double -> Double -> m TimeZoneName
lookupTimeZoneName :: TimeZoneDatabase -> Double -> Double -> m TimeZoneName
lookupTimeZoneName TimeZoneDatabase
database Double
lat Double
lng =
    IO (m TimeZoneName) -> m TimeZoneName
forall a. IO a -> a
unsafePerformIO (IO (m TimeZoneName) -> m TimeZoneName)
-> IO (m TimeZoneName) -> m TimeZoneName
forall a b. (a -> b) -> a -> b
$ do
        if TimeZoneDatabase
database TimeZoneDatabase -> TimeZoneDatabase -> Bool
forall a. Eq a => a -> a -> Bool
== TimeZoneDatabase
forall a. Ptr a
nullPtr then
            m TimeZoneName -> IO (m TimeZoneName)
forall (f :: * -> *) a. Applicative f => a -> f a
pure (m TimeZoneName -> IO (m TimeZoneName))
-> m TimeZoneName -> IO (m TimeZoneName)
forall a b. (a -> b) -> a -> b
$ TimeZoneName -> m TimeZoneName
forall (m :: * -> *) a. MonadFail m => TimeZoneName -> m a
fail TimeZoneName
"Invalid timezone database."
        else do
            CString
tzName <- TimeZoneDatabase -> CFloat -> CFloat -> IO CString
c_ZDHelperSimpleLookupString TimeZoneDatabase
database
                                                   (Double -> CFloat
forall a b. (Real a, Fractional b) => a -> b
realToFrac Double
lat)
                                                   (Double -> CFloat
forall a b. (Real a, Fractional b) => a -> b
realToFrac Double
lng)
            if CString
tzName CString -> CString -> Bool
forall a. Eq a => a -> a -> Bool
== CString
forall a. Ptr a
nullPtr then
                m TimeZoneName -> IO (m TimeZoneName)
forall (f :: * -> *) a. Applicative f => a -> f a
pure (m TimeZoneName -> IO (m TimeZoneName))
-> m TimeZoneName -> IO (m TimeZoneName)
forall a b. (a -> b) -> a -> b
$ TimeZoneName -> m TimeZoneName
forall (m :: * -> *) a. MonadFail m => TimeZoneName -> m a
fail TimeZoneName
"Invalid coordinates."
            else
                CString -> IO TimeZoneName
peekCAString CString
tzName IO TimeZoneName
-> (TimeZoneName -> IO (m TimeZoneName)) -> IO (m TimeZoneName)
forall (m :: * -> *) a b. Monad m => m a -> (a -> m b) -> m b
>>= (m TimeZoneName -> IO (m TimeZoneName)
forall (f :: * -> *) a. Applicative f => a -> f a
pure (m TimeZoneName -> IO (m TimeZoneName))
-> (TimeZoneName -> m TimeZoneName)
-> TimeZoneName
-> IO (m TimeZoneName)
forall b c a. (b -> c) -> (a -> b) -> a -> c
. TimeZoneName -> m TimeZoneName
forall (m :: * -> *) a. Monad m => a -> m a
return)

-- | Same as `lookupTimeZoneName`, but takes the path to the database file and only works in `IO`.
lookupTimeZoneNameFromFile :: FilePath -> Double -> Double -> IO TimeZoneName
lookupTimeZoneNameFromFile :: TimeZoneName -> Double -> Double -> IO TimeZoneName
lookupTimeZoneNameFromFile TimeZoneName
databaseLocation Double
lat Double
lng =
    TimeZoneName
-> (TimeZoneDatabase -> IO TimeZoneName) -> IO TimeZoneName
forall a. TimeZoneName -> (TimeZoneDatabase -> IO a) -> IO a
withTimeZoneDatabase TimeZoneName
databaseLocation
        (\TimeZoneDatabase
db -> TimeZoneDatabase -> Double -> Double -> IO TimeZoneName
forall (m :: * -> *).
MonadFail m =>
TimeZoneDatabase -> Double -> Double -> m TimeZoneName
lookupTimeZoneName TimeZoneDatabase
db Double
lat Double
lng) 

-- | Given a timezone name (presumably obtained via `lookupTimeZoneName`,)
-- and a reference time in `LocalTime`, find the UTC equivalent.
timeInTimeZoneToUTC :: TimeZoneName -> LocalTime -> IO UTCTime
timeInTimeZoneToUTC :: TimeZoneName -> LocalTime -> IO UTCTime
timeInTimeZoneToUTC TimeZoneName
tzName LocalTime
referenceTime = do
    TimeZoneSeries
tzSeries <- TimeZoneName -> IO TimeZoneSeries
getTimeZoneSeriesFromOlsonFileUNIX TimeZoneName
tzName
    UTCTime -> IO UTCTime
forall (m :: * -> *) a. Monad m => a -> m a
return (UTCTime -> IO UTCTime) -> UTCTime -> IO UTCTime
forall a b. (a -> b) -> a -> b
$ TimeZoneSeries -> LocalTime -> UTCTime
localTimeToUTC' TimeZoneSeries
tzSeries LocalTime
referenceTime

-- | Given a timezone database, latitude, longitude and a local reference time, find the UTC Time
-- equivalent of that reference time in the given timezone. The reference time helps determine
-- which offset was in effect, since daylight savings, historical circumstances, political revisions
-- and other circumstances (documented in the olson tz database) may have been in effect
-- at that point in spacetime.
timeAtPointToUTC :: TimeZoneDatabase -> Double -> Double -> LocalTime -> IO UTCTime
timeAtPointToUTC :: TimeZoneDatabase -> Double -> Double -> LocalTime -> IO UTCTime
timeAtPointToUTC TimeZoneDatabase
database Double
lat Double
lng LocalTime
referenceTime = do
    TimeZoneName
tzName <- TimeZoneDatabase -> Double -> Double -> IO TimeZoneName
forall (m :: * -> *).
MonadFail m =>
TimeZoneDatabase -> Double -> Double -> m TimeZoneName
lookupTimeZoneName TimeZoneDatabase
database Double
lat Double
lng
    TimeZoneName -> LocalTime -> IO UTCTime
timeInTimeZoneToUTC TimeZoneName
tzName LocalTime
referenceTime

-- | Same as `timeAtPointToUTC`, but takes the path to the timezone database file
-- and takes care of opening and closing the file.
timeAtPointToUTCFromFile :: FilePath -> Double -> Double -> LocalTime -> IO UTCTime
timeAtPointToUTCFromFile :: TimeZoneName -> Double -> Double -> LocalTime -> IO UTCTime
timeAtPointToUTCFromFile TimeZoneName
databaseLocation Double
lat Double
lng LocalTime
referenceTime =
    TimeZoneName -> (TimeZoneDatabase -> IO UTCTime) -> IO UTCTime
forall a. TimeZoneName -> (TimeZoneDatabase -> IO a) -> IO a
withTimeZoneDatabase TimeZoneName
databaseLocation
        (\TimeZoneDatabase
db -> TimeZoneDatabase -> Double -> Double -> LocalTime -> IO UTCTime
timeAtPointToUTC TimeZoneDatabase
db Double
lat Double
lng LocalTime
referenceTime) 

-- | Gets timezone info from the standard location in UNIX systems.
-- The name should be one of the standard tz database names, as returned
-- by `lookupTimeZoneName`.
-- See: <https://en.wikipedia.org/wiki/List_of_tz_database_time_zones>
getTimeZoneSeriesFromOlsonFileUNIX :: TimeZoneName -> IO TimeZoneSeries
getTimeZoneSeriesFromOlsonFileUNIX :: TimeZoneName -> IO TimeZoneSeries
getTimeZoneSeriesFromOlsonFileUNIX TimeZoneName
tzName =
    TimeZoneName -> IO TimeZoneSeries
getTimeZoneSeriesFromOlsonFile (TimeZoneName -> IO TimeZoneSeries)
-> TimeZoneName -> IO TimeZoneSeries
forall a b. (a -> b) -> a -> b
$ TimeZoneName
"/usr/share/zoneinfo/" TimeZoneName -> TimeZoneName -> TimeZoneName
forall a. [a] -> [a] -> [a]
++ TimeZoneName
tzName

-- | Open a timezone database file and obtain a pointer to the database,
-- as interpreted by the underlying C code.
openTimeZoneDatabase :: FilePath -> IO TimeZoneDatabase
openTimeZoneDatabase :: TimeZoneName -> IO TimeZoneDatabase
openTimeZoneDatabase TimeZoneName
databaseLocation = 
    TimeZoneName
-> (CString -> IO TimeZoneDatabase) -> IO TimeZoneDatabase
forall a. TimeZoneName -> (CString -> IO a) -> IO a
withCAString TimeZoneName
databaseLocation ((CString -> IO TimeZoneDatabase) -> IO TimeZoneDatabase)
-> (CString -> IO TimeZoneDatabase) -> IO TimeZoneDatabase
forall a b. (a -> b) -> a -> b
$ \CString
dbl -> CString -> IO TimeZoneDatabase
c_ZDOpenDatabase CString
dbl

-- | Given a pointer to a timezone database, close any allocated resources.
closeTimeZoneDatabase :: TimeZoneDatabase -> IO ()
closeTimeZoneDatabase :: TimeZoneDatabase -> IO ()
closeTimeZoneDatabase = TimeZoneDatabase -> IO ()
c_ZDCloseDatabase

-- | Given a path to a timezone database file, and a computation to run with it,
-- takes care of opening the file, running the computation and then closing it.
withTimeZoneDatabase :: FilePath -> (TimeZoneDatabase -> IO a) -> IO a
withTimeZoneDatabase :: TimeZoneName -> (TimeZoneDatabase -> IO a) -> IO a
withTimeZoneDatabase TimeZoneName
databaseLocation = 
    IO TimeZoneDatabase
-> (TimeZoneDatabase -> IO ())
-> (TimeZoneDatabase -> IO a)
-> IO a
forall a b c. IO a -> (a -> IO b) -> (a -> IO c) -> IO c
bracket (TimeZoneName -> IO TimeZoneDatabase
openTimeZoneDatabase TimeZoneName
databaseLocation)
            (TimeZoneDatabase -> IO ()
closeTimeZoneDatabase)