{-# LANGUAGE
    CPP
  , DoAndIfThenElse
  , LambdaCase
  , PatternGuards
  , TemplateHaskell
  , ViewPatterns
  #-}
module Rest.Gen.Haskell
  ( HaskellContext (..)
  , mkHsApi
  ) where

import Control.Applicative
import Control.Arrow (first, second)
import Control.Category
import Control.Monad
import Data.Label (modify, set)
import Data.Label.Derive (mkLabelsNamed)
import Data.List
import Data.Maybe
import Prelude hiding (id, (.))
import Safe
import System.Directory
import System.FilePath
import qualified Data.Generics.Uniplate.Data                 as U
import qualified Data.Label.Total                            as L
import qualified Data.List.NonEmpty                          as NList
import qualified Distribution.ModuleName                     as Cabal
import qualified Distribution.Package                        as Cabal
import qualified Distribution.PackageDescription             as Cabal
import qualified Distribution.PackageDescription.Parse       as Cabal
import qualified Distribution.PackageDescription.PrettyPrint as Cabal
import qualified Distribution.Simple.Utils                   as Cabal
import qualified Distribution.Verbosity                      as Cabal
import qualified Distribution.Version                        as Cabal
import qualified Language.Haskell.Exts.Pretty                as H
import qualified Language.Haskell.Exts.Syntax                as H

import Rest.Api (Router, Version)

import Rest.Gen.Base
import Rest.Gen.Types
import Rest.Gen.Utils
import qualified Rest.Gen.NoAnnotation          as N
import qualified Rest.Gen.Base.ActionInfo.Ident as Ident

mkLabelsNamed ("_" ++) [''Cabal.GenericPackageDescription, ''Cabal.CondTree, ''Cabal.Library]

data HaskellContext =
  HaskellContext
    { apiVersion     :: Version
    , targetPath     :: String
    , wrapperName    :: String
    , includePrivate :: Bool
    , sources        :: [N.ModuleName]
    , imports        :: [N.ImportDecl]
    , rewrites       :: [(N.ModuleName, N.ModuleName)]
    , namespace      :: [String]
    }

mkHsApi :: HaskellContext -> Router m s -> IO ()
mkHsApi ctx r =
  do let tree = sortTree . (if includePrivate ctx then id else noPrivate) . apiSubtrees $ r
     mkCabalFile ctx tree
     mapM_ (writeRes ctx) $ allSubTrees tree

mkCabalFile :: HaskellContext -> ApiResource -> IO ()
mkCabalFile ctx tree =
  do cabalExists <- doesFileExist cabalFile
     gpkg <-
       if cabalExists
       then updateExposedModules modules <$> Cabal.readPackageDescription Cabal.normal cabalFile
       else return (mkGenericPackageDescription (wrapperName ctx) modules)
     writeCabalFile cabalFile gpkg
  where
    cabalFile = targetPath ctx </> wrapperName ctx ++ ".cabal"
    modules   = map (Cabal.fromString . unModuleName) (sources ctx)
             ++ map (Cabal.fromString . qualModName . (namespace ctx ++)) (allSubResourceIds tree)

writeCabalFile :: FilePath -> Cabal.GenericPackageDescription -> IO ()
writeCabalFile path = Cabal.writeUTF8File path . unlines . filter emptyField . lines . Cabal.showGenericPackageDescription
  where emptyField = (/= "\"\" ") . takeWhile (/= ':') . reverse

updateExposedModules :: [Cabal.ModuleName] -> Cabal.GenericPackageDescription -> Cabal.GenericPackageDescription
updateExposedModules modules = modify _condLibrary (Just . maybe (mkCondLibrary modules) (set (_exposedModules . _condTreeData) modules))

mkGenericPackageDescription :: String -> [Cabal.ModuleName] -> Cabal.GenericPackageDescription
mkGenericPackageDescription name modules =
#if MIN_VERSION_Cabal(2,0,0)
  Cabal.GenericPackageDescription pkg [] (Just (mkCondLibrary modules)) [] [] [] [] []
#else
  Cabal.GenericPackageDescription pkg [] (Just (mkCondLibrary modules)) [] [] []
#endif
  where
    pkg = Cabal.emptyPackageDescription
      { Cabal.package        = Cabal.PackageIdentifier (cabalPackageName name) (cabalVersion [0, 1])
      , Cabal.buildType      = Just Cabal.Simple
      , Cabal.specVersionRaw = Right (Cabal.orLaterVersion (cabalVersion [1, 8]))
      }

mkCondLibrary :: [Cabal.ModuleName] -> Cabal.CondTree Cabal.ConfVar [Cabal.Dependency] Cabal.Library
mkCondLibrary modules = Cabal.CondNode
  { Cabal.condTreeData        = cabalLibrary modules
  , Cabal.condTreeConstraints =
     [ Cabal.Dependency (cabalPackageName "base")        (Cabal.withinVersion $ cabalVersion [4]      )
     , Cabal.Dependency (cabalPackageName "rest-types")  (Cabal.withinVersion $ cabalVersion [1, 10]  )
     , Cabal.Dependency (cabalPackageName "rest-client") (Cabal.withinVersion $ cabalVersion [0, 5, 2])
     ]
  , Cabal.condTreeComponents  = []
  }

cabalLibrary :: [Cabal.ModuleName] -> Cabal.Library
#if MIN_VERSION_Cabal(2,0,0)
cabalLibrary mods = Cabal.emptyLibrary
  { Cabal.exposedModules = mods
  , Cabal.libBuildInfo = (Cabal.libBuildInfo Cabal.emptyLibrary) { Cabal.hsSourceDirs = ["src"] }
  }
#else
#if MIN_VERSION_Cabal(1,22,0)
cabalLibrary mods = Cabal.Library mods [] [] [] True Cabal.emptyBuildInfo { Cabal.hsSourceDirs = ["src"] }
#else
cabalLibrary mods = Cabal.Library mods True Cabal.emptyBuildInfo { Cabal.hsSourceDirs = ["src"] }
#endif
#endif

cabalVersion :: [Int] -> Cabal.Version
#if MIN_VERSION_Cabal(2,0,0)
cabalVersion = Cabal.mkVersion
#else
cabalVersion v = Cabal.Version v []
#endif

cabalPackageName :: String -> Cabal.PackageName
#if MIN_VERSION_Cabal(2,0,0)
cabalPackageName =  Cabal.mkPackageName
#else
cabalPackageName = Cabal.PackageName
#endif

writeRes :: HaskellContext -> ApiResource -> IO ()
writeRes ctx node =
  do createDirectoryIfMissing True (targetPath ctx </> "src" </> modPath (namespace ctx ++ resParents node))
     writeFile (targetPath ctx </> "src" </> modPath (namespace ctx ++ resId node) ++ ".hs") (mkRes ctx node)

mkRes :: HaskellContext -> ApiResource -> String
mkRes ctx node = H.prettyPrint $ buildHaskellModule ctx node pragmas Nothing
  where
    pragmas :: [N.ModulePragma]
    pragmas = [ H.LanguagePragma () [H.Ident () "OverloadedStrings"]
              , H.OptionsPragma () (Just H.GHC) "-fno-warn-unused-imports"
              ]
    _warningText = "Warning!! This is automatically generated code, do not modify!"

buildHaskellModule :: HaskellContext -> ApiResource ->
                      [N.ModulePragma] -> Maybe N.WarningText ->
                      N.Module
buildHaskellModule ctx node pragmas warningText =
  rewriteModuleNames (rewrites ctx) $
     H.Module () (Just $ H.ModuleHead () name warningText exportSpecs) pragmas importDecls decls
  where
    name :: N.ModuleName
    name = H.ModuleName () $ qualModName $ namespace ctx ++ resId node
    exportSpecs :: Maybe N.ExportSpecList
    exportSpecs = Nothing
    importDecls :: [N.ImportDecl]
    importDecls = nub $ namedImport "Rest.Client.Internal"
                      : extraImports
                     ++ parentImports
                     ++ dataImports
                     ++ idImports
    decls :: [N.Decl]
    decls = idData node ++ concat funcs

    extraImports :: [N.ImportDecl]
    extraImports = imports ctx
    parentImports :: [N.ImportDecl]
    parentImports = map mkImport . tail . inits . resParents $ node
    dataImports :: [N.ImportDecl]
    dataImports = map (qualImport . unModuleName) datImp
    idImports :: [N.ImportDecl]
    idImports = concat . mapMaybe (return . map (qualImport . unModuleName) . Ident.haskellModules <=< snd) . resAccessors $ node

    funcs :: [[N.Decl]]
    datImp :: [N.ModuleName]
    (funcs, datImp) = second (nub . concat) . unzip . map (mkFunction (apiVersion ctx) . resName $ node) $ resItems node
    mkImport :: [String] -> N.ImportDecl
    mkImport p = (namedImport importName)
                   { H.importQualified = True
                   , H.importAs        = importAs'
                   }
      where
        importName :: String
        importName = qualModName $ namespace ctx ++ p
        importAs' :: Maybe N.ModuleName
        importAs' = fmap (H.ModuleName () . modName) . lastMay $ p

rewriteModuleNames :: [(N.ModuleName, N.ModuleName)] -> N.Module -> N.Module
rewriteModuleNames rews = U.transformBi $ \m -> lookupJustDef m m rews

noBinds :: Maybe N.Binds
noBinds = Nothing

use :: N.Name -> N.Exp
use = H.Var () . H.UnQual ()

useMQual :: Maybe N.ModuleName -> N.Name -> N.Exp
useMQual Nothing = use
useMQual (Just qual) = H.Var () . H.Qual () qual

mkFunction :: Version -> String -> ApiAction -> ([N.Decl], [N.ModuleName])
mkFunction ver res (ApiAction _ lnk ai) =
  ([H.TypeSig () [funName] fType,
    H.FunBind () [H.Match () funName fParams rhs noBinds]],
    responseModules errorI ++ responseModules output ++ maybe [] inputModules mInp)
     where
       funName :: N.Name
       funName = mkHsName ai
       fParams :: [N.Pat]
       fParams = map (H.PVar ()) $ lPars
                           ++ maybe [] ((:[]) . hsName . cleanName . description) (ident ai)
                           ++ maybe [] (const [input]) mInp
                           ++ (if null (params ai) then [] else [pList])
       lUrl :: N.Exp
       lPars :: [N.Name]
       (lUrl, lPars) = linkToURL res lnk
       mInp :: Maybe InputInfo
       mInp = fmap (inputInfo . L.get desc . chooseType) . NList.nonEmpty . inputs $ ai
       fType :: N.Type
       fType = H.TyForall () Nothing (Just ctx) $ fTypify tyParts
         where
           ctx :: N.Context
           ctx = H.CxSingle () $ H.ClassA () (H.UnQual () cls) [m]
           cls :: N.Name
           cls = H.Ident () "ApiStateC"
           m :: N.Type
           m = H.TyVar () $ H.Ident () "m"
           fTypify :: [N.Type] -> N.Type
           fTypify = \case
             []          -> error "Rest.Gen.Haskell.mkFunction.fTypify - expects at least one type"
             [ty1]       -> ty1
             [ty1, ty2]  -> H.TyFun () ty1 ty2
             (ty1 : tys) -> H.TyFun () ty1 (fTypify tys)
           tyParts :: [N.Type]
           tyParts = map qualIdent lPars
                  ++ maybe [] (return . Ident.haskellType) (ident ai)
                  ++ inp
                  ++ (if null (params ai)
                      then []
                      else [H.TyList ()
                              (H.TyTuple () H.Boxed
                                [ haskellStringType
                                , haskellStringType
                                ])])
                  ++ [H.TyApp () m
                        (H.TyApp ()
                          (H.TyApp ()
                            (H.TyCon () $ H.UnQual () (H.Ident () "ApiResponse"))
                            (responseHaskellType errorI))
                          (responseHaskellType output))]
           qualIdent :: N.Name -> N.Type
           qualIdent = \case
             (H.Ident _ s)
               | s == cleanHsName res -> H.TyCon () $ H.UnQual () tyIdent
               | otherwise            -> H.TyCon () $ H.Qual () (H.ModuleName () $ modName s) tyIdent
             H.Symbol{}               -> error "Rest.Gen.Haskell.mkFunction.qualIdent - not expecting a Symbol"
           inp :: [N.Type]
           inp | Just i <- mInp
               , i' <- inputHaskellType i = [i']
               | otherwise = []
       input :: N.Name
       input = H.Ident () "input"
       pList :: N.Name
       pList = H.Ident () "pList"
       rhs :: N.Rhs
       rhs = H.UnGuardedRhs () $ H.Let () binds expr
         where
           binds :: N.Binds
           binds = H.BDecls () [rHeadersBind, requestBind]
           rHeadersBind :: N.Decl
           rHeadersBind =
             H.PatBind () (H.PVar () rHeaders)
               (H.UnGuardedRhs () $
                 H.List ()
                   [ H.Tuple () H.Boxed
                     [ use hAccept
                     , stringLit $ dataTypesToAcceptHeader JSON $ responseAcceptType responseType
                     ]
                   , H.Tuple () H.Boxed
                     [ use hContentType
                     , stringLit $ maybe "text/plain" inputContentType mInp
                     ]])
               noBinds

           rHeaders     :: N.Name
           rHeaders     = H.Ident () "rHeaders"
           hAccept      :: N.Name
           hAccept      = H.Ident () "hAccept"
           hContentType :: N.Name
           hContentType = H.Ident () "hContentType"
           doRequest    :: N.Name
           doRequest    = H.Ident () "doRequest"

           requestBind :: N.Decl
           requestBind =
             H.PatBind () (H.PVar () request)
                (H.UnGuardedRhs () $
                  appLast
                    (H.App ()
                      (H.App ()
                        (H.App ()
                          (H.App () (H.App () (use makeReq) (stringLit str)) (stringLit ve))
                          url)
                        (if null (params ai) then H.List () [] else use pList))
                      (use rHeaders))) noBinds
             where
               str = show $ method ai
           appLast :: N.Exp -> N.Exp
           appLast e
             | Just i <- mInp = H.App () e (H.App () (use $ H.Ident () $ inputFunc i) (use input))
             | otherwise = H.App () e (stringLit "")
           makeReq :: N.Name
           makeReq = H.Ident () "makeReq"
           request :: N.Name
           request = H.Ident () "request"

           expr :: N.Exp
           expr = H.App () (H.App () (H.App () (use doRequest)
                                      (use . H.Ident () $ responseFunc errorI))
                                      (use . H.Ident () $ responseFunc output))
                                      (use request)

       ve :: String
       url :: N.Exp
       (ve, url) = ("v" ++ show ver, lUrl)
       errorI :: ResponseInfo
       errorI = errorInfo responseType
       output :: ResponseInfo
       output = outputInfo responseType
       responseType :: ResponseType
       responseType = chooseResponseType ai

