{-# LANGUAGE MultiWayIf        #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards   #-}
{-# LANGUAGE TupleSections     #-}
{-# LANGUAGE ViewPatterns      #-}
module Distribution.Client.CmdSdist
    ( sdistCommand, sdistAction, packageToSdist
    , OutputFormat(..)) where

import Prelude ()
import Distribution.Client.Compat.Prelude

import Distribution.Client.CmdErrorMessages
    ( Plural(..), renderComponentKind )
import Distribution.Client.ProjectOrchestration
    ( ProjectBaseContext(..), CurrentCommand(..), establishProjectBaseContext, establishProjectBaseContextWithRoot)
import Distribution.Client.NixStyleOptions
         ( NixStyleFlags (..), defaultNixStyleFlags )
import Distribution.Client.TargetSelector
    ( TargetSelector(..), ComponentKind
    , readTargetSelectors, reportTargetSelectorProblems )
import Distribution.Client.Setup
    ( GlobalFlags(..) )
import Distribution.Solver.Types.SourcePackage
    ( SourcePackage(..) )
import Distribution.Client.Types
    ( PackageSpecifier(..), PackageLocation(..), UnresolvedSourcePackage )
import Distribution.Client.DistDirLayout
    ( DistDirLayout(..), ProjectRoot (..) )
import Distribution.Client.ProjectConfig
    ( ProjectConfig, withProjectOrGlobalConfig, commandLineFlagsToProjectConfig, projectConfigConfigFile, projectConfigShared )
import Distribution.Client.ProjectFlags
     ( ProjectFlags (..), defaultProjectFlags, projectFlagsOptions )

import Distribution.Compat.Lens
    ( _1, _2 )
import Distribution.Package
    ( Package(packageId) )
import Distribution.PackageDescription.Configuration
    ( flattenPackageDescription )
import Distribution.ReadE
    ( succeedReadE )
import Distribution.Simple.Command
    ( CommandUI(..), OptionField, option, reqArg, liftOptionL, ShowOrParseArgs )
import Distribution.Simple.PreProcess
    ( knownSuffixHandlers )
import Distribution.Simple.Setup
    ( Flag(..), toFlag, fromFlagOrDefault, flagToList, flagToMaybe
    , optionVerbosity, optionDistPref, trueArg, configVerbosity, configDistPref
    )
import Distribution.Simple.SrcDist
    ( listPackageSourcesWithDie )
import Distribution.Client.SrcDist
    ( packageDirToSdist )
import Distribution.Simple.Utils
    ( die', notice, withOutputMarker, wrapText )
import Distribution.Types.ComponentName
    ( ComponentName, showComponentName )
import Distribution.Types.PackageName
    ( PackageName, unPackageName )
import Distribution.Verbosity
    ( normal )
import Distribution.Types.GenericPackageDescription (GenericPackageDescription)

import qualified Data.ByteString.Lazy.Char8 as BSL
import System.Directory
    ( getCurrentDirectory
    , createDirectoryIfMissing, makeAbsolute
    )
import System.FilePath
    ( (</>), (<.>), makeRelative, normalise )

-------------------------------------------------------------------------------
-- Command
-------------------------------------------------------------------------------

sdistCommand :: CommandUI (ProjectFlags, SdistFlags)
sdistCommand = CommandUI
    { commandName = "v2-sdist"
    , commandSynopsis = "Generate a source distribution file (.tar.gz)."
    , commandUsage = \pname ->
        "Usage: " ++ pname ++ " v2-sdist [FLAGS] [PACKAGES]\n"
    , commandDescription  = Just $ \_ -> wrapText
        "Generates tarballs of project packages suitable for upload to Hackage."
    , commandNotes = Nothing
    , commandDefaultFlags = (defaultProjectFlags, defaultSdistFlags)
    , commandOptions = \showOrParseArgs ->
        map (liftOptionL _1) (projectFlagsOptions showOrParseArgs) ++
        map (liftOptionL _2) (sdistOptions showOrParseArgs)
    }

-------------------------------------------------------------------------------
-- Flags
-------------------------------------------------------------------------------

data SdistFlags = SdistFlags
    { sdistVerbosity     :: Flag Verbosity
    , sdistDistDir       :: Flag FilePath
    , sdistListSources   :: Flag Bool
    , sdistNulSeparated  :: Flag Bool
    , sdistOutputPath    :: Flag FilePath
    }

defaultSdistFlags :: SdistFlags
defaultSdistFlags = SdistFlags
    { sdistVerbosity     = toFlag normal
    , sdistDistDir       = mempty
    , sdistListSources   = toFlag False
    , sdistNulSeparated  = toFlag False
    , sdistOutputPath    = mempty
    }

sdistOptions :: ShowOrParseArgs -> [OptionField SdistFlags]
sdistOptions showOrParseArgs =
    [ optionVerbosity
        sdistVerbosity (\v flags -> flags { sdistVerbosity = v })
    , optionDistPref
        sdistDistDir (\dd flags -> flags { sdistDistDir = dd })
        showOrParseArgs
    , option ['l'] ["list-only"]
        "Just list the sources, do not make a tarball"
        sdistListSources (\v flags -> flags { sdistListSources = v })
        trueArg
    , option [] ["null-sep"]
        "Separate the source files with NUL bytes rather than newlines."
        sdistNulSeparated (\v flags -> flags { sdistNulSeparated = v })
        trueArg
    , option ['o'] ["output-directory", "outputdir"]
        "Choose the output directory of this command. '-' sends all output to stdout"
        sdistOutputPath (\o flags -> flags { sdistOutputPath = o })
        (reqArg "PATH" (succeedReadE Flag) flagToList)
    ]

-------------------------------------------------------------------------------
-- Action
-------------------------------------------------------------------------------

sdistAction :: (ProjectFlags, SdistFlags) -> [String] -> GlobalFlags -> IO ()
sdistAction (pf@ProjectFlags{..}, SdistFlags{..}) targetStrings globalFlags = do
    (baseCtx, distDirLayout) <- withProjectOrGlobalConfig verbosity flagIgnoreProject globalConfigFlag withProject withoutProject

    let localPkgs = localPackages baseCtx

    targetSelectors <- either (reportTargetSelectorProblems verbosity) return
        =<< readTargetSelectors localPkgs Nothing targetStrings

    -- elaborate path, create target directory
    mOutputPath' <- case mOutputPath of
        Just "-"  -> return (Just "-")
        Just path -> do
            abspath <- makeAbsolute path
            createDirectoryIfMissing True abspath
            return (Just abspath)
        Nothing   -> do
            createDirectoryIfMissing True (distSdistDirectory distDirLayout)
            return Nothing

    let format :: OutputFormat
        format =
            if | listSources, nulSeparated -> SourceList '\0'
               | listSources               -> SourceList '\n'
               | otherwise                 -> TarGzArchive

        ext = case format of
                SourceList _  -> "list"
                TarGzArchive  -> "tar.gz"

        outputPath pkg = case mOutputPath' of
            Just path
                | path == "-" -> "-"
                | otherwise   -> path </> prettyShow (packageId pkg) <.> ext
            Nothing
                | listSources -> "-"
                | otherwise   -> distSdistFile distDirLayout (packageId pkg)


    case reifyTargetSelectors localPkgs targetSelectors of
        Left errs -> die' verbosity . unlines . fmap renderTargetProblem $ errs
        Right pkgs
            | length pkgs > 1, not listSources, Just "-" <- mOutputPath' ->
                die' verbosity "Can't write multiple tarballs to standard output!"
            | otherwise ->
                traverse_ (\pkg -> packageToSdist verbosity (distProjectRootDirectory distDirLayout) format (outputPath pkg) pkg) pkgs
  where
    verbosity      = fromFlagOrDefault normal sdistVerbosity
    listSources    = fromFlagOrDefault False sdistListSources
    nulSeparated   = fromFlagOrDefault False sdistNulSeparated
    mOutputPath    = flagToMaybe sdistOutputPath

    prjConfig :: ProjectConfig
    prjConfig = commandLineFlagsToProjectConfig
        globalFlags
        (defaultNixStyleFlags ())
          { configFlags = (configFlags $ defaultNixStyleFlags ())
            { configVerbosity = sdistVerbosity
            , configDistPref = sdistDistDir
            }
          , projectFlags = pf
          }
        mempty

    globalConfigFlag = projectConfigConfigFile (projectConfigShared prjConfig)

    withProject :: IO (ProjectBaseContext, DistDirLayout)
    withProject = do
        baseCtx <- establishProjectBaseContext verbosity prjConfig OtherCommand
        return (baseCtx, distDirLayout baseCtx)

    withoutProject :: ProjectConfig -> IO (ProjectBaseContext, DistDirLayout)
    withoutProject config = do
        cwd <- getCurrentDirectory
        baseCtx <- establishProjectBaseContextWithRoot verbosity (config <> prjConfig) (ProjectRootImplicit cwd) OtherCommand
        return (baseCtx, distDirLayout baseCtx)

data OutputFormat = SourceList Char
                  | TarGzArchive
                  deriving (Show, Eq)

packageToSdist :: Verbosity -> FilePath -> OutputFormat -> FilePath -> UnresolvedSourcePackage -> IO ()
packageToSdist verbosity projectRootDir format outputFile pkg = do
    let death = die' verbosity ("The impossible happened: a local package isn't local" <> (show pkg))
    dir0 <- case srcpkgSource pkg of
             LocalUnpackedPackage path             -> pure (Right path)
             RemoteSourceRepoPackage _ (Just tgz)  -> pure (Left tgz)
             RemoteSourceRepoPackage {}            -> death
             LocalTarballPackage tgz               -> pure (Left tgz)
             RemoteTarballPackage _ (Just tgz)     -> pure (Left tgz)
             RemoteTarballPackage {}               -> death
             RepoTarballPackage {}                 -> death

    let -- Write String to stdout or file, using the default TextEncoding.
        write str
          | outputFile == "-" = putStr (withOutputMarker verbosity str)
          | otherwise = do
            writeFile outputFile str
            notice verbosity $ "Wrote source list to " ++ outputFile ++ "\n"
        -- Write raw ByteString to stdout or file as it is, without encoding.
        writeLBS lbs
          | outputFile == "-" = BSL.putStr lbs
          | otherwise = do
            BSL.writeFile outputFile lbs
            notice verbosity $ "Wrote tarball sdist to " ++ outputFile ++ "\n"

    case dir0 of
      Left tgz -> do
        case format of
          TarGzArchive -> do
            writeLBS =<< BSL.readFile tgz
          _ -> die' verbosity ("cannot convert tarball package to " ++ show format)

      Right dir -> case format of
        SourceList nulSep -> do
          let gpd :: GenericPackageDescription
              gpd = srcpkgDescription pkg

          let thisDie :: Verbosity -> String -> IO a
              thisDie v s = die' v $ "sdist of " <> prettyShow (packageId gpd) ++ ": " ++ s

          files' <- listPackageSourcesWithDie verbosity thisDie dir (flattenPackageDescription gpd) knownSuffixHandlers
          let files = nub $ sort $ map normalise files'
          let prefix = makeRelative projectRootDir dir
          write $ concat [prefix </> i ++ [nulSep] | i <- files]

        TarGzArchive -> do
          packageDirToSdist verbosity (srcpkgDescription pkg) dir >>= writeLBS

--

reifyTargetSelectors :: [PackageSpecifier UnresolvedSourcePackage] -> [TargetSelector] -> Either [TargetProblem] [UnresolvedSourcePackage]
reifyTargetSelectors pkgs sels =
    case partitionEithers (foldMap go sels) of
        ([], sels') -> Right sels'
        (errs, _)   -> Left errs
    where
        -- there can be pkgs which are in extra-packages:
        -- these are not SpecificSourcePackage
        --
        -- Why these packages are in localPkgs, it's confusing.
        -- Anyhow, better to be lenient here.
        --
        flatten (SpecificSourcePackage pkg@SourcePackage{}) = Just pkg
        flatten _                                           = Nothing

        pkgs' = mapMaybe flatten pkgs

        getPkg pid = case find ((== pid) . packageId) pkgs' of
            Just pkg -> Right pkg
            Nothing -> error "The impossible happened: we have a reference to a local package that isn't in localPackages."

        go :: TargetSelector -> [Either TargetProblem UnresolvedSourcePackage]
        go (TargetPackage _ pids Nothing) = fmap getPkg pids
        go (TargetAllPackages Nothing) = Right <$> pkgs'

        go (TargetPackage _ _ (Just kind)) = [Left (AllComponentsOnly kind)]
        go (TargetAllPackages (Just kind)) = [Left (AllComponentsOnly kind)]

        go (TargetPackageNamed pname _) = [Left (NonlocalPackageNotAllowed pname)]
        go (TargetComponentUnknown pname _ _) = [Left (NonlocalPackageNotAllowed pname)]

        go (TargetComponent _ cname _) = [Left (ComponentsNotAllowed cname)]

data TargetProblem = AllComponentsOnly ComponentKind
                   | NonlocalPackageNotAllowed PackageName
                   | ComponentsNotAllowed ComponentName

renderTargetProblem :: TargetProblem -> String
renderTargetProblem (AllComponentsOnly kind) =
    "It is not possible to package only the " ++ renderComponentKind Plural kind ++ " from a package "
    ++ "for distribution. Only entire packages may be packaged for distribution."
renderTargetProblem (ComponentsNotAllowed cname) =
    "The component " ++ showComponentName cname ++ " cannot be packaged for distribution on its own. "
    ++ "Only entire packages may be packaged for distribution."
renderTargetProblem (NonlocalPackageNotAllowed pname) =
    "The package " ++ unPackageName pname ++ " cannot be packaged for distribution, because it is not "
    ++ "local to this project."
