{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeSynonymInstances #-}

{-|
    Module: NBA.Stats
    Copyright: Aaron Taylor, 2016
    License: MIT
    Maintainer: aaron@hamsterdam.co

    Functions and types for interacting with <http://stats.NBA.com NBA Stats>.
-}
module NBA.Stats (
    -- * How to use this library
    -- $use

    -- * Simple API
    getSplitRows,
    getSplitRow,

    -- * Generic API
    getSplitRowsGeneric,
    getSplitRowGeneric,

    -- * Types
    Stats(..),
    Split(..),
    SplitName,
    SplitColumn,
    SplitRow,
    StatsPath,
    StatsParameters,
    StatsError(..),
) where

import qualified Control.Monad.Catch as Catch
import qualified Control.Monad.Except as Except
import qualified Control.Monad.Trans as Trans
import qualified Control.Monad.Http as MonadHttp
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson
import Data.Aeson ((.:), (.=))
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString as SBS
import qualified Data.ByteString.Char8 as Char8
import qualified Data.List as List
import Data.Monoid ((<>))
import qualified Data.Text as Text
import qualified Network.HTTP.Client as HTTP
import qualified Safe

{- |
    Gets all the rows in a NBA Stats split.

    When using this function in a custom monad transformer, it may be desirable to use the generic version of this function, 'getSplitRowsGeneric', instead.
-}
getSplitRows ::
    (Aeson.FromJSON a)
    => StatsPath -- ^ The URL path for the stats web page containing the split.
    -> SplitName -- ^ The split name.
    -> StatsParameters -- ^ The parameters for customizing the stats.
    -> IO (Either StatsError [a]) -- ^ The return value: an IO action resulting in an error or split rows.
getSplitRows path splitName params = Except.runExceptT $ getSplitRowsGeneric path splitName params

{- |
    Gets a row in a NBA Stats split.

    When using this function in a custom monad transformer, it may be desirable to use the generic version of this function, 'getSplitRowGeneric', instead.
-}
getSplitRow ::
    (Eq v, Show v, Aeson.FromJSON v, Aeson.FromJSON a)
    => StatsPath -- ^ The URL path for the stats web page containing the split.
    -> SplitName -- ^ The split name.
    -> SplitColumn -- ^ The column name key for a the desired row.
    -> v -- ^ The expected row value associated with the column name key for a the desired row.
    -> StatsParameters -- ^ The parameters for customizing the stats.
    -> IO (Either StatsError a) -- ^ The return value: an IO action resulting in an error or a split row.
getSplitRow path splitName key value params = Except.runExceptT $ getSplitRowGeneric path splitName key value params

{- |
    Gets all the rows in a NBA Stats split.

    The simpler version of this function, 'getSplitRows', has a concrete 'm'.
-}
getSplitRowsGeneric ::
    (Trans.MonadIO m, MonadHttp.MonadHttp m, Except.MonadError StatsError m, Catch.MonadThrow m, Aeson.FromJSON a)
    => StatsPath -- ^ The URL path for the stats web page containing the split.
    -> SplitName -- ^ The split name.
    -> StatsParameters -- ^ The parameters for customizing the stats.
    -> m [a] -- ^ The return value: an action resulting in an error or split rows.
getSplitRowsGeneric path splitName params = do
    response <- get path params
    split <- findSplit response splitName
    traverse (parseSplitRow $ columns split) $ rows split

{- |
    Gets a row in an NBA Stats split.

    The simpler version of this function, 'getSplitRows', has a concrete 'm'.
-}
getSplitRowGeneric ::
    (Trans.MonadIO m, MonadHttp.MonadHttp m, Except.MonadError StatsError m, Catch.MonadThrow m, Eq v, Show v, Aeson.FromJSON v, Aeson.FromJSON a)
    => StatsPath -- ^ The URL path for the stats web page containing the split.
    -> SplitName -- ^ The split name.
    -> SplitColumn -- ^ The column name key for a the desired row.
    -> v -- ^ The expected row value associated with the column name key for a the desired row.
    -> StatsParameters -- ^ The parameters for customizing the stats.
    -> m a -- ^ The return value: an action resulting in an error or a split row.
getSplitRowGeneric path splitName key value params = do
    response <- get path params
    split <- findSplit response splitName
    keyIndex <- maybe
        (Except.throwError $ SplitColumnNameNotFound $ Text.unpack key)
        return
        (List.elemIndex key (columns split))
    row <- maybe
        (Except.throwError $ SplitKeyNotFound $ show value)
        return
        (List.find
            (\row -> maybe
                False
                (==value)
                (Safe.atMay row keyIndex >>= Aeson.parseMaybe Aeson.parseJSON))
            (rows split))
    parseSplitRow (columns split) row

{- |
    An NBA Stats split.

    This type represents splits available from NBA Stats.
-}
data Split =
    -- | Constructor for a split.
    Split {
        -- | The split's name.
        name :: SplitName,
        -- | The split's column names.
        columns :: [SplitColumn],
        -- | The split's rows of data.
        rows :: [SplitRow]
    }
    deriving (Show, Eq)

instance Aeson.FromJSON Split where
    parseJSON (Aeson.Object v) = do
        name <- v .: "name"
        columns <- v .: "headers"
        rows <- v .: "rowSet"
        return Split {..}
    parseJSON invalid = Aeson.typeMismatch "Split" invalid

instance Aeson.ToJSON Split where
    toJSON Split {..} = Aeson.object [
        "name" .= name,
        "headers" .= columns,
        "rowSet" .= rows]

-- | An NBA Stats split name.
type SplitName = Text.Text

-- | A column name in an NBA Stats split.
type SplitColumn = Text.Text

-- | A row of data in an NBA Stats split.
type SplitRow = [Aeson.Value]

{- |
    An NBA Stats resource.

    This type represents the top-level JSON object returned from the NBA Stats REST API.
-}
data Stats =
    -- | Constructor for stats resource.
    Stats {
        -- | The resource's splits.
        splits :: [Split]
    }
    deriving (Show, Eq)

instance Aeson.ToJSON Stats where
    toJSON Stats {..} = Aeson.object [
        "resultSets" .= splits]

instance Aeson.FromJSON Stats where
    parseJSON (Aeson.Object o) = do
        splits <- o .: "resultSets"
        return Stats {..}
    parseJSON invalid = Aeson.typeMismatch "Stats" invalid

-- | A URL path for an NBA Stats resource.
type StatsPath = SBS.ByteString

-- | A collection of parameters that customize NBA Stats resources.
type StatsParameters = [(SBS.ByteString, Maybe SBS.ByteString)]

{- |
    An error which may be generated by this library.
-}
data StatsError =
    -- | An HTTP response has invalid JSON.
    StatsResponseDecodeFailure String |
    -- | A stats resource does not have a split matching the given split name.
    SplitNameNotFound String |
    -- | A split does not have a row matching the given column key and row value.
    SplitKeyNotFound String |
    -- | A split row has a different cardinality than the associated columns.
    SplitRowCardinalityInconsistent String |
    -- | A split does not have a column name matching the given key.
    SplitColumnNameNotFound String |
    -- | A failure to parse a split row's tabular data to the destination type.
    SplitRowParseFailure String
    deriving (Eq)

instance Show StatsError where
    show statsError = "StatsError (" ++ showCase statsError ++ ")"
        where
            showCase err = case err of
                StatsResponseDecodeFailure message -> format "StatsResponseDecodeFailure" message
                SplitNameNotFound message -> format "SplitNameNotFound" message
                SplitKeyNotFound message -> format "SplitKeyNotFound" message
                SplitRowCardinalityInconsistent message -> format "SplitRowCardinalityInconsistent" message
                SplitColumnNameNotFound message -> format "SplitColumnNameNotFound" message
                SplitRowParseFailure message -> format "SplitRowParseFailure" message
            format :: String -> String -> String
            format name message = name ++ " " ++ message

parseSplitRow :: (Except.MonadError StatsError m, Aeson.FromJSON a) => [SplitColumn] -> SplitRow -> m a
parseSplitRow columns row =
    if length columns == length row
        then case Aeson.parse Aeson.parseJSON $ Aeson.object (zip columns row) of
            Aeson.Error message -> Except.throwError $ SplitRowParseFailure message
            Aeson.Success split -> return split
        else Except.throwError $ SplitRowCardinalityInconsistent $ show row

findSplit :: (Except.MonadError StatsError m) => HTTP.Response LBS.ByteString -> SplitName -> m Split
findSplit response splitName = do
    stats <- either
        (Except.throwError . StatsResponseDecodeFailure)
        return
        (Aeson.eitherDecode . HTTP.responseBody $ response)
    maybe
        (Except.throwError $ SplitNameNotFound $ Text.unpack splitName)
        return
        (List.find (\r -> name r == splitName) $ splits stats)



get :: (MonadHttp.MonadHttp m, Catch.MonadThrow m) => StatsPath -> StatsParameters -> m (HTTP.Response LBS.ByteString)
get path params = do
    request <- HTTP.parseRequest $ Char8.unpack $ "http://stats.nba.com/stats/" <> path
    MonadHttp.performRequest $ HTTP.setQueryString params request

{- $use
    The following is a working example of getting some "advanced statistics", split by month, for the San Antonio Spurs 2015-2016 regular season.

    To learn how to find the NBA Stats values, like 'teamdashboardbygeneralsplits', for this example, read the <https://github.com/hamsterdam/kawhi/blob/master/guide.md guide>.

    @
    import qualified Data.Aeson as Aeson
    import qualified Data.Aeson.Types as Aeson
    import Data.Aeson ((.:))
    import qualified NBA.Stats as Stats

    main :: IO ()
    main = do
        eitherErrorOrStats <- advancedStatsByMonth
        case eitherErrorOrStats of
            Left statsError -> print statsError
            Right stats -> mapM_ print stats

    data AdvancedStats = AdvancedStats {
        month :: String,
        offensiveRating :: Double,
        defensiveRating :: Double
    } deriving (Show, Eq)

    instance Aeson.FromJSON AdvancedStats where
        parseJSON (Aeson.Object o) = do
            month <- o .: \"SEASON_MONTH_NAME\"
            offensiveRating <- o .: \"OFF_RATING\"
            defensiveRating <- o .: \"DEF_RATING\"
            return AdvancedStats {..}
        parseJSON invalid = Aeson.typeMismatch \"AdvancedStats\" invalid

    advancedStatsByMonth :: IO (Either Stats.StatsError [AdvancedStats])
    advancedStatsByMonth = Stats.getSplitRows \"teamdashboardbygeneralsplits\" \"MonthTeamDashboard\"
        [
            (\"Conference\", Nothing),
            (\"DateFrom\", Nothing),
            (\"DateTo\", Nothing),
            (\"Division\", Nothing),
            (\"GameScope\", Nothing),
            (\"GameSegment\", Nothing),
            (\"LastNGames\", Just \"0\"),
            (\"LeagueID\", Just \"00\"),
            (\"Location\", Nothing),
            (\"MeasureType\", Just \"Advanced\"),
            (\"Month\", Just \"0\"),
            (\"OpponentTeamID\", Just \"0\"),
            (\"Outcome\", Nothing),
            (\"PaceAdjust\", Just \"N\"),
            (\"PerMode\", Just \"PerGame\"),
            (\"Period\", Just \"0\"),
            (\"PlayerExperience\", Nothing),
            (\"PlayerPosition\", Nothing),
            (\"PlusMinus\", Just \"N\"),
            (\"PORound\", Just \"0\"),
            (\"Rank\", Just \"N\"),
            (\"Season\", Just \"2015-16\"),
            (\"SeasonSegment\", Nothing),
            (\"SeasonType\", Just \"Regular Season\"),
            (\"ShotClockRange\", Nothing),
            (\"StarterBench\", Nothing),
            (\"TeamID\", Just \"1610612759\"),
            (\"VsConference\", Nothing),
            (\"VsDivision\", Nothing)
        ]
    @

    This program's output at the time of writing is:

    @
    AdvancedStats {month = \"October\", offensiveRating = 102.7, defensiveRating = 93.4}
    AdvancedStats {month = \"November\", offensiveRating = 102.5, defensiveRating = 93.4}
    AdvancedStats {month = \"December\", offensiveRating = 111.8, defensiveRating = 91.5}
    AdvancedStats {month = \"January\", offensiveRating = 114.0, defensiveRating = 100.7}
    AdvancedStats {month = \"February\", offensiveRating = 110.7, defensiveRating = 99.1}
    AdvancedStats {month = \"March\", offensiveRating = 107.8, defensiveRating = 97.2}
    AdvancedStats {month = \"April\", offensiveRating = 102.3, defensiveRating = 103.5}
    @
-}