linkToURL :: String -> Link -> (N.Exp, [N.Name])
linkToURL res lnk = first (H.List ()) $ urlParts res lnk ([], [])

urlParts :: String -> Link -> ([N.Exp], [N.Name]) -> ([N.Exp], [N.Name])
urlParts res lnk ac@(rlnk, pars) =
  case lnk of
    [] -> ac
    (LResource r : a@(LAccess _) : xs)
      | not (hasParam a) -> urlParts res xs (rlnk ++ [H.List () [stringLit r]], pars)
      | otherwise -> urlParts res xs (rlnk', pars ++ [H.Ident () . cleanHsName $ r])
           where
             rlnk' = rlnk ++ (H.List () [stringLit r] : tailed)
             tailed = [H.App () (useMQual qual $ H.Ident () "readId")
                             (use . hsName $ cleanName r)]
               where
                 qual :: Maybe N.ModuleName
                 qual | r == res  = Nothing
                      | otherwise = Just . H.ModuleName () $ modName r
    (LParam p : xs) -> urlParts res xs (rlnk ++ [H.List () [H.App () (use $ H.Ident () "showUrl")
                                                          (use $ hsName (cleanName p))]], pars)
    (i : xs) -> urlParts res xs (rlnk ++ [H.List () [stringLit $ itemString i]], pars)

idData :: ApiResource -> [N.Decl]
idData node =
  case resAccessors node of
    [] -> []
    [(_pth, Nothing)] -> []
    [(pth, Just i)] ->
      let pp xs | null pth = xs
                | otherwise = stringLit pth : xs
      in [ H.TypeDecl () (H.DHead () tyIdent) (Ident.haskellType i),
           H.TypeSig () [funName] fType,
           H.FunBind () [ H.Match () funName [H.PVar () x]
                            (H.UnGuardedRhs () $ H.List () $ pp [showURLx])
                            noBinds
                        ]
         ]
    ls ->
      let ctor :: (String, Maybe Ident) -> N.QualConDecl
          ctor (pth,mi) =
            H.QualConDecl () Nothing Nothing (H.ConDecl () (H.Ident () (dataName pth)) $ maybe [] f mi)
              where
                f ty = [Ident.haskellType ty]
          fun :: (String, Maybe Ident) -> [N.Decl]
          fun (pth, mi) = [H.FunBind () [H.Match () funName fparams rhs noBinds]]
            where
              (fparams, rhs) =
                case mi of
                  Nothing ->
                    ( [H.PVar () . H.Ident () $ dataName pth]
                    , H.UnGuardedRhs () $ H.List () [stringLit pth]
                    )
                  Just{}  ->  -- Pattern match with data constructor
                    ([H.PParen () $ H.PApp () (H.UnQual () $ H.Ident () (dataName pth)) [H.PVar () x]],
                     H.UnGuardedRhs () $ H.List () [stringLit pth, showURLx])
      in [ H.DataDecl () (H.DataType ()) Nothing (H.DHead () tyIdent) (map ctor ls) Nothing
         , H.TypeSig () [funName] fType
         ] ++ concatMap fun ls
    where
      x        :: N.Name
      x        = H.Ident () "x"
      fType    :: N.Type
      fType    = H.TyFun () (H.TyCon () $ H.UnQual () tyIdent) (H.TyList () haskellStringType)
      funName  :: N.Name
      funName  = H.Ident () "readId"
      showURLx :: N.Exp
      showURLx = H.App () (H.Var () $ H.UnQual () $ H.Ident () "showUrl") (H.Var () $ H.UnQual () x)

tyIdent :: N.Name
tyIdent = H.Ident () "Identifier"

mkHsName :: ActionInfo -> N.Name
mkHsName ai = hsName $ concatMap cleanName parts
  where
      parts = case actionType ai of
        Retrieve   -> case nm of
          [] -> ["access"]
          _  -> nm
          where
            nm = get ++ by ++ target
        Create     -> ["create"] ++ by ++ target
        -- Should be delete, but delete is a JS keyword and causes problems in collect.
        Delete     -> ["remove"] ++ by ++ target
        DeleteMany -> ["removeMany"] ++ by ++ target
        List       -> ["list"] ++ by ++ target
        Update     -> ["save"] ++ by ++ target
        UpdateMany -> ["saveMany"] ++ by ++ target
        Modify   -> if resDir ai == "" then ["do"] else [resDir ai]

      target = if resDir ai == "" then maybe [] ((:[]) . description) (ident ai) else [resDir ai]
      by     = ["by" | target /= [] && (isJust (ident ai) || actionType ai == UpdateMany)]
      get    = ["get" | not (isAccessor ai)]

hsName :: [String] -> N.Name
hsName []       = H.Ident () ""
hsName (x : xs) = H.Ident () $ cleanHsName $ downFirst x ++ concatMap upFirst xs

cleanHsName :: String -> String
cleanHsName s =
  if s `elem` reservedNames
    then s ++ "_"
    else intercalate "" . cleanName $ s
  where
    reservedNames =
      ["as","case","class","data","instance","default","deriving","do"
      ,"foreign","if","then","else","import","infix","infixl","infixr","let"
      ,"in","module","newtype","of","qualified","type","where"]

qualModName :: ResourceId -> String
qualModName = intercalate "." . map modName

modPath :: ResourceId -> String
modPath = intercalate "/" . map modName

dataName :: String -> String
dataName = modName

modName :: String -> String
modName = concatMap upFirst . cleanName

data InputInfo = InputInfo
  { inputModules     :: [N.ModuleName]
  , inputHaskellType :: N.Type
  , inputContentType :: String
  , inputFunc        :: String
  } deriving (Eq, Show)

inputInfo :: DataDesc -> InputInfo
inputInfo dsc =
  case L.get dataType dsc of
    String -> InputInfo [] haskellStringType "text/plain" "toLbs"
    XML    -> InputInfo (L.get haskellModules dsc) (L.get haskellType dsc) "text/xml" "toXML"
    JSON   -> InputInfo (L.get haskellModules dsc) (L.get haskellType dsc) "text/json" "toJSON"
    File   -> InputInfo [] haskellByteStringType "application/octet-stream" "id"
    Other  -> InputInfo [] haskellByteStringType "text/plain" "id"

data ResponseInfo = ResponseInfo
  { responseModules     :: [N.ModuleName]
  , responseHaskellType :: N.Type
  , responseFunc        :: String
  } deriving (Eq, Show)

outputInfo :: ResponseType -> ResponseInfo
outputInfo r =
  case outputType r of
    Nothing -> ResponseInfo [] haskellUnitType "(const ())"
    Just t -> case L.get dataType t of
      String -> ResponseInfo [] haskellStringType "toString"
      XML    -> ResponseInfo (L.get haskellModules t) (L.get haskellType t) "fromXML"
      JSON   -> ResponseInfo (L.get haskellModules t) (L.get haskellType t) "fromJSON"
      File   -> ResponseInfo [] haskellByteStringType "id"
      Other  -> ResponseInfo [] haskellByteStringType "id"

errorInfo :: ResponseType -> ResponseInfo
errorInfo r =
  case errorType r of
    -- Rest only has XML and JSON instances for errors, so we need to
    -- include at least one of these in the accept header. We don't
    -- want to make assumptions about the response type if there is no
    -- accept header so in that case we force it to be JSON.
    Nothing -> fromJustNote ("rest-gen bug: toResponseInfo' was called with a data type other than XML or JSON, responseType: " ++ show r)
             . toResponseInfo' . defaultErrorDataDesc . maybe JSON (\x -> case x of { XML -> XML; _ -> JSON })
             . fmap (L.get dataType) . outputType
             $ r
    Just t -> toResponseInfo [t]
  where
    toResponseInfo :: [DataDesc] -> ResponseInfo
    toResponseInfo xs
      = fromMaybe (error $ "Unsupported error formats: " ++ show xs ++ ", this is a bug in rest-gen.")
      . headMay
      . mapMaybe toResponseInfo'
      $ xs
    toResponseInfo' :: DataDesc -> Maybe ResponseInfo
    toResponseInfo' t = case L.get dataType t of
      XML  -> Just $ ResponseInfo (L.get haskellModules t) (L.get haskellType t) "fromXML"
      JSON -> Just $ ResponseInfo (L.get haskellModules t) (L.get haskellType t) "fromJSON"
      _    -> Nothing

defaultErrorDataDesc :: DataType -> DataDesc
defaultErrorDataDesc dt =
  DataDesc
    { _dataType       = dt
    , _haskellType    = haskellVoidType
    , _haskellModules = [ModuleName () "Rest.Types.Void"]
    }

stringLit :: String -> N.Exp
stringLit s = H.Lit () $ H.String () s s