-- |
-- This module provides functions wrapping the Azure REST API web methods.

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

import Network.HTTP
    ( HeaderName(HdrCustom),
      Header(Header),
      RequestMethod(Custom, DELETE, GET, POST, PUT),
      Response_String )
import Text.XML.Light
    ( Element(elName),
      QName(qName, qURI),
      showTopElement,
      filterChildren,
      findAttr,
      findChild,
      findChildren,
      strContent )
import Network.TableStorage.Types
    ( EntityQuery(..),
      Entity(..),
      EntityColumn(EdmDateTime, EdmString),
      EntityKey(..),
      Account(..),
      AccountKey )
import Network.TableStorage.Auth ( authenticatedRequest )
import Network.TableStorage.Request
    ( propertyList, entityKeyResource, buildQueryString )
import Network.TableStorage.Response
    ( parseEmptyResponse, parseXmlResponseOrError, parseEntityColumn )
import Network.TableStorage.Atom
    ( dataServicesNamespace,
      qualifyAtom,
      qualifyDataServices,
      qualifyMetadata,
      wrapContent )
import Control.Monad ( (>=>), unless )
import Control.Monad.Error ( ErrorT(..) )
import Data.Time.Clock ( getCurrentTime )
import Data.Maybe ( fromMaybe )

-- |
-- Parse the response body of the Query Tables web method
--
parseQueryTablesResponse :: Response_String -> Either String [String]
parseQueryTablesResponse = parseXmlResponseOrError (2, 0, 0) 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 :: Account -> IO (Either String [String])
queryTables acc = do
  let resource = "/Tables"
  response <- authenticatedRequest acc GET [] resource resource ""
  return $ response >>= parseQueryTablesResponse

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

-- |
-- Creates a new table with the specified name or returns an error message
--
createTable :: Account -> String -> IO (Either String ())
createTable acc tableName = do 
  let resource = "/Tables"
  requestXml <- createTableXml tableName
  response <- authenticatedRequest acc POST [] resource resource $ showTopElement requestXml
  return $ response >>= parseEmptyResponse (2, 0, 1)

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

-- |
-- Deletes the table with the specified name or returns an error message
--
deleteTable :: Account -> String -> IO (Either String ())
deleteTable acc tableName = do 
  let resource = "/Tables('" ++ tableName ++ "')" 
  response <- authenticatedRequest acc DELETE [] resource resource ""
  return $ response >>= parseEmptyResponse (2, 0, 4)

-- |
-- Construct the request body for the Insert Entity web method
--
createInsertEntityXml :: Entity -> IO Element
createInsertEntityXml entity = do
  time <- getCurrentTime
  wrapContent $ 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 :: Account -> String -> Entity -> IO (Either String ())
insertEntity acc tableName entity = do 
  let resource = '/' : tableName
  requestXml <- createInsertEntityXml entity
  response <- authenticatedRequest acc POST [] resource resource $ showTopElement requestXml
  return $ response >>= parseEmptyResponse (2, 0, 1)  

-- |
-- Shared method to update or merge an existing entity. The only difference between the
-- two methods is the request method used.
--
updateOrMergeEntity :: RequestMethod -> Account -> String -> Entity -> IO (Either String ())
updateOrMergeEntity method acc tableName entity = do 
  let resource = entityKeyResource tableName $ entityKey entity
  let additionalHeaders = [ Header (HdrCustom "If-Match") "*" ]
  requestXml <- createInsertEntityXml entity
  response <- authenticatedRequest acc method additionalHeaders resource resource $ showTopElement requestXml
  return $ response >>= parseEmptyResponse (2, 0, 4)
  
-- |
-- Updates the specified entity (possibly removing columns) or returns an error message
--
updateEntity :: Account -> String -> Entity -> IO (Either String ())
updateEntity = updateOrMergeEntity PUT

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

-- |
-- Deletes the entity with the specified key or returns an error message 
--
deleteEntity :: Account -> String -> EntityKey -> IO (Either String ())
deleteEntity acc tableName key = do 
  let resource = entityKeyResource tableName key
  let additionalHeaders = [ Header (HdrCustom "If-Match") "*" ]
  response <- authenticatedRequest acc DELETE additionalHeaders resource resource ""
  return $ response >>= parseEmptyResponse (2, 0, 4)

-- |
-- 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 :: Response_String -> Either String Entity
parseQueryEntityResponse = parseXmlResponseOrError (2, 0, 0) readEntity where 

-- |
-- Returns the entity with the specified table name and key or an error message
--
queryEntity :: Account -> String -> EntityKey -> IO (Either String Entity)
queryEntity acc tableName key = do 
  let resource = entityKeyResource tableName key
  response <- authenticatedRequest acc GET [] resource resource ""
  return $ response >>= parseQueryEntityResponse

-- |
-- Parse the response body of the Query Entities web method
--
parseQueryEntitiesResponse :: Response_String -> Either String [Entity]
parseQueryEntitiesResponse = parseXmlResponseOrError (2, 0, 0) 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 :: Account -> String -> EntityQuery -> IO (Either String [Entity])
queryEntities acc tableName query = do 
  let canonicalizedResource = '/' : tableName ++ "()"
  let queryString = buildQueryString query
  let resource = canonicalizedResource ++ '?' : queryString
  response <- authenticatedRequest acc GET [] resource canonicalizedResource ""
  return $ response >>= parseQueryEntitiesResponse
  
-- |
-- 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      = "" }