module MPlayer.Spot where

import Conduit (ConduitT, awaitForever, iterMC, runConduit, yield, (.|))
import Control.Applicative ((<|>))
import Control.Concurrent.Async (Concurrently(..))
import Control.Concurrent.MVar (MVar, modifyMVar_, newMVar, tryReadMVar)
import Control.Exception (IOException, finally, try)
import Control.Monad (void)
import Control.Monad.IO.Class (liftIO)
import Data.Attoparsec.ByteString.Char8
    ( Parser, char, notInClass, parseOnly, rational, skipSpace, skipWhile
    , string, takeWhile1
    )
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as C8
import Data.Conduit.Combinators (stderr, stdin, stdout)
import Data.Conduit.Process (streamingProcess, waitForStreamingProcess)
import Data.Monoid ((<>))
import Data.Streaming.Process (StreamingProcessHandle)
import Data.Text (unpack)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Void (Void)
import System.Directory (createDirectoryIfMissing, getHomeDirectory, removeFile)
import System.Environment (getArgs)
import System.Exit (exitWith)
import System.FilePath (takeFileName, (</>))
import System.IO (BufferMode(..), hSetBuffering)
import qualified System.IO as IO
import System.Process (proc)

-- Define the the types that should be defaulted to.  We can define one
-- type for string-like things, and one type for integer-like things.  It
-- doesn't matter what order they are in.
default (T.Text, Int)

-- | A config for our program.
data Config = Config { configMPlayerSpotRCDir :: FilePath -- ^ @~/.mplayer-spots@ dir
                     , configSpotsDir :: FilePath         -- ^ @~/.mplayer-spots/spots@ dir
                     , configIgnoreSeconds :: Float       -- how many seconds to ignore
                                                          -- in media before creating
                                                          -- spot file
                     }
    deriving Show

-- | Create a default 'Config'.
defaultConfig :: IO Config
defaultConfig = do
    homeDir <- getHomeDirectory
    let rcDir = homeDir </> ".mplayer-spot"
    let spotsDir = rcDir </> "spots"
    let ignoreSeconds = 180
    pure $ Config rcDir spotsDir ignoreSeconds

-- | Info about our media.
data MediaInfo = MediaInfo { mediaInfoLength :: Maybe Float          -- ^ length of the media
                           , mediaInfoFilename :: Maybe ByteString   -- ^ filename of the media
                           , mediaInfoCurPos :: Maybe Float          -- ^ current position
                           , mediaInfoAlreadySetOldLocation :: Bool  -- ^ whether we have already
                                                                     -- set the old location
                           }
    deriving Show

defaultMediaInfo :: MediaInfo
defaultMediaInfo = MediaInfo { mediaInfoLength = Nothing
                             , mediaInfoFilename = Nothing
                             , mediaInfoCurPos = Nothing
                             , mediaInfoAlreadySetOldLocation = False
                             }

-- | Datatype to hold mplayer's stdin, stdout, etc conduits.
data MPlayer = MPlayer { mplayerStdin :: ConduitT ByteString Void IO ()
                       , mplayerStdout :: ConduitT () ByteString IO ()
                       , mplayerStderr :: ConduitT () ByteString IO ()
                       , mplayerProcHandle :: StreamingProcessHandle
                       }

-- | Take in a bunch of arguments and use them to create the mplayer process.
createMPlayerProcess :: [String] -> IO MPlayer
createMPlayerProcess programArgs = do
    let mplayerArgs = ["-identify", "-slave"] <> programArgs
    ( processStdin :: ConduitT ByteString Void IO ()
      , processStdout :: ConduitT () ByteString IO ()
      , processStderr :: ConduitT () ByteString IO ()
      , processHandle) <-
        streamingProcess (proc "mplayer" mplayerArgs)
    return $! MPlayer processStdin processStdout processStderr processHandle

-- streamingProcess :: (MonadIO m, InputSource stdin, OutputSink stdout, OutputSink stderr) => CreateProcess -> m (stdin, stdout, stderr, StreamingProcessHandle)
--
-- (r ~ (), r' ~ (), MonadIO m, MonadIO n, i ~ ByteString) => InputSource (ConduitM i o m r, n r')
-- (r ~ (), r' ~ (), MonadIO m, MonadIO n, o ~ ByteString) => OutputSink (ConduitM i o m r, n r')
-- InputSource (ConduitM ByteString o IO ())
-- OutputSink (ConduitM i ByteString IO ())
--
--



-- | Parser for a value prefixed by a bytestring.  Uses skipWhile to make it
-- faster.
genericParser :: forall a . ByteString -> Parser a -> ByteString -> Maybe a
genericParser str parser mplayerLine =
    either (const Nothing) Just $ parseOnly go mplayerLine
  where
    go :: Parser a
    go = do
        -- Skip until we find the first character of the prefix that we are looking for.
        skipWhile (/= C8.head str)
        -- Try to match the prefix. If it matches, run the parser.
        string str *> parser
            -- If it doesn't match, then strip the first character and recurse.
            <|> char (C8.head str) *> go

