{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE FlexibleContexts   #-}
{-# LANGUAGE NoImplicitPrelude  #-}
{-# LANGUAGE OverloadedStrings  #-}
{-# LANGUAGE RecordWildCards    #-}
{-# LANGUAGE ViewPatterns       #-}
module Stackage.CompleteBuild
    ( BuildFlags (..)
    , checkPlan
    , getStackageAuthToken
    , createPlan
    , fetch
    , makeBundle
    , upload
    , hackageDistro
    , uploadGithub
    , uploadDocs'
    , checkTargetAvailable
    ) where

import System.Directory (getAppUserDataDirectory)
import Distribution.Package (Dependency)
import Filesystem (createTree, isFile, rename)
import Filesystem.Path (parent)
import qualified Filesystem.Path.CurrentOS as F
import Control.Concurrent        (threadDelay, getNumCapabilities)
import Control.Concurrent.Async  (withAsync)
import Data.Yaml                 (decodeFileEither, encodeFile, decodeEither')
import Network.HTTP.Client
import Network.HTTP.Client.Conduit (bodyReaderSource)
import Network.HTTP.Client.TLS   (tlsManagerSettings)
import Stackage.BuildConstraints
import Stackage.BuildPlan
import Stackage.CheckBuildPlan
import Stackage.PerformBuild
import Stackage.Prelude          hiding (threadDelay, getNumCapabilities, Concurrently (..), withAsync)
import Stackage.ServerBundle
import Stackage.UpdateBuildPlan
import Stackage.Upload
import System.Environment        (lookupEnv)
import Filesystem.Path           (dropExtension, filename)
import Control.Monad.Trans.Unlift (askRunBase, MonadBaseUnlift)
import Data.Function (fix)
import Control.Concurrent.Async (Concurrently (..))
import Stackage.Curator.UploadDocs (uploadDocs)
import Stackage.PackageIndex (getAllCabalHashesCommit)
import System.Directory (doesDirectoryExist, doesFileExist)

-- | Flags passed in from the command line.
data BuildFlags = BuildFlags
    { bfEnableTests      :: !Bool
    , bfEnableBenches    :: !Bool
    , bfEnableHaddock    :: !Bool
    , bfDoUpload         :: !Bool
    , bfEnableLibProfile :: !Bool
    , bfEnableExecDyn    :: !Bool
    , bfVerbose          :: !Bool
    , bfSkipCheck        :: !Bool
    , bfServer           :: !StackageServer
    , bfBuildHoogle      :: !Bool
    , bfBundleDest       :: !(Maybe FilePath)
    , bfGitPush          :: !Bool
    -- ^ push to Git (when doing an LTS build)
    , bfJobs             :: !(Maybe Int)
    , bfPlanFile         :: !(Maybe FilePath)
    , bfPreBuild         :: !Bool
    , bfLoadPlan         :: !Bool
    } deriving (Show)

createPlan :: Target
           -> FilePath
           -> [Dependency] -- ^ additional constraints
           -> [PackageName] -- ^ newly added packages
           -> [PackageName] -- ^ newly expected test failures
           -> [PackageName] -- ^ newly expected bench failures
           -> [PackageName] -- ^ newly expected haddock failures
           -> IO ()
createPlan target dest constraints addPackages expectTestFailures expectBenchFailures expectHaddockFailures = do
    man <- newManager tlsManagerSettings
    putStrLn $ "Creating plan for: " ++ tshow target
    bc <-
        case target of
            TargetLts x y | y /= 0 -> do
                let url = concat
                        [ "https://raw.githubusercontent.com/fpco/lts-haskell/master/lts-"
                        , show x
                        , "."
                        , show (y - 1)
                        , ".yaml"
                        ]
                putStrLn $ "Downloading old plan from " ++ pack url
                req <- parseUrlThrow url
                res <- httpLbs req man
                oldplan <- either throwM return
                         $ decodeEither' (toStrict $ responseBody res)
                return $ updateBuildConstraints oldplan
            _ -> defaultBuildConstraints man

    plan <- planFromConstraints
          $ flip (foldr expectHaddockFailure) expectHaddockFailures
          $ flip (foldr expectTestFailure) expectTestFailures
          $ flip (foldr expectBenchFailure) expectBenchFailures
          $ flip (foldr addPackage) addPackages
          $ setConstraints constraints bc

    putStrLn $ "Writing build plan to " ++ pack dest
    encodeFile dest plan
  where
    -- Add a new package to the build constraints
    addPackage :: PackageName -> BuildConstraints -> BuildConstraints
    addPackage name bc = bc { bcPackages = insertSet name $ bcPackages bc }

    expectTestFailure = tweak $ \pc -> pc { pcTests = ExpectFailure }
    expectBenchFailure = tweak $ \pc -> pc { pcBenches = ExpectFailure }
    expectHaddockFailure = tweak $ \pc -> pc { pcHaddocks = ExpectFailure }

    tweak f name bc = bc
        { bcPackageConstraints = \name' ->
            (if name == name' then f else id)
            (bcPackageConstraints bc name')
        }

planFromConstraints :: MonadIO m => BuildConstraints -> m BuildPlan
planFromConstraints bc = do
    putStrLn "Creating build plan"
    (plans, latestVersions) <- getLatestAllowedPlans bc
    allCabalHashesCommit <- getAllCabalHashesCommit
    newBuildPlan allCabalHashesCommit plans latestVersions bc

-- | Just print a message saying "still alive" every minute, to appease Travis.
stillAlive :: IO () -> IO ()
stillAlive inner =
    withAsync (printer 1) $ const inner
  where
    printer i = forever $ do
        threadDelay 60000000
        putStrLn $ "Still alive: " ++ tshow i
        printer $! i + (1 :: Int)

-- | Generate and check a new build plan, but do not execute it.
--
-- Since 0.3.1
checkPlan :: Maybe FilePath -> IO ()
checkPlan mfp = stillAlive $ do
    man <- newManager tlsManagerSettings
    plan <-
        case mfp of
            Nothing -> do
                putStrLn "Loading default build constraints"
                bc <- defaultBuildConstraints man

                plan <- planFromConstraints bc

                putStrLn "Writing build plan to check-plan.yaml"
                encodeFile "check-plan.yaml" plan

                return plan
            Just fp -> do
                putStrLn $ "Loading plan from " ++ pack fp
                decodeFileEither fp >>= either throwM return

    putStrLn "Checking plan"
    checkBuildPlan True plan

    putStrLn "Plan seems valid!"

{- FIXME remove
getPerformBuild :: BuildFlags -> Settings -> IO PerformBuild
getPerformBuild buildFlags Settings {..} = do
    jobs <- maybe getNumCapabilities return $ bfJobs buildFlags
    return PerformBuild
        { pbPlan = plan
        , pbInstallDest = buildDir
        , pbLogDir = logDir
        , pbLog = hPut stdout
        , pbJobs = jobs
        , pbGlobalInstall = False
        , pbEnableTests = bfEnableTests buildFlags
        , pbEnableHaddock = bfEnableHaddock buildFlags
        , pbEnableLibProfiling = bfEnableLibProfile buildFlags
        , pbEnableExecDyn = bfEnableExecDyn buildFlags
        , pbVerbose = bfVerbose buildFlags
        , pbAllowNewer = bfSkipCheck buildFlags
        , pbBuildHoogle = bfBuildHoogle buildFlags
        }

-- | Make a complete plan, build, test and upload bundle, docs and
-- distro.
completeBuild :: BuildType -> BuildFlags -> IO ()
completeBuild buildType buildFlags = do
    man <- newManager tlsManagerSettings
    hSetBuffering stdout LineBuffering

    settings@Settings {..} <- if bfLoadPlan buildFlags
        then
            case bfPlanFile buildFlags of
                Nothing -> error "When loading plan, plan file must be specified"
                Just file -> do
                    putStrLn $ "Loading build plan from: " ++ fpToText file
                    getSettings man buildFlags buildType $ Just file
        else do
            putStrLn $ "Loading settings for: " ++ tshow buildType
            settings@Settings {..} <- getSettings man buildFlags buildType Nothing

            putStrLn $ "Writing build plan to: " ++ fpToText planFile
            encodeFile planFile plan

            if bfSkipCheck buildFlags
                then putStrLn "Skipping build plan check"
                else do
                    putStrLn "Checking build plan"
                    checkBuildPlan plan

            return settings

    pb <- getPerformBuild buildFlags settings

    if bfPreBuild buildFlags
        then prefetchPackages pb
        else do
            putStrLn "Performing build"
            performBuild pb >>= mapM_ putStrLn

            putStrLn $ "Creating bundle (v2) at: " ++ fpToText bundleDest
            createBundleV2 CreateBundleV2
                { cb2Plan = plan
                , cb2Type = snapshotType
                , cb2DocsDir = pbDocDir pb
                , cb2Dest = bundleDest
                }

            postBuild `catchAny` print

            when (bfDoUpload buildFlags) $
                finallyUpload
                    buildFlags
                    settings
                    man
-}

getStackageAuthToken :: IO Text
getStackageAuthToken = do
    mtoken <- lookupEnv "STACKAGE_AUTH_TOKEN"
    case mtoken of
        Nothing -> decodeUtf8 <$> readFile "/auth-token"
        Just token -> return $ pack token

{- FIXME remove
-- | The final part of the complete build process: uploading a bundle,
-- docs and a distro to hackage.
finallyUpload :: BuildFlags
              -> Settings -> Manager -> IO ()
finallyUpload buildFlags settings@Settings{..} man = do
    let server = bfServer buildFlags
    pb <- getPerformBuild buildFlags settings

    putStrLn "Uploading bundle to Stackage Server"

    token <- getStackageAuthToken

    res <- flip uploadBundleV2 man UploadBundleV2
        { ub2Server = server
        , ub2AuthToken = token
        , ub2Bundle = bundleDest
        }
    putStrLn $ "New snapshot available at: " ++ res

-}

hackageDistro
    :: FilePath -- ^ plan file
    -> Target
    -> IO ()
hackageDistro planFile target = do
    man <- newManager tlsManagerSettings
    plan <- decodeFileEither planFile >>= either throwM return
    ecreds <- tryIO' $ readFile "/hackage-creds"
    case map encodeUtf8 $ words $ decodeUtf8 $ either (const "") id ecreds of
        [username, password] -> do
            putStrLn $ "Uploading as Hackage distro: " ++ distroName
            res2 <- uploadHackageDistro distroName plan username password man
            putStrLn $ "Distro upload response: " ++ tshow res2
        _ -> error "No Hackage creds found at /hackage-creds"
  where
    distroName =
        case target of
            TargetNightly _ -> "Stackage"
            TargetLts _ _ -> "LTSHaskell"

checkoutRepo :: Target -> IO ([String] -> IO (), FilePath, FilePath)
checkoutRepo target = do
    root <- fmap (</> "curator") $ getAppUserDataDirectory "stackage"

    let repoDir =
            case target of
                TargetNightly _ -> root </> "stackage-nightly"
                TargetLts _ _ -> root </> "lts-haskell"

        runIn wdir cmd args = do
            putStrLn $ concat
                [ pack wdir
                , ": "
                , tshow (cmd:args)
                ]
            withCheckedProcess
                (proc cmd args)
                    { cwd = Just wdir
                    } $ \ClosedStream Inherited Inherited -> return ()

        git = runIn repoDir "git"

        name =
            case target of
                TargetNightly day -> concat
                    [ "nightly-"
                    , show day
                    , ".yaml"
                    ]
                TargetLts x y -> concat
                    [ "lts-"
                    , show x
                    , "."
                    , show y
                    , ".yaml"
                    ]

        destFPPlan = repoDir </> name
        destFPDocmap = repoDir </> "docs" </> name

    exists <- doesDirectoryExist repoDir
    if exists
        then do
            git ["fetch"]
            git ["checkout", "origin/master"]
        else do
            createTree $ parent $ fromString repoDir
            runIn "." "git" ["clone", repoUrl, repoDir]

    whenM (liftIO $ doesFileExist destFPPlan)
        $ error $ "File already exists: " ++ destFPPlan
    whenM (liftIO $ doesFileExist destFPDocmap)
        $ error $ "File already exists: " ++ destFPDocmap

    return (git, destFPPlan, destFPDocmap)
  where
    repoUrl =
        case target of
            TargetNightly _ -> "git@github.com:fpco/stackage-nightly"
            TargetLts _ _ -> "git@github.com:fpco/lts-haskell"

uploadGithub
    :: FilePath -- ^ plan file
    -> FilePath -- ^ docmap file
    -> Target
    -> IO ()
uploadGithub planFile docmapFile target = do
    (git, destFPPlan, destFPDocmap) <- checkoutRepo target

    createTree $ parent $ fromString destFPDocmap
    runResourceT $ do
        sourceFile planFile $$ (sinkFile destFPPlan :: Sink ByteString (ResourceT IO) ())
        sourceFile docmapFile $$ (sinkFile destFPDocmap :: Sink ByteString (ResourceT IO) ())

    git ["add", destFPPlan, destFPDocmap]
    git ["commit", "-m", "Checking in " ++ F.encodeString (filename $ dropExtension $ fromString destFPPlan)]
    git ["push", "origin", "HEAD:master"]

upload
    :: FilePath -- ^ bundle file
    -> StackageServer -- ^ server URL
    -> IO ()
upload bundleFile server = do
    man <- newManager tlsManagerSettings
    putStrLn "Uploading bundle to Stackage Server"

    token <- getStackageAuthToken

    res <- flip uploadBundleV2 man UploadBundleV2
        { ub2Server = server
        , ub2AuthToken = token
        , ub2Bundle = bundleFile
        }
    putStrLn $ "New snapshot available at: " ++ res

uploadDocs' :: Target
            -> FilePath -- ^ bundle file
            -> IO ()
uploadDocs' target bundleFile = do
    name <-
        case target of
            TargetNightly day -> return $ "nightly-" ++ tshow day
            TargetLts x y -> return $ concat ["lts-", tshow x, ".", tshow y]
    uploadDocs
        (installDest target </> "doc")
        bundleFile
        name
        "haddock.stackage.org"

installDest :: Target -> FilePath
installDest target =
    case target of
        TargetNightly _ -> "builds/nightly"
        TargetLts x _ -> unpack $ "builds/lts-" ++ tshow x

makeBundle
    :: FilePath -- ^ plan file
    -> FilePath -- ^ docmap file
    -> FilePath -- ^ bundle file
    -> Target
    -> Maybe Int -- ^ jobs
    -> Bool -- ^ skip tests?
    -> Bool -- ^ skip benches?
    -> Bool -- ^ skip haddock?
    -> Bool -- ^ skip hoogle?
    -> Bool -- ^ enable library profiling?
    -> Bool -- ^ enable executable dynamic?
    -> Bool -- ^ verbose?
    -> Bool -- ^ allow-newer?
    -> Bool -- ^ no rebuild cabal?
    -> Bool -- ^ cabal from head?
    -> IO ()
makeBundle
  planFile docmapFile bundleFile target mjobs skipTests skipBenches skipHaddocks skipHoogle
  enableLibraryProfiling enableExecutableDynamic verbose allowNewer
  noRebuildCabal cabalFromHead
        = do
    plan <- decodeFileEither planFile >>= either throwM return
    jobs <- maybe getNumCapabilities return mjobs
    let pb = PerformBuild
            { pbPlan = plan
            , pbInstallDest = installDest target
            , pbLog = hPut stdout
            , pbLogDir =
                case target of
                    TargetNightly _ -> "logs/nightly"
                    TargetLts x _ -> unpack $ "logs/lts-" ++ tshow x
            , pbJobs = jobs
            , pbGlobalInstall = False
            , pbEnableTests = not skipTests
            , pbEnableBenches = not skipBenches
            , pbEnableHaddock = not skipHaddocks
            , pbEnableLibProfiling = enableLibraryProfiling
            , pbEnableExecDyn = enableExecutableDynamic
            , pbVerbose = verbose
            , pbAllowNewer = allowNewer
            , pbBuildHoogle = not skipHoogle
            , pbNoRebuildCabal = noRebuildCabal
            , pbCabalFromHead = cabalFromHead
            }

    putStrLn "Performing build"
    performBuild pb >>= mapM_ putStrLn

    putStrLn $ "Creating bundle (v2) at: " ++ pack bundleFile
    createBundleV2 CreateBundleV2
        { cb2Plan = plan
        , cb2Type =
            case target of
                TargetNightly day -> STNightly2 day
                TargetLts x y -> STLTS x y
        , cb2DocsDir = pbDocDir pb
        , cb2Dest = bundleFile
        , cb2DocmapFile = docmapFile
        }

fetch :: FilePath -> IO ()
fetch planFile = do
    man <- newManager tlsManagerSettings
    -- First make sure to fetch all of the dependencies... just in case Hackage
    -- has an outage. Don't feel like wasting hours of CPU time.
    putStrLn "Pre-fetching all packages"

    plan <- decodeFileEither planFile >>= either throwM return

    stackDir <- getAppUserDataDirectory "stack"
    parMapM_ 8 (download man stackDir) $ mapToList $ bpPackages plan
  where
    download man stackDir (display -> name, display . ppVersion -> version) = do
        unlessM (isFile fp) $ do
            hPut stdout $ encodeUtf8 $ concat
                [ "Downloading "
                , name
                , "-"
                , version
                , "\n"
                ]
            createTree $ parent fp
            req <- parseUrlThrow url
            withResponse req man $ \res -> do
                let tmp = F.encodeString fp <.> "tmp"
                runResourceT $ bodyReaderSource (responseBody res) $$ sinkFile tmp
                rename (fromString tmp) fp
      where
        url = unpack $ concat
            [ "https://s3.amazonaws.com/hackage.fpcomplete.com/package/"
            , name
            , "-"
            , version
            , ".tar.gz"
            ]
        fp = sdistFilePath stackDir name version

parMapM_ :: (MonadIO m, MonadBaseUnlift IO m, MonoFoldable mono)
         => Int
         -> (Element mono -> m ())
         -> mono
         -> m ()
parMapM_ (max 1 -> 1) f xs = mapM_ f xs
parMapM_ cnt f xs0 = do
    var <- liftBase $ newTVarIO $ toList xs0
    run <- askRunBase
    let worker :: IO ()
        worker = run $ fix $ \loop -> join $ atomically $ do
            xs <- readTVar var
            case xs of
                [] -> return $ return ()
                x:xs' -> do
                    writeTVar var xs'
                    return $ do
                        f x
                        loop
        workers 1 = Concurrently worker
        workers i = Concurrently worker *> workers (i - 1)
    liftBase $ runConcurrently $ workers cnt

-- | Check if the given target is already used in the Github repos
checkTargetAvailable :: Target -> IO ()
checkTargetAvailable = void . checkoutRepo

tryIO' :: IO a -> IO (Either IOException a)
tryIO' = try