packages feed

bloodhound-1.0.0.0: tests/Test/ISMSpec.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}

module Test.ISMSpec (spec) where

import Control.Monad.IO.Class (liftIO)
import Data.Aeson (Value (..), decode, eitherDecode, encode, object, (.=))
import Data.Aeson.KeyMap qualified as KM
import Data.Aeson.Types (parseEither, parseJSON)
import Data.ByteString.Lazy.Char8 qualified as LBS
import Data.Either (isLeft)
import Data.List (sort)
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Time.Clock.POSIX (getPOSIXTime)
import Database.Bloodhound.Client.Cluster (BH)
import Database.Bloodhound.OpenSearch1.Client qualified as OS1.Client
import Database.Bloodhound.OpenSearch1.Types qualified as OS1.Types
import Database.Bloodhound.OpenSearch2.Client qualified as OS2.Client
import Database.Bloodhound.OpenSearch2.Requests qualified as OS2Requests
import Database.Bloodhound.OpenSearch2.Types qualified as OS2.Types
import Database.Bloodhound.OpenSearch3.Client qualified as OS3.Client
import Database.Bloodhound.OpenSearch3.Requests qualified as OS3Requests
import Database.Bloodhound.OpenSearch3.Types qualified as OS3.Types
import TestsUtils.Common
import TestsUtils.Import
import Prelude

-- The shared typed policy body used by both the request sample and the
-- expected response sample below. Matches the JSON shape documented at
-- https://docs.opensearch.org/latest/im-plugin/ism/policies/.
samplePolicyBody :: OS1.Types.ISMPolicyBody
samplePolicyBody =
  OS1.Types.ISMPolicyBody
    { OS1.Types.ismPolicyBodyDescription = Just "test policy",
      OS1.Types.ismPolicyBodyDefaultState = "ingest",
      OS1.Types.ismPolicyBodyStates =
        [ OS1.Types.ISMState
            { OS1.Types.ismStateName = "ingest",
              OS1.Types.ismStateActions = [],
              OS1.Types.ismStateTransitions = []
            }
        ],
      OS1.Types.ismPolicyBodyIsmTemplate = [],
      OS1.Types.ismPolicyBodyErrorNotification = Nothing,
      OS1.Types.ismPolicyBodyUserVariables = Nothing
    }

-- A more elaborate policy body used by the typed-schema JSON tests below:
-- one state with a rollover action, a transition into the delete state, and
-- a transition condition. Exercises every typed sub-shape at least once.
sampleFullPolicyBody :: OS1.Types.ISMPolicyBody
sampleFullPolicyBody =
  OS1.Types.ISMPolicyBody
    { OS1.Types.ismPolicyBodyDescription = Just "rollover then delete",
      OS1.Types.ismPolicyBodyDefaultState = "hot",
      OS1.Types.ismPolicyBodyStates =
        [ OS1.Types.ISMState
            { OS1.Types.ismStateName = "hot",
              OS1.Types.ismStateActions =
                [ OS1.Types.mkISMActionEntry
                    ( OS1.Types.ISMActionRollover
                        ( Just
                            ( OS1.Types.ISMRolloverConfig
                                { OS1.Types.ismRolloverConfigMinSize = Just "1gb",
                                  OS1.Types.ismRolloverConfigMinDocCount = Nothing,
                                  OS1.Types.ismRolloverConfigMinIndexAge = Just "1d",
                                  OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                                  OS1.Types.ismRolloverConfigCopyAlias = Nothing
                                }
                            )
                        )
                    ),
                  OS1.Types.mkISMActionEntry
                    ( OS1.Types.ISMActionForceMerge
                        ( Just
                            ( OS1.Types.ISMForceMergeConfig
                                { OS1.Types.ismForceMergeConfigMaxNumSegments = Just 1,
                                  OS1.Types.ismForceMergeConfigWaitForCompletion = Nothing,
                                  OS1.Types.ismForceMergeConfigTaskExecutionTimeout = Nothing
                                }
                            )
                        )
                    )
                ],
              OS1.Types.ismStateTransitions =
                [ OS1.Types.ISMTransition
                    { OS1.Types.ismTransitionStateName = "delete",
                      OS1.Types.ismTransitionConditions =
                        Just
                          ( OS1.Types.ISMCondition
                              { OS1.Types.ismConditionMinSize = Just "50gb",
                                OS1.Types.ismConditionMinDocCount = Nothing,
                                OS1.Types.ismConditionMinIndexAge = Nothing,
                                OS1.Types.ismConditionMinRolloverAge = Nothing,
                                OS1.Types.ismConditionCron =
                                  Just
                                    ( OS1.Types.ISMCron
                                        ( OS1.Types.ISMCronCondition
                                            { OS1.Types.ismCronConditionExpression = "* 17 * * SAT",
                                              OS1.Types.ismCronConditionTimezone = Just "America/Los_Angeles"
                                            }
                                        )
                                    ),
                                OS1.Types.ismConditionDsl = Nothing
                              }
                          )
                    }
                ]
            },
          OS1.Types.ISMState
            { OS1.Types.ismStateName = "delete",
              OS1.Types.ismStateActions = [OS1.Types.mkISMActionEntry OS1.Types.ISMActionDelete],
              OS1.Types.ismStateTransitions = []
            }
        ],
      OS1.Types.ismPolicyBodyIsmTemplate =
        [ OS1.Types.ISMTemplate
            { OS1.Types.ismTemplateIndexPatterns = ["log-*"],
              OS1.Types.ismTemplatePriority = Just 50
            }
        ],
      OS1.Types.ismPolicyBodyErrorNotification = Nothing,
      OS1.Types.ismPolicyBodyUserVariables =
        Just (OS1.Types.ISMUserVariables (Map.fromList [("role", "data")]))
    }

os1SamplePolicy :: OS1.Types.ISMPolicyRequest
os1SamplePolicy = OS1.Types.ISMPolicyRequest samplePolicyBody

os1ExpectedResponsePolicy :: OS1.Types.ISMPolicyResponse
os1ExpectedResponsePolicy =
  OS1.Types.ISMPolicyResponse
    { OS1.Types.ismPolicyResponsePolicyId = Nothing,
      -- The test fixture's samplePutResponseBody carries schema_version and
      -- last_updated_time on the outer @policy@ wrapper, not inside the
      -- inner body. PutISMPolicyResponse merges the outer fields into the
      -- inner ISMPolicyResponse so callers see them regardless of where OS
      -- placed them.
      OS1.Types.ismPolicyResponseSchemaVersion = Just 1,
      OS1.Types.ismPolicyResponseLastUpdatedTime = Just 1548311893804,
      OS1.Types.ismPolicyResponseBody = samplePolicyBody
    }

os2SamplePolicy :: OS2.Types.ISMPolicyRequest
os2SamplePolicy =
  OS2.Types.ISMPolicyRequest
    ( OS2.Types.ISMPolicyBody
        { OS2.Types.ismPolicyBodyDescription = Just "test policy",
          OS2.Types.ismPolicyBodyDefaultState = "ingest",
          OS2.Types.ismPolicyBodyStates =
            [ OS2.Types.ISMState
                { OS2.Types.ismStateName = "ingest",
                  OS2.Types.ismStateActions = [],
                  OS2.Types.ismStateTransitions = []
                }
            ],
          OS2.Types.ismPolicyBodyIsmTemplate = [],
          OS2.Types.ismPolicyBodyErrorNotification = Nothing,
          OS2.Types.ismPolicyBodyUserVariables = Nothing
        }
    )

os2ExpectedResponsePolicy :: OS2.Types.ISMPolicyResponse
os2ExpectedResponsePolicy =
  OS2.Types.ISMPolicyResponse
    { OS2.Types.ismPolicyResponsePolicyId = Nothing,
      OS2.Types.ismPolicyResponseSchemaVersion = Just 1,
      OS2.Types.ismPolicyResponseLastUpdatedTime = Just 1548311893804,
      OS2.Types.ismPolicyResponseBody =
        OS2.Types.ISMPolicyBody
          { OS2.Types.ismPolicyBodyDescription = Just "test policy",
            OS2.Types.ismPolicyBodyDefaultState = "ingest",
            OS2.Types.ismPolicyBodyStates =
              [ OS2.Types.ISMState
                  { OS2.Types.ismStateName = "ingest",
                    OS2.Types.ismStateActions = [],
                    OS2.Types.ismStateTransitions = []
                  }
              ],
            OS2.Types.ismPolicyBodyIsmTemplate = [],
            OS2.Types.ismPolicyBodyErrorNotification = Nothing,
            OS2.Types.ismPolicyBodyUserVariables = Nothing
          }
    }

os3SamplePolicy :: OS3.Types.ISMPolicyRequest
os3SamplePolicy =
  OS3.Types.ISMPolicyRequest
    ( OS3.Types.ISMPolicyBody
        { OS3.Types.ismPolicyBodyDescription = Just "test policy",
          OS3.Types.ismPolicyBodyDefaultState = "ingest",
          OS3.Types.ismPolicyBodyStates =
            [ OS3.Types.ISMState
                { OS3.Types.ismStateName = "ingest",
                  OS3.Types.ismStateActions = [],
                  OS3.Types.ismStateTransitions = []
                }
            ],
          OS3.Types.ismPolicyBodyIsmTemplate = [],
          OS3.Types.ismPolicyBodyErrorNotification = Nothing,
          OS3.Types.ismPolicyBodyUserVariables = Nothing
        }
    )

os3ExpectedResponsePolicy :: OS3.Types.ISMPolicyResponse
os3ExpectedResponsePolicy =
  OS3.Types.ISMPolicyResponse
    { OS3.Types.ismPolicyResponsePolicyId = Nothing,
      OS3.Types.ismPolicyResponseSchemaVersion = Just 1,
      OS3.Types.ismPolicyResponseLastUpdatedTime = Just 1548311893804,
      OS3.Types.ismPolicyResponseBody =
        OS3.Types.ISMPolicyBody
          { OS3.Types.ismPolicyBodyDescription = Just "test policy",
            OS3.Types.ismPolicyBodyDefaultState = "ingest",
            OS3.Types.ismPolicyBodyStates =
              [ OS3.Types.ISMState
                  { OS3.Types.ismStateName = "ingest",
                    OS3.Types.ismStateActions = [],
                    OS3.Types.ismStateTransitions = []
                  }
              ],
            OS3.Types.ismPolicyBodyIsmTemplate = [],
            OS3.Types.ismPolicyBodyErrorNotification = Nothing,
            OS3.Types.ismPolicyBodyUserVariables = Nothing
          }
    }

-- Realistic OpenSearch PUT policy response body. The outer "policy" wraps
-- metadata (schema_version, last_updated_time, ...) plus the inner "policy"
-- which is the actual user-supplied policy body.
samplePutResponseBody :: LBS.ByteString
samplePutResponseBody =
  LBS.pack $
    unlines
      [ "{",
        "  \"_id\": \"test_policy\",",
        "  \"_version\": 2,",
        "  \"_primary_term\": 1,",
        "  \"_seq_no\": 5,",
        "  \"policy\": {",
        "    \"last_updated_time\": 1548311893804,",
        "    \"schema_version\": 1,",
        "    \"policy\": {",
        "      \"description\": \"test policy\",",
        "      \"default_state\": \"ingest\",",
        "      \"states\": [",
        "        {\"name\": \"ingest\", \"actions\": [], \"transitions\": []}",
        "      ]",
        "    }",
        "  }",
        "}"
      ]

