{-# LANGUAGE DataKinds #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE RecordWildCards #-}

module Distribution.Client.CmdClean (cleanCommand, cleanAction) where

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

import Distribution.Client.Config
  ( defaultScriptBuildsDir
  )
import Distribution.Client.DistDirLayout
  ( DistDirLayout (..)
  , defaultDistDirLayout
  )
import Distribution.Client.Errors
import Distribution.Client.ProjectConfig
  ( findProjectRoot
  )
import Distribution.Client.ProjectFlags
  ( ProjectFlags (..)
  , defaultProjectFlags
  , projectFlagsOptions
  , removeIgnoreProjectOption
  )
import Distribution.Client.Setup
  ( GlobalFlags
  )
import Distribution.Compat.Lens
  ( _1
  , _2
  )
import Distribution.Simple.Command
  ( CommandUI (..)
  , OptionField
  , ShowOrParseArgs
  , liftOptionL
  , option
  )
import Distribution.Simple.Setup
  ( Flag
  , falseArg
  , flagToMaybe
  , fromFlagOrDefault
  , optionDistPref
  , optionVerbosity
  , toFlag
  , pattern NoFlag
  )
import Distribution.Simple.Utils
  ( dieWithException
  , handleDoesNotExist
  , info
  , wrapText
  )
import Distribution.System
  ( OS (Windows)
  , buildOS
  )
import Distribution.Utils.Path hiding
  ( (<.>)
  , (</>)
  )
import Distribution.Verbosity
  ( normal
  )

import Control.Exception
  ( throw
  )
import Control.Monad
  ( forM
  , forM_
  , mapM
  )
import qualified Data.Set as Set
import System.Directory
  ( canonicalizePath
  , doesDirectoryExist
  , doesFileExist
  , getDirectoryContents
  , listDirectory
  , removeDirectoryRecursive
  , removeFile
  , removePathForcibly
  )
import System.FilePath
  ( (</>)
  )
import System.IO.Error
  ( isPermissionError
  )
import qualified System.Process as Process

data CleanFlags = CleanFlags
  { cleanSaveConfig :: Flag Bool
  , cleanVerbosity :: Flag Verbosity
  , cleanDistDir :: Flag (SymbolicPath Pkg (Dir Dist))
  }
  deriving (Eq)

defaultCleanFlags :: CleanFlags
defaultCleanFlags =
  CleanFlags
    { cleanSaveConfig = toFlag False
    , cleanVerbosity = toFlag normal
    , cleanDistDir = NoFlag
    }

cleanCommand :: CommandUI (ProjectFlags, CleanFlags)
cleanCommand =
  CommandUI
    { commandName = "v2-clean"
    , commandSynopsis = "Clean the package store and remove temporary files."
    , commandUsage = \pname ->
        "Usage: " ++ pname ++ " new-clean [FLAGS]\n"
    , commandDescription = Just $ \_ ->
        wrapText $
          "Removes all temporary files created during the building process "
            ++ "(.hi, .o, preprocessed sources, etc.) and also empties out the "
            ++ "local caches (by default).\n\n"
    , commandNotes = Nothing
    , commandDefaultFlags = (defaultProjectFlags, defaultCleanFlags)
    , commandOptions = \showOrParseArgs ->
        map
          (liftOptionL _1)
          (removeIgnoreProjectOption (projectFlagsOptions showOrParseArgs))
          ++ map (liftOptionL _2) (cleanOptions showOrParseArgs)
    }

cleanOptions :: ShowOrParseArgs -> [OptionField CleanFlags]
cleanOptions showOrParseArgs =
  [ optionVerbosity
      cleanVerbosity
      (\v flags -> flags{cleanVerbosity = v})
  , optionDistPref
      cleanDistDir
      (\dd flags -> flags{cleanDistDir = dd})
      showOrParseArgs
  , option
      ['s']
      ["save-config"]
      "Save configuration, only remove build artifacts"
      cleanSaveConfig
      (\sc flags -> flags{cleanSaveConfig = sc})
      falseArg
  ]

cleanAction :: (ProjectFlags, CleanFlags) -> [String] -> GlobalFlags -> IO ()
cleanAction (ProjectFlags{..}, CleanFlags{..}) extraArgs _ = do
  let verbosity = fromFlagOrDefault normal cleanVerbosity
      saveConfig = fromFlagOrDefault False cleanSaveConfig
      mdistDirectory = fmap getSymbolicPath $ flagToMaybe cleanDistDir
      mprojectDir = flagToMaybe flagProjectDir
      mprojectFile = flagToMaybe flagProjectFile

  -- TODO interpret extraArgs as targets and clean those targets only (issue #7506)
  --
  -- For now assume all files passed are the names of scripts
  notScripts <- filterM (fmap not . doesFileExist) extraArgs
  unless (null notScripts) $
    dieWithException verbosity $
      CleanAction notScripts

  projectRoot <- either throwIO return =<< findProjectRoot verbosity mprojectDir mprojectFile

  let distLayout = defaultDistDirLayout projectRoot mdistDirectory Nothing

  -- Do not clean a project if just running a script in it's directory
  when (null extraArgs || isJust mdistDirectory) $ do
    if saveConfig
      then do
        let buildRoot = distBuildRootDirectory distLayout

        buildRootExists <- doesDirectoryExist buildRoot

        when buildRootExists $ do
          info verbosity ("Deleting build root (" ++ buildRoot ++ ")")
          handleDoesNotExist () $ removeDirectoryRecursive buildRoot
      else do
        let distRoot = distDirectory distLayout

        info verbosity ("Deleting dist-newstyle (" ++ distRoot ++ ")")
        handleDoesNotExist () $ do
          if buildOS == Windows
            then do
              -- Windows can't delete some git files #10182
              void $
                Process.createProcess_ "attrib" $
                  Process.shell $
                    "attrib -s -h -r " <> distRoot <> "\\*.* /s /d"
              catch
                (removePathForcibly distRoot)
                (\e -> if isPermissionError e then removePathForcibly distRoot else throw e)
            else removeDirectoryRecursive distRoot

    removeEnvFiles $ distProjectRootDirectory distLayout

  -- Clean specified script build caches and orphaned caches.
  -- There is currently no good way to specify to only clean orphaned caches.
  -- It would be better as part of an explicit gc step (see issue #3333)
  toClean <- Set.fromList <$> mapM canonicalizePath extraArgs
  cacheDir <- defaultScriptBuildsDir
  existsCD <- doesDirectoryExist cacheDir
  caches <- if existsCD then listDirectory cacheDir else return []
  paths <- fmap concat . forM caches $ \cache -> do
    let locFile = cacheDir </> cache </> "scriptlocation"
    exists <- doesFileExist locFile
    if exists then pure . (,) (cacheDir </> cache) <$> readFile locFile else return []
  forM_ paths $ \(cache, script) -> do
    exists <- doesFileExist script
    when (not exists || script `Set.member` toClean) $ do
      info verbosity ("Deleting cache (" ++ cache ++ ") for script (" ++ script ++ ")")
      removeDirectoryRecursive cache

removeEnvFiles :: FilePath -> IO ()
removeEnvFiles dir =
  (traverse_ (removeFile . (dir </>)) . filter ((".ghc.environment" ==) . take 16))
    =<< getDirectoryContents dir
