{-# LANGUAGE FlexibleContexts           #-}
{-# LANGUAGE FlexibleInstances          #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses      #-}
{-# LANGUAGE ScopedTypeVariables        #-}
{-# LANGUAGE TypeFamilies               #-}
{-# LANGUAGE UndecidableInstances       #-}


-- |
-- Copyright :  (c) Charles Strahan 2014
-- License   :  MIT
-- Maintainer:  Charles Strahan <charles.c.strahan@gmail.com>
-- Stability :  experimental
--
module Happybara.Monad
    ( -- * Happybara Monad
      HappybaraT(..)
    , Happybara
    , runHappybaraT
    , runHappybara
      -- * Monad Settings & State
    , Exactness(..)
    , SingleMatchStrategy(..)
    , setWait
    , getWait
    , setExactness
    , getExactness
    , setSingleMatchStrategy
    , getSingleMatchStrategy
    , getDriver
    , withDriver
    , getCurrentNode
    , HappybaraState(..)
      -- * Browser State
    , visit
    , currentUrl
    , responseHeaders
    , statusCode
    , html
    , goBack
    , goForward
    , reset
    , saveScreenshot
      -- * Scoping
    , withinNode
    , withinPage
    , withinFrame
    , withinWindow
      -- * JavaScript Execution
    , executeScript
    , evaluateScript
      -- * Primitive Queries
    , findXPath
    , findCSS
      -- * Node Manipulation
    , allText
    , visibleText
    , attr
    , getValue
    , setValue
    , selectOption
    , unselectOption
    , click
    , rightClick
    , doubleClick
    , hover
    , dragTo
    , tagName
    , isVisible
    , isChecked
    , isSelected
    , isDisabled
    , path
    , trigger
    , nodeEq
      -- * Async Support
    , synchronize
    ) where

import           Data.Aeson
import           Data.Text                   (Text)
import           Data.Time.Clock

import           Control.Applicative
import           Control.Concurrent
import           Control.Exception.Lifted
import           Control.Monad
import           Control.Monad.Base
import           Control.Monad.Identity
import           Control.Monad.IO.Class
import           Control.Monad.Trans.Class
import           Control.Monad.Trans.Control
import           Control.Monad.Trans.State

import           Network.HTTP.Types          (Header, ResponseHeaders, Status)

import           Happybara.Driver            (Driver, FrameSelector (..), Node,
                                              NodeValue (..))
import qualified Happybara.Driver            as D
import           Happybara.Exceptions

-- |
-- The Happybara monad transformer.
--
-- Requirements:
--
-- * The /sess/ session type must be an instance of 'Driver'.
--
-- * The inner monad /m/ must be an instance of 'MonadBase' 'IO' /m/, 'MonadIO' /m/, and
-- 'MonadBaseControl' 'IO' /m/.
newtype HappybaraT sess m a = HappybaraT { unHappybaraT :: StateT (HappybaraState sess) m a }
                              deriving (Functor, Applicative, Monad, MonadTrans, MonadIO)

-- |
-- If you don't want to transform an existing monad, this type synonym
-- conveniently sets the inner monad to 'IO'.
type Happybara sess a = HappybaraT sess IO a

-- |
-- The exactness requirement when using the 'Happybara.Query.Query' DSL.
data Exactness = Exact       -- ^ Find elements that match exactly.
               | PreferExact -- ^ First try to find exact matches;
                             -- if that fails, fall back to inexact matches.
               | Inexact     -- ^ Find all elements that partially match - e.g.
                             -- the given string is infix of (but not necessarily equal to)
                             -- whatever property (id, attribute, etc) is being queried over.
               deriving (Eq, Ord, Show)

-- |
-- This controls the 'Happybara.Query.Query' behavior of 'Happybara.Query.findOrFail' in
-- the presence of multiple matches.
data SingleMatchStrategy = MatchFirst -- ^ If no elements matched, throw 'ElementNotFoundException';
                                      -- otherwise, return the first matching element.
                         | MatchOne   -- ^ If no elements matched, throw 'ElementNotFoundException';
                                      -- if more than element matches, throw 'AmbiguousElementException'.

data HappybaraState sess = HappybaraState { hsDriver              :: sess
                                          , hsWait                :: Double
                                          , hsExactness           :: Exactness
                                          , hsIsSynced            :: Bool
                                          , hsSingleMatchStrategy :: SingleMatchStrategy
                                          , hsCurrentNode         :: Maybe (Node sess)
                                          }

instance (MonadBase b m) => MonadBase b (HappybaraT sess m) where
    liftBase = lift . liftBase

instance MonadTransControl (HappybaraT sess) where
    newtype StT (HappybaraT sess) a = StHappybara {unStHappybara :: StT (StateT (HappybaraState sess)) a}
    liftWith = defaultLiftWith HappybaraT unHappybaraT StHappybara
    restoreT = defaultRestoreT HappybaraT unStHappybara
    {-# INLINE liftWith #-}
    {-# INLINE restoreT #-}

instance (MonadBaseControl b m) => MonadBaseControl b (HappybaraT sess m) where
    newtype StM (HappybaraT sess m) a = StMHappybara {unStMHappybara :: ComposeSt (HappybaraT sess) m a}
    liftBaseWith = defaultLiftBaseWith StMHappybara
    restoreM     = defaultRestoreM unStMHappybara
    {-# INLINE liftBaseWith #-}
    {-# INLINE restoreM #-}

-- | Evaluate the happybara computation.
runHappybara :: (Driver sess) => sess -> Happybara sess a -> IO a
runHappybara = runHappybaraT

-- | Evaluate the happybara computation.
runHappybaraT :: (Driver sess, MonadIO m, MonadBase IO m, MonadBaseControl IO m)
              => sess -> HappybaraT sess m a -> m a
runHappybaraT sess act =
    evalStateT (unHappybaraT act) initialState
  where
    initialState = HappybaraState { hsDriver = sess
                                  , hsWait = 2
                                  , hsExactness = Inexact
                                  , hsIsSynced = False
                                  , hsSingleMatchStrategy = MatchOne
                                  , hsCurrentNode = Nothing
                                  }

-- | Set the number of seconds to wait between retrying an action. See
-- 'synchronize'.
setWait :: (Monad m) => Double -> HappybaraT sess m ()
setWait time =
    HappybaraT $ modify $ \s -> s { hsWait = time }

-- | Get the number of seconds to wait between retrying an action. See
-- 'synchronize'.
getWait :: (Functor m, Monad m) => HappybaraT sess m Double
getWait =
    HappybaraT $ hsWait <$> get

-- | Set the required level of exactness for queries. See 'Exactness'.
setExactness :: (Monad m) => Exactness -> HappybaraT sess m ()
setExactness exact =
    HappybaraT $ modify $ \s -> s { hsExactness = exact }

-- | Get the required level of exactness for queries. See 'Exactness'.
getExactness :: (Functor m, Monad m) => HappybaraT sess m Exactness
getExactness =
    HappybaraT $ hsExactness <$> get

-- | Use a different driver for the given action.
--
-- /Note:/ This sets the current scope via 'withinPage' before invoking the
-- action, because the current node was acquired from a different driver
-- instance. Similarly, it's a bad idea to return a 'Node' from this new driver,
-- as you might try to use it with the wrong driver instance.
withDriver :: (Driver sess, Functor m, Monad m)
           => sess -> HappybaraT sess m a -> HappybaraT sess m a
withDriver driver act = do
    oldDriver <-getDriver
    HappybaraT $ modify $ \s -> s { hsDriver = driver }
    result <- withinPage act
    HappybaraT $ modify $ \s -> s { hsDriver = oldDriver }
    return result

getDriver :: (Functor m, Monad m) => HappybaraT sess m sess
getDriver =
    HappybaraT $ hsDriver <$> get

-- | Set the query matching strategy. See 'SingleMatchStrategy'.
setSingleMatchStrategy :: (Monad m) => SingleMatchStrategy -> HappybaraT sess m ()
setSingleMatchStrategy strategy =
    HappybaraT $ modify $ \s -> s { hsSingleMatchStrategy = strategy }

-- | Get the query matching strategy. See 'SingleMatchStrategy'.
getSingleMatchStrategy :: (Functor m, Monad m) => HappybaraT sess m SingleMatchStrategy
getSingleMatchStrategy =
    HappybaraT $ hsSingleMatchStrategy <$> get

-- | Get the node that all queries are currently relative to.
getCurrentNode :: (Driver sess, Functor m, Monad m) => HappybaraT sess m (Maybe (Node sess))
getCurrentNode =
    HappybaraT $ hsCurrentNode <$> get

-- | Make all queries relative to the supplied node within the given action.
withinNode :: (Driver sess, Functor m, Monad m)
           => Node sess -> HappybaraT sess m a -> HappybaraT sess m a
withinNode newNode act = do
    oldNode <- getCurrentNode
    HappybaraT $ modify $ \s -> s { hsCurrentNode = Just newNode }
    res <- act
    HappybaraT $ modify $ \s -> s { hsCurrentNode = oldNode }
    return res

-- | Make all queries relative to the document in the given action.
withinPage :: (Driver sess, Functor m, Monad m)
           => HappybaraT sess m a -> HappybaraT sess m a
withinPage act = do
    oldNode <- getCurrentNode
    HappybaraT $ modify $ \s -> s { hsCurrentNode = Nothing }
    res <- act
    HappybaraT $ modify $ \s -> s { hsCurrentNode = oldNode }
    return res

-- | Invoke the given action until:
--
-- * The action no longer throws 'InvalidElementException', or
--
-- * The total duration of the attempts excedes the number of seconds specified by 'getWait', in
-- which case the exception is rethrown.
--
-- A couple notes:
--
-- * The action is retried every 0.05 seconds.
--
-- * To prevent exponential retrying, any inner calls to 'synchronize' are
-- ignored.
--
-- * Unless you're doing something advanced,
-- like implementing custom 'Happybara.Query.Query' instances,
-- you probably don't need to invoke this directly.
synchronize :: (Functor m, Monad m, MonadIO m, MonadBase IO m, MonadBaseControl IO m)
            => HappybaraT sess m a -> HappybaraT sess m a
synchronize act = do
    synced <- HappybaraT $ hsIsSynced <$> get
    if synced
      then act
      else do
          startTime <- liftIO getCurrentTime
          maxWait <- getWait
          HappybaraT $ modify $ \s -> s { hsIsSynced = True }
          res <- retry startTime maxWait
          HappybaraT $ modify $ \s -> s { hsIsSynced = False }
          return res
  where
    delayMicros = 50000 -- 0.05 seconds
    retry startTime maxWait =
        act `catch` (\(e :: InvalidElementException) -> do
            currenTime <- liftBase getCurrentTime
            let elapsedSeconds = fromRational $ toRational $ currenTime `diffUTCTime` startTime
            if elapsedSeconds > maxWait
              then throw e
              else do
                  liftBase $ threadDelay delayMicros
                  retry startTime maxWait)

-- wrapped driver methods

currentUrl :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m Text
currentUrl = do
    driver <- getDriver
    liftBase $ D.currentUrl driver

visit :: (Driver sess, Monad m, MonadBase IO m) => Text -> HappybaraT sess m ()
visit url = do
    driver <- getDriver
    liftBase $ D.visit driver url

findXPath :: (Driver sess, Monad m, MonadBase IO m) => Text -> HappybaraT sess m [Node sess]
findXPath xpath = do
    driver <- getDriver
    currentNode <- getCurrentNode
    case currentNode of
        Just node -> liftBase $ D.findXPathRel driver node xpath
        Nothing   -> liftBase $ D.findXPath driver xpath

findCSS :: (Driver sess, Monad m, MonadBase IO m) => Text -> HappybaraT sess m [Node sess]
findCSS css = do
    driver <- getDriver
    currentNode <- getCurrentNode
    case currentNode of
        Just node -> liftBase $ D.findCSSRel driver node css
        Nothing   -> liftBase $ D.findCSS driver css

html :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m Text
html = do
    driver <- getDriver
    liftBase $ D.html driver

goBack :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m ()
goBack = do
    driver <- getDriver
    liftBase $ D.goBack driver

goForward :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m ()
goForward = do
    driver <- getDriver
    liftBase $ D.goForward driver

executeScript :: (Driver sess, Monad m, MonadBase IO m) => Text -> HappybaraT sess m ()
executeScript script = do
    driver <- getDriver
    liftBase $ D.executeScript driver script

evaluateScript :: (Driver sess, Monad m, MonadBase IO m) => Text -> HappybaraT sess m Value
evaluateScript script = do
    driver <- getDriver
    liftBase $ D.evaluateScript driver script

saveScreenshot :: (Driver sess, Monad m, MonadBase IO m) => Text -> Int -> Int -> HappybaraT sess m ()
saveScreenshot path width height = do
    driver <- getDriver
    liftBase $ D.saveScreenshot driver path width height

responseHeaders :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m ResponseHeaders
responseHeaders = do
    driver <- getDriver
    liftBase $ D.responseHeaders driver

statusCode :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m Status
statusCode = do
    driver <- getDriver
    liftBase $ D.statusCode driver

withinFrame :: (Driver sess, Monad m, MonadBase IO m, MonadBaseControl IO m)
            => FrameSelector -> HappybaraT sess m Status -> HappybaraT sess m Status
withinFrame frameId act = do
    driver <- getDriver
    bracket_
        (liftBase $ D.setFrameFocus driver frameId)
        (liftBase $ D.setFrameFocus driver DefaultFrame)
        (act)

withinWindow :: (Driver sess, Monad m, MonadBase IO m, MonadBaseControl IO m)
            => FrameSelector -> HappybaraT sess m Status -> HappybaraT sess m Status
withinWindow windowId act = error "NOT IMPLEMENTED"

reset :: (Driver sess, Monad m, MonadBase IO m) => HappybaraT sess m ()
reset = do
    driver <- getDriver
    liftBase $ D.reset driver

allText :: (Driver sess, Monad m, MonadBase IO m)
        => Node sess -> HappybaraT sess m Text
allText node = do
    driver <- getDriver
    liftBase $ D.allText driver node

visibleText :: (Driver sess, Monad m, MonadBase IO m)
            => Node sess -> HappybaraT sess m Text
visibleText node = do
    driver <- getDriver
    liftBase $ D.visibleText driver node

attr :: (Driver sess, Monad m, MonadBase IO m)
     => Node sess -> Text -> HappybaraT sess m (Maybe Text)
attr node name = do
    driver <- getDriver
    liftBase $ D.attr driver node name

getValue :: (Driver sess, Monad m, MonadBase IO m)
         => Node sess -> HappybaraT sess m NodeValue
getValue node = do
    driver <- getDriver
    liftBase $ D.getValue driver node

setValue :: (Driver sess, Monad m, MonadBase IO m)
         => Node sess -> NodeValue -> HappybaraT sess m ()
setValue node val = do
    driver <- getDriver
    liftBase $ D.setValue driver node val

selectOption :: (Driver sess, Monad m, MonadBase IO m)
             => Node sess -> HappybaraT sess m ()
selectOption node = do
    driver <- getDriver
    liftBase $ D.selectOption driver node

unselectOption :: (Driver sess, Monad m, MonadBase IO m)
               => Node sess -> HappybaraT sess m ()
unselectOption node = do
    driver <- getDriver
    liftBase $ D.unselectOption driver node

click :: (Driver sess, Monad m, MonadBase IO m)
      => Node sess -> HappybaraT sess m ()
click node = do
    driver <- getDriver
    liftBase $ D.click driver node

rightClick :: (Driver sess, Monad m, MonadBase IO m)
           => Node sess -> HappybaraT sess m ()
rightClick node = do
    driver <- getDriver
    liftBase $ D.rightClick driver node

doubleClick :: (Driver sess, Monad m, MonadBase IO m)
            => Node sess -> HappybaraT sess m ()
doubleClick node = do
    driver <- getDriver
    liftBase $ D.doubleClick driver node

hover :: (Driver sess, Monad m, MonadBase IO m)
      => Node sess -> HappybaraT sess m ()
hover node = do
    driver <- getDriver
    liftBase $ D.hover driver node

dragTo :: (Driver sess, Monad m, MonadBase IO m)
       => Node sess -> Node sess -> HappybaraT sess m ()
dragTo node other = do
    driver <- getDriver
    liftBase $ D.dragTo driver node other

tagName :: (Driver sess, Monad m, MonadBase IO m)
        => Node sess -> HappybaraT sess m Text
tagName node = do
    driver <- getDriver
    liftBase $ D.tagName driver node

isVisible :: (Driver sess, Monad m, MonadBase IO m)
          => Node sess -> HappybaraT sess m Bool
isVisible node = do
    driver <- getDriver
    liftBase $ D.isVisible driver node

isChecked :: (Driver sess, Monad m, MonadBase IO m)
          => Node sess -> HappybaraT sess m Bool
isChecked node = do
    driver <- getDriver
    liftBase $ D.isChecked driver node

isSelected :: (Driver sess, Monad m, MonadBase IO m)
           => Node sess -> HappybaraT sess m Bool
isSelected node = do
    driver <- getDriver
    liftBase $ D.isSelected driver node

isDisabled :: (Driver sess, Monad m, MonadBase IO m)
           => Node sess -> HappybaraT sess m Bool
isDisabled node = do
    driver <- getDriver
    liftBase $ D.isDisabled driver node

path :: (Driver sess, Monad m, MonadBase IO m)
     => Node sess -> HappybaraT sess m Text
path node = do
    driver <- getDriver
    liftBase $ D.path driver node

trigger :: (Driver sess, Monad m, MonadBase IO m)
        => Node sess -> Text -> HappybaraT sess m ()
trigger node event = do
    driver <- getDriver
    liftBase $ D.trigger driver node event

nodeEq :: (Driver sess, Monad m, MonadBase IO m)
        => Node sess -> Node sess -> HappybaraT sess m Bool
nodeEq node other = do
    driver <- getDriver
    liftBase $ D.nodeEq driver node other