{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TupleSections #-}

module Data.Medea.Analysis
  ( AnalysisError (..),
    ArrayType (..),
    CompiledSchema (..),
    TypeNode (..),
    compileSchemata,
  )
where

import Algebra.Graph.Acyclic.AdjacencyMap (toAcyclic)
import qualified Algebra.Graph.AdjacencyMap as Cyclic
import Control.Applicative ((<|>))
import Control.Monad (foldM, when)
import Control.Monad.Except (MonadError (..))
import Data.Coerce (coerce)
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HM
import qualified Data.List.NonEmpty as NEList
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (isJust, isNothing, mapMaybe)
import Data.Medea.JSONType (JSONType (..))
import Data.Medea.Parser.Primitive
  ( Identifier,
    MedeaString (..),
    Natural,
    PrimTypeIdentifier (..),
    ReservedIdentifier (..),
    identFromReserved,
    isReserved,
    isStartIdent,
    tryPrimType,
    typeOf,
  )
import Data.Medea.Parser.Spec.Array
  ( elementType,
    maxLength,
    minLength,
    tupleSpec,
  )
import Data.Medea.Parser.Spec.Object
  ( additionalAllowed,
    additionalSchema,
    properties,
  )
import Data.Medea.Parser.Spec.Property (propName, propOptional, propSchema)
import qualified Data.Medea.Parser.Spec.Schema as Schema
import qualified Data.Medea.Parser.Spec.Schemata as Schemata
import qualified Data.Medea.Parser.Spec.String as String
import qualified Data.Medea.Parser.Spec.Type as Type
import qualified Data.Set as S
import Data.Set.NonEmpty (NESet)
import qualified Data.Set.NonEmpty as NESet
import Data.Text (Text)
import Data.Vector (Vector)
import qualified Data.Vector as V
import Prelude

data AnalysisError
  = DuplicateSchemaName !Identifier
  | NoStartSchema
  | DanglingTypeReference !Identifier !Identifier
  | TypeRelationIsCyclic
  | ReservedDefined !Identifier
  | DefinedButNotUsed !Identifier
  | MinMoreThanMax !Identifier
  | DanglingTypeRefProp !Identifier !Identifier
  | DanglingTypeRefList !Identifier !Identifier
  | DanglingTypeRefTuple !Identifier !Identifier
  | DuplicatePropName !Identifier !MedeaString
  | PropertyWithoutObject !Identifier
  | ListWithoutArray !Identifier
  | TupleWithoutArray !Identifier
  | StringValsWithoutString !Identifier
  deriving stock (Eq, Show)

data TypeNode
  = AnyNode
  | PrimitiveNode !JSONType
  | CustomNode !Identifier
  deriving stock (Eq, Ord, Show)

data CompiledSchema = CompiledSchema
  { schemaNode :: !TypeNode,
    typesAs :: {-# UNPACK #-} !(NESet TypeNode),
    minArrayLen :: !(Maybe Natural),
    maxArrayLen :: !(Maybe Natural),
    arrayTypes :: !(Maybe ArrayType),
    props :: !(HashMap Text (TypeNode, Bool)),
    additionalProps :: !Bool,
    additionalPropSchema :: !TypeNode,
    stringVals :: {-# UNPACK #-} !(Vector Text)
  }
  deriving stock (Eq, Show)

data ArrayType
  = ListType !TypeNode
  | TupleType {-# UNPACK #-} !(Vector TypeNode)
  deriving stock (Eq, Show)

checkAcyclic ::
  (MonadError AnalysisError m) =>
  Map Identifier CompiledSchema ->
  m ()
checkAcyclic m =
  when (isNothing . toAcyclic . getTypesAsGraph $ m) $
    throwError TypeRelationIsCyclic

compileSchemata ::
  (MonadError AnalysisError m) =>
  Schemata.Specification ->
  m (Map Identifier CompiledSchema)
compileSchemata (Schemata.Specification v) = do
  m <- foldM go M.empty v
  checkStartSchema m
  checkDanglingReferences getTypeRefs DanglingTypeReference m
  checkDanglingReferences getPropertyTypeRefs DanglingTypeRefProp m
  checkDanglingReferences getListTypeRefs DanglingTypeRefList m
  checkDanglingReferences getTupleTypeRefs DanglingTypeRefTuple m
  checkUnusedSchemata m
  checkAcyclic m
  pure m
  where
    go acc spec = M.alterF (checkedInsert spec) (Schema.name spec) acc
    checkedInsert spec = \case
      Nothing -> Just <$> compileSchema spec
      Just _ -> throwError . DuplicateSchemaName $ ident
      where
        ident = Schema.name spec

compileSchema ::
  (MonadError AnalysisError m) =>
  Schema.Specification ->
  m CompiledSchema
compileSchema scm = do
  when (isReserved schemaName && (not . isStartIdent) schemaName)
    $ throwError . ReservedDefined
    $ schemaName
  let minListLen = minLength arraySpec
      maxListLen = maxLength arraySpec
  when (isJust minListLen && isJust maxListLen && minListLen > maxListLen)
    $ throwError
    $ MinMoreThanMax schemaName
  propMap <- foldM go HM.empty (maybe V.empty properties objSpec)
  let arrType = getArrayTypes (elementType arraySpec) (tupleSpec arraySpec)
      tupleLen = getTupleTypeLen arrType
      hasPropSpec = isJust objSpec
      compiledScm =
        CompiledSchema
          { schemaNode = identToNode . Just $ schemaName,
            typesAs = NESet.fromList . defaultToAny . V.toList . fmap (identToNode . Just) $ types,
            minArrayLen = minListLen <|> tupleLen,
            maxArrayLen = maxListLen <|> tupleLen,
            arrayTypes = arrType,
            props = propMap,
            additionalProps = maybe True additionalAllowed objSpec,
            additionalPropSchema = identToNode $ objSpec >>= additionalSchema,
            stringVals = String.toReducedSpec stringValsSpec
          }
  when (shouldNotHavePropertySpec compiledScm hasPropSpec)
    $ throwError . PropertyWithoutObject
    $ schemaName
  when (shouldNotHaveListSpec compiledScm)
    $ throwError . ListWithoutArray
    $ schemaName
  when (shouldNotHaveTupleSpec compiledScm)
    $ throwError . TupleWithoutArray
    $ schemaName
  when (shouldNotHaveStringSpec compiledScm)
    $ throwError . StringValsWithoutString
    $ schemaName
  pure compiledScm
  where
    Schema.Specification schemaName (Type.Specification types) stringValsSpec arraySpec objSpec =
      scm
    go acc prop = HM.alterF (checkedInsert prop) (coerce $ propName prop) acc
    checkedInsert prop = \case
      Nothing -> pure . Just $ (identToNode (propSchema prop), propOptional prop)
      Just _ -> throwError $ DuplicatePropName schemaName (propName prop)
    defaultToAny :: [TypeNode] -> NEList.NonEmpty TypeNode
    defaultToAny xs = case NEList.nonEmpty xs of
      Nothing -> (NEList.:|) AnyNode []
      Just xs' -> xs'

checkStartSchema ::
  (MonadError AnalysisError m) =>
  Map Identifier CompiledSchema ->
  m ()
checkStartSchema m = case M.lookup (identFromReserved RStart) m of
  Nothing -> throwError NoStartSchema
  Just _ -> pure ()

-- We need a 'getRefs' argument here so that we can differentiate between
-- different kinds of Dangling references(type/property/list/tuple).
checkDanglingReferences ::
  (MonadError AnalysisError m) =>
  (CompiledSchema -> [TypeNode]) ->
  (Identifier -> Identifier -> AnalysisError) ->
  Map Identifier CompiledSchema ->
  m ()
checkDanglingReferences getRefs err m = mapM_ go . M.toList $ m
  where
    go (schemaName, scm) = case getDanglingRefs scm of
      danglingRef : _ -> throwError $ err danglingRef schemaName
      [] -> pure ()
    getDanglingRefs = filter isUndefined . mapMaybe fromCustomNode . getRefs
    isUndefined ident = isNothing . M.lookup ident $ m
    fromCustomNode (CustomNode ident) = Just ident
    fromCustomNode _ = Nothing

checkUnusedSchemata ::
  (MonadError AnalysisError m) =>
  Map Identifier CompiledSchema ->
  m ()
checkUnusedSchemata m = mapM_ checkUnused . M.keys $ m
  where
    checkUnused ident
      | S.member (CustomNode ident) allReferences = pure ()
      | isStartIdent ident = pure ()
      | otherwise = throwError $ DefinedButNotUsed ident
    allReferences = S.unions . fmap getReferences . M.elems $ m
    getReferences scm =
      S.fromList $
        getTypeRefs scm ++ getPropertyTypeRefs scm ++ getListTypeRefs scm ++ getTupleTypeRefs scm

-- Helpers
identToNode :: Maybe Identifier -> TypeNode
identToNode ident = case ident of
  Nothing -> AnyNode
  Just t -> maybe (CustomNode t) (PrimitiveNode . typeOf) $ tryPrimType t

getTypeRefs :: CompiledSchema -> [TypeNode]
getTypeRefs = NEList.toList . NESet.toList . typesAs

getPropertyTypeRefs :: CompiledSchema -> [TypeNode]
getPropertyTypeRefs scm = (fmap fst . HM.elems . props $ scm) ++ [additionalPropSchema scm]

getListTypeRefs :: CompiledSchema -> [TypeNode]
getListTypeRefs scm = case arrayTypes scm of
  Just (ListType typeNode) -> [typeNode]
  _ -> []

getTupleTypeRefs :: CompiledSchema -> [TypeNode]
getTupleTypeRefs scm = case arrayTypes scm of
  Just (TupleType typeNodes) -> V.toList typeNodes
  _ -> []

getArrayTypes :: Maybe Identifier -> Maybe [Identifier] -> Maybe ArrayType
getArrayTypes Nothing Nothing = Nothing
getArrayTypes (Just ident) _ = Just . ListType . identToNode . Just $ ident
getArrayTypes _ (Just idents) =
  Just . TupleType . V.fromList $ identToNode . Just <$> idents

getTupleTypeLen :: Maybe ArrayType -> Maybe Natural
getTupleTypeLen (Just (TupleType types)) = Just . fromIntegral . V.length $ types
getTupleTypeLen _ = Nothing

getTypesAsGraph :: Map Identifier CompiledSchema -> Cyclic.AdjacencyMap TypeNode
getTypesAsGraph = Cyclic.edges . concatMap intoTypesAsEdges . M.elems

intoTypesAsEdges :: CompiledSchema -> [(TypeNode, TypeNode)]
intoTypesAsEdges scm = fmap (schemaNode scm,) . NEList.toList . NESet.toList . typesAs $ scm

arrayNode :: TypeNode
arrayNode = PrimitiveNode JSONArray

objectNode :: TypeNode
objectNode = PrimitiveNode JSONObject

stringNode :: TypeNode
stringNode = PrimitiveNode JSONString

hasListSpec :: CompiledSchema -> Bool
hasListSpec scm = case arrayTypes scm of
  Just (ListType _) -> True
  Just (TupleType _) -> False
  _ -> isJust $ minArrayLen scm <|> maxArrayLen scm

hasTupleSpec :: CompiledSchema -> Bool
hasTupleSpec scm = case arrayTypes scm of
  Just (TupleType _) -> True
  _ -> False

hasStringSpec :: CompiledSchema -> Bool
hasStringSpec = not . V.null . stringVals

shouldNotHavePropertySpec :: CompiledSchema -> Bool -> Bool
shouldNotHavePropertySpec scm hasPropSpec = hasPropSpec && (not . NESet.member objectNode . typesAs $ scm)

shouldNotHaveListSpec :: CompiledSchema -> Bool
shouldNotHaveListSpec scm = hasListSpec scm && (not . NESet.member arrayNode . typesAs $ scm)

shouldNotHaveTupleSpec :: CompiledSchema -> Bool
shouldNotHaveTupleSpec scm = hasTupleSpec scm && (not . NESet.member arrayNode . typesAs $ scm)

shouldNotHaveStringSpec :: CompiledSchema -> Bool
shouldNotHaveStringSpec scm = hasStringSpec scm && (not . NESet.member stringNode . typesAs $ scm)