{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ExtendedDefaultRules #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}

import Control.DeepSeq
import Control.Exception
import Data.Bits (Bits (..))
import Data.Char (ord)
import qualified Data.Text as Text
import PyF
import System.Exit
import System.FilePath
import System.IO.Temp
import System.Process (readProcessWithExitCode)
import Test.HUnit.Lang
import Test.Hspec

-- * Check compilation with external GHC (this is usefull to test compilation failure)

data CompilationStatus
  = -- | Fails during compilation (with error)
    CompileError String
  | RuntimeError String
  | Ok String
  deriving (Show, Eq)

makeTemplate :: String -> String
makeTemplate s =
  [fmt|{{-# LANGUAGE QuasiQuotes, ExtendedDefaultRules, TypeApplications #-}}
import PyF
truncate' = truncate @Float @Int
hello = "hello"
number = 3.14 :: Float
main :: IO ()
main = putStrLn [fmt|{s}|]
    <> "|]\n"

-- | Compile a formatting string
--
-- >>> checkCompile fileContent
-- CompileError "Bla bla bla, Floating cannot be formatted as hexa (`x`)
checkCompile :: (HasCallStack) => String -> IO CompilationStatus
checkCompile content = withSystemTempDirectory "PyF" $ \dirPath -> do
  let path = dirPath </> "PyFTest.hs"
  writeFile path content
  (ecode, _stdout, stderr) <-
    readProcessWithExitCode
      "ghc"
      [ path,
        -- Include all PyF files
        "-isrc",
        -- Disable the usage of the annoying .ghc environment file
        "-package-env",
        "-",
        -- Move the ".o" in the temporary dir
        "-odir",
        dirPath,
        "-hidir",
        dirPath,
        -- Tests use a filename in a temporary directory which may have a long filename which triggers
        -- line wrapping, reducing the reproducibility of error message
        -- By setting the column size to a high value, we ensure reproducible error messages
        "-dppr-cols=10000000000000",
        -- Clean package environment
        "-hide-all-packages",
        "-package base",
        "-package bytestring",
        "-package parsec",
        "-package text",
        "-package template-haskell",
        "-package ghc-boot",
        "-package mtl",
        "-package ghc",
        "-package time",
        "-package containers"
      ]
      ""
  case ecode of
    ExitFailure _ -> pure (CompileError (sanitize path stderr))
    ExitSuccess -> do
      (ecode', stdout', stderr') <- readProcessWithExitCode (take (length path - 3) path) [] ""
      case ecode' of
        ExitFailure _ -> pure (RuntimeError stderr')
        ExitSuccess -> pure (Ok stdout')

-- sanitize a compilation result by removing variables strings such as
-- temporary files name
sanitize :: FilePath -> String -> String
sanitize path =
  Text.unpack
    -- Strip the filename
    . Text.replace (Text.pack path) (Text.pack "INITIALPATH")
    -- GHC 9.0 replaces [Char] by String everywhere
    . Text.replace (Text.pack "[Char]") (Text.pack "String")
    . Text.pack

{- ORMOLU_DISABLE -}
golden :: HasCallStack => String -> String -> IO ()
golden name output = do
  let
#if __GLASGOW_HASKELL__ >= 906
      goldenFile = "test/golden96" </> (name <> ".golden")
#else
      goldenFile = "test/golden" </> (name <> ".golden")
#endif
      actualFile = "test/golden" </> (name <> ".actual")
  -- It can fail if the golden file does not exists
  goldenContentE :: Either SomeException String <- try $ readFile goldenFile
  let -- if no golden file, the golden file is the content
      goldenContent = case goldenContentE of
        Right e -> e
        Left _ -> output
  -- Flush lazy IO
  _ <- evaluate (force goldenContent)
  -- Apparently the whitespaces in GHC 9.10 changed
  -- By stripping, we just keep the test working as expected, but we are
  -- compatible with different version of GHC.
  --
  -- TODO: use GHC API to build directly the example and gather errors in a
  -- more reproducible way.
  if Text.strip (Text.pack output) /= Text.strip (Text.pack goldenContent)
    then do
      writeFile actualFile output
      (_, diffOutput, _) <- readProcessWithExitCode "diff" ["-b", goldenFile, actualFile] ""
      putStrLn diffOutput
      -- Update golden file
      writeFile goldenFile (Text.unpack $ Text.strip (Text.pack output))
      assertFailure diffOutput
    else writeFile goldenFile (Text.unpack $ Text.strip (Text.pack output))
{- ORMOLU_ENABLE -}

failCompile :: (HasCallStack) => String -> Spec
failCompile s = failCompileContent s s (makeTemplate s)

failCompileContent :: (HasCallStack) => String -> String -> String -> Spec
failCompileContent h caption fileContent =
  before (checkCompile fileContent) $ do
    let goldenName = concatMap cleanSpecialChars h
        -- Add an unique identifier, so golden files won't conflict on case
        -- insensitive systems
        -- See: bug #97.
        goldenPath = goldenName ++ "." ++ show (stableHash goldenName)
    it (show caption) $ \res -> case res of
      CompileError output -> golden goldenPath output
      _ -> assertFailure (show $ ".golden/" <> goldenPath <> "\n" <> show res)

-- | A stable hash from string, based on
-- https://en.wikipedia.org/wiki/Fowler%E2%80%93Noll%E2%80%93Vo_hash_function#FNV-1_hash
stableHash :: String -> Word
stableHash [] = 14695981039346656037
stableHash (x : xs) = fromIntegral (ord x) * stableHash xs `xor` 1099511628211

-- Remove chars which are not accepted in a path name
-- The encoding is rather approximative, I'm trying to avoid too long names.
cleanSpecialChars :: Char -> [Char]
cleanSpecialChars '/' = "SL"
cleanSpecialChars '\\' = "BS"
cleanSpecialChars ':' = "CL"
cleanSpecialChars '\n' = "NL"
cleanSpecialChars e = pure e

main :: IO ()
main = hspec $ parallel spec

spec :: Spec
spec =
  describe "error reporting" $ do
    describe "string" $ do
      describe "integral / fractional qualifiers" $ do
        failCompile "{hello:f}"
        failCompile "{hello:d}"
        failCompile "{hello:e}"
        failCompile "{hello:b}"
        failCompile "{hello:E}"
        failCompile "{hello:G}"
        failCompile "{hello:g}"
        failCompile "{hello:%}"
        failCompile "{hello:x}"
        failCompile "{hello:X}"
        failCompile "{hello:o}"
      describe "padding center" $ do
        failCompile "{hello:=100s}"
        failCompile "{hello:=100}"
      describe "grouping" $ do
        failCompile "{hello:_s}"
        failCompile "{hello:,s}"
      describe "sign" $ do
        failCompile "{hello:+s}"
        failCompile "{hello: s}"
        failCompile "{hello:-s}"
    describe "number with precision" $ do
      failCompile "{truncate number:.3d}"
      failCompile "{truncate number:.3o}"
      failCompile "{truncate number:.3b}"
      failCompile "{truncate number:.3x}"
    describe "floats" $ do
      failCompile "{number:o}"
      failCompile "{number:b}"
      failCompile "{number:x}"
      failCompile "{number:X}"
      failCompile "{number:d}"
    -- XXX: this are not failing for now, it should be fixed
    xdescribe "not specified" $ do
      failCompile "{truncate number:.3}"
      failCompile "{hello:#}"
      failCompile "{hello:+}"
      failCompile "{hello: }"
      failCompile "{hello:-}"
      failCompile "{hello:_}"
      failCompile "{hello:,}"
    describe "multiples lines" $
      failCompile "hello\n\n\n{pi:l}"
    describe "on haskell expression parsing" $ do
      describe "single line" $
        failCompile "{1 + - / lalalal}"
      describe "empty expression" $
        failCompile "{}"
      describe "sub expression" $ do
        describe "simple failure" $
          failCompile "{pi:.{/}}"
        describe "empty failure" $
          failCompile "{pi:.{}}"
      describe "multiples lines" $
        failCompile "hello\n    {\nlet a = 5\n    b = 10\nin 1 + - / lalalal}"
    describe "non-doubled delimiters" $ do
      failCompile "hello } world"
      failCompile "hello { world"
    describe "lexical errors" $ do
      describe "single line" $
        failCompile "foo\\Pbar"
      describe "multiple line" $
        failCompile "foo\nbli\\Pbar"
    describe "Wrong type" $ do
      failCompile "{True}"
      failCompile "{True:f}"
      failCompile "{True:d}"
    describe "Missing variables" $ do
      failCompile "Hello {name}"
      failCompile "Hello {length name}"
      failCompile "Hello {pi:.{precision}}"
      failCompile "Hello {pi:.{truncate number + precision}}"
      failCompile "Hello {pi:.{precision}}"
      failCompile "Hello {pi:{width}}"
