-- | Transactions, the heart of Penny. The Transaction data type is
-- abstract, so that only this module can create Transactions. This
-- provides assurance that if a Transaction exists, it is a valid,
-- balanced Transaction. In addition, the Posting data type is
-- abstract as well, so you know that if you have a Posting, it was
-- created as part of a balanced Transaction.
--
-- Functions prefixed with a @p@ query a particular posting for its
-- properties. Functions prefixed with a @t@ query transactions. Every
-- transaction has a single DateTime, and all the postings have this
-- same DateTime, so there is no function to query a posting's
-- DateTime. Just query the parent transaction. For other things such
-- as Number and Flag, the transaction might have data and the posting
-- might have data as well, so functions are provided to query both.
--
-- Often you will want to query a single posting and have a function
-- that gives you, for example, the posting's flag if it has one, or
-- the transaction's flag if it has one, or Nothing if neither the
-- posting nor the transaction has a flag. The functions in
-- "Penny.Lincoln.Queries" do that.
module Penny.Lincoln.Transaction (

  -- * Postings and transactions
  Posting,
  Transaction,
  PostFam,
  unPostFam,

  -- * Making and deconstructing transactions
  transaction,
  RTransaction(..),
  rTransaction,
  Error ( UnbalancedError, CouldNotInferError),
  toUnverified,

  -- * Querying postings
  Inferred(Inferred, NotInferred),
  pPayee, pNumber, pFlag, pAccount, pTags,
  pEntry, pMemo, pInferred, pPostingLine,
  pGlobalPosting, pFilePosting,

  -- * Querying transactions
  TopLine,
  tDateTime, tFlag, tNumber, tPayee, tMemo, tTopLineLine,
  tTopMemoLine, tFilename, tGlobalTransaction, tFileTransaction,
  unTransaction, postFam,

  -- * Box
  Box ( Box, boxMeta, boxPostFam ),

  -- * Changers

  -- | Functions allowing you to change aspects of an existing
  -- transaction, without having to destroy and completely rebuild the
  -- transaction. You cannot change the Entry or any of its
  -- components, as changing any of these would unbalance the
  -- Transaction.
  TopLineChangeData(..),
  emptyTopLineChangeData,
  PostingChangeData(..),
  emptyPostingChangeData,
  changeTransaction
  ) where

import qualified Penny.Lincoln.Bits as B
import Penny.Lincoln.Family ( children, orphans, adopt )
import qualified Penny.Lincoln.Family.Family as F
import qualified Penny.Lincoln.Family.Child as C
import qualified Penny.Lincoln.Family.Siblings as S
import qualified Penny.Lincoln.Transaction.Unverified as U
import qualified Penny.Lincoln.Balance as Bal

import Control.Monad.Exception.Synchronous (
  Exceptional (Exception, Success) , throw )
import qualified Control.Monad.Exception.Synchronous as Ex
import qualified Data.Foldable as Fdbl
import Data.Maybe ( catMaybes, fromMaybe )
import qualified Data.Traversable as Tr
import qualified Control.Monad.Trans.State.Lazy as St
import Control.Monad.Trans.Class ( lift )

-- | Indicates whether the entry for this posting was inferred. That
-- is, if the user did not supply an entry for this posting, then it
-- was inferred.
data Inferred = Inferred | NotInferred deriving (Eq, Show)

-- | Each Transaction consists of at least two Postings.
data Posting =
  Posting { pPayee    :: Maybe B.Payee
          , pNumber   :: Maybe B.Number
          , pFlag     :: Maybe B.Flag
          , pAccount  :: B.Account
          , pTags     :: B.Tags
          , pEntry    :: B.Entry
          , pMemo     :: Maybe B.Memo
          , pInferred :: Inferred
          , pPostingLine :: Maybe B.PostingLine
          , pGlobalPosting :: Maybe B.GlobalPosting
          , pFilePosting :: Maybe B.FilePosting
          }
  deriving (Eq, Show)

