{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}

{-# HLINT ignore "Use list comprehension" #-}
{-# HLINT ignore "Replace case with maybe" #-}

module AirGQL.GraphQL (
  getDerivedSchema,
  queryType,
  sqlDataToGQLValue,
  getMutationResponse,
  gqlValueToSQLData,
)
where

import Protolude (
  Applicative (pure),
  Bool (False, True),
  Double,
  Either (Left, Right),
  Eq ((==)),
  IO,
  Int,
  Integer,
  Maybe (Just, Nothing),
  MonadIO (liftIO),
  MonadReader (ask),
  Monoid (mempty),
  ReaderT,
  Semigroup ((<>)),
  Text,
  Traversable (sequence),
  fromIntegral,
  fromMaybe,
  notElem,
  otherwise,
  show,
  when,
  ($),
  (&),
  (&&),
  (.),
  (<$>),
  (<&>),
  (<=),
  (>),
  (>=),
 )
import Protolude qualified as P

import Control.Exception (throw)
import Control.Monad.Catch (catchAll)
import Data.Aeson (object, (.=))
import Data.HashMap.Strict qualified as HashMap
import Data.List (nub)
import Data.Ord (Ord (min))
import Data.Text (intercalate, isInfixOf, pack, toUpper)
import Data.Text qualified as T
import Database.SQLite.Simple (
  Connection,
  Query (Query),
  SQLData (SQLBlob, SQLFloat, SQLInteger, SQLNull, SQLText),
  changes,
  execute_,
  query,
  query_,
 )
import Database.SQLite.Simple qualified as SS
import DoubleXEncoding (doubleXDecode, doubleXEncodeGql)
import GHC.IO.Exception (userError)
import Language.GraphQL.AST.Document (Name)
import Language.GraphQL.Error (ResolverException (ResolverException))
import Language.GraphQL.Type as GQL (
  Arguments (Arguments),
  EnumType (EnumType),
  EnumValue (EnumValue),
  InputField (InputField),
  Resolver (EventStreamResolver, ValueResolver),
  ScalarType,
  Schema,
  Value (Boolean, Enum, Float, Int, List, Null, Object, String),
  boolean,
  float,
  int,
  schema,
  string,
 )
import Language.GraphQL.Type.In (
  InputObjectType (InputObjectType),
  Type (NamedInputObjectType),
 )
import Language.GraphQL.Type.In qualified as In
import Language.GraphQL.Type.Out qualified as Out
import Numeric (showFFloat)

import AirGQL.Config (
  maxGraphqlResultCount,
 )
import AirGQL.GQLWrapper (
  InArgument (InArgument, argDescMb, argType, valueMb),
  OutField (OutField, arguments, descriptionMb, fieldType),
  inArgumentToArgument,
  outFieldToField,
 )
import AirGQL.Introspection (getSchemaResolver, typeNameResolver)
import AirGQL.Lib (
  AccessMode (ReadAndWrite, ReadOnly, WriteOnly),
  ColumnEntry (column_name, datatype, datatype_gql),
  GqlTypeName (root),
  TableEntryRaw (name),
  column_name_gql,
  getColumns,
 )
import AirGQL.Types.OutObjectType (
  OutObjectType (OutObjectType, descriptionMb, fields, interfaceTypes, name),
  outObjectTypeToObjectType,
 )
import AirGQL.Types.PragmaConf (getSQLitePragmas)
import AirGQL.Types.SchemaConf (
  SchemaConf (accessMode, maxRowsPerTable, pragmaConf),
 )
import AirGQL.Types.Utils (encodeToText)
import AirGQL.Utils (colToFileUrl, collectErrorList, quoteKeyword, quoteText)


typeNameToScalarType :: Maybe GqlTypeName -> ScalarType
typeNameToScalarType Nothing = string
typeNameToScalarType (Just typeName) =
  case typeName.root of
    "Int" -> int
    "Float" -> float
    "String" -> string
    "Boolean" -> boolean
    _ -> string


-- | Prevent numbers of being shown with exponents (0.01 instead of 1e-2)
showFullPrecision :: Double -> Text
showFullPrecision x =
  pack $ showFFloat Nothing x ""


showGqlValue :: Value -> Text
showGqlValue = \case
  String str -> str
  Int integer -> show integer
  Float double -> showFullPrecision double
  Boolean bool -> show bool
  Enum text -> text
  List list -> "[" <> T.intercalate ", " (list <&> showGqlValue) <> "]"
  Object obj -> show $ Object obj
  Null -> "null"


gqlValueToSQLText :: Value -> Text
gqlValueToSQLText = \case
  String str -> quoteText str
  Int integer -> show integer
  Float double -> showFullPrecision double
  Boolean bool -> T.toUpper $ show bool
  Enum text -> text
  List list ->
    quoteText $
      "[" <> T.intercalate ", " (list <&> showGqlValue) <> "]"
  Object obj -> quoteText $ show $ Object obj
  Null -> "NULL"


-- TODO: Add Support for GraphQL's type "ID"

-- | Convert any GraphQL value to a nullable String
gqlValueToNullableString :: Value -> Value
gqlValueToNullableString value =
  case value of
    String text -> String text
    Null -> Null
    val -> String $ showGqlValue val


colNamesWithValResolver :: [ColumnEntry] -> [(Text, Resolver IO)]
colNamesWithValResolver columnEntries =
  columnEntries <&> \colEntry ->
    let
      fieldToResolve =
        Out.Field
          (Just colEntry.column_name_gql)
          ( Out.NamedScalarType $
              typeNameToScalarType
                colEntry.datatype_gql
          )
          mempty

      resolvedValue = do
        context <- ask
        pure $ case context.values of
          Object obj ->
            case obj & HashMap.lookup colEntry.column_name_gql of
              Nothing -> String "Error: Field does not exist"
              Just val ->
                case colEntry.datatype of
                  -- Coerce value to nullable String
                  -- if no datatype is set.
                  -- This happens for columns in views.
                  "" -> gqlValueToNullableString val
                  _ -> val
          _ -> String "Error: Value could not be retrieved"
    in
      ( colEntry.column_name_gql
      , ValueResolver fieldToResolve resolvedValue
      )


buildSortClause :: [ColumnEntry] -> [(Name, Value)] -> Text
buildSortClause columnEntries orderElems =
  if P.null orderElems
    then
      if "rowid" `P.elem` (columnEntries <&> T.toLower . AirGQL.Lib.column_name)
        then "ORDER BY rowid ASC"
        else ""
    else
      "ORDER BY "
        <> ( orderElems
              <&> ( \(name, value) ->
                      ( name
                      , case value of
                          Enum "ASC" -> "ASC"
                          Enum "asc" -> "ASC"
                          Enum "DESC" -> "DESC"
                          Enum "desc" -> "DESC"
                          _ -> ""
                      )
                  )
              <&> (\(name, order) -> name <> " " <> order)
              & T.intercalate ", "
           )


data Pagination = Pagination
  { limit :: Int
  , offset :: Maybe Int
  }


buildPaginationClause :: Maybe Pagination -> Text
buildPaginationClause = \case
  Nothing -> ""
  Just pagination ->
    P.fold
      [ "LIMIT "
      , show (min pagination.limit maxGraphqlResultCount)
      , case pagination.offset of
          Nothing -> ""
          Just offset -> "\nOFFSET " <> show offset
      ]


getColNamesQuoted :: [ColumnEntry] -> [Text]
getColNamesQuoted columnEntries =
  columnEntries
    <&> ( \col ->
            ( if "BLOB" `T.isPrefixOf` col.datatype
                then
                  "IIF("
                    <> quoteKeyword col.column_name
                    <> " IS NOT NULL, rowid, NULL)"
                    <> " AS "
                    <> quoteKeyword col.column_name
                else quoteKeyword col.column_name
            )
        )


opAndValToSql :: HashMap.HashMap Text Value -> [Text]
opAndValToSql operatorAndValue =
  case HashMap.toList operatorAndValue of
    [("eq", value)] ->
      pure $
        if value == Null
          then " IS NULL"
          else " == " <> gqlValueToSQLText value
    [("neq", value)] ->
      if value == Null
        then pure " IS NOT NULL"
        else
          [ " != " <> gqlValueToSQLText value
          , " IS NULL"
          ]
    [("in", List values)] ->
      let listValues = values <&> gqlValueToSQLText & intercalate ","
      in  [" IN (" <> listValues <> ")"]
    [("nin", List values)] ->
      let listValues = values <&> gqlValueToSQLText & intercalate ","
      in  [" NOT IN (" <> listValues <> ")"]
            <> if P.elem Null values
              then []
              else [" IS NULL"]
    [("gt", value)] -> [" > " <> gqlValueToSQLText value]
    [("gte", value)] -> [" >= " <> gqlValueToSQLText value]
    [("lt", value)] -> [" < " <> gqlValueToSQLText value]
    [("lte", value)] -> [" <= " <> gqlValueToSQLText value]
    [("like", value)] -> [" like " <> gqlValueToSQLText value]
    [("ilike", value)] -> [" like " <> gqlValueToSQLText value]
    filter -> do
      throw $
        userError $
          "Error: Filter "
            <> show filter
            <> " is not yet supported"


getWhereClause :: [(Text, Value)] -> Text
getWhereClause filterElements =
  if P.null filterElements
    then " "
    else
      "WHERE "
        <> ( filterElements
              <&> ( \(colName, x) -> case x of
                      Object operatorAndValue ->
                        let orClauses =
                              opAndValToSql operatorAndValue
                                <&> (colName <>)
                                & intercalate " OR "
                        in  "(" <> orClauses <> ")"
                      _ -> ""
                  )
              & intercalate " AND "
           )


setCaseInsensitive :: Connection -> [(Text, Value)] -> IO ()
setCaseInsensitive connection filterElements = do
  when
    ( filterElements
        & P.any
          ( \(_, value) -> case value of
              Object operatorAndValue ->
                case HashMap.toList operatorAndValue of
                  [("ilike", _)] -> True
                  _ -> False
              _ -> False
          )
    )
    $ do
      execute_ connection "PRAGMA case_sensitive_like = False"


executeSqlQuery
  :: Connection
  -> Text
  -> [ColumnEntry]
  -> [(Text, Value)]
  -> [(Text, Value)]
  -> Maybe Pagination
  -> IO [[SQLData]]
executeSqlQuery
  connection
  tableName
  colEntries
  filterElems
  orderElems
  paginationMb = do
    let
      sqlQuery :: Query
      sqlQuery =
        Query $
          "SELECT "
            <> intercalate ", " (getColNamesQuoted colEntries)
            <> "\n"
            <> "FROM "
            <> quoteKeyword tableName
            <> "\n"
            <> getWhereClause filterElems
            <> "\n"
            <> buildSortClause colEntries orderElems
            <> "\n"
            <> buildPaginationClause paginationMb

    setCaseInsensitive connection filterElems

    liftIO $ query_ connection sqlQuery


colNamesWithFilterField :: Text -> [ColumnEntry] -> [(Text, InputField)]
colNamesWithFilterField tableName columnEntries =
  columnEntries <&> \colEntry ->
    let
      inputField =
        InputField
          (Just $ "Filter for " <> colEntry.column_name_gql)
          ( NamedInputObjectType $
              InputObjectType
                (doubleXEncodeGql tableName <> "_filter")
                (Just "Filter object for the column")
                ( let theInputField =
                        InputField
                          (Just "Value to compare to")
                          ( In.NamedScalarType $
                              typeNameToScalarType
                                colEntry.datatype_gql
                          )
                          Nothing -- Default value
                      listInputField =
                        InputField
                          (Just "Values to compare to")
                          ( In.ListType $
                              In.NamedScalarType $
                                typeNameToScalarType
                                  colEntry.datatype_gql
                          )
                          Nothing -- Default value
                  in  HashMap.fromList
                        [ ("eq", theInputField)
                        , ("neq", theInputField)
                        , ("gt", theInputField)
                        , ("gte", theInputField)
                        , ("lt", theInputField)
                        , ("lte", theInputField)
                        , ("like", theInputField)
                        , ("ilike", theInputField)
                        , ("in", listInputField)
                        , ("nin", listInputField)
                        ]
                )
          )
          Nothing -- Default value
    in
      ( colEntry.column_name_gql
      , inputField
      )


queryType
  :: Connection
  -> AccessMode
  -> Text
  -> [TableEntryRaw]
  -> IO (Out.ObjectType IO)
queryType connection accessMode dbId tables = do
  let
    documentation :: Text
    documentation =
      "Available queries for database \"" <> dbId <> "\""

    getOutField :: Text -> IO (Out.Field IO)
    getOutField tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName

      let
        colNamesWithOrderingTerm :: [(Text, InputField)]
        colNamesWithOrderingTerm =
          columnEntries <&> \colEntry ->
            ( colEntry.column_name_gql
            , InputField
                (Just $ "Ordering term for " <> colEntry.column_name_gql)
                ( In.NamedEnumType $
                    EnumType
                      "OrderingTerm"
                      (Just "Ordering object for the column")
                      ( HashMap.fromList
                          [ ("ASC", EnumValue (Just "ASC"))
                          , ("asc", EnumValue (Just "ASC"))
                          , ("DESC", EnumValue (Just "DESC"))
                          , ("desc", EnumValue (Just "DESC"))
                          ]
                      )
                )
                Nothing -- Default value
            )

        typeNameField :: Text -> [(Text, Resolver IO)]
        typeNameField nameOfTable =
          let
            typeNameOutField =
              outFieldToField $
                OutField
                  { descriptionMb = Just $ "The type name of " <> nameOfTable
                  , fieldType = Out.NonNullScalarType string
                  , arguments = HashMap.empty
                  }
          in
            [
              ( "__typename"
              , ValueResolver typeNameOutField $
                  pure $
                    String $
                      doubleXEncodeGql nameOfTable <> "_row"
              )
            ]

      pure $
        outFieldToField $
          OutField
            { descriptionMb = Just $ "Provides entries from " <> tableName
            , fieldType =
                Out.ListType $
                  Out.NamedObjectType $
                    Out.ObjectType
                      tableName
                      (Just "short desc")
                      []
                      ( HashMap.fromList $
                          colNamesWithValResolver columnEntries
                            <> typeNameField tableName
                      )
            , arguments =
                HashMap.fromList
                  [
                    ( "filter"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb = Just "Filter objects"
                          , argType =
                              NamedInputObjectType $
                                InputObjectType
                                  (doubleXEncodeGql tableName <> "_filter")
                                  ( Just
                                      "Filter objects for the specified columns"
                                  )
                                  (HashMap.fromList (colNamesWithFilterField tableName columnEntries))
                          , valueMb = Nothing
                          }
                    )
                  ,
                    ( "order_by"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb = Just "Order by the specified columns"
                          , argType =
                              In.ListType $
                                In.NamedInputObjectType $
                                  InputObjectType
                                    (doubleXEncodeGql tableName <> "_order_by")
                                    (Just "Options for ordering by columns")
                                    (HashMap.fromList colNamesWithOrderingTerm)
                          , valueMb = Nothing
                          }
                    )
                  ,
                    ( "limit"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb =
                              Just "Limit the number of returned rows."
                          , argType = In.NamedScalarType int
                          , valueMb = Nothing
                          }
                    )
                  ,
                    ( "offset"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb =
                              Just
                                "Change the index rows \
                                \start being returned from"
                          , argType = In.NamedScalarType int
                          , valueMb = Nothing
                          }
                    )
                  ]
            }
    -- -- TODO: Use for retrieving record by primary key
    -- , arguments = HashMap.fromList $ columnEntries
    --     <&> (\colEntry ->
    --           ( colEntry.column_name_gql :: Text
    --           , inArgumentToArgument $ InArgument
    --               { argDescMb = Just "Retrieve object by primary key"
    --               , argType = In.NamedScalarType $
    --                   typeNameToScalarType $ colEntry.datatype
    --               , valueMb = Nothing
    --               }
    --           )
    --         )

    getDbEntries :: Text -> Out.Resolve IO
    getDbEntries tableName = do
      context <- ask
      colEntries <- liftIO $ getColumns dbId connection tableName

      rows :: [[SQLData]] <- case context.arguments of
        Arguments args -> do
          filterElements <- case args & HashMap.lookup "filter" of
            Nothing -> pure []
            Just colToFilter -> case colToFilter of
              Object filterObj -> case HashMap.toList filterObj of
                [] -> P.throwIO $ userError "Error: Filter must not be empty"
                filterElements -> pure filterElements
              _ -> pure []

          orderElements :: [(Name, Value)] <-
            case args & HashMap.lookup "order_by" of
              Nothing -> pure []
              Just colToOrder -> case colToOrder of
                List objects ->
                  -- => [Value]
                  objects
                    -- => IO [[(Name, Value)]]
                    & P.traverse
                      ( \case
                          Object orderObject -> case HashMap.toList orderObject of
                            [] -> P.throwIO $ userError "Error: Order must not be empty"
                            orderElements -> pure orderElements
                          _ -> pure [] -- Should not be reachable
                      )
                    -- => IO [(Name, Value)]
                    <&> P.join
                _ -> pure []

          limitElements :: Maybe P.Int32 <-
            case args & HashMap.lookup "limit" of
              Just (Int limit)
                | limit >= 0 ->
                    pure (Just limit)
                | otherwise ->
                    P.throwIO $
                      userError
                        "Error: limit must be positive"
              _ -> pure Nothing

          paginationMb :: Maybe Pagination <-
            case (limitElements, args & HashMap.lookup "offset") of
              (Just limit, Just (Int offset))
                | offset >= 0 ->
                    pure $
                      Just $
                        Pagination
                          (fromIntegral limit)
                          (Just $ fromIntegral offset)
                | otherwise ->
                    P.throwIO $ userError "Error: offset must be positive"
              (Just limit, _) ->
                pure $
                  Just $
                    Pagination
                      (fromIntegral limit)
                      Nothing
              (Nothing, Just (Int _)) ->
                P.throwIO $
                  userError
                    "Error: cannot specify offset \
                    \without also specifying a limit"
              _ -> pure Nothing

          let
            countQuery :: Query
            countQuery =
              Query $
                P.fold
                  [ "SELECT COUNT() FROM"
                  , quoteKeyword tableName
                  , "\n"
                  , getWhereClause filterElements
                  ]

          -- Will be equal `Just numRows` when the number of
          -- returned rows is too large.
          tooManyReturnedRows :: Maybe Int <- case paginationMb of
            -- Limit doesn't seem to affect COUNT(),
            -- so we consider it manually.
            Just pagination
              | pagination.limit <= maxGraphqlResultCount ->
                  pure Nothing
            _ -> do
              results <- liftIO $ SS.query_ connection countQuery

              let numRows = case P.head results of
                    Just numRowsOnly -> SS.fromOnly numRowsOnly
                    Nothing -> 0

              pure $
                if numRows > maxGraphqlResultCount
                  then Just numRows
                  else Nothing

          P.for_ tooManyReturnedRows $ \numRows -> do
            P.throwIO $
              userError $
                P.fold
                  [ "The graphql API cannot return more than "
                  , show maxGraphqlResultCount
                  , " entries at a time. Your query would have returned "
                  , show numRows
                  , " rows. "
                  , "Consider setting the `limit` argument on your query: `{ "
                  , T.unpack tableName
                  , " (limit: 50) { ... } }`"
                  ]

          liftIO $
            executeSqlQuery
              connection
              tableName
              colEntries
              filterElements
              orderElements
              paginationMb

      rowsToList dbId tableName colEntries rows

    getResolvers :: IO (HashMap.HashMap Text (Resolver IO))
    getResolvers = do
      let
        getTableTuple :: TableEntryRaw -> IO (Text, Resolver IO)
        getTableTuple table = do
          outField <- getOutField table.name
          pure
            ( doubleXEncodeGql table.name
            , ValueResolver
                outField
                ( -- Exceptions must be converted to ResolverExceptions
                  -- to be picked up by GQL query executor
                  catchAll
                    (getDbEntries table.name)
                    (throw . ResolverException)
                )
            )

        getTableTuples :: IO [(Text, Resolver IO)]
        getTableTuples =
          P.for tables getTableTuple

      getTableTuples <&> HashMap.fromList

  -- -- TODO: Add support for retriving record by ID
  -- getResolversPrimaryKey :: IO (HashMap.HashMap Text (Resolver IO))
  -- getResolversPrimaryKey = do
  --   let
  --     getTableTuple table = do
  --       outField <- getOutField $ table.name
  --       pure
  --         ( table.name) <> "_by_pk"
  --         , ValueResolver
  --             outField
  --             (getDbEntries $ table.name)
  --         )

  --     getTableTuples :: IO [(Text, Resolver IO)]
  --     getTableTuples =
  --       sequence $ tables <&> getTableTuple

  --   getTableTuples <&> HashMap.fromList

  resolvers <- getResolvers
  schemaResolver <- getSchemaResolver dbId connection accessMode tables

  -- resolversPrimaryKey <- getResolversPrimaryKey
  let
    -- Resolve = ReaderT Context m Value
    wrapResolve resolve = do
      when (accessMode == WriteOnly) $ do
        throw $
          ResolverException $
            userError "Cannot read field using writeonly access code"
      resolve

    protectResolver = \case
      ValueResolver field resolve ->
        ValueResolver field (wrapResolve resolve)
      EventStreamResolver field resolve subscribe ->
        EventStreamResolver field (wrapResolve resolve) subscribe

  pure $
    outObjectTypeToObjectType $
      OutObjectType
        { name = "Query"
        , descriptionMb = Just documentation
        , interfaceTypes = []
        , fields =
            P.fold
              [ schemaResolver
              , typeNameResolver
              , resolvers
              -- , resolversPrimaryKey)
              ]
              <&> protectResolver
        }


