packages feed

bloodhound-1.0.0.0: src/Database/Bloodhound/OpenSearch2/Requests.hs

{-# LANGUAGE OverloadedLists #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Database.Bloodhound.OpenSearch2.Requests
  ( module Reexport,
    deleteISMPolicy,
    addISMPolicy,
    openPointInTime,
    openPointInTimeWith,
    closePointInTime,
    listAllPITs,
    deleteAllPITs,
    getMLTask,
    putISMPolicy,
    putISMPolicyWith,
    removeISMPolicy,
    changeISMPolicy,
    retryISMIndex,
    explainISMIndex,
    explainISMIndexWith,
    explainISMIndexFiltered,
    getISMPolicy,
    getISMPolicies,
    postSMPolicy,
    putSMPolicyWith,
    getSMPolicy,
    getSMPolicies,
    deleteSMPolicy,
    startSMPolicy,
    stopSMPolicy,
    explainSMPolicies,
    getMLStats,
    getModel,
    registerModel,
    registerModelWith,
    deployModel,
    undeployModel,
    updateModel,
    searchModels,
    listModels,
    deleteModel,
    predict,
    trainModel,
    trainModelWith,
    trainAndPredictModel,
    searchMLTasks,
    deleteMLTask,
    registerModelGroup,
    updateModelGroup,
    getModelGroup,
    searchModelGroups,
    deleteModelGroup,
    createConnector,
    getConnector,
    searchConnectors,
    updateConnector,
    deleteConnector,
    createMemory,
    updateMemory,
    getMemory,
    listMemories,
    searchMemory,
    deleteMemory,
    createMemoryMessage,
    updateMemoryMessage,
    getMemoryMessage,
    listMemoryMessages,
    searchMemoryMessages,
    getMemoryTraces,
    createController,
    updateController,
    getController,
    deleteController,
    getMLProfile,
    getMLProfileModels,
    getMLProfileModel,
    getMLProfileTasks,
    getMLProfileTask,
    registerAgent,
    getAgent,
    deleteAgent,
    searchAgents,
    executeAgent,
    warmupKnnIndex,
    clearKnnCache,
    getKnnStats,
    getKnnModel,
    trainKnnModel,
    trainKnnModelWith,
    deleteKnnModel,
    searchKnnModels,
    submitOSAsyncSearch,
    submitOSAsyncSearchWith,
    getOSAsyncSearch,
    deleteOSAsyncSearch,
    getOSAsyncSearchStats,
    sqlQuery,
    sqlQueryNextPage,
    closeSqlCursor,
    explainSQL,
    pplQuery,
    explainPPL,
    getSQLStats,
    createNotificationConfig,
    createNotificationConfigWith,
    deleteNotificationConfig,
    deleteNotificationConfigs,
    getNotificationConfig,
    getNotificationConfigs,
    getNotificationConfigsWith,
    getNotificationFeatures,
    getNotificationChannels,
    getNotificationChannelsWith,
    updateNotificationConfig,
    sendTestNotification,
    createMonitor,
    getMonitor,
    updateMonitor,
    updateMonitorWith,
    deleteMonitor,
    searchMonitors,
    executeMonitor,
    createSADetector,
    getSADetector,
    updateSADetector,
    deleteSADetector,
    searchSADetectors,
    searchSAPrePackagedRules,
    searchSACustomRules,
    createSACustomRule,
    updateSACustomRule,
    deleteSACustomRule,
    getSAMappingsView,
    createSAMappings,
    getSAMappings,
    updateSAMappings,
    getSAAlertsWith,
    getSAAlerts,
    acknowledgeSADetectorAlerts,
    searchSAFindings,
    createSACorrelationRule,
    getSACorrelations,
    findSACorrelation,
    searchSACorrelationAlerts,
    acknowledgeSACorrelationAlerts,
    createSALogType,
    searchSALogTypes,
    updateSALogType,
    deleteSALogType,
    getDestinations,
    getDestinationsWith,
    createDestination,
    updateDestination,
    deleteDestination,
    getAlertsWith,
    getAlerts,
    acknowledgeAlert,
    getMonitorStats,
    createEmailAccount,
    getEmailAccount,
    updateEmailAccount,
    updateEmailAccountWith,
    deleteEmailAccount,
    createEmailGroup,
    getEmailGroup,
    updateEmailGroup,
    updateEmailGroupWith,
    deleteEmailGroup,
    getDestination,
    searchEmailAccounts,
    searchEmailGroups,
    searchFindings,
    createComment,
    updateComment,
    searchComments,
    deleteComment,
    createDetector,
    getDetector,
    previewDetector,
    previewDetectorInline,
    startDetector,
    stopDetector,
    updateDetector,
    updateDetectorWith,
    deleteDetector,
    validateDetector,
    searchDetectors,
    profileDetector,
    getDetectorStats,
    searchDetectorResults,
    deleteDetectorResults,
    searchDetectorTasks,
    topAnomalies,
    createRollup,
    createRollupWith,
    getRollup,
    getAllRollups,
    deleteRollup,
    startRollup,
    stopRollup,
    explainRollup,
    startReplication,
    stopReplication,
    pauseReplication,
    resumeReplication,
    updateReplicationSettings,
    getReplicationStatus,
    getReplicationStatusWith,
    getLeaderStats,
    getFollowerStats,
    getAutoFollowStats,
    createAutoFollowPattern,
    deleteAutoFollowPattern,
    createTransform,
    updateTransform,
    updateTransformWith,
    getTransform,
    getTransforms,
    startTransform,
    stopTransform,
    explainTransform,
    previewTransform,
    deleteTransform,
  )
where

import Data.Aeson
import Data.ByteString.Lazy qualified as BL
import Data.List.NonEmpty (NonEmpty, toList)
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes, listToMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Word (Word64)
import Database.Bloodhound.Client.Cluster
import Database.Bloodhound.Common.Requests as Reexport hiding
  ( deleteTransform,
    explainTransform,
    getTransforms,
    previewTransform,
    startTransform,
    stopTransform,
    updateTransform,
    updateTransformWith,
  )
import Database.Bloodhound.Internal.Client.BHRequest
import Database.Bloodhound.Internal.Utils.Imports (deleteSeveral, showText)
import Database.Bloodhound.Internal.Utils.Requests
import Database.Bloodhound.Internal.Versions.Common.Types.KeepAlive
import Database.Bloodhound.Internal.Versions.Common.Types.Newtypes
import Database.Bloodhound.OpenSearch2.Types
import Network.HTTP.Client (responseBody)
import Prelude hiding (filter, head)

-- | 'openPointInTime' opens a point in time for a single index given an
-- 'IndexName', using the supplied 'KeepAlive' duration (e.g.
-- @keepAliveMinutes 1@). Note that the point in time should be closed
-- with 'closePointInTime' as soon as it is no longer needed.
--
-- This is a convenience wrapper around 'openPointInTimeWith' that lifts
-- the single 'IndexName' into an 'IndexPattern'. To target multiple
-- comma-joined indices or a wildcard pattern (e.g. @\"logs-*,app-*\"@),
-- use 'openPointInTimeWith' directly with an 'IndexPattern'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/api-reference/search-apis/point-in-time-api/>.
openPointInTime ::
  IndexName ->
  KeepAlive ->
  BHRequest StatusDependant (ParsedEsResponse OpenPointInTimeResponse)
openPointInTime indexName keepAlive =
  openPointInTimeWith (IndexPattern (unIndexName indexName)) keepAlive defaultPITOptions

-- | 'openPointInTimeWith' is the parameterized variant of
-- 'openPointInTime' on OpenSearch 2: it takes an 'IndexPattern' as the
-- target (@target_indexes@ on the wire), which may be a single index
-- name, a comma-separated list of indices, or a wildcard pattern
-- (e.g. @\"logs-*\"@, @\"logs-2024,logs-2025\"@). It forwards the
-- supplied 'PITOptions' as query-string parameters in addition to the
-- mandatory @keep_alive@. All four documented parameters are emitted
-- (@routing@, @preference@, @expand_wildcards@,
-- @allow_partial_pit_creation@); the last is OpenSearch-only and is
-- silently dropped by the Elasticsearch request builders. The target is
-- carried as a URL path segment (not a query parameter). Pass
-- 'Database.Bloodhound.Internal.Versions.Common.Types.PointInTime.defaultPITOptions'
-- to reproduce the wire shape of 'openPointInTime'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/api-reference/search-apis/point-in-time-api/#create-a-pit>.
openPointInTimeWith ::
  IndexPattern ->
  KeepAlive ->
  PITOptions ->
  BHRequest StatusDependant (ParsedEsResponse OpenPointInTimeResponse)
openPointInTimeWith indexPattern keepAlive opts =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint emptyBody
  where
    endpoint =
      [unIndexPattern indexPattern, "_search", "point_in_time"]
        `withQueries` (("keep_alive", Just (keepAliveToText keepAlive)) : osPITOptionsParams opts)

-- | 'closePointInTime' closes a point in time given a 'ClosePointInTime'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/api-reference/search-apis/point-in-time-api/>.
closePointInTime ::
  ClosePointInTime ->
  BHRequest StatusDependant (ParsedEsResponse ClosePointInTimeResponse)
closePointInTime q = do
  withBHResponseParsedEsResponse $ deleteWithBody @StatusDependant ["_search", "point_in_time"] (encode q)

-- | 'listAllPITs' returns every currently-open point in time on the cluster
-- via @GET /_search/point_in_time/_all@. Each 'PITInfo' carries the pit
-- id, the @creation_time@ and the remaining @keep_alive@ (a bare
-- millisecond count on the wire); the @_shards@ summary is absent from
-- list entries (it appears only in the @POST@ create-PIT response, see
-- 'OpenPointInTimeResponse').
--
-- For more information see
-- <https://docs.opensearch.org/2.18/api-reference/search-apis/point-in-time-api/#list-all-pits>.
listAllPITs ::
  BHRequest StatusDependant [PITInfo]
listAllPITs =
  listPITs <$> get @StatusDependant ["_search", "point_in_time", "_all"]

-- | 'deleteAllPITs' deletes every currently-open point in time on the
-- cluster via @DELETE /_search/point_in_time/_all@. Unlike
-- 'closePointInTime' (the delete-by-ID endpoint, which takes a
-- 'ClosePointInTime' body), this call carries no request body. The
-- server processes each PIT individually and returns one
-- 'ClosePointInTimeResult' per deleted PIT (partial failures are
-- reported as failures); the response therefore reuses the
-- 'ClosePointInTimeResponse' envelope documented for the by-ID
-- endpoint.
--
-- Note: the Delete All PITs API deletes only local and mixed PITs; it
-- does not delete fully remote (cross-cluster) PITs.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/api-reference/search-apis/point-in-time-api/#delete-pits>.
deleteAllPITs ::
  BHRequest StatusDependant (ParsedEsResponse ClosePointInTimeResponse)
deleteAllPITs =
  withBHResponseParsedEsResponse $
    delete @StatusDependant ["_search", "point_in_time", "_all"]

-- | 'getMLTask' calls the ML Commons plugin Get Task API
-- (@GET /_plugins/_ml/tasks/{task_id}@). The plugin returns the current state
-- of a single asynchronous task created by @POST /_plugins/_ml/models/_register@,
-- @POST /_plugins/_ml/models/{id}/_deploy@, training, and friends. Callers
-- poll this endpoint with the @task_id@ returned by the submit call until
-- 'mlTaskInfoState' reaches 'MLTaskStateCompleted' or 'MLTaskStateFailed'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/get-task/>.
getMLTask ::
  MLTaskId ->
  BHRequest StatusDependant (ParsedEsResponse MLTaskInfo)
getMLTask mlTaskId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "tasks", unMLTaskId mlTaskId]

-- | 'putISMPolicy' creates or updates an ISM policy. Equivalent to
-- 'putISMPolicyWith' with 'Nothing' (no optimistic-concurrency guard), so
-- concurrent updates to the same policy id silently clobber each other.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#create-policy>
putISMPolicy ::
  PolicyId ->
  ISMPolicyRequest ->
  BHRequest StatusDependant (ParsedEsResponse PutISMPolicyResponse)
putISMPolicy policyId policy = putISMPolicyWith policyId policy Nothing

-- | 'putISMPolicyWith' creates or updates an ISM policy with an optional
-- optimistic-concurrency guard. Pass @'Just' (seqNo, primaryTerm)@ — both
-- values read from a prior 'PutISMPolicyResponse' — to make the PUT
-- conditional on the policy being unchanged since you last read it; a stale
-- pair causes OpenSearch to respond with HTTP 409 instead of silently
-- overwriting. Pass 'Nothing' for the unconditional semantics of
-- 'putISMPolicy'. The two values must be supplied together: OpenSearch
-- rejects a partial pair with HTTP 400 (mirroring the document-write
-- @if_seq_no@ / @if_primary_term@ semantics).
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#create-policy>
putISMPolicyWith ::
  PolicyId ->
  ISMPolicyRequest ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant (ParsedEsResponse PutISMPolicyResponse)
putISMPolicyWith policyId policy mOcc =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode policy)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_ism", "policies", unPolicyId policyId]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries baseEndpoint $
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- | 'addISMPolicy' applies an existing ISM policy to one or more indices.
-- The @policy_id@ in the body identifies the policy to attach. Indices that
-- already have a policy attached (or reject the operation for other reasons)
-- appear in the @failed_indices@ field of the response.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#add-policy>
addISMPolicy ::
  PolicyId ->
  IndexName ->
  BHRequest StatusDependant (ParsedEsResponse ISMUpdatedIndicesResponse)
addISMPolicy policyId indexName =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode body)
  where
    endpoint = mkEndpoint ["_plugins", "_ism", "add", unIndexName indexName]
    body = object ["policy_id" .= unPolicyId policyId]

-- | 'removeISMPolicy' detaches any ISM policy from the given index.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#remove-policy>
removeISMPolicy ::
  IndexName ->
  BHRequest StatusDependant (ParsedEsResponse ISMUpdatedIndicesResponse)
removeISMPolicy indexName =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (object []))
  where
    endpoint = mkEndpoint ["_plugins", "_ism", "remove", unIndexName indexName]

-- | 'changeISMPolicy' updates the managed-index policy attached to @index@.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#change-policy>
changeISMPolicy ::
  IndexName ->
  ChangePolicyRequest ->
  BHRequest StatusDependant (ParsedEsResponse ISMUpdatedIndicesResponse)
changeISMPolicy indexName req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ism", "change_policy", unIndexName indexName]

-- | 'retryISMIndex' retries the failed ISM action for an index.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#retry-failed-index>
retryISMIndex ::
  IndexName ->
  Maybe Text ->
  BHRequest StatusDependant (ParsedEsResponse ISMUpdatedIndicesResponse)
retryISMIndex indexName mState =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ism", "retry", unIndexName indexName]
    body = case mState of
      Nothing -> encode (object [])
      Just st -> encode (object ["state" .= st])

-- | 'explainISMIndex' returns the ISM state of @index@. Equivalent to
-- 'explainISMIndexWith' with 'defaultISMExplainOptions' (no query
-- parameters).
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#explain-index>
explainISMIndex ::
  IndexName ->
  BHRequest StatusDependant (ParsedEsResponse ISMExplanation)
explainISMIndex indexName = explainISMIndexWith indexName defaultISMExplainOptions

-- | 'explainISMIndexWith' returns the ISM state of @index@, forwarding the
-- supplied 'ISMExplainOptions' as query-string parameters. All four
-- documented parameters are emitted (@show_policy@, @validate_action@,
-- @local@, @expand_wildcards@); 'Nothing' \/ 'False' fields are omitted so
-- 'defaultISMExplainOptions' reproduces the wire shape of 'explainISMIndex'.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#explain-index>
explainISMIndexWith ::
  IndexName ->
  ISMExplainOptions ->
  BHRequest StatusDependant (ParsedEsResponse ISMExplanation)
explainISMIndexWith indexName opts =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_ism", "explain", unIndexName indexName]
    endpoint = withQueries baseEndpoint (ismExplainOptionsParams opts)

-- | 'explainISMIndexFiltered' returns the ISM state of @index@ via the
-- @POST /_plugins/_ism/explain/{index}@ "Explain index with filtering" form
-- (introduced in OpenSearch 2.12). The 'ISMExplainFilter' is sent as a
-- @{"filter": {...}}@ body; the server returns only indexes matching /all/
-- specified criteria. The response shape is identical to
-- 'explainISMIndex' ('ISMExplanation'). Pass 'defaultISMExplainFilter' for an
-- unfiltered POST.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#explain-index-with-filtering>
explainISMIndexFiltered ::
  IndexName ->
  ISMExplainFilter ->
  BHRequest StatusDependant (ParsedEsResponse ISMExplanation)
explainISMIndexFiltered indexName filt =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (ISMExplainFilterRequest filt))
  where
    endpoint = mkEndpoint ["_plugins", "_ism", "explain", unIndexName indexName]

-- | 'getISMPolicy' fetches a single policy by id.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#get-policy>
getISMPolicy ::
  PolicyId ->
  BHRequest StatusDependant (ParsedEsResponse ISMPolicyInfo)
getISMPolicy policyId =
  withBHResponseParsedEsResponse $
    get @StatusDependant (mkEndpoint ["_plugins", "_ism", "policies", unPolicyId policyId])

-- | 'getISMPolicies' lists policies, optionally filtered / paginated.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#get-policies>
getISMPolicies ::
  Maybe ISMPoliciesQuery ->
  BHRequest StatusDependant (ParsedEsResponse GetISMPoliciesResponse)
getISMPolicies mQuery =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_ism", "policies"]
    endpoint = case mQuery of
      Nothing -> baseEndpoint
      Just query -> withQueries baseEndpoint (ismPoliciesQueryToPairs query)

-- | 'deleteISMPolicy' deletes an ISM policy by id.
-- OpenSearch stores policies as documents in @.opendistro-ism-config@, so the
-- response is the standard document-delete envelope (parsed here as
-- 'IndexedDocument'), not an 'Acknowledged' ack.
-- See <https://docs.opensearch.org/2.18/im-plugin/ism/api/#delete-policy>
deleteISMPolicy ::
  PolicyId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteISMPolicy policyId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant (mkEndpoint ["_plugins", "_ism", "policies", unPolicyId policyId])

-- =========================================================================
-- Snapshot Management plugin
-- =========================================================================
--
-- The SM plugin runs snapshot creation/deletion on a schedule. Policies
-- live under @/_plugins/_sm/policies/{policy_name}@. The plugin was
-- introduced in OpenSearch 2.1; the path is identical across OS 2.x/3.x.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/>

-- | 'postSMPolicy' creates a new SM policy. The server rejects the request
-- with HTTP 409 if a policy with the same name already exists — use
-- 'putSMPolicyWith' for updates (which requires the current @_seq_no@ /
-- @_primary_term@ for optimistic concurrency).
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#create-or-update-policy>
postSMPolicy ::
  SMPolicyName ->
  SMPolicyBody ->
  BHRequest StatusDependant (ParsedEsResponse SMPolicyResponse)
postSMPolicy policyName body =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode body)
  where
    endpoint = mkEndpoint ["_plugins", "_sm", "policies", unSMPolicyName policyName]

-- | 'putSMPolicyWith' updates an existing SM policy. OpenSearch requires
-- the @if_seq_no@ / @if_primary_term@ pair for updates: pass the values
-- read from a prior 'SMPolicyResponse' (or the @_seq_no@ / @_primary_term@
-- of a create response). A stale pair causes HTTP 409; a missing pair
-- causes HTTP 400.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#create-or-update-policy>
putSMPolicyWith ::
  SMPolicyName ->
  SMPolicyBody ->
  Word64 ->
  Word64 ->
  BHRequest StatusDependant (ParsedEsResponse SMPolicyResponse)
putSMPolicyWith policyName body seqNo primaryTerm =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode body)
  where
    endpoint =
      withQueries
        (mkEndpoint ["_plugins", "_sm", "policies", unSMPolicyName policyName])
        [ ("if_seq_no", Just (showText seqNo)),
          ("if_primary_term", Just (showText primaryTerm))
        ]

-- | 'getSMPolicy' fetches a single SM policy by name.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#get-policy>
getSMPolicy ::
  SMPolicyName ->
  BHRequest StatusDependant (ParsedEsResponse SMPolicyResponse)
getSMPolicy policyName =
  withBHResponseParsedEsResponse $
    get @StatusDependant (mkEndpoint ["_plugins", "_sm", "policies", unSMPolicyName policyName])

-- | 'getSMPolicies' lists SM policies, optionally filtered / paginated via
-- a 'GetSMPoliciesQuery'. Pass 'Nothing' (or 'defaultGetSMPoliciesQuery')
-- to list all policies with server defaults.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#get-policies>
getSMPolicies ::
  Maybe GetSMPoliciesQuery ->
  BHRequest StatusDependant (ParsedEsResponse GetSMPoliciesResponse)
getSMPolicies mQuery =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_sm", "policies"]
    endpoint = case mQuery of
      Nothing -> baseEndpoint
      Just query -> withQueries baseEndpoint (getSMPoliciesQueryParams query)

-- | 'deleteSMPolicy' deletes an SM policy by name. The server returns the
-- standard OpenSearch document-delete envelope (SM policies are stored as
-- documents in @.opendistro-ism-config@).
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#delete-policy>
deleteSMPolicy ::
  SMPolicyName ->
  BHRequest StatusDependant (ParsedEsResponse DeleteSMPolicyResponse)
