{-# LANGUAGE DeriveAnyClass, DeriveGeneric, FlexibleContexts,
             MultiParamTypeClasses #-}

{- |
   Module      : System.JBI.Commands.Common
   Description : How to handle build tools
   Copyright   : (c) Ivan Lazar Miljenovic
   License     : MIT
   Maintainer  : Ivan.Miljenovic@gmail.com



 -}
module System.JBI.Commands.BuildTool where

import System.JBI.Commands.Tool
import System.JBI.Environment
import System.JBI.Tagged

import Control.Applicative (liftA2)
import Control.Exception   (SomeException(SomeException), handle)
import Control.Monad       (filterM, forM)
import Data.Aeson          (ToJSON(toJSON))
import Data.List           (span)
import Data.Maybe          (isJust)
import Data.String         (IsString(..))
import GHC.Generics        (Generic)
import System.Directory    (doesFileExist, getCurrentDirectory, listDirectory)
import System.Exit         (ExitCode)
import System.FilePath     (dropTrailingPathSeparator, isDrive, takeDirectory,
                            (</>))

--------------------------------------------------------------------------------

class (Tool bt) => BuildTool bt where

  -- | Make sure there's nothing in the environment preventing us from
  --   using this tool.
  --
  --   For example, a minimum version, need another tool installed, etc.
  canUseCommand :: GlobalEnv -> Installed bt -> IO Bool
  canUseCommand _ _ = return True

  -- | Try and determine the root directory for this project.
  commandProjectRoot :: Tagged bt CommandPath -> IO (Maybe (Tagged bt ProjectRoot))

  hasBuildArtifacts :: Tagged bt ProjectRoot -> IO Bool

  -- | Ensure's that 'hasBuildArtifacts' is 'True' afterwards;
  --   i.e. forces this build tool.
  --
  --   The intent for this is \"No build tool is currently being used
  --   (i.e. 'hasBuildArtifacts' is 'False' for all) so start using
  --   the one chosen.\" This will not do the equivalent of @stack
  --   init@ and create project configuration.
  --
  --   Some manual fiddling is allowed after this.
  --
  --   Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandPrepare :: GlobalEnv -> Tagged bt CommandPath -> IO ExitCode

  -- | Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandTargets :: Tagged bt CommandPath -> IO [Tagged bt ProjectTarget]

  -- | Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandBuild :: GlobalEnv -> Tagged bt CommandPath -> Maybe (Tagged bt ProjectTarget)
                  -> IO ExitCode

  -- | Launch a @ghci@ session within the current project.  Should
  --   pass through the @-ferror-spans@ argument to the underlying
  --   ghci process.
  --
  --   Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandRepl :: GlobalEnv -> Tagged bt CommandPath -> Maybe (Tagged bt ProjectTarget)
                 -> IO ExitCode

  -- | Remove /all/ build artifacts of using this build tool (that is,
  --   afterwards 'hasBuildArtifacts' should return 'False').
  --
  --   Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandClean :: GlobalEnv -> Tagged bt CommandPath -> IO ExitCode

  -- | Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandTest :: GlobalEnv -> Tagged bt CommandPath -> IO ExitCode

  -- | Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandBench :: GlobalEnv -> Tagged bt CommandPath -> IO ExitCode

  -- | Run an external command within this environment.
  --
  --   Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandExec :: GlobalEnv -> Tagged bt CommandPath -> String -> Args -> IO ExitCode

  -- | Run an executable component within this environment (building
  --   it first if required).
  --
  --   Assumes 'canUseBuildTool'.  Should be run within 'ProjectRoot'.
  commandRun :: GlobalEnv -> Tagged bt CommandPath -> Tagged bt ProjectTarget
                -> Args -> IO ExitCode

  -- | Update index of available packages.
  commandUpdate :: GlobalEnv -> Tagged bt CommandPath -> IO ExitCode

-- | This class exists because of:
--
--   a) Distinguish the different Cabal variants
--
--   b) Be able to use a wrapper GADT that takes a @proxy bt@ and can
--      be an instance of 'BuildTool' but not this.
class (BuildTool bt) => NamedTool bt where
  prettyName :: proxy bt -> String
  prettyName = nameOfCommand . proxy commandName