-- | WARNING: Also change duplicate `sqlDataToAesonValue`
sqlDataToGQLValue :: Text -> SQLData -> Either Text Value
sqlDataToGQLValue datatype sqlData = case (datatype, sqlData) of
  (_, SQLInteger int64) ->
    if isInfixOf "BOOL" $ toUpper datatype
      then pure $ case int64 of
        0 -> Boolean False
        _ -> Boolean True
      else
        if int64 >= fromIntegral (P.minBound :: P.Int32)
          && int64 <= fromIntegral (P.maxBound :: P.Int32)
          then pure $ Int $ fromIntegral int64 -- Int32
          else
            Left $
              "Integer "
                <> show int64
                <> " would overflow. "
                <> "This happens because SQLite uses 64-bit ints, "
                <> "but GraphQL uses 32-bit ints. "
                <> "Use a Number (64-bit float) or Text column instead."
  (_, SQLFloat double) -> pure $ Float double
  (_, SQLText text) -> pure $ String text
  (_, SQLBlob byteString) -> pure $ String $ show byteString
  (_, SQLNull) -> pure Null


{-| Convert a GraphQL `Value` to a `SQLData`
TODO: ? -> SQLBlob $ string
-}
gqlValueToSQLData :: Value -> SQLData
gqlValueToSQLData = \case
  Int int32 -> SQLInteger $ fromIntegral int32 -- Int64
  Float double -> SQLFloat double
  String text -> SQLText text
  Null -> SQLNull
  Boolean aBool ->
    if aBool
      then SQLInteger 1
      else SQLInteger 0
  Enum name -> SQLText name
  List aList -> SQLText $ show aList
  Object obj -> SQLText $ show obj


