# muc_ignore.tcl --
#
#       Support for ignoring occupant activity in MUC rooms.
#
#       A note on runtime ruleset format:
#       * A hash is used to hold ignore rules at runtime; each key
#         uniquely refers to its related "xlib, room, occupant,
#         message type" tuple; the existence of a key is used to
#         determine the fact of some type of a room occupant messages
#         being ignored.
#       * The format of the ruleset keys is as follows:
#         SESSION_JID NUL ROOM_JID/OCCUPANT NUL TYPE
#         where:
#       * NUL means character with code 0 (\u0000 in Tcl lingo).
#         It is used since ASCII NUL is prohibited in JIDs;
#       * SESSION_JID is the bare JID of a particular connection of
#         the Tkabber user (support for multiaccounting);
#       * ROOM_JID is the room bare JID;
#       * OCCUPANT is either an occupant's room nick OR her full
#         bare JID, if it's available;
#       * TYPE is either "chat" or "groupchat", literally, which determines
#         the type of messages to ignore.

namespace eval mucignore {
    variable options

    variable ignored

    variable tid 0
    variable tags

    variable menustate

    # Cutsomize section:

    custom::defvar stored_rules {} \
        "Stored MUC ignore rules" \
        -group Hidden \
        -type string

    hook::add post_custom_restore [namespace current]::restore_rules

    custom::defgroup {MUC Ignoring} \
        [::msgcat::mc "Ignoring groupchat and chat messages\
                       from selected occupants of multi-user conference\
                       rooms."] \
        -group Privacy \
        -group Chat

    custom::defvar options(transient_rules) 0 \
        [::msgcat::mc "When set, all changes to the ignore rules are\
                       applied only until Tkabber is closed\;\
                       they are not saved and thus will be not restored at\
                       the next run."] \
        -group {MUC Ignoring} \
        -type boolean

    # Event handlers:

    # Handlers for creating various menus:
    hook::add chat_create_conference_menu_hook \
        [namespace current]::setup_muc_menu
    hook::add chat_create_user_menu_hook \
        [namespace current]::setup_private_muc_chat_menu
    hook::add roster_create_groupchat_user_menu_hook \
        [namespace current]::setup_occupant_menu
    hook::add finload_hook \
        [namespace current]::on_init

    # Block private MUC messages:
    hook::add process_message_hook \
        [namespace current]::process_message

    # Weed out MUC room messages upon entering a room:
    hook::add open_chat_post_hook \
        [namespace current]::sanitize_muc_display

    # Catch presence of ignored users.
    # NOTE: the order of this handler must be higher than
    # that of ::muc::process_presence (which is the default of 50)
    # since that handler extracts and stores the room occupant's
    # real JID in the non-anonymous rooms.
    hook::add client_presence_hook \
        [namespace current]::catch_junkie_presence 55

    # Adjust ignore rules on nick renames.
    # NOTE: this hook must be run earlier than client_presence_hook.
    hook::add room_nickname_changed_hook \
        [namespace current]::trace_room_nick_change

    hook::add generate_completions_hook \
        [namespace current]::add_completions

    hook::add chat_send_message_hook \
        [namespace current]::handle_commands
}

# "Ignore tags" are used to mark whole messages posted in the room
# by an ignored occupant. Their names are autogenerated and unique
# throughout one Tkabber run. Each tag is bound to one particular
# "room JID" of the ignored occupant. Ignore tags may be rebound
# to another room JID when these change (on nickname changes).

# Creates the ignore tag for a particular "room JID".
# If matching tag exists, this proc does nothing, silently.
# This provides for ignore tag "persistence".
proc mucignore::ignore_tag_create {roomjid} {
    variable tid
    variable tags

    if {[info exists tags($roomjid)]} return

    set tags($roomjid) IGNORED-$tid
    incr tid
}

proc mucignore::ignore_tag_get {roomjid} {
    variable tags

    set tags($roomjid)
}

proc mucignore::ignore_tag_rebind {from to} {
    variable tags

    set tags($to) $tags($from)
    unset tags($from)
}

