{-# LANGUAGE OverloadedStrings #-}
-- |
-- This module provides functions wrapping the Azure REST API web methods.

module Network.TableStorage.API (
  withTableStorage,
  queryTables, createTable, createTableIfNecessary, deleteTable,
  insertEntity, updateEntity, mergeEntity, deleteEntity,
  queryEntity, queryEntities, defaultEntityQuery,
  defaultAccount, defaultConf
) where

import Network.HTTP.Types
import Text.XML.Light
    ( Element(elName),
      QName(qName, qURI),
      showTopElement,
      filterChildren,
      findAttr,
      findChild,
      findChildren,
      strContent )
import Network.TableStorage.Types
import Network.TableStorage.Auth
import Network.TableStorage.Request
import Network.TableStorage.Response
import Network.TableStorage.Atom
import Data.Time.Clock ( getCurrentTime )
import Data.Maybe ( fromMaybe )
import Control.Monad ( unless )
import Control.Monad.Reader
import Control.Monad.Error

-- |
-- Runs TableStorage actions given a configuration
--
withTableStorage :: TableConf -> TableStorage a -> IO (Either TableError a)
withTableStorage conf f = runReaderT (runErrorT f) conf

-- |
-- Simple helper function to convert non-monadic parser results into the monadic result
--
fromEither :: MonadError e m => Either e a -> m a
fromEither (Left e) = throwError e
fromEither (Right a) = return a
{-# INLINE fromEither #-}

-- |
-- Parse the response body of the Query Tables web method
--
parseQueryTablesResponse :: QueryResponse -> Either TableError [String]
parseQueryTablesResponse = parseXmlResponseOrError status200 readTables where
  readTables :: Element -> Maybe [String]
  readTables feed = sequence $ do
    entry <- findChildren (qualifyAtom "entry") feed
    return $ readTableName entry
  readTableName =
    findChild (qualifyAtom "content")
    >=> findChild (qualifyMetadata "properties")
    >=> findChild (qualifyDataServices "TableName")
    >=> return . strContent

-- |
-- List the names of tables for an account or returns an error message
--
queryTables :: TableStorage [String]
queryTables = do
  let resource = "/Tables"
  response <- authenticatedRequest methodGet [] resource resource ""
  fromEither $ parseQueryTablesResponse response

-- |
-- Construct the request body for the Create Table web method
--
createTableXml :: String -> IO Element
createTableXml tableName = wrapContent Nothing $ propertyList [("TableName", EdmString $ Just tableName)]

-- |
-- Creates a new table with the specified name or returns an error message
--
createTable :: String -> TableStorage ()
createTable tableName = do
  let resource = "/Tables"
  requestXml <- liftIO $ createTableXml tableName
  response <- authenticatedRequest methodPost [] resource resource $ showTopElement requestXml
  fromEither $ parseEmptyResponse status201 response

-- |
-- Creates a new table with the specified name if it does not already exist, or returns an erro message
--
createTableIfNecessary :: String -> TableStorage ()
createTableIfNecessary tableName = do
  tables <- queryTables
  unless (tableName `elem` tables) $ createTable tableName

-- |
-- Deletes the table with the specified name or returns an error message
--
deleteTable :: String -> TableStorage ()
deleteTable tableName = do
  let resource = "/Tables('" ++ tableName ++ "')"
  response <- authenticatedRequest methodDelete [] resource resource ""
  fromEither $ parseEmptyResponse status204 response

-- |
-- Construct the request body for the Insert Entity web method
--
createInsertEntityXml :: Entity -> Maybe String -> IO Element
createInsertEntityXml entity entityID = do
  time <- getCurrentTime
  wrapContent entityID $ propertyList $ [
      ("PartitionKey", EdmString $ Just $ ekPartitionKey $ entityKey entity),
      ("RowKey",       EdmString $ Just $ ekRowKey $ entityKey entity),
      ("Timestamp",    EdmDateTime $ Just time)
    ] ++ entityColumns entity

-- |
-- Inserts an entity into the table with the specified name or returns an error message
--
insertEntity :: String -> Entity -> TableStorage ()
insertEntity tableName entity = do
  let resource = '/' : tableName
  requestXml <- liftIO $ createInsertEntityXml entity Nothing
  response <- authenticatedRequest methodPost [] resource resource $ showTopElement requestXml
  fromEither $ parseEmptyResponse status201 response

-- |
-- Shared method to update or merge an existing entity. The only difference between the
-- two methods is the request method used.
--
updateOrMergeEntity :: Method -> String -> Entity -> TableStorage ()
updateOrMergeEntity method tableName entity = do
  let resource = entityKeyResource tableName $ entityKey entity
  let additionalHeaders = [ ("If-Match", "*") ]
  acc <- fmap tableAccount ask
  requestXml <- liftIO $ createInsertEntityXml entity (Just $
    accountScheme acc ++ "://" ++ accountHost acc ++ resource)
  response <- authenticatedRequest method additionalHeaders resource resource $ showTopElement requestXml
  fromEither $ parseEmptyResponse status204 response

-- |
-- Updates the specified entity (possibly removing columns) or returns an error message
--
updateEntity :: String -> Entity -> TableStorage ()
updateEntity = updateOrMergeEntity methodPut

-- |
-- Merges the specified entity (without removing columns) or returns an error message
--
mergeEntity :: String -> Entity -> TableStorage ()
mergeEntity = updateOrMergeEntity "MERGE"

-- |
-- Deletes the entity with the specified key or returns an error message
--
deleteEntity :: String -> EntityKey -> TableStorage ()
deleteEntity tableName key = do
  let resource = entityKeyResource tableName key
  let additionalHeaders = [ ("If-Match", "*") ]
  response <- authenticatedRequest methodDelete additionalHeaders resource resource ""
  fromEither $ parseEmptyResponse status204 response

-- |
-- Parse an Atom entry as an entity
--
readEntity :: Element -> Maybe Entity
readEntity entry = do
    properties <-
      findChild (qualifyAtom "content")
      >=> findChild (qualifyMetadata "properties")
      $ entry
    partitionKey <- findChild (qualifyDataServices "PartitionKey") properties
    rowKey <- findChild (qualifyDataServices "RowKey") properties
    let columnData = filterChildren filterProperties properties
    columns <- mapM elementToColumn columnData
    return Entity { entityKey = EntityKey { ekPartitionKey = strContent partitionKey,
                                            ekRowKey = strContent rowKey },
                    entityColumns = columns } where
  filterProperties el | elName el == qualifyDataServices "PartitionKey" = False
                      | elName el == qualifyDataServices "RowKey" = False
                      | otherwise = qURI (elName el) == Just dataServicesNamespace
  elementToColumn el =
    let propertyName = qName $ elName el in
    let typeAttr = fromMaybe "Edm.String" $ findAttr (qualifyMetadata "type") el in
    let typeNull = maybe False ("true" ==) $ findAttr (qualifyMetadata "null") el in
    (\val -> (propertyName, val)) `fmap` parseEntityColumn typeNull typeAttr (strContent el)

-- |
-- Parse the response body of the Query Entity web method
--
parseQueryEntityResponse :: QueryResponse -> Either TableError Entity
parseQueryEntityResponse = parseXmlResponseOrError status200 readEntity

-- |
-- Returns the entity with the specified table name and key or an error message
--
queryEntity :: String -> EntityKey -> TableStorage Entity
queryEntity tableName key = do
  let resource = entityKeyResource tableName key
  response <- authenticatedRequest methodGet [] resource resource ""
  fromEither $ parseQueryEntityResponse response

-- |
-- Parse the response body of the Query Entities web method
--
parseQueryEntitiesResponse :: QueryResponse -> Either TableError [Entity]
parseQueryEntitiesResponse = parseXmlResponseOrError status200 readEntities where
  readEntities :: Element -> Maybe [Entity]
  readEntities feed = sequence $ do
    entry <- findChildren (qualifyAtom "entry") feed
    return $ readEntity entry

-- |
-- Returns a collection of entities by executing the specified query or returns an error message
--
queryEntities :: String -> EntityQuery -> TableStorage [Entity]
queryEntities tableName query = do
  let canonicalizedResource = '/' : tableName ++ "()"
  let queryString = buildQueryString query
  let resource = canonicalizedResource ++ '?' : queryString
  response <- authenticatedRequest methodGet [] resource canonicalizedResource ""
  fromEither $ parseQueryEntitiesResponse response

-- |
-- An empty query with no filters and no specified page size
--
defaultEntityQuery :: EntityQuery
defaultEntityQuery = EntityQuery { eqPageSize = Nothing,
                                   eqFilter = Nothing }

-- |
-- Constructs an Account with the default values for Port and Resource Prefix
defaultAccount :: AccountKey -> String -> String -> Account
defaultAccount key name hostname = Account { accountScheme              = "http:",
                                             accountHost                = hostname,
                                             accountPort                = 80,
                                             accountKey                 = key,
                                             accountName                = name,
                                             accountResourcePrefix      = "" }

defaultConf :: AccountKey -> String -> String -> TableConf
defaultConf key name hostname = TableConf (defaultAccount key name hostname) Nothing Nothing