mutationTypeNameField :: Text -> (Text, Resolver IO)
mutationTypeNameField nameOfTable =
  let
    typeNameOutField =
      outFieldToField $
        OutField
          { descriptionMb = Just $ "The type name of " <> nameOfTable
          , fieldType = Out.NonNullScalarType string
          , arguments = HashMap.empty
          }
  in
    ( "__typename"
    , ValueResolver typeNameOutField $
        pure $
          String $
            doubleXEncodeGql nameOfTable <> "_mutation_response"
    )


getMutationResponse
  :: Text
  -> [ColumnEntry]
  -> Out.Type IO
getMutationResponse tableName columnEntries =
  Out.NamedObjectType $
    outObjectTypeToObjectType $
      OutObjectType
        { name = doubleXEncodeGql tableName <> "_mutation_response"
        , descriptionMb =
            Just $
              tableName <> " mutation response description"
        , interfaceTypes = []
        , fields =
            HashMap.fromList
              [
                ( "affected_rows"
                , let
                    field :: Out.Field m
                    field =
                      outFieldToField $
                        OutField
                          { descriptionMb = Just "nonNullInt description"
                          , fieldType = Out.NonNullScalarType int
                          , arguments = HashMap.empty
                          }

                    value :: ReaderT Out.Context IO Value
                    value = do
                      context <- ask
                      case context & Out.values of
                        Object obj ->
                          pure $
                            fromMaybe (Int 0) $
                              HashMap.lookup "affected_rows" obj
                        _ -> pure $ Int 0
                  in
                    ValueResolver field value
                )
              ,
                ( "returning"
                , let
                    field :: Out.Field IO
                    field =
                      outFieldToField $
                        OutField
                          { descriptionMb =
                              Just
                                "Non null returning description"
                          , fieldType =
                              Out.NonNullListType $
                                Out.NamedObjectType $
                                  Out.ObjectType
                                    "returning"
                                    (Just "short desc")
                                    []
                                    ( HashMap.fromList $
                                        colNamesWithValResolver columnEntries
                                    )
                          , arguments = HashMap.empty
                          }

                    value :: ReaderT Out.Context IO Value
                    value = do
                      context <- ask
                      case context & Out.values of
                        Object obj ->
                          pure $
                            fromMaybe (Object P.mempty) $
                              HashMap.lookup "returning" obj
                        _ -> pure $ Object P.mempty
                  in
                    ValueResolver field value
                )
              , mutationTypeNameField tableName
              ]
        }