-- | The TopLine holds information that applies to all the postings in
-- a transaction (so named because in a ledger file, this information
-- appears on the top line.)
data TopLine =
  TopLine { tDateTime :: B.DateTime
          , tFlag     :: Maybe B.Flag
          , tNumber   :: Maybe B.Number
          , tPayee    :: Maybe B.Payee
          , tMemo     :: Maybe B.Memo
          , tTopLineLine :: Maybe B.TopLineLine
          , tTopMemoLine :: Maybe B.TopMemoLine
          , tFilename :: Maybe B.Filename
          , tGlobalTransaction :: Maybe B.GlobalTransaction
          , tFileTransaction :: Maybe B.FileTransaction }
  deriving (Eq, Show)

-- | All the Postings in a Transaction must produce a Total whose
-- debits and credits are equal. That is, the Transaction must be
-- balanced. No Transactions are created that are not balanced.
newtype Transaction =
  Transaction { unTransaction :: F.Family TopLine Posting }
  deriving (Eq, Show)

-- | Errors that can arise when making a Transaction.
data Error = UnbalancedError
           | CouldNotInferError
           deriving (Eq, Show)

newtype PostFam = PostFam { unPostFam :: C.Child TopLine Posting }
                  deriving Show

-- | Get the Postings from a Transaction, with information on the
-- sibling Postings.
postFam :: Transaction -> [PostFam]
postFam (Transaction ps) = map PostFam . Fdbl.toList . children $ ps

{- BNF-like grammar for the various sorts of allowed postings.

postingGroup ::= (inferGroup balancedGroup*) | balancedGroup+
inferGroup ::= "at least 1 posting. All postings have same account and
                commodity. The balance is inferable."
balancedGroup ::= "at least 2 postings. All postings have the same
                   account and commodity. The balance is balanced."

-}

-- | Deconstruct a Transaction to a family of unverified data.
toUnverified :: Transaction -> F.Family U.TopLine U.Posting
toUnverified = F.mapParent fp . F.mapChildren fc . unTransaction
  where
    fp tl = toUTopLine tl
    fc p = toUPosting p

-- | Makes transactions.
transaction ::
  F.Family U.TopLine U.Posting
  -> Exceptional Error Transaction