proc mucignore::ignore_tag_forget {roomjid} {
    variable tags

    unset tags($roomjid)
}

# Returns bare JID of the session identified by $xlib
proc mucignore::session_bare_jid {xlib} {
    ::xmpp::jid::removeResource [connection_jid $xlib]
}

# Tries to get the real bare JID of the room occupant identified
# by the $room_occupant_jid; returns that JID if it's available,
# empty string otherwise.
proc mucignore::get_real_bare_jid {xlib room_occupant_jid} {
    set real_jid [::muc::get_real_jid $xlib $room_occupant_jid]
    if {$real_jid != {}} {
        return [::xmpp::jid::removeResource $real_jid]
    } else {
        return {}
    }
}

# Creates an ignore rule suitable for using as a key to a hash of rules.
# Expects:
# * entity -- session's bare JID;
# * jid -- JID to ignore ("room/nick" or "room/real_bare_jid");
# * type -- type of chat to ignore ("groupchat" or "chat").
# These parts are joined using the NUL character (since its appearance
# is prohibited in any part of a JID) and so the rule can be reliably
# split back into parts.
# See also: [split_rule].
proc mucignore::mkrulekey {entity jid type} {
    join [list $entity $jid $type] \u0000
}

# Creates an ignore rule suitable for using as a key to a hash of rules.
# The $xlib parameter is converted to the session's bare JID first.
# It's just a convenient wrapper around [mkrulekey].
proc mucignore::mkrule {xlib jid type} {
    mkrulekey [session_bare_jid $xlib] $jid $type
}

# Splits given rule into the list of [entity jid type], where:
# * entity -- is a bare JID of the user's session;
# * jid -- is a JID to be ignored (usually a full room JID);
# * type -- one of: "groupchat" or "chat", designating the type of messages
#   originating from jid to be ignored.
# This proc reverses what [mkrulekey] does.
proc mucignore::split_rule {rule} {
    split $rule \u0000
}

proc mucignore::setup_muc_menu {m xlib jid} {
    # TODO
    return
    $m add command \
        -label [::msgcat::mc "Edit MUC ignore rules"] \
        -command [list [namespace current]::editor::open $xlib $jid]
}

proc mucignore::on_init {} {
    # TODO
    return
    set menu [.mainframe getmenu plugins]
    $menu add command -label [::msgcat::mc "Edit MUC ignore rules"] \
        -command [list [namespace current]::editor::open {} {}]
}

proc mucignore::setup_private_muc_chat_menu {m xlib jid} {
    set room [::xmpp::jid::removeResource $jid]
    if {![::chat::is_groupchat [::chat::chatid $xlib $room]]} return

    setup_occupant_menu $m $xlib $jid
}

# Prepares two global variables mirroring the current state of
# ignoring for the room occupant on which groupchat roster nick
# the menu is being created. They are used to represent
# ignore state checkbutton menu entries.
proc mucignore::setup_occupant_menu {m xlib jid} {
    variable ignored
    variable menustate

    set our_nick [::get_our_groupchat_nick [
        ::chat::chatid $xlib [
            ::xmpp::jid::removeResource $jid]]]
    set nick [::chat::get_nick $xlib $jid groupchat]

    if {$nick == $our_nick} {
        # don't allow to ignore ourselves
        set state disabled
    } else {
        set state normal
    }

    foreach type {groupchat chat} {
        set menustate($xlib,$jid,$type) [
            info exists ignored([mkrule $xlib $jid $type])]
    }

    set sm [menu $m.mucignore -tearoff 0]
    $m add cascade -menu $sm \
        -state $state \
        -label [::msgcat::mc "Ignore"]

    $sm add checkbutton -label [::msgcat::mc "Ignore groupchat messages"] \
        -variable [namespace current]::menustate($xlib,$jid,groupchat) \
        -command [list [namespace current]::menu_toggle_ignoring \
                       $xlib $jid groupchat]
    $sm add checkbutton -label [::msgcat::mc "Ignore chat messages"] \
        -variable [namespace current]::menustate($xlib,$jid,chat) \
        -command [list [namespace current]::menu_toggle_ignoring \
                       $xlib $jid chat]

    bind $m <Destroy> +[double% [list \
        [namespace current]::menu_cleanup_state $xlib $jid]]
}

