-------------------------------------------------------------------------------
-- |
-- Module      :  Yesod.Goodies.Search
-- Copyright   :  (c) Patrick Brisbin 2010 
-- License     :  as-is
--
-- Maintainer  :  pbrisbin@gmail.com
-- Stability   :  unstable
-- Portability :  unportable
--
-------------------------------------------------------------------------------
module Yesod.Goodies.Search
    ( SearchResult(..)
    , Search(..)
    , search
    , search_
    , weightedSearch
    , weightedSearch_
    -- * search helpers
    , TextSearch(..)
    , keywordMatch
    ) where

import Data.List  (sortBy, intersect)
import Data.Ord   (comparing)
import Data.Maybe (catMaybes)

import qualified Data.Text as T

-- | A ranked search result
data SearchResult a = SearchResult
    { searchRank   :: Double
    , searchResult :: a
    }

-- | Any item can be searched by providing a @'match'@ function.
class Search a where
    -- | If two results have the same rank, optionally lend preference
    --   to one. The /greater/ value will appear first.
    preference :: SearchResult a -> SearchResult a -> Ordering
    preference _ _ = EQ

    -- | Given a search term and some @a@, provide @Just@ a ranked 
    --   result or @Nothing@.
    match :: T.Text -> a -> Maybe (SearchResult a)

-- | Excute a search on a list of @a@s and rank the results
search :: Search a => T.Text -> [a] -> [SearchResult a]
search t = rankResults . catMaybes . map (match t)

-- | Identical but discards the actual rank values.
search_ :: Search a => T.Text -> [a] -> [a]
search_ t = map searchResult . search t

-- | Add (or remove) weight from items that have certian properties.
weightedSearch :: Search a => (a -> Double) -> T.Text -> [a] -> [SearchResult a]
weightedSearch f t = rankResults . map (applyFactor f) . catMaybes . map (match t)

    where
        applyFactor :: (a -> Double) -> SearchResult a -> SearchResult a
        applyFactor f' (SearchResult d v) = SearchResult (d * f' v) v

weightedSearch_ :: Search a => (a -> Double) -> T.Text -> [a] -> [a]
weightedSearch_ f t = map searchResult . weightedSearch f t

-- | Reverse sort the results by rank and then preference.
rankResults :: Search a => [SearchResult a] -> [SearchResult a]
rankResults = reverse . sortBy (comparing searchRank `andthen` preference)

-- | Compare values in a compound way
--
--   > sortBy (comparing snd `andthen` comparing fst)
--
andthen :: (a -> a -> Ordering) -> (a -> a -> Ordering) -> a -> a -> Ordering
andthen f g a b =
    case f a b of
        EQ -> g a b
        x  -> x

-- | Being a member of this class means defining the way to represent 
--   your type as pure text so it can be searched by keyword, etc.
class TextSearch a where
    toText :: a -> T.Text

-- | Search term is interpreted as keywords. Results are ranked by the 
--   number of words that appear in the source text, a rank of 0 returns 
--   Nothing.
keywordMatch :: TextSearch a => T.Text -> a -> Maybe (SearchResult a)
keywordMatch t v = go $ fix (toText v) `intersect` fix t

        where
            go [] = Nothing
            go ms = Just $ SearchResult (fromIntegral $ length ms) v

            fix :: T.Text -> [T.Text]
            fix = filter (not . T.null)
                . map T.strip
                . T.words
                . T.toCaseFold
                . T.filter (`notElem` ",.-")