-- | Inhabited dungeon levels and the operations to query and change them
-- as the game progresses.
module Game.LambdaHack.Common.Level
  ( -- * Dungeon
    LevelId, AbsDepth, Dungeon, ascendInBranch
    -- * The @Level@ type and its components
  , Level(..), ActorPrio, ItemFloor, TileMap, SmellMap
    -- * Level query
  , at, checkAccess, checkDoorAccess
  , accessible, accessibleUnknown, accessibleDir
  , knownLsecret, isSecretPos, hideTile
  , findPos, findPosTry, mapLevelActors_, mapDungeonActors_
 ) where

import Control.Exception.Assert.Sugar
import Data.Binary
import qualified Data.Bits as Bits
import qualified Data.EnumMap.Strict as EM
import Data.Maybe
import Data.Text (Text)

import Game.LambdaHack.Common.Actor
import Game.LambdaHack.Common.Item
import qualified Game.LambdaHack.Common.Kind as Kind
import Game.LambdaHack.Common.Misc
import Game.LambdaHack.Common.Point
import qualified Game.LambdaHack.Common.PointArray as PointArray
import Game.LambdaHack.Common.Random
import Game.LambdaHack.Common.Tile
import qualified Game.LambdaHack.Common.Tile as Tile
import Game.LambdaHack.Common.Time
import Game.LambdaHack.Common.Vector
import Game.LambdaHack.Content.ItemKind (ItemKind)
import Game.LambdaHack.Content.RuleKind
import Game.LambdaHack.Content.TileKind (TileKind)

-- | The complete dungeon is a map from level names to levels.
type Dungeon = EM.EnumMap LevelId Level

-- | Levels in the current branch, @k@ levels shallower than the current.
ascendInBranch :: Dungeon -> Int -> LevelId -> [LevelId]
ascendInBranch dungeon k lid =
  -- Currently there is just one branch, so the computation is simple.
  let (minD, maxD) =
        case (EM.minViewWithKey dungeon, EM.maxViewWithKey dungeon) of
          (Just ((s, _), _), Just ((e, _), _)) -> (s, e)
          _ -> assert `failure` "null dungeon" `twith` dungeon
      ln = max minD $ min maxD $ toEnum $ fromEnum lid + k
  in case EM.lookup ln dungeon of
    Just _ | ln /= lid -> [ln]
    _ | ln == lid -> []
    _ -> ascendInBranch dungeon k ln  -- jump over gaps

-- | Actor time priority queue.
type ActorPrio = EM.EnumMap Time [ActorId]

-- | Items located on map tiles.
type ItemFloor = EM.EnumMap Point ItemBag

-- | Tile kinds on the map.
type TileMap = PointArray.Array (Kind.Id TileKind)

-- | Current smell on map tiles.
type SmellMap = EM.EnumMap Point SmellTime

-- | A view on single, inhabited dungeon level. "Remembered" fields
-- carry a subset of the info in the client copies of levels.
data Level = Level
  { ldepth      :: !AbsDepth   -- ^ absolute depth of the level
  , lprio       :: !ActorPrio  -- ^ remembered actor times on the level
  , lfloor      :: !ItemFloor  -- ^ remembered items lying on the floor
  , lembed      :: !ItemFloor  -- ^ items embedded in the tile
  , ltile       :: !TileMap    -- ^ remembered level map
  , lxsize      :: !X          -- ^ width of the level
  , lysize      :: !Y          -- ^ height of the level
  , lsmell      :: !SmellMap   -- ^ remembered smells on the level
  , ldesc       :: !Text       -- ^ level description
  , lstair      :: !([Point], [Point])
                               -- ^ positions of (up, down) stairs
  , lseen       :: !Int        -- ^ currently remembered clear tiles
  , lclear      :: !Int        -- ^ total number of initially clear tiles
  , ltime       :: !Time       -- ^ date of the last activity on the level
  , lactorCoeff :: !Int        -- ^ the lower, the more monsters spawn
  , lactorFreq  :: !(Freqs ItemKind)  -- ^ frequency of spawned actors; [] for clients
  , litemNum    :: !Int        -- ^ number of initial items, 0 for clients
  , litemFreq   :: !(Freqs ItemKind)  -- ^ frequency of initial items; [] for clients
  , lsecret     :: !Int        -- ^ secret tile seed
  , lhidden     :: !Int        -- ^ secret tile density
  , lescape     :: ![Point]    -- ^ positions of IK.Escape tiles
  }
  deriving (Show, Eq)

assertSparseItems :: ItemFloor -> ItemFloor
assertSparseItems m =
  assert (EM.null (EM.filter EM.null m)
          `blame` "null floors found" `twith` m) m

