{-#LANGUAGE NoImplicitPrelude #-}
{-#LANGUAGE OverloadedStrings #-}
{-#LANGUAGE OverloadedLists #-}
{-#LANGUAGE LambdaCase #-}
{-#LANGUAGE ScopedTypeVariables #-}
{-#LANGUAGE FlexibleInstances #-}
{-#LANGUAGE FlexibleContexts #-}
{-#LANGUAGE MultiParamTypeClasses #-}
{-#LANGUAGE TemplateHaskell #-}
{-#LANGUAGE TypeApplications #-}

module Web.Sprinkles.Bake
where

import Web.Sprinkles.Prelude
import qualified Data.Text as Text
import Data.Text (Text)
import qualified Data.Set as Set
import Data.Set (Set)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ( (</>), takeDirectory, replaceExtension )
import Control.Monad.State
import Control.Lens
import Control.Lens.TH (makeLenses)
import Text.Printf (printf)
import Network.HTTP.Types (Status (..), status200)
import Network.Wai.Test
import Network.Wai (Application, Request (..))
import qualified Network.Wai as Wai
import Web.Sprinkles.Serve (appFromProject)
import Web.Sprinkles.Project
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString as BS
import Data.Char (ord)
import Text.HTML.TagSoup (parseTags, Tag (..), Attribute)
import qualified Data.CSS.Syntax.Tokens as CSS
import Data.FileEmbed (embedStringFile)

defHtaccess :: ByteString
defHtaccess = $(embedStringFile "embedded/.htaccess")

data BakeState
    = BakeState
        { _bsTodo :: [FilePath]
        , _bsDone :: Set FilePath
        , _bsBasedir :: FilePath
        , _bsApp :: Application
        }

makeLenses ''BakeState

defBakeState :: BakeState
defBakeState = BakeState [] Set.empty "." defaultApplication

defaultApplication :: Application
defaultApplication rq respond =
    respond $
        Wai.responseLBS
            status200
            [("Content-type", "text/plain;charset=utf8")]
            "Hello, world!"

type Bake = StateT BakeState IO

bakeProject :: FilePath -> Project -> [FilePath] -> IO ()
bakeProject destDir project extraEntryPoints = do
    putStrLn @Text $ "Baking project into " <> pack destDir
    createDirectoryIfMissing True destDir
    let app = appFromProject project
    runBake destDir entryPoints app $ do
        bakeHtaccess
        bake404
        bakeApp
    where
        entryPoints =
            [ "/"
            , "/sitemap.xml"
            , "/favicon.ico"
            , "/robots.txt"
            ]
            ++ extraEntryPoints

runBake :: FilePath -> [FilePath] -> Application -> Bake a -> IO a
runBake baseDir entryPoints app a =
    evalStateT a $ defBakeState
        { _bsTodo = entryPoints
        , _bsBasedir = baseDir
        , _bsApp = app
        }

bakeHtaccess :: Bake ()
bakeHtaccess = do
    basedir <- use bsBasedir
    liftIO $ writeFile (basedir </> ".htaccess") defHtaccess

bakeApp :: Bake ()
bakeApp = do
    use bsTodo >>= \case
        (current:rest) -> do
            bsTodo .= rest
            bakePath current
            bsDone %= Set.insert current
            bakeApp
        _ -> return ()

bakePath :: FilePath -> Bake ()
bakePath fp = do
    done <- use bsDone
    unless (fp `Set.member` done) $
        bakePage CreateIndexHtml [200] fp (dropLeadingSlash fp)

data HtmlMappingMode = MapHtmlDirect | CreateIndexHtml

bake404 :: Bake ()
bake404 = do
    bakePage MapHtmlDirect [404] nonsensicalPath "_errors/404"
    where
        nonsensicalPath = "/123087408972309872109873012984709218371209847123"

dropLeadingSlash :: FilePath -> FilePath
dropLeadingSlash = \case
    '/':x -> x
    x -> x

bakePage :: HtmlMappingMode -> [Int] -> FilePath -> FilePath -> Bake ()
bakePage htmlMode expectedStatuses fp fn = do
    app <- use bsApp
    basedir <- use bsBasedir
    let dstFile = basedir </> fn
        dstDir = takeDirectory dstFile
    let session = do
            let rq = setPath defaultRequest (fromString fp)
            request rq
    rp <- liftIO $ runSession session app
    let status = simpleStatus rp
    liftIO $ printf "GET %s %i %s\n" ("/" </> fp) (statusCode status) (decodeUtf8 $ statusMessage status)
    if statusCode status `elem` expectedStatuses
        then do
            let ty = fromMaybe "application/octet-stream" $ lookup "content-type" (simpleHeaders rp)
                rawTy = BS.takeWhile (/= fromIntegral (ord ';')) ty
                rawTySplit = BS.split (fromIntegral . ord $ '/') rawTy

            liftIO $ printf "%s\n" (decodeUtf8 ty)
            let (linkUrls, dstDir', dstFile') = case rawTySplit of
                    ["text", "html"] ->
                        let body = LBS.toStrict $ simpleBody rp
                            soup = parseTags (decodeUtf8 body)
                            linkUrls = map (fp </>) . map Text.unpack $ extractLinkedUrls soup
                        in case htmlMode of
                                CreateIndexHtml ->
                                    (linkUrls, dstFile, dstFile </> "index.html")
                                MapHtmlDirect ->
                                    (linkUrls, dstDir, replaceExtension dstFile "html")
                    [_, "css"] ->
                        let body = decodeUtf8 . LBS.toStrict $ simpleBody rp
                            tokens = either error id $ CSS.tokenize body
                            linkUrls = map (takeDirectory fp </>) . map Text.unpack $ extractCssUrls tokens
                        in (linkUrls, dstDir, dstFile)
                    _ ->
                        ([], dstDir, dstFile)
            liftIO $ do
                createDirectoryIfMissing True dstDir'
                LBS.writeFile dstFile' (simpleBody rp)
            bsTodo <>= linkUrls
        else do
            liftIO $ putStrLn @String "skip"

extractLinkedUrls :: [Tag Text] -> [Text]
extractLinkedUrls tags = filter isLocalUrl $ do
    tags >>= \case
        TagOpen "a" attrs -> do
            attrs >>= \case
                ("href", url) -> return url
                _ -> []
        TagOpen "link" attrs -> do
            attrs >>= \case
                ("href", url) -> return url
                _ -> []
        TagOpen "script" attrs -> do
            attrs >>= \case
                ("src", url) -> return url
                _ -> []
        TagOpen "img" attrs -> do
            attrs >>= \case
                ("src", url) -> return url
                _ -> []
        _ -> []

isLocalUrl :: Text -> Bool
isLocalUrl url = not
    (  ("//" `Text.isPrefixOf` url)
    || ("http://" `Text.isPrefixOf` url)
    || ("https://" `Text.isPrefixOf` url)
    )

extractCssUrls :: [CSS.Token] -> [Text]
extractCssUrls tokens = filter isLocalUrl $ go tokens
    where
        go (CSS.Url url:xs) = url:go xs
        go (CSS.Function "url":CSS.String _ url:xs) = url:go xs
        go (x:xs) = go xs
        go _ = []