{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE DeriveDataTypeable #-}

-- | Dynamically look up available executables.
module Plugins
  ( Plugin
  , pluginPrefix
  , pluginName
  , pluginSummary
  , pluginProc

  , Plugins
  , findPlugins
  , listPlugins
  , lookupPlugin
  , callPlugin

  , PluginException (..)
  ) where

import Control.Applicative
import Control.Exception (Exception)
import Control.Monad
import Control.Monad.Catch (MonadThrow, throwM)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.State.Strict (StateT, get, put)
import Data.Conduit
import Data.Hashable (Hashable)
import Data.HashSet (HashSet)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashSet as HashSet
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Conduit.List as CL
import Data.Conduit.Lift (evalStateC)
import qualified Data.List as L
import Data.List.Split (splitOn)
import Data.Text (Text, pack, unpack)
import qualified Data.Text as T
import Data.Typeable (Typeable)
import Data.Monoid
import System.Directory
import System.Process (CreateProcess, proc, readProcess, readProcessWithExitCode, createProcess, waitForProcess)
import System.FilePath ((</>), getSearchPath, splitExtension)
import System.Environment (getEnv)
import System.Exit (ExitCode (..))

-- | Represents a runnable plugin.
-- Plugins must be discovered via `findPlugins`.
data Plugin = Plugin
  { _pluginPrefix :: !Text
  , _pluginName :: !Text
  , _pluginSummary :: !Text
  }
  deriving (Show)

-- | The program being plugged into.
pluginPrefix :: Plugin -> Text
pluginPrefix = _pluginPrefix

-- | The name of this plugin (without the prefix).
pluginName :: Plugin -> Text
pluginName = _pluginName

-- | A summary of what this plugin does
pluginSummary :: Plugin -> Text
pluginSummary = _pluginSummary

-- | Describes how to create a process out of a plugin and arguments.
-- You may use Data.Process and Data.Conduit.Process
-- to manage the process's stdin, stdout, and stderr in various ways.
pluginProc :: Plugin -> [String] -> CreateProcess
pluginProc = proc . pluginProcessName

-- Not exported
pluginProcessName :: Plugin -> String
pluginProcessName p = unpack $ pluginPrefix p <> "-" <> pluginName p


-- | Represents the plugins available to a given program.
-- See: `findPlugins`.
data Plugins = Plugins
  { _pluginsPrefix :: !Text
  , _pluginsMap :: !(HashMap Text Plugin)
  }
  deriving (Show)


-- | Find the plugins for a given program by inspecting everything on the PATH.
-- Any program that is prefixed with the given name and responds
-- to the `--summary` flag by writing one line to stdout
-- is considered a plugin.
findPlugins :: Text -> IO Plugins
findPlugins t = fmap (Plugins t)
   $ discoverPlugins t
  $$ awaitForever (toPlugin t)
  =$ CL.fold insertPlugin HashMap.empty
  where
    insertPlugin m p = HashMap.insert (pluginName p) p m

toPlugin :: (MonadIO m) => Text -> Text -> Producer m Plugin
toPlugin prefix name = do
  let proc = unpack $ prefix <> "-" <> name
  (exit, out, _err) <- liftIO $ readProcessWithExitCode proc ["--summary"] ""
  case exit of
    ExitSuccess -> case T.lines (pack out) of
      [summary] -> yield $ Plugin
        { _pluginPrefix = prefix
        , _pluginName = name
        , _pluginSummary = summary
        }
      _ -> return ()
    _ -> return ()


-- | Things that can go wrong when using `callPlugin`.
data PluginException
  = PluginNotFound !Plugins !Text
  | PluginExitFailure !Plugin !Int
  deriving (Show, Typeable)
instance Exception PluginException

-- | Look up a particular plugin by name.
lookupPlugin :: Plugins -> Text -> Maybe Plugin
lookupPlugin ps t = HashMap.lookup t $ _pluginsMap ps

-- | List the available plugins.
listPlugins :: Plugins -> [Plugin]
listPlugins = HashMap.elems . _pluginsMap

-- | A convenience wrapper around lookupPlugin and pluginProc.
-- Handles stdin, stdout, and stderr are all inherited by the plugin.
-- Throws PluginException.
callPlugin :: (MonadIO m, MonadThrow m)
  => Plugins -> Text -> [String] -> m ()
callPlugin ps name args = case lookupPlugin ps name of
  Nothing -> throwM $ PluginNotFound ps name
  Just plugin -> do
    exit <- liftIO $ do
      (_, _, _, process) <- createProcess $ pluginProc plugin args
      waitForProcess process
    case exit of
      ExitFailure i -> throwM $ PluginExitFailure plugin i
      ExitSuccess -> return ()


discoverPlugins :: MonadIO m => Text -> Producer m Text
discoverPlugins t
  = getPathDirs
 $= clNub -- unique dirs on path
 $= awaitForever (executablesPrefixed $ unpack $ t <> "-")
 $= CL.map pack
 $= clNub -- unique executables

executablesPrefixed :: (MonadIO m) => FilePath -> FilePath -> Producer m FilePath
executablesPrefixed prefix dir
  = pathToContents dir
 $= CL.filter (L.isPrefixOf prefix)
 $= clFilterM (fileExistsIn dir)
 $= clFilterM (isExecutableIn dir)
 $= CL.mapMaybe (L.stripPrefix prefix . dropExeExt)

-- | Drop the .exe extension if present
dropExeExt :: FilePath -> FilePath
dropExeExt fp
    | y == ".exe" = x
    | otherwise   = fp
  where
    (x, y) = splitExtension fp

getPathDirs :: (MonadIO m) => Producer m FilePath
getPathDirs = liftIO getSearchPath >>= mapM_ yield

pathToContents :: (MonadIO m) => FilePath -> Producer m FilePath
pathToContents dir = do
  exists <- liftIO $ doesDirectoryExist dir
  when exists $ do
    contents <- liftIO $ getDirectoryContents dir
    CL.sourceList contents

fileExistsIn :: (MonadIO m) => FilePath -> FilePath -> m Bool
fileExistsIn dir file = liftIO $ doesFileExist $ dir </> file

isExecutableIn :: (MonadIO m) => FilePath -> FilePath -> m Bool
isExecutableIn dir file = liftIO $ do
  perms <- getPermissions $ dir </> file
  return (executable perms)

clFilterM :: Monad m => (a -> m Bool) -> Conduit a m a
clFilterM pred = awaitForever $ \a -> do
  predPassed <- lift $ pred a
  when predPassed $ yield a

clNub :: (Monad m, Eq a, Hashable a)
  => Conduit a m a
clNub = evalStateC HashSet.empty clNubState

clNubState :: (Monad m, Eq a, Hashable a)
  => Conduit a (StateT (HashSet a) m) a
clNubState = awaitForever $ \a -> do
  seen <- lift get
  unless (HashSet.member a seen) $ do
    lift $ put $ HashSet.insert a seen
    yield a