module AirGQL.Introspection (
  typeNameResolver,
  getSchemaResolver,
  tableQueryField,
  tableQueryByPKField,
  tableInsertField,
  tableUpdateField,
  tableUpdateFieldByPk,
  tableDeleteField,
  tableDeleteFieldByPK,
)
where

import Protolude (
  Applicative (pure),
  Either (Left, Right),
  IO,
  Maybe (Just, Nothing),
  Monoid (mempty),
  Semigroup ((<>)),
  Text,
  ($),
  (&),
  (<&>),
  (==),
 )
import Protolude qualified as P

import Data.HashMap.Strict as HashMap (HashMap, singleton)
import Language.GraphQL.Type.Out as Out (
  Field (Field),
  Resolver (ValueResolver),
  Type (NonNullScalarType),
 )

import AirGQL.Introspection.NamingConflict (
  encodeOutsidePKNames,
  encodeOutsideTableNames,
 )
import AirGQL.Introspection.Resolver (makeType)
import AirGQL.Introspection.Types (IntrospectionType)
import AirGQL.Introspection.Types qualified as Type
import AirGQL.Lib (
  AccessMode,
  ColumnEntry,
  GqlTypeName (full, root),
  ObjectType (Table),
  TableEntry (columns, name, object_type),
  canInsert,
  canRead,
  canWrite,
  column_name_gql,
  datatype_gql,
  getPKColumns,
  isOmittable,
  notnull,
 )
import Control.Monad (MonadFail (fail))
import Data.List qualified as List
import Data.Text qualified as T
import DoubleXEncoding (doubleXEncodeGql)
import Language.GraphQL.Class (ToGraphQL (toGraphQL))
import Language.GraphQL.Type (string)


typeNameResolver :: HashMap Text (Resolver IO)
typeNameResolver =
  HashMap.singleton
    "__typename"
    $ ValueResolver
      (Out.Field Nothing (Out.NonNullScalarType string) mempty)
    $ pure "Query"


columnTypeName :: ColumnEntry -> Text
columnTypeName entry =
  case entry.datatype_gql of
    Nothing -> "String"
    Just type_ -> type_.full


columnType :: ColumnEntry -> IntrospectionType
columnType entry = case entry.datatype_gql of
  Nothing -> Type.typeString
  Just type_ ->
    Type.scalar type_.root
      -- NOTE: I wonder if the fact we're generating different descriptions
      -- based off the field is ok.
      & Type.withDescription
        ("Data type for column '" <> entry.column_name_gql <> "'")


orderingTermType :: Type.IntrospectionType
orderingTermType =
  Type.enum
    "OrderingTerm"
    [ Type.enumValue "ASC" & Type.enumValueWithDescription "In ascending order"
    , Type.enumValue "asc"
        & Type.enumValueWithDescription "In ascending order"
        & Type.deprecatedEnumValue "GraphQL spec recommends all caps for enums"
    , Type.enumValue "DESC" & Type.enumValueWithDescription "In descending order"
    , Type.enumValue "desc"
        & Type.enumValueWithDescription "In descending order"
        & Type.deprecatedEnumValue "GraphQL spec recommends all caps for enums"
    ]
    & Type.withDescription "Ordering options when ordering by a column"


filterType :: TableEntry -> IntrospectionType
filterType table = do
  let
    fieldsWithComparisonExp =
      table.columns <&> \columnEntry -> do
        let colName = columnEntry.column_name_gql
        let typeName = columnTypeName columnEntry
        let type_ = columnType columnEntry

        let comparisonField name ty =
              [ Type.deprecatedInputValue "Unify naming with Hasura" $
                  Type.inputValue name ty
              , Type.inputValue ("_" <> name) ty
              ]

        Type.inputValue colName
          $ Type.withDescription ("Compare to a(n) " <> typeName)
          $ Type.inputObject
            (typeName <> "Comparison")
          $ P.fold
            [ comparisonField "eq" type_
            , comparisonField "neq" type_
            , comparisonField "gt" type_
            , comparisonField "gte" type_
            , comparisonField "lt" type_
            , comparisonField "lte" type_
            , comparisonField "like" type_
            , comparisonField "ilike" type_
            , comparisonField "in" $ Type.list type_
            , comparisonField "nin" $ Type.list type_
            ]

  Type.inputObject
    (doubleXEncodeGql table.name <> "_filter")
    fieldsWithComparisonExp
    & Type.withDescription "Select rows matching the provided filter object"