-- | Parser for the length of the of the media.
getLength :: ByteString -> Maybe Float
getLength = genericParser "ID_LENGTH=" rational

-- | Parser for the filename of the of the media.
getFilename :: ByteString -> Maybe ByteString
getFilename = genericParser "ID_FILENAME=" $ takeWhile1 (notInClass "\n")

-- | Parser for the current location in the media.
getCurPos :: ByteString -> Maybe Float
getCurPos = genericParser "A:" $ skipSpace *> rational

updateLength :: MVar MediaInfo -> ByteString -> IO ()
updateLength mediaInfoMVar mplayerLine = do
    maybeMediaInfo <- tryReadMVar mediaInfoMVar
    case maybeMediaInfo of
        Just (MediaInfo (Just _) _ _ _) -> pure ()
        _ ->
            case getLength mplayerLine of
                Nothing -> pure ()
                Just mediaLength ->
                    modifyMVar_ mediaInfoMVar $ updateMediaInfo mediaLength
  where
    updateMediaInfo :: Float -> MediaInfo -> IO MediaInfo
    updateMediaInfo mediaLength mediaInfo =
        pure mediaInfo { mediaInfoLength = Just mediaLength }

updateFilename :: MVar MediaInfo -> ByteString -> IO ()
updateFilename mediaInfoMVar mplayerLine = do
    maybeMediaInfo <- tryReadMVar mediaInfoMVar
    case maybeMediaInfo of
        Just (MediaInfo _ (Just _) _ _) -> pure ()
        _ ->
            case getFilename mplayerLine of
                Nothing -> pure ()
                Just mediaFilename ->
                    modifyMVar_ mediaInfoMVar $ updateMediaInfo mediaFilename
  where
    updateMediaInfo :: ByteString -> MediaInfo -> IO MediaInfo
    updateMediaInfo mediaFilename mediaInfo =
        pure mediaInfo { mediaInfoFilename = Just mediaFilename }

updateCurPos :: MVar MediaInfo -> ByteString -> IO ()
updateCurPos mediaInfoMVar mplayerLine =
    maybe (pure ()) (modifyMVar_ mediaInfoMVar . updateMediaInfo) $ getCurPos mplayerLine
  where
    updateMediaInfo :: Float -> MediaInfo -> IO MediaInfo
    updateMediaInfo mediaCurPos mediaInfo =
        pure mediaInfo { mediaInfoCurPos = Just mediaCurPos }

-- | Try to read 3 different things from the mplayer stdout:
--
--   - length of the media
--   - media filename
--   - current position in the media
processMPlayerStdout :: MVar MediaInfo -> ByteString -> IO ()
processMPlayerStdout mediaInfoMVar mplayerLine = do
    updateLength mediaInfoMVar mplayerLine
    updateFilename mediaInfoMVar mplayerLine
    updateCurPos mediaInfoMVar mplayerLine

-- | 'Conduit' that reads in key presses (from stdin), and yields mplayer
-- commands.
--
-- In order for this to work right when hooked up to stdin, stdin should be set
-- to 'NoBuffering'.
sendMPlayerCommands :: ConduitT ByteString ByteString IO ()
sendMPlayerCommands = awaitForever go
  where
    go :: ByteString -> ConduitT ByteString ByteString IO ()
    go inputChar
        | inputChar == "q"       = yield "quit\n"
        | inputChar == "p"       = yield "pause\n"
        | inputChar == " "       = yield "pause\n"
        | inputChar == "\ESC[D"  = yield "seek -10\n"
        | inputChar == "\ESC[C"  = yield "seek 10\n"
        | inputChar == "\ESC[B"  = yield "seek -60\n"
        | inputChar == "\ESC[A"  = yield "seek 60\n"
        | inputChar == "\ESC[6~" = yield "seek -600\n"
        | inputChar == "\ESC[5~" = yield "seek 600\n"
        | otherwise =
            liftIO $ print $ "got keypress " <> inputChar <> " but don't know what to do with it"

calculateFullSpotsPath :: Config -> ByteString -> FilePath
calculateFullSpotsPath (Config _ spotsDir _) filename =
    spotsDir </> takeFileName (unpack (decodeUtf8 filename))

-- | Producer that tries to read the filename from the @'MVar' 'MediaInfo'@,
-- and if it succeeds, tries to open the spot file and read in the saved
-- position.  Produce it as a value.
setOldLocation :: Config -> MVar MediaInfo -> ConduitT i ByteString IO ()
setOldLocation config mediaInfoMVar = do
    maybeMediaInfo <- liftIO $ tryReadMVar mediaInfoMVar
    case maybeMediaInfo of
        Just (MediaInfo _ _ _ True) -> pure ()
        Just (MediaInfo _ (Just filename) _ False) -> do
            let fullPath = calculateFullSpotsPath config filename
            filecontents <- liftIO $ try $ readFile fullPath
            case filecontents of
                Right oldLocation -> do
                    yield $ "seek " <> encodeUtf8 (T.pack oldLocation) <> "\n"
                Left (_ :: IOException) -> pure ()
            liftIO $ modifyMVar_ mediaInfoMVar $ \mediaInfo -> pure mediaInfo { mediaInfoAlreadySetOldLocation = True }
        _ -> setOldLocation config mediaInfoMVar

