{-# LANGUAGE FlexibleInstances #-}
-- | 
-- This module can read and write BBDB files, and provides a few handy
-- functions for getting at fields inside of BBDB data.
-- 
-- BBDB (<http://bbdb.sourceforge.net/>) is short for the Insidious Big
-- Brother Database, which is a contact management utility that can
-- be integrated into Emacs (the one true editor.)  Since bbdb.el is
-- implemented in elisp, it can be difficult to \"get at\" the data
-- inside a .bbdb file with external programs.  Many years ago, I
-- wrote a BBDB interface for perl, but having experience
-- enlightenment at the hands of the category gods, I\`m now dabbling
-- with Haskell.  But having been a loyal Emacs user for many years
-- now, I wanted a way to spam my friends while still using my
-- favorite programming language.  Hence the module Data.BBDB.
-- 
-- The following is the data layout for a BBDB record.  I have created a
-- sample record with my own data.  Each field is just separated by a
-- space.  I have added comments to the right
-- 
-- > ["Henry"                                   The first name - a string
-- > "Laxen"                                    The last name - a string
-- > nil                                        Affixes - a comma separated list
-- > ("Henry, Enrique")                         Also Known As - a comma separated list
-- > ("Elegant Solutions")                      Organizations- a comma separated list
-- > (
-- >  ["reno" 775 624 1851 0]                   Phone number field - US style
-- >  ["chapala" "011-52-376-765-3181"]         Phone number field - International style
-- > ) 
-- > (
-- >  ["mailing"                                The address location, then a list
-- >  ("10580 N. McCarran Blvd." "#115-396")    for the street address, then one each
-- >  "Reno" "Nevada" "89503" "USA"             for City, State, Zip Code, and country
-- > ] 
-- >  ["home"                                   another Address field
-- >  ("Via Alta #6" "Gaviotas #10")            The street list
-- >  "Chapala" "Jalisco"                       City State
-- >  "45900" "Mexico"                          Zip and country
-- > ]) 
-- > (
-- >  "nadine.and.henry@pobox.com"              the net addresses - a list of strings
-- >  "nadinelaxen@pobox.com"
-- > ) 
-- > (
-- >  (notes . "Always split aces and eights")  The notes field - a list of alists
-- >  (creation-date . "2010-09-03") 
-- >  (timestamp . "2010-09-03")
-- >  (birthday . "6/15")
-- > ) 
-- > nil                                        The cache vector - always nil
-- > ]
-- 
-- Inside the .bbdb file, this looks like:
-- \[\"Henry\" \"Laxen\" nil (\"Henry, Enrique\") (\"Elegant Solutions\")
-- (\[\"reno\" 775 624 1851 0] \[\"chapala\" \"011-52-376-765-3181\"]) 
-- (\[\"mailing\" (\"10580 N. McCarran Blvd.\" 
-- \"#115-396\") \"Reno\" \"Nevada\" \"89503\" \"USA\"] 
-- \[\"home\" (\"Via Alta #6\" \"Gaviotas #10\") 
-- \"Chapala\" \"Jalisco\" \"45900\" \"Mexico\"]) 
-- (\"nadine.and.henry\@pobox.com\" \"nadinelaxen\@pobox.com\") 
-- ((notes . \"Always split aces and eights\") 
-- (creation-date . \"2010-09-03\") 
-- (timestamp . \"2010-09-03\") (birthday . \"6/15\")) nil]
--
-- When parsed, this is represented inside Haskell as:
--  
-- >      BBDBEntry
-- >        (BBDB{firstName = Just "Henry", lastName = Just "Laxen",
-- >              affix = Nothing
-- >              aka = Just ["Henry, Enrique"], company = Just ["Elegant Solutions"],
-- >              phone =
-- >                Just
-- >                  [USStyle "reno" ["775", "624", "1851", "0"],
-- >                   InternationalStyle "chapala" "011-52-376-765-3181"],
-- >              address =
-- >                Just
-- >                  [Address{location = "mailing",
-- >                           streets =
-- >                             Just ["10580 N. McCarran Blvd.", "#115-396"],
-- >                           city = Just "Reno", state = Just "Nevada",
-- >                           zipcode = Just "89503", country = Just "USA"},
-- >                   Address{location = "home",
-- >                           streets = Just ["Via Alta #6", "Gaviotas #10"],
-- >                           city = Just "Chapala", state = Just "Jalisco",
-- >                           zipcode = Just "45900", country = Just "Mexico"}],
-- >              net = Just ["nadine.and.henry@pobox.com", "nadinelaxen@pobox.com"],
-- >              notes =
-- >                Just
-- >                  (Note{unnote =
-- >                          [("notes", "Always split aces and eights"),
-- >                           ("creation-date", "2010-09-03"),
-- >                           ("timestamp", "2010-09-03"),
-- >                           ("birthday", "6/15")]})})]
-- > 

module Database.BBDB 
  (
    Location,
    Street,
    Symbol,
    Address(..), 
    Alist, 
    Note(..), 
    Phone(..), 
    BBDB(..), 
    BBDBFile(..),
    LispAble(..),
    bbdbDefault,
    key,value,
    parseBBDB,
    bbdbFileParse,
    justEntry,
    justEntries,
    readBBDB,
    wantNote,
    getNote,
    mapBBDB,
    filterBBDB
  ) where


import Text.Parsec.Char
import Text.Parsec.String (Parser) -- type Parser = Parsec String ()
import Text.Parsec hiding ((<|>))
import Control.Applicative hiding (many)
import Data.Maybe

doubleQuoteChar :: Char
doubleQuoteChar = '"'

betweenParens :: Parser a -> Parser a
betweenParens   = between (char '(') (char ')')

quotedString :: Parser String 
--quotedString :: (Stream s m Char) => ParsecT s u Identity String -> ParsecT s u Identity String
quotedString = 
    between (char doubleQuoteChar) (char doubleQuoteChar)  $
    many quotedChar

quotedChar :: Parser Char  
quotedChar =
  noneOf "\\\"" <|>
  try (string "\\\"" >> return '"') <|>
  noneOf "\""

-- | A Location is just a synonym for String.  Each BBDB Address and
-- Phone field must be associated with a location, such as /home/ or
-- /work/
type Location = String
-- | A Street is also a synonym for String.  Each Address may have a
-- list of Streets associated with it.
type Street = String
-- | A Symbol is just a String, but Lisp only wants
-- alphanumerics and the characters _ (underscore) and - (dash)
type Symbol = String

-- | For some unknow reason, BBDB can have phones in two different
-- formats.  In /USStyle/, the phone is list of integers, in the form
-- of Area code, Prefix, Number, and Extension.  I don\'t bother to
-- convert the strings of digits to actual integers.  In
-- /InternationalStyle/, the phone number is just a String.
data Phone =
    USStyle Location [String] 
     |
    InternationalStyle Location String
                 deriving (Eq, Ord, Show)
-- | An Address must have a location, and may have associated streets,
-- a city, a state, a zipcode, and an country.
data Address = Address {
                 location :: Location,
                 streets  :: Maybe [String],
                 city     :: Maybe String,
                 state    :: Maybe String,
                 zipcode  :: Maybe String,
                 country  :: Maybe String
                 }
               deriving (Eq, Ord, Show)

-- | An Alist is an Association List.  Lisp writes these as (key
-- . value) We convert these to a tuple in haskell where fst is key
-- and snd is value.  
type Alist = (Symbol,String)

-- | Given an Alist, return the key
key :: (x,y) -> x
key   (x,_) = x
-- | Given an Alist, return the value
value :: (x,y) -> y
value (_,y) = y

-- | The Note field of a BBDB record is just a list of associations.
-- If you don\'t provide a your own key, the BBDB will use the word \"note\"

data Note = Note {
                   unnote :: [Alist]
                 }
            deriving (Eq, Ord, Show)

data BBDB = BBDB {
-- | the first name.  Why is this a Maybe?  Because sometimes you just
-- have a company, and not a specific first name

                      firstName :: Maybe String,
                      lastName  :: Maybe String,
-- | aka = Also Known As.  Sometimes the same email address can match
-- several users, so BBDB gives you the option of remembering
-- different names for the same address
                      affix     :: Maybe [String],
                      aka       :: Maybe [String],
-- | The company if any                      
                      company   :: Maybe [String],
-- | A list of phone numbers, either in US Style or International Style
                      phone     :: Maybe [Phone],
-- | A list of addresses, keyed by location
                      address   :: Maybe [Address],
-- | A list of email addresses.  
-- BBDB uses the first element of this field when you create a new email
                      net       :: Maybe [String],
-- | Any number of key, value pairs.  Great for random data.
                      notes     :: Maybe Note
                  }             
                    deriving (Eq, Ord, Show)

bbdbDefault :: BBDB
bbdbDefault = BBDB Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing

-- | At the beginning of a BBDB file are a variable number of comments, which
-- specify the encoding type and the version.  We just ignore them.
-- Comments starts with a \; (semi-colon) and continue to end of line
data BBDBFile = 
  BBDBComment String 
   | 
  BBDBEntry BBDB
                    deriving (Eq, Ord, Show)

-- | return Nothing if parsing the string \"nil\"
nil :: Parser (Maybe a)
nil = string "nil" >> return Nothing

strings :: Parser [String]
strings = betweenParens (sepBy quotedString space)
  

stringOrNil :: Parser (Maybe String)
stringOrNil = 
    nil <|> Just <$> quotedString <?> "nil or string"


stringsOrNil :: Parser (Maybe [String])
stringsOrNil = 
    nil <|> Just <$> strings
        

listOfInts :: Parser [String]
listOfInts = sepBy1 (many1 digit) space

phoneParser :: Parser Phone
phoneParser = do
      char '[' 
      phoneType <- quotedString
      spaces
      n <- singlePhone phoneType
      char ']' 
      return n
  where 
    singlePhone phoneType = do
        n <- listOfInts
        return $ USStyle phoneType n
      <|> do
        n <- quotedString
        return $ InternationalStyle phoneType n

phonesParser :: Parser (Maybe [Phone])
phonesParser = 
        try nil
    <|> Just <$> betweenParens (sepBy phoneParser space)


singleAddress :: Parser Address
singleAddress = do
    char '['
    location <- quotedString
    space
    streets <- stringsOrNil
    space
    city <- stringOrNil
    space
    state <- stringOrNil
    space
    zip <- stringOrNil
    space
    country <- stringOrNil
    char ']'
    return $ Address location streets city state zip country



addressesParser :: Parser (Maybe [Address])
addressesParser = 
        nil
    <|> Just <$> betweenParens (sepBy singleAddress space)

    
lispSymbol :: Parser Symbol
lispSymbol = many1 (alphaNum <|> oneOf "-_")



alist :: Parser Alist
alist = betweenParens $
        (,) <$> lispSymbol <*> (string " . " *> quotedString)


notesParser :: Parser (Maybe Note)
notesParser = 
       nil
   <|> Just <$> betweenParens (Note <$> sepBy alist space)

bbdbEntry :: Parser BBDB              
bbdbEntry = do
  char '['
  firstName   <- stringOrNil
  space
  lastName    <- stringOrNil
  space
  affix       <- stringsOrNil
  space
  aka         <- stringsOrNil
  space
  company     <- stringsOrNil
  space
  phoneS      <- phonesParser
  space
  addresseS   <- addressesParser
  space
  net         <- stringsOrNil
  space
  noteS       <- notesParser
  space
  string "nil"
  char ']'
  return $ BBDB firstName lastName affix aka company phoneS addresseS net noteS


-- | The Parser for a BBDB file, as it is written on disk.  If you
-- read a .bbdb file with:
-- 
-- > testParse :: FilePath -> IO (Either ParseError [BBDBFile])
-- > testParse filename = do
-- >   b <- readFile filename
-- >   return $  parse bbdbFileParse "bbdb" b
-- 
-- You will get IO (Right [BBDBFile]) if the parse went ok
-- 
bbdbFileParse :: Parser [BBDBFile]
bbdbFileParse = do
  comments <-  many commentLine
  entries <- many (bbdbEntry <* newline)
  eof
  return $ map BBDBComment comments ++ map BBDBEntry entries
  where
    commentLine = (:) <$> char ';' <*> (many (noneOf "\n\r") <* newline)

-- | converts a BBDB comment to nothing, and a BBDB entry to just the entry
justEntry :: BBDBFile -> Maybe BBDB
justEntry (BBDBComment _) = Nothing
justEntry (BBDBEntry x) = Just x

-- | returns a list of  only the actual bbdb entries, removing the comments
justEntries :: [BBDBFile] -> [BBDB]
justEntries = mapMaybe justEntry

-- | surround a string with the given two characters  
surroundWith :: a -> a -> [a] -> [a]
surroundWith before after str = before : str ++ [after]

-- | convert a Haskell string to a string that Lisp likes
escapeLisp :: String -> String
escapeLisp [] = []
escapeLisp (c:cs) = 
  case c of
    '"' -> '\\' : '"' : escapeLisp cs
    _ -> c : escapeLisp cs

-- | LispAble is how we convert from our internal representation of a
-- BBDB record, to one that will make Lisp and Emacs happy.  (Sans bugs)
-- 
-- > testInverse = do
-- >   let inFile = "/home/henry/.bbdb"
-- >   actualBBDBFile <- readFile inFile
-- >   parsedBBDBdata <- readBBDB inFile
-- >   let bbdbDataOut = asLisp parsedBBDBdata
-- >   print $ actualBBDBFile == bbdbDataOut
-- >  
-- 
--  should print True
class LispAble s where
  asLisp :: s -> String

instance LispAble String where
  asLisp = escapeLisp  

instance LispAble (Maybe String) where
  asLisp   Nothing = "nil"
  asLisp   (Just x) = surroundWith '"' '"' . escapeLisp $ x

instance LispAble (Maybe [String]) where
  asLisp   Nothing = "nil"
  asLisp   (Just x) = surroundWith '(' ')' . unwords .
                        map (surroundWith '"' '"' . asLisp) $ x

instance LispAble Phone where
  asLisp (USStyle loc numbers) =
    surroundWith '[' ']' $ surroundWith '"' '"' loc ++ " " ++ 
    unwords numbers
  asLisp (InternationalStyle location numbers) =  
    surroundWith '[' ']' $ surroundWith '"' '"' location ++ " " ++ 
    surroundWith '"' '"' numbers

instance LispAble (Maybe [Phone]) where
  asLisp   Nothing = "nil"
  asLisp   (Just x) = surroundWith '(' ')' . unwords . map asLisp $ x

instance LispAble Address where
  asLisp x = surroundWith '[' ']' $ unwords 
    [asLisp $ Just (location x),
     asLisp (streets x),
     asLisp (city x),
     asLisp (state x),
     asLisp (zipcode x),
     asLisp (country x)]

instance LispAble (Maybe [Address]) where
  asLisp   Nothing = "nil"
  asLisp   (Just x) = surroundWith '(' ')' . unwords .
                        map asLisp $ x

instance LispAble Alist where
  asLisp x = surroundWith '(' ')' $
    key x ++ " . " ++ asLisp (Just (value x))

instance LispAble Note where
  asLisp (Note x)  = surroundWith '(' ')' . unwords .
                      map asLisp $ x
  
instance LispAble (Maybe Note) where
  asLisp   Nothing = "nil"
  asLisp   (Just x) = surroundWith '(' ')' . unwords . 
                        map asLisp $ unnote x
                        
instance LispAble BBDB where
  asLisp x = surroundWith '[' ']' $ unwords 
   [asLisp (firstName x),
    asLisp (lastName x),
    asLisp (aka x),
    asLisp (company x),
    asLisp (phone x),
    asLisp (address x),
    asLisp (net x),
    asLisp (notes x),
    "nil"
   ]

instance LispAble BBDBFile where
  asLisp (BBDBComment x) = x
  asLisp (BBDBEntry x) = asLisp x

-- | the inverse of bbdbFileParse
instance LispAble [BBDBFile] where
  asLisp = unlines . map asLisp

-- | parse the string as a BBDB File
parseBBDB :: String -> Either ParseError [BBDBFile]
parseBBDB  = parse bbdbFileParse "bbdb"

-- | read the given file and call error if the parse failed,
-- otherwise return the entire file as a list of BBDBFile records.
readBBDB :: String -> IO [BBDBFile]
readBBDB filename = do
  b <- readFile filename
  let ls = parseBBDB b
  return . either (error . show)  id $ ls

-- | Notes inside a BBDB record are awkward to get at.  This helper
-- function digs into the record and applies a function to each
-- Alist element of the record.  It returns true if it any of the
-- Alists in the note return true.  For example:
--  
-- > hasBirthday :: BBDB -> Bool
-- > hasBirthday = wantNote (\x -> key x == "birthday")
--  
-- will return True for any BBDB record that has a \"birthday\" key
-- in it\'s notes field
wantNote :: (Alist -> Bool) -> BBDB -> Bool
wantNote cond bbdb = maybe False alistTest (notes bbdb)
  where
    alistTest = any cond . unnote

-- | Lookup the value whose key is the given string.  If found returns 
-- Just the value, otherwise Nothing  For example:
--
-- > getBirthday :: BBDB -> Maybe String
-- > getBirthday = getNote "birthday"
--
getNote :: String -> BBDB -> Maybe String
getNote k b = lookup k  (maybe []  unnote (notes b))

-- | This and filterBBDB are the main functions you should use to
-- manipulate a set of BBDB entries.  You supply a function that
-- applies a transformation on a BBDB record, and this function will
-- apply that transformation to every BBDBEntry in a BBDB file.
-- Sample usage:
-- 
-- > starCompanies = do
-- >   b <- readBBDB "/home/henry/.bbdb"
-- >   writeFile "/home/henry/.bbdb-new" $ asLisp . mapBBDB starCompany $ b
-- >   where
-- >     starCompany x = case (company x) of
-- >       Nothing -> x
-- >       Just y -> x { company = Just ("*" ++ y) }
-- 
-- Prepend a star (\"*\") to each company 
-- field of a BBDB file and write the result
-- out as a new bbdb file.
mapBBDB :: (BBDB -> BBDB) -> [BBDBFile] -> [BBDBFile]
mapBBDB f = map g
  where
    g (BBDBComment x) = BBDBComment x
    g (BBDBEntry x) = BBDBEntry (f x)

-- | Just like mapBBDB except it filters.  You supply a function that
-- takes a BBDB record to a Bool, and filterBBDB will return a new
-- list of BBDBFile that satisfy that condition.  Sample usage:
-- 
-- > import Text.Regex.Posix
-- > -- do regex matching while ignoring case, so "reno" matches "Reno"
-- > matches x = match (makeRegexOpts compIgnoreCase defaultExecOpt x :: Regex)
-- 
-- > getReno = do
-- >   b <- readBBDB "/home/henry/.bbdb"
-- >   let c = justEntries . filterBBDB hasReno $ b
-- >   mapM_ print $ map (\a -> (firstName a, lastName a, address a)) c
-- >   where
-- >     isReno :: Maybe String -> Bool
-- >     isReno = maybe False (matches "reno")
-- >     anyAddressHasReno :: [Address] -> Bool
-- >     anyAddressHasReno = any id . map (isReno . city)
-- >     hasReno :: BBDB -> Bool
-- >     hasReno = maybe False anyAddressHasReno . address
-- 
-- print the name and all addresses of anyone in the BBDB file
-- who live in Reno.  
filterBBDB :: (BBDB -> Bool) -> [BBDBFile] -> [BBDBFile]
filterBBDB f = filter g
  where
    g (BBDBComment _) = False
    g (BBDBEntry x) = f x