{-# LANGUAGE DeriveGeneric     #-}

module SwissEphemeris (
    Planet(..)
,   HouseSystem(..)
,   JulianTime
,   Coordinates(..)
,   HouseCusps(..)
,   Angles(..)
,   CuspsCalculation(..)
-- constructors
,   defaultCoordinates
,   mkCoordinates
,   julianDay
-- management of data files
,   setEphemeridesPath
,   setNoEphemeridesPath
,   closeEphemerides
,   withEphemerides
,   withoutEphemerides
-- core calculations
,   calculateCoordinates
,   calculateCusps
,   calculateCuspsLenient
,   calculateCuspsStrict
)where

import           Foreign.SwissEphemeris

import           Foreign
import           GHC.Generics
import           Foreign.C.Types
import           Foreign.C.String
import           Data.Char                      ( ord )
import Control.Exception (bracket_)

data Planet = Sun
            | Moon
            | Mercury
            | Venus
            | Mars
            | Jupiter
            | Saturn
            | Uranus
            | Neptune
            | Pluto
            | MeanNode
            | TrueNode
            | MeanApog
            | OscuApog
            | Earth
            | Chiron
            deriving (Show, Eq, Ord, Enum, Generic)

data HouseSystem = Placidus
                 | Koch
                 | Porphyrius
                 | Regiomontanus
                 | Campanus
                 | Equal
                 | WholeSign
                 deriving (Show, Eq, Ord, Enum, Generic)

type JulianTime = Double

data Coordinates = Coordinates
  {
    lng :: Double
  , lat :: Double
  , distance :: Double
  , lngSpeed :: Double
  , latSpeed :: Double
  , distSpeed :: Double
  } deriving (Show, Eq, Ord, Generic)

-- | Default coordinates with all zeros -- when you don't care about/know the velocities,
-- which would be the case for most inputs (though most outputs _will_ include them.)
-- Usually you'll set only lat and lng (e.g. `defaultCoordinates{lat = 1.4, lng = 4.1}`)
-- when using it as an input for another function.
defaultCoordinates :: Coordinates
defaultCoordinates = Coordinates 0 0 0 0 0 0

-- | Constructor alias of `defaultCoordinates`, since it's used a lot in that role.
mkCoordinates :: Coordinates
mkCoordinates = defaultCoordinates

data HouseCusps = HouseCusps
  {
      i :: Double
    , ii :: Double
    , iii :: Double
    , iv :: Double
    , v :: Double
    , vi :: Double
    , vii :: Double
    , viii :: Double
    , ix :: Double
    , x :: Double
    , xi :: Double
    , xii :: Double
  } deriving (Show, Eq, Generic)

data Angles = Angles
  {
    ascendant :: Double
  , mc :: Double
  , armc :: Double
  , vertex :: Double
  , equatorialAscendant :: Double
  , coAscendantKoch :: Double
  , coAscendantMunkasey :: Double
  , polarAscendant :: Double
  } deriving (Show, Eq, Generic)

data CuspsCalculation = CuspsCalculation
  {
    houseCusps :: HouseCusps
  , angles :: Angles
  -- the underlying library may switch to Porphyrius
  -- if it's unable to determine a cusp.
  , systemUsed :: HouseSystem
  } deriving (Show, Eq, Generic)

-- in the C lib, house systems are expected as ASCII
-- codes for specific characters (!)
-- documentation at: https://www.astro.com/swisseph/swephprg.htm#_Toc19111265
toHouseSystemFlag :: HouseSystem -> Int
toHouseSystemFlag Placidus      = ord 'P'
toHouseSystemFlag Koch          = ord 'K'
toHouseSystemFlag Porphyrius    = ord 'O'
toHouseSystemFlag Regiomontanus = ord 'R'
toHouseSystemFlag Campanus      = ord 'C'
toHouseSystemFlag Equal         = ord 'A'
toHouseSystemFlag WholeSign     = ord 'W'


-- TODO: these fromList fns could be captured in a typeclass...
fromList :: [Double] -> Coordinates
-- N.B. note that for some reason the SWE guys really like lng,lat coordinates
-- though only for this one function: https://www.astro.com/swisseph/swephprg.htm#_Toc19111235
fromList (sLng : sLat : c : d : e : f : _) = Coordinates sLng sLat c d e f
fromList _                           = error "Invalid coordinate array"

fromCuspsList :: [Double] -> HouseCusps
fromCuspsList (_ : _i : _ii : _iii : _iv : _v : _vi : _vii : _viii : _ix : _x : _xi : _xii : _)
    = HouseCusps _i _ii _iii _iv _v _vi _vii _viii _ix _x _xi _xii
fromCuspsList _ = error "Invalid cusps list"

fromAnglesList :: [Double] -> Angles
fromAnglesList (a : _mc : _armc : vtx : ea : cak : cam : pa : _ : _) =
    Angles a _mc _armc vtx ea cak cam pa
fromAnglesList _ = error "Invalid angles list"

planetNumber :: Planet -> PlanetNumber
planetNumber p = PlanetNumber $ CInt y
  where
    y = fromIntegral $ fromEnum p :: Int32

-- | Given a path to a directory, point the underlying ephemerides library to it.
-- You only need to call this function to provide an explicit ephemerides path,
-- if the environment variable SE_EPHE_PATH is set, it overrides this function.
setEphemeridesPath :: FilePath -> IO ()
setEphemeridesPath path =
    withCString path $ \ephePath -> c_swe_set_ephe_path ephePath

-- | Explicitly state that we don't want to set an ephemeris path,
-- which will default to the built-in ephemeris, or use the directory
-- in the SE_EPHE_PATH environment variable, if set.
setNoEphemeridesPath :: IO ()
setNoEphemeridesPath = c_swe_set_ephe_path nullPtr

-- | Explicitly release all "cache" pointers and open files obtained by the C
-- library.
closeEphemerides :: IO ()
closeEphemerides = c_swe_close

-- | Run a computation with a given ephemerides path open, and then close it. 
-- Note that the computation does _not_ receive the ephemerides, 
-- in keeping with the underlying library's side-effectful conventions.
withEphemerides :: FilePath -> (IO a) -> IO a
withEphemerides ephemeridesPath =
  bracket_ (setEphemeridesPath ephemeridesPath)
           (closeEphemerides)


-- | Run a computation with no explicit ephemerides set, if the SE_EPHE_PATH
-- environment variable is set, that will be used. If not, it'll fall back to
-- in-memory data.
withoutEphemerides :: (IO a) -> IO a
withoutEphemerides =
  bracket_ (setNoEphemeridesPath)
           (closeEphemerides)

-- | Given year, month and day as @Int@ and a time as @Double@, return
-- a single floating point number representing absolute Julian Time.
-- The input date is assumed to be in Gregorian time.
-- More info on this:
-- https://www.astro.com/swisseph/swephprg.htm#_Toc46406824
julianDay :: Int -> Int -> Int -> Double -> JulianTime
julianDay year month day hour = realToFrac $ c_swe_julday y m d h gregorian
  where
    y = fromIntegral year
    m = fromIntegral month
    d = fromIntegral day
    h = realToFrac hour

-- | Given a decimal representation of Julian Time (see @julianDay@),
-- and a @Planet@, returns either the position of that planet at the given time,
-- if available in the ephemeris, or an error.
-- This function is in IO because it _may_ allocate memory/read data beyond
-- its scope, when using ephemeris data. 
-- Call it with `withEphemerides` or `withoutEphemerides`.
-- Failing to call `closeEphemerides` at some point after calling this function
-- will likely result in a segmentation fault down the line!!
calculateCoordinates :: JulianTime -> Planet -> IO (Either String Coordinates)
calculateCoordinates time planet =
    allocaArray 6 $ \coords -> alloca $ \errorP -> do
        iflgret <- c_swe_calc (realToFrac time)
                              (planetNumber planet)
                              speed
                              coords
                              errorP

        if unCalcFlag iflgret < 0
            then do
                msg <- if errorP == nullPtr then
                          pure $ "Unable to calculate position; NULL error from swiss ephemeris."
                        else
                          peekCAString errorP

                return $ Left msg
            else do
                result <- peekArray 6 coords
                return $ Right $ fromList $ map realToFrac result

-- | Alias for `calculateCuspsLenient`
calculateCusps :: JulianTime -> Coordinates -> HouseSystem -> IO CuspsCalculation
calculateCusps = calculateCuspsLenient

-- | Given a decimal representation of Julian Time (see `julianDay`),
-- a set of `Coordinates` (see `mkCoordinates`,) and a `HouseSystem`
-- (most applications use `Placidus`,) return a `CuspsCalculation` with all 12
-- house cusps in that system, and other relevant `Angles`. Notice that certain systems,
-- like `Placidus` and `Koch`, are very likely to fail close to the polar circles; in this
-- and other edge cases, the calculation returns cusps in the `Porphyrius` system.
-- This function is in IO because it _may_ allocate memory/read data beyond
-- its scope, when using ephemeris data. 
-- Call it with `withEphemerides` or `withoutEphemerides`.
-- Failing to call `closeEphemerides` at some point after calling this function
-- will likely result in a segmentation fault!!
calculateCuspsLenient :: JulianTime -> Coordinates -> HouseSystem -> IO CuspsCalculation
calculateCuspsLenient time loc sys = allocaArray 13 $ \cusps ->
    allocaArray 10 $ \ascmc -> do
        rval <- c_swe_houses (realToFrac time)
                             (realToFrac $ lat loc)
                             (realToFrac $ lng loc)
                             (fromIntegral $ toHouseSystemFlag sys)
                             cusps
                             ascmc
        cuspsL  <- peekArray 13 cusps
        anglesL <- peekArray 10 ascmc
        return $ CuspsCalculation
                  (fromCuspsList $ map realToFrac $ cuspsL)
                  (fromAnglesList $ map realToFrac $ anglesL)
                  (if rval < 0 then Porphyrius else sys)

-- | Unlike `calculateCuspsLenient`, return a `Left` value if the required house system
-- couldn't be used to perform the calculations.
calculateCuspsStrict :: JulianTime -> Coordinates -> HouseSystem -> IO (Either String CuspsCalculation)
calculateCuspsStrict time loc sys = do
  calcs@(CuspsCalculation _ _ sys') <- calculateCuspsLenient time loc sys
  if sys' /= sys then
    pure $ Left $ "Unable to calculate cusps in the requested house system (used " ++ (show sys') ++ "instead.)"
  else
    pure $ Right calcs