proc mucignore::menu_toggle_ignoring {xlib jid type} {
    variable menustate

    if {$menustate($xlib,$jid,$type)} {
        occupant_ignore $xlib $jid $type
    } else {
        occupant_attend $xlib $jid $type
    }
}

proc mucignore::menu_cleanup_state {xlib jid} {
    variable menustate

    array unset menustate $xlib,$jid,*
}

# Ignores specified room occupant:
# * Creates an ignore rule for her;
# * Creates an ignore tag, if needed;
# * Hides messages tagged with that tag, if any;
# * Builds and saves current ruleset to the Customize db.
proc mucignore::occupant_ignore {xlib jid args} {
    variable options
    variable ignored

    foreach type $args {
        set ignored([mkrule $xlib $jid $type]) true

        if {$type == "groupchat"} {
            ignore_tag_create $jid
            room_weed_messages $xlib $jid true
        }
    }

    if {!$options(transient_rules)} {
        store_rules $xlib
    }
}

# Un-ignores specified room occupant:
# * Removes her ignore rules;
# * Shows any hidden messages from her;
# * Ignore tag is NOT removed to provide for "quick picking"
#   into what the ignored occupant have had written so far --
#   when she is ignored again, all her messages tagged with
#   the appropriate ignore tag are again hidden.
# * Builds and saves current ruleset to the Customize db.
proc mucignore::occupant_attend {xlib jid args} {
    variable options
    variable ignored

    foreach type $args {
        set rule [mkrule $xlib $jid $type]
        if {[info exists ignored($rule)]} {
            unset ignored($rule)
            if {$type == "groupchat"} {
                room_weed_messages $xlib $jid false
                # we don't use [ignore_tag_forget] here
                # so when we switch ignoring back on,
                # all already marked messagess will be weed out
            }
        }
    }

    if {!$options(transient_rules)} {
        store_rules $xlib
    }
}

# Hides or shows messages tagged as ignored for the $jid, if any.
proc mucignore::room_weed_messages {xlib jid hide} {
    set room [::xmpp::jid::removeResource $jid]
    set cw [::chat::chat_win [::chat::chatid $xlib $room]]

    $cw tag configure [ignore_tag_get $jid] -elide $hide
}

# This handler blocks further processing of the private room message
# if its sender is blacklisted.
# If the message is groupchat and its sender is blacklisted, it sets
# the appropriate message property so that other message handlers
# could treat such message in some special way.
proc mucignore::process_message {xlib from id type args} {
    variable ignored

    if {$type == "chat" && \
        [info exists ignored([mkrule $xlib $from chat])]} {
        return stop
    }
}

proc mucignore::is_ignored {xlib jid type} {
    variable ignored

    if {[info exists ignored([mkrule $xlib $jid $type])]} {
        return [ignore_tag_get $jid]
    } else {
        return ""
    }
}