-- | Query for tile kinds on the map.
at :: Level -> Point -> Kind.Id TileKind
{-# INLINE at #-}
at Level{ltile} p = ltile PointArray.! p

checkAccess :: Kind.COps -> Level -> Maybe (Point -> Point -> Bool)
checkAccess Kind.COps{corule} _ =
  case raccessible $ Kind.stdRuleset corule of
    Nothing -> Nothing
    Just ch -> Just $ \spos tpos -> ch spos tpos

checkDoorAccess :: Kind.COps -> Level -> Maybe (Point -> Point -> Bool)
checkDoorAccess Kind.COps{corule, cotile} lvl =
  case raccessibleDoor $ Kind.stdRuleset corule of
    Nothing -> Nothing
    Just chDoor ->
      Just $ \spos tpos ->
        let st = lvl `at` spos
            tt = lvl `at` tpos
        in not (Tile.isDoor cotile st || Tile.isDoor cotile tt)
           || chDoor spos tpos

-- | Check whether one position is accessible from another,
-- using the formula from the standard ruleset.
-- Precondition: the two positions are next to each other.
accessible :: Kind.COps -> Level -> Point -> Point -> Bool
accessible cops@Kind.COps{cotile} lvl =
  let checkWalkability =
        Just $ \_ tpos -> Tile.isWalkable cotile $ lvl `at` tpos
      conditions = catMaybes [ checkWalkability
                             , checkAccess cops lvl
                             , checkDoorAccess cops lvl ]
  in \spos tpos -> all (\f -> f spos tpos) conditions

-- | Check whether one position is accessible from another,
-- using the formula from the standard ruleset,
-- but additionally treating unknown tiles as walkable.
-- Precondition: the two positions are next to each other.
accessibleUnknown :: Kind.COps -> Level -> Point -> Point -> Bool
accessibleUnknown cops@Kind.COps{cotile=cotile@Kind.Ops{ouniqGroup}} lvl =
  let unknownId = ouniqGroup "unknown space"
      checkWalkability =
        Just $ \_ tpos -> let t = lvl `at` tpos
                          in Tile.isWalkable cotile t || t == unknownId
      conditions = catMaybes [ checkWalkability
                             , checkAccess cops lvl
                             , checkDoorAccess cops lvl ]
  in \spos tpos -> all (\f -> f spos tpos) conditions

-- | Check whether actors can move from a position along a unit vector,
-- using the formula from the standard ruleset.
accessibleDir :: Kind.COps -> Level -> Point -> Vector -> Bool
accessibleDir cops lvl spos dir = accessible cops lvl spos $ spos `shift` dir

knownLsecret :: Level -> Bool
knownLsecret lvl = lsecret lvl /= 0

isSecretPos :: Level -> Point -> Bool
isSecretPos lvl (Point x y) =
  not (lhidden lvl == 0)
  && (lsecret lvl `Bits.rotateR` x `Bits.xor` y + x) `mod` lhidden lvl == 0

hideTile :: Kind.COps -> Level -> Point -> Kind.Id TileKind
hideTile Kind.COps{cotile} lvl p =
  let t = lvl `at` p
      ht = Tile.hideAs cotile t  -- TODO; tabulate with Speedup?
  in if isSecretPos lvl p then ht else t

-- | Find a random position on the map satisfying a predicate.
findPos :: TileMap -> (Point -> Kind.Id TileKind -> Bool) -> Rnd Point
findPos ltile p =
  let (x, y) = PointArray.sizeA ltile
      search = do
        px <- randomR (0, x - 1)
        py <- randomR (0, y - 1)
        let pos = Point{..}
            tile = ltile PointArray.! pos
        if p pos tile
          then return $! pos
          else search
  in search

-- | Try to find a random position on the map satisfying
-- the conjunction of the list of predicates.
-- If the permitted number of attempts is not enough,
-- try again the same number of times without the first predicate,
-- then without the first two, etc., until only one predicate remains,
-- at which point try as many times, as needed.
findPosTry :: Int                                  -- ^ the number of tries
           -> TileMap                              -- ^ look up in this map
           -> (Point -> Kind.Id TileKind -> Bool)  -- ^ mandatory predicate
           -> [Point -> Kind.Id TileKind -> Bool]  -- ^ optional predicates
           -> Rnd Point
findPosTry _        ltile m []         = findPos ltile m
findPosTry numTries ltile m l@(_ : tl) = assert (numTries > 0) $
  let (x, y) = PointArray.sizeA ltile
      search 0 = findPosTry numTries ltile m tl
      search k = do
        px <- randomR (0, x - 1)
        py <- randomR (0, y - 1)
        let pos = Point{..}
            tile = ltile PointArray.! pos
        if m pos tile && all (\p -> p pos tile) l
          then return $! pos
          else search (k - 1)
  in search numTries

mapLevelActors_ :: Monad m => (ActorId -> m a) -> Level -> m ()
mapLevelActors_ f Level{lprio} = do
  let as = concat $ EM.elems lprio
  mapM_ f as

mapDungeonActors_ :: Monad m => (ActorId -> m a) -> Dungeon -> m ()
mapDungeonActors_ f dungeon = do
  let ls = EM.elems dungeon
  mapM_ (mapLevelActors_ f) ls

instance Binary Level where
  put Level{..} = do
    put ldepth
    put lprio
    put (assertSparseItems lfloor)
    put (assertSparseItems lembed)
    put ltile
    put lxsize
    put lysize
    put lsmell
    put ldesc
    put lstair
    put lseen
    put lclear
    put ltime
    put lactorCoeff
    put lactorFreq
    put litemNum
    put litemFreq
    put lsecret
    put lhidden
    put lescape
  get = do
    ldepth <- get
    lprio <- get
    lfloor <- get
    lembed <- get
    ltile <- get
    lxsize <- get
    lysize <- get
    lsmell <- get
    ldesc <- get
    lstair <- get
    lseen <- get
    lclear <- get
    ltime <- get
    lactorCoeff <- get
    lactorFreq <- get
    litemNum <- get
    litemFreq <- get
    lsecret <- get
    lhidden <- get
    lescape <- get
    return $! Level{..}