deleteSMPolicy policyName =
  withBHResponseParsedEsResponse $
    delete @StatusDependant (mkEndpoint ["_plugins", "_sm", "policies", unSMPolicyName policyName])

-- | 'startSMPolicy' enables an SM policy (sets @enabled=true@). The job
-- scheduler picks up the policy on its next tick.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#start-policy>
startSMPolicy ::
  SMPolicyName ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
startSMPolicy policyName =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant (mkEndpoint ["_plugins", "_sm", "policies", unSMPolicyName policyName, "_start"])

-- | 'stopSMPolicy' disables an SM policy (sets @enabled=false@). In-flight
-- snapshot operations are not interrupted; the policy simply stops
-- scheduling new ones.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#stop-policy>
stopSMPolicy ::
  SMPolicyName ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
stopSMPolicy policyName =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant (mkEndpoint ["_plugins", "_sm", "policies", unSMPolicyName policyName, "_stop"])

-- | 'explainSMPolicies' returns the running state and metadata of the
-- policies matching the supplied name expression. The expression is a
-- comma-separated list and/or a wildcard pattern (e.g. @"daily*"@,
-- @"policy-a,policy-b"@); pass @"*"@ to explain all policies.
-- See <https://docs.opensearch.org/latest/tuning-your-cluster/availability-and-recovery/snapshots/sm-api/#explain-policy>
explainSMPolicies ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse SMExplainResponse)
explainSMPolicies policyNames =
  withBHResponseParsedEsResponse $
    get @StatusDependant (mkEndpoint ["_plugins", "_sm", "policies", policyNames, "_explain"])

-- | 'getMLStats' calls the ML Commons plugin Stats API
-- (@GET /_plugins/_ml/[{nodeId}/]stats[/{stat}]@). Both arguments are
-- optional and select, respectively, which node(s) to query (defaults to all)
-- and which stat(s) to return (defaults to all). The ML Commons stats
-- endpoint is always available whenever the plugin is installed — no cluster
-- setting needs to be enabled.
--
-- The response body is a JSON object whose top-level keys are either
-- cluster-level stat names (mapping to scalars) or the literal key @nodes@
-- (mapping to a per-node map keyed by opaque node ID); see the module docs
-- of 'MLStats' for why the entries are kept as aeson 'Value's rather than
-- modelled per-stat.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/ml-commons-plugin/api/stats/>.
getMLStats ::
  Maybe MLNodeId ->
  Maybe MLStatName ->
  BHRequest StatusDependant (ParsedEsResponse MLStats)
getMLStats mNodeId mStatName =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint segments
    segments =
      ["_plugins", "_ml"]
        <> foldMap (pure . unMLNodeId) mNodeId
        <> ["stats"]
        <> foldMap (pure . unMLStatName) mStatName

-- =========================================================================
-- ML Commons model APIs
-- =========================================================================

-- | 'getModel' calls the ML Commons Get Model API
-- (@GET /_plugins/_ml/models/{model_id}@). Returns the model's full metadata.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/get-model/>.
getModel ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse ModelInfo)
getModel modelId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "models", unModelId modelId]

-- | 'registerModel' registers a model. Equivalent to 'registerModelWith'
-- with @deploy = False@. Registration is asynchronous; poll the returned
-- @task_id@ via the Get ML Task API.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/register-model/>
registerModel ::
  RegisterModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse MLTaskAck)
registerModel = registerModelWith False

-- | 'registerModelWith' registers a model with an optional @?deploy=true@
-- query flag that chains a deploy after successful registration.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/register-model/>
registerModelWith ::
  Bool ->
  RegisterModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse MLTaskAck)
registerModelWith deploy req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_ml", "models", "_register"]
    endpoint =
      if deploy
        then withQueries baseEndpoint [("deploy", Just "true")]
        else baseEndpoint

-- | 'deployModel' loads a registered model into memory on ML worker nodes.
-- Pass 'Nothing' to let OpenSearch pick eligible nodes; pass a
-- 'DeployModelRequest' to restrict to a node list. Deployment is
-- asynchronous; poll the returned @task_id@ via the Get ML Task API.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/deploy-model/>
deployModel ::
  ModelId ->
  Maybe DeployModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse MLTaskAck)
deployModel modelId mReq =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "models", unModelId modelId, "_deploy"]
    body = maybe (encode (object [])) encode mReq

-- | 'deleteModel' deletes a registered model by id. OpenSearch stores
-- models as documents in @.plugins-ml-model@, so the response is the
-- standard document-delete envelope (parsed here as 'IndexedDocument'),
-- not an 'Acknowledged' ack — identical to 'deleteISMPolicy'. A
-- deployed model must be undeployed before it can be deleted, otherwise
-- the server returns an error which surfaces as an 'EsError' under
-- 'StatusDependant'.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/delete-model/>
deleteModel ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteModel modelId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "models", unModelId modelId]

-- | 'undeployModel' unloads a deployed model from ML worker nodes
-- (the inverse of 'deployModel'). Synchronous; returns a node-keyed
-- stats map ('UndeployModelResponse'), not a task ack.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/undeploy-model/>
undeployModel ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse UndeployModelResponse)
undeployModel modelId =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (object []))
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "models", unModelId modelId, "_undeploy"]

-- | 'updateModel' updates a subset of a registered model's metadata.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/update-model/>
updateModel ::
  ModelId ->
  UpdateModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
updateModel modelId req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "models", unModelId modelId]

-- | 'searchModels' searches the model index via
-- @POST /_plugins/_ml/models/_search@.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-apis/search-model/>
searchModels ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse ModelSearchResponse)
searchModels = searchModelsOn "_search"

-- | 'listModels' lists models. Historically this hit the
-- @POST /_plugins/_ml/models/_list@ endpoint, but OS 2.x removed POST
-- from @_list@ (it now returns 405). The modern, supported endpoint
-- for listing is @POST /_plugins/_ml/models/_search@ — the same one
-- 'searchModels' uses. Kept as a separate function rather than aliased
-- to 'searchModels' so callers and telemetry can distinguish the two
-- call sites.
listModels ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse ModelSearchResponse)
listModels = searchModelsOn "_search"

-- | Shared body of 'searchModels' and 'listModels'.
searchModelsOn ::
  Text ->
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse ModelSearchResponse)
searchModelsOn suffix mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "models", suffix]
    body = encode (maybe (object []) id mQuery)

-- =========================================================================
-- ML Commons agent APIs
-- =========================================================================

-- | 'registerAgent' registers an ML Commons agent (OS 2.12+) — a named
-- configuration that binds a large-language model (or a routed
-- population of sub-agents and tools) behind a server-assigned
-- 'AgentId'. Unlike 'registerModel', registration is synchronous: the
-- POST persists a single config document in the @.plugins-ml-agent@
-- system index and returns @{"agent_id": "..."}@ directly, so the
-- returned 'RegisterAgentResponse' carries no @task_id@ to poll and the
-- agent is callable immediately (subject to its bound model being
-- deployed).
--
-- Surfaced as 'StatusDependant' so a 4xx (e.g. an unknown tool @type@,
-- a missing @llm@ on a @conversation@ agent, or an @app_type@ mismatch
-- on a @template@ agent) decodes as an 'EsError' rather than throwing.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/agent-apis/register-agent/>
registerAgent ::
  RegisterAgentRequest ->
  BHRequest StatusDependant (ParsedEsResponse RegisterAgentResponse)
registerAgent req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "agents", "_register"]

-- | 'getAgent' fetches a registered agent by id. The body is the bare
-- stored agent document, decoded as 'AgentInfo'.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/agent-apis/get-agent/>
getAgent ::
  AgentId ->
  BHRequest StatusDependant (ParsedEsResponse AgentInfo)
getAgent agentId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "agents", unAgentId agentId]

-- | 'deleteAgent' deletes a registered agent by id. The response is
-- the standard document-delete envelope ('IndexedDocument').
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/agent-apis/delete-agent/>
deleteAgent ::
  AgentId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteAgent agentId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "agents", unAgentId agentId]

-- | 'searchAgents' searches the agent index via
-- @POST /_plugins/_ml/agents/_search@.
--
-- /Empty-cluster handling:/ when no agent has ever been registered
-- the @.plugins-ml-agent@ system index does not exist, and OpenSearch
-- responds with HTTP 404 @index_not_found_exception@ instead of an
-- empty search result. This wrapper detects that 404 and substitutes
-- 'emptyAgentSearchResponse', so callers see a uniform @Right empty@
-- regardless of whether any agent has been registered.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/agent-apis/search-agent/>
searchAgents ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse AgentSearchResponse)
searchAgents mQuery = withBHResponseParsedEsResponse innerReq
  where
    innerReq = baseInner {bhRequestParser = innerParser}
    baseInner = post @StatusDependant @AgentSearchResponse endpoint body
    endpoint = mkEndpoint ["_plugins", "_ml", "agents", "_search"]
    body = encode (maybe (object []) id mQuery)
    innerParser resp
      | isAgentsIndexMissing404 resp = Right (Right emptyAgentSearchResponse)
      | otherwise = bhRequestParser baseInner resp
    isAgentsIndexMissing404 resp =
      statusCodeIs (404, 404) resp
        && case eitherDecode (responseBody (getResponse resp)) of
          Right es -> "no such index" `T.isInfixOf` errorMessage (es :: EsError)
          Left _ -> False

-- | 'executeAgent' runs a registered agent synchronously via
-- @POST /_plugins/_ml/agents/{agent_id}/_execute@ (the Execute Agent
-- API, introduced in OpenSearch 2.13 — available on OpenSearch 2.x).
-- The agent runs its configured tools and returns an
-- 'ExecuteAgentResponse' carrying the @inference_results.output[]@
-- list. The request body is the permissive 'ExecuteAgentRequest'
-- (opaque @parameters@ map and\/or polymorphic @input@).
--
-- For asynchronous execution (OpenSearch 3.0+; not available on OS 2.x)
-- use 'Database.Bloodhound.OpenSearch3.Client.executeAgentAsync' on an
-- OpenSearch 3 client. The asynchronous variant is intentionally not
-- exposed on OpenSearch 2 because its @?async=true@ query parameter is
-- rejected by OS 2.x servers.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/agent-apis/execute-agent/>
executeAgent ::
  AgentId ->
  ExecuteAgentRequest ->
  BHRequest StatusDependant (ParsedEsResponse ExecuteAgentResponse)
executeAgent agentId req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "agents", unAgentId agentId, "_execute"]

-- | 'predict' invokes a deployed model. The path carries an
-- @algorithm_name@ and a @model_id@; the request body and the response are
-- model-specific, so both are typed as opaque 'Value's.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/ml-commons-plugin/api/train-predict/predict/>.
predict ::
  AlgorithmName ->
  ModelId ->
  Value ->
  BHRequest StatusDependant (ParsedEsResponse Value)
predict algo modelId body =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode body)
  where
    endpoint =
      mkEndpoint
        ["_plugins", "_ml", "_predict", unAlgorithmName algo, unModelId modelId]

-- =========================================================================
-- ML Commons train and task APIs
-- =========================================================================

-- | 'trainModel' trains a classical algorithm (kmeans, RCF, ...) on a
-- dataset, returning either a @model_id@ (sync, the default) or a
-- @task_id@ (when 'trainModelWith' is called with @async = True@). The
-- response shape is the same 'MLTaskAck' in both cases — sync populates
-- 'mlTaskAckModelId', async populates 'mlTaskAckTaskId'.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/train-predict/train/>.
trainModel ::
  AlgorithmName ->
  TrainModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse MLTaskAck)
trainModel = trainModelWith False

-- | 'trainModelWith' is the async-flagged variant of 'trainModel'. Pass
-- @True@ to run training asynchronously (the call returns a @task_id@
-- immediately; poll it via 'getMLTask').
trainModelWith ::
  Bool ->
  AlgorithmName ->
  TrainModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse MLTaskAck)
trainModelWith asyncFlag algorithm req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_ml", "_train", unAlgorithmName algorithm]
    endpoint =
      if asyncFlag
        then withQueries baseEndpoint [("async", Just "true")]
        else baseEndpoint

-- | 'trainAndPredictModel' trains an unsupervised algorithm on a dataset
-- and immediately runs prediction against the same data, returning the
-- prediction payload as 'TrainAndPredictResponse'.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/train-predict/train-and-predict/>.
trainAndPredictModel ::
  AlgorithmName ->
  TrainModelRequest ->
  BHRequest StatusDependant (ParsedEsResponse TrainAndPredictResponse)
trainAndPredictModel algorithm req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "_train_predict", unAlgorithmName algorithm]

-- | 'searchMLTasks' searches the @.plugins-ml-task@ system index via
-- @POST /_plugins/_ml/tasks/_search@. Pass 'Nothing' for the plain
-- empty-body @{}@ POST; pass @'Just' query@ to filter (e.g. by
-- @function_name@) or paginate. The @.plugins-ml-task@ index does not
-- exist until the first asynchronous ML operation runs, so a 404
-- @index_not_found@ is decoded as an empty result (mirroring
-- 'searchAgents').
searchMLTasks ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse (SearchResult MLTaskInfo))
searchMLTasks mQuery =
  withBHResponseParsedEsResponse $
    searchMLWithEmptyIndex404 $
      post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "tasks", "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'deleteMLTask' deletes an ML task by id via
-- @DELETE /_plugins/_ml/tasks/{task_id}@. The response is the standard
-- document-delete envelope ('IndexedDocument'). The plugin does not
-- check task status before deleting — a running task can be deleted.
deleteMLTask ::
  MLTaskId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteMLTask taskId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "tasks", unMLTaskId taskId]

-- =========================================================================
-- ML Commons model group APIs
-- =========================================================================

-- | 'registerModelGroup' registers a model group via
-- @POST /_plugins/_ml/model_groups/_register@ (OS 2.12+).
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/model-group-apis/register-model-group/>.
registerModelGroup ::
  RegisterModelGroupRequest ->
  BHRequest StatusDependant (ParsedEsResponse RegisterModelGroupResponse)
registerModelGroup req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "model_groups", "_register"]

-- | 'updateModelGroup' updates a model group via
-- @PUT /_plugins/_ml/model_groups/{model_group_id}@. The body is a
-- partial update; the response is the standard document-update envelope.
updateModelGroup ::
  ModelGroupId ->
  UpdateModelGroupRequest ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
updateModelGroup groupId req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "model_groups", unModelGroupId groupId]

-- | 'getModelGroup' fetches a model group by id via
-- @GET /_plugins/_ml/model_groups/{model_group_id}@.
getModelGroup ::
  ModelGroupId ->
  BHRequest StatusDependant (ParsedEsResponse ModelGroupInfo)
getModelGroup groupId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "model_groups", unModelGroupId groupId]

-- | 'searchModelGroups' searches the @.plugins-ml-model-group@ index via
-- @POST /_plugins/_ml/model_groups/_search@. The backing index does not
-- exist until the first group is registered, so a 404 is decoded as an
-- empty result.
searchModelGroups ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse (SearchResult ModelGroupInfo))
searchModelGroups mQuery =
  withBHResponseParsedEsResponse $
    searchMLWithEmptyIndex404 $
      post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "model_groups", "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'deleteModelGroup' deletes a model group via
-- @DELETE /_plugins/_ml/model_groups/{model_group_id}@. Idempotent: a
-- missing group returns HTTP 200 with @result = "not_found"@ (not a
-- 404). A group can only be manually deleted if it has no model
-- versions.
deleteModelGroup ::
  ModelGroupId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteModelGroup groupId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "model_groups", unModelGroupId groupId]

-- =========================================================================
-- ML Commons connector APIs
-- =========================================================================

-- | 'createConnector' creates a remote-model connector via
-- @POST /_plugins/_ml/connectors/_create@ (OS 2.12+). @credential@ is
-- write-only and never returned by subsequent get\/search calls.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/connector-apis/create-connector/>.
createConnector ::
  CreateConnectorRequest ->
  BHRequest StatusDependant (ParsedEsResponse CreateConnectorResponse)
createConnector req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "connectors", "_create"]

-- | 'getConnector' fetches a connector by id via
-- @GET /_plugins/_ml/connectors/{connector_id}@. The @credential@ field
-- is never present in the response.
getConnector ::
  ConnectorId ->
  BHRequest StatusDependant (ParsedEsResponse ConnectorInfo)
getConnector connId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "connectors", unConnectorId connId]

-- | 'searchConnectors' searches the @.plugins-ml-connector@ index via
-- @POST /_plugins/_ml/connectors/_search@. The backing index does not
-- exist until the first connector is created, so a 404 is decoded as an
-- empty result.
searchConnectors ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse (SearchResult ConnectorInfo))
searchConnectors mQuery =
  withBHResponseParsedEsResponse $
    searchMLWithEmptyIndex404 $
      post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "connectors", "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'updateConnector' updates a connector via
-- @PUT /_plugins/_ml/connectors/{connector_id}@ (OS 2.12+). Partial
-- update; omitted fields are preserved. All models using the connector
-- must be undeployed before updating.
updateConnector ::
  ConnectorId ->
  UpdateConnectorRequest ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
updateConnector connId req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "connectors", unConnectorId connId]

-- | 'deleteConnector' deletes a connector via
-- @DELETE /_plugins/_ml/connectors/{connector_id}@. Idempotent: a
-- missing connector returns HTTP 200 with @result = "not_found"@.
deleteConnector ::
  ConnectorId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteConnector connId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "connectors", unConnectorId connId]

-- =========================================================================
-- ML Commons memory and message APIs
-- =========================================================================

-- | 'createMemory' creates a conversation memory via
-- @POST /_plugins/_ml/memory/@ (OS 2.12+).
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/memory-apis/create-memory/>.
createMemory ::
  CreateMemoryRequest ->
  BHRequest StatusDependant (ParsedEsResponse CreateMemoryResponse)
createMemory req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory"]

-- | 'updateMemory' updates a memory's @name@ via
-- @PUT /_plugins/_ml/memory/{memory_id}@. The response is the standard
-- document-update envelope.
updateMemory ::
  MemoryId ->
  CreateMemoryRequest ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
updateMemory memoryId req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", unMemoryId memoryId]

-- | 'getMemory' fetches a single memory by id via
-- @GET /_plugins/_ml/memory/{memory_id}@.
getMemory ::
  MemoryId ->
  BHRequest StatusDependant (ParsedEsResponse Memory)
getMemory memoryId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", unMemoryId memoryId]

-- | 'listMemories' lists memories (newest first) via
-- @GET /_plugins/_ml/memory@. Both arguments are optional query
-- parameters: @maxResults@ maps to @max_results@ (default 10) and
-- @nextToken@ maps to @next_token@ (default 0, the index of the first
-- memory to return).
listMemories ::
  Maybe Int ->
  Maybe Int ->
  BHRequest StatusDependant (ParsedEsResponse ListMemoriesResponse)
listMemories mMaxResults mNextToken =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_ml", "memory"]
    endpoint = withQueries baseEndpoint renderedPairs
    renderedPairs =
      catMaybes
        [ fmap (\n -> ("max_results", Just (T.pack (show n)))) mMaxResults,
          fmap (\n -> ("next_token", Just (T.pack (show n)))) mNextToken
        ]

-- | 'searchMemory' searches the @.plugins-ml-memory-meta@ index via
-- @POST /_plugins/_ml/memory/_search@. The backing index does not exist
-- until the first memory is created, so a 404 is decoded as an empty
-- result.
searchMemory ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse (SearchResult Memory))
searchMemory mQuery =
  withBHResponseParsedEsResponse $
    searchMLWithEmptyIndex404 $
      post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'deleteMemory' deletes a memory by id via
-- @DELETE /_plugins/_ml/memory/{memory_id}@. The response is
-- @{success: <bool>}@. Unlike connector \/ model-group delete, memory
-- delete is /not/ idempotent — a missing memory returns HTTP 404.
deleteMemory ::
  MemoryId ->
  BHRequest StatusDependant (ParsedEsResponse DeleteMemoryResponse)
deleteMemory memoryId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", unMemoryId memoryId]

-- | 'createMemoryMessage' appends a message to a memory via
-- @POST /_plugins/_ml/memory/{memory_id}/messages@. At least one
-- request field must be non-null.
createMemoryMessage ::
  MemoryId ->
  CreateMemoryMessageRequest ->
  BHRequest StatusDependant (ParsedEsResponse CreateMemoryMessageResponse)
createMemoryMessage memoryId req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", unMemoryId memoryId, "messages"]

-- | 'updateMemoryMessage' updates a message's @additional_info@ via
-- @PUT /_plugins/_ml/memory/message/{message_id}@. Only @additional_info@
-- is updatable; the other 'CreateMemoryMessageRequest' fields are
-- write-once and ignored on update. The response is the standard
-- document-update envelope.
updateMemoryMessage ::
  MessageId ->
  CreateMemoryMessageRequest ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
updateMemoryMessage messageId req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", "message", unMessageId messageId]

-- | 'getMemoryMessage' fetches a single message by id via
-- @GET /_plugins/_ml/memory/message/{message_id}@.
getMemoryMessage ::
  MessageId ->
  BHRequest StatusDependant (ParsedEsResponse MemoryMessage)
getMemoryMessage messageId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", "message", unMessageId messageId]

-- | 'listMemoryMessages' lists the messages in a memory via
-- @GET /_plugins/_ml/memory/{memory_id}/messages@.
listMemoryMessages ::
  MemoryId ->
  BHRequest StatusDependant (ParsedEsResponse ListMemoryMessagesResponse)
listMemoryMessages memoryId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", unMemoryId memoryId, "messages"]

