{-|
Module      : Omnifmt.Config
Description : Configuration data structures.

Copyright   : (c) Henry J. Wylde, 2015
License     : BSD3
Maintainer  : public@hjwylde.com

Configuration data structures.
-}

{-# LANGUAGE OverloadedStrings #-}

module Omnifmt.Config (
    -- * Config
    Config(..),
    emptyConfig, readConfig, nearestConfigFile, defaultFileName, programFor, unsafeProgramFor,
    supported,

    -- * Program
    Program(..),
    emptyProgram, substitute, usesInputVariable, usesOutputVariable, inputVariableName,
    outputVariableName,
) where

import Control.Arrow        (second)
import Control.Monad.Except
import Control.Monad.Extra
import Control.Monad.Logger

import Data.Aeson.Types
import Data.HashMap.Lazy (toList)
import Data.List         (find)
import Data.Maybe        (fromJust, isJust)
import Data.Text         (Text, cons, isInfixOf, pack, replace, snoc)
import Data.Yaml         (prettyPrintParseException)
import Data.Yaml.Include (decodeFileEither)

import System.Directory.Extra
import System.FilePath

-- | A collection of 'Program's.
data Config = Config {
    programs :: [Program]
    }
    deriving (Eq, Show)

instance FromJSON Config where
    parseJSON (Object obj)  = Config <$> mapM (\(key, value) ->
            parseJSON value >>= \program -> return program { name = key }
            ) (toList obj)
    parseJSON value         = typeMismatch "Config" value

-- | An empty config (no programs).
emptyConfig :: Config
emptyConfig = Config []

-- | Reads a config from the given file path if possible.
--   If an error occurs it is logged using 'logDebugN'.
readConfig :: (MonadIO m, MonadLogger m) => FilePath -> m (Maybe Config)
readConfig filePath = liftIO (decodeFileEither filePath) >>= \ethr -> case ethr of
    Left error      -> do
        logDebugN . pack $ filePath ++ ": error\n" ++ prettyPrintParseException error
        return Nothing
    Right config    -> return $ Just config

-- | Finds the nearest config file by searching from the given directory upwards.
--
--   TODO (hjw): fix the bug where it won't search the root directory.
nearestConfigFile :: MonadIO m => FilePath -> m (Maybe FilePath)
nearestConfigFile dir = findM (liftIO . doesFileExist) $ map (</> defaultFileName) parents
    where
        parents = takeWhile (\dir -> dir /= takeDrive dir) (iterate takeDirectory dir)

-- | The file name of the default config, '.omnifmt.yaml'.
defaultFileName :: FilePath
defaultFileName = ".omnifmt.yaml"

-- | Attempts to find a 'Program' for the given extension.
--   Programs are searched in order as provided by the 'Config' and the first match will be
--   returned.
programFor :: Config -> Text -> Maybe Program
programFor config ext = find (\program -> ext `elem` extensions program) (programs config)

-- | @fromJust . programFor@
unsafeProgramFor :: Config -> Text -> Program
unsafeProgramFor config = fromJust . programFor config

-- | Checks if the given extension is supported (i.e., there is a 'Program' for it).
supported :: Config -> Text -> Bool
supported config = isJust . programFor config

-- | A program has a semantic name, associated extensions and formatting command.
--   The command string may contain variables, denoted by strings surrounded with '{{..}}'.
--   The command should return a 0 exit code for success, or a non-0 exit code for failure.
data Program = Program {
    name       :: Text,     -- ^ A semantic name (has no impact on formatting).
    extensions :: [Text],   -- ^ A list of extensions, without a period prefix.
    command    :: Text      -- ^ A command to run in a shell that prettifies an input file and
                            --   writes to an output file.
    }
    deriving (Eq, Show)

instance FromJSON Program where
    parseJSON (Object obj)  = Program "" <$> obj .: "extensions" <*> obj .: "command"
    parseJSON value         = typeMismatch "Program" value

-- | The empty program (no extensions and the command always fails).
emptyProgram :: Program
emptyProgram = Program "" [] "false"

-- | Substitutes the mapping throughout the command.
--   The mapping is a tuple of @(variable, value)@.
--   Values given are quoted and have any backslashes and double quotaiton marks escaped.
substitute :: Text -> [(Text, Text)] -> Text
substitute = foldr (uncurry replace . second (quote . escape))
    where
        quote   = cons '"' . (`snoc` '"')
        escape  = replace (pack "\"") (pack "\\\"") . replace (pack "\\") (pack "\\\\")

-- | Checks whether the text uses the input variable ('inputVariableName').
usesInputVariable :: Text -> Bool
usesInputVariable = isInfixOf inputVariableName

-- | Checks whether the text uses the output variable ('outputVariableName').
usesOutputVariable :: Text -> Bool
usesOutputVariable = isInfixOf outputVariableName

-- | The input variable name, '{{input}}'.
inputVariableName :: Text
inputVariableName = "{{input}}"

-- | The output variable name, '{{output}}'.
outputVariableName :: Text
outputVariableName = "{{output}}"