{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DerivingVia #-}
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE NumDecimals #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeOperators #-}

-- | Types for time intervals of various units. Each newtype wraps 'DiffTime',
-- but they have different 'Num' instances. The intent is to use the record
-- selectors to write literals with particular units, like this:
--
-- @
-- >>> 'milliseconds' 500
-- 0.5s
-- >>> 'hours' 3
-- 10800s
-- >>> 'minutes' 1.5 + 'seconds' 30
-- 120s
-- @
--
-- You can also go the other way using the constructors rather than the selectors:
--
-- @
-- >>> 'toRational' '$' 'Minutes' ('seconds' 17)
-- 17 % 60
-- >>> 'realToFrac' ('Days' ('hours' 12)) :: 'Double'
-- 0.5
-- @
--
-- NOTE: the 'Real' and 'Fractional' instances just essentially add or strip the
-- unit label (as  above), so you can't use 'realToFrac' to convert between the
-- units types here. Instead try  'convertDuration' which is less of a foot-gun.
--
-- The 'Read' instances for these types mirror the behavior of the 'RealFrac'
-- instance wrt numeric literals for convenient serialization (e.g. when working
-- with env vars):
--
-- @
-- >>> read "1.2" :: Milliseconds
-- Milliseconds {milliseconds = 0.0012s}
-- @
--
-- Generally, if you need to pass around a duration between functions you should
-- use 'DiffTime' directly. However if storing a duration in a type that will be
-- serialized, e.g. one having a 'ToJSON' instance, it is better to use one of
-- these explicit wrapper types so that it's  obvious what units will be used.
module Data.Time.Clock.Units
  ( Days (..),
    Hours (..),
    Minutes (..),
    Seconds (..),
    Milliseconds (..),
    Microseconds (..),
    Nanoseconds (..),

    -- * Converting between units
    Duration (..),
    convertDuration,

    -- * Reexports

    -- | We use 'DiffTime' as the standard type for unit-agnostic duration in our
    -- code. You'll need to convert to a 'NominalDiffTime'  (with 'convertDuration') in
    -- order to do anything useful with 'UTCTime' with these durations.
    --
    -- NOTE: some care must be taken especially when 'NominalDiffTime' interacts
    -- with 'UTCTime':
    --
    --  - a 'DiffTime' or 'NominalDiffTime' may be negative
    --  - 'addUTCTime' and 'diffUTCTime' do not attempt to handle leap seconds
    DiffTime,
    diffTimeToMicroSeconds,
  )
where

import Autodocodec (HasCodec (codec), dimapCodec)
import Control.Applicative ((<|>))
import Control.Arrow (first)
import Data.Aeson
import Data.Hashable
import Data.Proxy
import Data.Text (unpack)
import Data.Time.Clock
import GHC.TypeLits
import Numeric (readFloat)
import Text.Read qualified as TR
import Prelude

newtype Seconds = Seconds {seconds :: DiffTime}
  -- NOTE: we want Show to give a pastable data structure string, even
  -- though Read is custom.
  deriving (Duration, Show, Eq, Ord, ToJSON, FromJSON)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit (SecondsP 1))

instance HasCodec Seconds where
  codec = dimapCodec Seconds seconds codec

-- TODO if needed: deriving (ToJSON, FromJSON) via (TimeUnit ..) making sure
--      to copy Aeson instances (with withBoundedScientific), and e.g.
--         toJSON (5 :: Minutes) == Number 5
newtype Days = Days {days :: DiffTime}
  deriving (Duration, Show, Eq, Ord)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit (SecondsP 86400))

newtype Hours = Hours {hours :: DiffTime}
  deriving (Duration, Show, Eq, Ord)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit (SecondsP 3600))

newtype Minutes = Minutes {minutes :: DiffTime}
  deriving (Duration, Show, Eq, Ord)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit (SecondsP 60))

newtype Milliseconds = Milliseconds {milliseconds :: DiffTime}
  deriving (Duration, Show, Eq, Ord)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit 1000000000)

