{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

import           Control.Conditional(unlessM)
import           Control.Exception(Handler(..), catches, throw)
import           Control.Monad(forM_)
import           Control.Monad.Except(runExceptT)
import           Data.Aeson((.=), ToJSON, object, toJSON)
import           Data.Aeson.Encode.Pretty(encodePretty)
import           Data.ByteString.Lazy(toStrict)
import           Data.Conduit((.|), runConduit)
import qualified Data.Conduit.List as CL
import           Data.Maybe(catMaybes, fromMaybe, isJust, mapMaybe)
import qualified Data.Text as T
import           Data.Text.Encoding(decodeUtf8)
import           Data.Time.Clock.POSIX(getCurrentTime, posixSecondsToUTCTime)
import           Data.Time.Format(defaultTimeLocale, formatTime)
import           System.Console.GetOpt
import           System.Directory(doesFileExist)
import           System.Environment(getArgs)
import           System.Exit(exitFailure)
import           Text.Printf(printf)
import           Text.Regex.PCRE((=~))

import BDCS.DB(Files(..), KeyVal(..), checkAndRunSqlite)
import BDCS.Files(filesC, getKeyValuesForFile)
import BDCS.KeyType(KeyType(..))
import BDCS.KeyValue(keyValueListToJSON)
import BDCS.Label.Types(Label, labelDescriptions)
import BDCS.Utils.Either(whenLeft)
import BDCS.Utils.Mode(modeAsText)
import BDCS.Version

import Utils.Exceptions(InspectErrors(..))
import Utils.GetOpt(OptClass, commandLineArgs, compilerOpts)
import Utils.IO(liftedPutStrLn)
import Utils.KeyVal(formatKeyValList)

data LsOptions = LsOptions { lsJSONOutput :: Bool,
                             lsKeyVal :: Bool,
                             lsLabelMatches :: Maybe Label,
                             lsMatches :: String,
                             lsVerbose :: Bool }

instance OptClass LsOptions

defaultLsOptions :: LsOptions
defaultLsOptions = LsOptions { lsJSONOutput = False,
                               lsKeyVal = False,
                               lsLabelMatches = Nothing,
                               lsMatches = ".*",
                               lsVerbose = False }

data LsRow = LsRow { rowFiles :: Files,
                     rowKeyVals :: Maybe [KeyVal],
                     rowUseMetadata :: Bool }

instance ToJSON LsRow where
    toJSON r = let namePair = T.pack "path" .= toJSON (filesPath $ rowFiles r)
                   keyvals  = maybe [] keyValueListToJSON (rowKeyVals r)
                   optional = if not (rowUseMetadata r) then [] else catMaybes [
                       fileTypeString (rowFiles r)   >>= \ty     -> Just $ T.pack "fileType" .= toJSON ty,
                       Just $ T.pack "mode" .= toJSON (filesMode $ rowFiles r),
                       Just $ T.pack "size" .= toJSON (filesSize $ rowFiles r),
                       symlinkTarget (rowFiles r)    >>= \target -> Just $ T.pack "symlinkTarget" .= toJSON target,

                       Just $ T.pack "user"  .= toJSON (filesFile_user $ rowFiles r),
                       Just $ T.pack "group" .= toJSON (filesFile_group $ rowFiles r),

                       -- Don't do any special formatting of the mtime - leave that up to the consumer.
                       Just $ T.pack "mtime" .= toJSON (filesMtime $ rowFiles r)
                    ]
               in
                   object $ [namePair] ++ keyvals ++ optional

initRow :: Files -> LsRow
initRow f = LsRow { rowFiles=f,
                    rowUseMetadata=False,
                    rowKeyVals=Nothing }

fileType :: Files -> Maybe Char
fileType Files{filesCs_object=Nothing, ..} = Just 'd'
fileType Files{filesTarget=Just _, ..} = Just 'l'
fileType _ = Nothing

fileTypeString :: Files -> Maybe String
fileTypeString Files{filesCs_object=Nothing, ..} = Just "Directory"
fileTypeString Files{filesTarget=Just _, ..} = Just "Symlink"
fileTypeString _ = Just "File"

-- Figure out how to format the file's time.  If the time is in the current year, display
-- month, day, and hours/minutes.  If the time is in any other year, display that year
-- instead of hours and minutes.  This is not quite how ls does it - it appears to use
-- the threshold of if the file is more than a year old.  That's more time manipulation
-- than I am willing to do.
showTime :: Real t => String -> t -> String
showTime currentYear mtime = let
    utcMtime  = posixSecondsToUTCTime $ realToFrac mtime
    mtimeYear = formatTime defaultTimeLocale "%Y" utcMtime
    fmt       = "%b %e " ++ if currentYear == mtimeYear then "%R" else "%Y"
 in
    formatTime defaultTimeLocale fmt utcMtime

symlinkTarget :: Files -> Maybe String
symlinkTarget Files{filesTarget=Just x, ..} = Just $ T.unpack x
symlinkTarget _ = Nothing

keyValToLabel :: KeyVal -> Maybe Label
keyValToLabel KeyVal {keyValKey_value=LabelKey x} = Just x
keyValToLabel _                                   = Nothing

runCommand :: T.Text -> [String] -> IO (Either String ())
runCommand db args = do
    (opts, _) <- compilerOpts options defaultLsOptions args "ls"

    printer <- if | lsJSONOutput opts -> return $ liftedPutStrLn . jsonPrinter
                  | lsVerbose opts -> do currentYear <- formatTime defaultTimeLocale "%Y" <$> getCurrentTime
                                         return $ liftedPutStrLn . verbosePrinter currentYear
                  | otherwise -> return $ liftedPutStrLn . simplePrinter

    runExceptT $ checkAndRunSqlite db $ runConduit $
        -- Grab all the Files, filtering out any whose path does not match what we want.
        filesC .| CL.filter (\f -> T.unpack (filesPath f) =~ lsMatches opts)
        -- Convert them into LsRow records containing only the Files record.
               .| CL.map    initRow
        -- If we were asked for verbose output, add that to the LsRow.
               .| CL.mapM   (\row -> if lsVerbose opts then return row { rowUseMetadata=True }
                                     else return row)
        -- keyval output comes up in two different ways:  If we were
        -- given the --keyval flag, we want to add them to the LsRow,
        -- If we were given the --label flag, we want to grab the keyvals
        -- from the database and check for a match.  Note that both flags
        -- could be given at the same time.
               .| CL.mapMaybeM (\row -> do kvs <- if lsKeyVal opts || isJust (lsLabelMatches opts)
                                                  then getKeyValuesForFile (filesPath $ rowFiles row)
                                                  else return []

                                           let labels = mapMaybe keyValToLabel kvs

                                           if | maybe False (`notElem` labels) (lsLabelMatches opts) -> return Nothing
                                              | lsKeyVal opts -> return $ Just $ row { rowKeyVals=Just kvs }
                                              | otherwise -> return $ Just row)
        -- Finally, pass it to the appropriate printer.
               .| CL.mapM_  printer
 where
    options :: [OptDescr (LsOptions -> LsOptions)]
    options = [
        Option [] ["json"]
               (NoArg (\opts -> opts { lsJSONOutput = True }))
               "format output as JSON",
        Option ['k'] ["keyval"]
               (NoArg (\opts -> opts { lsKeyVal = True }))
               "add key/val pairs to output",
        Option ['l'] []
               (NoArg (\opts -> opts { lsVerbose = True }))
               "use a long listing format",
        Option [] ["label"]
               (ReqArg (\d opts -> case reads d :: [(Label, String)] of
                                       [(lbl, _)] -> opts { lsLabelMatches = Just lbl }
                                       _          -> throw $ InvalidLabelError d) "LABEL")
               "return only results with the given LABEL",
        Option ['m'] ["matches"]
               (ReqArg (\d opts -> opts { lsMatches = d }) "REGEX")
               "return only results that match REGEX"
     ]

    jsonPrinter :: LsRow -> T.Text
    jsonPrinter = decodeUtf8 . toStrict . encodePretty

    simplePrinter :: LsRow -> T.Text
    simplePrinter LsRow{..} = T.pack $
        printf "%s%s"
               (filesPath rowFiles)
               (maybe "" formatKeyValList rowKeyVals)

    verbosePrinter :: String -> LsRow -> T.Text
    verbosePrinter currentYear LsRow{..} = T.pack $
        printf "%c%s %8s %8s %10Ld %s %s%s%s"
               (fromMaybe '-' (fileType rowFiles))
               (if rowUseMetadata then T.unpack $ modeAsText $ fromIntegral $ filesMode rowFiles else "--ghost--")
               (T.unpack $ filesFile_user rowFiles) (T.unpack $ filesFile_group rowFiles)
               (if rowUseMetadata then filesSize rowFiles else 0)
               (showTime currentYear $ filesMtime rowFiles)
               (filesPath rowFiles) (maybe "" (" -> " ++) (symlinkTarget rowFiles))
               (maybe "" formatKeyValList rowKeyVals)

usage :: IO ()
usage = do
    printVersion "inspect-ls"
    putStrLn "Usage: inspect-ls output.db repo [args ...]"
    putStrLn "  List files in the content store"
    putStrLn "- output.db is the path to a metadata database"
    putStrLn "- repo is the path to a content store repo"
    exitFailure

runMain :: IO ()
runMain = do
    argv <- getArgs
    case commandLineArgs argv of
        Nothing               -> usage
        Just (db, _, args) -> do
            unlessM (doesFileExist db) $
                throw MissingDBError

            result <- runCommand (T.pack db) args
            whenLeft result print

main :: IO ()
main =
    -- Add handlers for other exception types (IOException, whatever) here.
    runMain `catches` [Handler (\(e :: InspectErrors) -> handleInspectErrors e)]
 where
    -- And then add handlers for the various kinds of InspectErrors here.
    handleInspectErrors :: InspectErrors -> IO ()
    handleInspectErrors (InvalidLabelError lbl) = do
        putStrLn $ lbl ++ " is not a recognized file label\n"
        putStrLn "Recognized labels:\n"
        forM_ labelDescriptions $ \(l, d) ->
            putStrLn $ "      " ++ l ++ " - " ++ d
        exitFailure

    handleInspectErrors MissingCSError = putStrLn "Content store does not exist\n" >> usage

    handleInspectErrors MissingDBError = putStrLn "Metadata database does not exist\n" >> usage
