{-# LANGUAGE CPP #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- Module: BDCS.Import.RPM
-- Copyright: (c) 2017 Red Hat, Inc.
-- License: LGPL
--
-- Maintainer: https://github.com/weldr
-- Stability: alpha
-- Portability: portable
--
-- Functions for importing individual RPM packages into the database

module BDCS.Import.RPM(consume,
                       loadIntoMDDB,
                       loadFromURI,
                       rpmExistsInMDDB)
 where

import           Codec.RPM.Conduit(parseRPMC, payloadContentsC)
import           Codec.RPM.Tags
import           Codec.RPM.Types
import           Control.Conditional(ifM)
import           Control.Exception(evaluate, tryJust)
import           Control.Monad(guard, void)
import           Control.Monad.Except
import           Control.Monad.IO.Class(liftIO)
import           Control.Monad.Reader(ReaderT, ask)
import           Control.Monad.Trans(lift)
import           Control.Monad.Trans.Control(MonadBaseControl)
import           Control.Monad.Trans.Resource(MonadResource, MonadThrow)
import qualified Data.ByteString.Char8 as C8
import           Data.CPIO(Entry(..))
import           Data.Conduit((.|), Conduit, Consumer, ZipConduit(..), await, awaitForever, mapOutput, runConduit, runConduitRes, transPipe, yield)
import           Data.Conduit.Combinators(sinkList)
import qualified Data.Conduit.List as CL
import           Data.ContentStore(ContentStore, CsError(..), runCsMonad, storeLazyByteStringC)
import           Data.ContentStore.Digest(ObjectDigest)
import           Database.Esqueleto
import           Data.Foldable(toList)
import qualified Data.Text as T
import           Data.Text.Encoding(decodeUtf8)
import           Network.URI(URI(..))
import           System.Posix.Files(fileTypeModes, intersectFileModes, regularFileMode)

import BDCS.Builds(associateBuildWithPackage, insertBuild)
import BDCS.DB
import BDCS.Exceptions(DBException(..), isMissingRPMTagException, throwIfNothing)
import BDCS.Files(associateFilesWithBuild, associateFilesWithPackage, insertFiles)
import BDCS.Import.Conduit(getFromURI)
import BDCS.Import.State(ImportState(..))
import BDCS.Label.FileLabels(apply)
import BDCS.Packages(insertPackageName)
import BDCS.Projects(insertProject)
import BDCS.RPM.Builds(mkBuild)
import BDCS.RPM.Files(mkFiles)
import BDCS.RPM.Groups(createGroup)
import BDCS.RPM.Projects(mkProject)
import BDCS.RPM.Signatures(mkRSASignature, mkSHASignature)
import BDCS.RPM.Sources(mkSource)
import BDCS.Signatures(insertBuildSignatures)
import BDCS.Sources(insertSource)
import BDCS.Utils.Error(mapError)

#ifdef SCRIPTS
import BDCS.RPM.Scripts(mkScripts, mkTriggerScripts)
import BDCS.Scripts(insertScript)
#endif

buildImported :: MonadResource m => [Tag] ->  SqlPersistT m Bool
buildImported sigs =
    case findStringTag "SHA1Header" sigs of
        Just sha -> do ndx <- select $ from $ \signatures -> do
                              where_ $ signatures ^. BuildSignaturesSignature_type ==. val "SHA1" &&.
                                       signatures ^. BuildSignaturesSignature_data ==. val (C8.pack sha)
                              return $ signatures ^. BuildSignaturesId
                       return $ not $ null ndx
        Nothing  -> return False

-- | A conduit consumer that takes in RPM data and stores its payload into the content store and its header
-- information into the mddb.  The return value is whether or not an import occurred.  This is not the
-- same as success vs. failure.  On failures, a 'CsError' will be thrown.  If the package already exists
-- in the database, this function will return False.
consume :: (MonadBaseControl IO m, MonadIO m, MonadThrow m, MonadError CsError m) => ContentStore -> FilePath -> Consumer RPM m Bool
consume repo db = await >>= \case
    Just rpm ->
        lift (runExceptT $ checkAndRunSqlite (T.pack db) (rpmExistsInMDDB rpm)) >>= \case
            Left e      -> throwError (CsError $ show e)
            Right True  -> return False
            Right False -> unsafeConsume repo db rpm
    Nothing  -> return False

-- Like consume, but does not first check to see if the RPM has previously been imported.  Running
-- this could result in a very confused, incorrect mddb.  It is currently for internal use only,
-- but that might change in the future.  If so, its type should also change to be a Consumer.
unsafeConsume :: (MonadIO m, MonadBaseControl IO m, MonadThrow m, MonadError CsError m) => ContentStore -> FilePath -> RPM -> Consumer RPM m Bool
unsafeConsume repo db rpm = do
    -- One source that takes an RPM, extracts its payload, and decompresses it.
    let src       = yield rpm .| payloadContentsC
    -- The first conduit just extracts filenames out of each cpio entry.  cpio puts a leading . on
    -- each filename, but the RPM headers do not have that.  Thus, we trim it out so the paths look
    -- the same.
        filenames = CL.map (T.dropWhile (== '.') . decodeUtf8 . cpioFileName) .| sinkList
    -- The second conduit extracts each file from a cpio entry, stores it in the content store,
    -- and returns its digest.
        digests = maybeStore .| sinkList

    -- And then those two conduits run in parallel and the results are packaged up together so
    -- we have filenames and their digests matched up.
    result <- liftIO $ runCsMonad $ runConduit $ src
                    .| getZipConduit ((,) <$> ZipConduit filenames
                                          <*> ZipConduit digests)

    -- checksums comes back as a ([filename], [digest]).  We need to turn that around
    -- to be [(filename, digest]).
    checksums <- either throwError (return . uncurry zip) result

    lift (runExceptT $ checkAndRunSqlite (T.pack db) (loadIntoMDDB rpm checksums)) >>= \case
        Left e  -> throwError (CsError $ show e)
        Right v -> return v
 where
    maybeStore :: (MonadResource m, MonadError CsError m) => Conduit Entry m (Maybe ObjectDigest)
    maybeStore = awaitForever $ \Entry{..} ->
        -- Only store regular files in the content store
        -- Checking the type is more complicated then you'd think it should be, because
        -- the type mode is more than just one bit. e.g., regular == 100000, symlink == 120000
        if fromIntegral cpioMode `intersectFileModes` fileTypeModes == regularFileMode then
            mapOutput Just $ yield cpioFileData .| storeLazyByteStringC repo
        else
            yield Nothing

-- | Load the headers from a parsed RPM into the MDDB.  The return value is whether or not an import
-- occurred.  This is not the same as success vs. failure.  If the package already exists in the
-- database, this function will return False.
loadIntoMDDB :: (MonadBaseControl IO m, MonadResource m) => RPM -> [(T.Text, Maybe ObjectDigest)] -> SqlPersistT m Bool
loadIntoMDDB rpm checksums =
    ifM (rpmExistsInMDDB rpm)
        (return False)
        (unsafeLoadIntoMDDB rpm checksums)

-- Like loadIntoMDDB, but does not first check to see if the RPM has previously been imported.  Running
-- this could result in a very confused, incorrect mddb.  It is currently for internal use only, but
-- that might change in the future.
unsafeLoadIntoMDDB :: (MonadBaseControl IO m, MonadResource m) => RPM -> [(T.Text, Maybe ObjectDigest)] -> SqlPersistT m Bool
unsafeLoadIntoMDDB RPM{..} checksums = do
    let sigHeaders = headerTags $ head rpmSignatures
    let tagHeaders = headerTags $ head rpmHeaders

    projectId <- insertProject $ mkProject tagHeaders
    sourceId  <- insertSource $ mkSource tagHeaders projectId
    buildId   <- insertBuild $ mkBuild tagHeaders sourceId

    -- Ignore missing tag errors from mkRSASignature, it just means the RPM is unsigned
    -- toList <$> tryJust will convert the result to [BuildSignatures], either containing one record on
    -- success or an empty list on error.
    rsaSignature <- liftIO $ toList <$> tryJust (guard . isMissingRPMTagException) (evaluate $ mkRSASignature sigHeaders buildId)
    void $ insertBuildSignatures (mkSHASignature sigHeaders buildId:rsaSignature)

    pkgNameId <- insertPackageName $ T.pack $ findStringTag "Name" tagHeaders `throwIfNothing` MissingRPMTag "Name"

    files     <- mkFiles tagHeaders checksums
    filesIds  <- insertFiles files

    -- Pair up files with their IDs in the Files table.  Then use this mapping to add all the
    -- various file-based labels to the KeyVal table.
    void $ apply (zip files filesIds)

    void $ associateFilesWithBuild filesIds buildId
    void $ associateFilesWithPackage filesIds pkgNameId
    void $ associateBuildWithPackage buildId pkgNameId

#ifdef SCRIPTS
    -- groups and reqs
    groupId <- createGroup filesIds tagHeaders

    -- scripts - These are here temporarily, just so we can figure out how widely they are
    -- used.  Once we are done, they are going away.
    mapM_ (insertScript groupId) (mkScripts tagHeaders ++ mkTriggerScripts tagHeaders)
#else
    void $ createGroup filesIds tagHeaders
#endif

    return True

-- | Fetch an RPM from a given 'URI' and load it into the MDDB.  This function must be
-- run within the 'ReaderT' monad, which should be given an 'ImportState' record.  This
-- is how importing knows where to store the results.  Errors will be printed to the
-- screen.
loadFromURI :: URI -> ReaderT ImportState IO ()
loadFromURI uri = do
    db <- stDB <$> ask
    repo <- stRepo <$> ask

    result <- runExceptT $ runConduitRes $
           getFromURI uri
        .| transPipe (mapError showParseError) parseRPMC
        .| transPipe (mapError showCsError) (consume repo db)

    case result of
        Left e     -> liftIO $ putStrLn e
        Right True -> liftIO $ putStrLn $ "Imported " ++ uriPath uri
        _          -> return ()

 where
    showParseError e = "Error fetching " ++ uriPath uri ++ ": " ++ show e
    showCsError    e = "Error importing " ++ uriPath uri ++ ": " ++ show e

-- | Query the MDDB to see if the package has already been imported.  If so, quit now to prevent it
-- from being added to the content store a second time.  Note that 'loadIntoMDDB' also performs this
-- check, but both of these functions are public and therefore both need to prevent duplicate imports.
rpmExistsInMDDB :: MonadResource m => RPM -> SqlPersistT m Bool
rpmExistsInMDDB RPM{..} = do
    let sigHeaders = headerTags $ head rpmSignatures
    buildImported sigHeaders
