module ConfCrypt.Commands (
    -- * Commands
    Command,
    evaluate,

    -- * Supported Commands
    ReadConfCrypt(..),
    GetConfCrypt(..),
    AddConfCrypt(..),
    EditConfCrypt(..),
    DeleteConfCrypt(..),
    ValidateConfCrypt(..),
    NewConfCrypt(..),

    -- * Utilities
    FileAction(..),
    -- ** Exported for testing
    genNewFileState,
    writeFullContentsToBuffer
    ) where

import ConfCrypt.Default (defaultLines)
import ConfCrypt.Types
import ConfCrypt.Encryption (MonadEncrypt, MonadDecrypt, encryptValue, decryptValue, TextKey(..), RemoteKey(..))
import ConfCrypt.Validation (runAllRules)
import ConfCrypt.Providers.AWS (AWSCtx)
import ConfCrypt.Template (renderTemplate)

import Control.Arrow (second)
import Control.Monad (unless, (<=<))
import Control.Monad.Reader (ask)
import Control.Monad.Except (throwError, runExcept, MonadError, Except)
import Crypto.Random (MonadRandom)
import Data.List (find, sortOn)
import Data.Maybe (maybeToList)
import GHC.Generics (Generic)
import qualified Crypto.PubKey.RSA.Types as RSA
import qualified Data.Text as T
import qualified Data.Map as M

-- | Commands may perform one of the following operations to a line of a confcrypt file
data FileAction
    = Add
    | Edit
    | Remove

-- | All confcrypt commands can be generalized into an 'evaluate' call. In reality, instances likely
-- need to provide some environment, although that's not required as everything could be contained
-- as record fields of the command argument itself.
class Monad m => Command a m where
    evaluate :: a -> m [T.Text]

-- | Read and return the full contents of an encrypted file. Provides support for using a local RSA key or an externl KMS service
data ReadConfCrypt = ReadConfCrypt {rTemplate :: Maybe T.Text}
    deriving (Eq, Read, Show, Generic)

