{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Confluence.Sync.SyncTool (
  sync
, ConfluenceConfig(..)
, TitleCaseConversion(..)
, confluenceXmlApi
) where
import Debug.Trace
import           Prelude hiding (readFile)

import           Control.Monad.Except
import           Control.Monad.Reader

import           Data.CaseInsensitive   (CI)
import qualified Data.CaseInsensitive as CI
import           Data.Char
import           Data.String.Utils
import           Data.List (break, intercalate, nubBy, find, foldl', sortOn, (\\))
import           Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import           Data.Maybe
import           Data.Set (Set)
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import           Data.Tree.Zipper
import           Data.Tuple (swap)
import qualified Data.ByteString as BS
import           Data.MIME.Types

import           System.FilePath

import           Text.Heredoc

import           Confluence.Sync.LocalSite
import           Confluence.Sync.Content
import           Confluence.Sync.ReferenceResolver
import           Confluence.Sync.PageNames

import qualified Confluence.Sync.XmlRpc.Api as Api
import           Confluence.Sync.XmlRpc.Types
import           Confluence.Sync.XmlRpc.Requests (ApiCall, runApiCall)
import           Confluence.Sync.Internal.RateLimiter

data ConfluenceConfig = ConfluenceConfig {
  user          :: String
, password      :: String
, confluenceUrl :: String
  -- Used as a prefix for all pages.
, syncTitle     :: String
  -- Id of the page to use as the sync base.
, syncSpaceKey  :: String
, syncPageId    :: Maybe String
, titleCaseConversion :: TitleCaseConversion
} deriving Show

-- Address of the XML-RPC Api root
confluenceXmlApi :: ConfluenceConfig -> String
confluenceXmlApi config = (confluenceUrl config) ++ "/rpc/xmlrpc"

createRootPage :: ConfluenceConfig -> ApiCall Page
createRootPage (ConfluenceConfig { syncSpaceKey, syncTitle }) = do
  let newRootPage = NewPage {
      newPageSpace        = syncSpaceKey
    , newPageParentId     = Nothing
    , newPageTitle        = syncTitle
    , newPageContent      = "Root page for synchronization (not initialized yet)"
    , newPagePermissions  = Nothing
  }
  liftIO . putStrLn $ "Creating new root page (\"" ++ syncTitle ++ "\") for synchronization (in space \"" ++ syncSpaceKey ++ "\")"
  newPage <- Api.createPage newRootPage
  liftIO . putStrLn $ "Root page (\"" ++ syncTitle ++ "\") successfully created (in space \"" ++ syncSpaceKey ++  "\"): page id = " ++ (show $ pageId newPage) ++ ", url = " ++ (show $ pageUrl newPage)
  return newPage

createOrFindPage :: ConfluenceConfig -> ApiCall Page
createOrFindPage (config@ConfluenceConfig { syncSpaceKey, syncTitle }) = do
  liftIO . putStrLn $ "No page id provided - looking up page \"" ++ syncTitle ++ "\" in \"" ++ syncSpaceKey ++ "\""
  findPage `catchError` (\_ -> createTheRootPage)
  where findPage = do
          page <- (Api.getPageByName syncSpaceKey syncTitle)
          liftIO . putStrLn $ "Found root page (by name lookup): page id = " ++ (show $ pageId page) ++ ", url = " ++ (show $ pageUrl page)
          return page
        createTheRootPage = do
          liftIO . putStrLn $ "Could not find page with name \"" ++ syncTitle ++ "\" (in space \"" ++ syncSpaceKey ++ "\"). Creating new page..."
          createRootPage config

-------------------------------------------------------------------------------
-- Meta Pages
-------------------------------------------------------------------------------

-- | True if the page is a meta page.
isMetaPage :: ConfluenceConfig -> String -> Bool
isMetaPage config other = other == (metaPageName config) || other == (metaTrashPageName config)

-- | Creates or updates all of the meta pages as necessary.
createMetaPages :: ConfluenceConfig -> Page -> ApiCall (Page, Page)
createMetaPages config rootPage = do
  metaPage  <- createOrFindMetaPage config rootPage
  trashPage <- createOrFindMetaTrashPage config metaPage
  return (metaPage, trashPage)

metaPageName :: ConfluenceConfig -> String
metaPageName (ConfluenceConfig { syncTitle }) = "Meta (" ++ syncTitle ++ ")"

createOrFindMetaPage :: ConfluenceConfig -> Page -> ApiCall Page
createOrFindMetaPage (config@ConfluenceConfig { syncSpaceKey }) (rootPage@Page { pageId = rootPageId, pageTitle = rootPageTitle }) = do
  liftIO . putStrLn $ "No page id provided - looking up page \"" ++ pageTitle ++ "\" in \"" ++ syncSpaceKey ++ "\""
  page <- findPage `catchError` (\_ -> createMetaPage)
  -- If the pages are not in the correct location - we require the user to manually move them.
  -- It means that someone has come in manually and moved them. The user should verify what has happened
  -- (we don't want to cause data loss or break something).
  if ((pageParentId page) /= rootPageId)
    then throwError $ "Metadata page (id = " ++ (pageId page) ++ ", name =\"" ++ pageTitle ++ "\") is not under the root page (id = " ++ rootPageId ++ ",name = \"" ++ rootPageTitle ++ "\"). Please move it to the correct location."
    else return ()
  return page
  where pageTitle = metaPageName config
        findPage = do
          page <- (Api.getPageByName syncSpaceKey pageTitle)
          liftIO . putStrLn $ "Found meta page (by name lookup): page id = " ++ (show $ pageId page) ++ ", url = " ++ (show $ pageUrl page)
          return page
        createMetaPage = do
          liftIO . putStrLn $ "Could not find page with name \"" ++ pageTitle ++ "\" (in space \"" ++ syncSpaceKey ++ "\"). Creating new page..."
          let newMetaPage = NewPage {
              newPageSpace        = syncSpaceKey
            , newPageParentId     = Just rootPageId
            , newPageTitle        = pageTitle
            , newPageContent      = "This page is the parent for all synchronization support pages (e.g. Trash)."
            , newPagePermissions  = Nothing
          }
          liftIO . putStrLn $ "Creating new meta page (\"" ++ pageTitle ++ "\") for synchronization (in space \"" ++ syncSpaceKey ++ "\")"
          newPage <- Api.createPage newMetaPage
          liftIO . putStrLn $ "Meta page (\"" ++ pageTitle ++ "\") successfully created (in space \"" ++ syncSpaceKey ++  "\"): page id = " ++ (show $ pageId newPage) ++ ", url = " ++ (show $ pageUrl newPage)
          return newPage

metaTrashPageName :: ConfluenceConfig -> String
metaTrashPageName (ConfluenceConfig { syncTitle }) = "Meta / Trash (" ++ syncTitle ++ ")"

createOrFindMetaTrashPage :: ConfluenceConfig -> Page -> ApiCall Page
createOrFindMetaTrashPage (config@ConfluenceConfig { syncSpaceKey }) (metaPage@Page { pageId = metaPageId, pageTitle = metaPageTitle }) = do
  liftIO . putStrLn $ "No page id provided - looking up page \"" ++ pageTitle ++ "\" in \"" ++ syncSpaceKey ++ "\""
  page <- findPage `catchError` (\_ -> createTrashPage)
  -- If the pages are not in the correct location - we require the user to manually move them.
  -- It means that someone has come in manually and moved them. The user should verify what has happened
  -- (we don't want to cause data loss or break something).
  if ((pageParentId page) /= metaPageId)
    then throwError $ "Trash page (id = " ++ (pageId page) ++ ", name =\"" ++ pageTitle ++ "\") is not under the meta page (id = " ++ metaPageId ++ ",name = \"" ++ metaPageTitle ++ "\"). Please move it to the correct location."
    else return ()
  return page
  where pageTitle = metaTrashPageName config
        findPage = do
          page <- (Api.getPageByName syncSpaceKey pageTitle)
          liftIO . putStrLn $ "Found trash page (by name lookup): page id = " ++ (show $ pageId page) ++ ", url = " ++ (show $ pageUrl page)
          return page
        createTrashPage = do
          liftIO . putStrLn $ "Could not find page with name \"" ++ pageTitle ++ "\" (in space \"" ++ syncSpaceKey ++ "\"). Creating new page..."
          let newMetaPage = NewPage {
              newPageSpace        = syncSpaceKey
            , newPageParentId     = Just metaPageId
            , newPageTitle        = pageTitle
            , newPageContent      = "All pages that have been deleted are moved to under this page (to hide them)."
            , newPagePermissions  = Nothing
          }
          liftIO . putStrLn $ "Creating new trash page (\"" ++ pageTitle ++ "\") for synchronization (in space \"" ++ syncSpaceKey ++ "\")"
          newPage <- Api.createPage newMetaPage
          liftIO . putStrLn $ "Trash page (\"" ++ pageTitle ++ "\") successfully created (in space \"" ++ syncSpaceKey ++  "\"): page id = " ++ (show $ pageId newPage) ++ ", url = " ++ (show $ pageUrl newPage)
          return newPage

-------------------------------------------------------------------------------
-- Content manipulation.
-------------------------------------------------------------------------------

-- | Creates placeholder pages for all new pages to be created.
--   These pages will be subsequently updated below.
createContentPage :: Page -> String -> ApiCall Page
createContentPage (Page { pageId = rootPageId, pageSpace }) title = do
  let newPage = NewPage {
      newPageSpace        = pageSpace
    , newPageParentId     = Just rootPageId
    , newPageTitle        = title
    , newPageContent      = newPagePlaceholderMessage
    , newPagePermissions  = Nothing
  }
  liftIO . putStrLn $ "Synchronizing - creating page \"" ++ title ++ "\""
  page <- Api.createPage newPage
  liftIO . putStrLn $ "Synchronizing - created page \"" ++ title ++ "\" (\"" ++ (pageUrl page) ++ "\")"
  return page

updateContentPage :: ConfluenceConfig -> (Map String String) -> ConfluenceZipper -> ApiCall Page
updateContentPage config renames zipper = do
  let pageSummary   = remotePage . label $ zipper
      pageId        = pageSummaryId pageSummary
      originalTitle = pageSummaryTitle pageSummary
      title         = Map.findWithDefault originalTitle originalTitle renames
      fullPath      = maybe "<root>" id $ (filePath . label) <$> (pageSource . pagePosition . label $ zipper)
      parentPageId  = maybe (pageSummaryParentId pageSummary) id ((pageSummaryId . remotePage . label) <$> (parent zipper))
  liftIO . putStrLn $ "Synchronizing - updating page \"" ++ title ++ "\" (\"" ++ fullPath ++ "\")"
  if (title /= originalTitle)
    then liftIO . putStrLn $ "Renaming page from \"" ++ originalTitle ++ "\" to \"" ++ title ++ "\""
    else return ()
  page              <- Api.getPage pageId
  localAttachments  <- liftIO $ getAttachments zipper
  remoteAttachments <- Api.getAttachments pageId
  attachmentMapping <- syncAttachments pageId localAttachments remoteAttachments
  localContents <- liftIO $ getPageContents zipper attachmentMapping
  let updatedPage = page { pageTitle = title, pageContent = localContents, pageParentId = parentPageId}
  if (page == updatedPage)
   then liftIO . putStrLn $ "Synchronizing - page \"" ++ title ++ "\" is identical to the local copy."
   else do
      liftIO . putStrLn $ "Synchronizing - page \"" ++ title ++ "\" is different and requires updating."
      Api.storePage updatedPage
      liftIO . putStrLn $ "Synchronizing - page \"" ++ title ++ "\" update successfully: " ++ (pageUrl page)
  return page

syncAttachments :: String -> [ LocalAttachment ] -> [ Attachment ] -> ApiCall [ (LocalAttachment, Attachment) ]
syncAttachments pageId localAttachments remoteAttachments = do
  let localRemoteNames = map attachmentRemoteName localAttachments
  let localRemotesNamesWithLocals = zip localRemoteNames localAttachments
  let nameToLocal = Map.fromList $ localRemotesNamesWithLocals
  let nameToRemote = Map.fromList $ zip (fmap attachmentFileName remoteAttachments) remoteAttachments

  let localSet = Set.fromList $ localRemoteNames
  let remoteSet = Set.fromList $ (fmap attachmentFileName remoteAttachments)
  let completeSet = localSet `Set.union` remoteSet

  let toRemove  = completeSet `Set.difference` localSet
  let toAdd     = completeSet `Set.difference` remoteSet
  let remaining = localSet `Set.intersection` remoteSet

  -- Log the attachment actions to take.
  liftIO $ putStrLn $ "Attachments to create: " ++ (if (Set.null toAdd) then "none." else "")
  liftIO . sequence $ map (\t -> putStrLn $ "  - " ++ show t) (Set.toList toAdd)
  liftIO $ putStrLn ""
  liftIO $ putStrLn $ "Attachments to delete: " ++ (if (Set.null toRemove) then "none." else "")
  liftIO . sequence $ map (\t -> putStrLn $ "  - " ++ show t) (Set.toList toRemove)
  liftIO $ putStrLn ""

  -- Actual perform the actions.
  forM (Set.toList toRemove) $ (\attachmentName -> do
    let attachment = nameToRemote Map.! attachmentName
    liftIO $ putStrLn $ "Removing attachment " ++ attachmentName
    (do
      Api.removeAttachment (attachmentPageId attachment) attachmentName
      liftIO $ putStrLn $ "Attachment removed: " ++ attachmentName
      ) `catchError` (\_ ->
        liftIO $ putStrLn $ "WARN: failed to remove attachment " ++ attachmentName ++ " from page (id = " ++ pageId ++ ")"
      )
    )

  added <- forM (Set.toList toAdd) $ (\attachmentName -> do
    let local = nameToLocal Map.! attachmentName
    liftIO $ putStrLn $ "Adding attachment " ++ attachmentName
    contents <- liftIO $ BS.readFile (filePath . label . resolvedAttachment $  local)
    let (mimeTypeGuess, encodingGuess) = guessType defaultmtd False attachmentName
    let contentType = maybe "application/unknown" id mimeTypeGuess
    remote <- Api.addAttachment pageId (NewAttachment attachmentName contentType) contents
    liftIO $ putStrLn $ "Attachment added: " ++ attachmentName
    return $ (local, remote)
    )

  -- Now we want to create a list of local files mapped to remote attachments.
  -- NB. the list of local files is more like a list of references (and thus there may be duplicates).
  let addedNameToRemote = Map.fromList $ (fmap (\a -> ((attachmentFileName a), a)) (fmap snd added))
  let allRemote = addedNameToRemote `Map.union` nameToRemote

  forM localRemotesNamesWithLocals $ (\(remoteName, local) -> do
      return $ (local, allRemote Map.! remoteName)
    )

trashContentPage :: Page -> PageSummary -> ApiCall Page
trashContentPage (trashPage@Page { pageId = trashPageId }) (PageSummary { pageSummaryId = pageId, pageSummaryTitle = title }) = do
  liftIO . putStrLn $ "Synchronizing - trashing page \"" ++ title ++ "\""
  page <- Api.getPage pageId
  Api.storePage page { pageContent = deletedPageMessage, pageParentId = trashPageId}
  liftIO . putStrLn $ "Synchronizing - page \"" ++ title ++ "\" has been moved to the trash successfully: " ++ (pageUrl page)
  return page

-------------------------------------------------------------------------------
-- Sync Messages
-------------------------------------------------------------------------------

newPagePlaceholderMessage = [here|
<ac:structured-macro ac:name="info">
  <ac:rich-text-body>
    <p>This page is currently being synchronized - please check back again soon.</p>
  </ac:rich-text-body>
</ac:structured-macro>
|]

deletedPageMessage = [here|
<ac:structured-macro ac:name="info">
  <ac:rich-text-body>
    <p>This page has been deleted and is no longer available.</p>
  </ac:rich-text-body>
</ac:structured-macro>
|]

-------------------------------------------------------------------------------
-- Sync implementation.
-------------------------------------------------------------------------------

type LocalPageTitle = String
type RemotePageTitle = String

sync :: Throttle -> ConfluenceConfig -> FilePath -> IO ()
sync throttle config path = do
  siteTree       <- buildSiteTree path
  let pageTree    = buildPageTree siteTree
  let pageZipper  = fromTree pageTree
  let localPages :: [PageZipper]
      localPages  = filter (notShadowReference . label) (traverseZipper pageZipper)
  -- Get all the potential page titles.
  let potentialPageTitles :: Set String
      potentialPageTitles = Set.fromList $ localPages >>= (potentialPageNames (titleCaseConversion config) (syncTitle config))

  -- Get all the pages with their potential titles.
  let localPagesWithPotentialTitles :: [(PageZipper, [String])]
      localPagesWithPotentialTitles = zip localPages ((potentialPageNames (titleCaseConversion config) (syncTitle config)) `fmap` localPages)

  -- Deduplicates potential page titles within the site.
  -- Note page titles are deduplicated on a case-insensitive basis.
  let flattenedPotentialPagesWithTitles :: [(PageZipper, CI String)]
      flattenedPotentialPagesWithTitles = localPagesWithPotentialTitles >>= (\(page, titles) -> (\title -> (page, CI.mk title)) `fmap` titles)

  let localPagesWithUniqueTitles :: [(PageZipper, CI String)]
      localPagesWithUniqueTitles = nubBy (\a b -> (snd $ a) == (snd $ b)) flattenedPotentialPagesWithTitles

  -- Map from page to titles (sorted from most human friendly to most unique).
  let localPagesWithTitles :: Map PageZipper [CI String]
      localPagesWithTitles = (sortOn $ length . CI.original) `Map.map` (foldl' (\m (page, title) -> Map.insertWithKey (\_ a b -> b ++ a) page [title] m) Map.empty localPagesWithUniqueTitles)

  -- Map from title to the corresponding page.
  let titleToLocalPage :: Map (CI String) PageZipper
      titleToLocalPage = Map.fromList (swap `fmap` localPagesWithUniqueTitles)

  putStrLn "Logging into Confluence"
  token <- Api.login (confluenceXmlApi config) (user config) (password config)
  putStrLn $ "Successfully logged into Confluence: token = " ++ token

  result <- runApiCall throttle (confluenceXmlApi config) token $ do
      rootPage <- case (syncPageId config) of
                    Just pageId -> Api.getPage pageId
                    Nothing     -> createOrFindPage config
      -- Create/find the meta pages.
      (metaPage, trashPage) <- createMetaPages config rootPage

      -- Now we have the root page get the descendants.
      descendants <- Api.getDescendents (pageId rootPage)
      let remotePages = filter (\p -> not $ (isMetaPage config) (pageSummaryTitle p)) descendants
      let remoteTitles = Set.fromList $ (syncTitle config) : (map pageSummaryTitle remotePages)

      -- Match each remote page with a potential local page.
      -- This allows us to do renames without creating/deleting excess pages.
      -- This match is done in a case-insensitive manner.
      let matchedPages :: [(PageZipper, RemotePageTitle, LocalPageTitle)]
          matchedPages = catMaybes $ lookupLocalPageFromTitle `fmap` (Set.toList remoteTitles)
            where lookupLocalPageFromTitle :: String -> Maybe (PageZipper, String, String)
                  lookupLocalPageFromTitle = ((\t -> (\p -> (p, t, (getLocalTitle p t))) `fmap` (Map.lookup (CI.mk t) titleToLocalPage)))
                  getLocalTitle :: PageZipper -> String -> String
                  getLocalTitle pageZipper remoteTitle = CI.original . fromJust $ find (== (CI.mk remoteTitle)) (localPagesWithTitles Map.! pageZipper)

      -- Calculate the possible renames.
      let possiblePageRenames :: [(String, [String])]
          possiblePageRenames = hasBetterAlternativePageNames `filter` pagesWithBetterTitles
            where pagesWithBetterTitles = ((\(p, t, _) -> (t, extractBetterTitles p t)) `fmap` matchedPages)
                  extractBetterTitles :: PageZipper -> String -> [String]
                  extractBetterTitles page currentTitle = CI.original <$> (fst $ break (== (CI.mk currentTitle)) (titlesForPage page))
                  titlesForPage :: PageZipper -> [CI String]
                  titlesForPage page = localPagesWithTitles Map.! page
                  hasBetterAlternativePageNames :: (String, [String]) -> Bool
                  hasBetterAlternativePageNames (current, others) = not . null $ others

      -- Pages to rename.
      -- A page rename can be done where the page doesn't already exist.
      -- For each possible rename we need to lookup the page by name to see if it exists.
      renames <- catMaybes <$> (forM possiblePageRenames $ (\(originalName, others) -> do
          -- A name is optimal if it doesn't already exist.
          -- The list of other names is sorted by order of human-friendliness.
          optimalName <- findM (\other -> ((\_ -> Nothing) `fmap` (Api.getPageByName (syncSpaceKey config) other)) `catchError` (\_ -> return $ Just other)) others
          return $ (\optimal -> (originalName, optimal)) `fmap` optimalName
        ))

      -- Pages which we need to rename where the only difference is the case.
      let extractLocalName (pageZipper, remote, local) = local
      let differenceInCaseRenames :: [(RemotePageTitle, LocalPageTitle)]
          differenceInCaseRenames = pairNotEqual `filter` (extractRemoteAndLocal <$> matchedPages)
            where extractRemoteAndLocal (_, remote, local) = (remote, local)
                  pairNotEqual (a, b) = a /= b

      -- Generate the combined map of renames
      let renameMap = Map.fromList (renames ++ differenceInCaseRenames)

      -- Pages to create.
      -- Where we are creating a new page, we need to find the most optimal title for it.
      let pagesToCreate = (\\) (Map.keys localPagesWithTitles) ((\(a, _, _) -> a) `fmap` matchedPages)
      titlesToCreate <- (Set.fromList . catMaybes) <$> (forM pagesToCreate $ (\page -> do
          let possibleTitles = CI.original <$> (localPagesWithTitles Map.! page)
          -- A name is optimal if it doesn't already exist.
          -- The list of other names is sorted by order of human-friendliness.
          optimalName <- findM (\title -> ((\_ -> Nothing) `fmap` (Api.getPageByName (syncSpaceKey config) title)) `catchError` (\_ -> return $ Just title)) possibleTitles
          return optimalName
        ))

      let titlesAlreadyDeleted = Set.fromList $ map pageSummaryTitle (filter (\p -> (pageSummaryParentId p) == (pageId trashPage)) remotePages)

      -- Calculate the page-level actions to take.
      let titlesToUpdate = titlesToCreate `Set.union` (Set.fromList (snd `fmap` renames)) `Set.union` (Set.fromList (extractLocalName `fmap` matchedPages))

      -- The titles to remove are all remote pages which we are not going to update, and which are not being renamed, or already deleted.
      let titlesToRemove = (remoteTitles `Set.difference` titlesToUpdate) `Set.difference` (Map.keysSet renameMap) `Set.difference` titlesAlreadyDeleted

      -- Log the page-level actions that will be taken.
      liftIO $ putStrLn $ "Pages to create: " ++ (if (Set.null titlesToCreate) then "none." else "")
      liftIO . sequence $ map (\t -> putStrLn $ "  - " ++ show t) (Set.toList titlesToCreate)
      liftIO $ putStrLn ""
      liftIO $ putStrLn $ "Pages to rename: " ++ (if (Map.null renameMap) then "none." else "")
      liftIO . sequence $ map (\t -> putStrLn $ "  - " ++ t) ((\(original, updated) -> original ++ " -> " ++ updated) `fmap` (Map.toList renameMap))
      liftIO $ putStrLn ""
      liftIO $ putStrLn $ "Pages to update: " ++ (if (Set.null titlesToUpdate) then "none." else "")
      liftIO . sequence $ map (\t -> putStrLn $ "  - " ++ show t) (Set.toList titlesToUpdate)
      liftIO $ putStrLn ""
      liftIO $ putStrLn $ "Pages to delete: " ++ (if (Set.null titlesToRemove) then "none." else "")
      liftIO . sequence $ map (\t -> putStrLn $ "  - " ++ show t) (Set.toList titlesToRemove)
      liftIO $ putStrLn ""

      -- Create all the placeholder pages first.
      createdPages <- sequence $ map (\title -> createContentPage rootPage title) (Set.toList titlesToCreate)
      let createdPageSummaries = pageSummaryFromPage <$> createdPages

      -- Create a list of page name to actual created page mappings.
      -- This is needed to map relative URLs/links between pages to absolute URLs.
      --
      -- This is the complete list of all possible pages (this is used for updating/creating links between pages).
      -- The root page is added in here because it will have the same name as the root directory page.
      let allRemotePages = remotePages ++ createdPageSummaries ++ [ (pageSummaryFromPage rootPage) ]
      let allRemotePagesMap :: Map String PageSummary
          allRemotePagesMap = Map.fromList $ zip (map pageSummaryTitle allRemotePages) allRemotePages
      -- Filter out remote pages that don't have a corresponding local page.
      -- (otherwise it breaks the mapKeys below because we can't find a local page for the given title)
      let remotePagesWithLocalPage = (Map.filterWithKey (\k _ -> Map.member (CI.mk k) titleToLocalPage) allRemotePagesMap)
      let localToRemoteMap :: Map PageZipper PageSummary
          localToRemoteMap = (\title -> titleToLocalPage Map.! (CI.mk title)) `Map.mapKeys` remotePagesWithLocalPage
      let localToRemoteLookup :: PageZipper -> PageSummary
          localToRemoteLookup local = localToRemoteMap Map.! local

      -- Create the tree of pages to sync (and the zipper on that tree).
      let syncTree = pageTreeToSyncTree pageZipper localToRemoteLookup
      let syncZipper = fromTree syncTree

      -- Update the content of all pages (including renaming where appropriate).
      let pagesToUpdate = filter (notShadowReference . pagePosition . label) (traverseZipper syncZipper)
      sequence $ map (updateContentPage config renameMap) pagesToUpdate

      -- Delete any old pages.
      sequence $ map (\title -> trashContentPage trashPage (allRemotePagesMap Map.! title)) (Set.toList titlesToRemove)

      ---
      liftIO $ putStrLn "Logging out of Confluence."
      Api.logout
      liftIO $ putStrLn "Logged out successfully."
  either (\err -> fail $ "ERROR: " ++ err) (\_ -> return ()) result

findM :: Monad m => (a -> m (Maybe b)) -> [a] -> m (Maybe b)
findM _ [] = return Nothing
findM f (x:xs) =
  (f x) >>= recurse
  where recurse (Just value) = return $ Just value
        recurse Nothing = findM f xs