data ToolInformation bt = ToolInformation
  { tool        :: !String
  , information :: !(Maybe (BuildUsage bt))
  } deriving (Eq, Show, Read, Generic, ToJSON)

commandToolInformation :: (NamedTool bt)
                          => GlobalEnv -> proxy bt
                          -> IO (ToolInformation bt)
commandToolInformation env pr =
  ToolInformation (prettyName pr) <$> commandBuildUsage env

data BuildUsage bt = BuildUsage
  { installation :: !(Installed bt)
  , usable       :: !Bool
  , project      :: !(Maybe (BuildProject bt))
  } deriving (Eq, Show, Read, Generic, ToJSON)

data BuildProject bt = BuildProject
  { projectRoot      :: !(Tagged bt ProjectRoot)
  , artifactsPresent :: !Bool
  } deriving (Eq, Show, Read, Generic, ToJSON)

-- | A 'Nothing' indicates that this tool cannot be used for this
--   project (i.e. needs configuration).
commandBuildUsage :: (BuildTool bt)
                     => GlobalEnv
                     -> IO (Maybe (BuildUsage bt))
commandBuildUsage env = do
  mInst <- commandInformation
  forM mInst $ \inst ->
    BuildUsage inst <$> canUseCommand env inst
                    <*> commandBuildProject (path inst)


commandBuildProject :: (BuildTool bt) => Tagged bt CommandPath
                       -> IO (Maybe (BuildProject bt))
commandBuildProject cmd = do
  mroot <- commandProjectRoot cmd
  forM mroot $ \root ->
    BuildProject root <$> hasBuildArtifacts root

canUseBuildTool :: Maybe (BuildUsage bt) -> Bool
canUseBuildTool = maybe False (liftA2 (&&) usable (isJust . project))

--------------------------------------------------------------------------------

newtype ProjectRoot = ProjectRoot { rootPath :: FilePath }
  deriving (Eq, Ord, Show, Read)

instance IsString ProjectRoot where
  fromString = ProjectRoot

instance ToJSON ProjectRoot where
  toJSON = toJSON . rootPath

-- | TODO: determine if this is a library, executable, test or benchmark component.
newtype ProjectTarget = ProjectTarget { projectTarget :: String }
  deriving (Eq, Ord, Show, Read)

instance IsString ProjectTarget where
  fromString = ProjectTarget

componentName :: Tagged bt ProjectTarget -> String
componentName = safeLast . splitOn ':' . stripTag

safeLast :: [[a]] -> [a]
safeLast []  = []
safeLast ass = last ass

splitOn :: (Eq a) => a -> [a] -> [[a]]
splitOn sep = go
  where
    go [] = []
    go as = case span (/= sep) as of
              (seg, [])    -> seg : []
              (seg, _:as') -> seg : go as'

--------------------------------------------------------------------------------

-- | If an exception occurs, return 'Nothing'
tryIO :: IO (Maybe a) -> IO (Maybe a)
tryIO = handle (\SomeException{} -> return Nothing)

-- | Recurse up until you find a directory containing a file that
--   matches the predicate, returning that directory.
recurseUpFindFile :: (FilePath -> Bool) -> IO (Maybe FilePath)
recurseUpFindFile p = tryIO $ go . dropTrailingPathSeparator =<< getCurrentDirectory
  where
    go dir = do cntns  <- listDirectory dir
                files <- filterM (doesFileExist . (dir </>)) cntns
                if any p files
                   then return (Just dir)
                   -- We do the base case check here so we can
                   -- actually check the top level directory.
                   else if isDrive dir
                           then return Nothing
                           else go (takeDirectory dir)