rowsToList :: (MonadIO m) => Text -> Text -> [ColumnEntry] -> [[SQLData]] -> m Value
rowsToList dbId tableName columnEntries updatedRows =
  let
    buildMetadataJson :: Text -> Text -> Text
    buildMetadataJson colName rowid =
      object ["url" .= colToFileUrl dbId tableName colName rowid]
        & encodeToText

    parseSqlData :: (ColumnEntry, SQLData) -> Either (Text, Text) (Text, Value)
    parseSqlData (colEntry, colVal) =
      if "BLOB" `T.isPrefixOf` colEntry.datatype
        then
          pure
            ( colEntry.column_name_gql
            , case colVal of
                SQLNull -> Null
                SQLInteger id ->
                  String $
                    buildMetadataJson colEntry.column_name (show id)
                SQLText id ->
                  String $
                    buildMetadataJson colEntry.column_name id
                _ -> Null
            )
        else case sqlDataToGQLValue colEntry.datatype colVal of
          Left err ->
            Left
              (colEntry.column_name_gql, err)
          Right gqlData ->
            Right
              (colEntry.column_name_gql, gqlData)
  in
    updatedRows
      <&> ( \row ->
              -- => [(ColumnEntry, SQLData)]
              P.zip columnEntries row
                -- => [Either (Text, Text) (Text, Value)]
                <&> parseSqlData
                -- => Either [(Text, Text)] (Text, Value)
                & collectErrorList
                -- => Either [(Text, Text)] (HashMap Text Value)
                <&> HashMap.fromList
                -- => Either [(Text, Text)] Value
                <&> Object
          )
      -- => Either [[(Text, Text)]] [Value]
      & collectErrorList
      & \case
        Right values -> pure $ List values
        Left errors ->
          let
            errorLines =
              P.join errors
                <&> \(column, err) -> "On column " <> show column <> ": " <> err
          in
            P.throwIO $
              userError $
                T.unpack $
                  "Multiple errors occurred:\n" <> P.unlines errorLines