transaction f@(F.Family p _ _ _) = do
  let os = orphans f
      t = totalAll os
      p' = toTopLine p
  a2 <- inferAll os t
  return $ Transaction (adopt p' a2)

totalAll :: S.Siblings U.Posting
         -> Bal.Balance
totalAll =
  Fdbl.foldr1 Bal.addBalances
  . catMaybes
  . Fdbl.toList
  . fmap (fmap Bal.entryToBalance . U.pEntry)

infer ::
  U.Posting
  -> Ex.ExceptionalT Error
  (St.State (Maybe B.Entry)) Posting
infer po =
  case U.pEntry po of
    Nothing -> do
      st <- lift St.get
      case st of
        Nothing -> Ex.throwT CouldNotInferError
        (Just e) -> do
          lift $ St.put Nothing
          return $ toPosting po e Inferred
    (Just e) -> return $ toPosting po e NotInferred

runInfer ::
  Maybe B.Entry
  -> S.Siblings U.Posting
  -> Exceptional Error (S.Siblings Posting)
runInfer me pos = do
  let (res, finalSt) = St.runState ext me
      ext = Ex.runExceptionalT (Tr.mapM infer pos)
  case finalSt of
    (Just _) -> throw UnbalancedError
    Nothing -> case res of
      (Exception e) -> throw e
      (Success g) -> return g

inferAll ::
  S.Siblings U.Posting
  -> Bal.Balance
  -> Exceptional Error (S.Siblings Posting)
inferAll pos t = do
  en <- case Bal.isBalanced t of
    Bal.Balanced -> return Nothing
    (Bal.Inferable e) -> return $ Just e
    Bal.NotInferable -> throw UnbalancedError
  runInfer en pos

toUPosting :: Posting -> U.Posting
toUPosting p = U.Posting
  { U.pPayee = pPayee p
  , U.pNumber = pNumber p
  , U.pFlag = pFlag p
  , U.pAccount = pAccount p
  , U.pTags = pTags p
  , U.pEntry = case pInferred p of
      Inferred -> Nothing
      NotInferred -> Just (pEntry p)
  , U.pMemo = pMemo p
  , U.pPostingLine = pPostingLine p
  , U.pGlobalPosting = pGlobalPosting p
  , U.pFilePosting = pFilePosting p
  }


toPosting :: U.Posting
             -> B.Entry
             -> Inferred
             -> Posting
toPosting u e i =
  Posting
  { pPayee    = U.pPayee u
  , pNumber   = U.pNumber u
  , pFlag     = U.pFlag u
  , pAccount  = U.pAccount u
  , pTags     = U.pTags u
  , pEntry    = e
  , pMemo     = U.pMemo u
  , pInferred = i
  , pPostingLine = U.pPostingLine u
  , pGlobalPosting = U.pGlobalPosting u
  , pFilePosting = U.pFilePosting u
  }


toUTopLine :: TopLine -> U.TopLine
toUTopLine t = U.TopLine
  { U.tDateTime = tDateTime t
  , U.tFlag     = tFlag t
  , U.tNumber   = tNumber t
  , U.tPayee    = tPayee t
  , U.tMemo     = tMemo t
  , U.tTopLineLine = tTopLineLine t
  , U.tTopMemoLine = tTopMemoLine t
  , U.tFilename = tFilename t
  , U.tGlobalTransaction = tGlobalTransaction t
  , U.tFileTransaction = tFileTransaction t
  }

toTopLine :: U.TopLine -> TopLine
toTopLine t = TopLine
  { tDateTime = U.tDateTime t
  , tFlag     = U.tFlag t
  , tNumber   = U.tNumber t
  , tPayee    = U.tPayee t
  , tMemo     = U.tMemo t
  , tTopLineLine = U.tTopLineLine t
  , tTopMemoLine = U.tTopMemoLine t
  , tFilename = U.tFilename t
  , tGlobalTransaction = U.tGlobalTransaction t
  , tFileTransaction = U.tFileTransaction t
  }

fromRPosting :: U.RPosting -> B.Entry -> Inferred -> Posting
fromRPosting u e i = Posting
  { pPayee    = U.rPayee u
  , pNumber   = U.rNumber u
  , pFlag     = U.rFlag u
  , pAccount  = U.rAccount u
  , pTags     = U.rTags u
  , pEntry    = e
  , pMemo     = U.rMemo u
  , pInferred = i
  , pPostingLine = U.rPostingLine u
  , pGlobalPosting = U.rGlobalPosting u
  , pFilePosting = U.rFilePosting u
  }

fromIPosting :: U.IPosting -> B.Entry -> Inferred -> Posting
fromIPosting u e i = Posting
  { pPayee    = U.iPayee u
  , pNumber   = U.iNumber u
  , pFlag     = U.iFlag u
  , pAccount  = U.iAccount u
  , pTags     = U.iTags u
  , pEntry    = e
  , pMemo     = U.iMemo u
  , pInferred = i
  , pPostingLine = U.iPostingLine u
  , pGlobalPosting = U.iGlobalPosting u
  , pFilePosting = U.iFilePosting u
  }

data RTransaction = RTransaction
  { rtCommodity :: B.Commodity
    -- ^ All postings will have this same commodity

  , rtSide :: Maybe B.Side
  -- ^ All commodities will be on this side of the amount

  , rtSpaceBetween :: Maybe B.SpaceBetween
  -- ^ All amounts will have this SpaceBetween

  , rtDrCr :: B.DrCr
  -- ^ All postings except the inferred one will have this DrCr

  , rtTopLine :: U.TopLine

  , rtPosting :: U.RPosting
  -- ^ You must have at least one posting whose quantity you specify

  , rtMorePostings :: [U.RPosting]
  -- ^ Optionally you can have additional restricted postings.

  , rtIPosting :: U.IPosting
  -- ^ And at least one posting whose quantity and DrCr will be inferred

  } deriving Show

-- | Creates a @restricted transaction@; that is, one in which all the
-- entries will have the same commodity, and in which all but one of
-- the postings will all be debits or credits. The last posting will
-- have no quantity specified at all and will be inferred. Creating
-- these transactions never fails, in contrast to the transactions
-- created by 'transaction', which can fail at runtime.
rTransaction :: RTransaction -> Transaction
rTransaction rt = Transaction (F.Family tl p1 p2 ps)
  where
    tl = toTopLine (rtTopLine rt)
    tot = foldl1 B.add $ (U.rQty . rtPosting $ rt)
                         : map U.rQty (rtMorePostings rt)
    sd = rtSide rt
    sb = rtSpaceBetween rt
    inf = fromIPosting (rtIPosting rt)
          ( B.Entry (B.opposite (rtDrCr rt))
            (B.Amount tot (rtCommodity rt) sd sb))
          Inferred
    toPstg p = fromRPosting p
               (B.Entry (rtDrCr rt)
               (B.Amount (U.rQty p) (rtCommodity rt) sd sb)) NotInferred
    p1 = toPstg . rtPosting $ rt
    (p2, ps) = case rtMorePostings rt of
      [] -> (inf, [])
      x:xs -> (toPstg x, (map toPstg xs) ++ [inf])

-- | A box stores a family of transaction data along with
-- metadata. The transaction is stored in child form, indicating a
-- particular posting of interest. The metadata is in addition to the
-- metadata associated with the TopLine and with each posting.
data Box m =
  Box { boxMeta :: m
      , boxPostFam :: PostFam }
  deriving Show

instance Functor Box where
  fmap f (Box m pf) = Box (f m) pf

-------------------------------------------------------------
-- Changers
-------------------------------------------------------------

-- | Each field in the record is a Maybe. If Nothing, make no change
-- to this part of the TopLine.
data TopLineChangeData = TopLineChangeData
  { tcDateTime :: Maybe B.DateTime
  , tcFlag :: Maybe (Maybe B.Flag)
  , tcNumber :: Maybe (Maybe B.Number)
  , tcPayee :: Maybe (Maybe B.Payee)
  , tcMemo :: Maybe (Maybe B.Memo)
  , tcTopLineLine :: Maybe (Maybe B.TopLineLine)
  , tcTopMemoLine :: Maybe (Maybe B.TopMemoLine)
  , tcFilename :: Maybe (Maybe B.Filename)
  , tcGlobalTransaction :: Maybe (Maybe B.GlobalTransaction)
  , tcFileTransaction :: Maybe (Maybe B.FileTransaction)
  } deriving Show

emptyTopLineChangeData :: TopLineChangeData
emptyTopLineChangeData = TopLineChangeData
  { tcDateTime = Nothing
  , tcFlag = Nothing
  , tcNumber = Nothing
  , tcPayee = Nothing
  , tcMemo = Nothing
  , tcTopLineLine = Nothing
  , tcTopMemoLine = Nothing
  , tcFilename = Nothing
  , tcGlobalTransaction = Nothing
  , tcFileTransaction = Nothing
  }


applyTopLineChange :: TopLineChangeData -> TopLine -> TopLine
applyTopLineChange c t = TopLine
  { tDateTime = fromMaybe (tDateTime t) (tcDateTime c)
  , tFlag = fromMaybe (tFlag t) (tcFlag c)
  , tNumber = fromMaybe (tNumber t) (tcNumber c)
  , tPayee = fromMaybe (tPayee t) (tcPayee c)
  , tMemo = fromMaybe (tMemo t) (tcMemo c)
  , tTopLineLine = fromMaybe (tTopLineLine t) (tcTopLineLine c)
  , tTopMemoLine = fromMaybe (tTopMemoLine t) (tcTopMemoLine c)
  , tFilename = fromMaybe (tFilename t) (tcFilename c)
  , tGlobalTransaction = fromMaybe (tGlobalTransaction t)
                         (tcGlobalTransaction c)
  , tFileTransaction = fromMaybe (tFileTransaction t)
                       (tcFileTransaction c)
  }

data PostingChangeData = PostingChangeData
  { pcPayee :: Maybe (Maybe B.Payee)
  , pcNumber :: Maybe (Maybe B.Number)
  , pcFlag :: Maybe (Maybe B.Flag)
  , pcAccount :: Maybe B.Account
  , pcTags :: Maybe B.Tags
  , pcMemo :: Maybe (Maybe B.Memo)
  , pcSide :: Maybe (Maybe B.Side)
  , pcSpaceBetween :: Maybe (Maybe B.SpaceBetween)
  , pcPostingLine :: Maybe (Maybe B.PostingLine)
  , pcGlobalPosting :: Maybe (Maybe B.GlobalPosting)
  , pcFilePosting :: Maybe (Maybe B.FilePosting)
  } deriving Show

emptyPostingChangeData :: PostingChangeData
emptyPostingChangeData = PostingChangeData
  { pcPayee = Nothing
  , pcNumber = Nothing
  , pcFlag = Nothing
  , pcAccount = Nothing
  , pcTags = Nothing
  , pcMemo = Nothing
  , pcSide = Nothing
  , pcSpaceBetween = Nothing
  , pcPostingLine = Nothing
  , pcGlobalPosting = Nothing
  , pcFilePosting = Nothing
  }


applyPostingChange :: PostingChangeData -> Posting -> Posting
applyPostingChange c p = Posting
  { pPayee = fromMaybe (pPayee p) (pcPayee c)
  , pNumber = fromMaybe (pNumber p) (pcNumber c)
  , pFlag = fromMaybe (pFlag p) (pcFlag c)
  , pAccount = fromMaybe (pAccount p) (pcAccount c)
  , pTags = fromMaybe (pTags p) (pcTags c)
  , pEntry = en
  , pMemo = fromMaybe (pMemo p) (pcMemo c)
  , pInferred = pInferred p
  , pPostingLine = fromMaybe (pPostingLine p) (pcPostingLine c)
  , pGlobalPosting = fromMaybe (pGlobalPosting p) (pcGlobalPosting c)
  , pFilePosting = fromMaybe (pFilePosting p) (pcFilePosting c)
  }
  where
    enOld = pEntry p
    amOld = B.amount enOld
    en = B.Entry (B.drCr enOld) am
    am = B.Amount (B.qty amOld) (B.commodity amOld) sd sb
    sd = fromMaybe (B.side amOld) (pcSide c)
    sb = fromMaybe (B.spaceBetween amOld) (pcSpaceBetween c)

-- | Allows you to change the parts of a transaction that can be
-- chanaged without unbalancing the transaction. You cannot change the
-- DrCr, Qty, or Commodity, as changing these might unbalance the
-- transaction. If there are elements you do not want to change at
-- all, use an 'emptyTopLineChangeData' or an 'emptyPostingChangeData'
-- in the appropriate part of the Family that you pass in. If the
-- Family of change data has more children than the transaction, these
-- extra children are ignored. If the Family in the Transaction has
-- more children than the Family of change data, the extra postings
-- are unchanged. That is, 'changeTransaction' will never delete
-- postings.
changeTransaction
  :: F.Family TopLineChangeData PostingChangeData
  -> Transaction
  -> Transaction
changeTransaction c (Transaction t) =
  let F.Family ctl cp1 cp2 cps = c
      F.Family tl p1 p2 ps = t
      tl' = applyTopLineChange ctl tl
      p1' = applyPostingChange cp1 p1
      p2' = applyPostingChange cp2 p2
      ps' = zipWith applyPostingChange
            (cps ++ repeat emptyPostingChangeData) ps
  in Transaction (F.Family tl' p1' p2' ps')