tableRowType :: TableEntry -> Type.IntrospectionType
tableRowType table = do
  let fields =
        table.columns <&> \columnEntry -> do
          let colName = columnEntry.column_name_gql
          let base = columnType columnEntry
          let type_ =
                if columnEntry.notnull
                  then Type.nonNull base
                  else base
          Type.field colName type_
  Type.object (doubleXEncodeGql table.name <> "_row") fields
    & Type.withDescription
      ("Available columns for table \"" <> table.name <> "\"")


tableQueryField :: TableEntry -> Type.Field
tableQueryField table =
  let
    fieldsWithOrderingTerm =
      table.columns <&> \columnEntry -> do
        let colName = columnEntry.column_name_gql
        Type.inputValue colName orderingTermType

    orderType =
      Type.inputObject
        (doubleXEncodeGql table.name <> "_order_by")
        fieldsWithOrderingTerm
        & Type.withDescription
          ( "Ordering options when selecting data from \""
              <> table.name
              <> "\"."
          )
  in
    Type.field
      (doubleXEncodeGql table.name)
      (Type.nonNull $ Type.list $ Type.nonNull $ tableRowType table)
      & Type.fieldWithDescription ("Rows from the table \"" <> table.name <> "\"")
      & Type.withArguments
        ( Type.inputValue "filter" (filterType table)
            & Type.inputValueWithDescription "Filter to select specific rows"
            & Type.renameInputValue "where" "Unify naming with Hasura"
        )
      & Type.withArguments
        [ Type.inputValue "order_by" (Type.list orderType)
            & Type.inputValueWithDescription "Columns used to sort the data"
        , Type.inputValue "limit" Type.typeInt
            & Type.inputValueWithDescription "Limit the number of returned rows"
        , Type.inputValue "offset" Type.typeInt
            & Type.inputValueWithDescription "The index to start returning rows from"
        ]


tablePKArguments :: TableEntry -> Maybe [Type.InputValue]
tablePKArguments table = do
  pks <- getPKColumns table

  pure $
    pks <&> \column -> do
      let name = doubleXEncodeGql column.column_name_gql
      Type.inputValue name $ Type.nonNull $ columnType column


tableQueryByPKField :: [TableEntry] -> TableEntry -> Maybe Type.Field
tableQueryByPKField tables table = do
  pkArguments <- tablePKArguments table
  pure $
    Type.field
      (encodeOutsideTableNames tables $ doubleXEncodeGql table.name <> "_by_pk")
      (tableRowType table)
      & Type.fieldWithDescription
        ( "Rows from the table \""
            <> table.name
            <> "\", accessible by their primary key"
        )
      & Type.withArguments pkArguments


mutationResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType
mutationResponseType accessMode table = do
  let tableName = doubleXEncodeGql table.name
  let readonlyFields =
        if canRead accessMode
          then
            pure
              $ Type.field
                "returning"
              $ Type.nonNull
              $ Type.list
              $ Type.nonNull
              $ tableRowType table
          else []

  Type.object
    (tableName <> "_mutation_response")
    ( [ Type.field "affected_rows" (Type.nonNull Type.typeInt)
      ]
        <> readonlyFields
    )
    & Type.withDescription
      ( "Mutation response for table \""
          <> table.name
          <> "\""
      )


mutationByPkResponseType :: AccessMode -> TableEntry -> Type.IntrospectionType
mutationByPkResponseType accessMode table = do
  let readonlyFields =
        if canRead accessMode
          then
            pure $
              Type.field "returning" $
                tableRowType table
          else []

  Type.object
    (doubleXEncodeGql table.name <> "_mutation_by_pk_response")
    ( [ Type.field "affected_rows" (Type.nonNull Type.typeInt)
      ]
        <> readonlyFields
    )
    & Type.withDescription
      ( "Response for a PK-based mutation on table \""
          <> table.name
          <> "\""
      )


