{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE ViewPatterns #-}

module Distribution.Client.Nix
  ( findNixExpr
  , inNixShell
  , nixInstantiate
  , nixShell
  ) where

import Distribution.Client.Compat.Prelude

import Control.Exception (bracket)
import System.Directory
  ( canonicalizePath
  , createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  , removeDirectoryRecursive
  , removeFile
  )
import System.Environment (getArgs, getExecutablePath)
import System.FilePath
  ( replaceExtension
  , takeDirectory
  , takeFileName
  , (</>)
  )
import System.IO (IOMode (..), hClose, openFile)
import System.IO.Error (isDoesNotExistError)
import System.Process (showCommandForUser)

import Distribution.Compat.Environment
  ( lookupEnv
  , setEnv
  , unsetEnv
  )

import Distribution.Simple.Program
  ( Program (..)
  , ProgramDb
  , addKnownProgram
  , configureProgram
  , emptyProgramDb
  , getDbProgramOutput
  , runDbProgram
  , simpleProgram
  )
import Distribution.Simple.Setup (fromFlagOrDefault)
import Distribution.Simple.Utils (debug, existsAndIsMoreRecentThan, warn)

import Distribution.Client.Config (SavedConfig (..))
import Distribution.Client.GlobalFlags (GlobalFlags (..))

configureOneProgram :: Verbosity -> Program -> IO ProgramDb
configureOneProgram verb prog =
  configureProgram verb prog (addKnownProgram prog emptyProgramDb)

touchFile :: FilePath -> IO ()
touchFile path = do
  catch (removeFile path) (\e -> when (isDoesNotExistError e) (return ()))
  createDirectoryIfMissing True (takeDirectory path)
  openFile path WriteMode >>= hClose

findNixExpr :: GlobalFlags -> SavedConfig -> IO (Maybe FilePath)
findNixExpr globalFlags config = do
  -- criteria for deciding to run nix-shell
  let nixEnabled =
        fromFlagOrDefault
          False
          (globalNix (savedGlobalFlags config) <> globalNix globalFlags)

  if nixEnabled
    then do
      let exprPaths = ["shell.nix", "default.nix"]
      filterM doesFileExist exprPaths >>= \case
        [] -> return Nothing
        (path : _) -> return (Just path)
    else return Nothing

-- set IN_NIX_SHELL so that builtins.getEnv in Nix works as in nix-shell
inFakeNixShell :: IO a -> IO a
inFakeNixShell f =
  bracket (fakeEnv "IN_NIX_SHELL" "1") (resetEnv "IN_NIX_SHELL") (\_ -> f)
  where
    fakeEnv var new = do
      old <- lookupEnv var
      setEnv var new
      return old
    resetEnv var = maybe (unsetEnv var) (setEnv var)

nixInstantiate
  :: Verbosity
  -> FilePath
  -> Bool
  -> GlobalFlags
  -> SavedConfig
  -> IO ()
nixInstantiate verb dist force' globalFlags config =
  findNixExpr globalFlags config >>= \case
    Nothing -> return ()
    Just shellNix -> do
      alreadyInShell <- inNixShell
      shellDrv <- drvPath dist shellNix
      instantiated <- doesFileExist shellDrv
      -- an extra timestamp file is necessary because the derivation lives in
      -- the store so its mtime is always 1.
      let timestamp = timestampPath dist shellNix
      upToDate <- existsAndIsMoreRecentThan timestamp shellNix

      let ready = alreadyInShell || (instantiated && upToDate && not force')
      unless ready $ do
        let prog = simpleProgram "nix-instantiate"
        progdb <- configureOneProgram verb prog

        removeGCRoots verb dist
        touchFile timestamp

        _ <-
          inFakeNixShell
            ( getDbProgramOutput
                verb
                prog
                progdb
                ["--add-root", shellDrv, "--indirect", shellNix]
            )
        return ()

nixShell
  :: Verbosity
  -> FilePath
  -> GlobalFlags
  -> SavedConfig
  -> IO ()
  -- ^ The action to perform inside a nix-shell. This is also the action
  -- that will be performed immediately if Nix is disabled.
  -> IO ()
nixShell verb dist globalFlags config go = do
  alreadyInShell <- inNixShell

  if alreadyInShell
    then go
    else do
      findNixExpr globalFlags config >>= \case
        Nothing -> go
        Just shellNix -> do
          -- Nix integration never worked with cabal-install v2 commands ...
          warn verb "Nix integration has been deprecated and will be removed in a future release. You can learn more about it here: https://cabal.readthedocs.io/en/latest/nix-integration.html"

          let prog = simpleProgram "nix-shell"
          progdb <- configureOneProgram verb prog

          cabal <- getExecutablePath

          -- alreadyInShell == True in child process
          setEnv "CABAL_IN_NIX_SHELL" "1"

          -- Run cabal with the same arguments inside nix-shell.
          -- When the child process reaches the top of nixShell, it will
          -- detect that it is running inside the shell and fall back
          -- automatically.
          shellDrv <- drvPath dist shellNix
          args <- getArgs
          runDbProgram
            verb
            prog
            progdb
            [ "--add-root"
            , gcrootPath dist </> "result"
            , "--indirect"
            , shellDrv
            , "--run"
            , showCommandForUser cabal args
            ]

drvPath :: FilePath -> FilePath -> IO FilePath
drvPath dist path = do
  -- We do not actually care about canonicity, but makeAbsolute is only
  -- available in newer versions of directory.
  -- We expect the path to be a symlink if it exists, so we do not canonicalize
  -- the entire path because that would dereference the symlink.
  distNix <- canonicalizePath (dist </> "nix")
  -- Nix garbage collector roots must be absolute paths
  return (distNix </> replaceExtension (takeFileName path) "drv")

timestampPath :: FilePath -> FilePath -> FilePath
timestampPath dist path =
  dist </> "nix" </> replaceExtension (takeFileName path) "drv.timestamp"

gcrootPath :: FilePath -> FilePath
gcrootPath dist = dist </> "nix" </> "gcroots"

inNixShell :: IO Bool
inNixShell = isJust <$> lookupEnv "CABAL_IN_NIX_SHELL"

removeGCRoots :: Verbosity -> FilePath -> IO ()
removeGCRoots verb dist = do
  let tgt = gcrootPath dist
  exists <- doesDirectoryExist tgt
  when exists $ do
    debug verb ("removing Nix gcroots from " ++ tgt)
    removeDirectoryRecursive tgt
