# -*- coding: UTF-8 -*-
#    This file is part of arena.
#
#    arena is free software: you can redistribute it and/or modify it
#    under the terms of the GNU General Public License as published by the
#    Free Software Foundation; either version 3 of the License or, at your
#    option, any later version.
#
#    arena is distributed in the hope that it will be useful, but
#    WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABLITY
#    or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public
#    License for more details.
#
#    You should have received a copy of the GNU General Public License along
#    with arena.  If not, see <http://www.gnu.org/licenses/>.

## @file
#  This file defines a class to manage a single arena post.

from Library.UI.Posts import APost, APostError
from Library.UI.Channels import chMetaItemsSeparator, \
    chMetaKeysValuesSeparator
from Library.String.Utilities import String, Time, sDefaultItemsDelimiter
from Library.IO.StringStreams import AStringStream
from Plugins.User.Subscriptions import Right
from Plugins.Content.Parse.MarkdownParsers import AMarkdownParser
from Plugins.Content.Parse.Lex.Languages import MarkdownCharacterTypes
from Library.Parse.Lex.Languages import CharacterType

from Local.Configuration import Site
from textwrap import TextWrapper
from Library.Memory.collections import Counter

## The maximum length of a post description
apstDescriptionLimit = 252
## Characters that are allowed to end a sentence
apstSentenceEndings = ".?!\""
## Added to a description to indicate that there is more to the post
apstDescriptionSuffix = "..."

## The minimum length of an automatically-generated keyword
apstAutoKeywordMinLength = 4
## The maximum number of keywords that we want a post to have
apstKeywordCountLimit = 10
## Characters that should be ignored when counting keywords
apstKeywordIgnoredCharacters = "?!.,:;+-=$%^&*()[]"
## Words that should be ignored when counting keywords
apstKeywordIgnoredWords = "a", "an", "in", "on", "the", "i", "then", \
    "than", "of", "above", "under", "out", "not", "is", "isn't", "isnt", \
    "can't", "cant", "cannot", "won't", "wont", "be", "am", \
    "to", "your", "you're", "for", "with", "this", "now", "as", \
    "are", "from", "don't", "dont", "that", "could", "couldn't", \
    "couldnt", "should", "shouldn't", "shouldnt", "below", "beneath", \
    "will", "shall", "shan't", "shant", "\n", "\r", "\t", "attach:"
       
