{-# OPTIONS_GHC -fno-warn-unused-do-bind #-}
{-# LANGUAGE TupleSections #-}

-- | Migrations support for SQLite databases
module Database.Beam.Sqlite.Migrate
  ( -- * @beam-migrate@ CLI support
    migrationBackend

    -- * @beam-migrate@ utility functions
  , migrateScript, writeMigrationScript
  , sqlitePredConverter, sqliteTypeToHs
  , getDbConstraints

    -- * SQLite-specific data types
  , sqliteText, sqliteBlob, sqliteBigInt
  ) where

import qualified Database.Beam.Migrate as Db
import qualified Database.Beam.Migrate.Backend as Tool
import qualified Database.Beam.Migrate.Serialization as Db

import           Database.Beam.Backend.SQL
import           Database.Beam.Haskell.Syntax
import           Database.Beam.Sqlite.Connection
import           Database.Beam.Sqlite.Syntax

import           Control.Applicative
import           Control.Exception
import           Control.Monad.Reader

import           Database.SQLite.Simple
                    ( Connection, open, close, query_ )

import           Data.Aeson
import           Data.Attoparsec.Text (asciiCI, skipSpace)
import qualified Data.Attoparsec.Text as A
import           Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy.Char8 as BL
import           Data.Char (isSpace)
import           Data.Int (Int64)
import           Data.List (sortBy)
import           Data.Maybe (mapMaybe, isJust)
import           Data.Monoid (Endo(..), (<>))
import           Data.Ord (comparing)
import           Data.String (fromString)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE

-- | Top-level 'Tool.BeamMigrationBackend' loaded dynamically by the
-- @beam-migrate@ CLI tool.
migrationBackend :: Tool.BeamMigrationBackend SqliteCommandSyntax Sqlite Connection SqliteM
migrationBackend = Tool.BeamMigrationBackend
                       "sqlite"
                       "For beam-sqlite, this is the path to a sqlite3 file"
                       (BL.concat . migrateScript)
                       getDbConstraints
                       (Db.sql92Deserializers <> sqliteDataTypeDeserializers <>
                        Db.beamCheckDeserializers)
                       (BL.unpack . (<> ";") . sqliteRenderSyntaxScript . fromSqliteCommand)
                       "sqlite.sql"
                       sqlitePredConverter Db.defaultActionProvider
                       (\fp action ->
                            bracket (open fp) close $ \conn ->
                              catch (Right <$> runReaderT (runSqliteM action)
                                                          (\_ -> pure (), conn))
                                    (\e -> pure (Left (show (e :: SomeException)))))

-- | 'Db.BeamDeserializers' or SQLite specific types. Specifically,
-- 'sqliteBlob', 'sqliteText', and 'sqliteBigInt'. These are compatible with the
-- "typical" serialized versions of the standard 'Db.binaryLargeObject',
-- 'Db.characterLargeObject', and 'Db.bigint' types.
sqliteDataTypeDeserializers :: Db.BeamDeserializers SqliteCommandSyntax
sqliteDataTypeDeserializers =
  Db.beamDeserializer $ \_ v ->
  fmap (id @SqliteDataTypeSyntax) $
  case v of
    "blob" -> pure sqliteBlobType
    "clob" -> pure sqliteTextType
    "bigint" -> pure sqliteBigIntType
    Object o ->
       (fmap (\(_ :: Maybe Word) -> sqliteBlobType) (o .: "binary")) <|>
       (fmap (\(_ :: Maybe Word) -> sqliteBlobType) (o .: "varbinary"))
    _ -> fail "Could not parse sqlite-specific data type"

-- | Render a series of 'Db.MigrationSteps' in the 'SqliteCommandSyntax' into a
-- line-by-line list of lazy 'BL'ByteString's. The output is suitable for
-- inclusion in a migration script. Comments are generated giving a description
-- of each migration step.
migrateScript :: Db.MigrationSteps SqliteCommandSyntax () a -> [BL.ByteString]
migrateScript steps =
  "-- Generated by beam-sqlite beam-migrate backend\n" :
  "\n" :
  appEndo (Db.migrateScript renderHeader renderCommand steps) []
  where
    renderHeader nm =
      Endo (("-- " <> BL.fromStrict (TE.encodeUtf8 nm) <> "\n"):)
    renderCommand cmd =
      Endo ((sqliteRenderSyntaxScript (fromSqliteCommand cmd) <> ";\n"):)

-- | Write the output of 'migrateScript' to a file
writeMigrationScript :: FilePath -> Db.MigrationSteps SqliteCommandSyntax () a -> IO ()
writeMigrationScript fp steps =
  let stepBs = migrateScript steps
  in BL.writeFile fp (BL.concat stepBs)

-- | 'Tool.HaskellPredicateConverter' that can convert all constraints generated
-- by 'getDbConstaints' into their equivalent in the @beam-migrate@ haskell
-- syntax. Suitable for auto-generation of a haskell migration.
sqlitePredConverter :: Tool.HaskellPredicateConverter
sqlitePredConverter = Tool.sql92HsPredicateConverters @SqliteColumnSchemaSyntax sqliteTypeToHs <>
                      Tool.hsPredicateConverter sqliteHasColumnConstraint
  where
    sqliteHasColumnConstraint (Db.TableColumnHasConstraint tblNm colNm c ::
                                  Db.TableColumnHasConstraint SqliteColumnSchemaSyntax)
      | c == Db.constraintDefinitionSyntax Nothing Db.notNullConstraintSyntax Nothing =
        Just (Db.SomeDatabasePredicate (Db.TableColumnHasConstraint tblNm colNm (Db.constraintDefinitionSyntax Nothing Db.notNullConstraintSyntax Nothing) ::
                                           Db.TableColumnHasConstraint HsColumnSchema))
      | otherwise = Nothing

-- | Convert a SQLite data type to the corresponding Haskell one
sqliteTypeToHs :: SqliteDataTypeSyntax
               -> Maybe HsDataType
sqliteTypeToHs = Just . sqliteDataTypeToHs

parseSqliteDataType :: T.Text -> SqliteDataTypeSyntax
parseSqliteDataType txt =
  case A.parseOnly dtParser txt of
    Left {} -> SqliteDataTypeSyntax (emit (TE.encodeUtf8 txt))
                                    (hsErrorType ("Unknown SQLite datatype '" ++ T.unpack txt ++ "'"))
                                    (Db.BeamSerializedDataType $
                                     Db.beamSerializeJSON "sqlite"
                                       (toJSON txt))
                                    False
    Right x -> x
  where
    dtParser = charP <|> varcharP <|>
               ncharP <|> nvarcharP <|>
               bitP <|> varbitP <|> numericP <|>
               doubleP <|> integerP <|>
               smallIntP <|> bigIntP <|> floatP <|>
               doubleP <|> realP <|> dateP <|>
               timestampP <|> timeP <|> textP <|>
               blobP <|> booleanP

    ws = A.many1 A.space

    characterP = asciiCI "CHARACTER" <|> asciiCI "CHAR"
    characterVaryingP = characterP >> ws >> asciiCI "VARYING"
    charP = do
      characterP
      charType <$> precP <*> charSetP
    varcharP = do
      asciiCI "VARCHAR" <|> characterVaryingP
      varCharType <$> precP <*> charSetP
    ncharP = do
      asciiCI "NATIONAL"
      ws
      characterP
      nationalCharType <$> precP
    nvarcharP = do
      asciiCI "NVARCHAR" <|> (asciiCI "NATIONAL" >> ws >> characterVaryingP)
      nationalVarCharType <$> precP
    bitP = do
      asciiCI "BIT"
      bitType <$> precP
    varbitP = do
      asciiCI "VARBIT" <|> (asciiCI "BIT" >> ws >> asciiCI "VARYING")
      varBitType <$> precP

    numericP = do
      asciiCI "NUMERIC"
      numericType <$> numericPrecP
    doubleP = do
      asciiCI "DOUBLE" <|> asciiCI "DECIMAL"
      decimalType <$> numericPrecP
    floatP = do
      asciiCI "FLOAT"
      floatType <$> precP
    realP = realType <$ asciiCI "REAL"

    intTypeP =
      asciiCI "INT" <|> asciiCI "INTEGER"
    integerP = do
      intTypeP
      pure intType
    smallIntP = do
      asciiCI "INT2" <|> (asciiCI "SMALL" >> optional ws >> intTypeP)
      pure smallIntType
    bigIntP = do
      asciiCI "INT8" <|> (asciiCI "BIG" >> optional ws >> intTypeP)
      pure sqliteBigIntType
    dateP = dateType <$ asciiCI "DATE"
    timeP = do
      asciiCI "TIME"
      timeType <$> precP <*> timezoneP
    timestampP = do
      asciiCI "TIMESTAMP"
      timestampType <$> precP <*> timezoneP
    textP = sqliteTextType <$ asciiCI "TEXT"
    blobP = sqliteBlobType <$ asciiCI "BLOB"

    booleanP = booleanType <$ (asciiCI "BOOL" <|> asciiCI "BOOLEAN")

    timezoneP = (skipSpace *>
                 asciiCI "WITH" *> ws *>
                 (asciiCI "TIMEZONE" <|>
                  (asciiCI "TIME" >> ws >>
                   asciiCI "ZONE")) *>
                 pure True) <|>
                pure False

    precP = optional (skipSpace *> A.char '(' *>
                      A.decimal <* A.char ')')
    numericPrecP = optional ((,) <$> (skipSpace *> A.char '(' *>
                                      A.decimal)
                                 <*> (skipSpace *>
                                      optional (A.char ',' *> skipSpace *>
                                                 A.decimal) <*
                                      skipSpace <* A.char ')'))

    charSetP = optional (skipSpace *>
                         asciiCI "CHARACTER" *> ws *>
                         asciiCI "SET" *> ws *>
                         A.takeWhile (not . isSpace))

-- TODO constraints and foreign keys

-- | Get a list of database predicates for the current database. This is beam's
-- best guess at providing a schema for the current database. Note that SQLite
-- type names are not standardized, and the so-called column "affinities" are
-- too broad to be of use. This function attemps to guess a good enough type
-- based on the exact type supplied in the @CREATE TABLE@ commands. It will
-- correctly parse any type generated by beam and most SQL compliant types, but
-- it may falter on databases created or managed by tools that do not follow
-- these standards.
getDbConstraints :: SqliteM [Db.SomeDatabasePredicate]
getDbConstraints =
  SqliteM . ReaderT $ \(_, conn) -> do
    tblNames <- query_ conn "SELECT name, sql from sqlite_master where type='table'"
    tblPreds <-
      fmap mconcat . forM tblNames $ \(tblName, sql) -> do
        columns <- fmap (sortBy (comparing (\(cid, _, _, _, _, _) -> cid :: Int))) $
                   query_ conn (fromString ("PRAGMA table_info('" <> T.unpack tblName <> "')"))

        let columnPreds =
              foldMap
                (\(_ ::Int, nm, typStr, notNull, _, _) ->
                     let dtType = if isAutoincrement then sqliteSerialType else parseSqliteDataType typStr
                         isAutoincrement = isJust (A.maybeResult (A.parse autoincrementParser sql))

                         autoincrementParser = do
                           A.manyTill A.anyChar $ do
                             hadQuote <- optional (A.char '"')
                             A.string nm
                             maybe (pure ()) (\_ -> void $ A.char '"') hadQuote
                             A.many1 A.space
                             asciiCI "INTEGER"
                             A.many1 A.space
                             asciiCI "PRIMARY"
                             A.many1 A.space
                             asciiCI "KEY"
                             A.many1 A.space
                             asciiCI "AUTOINCREMENT"

                         notNullPred =
                           if notNull
                           then [ Db.SomeDatabasePredicate
                                    (Db.TableColumnHasConstraint tblName nm
                                       (Db.constraintDefinitionSyntax Nothing Db.notNullConstraintSyntax Nothing)
                                         :: Db.TableColumnHasConstraint SqliteColumnSchemaSyntax) ]
                           else []

                     in [ Db.SomeDatabasePredicate
                            (Db.TableHasColumn tblName nm dtType ::
                               Db.TableHasColumn SqliteColumnSchemaSyntax) ] ++
                        notNullPred
                )
                columns

            pkColumns = map fst $ sortBy (comparing snd) $
                        mapMaybe (\(_, nm, _, _, _ :: Maybe T.Text, pk) ->
                                      (nm,) <$> (pk <$ guard (pk > (0 :: Int)))) columns
            pkPred = case pkColumns of
                       [] -> []
                       _  -> [ Db.SomeDatabasePredicate (Db.TableHasPrimaryKey tblName pkColumns) ]
        pure ( [ Db.SomeDatabasePredicate (Db.TableExistsPredicate tblName) ]
             ++ pkPred ++ columnPreds )

    pure tblPreds

sqliteText :: Db.DataType SqliteDataTypeSyntax T.Text
sqliteText = Db.DataType sqliteTextType

sqliteBlob :: Db.DataType SqliteDataTypeSyntax ByteString
sqliteBlob = Db.DataType sqliteBlobType

sqliteBigInt :: Db.DataType SqliteDataTypeSyntax Int64
sqliteBigInt = Db.DataType sqliteBigIntType