{-# LANGUAGE TemplateHaskell #-}

{-|

Copyright:
  This file is part of the package zxcvbn-hs. It is subject to the
  license terms in the LICENSE file found in the top-level directory
  of this distribution and at:

    https://code.devalot.com/sthenauth/zxcvbn-hs

  No part of this package, including this file, may be copied,
  modified, propagated, or distributed except according to the terms
  contained in the LICENSE file.

License: MIT

-}
module Text.Password.Strength.Internal.Date (
  -- * Date Matches
  Date,
  YMD,
  isDate,
  toYMD,
  estimateDate
  ) where

--------------------------------------------------------------------------------
-- Library Imports:
import Control.Lens ((&), (^.), (+~), _1)
import Control.Lens.TH (makeLenses)
import qualified Data.Attoparsec.Text as Atto
import Data.Char (isDigit)
import Data.Char (isSpace)
import Data.Function (on)
import Data.List (sortBy)
import Data.Maybe (catMaybes, listToMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Read as Text
import qualified Data.Time.Calendar as Time

--------------------------------------------------------------------------------
-- | A date as a triple.
data Date = Date
  { _year    :: Int     -- ^ A recent year.
  , _month   :: Int     -- ^ 1-12.
  , _day     :: Int     -- ^ 1-31.
  , _hasSep  :: Bool    -- ^ Was a separator found in the date string?
  , _refYear :: Integer -- ^ What year are we comparing to?
  } deriving Show

makeLenses ''Date

--------------------------------------------------------------------------------
-- | Components of a found date (year, month, day).
type YMD = (Int, Int, Int)

--------------------------------------------------------------------------------
-- | Helper function to construct a 'Date' record.
toDate :: Bool -> Integer -> YMD -> Date
toDate s r (x,y,z) = Date x y z s r

--------------------------------------------------------------------------------
-- | Extract the date components of a 'Date' record.
toYMD :: Date -> YMD
toYMD d = (d ^. year, d ^. month, d ^. day)

--------------------------------------------------------------------------------
-- | If the given text wholly contains a date, return it.
isDate :: Time.Day -> Text -> Maybe Date
isDate ref t =
  listToMaybe $
    order $
      filter valid $
        map fixYear dates
  where
    dates :: [Date]
    dates =
      case Atto.parseOnly dateAvecSep t of
        Left _   -> toDate False refY <$> dateSansSep t
        Right ds -> toDate True  refY <$> ds

    order :: [Date] -> [Date]
    order = sortBy (compare `on` distance)

    distance :: Date -> Integer
    distance d = abs (toInteger (d ^. year) - refY)

    valid :: Date -> Bool
    valid date =
      let d = date ^. day
          m = date ^. month
          y = date ^. year
      in    m >= 1 && m <= 12
         && d >= 1 && d <= 31
         && y >= lastCentury
         && y <= (thisCentury + 100)

    fixYear :: Date -> Date
    fixYear d | (d ^. year) > 99 = d
              | (d ^. year) > 50 = d & year +~ lastCentury
              | otherwise        = d & year +~ thisCentury

    -- Reference year for sorting and scoring.
    refY :: Integer
    refY = Time.toGregorian ref ^. _1

    lastCentury :: Int
    lastCentury = fromInteger ((refY `div` 100) - 1) * 100

    thisCentury :: Int
    thisCentury = fromInteger refY `div` 100 * 100

--------------------------------------------------------------------------------
-- | Estimate the number of guesses for a date match.
--
-- Deviations from the zxcvbn paper:
--
--   1. The other implementations limit the year multiplier to 20 so
--      we do the same here.
--
--   2. The other implementations multiply by 4 when date separators
--      are used in the token.  We do the same.
estimateDate :: Date -> Integer
estimateDate d =
  let space = max (abs (toInteger (d ^. year) - (d ^. refYear))) 20
      guesses = max 1 space * 365
  in if d ^. hasSep
       then guesses * 4
       else guesses

--------------------------------------------------------------------------------
-- | Helper type for a triple of @Text -> Maybe a@ parser.
type Read3 a = (Maybe a, Maybe a, Maybe a)

--------------------------------------------------------------------------------
-- | A function that can rearrange a triple.
type Arrange a = Read3 a -> Read3 a

--------------------------------------------------------------------------------
-- | Extract all possible date combinations from the given text.
dateSansSep :: Text -> [YMD]
dateSansSep t
  | not (Text.all isDigit t) = []
  | otherwise = catMaybes
  [ take3 (1, 1, 2) dmy
  , take3 (2, 1, 1) ymd
  , take3 (2, 2, 0) ym_
  , take3 (2, 2, 0) my_
  , take3 (1, 2, 2) dmy
  , take3 (2, 1, 2) mdy
  , take3 (2, 2, 1) ymd
  , take3 (2, 1, 2) ymd
  , take3 (1, 1, 4) dmy
  , take3 (1, 1, 4) mdy
  , take3 (2, 2, 2) dmy
  , take3 (2, 2, 2) mdy
  , take3 (2, 2, 2) ymd
  , take3 (4, 1, 1) ymd
  , take3 (1, 2, 4) dmy
  , take3 (1, 2, 4) mdy
  , take3 (2, 1, 4) dmy
  , take3 (2, 1, 4) mdy
  , take3 (4, 1, 2) ymd
  , take3 (4, 2, 1) ymd
  , take3 (2, 2, 4) dmy
  , take3 (2, 2, 4) mdy
  , take3 (4, 2, 2) ymd
  ]

  where
    -- Parse three numbers and reorder them.
    take3 :: (Int, Int, Int) -> Arrange Int -> Maybe YMD
    take3 (x,y,z) f
      | (x+y+z) /= Text.length t = Nothing
      | otherwise =
        let g = seq3 . f . read3
        in g ( Text.take x t
             , Text.take y (Text.drop x t)
             , Text.drop (x+y) t
             )

    -- Parser.
    read3 :: (Text, Text, Text) -> Read3 Int
    read3 (x, y, z) =
      let r = either (const Nothing) check . Text.decimal
          check (n,e) | Text.null e = Just n
                      | otherwise   = Nothing
      in (r x, r y, r z)

    -- Sequence for a triple.
    seq3 :: Read3 Int -> Maybe YMD
    seq3 (x, y, z) = (,,) <$> x <*> y <*> z

    -- Arrangement functions.
    dmy (d,m,y) = (y,m,d)
    mdy (m,d,y) = (y,m,d)
    ym_ (y,m,_) = (y,m, pure 1)
    my_ (m,y,_) = (y,m, pure 1)
    ymd         = id

--------------------------------------------------------------------------------
-- | Extract all possible date combinations that include component
-- separators.
dateAvecSep :: Atto.Parser [YMD]
dateAvecSep = do
    ds1 <- Atto.decimal
    sep <- Atto.satisfy isSep
    ds2 <- Atto.decimal
    _   <- Atto.char sep
    ds3 <- Atto.decimal
    Atto.endOfInput

    pure [ (ds1, ds2, ds3) -- Y-M-D
         , (ds3, ds2, ds1) -- D-M-Y
         , (ds3, ds1, ds2) -- M-D-Y
         ]
  where
    isSep :: Char -> Bool
    isSep c = isSpace c ||
              c == '/'  ||
              c == '\\' ||
              c == '.'  ||
              c == '_'  ||
              c == '-'