## This class represents a single post within a channel.  It allows the
#  contents of the post to be parsed before the post is displayed to the end
#  user.
class AnArenaPost(APost):        
    ## Constructor
    def __init__(Self, fieldsValues=None, **params):
        # Set up our expanding fields
        params.update({
            "expand": {
                "keywords": (list, sDefaultItemsDelimiter, True),
                "channels": (list, sDefaultItemsDelimiter, True)
            }
        })
                
        APost.__init__(Self, fieldsValues, **params)
        
        if "Mediator" in Self._params:
            Self._Mediator = Self._params["Mediator"]
            
            if Self._Source is None:
                Self._Source = Self._Mediator.DataSource()
                
            del Self._params["Mediator"]
            
        if "Channel" in Self._params:
            Self._Channel = Self._params["Channel"]
            del Self._params["Channel"]
            
        if "User" in Self._params:
            Self._User = Self._params["User"]
            del Self._params["User"]
                
        if Self._category is None:
            Self._category = Site.Posts.category
            
    ## Initializer
    def init(Self):
        result = APost.init(Self)
        
        if result:
            # Establish private attributes
            # The mediator driving display of content
            Self._Mediator = None
            # The channel for which posts are being displayed
            Self._Channel = None
            # The user publishing or editing the post
            Self._User = None
            #  The preamble of the post
            Self._preamble = None
            
        return result
                
    ## Parse the contents of the post.
    #
    #  Descendant classes should override this method to parse the contents
    #  of the post before it is displayed to the end user.
    def parseContent(Self):         
        result = ""
               
        Event = Self.generate("parseContent")
        if Event.handled():
            result = Event.result()
            del Event
            return result
            
        del Event
        
        if "content" in Self._fieldsValues:
            if len(Self._fieldsValues["content"]) > 0:
                # Construct a string stream for use in parsing.
                # We add the newline character to the beginning of the stream
                # in case a postfix header line is the very first line of the
                # stream.  We add the EOF character to the end of the stream to
                # ensure that the parser knows when to stop, since we are not
                # reading from an actual file.
                Input = AStringStream("\n" + Self._fieldsValues["content"] + 
                  MarkdownCharacterTypes[CharacterType.endOfStream])
                Output = AStringStream()
            
                # Parse the stream
                Markdown = AMarkdownParser(
                    Source = Input, 
                    Log = Self._Log,
                    Content = Self)
                
                Markdown.OutputTo(Output)
                Markdown.parse()
            
                result = Output.toString()
                    
                del Markdown
                del Output
                del Input
                
        return result
                
    ## Flush the post to the data source.
    def flush(Self):
        if (Self.readOnly == True) or (len(Self._changes) == 0):
            return
                
        # Ensure we have a valid ID
        if ("id" in Self._fieldsValues) and (Self["id"] <= 0):
            del Self._fieldsValues["id"]
                
        # Check to see whether we have an ID
        if "id" not in Self._fieldsValues:                
            Self.insert()
            
        else:
            APost.flush(Self)
        
    ## Generate a description for the post, based on the first 252 characters
    def describe(Self, content):
        result = ""
        
        Wrapper = TextWrapper()
        Wrapper.width = apstDescriptionLimit
        Wrapper.break_on_hyphens = False
        # Disable HTML markup and construct a description
        lines = Wrapper.wrap(content.replace("<", "&lt;"))
        result = lines[0]
        if result[-1] != apstSentenceEndings:
            result += apstDescriptionSuffix
            
        del Wrapper        
        
        return result
        
    ## Generate keywords for the post
    def countWords(Self, content, limit=apstKeywordCountLimit, 
      ignoredWords=apstKeywordIgnoredWords):
        commonWords = list()          
        words = content.split()
        WordCounter = Counter(words)
        
        # Get a list of the ten most common words used
        wordCounts = WordCounter.most_common()
        for word in wordCounts:
            if len(commonWords) >= limit:
                break
                
            verb = word[0].strip(apstKeywordIgnoredCharacters)
                
            if len(verb) >= apstAutoKeywordMinLength:
                if verb.lower() not in ignoredWords:
                    commonWords.append(verb)
                
        del wordCounts
        del WordCounter
        del words
        
        return commonWords
        
    ## Update post fields using the specified HTTP request information
    def updateFromRequest(Self, ThisRequest):
        # Set the appropriate fields of the post
        Self["title"] = ThisRequest["title"].value
        Self["icon"] = ThisRequest["icon"].value
        Self["content"] = ThisRequest["content"].value
        Self["ip"] = ThisRequest.Remote.address
            
        # If the post is a draft...
        if "draft" in ThisRequest:
            if ThisRequest["draft"].value.lower() == "y":
                Self["draft"] = "y"
                
            else:
                Self["draft"] = "n"             
            
        # Get the list of channels to which the post belongs
        if "channels" in ThisRequest:
            Self["channels"].append(
                *String.explode(ThisRequest["channels"].value)
            )
        
        # If no channels list is given, then use the default channel
        if len(Self["channels"]) == 0:
            Self["channels"].append(Site.Channels.default)
        
        # If no post description is given, then use the first 255 characters
        if "description" in ThisRequest:
            Self["description"] = ThisRequest["description"].value
                  
        elif Self["draft"] == "n":
            Self["description"] = Self.describe(Self.parsedContent())    
            
        Self["keywords"].clear()
        # If no post keywords are given, then generate a list ourselves
        if "keywords" in ThisRequest:
            Self["keywords"].append(
                *String.explode(ThisRequest["keywords"].value)
            )
                  
        elif Self["draft"] == "n":
            Self["keywords"].append(*Self.countWords(Self["content"]))
            
        # If a comments designator is given, then use it
        if "comments" in ThisRequest:
            if ThisRequest["comments"].value.lower() == "n":
                Self._meta["comments"] = "n"
                
            else:
                Self._meta["comments"] = "y"                            
                
        # If a stickiness designator is given, then use it
        if "sticky" in ThisRequest:
            if ThisRequest["sticky"].value.lower() == "y":
                Self["sticky"] = "y"
                
            else:
                Self["sticky"] = "n"
        
    ## Publish a post
    def publish(Self):
        Self.updateFromRequest(Self._Mediator.Request)
        
        # Ensure that the user has the right to publish posts in the
        # channels they specified
        if ("id" not in Self._fieldsValues) or (Self["id"] <= 0):
            requiredRight = Right.toPost
            
        else:
            requiredRight = Right.toEdit
        
        desiredChannels = list(Self["channels"])
        
        for channel in desiredChannels:
            if Self._User.has(requiredRight, channel) == False:
                # Check to see if the user is explicitly denied the right;
                # this is done by specifying a subscription to the channel that
                # omits the necessary right.  If this is so, then the user 
                # may not post to the channel.
                if channel in Self._User.Subscriptions():
                    Self["channels"].remove(channel)
                    
                # Check for a blanket subscription
                elif Self._User.has(requiredRight, "$site/channels/public") == False:
                    Self["channels"].remove(channel)
        
        Self["editor"] = Self._User.name()
        Self["published"] = Time.now()
        
        Self._Channel["meta"]["lastUpdate"] = Time.now()
        
        if ("id" not in Self._fieldsValues) or (Self["id"] <= 0):
            Self["author"] = Self._User.name()
            Self["created"] = Time.now()
            
    ## Delete the post
    def delete(Self):
        currentChannels = Self["channels"]
        
        # Ensure that the user has the right to delete the post
        for channel in currentChannels:
            if Self._User.has(Right.toDelete, channel) == False:
                # Check to see if the user is explicitly denied the right;
                # this is done by specifying a subscription to the channel that
                # omits the necessary right.  If this is so, then the user 
                # may not delete posts in that channel.
                if channel in Self._User.Subscriptions():
                    continue
                    
                # Check for a blanket subscription
                elif Self._User.has(Right.toDelete, "$site/channels/public") == False:
                    continue
                    
            # Remove the reference to the specified channel from the post --
            # this removes the post from that channel
            Self["channels"].remove(channel)
            
        # Only if no channels remain in our list of channels do we actually
        # remove the post from the data source
        if len(Self["channels"]) == 0:
            if APost.delete(Self) > 0:
                # Prevent attempted changes to a non-existent post
                Self.readOnly = True
                Self._preamble = ""
                Self["content"] = "This post has been deleted."
                Self._parsedContent = "This post has been deleted."
                        
    # Properties ---------------------------------------------------------------
    ## Retrieve the content mediator driving the display of posts
    def Mediator(Self):
        return Self._Mediator

    ## Determine whether the post has a preamble
    def hasPreamble(Self):
        return String.isEmpty(Self._preamble) == False
        
    ## Retrieve the preamble of the post
    def preamble(Self):
        if Self._preamble is None:
            Self._parsedContent = Self.parseContent()
            
            if Self._preamble is None:
                Self._preamble = ""
                        
        return Self._preamble
        
    ## Record the preamble parsed from the post
    def recordPreamble(Self, preamble):
        Self._preamble = preamble        
        
