{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
-- | Jenkins REST API method construction
module Jenkins.Rest.Method
  ( -- * Types
    Method
  , Type(..)
  , Format
  , As
    -- * Method construction
  , text, int
  , (-?-), (-/-), (-=-), (-&-)
  , query
  , as
  , JSONy(..)
  , XMLy(..)
  , Pythony(..)
    -- * Shortcuts
  , job
  , build
  , view
  , queue
  , overallLoad
  , computer
    -- * Rendering
  , render
  , slash
  ) where

import           Data.ByteString (ByteString)
import qualified Data.ByteString as B
import           Data.Data (Data, Typeable)
import           Data.Monoid (Monoid(..), (<>))
import           Data.String (IsString(..))
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import           Data.Text (Text)
import           GHC.Generics (Generic)
import           Network.URI (escapeURIChar, isUnreserved)

-- $setup
-- >>> :set -XOverloadedStrings


infix  1 :~?, -?-
infix  3 :~@, `as`
infix  7 :~=, -=-
infixr 5 :~/, -/-, :~&, -&-

-- | Jenkins RESTFul API method encoding
data Method :: Type -> Format -> * where
  Empty :: Method t f
  Text  :: Text -> Method Complete f
  (:~/)  :: Method Complete f -> Method Complete f -> Method Complete f
  (:~@)  :: Method Complete f -> As f -> Method Complete f
  (:~=)  :: Text -> Maybe Text -> Method Query f
  (:~&)  :: Method Query f -> Method Query f -> Method Query f
  (:~?)  :: Method Complete f -> Method Query f -> Method Complete f

deriving instance Show (As f) => Show (Method t f)

-- | Only to support number literals
instance t ~ Complete => Num (Method t f) where
  (+)         = error "Method.(+): not supposed to be used"
  (*)         = error "Method.(*): not supposed to be used"
  abs         = error "Method.abs: not supposed to be used"
  signum      = error "Method.signum: not supposed to be used"
  fromInteger = fromString . show

instance IsString (Method Complete f) where
  fromString = Text . T.pack

instance IsString (Method Query f) where
  fromString str = T.pack str :~= Nothing

-- | Method types
data Type = Query | Complete
  deriving (Show, Eq, Typeable, Data, Generic)

-- | Response formats
data Format = JSON | XML | Python
  deriving (Show, Eq, Typeable, Data, Generic)

-- | Response format singleton type
data As :: Format -> * where
  AsJSON   :: As JSON
  AsXML    :: As XML
  AsPython :: As Python

deriving instance Show (As f)
deriving instance Eq (As f)

-- | Convert 'Text' to 'Method'
text :: Text -> Method Complete f
text = Text

-- | Convert 'Integer' to 'Method'
int :: Integer -> Method Complete f
int = fromInteger

-- | Combine 2 paths
(-/-) :: Method Complete f -> Method Complete f -> Method Complete f
(-/-) = (:~/)

-- | Combine 2 queries
(-&-) :: Method Query f -> Method Query f -> Method Query f
(-&-) = (:~&)

-- | Make a field-value pair
(-=-) :: Text -> Text -> Method Query f
x -=- y = x :~= Just y

-- | Choose response format
as :: Method Complete f -> As f -> Method Complete f
as = (:~@)

-- | JSON response format
class JSONy t where
  json :: t JSON

instance JSONy As where
  json = AsJSON

instance t ~ Complete => JSONy (Method t) where
  json = "" `as` json

-- | XML response format
class XMLy t where
  xml :: t XML

instance XMLy As where
  xml = AsXML

instance t ~ Complete => XMLy (Method t) where
  xml = "" `as` xml

-- | Python response format
class Pythony t where
  python :: t Python

instance Pythony As where
  python = AsPython

instance t ~ Complete => Pythony (Method t) where
  python = "" `as` python

-- | Combine path and query
(-?-) :: Method Complete f -> Method Query f -> Method Complete f
(-?-) = (:~?)

-- | List-to-query convenience combinator
--
-- >>> render (query [("foo", Nothing), ("bar", Just "baz"), ("quux", Nothing)])
-- "foo&bar=baz&quux"
--
-- >>> render (query [])
-- ""
query :: [(Text, Maybe Text)] -> Method Query f
query [] = Empty
query xs = foldr1 (:~&) (map (uncurry (:~=)) xs)


-- | Render 'Method' to something that can be sent over the wire
--
-- >>> render ("" `as` xml)
-- "api/xml"
--
-- >>> render xml
-- "api/xml"
--
-- >>> render ("job" -/- 7 `as` xml)
-- "job/7/api/xml"
--
-- >>> render ("job" -/- 7 `as` xml)
-- "job/7/api/xml"
--
-- >>> render ("job" -/- 7 `as` json)
-- "job/7/api/json"
--
-- >>> render (text "restart")
-- "restart"
--
-- >>> render ("job" -?- "name" -=- "foo" -&- "title" -=- "bar")
-- "job?name=foo&title=bar"
--
-- >>> render ("job" -?- "name" -&- "title" -=- "bar")
-- "job?name&title=bar"
--
-- >>> render ("job" -/- 7 `as` json -?- "name" -&- "title" -=- "bar")
-- "job/7/api/json?name&title=bar"
--
-- >>> render ("job" -/- "ДМИТРИЙ" `as` xml)
-- "job/%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99/api/xml"
render :: Method t f -> ByteString
render Empty            = ""
render (Text s)         = renderText s
render (x :~/ y)        = render x `slash` render y
render (x :~@ f)        =
  let prefix  = render x
      postfix = renderFormat f
  in if B.null prefix then  "api" `slash` postfix else prefix `slash` "api" `slash` postfix
render (x :~= Just y)   = renderText x `equals` renderText y
render (x :~= Nothing)  = renderText x
render (x :~& y)        = render x `ampersand` render y
render (x :~? y)        = render x `question` render y

renderFormat :: IsString s => As f -> s
renderFormat AsJSON   = "json"
renderFormat AsXML    = "xml"
renderFormat AsPython = "python"

-- | Render unicode text as a query string
--
-- >>> renderText "foo-bar-baz"
-- "foo-bar-baz"
--
-- >>> renderText "foo bar baz"
-- "foo%20bar%20baz"
--
-- >>> renderText "ДМИТРИЙ МАЛИКОВ"
-- "%D0%94%D0%9C%D0%98%D0%A2%D0%A0%D0%98%D0%99%20%D0%9C%D0%90%D0%9B%D0%98%D0%9A%D0%9E%D0%92"
renderText :: Text -> ByteString
renderText = T.encodeUtf8 . T.concatMap (T.pack . escapeURIChar isUnreserved)

-- | Insert \"\/\" between two 'String'-like things and concatenate everything.
slash :: (IsString m, Monoid m) => m -> m -> m
slash = insert "/"

-- | Insert \"=\" between two 'String'-like things and concatenate everything.
equals :: (IsString m, Monoid m) => m -> m -> m
equals = insert "="

-- | Insert \"&\" between two 'String'-like things and concatenate everything.
ampersand :: (IsString m, Monoid m) => m -> m -> m
ampersand = insert "&"

-- | Insert \"?\" between two 'String'-like things and concatenate everything.
question :: (IsString m, Monoid m) => m -> m -> m
question = insert "?"

-- | Insert 'String'-like thing between two 'String'-like things and concatenate everything.
--
-- >>> "foo" `slash` "bar"
-- "foo/bar"
--
-- >>> "" `ampersand` "foo"
-- "&foo"
--
-- >>> "foo" `question` ""
-- "foo?"
insert :: (IsString m, Monoid m) => m -> m -> m -> m
insert t x y = x <> t <> y


-- | Job API method
--
-- >>> render (job "name" `as` json)
-- "job/name/api/json"
job :: Text -> Method Complete f
job name = "job" -/- text name

-- | Job build API method
--
-- >>> render (build "name" 4 `as` json)
-- "job/name/4/api/json"
build :: Integral a => Text -> a -> Method Complete f
build name num = "job" -/- text name -/- int (toInteger num)

-- | View API method
--
-- >>> render (view "name" `as` xml)
-- "view/name/api/xml"
view :: Text -> Method Complete f
view name = "view" -/- text name

-- | Queue API method
--
-- >>> render (queue `as` python)
-- "queue/api/python"
queue :: Method Complete f
queue = "queue"

-- | Statistics API method
--
-- >>> render (overallLoad `as` xml)
-- "overallLoad/api/xml"
overallLoad :: Method Complete f
overallLoad = "overallLoad"

-- | Node API method
--
-- >>> render (computer `as` python)
-- "computer/api/python"
computer :: Method Complete f
computer = "computer"