-- This is the default, but we have to instruct the formatter used on the repo:
{-# LANGUAGE NoImportQualifiedPost #-}

-- |
--
--  Example:
--
--  @
--      main :: IO ()
--      main =
--        defaultMainWithHooks $
--          simpleUserHooks
--            & addHook
--              (mkSettings "nix-store")
--                { macroName = "NIX",
--                  flagPrefixName = "nix"
--                }
--  @
--
--  The above will look for a pkg-config package @nix-store@, and then
--
--    * Define CPP, C and C++ macros
--
--        * @NIX_MAJOR@, an integer
--        * @NIX_MINOR@, an integer
--        * @NIX_PATCH@, an integer; 0 if missing
--        * @NIX_IS_AT_LEAST(major,minor,patch)@, returning true when the discovered version @>=@ the specified version
--
--    * Set or unset flags like `nix-2_4` so that the flag is true when the
--      discovered version is at least the version in the flag's name.
module Distribution.PkgConfigVersionHook
  ( addHook,
    mkSettings,
    Settings (..),
    composeConfHook,
  )
where

import Control.Lens ((%~), (^.))
import Control.Monad (when)
import qualified Data.Char as C
import Data.Foldable (toList)
import Data.Function ((&))
import qualified Data.List as L
import Distribution.Simple (UserHooks (confHook))
import Distribution.Simple.Setup (ConfigFlags, configConfigurationsFlags)
import Distribution.Types.BuildInfo.Lens (ccOptions, cppOptions, cxxOptions)
import Distribution.Types.Flag (flagName, mkFlagAssignment, mkFlagName, unFlagName)
import Distribution.Types.GenericPackageDescription.Lens
  ( GenericPackageDescription,
    condBenchmarks,
    condExecutables,
    condForeignLibs,
    condLibrary,
    condSubLibraries,
    condTestSuites,
    genPackageFlags,
  )
import System.IO (hPutStrLn, stderr)
import System.Process (readProcess)
import qualified Text.ParserCombinators.ReadP as P
import Prelude hiding (log)

-- | Hook into Cabal to provide pkg-config metadata. Can be applied multiple
-- times to support multiple packages.
addHook :: Settings -> UserHooks -> UserHooks
addHook settings hooks = hooks {confHook = composeConfHook settings (confHook hooks)}

-- | How the metadata for a pkg-config package should be made available to the
-- cabal file.
data Settings = Settings
  { -- | Name of the package; used for querying pkg-config.
    pkgConfigName :: String,
    -- | Name to use in the Haskell CPP and C/C++ preprocessor macros.
    --
    -- For example, `pkgConfigName = "FOO"` will set the macros
    --
    --  * @FOO_MAJOR@
    --
    --  * @FOO_MINOR@
    --
    --  * @FOO_PATCH@
    --
    --  * @FOO_IS_AT_LEAST(major, minor, patch)@
    macroName :: String,
    -- | Name to use when setting flag values in the cabal file.
    --
    -- Flags named with this prefix, followed by a dash, followed by a major version number, an underscore and a minor version number will be set when the detected package is at least that version.
    flagPrefixName :: String
  }

-- | Derive a default 'Settings' value from just a pkg-config package name.
mkSettings :: String -> Settings
mkSettings name =
  Settings
    { pkgConfigName = name,
      macroName = map (\c -> case c of '-' -> '_'; x -> x) name,
      flagPrefixName = name
    }

-- | Extend the value of 'confHook'. It's what powers 'addHook'.
composeConfHook ::
  Settings ->
  ((GenericPackageDescription, a) -> ConfigFlags -> IO b) ->
  (GenericPackageDescription, a) ->
  Distribution.Simple.Setup.ConfigFlags ->
  IO b
composeConfHook settings origHook = \(genericPackageDescription, hookedBuildInfo) confFlags -> do
  (actualMajor, actualMinor, actualPatch) <- getPkgConfigPackageVersion (pkgConfigName settings)

  let defines =
        [ "-D" <> macroName settings <> "_MAJOR=" <> show actualMajor,
          "-D" <> macroName settings <> "_MINOR=" <> show actualMinor,
          "-D" <> macroName settings <> "_PATCH=" <> show actualPatch,
          "-D" <> macroName settings <> "_IS_AT_LEAST(a,b,c)=(" <> show actualMajor <> ">a||(" <> show actualMajor <> "==a&&(" <> show actualMinor <> ">b||(" <> show actualMinor <> "==b&&" <> show actualPatch <> ">=c))))"
        ]
      extraFlags =
        [ (mkFlagName (flagPrefixName settings ++ "-" ++ show major ++ "_" ++ show minor), (actualMajor, actualMinor) >= (major, minor))
          | declaredFlag <- genericPackageDescription ^. genPackageFlags,
            let rawName = unFlagName $ flagName declaredFlag,
            rawVersion <- L.stripPrefix (flagPrefixName settings ++ "-") rawName & toList,
            [major, minor] <- unambiguously parseFlagVersion rawVersion & toList
        ]
      setDefines comp x =
        x
          & comp . cppOptions %~ (<> defines)
          & comp . ccOptions %~ (<> defines)
          & comp . cxxOptions %~ (<> defines)
      genericPackageDescription' =
        genericPackageDescription
          & setDefines (condLibrary . traverse . traverse)
          & setDefines (condSubLibraries . traverse . traverse . traverse)
          & setDefines (condForeignLibs . traverse . traverse . traverse)
          & setDefines (condExecutables . traverse . traverse . traverse)
          & setDefines (condTestSuites . traverse . traverse . traverse)
          & setDefines (condBenchmarks . traverse . traverse . traverse)

      configConfigurationsFlags' = configConfigurationsFlags confFlags `mappend` mkFlagAssignment extraFlags
      confFlags' =
        confFlags
          { configConfigurationsFlags = configConfigurationsFlags'
          }
  origHook (genericPackageDescription', hookedBuildInfo) confFlags'

parseVersion :: P.ReadP [Int]
parseVersion = do
  map read <$> do
    P.many1 (P.satisfy C.isDigit) `P.sepBy` P.char '.'

parseFlagVersion :: P.ReadP [Int]
parseFlagVersion =
  map read <$> do
    P.many1 (P.satisfy C.isDigit) `P.sepBy` P.char '_'

unambiguously :: P.ReadP a -> String -> Maybe a
unambiguously p s =
  case filter (\(_a, x) -> x == "") $ P.readP_to_S p s of
    [(v, _)] -> Just v
    _ -> Nothing

getPkgConfigPackageVersion :: String -> IO (Int, Int, Int)
getPkgConfigPackageVersion pkgName = do
  s <- readProcess "pkg-config" ["--modversion", pkgName] ""
  case L.sortOn (\(_, remainder) -> length remainder) $ P.readP_to_S parseVersion s of
    [] -> error ("Could not parse version " ++ show s ++ " returned by pkg-config for package " ++ pkgName)
    (v, r) : _ -> do
      when (L.dropWhile C.isSpace r /= "") $ do
        log ("ignoring trailing text " ++ show r ++ " in version " ++ show s ++ " of pkg-config package " ++ pkgName)
      let v' = v ++ L.repeat 0
      pure (v' L.!! 0, v' L.!! 1, v' L.!! 2)

-- Should probably use a Cabal function?
log :: String -> IO ()
log = hPutStrLn stderr
