{-| Provides utilities to parse Dhall comments
-}

{-# LANGUAGE DataKinds         #-}
{-# LANGUAGE KindSignatures    #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ViewPatterns      #-}

module Dhall.Docs.Comment
    ( parseComments
    , CommentParseError(..)
    , DhallDocsText
    , parseSingleDhallDocsComment
    , unDhallDocsText
    ) where

import Control.Applicative (many, some, (<|>))
import Data.Functor        (void)
import Data.List.NonEmpty  (NonEmpty (..), (<|))
import Data.Text           (Text)
import Dhall.Docs.Util
import Dhall.Parser        (Parser (..))
import Text.Megaparsec     (SourcePos, (<?>))

import qualified Data.Either
import qualified Data.Foldable
import qualified Data.List.NonEmpty  as NonEmpty
import qualified Data.Maybe          as Maybe
import qualified Data.Text
import qualified Dhall.Parser.Token  as Token
import qualified Text.Megaparsec
import qualified Text.Megaparsec.Pos as Megaparsec.Pos

-- | For explanation of this data-type see 'DhallComment'
data CommentType = DhallDocsComment | MarkedComment | RawComment

type ListOfSingleLineComments = NonEmpty (SourcePos, Text)

{-| Internal representation of Dhall comments. Text is always stripped from
    whitespace from both the start and begin of the string

    * If @a = 'DhallDocsComment'@ then comment is valid to extract its contents
    and be rendered on documentation
    * If @a = 'MarkedComment'@ then comment has the @|@ marker that
    @dhall-docs@ will be aware of, but this comment may not be a @dhall-docs@
    comment
    * If @a = `RawComment`@ then the comment is a raw comment
-}
data DhallComment (a :: CommentType)
    -- | A single block comment: starting from @{-@ and ending in @-}@
    = BlockComment Text
    {-| A group of subsequent single line comment, each one starting from @--@
        and ending in the last character to the end of the line. Each one keeps
        its 'SourcePos' to validate indentation.

        A property of 'SingleLineComments' is that the 'sourceLine's in the
        'NonEmpty (SourcePos, Text)' are in strictly-increasing order /and/
        the difference between the 'sourceLine' of any adyacent pair is @1@.

        Note that several @dhall-docs@ comments maybe inside a single 'SingleLineComments'
    -}
    | SingleLineComments ListOfSingleLineComments
    deriving Show

-- | Extracted text from a valid @dhall-docs@ comment
newtype DhallDocsText = DhallDocsText Text
    deriving Show

unDhallDocsText :: DhallDocsText -> Text
unDhallDocsText (DhallDocsText t) = t

-- | A mirror of "Dhall.Parser.Token".'Dhall.Parser.Token.lineComment' but
--   returning a 'DhallComment'
lineCommentParser :: Parser (NonEmpty (DhallComment 'RawComment))
lineCommentParser = do
    (l : ls) <- some singleLine
    pure $ NonEmpty.map SingleLineComments $ groupComments (l :| ls)
  where
    groupComments :: ListOfSingleLineComments -> NonEmpty ListOfSingleLineComments
    groupComments ls = case NonEmpty.nonEmpty remaining of
        Nothing -> g :| []
        Just l -> g <| groupComments l
      where
        lineNumber = Megaparsec.Pos.unPos . Megaparsec.Pos.sourceLine

        (g, remaining) = removeSubseq ls
        removeSubseq :: ListOfSingleLineComments -> (ListOfSingleLineComments, [(SourcePos, Text)])
        removeSubseq (x :| []) = (x :| [], [])
        removeSubseq (x@(xPos, _) :| ys@(y@(yPos, _) : rest))
            | lineNumber yPos - lineNumber xPos == 1
                = let (subSeq, r) = removeSubseq (y :| rest) in (x <| subSeq, r)
            | otherwise = (x :| [], ys)

    singleLine = do
      sourcePos <- Text.Megaparsec.getSourcePos
      commentLine <- Token.lineComment
      whitespace
      pure (sourcePos, commentLine)

-- | Consume whitespace lines or lines that only have whitespaces *before* a comment
whitespace :: Parser ()
whitespace = Text.Megaparsec.skipMany (Text.Megaparsec.choice
    [ void (Text.Megaparsec.takeWhile1P Nothing predicate)
    , void (Token.text "\r\n")
    ] <?> "whitespace")
  where
    predicate c = c == ' ' || c == '\t' || c == '\n'

blockCommentParser :: Parser (DhallComment 'RawComment)
blockCommentParser = do
    c <- Token.blockComment
    whitespace
    pure $ BlockComment c

-- | Parse all comments in a text fragment
parseComments :: String -> Text -> [DhallComment 'RawComment]
parseComments delta text = case result of
    Left err -> error ("An error has occurred while parsing comments:\n "
      <> Text.Megaparsec.errorBundlePretty err)
    Right comments -> comments
  where
    parser = do
        comments <- many $ do
            whitespace
            lineCommentParser <|> ((:| []) <$> blockCommentParser)
        Text.Megaparsec.eof
        pure $ concatMap NonEmpty.toList comments

    result = Text.Megaparsec.parse (unParser parser) delta text

data CommentParseError
    = MissingNewlineOnBlockComment
    | BadSingleLineCommentsAlignment
    | BadPrefixesOnSingleLineComments
    | SeveralSubseqDhallDocsComments
    deriving Show

-- | Checks if a 'RawComment' has the @dhall-docs@ marker
parseMarkedComment :: DhallComment 'RawComment -> Maybe (DhallComment 'MarkedComment)
parseMarkedComment (BlockComment comment)
    | "{-|" `Data.Text.isPrefixOf` comment = Just $ BlockComment comment
    | otherwise = Nothing

parseMarkedComment (SingleLineComments ls)
    | any (("--|" `Data.Text.isPrefixOf`) . snd) ls = Just (SingleLineComments ls)
    | otherwise = Nothing

-- | Knowing that there is a @dhall-docs@ marker inside the comment, this
--   checks if a 'MarkedComment' is a 'DhallDocsComment'. For 'SingleLineComments'
--   this also removes the prefix lines before the first marked comment
parseDhallDocsComment :: DhallComment 'MarkedComment -> Either CommentParseError (DhallComment 'DhallDocsComment)
parseDhallDocsComment (BlockComment comment) =
    if any (`Data.Text.isPrefixOf` comment) ["{-|\n", "{-|\r\n"] then Right $ BlockComment comment
    else Left MissingNewlineOnBlockComment

parseDhallDocsComment (SingleLineComments lineComments) =
    fmap SingleLineComments $ checkAlignment lineComments >>= checkAmountOfMarkers >>= checkPrefixes
  where
    sourceCol = Text.Megaparsec.unPos . Text.Megaparsec.sourceColumn

    checkAmountOfMarkers :: ListOfSingleLineComments -> Either CommentParseError ListOfSingleLineComments
    checkAmountOfMarkers ls =
        if numberOfMarkers > 1 then Left SeveralSubseqDhallDocsComments
        else case newLines of
            [] -> fileAnIssue "checkAmountOfMarkers failed with newLines = []"
            l : remainder -> Right $ l :| remainder
      where
        commentLines = NonEmpty.toList ls
        numberOfMarkers = length $ filter (Data.Text.isPrefixOf "--|" . snd) commentLines
        (_, newLines) = break (Data.Text.isPrefixOf "--|" . snd) commentLines

    checkAlignment :: ListOfSingleLineComments -> Either CommentParseError ListOfSingleLineComments
    checkAlignment ls@((first, _) :| rest)
        | all ((== sourceCol first) . sourceCol . fst) rest = Right ls
        | otherwise = Left BadSingleLineCommentsAlignment

    checkPrefixes :: ListOfSingleLineComments -> Either CommentParseError ListOfSingleLineComments
    checkPrefixes ls@((_, first) :| rest)
        | "--| " `Data.Text.isPrefixOf` first && all (p . snd) rest
            = Right ls
        | otherwise = Left BadPrefixesOnSingleLineComments
      where
        p t = Data.Text.isPrefixOf "--  " t || (Data.Text.compareLength t 2 == EQ && "--" == t)

parseDhallDocsText :: DhallComment 'DhallDocsComment -> DhallDocsText
parseDhallDocsText (BlockComment blockComment) =
    case Data.Text.stripSuffix "-}" joinedText of
        Nothing -> fileAnIssue ("Obtained 'Nothing' on extractText.stripSuffix with text: \"" <> joinedText <> "\"")
        Just e -> DhallDocsText e
  where
    joinedText = Data.Text.strip $ Data.Text.unlines reIndentedLines
    commentLines = tail $ Data.Text.lines blockComment

    leadingSpaces = Data.Text.takeWhile isSpace
        where
        isSpace t = t == ' ' || t == '\t'

    nonEmptyCommentLines = filter (not . Data.Text.null) commentLines

    commonIndentation = Data.Text.length $
        case map leadingSpaces nonEmptyCommentLines of
            l : ls -> Data.Foldable.foldl' sharedPrefix l ls
            []     -> ""
        where
        sharedPrefix ab ac =
            case Data.Text.commonPrefixes ab ac of
                Just (a, _, _) -> a
                Nothing          -> ""

    reIndentedLines = map (Data.Text.drop commonIndentation) commentLines

parseDhallDocsText (SingleLineComments (fmap snd -> (first :| rest))) =
    DhallDocsText $ Data.Text.unlines $ firstLine : map cleanRest rest
  where
    debugLines = Data.Text.unlines (first : rest)
    firstLine = case Data.Text.stripPrefix "--| " first of
        Nothing -> fileAnIssue $
            "Error strippping \"--| \" prefix on parseDhallDocsText. " <>
            "All comment lines are here:\n" <> debugLines

        Just s -> s

    cleanRest l = case Data.Text.stripPrefix "--  " l <|> Data.Text.stripPrefix "--" l of
        Nothing -> fileAnIssue $
            "Error strippping \"--  \" prefix on parseDhallDocsText. " <>
            "All comment lines are here:\n" <> debugLines

        Just s -> s

-- | Returns 'Nothing' when 'DhallDocsComment' was parsed or no error was detected
parseSingleDhallDocsComment :: String -> Text -> Maybe (Either [CommentParseError] DhallDocsText)
parseSingleDhallDocsComment delta text = do
    let rawComments = parseComments delta text
    let markedComments = Maybe.mapMaybe parseMarkedComment rawComments
    let (errors_, dhallDocsComments) = Data.Either.partitionEithers $ map parseDhallDocsComment markedComments

    let errors =
                if length dhallDocsComments >= 2 then
                    SeveralSubseqDhallDocsComments : errors_
                else
                    errors_

    case (errors, dhallDocsComments) of
        ([] ,  []) -> Nothing
        (_:_,   _) -> Just $ Left errors
        (_  , [a]) -> Just $ Right $ parseDhallDocsText a
        (_  ,   _) -> fileAnIssue "Returned more than one comment at parseSingleDhallDocsComment"