-- | Run the mplayer process while doing 3 things:
--
--  - Read mplayer's stdout, looking for the length, filename, and current
--    position of the media.
--  - Once the filename has been found, look for a spot file, and if it exists,
--    make mplayer seek to the location.
--  - Take keys from stdin and translate them to mplayer commands.  Since mplayer
--    is in slave mode, it can't take in commands like normal.
runMPlayerUpdateMediaInfo :: Config -> MPlayer -> MVar MediaInfo -> IO ()
runMPlayerUpdateMediaInfo config mplayer mediaInfoMVar = do
    -- set stdin to not be buffered so we only get one character at a time
    hSetBuffering IO.stdin NoBuffering

    -- a conduit for sending the old location to mplayer
    let oldLocationToMplayerStdin =
            runConduit $ setOldLocation config mediaInfoMVar .| mplayerStdin mplayer

    -- Read keypresses from stdin, translate to mplayer commands, and pipe to mplayer.
    let stdinToMplayerStdin =
          runConduit $
            stdin .|
            sendMPlayerCommands .|
            mplayerStdin mplayer

    -- Process mplayer's stdout to find filename, length, and current position.
    -- Print mplayer's stdout to our stdout.
    let mplayerStdoutToStdout =
          runConduit $
            mplayerStdout mplayer .|
            iterMC (processMPlayerStdout mediaInfoMVar) .|
            stdout

    -- Print mplayer's stderr to our stderr.
    let mplayerStderrToStderr = runConduit $ mplayerStderr mplayer .| stderr

    -- Handle for controlling the mplayer process.
    let mplayerHandle = mplayerProcHandle mplayer

    -- Run all conduits concurrently.
    runConcurrently $
        Concurrently mplayerStdoutToStdout *>
        Concurrently mplayerStderrToStderr *>
        Concurrently oldLocationToMplayerStdin *>
        Concurrently stdinToMplayerStdin *>
        Concurrently (waitForStreamingProcess mplayerHandle >>= exitWith)

-- | Create the .mplayer-spots/ and spots/ directories from the config file.
createMPlayerSpotsDir :: Config -> IO ()
createMPlayerSpotsDir (Config rcDir spotsDir _) = do
    createDirectoryIfMissing True rcDir
    createDirectoryIfMissing True spotsDir

-- | Write out a spot file to the spots directory if all the required fields
-- have been filled in the MediaInfo, and if our current position in the media
-- file is not too early or not too late.
writeSpotFile :: Config -> MVar MediaInfo -> IO ()
writeSpotFile config@(Config _ _ ignoreLength) mediaInfoMVar = do
    maybeMediaInfo <- tryReadMVar mediaInfoMVar
    case maybeMediaInfo of
        Just (MediaInfo (Just mediaLength) (Just filename) (Just exitPos) _) -> do
            let spotFilename = calculateFullSpotsPath config filename
            writeSpotFile' mediaLength spotFilename exitPos
        _ -> putStrLn "When exiting, do not currently have all fields of media info, so cannot write out spot file."
  where
    -- | Check that the length is not too early or too late.  If it is not,
    -- then write out the spot file.
    writeSpotFile' :: Float -> FilePath -> Float -> IO ()
    writeSpotFile' mediaLength spotFilename exitPos
        | exitPos <= ignoreLength = do
            putStrLn $ "exit position is " <> show exitPos <> " seconds so not writing spot file (not far enough)"
        | exitPos >= (mediaLength - ignoreLength) = do
            putStrLn $ "exit position is " <> show exitPos <> " seconds so not writing spot file (too close to end)"
            removeOldSpotFile spotFilename
        | otherwise =
            writeFloatToFile spotFilename (max (exitPos - 10) 0)

    -- | Write a Float to a FilePath.
    writeFloatToFile :: FilePath -> Float -> IO ()
    writeFloatToFile spotFilename exitPos = do
        putStrLn $ "writing to file: " <> spotFilename <> " (" <> show exitPos <> ")"
        writeFile spotFilename $ show exitPos

    -- | Remove a file and ignore errors (like if the file doesn't exist).
    removeOldSpotFile :: FilePath -> IO ()
    removeOldSpotFile =
        void . (try :: IO () -> IO (Either IOException ())) . removeFile

defaultMain :: IO ()
defaultMain = do
    -- read in program arguments
    programArgs <- getArgs

    -- create the config we will be using
    config <- defaultConfig

    -- create the .mplayer-spots directory if it doesn't exist
    createMPlayerSpotsDir config

    -- create the mplayer process
    mplayerProcess <- createMPlayerProcess programArgs

    -- create the MediaInfo MVar we will be using to do concurrent stuff
    mediaInfoMVar <- newMVar defaultMediaInfo

    finally (runMPlayerUpdateMediaInfo config mplayerProcess mediaInfoMVar) $
        -- write the spot file after exiting
        writeSpotFile config mediaInfoMVar