-- |Interface to CouchDB.
module Database.CouchDB 
  ( -- * Initialization
    CouchMonad
  , runCouchDB
  , runCouchDB'
  -- * Databases
  , createDB
  , dropDB
  -- * Documents
  , newNamedDoc
  , newDoc
  , updateDoc
  , deleteDoc
  , forceDeleteDoc
  , getDocPrim
  , getDoc
  , getAndUpdateDoc
  , getAllDocIds
  -- * Views
  -- $views
  , CouchView (..)
  , newView
  , queryView
  , queryViewKeys
  ) where

import System.Log.Logger (errorM)
import Database.CouchDB.HTTP
import Database.CouchDB.Safety
import Control.Monad
import Control.Monad.Trans (liftIO)
import Data.Maybe (fromJust,mapMaybe)
import Text.JSON

import qualified Data.List as L

couchResponse :: String -> [(String,JSValue)]
couchResponse respBody = case decode respBody of
  Error s -> error s
  Ok r -> fromJSObject r

request' path method = request path [] method [] ""

-- |Creates a new database.  Throws an exception if the database already
-- exists. 
createDB :: String -> CouchMonad ()
createDB name = do
  resp <- request' name PUT
  unless (rspCode resp == (2,0,1)) $
    error (rspReason resp)

dropDB :: String -> CouchMonad Bool -- ^False if the database does not exist
dropDB name = do
  resp <- request' name DELETE
  case rspCode resp of
    (2,0,0) -> return True
    (4,0,4) -> return False
    otherwise -> error (rspReason resp)

unsafeNewNamedDoc :: (JSON a)
            => String -- ^database name
            -> String -- ^document name
            -> a -- ^document body
            -> CouchMonad (Either String String)
            -- ^Returns 'Left' on a conflict.  Returns 'Right' with the
            -- revision number on success.
unsafeNewNamedDoc dbName docName body = do
  r <- request (dbName ++ "/" ++ docName) [] PUT [] 
               (encode $ showJSON body)
  case rspCode r of
    (2,0,1) -> do
      let result = couchResponse (rspBody r)
      let (JSString rev) = fromJust $ lookup "rev" result
      return (Right $ fromJSString rev)
    (4,0,9) ->  do
      let result = couchResponse (rspBody r)
      let (JSObject errorObj) = fromJust $ lookup "error" result
      let (JSString reason) = 
            fromJust $ lookup "reason" (fromJSObject errorObj)
      return $ Left (fromJSString reason)
    otherwise -> error (show r)


newNamedDoc :: (JSON a)
            => DB -- ^database name
            -> Doc -- ^document name
            -> a -- ^document body
            -> CouchMonad (Either String String)
            -- ^Returns 'Left' on a conflict.  Returns 'Right' with the
            -- revision number on success.
newNamedDoc dbName docName body = 
  unsafeNewNamedDoc (show dbName) (show docName) body

updateDoc :: (JSON a)
          => DB -- ^database
          -> (JSString,JSString) -- ^document and revision
          -> a -- ^ new value
          -> CouchMonad (Maybe (JSString,JSString)) 
updateDoc db (doc,rev) val = do
  let (JSObject obj) = showJSON val
  let doc' = fromJSString doc
  let obj' = ("_id",JSString doc):("_rev",JSString rev):(fromJSObject obj)
  r <- request (show db ++ "/" ++ doc') [] PUT [] (encode $ toJSObject obj')
  case rspCode r of
    (2,0,1) ->  do
      let result = couchResponse (rspBody r)
      let (JSString rev) = fromJust $ lookup "rev" result
      return $ Just (doc,rev)
    (4,0,9) ->  return Nothing
    otherwise -> 
      error $ "updateDoc error.\n" ++ (show r) ++ rspBody r


