{-# LANGUAGE NumericUnderscores #-}

-- |
-- Static B9 configuration. Read, write and merge configurable properties.
-- The properties are independent of specific build targets.
module B9.B9Config
  ( B9Config (..),
    Timeout (..),
    runB9ConfigReader,
    B9ConfigReader,
    getB9Config,
    getConfig,
    getLogVerbosity,
    getProjectRoot,
    getRemoteRepos,
    isInteractive,
    B9ConfigWriter,
    verbosity,
    logFile,
    projectRoot,
    keepTempDirs,
    uniqueBuildDirs,
    repositoryCache,
    repository,
    interactive,
    defaultTimeout,
    libVirtLXCConfigs,
    dockerConfigs,
    podmanConfigs,
    systemdNspawnConfigs,
    remoteRepos,
    timeoutFactor,
    maxLocalSharedImageRevisionsK,
    maxLocalSharedImageRevisions,
    B9ConfigOverride (..),
    noB9ConfigOverride,
    B9ConfigAction (),
    runB9ConfigActionWithOverrides,
    runB9ConfigAction,
    localB9Config,
    modifyPermanentConfig,
    customB9Config,
    customB9ConfigPath,
    customEnvironment,
    customDefaulB9ConfigPath,
    overrideB9ConfigPath,
    overrideDefaultB9ConfigPath,
    overrideB9Config,
    overrideWorkingDirectory,
    overrideDefaultTimeout,
    overrideTimeoutFactor,
    overrideVerbosity,
    overrideKeepBuildDirs,
    defaultB9ConfigFile,
    defaultRepositoryCache,
    defaultB9Config,
    openOrCreateB9Config,
    writeB9CPDocument,
    readB9Config,
    parseB9Config,
    modifyCPDocument,
    b9ConfigToCPDocument,
    LogLevel (..),
    Environment,
    module X,
  )
where

import B9.B9Config.Container as X
import B9.B9Config.Docker as X
import B9.B9Config.LibVirtLXC as X
import B9.B9Config.Podman as X
import B9.B9Config.Repository as X
import B9.B9Config.SystemdNspawn as X
import B9.Environment
import Control.Eff
import Control.Eff.Reader.Lazy
import Control.Eff.Writer.Lazy
import Control.Exception
import Control.Lens as Lens ((<>~), (?~), (^.), makeLenses, set)
import Control.Monad ((>=>), filterM)
import Control.Monad.IO.Class
import Data.ConfigFile.B9Extras
  ( CPDocument,
    CPError,
    CPGet,
    CPOptionSpec,
    CPReadException (..),
    addSectionCP,
    emptyCP,
    mergeCP,
    readCP,
    readCPDocument,
    setShowCP,
    toStringCP,
  )
import Data.Function (on)
import Data.List (inits)
import Data.Maybe (fromMaybe)
import Data.Monoid
import Data.Semigroup as Semigroup hiding (Last (..))
import Data.Version
import GHC.Stack
import qualified Paths_b9 as My
import System.Directory
import System.FilePath ((<.>))
import System.IO.B9Extras (SystemPath (..), ensureDir, resolve)
import Text.Printf (printf)

-- | A way to specify a time intervall for example for the timeouts
-- of system commands.
--
-- @since 1.1.0
newtype Timeout = TimeoutMicros Int
  deriving (Show, Eq, Ord, Read)

data LogLevel
  = LogTrace
  | LogDebug
  | LogInfo
  | LogError
  | LogNothing
  deriving (Eq, Show, Ord, Read)

data B9Config
  = B9Config
      { _verbosity :: Maybe LogLevel,
        _logFile :: Maybe FilePath,
        _projectRoot :: Maybe FilePath,
        _keepTempDirs :: Bool,
        _uniqueBuildDirs :: Bool,
        _repositoryCache :: Maybe SystemPath,
        _repository :: Maybe String,
        _interactive :: Bool,
        _maxLocalSharedImageRevisions :: Maybe Int,
        _systemdNspawnConfigs :: Maybe SystemdNspawnConfig,
        _podmanConfigs :: Maybe PodmanConfig,
        _dockerConfigs :: Maybe DockerConfig,
        _libVirtLXCConfigs :: Maybe LibVirtLXCConfig,
        _remoteRepos :: [RemoteRepo],
        _defaultTimeout :: Maybe Timeout,
        _timeoutFactor :: Maybe Int
      }
  deriving (Show, Eq)

instance Semigroup B9Config where
  c <> c' =
    B9Config
      { _verbosity = getLast $ on mappend (Last . _verbosity) c c',
        _logFile = getLast $ on mappend (Last . _logFile) c c',
        _projectRoot = getLast $ on mappend (Last . _projectRoot) c c',
        _keepTempDirs = getAny $ on mappend (Any . _keepTempDirs) c c',
        _uniqueBuildDirs = getAll ((mappend `on` (All . _uniqueBuildDirs)) c c'),
        _repositoryCache = getLast $ on mappend (Last . _repositoryCache) c c',
        _repository = getLast ((mappend `on` (Last . _repository)) c c'),
        _interactive = getAny ((mappend `on` (Any . _interactive)) c c'),
        _maxLocalSharedImageRevisions = getLast ((mappend `on` (Last . _maxLocalSharedImageRevisions)) c c'),
        _systemdNspawnConfigs = getLast ((mappend `on` (Last . _systemdNspawnConfigs)) c c'),
        _podmanConfigs = getLast ((mappend `on` (Last . _podmanConfigs)) c c'),
        _dockerConfigs = getLast ((mappend `on` (Last . _dockerConfigs)) c c'),
        _libVirtLXCConfigs = getLast ((mappend `on` (Last . _libVirtLXCConfigs)) c c'),
        _remoteRepos = (mappend `on` _remoteRepos) c c',
        _defaultTimeout = getLast $ on mappend (Last . _defaultTimeout) c c',
        _timeoutFactor = getLast $ on mappend (Last . _timeoutFactor) c c'
      }

instance Monoid B9Config where
  mappend = (<>)
  mempty = B9Config Nothing Nothing Nothing False True Nothing Nothing False Nothing Nothing Nothing Nothing Nothing [] (Just (TimeoutMicros (60 * 60 * 1_000_000))) Nothing

-- | Reader for 'B9Config'. See 'getB9Config' and 'localB9Config'.
--
-- @since 0.5.65
type B9ConfigReader = Reader B9Config

-- | Run a 'B9ConfigReader'.
--
-- @since 0.5.65
runB9ConfigReader :: HasCallStack => B9Config -> Eff (B9ConfigReader ': e) a -> Eff e a
runB9ConfigReader = runReader

-- | Return the runtime configuration, that should be the configuration merged
-- from all configuration sources. This is the configuration to be used during
-- a VM image build.
--
-- @since 0.5.65
getB9Config :: Member B9ConfigReader e => Eff e B9Config
getB9Config = ask

-- | Run an action with an updated runtime configuration.
--
-- @since 0.5.65
localB9Config :: Member B9ConfigReader e => (B9Config -> B9Config) -> Eff e a -> Eff e a
localB9Config = local

-- | An alias for 'getB9Config'.
--
-- @deprecated
--
-- @since 0.5.65
getConfig :: Member B9ConfigReader e => Eff e B9Config
getConfig = getB9Config

-- | Ask whether @stdin@ of the @B9@ process should be redirected to the
-- external commands executed during the build.
--
-- @since 0.5.65
isInteractive :: Member B9ConfigReader e => Eff e Bool
isInteractive = _interactive <$> getB9Config

-- | Ask for the 'RemoteRepo's.
--
-- @since 0.5.65
getRemoteRepos :: Member B9ConfigReader e => Eff e [RemoteRepo]
getRemoteRepos = _remoteRepos <$> getB9Config

-- | Ask for the 'LogLevel'.
--
-- @since 0.5.65
getLogVerbosity :: Member B9ConfigReader e => Eff e (Maybe LogLevel)
getLogVerbosity = _verbosity <$> getB9Config

-- | Ask for the project root directory.
--
-- @since 0.5.65
getProjectRoot :: Member B9ConfigReader e => Eff e FilePath
getProjectRoot = fromMaybe "." . _projectRoot <$> ask

-- | Override b9 configuration items and/or the path of the b9 configuration file.
-- This is useful, i.e. when dealing with command line parameters.
data B9ConfigOverride
  = B9ConfigOverride
      { _customB9ConfigPath :: Maybe SystemPath,
        _customB9Config :: Endo B9Config,
        _customEnvironment :: Environment,
        _customDefaulB9ConfigPath :: Maybe SystemPath
      }

instance Show B9ConfigOverride where
  show x =
    unlines
      [ "config file path:    " ++ show (_customB9ConfigPath x),
        "config modification: " ++ show (appEndo (_customB9Config x) mempty),
        "environment:         " ++ show (_customEnvironment x),
        "default config file: " ++ show (_customDefaulB9ConfigPath x)
      ]

-- | An empty default 'B9ConfigOverride' value, that will neither apply any
-- additional 'B9Config' nor change the path of the configuration file.
noB9ConfigOverride :: B9ConfigOverride
noB9ConfigOverride = B9ConfigOverride Nothing mempty mempty Nothing

makeLenses ''B9Config

makeLenses ''B9ConfigOverride

-- | Convenience utility to override the B9 configuration file path.
overrideB9ConfigPath :: SystemPath -> B9ConfigOverride -> B9ConfigOverride
overrideB9ConfigPath p = customB9ConfigPath ?~ p

-- | Modify the runtime configuration.
overrideB9Config :: (B9Config -> B9Config) -> B9ConfigOverride -> B9ConfigOverride
overrideB9Config e = customB9Config <>~ Endo e

-- | Convenience utility to override the *default* B9 configuration file path.
--
-- @since 1.1.0
overrideDefaultB9ConfigPath :: SystemPath -> B9ConfigOverride -> B9ConfigOverride
overrideDefaultB9ConfigPath p = customDefaulB9ConfigPath ?~ p

-- | Define the current working directory to be used when building.
overrideWorkingDirectory :: FilePath -> B9ConfigOverride -> B9ConfigOverride
overrideWorkingDirectory p = overrideB9Config (projectRoot ?~ p)

-- | Define the default timeout for external commands.
--
-- @since 1.1.0
overrideDefaultTimeout :: Maybe Timeout -> B9ConfigOverride -> B9ConfigOverride
overrideDefaultTimeout = overrideB9Config . Lens.set defaultTimeout

-- | Define the timeout factor for external commands.
--
-- @since 1.1.0
overrideTimeoutFactor :: Maybe Int -> B9ConfigOverride -> B9ConfigOverride
overrideTimeoutFactor = overrideB9Config . Lens.set timeoutFactor

-- | Overwrite the 'verbosity' settings in the configuration with those given.
overrideVerbosity :: LogLevel -> B9ConfigOverride -> B9ConfigOverride
overrideVerbosity = overrideB9Config . Lens.set verbosity . Just

-- | Overwrite the 'keepTempDirs' flag in the configuration with those given.
overrideKeepBuildDirs :: Bool -> B9ConfigOverride -> B9ConfigOverride
overrideKeepBuildDirs = overrideB9Config . Lens.set keepTempDirs

-- | A monad that gives access to the (transient) 'B9Config' to be used at
-- _runtime_ with 'getB9Config' or 'localB9Config', and that allows
-- to write permanent 'B9Config' changes back to the configuration file using
-- 'modifyPermanentConfig'. This is the amalgamation of 'B9ConfigWriter'
-- 'B9ConfigReader' and 'IO'.
--
-- @since 0.5.65
type B9ConfigAction a = Eff '[B9ConfigWriter, B9ConfigReader, EnvironmentReader, Lift IO] a

-- | Accumulate 'B9Config' changes that go back to the config file. See
-- 'B9ConfigAction' and 'modifyPermanentConfig'.
--
-- @since 0.5.65
type B9ConfigWriter = Writer (Semigroup.Endo B9Config)

-- | Add a modification to the permanent configuration file.
modifyPermanentConfig :: (HasCallStack, Member B9ConfigWriter e) => Endo B9Config -> Eff e ()
modifyPermanentConfig = tell

-- | Execute a 'B9ConfigAction'.
-- It will take a 'B9ConfigOverride' as input. The 'B9Config' in that value is
-- treated as the _runtime_ configuration, and the '_customConfigPath' is used
-- as the alternative location of the configuration file.
-- The configuration file is read from either the path in '_customB9ConfigPath'
-- or from 'defaultB9ConfigFile'.
-- Every modification done via 'modifyPermanentConfig' is applied to
-- the **contents** of the configuration file
-- and written back to that file, note that these changes are ONLY reflected
-- in the configuration file and **not** in the _runtime configuration_.
--
-- See also 'runB9ConfigAction', which does not need the 'B9ConfigOverride' parameter.
--
-- @since 0.5.65
runB9ConfigActionWithOverrides :: HasCallStack => B9ConfigAction a -> B9ConfigOverride -> IO a
runB9ConfigActionWithOverrides act cfg = do
  configuredCfgPaths <- traverse resolve (cfg ^. customB9ConfigPath)
  defCfgPath <- resolve (fromMaybe defaultB9ConfigFile (cfg ^. customDefaulB9ConfigPath))
  let (Version myVer _) = My.version
      appendVersionVariations name =
        (\v' -> name <.> showVersion (makeVersion v')) <$> reverse (inits myVer)
      (pathsToTry, pathsToCreate) =
        case configuredCfgPaths of
          Just configuredCfgPath ->
            (appendVersionVariations configuredCfgPath, Nothing)
          Nothing ->
            (appendVersionVariations defCfgPath, Just defCfgPath)
  existingCfgPaths <- filterM doesFileExist pathsToTry
  cfgPath <-
    case existingCfgPaths of
      (cfgPath : _) ->
        return cfgPath
      [] -> do
        putStrLn ("B9 config file resolver: None of these files exists " ++ show pathsToTry)
        case pathsToCreate of
          Just c -> do
            putStrLn ("creating a new config file with defaults at: " ++ c)
            return c
          Nothing ->
            error "Please provide a valid config file path."
  cp <- openOrCreateB9Config cfgPath
  case parseB9Config cp of
    Left e -> error (printf "Internal configuration load error, please report this: %s\n" (show e))
    Right permanentConfigIn -> do
      let runtimeCfg = appEndo (cfg ^. customB9Config) permanentConfigIn
      (res, permanentB9ConfigUpdates) <-
        runLift (runEnvironmentReader (cfg ^. customEnvironment) (runReader runtimeCfg (runMonoidWriter act)))
      let cpExtErr = modifyCPDocument cp <$> permanentB9ConfigUpdateMaybe
          permanentB9ConfigUpdateMaybe =
            if appEndo permanentB9ConfigUpdates permanentConfigIn == permanentConfigIn
              then Nothing
              else Just permanentB9ConfigUpdates
      cpExt <-
        maybe
          (return Nothing)
          (either (error . printf "Internal configuration update error! Please report this: %s\n" . show) (return . Just))
          cpExtErr
      mapM_ (writeB9CPDocument (cfg ^. customB9ConfigPath)) cpExt
      return res

-- | Run a 'B9ConfigAction' using 'noB9ConfigOverride'.
-- See 'runB9ConfigActionWithOverrides' for more details.
--
-- @since 0.5.65
runB9ConfigAction :: HasCallStack => B9ConfigAction a -> IO a
runB9ConfigAction = flip runB9ConfigActionWithOverrides noB9ConfigOverride

-- | Open the configuration file that contains the 'B9Config'.
-- If the configuration does not exist, write a default configuration file,
-- and create a all missing directories.
openOrCreateB9Config :: (HasCallStack, MonadIO m) => FilePath -> m CPDocument
openOrCreateB9Config cfgFile = do
  ensureDir cfgFile
  liftIO $ do
    exists <- doesFileExist cfgFile
    if exists
      then readCPDocument (Path cfgFile)
      else
        let res = b9ConfigToCPDocument defaultB9Config
         in case res of
              Left e -> throwIO (CPReadException cfgFile e)
              Right cp -> writeFile cfgFile (toStringCP cp) >> return cp

-- | Write the configuration in the 'CPDocument' to either the user supplied
-- configuration file path or to 'defaultB9ConfigFile'.
-- Create all missing (parent) directories.
writeB9CPDocument :: (HasCallStack, MonadIO m) => Maybe SystemPath -> CPDocument -> m ()
writeB9CPDocument cfgFileIn cp = do
  cfgFile <- resolve (fromMaybe defaultB9ConfigFile cfgFileIn)
  ensureDir cfgFile
  liftIO (writeFile cfgFile (toStringCP cp))

defaultB9Config :: B9Config
defaultB9Config =
  B9Config
    { _verbosity = Just LogInfo,
      _logFile = Nothing,
      _projectRoot = Nothing,
      _keepTempDirs = False,
      _uniqueBuildDirs = True,
      _repository = Nothing,
      _repositoryCache = Just defaultRepositoryCache,
      _interactive = False,
      _maxLocalSharedImageRevisions = Just 2,
      _systemdNspawnConfigs = Just defaultSystemdNspawnConfig,
      _podmanConfigs = Just defaultPodmanConfig,
      _libVirtLXCConfigs = Just defaultLibVirtLXCConfig,
      _dockerConfigs = Just defaultDockerConfig,
      _remoteRepos = [],
      _defaultTimeout = Just (TimeoutMicros (3_600_000_000)),
      _timeoutFactor = Nothing
    }

defaultRepositoryCache :: SystemPath
defaultRepositoryCache = InB9UserDir "repo-cache"

defaultB9ConfigFile :: SystemPath
defaultB9ConfigFile = InB9UserDir "b9.conf"

verbosityK :: String
verbosityK = "verbosity"

logFileK :: String
logFileK = "log_file"

projectRootK :: String
projectRootK = "build_dir_root"

keepTempDirsK :: String
keepTempDirsK = "keep_temp_dirs"

uniqueBuildDirsK :: String
uniqueBuildDirsK = "unique_build_dirs"

repositoryCacheK :: String
repositoryCacheK = "repository_cache"

maxLocalSharedImageRevisionsK :: String
maxLocalSharedImageRevisionsK = "max_cached_shared_images"

repositoryK :: String
repositoryK = "repository"

defaultTimeoutK :: String
defaultTimeoutK = "default_timeout_seconds"

timeoutFactorK :: String
timeoutFactorK = "timeout_factor"

cfgFileSection :: String
cfgFileSection = "global"

-- | Parse a 'B9Config', modify it, and merge it back to the given 'CPDocument'.
modifyCPDocument :: CPDocument -> Endo B9Config -> Either CPError CPDocument
modifyCPDocument cp f = do
  cfg <- parseB9Config cp
  cp2 <- b9ConfigToCPDocument (appEndo f cfg)
  return (mergeCP cp cp2)

-- | Append a config file section for the 'B9Config' to an empty 'CPDocument'.
b9ConfigToCPDocument :: HasCallStack => B9Config -> Either CPError CPDocument
b9ConfigToCPDocument c = do
  cp1 <- addSectionCP emptyCP cfgFileSection
  cp2 <- setShowCP cp1 cfgFileSection verbosityK (_verbosity c)
  cp3 <- setShowCP cp2 cfgFileSection logFileK (_logFile c)
  cp4 <- setShowCP cp3 cfgFileSection projectRootK (_projectRoot c)
  cp5 <- setShowCP cp4 cfgFileSection keepTempDirsK (_keepTempDirs c)
  cp7 <- setShowCP cp5 cfgFileSection uniqueBuildDirsK (_uniqueBuildDirs c)
  cp8 <- setShowCP cp7 cfgFileSection maxLocalSharedImageRevisionsK (_maxLocalSharedImageRevisions c)
  cp9 <- setShowCP cp8 cfgFileSection repositoryCacheK (_repositoryCache c)
  cpA <- foldr (>=>) return (systemdNspawnConfigToCPDocument <$> _systemdNspawnConfigs c) cp9
  cpB <- foldr (>=>) return (podmanConfigToCPDocument <$> _podmanConfigs c) cpA
  cpC <- foldr (>=>) return (dockerConfigToCPDocument <$> _dockerConfigs c) cpB
  cpD <- foldr (>=>) return (libVirtLXCConfigToCPDocument <$> _libVirtLXCConfigs c) cpC
  cpE <- foldr (>=>) return (remoteRepoToCPDocument <$> _remoteRepos c) cpD
  cpF <- setShowCP cpE cfgFileSection repositoryK (_repository c)
  cpFinal <- setShowCP cpF cfgFileSection defaultTimeoutK (_defaultTimeout c)
  setShowCP cpFinal cfgFileSection timeoutFactorK (_timeoutFactor c)

readB9Config :: (HasCallStack, MonadIO m) => Maybe SystemPath -> m CPDocument
readB9Config cfgFile = readCPDocument (fromMaybe defaultB9ConfigFile cfgFile)

parseB9Config :: HasCallStack => CPDocument -> Either CPError B9Config
parseB9Config cp =
  let getr :: (CPGet a) => CPOptionSpec -> Either CPError a
      getr = readCP cp cfgFileSection
   in B9Config <$> getr verbosityK <*> getr logFileK <*> getr projectRootK <*> getr keepTempDirsK
        <*> getr uniqueBuildDirsK
        <*> getr repositoryCacheK
        <*> getr repositoryK
        <*> pure False
        <*> pure (either (const Nothing) id (getr maxLocalSharedImageRevisionsK))
        <*> pure (either (const Nothing) Just (parseSystemdNspawnConfig cp))
        <*> pure (either (const Nothing) Just (parsePodmanConfig cp))
        <*> pure (either (const Nothing) Just (parseDockerConfig cp))
        <*> pure (either (const Nothing) Just (parseLibVirtLXCConfig cp))
        <*> parseRemoteRepos cp
        <*> pure (either (const Nothing) Just (parseDefaultTimeoutConfig cp))
        <*> pure (either (const Nothing) Just (getr timeoutFactorK))

parseDefaultTimeoutConfig :: CPDocument -> Either CPError Timeout
parseDefaultTimeoutConfig cp = do
  seconds <- readCP cp cfgFileSection defaultTimeoutK
  let mu = seconds * 1_000_000
  return (TimeoutMicros mu)