-- | 'searchMemoryMessages' searches the messages of a memory via
-- @POST /_plugins/_ml/memory/{memory_id}/_search@. The backing
-- @.plugins-ml-memory-message@ index does not exist until the first
-- message is created, so a 404 is decoded as an empty result.
searchMemoryMessages ::
  MemoryId ->
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse (SearchResult MemoryMessage))
searchMemoryMessages memoryId mQuery =
  withBHResponseParsedEsResponse $
    searchMLWithEmptyIndex404 $
      post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", unMemoryId memoryId, "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'getMemoryTraces' fetches the trace messages of a message via
-- @GET /_plugins/_ml/memory/message/{message_id}/traces@. Traces are
-- diagnostic sub-messages recording an agent's intermediate tool calls.
getMemoryTraces ::
  MessageId ->
  BHRequest StatusDependant (ParsedEsResponse MemoryMessageTraces)
getMemoryTraces messageId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "memory", "message", unMessageId messageId, "traces"]

-- =========================================================================
-- ML Commons controller APIs
-- =========================================================================

-- | 'createController' creates a per-user rate-limit controller for a
-- model via @POST /_plugins/_ml/controllers/{model_id}@ (OS 2.12+).
-- POST overwrites the entire @user_rate_limiter@ map. The controller is
-- keyed by the model's id.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/controller-apis/create-controller/>.
createController ::
  ModelId ->
  ControllerConfig ->
  BHRequest StatusDependant (ParsedEsResponse ControllerCreateResponse)
createController modelId config =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode config)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "controllers", unModelId modelId]

-- | 'updateController' updates a controller via
-- @PUT /_plugins/_ml/controllers/{model_id}@. PUT updates individual
-- user entries, preserving others. The response is the standard
-- document-update envelope.
updateController ::
  ModelId ->
  ControllerConfig ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
updateController modelId config =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode config)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "controllers", unModelId modelId]

-- | 'getController' fetches a controller by model id via
-- @GET /_plugins/_ml/controllers/{model_id}@. Returns HTTP 404 when no
-- controller exists for the model.
getController ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse ControllerConfig)
getController modelId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "controllers", unModelId modelId]

-- | 'deleteController' deletes a controller via
-- @DELETE /_plugins/_ml/controllers/{model_id}@. Returns HTTP 404 with
-- @index_not_found_exception@ when the @.plugins-ml-controller@ index
-- does not exist.
deleteController ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse IndexedDocument)
deleteController modelId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "controllers", unModelId modelId]

-- =========================================================================
-- ML Commons profile API
-- =========================================================================

-- | 'getMLProfile' profiles ML models and tasks across all nodes via
-- @GET /_plugins/_ml/profile@. Pass @'Just' body@ to filter by
-- @node_ids@ \/ @model_ids@ \/ @task_ids@ or to toggle
-- @return_all_tasks@ \/ @return_all_models@; pass 'Nothing' for the
-- bodyless form that returns everything. Use 'getMLProfileModels',
-- 'getMLProfileModel', 'getMLProfileTasks', or 'getMLProfileTask' for
-- the path-scoped forms.
-- See <https://docs.opensearch.org/2.18/ml-commons-plugin/api/profile/>.
getMLProfile ::
  Maybe MLProfileRequest ->
  BHRequest StatusDependant (ParsedEsResponse MLProfileResponse)
getMLProfile mReq =
  withBHResponseParsedEsResponse $
    case mReq of
      Nothing -> get @StatusDependant endpoint
      Just req -> getWithBody @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "profile"]

-- | 'getMLProfileModels' profiles all deployed models via
-- @GET /_plugins/_ml/profile/models@.
getMLProfileModels ::
  BHRequest StatusDependant (ParsedEsResponse MLProfileResponse)
getMLProfileModels =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "profile", "models"]

-- | 'getMLProfileModel' profiles one or more models via
-- @GET /_plugins/_ml/profile/models/{model_id}@. Pass multiple ids to
-- fetch several profiles at once (rendered as a single comma-separated
-- path segment).
getMLProfileModel ::
  [ModelId] ->
  BHRequest StatusDependant (ParsedEsResponse MLProfileResponse)
getMLProfileModel modelIds =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    segment = T.intercalate "," (map unModelId modelIds)
    endpoint = mkEndpoint ["_plugins", "_ml", "profile", "models", segment]

-- | 'getMLProfileTasks' profiles all ML tasks via
-- @GET /_plugins/_ml/profile/tasks@.
getMLProfileTasks ::
  BHRequest StatusDependant (ParsedEsResponse MLProfileResponse)
getMLProfileTasks =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_ml", "profile", "tasks"]

-- | 'getMLProfileTask' profiles one or more tasks via
-- @GET /_plugins/_ml/profile/tasks/{task_id}@. Pass multiple ids to
-- fetch several profiles at once (rendered as a single comma-separated
-- path segment).
getMLProfileTask ::
  [MLTaskId] ->
  BHRequest StatusDependant (ParsedEsResponse MLProfileResponse)
getMLProfileTask taskIds =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    segment = T.intercalate "," (map unMLTaskId taskIds)
    endpoint = mkEndpoint ["_plugins", "_ml", "profile", "tasks", segment]

-- =========================================================================
-- ML Commons search helpers
-- =========================================================================

-- | Empty 'SearchResult' substituted by 'searchMLWithEmptyIndex404' when
-- a plugin's backing @.plugins-ml-*@ system index does not yet exist.
emptyMLSearchResult :: SearchResult a
emptyMLSearchResult =
  SearchResult
    { took = 0,
      timedOut = False,
      shards = ShardResult 0 0 0 0,
      searchHits = mempty,
      aggregations = Nothing,
      scrollId = Nothing,
      suggest = Nothing,
      pitId = Nothing
    }

-- | Wrap a plugin search 'BHRequest' so that the HTTP 404
-- @index_not_found_exception@ the plugin returns when its backing
-- @.plugins-ml-*@ system index does not yet exist decodes as
-- 'emptyMLSearchResult' instead of an 'EsError'. Mirrors the
-- 'searchAgents' empty-cluster handling.
searchMLWithEmptyIndex404 ::
  BHRequest StatusDependant (SearchResult a) ->
  BHRequest StatusDependant (SearchResult a)
searchMLWithEmptyIndex404 base = base {bhRequestParser = parser}
  where
    parser resp
      | isMLIndexMissing404 resp = Right (Right emptyMLSearchResult)
      | otherwise = bhRequestParser base resp
    isMLIndexMissing404 resp =
      statusCodeIs (404, 404) resp
        && case eitherDecode (responseBody (getResponse resp)) of
          Right es -> "no such index" `T.isInfixOf` errorMessage (es :: EsError)
          Left _ -> False

-- =========================================================================
-- k-NN plugin
-- =========================================================================

-- | 'warmupKnnIndex' calls the k-NN plugin Warmup API
-- (@GET /_plugins/_knn/warmup/{index}@). Loads the k-NN native library
-- indices for the supplied indices into memory on the data nodes so the
-- first k-NN search against each index does not pay the native-engine
-- load latency. The request carries no body; the response is a
-- 'ShardsResult' (@{\"_shards\":{...}}@ envelope) reporting how many
-- shards scheduled the load.
--
-- The @index@ path segment follows the OpenSearch multi-target syntax: the
-- supplied list is rendered as a single comma-separated segment
-- (e.g. @[i1, i2]@ becomes @i1,i2@). An empty list renders an empty
-- segment and the server will respond with an error.
--
-- The warmup is asynchronous: the response is returned as soon as the
-- load is scheduled, not when it completes.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#warmup-operation>.
warmupKnnIndex ::
  [IndexName] ->
  BHRequest StatusIndependant ShardsResult
warmupKnnIndex indexNames =
  get endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_knn", "warmup", T.intercalate "," (map unIndexName indexNames)]

-- | 'clearKnnCache' calls the k-NN plugin Clear Cache API
-- (@POST /_plugins/_knn/clear_cache/{index}@), available since OpenSearch 2.14.
-- Drops the native cache entries that @faiss@ and @nmslib@ (deprecated)
-- maintain for the supplied indices, freeing the associated native-memory
-- reservations. This API has no effect on @lucene@ indexes — their graph
-- lives in the Lucene heap and is not cleared here. The request body is
-- an empty JSON object (@{}@); the response is a 'ShardsResult'
-- (@{\"_shards\":{...}}@ envelope) reporting how many shards were
-- cleared. The resulting cache-size drop is visible immediately in
-- 'getKnnStats'.
--
-- The @index@ path segment follows the OpenSearch multi-target syntax: the
-- supplied list is rendered as a single comma-separated segment
-- (e.g. @[i1, i2]@ becomes @i1,i2@). An empty list renders an empty
-- segment and the server will respond with an error.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#k-nn-clear-cache>.
clearKnnCache ::
  [IndexName] ->
  BHRequest StatusIndependant ShardsResult
clearKnnCache indexNames =
  post endpoint (encode (object []))
  where
    endpoint = mkEndpoint ["_plugins", "_knn", "clear_cache", T.intercalate "," (map unIndexName indexNames)]

-- | 'getKnnStats' calls the k-NN plugin Stats API
-- (@GET /_plugins/_knn/[{nodeId}/]stats[/{stat}]@). Both arguments are
-- optional and select, respectively, which node(s) to query (defaults to all)
-- and which stat(s) to return (defaults to all).
--
-- The response body is a typed envelope ('KnnStats') — a flat
-- 'Map.Map Text Value' whose top-level keys are either cluster-level
-- stat names (mapping to scalars) or the literal @nodes@ key (mapping to
-- a per-node map keyed by opaque node ID); see the module docs of
-- 'KnnStats' for why the entries are kept as aeson 'Value's rather than
-- modelled per-stat, and for how to navigate to a given node or cluster
-- value.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#stats>.
getKnnStats ::
  Maybe KnnNodeId ->
  Maybe KnnStatName ->
  BHRequest StatusDependant (ParsedEsResponse KnnStats)
getKnnStats mNodeId mStatName =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint segments
    segments =
      ["_plugins", "_knn"]
        <> foldMap (pure . unKnnNodeId) mNodeId
        <> ["stats"]
        <> foldMap (pure . unKnnStatName) mStatName

-- | 'getKnnModel' calls the k-NN plugin Get Model API
-- (@GET /_plugins/_knn/models/{model_id}@). Returns the model's metadata,
-- including its lifecycle 'KnnModelState', the base64-serialized
-- 'knnModelModelBlob' (large, often excluded server-side via @?filter_path@),
-- the vector 'knnModelDimension' and distance 'knnModelSpaceType', and the
-- native 'knnModelEngine' (@faiss@ or deprecated @nmslib@). The response is a
-- bare object (no envelope), decoded into 'KnnModel'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#get-a-model>.
getKnnModel ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse KnnModel)
getKnnModel modelId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_knn", "models", unModelId modelId]

-- | 'trainKnnModel' calls the k-NN plugin Train Model API
-- (@POST /_plugins/_knn/models/{model_id}/_train@). Schedules asynchronous
-- training of a native library model (FAISS IVF and friends) from the
-- @knn_vector@ field of a training index. The request returns as soon as
-- training is scheduled, carrying only the @model_id@ in
-- 'KnnTrainResponse'; the model enters the @training@ state. Poll
-- 'getKnnModel' to observe the transition to @created@ (success) or
-- @failed@ (with the @error@ field populated).
--
-- Equivalent to 'trainKnnModelWith' with @Nothing@ (no node preference).
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#train-a-model>.
trainKnnModel ::
  ModelId ->
  KnnTrainingRequest ->
  BHRequest StatusDependant (ParsedEsResponse KnnTrainResponse)
trainKnnModel = trainKnnModelWith Nothing

-- | 'trainKnnModelWith' extends 'trainKnnModel' with an optional
-- @?preference@ query parameter. Supplying 'Just' a 'KnnNodeId' pins the
-- asynchronous training task to the specified data node, sent on the wire
-- as @?preference={node_id}@; 'Nothing' lets OpenSearch choose the training
-- node. The pinned node must already host a shard of the training index,
-- otherwise OpenSearch rejects the request.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#train-a-model>.
trainKnnModelWith ::
  Maybe KnnNodeId ->
  ModelId ->
  KnnTrainingRequest ->
  BHRequest StatusDependant (ParsedEsResponse KnnTrainResponse)
trainKnnModelWith mNodeId modelId req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_knn", "models", unModelId modelId, "_train"]
    endpoint = case mNodeId of
      Nothing -> baseEndpoint
      Just nodeId -> withQueries baseEndpoint [("preference", Just (unKnnNodeId nodeId))]

-- | 'deleteKnnModel' calls the k-NN plugin Delete Model API
-- (@DELETE /_plugins/_knn/models/{model_id}@). Removes a trained model
-- from the cluster's @.opensearch-knn-models@ system index. The response
-- is a 'KnnDeleteModelResponse' echoing the @model_id@ and reporting
-- @result = "deleted"@ — note this is NOT the standard 'Acknowledged'
-- envelope (the k-NN plugin returns a richer response than the typical
-- cluster-level ack). A 404 for a missing model id surfaces as an
-- 'EsError' under 'StatusDependant', matching 'getKnnModel' and the ML
-- Commons 'deleteModel' precedent.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#delete-a-model>.
deleteKnnModel ::
  ModelId ->
  BHRequest StatusDependant (ParsedEsResponse KnnDeleteModelResponse)
deleteKnnModel modelId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_knn", "models", unModelId modelId]

-- | 'searchKnnModels' searches the @.opensearch-knn-models@ system index
-- via @POST /_plugins/_knn/models/_search@ (k-NN plugin Search Models
-- API). The caller drives the query entirely through the standard
-- bloodhound 'Search' DSL — pagination (@from@\/@size@), filtering,
-- sorting, and @_source@ projection (use 'source' to exclude the large
-- 'knnModelModelBlob', mirroring the docs'
-- @_source_excludes=model_blob@ example).
--
-- The response is the standard OpenSearch search envelope, decoded as
-- 'SearchResult' whose hits carry a 'KnnModel' in each @_source@. The
-- 'KnnModel' parser tolerates any field except @model_id@ being absent,
-- so a blob-excluding @_source@ projection decodes cleanly.
--
-- Surfaced as 'StatusDependant' so a non-2xx response is parsed as an
-- 'EsError' and thrown by 'performBHRequest' — matching the vanilla
-- search envelope rather than 'getKnnModel' and 'deleteKnnModel', which
-- capture the error as a value via 'ParsedEsResponse'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/vector-search/api/knn/#search-for-a-model>.
searchKnnModels ::
  Search ->
  BHRequest StatusDependant (SearchResult KnnModel)
searchKnnModels search =
  post @StatusDependant endpoint (encode search)
  where
    endpoint = mkEndpoint ["_plugins", "_knn", "models", "_search"]

-- =========================================================================
-- Async Search plugin
-- =========================================================================

-- | 'submitOSAsyncSearch' submits a 'Search' for asynchronous execution via
-- the OpenSearch async search plugin (@POST /_plugins/_asynchronous_search@). It
-- returns immediately with an id and partial results; poll the returned id
-- with 'getOSAsyncSearch' until 'asyncSearchIsRunning' is @False@.
--
-- The OpenSearch async search response is shaped exactly like Elasticsearch's
-- (the plugin was forked from ES 7.7's async search), so the result is decoded
-- into the shared 'AsyncSearchResult' — no OpenSearch-specific type is needed.
--
-- Equivalent to @'submitOSAsyncSearchWith' 'defaultAsyncSearchSubmitOptions'@
-- — it forwards no URI parameters.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/search-plugins/async/index/#submit>.
submitOSAsyncSearch ::
  (FromJSON a) =>
  Search ->
  BHRequest StatusDependant (AsyncSearchResult a)
submitOSAsyncSearch = submitOSAsyncSearchWith defaultAsyncSearchSubmitOptions

-- | Like 'submitOSAsyncSearch' but accepts 'AsyncSearchSubmitOptions'
-- carrying the URI parameters documented for
-- @POST /_plugins/_asynchronous_search@. OpenSearch documents only the
-- @wait_for_completion@, @keep_on_completion@ and @keep_alive@ parameters;
-- the Elasticsearch-only 'assoBatchedReduceSize' and
-- 'assoCcsMinimizeRoundtrips' are silently dropped by the underlying
-- 'osAsyncSearchSubmitOptionsParams' renderer. 'defaultAsyncSearchSubmitOptions'
-- makes this byte-for-byte equivalent to 'submitOSAsyncSearch'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/search-plugins/async/index/#submit>.
submitOSAsyncSearchWith ::
  (FromJSON a) =>
  AsyncSearchSubmitOptions ->
  Search ->
  BHRequest StatusDependant (AsyncSearchResult a)
submitOSAsyncSearchWith opts search =
  post
    (["_plugins", "_asynchronous_search"] `withQueries` osAsyncSearchSubmitOptionsParams opts)
    (encode search)

-- | 'getOSAsyncSearch' retrieves the current state and (partial or final)
-- results of an OpenSearch async search by id
-- (@GET /_plugins/_asynchronous_search/{id}@).
--
-- For more information see
-- <https://docs.opensearch.org/2.18/search-plugins/async/index/#get>.
getOSAsyncSearch ::
  (FromJSON a) =>
  AsyncSearchId ->
  BHRequest StatusDependant (AsyncSearchResult a)
getOSAsyncSearch (AsyncSearchId searchId) =
  get ["_plugins", "_asynchronous_search", searchId]

-- | 'deleteOSAsyncSearch' deletes an OpenSearch async search result and its
-- context by id (@DELETE /_plugins/_asynchronous_search/{id}@) and returns
-- 'Acknowledged' on success.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/search-plugins/async/index/#delete>.
deleteOSAsyncSearch ::
  AsyncSearchId ->
  BHRequest StatusIndependant Acknowledged
deleteOSAsyncSearch (AsyncSearchId searchId) =
  delete ["_plugins", "_asynchronous_search", searchId]

-- | 'getOSAsyncSearchStats' fetches cluster-wide async-search statistics
-- via @GET /_plugins/_asynchronous_search/stats@. Returns per-node
-- counters for the nine documented async-search lifecycle events
-- (submitted, initialized, rejected, search_completed, search_failed,
-- persisted, persist_failed, running_current, cancelled).
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/search-plugins/async/index/#monitor-stats>.
getOSAsyncSearchStats ::
  BHRequest StatusDependant AsyncSearchStatsResponse
getOSAsyncSearchStats =
  get ["_plugins", "_asynchronous_search", "stats"]

-- =========================================================================
-- SQL & PPL plugin
-- =========================================================================

-- | 'sqlQuery' calls the SQL plugin Query API
-- (@POST /_plugins/_sql@). Executes a SQL query against the cluster and
-- returns a JDBC-style rowset ('SQLResult'). Pagination is opt-in: pass a
-- positive 'sqlRequestFetchSize' to receive a 'SQLResult' carrying a
-- 'sqlResultCursor' and then use 'sqlQueryNextPage' to walk subsequent
-- pages. Release an unfinished cursor with 'closeSqlCursor' to free the
-- server-side context.
--
-- Surfaced as 'StatusDependant' so a 4xx error (bad SQL, missing index)
-- decodes as 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/>.
sqlQuery ::
  SQLRequest ->
  BHRequest StatusDependant (ParsedEsResponse SQLResult)
sqlQuery req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_sql"]

-- | 'sqlQueryNextPage' fetches the next page of a paginated SQL result by
-- POSTing a cursor body (@{"cursor": ...}@) to @/_plugins/_sql@. Use the
-- 'sqlResultCursor' returned by the previous 'sqlQuery' or
-- 'sqlQueryNextPage' call. Continuation responses carry only 'sqlResultDatarows'
-- and a new 'sqlResultCursor'; @schema@, @total@, @size@ and @status@ are
-- dropped by the server, so every other 'SQLResult' field is 'Maybe'.
--
-- When the response no longer carries a 'sqlResultCursor' the last page has
-- been consumed and the server-side context is released automatically; no
-- 'closeSqlCursor' call is needed in that case.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/#cursor-and-paginating-results>.
sqlQueryNextPage ::
  SQLCursor ->
  BHRequest StatusDependant (ParsedEsResponse SQLResult)
sqlQueryNextPage cursor =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (SQLCursorRequest cursor))
  where
    endpoint = mkEndpoint ["_plugins", "_sql"]

-- | 'closeSqlCursor' releases the server-side context held by a SQL
-- pagination cursor (@POST /_plugins/_sql/close@). Call this to free the
-- context when you stop reading before the cursor is naturally exhausted
-- (i.e. before 'sqlResultCursor' disappears from a 'sqlQueryNextPage'
-- response). Closing an already-released cursor is a no-op server-side.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/#cursor-and-paginating-results>.
closeSqlCursor ::
  SQLCursor ->
  BHRequest StatusDependant (ParsedEsResponse SQLCloseResult)
closeSqlCursor cursor =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (SQLCursorRequest cursor))
  where
    endpoint = mkEndpoint ["_plugins", "_sql", "close"]

-- | 'explainSQL' calls the SQL plugin Explain API
-- (@POST /_plugins/_sql/_explain@). Translates a SQL query into the underlying
-- Elasticsearch query DSL without executing it. The request body is the same
-- 'SQLRequest' shape accepted by 'sqlQuery'. The response is a free-form JSON
-- object whose structure tracks the translated DSL (and varies by query and
-- plugin version), so it is returned as an opaque aeson 'Value'.
--
-- Surfaced as 'StatusDependant' so a 4xx error (bad SQL, missing index)
-- decodes as 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/#explain-api>.
explainSQL ::
  SQLRequest ->
  BHRequest StatusDependant (ParsedEsResponse Value)
explainSQL req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_sql", "_explain"]

-- | 'pplQuery' calls the PPL plugin Query API
-- (@POST /_plugins/_ppl@). Executes a PPL (Piped Processing Language) query
-- against the cluster and returns the same JDBC-style rowset shape as
-- 'sqlQuery' (typed here as 'PPLResult', an alias of 'SQLResult'). PPL does
-- not support cursor pagination (the plugin documents 'fetch_size' as
-- SQL-only), so 'sqlResultCursor' will always be 'Nothing' on a PPL
-- response.
--
-- Surfaced as 'StatusDependant' so a 4xx error decodes as 'EsError'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/>.
pplQuery ::
  PPLRequest ->
  BHRequest StatusDependant (ParsedEsResponse PPLResult)
pplQuery req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ppl"]

-- | 'explainPPL' calls the PPL plugin Explain API
-- (@POST /_plugins/_ppl/_explain@). Translates a PPL query into the underlying
-- Elasticsearch query DSL without executing it. The request body is the same
-- 'PPLRequest' shape accepted by 'pplQuery'. The response is a free-form JSON
-- object whose structure tracks the translated DSL (and varies by query and
-- plugin version), so it is returned as an opaque aeson 'Value'.
--
-- Surfaced as 'StatusDependant' so a 4xx error decodes as 'EsError'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/#explain-api>.
explainPPL ::
  PPLRequest ->
  BHRequest StatusDependant (ParsedEsResponse Value)
explainPPL req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_ppl", "_explain"]

-- | 'getSQLStats' calls the SQL plugin Stats API
-- (@GET /_plugins/_sql/stats@). Returns node-level plugin metrics as a flat
-- 'SQLPluginStats' map (cluster-level stats are not implemented by the
-- plugin; the response describes only the node you hit). The metric-name
-- set is large and grows across OpenSearch releases (eight keys on 1.x;
-- @ppl_*@, @datasource_*@, @async_query_*@, @emr_*@ and @streaming_*@
-- keys land later), so the body is decoded as 'Data.Aeson.Value' entries
-- keyed by metric name.
--
-- Surfaced as 'StatusDependant' so a 404 (SQL plugin not installed)
-- decodes as 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/sql-and-ppl/sql-and-ppl-api/index/>.
getSQLStats ::
  BHRequest StatusDependant (ParsedEsResponse SQLPluginStats)
getSQLStats =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_sql", "stats"]

-- =========================================================================
-- Notifications plugin
-- =========================================================================

-- | 'createNotificationConfig' calls the Notifications plugin Create Config
-- API (@POST /_plugins/_notifications/configs@). Persists a new
-- notification channel configuration (a Slack webhook, an email account,
-- an SNS topic, ...) to the @.opendistro-notifications-config@ system
-- index and returns the resulting 'CreateNotificationConfigResponse',
-- which echoes the server-assigned @config_id@.
--
-- Equivalent to 'createNotificationConfigWith' with 'Nothing' — the
-- server generates the @config_id@. Supply a custom id via
-- 'createNotificationConfigWith' if you need a stable, predictable
-- identifier (a pre-existing id yields HTTP 409, surfacing as an
-- 'EsError' under 'StatusDependant').
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#create-notification-config>.
createNotificationConfig ::
  NotificationConfig ->
  BHRequest StatusDependant (ParsedEsResponse CreateNotificationConfigResponse)
createNotificationConfig = createNotificationConfigWith Nothing

-- | 'createNotificationConfigWith' is the parameterized variant of
-- 'createNotificationConfig': the optional 'Text' is forwarded as the
-- @config_id@ of the request body. Pass 'Nothing' to let OpenSearch
-- generate one; pass @'Just' id@ for a stable identifier, bearing in
-- mind that a duplicate surfaces as HTTP 409 ('EsError').
-- See <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#create-notification-config>.
createNotificationConfigWith ::
  Maybe Text ->
  NotificationConfig ->
  BHRequest StatusDependant (ParsedEsResponse CreateNotificationConfigResponse)
createNotificationConfigWith mConfigId config =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_notifications", "configs"]
    req = CreateNotificationConfigRequest mConfigId config

-- | 'deleteNotificationConfig' calls the Notifications plugin Delete Config
-- API (@DELETE /_plugins/_notifications/configs/{config_id}@). Removes the
-- named channel configuration from the @.opendistro-notifications-config@
-- system index and returns a 'DeleteNotificationConfigResponse' mapping
-- the requested @config_id@ to its per-id deletion status (@"OK"@ on
-- success). Use 'deleteResponseStatus' \/ 'deleteResponseAllOK' to
-- inspect the result.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @config_id@, or the
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- This is the single-id variant; for batch deletion use
-- 'deleteNotificationConfigs' with the @config_id_list@ query.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#delete-channel-configuration>.
deleteNotificationConfig ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse DeleteNotificationConfigResponse)
deleteNotificationConfig configId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_notifications", "configs", configId]

-- | 'deleteNotificationConfigs' is the batch variant of
-- 'deleteNotificationConfig': it calls
-- @DELETE /_plugins/_notifications/configs/?config_id_list=id1,id2,...@
-- and removes every supplied channel configuration in a single
-- round-trip. The plugin returns the same 'DeleteNotificationConfigResponse'
-- shape as the single-id variant, mapping each requested id to its
-- per-id deletion status (@"OK"@ on success, a failure tag otherwise);
-- use 'deleteResponseStatus' to inspect a specific id or
-- 'deleteResponseAllOK' to summarise the whole batch. The batch
-- variant is preferred when sweeping many configs: it amortises the
-- per-request RBAC and system-index refresh cost.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @config_id@, or the
-- plugin not installed) decodes as an 'EsError' rather than throwing.
-- An empty id list is rejected by the plugin with HTTP 400; callers
-- who want a no-op on empty should guard the call site. The
-- @config_id_list@ value is rendered comma-separated by the underlying
-- 'withQueries' without URL-encoding, so config ids must not contain
-- @,@ (which would inject extra ids) or other URL-significant
-- characters (@&@, @=@, @\/@). The plugin emits URL-safe server-assigned
-- identifiers (e.g. @0Jnlh4ABa4TCWn5C5H2G@), so this only bites
-- hand-rolled ids.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#delete-channel-configuration>.
deleteNotificationConfigs ::
  NonEmpty Text ->
  BHRequest StatusDependant (ParsedEsResponse DeleteNotificationConfigResponse)