-- |Delete a doc by document identifier (revision number not needed).  This
-- operation first retreives the document to get its revision number.  It fails
-- if the document doesn't exist or there is a conflict.
forceDeleteDoc :: DB -- ^ database
               -> Doc -- ^ document identifier
               -> CouchMonad Bool
forceDeleteDoc db doc = do
  r <- getDocPrim db doc
  case r of
    Just (id,rev,_) -> deleteDoc db (id,rev)
    Nothing -> return False

deleteDoc :: DB  -- ^database
          -> (JSString,JSString) -- ^document and revision
          -> CouchMonad Bool
deleteDoc db (doc,rev) = do 
  r <- request (show db ++ "/" ++ (fromJSString doc)) 
               [("rev",fromJSString rev)]
         DELETE [] ""
  case rspCode r of
    (2,0,0) -> return True
    -- TODO: figure out which error codes are normal (delete conflicts)
    otherwise -> fail $ "deleteDoc failed: " ++ (show r)
      

newDoc :: (JSON a)
       => DB -- ^database name
      -> a       -- ^document body
      -> CouchMonad (JSString,JSString) -- ^ id and rev of new document
newDoc db doc = do
  r <- request (show db) [] POST [] (encode $ showJSON doc)
  case rspCode r of
    (2,0,1) -> do
      let result = couchResponse (rspBody r)
      let (JSString rev) = fromJust $ lookup "rev" result
      let (JSString id) = fromJust $ lookup "id" result
      return (id,rev)
    otherwise -> error (show r)
    
getDoc :: (JSON a)
       => DB -- ^database name
       -> Doc -- ^document name
       -> CouchMonad (Maybe (JSString,JSString,a)) -- ^'Nothing' if the 
                                                   -- doc does not exist
getDoc db doc = do
  r <- getDocPrim db doc
  case r of
    Nothing -> return Nothing
    Just (id,rev,val) -> case readJSON (JSObject $ toJSObject val) of
      Ok a -> return $ Just (id,rev,a)
      otherwise -> fail $ "error parsing: " ++ encode val

-- |Gets a document as a raw JSON value.  Returns the document id,
-- revision and value as a 'JSObject'.  These fields are queried lazily,
-- and may fail later if the response from the server is malformed.
getDocPrim :: DB -- ^database name
           -> Doc -- ^document name
           -> CouchMonad (Maybe (JSString,JSString,[(String,JSValue)]))
           -- ^'Nothing' if the document does not exist.
getDocPrim db doc = do
  r <- request' (show db ++ "/" ++ show doc) GET
  case rspCode r of
    (2,0,0) -> do
      let obj = couchResponse (rspBody r)
      let ~(JSString rev) = fromJust $ lookup "_rev" obj
      let ~(JSString id) = fromJust $ lookup "_id" obj
      return $ Just (id,rev,obj)
    (4,0,4) -> return Nothing -- doc does not exist
    code -> fail $ "getDocPrim: " ++ show code ++ " error"

getAndUpdateDoc :: (JSON a)
                => DB -- ^database
                -> Doc -- ^document name
                -> (a -> a) -- ^update function
                -> CouchMonad (Maybe String) -- ^If the update succeeds,
                                             -- return the revision number
                                             -- of the result.
getAndUpdateDoc db docId fn = do
  r <- getDoc db docId
  case r of
    Just (id,rev,val) -> do
      r <- updateDoc db (id,rev) (fn val)
      case r of
        Just (id,rev) -> return (Just $ fromJSString rev)
        Nothing -> return Nothing
    Nothing -> return Nothing


allDocRow :: JSValue -> Maybe String
allDocRow (JSObject row) = case lookup "key" (fromJSObject row) of
  Just (JSString s) -> let key = fromJSString s
                         in case key of
                              '_':_ -> Nothing
                              otherwise -> Just key
  Just _ -> error $ "key not a string in row " ++ show row
  Nothing -> error $ "no key in a row " ++ show row
allDocRow v = error $ "expected row to be an object, received " ++ show v