tableInsertField :: AccessMode -> TableEntry -> Type.Field
tableInsertField accessMode table = do
  let
    tableName = doubleXEncodeGql table.name
    fields =
      table.columns <&> \columnEntry -> do
        let colName = columnEntry.column_name_gql
        let base = columnType columnEntry
        let type_ =
              if columnEntry.isOmittable
                then base
                else Type.nonNull base
        Type.inputValue colName type_

    insertRows =
      Type.inputObject
        (tableName <> "_insert_input")
        fields
        & Type.withDescription
          ("Input object for " <> table.name)

    fieldEnumVariants =
      table.columns <&> \columnEntry ->
        Type.enumValue $ column_name_gql columnEntry

    fieldEnumType =
      Type.enum (tableName <> "_column") fieldEnumVariants
        & Type.withDescription
          "This enum contains a variant for each column in the table"

    onConflict =
      Type.inputObject
        (tableName <> "_upsert_on_conflict")
        [ Type.inputValue
            "constraint"
            (Type.nonNull $ Type.list $ Type.nonNull fieldEnumType)
            & Type.inputValueWithDescription
              "columns to handle conflicts of"
        , Type.inputValue
            "update_columns"
            (Type.nonNull $ Type.list $ Type.nonNull fieldEnumType)
            & Type.inputValueWithDescription
              "columns to override on conflict"
        , Type.inputValue "where" (filterType table)
            & Type.inputValueWithDescription
              "filter specifying which conflicting columns to update"
        ]
        & Type.withDescription
          ( "Specifies how broken UNIQUE constraints for "
              <> table.name
              <> " should be handled"
          )

  Type.field
    ("insert_" <> tableName)
    (Type.nonNull $ mutationResponseType accessMode table)
    & Type.fieldWithDescription
      ("Insert new rows in table \"" <> table.name <> "\"")
    & Type.withArguments
      [ Type.inputValue
          "objects"
          (Type.nonNull $ Type.list $ Type.nonNull insertRows)
          & Type.inputValueWithDescription "Rows to be inserted"
      , Type.inputValue
          "on_conflict"
          (Type.list $ Type.nonNull onConflict)
          & Type.inputValueWithDescription
            "Specifies how to handle broken UNIQUE constraints"
      ]


tableSetInput :: TableEntry -> Type.IntrospectionType
tableSetInput table =
  let
    fields =
      table.columns <&> \columnEntry -> do
        let colName = columnEntry.column_name_gql
        let base = columnType columnEntry
        Type.inputValue colName base
  in
    Type.inputObject
      (doubleXEncodeGql table.name <> "_set_input")
      fields
      & Type.withDescription
        ("Fields to set for " <> table.name)


tableUpdateField :: AccessMode -> TableEntry -> Type.Field
tableUpdateField accessMode table = do
  let tableName = doubleXEncodeGql table.name

  Type.field
    ("update_" <> tableName)
    (Type.nonNull $ mutationResponseType accessMode table)
    & Type.fieldWithDescription
      ("Update rows in table \"" <> table.name <> "\"")
    & Type.withArguments
      ( Type.inputValue
          "set"
          (tableSetInput table)
          & Type.inputValueWithDescription "Fields to be updated"
          & Type.renameInputValue "_set" "Unify naming with Hasura"
      )
    & Type.withArguments
      ( Type.inputValue
          "filter"
          (filterType table)
          & Type.inputValueWithDescription "Filter to select rows to be updated"
          & Type.renameInputValue "where" "Unify naming with Hasura"
      )


tableUpdateFieldByPk
  :: AccessMode
  -> [TableEntry]
  -> TableEntry
  -> Maybe Type.Field
tableUpdateFieldByPk accessMode tables table = do
  pkArguments <- tablePKArguments table

  pure $
    Type.field
      ( "update_"
          <> encodeOutsideTableNames
            tables
            ( doubleXEncodeGql table.name
                <> "_by_pk"
            )
      )
      (Type.nonNull $ mutationByPkResponseType accessMode table)
      & Type.fieldWithDescription
        ("Update row in table \"" <> table.name <> "\" by PK")
      & Type.withArguments pkArguments
      & Type.withArguments
        ( Type.inputValue
            (encodeOutsidePKNames table "set")
            (tableSetInput table)
            & Type.inputValueWithDescription "Fields to be updated"
            & Type.renameInputValue
              (encodeOutsidePKNames table "_set")
              "Unify naming with Hasura"
        )


