-- | Provides the rangedParam and rangedParamAdjuster helper functions.

{-# LANGUAGE LambdaCase #-}

module Test.Tasty.Sugar.Ranged
  ( rangedParam
  , rangedParamAdjuster
  )
where

import           Control.Applicative ( liftA2 )
import           Control.Monad.IO.Class ( MonadIO )
import           Data.Function ( on )
import qualified Data.List as L
import           Data.Maybe ( isNothing )
import qualified Data.Set as Set

import           Test.Tasty.Sugar.Types


-- | Given a Parameter Name and a boolean that indicates valid/not-valid for a
-- Parameter Value, update the expectations in the Sweets to treat the parameter
-- as a ranged value.
--
-- [This is the pure internals version; the recommended usage is via the
-- 'rangedParamAdjuster' wrapper specification in the 'sweetAdjuster' field of
-- the 'CUBE' structure.]
--
-- Normal sweets results expect a 1:1 match between parameter value and the
-- expected file markup, but this function modifies the sweets results to
-- accomodate a parameter range with range boundaries.  For example, if the test
-- might vary the output based on the version of clang used to compile the file,
-- the 'CUBE' might specify:
--
-- > mkCUBE { rootName = "*.c"
-- >        , expectedSuffix = "good"
-- >        , validParams = [ ("clang-range", Just ["pre_clang11", "pre_clang13" ] ) ]
-- >        ...
-- >        }
--
-- Then if the following files were present:
--
-- > foo.c
-- > foo-pre_clang11.good
-- > foo.good
--
-- Then a normal sweets response would include the expectations:
--
--  > foo-pre_clang11.good ==> Explicit "pre_clang11"
--  > foo.good             ==> Assumed  "pre_clang13"
--
-- The 'Test.Tasty.Sugar.withSugarGroups' callback would then be invoked with
-- these two expectations.  The callback might check the actual version of clang
-- available to run in the environment.  If it detected clang version 10 was
-- available, the best file would be the @foo-pre_clang11.good@, even though the
-- parameters didn't mention @clang9@ and the @foo.good@ would be the usual match
-- to pick when there was no explicit match.
--
-- To handle this case, the 'rangedParam' function is used to filter the sweets,
-- and is also given the version of clang locally available:
--
-- > let rangedSweets = rangedParam "clang-range" extract (<=) (Just "9") sweets
-- >     extract = readMaybe . drop (length "pre-clang")
-- > withSugarGroups rangedSweets TT.testGroup $ \sweet instnum exp ->
-- >   ... generate test ...
--
-- Where the above would result in a single call to the _generate test_ code with
-- the @foo-pre_clang11.good@ expectation.  The @extract@ function removes the
-- numeric value from the parameter value, and the @<=@ test checks to see if the
-- version supplied is less than or equal to the extracted parameter value.
--
-- The @>@ comparator could be used if the validParams values specified a lower
-- limit instead of an upper limit, and the comparator and extractor can be
-- extended to handle other ways of specifying ranges.
--
-- If the extract function returns Nothing, then the corresponding parameter
-- value is /not/ a ranged parameter value (there can be a mix of ranged values
-- and non-ranged values), and the corresponding value(s) will be used whenever
-- there is not a ranged match.  As an example, if the 'validParams' above was
-- extended with a "recent-clang" value; for actual clang versions up through 12
-- one of the pre_clang values provides the ranged match, but for clang versions
-- of 13 or later, there is no pre_clang match so recent-clang will be used.
-- Providing a non-extractable parameter value is recommended as the default to
-- select when no ranged value is applicable; the expected file does /not/ need
-- to have the same parameter value since a weak match (no parameter match) file
-- will match with the 'Assumed' value, which will be selected if no better
-- ranged match is applicable.

rangedParam :: Enum a => Ord a
            => String -> (String -> Maybe a) -> (a -> a -> Bool)
            -> Maybe a
            -> CUBE -> [Sweets] -> [Sweets]
rangedParam pname extractVal cmpVal targetVal cube sweets =
  let adj sweet = let exps = expected sweet
                  in sweet { expected = adjustExp exps }

      -- extracts all parameters except the named parameter
      paramsExceptPName = filter ((pname /=) . fst) . expParamsMatch

      -- Compares two assoc-lists for equality on the union of both.
      assocUnionEq = \case
        [] -> const True
        ((an,av):as) -> \case
          [] -> True
          bs -> case lookup an bs of
                  Nothing -> assocUnionEq as bs
                  Just bv -> av == bv && assocUnionEq as bs

      -- This divides a list into clusters of lists, where each sub-list contains
      -- members that satisfy a comparison predicate between the list members
      -- (comparing against the first member of each sub-list).  This is
      -- effectively List.groupBy, but with global clustering instead of local
      -- clustering.
      --
      -- > Data.List.groupBy (==) "Mississippi" = ["M", "i", "ss", "i", "ss" ...]
      -- > cluster (==) "Mississippi" = ["M", "iiii", "ssss", "pp"]
      clusterBy equiv = \case
        [] -> []
        (x:xs) -> let (same,diff) = L.partition (equiv x) xs
                  in (x:same) : clusterBy equiv diff

      adjustExp :: [Expectation] -> [Expectation]
      adjustExp exps = concatMap expInRange
                       $ clusterBy (assocUnionEq `on` paramsExceptPName) exps

      notRange e = maybe False
                   (isNothing . extractVal)
                   (getParamVal =<< lookup pname (expParamsMatch e))

      expInRange :: [Expectation] -> [Expectation]
      expInRange =
        case targetVal of
          Nothing ->
            -- User did not specify which target version of clang was desired.
            -- Iterate through the possible parameter values, extract the version
            -- associated with each, and return the expectations that would have
            -- been chosen for that version.  Also use a version that is the succ
            -- of the highest and the pred of the lowest, to ensure
            -- out-of-known-range values are also considered.
            case lookup pname (validParams cube) of
              Nothing ->
                -- Should not happen: this means the user called rangedParam with
                -- a parameter name that is not an actual parameter.  In this
                -- case, just return the inputs.
                id
              Just Nothing ->
                -- Cannot support ranges on existentials (parameters whose value
                -- can be *anything*).  This can happen if the user specifies a
                -- parameter name of this type.  In this case, there is no
                -- meaningful range that can be predicted, so just return the
                -- inputs
                id
              Just (Just vals) -> \exps ->
                -- Iterate through the possible values to extract the
                -- corresponding parameter value.  This may be a subset of the
                -- actual values that could be encountered, but it at least
                -- allows the proper expected file to be determined for this set
                -- of values.  For possible values that do not have a valid
                -- extraction, just pass those Expectation entires through
                -- directly.
                let withPVal = \case
                      Nothing -> filter notRange exps
                      Just v -> expInRangeFor v exps
                    vals' = Set.fromList (extractVal <$> vals)
                            -- Use a Set to eliminate duplicates, especially of
                            -- Nothing results.
                    vals'' = let vs = Nothing `Set.delete` vals'
                                 lower = pred <$> minimum vs
                                 higher = succ <$> maximum vs
                             in if Set.null vs
                                then vs
                                else lower `Set.insert` (higher `Set.insert` vs)
                in Set.toList $ Set.unions
                   -- Set operations combine/eliminate identical results
                   $ foldr (Set.insert . Set.fromList . withPVal) mempty vals''
          Just tv -> expInRangeFor tv

      -- expInRangeFor :: a -> [Expectation] -> [Expectation]
      expInRangeFor tgtVal exps =
            -- Find the expectations with the _cmpVal-est_ Explicit that is still
            -- a _cmpVal_ of the input value than the target value.  If none
            -- exist, use the expectations that Assume the target value.  There
            -- can be multiple matches because of differences in other parameter
            -- values; stated another way: for any set of parameter values, find
            -- the expectations with the cmpVal-est Explicit ...
            let explParam e = case lookup pname $ expParamsMatch e of
                                Just (Explicit v) ->
                                  maybe False (cmpVal tgtVal) $ extractVal v
                                _ -> False
                okParam e = case lookup pname $ expParamsMatch e of
                              Just (Assumed v) ->
                                  maybe False (cmpVal tgtVal) $ extractVal v
                              _ -> False
                pval e = do pm <- lookup pname $ expParamsMatch e
                            pv <- getParamVal pm
                            extractVal pv
                -- bestsBy finds the testVal-est value for each set of
                -- expectations whose other parameter values are the same.
                bestsBy getVal testVal = \case
                  [] -> []
                  (xp:xps) ->
                    let chk e bests =
                          -- e is an Expectation, bests is the best testVal-est
                          -- [Expectation] collected so-far.
                          let ev = getVal e
                              ep = paramsExceptPName e
                              matchE = assocUnionEq ep . paramsExceptPName
                              (yes,oBest) = L.partition matchE bests
                                -- yes is the entries in bests whose non-PName
                                -- parameters match e, so we can now determine if
                                -- yes or z is testVal-est (yes may have multiple
                                -- entries, but if it does they should have the
                                -- same value for pname, which mostly happens on
                                -- the Nothing case... param does not exist or
                                -- has NotSpecified value).
                                --
                                -- oBest has the other entries in bests that
                                -- don't match e and should therefore just be
                                -- passed through.  Note that due to adjustExp
                                -- this should usually be a null list.
                              yv = getVal $ head yes
                          in case () of
                               _ | null yes -> e:bests
                               _ | ev == yv -> e:bests
                               _ -> case liftA2 testVal yv ev of
                                      Just True -> bests
                                      Just False -> e:oBest
                                      Nothing -> e:oBest
                                             -- maybe bests (const (e:oBest)) ev
                    in foldr chk [xp] xps
                exps' = let expl = L.filter explParam exps
                            assum = L.filter okParam exps
                            nonRanged = L.filter notRange exps
                        in if null expl
                           then if null assum
                                then nonRanged
                                else assum
                           else expl
            in bestsBy pval cmpVal $ exps'
  in adj <$> sweets


-- | Given a Parameter Name and a boolean that indicates valid/not-valid for a
-- Parameter Value, update the expectations in the Sweets to treat the parameter
-- as a ranged value.  This provides the functionality described by the
-- 'rangedParam' function and is intended for use via the 'sweetAdjuster' field
-- of the 'CUBE' structure.

rangedParamAdjuster :: Enum a => Ord a
                    => MonadIO m
                    => String -> (String -> Maybe a) -> (a -> a -> Bool)
                    -> Maybe a
                    -> CUBE -> [Sweets] -> m [Sweets]
rangedParamAdjuster pname extractVal cmpVal targetVal cube =
  return . rangedParam pname extractVal cmpVal targetVal cube