deleteNotificationConfigs configIds =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_notifications", "configs"]
        `withQueries` [("config_id_list", Just (T.intercalate "," (toList configIds)))]

-- | 'getNotificationConfigs' lists every notification channel
-- configuration via the Notifications plugin
-- (@GET /_plugins/_notifications/configs@). Returns each entry as a
-- 'NotificationConfigEntry' (config_id + timestamps + the full
-- 'NotificationConfig'). Equivalent to 'getNotificationConfigsWith'
-- with 'defaultNotificationListOptions' (no filtering or pagination).
--
-- Surfaced as 'StatusDependant' so a 404 (plugin not installed)
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#get-notification-config>.
getNotificationConfigs ::
  BHRequest StatusDependant (ParsedEsResponse [NotificationConfigEntry])
getNotificationConfigs = getNotificationConfigsWith defaultNotificationListOptions

-- | 'getNotificationConfigsWith' is the parameterized variant of
-- 'getNotificationConfigs': the supplied 'NotificationListOptions' are
-- forwarded as query-string parameters (filtering by config_id \/ type
-- \/ enabled state, pagination via from_index \/ max_items, and
-- sort_order \/ sort_field). Pass 'defaultNotificationListOptions' for
-- the plain no-arg GET.
--
-- The paging envelope (@start_index@, @total_hits@,
-- @total_hit_relation@) is decoded and discarded; the public return
-- type is the bare @config_list@. File a feature request if you need
-- the paging metadata surfaced.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#get-notification-config>.
getNotificationConfigsWith ::
  NotificationListOptions ->
  BHRequest StatusDependant (ParsedEsResponse [NotificationConfigEntry])
getNotificationConfigsWith opts =
  fmap (fmap getNotificationConfigsResponseConfigList)
    . withBHResponseParsedEsResponse
    $ get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_notifications", "configs"]
        `withQueries` notificationListOptionsParams opts

-- | 'getNotificationChannels' lists every notification channel via the
-- Notifications plugin (@GET /_plugins/_notifications/channels@). The
-- channels endpoint is the lightweight \"runtime\" view: each 'Channel'
-- carries the identifying metadata (config_id, name, description,
-- config_type, is_enabled) but omits the transport-specific details
-- exposed by 'getNotificationConfigs'. Equivalent to
-- 'getNotificationChannelsWith' with 'defaultNotificationListOptions'.
--
-- Surfaced as 'StatusDependant' so a 404 (plugin not installed)
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#list-all-notification-channels>.
getNotificationChannels ::
  BHRequest StatusDependant (ParsedEsResponse [Channel])
getNotificationChannels = getNotificationChannelsWith defaultNotificationListOptions

-- | 'getNotificationChannelsWith' is the parameterized variant of
-- 'getNotificationChannels': the supplied 'NotificationListOptions' are
-- forwarded as query-string parameters (same filter \/ pagination
-- surface as 'getNotificationConfigsWith'). Pass
-- 'defaultNotificationListOptions' for the plain no-arg GET.
--
-- The paging envelope is decoded and discarded; the public return type
-- is the bare @channel_list@.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#list-all-notification-channels>.
getNotificationChannelsWith ::
  NotificationListOptions ->
  BHRequest StatusDependant (ParsedEsResponse [Channel])
getNotificationChannelsWith opts =
  fmap (fmap getNotificationChannelsResponseChannelList)
    . withBHResponseParsedEsResponse
    $ get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_notifications", "channels"]
        `withQueries` notificationListOptionsParams opts

-- | 'getNotificationConfig' fetches a single notification channel
-- configuration by id via the Notifications plugin
-- (@GET /_plugins/_notifications/configs/{config_id}@). The endpoint
-- returns the same @start_index@ \/ @total_hits@ \/ @total_hit_relation@
-- \/ @config_list@ envelope as the list-all 'getNotificationConfigs',
-- with @total_hits@ clamped to @1@ and a single-element @config_list@
-- on hit; we decode the envelope and project out the lone entry as a
-- 'Maybe' (using 'listToMaybe') so the public type reads as a plain
-- per-id lookup. The 'Nothing' branch surfaces when the server returns
-- HTTP 200 with an empty @config_list@ — a genuine miss is expected to
-- come back as HTTP 404 (surfacing as @'Left' 'EsError'@ via
-- 'StatusDependant' before the body decode runs), but the plugin
-- version-dependent miss encoding is not pinned by a live test, so
-- 'Nothing' is the total fallback rather than a partial head. Callers
-- needing the paging metadata can build the underlying request
-- directly.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @config_id@, or the
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#get-channel-configuration>.
getNotificationConfig ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse (Maybe NotificationConfigEntry))
getNotificationConfig configId =
  fmap (fmap (listToMaybe . getNotificationConfigsResponseConfigList))
    . withBHResponseParsedEsResponse
    $ get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_notifications", "configs", configId]

-- | 'updateNotificationConfig' replaces a notification channel
-- configuration via the Notifications plugin
-- (@PUT /_plugins/_notifications/configs/{config_id}@). The body is
-- the 'NotificationConfig' wrapped under the @config@ key (see
-- 'UpdateNotificationConfigRequest'); the @config_id@ identity is the
-- URL path segment, not a body field, so a body shaped like
-- 'CreateNotificationConfigRequest' would be rejected server-side.
-- The response echoes the @config_id@ as a
-- 'CreateNotificationConfigResponse' (the same shape the create
-- endpoint returns).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing @config_id@
-- (or a 409 on a concurrent modification) decodes as an 'EsError'
-- rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#update-channel-configuration>.
updateNotificationConfig ::
  Text ->
  NotificationConfig ->
  BHRequest StatusDependant (ParsedEsResponse CreateNotificationConfigResponse)
updateNotificationConfig configId config =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_notifications", "configs", configId]
    req = UpdateNotificationConfigRequest config

-- | 'getNotificationFeatures' lists the channel configuration types
-- this cluster's Notifications plugin supports via
-- @GET /_plugins/_notifications/features@. The result is a
-- 'NotificationFeaturesResponse' carrying the @allowed_config_type_list@
-- (a subset of 'NotificationConfigType') and the @plugin_features@
-- feature-flag map. Useful for capability discovery before creating a
-- config — e.g. gate an SNS setup on @NotificationConfigTypeSns@
-- being present in 'notificationFeaturesResponseAllowedConfigTypeList'.
--
-- Surfaced as 'StatusDependant' so a 404 (plugin not installed)
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#list-supported-channel-configurations>.
getNotificationFeatures ::
  BHRequest StatusDependant (ParsedEsResponse NotificationFeaturesResponse)
getNotificationFeatures =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_notifications", "features"]

-- | 'sendTestNotification' triggers a one-shot probe delivery to the
-- channel identified by @config_id@ via the Notifications plugin
-- (@GET /_plugins/_notifications/feature/test/{config_id}@). The
-- plugin synthesises an 'TestNotificationEventSource' (title,
-- reference_id, severity, tags), attempts delivery through the
-- config's transport, and returns a 'TestNotificationResponse'
-- carrying a per-config 'TestNotificationStatus' with the upstream
-- delivery result (HTTP code + raw response body in
-- 'testNotificationDeliveryStatusText'). The probe is a real
-- delivery attempt: a Slack config POSTs to its webhook, an email
-- config sends mail, etc. — do not call this against production
-- channels from a loop.
--
-- Surfaced as 'StatusDependant' so a 4xx (unknown @config_id@,
-- transport misconfiguration, plugin not installed) decodes as an
-- 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/notifications/api/#send-test-notification>.
sendTestNotification ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse TestNotificationResponse)
sendTestNotification configId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_notifications", "feature", "test", configId]

-- =========================================================================
-- Alerting plugin
-- =========================================================================

-- | 'createMonitor' creates an alerting monitor via the Alerting plugin
-- (@POST /_plugins/_alerting/monitors@). The body is the encoded
-- 'Monitor'; the response is the 'MonitorResponse' wrapper carrying the
-- server-assigned @_id@, @_version@, @_seq_no@, @_primary_term@, and the
-- persisted monitor document.
--
-- Surfaced as 'StatusDependant' so a 4xx\/5xx (invalid monitor body,
-- plugin not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#create-monitor>.
createMonitor ::
  Monitor ->
  BHRequest StatusDependant MonitorResponse
createMonitor monitor =
  post (mkEndpoint ["_plugins", "_alerting", "monitors"]) (encode monitor)

-- | 'getMonitor' fetches a single monitor by id via
-- @GET /_plugins/_alerting/monitors/{monitor_id}@. The endpoint returns
-- the 'MonitorResponse' wrapper on the wire
-- (@{_id,_version,_seq_no,_primary_term, monitor:{...}}@); the wrapper
-- is decoded and the 'Monitor' is projected out so the public type
-- matches the bead acceptance (@m 'Monitor'@). Callers needing the
-- indexing metadata can build the underlying request directly.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing monitor id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#get-monitor>.
getMonitor ::
  MonitorId ->
  BHRequest StatusDependant Monitor
getMonitor (MonitorId mid) =
  monitorResponseMonitor <$> get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "monitors", mid]

-- | 'updateMonitor' replaces a monitor via
-- @PUT /_plugins/_alerting/monitors/{monitor_id}@. The body is the
-- encoded 'Monitor'; the response is the same 'MonitorResponse' wrapper
-- as 'createMonitor' (with the incremented @_version@ and @_seq_no@).
-- Equivalent to 'updateMonitorWith' with 'Nothing' (no
-- optimistic-concurrency guard), so concurrent updates to the same
-- monitor id silently clobber each other.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing monitor id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#update-monitor>.
updateMonitor ::
  MonitorId ->
  Monitor ->
  BHRequest StatusDependant MonitorResponse
updateMonitor monitorId monitor = updateMonitorWith monitorId monitor Nothing

-- | 'updateMonitorWith' replaces a monitor via
-- @PUT /_plugins/_alerting/monitors/{monitor_id}@ with an optional
-- optimistic-concurrency guard. Pass @'Just' (seqNo, primaryTerm)@ —
-- both values read from a prior 'MonitorResponse' — to make the PUT
-- conditional on the monitor being unchanged since you last read it; a
-- stale pair causes OpenSearch to respond with HTTP 409 instead of
-- silently overwriting. Pass 'Nothing' for the unconditional semantics
-- of 'updateMonitor'. The two values must be supplied together:
-- OpenSearch rejects a partial pair with HTTP 400 (mirroring the
-- document-write @if_seq_no@ \/ @if_primary_term@ semantics). Mirrors
-- the 'putISMPolicyWith' precedent.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing monitor id (or a
-- 409 on a sequence-number mismatch) decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#update-monitor>.
updateMonitorWith ::
  MonitorId ->
  Monitor ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant MonitorResponse
updateMonitorWith (MonitorId mid) monitor mOcc =
  put @StatusDependant endpoint (encode monitor)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_alerting", "monitors", mid]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries baseEndpoint $
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- | 'deleteMonitor' deletes a monitor by id via
-- @DELETE /_plugins/_alerting/monitors/{monitor_id}@.
--
-- The return type 'DeleteMonitorResponse' deviates from the bead's
-- @m 'Acknowledged'@ acceptance: the body never carries an
-- @acknowledged@ key, so an 'Acknowledged' decoder would raise
-- 'EsProtocolException' at runtime (same deviation pattern as
-- bloodhound-04f.3.13 rethrottle). The exact shape is version-dependent
-- and live-verified on OS 1.3.19, 2.19.5 and 3.7.0 (bead
-- bloodhound-z5j): OS 1.3.x returns the full bare Elasticsearch
-- delete-document response; OS 2.x/3.x return only @_id@ + @_version@.
-- See the Haddock on 'DeleteMonitorResponse' for the field-level detail.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing monitor id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#delete-monitor>.
deleteMonitor ::
  MonitorId ->
  BHRequest StatusDependant DeleteMonitorResponse
deleteMonitor (MonitorId mid) =
  delete (mkEndpoint ["_plugins", "_alerting", "monitors", mid])

-- | 'searchMonitors' searches the monitor store via
-- @GET /_plugins/_alerting/monitors/_search@. Pass 'Nothing' for the
-- plain empty-body @{}@ request that returns the first page of all
-- monitors; pass @'Just' query@ to paginate (@from@\/@size@) or filter
-- by name, type, enabled state, ... using the standard OpenSearch
-- query DSL carried opaquely in 'monitorsSearchQueryQuery'.
--
-- The endpoint is documented as GET but, like the core search API,
-- carries the query DSL in the request body; 'getWithBody' sends a
-- GET with a body (mirrors the @GET /_render/template@ precedent).
--
-- Returns the full search envelope 'SearchMonitorsResponse' so callers
-- can drive pagination and inspect @_shards@ health; use
-- 'searchMonitorsResponseMonitors' to project out just the @['Monitor']@
-- list. Mirrors the Security Analytics 'searchSAPrePackagedRules'
-- precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#search-monitors>.
searchMonitors ::
  Maybe MonitorsSearchQuery ->
  BHRequest StatusDependant (ParsedEsResponse SearchMonitorsResponse)
searchMonitors mQuery =
  withBHResponseParsedEsResponse $
    getWithBody @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "monitors", "_search"]
    body = encode (maybe defaultMonitorsSearchQuery id mQuery)

-- | 'executeMonitor' runs a monitor's inputs and triggers immediately
-- (out of schedule) via
-- @POST /_plugins/_alerting/monitors/{monitor_id}/_execute@. The
-- endpoint takes no request body; a dry run (which exercises the
-- triggers without dispatching actions to destinations) is requested
-- with the @?dryrun=true@ query parameter, so pass @'Just'
-- ('ExecuteMonitorRequest' {dryrun = Just True})@ to set it. Pass
-- 'Nothing' for a real execution with action dispatch.
--
-- Returns 'ExecuteMonitorResponse' (typed shell + opaque aeson
-- 'Value's for the per-kind @trigger_results@ \/ @input_results@ \/
-- @script_actions@ sub-objects). Mirrors the ML Commons @predict@
-- pragmatic-typing precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid monitor id, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#execute-monitor>.
executeMonitor ::
  MonitorId ->
  Maybe ExecuteMonitorRequest ->
  BHRequest StatusDependant (ParsedEsResponse ExecuteMonitorResponse)
executeMonitor (MonitorId mid) mRequest =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_alerting", "monitors", mid, "_execute"]
        `withQueries` dryrunQuery
    dryrunQuery =
      case mRequest >>= executeMonitorRequestDryrun of
        Just True -> [("dryrun", Just "true")]
        _ -> []

-- =========================================================================
-- Security Analytics plugin
-- =========================================================================

-- | 'createSADetector' creates a Security Analytics detector
-- (@POST /_plugins/_security_analytics/detectors@). The body is the
-- encoded 'SADetector'; the response is the 'SADetectorResponse'
-- wrapper carrying the server-assigned @_id@, @_version@, and the
-- persisted detector document (with server-injected @last_update_time@
-- and @enabled_time@).
--
-- Surfaced as 'StatusDependant' so a 4xx\/5xx (invalid detector body,
-- plugin not installed, RBAC denial) raises a structured 'EsError'
-- rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/detector-api/#create-detector-api>.
createSADetector ::
  SADetector ->
  BHRequest StatusDependant SADetectorResponse
