packages feed

tuispec-0.3.1.1: src/TuiSpec/Server.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : TuiSpec.Server
Description : JSON-RPC 2.0 server for interactive TUI orchestration.

Runs a newline-delimited JSON-RPC server on stdin\/stdout, allowing
external tools to drive TUI sessions programmatically.
-}
module TuiSpec.Server (
    ServerOptions (..),
    runServer,
) where

import Control.Concurrent (forkIO, threadDelay)
import Control.Exception (SomeException, displayException, finally, throwIO, try)
import Control.Monad (foldM, when)
import Data.Aeson (FromJSON (parseJSON), Result (Error, Success), Value (Null, Object), eitherDecode, eitherDecodeStrict', encode, fromJSON, object, withObject, (.:), (.:?), (.=))
import Data.Aeson.Key qualified as K
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types qualified as AesonTypes
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BS8
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Lazy.Char8 qualified as BL8
import Data.Char (toLower)
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.List (isSuffixOf)
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Text.Encoding.Error qualified as TEE
import Data.Text.IO qualified as TIO
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (getPOSIXTime)
import JSONRPC qualified as RPC
import System.Directory (canonicalizePath, doesFileExist)
import System.Exit (ExitCode (ExitSuccess))
import System.FilePath ((</>))
import System.IO (hFlush, stdin, stdout)
import System.IO.Error (isEOFError, tryIOError)
import System.Posix.Process (exitImmediately)
import System.Posix.Signals (Handler (Catch), installHandler, sigHUP)
import TuiSpec.Internal (regexLikeMatch, safeFileStem, safeIndex, snapshotMetadataPath)
import TuiSpec.Render (renderAnsiSnapshotFileWithFont)
import TuiSpec.Replay (RecordingDirection (DirectionFrame, DirectionFrameDelta, DirectionNotification, DirectionRequest, DirectionResponse), RecordingHandle, ReplaySpeed (ReplayAsFastAsPossible, ReplayRealTime), appendRecordingEvent, closeRecording, computeFrameDelta, openRecording, streamReplayRequests)
import TuiSpec.Runner (clickSelectorWith, clickWith, currentView, defaultWaitOptionsFor, dumpView, expectNotVisible, expectSnapshot, expectVisible, killSessionChildrenNow, launch, openSession, press, pressCombo, renderAnsiViewportText, sendLine, serializeAnsiSnapshot, typeText, waitForSelectorWithAmbiguity, waitForStable)
import TuiSpec.Types (AmbiguityMode (FailOnAmbiguous, FirstVisibleMatch, LastVisibleMatch), App (..), ClickOptions (..), Key (..), Modifier (Alt, Control, Shift), MouseButton (..), MouseEncoding (..), Rect (Rect), RunOptions (..), Selector (..), SnapshotName (SnapshotName), Tui (..), WaitOptions (..), defaultClickOptions, defaultRunOptions, tuispecVersion)

-- | Configuration for the JSON-RPC server.
data ServerOptions = ServerOptions
    { serverArtifactsDir :: FilePath
    -- ^ Base directory for session artifacts.
    , serverTerminalCols :: Int
    -- ^ Default terminal columns for launched sessions.
    , serverTerminalRows :: Int
    -- ^ Default terminal rows for launched sessions.
    , serverTimeoutSeconds :: Int
    -- ^ Default timeout for wait operations.
    , serverAmbiguityMode :: AmbiguityMode
    -- ^ Default ambiguity mode for selector assertions.
    }
    deriving (Eq, Show)

data ActiveSession = ActiveSession
    { activeTui :: Tui
    }

data RecordingSession = RecordingSession
    { activeRecordingPath :: FilePath
    , activeRecordingHandle :: RecordingHandle
    , activeFrameSamplerStop :: Maybe (IORef Bool)
    }

data ViewSubscription = ViewSubscription
    { subscriptionDebounceMs :: Int
    , subscriptionIncludeText :: Bool
    , subscriptionLastSentMicros :: Maybe Int64
    , subscriptionLastView :: Maybe Text
    }

data ServerState = ServerState
    { stateOptions :: ServerOptions
    , stateActiveSession :: IORef (Maybe ActiveSession)
    , stateRecording :: IORef (Maybe RecordingSession)
    , stateViewSubscription :: IORef (Maybe ViewSubscription)
    }

data DispatchOutcome
    = Continue Value
    | Shutdown Value

data RpcFailure = RpcFailure
    { failureCode :: Int
    , failureMessage :: Text
    , failureData :: Maybe Value
    }

-- | Run the JSON-RPC server, reading requests from stdin and writing responses to stdout.
runServer :: ServerOptions -> IO ()
runServer options = do
    sessionRef <- newIORef Nothing
    recordingRef <- newIORef Nothing
    subscriptionRef <- newIORef Nothing
    let state =
            ServerState
                { stateOptions = options
                , stateActiveSession = sessionRef
                , stateRecording = recordingRef
                , stateViewSubscription = subscriptionRef
                }
    _ <- installHandler sigHUP (Catch (handleSighup state)) Nothing
    loop state `finally` shutdownServer state
  where
    handleSighup state = do
        killActiveChildrenNow state
        closeActiveRecording state
        exitImmediately ExitSuccess

    loop state = do
        lineResult <- tryIOError (BS8.hGetLine stdin)
        case lineResult of
            Left ioErr
                | isEOFError ioErr -> pure ()
                | otherwise -> pure ()
            Right line ->
                if BS.null line
                    then loop state
                    else do
                        shouldContinue <- handleLine state line
                        when shouldContinue (loop state)

shutdownServer :: ServerState -> IO ()
shutdownServer state = do
    killActiveChildrenNow state
    closeActiveRecording state

handleLine :: ServerState -> BS.ByteString -> IO Bool
handleLine state line = do
    recordIncomingRequestLine state line
    case eitherDecodeStrict' line :: Either String RPC.JSONRPCRequest of
        Left parseErr -> do
            writeErrorResponse state (RPC.RequestId Null) (parseError parseErr)
        Right request ->
            case validateRequestVersion request of
                Left err -> do
                    writeErrorResponse state (requestId request) err
                Right () -> do
                    outcome <- dispatchMethod state (requestMethod request) (requestParams request)
                    case outcome of
                        Left err ->
                            writeErrorResponse state (requestId request) err
                        Right (Continue resultValue) ->
                            writeSuccessResponse state (requestId request) resultValue
                        Right (Shutdown resultValue) -> do
                            _ <- writeSuccessResponse state (requestId request) resultValue
                            pure False

validateRequestVersion :: RPC.JSONRPCRequest -> Either RpcFailure ()
validateRequestVersion request =
    if requestVersion request == RPC.rPC_VERSION
        then Right ()
        else Left (invalidRequest "Expected jsonrpc field to equal \"2.0\"")

dispatchMethod :: ServerState -> Text -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchMethod state methodName paramsValue =
    case methodName of
        "initialize" -> dispatchInitialize state paramsValue
        "launch" -> dispatchLaunch state paramsValue
        "sendKey" -> dispatchSendKey state paramsValue
        "click" -> dispatchClick state paramsValue
        "sendText" -> dispatchSendText state paramsValue
        "sendLine" -> dispatchSendLine state paramsValue
        "currentView" -> dispatchCurrentView state paramsValue
        "dumpView" -> dispatchDumpView state paramsValue
        "renderView" -> dispatchRenderView state paramsValue
        "expectSnapshot" -> dispatchExpectSnapshot state paramsValue
        "waitForText" -> dispatchWaitForText state paramsValue
        "waitUntil" -> dispatchWaitUntil state paramsValue
        "waitForStable" -> dispatchWaitForStable state paramsValue
        "diffView" -> dispatchDiffView state paramsValue
        "expectVisible" -> dispatchExpectVisible state paramsValue
        "expectNotVisible" -> dispatchExpectNotVisible state paramsValue
        "viewSubscribe" -> dispatchViewSubscribe state paramsValue
        "viewUnsubscribe" -> dispatchViewUnsubscribe state paramsValue
        "batch" -> dispatchBatch state paramsValue
        "recording.start" -> dispatchRecordingStart state paramsValue
        "recording.stop" -> dispatchRecordingStop state paramsValue
        "recording.status" -> dispatchRecordingStatus state paramsValue
        "replay" -> dispatchReplay state paramsValue
        "server.ping" -> dispatchPing paramsValue
        "server.shutdown" -> dispatchShutdown state paramsValue
        unknownMethod ->
            pure $
                Left
                    ( RpcFailure
                        { failureCode = RPC.mETHOD_NOT_FOUND
                        , failureMessage = "Method not found"
                        , failureData = Just (object ["method" .= unknownMethod])
                        }
                    )

dispatchInitialize :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchInitialize state paramsValue = do
    existing <- readIORef (stateActiveSession state)
    case existing of
        Just _ ->
            pure (Left sessionAlreadyStartedError)
        Nothing ->
            case decodeParamsValue paramsValue of
                Left err -> pure (Left err)
                Right params -> do
                    let sessionName = T.unpack (T.strip (fromMaybeText "session" (startName params)))
                    if null sessionName
                        then pure (Left (invalidParams "session name cannot be empty"))
                        else do
                            case resolveAmbiguityOverride (startAmbiguityMode params) of
                                Left ambiguityErr ->
                                    pure (Left (invalidParams ambiguityErr))
                                Right ambiguityOverride -> do
                                    let runOptions = applyStartParams (stateOptions state) params ambiguityOverride
                                    sessionResult <- try (openSession runOptions sessionName) :: IO (Either SomeException Tui)
                                    case sessionResult of
                                        Left err ->
                                            pure (Left (methodFailed (displayException err)))
                                        Right tui -> do
                                            writeIORef (stateActiveSession state) (Just (ActiveSession tui))
                                            pure $
                                                Right $
                                                    Continue
                                                        ( object
                                                            [ "sessionName" .= sessionName
                                                            , "artifactRoot" .= tuiTestRoot tui
                                                            , "rows" .= terminalRows (tuiOptions tui)
                                                            , "cols" .= terminalCols (tuiOptions tui)
                                                            , "version" .= tuispecVersion
                                                            ]
                                                        )

dispatchLaunch :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchLaunch state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let tui = activeTui active
                    launch
                        tui
                        App
                            { command = launchCommand params
                            , args = launchArgs params
                            , env = launchEnv params
                            , cwd = launchCwd params
                            }
                    case launchReadySelector params of
                        Nothing -> pure ()
                        Just selector -> do
                            let defaults = defaultWaitOptionsFor tui
                            let waitOptions = mergeWaitOptions defaults (launchReadyTimeoutMs params) (launchReadyPollIntervalMs params)
                            waitForSelectorWithAmbiguity tui waitOptions Nothing selector
                    emitViewChangedNotification state tui
                    pure (object ["ok" .= True])

dispatchSendKey :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchSendKey state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                case parseSendKey (sendKeyValue params) of
                    Left keyErr -> pure (Left (invalidParams keyErr))
                    Right (modifiers, keyValue) ->
                        runMethod $ do
                            let tui = activeTui active
                            if null modifiers
                                then press tui keyValue
                                else pressCombo tui modifiers keyValue
                            emitViewChangedNotification state tui
                            pure (object ["ok" .= True])

dispatchClick :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchClick state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                case resolveClickOptions params of
                    Left optionErr -> pure (Left (invalidParams optionErr))
                    Right options ->
                        case resolveClickTarget params of
                            Left targetErr -> pure (Left (invalidParams targetErr))
                            Right target ->
                                runMethod $ do
                                    let tui = activeTui active
                                    case target of
                                        ClickAtCoordinate col row ->
                                            clickWith tui options (col - 1) (row - 1)
                                        ClickAtSelector selector ->
                                            clickSelectorWith tui options selector
                                    emitViewChangedNotification state tui
                                    pure (object ["ok" .= True])

-- | Where a @click@ request should land: an explicit coordinate or a selector.
data ClickTarget
    = ClickAtCoordinate Int Int
    | ClickAtSelector Selector

resolveClickTarget :: ClickParams -> Either String ClickTarget
resolveClickTarget params =
    case (clickParamSelector params, clickParamCol params, clickParamRow params) of
        (Just selector, _, _) -> Right (ClickAtSelector selector)
        (Nothing, Just col, Just row)
            | col < 1 || row < 1 ->
                Left "click uses 1-based coordinates; col/row must be >= 1"
            | otherwise -> Right (ClickAtCoordinate col row)
        (Nothing, _, _) ->
            Left "click requires either a selector or both col and row"

resolveClickOptions :: ClickParams -> Either String ClickOptions
resolveClickOptions params = do
    button <- maybe (Right (clickButton defaultClickOptions)) parseMouseButton (clickParamButton params)
    encoding <- maybe (Right (clickEncoding defaultClickOptions)) parseMouseEncoding (clickParamEncoding params)
    pure ClickOptions{clickButton = button, clickEncoding = encoding}
  where
    parseMouseButton textValue =
        case T.toLower textValue of
            "left" -> Right MouseLeft
            "middle" -> Right MouseMiddle
            "right" -> Right MouseRight
            _ -> Left ("unknown mouse button: " <> T.unpack textValue)
    parseMouseEncoding textValue =
        case T.toLower textValue of
            "sgr" -> Right MouseSGR
            "x10" -> Right MouseX10
            _ -> Left ("unknown mouse encoding: " <> T.unpack textValue)

dispatchSendText :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchSendText state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let tui = activeTui active
                    typeText tui (sendTextValue params)
                    emitViewChangedNotification state tui
                    pure (object ["ok" .= True])

dispatchSendLine :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchSendLine state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                case resolveAmbiguityOverride (sendLineAmbiguityMode params) of
                    Left ambiguityErr -> pure (Left (invalidParams ambiguityErr))
                    Right ambiguityOverride ->
                        runMethod $ do
                            let tui = activeTui active
                            sendLine tui (sendLineValue params)
                            case sendLineExpectAfter params of
                                Nothing -> pure ()
                                Just selector -> do
                                    let defaults = defaultWaitOptionsFor tui
                                    let waitOptions = mergeWaitOptions defaults (sendLineTimeoutMs params) (sendLinePollIntervalMs params)
                                    waitForSelectorWithAmbiguity tui waitOptions ambiguityOverride selector
                            emitViewChangedNotification state tui
                            pure (object ["ok" .= True])

dispatchCurrentView :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchCurrentView state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                let options = tuiOptions (activeTui active)
                    totalRows = terminalRows options
                    totalCols = terminalCols options
                 in case resolveCurrentViewFilter params totalRows totalCols of
                        Left filterErr -> pure (Left (invalidParams filterErr))
                        Right filterValue ->
                            runMethod $ do
                                textValue <- currentView (activeTui active)
                                let (filteredText, outRows, outCols) = applyCurrentViewFilter filterValue totalRows totalCols textValue
                                pure
                                    ( object
                                        [ "text" .= filteredText
                                        , "rows" .= outRows
                                        , "cols" .= outCols
                                        ]
                                    )

dispatchDumpView :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchDumpView state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let tui = activeTui active
                    ansiPathRaw <- dumpView tui (SnapshotName (dumpName params))
                    ansiPath <- canonicalizeExistingPath ansiPathRaw
                    metaPath <- canonicalizeExistingPath (snapshotMetadataPath ansiPath)
                    artifactRoot <- canonicalizePath (tuiTestRoot tui)
                    maybePngPath <-
                        case dumpFormat params of
                            DumpAnsi -> pure Nothing
                            DumpPng -> Just <$> renderSnapshotFromDump params ansiPath
                            DumpBoth -> Just <$> renderSnapshotFromDump params ansiPath
                    pure
                        ( object
                            ( [ "snapshotPath" .= ansiPath
                              , "metaPath" .= metaPath
                              , "artifactRoot" .= artifactRoot
                              ]
                                <> maybe [] (\pngPath -> ["pngPath" .= pngPath]) maybePngPath
                            )
                        )

dispatchRenderView :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchRenderView state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let tui = activeTui active
                    ansiPathRaw <- dumpView tui (SnapshotName (renderViewName params))
                    ansiPath <- canonicalizeExistingPath ansiPathRaw
                    metaPath <- canonicalizeExistingPath (snapshotMetadataPath ansiPath)
                    artifactRoot <- canonicalizePath (tuiTestRoot tui)
                    pngPath <- renderSnapshotFromRenderView params ansiPath
                    pure
                        ( object
                            [ "snapshotPath" .= ansiPath
                            , "metaPath" .= metaPath
                            , "pngPath" .= pngPath
                            , "artifactRoot" .= artifactRoot
                            ]
                        )

dispatchExpectSnapshot :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchExpectSnapshot state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let snapshotText = expectSnapshotNameValue params
                    let snapshotStem = safeFileStem (T.unpack snapshotText)
                    let tui = activeTui active
                    let actualPath = tuiTestRoot tui </> "snapshots" </> (snapshotStem <> ".ansi.txt")
                    let baselinePath = tuiSnapshotRoot tui </> (snapshotStem <> ".ansi.txt")
                    expectSnapshot tui (SnapshotName snapshotText)
                    baselineExists <- doesFileExist baselinePath
                    pure
                        ( object
                            [ "ok" .= True
                            , "actualPath" .= actualPath
                            , "baselinePath" .= baselinePath
                            , "baselineExists" .= baselineExists
                            ]
                        )

dispatchWaitForText :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchWaitForText state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                case resolveAmbiguityOverride (waitAmbiguityMode params) of
                    Left ambiguityErr -> pure (Left (invalidParams ambiguityErr))
                    Right ambiguityOverride ->
                        runMethod $ do
                            let tui = activeTui active
                            let defaults = defaultWaitOptionsFor tui
                            let mergedWaitOptions = mergeWaitOptions defaults (waitTimeoutMs params) (waitPollIntervalMs params)
                            waitForSelectorWithAmbiguity tui mergedWaitOptions ambiguityOverride (waitSelector params)
                            pure (object ["ok" .= True])

dispatchWaitUntil :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchWaitUntil state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let tui = activeTui active
                    let defaults = defaultWaitOptionsFor tui
                    let mergedWaitOptions = mergeWaitOptions defaults (waitUntilTimeoutMs params) (waitUntilPollIntervalMs params)
                    waitUntilPattern tui mergedWaitOptions (waitUntilPatternValue params)
                    pure (object ["ok" .= True])

dispatchWaitForStable :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchWaitForStable state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let tui = activeTui active
                    let defaults = defaultWaitOptionsFor tui
                    let mergedWaitOptions = mergeWaitOptions defaults (waitStableTimeoutMs params) (waitStablePollIntervalMs params)
                    waitForStable tui mergedWaitOptions (waitStableDebounceMs params)
                    pure (object ["ok" .= True])

dispatchDiffView :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchDiffView state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $ do
                    let options = tuiOptions (activeTui active)
                    diffResult <- computeSnapshotDiff options params
                    pure
                        ( object
                            [ "changed" .= diffChanged diffResult
                            , "changedLines" .= diffChangedLines diffResult
                            , "summary" .= diffSummary diffResult
                            ]
                        )

dispatchExpectVisible :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchExpectVisible state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $
                    expectVisible (activeTui active) (selectorValue params)
                        >> pure (object ["ok" .= True])

dispatchExpectNotVisible :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchExpectNotVisible state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                runMethod $
                    expectNotVisible (activeTui active) (selectorValue params)
                        >> pure (object ["ok" .= True])

dispatchViewSubscribe :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchViewSubscribe state paramsValue =
    withActiveSession state $ \active ->
        case decodeParamsValue paramsValue of
            Left err -> pure (Left err)
            Right params ->
                if subscribeDebounceMs params < 0
                    then pure (Left (invalidParams "debounceMs must be >= 0"))
                    else runMethod $ do
                        let subscription =
                                ViewSubscription
                                    { subscriptionDebounceMs = subscribeDebounceMs params
                                    , subscriptionIncludeText = subscribeIncludeText params
                                    , subscriptionLastSentMicros = Nothing
                                    , subscriptionLastView = Nothing
                                    }
                        writeIORef (stateViewSubscription state) (Just subscription)
                        emitViewChangedNotification state (activeTui active)
                        pure
                            ( object
                                [ "ok" .= True
                                , "debounceMs" .= subscribeDebounceMs params
                                , "includeText" .= subscribeIncludeText params
                                ]
                            )

dispatchViewUnsubscribe :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchViewUnsubscribe state paramsValue =
    case requireNoParamsValue paramsValue of
        Left err -> pure (Left err)
        Right () ->
            runMethod $ do
                writeIORef (stateViewSubscription state) Nothing
                pure (object ["ok" .= True])

dispatchBatch :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchBatch state paramsValue =
    case decodeParamsValue paramsValue of
        Left err -> pure (Left err)
        Right params ->
            runMethod $ do
                (completed, results, maybeFailure) <- foldM runBatchStep (0 :: Int, [], Nothing) (batchSteps params)
                case maybeFailure of
                    Nothing ->
                        pure
                            ( object
                                [ "ok" .= True
                                , "completed" .= completed
                                , "results" .= reverse results
                                ]
                            )
                    Just (stepIndex, failureValue) ->
                        pure
                            ( object
                                [ "ok" .= False
                                , "completed" .= completed
                                , "results" .= reverse results
                                , "errorStep" .= stepIndex
                                , "error" .= failureValue
                                ]
                            )
  where
    runBatchStep (completed, results, Just existingFailure) _ =
        pure (completed, results, Just existingFailure)
    runBatchStep (completed, results, Nothing) stepValue = do
        outcome <- dispatchMethod state (batchStepMethod stepValue) (batchStepParams stepValue)
        case outcome of
            Left err ->
                pure (completed, results, Just (completed + 1, rpcFailureToValue err))
            Right (Shutdown _) ->
                pure (completed, results, Just (completed + 1, object ["code" .= (-32020 :: Int), "message" .= ("batch step cannot call server.shutdown" :: Text)]))
            Right (Continue value) ->
                pure (completed + 1, value : results, Nothing)

dispatchRecordingStart :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchRecordingStart state paramsValue =
    case decodeParamsValue paramsValue of
        Left err -> pure (Left err)
        Right params ->
            runMethod $ do
                closeActiveRecording state
                handle <- openRecording (recordingStartPath params)
                canonicalPath <- canonicalizePath (recordingStartPath params)
                let intervalMs = recordingFrameIntervalMs params
                let keyframeEvery = max 1 (1000 `div` max 1 intervalMs)
                samplerStop <-
                    if intervalMs > 0
                        then do
                            stopRef <- newIORef False
                            lastFrameRef <- newIORef ("" :: Text)
                            tickRef <- newIORef (0 :: Int)
                            _ <- forkIO (frameSamplerLoop state handle lastFrameRef tickRef keyframeEvery stopRef intervalMs)
                            pure (Just stopRef)
                        else pure Nothing
                writeIORef
                    (stateRecording state)
                    (Just (RecordingSession canonicalPath handle samplerStop))
                pure
                    ( object
                        [ "ok" .= True
                        , "path" .= canonicalPath
                        , "frameIntervalMs" .= intervalMs
                        ]
                    )

dispatchRecordingStop :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchRecordingStop state paramsValue =
    case requireNoParamsValue paramsValue of
        Left err -> pure (Left err)
        Right () ->
            runMethod $ do
                closeActiveRecording state
                pure (object ["ok" .= True])

dispatchRecordingStatus :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchRecordingStatus state paramsValue =
    case requireNoParamsValue paramsValue of
        Left err -> pure (Left err)
        Right () ->
            runMethod $ do
                active <- readIORef (stateRecording state)
                pure
                    ( object
                        [ "active" .= isJust active
                        , "path" .= fmap activeRecordingPath active
                        ]
                    )

dispatchReplay :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchReplay state paramsValue =
    case decodeParamsValue paramsValue of
        Left err -> pure (Left err)
        Right params ->
            runMethod $ do
                replayed <-
                    streamReplayRequests
                        (replaySpeed params)
                        (replayPath params)
                        (replayRecordedRequestLine state)
                pure
                    ( object
                        [ "ok" .= True
                        , "replayedRequests" .= replayed
                        , "path" .= replayPath params
                        , "speed" .= renderReplaySpeed (replaySpeed params)
                        ]
                    )

dispatchPing :: Value -> IO (Either RpcFailure DispatchOutcome)
dispatchPing paramsValue =
    case requireNoParamsValue paramsValue of
        Left err -> pure (Left err)
        Right () ->
            pure
                ( Right
                    ( Continue
                        ( object
                            [ "pong" .= True
                            , "version" .= tuispecVersion
                            ]
                        )
                    )
                )

dispatchShutdown :: ServerState -> Value -> IO (Either RpcFailure DispatchOutcome)
dispatchShutdown state paramsValue =
    case requireNoParamsValue paramsValue of
        Left err -> pure (Left err)
        Right () -> do
            killActiveChildrenNow state
            pure
                ( Right
                    ( Shutdown
                        ( object
                            [ "shuttingDown" .= True
                            ]
                        )
                    )
                )

withActiveSession :: ServerState -> (ActiveSession -> IO (Either RpcFailure DispatchOutcome)) -> IO (Either RpcFailure DispatchOutcome)
withActiveSession state action = do
    maybeActive <- readIORef (stateActiveSession state)
    case maybeActive of
        Nothing -> pure (Left noActiveSessionError)
        Just active -> action active

killActiveChildrenNow :: ServerState -> IO ()
killActiveChildrenNow state = do
    maybeActive <- readIORef (stateActiveSession state)
    case maybeActive of
        Nothing -> pure ()
        Just active -> killSessionChildrenNow (activeTui active)

closeActiveRecording :: ServerState -> IO ()
closeActiveRecording state = do
    maybeRecording <- readIORef (stateRecording state)
    case maybeRecording of
        Nothing -> pure ()
        Just recording -> do
            case activeFrameSamplerStop recording of
                Just stopRef -> do
                    writeIORef stopRef True
                    threadDelay 50000
                Nothing -> pure ()
            closeRecording (activeRecordingHandle recording)
            writeIORef (stateRecording state) Nothing

runMethod :: IO Value -> IO (Either RpcFailure DispatchOutcome)
runMethod action = do
    result <- try action :: IO (Either SomeException Value)
    pure $
        case result of
            Left err -> Left (methodFailed (displayException err))
            Right value -> Right (Continue value)

writeSuccessResponse :: ServerState -> RPC.RequestId -> Value -> IO Bool
writeSuccessResponse state reqId resultValue =
    writeJsonLine
        state
        DirectionResponse
        ( encode
            (RPC.JSONRPCResponse RPC.rPC_VERSION reqId resultValue)
        )

writeErrorResponse :: ServerState -> RPC.RequestId -> RpcFailure -> IO Bool
writeErrorResponse state reqId failure =
    writeJsonLine
        state
        DirectionResponse
        ( encode
            ( RPC.JSONRPCError
                RPC.rPC_VERSION
                reqId
                (RPC.JSONRPCErrorInfo (failureCode failure) (failureMessage failure) (failureData failure))
            )
        )

writeNotification :: ServerState -> Text -> Value -> IO Bool
writeNotification state methodName paramsValue =
    writeJsonLine
        state
        DirectionNotification
        ( encode
            ( object
                [ "jsonrpc" .= RPC.rPC_VERSION
                , "method" .= methodName
                , "params" .= paramsValue
                ]
            )
        )

writeJsonLine :: ServerState -> RecordingDirection -> BL8.ByteString -> IO Bool
writeJsonLine state direction bytes = do
    writeResult <-
        tryIOError $ do
            BL8.hPutStr stdout bytes
            BL8.hPutStr stdout "\n"
            hFlush stdout
    case writeResult of
        Left _ -> pure False
        Right () -> do
            recordOutgoingLine state direction bytes
            pure True

recordIncomingRequestLine :: ServerState -> BS.ByteString -> IO ()
recordIncomingRequestLine state line =
    withActiveRecording state $ \recording ->
        appendRecordingEvent
            (activeRecordingHandle recording)
            DirectionRequest
            (TE.decodeUtf8With TEE.lenientDecode line)

recordOutgoingLine :: ServerState -> RecordingDirection -> BL.ByteString -> IO ()
recordOutgoingLine state direction line =
    withActiveRecording state $ \recording ->
        appendRecordingEvent
            (activeRecordingHandle recording)
            direction
            (TE.decodeUtf8With TEE.lenientDecode (BL.toStrict line))

withActiveRecording :: ServerState -> (RecordingSession -> IO ()) -> IO ()
withActiveRecording state action = do
    maybeRecording <- readIORef (stateRecording state)
    case maybeRecording of
        Nothing -> pure ()
        Just recording -> action recording

{- | Background thread that samples the viewport at a fixed interval and
writes frame events to the recording JSONL. Emits full keyframes
periodically (roughly every second) and compact line-level deltas in
between. Deduplicates consecutive identical frames. Exits when the stop
flag is set to @True@.
-}
frameSamplerLoop :: ServerState -> RecordingHandle -> IORef Text -> IORef Int -> Int -> IORef Bool -> Int -> IO ()
frameSamplerLoop state handle lastFrameRef tickRef keyframeEvery stopRef intervalMs = loop
  where
    loop = do
        threadDelay (intervalMs * 1000)
        stopped <- readIORef stopRef
        if stopped
            then pure ()
            else do
                maybeActive <- readIORef (stateActiveSession state)
                case maybeActive of
                    Nothing -> loop
                    Just active -> do
                        viewResult <- try (currentView (activeTui active)) :: IO (Either SomeException Text)
                        case viewResult of
                            Left _ -> loop
                            Right frameText -> do
                                lastFrame <- readIORef lastFrameRef
                                when (frameText /= lastFrame) $ do
                                    tick <- readIORef tickRef
                                    if tick == 0 || T.null lastFrame
                                        then appendRecordingEvent handle DirectionFrame frameText
                                        else case computeFrameDelta lastFrame frameText of
                                            Nothing -> pure ()
                                            Just deltaText -> appendRecordingEvent handle DirectionFrameDelta deltaText
                                    writeIORef lastFrameRef frameText
                                    writeIORef tickRef ((tick + 1) `mod` keyframeEvery)
                                loop

decodeParamsValue :: (FromJSON a) => Value -> Either RpcFailure a
decodeParamsValue paramsValue =
    case fromJSON paramsValue of
        Error err -> Left (invalidParams err)
        Success value -> Right value

requireNoParamsValue :: Value -> Either RpcFailure ()
requireNoParamsValue paramsValue =
    case paramsValue of
        Null -> Right ()
        Object keyMap
            | KM.null keyMap -> Right ()
        _ -> Left (invalidParams "expected params to be null or empty object")

invalidRequest :: String -> RpcFailure
invalidRequest details =
    RpcFailure
        { failureCode = RPC.iNVALID_REQUEST
        , failureMessage = "Invalid request"
        , failureData = Just (object ["details" .= details])
        }

invalidParams :: String -> RpcFailure
invalidParams details =
    RpcFailure
        { failureCode = RPC.iNVALID_PARAMS
        , failureMessage = "Invalid params"
        , failureData = Just (object ["details" .= details])
        }

parseError :: String -> RpcFailure
parseError details =
    RpcFailure
        { failureCode = RPC.pARSE_ERROR
        , failureMessage = "Parse error"
        , failureData = Just (object ["details" .= details])
        }

methodFailed :: String -> RpcFailure
methodFailed details =
    RpcFailure
        { failureCode = -32004
        , failureMessage = "Method failed"
        , failureData = Just (object ["details" .= details])
        }

rpcFailureToValue :: RpcFailure -> Value
rpcFailureToValue failure =
    object
        [ "code" .= failureCode failure
        , "message" .= failureMessage failure
        , "data" .= failureData failure
        ]

noActiveSessionError :: RpcFailure
noActiveSessionError =
    RpcFailure
        { failureCode = -32001
        , failureMessage = "No active session"
        , failureData = Nothing
        }

sessionAlreadyStartedError :: RpcFailure
sessionAlreadyStartedError =
    RpcFailure
        { failureCode = -32002
        , failureMessage = "Session already started"
        , failureData = Nothing
        }

data StartParams = StartParams
    { startName :: Maybe Text
    , startTimeoutSeconds :: Maybe Int
    , startTerminalCols :: Maybe Int
    , startTerminalRows :: Maybe Int
    , startAmbiguityMode :: Maybe Text
    , startSnapshotTheme :: Maybe Text
    , startUpdateSnapshots :: Maybe Bool
    }

instance FromJSON StartParams where
    parseJSON value =
        case value of
            Null -> pure defaultStartParams
            _ ->
                withObject "StartParams" parseObject value
      where
        parseObject o =
            StartParams
                <$> o .:? "name"
                <*> o .:? "timeoutSeconds"
                <*> o .:? "terminalCols"
                <*> o .:? "terminalRows"
                <*> o .:? "ambiguityMode"
                <*> o .:? "snapshotTheme"
                <*> o .:? "updateSnapshots"

defaultStartParams :: StartParams
defaultStartParams =
    StartParams
        { startName = Nothing
        , startTimeoutSeconds = Nothing
        , startTerminalCols = Nothing
        , startTerminalRows = Nothing
        , startAmbiguityMode = Nothing
        , startSnapshotTheme = Nothing
        , startUpdateSnapshots = Nothing
        }

data LaunchParams = LaunchParams
    { launchCommand :: FilePath
    , launchArgs :: [String]
    , launchEnv :: Maybe [(String, Maybe String)]
    , launchCwd :: Maybe FilePath
    , launchReadySelector :: Maybe Selector
    , launchReadyTimeoutMs :: Maybe Int
    , launchReadyPollIntervalMs :: Maybe Int
    }

instance FromJSON LaunchParams where
    parseJSON =
        withObject "LaunchParams" $ \o ->
            LaunchParams
                <$> o .: "command"
                <*> o .:? "args" AesonTypes..!= []
                <*> (o .:? "env" >>= traverse parseLaunchEnvObject)
                <*> o .:? "cwd"
                <*> (o .:? "readySelector" >>= traverse parseSelector)
                <*> o .:? "readyTimeoutMs"
                <*> o .:? "readyPollIntervalMs"

parseLaunchEnvObject :: Value -> AesonTypes.Parser [(String, Maybe String)]
parseLaunchEnvObject =
    withObject "launch.env" $ \envObject ->
        mapM parseLaunchEnvPair (KM.toList envObject)

parseLaunchEnvPair :: (K.Key, Value) -> AesonTypes.Parser (String, Maybe String)
parseLaunchEnvPair (key, value) =
    case value of
        Null -> pure (K.toString key, Nothing)
        _ -> do
            textValue <- (AesonTypes.parseJSON value :: AesonTypes.Parser Text)
            pure (K.toString key, Just (T.unpack textValue))

data SendKeyParams = SendKeyParams
    { sendKeyValue :: Text
    }

instance FromJSON SendKeyParams where
    parseJSON =
        withObject "SendKeyParams" $ \o ->
            SendKeyParams <$> o .: "key"

data ClickParams = ClickParams
    { clickParamSelector :: Maybe Selector
    , clickParamCol :: Maybe Int
    , clickParamRow :: Maybe Int
    , clickParamButton :: Maybe Text
    , clickParamEncoding :: Maybe Text
    }

instance FromJSON ClickParams where
    parseJSON =
        withObject "ClickParams" $ \o ->
            ClickParams
                <$> (o .:? "selector" >>= traverse parseSelector)
                <*> o .:? "col"
                <*> o .:? "row"
                <*> o .:? "button"
                <*> o .:? "encoding"

data SendTextParams = SendTextParams
    { sendTextValue :: Text
    }

instance FromJSON SendTextParams where
    parseJSON =
        withObject "SendTextParams" $ \o ->
            SendTextParams <$> o .: "text"

data SendLineParams = SendLineParams
    { sendLineValue :: Text
    , sendLineExpectAfter :: Maybe Selector
    , sendLineTimeoutMs :: Maybe Int
    , sendLinePollIntervalMs :: Maybe Int
    , sendLineAmbiguityMode :: Maybe Text
    }

instance FromJSON SendLineParams where
    parseJSON =
        withObject "SendLineParams" $ \o ->
            SendLineParams
                <$> o .: "text"
                <*> (o .:? "expectAfter" >>= traverse parseSelector)
                <*> o .:? "timeoutMs"
                <*> o .:? "pollIntervalMs"
                <*> o .:? "ambiguityMode"

data CurrentViewParams = CurrentViewParams
    { currentRowsFilter :: Maybe CurrentViewRowsFilter
    , currentRegionFilter :: Maybe CurrentViewRegionFilter
    , currentEntireRowFilter :: Maybe Int
    , currentEntireColFilter :: Maybe Int
    }

instance FromJSON CurrentViewParams where
    parseJSON value =
        case value of
            Null -> pure defaultCurrentViewParams
            _ ->
                withObject
                    "CurrentViewParams"
                    ( \o ->
                        CurrentViewParams
                            <$> o .:? "rows"
                            <*> o .:? "region"
                            <*> o .:? "entireRow"
                            <*> o .:? "entireCol"
                    )
                    value

defaultCurrentViewParams :: CurrentViewParams
defaultCurrentViewParams =
    CurrentViewParams
        { currentRowsFilter = Nothing
        , currentRegionFilter = Nothing
        , currentEntireRowFilter = Nothing
        , currentEntireColFilter = Nothing
        }

data CurrentViewRowsFilter = CurrentViewRowsFilter
    { currentRowsStart :: Int
    , currentRowsEnd :: Int
    }

instance FromJSON CurrentViewRowsFilter where
    parseJSON =
        withObject "CurrentViewRowsFilter" $ \o ->
            CurrentViewRowsFilter
                <$> o .: "start"
                <*> o .: "end"

data CurrentViewRegionFilter = CurrentViewRegionFilter
    { currentRegionCol :: Int
    , currentRegionRow :: Int
    , currentRegionWidth :: Int
    , currentRegionHeight :: Int
    }

instance FromJSON CurrentViewRegionFilter where
    parseJSON =
        withObject "CurrentViewRegionFilter" $ \o ->
            CurrentViewRegionFilter
                <$> o .: "col"
                <*> o .: "row"
                <*> o .: "width"
                <*> o .: "height"

data DumpFormat
    = DumpAnsi
    | DumpPng
    | DumpBoth
    deriving (Eq, Show)

parseDumpFormat :: Text -> Maybe DumpFormat
parseDumpFormat raw =
    case map toLower (T.unpack (T.strip raw)) of
        "ansi" -> Just DumpAnsi
        "png" -> Just DumpPng
        "both" -> Just DumpBoth
        _ -> Nothing

data DumpViewParams = DumpViewParams
    { dumpName :: Text
    , dumpFormat :: DumpFormat
    , dumpTheme :: Maybe String
    , dumpFontPath :: Maybe FilePath
    , dumpRows :: Maybe Int
    , dumpCols :: Maybe Int
    }

instance FromJSON DumpViewParams where
    parseJSON =
        withObject "DumpViewParams" $ \o -> do
            maybeFormat <- o .:? "format"
            formatValue <-
                case maybeFormat of
                    Nothing -> pure DumpAnsi
                    Just raw ->
                        case parseDumpFormat raw of
                            Just parsed -> pure parsed
                            Nothing -> fail "format must be one of: ansi, png, both"
            DumpViewParams
                <$> o .: "name"
                <*> pure formatValue
                <*> o .:? "theme"
                <*> o .:? "font"
                <*> o .:? "rows"
                <*> o .:? "cols"

data RenderViewParams = RenderViewParams
    { renderViewName :: Text
    , renderViewTheme :: Maybe String
    , renderViewFontPath :: Maybe FilePath
    , renderViewRows :: Maybe Int
    , renderViewCols :: Maybe Int
    }

instance FromJSON RenderViewParams where
    parseJSON =
        withObject "RenderViewParams" $ \o ->
            RenderViewParams
                <$> o .: "name"
                <*> o .:? "theme"
                <*> o .:? "font"
                <*> o .:? "rows"
                <*> o .:? "cols"

data ExpectSnapshotParams = ExpectSnapshotParams
    { expectSnapshotNameValue :: Text
    }

instance FromJSON ExpectSnapshotParams where
    parseJSON =
        withObject "ExpectSnapshotParams" $ \o ->
            ExpectSnapshotParams <$> o .: "name"

data SelectorParams = SelectorParams
    { selectorValue :: Selector
    }

instance FromJSON SelectorParams where
    parseJSON =
        withObject "SelectorParams" $ \o ->
            SelectorParams <$> (o .: "selector" >>= parseSelector)

data WaitForTextParams = WaitForTextParams
    { waitSelector :: Selector
    , waitTimeoutMs :: Maybe Int
    , waitPollIntervalMs :: Maybe Int
    , waitAmbiguityMode :: Maybe Text
    }

instance FromJSON WaitForTextParams where
    parseJSON =
        withObject "WaitForTextParams" $ \o ->
            WaitForTextParams
                <$> (o .: "selector" >>= parseSelector)
                <*> o .:? "timeoutMs"
                <*> o .:? "pollIntervalMs"
                <*> o .:? "ambiguityMode"

data WaitUntilParams = WaitUntilParams
    { waitUntilPatternValue :: Text
    , waitUntilTimeoutMs :: Maybe Int
    , waitUntilPollIntervalMs :: Maybe Int
    }

instance FromJSON WaitUntilParams where
    parseJSON =
        withObject "WaitUntilParams" $ \o ->
            WaitUntilParams
                <$> o .: "pattern"
                <*> o .:? "timeoutMs"
                <*> o .:? "pollIntervalMs"

data WaitForStableParams = WaitForStableParams
    { waitStableDebounceMs :: Int
    , waitStableTimeoutMs :: Maybe Int
    , waitStablePollIntervalMs :: Maybe Int
    }

instance FromJSON WaitForStableParams where
    parseJSON =
        withObject "WaitForStableParams" $ \o ->
            WaitForStableParams
                <$> o .: "debounceMs"
                <*> o .:? "timeoutMs"
                <*> o .:? "pollIntervalMs"

data DiffMode
    = DiffText
    | DiffStyled
    deriving (Eq, Show)

data DiffViewParams = DiffViewParams
    { diffLeftPath :: FilePath
    , diffRightPath :: FilePath
    , diffMode :: DiffMode
    }

instance FromJSON DiffViewParams where
    parseJSON =
        withObject "DiffViewParams" $ \o -> do
            maybeMode <- o .:? "mode"
            modeValue <-
                case maybeMode of
                    Nothing -> pure DiffText
                    Just raw ->
                        case parseDiffMode raw of
                            Just modeValue -> pure modeValue
                            Nothing -> fail "mode must be one of: text, styled"
            DiffViewParams
                <$> o .: "leftPath"
                <*> o .: "rightPath"
                <*> pure modeValue

parseDiffMode :: Text -> Maybe DiffMode
parseDiffMode raw =
    case map toLower (T.unpack (T.strip raw)) of
        "text" -> Just DiffText
        "styled" -> Just DiffStyled
        _ -> Nothing

data ViewSubscribeParams = ViewSubscribeParams
    { subscribeDebounceMs :: Int
    , subscribeIncludeText :: Bool
    }

instance FromJSON ViewSubscribeParams where
    parseJSON value =
        case value of
            Null -> pure defaultViewSubscribeParams
            _ ->
                withObject
                    "ViewSubscribeParams"
                    ( \o ->
                        ViewSubscribeParams
                            <$> o .:? "debounceMs" AesonTypes..!= 100
                            <*> o .:? "includeText" AesonTypes..!= False
                    )
                    value

defaultViewSubscribeParams :: ViewSubscribeParams
defaultViewSubscribeParams =
    ViewSubscribeParams
        { subscribeDebounceMs = 100
        , subscribeIncludeText = False
        }

data BatchParams = BatchParams
    { batchSteps :: [BatchStep]
    }

instance FromJSON BatchParams where
    parseJSON =
        withObject "BatchParams" $ \o ->
            BatchParams
                <$> o .:? "steps" AesonTypes..!= []

data BatchStep = BatchStep
    { batchStepMethod :: Text
    , batchStepParams :: Value
    }

instance FromJSON BatchStep where
    parseJSON =
        withObject "BatchStep" $ \o ->
            BatchStep
                <$> o .: "method"
                <*> o .:? "params" AesonTypes..!= Null

data RecordingStartParams = RecordingStartParams
    { recordingStartPath :: FilePath
    , recordingFrameIntervalMs :: Int
    }

instance FromJSON RecordingStartParams where
    parseJSON =
        withObject "RecordingStartParams" $ \o ->
            RecordingStartParams
                <$> o .: "path"
                <*> o .:? "frameIntervalMs" AesonTypes..!= 200

data ReplayParams = ReplayParams
    { replayPath :: FilePath
    , replaySpeed :: ReplaySpeed
    }

instance FromJSON ReplayParams where
    parseJSON =
        withObject "ReplayParams" $ \o -> do
            maybeSpeed <- o .:? "speed"
            speed <-
                case maybeSpeed of
                    Nothing -> pure ReplayAsFastAsPossible
                    Just raw ->
                        case parseReplaySpeed raw of
                            Just speed -> pure speed
                            Nothing -> fail "speed must be one of: as-fast-as-possible, real-time"
            ReplayParams
                <$> o .: "path"
                <*> pure speed

parseReplaySpeed :: Text -> Maybe ReplaySpeed
parseReplaySpeed raw =
    case map toLower (T.unpack (T.strip raw)) of
        "as-fast-as-possible" -> Just ReplayAsFastAsPossible
        "real-time" -> Just ReplayRealTime
        _ -> Nothing

renderReplaySpeed :: ReplaySpeed -> Text
renderReplaySpeed speed =
    case speed of
        ReplayAsFastAsPossible -> "as-fast-as-possible"
        ReplayRealTime -> "real-time"

parseSelector :: Value -> AesonTypes.Parser Selector
parseSelector =
    withObject "Selector" $ \o -> do
        selectorType <- (T.toLower <$> (o .: "type" :: AesonTypes.Parser Text))
        case selectorType of
            "exact" -> Exact <$> o .: "text"
            "regex" -> Regex <$> o .: "pattern"
            "at" -> do
                col <- o .: "col"
                row <- o .: "row"
                if col < 1 || row < 1
                    then fail "selector.at uses 1-based coordinates; col/row must be >= 1"
                    else pure (At (col - 1) (row - 1))
            "within" -> do
                rectValue <- o .: "rect" >>= parseRect
                nested <- o .: "selector" >>= parseSelector
                pure (Within rectValue nested)
            "nth" -> Nth <$> o .: "index" <*> (o .: "selector" >>= parseSelector)
            _ -> fail ("unknown selector type: " <> T.unpack selectorType)

parseRect :: Value -> AesonTypes.Parser Rect
parseRect =
    withObject "Rect" $ \o -> do
        col <- o .: "col"
        row <- o .: "row"
        width <- o .: "width"
        height <- o .: "height"
        if col < 1 || row < 1
            then fail "selector.within.rect uses 1-based col/row coordinates"
            else
                if width < 1 || height < 1
                    then fail "selector.within.rect width/height must be >= 1"
                    else pure (Rect (col - 1) (row - 1) width height)

requestId :: RPC.JSONRPCRequest -> RPC.RequestId
requestId (RPC.JSONRPCRequest _ reqId _ _) = reqId

requestVersion :: RPC.JSONRPCRequest -> Text
requestVersion (RPC.JSONRPCRequest version _ _ _) = version

requestMethod :: RPC.JSONRPCRequest -> Text
requestMethod (RPC.JSONRPCRequest _ _ methodName _) = methodName

requestParams :: RPC.JSONRPCRequest -> Value
requestParams (RPC.JSONRPCRequest _ _ _ paramsValue) = paramsValue

applyStartParams :: ServerOptions -> StartParams -> Maybe AmbiguityMode -> RunOptions
applyStartParams options params ambiguityOverride =
    defaultRunOptions
        { timeoutSeconds = maybe (serverTimeoutSeconds options) id (startTimeoutSeconds params)
        , terminalCols = maybe (serverTerminalCols options) id (startTerminalCols params)
        , terminalRows = maybe (serverTerminalRows options) id (startTerminalRows params)
        , artifactsDir = serverArtifactsDir options
        , ambiguityMode = maybe (serverAmbiguityMode options) id ambiguityOverride
        , updateSnapshots = maybe False id (startUpdateSnapshots params)
        , snapshotTheme = maybe "auto" T.unpack (startSnapshotTheme params)
        }

resolveAmbiguityOverride :: Maybe Text -> Either String (Maybe AmbiguityMode)
resolveAmbiguityOverride maybeRaw =
    case maybeRaw of
        Nothing -> Right Nothing
        Just raw ->
            case parseAmbiguityMode raw of
                Just mode -> Right (Just mode)
                Nothing ->
                    Left "ambiguityMode must be one of: fail, first, first-visible, last, last-visible"

parseAmbiguityMode :: Text -> Maybe AmbiguityMode
parseAmbiguityMode raw =
    case map toLower (T.unpack (T.strip raw)) of
        "fail" -> Just FailOnAmbiguous
        "first" -> Just FirstVisibleMatch
        "first-visible" -> Just FirstVisibleMatch
        "last" -> Just LastVisibleMatch
        "last-visible" -> Just LastVisibleMatch
        _ -> Nothing

parseSendKey :: Text -> Either String ([Modifier], Key)
parseSendKey rawKey =
    if trimmed == "+"
        then (,) [] <$> parseBaseKey trimmed
        else case T.breakOn "+" trimmed of
            (_, "") ->
                (,) [] <$> parseBaseKey trimmed
            (modifierText, remainder) ->
                do
                    modifier <- parseModifier (T.strip modifierText)
                    keyValue <- parseModifiedKey (T.strip (T.drop 1 remainder))
                    pure ([modifier], keyValue)
  where
    trimmed = T.strip rawKey

    parseModifier textValue =
        case map toLower (T.unpack textValue) of
            "ctrl" -> Right Control
            "control" -> Right Control
            "alt" -> Right Alt
            "shift" -> Right Shift
            _ -> Left ("unknown modifier: " <> T.unpack textValue)

    parseModifiedKey textValue =
        case T.unpack textValue of
            [charValue] -> Right (CharKey charValue)
            _ -> Left "modified keys must use a single character (for example Ctrl+C)"

parseBaseKey :: Text -> Either String Key
parseBaseKey keyText =
    let lowered = map toLower (T.unpack (T.strip keyText))
     in case lowered of
            "enter" -> Right Enter
            "esc" -> Right Esc
            "escape" -> Right Esc
            "tab" -> Right Tab
            "backspace" -> Right Backspace
            "arrowup" -> Right ArrowUp
            "arrowdown" -> Right ArrowDown
            "arrowleft" -> Right ArrowLeft
            "arrowright" -> Right ArrowRight
            "space" -> Right (CharKey ' ')
            _ ->
                case parseFunctionKey lowered of
                    Just functionNumber -> Right (FunctionKey functionNumber)
                    Nothing ->
                        case T.unpack (T.strip keyText) of
                            [charValue] -> Right (CharKey charValue)
                            _ -> Left ("unknown key: " <> T.unpack keyText)

parseFunctionKey :: String -> Maybe Int
parseFunctionKey lowered =
    case lowered of
        'f' : digits
            | all isDigitAscii digits ->
                case reads digits of
                    [(value, "")]
                        | value >= 1 && value <= 12 -> Just value
                    _ -> Nothing
        _ -> Nothing
  where
    isDigitAscii c = c >= '0' && c <= '9'

mergeWaitOptions :: WaitOptions -> Maybe Int -> Maybe Int -> WaitOptions
mergeWaitOptions defaults maybeTimeout maybePoll =
    defaults
        { timeoutMs = maybe (timeoutMs defaults) id maybeTimeout
        , pollIntervalMs = maybe (pollIntervalMs defaults) id maybePoll
        }

waitUntilPattern :: Tui -> WaitOptions -> Text -> IO ()
waitUntilPattern tui waitOptions patternText = do
    start <- getCurrentTime
    loop start
  where
    timeoutLimit = fromIntegral (timeoutMs waitOptions) / 1000 :: NominalDiffTime

    loop :: UTCTime -> IO ()
    loop startedAt = do
        viewText <- currentView tui
        if regexLikeMatch patternText viewText
            then pure ()
            else do
                now <- getCurrentTime
                if diffUTCTime now startedAt >= timeoutLimit
                    then throwIO (userError "waitUntil timed out")
                    else do
                        threadDelay (pollIntervalMs waitOptions * 1000)
                        loop startedAt

data CurrentViewFilter
    = FilterFull
    | FilterRows Int Int
    | FilterRegion Int Int Int Int
    | FilterEntireRow (Maybe Int)
    | FilterEntireCol (Maybe Int)

resolveCurrentViewFilter :: CurrentViewParams -> Int -> Int -> Either String CurrentViewFilter
resolveCurrentViewFilter params totalRows totalCols =
    case length activeFilters of
        n | n > 1 -> Left "currentView accepts exactly one of: rows, region, entireRow, entireCol"
        _ ->
            case activeFilters of
                [] -> Right FilterFull
                ["rows"] ->
                    case currentRowsFilter params of
                        Nothing -> Right FilterFull
                        Just rowsFilter -> do
                            start <- normalizeRowIndex "rows.start" totalRows (currentRowsStart rowsFilter)
                            end <- normalizeRowIndex "rows.end" totalRows (currentRowsEnd rowsFilter)
                            if start > end
                                then Left "rows.start must be <= rows.end"
                                else Right (FilterRows start end)
                ["region"] ->
                    case currentRegionFilter params of
                        Nothing -> Right FilterFull
                        Just regionFilter -> resolveRegion regionFilter
                ["entireRow"] ->
                    case currentEntireRowFilter params of
                        Nothing -> Right FilterFull
                        Just rowValue ->
                            if rowValue == 0
                                then Right (FilterEntireRow Nothing)
                                else
                                    if rowValue < 0 || rowValue > totalRows
                                        then Left "entireRow must be 0 or between 1 and total rows"
                                        else Right (FilterEntireRow (Just rowValue))
                ["entireCol"] ->
                    case currentEntireColFilter params of
                        Nothing -> Right FilterFull
                        Just colValue ->
                            if colValue == 0
                                then Right (FilterEntireCol Nothing)
                                else
                                    if colValue < 0 || colValue > totalCols
                                        then Left "entireCol must be 0 or between 1 and total cols"
                                        else Right (FilterEntireCol (Just colValue))
                _ -> Left "invalid currentView filter selection"
  where
    activeFilters :: [Text]
    activeFilters =
        concat
            [ maybe [] (const ["rows"]) (currentRowsFilter params)
            , maybe [] (const ["region"]) (currentRegionFilter params)
            , maybe [] (const ["entireRow"]) (currentEntireRowFilter params)
            , maybe [] (const ["entireCol"]) (currentEntireColFilter params)
            ]

    resolveRegion regionFilter = do
        let rawCol = currentRegionCol regionFilter
        let rawRow = currentRegionRow regionFilter
        let rawWidth = currentRegionWidth regionFilter
        let rawHeight = currentRegionHeight regionFilter

        startCol <-
            if rawCol == 0
                then Right 1
                else normalizeColIndex "region.col" totalCols rawCol

        startRow <-
            if rawRow == 0
                then Right 1
                else normalizeRowIndex "region.row" totalRows rawRow

        endCol <-
            if rawCol == 0
                then Right totalCols
                else
                    if rawWidth <= 0
                        then Left "region.width must be > 0 when region.col is non-zero"
                        else Right (min totalCols (startCol + rawWidth - 1))

        endRow <-
            if rawRow == 0
                then Right totalRows
                else
                    if rawHeight <= 0
                        then Left "region.height must be > 0 when region.row is non-zero"
                        else Right (min totalRows (startRow + rawHeight - 1))

        if startCol > endCol || startRow > endRow
            then Left "resolved region is empty"
            else Right (FilterRegion startCol startRow endCol endRow)

normalizeRowIndex :: String -> Int -> Int -> Either String Int
normalizeRowIndex label totalRows rawValue =
    if rawValue == 0
        then Right 1
        else
            if rawValue < 1 || rawValue > totalRows
                then Left (label <> " must be 0 or between 1 and " <> show totalRows)
                else Right rawValue

normalizeColIndex :: String -> Int -> Int -> Either String Int
normalizeColIndex label totalCols rawValue =
    if rawValue == 0
        then Right 1
        else
            if rawValue < 1 || rawValue > totalCols
                then Left (label <> " must be 0 or between 1 and " <> show totalCols)
                else Right rawValue

applyCurrentViewFilter :: CurrentViewFilter -> Int -> Int -> Text -> (Text, Int, Int)
applyCurrentViewFilter filterValue totalRows totalCols textValue =
    case filterValue of
        FilterFull -> renderGrid grid
        FilterRows start end ->
            let selected = take (end - start + 1) (drop (start - 1) grid)
             in renderGrid selected
        FilterRegion startCol startRow endCol endRow ->
            let selectedRows = take (endRow - startRow + 1) (drop (startRow - 1) grid)
                selectedCols = map (sliceColumns startCol endCol) selectedRows
             in renderGrid selectedCols
        FilterEntireRow maybeRow ->
            case maybeRow of
                Nothing -> renderGrid grid
                Just rowIdx ->
                    case safeIndex (rowIdx - 1) grid of
                        Nothing -> renderGrid []
                        Just rowText -> renderGrid [rowText]
        FilterEntireCol maybeCol ->
            case maybeCol of
                Nothing -> renderGrid grid
                Just colIdx ->
                    let selected = map (sliceColumns colIdx colIdx) grid
                     in renderGrid selected
  where
    grid = normalizeViewportGrid totalRows totalCols textValue

normalizeViewportGrid :: Int -> Int -> Text -> [Text]
normalizeViewportGrid rows cols textValue =
    map normalizeLine [0 .. rows - 1]
  where
    sourceLines = T.lines textValue
    padding = T.replicate cols " "

    normalizeLine idx =
        let sourceLine = fromMaybe "" (safeIndex idx sourceLines)
         in T.take cols (sourceLine <> padding)

renderGrid :: [Text] -> (Text, Int, Int)
renderGrid rowsText =
    let rowCount = length rowsText
        colCount =
            case rowsText of
                [] -> 0
                rowValue : _ -> T.length rowValue
     in (T.intercalate "\n" rowsText, rowCount, colCount)

sliceColumns :: Int -> Int -> Text -> Text
sliceColumns startCol endCol lineText =
    T.take (endCol - startCol + 1) (T.drop (startCol - 1) lineText)

renderSnapshotFromDump :: DumpViewParams -> FilePath -> IO FilePath
renderSnapshotFromDump params ansiPath = do
    let pngPath = defaultPngPath ansiPath
    renderAnsiSnapshotFileWithFont
        (dumpFontPath params)
        (dumpRows params)
        (dumpCols params)
        (dumpTheme params)
        ansiPath
        pngPath
    canonicalizeExistingPath pngPath

renderSnapshotFromRenderView :: RenderViewParams -> FilePath -> IO FilePath
renderSnapshotFromRenderView params ansiPath = do
    let pngPath = defaultPngPath ansiPath
    renderAnsiSnapshotFileWithFont
        (renderViewFontPath params)
        (renderViewRows params)
        (renderViewCols params)
        (renderViewTheme params)
        ansiPath
        pngPath
    canonicalizeExistingPath pngPath

defaultPngPath :: FilePath -> FilePath
defaultPngPath ansiPath =
    if ".ansi.txt" `isSuffixOf` ansiPath
        then take (length ansiPath - length (".ansi.txt" :: String)) ansiPath <> ".png"
        else ansiPath <> ".png"

canonicalizeExistingPath :: FilePath -> IO FilePath
canonicalizeExistingPath path = do
    exists <- doesFileExist path
    if exists
        then canonicalizePath path
        else pure path

data SnapshotDiffResult = SnapshotDiffResult
    { diffChanged :: Bool
    , diffChangedLines :: Int
    , diffSummary :: Text
    }

computeSnapshotDiff :: RunOptions -> DiffViewParams -> IO SnapshotDiffResult
computeSnapshotDiff options params = do
    leftPath <- canonicalizeExistingPath (diffLeftPath params)
    rightPath <- canonicalizeExistingPath (diffRightPath params)

    leftExists <- doesFileExist leftPath
    rightExists <- doesFileExist rightPath

    if not leftExists || not rightExists
        then
            throwIO
                ( userError
                    ( "diffView requires both paths to exist (left="
                        <> leftPath
                        <> ", right="
                        <> rightPath
                        <> ")"
                    )
                )
        else do
            leftComparable <- loadComparableSnapshot options (diffMode params) leftPath
            rightComparable <- loadComparableSnapshot options (diffMode params) rightPath
            let changed = leftComparable /= rightComparable
            let changedLineCount = lineDifferenceCount leftComparable rightComparable
            let summary =
                    if changed
                        then
                            "Snapshots differ ("
                                <> T.pack (show changedLineCount)
                                <> " changed lines)"
                        else "Snapshots are identical"
            pure
                SnapshotDiffResult
                    { diffChanged = changed
                    , diffChangedLines = changedLineCount
                    , diffSummary = summary
                    }

loadComparableSnapshot :: RunOptions -> DiffMode -> FilePath -> IO Text
loadComparableSnapshot options mode path = do
    ansiText <- TIO.readFile path
    (rows, cols) <- loadSnapshotDimensions path options
    case mode of
        DiffText -> pure (renderAnsiViewportText rows cols ansiText)
        DiffStyled -> pure (T.pack (serializeAnsiSnapshot rows cols (snapshotTheme options) ansiText))

loadSnapshotDimensions :: FilePath -> RunOptions -> IO (Int, Int)
loadSnapshotDimensions path options = do
    let metaPath = snapshotMetadataPath path
    metaExists <- doesFileExist metaPath
    if not metaExists
        then pure (terminalRows options, terminalCols options)
        else do
            metaBytes <- BL.readFile metaPath
            case eitherDecode metaBytes of
                Left _ -> pure (terminalRows options, terminalCols options)
                Right meta -> pure (snapshotMetaRows meta, snapshotMetaCols meta)

data SnapshotMeta = SnapshotMeta
    { snapshotMetaRows :: Int
    , snapshotMetaCols :: Int
    }

instance FromJSON SnapshotMeta where
    parseJSON =
        withObject "SnapshotMeta" $ \o ->
            SnapshotMeta
                <$> o .: "rows"
                <*> o .: "cols"

lineDifferenceCount :: Text -> Text -> Int
lineDifferenceCount left right =
    go 0 leftLines rightLines
  where
    leftLines = T.lines left
    rightLines = T.lines right

    go count [] [] = count
    go count (l : ls) [] = go (if T.null l then count else count + 1) ls []
    go count [] (r : rs) = go (if T.null r then count else count + 1) [] rs
    go count (l : ls) (r : rs) =
        go
            (if l == r then count else count + 1)
            ls
            rs

emitViewChangedNotification :: ServerState -> Tui -> IO ()
emitViewChangedNotification state tui = do
    maybeSubscription <- readIORef (stateViewSubscription state)
    case maybeSubscription of
        Nothing -> pure ()
        Just subscription -> do
            nowMicros <- currentMicros
            textValue <- currentView tui
            let changedSinceLast = maybe True (/= textValue) (subscriptionLastView subscription)
            let debounceMicros = fromIntegral (subscriptionDebounceMs subscription) * 1000
            let pastDebounce =
                    case subscriptionLastSentMicros subscription of
                        Nothing -> True
                        Just lastSent -> nowMicros - lastSent >= debounceMicros
            let updatedSubscription = subscription{subscriptionLastView = Just textValue}
            if changedSinceLast && pastDebounce
                then do
                    let options = tuiOptions tui
                    let payloadBase =
                            [ "rows" .= terminalRows options
                            , "cols" .= terminalCols options
                            ]
                    let payload =
                            if subscriptionIncludeText subscription
                                then object (payloadBase <> ["text" .= textValue])
                                else object payloadBase
                    _ <- writeNotification state "view.changed" payload
                    writeIORef
                        (stateViewSubscription state)
                        (Just updatedSubscription{subscriptionLastSentMicros = Just nowMicros})
                else
                    writeIORef (stateViewSubscription state) (Just updatedSubscription)

currentMicros :: IO Int64
currentMicros = do
    now <- getPOSIXTime
    pure (floor (now * 1000000))

replayRecordedRequestLine :: ServerState -> Text -> IO ()
replayRecordedRequestLine state lineText =
    case eitherDecodeStrict' (TE.encodeUtf8 lineText) :: Either String RPC.JSONRPCRequest of
        Left parseErr ->
            throwIO (userError ("replay failed to parse request line: " <> parseErr))
        Right request ->
            if requestMethod request == "server.shutdown"
                then pure ()
                else case validateRequestVersion request of
                    Left err ->
                        throwIO (userError ("replay request rejected: " <> T.unpack (failureMessage err)))
                    Right () -> do
                        outcome <- dispatchMethod state (requestMethod request) (requestParams request)
                        case outcome of
                            Left failure ->
                                throwIO
                                    ( userError
                                        ( "replay step failed ("
                                            <> T.unpack (requestMethod request)
                                            <> "): "
                                            <> T.unpack (failureMessage failure)
                                        )
                                    )
                            Right _ -> pure ()

fromMaybeText :: Text -> Maybe Text -> Text
fromMaybeText fallback maybeValue =
    case maybeValue of
        Just value -> value
        Nothing -> fallback