-- | Effectful functions executing shared image respository operations.
--    See "B9.Repository"
module B9.RepositoryIO
  ( repoSearch, -- internal
    pushToRepo, -- internal
    pullFromRepo, -- internal
    withRemoteRepos,
    pullGlob,
    FilePathGlob (..),
    getSharedImages,
    getSharedImagesCacheDir,
    getSelectedRepos,
    pullRemoteRepos,
    pullLatestImage,
    cleanOldSharedImageRevisionsFromCache,
    cleanLocalRepoCache,
    pushToSelectedRepo,
    pushSharedImageLatestVersion,
    getLatestImageByName,
    -- initRemoteRepo,
    cleanRemoteRepo,
    remoteRepoCheckSshPrivKey,
  )
where

import B9.B9Config
import B9.B9Error
import B9.B9Exec
import B9.B9Logging
import B9.DiskImages
import B9.Repository
import Control.Eff
import Control.Eff.Reader.Lazy
import Control.Exception
import Control.Lens ((.~), (^.), view)
import Control.Monad (forM_, unless, when)
import Control.Monad.IO.Class
import Data.Foldable
import Data.List
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Maybe
import Data.Set (Set)
import qualified Data.Set as Set
import GHC.Stack
import System.Directory
import System.FilePath
import System.IO.B9Extras
import System.IO.B9Extras (SystemPath, consult, ensureDir, resolve)
import Text.Printf (printf)
import Text.Show.Pretty (ppShow)