createSADetector detector =
  post (mkEndpoint ["_plugins", "_security_analytics", "detectors"]) (encode detector)

-- | 'getSADetector' fetches a single detector by id via
-- @GET /_plugins/_security_analytics/detectors/{detector_id}@. The
-- endpoint returns the 'SADetectorResponse' wrapper on the wire
-- (@{_id,_version, detector:{...}}@); the wrapper is decoded and the
-- 'SADetector' is projected out so the public type matches the bead
-- acceptance (@m 'SADetector'@). Callers needing the indexing
-- metadata can build the underlying request directly.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/detector-api/#get-detector-api>.
getSADetector ::
  DetectorId ->
  BHRequest StatusDependant SADetector
getSADetector (DetectorId did) =
  saDetectorResponseDetector <$> get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_security_analytics", "detectors", did]

-- | 'updateSADetector' replaces a Security Analytics detector via
-- @PUT /_plugins/_security_analytics/detectors/{detector_id}@. The
-- body is the encoded 'SADetector'; the response is the same
-- 'SADetectorResponse' wrapper as 'createSADetector' (with the
-- incremented @_version@ and a refreshed @last_update_time@).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id
-- (or a 4xx for an invalid detector body) decodes as an 'EsError'
-- rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/detector-api/#update-detector-api>.
updateSADetector ::
  DetectorId ->
  SADetector ->
  BHRequest StatusDependant SADetectorResponse
updateSADetector (DetectorId did) detector =
  put (mkEndpoint ["_plugins", "_security_analytics", "detectors", did]) (encode detector)

-- | 'deleteSADetector' deletes a Security Analytics detector via
-- @DELETE /_plugins/_security_analytics/detectors/{detector_id}@.
-- Returns 'DeleteSADetectorResponse', which is NOT an 'Acknowledged'
-- ack (an 'Acknowledged' decoder would raise 'EsProtocolException' at
-- runtime — same deviation pattern as the Alerting 'deleteMonitor').
-- The exact shape is version-dependent and live-verified on OS 2.19.5
-- and 3.7.0 (bead bloodhound-z5j): OS 2.x/3.x return only
-- @_id@ + @_version@. OS 1.3.x does not ship the Security Analytics
-- plugin. See the Haddock on 'DeleteSADetectorResponse' for detail.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/detector-api/#delete-detector-api>.
deleteSADetector ::
  DetectorId ->
  BHRequest StatusDependant DeleteSADetectorResponse
deleteSADetector (DetectorId did) =
  delete (mkEndpoint ["_plugins", "_security_analytics", "detectors", did])

-- | 'searchSAPrePackagedRules' searches the pre-packaged Sigma rules
-- index (@POST /_plugins/_security_analytics/rules/_search?pre_packaged=true@)
-- via the Security Analytics plugin. Pass 'Nothing' for the plain
-- empty-body @{}@ POST that returns the first page of all
-- pre-packaged rules; pass @'Just' query@ to paginate (@from@\/@size@)
-- or filter by category, tag, level, ... using the standard OpenSearch
-- query DSL carried opaquely in 'saRulesQueryQuery'.
--
-- Returns the full search envelope 'SARulesSearchResponse' so callers
-- can drive pagination and inspect @_shards@ health; use
-- 'saRulesSearchResponseRules' to project out just the @['SARule']@
-- list.
--
-- /Deviation from the bead acceptance/ (bloodhound-04f.6.11.3): the
-- bead names a @GET /_plugins/_security_analytics/rules@ endpoint
-- that does not exist on the server. Rule retrieval is via the
-- search endpoints; this function and 'searchSACustomRules' are
-- shipped instead of the bead's @getSARules :: m [SARule]@, mirroring
-- the bloodhound-04f.6.7.4 'deleteMonitor' precedent (bead
-- acceptance typed as a simpler shape than the real wire). See the
-- changelog entry for bloodhound-04f.6.11.3 for the full rationale.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/rule-api/#search-pre-packaged-rules>.
searchSAPrePackagedRules ::
  Maybe SARulesQuery ->
  BHRequest StatusDependant (ParsedEsResponse SARulesSearchResponse)
searchSAPrePackagedRules = searchSARules True

-- | 'searchSACustomRules' searches the custom Sigma rules index
-- (@POST /_plugins/_security_analytics/rules/_search?pre_packaged=false@).
-- Otherwise identical in shape and semantics to
-- 'searchSAPrePackagedRules'; see that function's Haddock for the
-- query-body handling and the bead-acceptance deviation note.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/rule-api/#search-custom-rules>.
searchSACustomRules ::
  Maybe SARulesQuery ->
  BHRequest StatusDependant (ParsedEsResponse SARulesSearchResponse)
searchSACustomRules = searchSARules False

-- | Shared body of 'searchSAPrePackagedRules' and 'searchSACustomRules':
-- POSTs the (possibly-empty) query body to the rules search endpoint
-- with the @pre_packaged@ query flag set accordingly.
searchSARules ::
  Bool ->
  Maybe SARulesQuery ->
  BHRequest StatusDependant (ParsedEsResponse SARulesSearchResponse)
searchSARules prePackaged mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    baseEndpoint = mkEndpoint ["_plugins", "_security_analytics", "rules", "_search"]
    endpoint =
      withQueries
        baseEndpoint
        [ ("pre_packaged", Just (if prePackaged then "true" else "false"))
        ]
    body = encode (maybe defaultSARulesQuery id mQuery)

-- | 'createSACustomRule' creates a custom Sigma rule via
-- @POST /_plugins/_security_analytics/rules?category={logtype}@.
-- The request body is the /raw Sigma YAML text/ (NOT JSON-encoded):
-- the Security Analytics plugin parses Sigma YAML directly, even
-- though the request carries an @application/json@ content type. The
-- supplied 'SADetectorType' selects the rule's @category@ query
-- parameter (rendered via 'saDetectorTypeText'); pass the full Sigma
-- rule document as 'Text'.
--
-- Returns 'SARuleResponse' (@{_id, _version, rule}@), where the inner
-- 'SARule' carries the server-normalised typed fields plus the
-- original YAML serialised in 'saRuleRule'.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid Sigma, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/rule-api/#create-custom-rule>.
createSACustomRule ::
  SADetectorType ->
  Text ->
  BHRequest StatusDependant SARuleResponse
createSACustomRule category sigmaYaml =
  post endpoint (BL.fromStrict (TE.encodeUtf8 sigmaYaml))
  where
    endpoint =
      withQueries
        (mkEndpoint ["_plugins", "_security_analytics", "rules"])
        [("category", Just (saDetectorTypeText category))]

-- | 'updateSACustomRule' replaces a custom Sigma rule via
-- @PUT /_plugins/_security_analytics/rules/{id}?category={logtype}[&forced=true]@.
-- Like 'createSACustomRule' the body is the raw Sigma YAML text (not
-- JSON). The @forced@ flag, when 'True', forces the update even when
-- the rule is actively referenced by detectors (the server returns a
-- @500@ otherwise); when 'False' the @forced@ query parameter is
-- omitted. Returns the updated 'SARuleResponse'.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing rule id (or a
-- 4xx\/5xx for an invalid body or an un-forced update of a referenced
-- rule) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/rule-api/#update-custom-rule>.
updateSACustomRule ::
  RuleId ->
  SADetectorType ->
  Bool ->
  Text ->
  BHRequest StatusDependant SARuleResponse
updateSACustomRule (RuleId rid) category forced sigmaYaml =
  put endpoint (BL.fromStrict (TE.encodeUtf8 sigmaYaml))
  where
    baseEndpoint = mkEndpoint ["_plugins", "_security_analytics", "rules", rid]
    endpoint =
      withQueries baseEndpoint $
        ("category", Just (saDetectorTypeText category))
          : [("forced", Just "true") | forced]

-- | 'deleteSACustomRule' deletes a custom Sigma rule via
-- @DELETE /_plugins/_security_analytics/rules/{id}[?forced=true]@.
-- The @forced@ flag, when 'True', forces the delete even when the rule
-- is actively referenced by detectors; when 'False' the @forced@ query
-- parameter is omitted. There is no request body.
--
-- Returns the bare Elasticsearch delete-document response
-- ('DeleteSARuleResponse', wire shape
-- @{_index,_id,_version,result,forced_refresh,_shards,_seq_no,_primary_term}@)
-- — NOT an 'Acknowledged' ack, which would raise 'EsProtocolException'
-- at runtime. Same deviation pattern as 'deleteSADetector'.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing rule id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/rule-api/#delete-custom-rule>.
deleteSACustomRule ::
  RuleId ->
  Bool ->
  BHRequest StatusDependant DeleteSARuleResponse
deleteSACustomRule (RuleId rid) forced =
  delete endpoint
  where
    endpoint =
      withQueries
        (mkEndpoint ["_plugins", "_security_analytics", "rules", rid])
        [("forced", Just "true") | forced]

-- | 'searchSADetectors' searches the detector store via
-- @POST /_plugins/_security_analytics/detectors/_search@. Pass
-- 'Nothing' for the plain empty-body @{}@ POST that returns the first
-- page of all detectors; pass @'Just' query@ to paginate
-- (@from@\/@size@) or filter by name, type, enabled state, ... using
-- the standard OpenSearch query DSL carried opaquely in
-- 'saDetectorsSearchQueryQuery'. Mirrors 'searchSAPrePackagedRules'
-- (without the @pre_packaged@ query flag, which is rule-specific).
--
-- Returns the full search envelope 'SADetectorsSearchResponse' so
-- callers can drive pagination and inspect @_shards@ health; use
-- 'saDetectorsSearchResponseDetectors' to project out just the
-- @['SADetector']@ list.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/detector-api/#search-detectors-api>.
searchSADetectors ::
  Maybe SADetectorsSearchQuery ->
  BHRequest StatusDependant (ParsedEsResponse SADetectorsSearchResponse)
searchSADetectors mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_security_analytics", "detectors", "_search"]
    body = encode (maybe defaultSADetectorsSearchQuery id mQuery)

-- | 'getSAMappingsView' returns a view of the fields contained in an
-- index used as a log source, via
-- @GET /_plugins/_security_analytics/mappings/view@. The
-- 'SAMappingsViewRequest' body names the index and the log type (rule
-- topic); the server requires these as a request body even though the
-- method is GET, so this uses a body-bearing GET. The
-- 'SAMappingsViewResponse' carries the proposed @properties@ map and
-- the list of @unmapped_index_fields@.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, missing index) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/mappings-api/#get-mappings-view>.
getSAMappingsView ::
  SAMappingsViewRequest ->
  BHRequest StatusDependant SAMappingsViewResponse
getSAMappingsView req =
  getWithBody @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_security_analytics", "mappings", "view"]

-- | 'createSAMappings' creates the field-to-alias mappings for an
-- index via @POST /_plugins/_security_analytics/mappings@. The
-- 'SACreateMappingsRequest' body names the index, the log type (rule
-- topic), an optional @partial@ flag, and the @alias_mappings@
-- ('SAMappingsBody') to persist. The server acknowledges with the
-- standard @{"acknowledged": true}@ envelope ('Acknowledged').
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, invalid body) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/mappings-api/#create-mappings>.
createSAMappings ::
  SACreateMappingsRequest ->
  BHRequest StatusDependant Acknowledged
createSAMappings req =
  post (mkEndpoint ["_plugins", "_security_analytics", "mappings"]) (encode req)

-- | 'getSAMappings' fetches the persisted mappings for a single index
-- via @GET /_plugins/_security_analytics/mappings?index_name={name}@.
-- The response is keyed by index name
-- ('SAGetMappingsResponse'); each value is an 'SAMappingsIndexBody'
-- carrying the @mappings.properties@ map.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, unknown index) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/mappings-api/#get-mappings>.
getSAMappings ::
  Text ->
  BHRequest StatusDependant SAGetMappingsResponse
getSAMappings indexName =
  get @StatusDependant endpoint
  where
    endpoint =
      withQueries
        (mkEndpoint ["_plugins", "_security_analytics", "mappings"])
        [("index_name", Just indexName)]

-- | 'updateSAMappings' updates a single field-to-alias mapping for an
-- index via @PUT /_plugins/_security_analytics/mappings@. The
-- 'SAUpdateMappingsRequest' body names the index, the source @field@,
-- and the @alias@ to assign. The server acknowledges with the
-- standard @{"acknowledged": true}@ envelope ('Acknowledged').
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, unknown index\/field) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/mappings-api/#update-mappings>.
updateSAMappings ::
  SAUpdateMappingsRequest ->
  BHRequest StatusDependant Acknowledged
updateSAMappings req =
  put (mkEndpoint ["_plugins", "_security_analytics", "mappings"]) (encode req)

-- =========================================================================
-- Security Analytics plugin: alerts, findings, correlations, log types
-- =========================================================================

-- | 'getSAAlertsWith' lists Security Analytics alert documents via
-- @GET /_plugins/_security_analytics/alerts@, forwarding the supplied
-- 'SAGetAlertsOptions' as query-string parameters (filtering by
-- @detector_id@ \/ @detectorType@ — one of which the server requires —
-- plus @alertState@ \/ @severityLevel@, pagination via @size@ \/
-- @startIndex@, sort via @sortString@ \/ @sortOrder@ \/ @missing@,
-- free-text @searchString@). Pass 'defaultSAGetAlertsOptions' (with one
-- of the two detector filters set via record update) for the plain
-- GET. Returns the full 'SAGetAlertsResponse' envelope so callers can
-- read @total_alerts@ (which differs from the page size when @size@
-- truncates the result). Mirrors the Alerting 'getAlertsWith' precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, missing required @detector_id@\/@detectorType@) decodes as an
-- 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/alert-finding-api/#get-alerts>.
getSAAlertsWith ::
  SAGetAlertsOptions ->
  BHRequest StatusDependant SAGetAlertsResponse
getSAAlertsWith opts =
  get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_security_analytics", "alerts"]
        `withQueries` saGetAlertsOptionsParams opts

-- | 'getSAAlerts' lists the alert documents of a single detector.
-- Equivalent to 'getSAAlertsWith' with 'defaultSAGetAlertsOptions' (the
-- @detector_id@ is set from the argument) and projects out the bare
-- @['SAAlert']@ list (the paging envelope is discarded). Callers
-- needing @total_alerts@ or custom filtering should use
-- 'getSAAlertsWith'. Mirrors the Alerting 'getAlerts' precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx decodes as an 'EsError'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/alert-finding-api/#get-alerts>.
getSAAlerts ::
  DetectorId ->
  BHRequest StatusDependant [SAAlert]
getSAAlerts did =
  saGetAlertsResponseAlerts
    <$> getSAAlertsWith
      defaultSAGetAlertsOptions
        { saGetAlertsOptionsDetectorId = Just (unDetectorId did)
        }

-- | 'acknowledgeSADetectorAlerts' acknowledges one or more active alerts
-- of a Security Analytics detector via
-- @POST /_plugins/_security_analytics/detectors/{detector_id}/_acknowledge/alerts@.
-- The request body is the encoded 'AcknowledgeSADetectorAlertsRequest'
-- (@{"alerts":[ids]}@). Alerts already in a terminal state are not
-- transitioned and are echoed in the @failed@ array of the
-- 'AcknowledgeSADetectorAlertsResponse'. Mirrors the Alerting
-- 'acknowledgeAlert' precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid detector id, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/alert-finding-api/#acknowledge-alerts>.
acknowledgeSADetectorAlerts ::
  DetectorId ->
  [Text] ->
  BHRequest StatusDependant AcknowledgeSADetectorAlertsResponse
acknowledgeSADetectorAlerts (DetectorId did) alertIds =
  post
    (mkEndpoint ["_plugins", "_security_analytics", "detectors", did, "_acknowledge", "alerts"])
    (encode (AcknowledgeSADetectorAlertsRequest alertIds))

-- | 'searchSAFindings' lists Security Analytics finding documents via
-- @GET /_plugins/_security_analytics/findings/_search@, forwarding the
-- supplied 'SASearchFindingsOptions' as query-string parameters
-- (filtering by @detector_id@ \/ @detectorType@ \/ @severity@ \/
-- @detectionType@, pagination via @size@ \/ @startIndex@, sort via
-- @sortOrder@). Pass 'defaultSASearchFindingsOptions' for the plain
-- no-arg GET that returns the first page of all findings.
--
-- Note: the @GET \/findings\/_search@ endpoint is the only documented
-- findings-retrieval surface; per-id GET is not documented. The
-- response envelope is plugin-specific (@{total_findings, findings}@)
-- and is NOT the standard OpenSearch search envelope; the decoded
-- 'SASearchFindingsResponse' reflects this.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/alert-finding-api/#get-findings>.
searchSAFindings ::
  SASearchFindingsOptions ->
  BHRequest StatusDependant (ParsedEsResponse SASearchFindingsResponse)
searchSAFindings opts =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_security_analytics", "findings", "_search"]
        `withQueries` saSearchFindingsOptionsParams opts

-- | 'createSACorrelationRule' creates a Security Analytics correlation
-- rule via @POST /_plugins/_security_analytics/correlation/rules@. The
-- request body carries the list of index\/query\/category triples
-- ('SACorrelateEntry') that define the correlation; the server assigns
-- an @_id@ and @_version@ and echoes the persisted 'SACorrelationRule'
-- in 'createSACorrelationRuleResponseRule'.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid body, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/correlation-eng/#create-correlation-rule>.
createSACorrelationRule ::
  CreateSACorrelationRuleRequest ->
  BHRequest StatusDependant CreateSACorrelationRuleResponse
createSACorrelationRule req =
  post (mkEndpoint ["_plugins", "_security_analytics", "correlation", "rules"]) (encode req)

-- | 'getSACorrelations' lists pairwise finding correlations within a
-- time window via @GET /_plugins/_security_analytics/correlations@. The
-- two required query parameters @start_timestamp@ and @end_timestamp@
-- (epoch-millis integers) are carried in 'GetSACorrelationsOptions';
-- the caller MUST set both via record update on
-- 'defaultGetSACorrelationsOptions'. Returns the
-- 'GetSACorrelationsResponse' envelope (@{findings: [...]}@ of
-- 'SACorrelationFinding' pairwise entries).
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, missing required timestamps) decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/correlation-eng/#query-correlations>.
getSACorrelations ::
  GetSACorrelationsOptions ->
  BHRequest StatusDependant GetSACorrelationsResponse
getSACorrelations opts =
  get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_security_analytics", "correlations"]
        `withQueries` getSACorrelationsOptionsParams opts

-- | 'findSACorrelation' lists correlated findings for a given finding
-- via @GET /_plugins/_security_analytics/findings/correlate@. The two
-- required query parameters @finding@ (a finding id) and
-- @detector_type@ (a log type) are carried in 'FindSACorrelationOptions';
-- the caller MUST set both via record update on
-- 'defaultFindSACorrelationOptions'. The optional @nearby_findings@
-- (int) and @time_window@ (e.g. @"10m"@) tune the correlation window.
-- Returns the 'FindSACorrelationResponse' envelope (@{findings: [...]}@
-- of 'SACorrelationScore' entries ranked by proximity score).
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, missing required parameters) decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/correlation-eng/#find-correlation>.
findSACorrelation ::
  FindSACorrelationOptions ->
  BHRequest StatusDependant FindSACorrelationResponse
findSACorrelation opts =
  get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_security_analytics", "findings", "correlate"]
        `withQueries` findSACorrelationOptionsParams opts

-- | 'searchSACorrelationAlerts' lists Security Analytics correlation
-- alerts via @GET /_plugins/_security_analytics/correlationAlerts@.
-- The optional @correlation_rule_id@ query parameter filters the result
-- to alerts produced by a specific correlation rule. Pass
-- 'defaultSearchSACorrelationAlertsOptions' for the plain no-arg GET
-- that returns all correlation alerts. Returns the
-- 'SearchSACorrelationAlertsResponse' envelope (@{correlationAlerts,
-- total_alerts}@).
--
-- /Documentation gotcha/ (pinned in the Haddock on
-- 'SearchSACorrelationAlertsOptions'): the endpoint is declared as
-- @GET \/correlationAlerts@, but the docs' example request uses
-- @GET \/correlations?correlation_rule_id=...@. This function ships
-- the declared path.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/correlation-eng/#get-correlation-alerts>.
searchSACorrelationAlerts ::
  SearchSACorrelationAlertsOptions ->
  BHRequest StatusDependant SearchSACorrelationAlertsResponse
searchSACorrelationAlerts opts =
  get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_security_analytics", "correlationAlerts"]
        `withQueries` searchSACorrelationAlertsOptionsParams opts

-- | 'acknowledgeSACorrelationAlerts' acknowledges one or more
-- correlation alerts via
-- @POST /_plugins/_security_analytics/_acknowledge/correlationAlerts@.
-- The request body is the encoded
-- 'AcknowledgeSACorrelationAlertsRequest' (@{"alertIds":[ids]}@ — note
-- the camelCase key, contrast with 'acknowledgeSADetectorAlerts' which
-- uses snake_case @alerts@). The unusual path (@\/_acknowledge\/...@,
-- with @_acknowledge@ as a sibling of @correlationAlerts@ rather than
-- nested under it) is documented and matches the live server.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid alert ids, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/correlation-eng/#acknowledge-correlation-alerts>.
acknowledgeSACorrelationAlerts ::
  [Text] ->
  BHRequest StatusDependant AcknowledgeSACorrelationAlertsResponse
acknowledgeSACorrelationAlerts alertIds =
  post
    (mkEndpoint ["_plugins", "_security_analytics", "_acknowledge", "correlationAlerts"])
    (encode (AcknowledgeSACorrelationAlertsRequest alertIds))

