{-# LANGUAGE CPP                 #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell   #-}
module Devel
    ( devel
    , DevelOpts(..)
    , DevelTermOpt(..)
    , defaultDevelOpts
    ) where

import qualified Distribution.Compiler                 as D
import qualified Distribution.ModuleName               as D
import qualified Distribution.PackageDescription       as D
import qualified Distribution.PackageDescription.Parse as D
import qualified Distribution.Simple.Configure         as D
import qualified Distribution.Simple.Program           as D
import qualified Distribution.Simple.Utils             as D
import qualified Distribution.Verbosity                as D

import           Control.Applicative                   ((<$>), (<*>))
import           Control.Concurrent                    (forkIO, threadDelay)
import           Control.Concurrent.MVar               (MVar, newEmptyMVar,
                                                        takeMVar, tryPutMVar)
import           Control.Concurrent.Async              (race_)
import qualified Control.Exception                     as Ex
import           Control.Monad                         (forever, unless, void,
                                                        when, forM)
import           Control.Monad.IO.Class                (liftIO)
import           Control.Monad.Trans.State             (evalStateT, get)
import qualified Data.IORef                            as I

import qualified Data.ByteString.Lazy                  as LB
import           Data.Char                             (isNumber, isUpper)
import qualified Data.List                             as L
import qualified Data.Map                              as Map
import           Data.Maybe                            (fromMaybe)
import qualified Data.Set                              as Set

import           System.Directory
import           System.Environment                    (getEnvironment)
import           System.Exit                           (ExitCode (..),
                                                        exitFailure,
                                                        exitSuccess)
import           System.FilePath                       (dropExtension,
                                                        splitDirectories,
                                                        takeExtension, (</>))
import           System.FSNotify
import           System.IO                             (Handle)
import           System.IO.Error                       (isDoesNotExistError)
import           System.Posix.Types                    (EpochTime)
import           System.PosixCompat.Files              (getFileStatus,
                                                        modificationTime)
import           System.Process                        (ProcessHandle,
                                                        createProcess, env,
                                                        getProcessExitCode,
                                                        proc, readProcess,
                                                        system,
                                                        terminateProcess)
import           System.Timeout                        (timeout)

import           Build                                 (getDeps, isNewerThan,
                                                        recompDeps)
import           GhcBuild                              (buildPackage,
                                                        getBuildFlags, getPackageArgs)

import qualified Config                                as GHC
import           Data.Streaming.Network                (bindPortTCP)
import           Network                               (withSocketsDo)
import           Network.HTTP.Conduit                  (conduitManagerSettings, newManager)
import           Data.Default.Class                    (def)
#if MIN_VERSION_http_client(0,4,7)
import           Network.HTTP.Client                   (managerSetProxy, noProxy)
#endif
import           Network.HTTP.ReverseProxy             (ProxyDest (ProxyDest),
                                                        waiProxyToSettings, wpsTimeout, wpsOnExc)
import qualified Network.HTTP.ReverseProxy             as ReverseProxy
import           Network.HTTP.Types                    (status200, status503)
import           Network.Socket                        (sClose)
import           Network.Wai                           (responseLBS, requestHeaders)
import           Network.Wai.Parse                     (parseHttpAccept)
import           Network.Wai.Handler.Warp              (run, defaultSettings, setPort)
import           Network.Wai.Handler.WarpTLS           (runTLS, tlsSettingsMemory)
import           SrcLoc                                (Located)
import           Data.FileEmbed        (embedFile)

lockFile :: FilePath
lockFile =  "yesod-devel/devel-terminate"

writeLock :: DevelOpts -> IO ()
writeLock _opts = do
    createDirectoryIfMissing True "yesod-devel"
    writeFile lockFile ""
    createDirectoryIfMissing True "dist" -- for compatibility with old devel.hs
    writeFile "dist/devel-terminate" ""

removeLock :: DevelOpts -> IO ()
removeLock _opts = do
    removeFileIfExists lockFile
    removeFileIfExists "dist/devel-terminate"  -- for compatibility with old devel.hs

data DevelTermOpt = TerminateOnEnter | TerminateOnlyInterrupt
     deriving (Show, Eq)
     
data DevelOpts = DevelOpts
      { isCabalDev   :: Bool
      , forceCabal   :: Bool
      , verbose      :: Bool
      , eventTimeout :: Int -- negative value for no timeout
      , successHook  :: Maybe String
      , failHook     :: Maybe String
      , buildDir     :: Maybe String
      , develPort    :: Int
      , develTlsPort :: Int
      , proxyTimeout :: Int
      , useReverseProxy :: Bool
      , terminateWith :: DevelTermOpt

      -- Support for GHC_PACKAGE_PATH wrapping
      , develConfigOpts :: [String]
      , develEnv :: Maybe [(String, String)]
      } deriving (Show, Eq)

getBuildDir :: DevelOpts -> String
getBuildDir opts = fromMaybe "dist" (buildDir opts)

defaultDevelOpts :: DevelOpts
defaultDevelOpts = DevelOpts
    { isCabalDev   = False
    , forceCabal   = False
    , verbose      = False
    , eventTimeout = -1
    , successHook  = Nothing
    , failHook     = Nothing
    , buildDir     = Nothing
    , develPort    = 3000
    , develTlsPort = 3443
    , proxyTimeout = 10
    , useReverseProxy = True
    , terminateWith = TerminateOnEnter
    , develConfigOpts = []
    , develEnv = Nothing
    }

cabalProgram :: DevelOpts -> FilePath
cabalProgram opts
  | isCabalDev opts = "cabal-dev"
  | otherwise       = "cabal"

-- | Run a reverse proxy from port 3000 to 3001. If there is no response on
-- 3001, give an appropriate message to the user.
reverseProxy :: DevelOpts -> I.IORef Int -> IO ()
reverseProxy opts iappPort = do
#if MIN_VERSION_http_client(0,4,7)
    manager <- newManager $ managerSetProxy noProxy conduitManagerSettings
#else
    manager <- newManager conduitManagerSettings
#endif
    let refreshHtml = LB.fromChunks $ return $(embedFile "refreshing.html")
    let onExc _ req
            | maybe False (("application/json" `elem`) . parseHttpAccept)
                (lookup "accept" $ requestHeaders req) =
                    return $ responseLBS status503
                        [ ("Retry-After", "1")
                        ]
                        "{\"message\":\"Recompiling\"}"
            | otherwise = return $ responseLBS status200
                [ ("content-type", "text/html")
                , ("Refresh", "1")
                ]
                refreshHtml

    let proxyApp = waiProxyToSettings
                (const $ do
                    appPort <- liftIO $ I.readIORef iappPort
                    return $
                        ReverseProxy.WPRProxyDest
                        $ ProxyDest "127.0.0.1" appPort)
                def
                    { wpsOnExc = \e req f -> onExc e req >>= f
                    , wpsTimeout =
                        if proxyTimeout opts == 0
                            then Nothing
                            else Just (1000000 * proxyTimeout opts)
                    }
                manager
        runProxyTls port app = do
          let cert = $(embedFile "certificate.pem")
              key = $(embedFile "key.pem")
              tlsSettings = tlsSettingsMemory cert key
          runTLS tlsSettings (setPort port defaultSettings) app
        httpProxy = run (develPort opts) proxyApp
        httpsProxy = runProxyTls (develTlsPort opts) proxyApp
    putStrLn "Application can be accessed at:\n"
    putStrLn $ "http://127.0.0.1:" ++ show (develPort opts)
    putStrLn $ "https://127.0.0.1:" ++ show (develTlsPort opts)
    putStrLn $ "If you wish to test https capabilities, you should set the following variable:"
    putStrLn $ "  export APPROOT=https://127.0.0.1:" ++ show (develTlsPort opts)
    putStrLn ""
    loop (race_ httpProxy httpsProxy) `Ex.catch` \e -> do
        print (e :: Ex.SomeException)
        _ <- exitFailure
        Ex.throwIO e -- heh, just for good measure
  where
    loop proxies = forever $ do
        void proxies
        putStrLn $ "Reverse proxy stopped, but it shouldn't"
        threadDelay 1000000
        putStrLn $ "Restarting reverse proxies"

checkPort :: Int -> IO Bool
checkPort p = do
    es <- Ex.try $ bindPortTCP p "*4"
    case es of
        Left (_ :: Ex.IOException) -> return False
        Right s -> do
            sClose s
            return True

getPort :: DevelOpts -> Int -> IO Int
getPort opts _ 
  | not (useReverseProxy opts) = return $ develPort opts
getPort _ p0 =
    loop p0
  where
    loop p = do
        avail <- checkPort p
        if avail then return p else loop (succ p)

unlessM :: Monad m => m Bool -> m () -> m ()
unlessM c a = c >>= \res -> unless res a

devel :: DevelOpts -> [String] -> IO ()
devel opts passThroughArgs = withSocketsDo $ withManager $ \manager -> do
    unlessM (checkPort $ develPort opts) $ error "devel port unavailable"
    iappPort <- getPort opts 17834 >>= I.newIORef
    when (useReverseProxy opts) $ void $ forkIO $ reverseProxy opts iappPort
    develHsPath <- checkDevelFile
    writeLock opts

    let (terminator, after) = case terminateWith opts of
          TerminateOnEnter ->
              ("Type 'quit'", blockQuit)
          TerminateOnlyInterrupt ->  -- run for one year
              ("Interrupt", threadDelay $ 1000 * 1000 * 60 * 60 * 24 * 365)

        blockQuit = do
            s <- getLine
            if s == "quit"
                then return ()
                else do
                    putStrLn "Type 'quit' to quit"
                    blockQuit


    putStrLn $ "Yesod devel server. "  ++ terminator ++ " to quit"
    void $ forkIO $ do
      filesModified <- newEmptyMVar
      void $ forkIO $
        void $ watchTree manager "." (const True) (\_ -> void (tryPutMVar filesModified ()))
      evalStateT (mainOuterLoop develHsPath iappPort filesModified) Map.empty
    after
    writeLock opts
    exitSuccess
  where
    bd = getBuildDir opts

    -- outer loop re-reads the cabal file
    mainOuterLoop develHsPath iappPort filesModified = do
      ghcVer <- liftIO ghcVersion
#if MIN_VERSION_Cabal(1,20,0)
      cabal  <- liftIO $ D.tryFindPackageDesc "."
#else
      cabal  <- liftIO $ D.findPackageDesc "."
#endif
      gpd    <- liftIO $ D.readPackageDescription D.normal cabal
      ldar   <- liftIO lookupLdAr
      (hsSourceDirs, _) <- liftIO $ checkCabalFile gpd
      liftIO $ removeFileIfExists (bd </> "setup-config")
      c <- liftIO $ configure opts passThroughArgs
      if c then do
             -- these files contain the wrong data after the configure step,
             -- remove them to force a cabal build first
             liftIO $ mapM_ removeFileIfExists [ "yesod-devel/ghcargs.txt"
                                               , "yesod-devel/arargs.txt"
                                               , "yesod-devel/ldargs.txt"
                                               ]
             rebuild <- liftIO $ mkRebuild ghcVer cabal opts ldar
             mainInnerLoop develHsPath iappPort hsSourceDirs filesModified cabal rebuild
           else do
             liftIO (threadDelay 5000000)
             mainOuterLoop develHsPath iappPort filesModified

    -- inner loop rebuilds after files change
    mainInnerLoop develHsPath iappPort hsSourceDirs filesModified cabal rebuild = go
       where
         go = do
           _ <- recompDeps hsSourceDirs
           list <- liftIO $ getFileList hsSourceDirs [cabal]
           success <- liftIO rebuild
           pkgArgs <- liftIO (ghcPackageArgs opts)
           let devArgs = pkgArgs ++ [develHsPath]
           let loop list0 = do
                   (haskellFileChanged, list1) <- liftIO $
                       watchForChanges filesModified hsSourceDirs [cabal] list0 (eventTimeout opts)
                   anyTouched <- recompDeps hsSourceDirs
                   unless (anyTouched || haskellFileChanged) $ loop list1
           if not success
             then liftIO $ do
                   putStrLn "\x1b[1;31mBuild failure, pausing...\x1b[0m"
                   runBuildHook $ failHook opts
             else do
                   liftIO $ runBuildHook $ successHook opts
                   liftIO $ removeLock opts
                   liftIO $ putStrLn
                            $ if verbose opts then "Starting development server: runghc " ++ L.unwords devArgs
                                              else "Starting development server..."
                   env0 <- liftIO getEnvironment

                   -- get a new port for the new process to listen on
                   appPort <- liftIO $ I.readIORef iappPort >>= getPort opts . (+ 1)
                   liftIO $ I.writeIORef iappPort appPort

                   (_,_,_,ph) <- liftIO $ createProcess (proc "runghc" devArgs)
                        { env = Just $ Map.toList
                                     $ Map.insert "PORT" (show appPort)
                                     $ Map.insert "DISPLAY_PORT" (show $ develPort opts)
                                     $ Map.fromList env0
                        }
                   derefMap <- get
                   watchTid <- liftIO . forkIO . try_ $ flip evalStateT derefMap $ do
                      loop list
                      liftIO $ do
                         putStrLn "Stopping development server..."
                         writeLock opts
                         threadDelay 1000000
                         putStrLn "Terminating development server..."
                         terminateProcess ph
                   ec <- liftIO $ waitForProcess' ph
                   liftIO $ putStrLn $ "Exit code: " ++ show ec
                   liftIO $ Ex.throwTo watchTid (userError "process finished")
           loop list
           n <- liftIO $ cabal `isNewerThan` (bd </> "setup-config")
           if n then mainOuterLoop develHsPath iappPort filesModified else go

runBuildHook :: Maybe String -> IO ()
runBuildHook (Just s) = do
             ret <- system s
             case ret of
                  ExitFailure _ -> putStrLn ("Error executing hook: " ++ s)
                  _             -> return ()
runBuildHook Nothing = return ()

{-
   run `cabal configure' with our wrappers
-}
configure :: DevelOpts -> [String] -> IO Bool
configure opts extraArgs =
  checkExit =<< createProcess (proc (cabalProgram opts) $
                                 [ "configure"
                                 , "-flibrary-only"
                                 , "--disable-tests"
                                 , "--disable-benchmarks"
                                 , "-fdevel"
                                 , "--disable-library-profiling"
                                 , "--with-ld=yesod-ld-wrapper"
                                 , "--with-ghc=yesod-ghc-wrapper"
                                 , "--with-ar=yesod-ar-wrapper"
                                 , "--with-hc-pkg=ghc-pkg"
                                 ] ++ develConfigOpts opts ++ extraArgs
               ) { env = develEnv opts }

removeFileIfExists :: FilePath -> IO ()
removeFileIfExists file = removeFile file `Ex.catch` handler
    where
      handler :: IOError -> IO ()
      handler e | isDoesNotExistError e = return ()
                | otherwise             = Ex.throw e

mkRebuild :: String -> FilePath -> DevelOpts -> (FilePath, FilePath) -> IO (IO Bool)
mkRebuild ghcVer cabalFile opts (ldPath, arPath)
  | GHC.cProjectVersion /= ghcVer =
       failWith "Yesod has been compiled with a different GHC version, please reinstall yesod-bin"
  | forceCabal opts               = return (rebuildCabal opts)
  | otherwise                     =
      return $ do
        ns <- mapM (cabalFile `isNewerThan`)
           [ "yesod-devel/ghcargs.txt", "yesod-devel/arargs.txt", "yesod-devel/ldargs.txt" ]
        if or ns
          then rebuildCabal opts
          else do
            bf <- getBuildFlags
            rebuildGhc bf ldPath arPath


rebuildGhc :: [Located String] -> FilePath -> FilePath -> IO Bool
rebuildGhc bf ld ar = do
  putStrLn "Rebuilding application... (using GHC API)"
  buildPackage bf ld ar

rebuildCabal :: DevelOpts -> IO Bool
rebuildCabal opts = do
  putStrLn $ "Rebuilding application... (using " ++ cabalProgram opts ++ ")"
  checkExit =<< createProcess (proc (cabalProgram opts) args)
        { env = develEnv opts
        }
    where
      args | verbose opts = [ "build" ]
           | otherwise    = [ "build", "-v0" ]

try_ :: forall a. IO a -> IO ()
try_ x = void (Ex.try x :: IO (Either Ex.SomeException a))

type FileList = Map.Map FilePath EpochTime

getFileList :: [FilePath] -> [FilePath] -> IO FileList
getFileList hsSourceDirs extraFiles = do
    (files, deps) <- getDeps hsSourceDirs
    let files' = extraFiles ++ files ++ map fst (Map.toList deps)
    fmap Map.fromList $ forM files' $ \f -> do
        efs <- Ex.try $ getFileStatus f
        return $ case efs of
            Left (_ :: Ex.SomeException) -> (f, 0)
            Right fs -> (f, modificationTime fs)

-- | Returns @True@ if a .hs file changed.
watchForChanges :: MVar () -> [FilePath] -> [FilePath] -> FileList -> Int -> IO (Bool, FileList)
watchForChanges filesModified hsSourceDirs extraFiles list t = do
    newList <- getFileList hsSourceDirs extraFiles
    if list /= newList
      then do
        let haskellFileChanged = not $ Map.null $ Map.filterWithKey isHaskell $
                Map.differenceWith compareTimes newList list `Map.union`
                Map.differenceWith compareTimes list newList
        return (haskellFileChanged, newList)
      else timeout (1000000*t) (takeMVar filesModified) >>
           watchForChanges filesModified hsSourceDirs extraFiles list t
  where
    compareTimes x y
        | x == y = Nothing
        | otherwise = Just x

    isHaskell filename _ = takeExtension filename `elem` [".hs", ".lhs", ".hsc", ".cabal"]

checkDevelFile :: IO FilePath
checkDevelFile =
    loop paths
  where
    paths = ["app/devel.hs", "devel.hs", "src/devel.hs"]

    loop [] = failWith $ "file devel.hs not found, checked: " ++ show paths
    loop (x:xs) = do
        e <- doesFileExist x
        if e
            then return x
            else loop xs

checkCabalFile :: D.GenericPackageDescription -> IO ([FilePath], D.Library)
checkCabalFile gpd = case D.condLibrary gpd of
    Nothing -> failWith "incorrect cabal file, no library"
    Just ct ->
      case lookupDevelLib gpd ct of
        Nothing   ->
          failWith "no development flag found in your configuration file. Expected a 'library-only' flag or the older 'devel' flag"
        Just dLib -> do
           let hsSourceDirs = D.hsSourceDirs . D.libBuildInfo $ dLib
           fl <- getFileList hsSourceDirs []
           let unlisted = checkFileList fl dLib
           unless (null unlisted) $ do
                putStrLn "WARNING: the following source files are not listed in exposed-modules or other-modules:"
                mapM_ putStrLn unlisted
           when ("Application" `notElem` (map (last . D.components) $ D.exposedModules dLib)) $
                putStrLn "WARNING: no exposed module Application"
           return (hsSourceDirs, dLib)

failWith :: String -> IO a
failWith msg = do
    putStrLn $ "ERROR: " ++ msg
    exitFailure

checkFileList :: FileList -> D.Library -> [FilePath]
checkFileList fl lib = filter (not . isSetup) . filter isUnlisted . filter isSrcFile $ sourceFiles
  where
    al = allModules lib
    -- a file is only a possible 'module file' if all path pieces start with a capital letter
    sourceFiles = filter isSrcFile . map fst . Map.toList $ fl
    isSrcFile file = let dirs = filter (/=".") $ splitDirectories file
                     in  all (isUpper . head) dirs && (takeExtension file `elem` [".hs", ".lhs"])
    isUnlisted file = not (toModuleName file `Set.member` al)
    toModuleName = L.intercalate "." . filter (/=".") . splitDirectories . dropExtension

    isSetup "Setup.hs" = True
    isSetup "./Setup.hs" = True
    isSetup "Setup.lhs" = True
    isSetup "./Setup.lhs" = True
    isSetup _ = False

allModules :: D.Library -> Set.Set String
allModules lib = Set.fromList $ map toString $ D.exposedModules lib ++ (D.otherModules . D.libBuildInfo) lib
    where
      toString = L.intercalate "." . D.components

ghcVersion :: IO String
ghcVersion = fmap getNumber $ readProcess "runghc" ["--numeric-version", "0"] []
    where
      getNumber = filter (\x -> isNumber x || x == '.')

ghcPackageArgs :: DevelOpts -> IO [String]
ghcPackageArgs opts = getBuildFlags >>= getPackageArgs (buildDir opts)

lookupDevelLib :: D.GenericPackageDescription -> D.CondTree D.ConfVar c a -> Maybe a
lookupDevelLib gpd ct | found     = Just (D.condTreeData ct)
                      | otherwise = Nothing
  where
    flags = map (unFlagName . D.flagName) $ D.genPackageFlags gpd
    unFlagName (D.FlagName x) = x
    found = any (`elem` ["library-only", "devel"]) flags

-- location of `ld' and `ar' programs
lookupLdAr :: IO (FilePath, FilePath)
lookupLdAr = do
  mla <- lookupLdAr'
  case mla of
    Nothing -> failWith "Cannot determine location of `ar' or `ld' program"
    Just la -> return la

lookupLdAr' :: IO (Maybe (FilePath, FilePath))
lookupLdAr' = do
#if MIN_VERSION_Cabal(1,18,0)
  (_, _, pgmc) <- D.configCompilerEx (Just D.GHC) Nothing Nothing D.defaultProgramConfiguration D.silent
#else
  (_, pgmc) <- D.configCompiler (Just D.GHC) Nothing Nothing D.defaultProgramConfiguration D.silent
#endif
  pgmc' <- D.configureAllKnownPrograms D.silent pgmc
  return $ (,) <$> look D.ldProgram pgmc' <*> look D.arProgram pgmc'
     where
       look pgm pdb = fmap D.programPath (D.lookupProgram pgm pdb)

-- | nonblocking version of @waitForProcess@
waitForProcess' :: ProcessHandle -> IO ExitCode
waitForProcess' pid = go
  where
    go = do
      mec <- getProcessExitCode pid
      case mec of
        Just ec -> return ec
        Nothing -> threadDelay 100000 >> go

-- | wait for process started by @createProcess@, return True for ExitSuccess
checkExit :: (Maybe Handle, Maybe Handle, Maybe Handle, ProcessHandle) -> IO Bool
checkExit (_,_,_,h) = (==ExitSuccess) <$> waitForProcess' h