instance (Monad m, MonadDecrypt (ConfCryptM m key) key) => Command ReadConfCrypt (ConfCryptM m key) where
    evaluate (ReadConfCrypt template) = do
        (ccFile, ctx) <- ask
        let params = parameters ccFile
        case template of
            Nothing -> do
                transformed <- mapM (\p -> decryptedParam p <$> decryptValue ctx (paramValue p)) params
                processReadLines transformed ccFile
            Just tpl ->
                case renderTemplate tpl of
                    Left e -> throwError $ FormatParseError e
                    Right parsedTpl -> do
                        params' <- sequence $ decryptParam ctx <$> params
                        pure $ parsedTpl <$> params'

        where
            decryptParam ctx param = do
                value' <- decryptValue ctx $ paramValue param
                pure param {paramValue = value'}

            decryptedParam param v = ParameterLine ParamLine {pName = paramName param, pValue = v}

            -- Given a transformation, apply it to the current file contents then write them to the output buffer
            processReadLines transformed ccFile =
                    writeFullContentsToBuffer False =<<  genNewFileState (fileContents ccFile) transformedLines
                where
                transformedLines = [(p, Edit)| p <- transformed]

-- | Used to get the decrypted value of a single encrypted config parameter
data GetConfCrypt = GetConfCrypt {gName :: T.Text}
    deriving (Eq, Read, Show, Generic)

instance (Monad m, MonadDecrypt (ConfCryptM m key) key) => Command GetConfCrypt (ConfCryptM m key) where
    evaluate (GetConfCrypt name) = do
        (ccFile, ctx) <- ask
        let mParam = find ((==name) . paramName) (parameters ccFile)
        traverse (decrypt ctx) $ maybeToList mParam
        where
            decrypt ctx = decryptValue ctx . paramValue

-- | Used to add a new config parameter to the file
data AddConfCrypt = AddConfCrypt {aName :: T.Text, aValue :: T.Text, aType :: SchemaType}
    deriving (Eq, Read, Show, Generic)

instance (Monad m, MonadRandom m, MonadEncrypt (ConfCryptM m key) key) =>
    Command AddConfCrypt (ConfCryptM m key) where
    evaluate ac@AddConfCrypt {aName, aValue, aType} =  do
        (ccFile, ctx ) <- ask
        encryptedValue <- encryptValue ctx aValue
        let contents = fileContents ccFile
            instructions = [(SchemaLine sl, Add), (ParameterLine (pl {pValue = encryptedValue}), Add)]
        newcontents <- genNewFileState contents instructions
        writeFullContentsToBuffer False newcontents
        where
            (pl, Just sl) = parameterToLines Parameter {paramName = aName, paramValue = aValue, paramType = Just aType}

-- | Modify the value or type of a parameter in-place. This should result in a diff touching only the impacted lines.
-- Very important that this property holds to make reviews easier.
data EditConfCrypt = EditConfCrypt {eName:: T.Text, eValue :: T.Text, eType :: SchemaType}
    deriving (Eq, Read, Show, Generic)

instance (Monad m, MonadRandom m, MonadEncrypt (ConfCryptM m key) key) =>
    Command EditConfCrypt (ConfCryptM m key) where
    evaluate ec@EditConfCrypt {eName, eValue, eType} = do
        (ccFile, ctx) <- ask

        -- Editing an existing parameter requires that the file is inplace. Its not difficult to fall back into
        -- 'add' behavior in the case where the parameter isn't present, but I'm not implementing that right now.
        unless ( any ((==) eName . paramName) $ parameters ccFile) $
            throwError $ UnknownParameter eName

        rawEncrypted <- encryptValue ctx eValue
        editOutput ccFile ec rawEncrypted
        where
        -- Editing consists of replacing the encrypted value assocaited and type assocaited with a given parameter
        -- then writing the changes as in-place updates to the file.
        editOutput ccFile EditConfCrypt {eName, eValue, eType} encryptedValue = do
                let contents = fileContents ccFile
                    instructions = [(SchemaLine sl, Edit),
                                    (ParameterLine (pl {pValue = encryptedValue}), Edit)
                                ]
                newcontents <- genNewFileState contents instructions
                writeFullContentsToBuffer False newcontents
                where
                    (pl, Just sl) = parameterToLines Parameter {paramName = eName, paramValue = eValue, paramType = Just eType}

-- | Removes a particular parameter and schema from the config file. This does not require an encryption key because the
-- lines may simply be deleted based on the parameter name.
data DeleteConfCrypt = DeleteConfCrypt {dName:: T.Text}
    deriving (Eq, Read, Show, Generic)
instance Monad m => Command DeleteConfCrypt (ConfCryptM m ()) where
    evaluate DeleteConfCrypt {dName} = do
        (ccFile, ()) <- ask

        unless (any ((==) dName . paramName) $ parameters ccFile) $
            throwError $ UnknownParameter dName

        let contents = fileContents ccFile
            instructions = fmap (second (const Remove)) . M.toList $ M.filterWithKey findNamedLine contents

        newcontents <- genNewFileState contents instructions
        writeFullContentsToBuffer False newcontents
        where
            findNamedLine (SchemaLine Schema {sName}) _ = dName == sName
            findNamedLine (ParameterLine ParamLine {pName}) _ = dName == pName
            findNamedLine _ _ = False

-- | Run all of the rules in 'ConfCrypt.Validation' on this file.
data ValidateConfCrypt = ValidateConfCrypt
instance (Monad m, MonadDecrypt (ConfCryptM m key) key) => Command ValidateConfCrypt (ConfCryptM m key) where
    evaluate _ = runAllRules

-- | Dumps the contents of 'defaultLines' to the output buffer. This is the same example config used
-- in the readme.
data NewConfCrypt = NewConfCrypt
instance Monad m => Command NewConfCrypt (ConfCryptM m ()) where
    evaluate _ =
        writeFullContentsToBuffer False (fileContents defaultLines)


-- | Given a known file state and some edits, apply the edits and produce the new file contents
genNewFileState :: (Monad m, MonadError ConfCryptError m) =>
    M.Map ConfCryptElement LineNumber -- ^ initial file state
    -> [(ConfCryptElement, FileAction)] -- ^ edits
    -> m (M.Map ConfCryptElement LineNumber) -- ^ new file, with edits applied in-place
genNewFileState fileContents [] = pure fileContents
genNewFileState fileContents ((CommentLine _, _):rest) = genNewFileState fileContents rest
genNewFileState fileContents ((line, action):rest) =
    case M.toList (mLine line) of
        [] ->
            case action of
                Add -> let
                    nums =  M.elems fileContents
                    LineNumber highestLineNum = if null nums then LineNumber 0 else maximum nums
                    fc' = M.insert line (LineNumber $ highestLineNum + 1) fileContents
                    in genNewFileState fc' rest
                _ -> throwError $ MissingLine (T.pack $ show line)
        [(key, lineNum@(LineNumber lnValue))] ->
            case action of
                Remove -> let
                    fc' = M.delete key fileContents
                    fc'' = (\(LineNumber l) -> if l > lnValue then LineNumber (l - 1) else LineNumber l) <$> fc'
                    in genNewFileState fc'' rest
                Edit -> let
                    fc' = M.delete key fileContents
                    fc'' = M.insert line lineNum fc'
                    in genNewFileState fc'' rest
                _ -> throwError $ WrongFileAction ((<> " is an Add, but the line already exists. Did you mean to edit?"). T.pack $ show line)
        _ -> error "viloates map key uniqueness"

    where
        mLine l = M.filterWithKey (\k _ -> k == l) fileContents

-- | Writes the provided 'ConfCryptFile' (provided as a Map) to the output buffer in line-number order. This
-- allows for producing an easily diffable output and makes in-place edits easy to spot in source control diffs.
writeFullContentsToBuffer :: Monad m =>
    Bool
    -> M.Map ConfCryptElement LineNumber
    -> m [T.Text]
writeFullContentsToBuffer wrap contents =
    return $ toDisplayLine wrap <$> sortedLines
    where
        sortedLines = fmap fst . sortOn snd $ M.toList contents

        toDisplayLine ::
            Bool
            -> ConfCryptElement
            -> T.Text
        toDisplayLine _ (CommentLine comment) = "# " <> comment
        toDisplayLine _ (SchemaLine (Schema name tpe)) = name <> " : " <> typeToOutputString tpe
        toDisplayLine wrap (ParameterLine (ParamLine name val)) = name <> " = " <> if wrap then wrapEncryptedValue val else val

-- TODO remove this
-- | Because the encrypted results are stored as UTF8 text, its possible for an encrypted value
-- to embed end-of-line (eol) characters into the output value. This means rather than relying on eol
-- as our delimeter we need to explicitly wrap encrypted values in something very unlikely to occur w/in
-- an encrypted value.
wrapEncryptedValue ::
    T.Text
    -> T.Text
wrapEncryptedValue v = "BEGIN"<>v<>"END"