{-# LANGUAGE BangPatterns #-}
module Data.API.JSONToCBOR
    ( serialiseJSONWithSchema
    , jsonToCBORWithSchema
    , deserialiseJSONWithSchema
    , postprocessJSON
    ) where

import           Data.API.Changes
import           Data.API.JSON
import           Data.API.Types
import           Data.API.Utils

import           Control.Applicative
import           Data.Aeson hiding (encode)
import qualified Data.ByteString.Base64         as B64
import qualified Data.ByteString.Lazy           as LBS
import qualified Data.HashMap.Strict            as HMap
import qualified Data.Map                       as Map
import           Data.Traversable
import qualified Data.Vector                    as Vec
import           Codec.Serialise     as CBOR
import           Data.Binary.Serialise.CBOR.JSON (cborToJson, jsonToCbor)
import           Codec.CBOR.Term
import           Data.Fixed (Pico)
import           Data.Maybe (fromMaybe)
import           Data.Scientific
import qualified Data.Text                      as T
import qualified Data.Text.Encoding             as TE
import           Data.Time.Clock.POSIX
import           Data.Time (UTCTime(UTCTime))
import           Prelude


-- | Serialise a JSON value as a CBOR term in a generic but
-- schema-dependent fashion.  This is necessary because the JSON
-- representation carries less information than we need in CBOR
-- (e.g. it lacks a distinction between bytestrings and text).
serialiseJSONWithSchema :: API -> TypeName -> Value -> LBS.ByteString
serialiseJSONWithSchema api tn v = serialise $ jsonToCBORWithSchema api tn v

-- | Convert a JSON value into a CBOR term in a generic but
-- schema-dependent fashion.
jsonToCBORWithSchema :: API -> TypeName -> Value -> Term
jsonToCBORWithSchema = jsonToCBORTypeName . apiNormalForm

jsonToCBORTypeName :: NormAPI -> TypeName -> Value -> Term
jsonToCBORTypeName napi tn v =
    case Map.lookup tn napi of
      Just (NRecordType nrt) -> jsonToCBORRecord napi nrt v
      Just (NUnionType  nut) -> jsonToCBORUnion  napi nut v
      Just (NEnumType   net) -> jsonToCBOREnum   napi net v
      Just (NTypeSynonym ty) -> jsonToCBORType   napi ty  v
      Just (NNewtype     bt) -> jsonToCBORBasic       bt  v
      Nothing                -> error $ "serialiseJSONWithSchema: missing definition for type "
                                          ++ T.unpack (_TypeName tn)

jsonToCBORType :: NormAPI -> APIType -> Value -> Term
jsonToCBORType napi ty0 v = case (ty0, v) of
    (TyList  ty, Array arr) | Vec.null arr -> TList []
                            | otherwise    -> TListI $ map (jsonToCBORType napi ty) (Vec.toList arr)
    (TyList  _ , _)         -> error "serialiseJSONWithSchema: expected array"
    (TyMaybe _ , Null)      -> TList []
    (TyMaybe ty, _)         -> TList [jsonToCBORType napi ty v]
    (TyName  tn, _)         -> jsonToCBORTypeName napi tn v
    (TyBasic bt, _)         -> jsonToCBORBasic bt v
    (TyJSON    , _)         -> jsonToCbor v

-- | Encode a record as a map from field names to values.  Crucially,
-- the fields are in ascending order by field name.
jsonToCBORRecord :: NormAPI -> NormRecordType -> Value -> Term
jsonToCBORRecord napi nrt v = case v of
    Object hm -> TMap $ map (f hm) $ Map.toAscList nrt
    _         -> error "serialiseJSONWithSchema: expected object"
  where
    f hm (fn, ty) = case HMap.lookup (_FieldName fn) hm of
                      Nothing -> error $ "serialiseJSONWithSchema: missing field " ++ T.unpack (_FieldName fn)
                      Just v' -> (TString (_FieldName fn), jsonToCBORType napi ty v')

-- | Encode a union as a single-element map from the field name to the value.
jsonToCBORUnion :: NormAPI -> NormUnionType -> Value -> Term
jsonToCBORUnion napi nut v = case v of
    Object hm | [(k, r)] <- HMap.toList hm -> case Map.lookup (FieldName k) nut of
       Just ty -> TMap [(TString k, jsonToCBORType napi ty r)]
       Nothing -> error "serialiseJSONWithSchema: unexpected alternative in union"
    _ -> error "serialiseJSONWithSchema: expected single-field object"

-- | Encode an enumerated value as its name; we do not check that it
-- actually belongs to the type here.
jsonToCBOREnum :: NormAPI -> NormEnumType -> Value -> Term
jsonToCBOREnum _ _ v = case v of
                         String t -> TString t
                         _        -> error "serialiseJSONWithSchema: expected string"