getAllDocIds ::DB -- ^database name
             -> CouchMonad [String]
getAllDocIds db = do
  response <- request' (show db ++ "/_all_docs") GET
  case rspCode response of
    (2,0,0) -> do
      let result = couchResponse (rspBody response)
      let (JSArray rows) = fromJust $ lookup "rows" result
      return $ mapMaybe allDocRow rows
    otherwise -> error (show response)

--
-- $views
-- Creating and querying views
--

data CouchView = ViewMap String String
               | ViewMapReduce String String String

couchViewToJSON :: CouchView -> (String,JSValue)
couchViewToJSON (ViewMap name fn) = (name,JSObject $ toJSObject fn') where
  fn' = [("map", JSString $ toJSString fn)]
couchViewToJSON (ViewMapReduce name m r) =
  (name, JSObject $ toJSObject obj) where
    obj = [("map", JSString $ toJSString m),
           ("reduce", JSString $ toJSString r)]

newView :: String -- ^database name
        -> String -- ^view set name
        -> [CouchView] -- ^views
        -> CouchMonad ()
newView dbName viewName views = do
  let body = toJSObject 
        [("language", JSString $ toJSString "javascript"),
         ("views", JSObject $ toJSObject (map couchViewToJSON views))]
  result <- unsafeNewNamedDoc dbName ("_design/" ++ viewName) 
             (JSObject body)
  case result of
    Right _ -> return ()
    Left err -> error err

toRow :: JSON a => JSValue -> (JSString,a)
toRow (JSObject objVal) = (key,value) where
   obj = fromJSObject objVal
   key = case lookup "id" obj of
     Just (JSString s) -> s
     Just v -> error $ "toRow: expected id to be a string, got " ++ show v
     Nothing -> error $ "toRow: row does not have an id field in " 
                        ++ show obj
   value = case lookup "value" obj of
     Just v -> case readJSON v of
       Ok v' -> v'
       Error s -> error s
     Nothing -> error $ "toRow: row does not have a value in " ++ show obj
toRow val =
  error $ "toRow: expected row to be an object, received " ++ show val

queryView :: (JSON a)
          => DB  -- ^database
          -> Doc  -- ^design
          -> Doc  -- ^view
          -> [(String, JSValue)] -- ^query parameters
          -- |Returns a list of rows.  Each row is a key, value pair.
          -> CouchMonad [(JSString, a)]
queryView db viewSet view args = do
  let args' = map (\(k,v) -> (k,encode v)) args
  let url' = concat [show db,"/_view/",show viewSet,"/",show view]
  r <- request url' args' GET [] ""
  case rspCode r of
    (2,0,0) -> do
      let result = couchResponse (rspBody r)
      let (JSArray rows) = fromJust $ lookup "rows" result
      return $ map toRow rows
    otherwise -> error (show r)

-- |Like 'queryView', but only returns the keys.  Use this for key-only
-- views where the value is completely ignored.
queryViewKeys :: DB  -- ^database
            -> Doc  -- ^design
            -> Doc  -- ^view
            -> [(String, JSValue)] -- ^query parameters
            -> CouchMonad [String]
queryViewKeys db viewSet view args = do
  let args' = map (\(k,v) -> (k,encode v)) args
  let url' = concat [show db,"/_view/",show viewSet,"/",show view]
  r <- request url' args' GET [] ""
  case rspCode r of
    (2,0,0) -> do
      let result = couchResponse (rspBody r)
      case lookup "rows" result of
        Just (JSArray rows) -> liftIO $ mapM rowKey rows
        otherwise -> fail $ "queryView: expected rows"
    otherwise -> error (show r)

rowKey :: JSValue -> IO String
rowKey (JSObject obj) = do
  let assoc = fromJSObject obj
  case lookup "id" assoc of
    Just (JSString s) -> return (fromJSString s)
    v -> fail "expected id"
rowKey v = fail "expected id"