-- | 'createSALogType' creates a custom Security Analytics log type via
-- @POST /_plugins/_security_analytics/logtype@. The request body is the
-- encoded 'SALogType' (carrying @name@, @description@, @source@, and
-- optional @tags@); the server assigns an @_id@ and @_version@ and
-- echoes the persisted log type in the @logType@ wrapper of
-- 'SALogTypeResponse'. Built-in log types (@source: "Sigma"@) cannot
-- be created via this endpoint; pass @source = 'SALogTypeSourceCustom'@
-- (or omit the field, which the server defaults to @"Custom"@).
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid body, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/log-type-api/#create-custom-log-type>.
createSALogType ::
  SALogType ->
  BHRequest StatusDependant SALogTypeResponse
createSALogType logType =
  post (mkEndpoint ["_plugins", "_security_analytics", "logtype"]) (encode logType)

-- | 'searchSALogTypes' searches the Security Analytics log type store
-- via @POST /_plugins/_security_analytics/logtype/_search@. Pass
-- 'Nothing' for the plain @{"query": {"match_all": {}}}@ POST that
-- returns the first page of all log types (built-in and custom); pass
-- @'Just' query@ to filter by name, source, tags, ... using the
-- standard OpenSearch query DSL carried opaquely in
-- 'saLogTypeSearchQueryQuery'. Returns the full search envelope
-- 'SALogTypesSearchResponse' so callers can drive pagination and
-- inspect @_shards@ health; use 'saLogTypesSearchResponseLogTypes' to
-- project out just the @['SALogType']@ list.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/log-type-api/#search-custom-log-types>.
searchSALogTypes ::
  Maybe SALogTypeSearchQuery ->
  BHRequest StatusDependant (ParsedEsResponse SALogTypesSearchResponse)
searchSALogTypes mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_security_analytics", "logtype", "_search"]
    body = encode (maybe defaultSALogTypeSearchQuery id mQuery)

-- | 'updateSALogType' replaces a custom Security Analytics log type via
-- @PUT /_plugins/_security_analytics/logtype/{log_type_id}@. The
-- request body is the encoded 'SALogType'; the response is the same
-- 'SALogTypeResponse' wrapper as 'createSALogType' (with the
-- incremented @_version@). Built-in log types cannot be updated.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing log type id (or
-- a 4xx for an invalid body, or an attempt to update a built-in log
-- type) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/log-type-api/#update-custom-log-type>.
updateSALogType ::
  Text ->
  SALogType ->
  BHRequest StatusDependant SALogTypeResponse
updateSALogType logTypeId logType =
  put (mkEndpoint ["_plugins", "_security_analytics", "logtype", logTypeId]) (encode logType)

-- | 'deleteSALogType' deletes a custom Security Analytics log type via
-- @DELETE /_plugins/_security_analytics/logtype/{log_type_id}@.
-- Built-in log types cannot be deleted — the server rejects the
-- request; the resulting 'EsError' surfaces via 'StatusDependant'.
-- Returns 'DeleteSALogTypeResponse' carrying just @_id@ and @_version@.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing log type id (or
-- a 4xx for a built-in log type id) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/security-analytics/api-tools/log-type-api/#delete-custom-log-type>.
deleteSALogType ::
  Text ->
  BHRequest StatusDependant DeleteSALogTypeResponse
deleteSALogType logTypeId =
  delete (mkEndpoint ["_plugins", "_security_analytics", "logtype", logTypeId])

-- | 'getDestinations' lists every configured alerting destination via
-- @GET /_plugins/_alerting/destinations@. The endpoint returns a
-- paging envelope (@{totalDestinations, destinations}@); this wrapper
-- projects out the bare 'Destination' list so the public type matches
-- the bead acceptance (@m [Destination]@). Equivalent to
-- 'getDestinationsWith' with 'defaultDestinationListOptions' (server
-- defaults: @size=20@, @start_index=0@,
-- @sortString=destination.name.keyword@, @sortOrder=asc@). Callers
-- needing paging metadata (@totalDestinations@) or custom filtering
-- should use 'getDestinationsWith'.
--
-- Surfaced as 'StatusDependant' so a 4xx\/5xx (plugin not installed,
-- RBAC denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#get-destinations>.
getDestinations ::
  BHRequest StatusDependant [Destination]
getDestinations =
  getDestinationsResponseDestinations
    <$> getDestinationsWith defaultDestinationListOptions

-- | 'getDestinationsWith' is the parameterized variant of
-- 'getDestinations': the supplied 'DestinationListOptions' are
-- forwarded as query-string parameters (filtering by
-- 'DestinationListOptions''s @destinationType@, pagination via
-- @size@ \/ @start_index@, sort via @sortString@ \/ @sortOrder@ \/
-- @missing@, and free-text @searchString@). Pass
-- 'defaultDestinationListOptions' for the plain no-arg GET. Returns
-- the full 'GetDestinationsResponse' envelope so callers can read
-- @totalDestinations@ (the total hit count, which differs from the
-- page size when @size@ truncates the result).
--
-- Surfaced as 'StatusDependant' so a 4xx\/5xx (plugin not installed,
-- RBAC denial, or the documented HTTP 405 when multi-tenancy is
-- enabled) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#get-destinations>.
getDestinationsWith ::
  DestinationListOptions ->
  BHRequest StatusDependant GetDestinationsResponse
getDestinationsWith opts =
  get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_alerting", "destinations"]
        `withQueries` destinationListOptionsParams opts

-- | The Alerting server assigns @id@, @schema_version@, @seq_no@, and
-- @primary_term@ when a destination is first written, so the documented
-- 'createDestination' request body omits them (a caller-supplied @id@
-- is ignored; @seq_no@\/@primary_term@ are meaningless on a create).
-- This drops those keys from the encoded 'Destination' so the create
-- body matches the documented shape. It is local to the create path:
-- 'updateDestination' and the 'Destination' round-trip keep the full
-- document (server-assigned fields and all).
stripDestinationCreateKeys :: Value -> Value
stripDestinationCreateKeys (Object o) =
  Object (deleteSeveral ["id", "schema_version", "seq_no", "primary_term"] o)
stripDestinationCreateKeys v = v

-- | 'createDestination' creates an alerting destination via the
-- Alerting plugin (@POST /_plugins/_alerting/destinations@). The body
-- is the encoded 'Destination' with the server-assigned @id@,
-- @schema_version@, @seq_no@, and @primary_term@ stripped
-- ('stripDestinationCreateKeys') so it matches the documented
-- create-request shape; the response is the 'DestinationResponse'
-- wrapper carrying the server-assigned indexing metadata and the
-- persisted destination document.
--
-- Surfaced as 'StatusDependant' so a 4xx\/5xx (invalid destination
-- body, plugin not installed, RBAC denial) decodes as an 'EsError'
-- rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#create-destination>.
createDestination ::
  Destination ->
  BHRequest StatusDependant DestinationResponse
createDestination destination =
  post (mkEndpoint ["_plugins", "_alerting", "destinations"]) body
  where
    body = encode (stripDestinationCreateKeys (toJSON destination))

-- | 'updateDestination' replaces a destination via
-- @PUT /_plugins/_alerting/destinations/{destination_id}@. The body is
-- the encoded 'Destination'; the response is the same
-- 'DestinationResponse' wrapper as 'createDestination' (with the
-- incremented @_version@ and @_seq_no@).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing destination id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#update-destination>.
updateDestination ::
  DestinationId ->
  Destination ->
  BHRequest StatusDependant DestinationResponse
updateDestination (DestinationId did) destination =
  put (mkEndpoint ["_plugins", "_alerting", "destinations", did]) (encode destination)

-- | 'deleteDestination' deletes a destination by id via
-- @DELETE /_plugins/_alerting/destinations/{destination_id}@.
--
-- The return type 'DeleteDestinationResponse' deviates from a naive
-- @m 'Acknowledged'@ acceptance for the same reason as 'deleteMonitor':
-- the OpenSearch alerting delete endpoint returns the standard
-- Elasticsearch delete-document response (bare, with a @_shards@
-- report, NO @acknowledged@ key), so an 'Acknowledged' decoder would
-- raise 'EsProtocolException' at runtime.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing destination id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#delete-destination>.
deleteDestination ::
  DestinationId ->
  BHRequest StatusDependant DeleteDestinationResponse
deleteDestination (DestinationId did) =
  delete (mkEndpoint ["_plugins", "_alerting", "destinations", did])

-- =========================================================================
-- Alerting plugin: alerts, acknowledge, stats, email accounts/groups
-- =========================================================================

-- | 'getAlertsWith' lists alert documents via
-- @GET /_plugins/_alerting/monitors/alerts@, forwarding the supplied
-- 'GetAlertsOptions' as query-string parameters (filtering by
-- @alertState@ \/ @severityLevel@ \/ @monitorId@, pagination via
-- @size@ \/ @startIndex@, sort via @sortString@ \/ @sortOrder@ \/
-- @missing@, free-text @searchString@, and @workflowIds@ for chained
-- alerts on OpenSearch 2.9+). Pass 'defaultGetAlertsOptions' for the
-- plain no-arg GET. Returns the full 'GetAlertsResponse' envelope so
-- callers can read @totalAlerts@ (the total hit count, which differs
-- from the page size when @size@ truncates the result). Mirrors the
-- 'getDestinationsWith' precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#get-alerts>.
getAlertsWith ::
  GetAlertsOptions ->
  BHRequest StatusDependant GetAlertsResponse
getAlertsWith opts =
  get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_alerting", "monitors", "alerts"]
        `withQueries` getAlertsOptionsParams opts

-- | 'getAlerts' lists every active alert. Equivalent to 'getAlertsWith'
-- with 'defaultGetAlertsOptions' (server defaults apply) and projects
-- out the bare @['Alert']@ list (the paging envelope is discarded).
-- Callers needing @totalAlerts@ or custom filtering should use
-- 'getAlertsWith'. Mirrors the 'getDestinations' precedent.
getAlerts ::
  BHRequest StatusDependant [Alert]
getAlerts =
  getAlertsResponseAlerts <$> getAlertsWith defaultGetAlertsOptions

-- | 'acknowledgeAlert' acknowledges one or more active alerts of a
-- monitor via
-- @POST /_plugins/_alerting/monitors/{monitor_id}/_acknowledge/alerts@.
-- Alerts already in an @ERROR@, @COMPLETED@, or @ACKNOWLEDGED@ state are
-- not transitioned and are echoed in the @failed@ array of the
-- 'AcknowledgeAlertResponse'. The request body is the encoded
-- 'AcknowledgeAlertRequest' (@{"alerts":[ids]}@).
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid monitor id, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#acknowledge-alert>.
acknowledgeAlert ::
  MonitorId ->
  [Text] ->
  BHRequest StatusDependant AcknowledgeAlertResponse
acknowledgeAlert (MonitorId mid) alertIds =
  post
    (mkEndpoint ["_plugins", "_alerting", "monitors", mid, "_acknowledge", "alerts"])
    (encode (AcknowledgeAlertRequest alertIds))

-- | 'getMonitorStats' fetches alerting scheduler statistics via
-- @GET /_plugins/_alerting/stats[/...]@. The four documented forms are
-- selected by the two optional arguments:
--
-- * @Nothing Nothing@ — @GET /_plugins/_alerting/stats@ (cluster-wide)
-- * @'Nothing' metric@ — @GET /_plugins/_alerting/stats/{metric}@
-- * @nodeId 'Nothing'@ — @GET /_plugins/_alerting/{node-id}/stats@
-- * @nodeId metric@ — @GET /_plugins/_alerting/{node-id}/stats/{metric}@
--
-- The response is large and partly node-specific; the stable shell is
-- decoded into 'MonitorStats' and the variable @nodes@ map is kept as
-- an opaque aeson 'Value' (see 'monitorStatsOther'). Mirrors the
-- 'getSQLStats' pragmatic-typing precedent.
--
-- Surfaced as 'StatusDependant' so a 404 (Alerting plugin disabled
-- \/ unknown metric \/ unknown node id) decodes as an 'EsError'
-- rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#monitor-stats>.
getMonitorStats ::
  Maybe Text ->
  Maybe Text ->
  BHRequest StatusDependant (ParsedEsResponse MonitorStats)
getMonitorStats mNodeId mMetric =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = case mNodeId of
      Nothing ->
        case mMetric of
          Nothing -> mkEndpoint ["_plugins", "_alerting", "stats"]
          Just metric -> mkEndpoint ["_plugins", "_alerting", "stats", metric]
      Just nodeId ->
        case mMetric of
          Nothing -> mkEndpoint ["_plugins", "_alerting", nodeId, "stats"]
          Just metric -> mkEndpoint ["_plugins", "_alerting", nodeId, "stats", metric]

-- | 'createEmailAccount' creates a legacy alerting email account via
-- @POST /_plugins/_alerting/destinations/email_accounts@. The body is
-- the encoded 'EmailAccount' (the server assigns @_id@, @_version@,
-- @_seq_no@, @_primary_term@, and @schema_version@ on write); the
-- response is the 'EmailAccountResponse' wrapper. Mirrors
-- 'createDestination'.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid body, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#create-email-account>.
createEmailAccount ::
  EmailAccount ->
  BHRequest StatusDependant EmailAccountResponse
createEmailAccount account =
  post (mkEndpoint ["_plugins", "_alerting", "destinations", "email_accounts"]) (encode account)

-- | 'getEmailAccount' fetches a single email account by id via
-- @GET /_plugins/_alerting/destinations/email_accounts/{id}@. The
-- endpoint returns the 'EmailAccountResponse' wrapper on the wire
-- (@{_id,_version,_seq_no,_primary_term, email_account:{...}}@); the
-- wrapper is decoded and the 'EmailAccount' is projected out so the
-- public type matches the 'getMonitor' precedent.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing id decodes as
-- an 'EsError' rather than throwing.
getEmailAccount ::
  EmailAccountId ->
  BHRequest StatusDependant EmailAccount
getEmailAccount (EmailAccountId eid) =
  emailAccountResponseEmailAccount <$> get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "destinations", "email_accounts", eid]

-- | 'updateEmailAccount' replaces an email account via
-- @PUT /_plugins/_alerting/destinations/email_accounts/{id}@.
-- Equivalent to 'updateEmailAccountWith' with 'Nothing' (no
-- optimistic-concurrency guard). Mirrors 'updateDestination'.
updateEmailAccount ::
  EmailAccountId ->
  EmailAccount ->
  BHRequest StatusDependant EmailAccountResponse
updateEmailAccount emailAccountId account =
  updateEmailAccountWith emailAccountId account Nothing

-- | 'updateEmailAccountWith' replaces an email account with an optional
-- optimistic-concurrency guard (@if_seq_no@ \/ @if_primary_term@). See
-- 'updateMonitorWith' for the guard semantics. Mirrors the
-- 'updateMonitorWith' precedent.
--
-- Surfaced as 'StatusDependant' so a 404 (or a 409 on a sequence-number
-- mismatch) decodes as an 'EsError' rather than throwing.
updateEmailAccountWith ::
  EmailAccountId ->
  EmailAccount ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant EmailAccountResponse
updateEmailAccountWith (EmailAccountId eid) account mOcc =
  put @StatusDependant endpoint (encode account)
  where
    baseEndpoint =
      mkEndpoint ["_plugins", "_alerting", "destinations", "email_accounts", eid]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries
          baseEndpoint
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- | 'deleteEmailAccount' deletes an email account by id via
-- @DELETE /_plugins/_alerting/destinations/email_accounts/{id}@. Returns
-- the bare Elasticsearch delete-document response (see
-- 'DeleteEmailAccountResponse' for why this deviates from
-- @m 'Acknowledged'@). Mirrors 'deleteDestination'.
deleteEmailAccount ::
  EmailAccountId ->
  BHRequest StatusDependant DeleteEmailAccountResponse
deleteEmailAccount (EmailAccountId eid) =
  delete (mkEndpoint ["_plugins", "_alerting", "destinations", "email_accounts", eid])

-- | 'createEmailGroup' creates a legacy alerting email group via
-- @POST /_plugins/_alerting/destinations/email_groups@. Mirrors
-- 'createEmailAccount'.
createEmailGroup ::
  EmailGroup ->
  BHRequest StatusDependant EmailGroupResponse
createEmailGroup group =
  post (mkEndpoint ["_plugins", "_alerting", "destinations", "email_groups"]) (encode group)

-- | 'getEmailGroup' fetches a single email group by id via
-- @GET /_plugins/_alerting/destinations/email_groups/{id}@, projecting
-- out the 'EmailGroup'. Mirrors 'getEmailAccount'.
getEmailGroup ::
  EmailGroupId ->
  BHRequest StatusDependant EmailGroup
getEmailGroup (EmailGroupId gid) =
  emailGroupResponseEmailGroup <$> get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "destinations", "email_groups", gid]

-- | 'updateEmailGroup' replaces an email group. Equivalent to
-- 'updateEmailGroupWith' with 'Nothing'. Mirrors 'updateEmailAccount'.
updateEmailGroup ::
  EmailGroupId ->
  EmailGroup ->
  BHRequest StatusDependant EmailGroupResponse
updateEmailGroup emailGroupId group =
  updateEmailGroupWith emailGroupId group Nothing

-- | 'updateEmailGroupWith' replaces an email group with an optional
-- optimistic-concurrency guard. Mirrors 'updateEmailAccountWith'.
updateEmailGroupWith ::
  EmailGroupId ->
  EmailGroup ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant EmailGroupResponse
updateEmailGroupWith (EmailGroupId gid) group mOcc =
  put @StatusDependant endpoint (encode group)
  where
    baseEndpoint =
      mkEndpoint ["_plugins", "_alerting", "destinations", "email_groups", gid]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries
          baseEndpoint
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- | 'deleteEmailGroup' deletes an email group by id via
-- @DELETE /_plugins/_alerting/destinations/email_groups/{id}@. Mirrors
-- 'deleteEmailAccount'.
deleteEmailGroup ::
  EmailGroupId ->
  BHRequest StatusDependant DeleteEmailGroupResponse
deleteEmailGroup (EmailGroupId gid) =
  delete (mkEndpoint ["_plugins", "_alerting", "destinations", "email_groups", gid])

-- | 'getDestination' fetches a single destination by id via
-- @GET /_plugins/_alerting/destinations/{id}@. The endpoint returns the
-- same envelope as 'getDestinationsWith'
-- (@{totalDestinations, destinations}@), so the response is decoded as
-- 'GetDestinationsResponse'.  When the id exists the @destinations@
-- list contains exactly one element.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing destination id
-- decodes as an 'EsError' rather than throwing.
--
-- The list-envelope response shape is live-verified on OS 1.3.19,
-- 2.19.5 and 3.7.0 (bead bloodhound-z5j): the single-id GET returns
-- @{totalDestinations: 1, destinations: [<that destination>]}@, not a
-- bare single object.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#get-destination>.
getDestination ::
  DestinationId ->
  BHRequest StatusDependant GetDestinationsResponse
getDestination (DestinationId did) =
  get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "destinations", did]

-- | 'searchEmailAccounts' searches the email account store via
-- @POST /_plugins/_alerting/destinations/email_accounts/_search@. Pass
-- 'Nothing' for the plain empty-body @{}@ POST that returns the first
-- page of all email accounts; pass @'Just' query@ to paginate
-- (@from@\/@size@), sort, or filter using the standard OpenSearch query
-- DSL carried opaquely in 'emailAccountSearchQueryQuery'.
--
-- Returns the full search envelope 'SearchEmailAccountsResponse' so
-- callers can drive pagination and inspect @_shards@ health.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#search-email-account>.
searchEmailAccounts ::
  Maybe EmailAccountSearchQuery ->
  BHRequest StatusDependant (ParsedEsResponse SearchEmailAccountsResponse)
searchEmailAccounts mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "destinations", "email_accounts", "_search"]
    body = encode (maybe defaultEmailAccountSearchQuery id mQuery)

-- | 'searchEmailGroups' searches the email group store via
-- @POST /_plugins/_alerting/destinations/email_groups/_search@. Mirrors
-- 'searchEmailAccounts'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#search-email-group>.
searchEmailGroups ::
  Maybe EmailGroupSearchQuery ->
  BHRequest StatusDependant (ParsedEsResponse SearchEmailGroupsResponse)
searchEmailGroups mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "destinations", "email_groups", "_search"]
    body = encode (maybe defaultEmailGroupSearchQuery id mQuery)

-- | 'searchFindings' searches the document-level monitor findings index
-- via @GET /_plugins/_alerting/findings/_search@. Unlike the other
-- alerting search endpoints, this is a GET with query-string parameters
-- only (no body). Pass 'Nothing' for the plain no-arg GET that returns
-- all findings; pass @'Just' opts@ to filter by @findingId@, sort via
-- @sortString@ \/ @sortOrder@, paginate via @size@ \/ @startIndex@, or
-- filter by @searchString@.
--
-- Returns 'SearchFindingsResponse' (typed @total_findings@ count +
-- opaque findings payload).
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#search-the-findings-index>.
searchFindings ::
  Maybe FindingsSearchOptions ->
  BHRequest StatusDependant (ParsedEsResponse SearchFindingsResponse)
searchFindings mOpts =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_alerting", "findings", "_search"]
        `withQueries` findingsSearchOptionsParams opts
    opts = maybe defaultFindingsSearchOptions id mOpts