-- | Initialize the local repository cache directory and the 'RemoteRepo's.
-- Run the given action with a 'B9Config' that contains the initialized
-- repositories in '_remoteRepos'.
--
-- @since 0.5.65
withRemoteRepos ::
  (Member B9ConfigReader e, Lifted IO e) =>
  Eff (RepoCacheReader ': e) a ->
  Eff e a
withRemoteRepos f = do
  cfg <- getB9Config
  repoCache <-
    lift
      (initRepoCache (fromMaybe defaultRepositoryCache (_repositoryCache cfg)))
  remoteRepos' <- Set.fromList <$> mapM (initRemoteRepo repoCache) (toList (_remoteRepos cfg))
  let setRemoteRepos = remoteRepos .~ remoteRepos'
  localB9Config setRemoteRepos (runReader repoCache f)

-- | Initialize the local repository cache directory.
initRepoCache :: MonadIO m => SystemPath -> m RepoCache
initRepoCache repoDirSystemPath = do
  repoDir <- resolve repoDirSystemPath
  ensureDir (repoDir ++ "/")
  return (RepoCache repoDir)

-- | Check for existance of priv-key and make it an absolute path.
remoteRepoCheckSshPrivKey :: MonadIO m => RemoteRepo -> m RemoteRepo
remoteRepoCheckSshPrivKey (RemoteRepo rId rp (SshPrivKey keyFile) h u) = do
  exists <- liftIO (doesFileExist keyFile)
  keyFile' <- liftIO (canonicalizePath keyFile)
  unless
    exists
    ( error
        (printf "SSH Key file '%s' for repository '%s' is missing." keyFile' rId)
    )
  return (RemoteRepo rId rp (SshPrivKey keyFile') h u)

-- | Initialize the repository; load the corresponding settings from the config
-- file, check that the priv key exists and create the correspondig cache
-- directory.
initRemoteRepo :: MonadIO m => RepoCache -> RemoteRepo -> m RemoteRepo
initRemoteRepo cache repo = do
  -- TODO logging traceL $ printf "Initializing remote repo: %s" (remoteRepoRepoId repo)
  repo' <- remoteRepoCheckSshPrivKey repo
  let (RemoteRepo repoId _ _ _ _) = repo'
  ensureDir (remoteRepoCacheDir cache repoId ++ "/")
  return repo'

-- | Empty the repository; load the corresponding settings from the config
-- file, check that the priv key exists and create the correspondig cache
-- directory.
cleanRemoteRepo :: MonadIO m => RepoCache -> RemoteRepo -> m ()
cleanRemoteRepo cache repo = do
  let repoId = remoteRepoRepoId repo
      repoDir = remoteRepoCacheDir cache repoId ++ "/"
  -- TODO logging infoL $ printf "Cleaning remote repo: %s" repoId
  ensureDir repoDir
  -- TODO logging traceL $ printf "Deleting directory: %s" repoDir
  liftIO $ removeDirectoryRecursive repoDir
  ensureDir repoDir

-- | Find files which are in 'subDir' and match 'glob' in the repository
-- cache. NOTE: This operates on the repository cache, but does not enforce a
-- repository cache update.
repoSearch ::
  forall e.
  (CommandIO e, Member RepoCacheReader e) =>
  FilePath ->
  FilePathGlob ->
  Eff e [(Repository, [FilePath])]
repoSearch subDir glob = (:) <$> localMatches <*> remoteRepoMatches
  where
    remoteRepoMatches =
      getRemoteRepos >>= mapM remoteRepoSearch . toList
    localMatches :: Eff e (Repository, [FilePath])
    localMatches = do
      cache <- getRepoCache
      let dir = localRepoDir cache </> subDir
      files <- findGlob dir
      return (Cache, files)
    remoteRepoSearch :: RemoteRepo -> Eff e (Repository, [FilePath])
    remoteRepoSearch repo = do
      cache <- getRepoCache
      let dir = remoteRepoCacheDir cache repoId </> subDir
          (RemoteRepo repoId _ _ _ _) = repo
      files <- findGlob dir
      return (Remote repoId, files)
    findGlob :: FilePath -> Eff e [FilePath]
    findGlob dir = do
      traceL (printf "reading contents of directory '%s'" dir)
      ensureDir (dir ++ "/")
      files <- liftIO (getDirectoryContents dir)
      return ((dir </>) <$> filter (matchGlob glob) files)

-- | Push a file from the cache to a remote repository
pushToRepo :: (Member ExcB9 e, CommandIO e) => RemoteRepo -> FilePath -> FilePath -> Eff e ()
pushToRepo repo@(RemoteRepo repoId _ _ _ _) src dest = do
  dbgL (printf "PUSHING '%s' TO REPO '%s'" (takeFileName src) repoId)
  cmd (repoEnsureDirCmd repo dest)
  cmd (pushCmd repo src dest)

-- | Pull a file from a remote repository to cache
pullFromRepo :: (Member ExcB9 e, CommandIO e) => RemoteRepo -> FilePath -> FilePath -> Eff e ()
pullFromRepo repo@(RemoteRepo repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) src dest =
  do
    dbgL (printf "PULLING '%s' FROM REPO '%s'" (takeFileName src) repoId)
    cmd
      ( printf
          "rsync -rtv -e 'ssh %s' '%s@%s:%s' '%s'"
          (sshOpts repo)
          user
          host
          (rootDir </> src)
          dest
      )

-- | Push a file from the cache to a remote repository
pullGlob ::
  (Member ExcB9 e, CommandIO e, Member RepoCacheReader e) =>
  FilePath ->
  FilePathGlob ->
  RemoteRepo ->
  Eff e ()
pullGlob subDir glob repo@(RemoteRepo repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) =
  do
    cache <- getRepoCache
    infoL (printf "SYNCING REPO METADATA '%s'" repoId)
    let c =
          printf
            "rsync -rtv --include '%s' --exclude '*.*' -e 'ssh %s' '%s@%s:%s/' '%s/'"
            (globToPattern glob)
            (sshOpts repo)
            user
            host
            (rootDir </> subDir)
            destDir
        destDir = repoCacheDir </> subDir
        repoCacheDir = remoteRepoCacheDir cache repoId
    ensureDir destDir
    cmd c

-- | Express a pattern for file paths, used when searching repositories.
newtype FilePathGlob
  = FileExtension String

-- * Internals

globToPattern :: FilePathGlob -> String
globToPattern (FileExtension ext) = "*." ++ ext

-- | A predicate that is satisfied if a file path matches a glob.
matchGlob :: FilePathGlob -> FilePath -> Bool
matchGlob (FileExtension ext) = isSuffixOf ("." ++ ext)

-- | A shell command string for invoking rsync to push a path to a remote host
-- via ssh.
pushCmd :: RemoteRepo -> FilePath -> FilePath -> String
pushCmd repo@(RemoteRepo _repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) src dest =
  printf
    "rsync -rtv --inplace --ignore-existing -e 'ssh %s' '%s' '%s'"
    (sshOpts repo)
    src
    sshDest
  where
    sshDest = printf "%s@%s:%s/%s" user host rootDir dest :: String

-- | A shell command string for invoking rsync to create the directories for a
-- file push.
repoEnsureDirCmd :: RemoteRepo -> FilePath -> String
repoEnsureDirCmd repo@(RemoteRepo _repoId rootDir _key (SshRemoteHost (host, _port)) (SshRemoteUser user)) dest =
  printf
    "ssh %s %s@%s mkdir -p '%s'"
    (sshOpts repo)
    user
    host
    (rootDir </> takeDirectory dest)

sshOpts :: RemoteRepo -> String
sshOpts (RemoteRepo _repoId _rootDir (SshPrivKey key) (SshRemoteHost (_host, port)) _user) =
  unwords
    [ "-o",
      "StrictHostKeyChecking=no",
      "-o",
      "UserKnownHostsFile=/dev/null",
      "-o",
      printf "Port=%i" port,
      "-o",
      "IdentityFile=" ++ key
    ]

-- | Return a list of all existing sharedImages from cached repositories.
getSharedImages ::
  (HasCallStack, CommandIO e, Lifted IO e, Member RepoCacheReader e) =>
  Eff e (Map Repository (Set SharedImage))
getSharedImages = do
  reposAndFiles <-
    repoSearch
      sharedImagesRootDirectory
      (FileExtension sharedImageFileExtension)
  foldrM
    ( \(repo, files) acc -> do
        imgs <- catMaybes <$> mapM consult' files
        let imgSet = Set.fromList imgs
        return (Map.insert repo imgSet acc)
    )
    Map.empty
    reposAndFiles
  where
    consult' f = do
      r <- liftIO (try (consult f))
      case r of
        Left (e :: SomeException) -> do
          dbgL
            ( printf
                "Failed to load shared image meta-data from '%s': '%s'"
                (takeFileName f)
                (show e)
            )
          dbgL (printf "Removing bad meta-data file '%s'" f)
          liftIO (removeFile f)
          return Nothing
        Right c -> return (Just c)

-- | Pull metadata files from all remote repositories.
pullRemoteRepos ::
  (HasCallStack, Member ExcB9 e, Lifted IO e, CommandIO e, '[SelectedRemoteRepoReader, RepoCacheReader] <:: e) =>
  Eff e ()
pullRemoteRepos = do
  repos <- getSelectedRepos
  mapM_ dl repos
  where
    dl =
      pullGlob
        sharedImagesRootDirectory
        (FileExtension sharedImageFileExtension)

-- | Pull the latest version of an image, either from the selected remote
-- repo or from the repo that has the latest version.
pullLatestImage ::
  (HasCallStack, Lifted IO e, CommandIO e, '[ExcB9, RepoCacheReader, SelectedRemoteRepoReader] <:: e) =>
  SharedImageName ->
  Eff e (Maybe SharedImageBuildId)
pullLatestImage name@(SharedImageName dbgName) = do
  repos <- getSelectedRepos
  let repoPredicate Cache = False
      repoPredicate (Remote repoId) = repoId `Set.member` repoIds
      repoIds = Set.map remoteRepoRepoId repos
      hasName sharedImage = name == sharedImageName sharedImage
  candidates <-
    filterRepoImagesMap repoPredicate hasName <$> getSharedImages
  case maxSharedImageOfAllRepos candidates of
    Nothing ->
      do
        errorL
          ( printf
              "No shared image named '%s' on these remote repositories: '%s'"
              dbgName
              (ppShow repoIds)
          )
        return Nothing
    Just (image, Cache) -> do
      errorExitL (printf "Unreachable code reached in `pullLastestImage`: '%s'  %s" dbgName (ppShow image))
    Just (image, Remote repoId) -> do
      dbgL (printf "PULLING SHARED IMAGE: '%s'" (ppShow image))
      cacheDir <- getSharedImagesCacheDir
      let (Image imgFile' _imgType _fs) = sharedImageImage image
          cachedImgFile = cacheDir </> imgFile'
          cachedInfoFile = cacheDir </> sharedImageFileName image
          repoImgFile = sharedImagesRootDirectory </> imgFile'
          repoInfoFile = sharedImagesRootDirectory </> sharedImageFileName image
          repo = fromJust (lookupRemoteRepo repos repoId)
      pullFromRepo repo repoImgFile cachedImgFile
      pullFromRepo repo repoInfoFile cachedInfoFile
      infoL (printf "PULLED '%s' FROM '%s'" dbgName repoId)
      cleanOldSharedImageRevisionsFromCache name
      return (Just (sharedImageBuildId image))

-- | Return the 'Image' of the latest version of a shared image named 'name'
-- from the local cache.
getLatestImageByName ::
  (HasCallStack, Lifted IO e, CommandIO e, Member RepoCacheReader e) =>
  SharedImageName ->
  Eff e (Maybe Image)
getLatestImageByName name = do
  sharedImageRevisions <- lookupCachedImages name <$> getSharedImages
  cacheDir <- getSharedImagesCacheDir
  let image =
        changeImageDirectory cacheDir . sharedImageImage
          <$> Set.lookupMax sharedImageRevisions
  case image of
    Just i -> dbgL (printf "USING SHARED SOURCE IMAGE '%s'" (show i))
    Nothing -> errorL (printf "SOURCE IMAGE '%s' NOT FOUND" (show name))
  return image

-- | Depending on the 'maxLocalSharedImageRevisions' 'B9Config' settings either
-- do nothing or delete all but the configured number of most recent shared
-- images with the given name from the local cache.
cleanOldSharedImageRevisionsFromCache ::
  ('[RepoCacheReader, ExcB9] <:: e, Lifted IO e, CommandIO e) =>
  SharedImageName ->
  Eff e ()
cleanOldSharedImageRevisionsFromCache sn = do
  b9Cfg <- getConfig
  forM_ (b9Cfg ^. maxLocalSharedImageRevisions) $ \maxRevisions -> do
    when
      (maxRevisions < 1)
      ( throwB9Error_
          ( printf
              "Invalid maximum local shared images revision configuration value: %d. Please change the [global] '%s' key in the B9 configuration file to 'Just x' with 'x > 0', or to 'Nothing'."
              maxRevisions
              maxLocalSharedImageRevisionsK
          )
      )
    allRevisions <- lookupCachedImages sn <$> getSharedImages
    let toDelete = dropAllButNLatestSharedImages maxRevisions allRevisions
    removeCachedSharedImages toDelete

-- | Clean all obsolete images in the local image cache.
--
-- @since 1.1.0
cleanLocalRepoCache ::
  ('[RepoCacheReader, ExcB9] <:: e, Lifted IO e, CommandIO e) =>
  Eff e ()
cleanLocalRepoCache = do
  allCached <- allCachedSharedImages <$> getSharedImages
  maxRevConfig <- view maxLocalSharedImageRevisions <$> getConfig
  let maxRev = maybe 0 (max 0) maxRevConfig
      byName = groupBySharedImageName allCached
      toKeep =
        fold
          ( Map.map
              (keepNLatestSharedImages maxRev)
              byName
          )
      toDelete =
        fold
          ( Map.map
              (dropAllButNLatestSharedImages maxRev)
              byName
          )
  infoL "ALL CACHED IMAGES:"
  forM_ allCached (infoL . show)
  infoL ("CACHED " ++ maybe "" (("(" ++) . (++ ") ") . show) maxRevConfig ++ "IMAGES TO KEEP:")
  forM_ toKeep (infoL . show)
  infoL "CACHED IMAGES TO DELETE:"
  forM_ toDelete (infoL . show)
  removeCachedSharedImages toDelete
  return ()

-- | Publish the latest version of a shared image identified by name to the
-- selected repository from the cache.
pushSharedImageLatestVersion ::
  (Lifted IO e, CommandIO e, '[SelectedRemoteRepoReader, RepoCacheReader, ExcB9] <:: e) =>
  SharedImageName ->
  Eff e ()
pushSharedImageLatestVersion name@(SharedImageName imgName) =
  lookupCachedImages name <$> getSharedImages
    >>= maybe
      (errorExitL (printf "Nothing found for %s." (show imgName)))
      ( \sharedImage -> do
          dbgL (printf "PUSHING '%s'" (ppShow sharedImage))
          pushToSelectedRepo sharedImage
          infoL (printf "PUSHED '%s'" imgName)
      )
      . Set.lookupMax

-- | Upload a shared image from the cache to a selected remote repository
pushToSelectedRepo ::
  (Member ExcB9 e, Lifted IO e, CommandIO e, '[RepoCacheReader, SelectedRemoteRepoReader] <:: e) =>
  SharedImage ->
  Eff e ()
pushToSelectedRepo i = do
  c <- getSharedImagesCacheDir
  MkSelectedRemoteRepo r <- getSelectedRemoteRepo
  when (isJust r) $ do
    let (Image imgFile' _imgType _imgFS) = sharedImageImage i
        cachedImgFile = c </> imgFile'
        cachedInfoFile = c </> sharedImageFileName i
        repoImgFile = sharedImagesRootDirectory </> imgFile'
        repoInfoFile = sharedImagesRootDirectory </> sharedImageFileName i
    pushToRepo (fromJust r) cachedImgFile repoImgFile
    pushToRepo (fromJust r) cachedInfoFile repoInfoFile

-- | Return either all remote repos or just the single selected repo.
getSelectedRepos :: '[B9ConfigReader, SelectedRemoteRepoReader] <:: e => Eff e (Set RemoteRepo)
getSelectedRepos = do
  allRepos <- getRemoteRepos
  MkSelectedRemoteRepo selectedRepo <- getSelectedRemoteRepo
  let repos = maybe allRepos Set.singleton selectedRepo -- 'Maybe' a repo
  return repos

-- | Return the path to the sub directory in the cache that contains files of
-- shared images.
getSharedImagesCacheDir :: '[RepoCacheReader] <:: e => Eff e FilePath
getSharedImagesCacheDir = do
  cacheDir <- localRepoDir <$> getRepoCache
  return (cacheDir </> sharedImagesRootDirectory)

removeCachedSharedImages :: (CommandIO e, Member (Reader RepoCache) e) => Set SharedImage -> Eff e ()
removeCachedSharedImages toDelete =
  do
    imgDir <- getSharedImagesCacheDir
    let filesToDelete = Set.map (imgDir </>) (infoFiles <> imgFiles)
        infoFiles = Set.map sharedImageFileName toDelete
        imgFiles = Set.map (imageFileName . sharedImageImage) toDelete
    if Set.null filesToDelete
      then infoL "NO IMAGES TO DELETE"
      else do
        infoL "DELETING FILES:"
        forM_ filesToDelete (infoL . show)
        liftIO (mapM_ removeIfExists filesToDelete)