-- TODO: Has an alternative string representation instead of a numberic one here
-- in order to clarify what's going on.
-- Rounding is also problematic, but should be ok for now...
instance ToJSON Milliseconds where
  toJSON = toJSON . flip div 1e9 . diffTimeToPicoseconds . milliseconds

instance FromJSON Milliseconds where
  parseJSON v =
    withScientific "Milliseconds Number" (pure . Milliseconds . picosecondsToDiffTime . (* 1e9) . ceiling) v
      <|> withText "Milliseconds String" (either (fail . show) pure . TR.readEither . unpack) v

newtype Microseconds = Microseconds {microseconds :: DiffTime}
  deriving (Duration, Show, Eq, Ord)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit 1000000)

newtype Nanoseconds = Nanoseconds {nanoseconds :: DiffTime}
  deriving (Duration, Show, Eq, Ord)
  deriving (Read, Num, Fractional, Real, Hashable, RealFrac) via (TimeUnit 1000)

-- Internal for deriving via
newtype TimeUnit (picosPerUnit :: Nat) = TimeUnit DiffTime
  deriving (Show, Eq, Ord)

type SecondsP n = n GHC.TypeLits.* 1000000000000

natNum :: forall n a. (KnownNat n, Num a) => a
natNum = fromInteger $ natVal (Proxy @n)

instance (KnownNat picosPerUnit) => Num (TimeUnit picosPerUnit) where
  TimeUnit a + TimeUnit b = TimeUnit $ a + b
  TimeUnit a - TimeUnit b = TimeUnit $ a - b
  TimeUnit a * TimeUnit b =
    TimeUnit . picosecondsToDiffTime $
      diffTimeToPicoseconds a * diffTimeToPicoseconds b `div` natNum @picosPerUnit
  negate (TimeUnit a) = TimeUnit $ negate a
  abs (TimeUnit a) = TimeUnit $ abs a
  signum (TimeUnit a) = TimeUnit $ signum a
  fromInteger a = TimeUnit . picosecondsToDiffTime $ a * natNum @picosPerUnit

instance (KnownNat picosPerUnit) => Read (TimeUnit picosPerUnit) where
  readsPrec _ = map (first fromRational) . readFloat

instance (KnownNat picosPerUnit) => Fractional (TimeUnit picosPerUnit) where
  TimeUnit a / TimeUnit b =
    TimeUnit . picosecondsToDiffTime $
      diffTimeToPicoseconds a * natNum @picosPerUnit `div` diffTimeToPicoseconds b
  fromRational a = TimeUnit . picosecondsToDiffTime $ round (a * natNum @picosPerUnit)

instance (KnownNat picosPerUnit) => Real (TimeUnit picosPerUnit) where
  toRational (TimeUnit a) = toRational (diffTimeToPicoseconds a) / natNum @picosPerUnit

instance (KnownNat picosPerUnit) => RealFrac (TimeUnit picosPerUnit) where
  properFraction a = (i, a - fromIntegral i)
    where
      i = truncate a
  truncate = truncate . toRational
  round = round . toRational
  ceiling = ceiling . toRational
  floor = floor . toRational

-- we can ignore unit:
instance Hashable (TimeUnit a) where
  hashWithSalt salt (TimeUnit dt) =
    hashWithSalt salt $
      (realToFrac :: DiffTime -> Double) dt

-- | Duration types isomorphic to 'DiffTime', powering 'convertDuration'.
class Duration d where
  fromDiffTime :: DiffTime -> d
  toDiffTime :: d -> DiffTime

instance Duration DiffTime where
  fromDiffTime = id
  toDiffTime = id

instance Duration NominalDiffTime where
  fromDiffTime = realToFrac
  toDiffTime = realToFrac

-- | Safe conversion between duration units.
convertDuration :: (Duration x, Duration y) => x -> y
convertDuration = fromDiffTime . toDiffTime

diffTimeToMicroSeconds :: DiffTime -> Integer
diffTimeToMicroSeconds = (`div` 1000000) . diffTimeToPicoseconds