-- | 'createComment' adds a comment to a specific alert via
-- @POST /_plugins/_alerting/comments/{alert-id}@. The request body is
-- the encoded 'CreateCommentRequest' (@{"content":"..."}@); the response
-- is the 'CommentResponse' wrapper carrying the server-assigned @_id@,
-- @_seq_no@, @_primary_term@, and the persisted 'Comment'.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid alert id, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#create-comment>.
createComment ::
  Text ->
  CreateCommentRequest ->
  BHRequest StatusDependant CommentResponse
createComment alertId req =
  post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "comments", alertId]

-- | 'updateComment' modifies a previously created comment via
-- @PUT /_plugins/_alerting/comments/{comment-id}@. The request body is
-- the encoded 'UpdateCommentRequest' (@{"content":"..."}@); the response
-- is the same 'CommentResponse' wrapper (with incremented @_seq_no@ and
-- updated @last_updated_time@).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing comment id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#update-comment>.
updateComment ::
  CommentId ->
  UpdateCommentRequest ->
  BHRequest StatusDependant CommentResponse
updateComment (CommentId cid) req =
  put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "comments", cid]

-- | 'searchComments' queries and retrieves existing comments via
-- @GET /_plugins/_alerting/comments/_search@ (a GET-with-body endpoint).
-- Pass 'Nothing' for the plain empty-body @{}@ request that returns all
-- comments; pass @'Just' query@ to filter using the standard OpenSearch
-- query DSL.
--
-- Returns the full search envelope 'SearchCommentsResponse'.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#search-comment>.
searchComments ::
  Maybe CommentSearchQuery ->
  BHRequest StatusDependant (ParsedEsResponse SearchCommentsResponse)
searchComments mQuery =
  withBHResponseParsedEsResponse $
    getWithBody @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_alerting", "comments", "_search"]
    body = encode (maybe defaultCommentSearchQuery id mQuery)

-- | 'deleteComment' removes a specific comment via
-- @DELETE /_plugins/_alerting/comments/{comment-id}@. Returns the
-- minimal 'DeleteCommentResponse' (@{"_id":"..."}@).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing comment id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/alerting/api/#delete-comment>.
deleteComment ::
  CommentId ->
  BHRequest StatusDependant DeleteCommentResponse
deleteComment (CommentId cid) =
  delete (mkEndpoint ["_plugins", "_alerting", "comments", cid])

-- =========================================================================
-- Anomaly Detection plugin
-- =========================================================================

-- | 'createDetector' calls the Anomaly Detection plugin Create Detector API
-- (@POST /_plugins/_anomaly_detection/detectors@). Persists a new
-- detector (single-entity or, when @category_field@ is set,
-- high-cardinality multi-entity) to the
-- @.opendistro-anomaly-detection-state@ system index and returns the
-- standard document-write envelope (@_id@, @_version@, @_primary_term@,
-- @_seq_no@) wrapping the persisted detector under @anomaly_detector@.
--
-- The target index must already contain at least one document, otherwise
-- the plugin rejects the request with HTTP 400 (@Can't create anomaly
-- detector as no document is found in the indices: [...]@). Surfaced as
-- 'StatusDependant' so that 400 (and a 404 for the plugin-not-installed
-- case) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#create-anomaly-detector>.
createDetector ::
  Detector ->
  BHRequest StatusDependant (ParsedEsResponse CreateDetectorResponse)
createDetector detector =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode detector)
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors"]

-- | 'getDetector' calls the Anomaly Detection plugin Get Detector API
-- (@GET /_plugins/_anomaly_detection/detectors/{detector_id}@). Returns
-- the full detector record — the same shape 'createDetector' returns —
-- plus, when the corresponding 'GetDetectorParams' flag is set, the
-- real-time job (@?job=true@) and/or the running task records
-- (@?task=true@) embedded as siblings of @anomaly_detector@.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @detector_id@ or
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#get-detector>.
getDetector ::
  DetectorId ->
  GetDetectorParams ->
  BHRequest StatusDependant (ParsedEsResponse GetDetectorResponse)
getDetector detectorId params =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId]
    endpoint = withQueries baseEndpoint (getDetectorParamsPairs params)

-- | Renders the 'GetDetectorParams' flags as the @?job@ \/ @?task@ query
-- pairs the plugin expects. A 'False' flag contributes no pair, so the
-- default ('defaultGetDetectorParams') produces a bare GET. Uses '(<>)'
-- rather than @concat [..]@ because OS2\/OS3 Requests enable
-- @OverloadedLists@, which would otherwise leave the outer list literal
-- passed to 'concat' (which is @Foldable@-polymorphic) ambiguous.
getDetectorParamsPairs :: GetDetectorParams -> [(Text, Maybe Text)]
getDetectorParamsPairs params =
  [("job", Just "true") | getDetectorParamsJob params]
    <> [("task", Just "true") | getDetectorParamsTask params]

-- | 'previewDetector' calls the Anomaly Detection plugin Preview Detector
-- API (@POST /_plugins/_anomaly_detection/detectors/_preview@). Runs the
-- detector's feature aggregations and RCF model over the historical
-- window bounded by 'previewRequestPeriodStart' /
-- 'previewRequestPeriodEnd' (epoch milliseconds) and returns the
-- resulting anomaly grades alongside a snapshot of the detector. No
-- state is persisted.
--
-- The 'DetectorId' is sent in the request body (@detector_id@) rather
-- than the URL path, matching the documented canonical @_plugins@ form;
-- the path-param form is only documented under the legacy @_opendistro@
-- prefix. The plugin requires both endpoints of the window; a missing
-- field surfaces as HTTP 400 with @Must set both period start and end
-- date with epoch of milliseconds@.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @detector_id@ or
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#preview-detector>.
previewDetector ::
  DetectorId ->
  PreviewRequest ->
  BHRequest StatusDependant (ParsedEsResponse PreviewResponse)
previewDetector detectorId req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint =
      mkEndpoint ["_plugins", "_anomaly_detection", "detectors", "_preview"]
    body =
      encode $
        object
          [ "detector_id" .= unDetectorId detectorId,
            "period_start" .= previewRequestPeriodStart req,
            "period_end" .= previewRequestPeriodEnd req
          ]

-- | 'previewDetectorInline' previews a detector whose identity is carried
-- entirely in the request body rather than as a separate 'DetectorId'
-- argument — the variant 'previewDetector' does not expose. Use it for an
-- unpersisted detector by setting 'previewRequestDetector' to the full
-- inline 'Detector' config, or to preview a persisted detector by
-- reference via 'previewRequestDetectorId'. The endpoint
-- (@POST /_plugins/_anomaly_detection/detectors/_preview@) and the rest
-- of the semantics match 'previewDetector'; no state is persisted.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown
-- 'previewRequestDetectorId' or plugin not installed) decodes as an
-- 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#preview-detector>.
previewDetectorInline ::
  PreviewRequest ->
  BHRequest StatusDependant (ParsedEsResponse PreviewResponse)
previewDetectorInline req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", "_preview"]

-- | 'startDetector' starts an Anomaly Detection detector job via the
-- documented Start Detector Job API
-- (@POST /_plugins/_anomaly_detection/detectors/{detector_id}/_start@).
--
-- Pass 'Nothing' to start the real-time detector job; pass @'Just' req@
-- to kick off a one-shot historical backfill over the window bounded by
-- 'startDetectorJobRequestStartTime' / 'startDetectorJobRequestEndTime'.
-- The server returns a 'DetectorJobAcknowledgment' whose @_id@ is the
-- real-time job id (= the detector id) for the no-body form, or the
-- historical batch task id (a UUID) for the body form.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @detector_id@ or
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#start-detector-job>.
startDetector ::
  DetectorId ->
  Maybe StartDetectorJobRequest ->
  BHRequest StatusDependant (ParsedEsResponse DetectorJobAcknowledgment)
startDetector detectorId mReq =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (maybe emptyBody encode mReq)
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId, "_start"]

-- | 'stopDetector' stops an Anomaly Detection detector job via the
-- documented Stop Detector Job API
-- (@POST /_plugins/_anomaly_detection/detectors/{detector_id}/_stop@).
--
-- Pass 'False' to stop the real-time detector job; pass 'True' to stop
-- the historical analysis, which adds the @?historical=true@ query
-- parameter. The server returns a 'DetectorJobAcknowledgment' with
-- zeroed @_version@ / @_seq_no@ / @_primary_term@ on success.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @detector_id@ or
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#stop-detector-job>.
stopDetector ::
  DetectorId ->
  Bool ->
  BHRequest StatusDependant (ParsedEsResponse DetectorJobAcknowledgment)
stopDetector detectorId historical =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint emptyBody
  where
    endpoint =
      mkEndpoint ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId, "_stop"]
        `withQueries` [("historical", Just "true") | historical]

-- | 'updateDetector' replaces a detector via
-- @PUT /_plugins/_anomaly_detection/detectors/{detector_id}@. The body
-- is the encoded 'Detector' (you cannot update @category_field@, and the
-- server requires both the real-time and historical jobs to be stopped
-- first); the response is the same 'CreateDetectorResponse' envelope as
-- 'createDetector' (with the incremented @_version@ / @_seq_no@).
-- Equivalent to 'updateDetectorWith' with 'Nothing' (no
-- optimistic-concurrency guard), so concurrent updates to the same
-- detector id silently clobber each other.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#update-detector>.
updateDetector ::
  DetectorId ->
  Detector ->
  BHRequest StatusDependant (ParsedEsResponse CreateDetectorResponse)
updateDetector detectorId detector = updateDetectorWith detectorId detector Nothing

-- | 'updateDetectorWith' replaces a detector via
-- @PUT /_plugins/_anomaly_detection/detectors/{detector_id}@ with an
-- optional optimistic-concurrency guard. Pass @'Just' (seqNo, primaryTerm)@
-- — both values read from a prior 'CreateDetectorResponse' — to make the
-- PUT conditional on the detector being unchanged since you last read it;
-- a stale pair causes OpenSearch to respond with HTTP 409 instead of
-- silently overwriting. Pass 'Nothing' for the unconditional semantics of
-- 'updateDetector'. The two values must be supplied together (mirroring
-- the 'updateMonitorWith' / 'putISMPolicyWith' precedent).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id (or a
-- 409 on a sequence-number mismatch) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#update-detector>.
updateDetectorWith ::
  DetectorId ->
  Detector ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant (ParsedEsResponse CreateDetectorResponse)
updateDetectorWith detectorId detector mOcc =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode detector)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries
          baseEndpoint
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- | 'deleteDetector' deletes a detector by id via
-- @DELETE /_plugins/_anomaly_detection/detectors/{detector_id}@.
--
-- The return type 'DeleteDetectorResponse' deviates from an
-- @m 'Acknowledged'@ acceptance for the same reason as the Alerting
-- 'deleteMonitor' and Security Analytics 'deleteSADetector': the AD
-- delete endpoint returns the bare Elasticsearch delete-document response
-- (@_index@, @_id@, @_version@, @result@, @forced_refresh@, @_shards@,
-- @_seq_no@, @_primary_term@, NO @acknowledged@ key), so an
-- 'Acknowledged' decoder would raise 'EsProtocolException' at runtime.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id
-- decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#delete-detector>.
deleteDetector ::
  DetectorId ->
  BHRequest StatusDependant (ParsedEsResponse DeleteDetectorResponse)
deleteDetector detectorId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId]

-- | 'validateDetector' validates a detector configuration via
-- @POST /_plugins/_anomaly_detection/detectors/_validate[/mode]@. The
-- 'ValidateDetectorMode' argument selects the validation mode:
-- 'ValidateDetector' (the bare @\/_validate@ alias, which finds issues
-- that would block creation) or 'ValidateModel' (the @\/_validate\/model@
-- advisory check that estimates the likelihood of successful model
-- training). The body is the encoded 'Detector'. The response is an
-- opaque 'ValidateDetectorResponse' (@{}@ on success; a @detector@ /
-- @model@ sub-object carrying the issues otherwise).
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid detector body, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#validate-detector>.
validateDetector ::
  ValidateDetectorMode ->
  Detector ->
  BHRequest StatusDependant (ParsedEsResponse ValidateDetectorResponse)
validateDetector mode detector =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode detector)
  where
    endpoint =
      mkEndpoint $
        ["_plugins", "_anomaly_detection", "detectors", "_validate"]
          <> validateDetectorModeSegments mode

-- | 'searchDetectors' searches the detector-config index via
-- @POST /_plugins/_anomaly_detection/detectors/_search@. Pass 'Nothing'
-- for the plain empty-body @{}@ POST that returns the first page of all
-- detectors; pass @'Just' query@ to paginate (@from@\/@size@) or filter
-- using the standard OpenSearch query DSL carried opaquely as a 'Value'.
--
-- Returns the full 'SearchDetectorsResponse' search envelope so callers
-- can drive pagination and inspect @_shards@ health; use
-- 'anomalySearchResponseSources' to project out just the
-- @['DetectorResponse']@ list. Mirrors the Alerting 'searchMonitors'
-- precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query DSL) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#search-detector>.
searchDetectors ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse SearchDetectorsResponse)
searchDetectors mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'profileDetector' profiles a detector via
-- @GET /_plugins/_anomaly_detection/detectors/{detector_id}/_profile[/{types}]@.
-- Pass the profile-kind segments to select (e.g. @["ad_task"]@,
-- @["total_size_in_bytes"]@, or multiple @["ad_task","models"]@ joined
-- comma-separated); pass @[]@ for the bare profile. Pass 'True' to add
-- the @?_all=true@ flag (every profile section — expensive for
-- high-cardinality detectors).
--
-- For a multi-entity detector (one created with a @category_field@),
-- pass @'Just' entities@ to filter the profile to a specific categorical
-- slice. The plugin documents this as a GET with a request body
-- @{entity: [{name, value}]}@; 'Entity' is reused for both the request
-- filter and the 'AnomalyResult' response. Pass 'Nothing' for a bodyless
-- profile.
--
-- The response shape varies widely by requested kind and detector kind;
-- it is surfaced as an opaque 'DetectorProfileResponse' so every variant
-- round-trips losslessly (pragmatic-typing precedent).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing detector id (or
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#profile-detector>.
profileDetector ::
  DetectorId ->
  [Text] ->
  Bool ->
  Maybe [Entity] ->
  BHRequest StatusDependant (ParsedEsResponse DetectorProfileResponse)
profileDetector detectorId types allFlag mEntities =
  withBHResponseParsedEsResponse $
    case mEntities of
      Nothing -> get @StatusDependant endpoint
      Just entities -> getWithBody @StatusDependant endpoint (encode (object ["entity" .= entities]))
  where
    baseEndpoint =
      mkEndpoint $
        ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId, "_profile"]
          <> (case types of [] -> []; xs -> [T.intercalate "," xs])
    endpoint =
      if allFlag
        then withQueries baseEndpoint [("_all", Just "true")]
        else baseEndpoint

-- | 'getDetectorStats' fetches plugin statistics via
-- @GET /_plugins/_anomaly_detection/stats@ (and its filtered forms).
-- The OpenSearch path convention is asymmetric: when filtering by node,
-- the path is @\/{node_id}\/stats@; when filtering by stat only, it is
-- @\/stats\/{stat}@; both filters combine as
-- @\/{node_id}\/stats\/{stat}@. Pass the node id first (or 'Nothing'),
-- then the stat name (or 'Nothing').
--
-- The full response carries several top-level index-status keys plus a
-- @nodes@ map; the filtered forms return a subset. It is surfaced as an
-- opaque 'DetectorStatsResponse' so the variable top-level surface
-- round-trips losslessly (pragmatic-typing precedent).
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, unknown
-- node id) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#get-stats>.
getDetectorStats ::
  Maybe Text ->
  Maybe Text ->
  BHRequest StatusDependant (ParsedEsResponse DetectorStatsResponse)
getDetectorStats mNodeId mStat =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_anomaly_detection"]
    withNode = case mNodeId of
      Nothing -> baseEndpoint
      Just nodeId -> mkEndpoint (getRawEndpoint baseEndpoint <> [nodeId])
    withStats = mkEndpoint (getRawEndpoint withNode <> ["stats"])
    endpoint = case mStat of
      Nothing -> withStats
      Just stat -> mkEndpoint (getRawEndpoint withStats <> [stat])

-- | 'searchDetectorResults' searches the anomaly-result index via
-- @POST /_plugins/_anomaly_detection/detectors/results/_search[/{custom_result_index}]@.
-- Pass a custom result-index name to search it alongside (or, with
-- @'Just' True@ for @only_query_custom_result_index@, instead of) the
-- default result index. Pass 'Nothing' for the query body to send the
-- plain empty-body @{}@ POST.
--
-- Each hit's @_source@ is an anomaly-result record; the response is
-- surfaced as a 'SearchDetectorResultsResponse' with opaque 'Value'
-- sources (the full result schema carries many optional fields and lives
-- in a separate mapping page).
--
-- Surfaced as 'StatusDependant' so a 4xx decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#search-results>.
searchDetectorResults ::
  Maybe Text ->
  Maybe Bool ->
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse SearchDetectorResultsResponse)
searchDetectorResults mCustomResultIndex mOnlyCustom mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    baseEndpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", "results", "_search"]
    endpointWithCustom = case mCustomResultIndex of
      Nothing -> baseEndpoint
      Just idx -> mkEndpoint (getRawEndpoint baseEndpoint <> [idx])
    endpoint = case mOnlyCustom of
      Nothing -> endpointWithCustom
      Just b -> withQueries endpointWithCustom [("only_query_custom_result_index", Just (showText b))]
    body = encode (maybe (object []) id mQuery)

-- | 'deleteDetectorResults' deletes anomaly-result documents by query via
-- @DELETE /_plugins/_anomaly_detection/detectors/results@ (introduced in
-- OpenSearch 1.1). The body is a standard OpenSearch query-DSL object
-- (the same shape @deleteByQuery@ accepts) carrying the selection
-- predicate — typically a @bool@ filter on @detector_id@, @task_id@ and
-- a @data_start_time@ range, as shown in the plugin docs. The endpoint
-- only operates on the default result index; anomaly results stored in
-- a custom result index must be deleted manually (per the docs).
--
-- The body is mandatory by design: an empty @{}@ would match every
-- anomaly-result document in the default index, so we make the caller
-- pass the selection explicitly rather than defaulting to @{}@ (the
-- 'searchDetectorResults' precedent defaults to @{}@ because a bare
-- search is harmless; a bare delete-by-query is not). Construct the
-- body with the usual aeson helpers, e.g.
--
-- @
-- 'object' ["query" .= 'object' ["bool" .= 'object' ["filter" .= [...] ]]]
-- @
--
-- The response is the bare @_delete_by_query@ envelope (reused as
-- 'DeleteDetectorResultsResponse', which aliases the document-API
-- 'DeletedDocuments' since the shape is identical — @took@,
-- @timed_out@, @total@, @deleted@, @batches@, @version_conflicts@,
-- @noops@, @retries@, @throttled_millis@, @requests_per_second@,
-- @throttled_until_millis@, @failures@).
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial, malformed query) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#delete-results>.
deleteDetectorResults ::
  Value ->
  BHRequest StatusDependant (ParsedEsResponse DeleteDetectorResultsResponse)
deleteDetectorResults query =
  withBHResponseParsedEsResponse $
    deleteWithBody @StatusDependant endpoint (encode query)
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", "results"]

-- | 'searchDetectorTasks' searches the detection-state index via
-- @POST /_plugins/_anomaly_detection/detectors/tasks/_search@ — the
-- record store for real-time and historical task state. Pass 'Nothing'
-- for the plain empty-body @{}@ POST; pass @'Just' query@ to filter
-- (e.g. by @detector_id@, @task_type@, @is_latest@) using the standard
-- query DSL.
--
-- Each hit's @_source@ is a task record; the response is surfaced as a
-- 'SearchDetectorTasksResponse' with opaque 'Value' sources.
--
-- Surfaced as 'StatusDependant' so a 4xx decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#search-task>.
searchDetectorTasks ::
  Maybe Value ->
  BHRequest StatusDependant (ParsedEsResponse SearchDetectorTasksResponse)
searchDetectorTasks mQuery =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint body
  where
    endpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", "tasks", "_search"]
    body = encode (maybe (object []) id mQuery)

-- | 'topAnomalies' returns the top anomaly results for a
-- high-cardinality detector via
-- @GET /_plugins/_anomaly_detection/detectors/{detector_id}/results/_topAnomalies?historical={bool}@,
-- bucketed by category-field values. Pass 'True' for historical results,
-- 'False' for real-time. The 'TopAnomaliesRequest' body is sent with the
-- GET (it carries @start_time_ms@ / @end_time_ms@ which are required).
--
-- The response is a 'TopAnomaliesResponse' whose @buckets@ each carry a
-- categorical @key@, a @doc_count@, and the @max_anomaly_grade@.
--
-- Surfaced as 'StatusDependant' so a 4xx decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/observing-your-data/ad/api/#get-top-anomaly-results>.
topAnomalies ::
  DetectorId ->
  Bool ->
  TopAnomaliesRequest ->
  BHRequest StatusDependant (ParsedEsResponse TopAnomaliesResponse)
topAnomalies detectorId historical req =
  withBHResponseParsedEsResponse $
    getWithBody @StatusDependant endpoint (encode req)
  where
    baseEndpoint = mkEndpoint ["_plugins", "_anomaly_detection", "detectors", unDetectorId detectorId, "results", "_topAnomalies"]
    endpoint =
      if historical
        then withQueries baseEndpoint [("historical", Just "true")]
        else withQueries baseEndpoint [("historical", Just "false")]

