-- A simple push button calcualtor without operator precedence

module CalcModel (
  Number,
  Calc,
  BinOp, plus, minus, times, divide,
  clearCalc, enterDigit, enterDecimalPoint, enterBinOp, evaluate
  ) where

import Data.Char     (isDigit)
import Control.Monad    (when)
import Numeric  (showGFloat)

-- we could change this to rational
type Number = Double

data Calc = Calc {
    number   :: [Digit],
    operator :: BinOp,
    total    :: Number,
    resetOnNum :: Bool    -- a state flag, after pressing '=', if we enter an
  }                       -- operator then we're carrying on the previous
                          -- calculation, otherwise we should start a new one.

data Digit = Digit Int    -- in range [0..9]
           | DecimalPoint
  deriving Eq

data BinOp = BinOp (Number -> Number -> Number)

plus, minus, times, divide :: BinOp
plus   = BinOp (+)
minus  = BinOp (-)
times  = BinOp (*)
divide = BinOp (/)

clearCalc :: Calc
clearCalc = Calc {
    number = [],
    operator = plus,
    total = 0,
    resetOnNum = True
  }

-- Maybe for the case when the operation makes no sense
enterDigit :: Int -> Calc -> Maybe (String, Calc)
enterDigit digit calc
  | digit `elem` [0..9]
 && not (number calc == [] && digit == 0)
  = let newNumber = number calc ++ [Digit digit]
     in if resetOnNum calc
          then Just (show newNumber,
                     calc {
                       number = newNumber,
                       total = 0,
                       resetOnNum = False
                     })
          else Just (show newNumber, calc { number = newNumber })
  | otherwise = Nothing

enterDecimalPoint :: Calc -> Maybe (String, Calc)
enterDecimalPoint calc
  | DecimalPoint `notElem` number calc
  = let newNumber = number calc ++ [DecimalPoint]
     in if resetOnNum calc
          then Just (show newNumber,
                     calc {
                       number = newNumber,
                       total = 0,
                       resetOnNum = False
                     })
          else Just (show newNumber, calc { number = newNumber })
  | otherwise = Nothing

enterBinOp :: BinOp -> Calc -> Maybe (String, Calc)
enterBinOp binop calc =
  let newTotal = (case operator calc of BinOp op -> op)
                   (total calc)
                   (digitsToNumber (number calc))
   in Just (showNumber newTotal,
            Calc {
              number = [],
              operator = binop,
              total = newTotal,
              resetOnNum = False
            })

evaluate :: Calc -> Maybe (String, Calc)
evaluate calc =
  let newTotal = (case operator calc of BinOp op -> op)
                   (total calc)
                   (digitsToNumber (number calc))
   in Just (showNumber newTotal,
            Calc {
              number = [],
              operator = plus,
              total = newTotal,
              resetOnNum = True
            })

instance Show Digit where
  show (Digit n) = show n
  show DecimalPoint = "."
  showList = showString . concatMap show

digitsToNumber :: [Digit] -> Number
digitsToNumber [] = 0
digitsToNumber digits@(DecimalPoint:_) = digitsToNumber (Digit 0:digits)
digitsToNumber digits | last digits == DecimalPoint
                                  = digitsToNumber (init digits)
                      | otherwise = read (show digits) --CHEAT!

precision = Just 5  --digits of precision, or Nothing for as much as possible

showNumber :: Number -> String
showNumber num =
  if '.' `elem` numStr then stripTrailingZeros numStr
                       else numStr
  where numStr = showGFloat precision num ""
        stripTrailingZeros =
            reverse
          . (\str -> if head str == '.' then tail str else str)
          . dropWhile (\c -> c=='0')
          . reverse

testProg :: IO ()
testProg = do
  evalLoop clearCalc

  where evalLoop :: Calc -> IO ()
        evalLoop calc = do
          putStr "calc> "
          line <- getLine
          when (line /= "q") $ do
            result <- case line of
                        [digit] | isDigit digit
                            -> return $ enterDigit (read [digit]) calc
                        "." -> return $ enterDecimalPoint calc
                        "+" -> return $ enterBinOp plus calc
                        "-" -> return $ enterBinOp minus calc
                        "*" -> return $ enterBinOp times calc
                        "/" -> return $ enterBinOp divide calc
                        "=" -> return $ evaluate calc
                        "c" -> return $ Just ("0",clearCalc)
                        _ -> do putStrLn "invalid input"
                                return Nothing
            case result of
              Nothing -> evalLoop calc
              Just (display, calc') -> do putStrLn display
                                          evalLoop calc'
