{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}

module Property (testProperties) where

import Control.Monad (unless)
import Control.Monad.Catch (SomeException(..), try)
import Control.Monad.IO.Class (liftIO)
import Data.List (nub)
import Data.Maybe (mapMaybe)
#if !MIN_VERSION_base(4,11,0)
import Data.Monoid ((<>))
#endif
import Data.Pool (Pool)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Database.Persist.Migration
import Database.Persist.Sql (SqlBackend, SqlPersistT)
import Test.QuickCheck
import Test.QuickCheck.Monadic (PropertyM, monadicIO, pick, run, stop)
import Test.QuickCheck.Property (rejected)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.QuickCheck (testProperty)

import Utils.QuickCheck
    ( ColumnIdentifier(..)
    , CreateTable'(..)
    , Identifier(..)
    , genPersistValue
    , toOperation
    )
import Utils.RunSql (runSql)

-- | A test suite for testing migration properties.
testProperties :: MigrateBackend -> IO (Pool SqlBackend) -> TestTree
testProperties backend getPool = testGroup "properties"
  [ testProperty "Create and drop tables" $ withCreateTable $
      const $ return ()
  , testProperty "Rename table" $ withCreateTable $ \(table, fkTables) -> do
      let tableName = ctName table
          fkNames = map ctName fkTables
      Identifier newName <- pick $ arbitrary `suchThat`
        ((`notElem` tableName:fkNames) . unIdent)
      runSqlPool' $ do
        runOperation' $ RenameTable tableName newName
        runOperation' $ DropTable newName
  , testProperty "Add UNIQUE constraint" $ withCreateTable $ \(table, _) -> do
      let getUniqueCols = \case
            PrimaryKey _ -> []
            Unique _ cols -> cols
          tableCols = map colName $ ctSchema table
          uniqueCols = concatMap getUniqueCols $ ctConstraints table
          nonUniqueCols = take 32 $ filter (`notElem` uniqueCols) tableCols
      if null nonUniqueCols
        then return False
        else do
          let uniqueName = Text.take 63 $ "unique_" <> Text.intercalate "_" nonUniqueCols
          runSqlPool' $
            runOperation' $ AddConstraint (ctName table) $ Unique uniqueName nonUniqueCols
          return True
  , testProperty "Drop UNIQUE constraint" $ withCreateTable $ \(table, _) -> do
      let getUniqueName = \case
            PrimaryKey _ -> Nothing
            Unique n _ -> Just n
          uniqueNames = mapMaybe getUniqueName $ ctConstraints table
      if null uniqueNames
        then return False
        else do
          uniqueName <- pick $ elements uniqueNames
          runSqlPool' $ runOperation' $ DropConstraint (ctName table) uniqueName
          return True
  , testProperty "Add column" $ withCreateTable $ \(table, fkTables) -> do
      -- generate a new column
      col <- pick arbitrary

      -- pick a new name for the column
      let cols = map colName $ ctSchema table
      Identifier newName <- pick $ arbitrary `suchThat` ((`notElem` cols) . unIdent)

      -- if foreign key tables exist, update any foreign key references to point to one of those.
      -- otherwise, strip out any foreign key references.
      let splitProps [] = (False, [])
          splitProps (x:xs) =
            let (mRef, props) = splitProps xs
            in case x of
              References _ -> (True, props)
              AutoIncrement -> (mRef, props) -- don't test adding AutoIncrement columns
              _ -> (mRef, x:props)
      col' <-
        let (hasReference, props) = splitProps $ colProps col
        in if null fkTables || not hasReference
            then return col{colProps = props}
            else do
              fkTable <- pick $ ctName <$> elements fkTables
              return $ col{colProps = References (fkTable, "id") : props}

      -- pick a default value according to nullability and sqltype
      defaultVal <- pick $ genPersistValue $ colType col
      defaultVal' <- if NotNull `elem` colProps col
        then return $ Just defaultVal
        else pick $ elements [Nothing, Just defaultVal]

      runSqlPool' $ runOperation' $ AddColumn (ctName table) col'{colName = newName} defaultVal'
  , testProperty "Rename column" $ withCreateTable $ \(table, _) -> do
      let cols = map colName $ ctSchema table
      col <- pick $ elements cols
      ColumnIdentifier newName <- pick $ arbitrary `suchThat` ((`notElem` cols) . unColIdent)
      runSqlPool' $ runOperation' $ RenameColumn (ctName table) col newName
  , testProperty "Drop column" $ withCreateTable $ \(table, _) -> do
      let cols = map colName $ ctSchema table
      col <- pick $ elements cols
      runSqlPool' $ runOperation' $ DropColumn (ctName table, col)
  ]
  where
    runSqlPool' = runSqlPool getPool
    runOperation' = runOperation backend
    -- | Create a table and its foreign key dependencies, then run the given action, which should
    -- return False if the test case should be discarded (`()` == True). The tables will be dropped
    -- when finished
    withCreateTable :: PseudoBool a => ((CreateTable', [CreateTable']) -> PropertyM IO a) -> Property
    withCreateTable action = monadicIO $ do
      table <- pick arbitrary
      fkTables <- pick $ getForeignKeyTables table
      runSqlPool' $ mapM_ (runOperation' . toOperation) (fkTables ++ [table])
      isSuccessful <- toBool <$> action (table, fkTables)
      runSqlPool' $ mapM_ dropTable' (table:fkTables)
      unless isSuccessful $ stop rejected
    dropTable' CreateTable'{ctName} = runOperation' $ DropTable ctName

{- Helpers -}

-- | Run the given Sql query in the given SqlBackend.
runSqlPool :: IO (Pool SqlBackend) -> SqlPersistT IO () -> PropertyM IO ()
runSqlPool getPool f = run $ getPool >>= \pool -> runSql pool f

-- | Run the given operation.
runOperation :: MigrateBackend -> Operation -> SqlPersistT IO ()
runOperation backend operation = do
  case validateOperation operation of
    Right () -> return ()
    Left msg -> do
      liftIO $ putStrLn "\n*** Failure: validateOperation failed"
      fail msg

  getMigrationSql backend operation >>= mapM_ rawExecutePrint
  where
    -- if rawExecute fails, show the sql query run
    rawExecutePrint sql = try (executeSql sql) >>= \case
      Right () -> return ()
      Left (SomeException e) -> do
        liftIO $ do
          putStrLn "\n*** Failure:"
          Text.putStrLn $ sqlText sql
          print $ sqlVals sql
        fail $ show e

-- | Get the CreateTable operations that are necessary for the foreign keys in the
-- given CreateTable operation.
getForeignKeyTables :: CreateTable' -> Gen [CreateTable']
getForeignKeyTables ct =
  zipWith modifyTable neededTables <$> vectorOf (length neededTables) arbitrary
  where
    neededTables = nub $ concatMap (mapMaybe getReferenceTable . colProps) $ ctSchema ct
    getReferenceTable = \case
      References (table, _) -> Just table
      _ -> Nothing
    isReference = \case
      References _ -> True
      _ -> False
    noFKs = filter (not . isReference) . colProps
    modifyTable name ct' = ct'
      { ctName = name
      , ctSchema = map (\col -> col{colProps = noFKs col}) $ ctSchema ct'
      }

class PseudoBool a where
  toBool :: a -> Bool
instance PseudoBool () where
  toBool = const True
instance PseudoBool Bool where
  toBool = id