-- Index Rollups plugin
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/>
-- ----------------------------------------------------------------------------

-- | 'createRollup' creates or replaces an index rollup job via
-- @PUT /_plugins/_rollup/jobs/{rollup_id}@. The 'Rollup' body is wrapped
-- in the @{"rollup": ...}@ envelope the endpoint expects. Equivalent to
-- 'createRollupWith' with 'Nothing' (no optimistic-concurrency guard), so
-- a concurrent update silently clobbers the prior job definition.
--
-- Surfaced as 'StatusDependant' so a 4xx (malformed body, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#create-or-update-an-index-rollup-job>.
createRollup ::
  RollupId ->
  Rollup ->
  BHRequest StatusDependant (ParsedEsResponse RollupResponse)
createRollup rollupId rollup = createRollupWith rollupId rollup Nothing

-- | 'createRollupWith' creates or replaces an index rollup job via
-- @PUT /_plugins/_rollup/jobs/{rollup_id}@ with an optional
-- optimistic-concurrency guard. Pass @'Just' (seqNo, primaryTerm)@ — both
-- values read from a prior 'RollupResponse' — to make the PUT conditional
-- on the job being unchanged since you last read it; a stale pair causes
-- OpenSearch to respond with HTTP 409 instead of silently overwriting.
-- Pass 'Nothing' for the unconditional semantics of 'createRollup'. The
-- two values must be supplied together (mirroring the 'putISMPolicyWith'
-- precedent).
--
-- Surfaced as 'StatusDependant' so a 4xx (or a 409 on a sequence-number
-- mismatch) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#create-or-update-an-index-rollup-job>.
createRollupWith ::
  RollupId ->
  Rollup ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant (ParsedEsResponse RollupResponse)
createRollupWith rollupId rollup mOcc =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode (RollupWrapper rollup))
  where
    baseEndpoint = mkEndpoint ["_plugins", "_rollup", "jobs", unRollupId rollupId]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries baseEndpoint $
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- =========================================================================
-- Index Transforms plugin
-- =========================================================================

-- | 'createTransform' creates or replaces an Index Transforms job via
-- @PUT /_plugins/_transform/{transform_id}@. The body is the 'Transform'
-- wrapped under the top-level @transform@ key (see 'TransformRequest');
-- the response is the 'TransformDocumentResponse' document envelope
-- (@_id@, @_version@, @_seq_no@, @_primary_term@, @transform@).
--
-- OpenSearch treats the PUT as upsert: the same call creates a new
-- transform when @transform_id@ is unused and overwrites an existing one
-- otherwise (without an optimistic-concurrency guard). Use
-- 'updateTransformWith' to make the overwrite conditional on a prior
-- @_seq_no@ \/ @_primary_term@.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid body, RBAC denial,
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
createTransform ::
  TransformId ->
  Transform ->
  BHRequest StatusDependant (ParsedEsResponse TransformDocumentResponse)
createTransform transformId transform =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode (TransformRequest transform))
  where
    endpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId]

-- | 'updateTransform' replaces an Index Transforms job via
-- @PUT /_plugins/_transform/{transform_id}@. Equivalent to
-- 'updateTransformWith' with 'Nothing' (no optimistic-concurrency
-- guard), so concurrent updates to the same @transform_id@ silently
-- clobber each other. Mirrors the 'updateMonitor' / 'updateDetector'
-- unconditional-update precedent.
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid body, unknown
-- @transform_id@, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
updateTransform ::
  TransformId ->
  Transform ->
  BHRequest StatusDependant (ParsedEsResponse TransformDocumentResponse)
updateTransform transformId transform = updateTransformWith transformId transform Nothing

-- | 'updateTransformWith' replaces an Index Transforms job via
-- @PUT /_plugins/_transform/{transform_id}@ with an optional
-- optimistic-concurrency guard. Pass @'Just' (seqNo, primaryTerm)@ —
-- both values read from a prior 'TransformDocumentResponse' — to make
-- the PUT conditional on the transform being unchanged since you last
-- read it; a stale pair causes OpenSearch to respond with HTTP 409
-- instead of silently overwriting. Pass 'Nothing' for the unconditional
-- semantics of 'updateTransform'. The two values must be supplied
-- together (mirroring the 'updateMonitorWith' / 'updateDetectorWith' /
-- 'putISMPolicyWith' precedent).
--
-- The plugin docs document @if_seq_no@ \/ @if_primary_term@ as required
-- for the Update operation; OpenSearch still accepts an unparameterised
-- PUT as an overwrite (the @Nothing@ form), which is why
-- 'updateTransform' exists as a convenience.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing @transform_id@
-- (or a 409 on a sequence-number mismatch) decodes as an 'EsError'
-- rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
updateTransformWith ::
  TransformId ->
  Transform ->
  Maybe (Word64, Word64) ->
  BHRequest StatusDependant (ParsedEsResponse TransformDocumentResponse)
updateTransformWith transformId transform mOcc =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode (TransformRequest transform))
  where
    baseEndpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId]
    endpoint = case mOcc of
      Nothing -> baseEndpoint
      Just (seqNo, primaryTerm) ->
        withQueries baseEndpoint $
          [ ("if_seq_no", Just (showText seqNo)),
            ("if_primary_term", Just (showText primaryTerm))
          ]

-- | 'getRollup' fetches a single rollup job by id via
-- @GET /_plugins/_rollup/jobs/{rollup_id}@. The response is the same
-- document-write envelope (@_id@, @_version@, @_seq_no@,
-- @_primary_term@) wrapping the inner rollup that 'createRollup'
-- returns, except the GET form emits @_seqNo@\/@_primaryTerm@ in
-- camelCase; both spellings are decoded (see 'RollupResponse').
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @rollup_id@ or plugin
-- not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#get-an-index-rollup-job>.
getRollup ::
  RollupId ->
  BHRequest StatusDependant (ParsedEsResponse RollupResponse)
getRollup rollupId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_rollup", "jobs", unRollupId rollupId]

-- | 'getAllRollups' lists every rollup job via
-- @GET /_plugins/_rollup/jobs@. The response shape is not documented in
-- the field tables, so the body is returned as an opaque 'Value' for the
-- caller to inspect.
-- | 'getTransform' fetches a single Index Transforms job by id via
-- @GET /_plugins/_transform/{transform_id}@. The response is the
-- 'TransformDocumentResponse' document envelope carrying the persisted
-- 'TransformResponse' (with server-injected bookkeeping fields
-- @schema_version@, @updated_at@, @enabled_at@, @roles@,
-- @transform_id@) alongside the document-write metadata
-- (@_id@, @_version@, @_seq_no@, @_primary_term@).
--
-- Surfaced as 'StatusDependant' so a 404 for a missing @transform_id@
-- (or the plugin not installed) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
getTransform ::
  TransformId ->
  BHRequest StatusDependant (ParsedEsResponse TransformDocumentResponse)
getTransform transformId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId]

-- | 'getTransforms' lists Index Transforms jobs via
-- @GET /_plugins/_transform\/@. Pass 'Nothing' for the plain no-arg
-- list; pass @'Just' opts@ to filter by free-text @search@, paginate
-- with @from@ \/ @size@, or sort by @sortField@ \/ @sortDirection@ (see
-- 'TransformsListOptions').
--
-- The response is a 'GetTransformsResponse' carrying @total_transforms@
-- (the count of all transforms the caller can see, independent of
-- pagination) and the per-page @transforms@ array. Each entry is a
-- 'GetTransformsListEntry' with the same document envelope +
-- 'TransformResponse' payload as 'getTransform'.
--
-- Surfaced as 'StatusDependant' so a 4xx (plugin not installed, RBAC
-- denial) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/>.
getAllRollups ::
  BHRequest StatusDependant (ParsedEsResponse Value)
getAllRollups =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_rollup", "jobs"]

-- | 'deleteRollup' deletes a rollup job by id via
-- @DELETE /_plugins/_rollup/jobs/{rollup_id}@. The plugin returns the bare
-- Elasticsearch delete-document response (@_index@, @_id@, @_version@,
-- @result@, @forced_refresh@, @_shards@, @_seq_no@, @_primary_term@, NO
-- @acknowledged@ key), so the body is returned as an opaque 'Value'.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @rollup_id@ or plugin
-- not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#delete-an-index-rollup-job>.
deleteRollup ::
  RollupId ->
  BHRequest StatusDependant (ParsedEsResponse Value)
deleteRollup rollupId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_rollup", "jobs", unRollupId rollupId]

-- | 'startRollup' starts a rollup job by id via
-- @POST /_plugins/_rollup/jobs/{rollup_id}/_start@. The plugin
-- acknowledges with an 'Acknowledged' body.
--
-- Surfaced as 'StatusDependant' so a 4xx (unknown @rollup_id@, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#start-or-stop-an-index-rollup-job>.
startRollup ::
  RollupId ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
startRollup rollupId =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_rollup", "jobs", unRollupId rollupId, "_start"]

-- | 'stopRollup' stops a rollup job by id via
-- @POST /_plugins/_rollup/jobs/{rollup_id}/_stop@. The plugin
-- acknowledges with an 'Acknowledged' body.
--
-- Surfaced as 'StatusDependant' so a 4xx (unknown @rollup_id@, plugin
-- not installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#start-or-stop-an-index-rollup-job>.
stopRollup ::
  RollupId ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
stopRollup rollupId =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_rollup", "jobs", unRollupId rollupId, "_stop"]

-- | 'explainRollup' returns execution metadata for a rollup job by id
-- via @GET /_plugins/_rollup/jobs/{rollup_id}/_explain@. The response is
-- keyed by the rollup id; decode it into 'ExplainRollupResponse' and
-- look up the entry with 'explainRollupResponseLookup'. Before the job
-- has executed, both @metadata_id@ and @rollup_metadata@ are JSON @null@.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @rollup_id@ or plugin
-- not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-rollups/rollup-api/#explain-an-index-rollup-job>.
explainRollup ::
  RollupId ->
  BHRequest StatusDependant (ParsedEsResponse ExplainRollupResponse)
explainRollup rollupId =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_rollup", "jobs", unRollupId rollupId, "_explain"]

-- =========================================================================
-- Cross-Cluster Replication (CCR) plugin
-- =========================================================================

-- | 'startReplication' calls the CCR plugin Start Replication API
-- (@PUT /_plugins/_replication/{follower-index}/_start@) on the
-- /follower/ cluster. Begins replicating the named leader index into the
-- given follower index. The follower index is created read-only and
-- stays so while replication is active; call 'stopReplication' to
-- convert it back into a standard writable index.
--
-- The 'StartReplicationRequest' carries the leader connection alias,
-- the leader index name, and (when the Security plugin is enabled) the
-- cross-cluster roles. Returns 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#start-replication>.
startReplication ::
  Text ->
  StartReplicationRequest ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
startReplication followerIndex req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_replication", followerIndex, "_start"]

-- | 'stopReplication' calls the CCR plugin Stop Replication API
-- (@POST /_plugins/_replication/{follower-index}/_stop@) on the
-- /follower/ cluster. Terminates replication and converts the follower
-- index into a standard (writable) index. The body is the empty object
-- @{}@ per the plugin docs. Returns 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#stop-replication>.
stopReplication ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
stopReplication followerIndex =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (object []))
  where
    endpoint = mkEndpoint ["_plugins", "_replication", followerIndex, "_stop"]

-- | 'pauseReplication' calls the CCR plugin Pause Replication API
-- (@POST /_plugins/_replication/{follower-index}/_pause@) on the
-- /follower/ cluster. Pauses replication of the leader index.
-- Replication cannot be resumed after being paused for more than 12
-- hours — beyond that you must stop, delete the follower, and restart.
-- The body is the empty object @{}@. Returns 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#pause-replication>.
pauseReplication ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
pauseReplication followerIndex =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (object []))
  where
    endpoint = mkEndpoint ["_plugins", "_replication", followerIndex, "_pause"]

-- | 'resumeReplication' calls the CCR plugin Resume Replication API
-- (@POST /_plugins/_replication/{follower-index}/_resume@) on the
-- /follower/ cluster. Resumes replication of the leader index after a
-- 'pauseReplication'. The body is the empty object @{}@. Returns
-- 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#resume-replication>.
resumeReplication ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
resumeReplication followerIndex =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (object []))
  where
    endpoint = mkEndpoint ["_plugins", "_replication", followerIndex, "_resume"]

-- | 'updateReplicationSettings' calls the CCR plugin Update Settings API
-- (@PUT /_plugins/_replication/{follower-index}/_update@) on the
-- /follower/ cluster. Applies index-level settings (e.g.
-- @index.number_of_replicas@) to the follower index. Returns
-- 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#update-settings>.
updateReplicationSettings ::
  Text ->
  UpdateReplicationSettingsRequest ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
updateReplicationSettings followerIndex req =
  withBHResponseParsedEsResponse $
    put @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_replication", followerIndex, "_update"]

-- | 'getReplicationStatus' calls the CCR plugin Status API
-- (@GET /_plugins/_replication/{follower-index}/_status@) on the
-- /follower/ cluster without the @verbose@ flag. Equivalent to
-- 'getReplicationStatusWith' with 'defaultReplicationStatusOptions'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#get-status>.
getReplicationStatus ::
  Text ->
  BHRequest StatusDependant (ParsedEsResponse ReplicationStatusResponse)
getReplicationStatus = getReplicationStatusWith defaultReplicationStatusOptions

-- | 'getReplicationStatusWith' is the parameterized variant of
-- 'getReplicationStatus': the supplied 'ReplicationStatusOptions' are
-- forwarded as query-string parameters. Set
-- 'replicationStatusOptionsVerbose' to @'Just' True@ to include
-- shard-level 'SyncingDetails' in the response.
--
-- See 'Database.Bloodhound.OpenSearch2.Requests.getReplicationStatus'.
getReplicationStatusWith ::
  ReplicationStatusOptions ->
  Text ->
  BHRequest StatusDependant (ParsedEsResponse ReplicationStatusResponse)
getReplicationStatusWith opts followerIndex =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint =
      mkEndpoint ["_plugins", "_replication", followerIndex, "_status"]
        `withQueries` replicationStatusOptionsParams opts

-- | 'getLeaderStats' calls the CCR plugin Leader Stats API
-- (@GET /_plugins/_replication/leader_stats@) on the /leader/ cluster.
-- Returns aggregate and per-index read-side counters across all
-- replicated leader indexes.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#get-leader-cluster-stats>.
getLeaderStats ::
  BHRequest StatusDependant (ParsedEsResponse LeaderStatsResponse)
getLeaderStats =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_replication", "leader_stats"]

-- | 'getFollowerStats' calls the CCR plugin Follower Stats API
-- (@GET /_plugins/_replication/follower_stats@) on the /follower/
-- cluster. Returns counts of follower indexes by state and aggregate
-- write-side throughput, plus a per-index breakdown.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#get-follower-cluster-stats>.
getFollowerStats ::
  BHRequest StatusDependant (ParsedEsResponse FollowerStatsResponse)
getFollowerStats =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_replication", "follower_stats"]

-- | 'getAutoFollowStats' calls the CCR plugin Auto-Follow Stats API
-- (@GET /_plugins/_replication/autofollow_stats@) on the /follower/
-- cluster. Returns auto-follow activity and the list of configured
-- replication rules. The docs note there is no dedicated \"list
-- patterns\" endpoint — this stats response is the only way to discover
-- existing auto-follow rules.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#get-autofollow-stats>.
getAutoFollowStats ::
  BHRequest StatusDependant (ParsedEsResponse AutoFollowStatsResponse)
getAutoFollowStats =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_replication", "autofollow_stats"]

-- | 'createAutoFollowPattern' calls the CCR plugin Create Replication
-- Rule API (@POST /_plugins/_replication/_autofollow@) on the
-- /follower/ cluster. Creates (or, re-POSTed with the same @name@,
-- updates) an auto-follow rule that automatically starts replication on
-- any existing and future leader indexes whose names match the rule's
-- @pattern@. Returns 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#create-replication-rule>.
createAutoFollowPattern ::
  CreateAutoFollowPatternRequest ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
createAutoFollowPattern req =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_replication", "_autofollow"]

-- | 'deleteAutoFollowPattern' calls the CCR plugin Delete Replication
-- Rule API (@DELETE /_plugins/_replication/_autofollow@) on the
-- /follower/ cluster. Deletes an auto-follow rule. This prevents /new/
-- indexes from being replicated but does /not/ stop replication already
-- initiated by the rule — stop those individually with
-- 'stopReplication'. The delete carries a request body (the rule's
-- @leader_alias@ and @name@), hence 'deleteWithBody'. Returns
-- 'Acknowledged'.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/tuning-your-cluster/replication-plugin/api/#delete-replication-rule>.
deleteAutoFollowPattern ::
  DeleteAutoFollowPatternRequest ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
deleteAutoFollowPattern req =
  withBHResponseParsedEsResponse $
    deleteWithBody @StatusDependant endpoint (encode req)
  where
    endpoint = mkEndpoint ["_plugins", "_replication", "_autofollow"]

-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
getTransforms ::
  Maybe TransformsListOptions ->
  BHRequest StatusDependant (ParsedEsResponse GetTransformsResponse)
getTransforms mOpts =
  withBHResponseParsedEsResponse $
    get @StatusDependant endpoint
  where
    baseEndpoint = mkEndpoint ["_plugins", "_transform"]
    endpoint = case mOpts of
      Nothing -> baseEndpoint
      Just opts -> withQueries baseEndpoint (transformsListOptionsParams opts)

-- | 'startTransform' starts an Index Transforms job via
-- @POST /_plugins/_transform/{transform_id}/_start@. The endpoint takes
-- no request body and returns the 'Acknowledged' envelope on success.
-- A non-continuous transform runs to completion and transitions to the
-- @finished@ state; a continuous transform runs on the schedule until
-- 'stopTransform' is called. Poll 'explainTransform' for the runtime
-- state.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @transform_id@, the
-- plugin not installed, RBAC denial) decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
startTransform ::
  TransformId ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
startTransform transformId =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId, "_start"]

-- | 'stopTransform' stops an Index Transforms job via
-- @POST /_plugins/_transform/{transform_id}/_stop@. The endpoint takes
-- no request body and returns the 'Acknowledged' envelope on success.
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @transform_id@, the
-- plugin not installed, RBAC denial) decodes as an 'EsError' rather
-- than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
stopTransform ::
  TransformId ->
  BHRequest StatusDependant (ParsedEsResponse Acknowledged)
stopTransform transformId =
  withBHResponseParsedEsResponse $
    postNoBody @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId, "_stop"]

-- | 'explainTransform' returns the runtime status and statistics of an
-- Index Transforms job via
-- @GET /_plugins/_transform/{transform_id}/_explain@. The response is
-- keyed by @transform_id@ at the top level; the request builder
-- projects out the lone entry as a 'Maybe' 'TransformExplain' so the
-- public type reads as a plain per-id lookup. The 'Nothing' branch
-- surfaces when the server returns HTTP 200 with no entry (a miss is
-- more typically HTTP 404 via 'StatusDependant' before the body decode
-- runs).
--
-- Surfaced as 'StatusDependant' so a 404 (unknown @transform_id@, the
-- plugin not installed) decodes as an 'EsError' rather than throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
explainTransform ::
  TransformId ->
  BHRequest StatusDependant (ParsedEsResponse (Maybe TransformExplain))
explainTransform transformId =
  fmap (fmap (Map.lookup (unTransformId transformId)))
    . withBHResponseParsedEsResponse
    $ get @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId, "_explain"]

-- | 'previewTransform' previews what the transformed target index would
-- look like via @POST /_plugins/_transform/_preview@. The body is the
-- same 'Transform' wrapper as 'createTransform'; no state is
-- persisted. The response is a 'PreviewTransformResponse' carrying the
-- would-be target documents as opaque aeson 'Value's (the row shape is
-- caller-driven by @groups[].target_field@ names plus @aggregations@
-- keys).
--
-- Surfaced as 'StatusDependant' so a 4xx (invalid body, plugin not
-- installed, RBAC denial) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
previewTransform ::
  Transform ->
  BHRequest StatusDependant (ParsedEsResponse PreviewTransformResponse)
previewTransform transform =
  withBHResponseParsedEsResponse $
    post @StatusDependant endpoint (encode (TransformRequest transform))
  where
    endpoint = mkEndpoint ["_plugins", "_transform", "_preview"]

-- | 'deleteTransform' deletes an Index Transforms job by id via
-- @DELETE /_plugins/_transform/{transform_id}@. The endpoint does NOT
-- delete the source or target indexes — only the transform job itself.
--
-- The response is the bulk-response envelope (the transform is stored
-- as a doc in @.opensearch-ism-config@, so DELETE surfaces through the
-- bulk handler). The shared 'BulkResponse' from
-- @Common.Types.Bulk@ matches that envelope byte-for-byte, so we reuse
-- it rather than introducing a per-plugin wrapper.
--
-- Surfaced as 'StatusDependant' so a 404 for a missing @transform_id@
-- (or the plugin not installed) decodes as an 'EsError' rather than
-- throwing.
--
-- For more information see
-- <https://docs.opensearch.org/2.18/im-plugin/index-transforms/transforms-apis/>.
deleteTransform ::
  TransformId ->
  BHRequest StatusDependant (ParsedEsResponse BulkResponse)
deleteTransform transformId =
  withBHResponseParsedEsResponse $
    delete @StatusDependant endpoint
  where
    endpoint = mkEndpoint ["_plugins", "_transform", unTransformId transformId]