{- Copyright 2017 Joey Hess <id@joeyh.name>
 -
 - Licensed under the GNU AGPL version 3 or higher.
 -}

{-# LANGUAGE OverloadedStrings, BangPatterns #-}

module Server where

import Types
import CmdLine
import WebSockets
import SessionID
import Log

import Network.Wai
import Network.Wai.Handler.Warp
import Network.Wai.Handler.WebSockets
import Network.WebSockets hiding (Message)
import qualified Network.WebSockets as WS
import Network.HTTP.Types
import Control.Concurrent
import Control.Concurrent.STM
import Control.Concurrent.STM.TMChan
import Control.Concurrent.Async
import Control.Exception
import Control.Monad
import Data.Maybe
import qualified Data.Map as M
import qualified Data.Text as T
import Data.Time.Clock.POSIX
import System.IO
import System.Directory
import System.Mem.Weak
import Network.Mail.Mime
import System.Environment

type ServerState = M.Map SessionID Session

newServerState :: IO (TVar ServerState)
newServerState = newTVarIO M.empty

-- | A session consists of a broadcast TMChan, which both users and
-- developers write messages to. Writes are stored in the log file,
-- and a log lock allows atomic access to the log file for replays.
data Session = Session (TMChan (Broadcast Log)) (TVar Handle) (TMVar LogLock)

data LogLock = LogLock

-- | A broadcast message, with the ThreadId of the sending thread
-- (which probably wants to ignore the message it sent).
data Broadcast a = Broadcast a (Weak ThreadId)

newSession :: TVar Handle -> IO Session
newSession loghv = Session
	<$> newBroadcastTMChanIO
	<*> pure loghv
	<*> newTMVarIO LogLock

listenSession :: Session -> STM (TMChan (Broadcast Log))
listenSession (Session bchan _ _) = dupTMChan bchan

-- | While writing a log to the session the LogLock is drained until
-- the write has reached the log file. This prevents concurrent writes
-- to the file, and allows writes to be blocked while reading the log file.
writeSession :: Weak ThreadId -> Session -> Log -> IO ()
writeSession tid (Session bchan loghv loglock) l = do
	(ll, logh) <- atomically $ (,)
		<$> takeTMVar loglock
		<*> readTVar loghv
	writeLogHandle l logh
	atomically $ do
		putTMVar loglock ll
		writeTMChan bchan (Broadcast l tid)

-- | Run an action with the log file quiescent (and its write handle closed),
-- and nothing being added to the session's broadcast TMChan.
preventWriteWhile :: Session -> ServerOpts -> SessionID -> IO a -> IO a
preventWriteWhile (Session _ loghv loglock) o sid a = bracket setup cleanup go
  where
	setup = do
		(ll, logh) <- atomically $ (,)
			<$> takeTMVar loglock
			<*> readTVar loghv
		hClose logh
		return ll
	cleanup ll = do
		let f = sessionLogFile (serverDirectory o) sid
		h <- openFile f AppendMode
		atomically $ do
			putTMVar loglock ll
			writeTVar loghv h
	go _ = a

closeSession :: Session -> STM ()
closeSession (Session bchan _ _) = closeTMChan bchan

server :: ServerOpts -> IO ()
server o = do
	o' <- checkEnv o
	runSettings settings . app o' =<< newServerState
  where
	settings =
		-- Prefer IPv6 but allow IPv4 as well
		-- (Workaround for
		-- https://github.com/jaspervdj/websockets/issues/140)
		setHost "*6" $
		setPort (serverPort o) $
		defaultSettings

checkEnv :: ServerOpts -> IO ServerOpts
checkEnv o = go <$> lookupEnv "DEBUG_ME_FROM_EMAIL"
  where
	go Nothing = o
	go (Just email) = o { serverEmail = Just (T.pack email) }

app :: ServerOpts -> TVar ServerState -> Application
app o ssv = websocketsOr connectionOptions (websocketApp o ssv) webapp
  where
	webapp _ respond = respond $
		responseLBS status400 [] "This is a debug-me server, it does not serve any html pages."

websocketApp :: ServerOpts -> TVar ServerState -> WS.ServerApp
websocketApp o ssv pending_conn = do
	conn <- WS.acceptRequest pending_conn
	_v <- negotiateWireVersion conn
	r <- receiveData conn
	case r of
		SelectMode ClientSends (InitMode email) -> user email o ssv conn
		SelectMode ClientSends (ConnectMode t) -> 
			case mkSessionID (T.unpack t) of
				Nothing -> protocolError conn "Invalid session id!"
				Just sid -> developer o ssv sid conn
		_ -> protocolError conn "Expected SelectMode"

user :: EmailAddress -> ServerOpts -> TVar ServerState -> WS.Connection -> IO ()
user email o ssv conn = do
	sid <- withSessionID (serverDirectory o) $ \(loghv, sid) -> do
		sendBinaryData conn (Ready ServerSends sid)
		bracket (setup sid loghv) (cleanup sid) go
		return sid
	doneSessionLog email o sid
  where
	setup sid loghv = do
		session <- newSession loghv
		atomically $ modifyTVar' ssv $ M.insert sid session
		return session

	cleanup sid session = do
		atomically $ do
			closeSession session
			modifyTVar' ssv $ M.delete sid

	go session = do
		mytid <- mkWeakThreadId =<< myThreadId
		userchan <- atomically $ listenSession session
		_ <- relaytouser userchan
			`race` relayfromuser mytid session
		return ()
	
	-- Relay all messages from the user's websocket to the
	-- session broadcast channel.
	-- (The user is allowed to send Developer messages too.. perhaps
	-- they got them from a developer connected to them some other
	-- way.)
	relayfromuser mytid session = relayFromSocket conn $ \msg -> do
		l <- mkLog msg <$> getPOSIXTime
		writeSession mytid session l
	
	-- Relay Developer messages from the channel to the user's websocket.
	relaytouser userchan = do
		v <- atomically $ readTMChan userchan
		case v of
			Just (Broadcast l _from) -> case loggedMessage l of
				Developer m -> do
					sendBinaryData conn (AnyMessage (Developer m))
					relaytouser userchan
				User _ -> relaytouser userchan
			Nothing -> return ()

developer ::  ServerOpts -> TVar ServerState -> SessionID -> WS.Connection -> IO ()
developer o ssv sid conn = bracket setup cleanup go
  where
	setup = atomically $ M.lookup sid <$> readTVar ssv
	cleanup _ = return ()
	go Nothing = do
		exists <- doesFileExist $
			sessionLogFile (serverDirectory o) sid
		if exists
			then do
				sendBinaryData conn (Ready ServerSends sid)
				replayBacklog o sid conn
				sendBinaryData conn Done
			else protocolError conn "Unknown session ID"
	go (Just session) = do
		sendBinaryData conn (Ready ServerSends sid)
		devchan <- replayBacklogAndListen o sid session conn
		mytid <- mkWeakThreadId =<< myThreadId
		_ <- relayfromdeveloper mytid session
			`concurrently` relaytodeveloper mytid devchan
		return ()
	
	-- Relay all Developer amessages from the developer's websocket
	-- to the broadcast channel.
	relayfromdeveloper mytid session = relayFromSocket conn
		$ \msg -> case msg of
			Developer _ -> do
				l <- mkLog msg <$> getPOSIXTime
				writeSession mytid session l
			-- developer cannot send User messages
			User _ -> return ()
	
	-- Relay user messages from the developer's clone of the
	-- broadcast channel to the developer's websocket.
	relaytodeveloper mytid devchan = do
		v <- atomically $ readTMChan devchan
		case v of
			Just (Broadcast l from) -> do
				let sendit = sendBinaryData conn
					(AnyMessage $ loggedMessage l)
				case loggedMessage l of
					User _ -> sendit
					-- Relay messages from other
					-- developers, without looping
					-- back the developer's own messages.
					Developer _ -> do
						rfrom <- deRefWeak from
						rmy <- deRefWeak mytid
						if rfrom == rmy
							then return ()
							else sendit
				relaytodeveloper mytid devchan
			Nothing -> do
				sendBinaryData conn Done
				return ()

-- | Replay the log of what's happened in the session so far,
-- and return a channel that will get new session activity.
--
-- This is done atomically; even if new activity arrives while it's
-- running nothing more will be logged until the log file has been
-- replayed and the channel set up. 
--
-- Note that the session may appear to freeze for other users while
-- this is running.
replayBacklogAndListen :: ServerOpts -> SessionID -> Session -> WS.Connection -> IO (TMChan (Broadcast Log))
replayBacklogAndListen o sid session conn = 
	preventWriteWhile session o sid $ do
		replayBacklog o sid conn
		atomically $ listenSession session

replayBacklog :: ServerOpts -> SessionID -> WS.Connection -> IO ()
replayBacklog o sid conn = do
	ls <- streamLog (sessionLogFile (serverDirectory o) sid)
	forM_ ls $ \l -> case loggedMessage <$> l of
		Right m -> sendBinaryData conn (AnyMessage m)
		Left _ -> return ()

doneSessionLog :: EmailAddress -> ServerOpts -> SessionID -> IO ()
doneSessionLog email o sid = do
	let logfile = sessionLogFile (serverDirectory o) sid
	emailSessionLog email o logfile
	if serverDeleteOldLogs o
		then removeFile logfile
		else return ()

emailSessionLog :: EmailAddress -> ServerOpts -> FilePath -> IO ()
emailSessionLog email o logfile
	| isemail = renderSendMail 
		=<< simpleMail to from subject body body [("text/plain", logfile)]
	| otherwise = return ()
  where
	to = Address Nothing email
	from = Address Nothing $ fromMaybe "postmaster" (serverEmail o)
	subject = "Your recent debug-me session"
	body = "Attached is the log from your recent debug-me session."
	isemail = "@" `T.isInfixOf` email
