{-# LANGUAGE CPP, TypeSynonymInstances, OverloadedStrings,
             GeneralizedNewtypeDeriving, DeriveDataTypeable,
             FlexibleInstances #-}
-- |A module for working with Firefox profiles. Firefox profiles are manipulated
-- in pure code and then \"prepared\" for network transmission. 
module Test.WebDriver.Firefox.Profile 
       ( -- * Profiles
         FirefoxProfile(..), PreparedFirefoxProfile
         -- * Preferences
       , FirefoxPref(..), ToFirefox(..)
       , addPref, getPref, deletePref
         -- * Extensions
       , addExtension, deleteExtension
         -- * Loading and preparing profiles
       , loadProfile, prepareProfile
       , prepareTempProfile, prepareLoadedProfile
       ) where
import Data.Aeson
import Data.Attoparsec.Text as AP
import qualified Data.HashMap.Strict as HM
import qualified Data.HashSet as HS
import Data.Text (Text, pack)
import Data.Text.IO as TIO
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as SBS
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.ByteString.Base64 as B64

import Data.Fixed
import Data.Ratio
import Data.Int
import Data.Word
import Data.Char

import System.IO
import System.FilePath hiding (hasExtension, addExtension)
import System.Directory
import System.IO.Temp
import Codec.Archive.Zip
import Distribution.Simple.Utils
import Distribution.Verbosity

import Control.Monad
import Control.Applicative
import Control.Monad.IO.Class
import Control.Exception.Lifted
import Data.Typeable

-- |This structure allows you to
-- construct and manipulate Firefox profiles in pure code, deferring execution
-- of IO operations until the profile is \"prepared\" using either
-- 'prepareProfile' or one of the wrapper functions 'prepareTempProfile' and 
-- 'prepareLoadedProfile'.
data FirefoxProfile = FirefoxProfile 
                      { -- |Location of the profile in the local file system
                        profileDir    :: FilePath
                        -- |A set of filepaths pointing to Firefox extensions.
                        -- These paths can either refer to an .xpi file
                        -- or an extension directory
                      , profileExts   :: HS.HashSet FilePath
                        -- |A map of Firefox preferences. These are the settings
                        -- found in the profile's prefs.js, and entries found in
                        -- about:config
                      , profilePrefs  :: HM.HashMap Text FirefoxPref
                      }
                    deriving (Eq, Show)


-- |Represents a Firefox profile that has been prepared for 
-- network transmission. The profile cannot be modified in this form.
newtype PreparedFirefoxProfile = PreparedFirefoxProfile ByteString
  deriving (Eq, Show, ToJSON, FromJSON)

-- |A Firefox preference value. This is the subset of JSON values that excludes
-- arrays and objects.
data FirefoxPref = PrefInteger !Integer
                 | PrefDouble  !Double
                 | PrefString  !Text
                 | PrefBool    !Bool
                   deriving (Eq, Show)

instance ToJSON FirefoxPref where
  toJSON v = case v of
    PrefInteger i -> toJSON i
    PrefDouble d  -> toJSON d
    PrefString s  -> toJSON s
    PrefBool  b   -> toJSON b



instance Exception ProfileParseError
-- |An error occured while attempting to parse a profile's prefs.js file 
newtype ProfileParseError = ProfileParseError String
                          deriving  (Eq, Show, Read, Typeable)

-- |A typeclass to convert types to Firefox preference values
class ToFirefox a where
  toFirefox :: a -> FirefoxPref

instance ToFirefox Text where
  toFirefox = PrefString
  
instance ToFirefox String where
  toFirefox = toFirefox . pack

instance ToFirefox Bool where
  toFirefox = PrefBool
  
instance ToFirefox Integer where
  toFirefox = PrefInteger

#define I(t) instance ToFirefox t where toFirefox = PrefInteger . toInteger

I(Int)
I(Int8)
I(Int16)
I(Int32)
I(Int64)
I(Word)
I(Word8)
I(Word16)
I(Word32)
I(Word64)

instance ToFirefox Double where
  toFirefox = PrefDouble

instance ToFirefox Float where
  toFirefox = PrefDouble . realToFrac
  
instance (Integral a) => ToFirefox (Ratio a) where
  toFirefox = PrefDouble . realToFrac
  
instance (HasResolution r) => ToFirefox (Fixed r) where
  toFirefox = PrefDouble . realToFrac

-- |Retrieve a preference from a profile by key name.
getPref :: Text -> FirefoxProfile -> Maybe FirefoxPref
getPref k (FirefoxProfile _ _ m) = HM.lookup k m

-- |Add a new preference entry to a profile, overwriting any existing entry
-- with the same key.
addPref :: ToFirefox a => Text -> a -> FirefoxProfile -> FirefoxProfile
addPref k v p = asMap p $ HM.insert k (toFirefox v)

-- |Delete an existing preference entry from a profile. This operation is
-- silent if the preference wasn't found.
deletePref :: Text -> FirefoxProfile -> FirefoxProfile
deletePref k p = asMap p $ HM.delete k

-- |Add a new extension to the profile. The file path should refer to
-- an .xpi file or an extension directory. This operation has no effect if
-- the same extension has already been added to this profile.
addExtension :: FilePath -> FirefoxProfile -> FirefoxProfile
addExtension path p = asSet p $ HS.insert path

-- |Delete an existing extension from the profile. The file path should refer
-- to an .xpi file or an extension directory. This operation has no effect if
-- the extension was never added to the profile.
deleteExtension :: FilePath -> FirefoxProfile -> FirefoxProfile
deleteExtension path p = asSet p $ HS.delete path

asMap :: FirefoxProfile
         -> (HM.HashMap Text FirefoxPref -> HM.HashMap Text FirefoxPref)
         -> FirefoxProfile
asMap (FirefoxProfile p hs hm) f = FirefoxProfile p hs (f hm)

asSet :: FirefoxProfile
         -> (HS.HashSet FilePath -> HS.HashSet FilePath)
         -> FirefoxProfile
asSet (FirefoxProfile p hs hm) f = FirefoxProfile p (f hs) hm


tempProfile :: MonadIO m => m FirefoxProfile
tempProfile = liftIO $ defaultProfile <$> mkTemp

-- |Load an existing profile from the file system. Any prepared changes made to
-- the FirefoxProfile will have no effect to the profile on disk.
loadProfile :: MonadIO m => FilePath -> m FirefoxProfile
loadProfile path = liftIO $ do
  FirefoxProfile{ profileDir = d } <- tempProfile
  FirefoxProfile <$> pure d <*> getExtensions <*> getPrefs
  where
    extD = path </> "extensions"
    userPref = path </> "prefs" <.> "js"
    getExtensions = HS.fromList . filter (`elem` [".",".."]) 
                    <$> getDirectoryContents extD
    getPrefs = HM.fromList <$> (parsePrefs =<< TIO.readFile userPref)
    
    parsePrefs s = either (throwIO . ProfileParseError)
                          return 
                   $ parseOnly prefsParser s

-- |Prepare a FirefoxProfile for network transmission.
-- Internally, this function constructs a Firefox profile within a temp 
-- directory, archives it as a zip file, and then base64 encodes the zipped 
-- data. The temporary directory is deleted afterwards
prepareProfile :: MonadIO m => FirefoxProfile -> m PreparedFirefoxProfile
prepareProfile FirefoxProfile {profileDir = d, profileExts = s, 
                               profilePrefs = m} 
  = liftIO $ do 
      createDirectoryIfMissing False extensionD
      extPaths <- mapM canonicalizePath . HS.toList $ s
      forM_ extPaths installExtension
      withFile userPrefs WriteMode writeUserPrefs
      prof <-  PreparedFirefoxProfile . B64.encode . SBS.concat . LBS.toChunks 
               . fromArchive 
               <$> addFilesToArchive [OptRecursive] emptyArchive [d]
      removeDirectoryRecursive d
      return prof
  where
    extensionD = d </> "extensions"
    userPrefs  = d </> "prefs" <.> "js"
    
    installExtension ePath = 
      case splitExtension ePath of
           (_,".xpi") -> installOrdinaryFile silent ePath dest
           _          -> installDirectoryContents silent ePath dest
      where
        dest = extensionD </> eFile
        (_,eFile) = splitFileName ePath
      
    writeUserPrefs h =
      forM_ (HM.toList m) $ \(k, v) ->
        LBS.hPut h . LBS.concat
          $ [ "user_pref(", encode k, ", ", encode v, ");\n"]
  

-- |Apply a function on an automatically generated default profile, and
-- prepare the result. The FirefoxProfile passed to the handler function is
-- the default profile used by sessions when Nothing is specified
prepareTempProfile :: MonadIO m => 
                     (FirefoxProfile -> FirefoxProfile) 
                     -> m PreparedFirefoxProfile
prepareTempProfile f = liftM f tempProfile >>= prepareProfile

-- |Convenience function to load an existing Firefox profile from disk, apply
-- a handler function, and then prepare the result for network transmission.
prepareLoadedProfile :: MonadIO m =>
                        FilePath
                        -> (FirefoxProfile -> FirefoxProfile)
                        -> m PreparedFirefoxProfile
prepareLoadedProfile path f = liftM f (loadProfile path) >>= prepareProfile

defaultProfile :: FilePath -> FirefoxProfile
defaultProfile d = 
  FirefoxProfile d HS.empty
  $ HM.fromList [("app.update.auto", PrefBool False)
                ,("app.update.enabled", PrefBool False)
                ,("browser.startup.page" , PrefInteger 0)
                ,("browser.download.manager.showWhenStarting", PrefBool False)
                ,("browser.EULA.override", PrefBool True)
                ,("browser.EULA.3.accepted", PrefBool True)
                ,("browser.link.open_external", PrefInteger 2)
                ,("browser.link.open_newwindow", PrefInteger 2)
                ,("browser.offline", PrefBool False)
                ,("browser.safebrowsing.enabled", PrefBool False)
                ,("browser.search.update", PrefBool False)
                ,("browser.sessionstore.resume_from_crash", PrefBool False)
                ,("browser.shell.checkDefaultBrowser", PrefBool False)
                ,("browser.tabs.warnOnClose", PrefBool False)
                ,("browser.tabs.warnOnOpen", PrefBool False)
                ,("browser.startup.page", PrefInteger 0)
                ,("browser.safebrowsing.malware.enabled", PrefBool False)
                ,("startup.homepage_welcome_url", PrefString "about:blank")
                ,("devtools.errorconsole.enabled", PrefBool True)
                ,("focusmanager.testmode", PrefBool True)
                ,("dom.disable_open_during_load", PrefBool False)
                ,("extensions.autoDisableScopes" , PrefInteger 10)
                ,("extensions.logging.enabled", PrefBool True)
                ,("extensions.update.enabled", PrefBool False)
                ,("extensions.update.notifyUser", PrefBool False)
                ,("network.manage-offline-status", PrefBool False)
                ,("network.http.max-connections-per-server", PrefInteger 10)
                ,("network.http.phishy-userpass-length", PrefInteger 255)
                ,("offline-apps.allow_by_default", PrefBool True)
                ,("prompts.tab_modal.enabled", PrefBool False)
                ,("security.fileuri.origin_policy", PrefInteger 3)
                ,("security.fileuri.strict_origin_policy", PrefBool False)
                ,("security.warn_entering_secure", PrefBool False)
                ,("security.warn_submit_insecure", PrefBool False)
                ,("security.warn_entering_secure.show_once", PrefBool False)
                ,("security.warn_entering_weak", PrefBool False)
                ,("security.warn_entering_weak.show_once", PrefBool False)
                ,("security.warn_leaving_secure", PrefBool False)
                ,("security.warn_leaving_secure.show_once", PrefBool False)
                ,("security.warn_submit_insecure", PrefBool False)
                ,("security.warn_viewing_mixed", PrefBool False)
                ,("security.warn_viewing_mixed.show_once", PrefBool False)
                ,("signon.rememberSignons", PrefBool False)
                ,("toolkit.networkmanager.disable", PrefBool True)
                ,("toolkit.telemetry.enabled", PrefBool False)
                ,("toolkit.telemetry.prompted", PrefInteger 2)
                ,("toolkit.telemetry.rejected", PrefBool True)
                ,("javascript.options.showInConsole", PrefBool True)
                ,("browser.dom.window.dump.enabled", PrefBool True)
                ,("webdriver_accept_untrusted_certs", PrefBool True)
                ,("webdriver_enable_native_events", native_events)
                ,("webdriver_assume_untrusted_issuer", PrefBool True)
                ,("dom.max_script_run_time", PrefInteger 30)
                ]
    where
#ifdef darwin_HOST_OS  
      native_events = PrefBool False
#else
      native_events = PrefBool True
#endif

mkTemp :: IO FilePath
mkTemp = do 
  d <- getTemporaryDirectory
  createTempDirectory d ""
  
-- firefox prefs.js parser

prefsParser = many prefLine

prefLine = do 
  padSpaces $ string "user_pref("
  k <- prefKey
  padSpaces $ char ','
  v <- prefVal
  padSpaces $  string ");"
  endOfLine
  return (k,v)
  where
    spaces = AP.takeWhile isSpace
    padSpaces p = spaces >> p >> spaces

prefKey = str
prefVal = boolVal <|> stringVal <|> intVal <|> doubleVal
  where
    boolVal   = boolTrue <|> boolFalse     
    boolTrue  = string "true"  >> return (PrefBool True)
    boolFalse = string "false" >> return (PrefBool False)
    stringVal = PrefString <$> str
    intVal    = PrefInteger <$> signed decimal
    doubleVal = PrefDouble <$> double
    
str = char '"' >> AP.takeWhile (not . (=='"')) <* char '"'