{-# LANGUAGE ScopedTypeVariables #-}
module Debian.Repo.Repository
    ( UploadFile(..)
    , prepareRepository
    , repoArchList
    , readPkgVersion
    , showPkgVersion
    , invalidRevision
    , verifyUploadURI
    , uploadRemote
    ) where

import Control.Exception (Exception(..))
import Control.Monad.Trans
import Control.Monad.State (get, put)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as L
import Data.List
import Data.Maybe
import qualified Data.Set as Set
import qualified Debian.Control.ByteString as B	-- required despite warning
import qualified Debian.Control.String as S
import Debian.Extra.CIO (tMessage, printOutput)
import Debian.Repo.Changes
import Debian.Repo.IO
import Debian.Repo.LocalRepository
import Debian.Repo.Types
import Debian.Shell
import Debian.URI
import Debian.Version
import Extra.Bool
import Extra.Either
import Extra.Files
import Extra.List
import Extra.Net
import Extra.SSH
import Extra.CIO
import System.FilePath
import System.Unix.Process
import System.Cmd
import System.Directory
import qualified System.IO as IO
import System.IO.Unsafe
import System.Time
import Text.Regex

-- |The file produced by dupload when a package upload attempt is made.
data UploadFile = Upload FilePath String DebianVersion Arch

-- |This is a remote repository which we have queried to find out the
-- names, sections, and supported architectures of its releases.
--data VerifiedRepo = VerifiedRepo URI [ReleaseInfo]

{- instance Show VerifiedRepo where
    show (VerifiedRepo uri _) = "Verified Repository " ++ show uri -- ++ " " ++ show dists
instance Ord VerifiedRepo where
    compare a b = compare (repoURI a) (repoURI b)
instance Eq VerifiedRepo where
    a == b = compare a b == EQ -}

-- |This is a repository whose structure we haven't examined 
-- to determine what release it contains.
--data UnverifiedRepo = UnverifiedRepo URI

{- instance Show UnverifiedRepo where
    show (UnverifiedRepo uri) = "Unverified Repository " ++ show uri -- ++ " (unverified)"
instance Ord UnverifiedRepo where
    compare a b = compare (repoURI a) (repoURI b)
instance Eq UnverifiedRepo where
    a == b = compare a b == EQ -}

-- | Prepare a repository, which may be remote or local depending on
-- the URI.
prepareRepository :: CIO m => URI -> AptIOT m Repository
prepareRepository uri =
    do state <- get
       repo <- maybe newRepo return (lookupRepository uri state)
       put (insertRepository uri repo state)
       return repo
    where
      newRepo =
             case uriScheme uri of
               "file:" -> prepareLocalRepository (EnvPath (EnvRoot "") (uriPath uri)) Nothing >>= return . LocalRepo
               -- FIXME: We only want to verifyRepository on demand.
               -- Perhaps we want to use System.IO.Unsafe.unsafeInterleaveIO?
               _ -> verifyRepository (UnverifiedRepo (show uri))
               -- _ -> return . Repository . UnverifiedRepo $ uri

{-# NOINLINE verifyRepository #-}
verifyRepository :: CIO m => Repository -> AptIOT m Repository
verifyRepository (UnverifiedRepo uri) =
    do --tio (vHPutStrBl IO.stderr 0 $ "Verifying repository " ++ show uri ++ "...")
       -- Use unsafeInterleaveIO to avoid querying the repository
       -- until the value is actually needed.
       lift (vPutStrBl 2 ("verifyRepository " ++ uri))
       releaseInfo <- do lift (vPutChar 2 '*')
                         liftIO . unsafeInterleaveIO . getReleaseInfoRemote . fromJust . parseURI $ uri
       {- tio (vHPutStrLn IO.stderr 0 $ "\n" {- -> VerifiedRepo " ++ show uri ++ " " ++ show releaseInfo -} ) -}
       return $ VerifiedRepo uri releaseInfo
verifyRepository x = return x

-- |Get the list of releases of a remote repository.
getReleaseInfoRemote :: URI -> IO [ReleaseInfo]
getReleaseInfoRemote uri =
    IO.hPutStr IO.stderr ("(verifying " ++ uriToString' uri ++ ".") >>
    dirFromURI distsURI >>=
    either (error . show) verify >>= return . catMaybes >>= 
    (\ result -> IO.hPutStr IO.stderr ")" >> return result)
    where
      distsURI = uri {uriPath = uriPath uri </> "dists/"}
      verify names =
          do let dists = map parseReleaseName names
             releaseFiles <- mapM getReleaseFile dists
             let releasePairs = zip3 (map getSuite releaseFiles) releaseFiles dists
             return $ map (uncurry3 getReleaseInfo) releasePairs
      releaseNameField releaseFile = case fmap B.unpack (B.fieldValue "Origin" releaseFile) of Just "Debian" -> "Codename"; _ -> "Suite"
      getReleaseInfo :: Maybe B.ByteString -> B.Paragraph -> ReleaseName -> Maybe ReleaseInfo
      getReleaseInfo Nothing _ _ = Nothing
      getReleaseInfo (Just dist) _ relname | (parseReleaseName (B.unpack dist)) /= relname = Nothing
      getReleaseInfo (Just dist) info _ = Just $ makeReleaseInfo "" info (parseReleaseName (B.unpack dist)) []
      getSuite releaseFile = B.fieldValue (releaseNameField releaseFile) releaseFile
      getReleaseFile :: ReleaseName -> IO (S.Paragraph' B.ByteString)
      getReleaseFile distName =
          do IO.hPutChar IO.stderr '.'
             release <- fileFromURI releaseURI >>= return . either Left (Right . B.concat . L.toChunks)
             let control = either Left (either (Left . ErrorCall . show) Right . B.parseControl (show uri)) release
             case control of
               Right (B.Control [info]) -> return info
               _ -> error ("Failed to get release info from " ++ show releaseURI)
          where
            releaseURI = distURI {uriPath = uriPath distURI </> "Release"}
            distURI = distsURI {uriPath = uriPath distsURI </> releaseName' distName}
      uncurry3 :: (a -> b -> c -> d) -> (a, b, c) -> d
      uncurry3 f (a, b, c) =  f a b c

-- |Make sure we can access the upload uri without typing a password.
verifyUploadURI :: CIO m => Bool -> URI -> AptIOT m ()
verifyUploadURI doExport uri =
    case doExport of
      True -> export
      False -> verify
    where
      export =
          do liftIO $ uncurry sshExport (uriDest uri)
             verify
             mkdir
      verify =
          do result <- liftIO $ uncurry sshVerify (uriDest uri)
             case result of
               False -> error $ "Unable to reach " ++ uriToString' uri ++ ", consider using --ssh-export"
               True -> return ()
             mkdir
      uriDest uri =
          let auth = maybe (error "Internal error 8") id (uriAuthority uri) in
          let port =
                  case uriPort auth of
                    (':' : number) -> Just (read number)
                    "" -> Nothing
                    x -> error $ "Internal error 9: invalid port " ++ x in
          (uriUserInfo auth ++ uriRegName auth, port)
      mkdir :: CIO m => AptIOT m ()
      mkdir =
          case uriAuthority uri of
            Nothing -> error $ "Internal error 7"
            Just auth ->
                do let cmd = "ssh " ++ uriUserInfo auth ++ uriRegName auth ++ uriPort auth  ++ " mkdir -p " ++ uriPath uri ++ "/incoming"
                   result <- liftIO $ system cmd
                   case result of
                     ExitSuccess -> return ()
                     _ -> error $ "Failure: " ++ cmd

-- | Upload all the packages in a local repository to a the incoming
-- directory of a remote repository (using dupload.)
uploadRemote :: CIO m
	     => LocalRepository		-- ^ Local repository holding the packages.
             -> URI			-- ^ URI of upload repository
             -> AptIOT m [Either String ([Output], TimeDiff)]
uploadRemote repo uri =
    do uploaded <- liftIO (uploadFind (outsidePath root)) >>=
                   return . Set.fromList . map uploadKey . rightOnly
       (accepted, rejected) <- liftIO (findChangesFiles (outsidePath root)) >>= return . (\x -> (x, [])) >>=
                               return . accept (notUploaded uploaded) (\ x -> (x, "Already uploaded")) >>=
                               return . rejectOlder >>=
                               acceptM (liftIO . validRevision) (\ x -> (x, "Invalid revision"))
       case rejected of
         [] -> return ()
         _ -> lift (vPutStr 0 ("Rejected:\n  " ++ consperse "\n  " (map showReject rejected) ++ "\n"))
       case accepted of
         [] -> do lift (vPutStr 0 "Nothing to upload."); return []
         _ -> do mapM (lift . dupload uri (outsidePath root)) (map Debian.Repo.Changes.path accepted)
    where
      root = repoRoot repo
      rejectOlder :: ([ChangesFile], [(ChangesFile, String)]) ->  ([ChangesFile], [(ChangesFile, String)])
      rejectOlder (accept, reject) =
          (accept', (map tag reject' ++ reject))
          where accept' = map head sortedGroups
                reject' = concat . map tail $ sortedGroups
                sortedGroups = map (sortBy compareVersions) (groupByNameAndDist accept)
                tag x = (x, "Not the newest version in incoming")
      compareVersions a b = compare (changeVersion b) (changeVersion a)
      groupByNameAndDist = groupBy equalNameAndDist . sortBy compareNameAndDist
      equalNameAndDist a b = compareNameAndDist a b == EQ
      compareNameAndDist a b =
          case compare (changePackage a) (changePackage b) of
            EQ -> compare (changeRelease a) (changeRelease b)
            x -> x
      notUploaded uploaded changes = not . Set.member (Debian.Repo.Changes.key changes) $ uploaded
      validRevision c =
          do
            let dscPath = changeDir c </> changePackage c ++ "_" ++ show (changeVersion c) ++ ".dsc"
            doesFileExist dscPath >>= cond (S.parseControlFromFile dscPath >>= either (error . show) (checkRevision dscPath)) (return True)
          where
            checkRevision _dscPath (S.Control [p]) =
                case maybe Nothing parseRevision (S.fieldValue "Revision" p) of
                    Nothing -> return False
                    Just (x, _) | x == invalidRevision -> return False
                    Just _ -> return True
            checkRevision dscPath _ = error ("Invalid .dsc file: " ++ show dscPath)
      showReject (changes, tag) = Debian.Repo.Changes.name changes ++ ": " ++ tag

uploadKey :: UploadFile -> (String, DebianVersion, Arch)
uploadKey (Upload _ name ver arch) = (name, ver, arch)

uploadLoad :: FilePath -> String -> (Either [String] UploadFile)
uploadLoad dir file =
    case parseUploadFilename file of
      Just (name, ver, arch) -> Right $ Upload dir name ver arch
      Nothing -> Left ["Couldn't parse upload filename: " ++ file]

uploadFind :: FilePath -> IO [Either [String] UploadFile]
uploadFind dir =
    getDirectoryContents dir >>=
    return . filter (isSuffixOf ".upload") >>=
    return . map (uploadLoad dir)

{-
base :: UploadFile -> String
base (Upload _ name ver arch) = name ++ "_" ++ show ver ++ "_" ++ show arch
-}

-- 		       filename     name   version   arch    ext
parseUploadFilename :: String -> Maybe (String, DebianVersion, Arch)
parseUploadFilename name =
    case matchRegex (mkRegex "^(.*/)?([^_]*)_(.*)_([^.]*)\\.upload$") name of
      Just [_, name, version, arch] -> Just (name, parseDebianVersion version, Binary arch)
      _ -> error ("Invalid .upload file name: " ++ name)

invalidRevision = "none"

-- | Parse the "Revision:" value describing the origin of the
-- package's source and the dependency versions used to build it:
--   Revision: <revisionstring> dep1=ver1 dep2=ver2 ...
parseRevision :: String -> Maybe (String, [PkgVersion])
parseRevision s =
    case words s of
      [] -> Nothing
      (revision : buildDeps) -> Just (revision, map readPkgVersion buildDeps)

showPkgVersion :: PkgVersion -> String
showPkgVersion v = show v

readPkgVersion :: String -> PkgVersion
readPkgVersion s = case mapSnd (parseDebianVersion . (drop 1)) (span (/= '=') s) of
                     (n, v) -> PkgVersion { getName = n, getVersion = v }

mapSnd f (a, b) = (a, f b)

accept :: (a -> Bool) -> (a -> (a, String)) -> ([a], [(a, String)]) -> ([a], [(a, String)])
accept p tag (accepted, rejected) =
    (accepted', map tag rejected' ++ rejected)
    where (accepted', rejected') = partition p accepted

acceptM :: (Monad m) => (a -> m Bool) -> (a -> (a, String)) -> ([a], [(a, String)]) -> m ([a], [(a, String)])
acceptM p tag (accept, reject) =
    do (accept', reject') <- partitionM p accept
       return (accept', (map tag reject' ++ reject))

-- |Run dupload on a changes file with an optional host (--to)
-- argument.
dupload :: CIO m
	=> URI		-- user
        -> FilePath	-- The directory containing the .changes file
        -> String	-- The name of the .changes file to upload
        -> m (Either String ([Output], TimeDiff))
dupload uri dir changesFile  =
    case uriAuthority uri of
      Nothing -> error ("Invalid Upload-URI: " ++ uriToString' uri)
      Just auth ->
          do
            let config = ("package config;\n" ++
                          "$cfg{'default'} = {\n" ++
                          "        fqdn => \"" ++ uriRegName auth ++ uriPort auth ++ "\",\n" ++
                          "        method => \"scpb\",\n" ++
	                  "        login => \"" ++ init (uriUserInfo auth) ++ "\",\n" ++
                          "        incoming => \"" ++ uriPath uri ++ "/incoming\",\n" ++
                          "        dinstall_runs => 1,\n" ++
                          "};\n\n" ++
			  "$preupload{'changes'} = '';\n\n" ++
                          "1;\n")
            liftIO $ replaceFile (dir ++ "/dupload.conf") config
            liftIO (lazyCommand (cmd changesFile) L.empty) >>=
                   tMessage ("Uploading " ++ show changesFile) >>=
                   printOutput >>=
                   dotOutput 128 >>=
                   (\ output -> timeTask (checkResult fail (return (Right output)) output)) >>=
		   (\ (result, elapsed) -> return (either Left (\ output -> Right (output, elapsed)) result))
            --style' $ runCommandQuietlyTimed (cmd changesFile)
    where
{-
      style' = setStyle (setStart (Just ("Uploading " ++ show changesFile)) .
                         setError (Just "dupload failed") .
                         setEcho True)
-}
      fail n = 
          ePutStrBl message >> return (Left message)
          where message = "dupload failed: " ++ cmd changesFile ++ " -> " ++ show n
      cmd file = "cd " ++ dir ++ " && dupload --to default -c " ++ file

repoArchList :: Repo r => r -> [Arch]
repoArchList repo =
    listIntersection (map releaseInfoArchitectures (repoReleaseInfo repo))