{-|
  Module      : System.Game.H2048.Core
  Copyright   : (c) 2014 Javran Cheng
  License     : MIT
  Maintainer  : Javran.C@gmail.com
  Stability   : experimental
  Portability : POSIX

The core game logic implementation for Game 2048.

The routine for using this library would be:

1. use `initGameBoard` to get a valid board to begin with.
(two new cells are inserted for you, if you want to use an empty board,
`initBoard` is a shorthand)

2. interact with user / algorithm / etc., use `updateBoard` to update a board.

3. use `insertNewCell` to insert a new cell randomly

4. examine if the player wins / loses / is still alive using `gameState`.

-}
module System.Game.H2048.Core
    ( Board
    , Line
    , Dir (..)
    , BoardUpdated (..)
    , GameState (..)
    , gameState
    , compactLine
    , initBoard
    , initGameBoard
    , updateBoard
    , insertNewCell
    , generateNewCell
    )
where

import Control.Arrow
import Control.Monad
import Control.Monad.Writer
import Control.Monad.Random
import Data.List
import Data.Maybe

import System.Game.H2048.Utils

-- | represent a 4x4 board for Game 2048
--   each element should be either zero or 2^i
--   where i >= 1.
type Board = [[Int]]

-- | a list of 4 elements, stands for
--   one column / row in the board
type Line  =  [Int]

-- | result after a successful 'updateBoard'
data BoardUpdated = BoardUpdated
    { brBoard    :: Board  -- ^ new board
    , brScore    :: Int    -- ^ score collected in this update
    } deriving (Eq, Show)

-- | current game state, see also 'gameState'
data GameState = Win
               | Lose
               | Alive
                 deriving (Enum, Eq, Show)

-- | move direction
data Dir = DUp
         | DDown
         | DLeft
         | DRight
         deriving (Enum, Bounded, Eq, Ord, Show)

-- | the initial board before a game started
initBoard :: Board
initBoard = (replicate 4 . replicate 4) 0

-- | move each non-zero element to their leftmost possible
--   position while preserving the order
compactLine :: Line -> Writer (Sum Int) Line
compactLine = runKleisli
                    -- remove zeros
                  ( filter (/=0)
                    -- do merge and collect score
                ^>> Kleisli merge
                    -- restore zeros, on the "fst" part
                >>^ take 4 . (++ repeat 0))

    where
        merge :: [Int] -> Writer (Sum Int) [Int]
        merge (x:y:xs) =
            if x == y
                -- only place where score are collected.
                then do
                    -- try to merge first two elements,
                    -- and process rest of it.
                    xs' <- merge xs
                    tell . Sum $ x + y
                    return $ (x+y) : xs'
                else do
                    -- just skip the first one,
                    -- and process rest of it.
                    xs' <- merge (y:xs)
                    return $ x : xs'
        merge r = return r

-- | update the board taking a direction,
--   a 'BoardUpdated' is returned on success,
--   if this update does nothing, that means a failure (Nothing)
updateBoard :: Dir -> Board -> Maybe BoardUpdated
updateBoard d board = if board /= board'
                          then Just $ BoardUpdated board' (getSum score)
                          else Nothing
    where
        board' :: Board
        -- transform boards so that
        -- we only focus on "gravitize to the left".
        -- and convert back after the gravitization is done.
        (board',score) = runWriter $
                         runKleisli
                               -- transform to a "gravitize to the left" problem
                             ( rTransL
                               -- gravitize to the left
                           ^>> Kleisli (mapM compactLine)
                               -- transform back
                           >>^ rTransR) board

        -- rTrans for "a list of reversible transformations, that will be performed in order"
        rTrans :: [Board -> Board]
        rTrans =
            case d of
              -- the problem itself is "gravitize to the left"
              DLeft  -> []
              -- we use a mirror
              DRight -> [map reverse]
              -- diagonal mirror
              DUp    -> [transpose]
              -- same as DUp case + DRight case
              DDown  -> [transpose, map reverse]

        -- how we convert it "into" and "back"
        rTransL = foldl (flip (.)) id rTrans
        rTransR = foldr       (.)  id rTrans

-- | find blank cells in a board,
--   return coordinates for each blank cell
blankCells :: Board -> [(Int, Int)]
blankCells b = map (\(row, (col, _)) -> (row,col)) blankCells'
    where
        blankCells' = filter ((== 0) . snd . snd) linearBoard
        -- flatten to make it ready for filter
        linearBoard = concat $ zipWith tagRow [0..] colTagged

        -- tag cells with row num
        tagRow row = map ( (,) row )
        -- tag cells with column num
        colTagged = map (zip [0..]) b

-- | return current game state.
--   'Win' if any cell is equal to or greater than 2048
--   or 'Lose' if we can move no further
--   otherwise, 'Alive'.
gameState :: Board -> GameState
gameState b
    | any (>= 2048) . concat $ b
        = Win
    | all (isNothing . ( `updateBoard` b)) universe
        = Lose
    | otherwise
        = Alive

-- | initialize the board by puting two cells randomly
--   into the board.
--   See 'generateNewCell' for the cell generating rule.
initGameBoard :: (MonadRandom r) => r (Board, Int)
initGameBoard =
    -- insert two cells and return the resulting board
    -- here we can safely assume that the board has at least two empty cells
    -- so that we can never have Nothing on the LHS
    liftM ( (\x -> (x,0)) . fromJust) (insertNewCell initBoard >>= (insertNewCell . fromJust))

-- | try to insert a new cell randomly
insertNewCell :: (MonadRandom r) => Board -> r (Maybe Board)
insertNewCell b = do
    -- get a list of coordinates of blank cells
    let availableCells = blankCells b

    if null availableCells
       -- cannot find any empty cell, then fail
       then return Nothing
       else do
           -- randomly pick up an available cell by choosing index
           choice <- getRandomR (0, length availableCells - 1)
           let (row,col) = availableCells !! choice
           value <- generateNewCell
           return $ Just $ (inPos row . inPos col) (const value) b

-- | generate a new cell according to the game rule
--   we have 90% probability of getting a cell of value 2,
--   and 10% probability of getting a cell of value 4.
generateNewCell :: (MonadRandom r) => r Int
generateNewCell = do
    r <- getRandom
    return $ if r < (0.9 :: Float) then 2 else 4