executeSqlMutation
  :: Connection
  -> Text
  -> HashMap.HashMap Text Value
  -> [ColumnEntry]
  -> [(Text, Value)]
  -> IO (Int, [[SQLData]])
executeSqlMutation connection tableName args columnEntries filterElements = do
  let
    colNamesToUpdateRaw :: [Text]
    colNamesToUpdateRaw =
      case HashMap.lookup "set" args of
        Just (Object dataObj) -> HashMap.keys dataObj
        _ -> []

    colNamesToUpdate :: [Text]
    colNamesToUpdate =
      columnEntries
        <&> column_name
        <&> ( \columnName ->
                if doubleXEncodeGql columnName `P.elem` colNamesToUpdateRaw
                  then Just columnName
                  else Nothing
            )
        & P.catMaybes

    columnNamesText :: Text
    columnNamesText =
      columnEntries
        <&> column_name
        <&> quoteKeyword
        & intercalate ", "

    setText :: Text
    setText =
      colNamesToUpdate
        <&> (\columnName -> quoteKeyword columnName <> " = ?")
        & intercalate ", "

    valuesToSet :: [SQLData]
    valuesToSet =
      case HashMap.lookup "set" args of
        Just (Object dataObj) ->
          columnEntries
            <&> column_name
            <&> ( \columnName ->
                    HashMap.lookup
                      (doubleXEncodeGql columnName)
                      dataObj
                )
            & P.catMaybes
            <&> gqlValueToSQLData
        _ -> []

  updatedRows :: [[SQLData]] <-
    if setText == ""
      then pure []
      else
        let
          sqlQuery =
            Query $
              "UPDATE "
                <> quoteKeyword tableName
                <> "\n"
                <> "SET "
                <> setText
                <> "\n"
                <> getWhereClause filterElements
                <> "\n"
                <> "RETURNING "
                <> columnNamesText

          colTypesToUpdate :: [Text]
          colTypesToUpdate =
            columnEntries
              <&> ( \colEntry ->
                      if doubleXEncodeGql colEntry.column_name
                        `P.elem` colNamesToUpdateRaw
                        then Just colEntry.datatype
                        else Nothing
                  )
              & P.catMaybes

          valuesToSetNorm =
            P.zip valuesToSet colTypesToUpdate
              <&> \(val, datatype) ->
                if (val == SQLText "{}")
                  P.&& ("BLOB" `T.isPrefixOf` T.toUpper datatype)
                  then SQLBlob ""
                  else val
        in
          catchAll
            ( liftIO $ do
                setCaseInsensitive connection filterElements
                query connection sqlQuery valuesToSetNorm
            )
            (throw . ResolverException)

  liftIO $
    changes connection
      & P.fmap (,updatedRows)