tableDeleteField :: AccessMode -> TableEntry -> Type.Field
tableDeleteField accessMode table = do
  Type.field
    ("delete_" <> doubleXEncodeGql table.name)
    (Type.nonNull $ mutationResponseType accessMode table)
    & Type.fieldWithDescription
      ("Delete rows in table \"" <> table.name <> "\"")
    & Type.withArguments
      ( Type.inputValue
          "filter"
          (filterType table)
          & Type.inputValueWithDescription "Filter to select rows to be deleted"
          & Type.renameInputValue "where" "Unify naming with Hasura"
      )


tableDeleteFieldByPK
  :: AccessMode
  -> [TableEntry]
  -> TableEntry
  -> Maybe Type.Field
tableDeleteFieldByPK accessMode tables table = do
  args <- tablePKArguments table
  pure $
    Type.field
      ( "delete_"
          <> encodeOutsideTableNames
            tables
            (doubleXEncodeGql table.name <> "_by_pk")
      )
      (Type.nonNull $ mutationByPkResponseType accessMode table)
      & Type.fieldWithDescription
        ("Delete row in table \"" <> table.name <> "\" by PK")
      & Type.withArguments args


directives :: [Type.Directive]
directives =
  [ Type.directive
      "skip"
      ["INLINE_FRAGMENT", "FRAGMENT_SPREAD", "FIELD"]
      [ Type.inputValue "if" (Type.nonNull Type.typeBool)
          & Type.inputValueWithDescription "Skipped when true."
      ]
      & Type.directiveWithDescription
        "Directs the executor to skip this field or fragment \
        \when the `if` argument is true."
  , Type.directive
      "include"
      ["INLINE_FRAGMENT", "FRAGMENT_SPREAD", "FIELD"]
      [ Type.inputValue "if" (Type.nonNull Type.typeBool)
          & Type.inputValueWithDescription "Included when true."
      ]
      & Type.directiveWithDescription
        "Directs the executor to include this field or fragment \
        \only when the `if` argument is true."
  ]


getSchema
  :: AccessMode
  -> [TableEntry]
  -> Type.Schema
getSchema accessMode tables = do
  let
    queryType = do
      P.guard $ canRead accessMode
      P.fold
        [ tables <&> tableQueryField
        , tables & P.mapMaybe (tableQueryByPKField tables)
        ]

    tablesWithoutViews =
      List.filter
        (\table -> table.object_type == Table)
        tables

    insertMutations = do
      P.guard $ canInsert accessMode
      P.fold
        [ tablesWithoutViews <&> tableInsertField accessMode
        ]

    writeMutations = do
      P.guard $ canWrite accessMode
      P.fold
        [ tablesWithoutViews <&> tableUpdateField accessMode
        , tablesWithoutViews <&> tableDeleteField accessMode
        , tablesWithoutViews
            & P.mapMaybe (tableUpdateFieldByPk accessMode tables)
        , tablesWithoutViews
            & P.mapMaybe (tableDeleteFieldByPK accessMode tables)
        ]

    mutationType = insertMutations <> writeMutations

  Type.collectSchemaTypes $
    Type.Schema
      Nothing
      []
      (Type.object "Query" queryType)
      (Just $ Type.object "Mutation" mutationType)
      directives


-- We make this toplevel, because putting it inside `getSchemaResolver`
-- means haskell will evaluate it each time, which leads to each execution
-- taking 2-3 additional seconds
schemaField :: Either Text (Out.Field IO)
schemaField = do
  let field = Type.field "__schema" $ Type.nonNull Type.typeSchema
  ty <- makeType field.type_
  pure $ Out.Field field.description ty mempty


getSchemaResolver
  :: AccessMode
  -> [TableEntry]
  -> IO (HashMap Text (Resolver IO))
getSchemaResolver accessMode tables = do
  case schemaField of
    Right field -> do
      let schema = getSchema accessMode tables
      let resolver = Out.ValueResolver field $ pure $ toGraphQL schema
      pure $ HashMap.singleton "__schema" resolver
    Left err -> fail $ T.unpack err