# This handler is being run after opening the chat window.
# It searches the ignore rules for JIDs matching the JID of the room,
# extracts them from the rules and weeds out their messages from
# the room display (chatlog).
# NOTE that it gets executed before any presences arrive from the room
# occupants, so the whole idea is to weed out messages with known (ignored)
# nicks.
proc mucignore::sanitize_muc_display {chatid type} {
    variable ignored

    if {$type != "groupchat"} return

    set xlib [::chat::get_xlib $chatid]
    set jid [::chat::get_jid $chatid]

    foreach rule [array names ignored [mkrule $xlib $jid/* groupchat]] {
        set junkie [lindex [split_rule $rule] 1]
        # TODO handle "real JIDs" case...
        ignore_tag_create $junkie
        room_weed_messages $xlib $junkie true
    }
}

# This handler is being run after the room_nickname_changed_hook
# (which takes care of renaming the ignore list entries).
# This proc serves two purposes:
# * It converts rules from real JIDs and room JIDs and back
#   so that room JIDs are used for rule matching and real JIDs
#   are stored, if they are available, between sessions.
# * It arranges for chat log display to be prepared to weed out
#   messages from ignored JIDs.

# TODO why does real JID is available when this handler is run with
#      $type == "unavailable". memory leak in chats.tcl?
# TODO use chat_user_enter/chat_user_exit instead?
proc mucignore::catch_junkie_presence {xlib from pres args} {
    variable options
    variable ignored

    set room [::xmpp::jid::removeResource $from]
    set rjid [get_real_bare_jid $xlib $from]

    if {$pres == "available"} {
        debugmsg mucignore "avail: $from; real jid: $rjid"
        foreach type {groupchat chat} {
            if {$rjid != {} && \
                [info exists ignored([mkrule $xlib $room/$rjid $type])]} {
                rename_rule_jid $xlib $room/$rjid $from $type
            }
        }

        if {[info exists ignored([mkrule $xlib $from groupchat])]} {
            ignore_tag_create $from
            room_weed_messages $xlib $from true
        }
    } elseif {$pres == "unavailable"} {
        debugmsg mucignore "unavail: $from; real jid: $rjid"
        if {[info exists ignored([mkrule $xlib $from groupchat])]} {
            ignore_tag_forget $from
        }

        foreach type {groupchat chat} {
            if {$rjid != {} && \
                [info exists ignored([mkrule $xlib $from $type])]} {
                rename_rule_jid $xlib $from $room/$rjid $type
            }
        }
    }
}

proc mucignore::trace_room_nick_change {chatid oldnick newnick} {
    variable ignored

    set xlib [chat::get_xlib $chatid]
    set room [chat::get_jid $chatid]
    foreach type {groupchat chat} {
        if {[info exists ignored([mkrule $xlib $room/$oldnick $type])]} {
            rename_rule_jid $xlib $room/$oldnick $room/$newnick $type

            if {$type == "groupchat"} {
                ignore_tag_rebind $room/$oldnick $room/$newnick
            }
        }
    }
}

proc mucignore::rename_rule_jid {xlib from to type} {
    variable ignored

    set oldrule [mkrule $xlib $from $type]
    set newrule [mkrule $xlib $to $type]

    set ignored($newrule) [set ignored($oldrule)]
    unset ignored($oldrule)

    debugmsg mucignore "rule renamed:\
        [string map {\u0000 |} $oldrule]\
        [string map {\u0000 |} $newrule]"
}

proc mucignore::explode_room_jid {xlib room_occupant_jid vroom voccupant} {
    upvar 1 $vroom room $voccupant occupant

    set room [::xmpp::jid::removeResource $room_occupant_jid]

    set occupant [get_real_bare_jid $xlib $room_occupant_jid]
    if {$occupant == {}} {
        set occupant [::xmpp::jid::resource $room_occupant_jid]
    }
}

# Parses the runtime hash of ignore rules, makes up the hierarchical list
# (a tree) of ignore rules, resolving the room JIDs to real JIDs,
# if possible, then saves the list to the corresponding Customize variable.
# The list has the form:
# * session_bare_jid_1
#   * room_bare_jid_1
#     * occupant_1 (nick or real_jid)
#       * "groupchat" or "chat" or both
# ...and so on
proc mucignore::store_rules {xlib} {
    variable ignored
    variable stored_rules

    array set entities {}

    foreach rule [array names ignored] {
        lassign [split_rule $rule] entity jid type

        explode_room_jid $xlib $jid room occupant

        set entities($entity) 1

        set rooms rooms_$entity
        if {![info exists $rooms]} {
            array set $rooms {}
        }
        set [set rooms]($room) 1

        set occupants occupants_$entity$room
        if {![info exists $occupants]} {
            array set $occupants {}
        }

        lappend [set occupants]($occupant) $type
    }

    set LE {}
    foreach entity [array names entities] {
        set LR {}
        foreach room [array names rooms_$entity] {
            set LO {}
            set occupants occupants_$entity$room
            foreach occupant [array names $occupants] {
                lappend LO $occupant [set [set occupants]($occupant)]
            }

            lappend LR $room $LO
        }

        lappend LE $entity $LR
    }

    set stored_rules [list 1.0 $LE] ;# also record "ruleset syntax" version

    debugmsg mucignore "STORED: $LE"
}

proc mucignore::restore_rules {args} {
    variable ignored
    variable stored_rules

    array set ignored {}

    set failed [catch {
        lassign $stored_rules version ruleset
        array set entities $ruleset
        foreach entity [array names entities] {
            array set rooms $entities($entity)
            foreach room [array names rooms] {
                array set occupants $rooms($room)
                foreach occupant [array names occupants] {
                    foreach type $occupants($occupant) {
                        set ignored([mkrulekey $entity $room/$occupant $type]) true
                    }
                }
                array unset occupants
            }
            array unset rooms
        }
    } err]

    if {$failed} {
        global errorInfo
        set bt $errorInfo

        set stored_rules {}

        after idle [list error \
            [::msgcat::mc "Error loading MUC ignore rules, purged."] $bt]
    }

    debugmsg mucignore "RESTORED: [string map {\u0000 |} [array names ignored]]"
}

proc mucignore::add_completions {chatid compsvar wordstart line} {
    upvar 0 $compsvar comps
    lappend comps "/ignore " "/unignore "
}

proc mucignore::handle_commands {chatid user body type} {
    set xlib [chat::get_xlib $chatid]

    if {[string equal -length 8 $body "/ignore "]} {
        set nick [string range $body 8 end]
        set jid [chat::get_jid $chatid]/$nick
        occupant_ignore $xlib $jid $type

        set msg [::msgcat::mc "%s was ignored" $nick]
        ::chat::add_message $chatid "" chat $msg {}

        return stop
    }
    if {[string equal -length 10 $body "/unignore "]} {
        set nick [string range $body 10 end]
        set jid [chat::get_jid $chatid]/$nick
        occupant_attend $xlib $jid $type

        set msg [::msgcat::mc "%s was unignored" $nick]
        ::chat::add_message $chatid "" chat $msg {}

        return stop
    }
}

########################################################################
# MUC Ignore ruleset editor
########################################################################

namespace eval mucignore::editor {}

# ...
# NOTE that both $xlib and $jid may be empty at the time of invocation.
proc mucignore::editor::open {xlib jid} {
    set w .mucignore_rules_editor
    if {[winfo exists $w]} {
        return
    }

    add_win $w -title [::msgcat::mc "MUC Ignore Rules"] \
        -tabtitle [::msgcat::mc "MUC Ignore"] \
        -class MUCIgnoreRulesetEditor \
        -raise 1

    set sw [ScrolledWindow $w.sw -auto both]
    set t [MyTree $w.tree]
    $sw setwidget $t

    bind $sw <Destroy> [list [namespace current]::cleanup [double% $w]]

    pack $sw -fill both -expand true

    # NOTE that BWidget Tree doesn't aceept keyboard bindings.

    $t tag bind Text <Double-ButtonPress-1> [list [namespace current]::tree_toggle [double% $t]]
    bind $w <KeyPress-Return> [list [namespace current]::tree_toggle [double% $t]]

    bind $w <KeyPress-F2> [list [namespace current]::tree_edit_item [double% $t]]
    bind $w <Any-KeyPress-Insert> [list [namespace current]::tree_insert_item [double% $t]]
    bind $w <Any-KeyPress-Delete> [list [namespace current]::tree_insert_item [double% $t]]
}

proc mucignore::editor::cleanup {w} {
    # TODO do appropriate cleanup...
}

proc mucignore::editor::tree_toggle {t} {
    set node [lindex [$t selection] 0]
    if {$node != {}} {
        $t item $node -open [expr {![$t item $node -open]}]
    }
}

proc mucignore::editor::tree_edit_item {t} {
    set node [lindex [$t selection] 0]
    if {$node == {}} return

    set text [$t item $node -text]

    # TODO implement
    #$t edit $node $text
}

proc mucignore::editor::tree_insert_item {t} {
    set parent [lindex [$t selection] 0]

    # TODO implement
    #add_nodes $t $parent {New {}}
}

# vim:ft=tcl:ts=8:sw=4:sts=4:et