mutationType
  :: Connection
  -> Integer
  -> Text
  -> [TableEntryRaw]
  -> IO (Maybe (Out.ObjectType IO))
mutationType connection maxRowsPerTable dbId tables = do
  let
    documentation =
      "Available queries for database \"" <> dbId <> "\""

    getTableFilterType :: Text -> [ColumnEntry] -> InputObjectType
    getTableFilterType tableName columnEntries = do
      InputObjectType
        (doubleXEncodeGql tableName <> "_filter")
        ( Just
            "Filter objects for the specified columns"
        )
        (HashMap.fromList (colNamesWithFilterField tableName columnEntries))

    getOutField :: Text -> IO (Out.Field IO)
    getOutField tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName

      let
        colNamesWithField :: [(Text, InputField)]
        colNamesWithField =
          columnEntries <&> \colEntry ->
            let
              inputField =
                InputField
                  (Just colEntry.column_name_gql)
                  ( In.NamedScalarType $
                      typeNameToScalarType colEntry.datatype_gql
                  )
                  Nothing -- Default value
            in
              ( colEntry.column_name_gql
              , inputField
              )

      let
        objectsType =
          inArgumentToArgument $
            InArgument
              { argDescMb =
                  Just
                    "Objects to be inserted into the database"
              , argType =
                  In.ListType $
                    NamedInputObjectType $
                      InputObjectType
                        ( doubleXEncodeGql tableName
                            <> "_insert_input"
                        )
                        ( Just
                            "Object to be inserted into the database"
                        )
                        (HashMap.fromList colNamesWithField)
              , valueMb = Nothing
              }

        onConflictDescription =
          "Specifies how to handle brtoken unique constraints" :: Text

        columnEnumVariants =
          columnEntries
            <&> \entry ->
              (entry.column_name_gql, EnumValue Nothing)

        columnEnumType =
          EnumType
            (doubleXEncodeGql tableName <> "_column")
            (Just "This enum contains a variant for each colum in the table")
            (HashMap.fromList columnEnumVariants)

        onConflictType =
          inArgumentToArgument $
            InArgument
              { argDescMb = Just onConflictDescription
              , argType =
                  In.ListType
                    $ In.NonNullInputObjectType
                    $ InputObjectType
                      ( doubleXEncodeGql tableName
                          <> "_upsert_on_conflict"
                      )
                      (Just onConflictDescription)
                    $ HashMap.fromList
                      [
                        ( "constraint"
                        , InputField
                            (Just "columns to handle conflicts of")
                            ( In.NonNullListType $
                                In.NonNullEnumType columnEnumType
                            )
                            Nothing
                        )
                      ,
                        ( "update_columns"
                        , InputField
                            (Just "columns to override on conflict")
                            ( In.NonNullListType $
                                In.NonNullEnumType columnEnumType
                            )
                            Nothing
                        )
                      ,
                        ( "where"
                        , InputField
                            (Just "filter specifying which conflicting columns to update")
                            ( In.NamedInputObjectType $
                                getTableFilterType tableName columnEntries
                            )
                            Nothing
                        )
                      ]
              , valueMb = Nothing
              }

      pure $
        outFieldToField $
          OutField
            { descriptionMb = Just "description"
            , fieldType = getMutationResponse tableName columnEntries
            , arguments =
                HashMap.fromList
                  [ ("objects", objectsType)
                  , ("on_conflict", onConflictType)
                  ]
            }

    getColValue :: HashMap.HashMap Text Value -> Text -> Value
    getColValue rowObj columnName =
      HashMap.findWithDefault Null (doubleXEncodeGql columnName) rowObj

    executeDbInserts :: Text -> ReaderT Out.Context IO Value
    executeDbInserts tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName

      context <- ask

      let
        columnNames :: [Text]
        columnNames =
          columnEntries <&> column_name

        columnNamesText :: Text
        columnNamesText =
          columnNames
            <&> quoteKeyword
            & intercalate ", "

        insertInDb :: Arguments -> ReaderT Out.Context IO (Int, [[SQLData]])
        insertInDb (Arguments argMap) = do
          let
            -- Yields for example:
            --   [ { name: "John", email: "john@example.com" }
            --   , { name: "Eve",  email: "eve@example.com" }
            --   ]
            entries =
              HashMap.findWithDefault
                (List [])
                "objects"
                argMap

            -- All colums that are contained in the entries
            containedColumns :: [Text]
            containedColumns =
              case entries of
                List values ->
                  ( values
                      <&> \case
                        Object rowObj ->
                          HashMap.keys rowObj
                        _ -> []
                  )
                    & P.concat
                    & nub
                    <&> doubleXDecode
                _ -> []

            boundVariableNames :: [Text]
            boundVariableNames =
              containedColumns
                <&> (\name -> ":" <> doubleXEncodeGql name)

            onConflictArg =
              case HashMap.lookup "on_conflict" argMap of
                Just (List values) -> values
                _ -> []

          onConflictClauses <- P.for onConflictArg $ \case
            Object fields -> do
              let
                getColumnList fieldName = do
                  case HashMap.lookup fieldName fields of
                    Just (List elements) -> do
                      element <- elements
                      case element of
                        Enum columnName -> pure columnName
                        _ -> []
                    _ -> []

                constraint = getColumnList "constraint"
                update = getColumnList "update_columns"

              updateClauses <- P.for update $ \column -> do
                when (column `notElem` containedColumns) $ do
                  P.throwIO $
                    userError $
                      "Column "
                        <> T.unpack column
                        <> " cannot be set on conflicts without being explicitly provided"

                pure $
                  quoteKeyword column
                    <> " = :"
                    <> doubleXEncodeGql column

              let
                filterElements = case HashMap.lookup "where" fields of
                  Just (Object filterObj) -> HashMap.toList filterObj
                  _ -> []

              pure $
                "ON CONFLICT ("
                  <> ( constraint
                        <&> quoteKeyword
                        & intercalate "<>"
                     )
                  <> ")\n DO UPDATE SET \n"
                  <> intercalate ",\n" updateClauses
                  <> "\n"
                  <> getWhereClause filterElements
            _ -> pure ""

          let
            columnList =
              if P.null containedColumns
                then ""
                else
                  " ("
                    <> ( containedColumns
                          <&> quoteKeyword
                          & intercalate ", "
                       )
                    <> ")"
            insertedValues =
              if P.null boundVariableNames
                then "DEFAULT VALUES"
                else
                  "VALUES ("
                    <> intercalate ", " boundVariableNames
                    <> ")"
            sqlQuery =
              Query $
                "INSERT INTO "
                  <> quoteKeyword tableName
                  <> columnList
                  <> insertedValues
                  <> "\n"
                  <> P.unlines onConflictClauses
                  <> "RETURNING "
                  <>
                  -- TODO: Only return the actually requested values
                  columnNamesText

            sqlDataRows :: [[SQLData]]
            sqlDataRows =
              case entries of
                List values ->
                  values <&> \case
                    Object rowObj ->
                      containedColumns
                        <&> getColValue rowObj
                        <&> gqlValueToSQLData
                    _ -> []
                _ -> []

          -- Exception from SQLite must be converted into
          -- ResolverExceptions to be picked up by GQL query executor
          returnedRows <-
            catchAll
              ( liftIO $ P.forM sqlDataRows $ \sqlDataRow -> do
                  numRowsRes :: [[Integer]] <-
                    query_
                      connection
                      $ Query
                      $ "SELECT COUNT() FROM "
                        <> quoteKeyword tableName

                  case numRowsRes of
                    [[numRows]] -> do
                      when (numRows >= maxRowsPerTable) $
                        P.throwIO $
                          userError $
                            "Please upgrade to a Pro account \
                            \to insert more than "
                              <> show maxRowsPerTable
                              <> " rows into a table"
                    _ -> pure ()

                  SS.queryNamed connection sqlQuery $
                    P.zipWith (SS.:=) boundVariableNames sqlDataRow
              )
              (throw . ResolverException)

          -- FIXME:
          --   This should probably be used, but sqlite-simple
          --   doesn't use only one query to execute the insert
          --   https://github.com/nurpax/sqlite-simple/issues/82
          -- liftIO $ changes connection
          pure (P.length sqlDataRows, returnedRows & P.concat)

      (numOfChanges, returnedRows) <- insertInDb context.arguments
      returning <- rowsToList dbId tableName columnEntries returnedRows

      pure $
        Object $
          HashMap.fromList
            [ ("affected_rows", Int $ fromIntegral numOfChanges)
            , ("returning", returning)
            ]

    -- Execute SQL query to update selected entries
    executeDbUpdates :: Text -> ReaderT Out.Context IO Value
    executeDbUpdates tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName

      context <- ask

      let Arguments args = context.arguments

      (numOfChanges, updatedRows) <- case HashMap.lookup "filter" args of
        Just (Object filterObj) -> case HashMap.toList filterObj of
          [] -> P.throwIO $ userError "Error: Filter must not be empty"
          filterElements ->
            liftIO $
              executeSqlMutation
                connection
                tableName
                args
                columnEntries
                filterElements
        _ -> pure (0, [])

      returning <- rowsToList dbId tableName columnEntries updatedRows

      pure $
        Object $
          HashMap.fromList
            [ ("affected_rows", Int $ fromIntegral (numOfChanges :: Int))
            , ("returning", returning)
            ]

    -- Execute SQL query to delete selected entries
    executeDbDeletions :: Text -> ReaderT Out.Context IO Value
    executeDbDeletions tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName
      context <- ask

      let
        columnNamesText :: Text
        columnNamesText =
          columnEntries
            <&> column_name
            <&> quoteKeyword
            & intercalate ", "

        deleteEntry columnName value = do
          let sqlQuery =
                Query $
                  "DELETE FROM "
                    <> quoteKeyword tableName
                    <> " \
                       \WHERE "
                    <> quoteKeyword columnName
                    <> " = ?\n"
                    <> "RETURNING "
                    <> columnNamesText
          deletedRows :: [[SQLData]] <-
            catchAll
              (liftIO $ query connection sqlQuery [value])
              (throw . ResolverException)
          numChanges <- liftIO $ changes connection

          pure (numChanges, deletedRows)

      (numOfChanges, deletedRows) <- case context.arguments of
        Arguments args -> case HashMap.lookup "filter" args of
          Just colToFilter -> case colToFilter of
            Object filterObj -> case HashMap.toList filterObj of
              [(columnName, Object operatorAndValue)] -> do
                case HashMap.toList operatorAndValue of
                  [("eq", String value)] ->
                    deleteEntry columnName value
                  [("eq", Int value)] ->
                    deleteEntry columnName $ show value
                  _ -> pure (0, [])
              _ -> pure (0, [])
            _ -> pure (0, [])
          Nothing -> pure (0, [])

      returning <- rowsToList dbId tableName columnEntries deletedRows

      pure $
        Object $
          HashMap.fromList
            [ ("affected_rows", Int $ fromIntegral numOfChanges)
            , ("returning", returning)
            ]

    getOutFieldUpdate :: Text -> IO (Out.Field IO)
    getOutFieldUpdate tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName

      let
        colNamesWithField :: [(Text, InputField)]
        colNamesWithField =
          columnEntries <&> \colEntry ->
            let
              inputField =
                InputField
                  (Just colEntry.column_name_gql)
                  ( In.NamedScalarType $
                      typeNameToScalarType colEntry.datatype_gql
                  )
                  Nothing -- Default value
            in
              ( colEntry.column_name_gql
              , inputField
              )

      pure $
        outFieldToField $
          OutField
            { descriptionMb = Just $ "Provides entries from " <> tableName
            , fieldType = getMutationResponse tableName columnEntries
            , arguments =
                HashMap.fromList
                  [
                    ( "filter"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb = Just "Filter objects"
                          , argType =
                              NamedInputObjectType $
                                getTableFilterType tableName columnEntries
                          , valueMb = Nothing
                          }
                    )
                  ,
                    ( "set"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb = Just "Map with new values"
                          , argType =
                              NamedInputObjectType $
                                InputObjectType
                                  (doubleXEncodeGql tableName <> "_set_input")
                                  (Just "New values for the specified columns")
                                  (HashMap.fromList colNamesWithField)
                          , valueMb = Nothing
                          }
                    )
                  ]
            }

    getOutFieldDeletion :: Text -> IO (Out.Field IO)
    getOutFieldDeletion tableName = do
      columnEntries <- liftIO $ getColumns dbId connection tableName

      pure $
        outFieldToField $
          OutField
            { descriptionMb = Just $ "Provides entries from " <> tableName
            , fieldType = getMutationResponse tableName columnEntries
            , arguments =
                HashMap.fromList
                  [
                    ( "filter"
                    , inArgumentToArgument $
                        InArgument
                          { argDescMb = Just "Filter objects"
                          , argType =
                              NamedInputObjectType $
                                InputObjectType
                                  (doubleXEncodeGql tableName <> "_filter")
                                  ( Just
                                      "Filter objects for the specified columns"
                                  )
                                  (HashMap.fromList (colNamesWithFilterField tableName columnEntries))
                          , valueMb = Nothing
                          }
                    )
                  ]
            }
    -- -- TODO: Use for retrieving record by primary key
    -- , arguments = HashMap.fromList $ columnEntries
    --     <&> (\colEntry ->
    --           ( colEntry & column_name_gql :: Text
    --           , inArgumentToArgument $ InArgument
    --               { argDescMb = Just "Retrieve object by primary key"
    --               , argType = In.NamedScalarType $
    --                   typeNameToScalarType $ colEntry & datatype
    --               , valueMb = Nothing
    --               }
    --           )
    --         )

    getMutationResolvers :: IO (HashMap.HashMap Text (Resolver IO))
    getMutationResolvers = do
      let
        getInsertTableTuple :: TableEntryRaw -> IO (Text, Resolver IO)
        getInsertTableTuple table = do
          outFieldInsertion <- getOutField table.name
          pure
            ( "insert_" <> doubleXEncodeGql table.name
            , ValueResolver
                outFieldInsertion
                (executeDbInserts table.name)
            )

        getUpdateTableTuple :: TableEntryRaw -> IO (Text, Resolver IO)
        getUpdateTableTuple table = do
          outFieldUpdate <- getOutFieldUpdate table.name
          pure
            ( "update_" <> doubleXEncodeGql table.name
            , ValueResolver
                outFieldUpdate
                (executeDbUpdates table.name)
            )

        getDeleteTableTuple :: TableEntryRaw -> IO (Text, Resolver IO)
        getDeleteTableTuple table = do
          outFieldDeletion <- getOutFieldDeletion table.name
          pure
            ( "delete_" <> doubleXEncodeGql table.name
            , ValueResolver
                outFieldDeletion
                (executeDbDeletions table.name)
            )

        getTableTuples :: IO [(Text, Resolver IO)]
        getTableTuples =
          sequence $
            (tables <&> getInsertTableTuple)
              <> (tables <&> getUpdateTableTuple)
              <> (tables <&> getDeleteTableTuple)

      getTableTuples <&> HashMap.fromList

  Just
    . Out.ObjectType
      "Mutation"
      (Just documentation)
      []
    <$> getMutationResolvers


-- | Automatically generated schema derived from the SQLite database
getDerivedSchema
  :: SchemaConf
  -> Connection
  -> Text
  -> [TableEntryRaw]
  -> IO (Schema IO)
getDerivedSchema schemaConf connection dbId tables = do
  sqlitePragmas <- getSQLitePragmas schemaConf.pragmaConf
  P.forM_ sqlitePragmas (execute_ connection)

  queries <- queryType connection schemaConf.accessMode dbId tables
  mutations <-
    mutationType
      connection
      schemaConf.maxRowsPerTable
      dbId
      tables

  pure $
    schema
      queries
      ( case schemaConf.accessMode of
          ReadOnly -> Nothing
          WriteOnly -> mutations
          ReadAndWrite -> mutations
      )
      Nothing -- subscriptions
      mempty