jsonToCBORBasic :: BasicType -> Value -> Term
jsonToCBORBasic bt v = case (bt, v) of
    (BTstring, String t) -> TString t
    (BTstring, _)        -> error "serialiseJSONWithSchema: expected string"
    (BTbinary, String t) -> case B64.decode $ TE.encodeUtf8 t of
                              Left  err-> error $ "serialiseJSONWithSchema: base64-decoding failed: " ++ err
                              Right bs -> TBytes bs
    (BTbinary, _)        -> error "serialiseJSONWithSchema: expected string"
    (BTbool  , Bool b)   -> TBool b
    (BTbool  , _)        -> error "serialiseJSONWithSchema: expected bool"
    (BTint   , Number n) | Right i <- (floatingOrInteger n :: Either Double Int) -> TInt i
    (BTint   , _)        -> error "serialiseJSONWithSchema: expected integer"
    (BTutc   , String t) ->
      TTagged 1000 (TMap [ (TInt 1, TInt secs)
                         , (TInt (-12), TInt psecs) ])
        where  -- taken from @Codec.Serialise.Class@:
          (secs, frac) = case properFraction $ utcTimeToPOSIXSeconds utc of
                           -- fractional part must be positive
                           (secs', frac')
                             | frac' < 0  -> (secs' - 1, frac' + 1)
                             | otherwise -> (secs', frac')
          psecs = round $ frac * 1000000000000
          utc = fromMaybe (error $ "jsonToCBORBasic: " ++ T.unpack t) $
                  parseUTC' t
    (BTutc   , _)        -> error "serialiseJSONWithSchema: expected string"


-- | When a JSON value has been deserialised from CBOR, the
-- representation may need some modifications in order to match the
-- result of 'toJSON' on a Haskell datatype.  In particular, Aeson's
-- representation of 'Maybe' does not round-trip (because 'Nothing' is
-- encoded as 'Null' and @'Just' x@ as @'toJSON' x@), so CBOR uses a
-- different representation (as an empty or 1-element list).
deserialiseJSONWithSchema :: API -> TypeName -> LBS.ByteString -> Value
deserialiseJSONWithSchema api tn bs = case postprocessJSON api tn (cborToJson (deserialise bs)) of
    Right v  -> v
    Left err -> error $ "deserialiseJSONWithSchema could not post-process: " ++ prettyValueError err

postprocessJSON :: API -> TypeName -> Value -> Either ValueError Value
postprocessJSON api = postprocessJSONTypeName (apiNormalForm api)

postprocessJSONTypeName :: NormAPI -> TypeName -> Value -> Either ValueError Value
postprocessJSONTypeName napi tn v = do
    t <- Map.lookup tn napi ?! InvalidAPI (TypeDoesNotExist tn)
    case t of
      NRecordType nrt -> postprocessJSONRecord napi nrt v
      NUnionType  nut -> postprocessJSONUnion  napi nut v
      NEnumType    _  -> pure v
      NTypeSynonym ty -> postprocessJSONType   napi ty  v
      NNewtype     bt -> postprocessJSONType   napi (TyBasic bt) v

postprocessJSONType :: NormAPI -> APIType -> Value -> Either ValueError Value
postprocessJSONType napi ty0 v = case ty0 of
    TyList ty  -> case v of
                   Array arr -> Array <$> traverse (postprocessJSONType napi ty) arr
                   _         -> Left $ JSONError $ expectedArray v
    TyMaybe ty -> case v of
                    Array arr -> case Vec.toList arr of
                                   []    -> pure Null
                                   [v1]  -> postprocessJSONType napi ty v1
                                   _:_:_ -> Left $ JSONError $ SyntaxError "over-long array when converting Maybe value"
                    _         -> Left $ JSONError $ expectedArray v
    TyName tn  -> postprocessJSONTypeName napi tn v
    TyBasic BTutc -> case v of
      Object obj -> case HMap.toList obj of
        [(k1, Number v0), (km12, Number v1)]
          | T.unpack k1 == "1" && T.unpack km12 == "-12" ->
          -- Taken from @Codec.Serialise.Class@:
          let psecs :: Pico
              psecs = realToFrac v1 / 1000000000000

              dt :: POSIXTime
              dt = realToFrac v0 + realToFrac psecs

          in pure $! String $! mkUTC' $! forceUTCTime (posixSecondsToUTCTime dt)
        _ -> Left $ JSONError UnexpectedField
      String t -> case parseUTC' t of
        Nothing -> Left $ JSONError $ SyntaxError $
                     "UTC time in wrong format: " ++ T.unpack t
        Just utcTime -> pure $! String $! mkUTC' $! forceUTCTime utcTime
      _ -> Left $ JSONError $ expectedObject v
    TyBasic _  -> pure v
    TyJSON     -> pure v

-- | Force the unnecessarily lazy @'UTCTime'@ representation.
forceUTCTime :: UTCTime -> UTCTime
forceUTCTime t@(UTCTime !_day !_daytime) = t

postprocessJSONRecord :: NormAPI -> NormRecordType -> Value -> Either ValueError Value
postprocessJSONRecord napi nrt v = case v of
    Object hm -> Object <$> HMap.traverseWithKey f hm
    _         -> Left $ JSONError $ expectedObject v
  where
    f t v' = do ty <- Map.lookup (FieldName t) nrt ?! JSONError UnexpectedField
                postprocessJSONType napi ty v'

postprocessJSONUnion :: NormAPI -> NormUnionType -> Value -> Either ValueError Value
postprocessJSONUnion napi nut v = case v of
    Object hm | [(k, r)] <- HMap.toList hm
              , Just ty <- Map.lookup (FieldName k) nut
              -> Object . HMap.singleton k <$> postprocessJSONType napi ty r
    _ -> Left $ JSONError $ expectedObject v