spec :: Spec
spec = do
  describe "ISM PolicyId JSON" $ do
    it "encodes PolicyId as a bare JSON string" $ do
      encode (OS1.Types.PolicyId "my_policy") `shouldBe` "\"my_policy\""

    it "decodes a bare JSON string into PolicyId" $ do
      let decoded = decode "\"my_policy\"" :: Maybe OS1.Types.PolicyId
      decoded `shouldBe` Just (OS1.Types.PolicyId "my_policy")

    it "round-trips PolicyId through encode -> decode" $ do
      let original = OS1.Types.PolicyId "round_trip_id"
      let reencoded = encode original
      let decoded = decode reencoded :: Maybe OS1.Types.PolicyId
      decoded `shouldBe` Just original

  describe "ISM PutISMPolicyResponse JSON decoding" $ do
    it "decodes OpenSearch1 response envelope with nested policy" $ do
      let decoded = decode samplePutResponseBody :: Maybe OS1.Types.PutISMPolicyResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS1.Types.putISMPolicyResponseId r `shouldBe` "test_policy"
      OS1.Types.putISMPolicyResponsePrimaryTerm r `shouldBe` 1
      OS1.Types.putISMPolicyResponseSeqNo r `shouldBe` 5
      OS1.Types.putISMPolicyResponsePolicy r `shouldBe` os1ExpectedResponsePolicy

    it "decodes OpenSearch2 response envelope with nested policy" $ do
      let decoded = decode samplePutResponseBody :: Maybe OS2.Types.PutISMPolicyResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS2.Types.putISMPolicyResponseId r `shouldBe` "test_policy"
      OS2.Types.putISMPolicyResponsePolicy r `shouldBe` os2ExpectedResponsePolicy

    it "decodes OpenSearch3 response envelope with nested policy" $ do
      let decoded = decode samplePutResponseBody :: Maybe OS3.Types.PutISMPolicyResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS3.Types.putISMPolicyResponseId r `shouldBe` "test_policy"
      OS3.Types.putISMPolicyResponsePolicy r `shouldBe` os3ExpectedResponsePolicy

    it "rejects a response where the outer policy is not an object" $ do
      let malformed =
            LBS.pack $
              unlines
                [ "{",
                  "  \"_id\": \"test_policy\",",
                  "  \"_version\": 1,",
                  "  \"_primary_term\": 1,",
                  "  \"_seq_no\": 0,",
                  "  \"policy\": \"not-an-object\"",
                  "}"
                ]
      let result = parseEither parseJSON =<< eitherDecode malformed :: Either String OS1.Types.PutISMPolicyResponse
      result `shouldSatisfy` isLeft

    it "rejects a response missing the inner policy key" $ do
      let missingInner =
            LBS.pack $
              unlines
                [ "{",
                  "  \"_id\": \"test_policy\",",
                  "  \"_version\": 1,",
                  "  \"_primary_term\": 1,",
                  "  \"_seq_no\": 0,",
                  "  \"policy\": {\"schema_version\": 1, \"last_updated_time\": 1548311893804}",
                  "}"
                ]
      let result = parseEither parseJSON =<< eitherDecode missingInner :: Either String OS1.Types.PutISMPolicyResponse
      result `shouldSatisfy` isLeft

    it "round-trips OpenSearch1 decode -> encode -> decode" $ do
      let Just (r1 :: OS1.Types.PutISMPolicyResponse) = decode samplePutResponseBody
      let reencoded = encode r1
      let decodedAgain = decode reencoded :: Maybe OS1.Types.PutISMPolicyResponse
      decodedAgain `shouldBe` Just r1

    -- Asserts the typed inner policy is populated from the response body:
    -- description and default_state come through, and the server-only fields
    -- (policy_id, schema_version, last_updated_time) are merged from the
    -- outer envelope (where the test fixture carries them) into the inner
    -- ISMPolicyResponse by PutISMPolicyResponse's decoder.
    it "decodes typed inner policy fields from the PUT response" $ do
      let Just (r :: OS1.Types.PutISMPolicyResponse) = decode samplePutResponseBody
      let inner = OS1.Types.putISMPolicyResponsePolicy r
      OS1.Types.ismPolicyResponsePolicyId inner `shouldBe` Nothing
      OS1.Types.ismPolicyResponseSchemaVersion inner `shouldBe` Just 1
      OS1.Types.ismPolicyResponseLastUpdatedTime inner `shouldBe` Just 1548311893804
      let body = OS1.Types.ismPolicyResponseBody inner
      OS1.Types.ismPolicyBodyDescription body `shouldBe` Just "test policy"
      OS1.Types.ismPolicyBodyDefaultState body `shouldBe` "ingest"
      OS1.Types.ismPolicyBodyStates body `shouldSatisfy` (not . null)
      let firstState = head (OS1.Types.ismPolicyBodyStates body)
      OS1.Types.ismStateName firstState `shouldBe` "ingest"

  -- ========================================================================
  -- Typed schema: ISMPolicyBody, ISMState, ISMAction, ISMTransition,
  -- ISMCondition. Pure JSON round-trip and rejection tests for the typed
  -- shapes added in bloodhound-04f.6.1.1. Almost all cases exercise OS1 only
  -- because the OS2/OS3 ISM modules are byte-identical mirrors (the build
  -- verifies them); the allocation decode is spot-checked on OS3 as well.
  -- ========================================================================

  describe "ISM typed schema: ISMPolicyBody" $ do
    it "round-trips the minimal body through encode -> decode" $ do
      let encoded = encode samplePolicyBody
      decode encoded `shouldBe` Just samplePolicyBody

    it "encodes the minimal body without the server-only fields" $ do
      -- omitNulls drops empty arrays (actions/transitions) and Nothing
      -- fields (ism_template, error_notification, user_vars); the body
      -- therefore carries just description, default_state and states.
      let encoded = encode samplePolicyBody
      let Just (parsed :: Value) = decode encoded
      case parsed of
        Object o -> do
          KM.lookup "default_state" o `shouldBe` Just (String "ingest")
          KM.lookup "description" o `shouldBe` Just (String "test policy")
          -- Empty actions/transitions arrays must be omitted by omitNulls:
          KM.lookup "actions" o `shouldBe` Nothing
          KM.lookup "transitions" o `shouldBe` Nothing
          KM.lookup "ism_template" o `shouldBe` Nothing
        other -> expectationFailure ("Expected Object, got " <> show other)

    it "round-trips the full body (states, actions, transitions, template, user_vars)" $ do
      let encoded = encode sampleFullPolicyBody
      decode encoded `shouldBe` Just sampleFullPolicyBody

    it "rejects a body missing default_state" $ do
      let body = LBS.pack "{\"description\":\"no default\",\"states\":[]}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMPolicyBody
      result `shouldSatisfy` isLeft

    it "rejects a body missing states (states is required by OpenSearch)" $ do
      let body = LBS.pack "{\"description\":\"no states\",\"default_state\":\"hot\"}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMPolicyBody
      result `shouldSatisfy` isLeft

    it "decodes a body with no description but with states" $ do
      let body =
            LBS.pack
              "{\"default_state\":\"hot\",\"states\":[{\"name\":\"hot\"}]}"
      let Just (parsed :: OS1.Types.ISMPolicyBody) = decode body
      OS1.Types.ismPolicyBodyDescription parsed `shouldBe` Nothing
      OS1.Types.ismPolicyBodyDefaultState parsed `shouldBe` "hot"
      OS1.Types.ismPolicyBodyStates parsed `shouldSatisfy` (not . null)

  -- ========================================================================
  -- ism_template list form + fractional priority. The OpenSearch spec allows
  -- ism_template as a single object, null, or an array of IsmTemplate, and
  -- types priority as a generic number. These pin the normalisation
  -- (single/array/null/missing -> list) and the Scientific priority decoder
  -- that previously rejected fractional values. OS1, OS2 and OS3 share the
  -- codec, so each is exercised.
  -- ========================================================================
  describe "ISM typed schema: ism_template list form" $ do
    let os1Tpl =
          OS1.Types.ISMTemplate
            { OS1.Types.ismTemplateIndexPatterns = ["log-*"],
              OS1.Types.ismTemplatePriority = Just 50
            }
        os2Tpl =
          OS2.Types.ISMTemplate
            { OS2.Types.ismTemplateIndexPatterns = ["log-*"],
              OS2.Types.ismTemplatePriority = Just 50
            }
        os3Tpl =
          OS3.Types.ISMTemplate
            { OS3.Types.ismTemplateIndexPatterns = ["log-*"],
              OS3.Types.ismTemplatePriority = Just 50
            }

    it "OS1 decodes a single-object ism_template into a one-element list" $ do
      let body =
            LBS.pack
              "{\"default_state\":\"hot\",\"states\":[],\"ism_template\":{\"index_patterns\":[\"log-*\"],\"priority\":50}}"
      let Just (p :: OS1.Types.ISMPolicyBody) = decode body
      OS1.Types.ismPolicyBodyIsmTemplate p `shouldBe` [os1Tpl]

    it "OS1 decodes an array ism_template into a multi-element list" $ do
      let body =
            LBS.pack
              "{\"default_state\":\"hot\",\"states\":[],\"ism_template\":[{\"index_patterns\":[\"log-*\"],\"priority\":50},{\"index_patterns\":[\"audit-*\"],\"priority\":10}]}"
      let Just (p :: OS1.Types.ISMPolicyBody) = decode body
      OS1.Types.ismPolicyBodyIsmTemplate p `shouldSatisfy` (\l -> length l == 2)
      OS1.Types.ismTemplateIndexPatterns (head (OS1.Types.ismPolicyBodyIsmTemplate p))
        `shouldBe` ["log-*"]

    it "OS1 decodes a null ism_template into the empty list" $ do
      let body =
            LBS.pack
              "{\"default_state\":\"hot\",\"states\":[],\"ism_template\":null}"
      let Just (p :: OS1.Types.ISMPolicyBody) = decode body
      OS1.Types.ismPolicyBodyIsmTemplate p `shouldBe` []

    it "OS1 decodes a missing ism_template into the empty list" $ do
      let body = LBS.pack "{\"default_state\":\"hot\",\"states\":[]}"
      let Just (p :: OS1.Types.ISMPolicyBody) = decode body
      OS1.Types.ismPolicyBodyIsmTemplate p `shouldBe` []

    it "OS1 encodes a non-empty ism_template as a JSON array" $ do
      let b = samplePolicyBody {OS1.Types.ismPolicyBodyIsmTemplate = [os1Tpl]}
      let Just (parsed :: Value) = decode (encode b)
      case parsed of
        Object o -> case KM.lookup "ism_template" o of
          Just (Array _) -> pure ()
          other -> expectationFailure ("Expected Array, got " <> show other)
        other -> expectationFailure ("Expected Object, got " <> show other)

    it "OS1 omits ism_template when the list is empty" $ do
      let Just (parsed :: Value) = decode (encode samplePolicyBody)
      case parsed of
        Object o -> KM.lookup "ism_template" o `shouldBe` Nothing
        other -> expectationFailure ("Expected Object, got " <> show other)

    it "OS2 decodes a single-object ism_template into a one-element list" $ do
      let body =
            LBS.pack
              "{\"default_state\":\"hot\",\"states\":[],\"ism_template\":{\"index_patterns\":[\"log-*\"],\"priority\":50}}"
      let Just (p :: OS2.Types.ISMPolicyBody) = decode body
      OS2.Types.ismPolicyBodyIsmTemplate p `shouldBe` [os2Tpl]

    it "OS3 decodes a single-object ism_template into a one-element list" $ do
      let body =
            LBS.pack
              "{\"default_state\":\"hot\",\"states\":[],\"ism_template\":{\"index_patterns\":[\"log-*\"],\"priority\":50}}"
      let Just (p :: OS3.Types.ISMPolicyBody) = decode body
      OS3.Types.ismPolicyBodyIsmTemplate p `shouldBe` [os3Tpl]

  describe "ISM typed schema: ISMTemplate priority (Scientific)" $ do
    it "OS1 decodes a fractional priority" $ do
      let body = LBS.pack "{\"index_patterns\":[\"log-*\"],\"priority\":1.5}"
      let Just (t :: OS1.Types.ISMTemplate) = decode body
      OS1.Types.ismTemplatePriority t `shouldBe` Just 1.5

    it "OS1 round-trips a fractional priority" $ do
      let t =
            OS1.Types.ISMTemplate
              { OS1.Types.ismTemplateIndexPatterns = ["log-*"],
                OS1.Types.ismTemplatePriority = Just 1.5
              }
      decode (encode t) `shouldBe` Just t

    it "OS2 decodes a fractional priority" $ do
      let body = LBS.pack "{\"index_patterns\":[\"log-*\"],\"priority\":2.5}"
      let Just (t :: OS2.Types.ISMTemplate) = decode body
      OS2.Types.ismTemplatePriority t `shouldBe` Just 2.5

    it "OS3 decodes a fractional priority" $ do
      let body = LBS.pack "{\"index_patterns\":[\"log-*\"],\"priority\":3.5}"
      let Just (t :: OS3.Types.ISMTemplate) = decode body
      OS3.Types.ismTemplatePriority t `shouldBe` Just 3.5

  describe "ISM typed schema: ISMState" $ do
    it "round-trips a state with actions and transitions" $ do
      let s =
            OS1.Types.ISMState
              { OS1.Types.ismStateName = "hot",
                OS1.Types.ismStateActions = [OS1.Types.mkISMActionEntry OS1.Types.ISMActionDelete],
                OS1.Types.ismStateTransitions = []
              }
      decode (encode s) `shouldBe` Just s

    it "rejects a state missing name" $ do
      let body = LBS.pack "{\"actions\":[],\"transitions\":[]}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMState
      result `shouldSatisfy` isLeft

    it "decodes a state with no actions or transitions keys (defaults to empty)" $ do
      let body = LBS.pack "{\"name\":\"hot\"}"
      let Just (s :: OS1.Types.ISMState) = decode body
      OS1.Types.ismStateActions s `shouldBe` []
      OS1.Types.ismStateTransitions s `shouldBe` []

  describe "ISM typed schema: ISMAction encoding" $ do
    it "encodes ISMActionRollover as a single-key object {\"rollover\":{...}}" $ do
      let a =
            OS1.Types.ISMActionRollover
              ( Just
                  ( OS1.Types.ISMRolloverConfig
                      { OS1.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS1.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS1.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS1.Types.ismRolloverConfigCopyAlias = Nothing
                      }
                  )
              )
      encode a `shouldBe` encode (object ["rollover" .= object ["min_size" .= String "1gb"]])

    it "round-trips rollover action with typed config" $ do
      let a =
            OS1.Types.ISMActionRollover
              ( Just
                  ( OS1.Types.ISMRolloverConfig
                      { OS1.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS1.Types.ismRolloverConfigMinDocCount = Just 1000,
                        OS1.Types.ismRolloverConfigMinIndexAge = Just "1d",
                        OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS1.Types.ismRolloverConfigCopyAlias = Nothing
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS2 round-trips rollover action with copy_alias" $ do
      let a =
            OS2.Types.ISMActionRollover
              ( Just
                  ( OS2.Types.ISMRolloverConfig
                      { OS2.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS2.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS2.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS2.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS2.Types.ismRolloverConfigCopyAlias = Just True
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS2 decodes rollover action with copy_alias from OpenSearch" $ do
      let body = LBS.pack "{\"rollover\":{\"min_size\":\"1gb\",\"copy_alias\":true}}"
      let Just (a :: OS2.Types.ISMAction) = decode body
      case a of
        OS2.Types.ISMActionRollover (Just cfg) -> do
          OS2.Types.ismRolloverConfigMinSize cfg `shouldBe` Just "1gb"
          OS2.Types.ismRolloverConfigCopyAlias cfg `shouldBe` Just True
        other -> expectationFailure ("Expected ISMActionRollover, got " <> show other)

    it "OS2 encodes copy_alias as a top-level rollover key" $ do
      let a =
            OS2.Types.ISMActionRollover
              ( Just
                  ( OS2.Types.ISMRolloverConfig
                      { OS2.Types.ismRolloverConfigMinSize = Nothing,
                        OS2.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS2.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS2.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS2.Types.ismRolloverConfigCopyAlias = Just True
                      }
                  )
              )
      encode a `shouldBe` encode (object ["rollover" .= object ["copy_alias" .= Bool True]])

    it "OS3 round-trips rollover action with copy_alias" $ do
      let a =
            OS3.Types.ISMActionRollover
              ( Just
                  ( OS3.Types.ISMRolloverConfig
                      { OS3.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS3.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS3.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS3.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS3.Types.ismRolloverConfigCopyAlias = Just True
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS3 decodes rollover action with copy_alias from OpenSearch" $ do
      let body = LBS.pack "{\"rollover\":{\"min_size\":\"1gb\",\"copy_alias\":true}}"
      let Just (a :: OS3.Types.ISMAction) = decode body
      case a of
        OS3.Types.ISMActionRollover (Just cfg) -> do
          OS3.Types.ismRolloverConfigMinSize cfg `shouldBe` Just "1gb"
          OS3.Types.ismRolloverConfigCopyAlias cfg `shouldBe` Just True
        other -> expectationFailure ("Expected ISMActionRollover, got " <> show other)

    it "OS2 round-trips rollover with copy_alias = Just False (not omitted)" $ do
      let a =
            OS2.Types.ISMActionRollover
              ( Just
                  ( OS2.Types.ISMRolloverConfig
                      { OS2.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS2.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS2.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS2.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS2.Types.ismRolloverConfigCopyAlias = Just False
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "round-trips force_merge action with typed config" $ do
      let a =
            OS1.Types.ISMActionForceMerge
              ( Just
                  ( OS1.Types.ISMForceMergeConfig
                      { OS1.Types.ismForceMergeConfigMaxNumSegments = Just 5,
                        OS1.Types.ismForceMergeConfigWaitForCompletion = Nothing,
                        OS1.Types.ismForceMergeConfigTaskExecutionTimeout = Nothing
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS1 round-trips rollover action with copy_alias" $ do
      let a =
            OS1.Types.ISMActionRollover
              ( Just
                  ( OS1.Types.ISMRolloverConfig
                      { OS1.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS1.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS1.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS1.Types.ismRolloverConfigCopyAlias = Just True
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS1 decodes rollover action with copy_alias from OpenSearch" $ do
      let body = LBS.pack "{\"rollover\":{\"min_size\":\"1gb\",\"copy_alias\":true}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      case a of
        OS1.Types.ISMActionRollover (Just cfg) -> do
          OS1.Types.ismRolloverConfigMinSize cfg `shouldBe` Just "1gb"
          OS1.Types.ismRolloverConfigCopyAlias cfg `shouldBe` Just True
        other -> expectationFailure ("Expected ISMActionRollover, got " <> show other)

    it "OS1 encodes copy_alias as a top-level rollover key" $ do
      let a =
            OS1.Types.ISMActionRollover
              ( Just
                  ( OS1.Types.ISMRolloverConfig
                      { OS1.Types.ismRolloverConfigMinSize = Nothing,
                        OS1.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS1.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS1.Types.ismRolloverConfigCopyAlias = Just True
                      }
                  )
              )
      encode a `shouldBe` encode (object ["rollover" .= object ["copy_alias" .= Bool True]])

    it "OS1 round-trips force_merge with wait_for_completion and task_execution_timeout" $ do
      let a =
            OS1.Types.ISMActionForceMerge
              ( Just
                  ( OS1.Types.ISMForceMergeConfig
                      { OS1.Types.ismForceMergeConfigMaxNumSegments = Just 1,
                        OS1.Types.ismForceMergeConfigWaitForCompletion = Just False,
                        OS1.Types.ismForceMergeConfigTaskExecutionTimeout = Just "1h"
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS1 decodes force_merge payload straight from OpenSearch" $ do
      let body =
            LBS.pack
              "{\"force_merge\":{\"max_num_segments\":1,\"wait_for_completion\":false,\"task_execution_timeout\":\"1h\"}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      case a of
        OS1.Types.ISMActionForceMerge (Just cfg) -> do
          OS1.Types.ismForceMergeConfigMaxNumSegments cfg `shouldBe` Just 1
          OS1.Types.ismForceMergeConfigWaitForCompletion cfg `shouldBe` Just False
          OS1.Types.ismForceMergeConfigTaskExecutionTimeout cfg `shouldBe` Just "1h"
        other -> expectationFailure ("Expected ISMActionForceMerge, got " <> show other)

    it "OS1 omits wait_for_completion/task_execution_timeout when Nothing" $ do
      let a =
            OS1.Types.ISMActionForceMerge
              ( Just
                  ( OS1.Types.ISMForceMergeConfig
                      { OS1.Types.ismForceMergeConfigMaxNumSegments = Just 5,
                        OS1.Types.ismForceMergeConfigWaitForCompletion = Nothing,
                        OS1.Types.ismForceMergeConfigTaskExecutionTimeout = Nothing
                      }
                  )
              )
      case decode (encode a) :: Maybe (KM.KeyMap Value) of
        Just outer
          | Just (Object inner) <- KM.lookup "force_merge" outer -> do
              KM.member "wait_for_completion" inner `shouldBe` False
              KM.member "task_execution_timeout" inner `shouldBe` False
        _ -> expectationFailure "expected force_merge object"

    it "OS2 round-trips force_merge with wait_for_completion and task_execution_timeout" $ do
      let a =
            OS2.Types.ISMActionForceMerge
              ( Just
                  ( OS2.Types.ISMForceMergeConfig
                      { OS2.Types.ismForceMergeConfigMaxNumSegments = Just 1,
                        OS2.Types.ismForceMergeConfigWaitForCompletion = Just False,
                        OS2.Types.ismForceMergeConfigTaskExecutionTimeout = Just "1h"
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS3 round-trips force_merge with wait_for_completion and task_execution_timeout" $ do
      let a =
            OS3.Types.ISMActionForceMerge
              ( Just
                  ( OS3.Types.ISMForceMergeConfig
                      { OS3.Types.ismForceMergeConfigMaxNumSegments = Just 1,
                        OS3.Types.ismForceMergeConfigWaitForCompletion = Just False,
                        OS3.Types.ismForceMergeConfigTaskExecutionTimeout = Just "1h"
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS2 decodes force_merge payload straight from OpenSearch 2.18" $ do
      let body =
            LBS.pack
              "{\"force_merge\":{\"max_num_segments\":1,\"wait_for_completion\":false,\"task_execution_timeout\":\"1h\"}}"
      let Just (a :: OS2.Types.ISMAction) = decode body
      case a of
        OS2.Types.ISMActionForceMerge (Just cfg) -> do
          OS2.Types.ismForceMergeConfigMaxNumSegments cfg `shouldBe` Just 1
          OS2.Types.ismForceMergeConfigWaitForCompletion cfg `shouldBe` Just False
          OS2.Types.ismForceMergeConfigTaskExecutionTimeout cfg `shouldBe` Just "1h"
        other -> expectationFailure ("Expected ISMActionForceMerge, got " <> show other)

    it "OS2 omits wait_for_completion/task_execution_timeout when Nothing" $ do
      let a =
            OS2.Types.ISMActionForceMerge
              ( Just
                  ( OS2.Types.ISMForceMergeConfig
                      { OS2.Types.ismForceMergeConfigMaxNumSegments = Just 5,
                        OS2.Types.ismForceMergeConfigWaitForCompletion = Nothing,
                        OS2.Types.ismForceMergeConfigTaskExecutionTimeout = Nothing
                      }
                  )
              )
      case decode (encode a) :: Maybe (KM.KeyMap Value) of
        Just outer
          | Just (Object inner) <- KM.lookup "force_merge" outer -> do
              KM.member "wait_for_completion" inner `shouldBe` False
              KM.member "task_execution_timeout" inner `shouldBe` False
        _ -> expectationFailure "expected force_merge object"

    it "round-trips allocation action with typed config" $ do
      let a =
            OS1.Types.ISMActionAllocation
              ( Just
                  ( OS1.Types.ISMAllocationConfig
                      { OS1.Types.ismAllocationConfigRequire = Just (Map.fromList [("box_type", "hot")]),
                        OS1.Types.ismAllocationConfigInclude = Nothing,
                        OS1.Types.ismAllocationConfigExclude = Nothing,
                        OS1.Types.ismAllocationConfigWaitFor = Nothing
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "round-trips allocation action with wait_for" $ do
      let a =
            OS1.Types.ISMActionAllocation
              ( Just
                  ( OS1.Types.ISMAllocationConfig
                      { OS1.Types.ismAllocationConfigRequire = Just (Map.fromList [("box_type", "hot")]),
                        OS1.Types.ismAllocationConfigInclude = Nothing,
                        OS1.Types.ismAllocationConfigExclude = Just (Map.fromList [("box_type", "warm")]),
                        OS1.Types.ismAllocationConfigWaitFor = Just True
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "round-trips allocation action with wait_for = Just False" $ do
      let a =
            OS1.Types.ISMActionAllocation
              ( Just
                  ( OS1.Types.ISMAllocationConfig
                      { OS1.Types.ismAllocationConfigRequire = Just (Map.fromList [("box_type", "hot")]),
                        OS1.Types.ismAllocationConfigInclude = Nothing,
                        OS1.Types.ismAllocationConfigExclude = Nothing,
                        OS1.Types.ismAllocationConfigWaitFor = Just False
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "decodes an allocation action payload straight from OpenSearch" $ do
      let body = LBS.pack "{\"allocation\":{\"require\":{\"box_type\":\"hot\"},\"wait_for\":false}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      case a of
        OS1.Types.ISMActionAllocation (Just cfg) -> do
          OS1.Types.ismAllocationConfigWaitFor cfg `shouldBe` Just False
          OS1.Types.ismAllocationConfigRequire cfg
            `shouldBe` Just (Map.fromList [("box_type", "hot")])
        other -> expectationFailure ("Expected ISMActionAllocation, got " <> show other)

    it "OS3 decodes an allocation action payload straight from OpenSearch" $ do
      let body = LBS.pack "{\"allocation\":{\"require\":{\"box_type\":\"hot\"},\"wait_for\":false}}"
      let Just (a :: OS3.Types.ISMAction) = decode body
      case a of
        OS3.Types.ISMActionAllocation (Just cfg) -> do
          OS3.Types.ismAllocationConfigWaitFor cfg `shouldBe` Just False
          OS3.Types.ismAllocationConfigRequire cfg
            `shouldBe` Just (Map.fromList [("box_type", "hot")])
        other -> expectationFailure ("Expected ISMActionAllocation, got " <> show other)

    it "round-trips replica_count action with typed config" $ do
      let a =
            OS1.Types.ISMActionReplicaCount
              ( Just
                  ( OS1.Types.ISMReplicaCountConfig
                      { OS1.Types.ismReplicaCountConfigNumberOfReplicas = 2
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "encodes ISMActionReplicaCount as {\"replica_count\":{\"number_of_replicas\":N}}" $ do
      let a =
            OS1.Types.ISMActionReplicaCount
              ( Just
                  ( OS1.Types.ISMReplicaCountConfig
                      { OS1.Types.ismReplicaCountConfigNumberOfReplicas = 3
                      }
                  )
              )
      encode a `shouldBe` encode (object ["replica_count" .= object ["number_of_replicas" .= (3 :: Int)]])

    it "encodes ISMActionReplicaCount Nothing as {\"replica_count\":{}}" $
      encode (OS1.Types.ISMActionReplicaCount Nothing)
        `shouldBe` encode (object ["replica_count" .= object []])

    it "decodes a replica_count action payload into the typed constructor (not ISMActionCustom)" $ do
      let body = LBS.pack "{\"replica_count\":{\"number_of_replicas\":2}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      case a of
        OS1.Types.ISMActionReplicaCount (Just cfg) ->
          OS1.Types.ismReplicaCountConfigNumberOfReplicas cfg `shouldBe` 2
        other ->
          expectationFailure ("Expected ISMActionReplicaCount, got " <> show other)

    it "round-trips alias action with add and remove sub-actions" $ do
      let a =
            OS1.Types.ISMActionAlias
              ( Just
                  ( OS1.Types.ISMAliasConfig
                      { OS1.Types.ismAliasConfigActions =
                          [ OS1.Types.ISMAliasActionAdd
                              { OS1.Types.ismAliasActionAddAlias = "log",
                                OS1.Types.ismAliasActionAddIndex = Just "log-2024-01-01",
                                OS1.Types.ismAliasActionAddIsWriteIndex = Just True
                              },
                            OS1.Types.ISMAliasActionRemove
                              { OS1.Types.ismAliasActionRemoveAlias = "old-log"
                              }
                          ]
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "round-trips alias action with a minimal add (alias only)" $ do
      let a =
            OS1.Types.ISMActionAlias
              ( Just
                  ( OS1.Types.ISMAliasConfig
                      { OS1.Types.ismAliasConfigActions =
                          [ OS1.Types.ISMAliasActionAdd
                              { OS1.Types.ismAliasActionAddAlias = "log",
                                OS1.Types.ismAliasActionAddIndex = Nothing,
                                OS1.Types.ismAliasActionAddIsWriteIndex = Nothing
                              }
                          ]
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "decodes the doc example alias payload (single remove) into the typed constructor" $ do
      let body = LBS.pack "{\"alias\":{\"actions\":[{\"remove\":{\"alias\":\"log\"}}]}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      case a of
        OS1.Types.ISMActionAlias (Just cfg) ->
          OS1.Types.ismAliasConfigActions cfg
            `shouldBe` [ OS1.Types.ISMAliasActionRemove
                           { OS1.Types.ismAliasActionRemoveAlias = "log"
                           }
                       ]
        other ->
          expectationFailure ("Expected ISMActionAlias, got " <> show other)

    it "encodes ISMActionAlias Nothing as {\"alias\":{}}" $
      encode (OS1.Types.ISMActionAlias Nothing)
        `shouldBe` encode (object ["alias" .= object []])

    it "OS2 round-trips replica_count action with typed config" $ do
      let a =
            OS2.Types.ISMActionReplicaCount
              ( Just
                  ( OS2.Types.ISMReplicaCountConfig
                      { OS2.Types.ismReplicaCountConfigNumberOfReplicas = 2
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS3 round-trips replica_count action with typed config" $ do
      let a =
            OS3.Types.ISMActionReplicaCount
              ( Just
                  ( OS3.Types.ISMReplicaCountConfig
                      { OS3.Types.ismReplicaCountConfigNumberOfReplicas = 2
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS2 round-trips alias action with add and remove sub-actions" $ do
      let a =
            OS2.Types.ISMActionAlias
              ( Just
                  ( OS2.Types.ISMAliasConfig
                      { OS2.Types.ismAliasConfigActions =
                          [ OS2.Types.ISMAliasActionAdd
                              { OS2.Types.ismAliasActionAddAlias = "log",
                                OS2.Types.ismAliasActionAddIndex = Just "log-2024-01-01",
                                OS2.Types.ismAliasActionAddIsWriteIndex = Just True
                              },
                            OS2.Types.ISMAliasActionRemove
                              { OS2.Types.ismAliasActionRemoveAlias = "old-log"
                              }
                          ]
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS3 round-trips alias action with add and remove sub-actions" $ do
      let a =
            OS3.Types.ISMActionAlias
              ( Just
                  ( OS3.Types.ISMAliasConfig
                      { OS3.Types.ismAliasConfigActions =
                          [ OS3.Types.ISMAliasActionAdd
                              { OS3.Types.ismAliasActionAddAlias = "log",
                                OS3.Types.ismAliasActionAddIndex = Just "log-2024-01-01",
                                OS3.Types.ismAliasActionAddIsWriteIndex = Just True
                              },
                            OS3.Types.ISMAliasActionRemove
                              { OS3.Types.ismAliasActionRemoveAlias = "old-log"
                              }
                          ]
                      }
                  )
              )
      decode (encode a) `shouldBe` Just a

    it "OS2 actionTagName yields the wire name for replica_count and alias" $ do
      OS2.Types.actionTagName (OS2.Types.ISMActionReplicaCount Nothing)
        `shouldBe` "replica_count"
      OS2.Types.actionTagName (OS2.Types.ISMActionAlias Nothing)
        `shouldBe` "alias"

    it "OS3 actionTagName yields the wire name for replica_count and alias" $ do
      OS3.Types.actionTagName (OS3.Types.ISMActionReplicaCount Nothing)
        `shouldBe` "replica_count"
      OS3.Types.actionTagName (OS3.Types.ISMActionAlias Nothing)
        `shouldBe` "alias"

    it "encodes ISMActionDelete as {\"delete\":{}}" $
      encode OS1.Types.ISMActionDelete `shouldBe` encode (object ["delete" .= object []])

    it "encodes ISMActionReadOnly as {\"read_only\":{}}" $
      encode OS1.Types.ISMActionReadOnly `shouldBe` encode (object ["read_only" .= object []])

    it "decodes an unknown action name into ISMActionCustom (lossless)" $ do
      let body = LBS.pack "{\"new_action\":{\"foo\":\"bar\"}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      case a of
        OS1.Types.ISMActionCustom tag _ ->
          tag `shouldBe` "new_action"
        other -> expectationFailure ("Expected ISMActionCustom, got " <> show other)

    it "round-trips an unknown action through ISMActionCustom" $ do
      let body = LBS.pack "{\"new_action\":{\"foo\":\"bar\"}}"
      let Just (a :: OS1.Types.ISMAction) = decode body
      -- Re-encoding should preserve the tag name and the inner config.
      let Just (a' :: OS1.Types.ISMAction) = decode (encode a)
      a' `shouldBe` a

    it "rejects an empty action object" $ do
      let body = LBS.pack "{}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMAction
      result `shouldSatisfy` isLeft

  describe "ISM typed schema: ISMActionEntry meta (timeout/retry)" $ do
    it "mkISMActionEntry encodes byte-for-byte like the bare action" $ do
      let bareA = OS1.Types.ISMActionDelete
      encode (OS1.Types.mkISMActionEntry bareA) `shouldBe` encode bareA

    it "mkISMActionEntry preserves the bare action for a typed-config action" $ do
      let rollover =
            OS1.Types.ISMActionRollover
              ( Just
                  ( OS1.Types.ISMRolloverConfig
                      { OS1.Types.ismRolloverConfigMinSize = Just "1gb",
                        OS1.Types.ismRolloverConfigMinDocCount = Nothing,
                        OS1.Types.ismRolloverConfigMinIndexAge = Nothing,
                        OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                        OS1.Types.ismRolloverConfigCopyAlias = Nothing
                      }
                  )
              )
      encode (OS1.Types.mkISMActionEntry rollover) `shouldBe` encode rollover

    it "encodes timeout as a sibling of the action key (flat merge)" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction = OS1.Types.ISMActionDelete,
                OS1.Types.ismActionEntryTimeout = Just "1d",
                OS1.Types.ismActionEntryRetry = Nothing
              }
      encode entry
        `shouldBe` encode
          ( object
              [ "delete" .= object [],
                "timeout" .= String "1d"
              ]
          )

    it "encodes retry as a sibling of the action key (flat merge)" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction = OS1.Types.ISMActionDelete,
                OS1.Types.ismActionEntryTimeout = Nothing,
                OS1.Types.ismActionEntryRetry =
                  Just
                    ( OS1.Types.ISMRetryConfig
                        { OS1.Types.ismRetryConfigCount = Just 3,
                          OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffExponential,
                          OS1.Types.ismRetryConfigDelay = Just "1m"
                        }
                    )
              }
      encode entry
        `shouldBe` encode
          ( object
              [ "delete" .= object [],
                "retry"
                  .= object
                    [ "count" .= (3 :: Int),
                      "backoff" .= String "exponential",
                      "delay" .= String "1m"
                    ]
              ]
          )

    it "encodes a fully-populated entry with action+timeout+retry" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction =
                  OS1.Types.ISMActionRollover
                    ( Just
                        ( OS1.Types.ISMRolloverConfig
                            { OS1.Types.ismRolloverConfigMinSize = Just "50gb",
                              OS1.Types.ismRolloverConfigMinDocCount = Nothing,
                              OS1.Types.ismRolloverConfigMinIndexAge = Nothing,
                              OS1.Types.ismRolloverConfigMinPrimaryShardSize = Nothing,
                              OS1.Types.ismRolloverConfigCopyAlias = Nothing
                            }
                        )
                    ),
                OS1.Types.ismActionEntryTimeout = Just "1d",
                OS1.Types.ismActionEntryRetry =
                  Just
                    ( OS1.Types.ISMRetryConfig
                        { OS1.Types.ismRetryConfigCount = Just 3,
                          OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffExponential,
                          OS1.Types.ismRetryConfigDelay = Just "1m"
                        }
                    )
              }
      encode entry
        `shouldBe` encode
          ( object
              [ "rollover" .= object ["min_size" .= String "50gb"],
                "timeout" .= String "1d",
                "retry"
                  .= object
                    [ "count" .= (3 :: Int),
                      "backoff" .= String "exponential",
                      "delay" .= String "1m"
                    ]
              ]
          )

    it "decodes an action entry without meta (backwards compat)" $ do
      let body = LBS.pack "{\"delete\":{}}"
      let Just (e :: OS1.Types.ISMActionEntry) = decode body
      OS1.Types.ismActionEntryAction e `shouldBe` OS1.Types.ISMActionDelete
      OS1.Types.ismActionEntryTimeout e `shouldBe` Nothing
      OS1.Types.ismActionEntryRetry e `shouldBe` Nothing

    it "decodes timeout sibling" $ do
      let body = LBS.pack "{\"delete\":{}, \"timeout\":\"1d\"}"
      let Just (e :: OS1.Types.ISMActionEntry) = decode body
      OS1.Types.ismActionEntryAction e `shouldBe` OS1.Types.ISMActionDelete
      OS1.Types.ismActionEntryTimeout e `shouldBe` Just "1d"
      OS1.Types.ismActionEntryRetry e `shouldBe` Nothing

    it "decodes retry sibling" $ do
      let body =
            LBS.pack
              "{\"delete\":{}, \"retry\":{\"count\":3,\"backoff\":\"exponential\",\"delay\":\"1m\"}}"
      let Just (e :: OS1.Types.ISMActionEntry) = decode body
      OS1.Types.ismActionEntryAction e `shouldBe` OS1.Types.ISMActionDelete
      OS1.Types.ismActionEntryTimeout e `shouldBe` Nothing
      OS1.Types.ismActionEntryRetry e
        `shouldBe` Just
          ( OS1.Types.ISMRetryConfig
              { OS1.Types.ismRetryConfigCount = Just 3,
                OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffExponential,
                OS1.Types.ismRetryConfigDelay = Just "1m"
              }
          )

    it "decodes both timeout and retry siblings" $ do
      let body =
            LBS.pack
              "{\"rollover\":{\"min_size\":\"50gb\"}, \"timeout\":\"1d\", \"retry\":{\"count\":3,\"backoff\":\"exponential\",\"delay\":\"1m\"}}"
      let Just (e :: OS1.Types.ISMActionEntry) = decode body
      OS1.Types.ismActionEntryTimeout e `shouldBe` Just "1d"
      OS1.Types.ismActionEntryRetry e
        `shouldBe` Just
          ( OS1.Types.ISMRetryConfig
              { OS1.Types.ismRetryConfigCount = Just 3,
                OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffExponential,
                OS1.Types.ismRetryConfigDelay = Just "1m"
              }
          )
      case OS1.Types.ismActionEntryAction e of
        OS1.Types.ISMActionRollover (Just cfg) ->
          OS1.Types.ismRolloverConfigMinSize cfg `shouldBe` Just "50gb"
        other ->
          expectationFailure ("Expected ISMActionRollover, got " <> show other)

    it "round-trips a fully-populated entry through encode -> decode" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction = OS1.Types.ISMActionDelete,
                OS1.Types.ismActionEntryTimeout = Just "1d",
                OS1.Types.ismActionEntryRetry =
                  Just
                    ( OS1.Types.ISMRetryConfig
                        { OS1.Types.ismRetryConfigCount = Just 3,
                          OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffExponential,
                          OS1.Types.ismRetryConfigDelay = Just "1m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    it "round-trips an entry built with mkISMActionEntry (no meta)" $ do
      let entry = OS1.Types.mkISMActionEntry OS1.Types.ISMActionDelete
      decode (encode entry) `shouldBe` Just entry

    it "ISMRetryBackoff renders all three strategies" $ do
      OS1.Types.ismRetryBackoffText OS1.Types.ISMRetryBackoffExponential `shouldBe` "exponential"
      OS1.Types.ismRetryBackoffText OS1.Types.ISMRetryBackoffConstant `shouldBe` "constant"
      OS1.Types.ismRetryBackoffText OS1.Types.ISMRetryBackoffLinear `shouldBe` "linear"

    it "ISMRetryBackoff decodes all three strategies" $ do
      decode "\"exponential\"" `shouldBe` Just OS1.Types.ISMRetryBackoffExponential
      decode "\"constant\"" `shouldBe` Just OS1.Types.ISMRetryBackoffConstant
      decode "\"linear\"" `shouldBe` Just OS1.Types.ISMRetryBackoffLinear

    it "ISMRetryBackoff rejects an unknown strategy" $ do
      let result =
            parseEither parseJSON =<< eitherDecode (LBS.pack "\"backoff\"") ::
              Either String OS1.Types.ISMRetryBackoff
      result `shouldSatisfy` isLeft

    it "ISMRetryConfig omits null fields (partial config)" $ do
      let cfg =
            OS1.Types.ISMRetryConfig
              { OS1.Types.ismRetryConfigCount = Just 3,
                OS1.Types.ismRetryConfigBackoff = Nothing,
                OS1.Types.ismRetryConfigDelay = Nothing
              }
      encode cfg `shouldBe` encode (object ["count" .= (3 :: Int)])

    it "round-trips a state whose action carries timeout/retry meta" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction = OS1.Types.ISMActionDelete,
                OS1.Types.ismActionEntryTimeout = Just "1d",
                OS1.Types.ismActionEntryRetry =
                  Just
                    ( OS1.Types.ISMRetryConfig
                        { OS1.Types.ismRetryConfigCount = Just 3,
                          OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffConstant,
                          OS1.Types.ismRetryConfigDelay = Just "30m"
                        }
                    )
              }
      let s =
            OS1.Types.ISMState
              { OS1.Types.ismStateName = "hot",
                OS1.Types.ismStateActions = [entry],
                OS1.Types.ismStateTransitions = []
              }
      decode (encode s) `shouldBe` Just s

    it "OS1 round-trips an entry whose retry uses Linear backoff (wire-level)" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction = OS1.Types.ISMActionDelete,
                OS1.Types.ismActionEntryTimeout = Nothing,
                OS1.Types.ismActionEntryRetry =
                  Just
                    ( OS1.Types.ISMRetryConfig
                        { OS1.Types.ismRetryConfigCount = Just 5,
                          OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffLinear,
                          OS1.Types.ismRetryConfigDelay = Just "10m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    it "OS2 round-trips an entry whose retry uses Linear backoff (wire-level)" $ do
      let entry =
            OS2.Types.ISMActionEntry
              { OS2.Types.ismActionEntryAction = OS2.Types.ISMActionDelete,
                OS2.Types.ismActionEntryTimeout = Nothing,
                OS2.Types.ismActionEntryRetry =
                  Just
                    ( OS2.Types.ISMRetryConfig
                        { OS2.Types.ismRetryConfigCount = Just 5,
                          OS2.Types.ismRetryConfigBackoff = Just OS2.Types.ISMRetryBackoffLinear,
                          OS2.Types.ismRetryConfigDelay = Just "10m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    it "OS3 round-trips an entry whose retry uses Linear backoff (wire-level)" $ do
      let entry =
            OS3.Types.ISMActionEntry
              { OS3.Types.ismActionEntryAction = OS3.Types.ISMActionDelete,
                OS3.Types.ismActionEntryTimeout = Nothing,
                OS3.Types.ismActionEntryRetry =
                  Just
                    ( OS3.Types.ISMRetryConfig
                        { OS3.Types.ismRetryConfigCount = Just 5,
                          OS3.Types.ismRetryConfigBackoff = Just OS3.Types.ISMRetryBackoffLinear,
                          OS3.Types.ismRetryConfigDelay = Just "10m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    it "OS1 round-trips an ISMActionCustom entry carrying timeout+retry meta" $ do
      let entry =
            OS1.Types.ISMActionEntry
              { OS1.Types.ismActionEntryAction =
                  OS1.Types.ISMActionCustom
                    "custom_op"
                    (Just (object ["steps" .= (5 :: Int)])),
                OS1.Types.ismActionEntryTimeout = Just "1d",
                OS1.Types.ismActionEntryRetry =
                  Just
                    ( OS1.Types.ISMRetryConfig
                        { OS1.Types.ismRetryConfigCount = Just 3,
                          OS1.Types.ismRetryConfigBackoff = Just OS1.Types.ISMRetryBackoffExponential,
                          OS1.Types.ismRetryConfigDelay = Just "1m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    it "OS2 round-trips an ISMActionCustom entry carrying timeout+retry meta" $ do
      let entry =
            OS2.Types.ISMActionEntry
              { OS2.Types.ismActionEntryAction =
                  OS2.Types.ISMActionCustom
                    "custom_op"
                    (Just (object ["steps" .= (5 :: Int)])),
                OS2.Types.ismActionEntryTimeout = Just "1d",
                OS2.Types.ismActionEntryRetry =
                  Just
                    ( OS2.Types.ISMRetryConfig
                        { OS2.Types.ismRetryConfigCount = Just 3,
                          OS2.Types.ismRetryConfigBackoff = Just OS2.Types.ISMRetryBackoffExponential,
                          OS2.Types.ismRetryConfigDelay = Just "1m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    it "OS3 round-trips an ISMActionCustom entry carrying timeout+retry meta" $ do
      let entry =
            OS3.Types.ISMActionEntry
              { OS3.Types.ismActionEntryAction =
                  OS3.Types.ISMActionCustom
                    "custom_op"
                    (Just (object ["steps" .= (5 :: Int)])),
                OS3.Types.ismActionEntryTimeout = Just "1d",
                OS3.Types.ismActionEntryRetry =
                  Just
                    ( OS3.Types.ISMRetryConfig
                        { OS3.Types.ismRetryConfigCount = Just 3,
                          OS3.Types.ismRetryConfigBackoff = Just OS3.Types.ISMRetryBackoffExponential,
                          OS3.Types.ismRetryConfigDelay = Just "1m"
                        }
                    )
              }
      decode (encode entry) `shouldBe` Just entry

    -- 'rollover' iterates after 'retry' in aeson's KeyMap, so without the
    -- decode-side 'deleteSeveral ["timeout","retry"]' guard the meta 'retry'
    -- key would be mis-read as an 'ISMActionCustom "retry"'. This pins the
    -- guard for OS2 (the OS1 twin is "decodes both timeout and retry
    -- siblings" above).
    it "OS2 decodes a rollover entry with meta even when 'retry' iterates first" $ do
      let body =
            LBS.pack
              "{\"rollover\":{\"min_size\":\"50gb\"},\"timeout\":\"1d\",\"retry\":{\"count\":3,\"backoff\":\"exponential\",\"delay\":\"1m\"}}"
      let Just (e :: OS2.Types.ISMActionEntry) = decode body
      OS2.Types.ismActionEntryTimeout e `shouldBe` Just "1d"
      case OS2.Types.ismActionEntryAction e of
        OS2.Types.ISMActionRollover (Just cfg) ->
          OS2.Types.ismRolloverConfigMinSize cfg `shouldBe` Just "50gb"
        other ->
          expectationFailure ("Expected ISMActionRollover, got " <> show other)

    it "OS3 decodes a rollover entry with meta even when 'retry' iterates first" $ do
      let body =
            LBS.pack
              "{\"rollover\":{\"min_size\":\"50gb\"},\"timeout\":\"1d\",\"retry\":{\"count\":3,\"backoff\":\"exponential\",\"delay\":\"1m\"}}"
      let Just (e :: OS3.Types.ISMActionEntry) = decode body
      OS3.Types.ismActionEntryTimeout e `shouldBe` Just "1d"
      case OS3.Types.ismActionEntryAction e of
        OS3.Types.ISMActionRollover (Just cfg) ->
          OS3.Types.ismRolloverConfigMinSize cfg `shouldBe` Just "50gb"
        other ->
          expectationFailure ("Expected ISMActionRollover, got " <> show other)

  describe "ISM typed schema: ISMPolicyRequest" $ do
    it "round-trips the request through encode -> decode" $ do
      let encoded = encode os1SamplePolicy
      decode encoded `shouldBe` Just os1SamplePolicy

    it "wraps the body in a top-level policy key on encode" $ do
      -- OpenSearch's PUT /_plugins/_ism/policies/{id} endpoint requires the
      -- user-supplied body to sit inside a top-level "policy" wrapper.
      -- ISMPolicyRequest's ToJSON instance emits that wrapper.
      let encoded = encode os1SamplePolicy
      let Just (parsed :: Value) = decode encoded
      case parsed of
        Object o -> do
          KM.lookup "policy" o `shouldSatisfy` isJust
          -- The body fields (default_state) live INSIDE the wrapper, not at
          -- the top level.
          KM.lookup "default_state" o `shouldBe` Nothing
          case KM.lookup "policy" o of
            Just (Object inner) ->
              KM.lookup "default_state" inner `shouldBe` Just (String "ingest")
            other -> expectationFailure ("Expected inner Object, got " <> show other)
        other -> expectationFailure ("Expected Object, got " <> show other)

    it "decodes both wrapped ({\"policy\":{...}}) and bare body shapes" $ do
      -- The decoder accepts both the wrapped form (what ToJSON emits and what
      -- OS expects on the wire) and the bare form (used by hand-built
      -- fixtures and unit tests).
      let wrapped = encode os1SamplePolicy
          bare = encode samplePolicyBody
      decode wrapped `shouldBe` Just os1SamplePolicy
      decode bare `shouldBe` Just os1SamplePolicy

  describe "ISM typed schema: ISMPolicyResponse" $ do
    it "round-trips a response with server-injected fields" $ do
      let r =
            OS1.Types.ISMPolicyResponse
              { OS1.Types.ismPolicyResponsePolicyId = Just "log_policy",
                OS1.Types.ismPolicyResponseSchemaVersion = Just 1,
                OS1.Types.ismPolicyResponseLastUpdatedTime = Just 1700000000000,
                OS1.Types.ismPolicyResponseBody = samplePolicyBody
              }
      decode (encode r) `shouldBe` Just r

  -- ========================================================================
  -- Direct tests for the cron transition condition. The OpenSearch docs
  -- (https://docs.opensearch.org/latest/im-plugin/ism/policies/#transitions)
  -- shape conditions.cron as a nested object:
  --   {"cron":{"cron":{"expression":"...","timezone":"..."}}}
  -- The ISMCondition parser must decode that shape (not a bare string) and
  -- re-encode it losslessly. Only OS1 is exercised because OS2/OS3 ISM are
  -- byte-identical mirrors (the build verifies them).
  -- ========================================================================

  describe "ISM typed schema: ISMCondition cron" $ do
    let documentedCronBody =
          LBS.pack
            ( unlines
                [ "{",
                  "  \"cron\": {",
                  "    \"cron\": {",
                  "      \"expression\": \"* 17 * * SAT\",",
                  "      \"timezone\": \"America/Los_Angeles\"",
                  "    }",
                  "  }",
                  "}"
                ]
            )
        documentedCronCondition =
          OS1.Types.ISMCondition
            { OS1.Types.ismConditionMinSize = Nothing,
              OS1.Types.ismConditionMinDocCount = Nothing,
              OS1.Types.ismConditionMinIndexAge = Nothing,
              OS1.Types.ismConditionMinRolloverAge = Nothing,
              OS1.Types.ismConditionCron =
                Just
                  ( OS1.Types.ISMCron
                      ( OS1.Types.ISMCronCondition
                          { OS1.Types.ismCronConditionExpression = "* 17 * * SAT",
                            OS1.Types.ismCronConditionTimezone = Just "America/Los_Angeles"
                          }
                      )
                  ),
              OS1.Types.ismConditionDsl = Nothing
            }

    it "decodes the documented nested-object cron shape" $ do
      let Just (c :: OS1.Types.ISMCondition) = decode documentedCronBody
      c `shouldBe` documentedCronCondition

    it "round-trips the documented cron condition through encode -> decode" $ do
      let encoded = encode documentedCronCondition
      decode encoded `shouldBe` Just documentedCronCondition

    it "omits the timezone key when timezone is Nothing" $ do
      let withoutTz =
            OS1.Types.ISMCondition
              { OS1.Types.ismConditionMinSize = Nothing,
                OS1.Types.ismConditionMinDocCount = Nothing,
                OS1.Types.ismConditionMinIndexAge = Nothing,
                OS1.Types.ismConditionMinRolloverAge = Nothing,
                OS1.Types.ismConditionCron =
                  Just
                    ( OS1.Types.ISMCron
                        ( OS1.Types.ISMCronCondition
                            { OS1.Types.ismCronConditionExpression = "* 0 * * SUN",
                              OS1.Types.ismCronConditionTimezone = Nothing
                            }
                        )
                    ),
                OS1.Types.ismConditionDsl = Nothing
              }
      let Just (parsed :: Value) = decode (encode withoutTz)
      case parsed of
        Object o -> do
          -- The outer wrapper ("cron") and the inner "expression" must be
          -- present, but "timezone" must be omitted by omitNulls.
          case KM.lookup "cron" o of
            Just (Object cronOuter) ->
              case KM.lookup "cron" cronOuter of
                Just (Object cronInner) -> do
                  KM.lookup "expression" cronInner `shouldBe` Just (String "* 0 * * SUN")
                  KM.lookup "timezone" cronInner `shouldBe` Nothing
                other -> expectationFailure ("Expected inner cron Object, got " <> show other)
            other -> expectationFailure ("Expected outer cron Object, got " <> show other)
        other -> expectationFailure ("Expected top-level Object, got " <> show other)

    it "decodes a cron condition without a timezone field" $ do
      let body =
            LBS.pack
              ( unlines
                  [ "{",
                    "  \"cron\": {",
                    "    \"cron\": {",
                    "      \"expression\": \"* 0 * * SUN\"",
                    "    }",
                    "  }",
                    "}"
                  ]
              )
      let Just (c :: OS1.Types.ISMCondition) = decode body
      case OS1.Types.ismConditionCron c of
        Just (OS1.Types.ISMCron (OS1.Types.ISMCronCondition {OS1.Types.ismCronConditionTimezone = tz})) ->
          tz `shouldBe` Nothing
        other ->
          expectationFailure ("Expected Just ISMCron, got " <> show other)

    it "rejects the legacy bare-string cron shape (the pre-fix wire contract)" $ do
      -- Before the fix, ismConditionCron was `Maybe Text` and parsed a bare
      -- cron expression string. The documented shape is the nested object
      -- exercised above; a bare string must now fail to decode so silently
      -- accepting the wrong wire shape can never regress.
      let legacyBody = LBS.pack "{\"cron\": \"* 17 * * SAT\"}"
      let result = parseEither parseJSON =<< eitherDecode legacyBody :: Either String OS1.Types.ISMCondition
      result `shouldSatisfy` isLeft

    it "rejects a cron object missing the inner expression field" $ do
      let body =
            LBS.pack
              ( unlines
                  [ "{",
                    "  \"cron\": {",
                    "    \"cron\": {",
                    "      \"timezone\": \"America/Los_Angeles\"",
                    "    }",
                    "  }",
                    "}"
                  ]
              )
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMCondition
      result `shouldSatisfy` isLeft

  -- ========================================================================
  -- Direct tests for the notification/error_notification sub-shapes that the
  -- round-trip-only coverage above leaves implicit. These pin the wire shape
  -- for the destination sum type and its escape hatch.
  -- ========================================================================

  describe "ISM typed schema: ISMErrorNotificationDestination" $ do
    it "encodes Slack destination as {\"slack\":{...}}" $ do
      let d =
            OS1.Types.ISMErrorNotificationDestinationSlack
              (Just (object ["message" .= String "fire!"]))
      encode d `shouldBe` encode (object ["slack" .= object ["message" .= String "fire!"]])

    it "encodes Chime destination as {\"chime\":{...}}" $ do
      let d = OS1.Types.ISMErrorNotificationDestinationChime Nothing
      encode d `shouldBe` encode (object ["chime" .= object []])

    it "encodes CustomWebhook destination as {\"custom_webhook\":{...}}" $ do
      let d = OS1.Types.ISMErrorNotificationDestinationCustomWebhook Nothing
      encode d `shouldBe` encode (object ["custom_webhook" .= object []])

    it "decodes {\"slack\":{...}} into Slack destination" $ do
      let body = LBS.pack "{\"slack\":{\"message\":\"fire!\"}}"
      let Just (d :: OS1.Types.ISMErrorNotificationDestination) = decode body
      case d of
        OS1.Types.ISMErrorNotificationDestinationSlack _ -> pure ()
        other -> expectationFailure ("Expected Slack, got " <> show other)

    it "decodes an unknown destination kind into Custom (lossless)" $ do
      let body = LBS.pack "{\"future_destination\":{\"url\":\"http://x\"}}"
      let Just (d :: OS1.Types.ISMErrorNotificationDestination) = decode body
      case d of
        OS1.Types.ISMErrorNotificationDestinationCustom tag _ ->
          tag `shouldBe` "future_destination"
        other -> expectationFailure ("Expected Custom, got " <> show other)

    it "round-trips an unknown destination through Custom" $ do
      let body = LBS.pack "{\"future_destination\":{\"url\":\"http://x\"}}"
      let Just (d :: OS1.Types.ISMErrorNotificationDestination) = decode body
      decode (encode d) `shouldBe` Just d

    it "rejects an empty destination object" $ do
      let body = LBS.pack "{}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMErrorNotificationDestination
      result `shouldSatisfy` isLeft

  describe "ISM typed schema (OS2): ISMErrorNotificationDestination" $ do
    it "OS2 encodes Slack destination as {\"slack\":{...}}" $ do
      let d =
            OS2.Types.ISMErrorNotificationDestinationSlack
              (Just (object ["message" .= String "fire!"]))
      encode d `shouldBe` encode (object ["slack" .= object ["message" .= String "fire!"]])

    it "OS2 encodes Chime destination as {\"chime\":{...}}" $ do
      let d = OS2.Types.ISMErrorNotificationDestinationChime Nothing
      encode d `shouldBe` encode (object ["chime" .= object []])

    it "OS2 encodes CustomWebhook destination as {\"custom_webhook\":{...}}" $ do
      let d = OS2.Types.ISMErrorNotificationDestinationCustomWebhook Nothing
      encode d `shouldBe` encode (object ["custom_webhook" .= object []])

    it "OS2 decodes {\"slack\":{...}} into Slack destination" $ do
      let body = LBS.pack "{\"slack\":{\"message\":\"fire!\"}}"
      let Just (d :: OS2.Types.ISMErrorNotificationDestination) = decode body
      case d of
        OS2.Types.ISMErrorNotificationDestinationSlack _ -> pure ()
        other -> expectationFailure ("Expected Slack, got " <> show other)

    it "OS2 decodes an unknown destination kind into Custom (lossless)" $ do
      let body = LBS.pack "{\"future_destination\":{\"url\":\"http://x\"}}"
      let Just (d :: OS2.Types.ISMErrorNotificationDestination) = decode body
      case d of
        OS2.Types.ISMErrorNotificationDestinationCustom tag _ ->
          tag `shouldBe` "future_destination"
        other -> expectationFailure ("Expected Custom, got " <> show other)

    it "OS2 round-trips an unknown destination through Custom" $ do
      let body = LBS.pack "{\"future_destination\":{\"url\":\"http://x\"}}"
      let Just (d :: OS2.Types.ISMErrorNotificationDestination) = decode body
      decode (encode d) `shouldBe` Just d

    it "OS2 rejects an empty destination object" $ do
      let body = LBS.pack "{}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS2.Types.ISMErrorNotificationDestination
      result `shouldSatisfy` isLeft

    it "OS2 destinationTag yields the wire name for every destination kind" $ do
      OS2.Types.destinationTag (OS2.Types.ISMErrorNotificationDestinationChime Nothing)
        `shouldBe` "chime"
      OS2.Types.destinationTag (OS2.Types.ISMErrorNotificationDestinationCustomWebhook Nothing)
        `shouldBe` "custom_webhook"
      OS2.Types.destinationTag (OS2.Types.ISMErrorNotificationDestinationSlack Nothing)
        `shouldBe` "slack"
      OS2.Types.destinationTag (OS2.Types.ISMErrorNotificationDestinationCustom "future_destination" Nothing)
        `shouldBe` "future_destination"

    it "OS2 destinationConfig extracts the carried config payload" $ do
      let cfg = Just (object ["message" .= String "fire!"])
      OS2.Types.destinationConfig (OS2.Types.ISMErrorNotificationDestinationSlack cfg)
        `shouldBe` cfg
      OS2.Types.destinationConfig (OS2.Types.ISMErrorNotificationDestinationChime Nothing)
        `shouldBe` Nothing

  describe "ISM typed schema (OS3): ISMErrorNotificationDestination" $ do
    it "OS3 encodes Slack destination as {\"slack\":{...}}" $ do
      let d =
            OS3.Types.ISMErrorNotificationDestinationSlack
              (Just (object ["message" .= String "fire!"]))
      encode d `shouldBe` encode (object ["slack" .= object ["message" .= String "fire!"]])

    it "OS3 encodes Chime destination as {\"chime\":{...}}" $ do
      let d = OS3.Types.ISMErrorNotificationDestinationChime Nothing
      encode d `shouldBe` encode (object ["chime" .= object []])

    it "OS3 encodes CustomWebhook destination as {\"custom_webhook\":{...}}" $ do
      let d = OS3.Types.ISMErrorNotificationDestinationCustomWebhook Nothing
      encode d `shouldBe` encode (object ["custom_webhook" .= object []])

    it "OS3 decodes {\"slack\":{...}} into Slack destination" $ do
      let body = LBS.pack "{\"slack\":{\"message\":\"fire!\"}}"
      let Just (d :: OS3.Types.ISMErrorNotificationDestination) = decode body
      case d of
        OS3.Types.ISMErrorNotificationDestinationSlack _ -> pure ()
        other -> expectationFailure ("Expected Slack, got " <> show other)

    it "OS3 decodes an unknown destination kind into Custom (lossless)" $ do
      let body = LBS.pack "{\"future_destination\":{\"url\":\"http://x\"}}"
      let Just (d :: OS3.Types.ISMErrorNotificationDestination) = decode body
      case d of
        OS3.Types.ISMErrorNotificationDestinationCustom tag _ ->
          tag `shouldBe` "future_destination"
        other -> expectationFailure ("Expected Custom, got " <> show other)

    it "OS3 round-trips an unknown destination through Custom" $ do
      let body = LBS.pack "{\"future_destination\":{\"url\":\"http://x\"}}"
      let Just (d :: OS3.Types.ISMErrorNotificationDestination) = decode body
      decode (encode d) `shouldBe` Just d

    it "OS3 rejects an empty destination object" $ do
      let body = LBS.pack "{}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS3.Types.ISMErrorNotificationDestination
      result `shouldSatisfy` isLeft

    it "OS3 destinationTag yields the wire name for every destination kind" $ do
      OS3.Types.destinationTag (OS3.Types.ISMErrorNotificationDestinationChime Nothing)
        `shouldBe` "chime"
      OS3.Types.destinationTag (OS3.Types.ISMErrorNotificationDestinationCustomWebhook Nothing)
        `shouldBe` "custom_webhook"
      OS3.Types.destinationTag (OS3.Types.ISMErrorNotificationDestinationSlack Nothing)
        `shouldBe` "slack"
      OS3.Types.destinationTag (OS3.Types.ISMErrorNotificationDestinationCustom "future_destination" Nothing)
        `shouldBe` "future_destination"

    it "OS3 destinationConfig extracts the carried config payload" $ do
      let cfg = Just (object ["message" .= String "fire!"])
      OS3.Types.destinationConfig (OS3.Types.ISMErrorNotificationDestinationSlack cfg)
        `shouldBe` cfg
      OS3.Types.destinationConfig (OS3.Types.ISMErrorNotificationDestinationChime Nothing)
        `shouldBe` Nothing

  describe "ISM typed schema: ISMMessageTemplate" $ do
    it "round-trips a {source: ...} template (lang defaults to Nothing)" $ do
      let t =
            OS1.Types.ISMMessageTemplate
              { OS1.Types.ismMessageTemplateSource = "policy failed: <ctx>",
                OS1.Types.ismMessageTemplateLang = Nothing
              }
      decode (encode t) `shouldBe` Just t

    it "encodes as {\"source\":\"...\"} with no lang key when lang is Nothing" $ do
      let t =
            OS1.Types.ISMMessageTemplate
              { OS1.Types.ismMessageTemplateSource = "boom",
                OS1.Types.ismMessageTemplateLang = Nothing
              }
      encode t `shouldBe` encode (object ["source" .= String "boom"])

    it "round-trips a template carrying a server-injected lang" $ do
      let t =
            OS1.Types.ISMMessageTemplate
              { OS1.Types.ismMessageTemplateSource = "boom",
                OS1.Types.ismMessageTemplateLang = Just "mustache"
              }
      decode (encode t) `shouldBe` Just t

    it "encodes lang when present" $ do
      let t =
            OS1.Types.ISMMessageTemplate
              { OS1.Types.ismMessageTemplateSource = "boom",
                OS1.Types.ismMessageTemplateLang = Just "mustache"
              }
      encode t
        `shouldBe` encode (object ["source" .= String "boom", "lang" .= String "mustache"])

    it "decodes a server object that omits lang" $ do
      let body = LBS.pack "{\"source\":\"hi\"}"
      decode body
        `shouldBe` Just
          ( OS1.Types.ISMMessageTemplate
              { OS1.Types.ismMessageTemplateSource = "hi",
                OS1.Types.ismMessageTemplateLang = Nothing
              }
          )

    it "decodes a server object that includes lang" $ do
      let body = LBS.pack "{\"source\":\"hi\",\"lang\":\"mustache\"}"
      decode body
        `shouldBe` Just
          ( OS1.Types.ISMMessageTemplate
              { OS1.Types.ismMessageTemplateSource = "hi",
                OS1.Types.ismMessageTemplateLang = Just "mustache"
              }
          )

  describe "ISM typed schema: ISMErrorNotification" $ do
    it "round-trips a destination-based error_notification block" $ do
      let n =
            OS1.Types.ISMErrorNotification
              { OS1.Types.ismErrorNotificationDestination =
                  Just (OS1.Types.ISMErrorNotificationDestinationSlack (Just (object ["message" .= String "fire!"]))),
                OS1.Types.ismErrorNotificationChannelId = Nothing,
                OS1.Types.ismErrorNotificationMessageTemplate =
                  OS1.Types.ISMMessageTemplate {OS1.Types.ismMessageTemplateSource = "policy failed: <ctx>", OS1.Types.ismMessageTemplateLang = Nothing}
              }
      decode (encode n) `shouldBe` Just n

    it "encodes a destination-based block with no channel key" $ do
      let n =
            OS1.Types.ISMErrorNotification
              { OS1.Types.ismErrorNotificationDestination =
                  Just (OS1.Types.ISMErrorNotificationDestinationSlack (Just (object ["message" .= String "fire!"]))),
                OS1.Types.ismErrorNotificationChannelId = Nothing,
                OS1.Types.ismErrorNotificationMessageTemplate =
                  OS1.Types.ISMMessageTemplate {OS1.Types.ismMessageTemplateSource = "policy failed: <ctx>", OS1.Types.ismMessageTemplateLang = Nothing}
              }
      encode n
        `shouldBe` encode
          ( object
              [ "destination" .= object ["slack" .= object ["message" .= String "fire!"]],
                "message_template" .= object ["source" .= String "policy failed: <ctx>"]
              ]
          )

    it "parses the documented Example 4 (channel-based) notification" $ do
      let body =
            LBS.pack
              "{\"channel\":{\"id\":\"some-channel-config-id\"},\"message_template\":{\"source\":\"The index {{ctx.index}} failed.\"}}"
      let Just (n :: OS1.Types.ISMErrorNotification) = decode body
      OS1.Types.ismErrorNotificationChannelId n `shouldBe` Just "some-channel-config-id"
      OS1.Types.ismErrorNotificationDestination n `shouldBe` Nothing

    it "round-trips a channel-based error_notification block" $ do
      let n =
            OS1.Types.ISMErrorNotification
              { OS1.Types.ismErrorNotificationDestination = Nothing,
                OS1.Types.ismErrorNotificationChannelId = Just "some-channel-config-id",
                OS1.Types.ismErrorNotificationMessageTemplate =
                  OS1.Types.ISMMessageTemplate {OS1.Types.ismMessageTemplateSource = "The index {{ctx.index}} failed.", OS1.Types.ismMessageTemplateLang = Nothing}
              }
      decode (encode n) `shouldBe` Just n

    it "encodes a channel-based block with no destination key" $ do
      let n =
            OS1.Types.ISMErrorNotification
              { OS1.Types.ismErrorNotificationDestination = Nothing,
                OS1.Types.ismErrorNotificationChannelId = Just "some-channel-config-id",
                OS1.Types.ismErrorNotificationMessageTemplate =
                  OS1.Types.ISMMessageTemplate {OS1.Types.ismMessageTemplateSource = "boom", OS1.Types.ismMessageTemplateLang = Nothing}
              }
      encode n
        `shouldBe` encode
          ( object
              [ "channel" .= object ["id" .= String "some-channel-config-id"],
                "message_template" .= object ["source" .= String "boom"]
              ]
          )

    it "rejects an error_notification missing both destination and channel" $ do
      let body = LBS.pack "{\"message_template\":{\"source\":\"s\"}}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMErrorNotification
      result `shouldSatisfy` isLeft

    it "rejects an error_notification specifying both destination and channel" $ do
      let body =
            LBS.pack
              "{\"destination\":{\"slack\":{\"url\":\"http://x\"}},\"channel\":{\"id\":\"c\"},\"message_template\":{\"source\":\"s\"}}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMErrorNotification
      result `shouldSatisfy` isLeft

    it "rejects both keys present even when channel is malformed (empty)" $ do
      let body =
            LBS.pack
              "{\"destination\":{\"slack\":{\"url\":\"http://x\"}},\"channel\":{},\"message_template\":{\"source\":\"s\"}}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMErrorNotification
      result `shouldSatisfy` isLeft

    it "rejects a channel object missing its id" $ do
      let body = LBS.pack "{\"channel\":{},\"message_template\":{\"source\":\"s\"}}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMErrorNotification
      result `shouldSatisfy` isLeft

  describe "ISM typed schema: ISMUserVariables" $ do
    it "round-trips a populated user_vars map" $ do
      let v = OS1.Types.ISMUserVariables (Map.fromList [("role", "hot"), ("ttl", "7d")])
      decode (encode v) `shouldBe` Just v

    it "encodes as a JSON object of key→string" $ do
      let v = OS1.Types.ISMUserVariables (Map.fromList [("role", "hot")])
      encode v `shouldBe` encode (object ["role" .= String "hot"])

  -- ========================================================================
  -- Pure unit tests for ISMExplainOptions rendering. The explain endpoint
  -- (GET /_plugins/_ism/explain/{index}) documents four query parameters;
  -- these tests pin the wire shape produced by ismExplainOptionsParams so a
  -- regression that drops or renames one cannot pass silently. Only OS1 is
  -- exercised because the OS2/OS3 ISM modules are byte-identical mirrors
  -- (the build verifies them).
  -- ========================================================================

  describe "ISM ISMExplainOptions rendering" $ do
    it "defaultISMExplainOptions produces no query string" $ do
      OS1.Types.ismExplainOptionsParams OS1.Types.defaultISMExplainOptions
        `shouldBe` []

    it "renders show_policy=True alone" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsShowPolicy = True
              }
      OS1.Types.ismExplainOptionsParams opts
        `shouldBe` [("show_policy", Just "true")]

    it "renders validate_action=True alone" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsValidateAction = True
              }
      OS1.Types.ismExplainOptionsParams opts
        `shouldBe` [("validate_action", Just "true")]

    it "omits show_policy and validate_action when False" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsShowPolicy = False,
                OS1.Types.ismExplainOptionsValidateAction = False
              }
      OS1.Types.ismExplainOptionsParams opts `shouldBe` []

    it "renders local=Just True as \"true\"" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsLocal = Just True
              }
      OS1.Types.ismExplainOptionsParams opts
        `shouldBe` [("local", Just "true")]

    it "renders local=Just False as \"false\"" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsLocal = Just False
              }
      OS1.Types.ismExplainOptionsParams opts
        `shouldBe` [("local", Just "false")]

    it "renders expand_wildcards as a comma-separated list" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsExpandWildcards =
                  Just (ExpandWildcardsOpen :| [ExpandWildcardsClosed])
              }
      OS1.Types.ismExplainOptionsParams opts
        `shouldBe` [("expand_wildcards", Just "open,closed")]

    it "renders a single-element expand_wildcards without trailing comma" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsExpandWildcards =
                  Just (ExpandWildcardsAll :| [])
              }
      OS1.Types.ismExplainOptionsParams opts
        `shouldBe` [("expand_wildcards", Just "all")]

    it "renders all four parameters set at once (order-insensitive)" $ do
      let opts =
            OS1.Types.defaultISMExplainOptions
              { OS1.Types.ismExplainOptionsShowPolicy = True,
                OS1.Types.ismExplainOptionsValidateAction = True,
                OS1.Types.ismExplainOptionsLocal = Just False,
                OS1.Types.ismExplainOptionsExpandWildcards =
                  Just (ExpandWildcardsOpen :| [ExpandWildcardsHidden])
              }
      sort (OS1.Types.ismExplainOptionsParams opts)
        `shouldBe` sort
          [ ("show_policy", Just "true"),
            ("validate_action", Just "true"),
            ("local", Just "false"),
            ("expand_wildcards", Just "open,hidden")
          ]

  describe "ISM PutISMPolicy API" $ do
    -- OpenSearch's PUT /_plugins/_ism/policies/{id} returns 409 if the policy
    -- already exists unless ?if_seq_no=&if_primary_term= are provided, so each
    -- invocation uses a unique policy id to keep the test idempotent.
    os1It <- runIO os1OnlyIT
    os1It "creates and updates an ISM policy (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os1_" <> suffix
        resp <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        _ <- OS1.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> OS1.Types.putISMPolicyResponseId r `shouldBe` OS1.Types.unPolicyId policyId

    os2It <- runIO os2OnlyIT
    os2It "creates and updates an ISM policy (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os2_" <> suffix
        resp <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        _ <- OS2.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> OS2.Types.putISMPolicyResponseId r `shouldBe` OS2.Types.unPolicyId policyId

    os3It <- runIO os3OnlyIT
    os3It "creates and updates an ISM policy (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os3_" <> suffix
        resp <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        _ <- OS3.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> OS3.Types.putISMPolicyResponseId r `shouldBe` OS3.Types.unPolicyId policyId

  describe "ISM PutISMPolicyWith (optimistic concurrency)" $ do
    -- Each test PUTs a fresh policy via 'putISMPolicy', reads the returned
    -- (_seq_no, _primary_term), then re-PUTs via 'putISMPolicyWith' to verify
    -- the if_seq_no / if_primary_term query params reach OpenSearch and are
    -- enforced: a stale pair yields HTTP 409 (Left EsError), the current
    -- pair succeeds (Right). Unique policy ids keep each test idempotent.
    os1It <- runIO os1OnlyIT
    os1It "rejects a stale (seqNo, primaryTerm) with 409 (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os1_occ_stale_" <> suffix
        firstResp <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        mOcc <- liftIO $ case firstResp of
          Left e -> do
            expectationFailure ("Setup PUT failed: " <> show e)
            pure Nothing
          Right r ->
            pure $
              Just
                ( fromIntegral (OS1.Types.putISMPolicyResponseSeqNo r) + 1,
                  fromIntegral (OS1.Types.putISMPolicyResponsePrimaryTerm r)
                )
        case mOcc of
          Nothing -> pure ()
          Just stale -> do
            staleResp <- OS1.Client.putISMPolicyWith policyId os1SamplePolicy (Just stale)
            liftIO $ staleResp `shouldSatisfy` isLeft

    os1It "accepts the current (seqNo, primaryTerm) (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os1_occ_fresh_" <> suffix
        firstResp <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        mOcc <- liftIO $ case firstResp of
          Left e -> do
            expectationFailure ("Setup PUT failed: " <> show e)
            pure Nothing
          Right r ->
            pure $
              Just
                ( fromIntegral (OS1.Types.putISMPolicyResponseSeqNo r),
                  fromIntegral (OS1.Types.putISMPolicyResponsePrimaryTerm r)
                )
        case mOcc of
          Nothing -> pure ()
          Just fresh -> do
            freshResp <- OS1.Client.putISMPolicyWith policyId os1SamplePolicy (Just fresh)
            liftIO $
              case freshResp of
                Left e -> expectationFailure ("Expected Right, got: " <> show e)
                Right r' -> OS1.Types.putISMPolicyResponseId r' `shouldBe` OS1.Types.unPolicyId policyId

    os2It <- runIO os2OnlyIT
    os2It "rejects a stale (seqNo, primaryTerm) with 409 (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os2_occ_stale_" <> suffix
        firstResp <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        mOcc <- liftIO $ case firstResp of
          Left e -> do
            expectationFailure ("Setup PUT failed: " <> show e)
            pure Nothing
          Right r ->
            pure $
              Just
                ( fromIntegral (OS2.Types.putISMPolicyResponseSeqNo r) + 1,
                  fromIntegral (OS2.Types.putISMPolicyResponsePrimaryTerm r)
                )
        case mOcc of
          Nothing -> pure ()
          Just stale -> do
            staleResp <- OS2.Client.putISMPolicyWith policyId os2SamplePolicy (Just stale)
            liftIO $ staleResp `shouldSatisfy` isLeft

    os2It "accepts the current (seqNo, primaryTerm) (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os2_occ_fresh_" <> suffix
        firstResp <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        mOcc <- liftIO $ case firstResp of
          Left e -> do
            expectationFailure ("Setup PUT failed: " <> show e)
            pure Nothing
          Right r ->
            pure $
              Just
                ( fromIntegral (OS2.Types.putISMPolicyResponseSeqNo r),
                  fromIntegral (OS2.Types.putISMPolicyResponsePrimaryTerm r)
                )
        case mOcc of
          Nothing -> pure ()
          Just fresh -> do
            freshResp <- OS2.Client.putISMPolicyWith policyId os2SamplePolicy (Just fresh)
            liftIO $
              case freshResp of
                Left e -> expectationFailure ("Expected Right, got: " <> show e)
                Right r' -> OS2.Types.putISMPolicyResponseId r' `shouldBe` OS2.Types.unPolicyId policyId

    os3It <- runIO os3OnlyIT
    os3It "rejects a stale (seqNo, primaryTerm) with 409 (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os3_occ_stale_" <> suffix
        firstResp <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        mOcc <- liftIO $ case firstResp of
          Left e -> do
            expectationFailure ("Setup PUT failed: " <> show e)
            pure Nothing
          Right r ->
            pure $
              Just
                ( fromIntegral (OS3.Types.putISMPolicyResponseSeqNo r) + 1,
                  fromIntegral (OS3.Types.putISMPolicyResponsePrimaryTerm r)
                )
        case mOcc of
          Nothing -> pure ()
          Just stale -> do
            staleResp <- OS3.Client.putISMPolicyWith policyId os3SamplePolicy (Just stale)
            liftIO $ staleResp `shouldSatisfy` isLeft

    os3It "accepts the current (seqNo, primaryTerm) (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os3_occ_fresh_" <> suffix
        firstResp <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        mOcc <- liftIO $ case firstResp of
          Left e -> do
            expectationFailure ("Setup PUT failed: " <> show e)
            pure Nothing
          Right r ->
            pure $
              Just
                ( fromIntegral (OS3.Types.putISMPolicyResponseSeqNo r),
                  fromIntegral (OS3.Types.putISMPolicyResponsePrimaryTerm r)
                )
        case mOcc of
          Nothing -> pure ()
          Just fresh -> do
            freshResp <- OS3.Client.putISMPolicyWith policyId os3SamplePolicy (Just fresh)
            liftIO $
              case freshResp of
                Left e -> expectationFailure ("Expected Right, got: " <> show e)
                Right r' -> OS3.Types.putISMPolicyResponseId r' `shouldBe` OS3.Types.unPolicyId policyId

  describe "ISM DeleteISMPolicy API" $ do
    -- PUT a fresh policy, then DELETE it. OpenSearch stores policies as
    -- documents in @.opendistro-ism-config@, so DELETE returns the standard
    -- document-delete envelope (IndexedDocument) with @result = "deleted"@,
    -- not an Acknowledged ack. Each invocation uses a unique policy id to
    -- keep the test idempotent (PUT 409s on duplicate ids without
    -- ?if_seq_no=&if_primary_term=, and DELETE 404s on unknown ids).
    os1It <- runIO os1OnlyIT
    os1It "creates then deletes an ISM policy (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os1_del_" <> suffix
        putResp <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        liftIO $
          case putResp of
            Left e -> expectationFailure ("PUT failed: " <> show e)
            Right _ -> pure ()
        delResp <- OS1.Client.deleteISMPolicy policyId
        liftIO $
          case delResp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              idxDocResult r `shouldBe` "deleted"
              idxDocId r `shouldBe` OS1.Types.unPolicyId policyId

    os2It <- runIO os2OnlyIT
    os2It "creates then deletes an ISM policy (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os2_del_" <> suffix
        putResp <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        liftIO $
          case putResp of
            Left e -> expectationFailure ("PUT failed: " <> show e)
            Right _ -> pure ()
        delResp <- OS2.Client.deleteISMPolicy policyId
        liftIO $
          case delResp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              idxDocResult r `shouldBe` "deleted"
              idxDocId r `shouldBe` OS2.Types.unPolicyId policyId

    os3It <- runIO os3OnlyIT
    os3It "creates then deletes an ISM policy (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os3_del_" <> suffix
        putResp <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        liftIO $
          case putResp of
            Left e -> expectationFailure ("PUT failed: " <> show e)
            Right _ -> pure ()
        delResp <- OS3.Client.deleteISMPolicy policyId
        liftIO $
          case delResp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              idxDocResult r `shouldBe` "deleted"
              idxDocId r `shouldBe` OS3.Types.unPolicyId policyId

    -- Deleting a non-existent policy yields 404 -> Left EsError whose message
    -- mentions the missing policy. We pin the message rather than just `isLeft`
    -- so a future regression that surfaces an unrelated Left (network blip,
    -- JSON parse bug) does not silently pass.
    os1It <- runIO os1OnlyIT
    os1It "returns Left when deleting a non-existent ISM policy (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_policy_os1_missing_" <> suffix
        resp <- OS1.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e ->
              errorMessage e `shouldSatisfy` Text.isInfixOf "not found"
            Right _ -> expectationFailure "Expected Left, got Right"

  describe "ISM ISMUpdatedIndicesResponse JSON decoding" $ do
    -- Realistic success body for POST /_plugins/_ism/add/{index}:
    --   {"updated_indices": N, "failures": false, "failed_indices": []}
    -- See https://docs.opensearch.org/latest/im-plugin/ism/api/#add-policy
    let successBody =
          LBS.pack
            "{ \"updated_indices\": 1, \"failures\": false, \"failed_indices\": [] }"

    it "decodes OpenSearch1 success body" $ do
      let decoded = decode successBody :: Maybe OS1.Types.ISMUpdatedIndicesResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS1.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldBe` 1
      OS1.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False
      OS1.Types.ismUpdatedIndicesResponseFailedIndices r `shouldBe` []

    it "decodes OpenSearch2 success body" $ do
      let decoded = decode successBody :: Maybe OS2.Types.ISMUpdatedIndicesResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS2.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldBe` 1
      OS2.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False

    it "decodes OpenSearch3 success body" $ do
      let decoded = decode successBody :: Maybe OS3.Types.ISMUpdatedIndicesResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS3.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldBe` 1
      OS3.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False

    it "decodes a body without failed_indices as empty list" $ do
      let body =
            LBS.pack
              "{ \"updated_indices\": 0, \"failures\": false }"
      let decoded = decode body :: Maybe OS1.Types.ISMUpdatedIndicesResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS1.Types.ismUpdatedIndicesResponseFailedIndices r `shouldBe` []

    it "decodes a failure body with one failed index" $ do
      let body =
            LBS.pack $
              unlines
                [ "{",
                  "  \"updated_indices\": 0,",
                  "  \"failures\": true,",
                  "  \"failed_indices\": [",
                  "    {",
                  "      \"index_name\": \"missing-index\",",
                  "      \"index_uuid\": null,",
                  "      \"reason\": \"index missing\"",
                  "    }",
                  "  ]",
                  "}"
                ]
      let decoded = decode body :: Maybe OS1.Types.ISMUpdatedIndicesResponse
      decoded `shouldSatisfy` isJust
      let Just r = decoded
      OS1.Types.ismUpdatedIndicesResponseFailures r `shouldBe` True
      OS1.Types.ismUpdatedIndicesResponseFailedIndices r `shouldSatisfy` (not . null)
      let firstFailure = head (OS1.Types.ismUpdatedIndicesResponseFailedIndices r)
      OS1.Types.ismFailedIndexName firstFailure `shouldBe` "missing-index"
      OS1.Types.ismFailedIndexUuid firstFailure `shouldBe` Nothing
      OS1.Types.ismFailedIndexReason firstFailure `shouldBe` "index missing"

    it "rejects a body with non-boolean failures" $ do
      let malformed =
            LBS.pack
              "{ \"updated_indices\": 0, \"failures\": \"yes\", \"failed_indices\": [] }"
      let result = parseEither parseJSON =<< eitherDecode malformed :: Either String OS1.Types.ISMUpdatedIndicesResponse
      result `shouldSatisfy` isLeft

    it "round-trips OpenSearch1 decode -> encode -> decode" $ do
      let Just (r1 :: OS1.Types.ISMUpdatedIndicesResponse) =
            decode
              ( LBS.pack
                  "{ \"updated_indices\": 2, \"failures\": false, \"failed_indices\": [] }"
              )
      let reencoded = encode r1
      let decodedAgain = decode reencoded :: Maybe OS1.Types.ISMUpdatedIndicesResponse
      decodedAgain `shouldBe` Just r1

    it "round-trips a populated failed_indices list through encode -> decode" $ do
      -- Exercises the ToJSON/FromJSON of ISMFailedIndex (including the
      -- Nothing index_uuid path) that the empty-list round-trip above
      -- does not touch.
      let body =
            LBS.pack $
              unlines
                [ "{",
                  "  \"updated_indices\": 0,",
                  "  \"failures\": true,",
                  "  \"failed_indices\": [",
                  "    {",
                  "      \"index_name\": \"foo\",",
                  "      \"index_uuid\": null,",
                  "      \"reason\": \"already managed\"",
                  "    }",
                  "  ]",
                  "}"
                ]
      let Just (r1 :: OS1.Types.ISMUpdatedIndicesResponse) = decode body
      let reencoded = encode r1
      let Just r2 = decode reencoded :: Maybe OS1.Types.ISMUpdatedIndicesResponse
      r2 `shouldBe` r1
      OS1.Types.ismFailedIndexUuid (head (OS1.Types.ismUpdatedIndicesResponseFailedIndices r2)) `shouldBe` Nothing

  describe "ISM AddISMPolicy API" $ do
    -- POST /_plugins/_ism/add/{index} attaches an existing policy to a fresh
    -- index. We PUT a unique policy, create a unique index, then call
    -- addISMPolicy and assert updated_indices >= 1 with no failures. Cleanup
    -- deletes the index; the policy is left in place (PUT 409s on duplicate ids
    -- and DELETE 404s on missing ids, so unique names keep the test
    -- idempotent, matching the existing Put/Delete ISM policy tests above).
    os1It <- runIO os1OnlyIT
    os1It "adds an ISM policy to an index (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_add_policy_os1_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_add_index_os1_" <> suffix
        _ <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS1.Client.addISMPolicy policyId indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              OS1.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldSatisfy` (>= 1)
              OS1.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False
              OS1.Types.ismUpdatedIndicesResponseFailedIndices r `shouldBe` []

    os2It <- runIO os2OnlyIT
    os2It "adds an ISM policy to an index (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_add_policy_os2_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_add_index_os2_" <> suffix
        _ <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS2.Client.addISMPolicy policyId indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              OS2.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldSatisfy` (>= 1)
              OS2.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False
              OS2.Types.ismUpdatedIndicesResponseFailedIndices r `shouldBe` []

    os3It <- runIO os3OnlyIT
    os3It "adds an ISM policy to an index (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_add_policy_os3_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_add_index_os3_" <> suffix
        _ <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS3.Client.addISMPolicy policyId indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              OS3.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldSatisfy` (>= 1)
              OS3.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False
              OS3.Types.ismUpdatedIndicesResponseFailedIndices r `shouldBe` []

    -- Adding a non-existent policy to an index yields 404 with an EsError
    -- message mentioning the missing policy, rather than a 200 with
    -- failures=true. We pin the message rather than just `isLeft` so a future
    -- regression that surfaces an unrelated Left (network blip, JSON parse
    -- bug) does not silently pass.
    os1It <- runIO os1OnlyIT
    os1It "returns Left when adding a non-existent ISM policy (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_add_missing_os1_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_add_missing_idx_os1_" <> suffix
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS1.Client.addISMPolicy policyId indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e ->
              errorMessage e `shouldSatisfy` Text.isInfixOf "policy"
            Right _ -> expectationFailure "Expected Left, got Right"

  -- ====================================================================
  -- New endpoint types: ChangePolicyRequest, ISMExplanation, ISMPolicyInfo,
  -- GetISMPoliciesResponse, ISMPoliciesQuery. Pure JSON tests first, then
  -- live integration per backend. The pure tests exercise OS1.Types only
  -- because the OS2/OS3 ISM modules are byte-identical mirrors (verified by
  -- the build); the live tests cover all three backends.
  -- ====================================================================

  describe "ISM ChangePolicyRequest JSON" $ do
    let body =
          LBS.pack
            "{\"policy_id\":\"p1\",\"state\":\"delete\",\"include\":[{\"state\":\"search\"}]}"
        parsed = decode body :: Maybe OS1.Types.ChangePolicyRequest
        expectedReq =
          OS1.Types.ChangePolicyRequest
            { OS1.Types.changePolicyRequestId = OS1.Types.PolicyId "p1",
              OS1.Types.changePolicyRequestState = Just "delete",
              OS1.Types.changePolicyRequestInclude =
                [OS1.Types.ISMChangePolicyInclude "search"]
            }
    it "decodes policy_id, state and include" $
      parsed `shouldBe` Just expectedReq
    it "round-trips decode -> encode -> decode" $ do
      let Just r = parsed
      decode (encode r) `shouldBe` Just r
    it "omits Nothing state and empty include on encode" $ do
      let r =
            OS1.Types.ChangePolicyRequest
              { OS1.Types.changePolicyRequestId = OS1.Types.PolicyId "p1",
                OS1.Types.changePolicyRequestState = Nothing,
                OS1.Types.changePolicyRequestInclude = []
              }
      encode r `shouldBe` encode (object ["policy_id" .= String "p1"])

  describe "ISM ISMExplanation JSON" $ do
    -- Minimal unmanaged body: just the dotted policy_id setting, no
    -- managed-index fields, no total_managed_indices.
    let unmanaged =
          LBS.pack
            "{\"index_1\":{\"index.plugins.index_state_management.policy_id\":\"policy_1\"}}"
    it "decodes the minimal unmanaged body, keeping the policy_id setting" $ do
      let Just (e :: OS1.Types.ISMExplanation) = decode unmanaged
      OS1.Types.ismExplanationTotalManagedIndices e `shouldBe` Nothing
      let entry = lookup "index_1" (Map.toList (OS1.Types.ismExplanationEntries e))
      entry `shouldSatisfy` isJust
      let Just ent = entry
      OS1.Types.ismExplanationEntryPolicyIdSetting ent `shouldBe` Just "policy_1"
      OS1.Types.ismExplanationEntryIndex ent `shouldBe` Nothing

    -- Full managed body (representative slice of the documented response).
    let managed =
          LBS.pack $
            unlines
              [ "{",
                "  \"index_1\": {",
                "    \"index\": \"index_1\",",
                "    \"index_uuid\": \"abc\",",
                "    \"policy_id\": \"policy_1\",",
                "    \"enabled\": true,",
                "    \"state\": {\"name\":\"hot\",\"start_time\":123},",
                "    \"action\": {\"name\":\"rollover\",\"start_time\":123,\"index\":0,\"failed\":false,\"consumed_retries\":0,\"last_retry_time\":0},",
                "    \"step\": {\"name\":\"attempt_rollover\",\"start_time\":123,\"step_status\":\"starting\"},",
                "    \"retry_info\": {\"failed\":false,\"consumed_retries\":0},",
                "    \"info\": {\"message\":\"Currently checking rollover conditions\"}",
                "  },",
                "  \"total_managed_indices\": 1",
                "}"
              ]
    it "decodes the managed body including nested sub-objects" $ do
      let Just (e :: OS1.Types.ISMExplanation) = decode managed
      OS1.Types.ismExplanationTotalManagedIndices e `shouldBe` Just 1
      let Just ent = lookup "index_1" (Map.toList (OS1.Types.ismExplanationEntries e))
      OS1.Types.ismExplanationEntryIndex ent `shouldBe` Just "index_1"
      OS1.Types.ismExplanationEntryEnabled ent `shouldBe` Just True
      OS1.Types.ismExplanationEntryState ent `shouldSatisfy` isJust
      OS1.Types.ismExplanationEntryAction ent `shouldSatisfy` isJust
      OS1.Types.ismExplanationEntryRetryInfo ent `shouldSatisfy` isJust

    it "decodes the validate_action body (validate sub-object present)" $ do
      let body =
            LBS.pack $
              unlines
                [ "{",
                  "  \"test-000001\": {",
                  "    \"index\": \"test-000001\",",
                  "    \"enabled\": false,",
                  "    \"validate\": {",
                  "      \"validation_message\": \"Missing rollover_alias\",",
                  "      \"validation_status\": \"re_validating\"",
                  "    }",
                  "  },",
                  "  \"total_managed_indices\": 1",
                  "}"
                ]
      let Just (e :: OS1.Types.ISMExplanation) = decode body
      let Just ent = lookup "test-000001" (Map.toList (OS1.Types.ismExplanationEntries e))
      OS1.Types.ismExplanationEntryValidate ent `shouldSatisfy` isJust
      let Just v = OS1.Types.ismExplanationEntryValidate ent
      OS1.Types.ismExplanationValidateValidationStatus v `shouldBe` "re_validating"

    it "round-trips decode -> encode -> decode (managed)" $ do
      let Just (e :: OS1.Types.ISMExplanation) = decode managed
      decode (encode e) `shouldBe` Just e

    it "rejects a body whose entry value is not an object" $ do
      let body = LBS.pack "{\"index_1\": 42}"
      let result = parseEither parseJSON =<< eitherDecode body :: Either String OS1.Types.ISMExplanation
      result `shouldSatisfy` isLeft

  describe "ISM ISMPolicyInfo / GetISMPoliciesResponse JSON" $ do
    let singleBody =
          LBS.pack
            "{\"_id\":\"policy_1\",\"_version\":2,\"_seq_no\":10,\"_primary_term\":1,\"policy\":{\"policy_id\":\"policy_1\"}}"
    it "decodes a single-policy body" $ do
      let Just (p :: OS1.Types.ISMPolicyInfo) = decode singleBody
      OS1.Types.ismPolicyInfoId p `shouldBe` "policy_1"
      OS1.Types.ismPolicyInfoSeqNo p `shouldBe` 10
    it "round-trips a single policy" $ do
      let Just (p :: OS1.Types.ISMPolicyInfo) = decode singleBody
      decode (encode p) `shouldBe` Just p
    let listBody =
          LBS.pack
            "{\"policies\":[{\"_id\":\"policy_1\",\"_version\":2,\"_seq_no\":10,\"_primary_term\":1,\"policy\":{\"policy_id\":\"policy_1\"}}]}"
    it "decodes the policies list" $ do
      let Just (r :: OS1.Types.GetISMPoliciesResponse) = decode listBody
      OS1.Types.getISMPoliciesResponsePolicies r `shouldSatisfy` (not . null)
      OS1.Types.ismPolicyInfoId (head (OS1.Types.getISMPoliciesResponsePolicies r)) `shouldBe` "policy_1"
    it "round-trips the policies list" $ do
      let Just (r :: OS1.Types.GetISMPoliciesResponse) = decode listBody
      decode (encode r) `shouldBe` Just r

  describe "ISM ISMPoliciesQuery to pairs" $ do
    it "omits all Nothing fields" $
      OS1.Types.ismPoliciesQueryToPairs
        (OS1.Types.ISMPoliciesQuery Nothing Nothing Nothing Nothing Nothing)
        `shouldBe` []
    it "renders size as text and includes only set fields" $
      OS1.Types.ismPoliciesQueryToPairs
        (OS1.Types.ISMPoliciesQuery (Just 5) Nothing (Just "policy_id") Nothing Nothing)
        `shouldBe` [("size", Just "5"), ("sortField", Just "policy_id")]

  -- -------------------- Live integration: removeISMPolicy --------------------

  describe "ISM removeISMPolicy API" $ do
    os1It <- runIO os1OnlyIT
    os1It "removes an ISM policy from an index (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_rem_policy_os1_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_rem_index_os1_" <> suffix
        _ <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        _ <- OS1.Client.addISMPolicy policyId indexName
        resp <- OS1.Client.removeISMPolicy indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              OS1.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldSatisfy` (>= 1)
              OS1.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False

    os2It <- runIO os2OnlyIT
    os2It "removes an ISM policy from an index (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_rem_policy_os2_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_rem_index_os2_" <> suffix
        _ <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        _ <- OS2.Client.addISMPolicy policyId indexName
        resp <- OS2.Client.removeISMPolicy indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              OS2.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldSatisfy` (>= 1)
              OS2.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False

    os3It <- runIO os3OnlyIT
    os3It "removes an ISM policy from an index (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_rem_policy_os3_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_rem_index_os3_" <> suffix
        _ <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        _ <- OS3.Client.addISMPolicy policyId indexName
        resp <- OS3.Client.removeISMPolicy indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> do
              OS3.Types.ismUpdatedIndicesResponseUpdatedIndices r `shouldSatisfy` (>= 1)
              OS3.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False

  -- -------------------- Pure wire shape: explainISMIndexFiltered --------------------

  describe "ISM explainISMIndexFiltered API (POST filter body shape)" $ do
    let os3Idx = [qqIndexName|my-index-os3|]
        os2Idx = [qqIndexName|my-index-os2|]

    it "OS3: POSTs to /_plugins/_ism/explain/{index} with no query string" $ do
      let req = OS3Requests.explainISMIndexFiltered os3Idx OS3.Types.defaultISMExplainFilter
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ism", "explain", "my-index-os3"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "OS3: sends a {\"filter\":{...}} body, omitting Nothing fields" $ do
      let filt =
            OS3.Types.ISMExplainFilter
              (Just "hot-warm-delete-policy")
              (Just "warm")
              Nothing
              Nothing
          req = OS3Requests.explainISMIndexFiltered os3Idx filt
          Just body = bhRequestBody req
      decode body
        `shouldBe` ( Just $
                       object
                         [ "filter"
                             .= object
                               [ "policy_id" .= ("hot-warm-delete-policy" :: Text),
                                 "state" .= ("warm" :: Text)
                               ]
                         ]
                   )

    it "OS3: defaultISMExplainFilter produces {\"filter\":{}}" $ do
      let req = OS3Requests.explainISMIndexFiltered os3Idx OS3.Types.defaultISMExplainFilter
          Just body = bhRequestBody req
      decode body `shouldBe` (Just $ object ["filter" .= object []])

    it "OS2: POSTs to /_plugins/_ism/explain/{index} with no query string" $ do
      let req = OS2Requests.explainISMIndexFiltered os2Idx OS2.Types.defaultISMExplainFilter
      bhRequestMethod req `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint req)
        `shouldBe` ["_plugins", "_ism", "explain", "my-index-os2"]
      getRawEndpointQueries (bhRequestEndpoint req) `shouldBe` []

    it "OS2: sends a {\"filter\":{...}} body, omitting Nothing fields" $ do
      let filt =
            OS2.Types.ISMExplainFilter
              (Just "data-lifecycle-policy")
              Nothing
              (Just "rollover")
              (Just True)
          req = OS2Requests.explainISMIndexFiltered os2Idx filt
          Just body = bhRequestBody req
      decode body
        `shouldBe` ( Just $
                       object
                         [ "filter"
                             .= object
                               [ "policy_id" .= ("data-lifecycle-policy" :: Text),
                                 "action_type" .= ("rollover" :: Text),
                                 "failed" .= True
                               ]
                         ]
                   )

    it "OS2: defaultISMExplainFilter produces {\"filter\":{}}" $ do
      let req = OS2Requests.explainISMIndexFiltered os2Idx OS2.Types.defaultISMExplainFilter
          Just body = bhRequestBody req
      decode body `shouldBe` (Just $ object ["filter" .= object []])

  -- -------------------- Live integration: explainISMIndex --------------------

  describe "ISM explainISMIndex API" $ do
    -- explain on a fresh (unmanaged) index is deterministic: the response
    -- contains an entry for the index (with the dotted policy_id setting,
    -- possibly null). The managed path (show_policy=true) is exercised by
    -- the pure JSON tests above since ISM management is asynchronous.
    os1It <- runIO os1OnlyIT
    os1It "explains an index (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_explain_idx_os1_" <> suffix
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS1.Client.explainISMIndex indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right e ->
              OS1.Types.ismExplanationEntries e `shouldSatisfy` (not . null)

    os2It <- runIO os2OnlyIT
    os2It "explains an index (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_explain_idx_os2_" <> suffix
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS2.Client.explainISMIndex indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right e ->
              OS2.Types.ismExplanationEntries e `shouldSatisfy` (not . null)

    os3It <- runIO os3OnlyIT
    os3It "explains an index (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_explain_idx_os3_" <> suffix
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        resp <- OS3.Client.explainISMIndex indexName
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right e ->
              OS3.Types.ismExplanationEntries e `shouldSatisfy` (not . null)

  -- -------------------- Live integration: getISMPolicy / getISMPolicies --------

  describe "ISM getISMPolicy API" $ do
    os1It <- runIO os1OnlyIT
    os1It "fetches a single policy by id (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_get_policy_os1_" <> suffix
        _ <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        resp <- OS1.Client.getISMPolicy policyId
        _ <- OS1.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r ->
              OS1.Types.ismPolicyInfoId r `shouldBe` OS1.Types.unPolicyId policyId

    os2It <- runIO os2OnlyIT
    os2It "fetches a single policy by id (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_get_policy_os2_" <> suffix
        _ <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        resp <- OS2.Client.getISMPolicy policyId
        _ <- OS2.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r ->
              OS2.Types.ismPolicyInfoId r `shouldBe` OS2.Types.unPolicyId policyId

    os3It <- runIO os3OnlyIT
    os3It "fetches a single policy by id (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_get_policy_os3_" <> suffix
        _ <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        resp <- OS3.Client.getISMPolicy policyId
        _ <- OS3.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r ->
              OS3.Types.ismPolicyInfoId r `shouldBe` OS3.Types.unPolicyId policyId

  describe "ISM getISMPolicies API" $ do
    -- Each test creates a fresh policy, then lists with a 'queryString'
    -- filter scoped to that policy id. Filtering keeps the assertion
    -- deterministic regardless of how many policies accumulated from prior
    -- runs (the unfiltered list's default page would otherwise push the
    -- new policy out of the first page on a dirty container). Each test
    -- also best-effort deletes its policy to keep the cluster from
    -- accumulating test policies across runs.
    os1It <- runIO os1OnlyIT
    os1It "lists policies and contains the created one (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_list_policy_os1_" <> suffix
            listQuery =
              OS1.Types.ISMPoliciesQuery
                { OS1.Types.ismPoliciesQuerySize = Nothing,
                  OS1.Types.ismPoliciesQueryFrom = Nothing,
                  OS1.Types.ismPoliciesQuerySortField = Nothing,
                  OS1.Types.ismPoliciesQuerySortOrder = Nothing,
                  OS1.Types.ismPoliciesQueryQueryString = Just (OS1.Types.unPolicyId policyId)
                }
        _ <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        resp <- OS1.Client.getISMPolicies (Just listQuery)
        _ <- OS1.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r ->
              OS1.Types.getISMPoliciesResponsePolicies r
                `shouldSatisfy` any ((== OS1.Types.unPolicyId policyId) . OS1.Types.ismPolicyInfoId)

    os2It <- runIO os2OnlyIT
    os2It "lists policies and contains the created one (OpenSearch 2)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS2.Types.PolicyId $ Text.pack $ "bloodhound_test_list_policy_os2_" <> suffix
            listQuery =
              OS2.Types.ISMPoliciesQuery
                { OS2.Types.ismPoliciesQuerySize = Nothing,
                  OS2.Types.ismPoliciesQueryFrom = Nothing,
                  OS2.Types.ismPoliciesQuerySortField = Nothing,
                  OS2.Types.ismPoliciesQuerySortOrder = Nothing,
                  OS2.Types.ismPoliciesQueryQueryString = Just (OS2.Types.unPolicyId policyId)
                }
        _ <- OS2.Client.putISMPolicy policyId os2SamplePolicy
        resp <- OS2.Client.getISMPolicies (Just listQuery)
        _ <- OS2.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r ->
              OS2.Types.getISMPoliciesResponsePolicies r
                `shouldSatisfy` any ((== OS2.Types.unPolicyId policyId) . OS2.Types.ismPolicyInfoId)

    os3It <- runIO os3OnlyIT
    os3It "lists policies and contains the created one (OpenSearch 3)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS3.Types.PolicyId $ Text.pack $ "bloodhound_test_list_policy_os3_" <> suffix
            listQuery =
              OS3.Types.ISMPoliciesQuery
                { OS3.Types.ismPoliciesQuerySize = Nothing,
                  OS3.Types.ismPoliciesQueryFrom = Nothing,
                  OS3.Types.ismPoliciesQuerySortField = Nothing,
                  OS3.Types.ismPoliciesQuerySortOrder = Nothing,
                  OS3.Types.ismPoliciesQueryQueryString = Just (OS3.Types.unPolicyId policyId)
                }
        _ <- OS3.Client.putISMPolicy policyId os3SamplePolicy
        resp <- OS3.Client.getISMPolicies (Just listQuery)
        _ <- OS3.Client.deleteISMPolicy policyId
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r ->
              OS3.Types.getISMPoliciesResponsePolicies r
                `shouldSatisfy` any ((== OS3.Types.unPolicyId policyId) . OS3.Types.ismPolicyInfoId)

  -- -------- Live integration: changeISMPolicy / retryISMIndex (smoke) ----------
  --
  -- These mutate managed-index state, which ISM applies asynchronously, so we
  -- assert only that the endpoint responds with the shared update shape and no
  -- failures (the deterministic managed-path coverage lives in the JSON tests).

  describe "ISM changeISMPolicy API" $ do
    os1It <- runIO os1OnlyIT
    os1It "changes the policy on a managed index without failures (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_chg_policy_os1_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_chg_index_os1_" <> suffix
        _ <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        _ <- OS1.Client.addISMPolicy policyId indexName
        let req = OS1.Types.ChangePolicyRequest policyId Nothing []
        resp <- OS1.Client.changeISMPolicy indexName req
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            Left e -> expectationFailure ("Expected Right, got: " <> show e)
            Right r -> OS1.Types.ismUpdatedIndicesResponseFailures r `shouldBe` False

  describe "ISM retryISMIndex API" $ do
    -- retry is only exercised against OS1 because OS2/OS3 share the exact same
    -- Requests/Client code path (the ISM type modules are byte-identical
    -- mirrors); the pure JSON tests cover the response shape.
    os1It <- runIO os1OnlyIT
    os1It "hits POST /_plugins/_ism/retry/{index} and decodes the response (OpenSearch 1)" $
      withTestEnv $ do
        suffix <- liftIO $ show @Int . floor <$> getPOSIXTime
        let policyId = OS1.Types.PolicyId $ Text.pack $ "bloodhound_test_rty_policy_os1_" <> suffix
        let Right indexName = mkIndexName $ Text.pack $ "bloodhound_test_rty_index_os1_" <> suffix
        _ <- OS1.Client.putISMPolicy policyId os1SamplePolicy
        _ <- performBHRequest $ createIndex (IndexSettings (ShardCount 1) (ReplicaCount 0) defaultIndexMappingsLimits) indexName
        _ <- OS1.Client.addISMPolicy policyId indexName
        resp <- OS1.Client.retryISMIndex indexName Nothing
        _ <- performBHRequest $ deleteIndex indexName
        liftIO $
          case resp of
            -- A wrong endpoint path / method would surface as a 404/400 Left,
            -- so requiring Right (a decoded ISMUpdatedIndicesResponse) proves
            -- the retry path and body shape. On a managed-but-not-failed
            -- index OS legitimately returns failures=true here; we only assert
            -- that the shared update shape decoded, not the failure flag.
            Left e -> expectationFailure ("Expected decoded Right, got Left: " <> show e)
            Right _ -> pure ()

  describe "simulateISMPolicy endpoint shape (OS 3.7+)" $ do
    it "POSTs /_plugins/_ism/simulate with the encoded body" $ do
      let req =
            OS3.Types.SimulateISMPolicyRequest
              { OS3.Types.simulateISMPolicyRequestPolicyId = Just "my-policy",
                OS3.Types.simulateISMPolicyRequestPolicy = Nothing,
                OS3.Types.simulateISMPolicyRequestIndices = ["logs-*"]
              }
          r = OS3Requests.simulateISMPolicy req
      bhRequestMethod r `shouldBe` "POST"
      getRawEndpoint (bhRequestEndpoint r) `shouldBe` ["_plugins", "_ism", "simulate"]
      getRawEndpointQueries (bhRequestEndpoint r) `shouldBe` []
      let body = case bhRequestBody r of Just b -> b
      LBS.unpack body `shouldContain` "\"policy_id\""
      LBS.unpack body `shouldContain` "\"logs-*\""

    it "forwards an inline policy when policy_id is Nothing" $ do
      let req =
            OS3.Types.SimulateISMPolicyRequest
              { OS3.Types.simulateISMPolicyRequestPolicyId = Nothing,
                OS3.Types.simulateISMPolicyRequestPolicy = Just os3SimulateSamplePolicyBody,
                OS3.Types.simulateISMPolicyRequestIndices = ["my-index"]
              }
          r = OS3Requests.simulateISMPolicy req
      let body = case bhRequestBody r of Just b -> b
      LBS.unpack body `shouldContain` "\"policy\""
      LBS.unpack body `shouldNotContain` "\"policy_id\""

  describe "SimulateISMPolicyResponse decoding (simulate fixture)" $ do
    let fixture =
          LBS.pack
            "{\"simulate_results\":[{\"index_name\":\"logs-2024-01-15\",\
            \\"index_uuid\":\"gCFlS_zcTdih8xyxf3jQ-A\",\"policy_id\":\"my-policy\",\
            \\"is_managed\":false,\"current_state\":\"hot\",\"current_action\":\"transition\",\
            \\"transition_evaluation\":[{\"state_name\":\"warm\",\"condition_met\":true,\
            \\"condition_type\":\"min_index_age\",\"current_value\":\"10d\",\"required_value\":\"7d\"}],\
            \\"next_state\":\"warm\"},{\"index_name\":\"nonexistent\",\"index_uuid\":null,\
            \\"policy_id\":\"my-policy\",\"is_managed\":false,\"error\":\"Index not found\"}]}"

    it "decodes the documented success + error fixture" $ do
      let Just decoded = decode fixture :: Maybe OS3.Types.SimulateISMPolicyResponse
      let [r1, r2] = OS3.Types.simulateISMPolicyResponseSimulateResults decoded
      OS3.Types.simulateISMPolicyResultIndexName r1 `shouldBe` Just "logs-2024-01-15"
      OS3.Types.simulateISMPolicyResultCurrentState r1 `shouldBe` Just "hot"
      OS3.Types.simulateISMPolicyResultNextState r1 `shouldBe` Just "warm"
      let [te] = case OS3.Types.simulateISMPolicyResultTransitionEvaluation r1 of Just xs -> xs
      OS3.Types.simulateISMTransitionEvaluationConditionMet te `shouldBe` Just True
      OS3.Types.simulateISMTransitionEvaluationConditionType te `shouldBe` Just "min_index_age"
      OS3.Types.simulateISMTransitionEvaluationCurrentValue te `shouldBe` Just "10d"
      -- the error case: index not found returns inline error, not HTTP 404
      OS3.Types.simulateISMPolicyResultIndexName r2 `shouldBe` Just "nonexistent"
      OS3.Types.simulateISMPolicyResultIndexUuid r2 `shouldBe` Nothing
      OS3.Types.simulateISMPolicyResultError r2 `shouldBe` Just "Index not found"
      OS3.Types.simulateISMPolicyResultCurrentState r2 `shouldBe` Nothing

os3SimulateSamplePolicyBody :: OS3.Types.ISMPolicyBody
os3SimulateSamplePolicyBody =
  OS3.Types.ISMPolicyBody
    { OS3.Types.ismPolicyBodyDescription = Just "test policy",
      OS3.Types.ismPolicyBodyDefaultState = "ingest",
      OS3.Types.ismPolicyBodyStates =
        [ OS3.Types.ISMState
            { OS3.Types.ismStateName = "ingest",
              OS3.Types.ismStateActions = [],
              OS3.Types.ismStateTransitions = []
            }
        ],
      OS3.Types.ismPolicyBodyIsmTemplate = [],
      OS3.Types.ismPolicyBodyErrorNotification = Nothing,
      OS3.Types.ismPolicyBodyUserVariables = Nothing
    }