#
# Copyright (c) 2023-2024 Andrea Biscuola <a@abiscuola.com>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
#

namespace eval exts {
	namespace export list load killbyname writemsg readmsg \
	    bootstrapped init newmsg

	namespace ensemble create

	#
	# Dictionary with the list of loaded extensions
	#
	variable exts

	#
	# Dictionary with the list of outstanding acks from an
	# extension, or to an extension from the application.
	#
	variable acks

	#
	# Track message ids
	#
	variable nextid
}

proc ::exts::newid {} {
	variable nextid

	incr nextid
}

proc ::exts::newmsg {} {
	set msg [dict create]

	dict set msg id ""
	dict set msg type ""
	dict set msg timestamp ""
	dict set msg cid ""
	dict set msg nick ""
	dict set msg level ""
	dict set msg focus ""
	dict set msg status ""
	dict set msg network ""
	dict set msg channel ""
	dict set msg tags {}
	dict set msg command ""
	dict set msg args {}

	return $msg
}

proc ::exts::init {} {
	variable exts
	variable acks
	variable nextid

	set nextid 0

	set exts [dict create]
	set acks [dict create]

	#
	# Load extensions from the default directory automatically.
	#
	foreach ext [glob -nocomplain $::extdir/*] {
		load $ext "::irctk::extmsg"
	}

	#
	# Load extensions the user loaded manually the last time
	# irctk was executed.
	#
	# This is what the user likes the most.
	#
	if {![catch {set fd [open $::cfgdir/extensions.conf r]} errstr]} {
		while {[gets $fd path] >= 0} {load $path "::irctk::extmsg"}

		close $fd
	} else {
		log console "::exts::init: $errstr"
	}

	# Poll to check if the bootstrapping process is complete.
	after $::bootpollingms "exts bootstrapped"
}

proc ::exts::list {} {
	variable exts

	set extlist {}

	dict for {ch ext} $exts {
		if {[dict get $ext loaded]} {
			lappend extlist [::list [dict get $exts $ch name] \
			    [dict get $exts $ch extversion] \
			    [dict get $exts $ch caps] \
			    [dict get $exts $ch path]]
		}
	}

	return $extlist
}

proc ::exts::readmsg {ch cb} {
	variable exts
	variable acks

	#
	# Read a line from the extension and convert it from utf-8
	# before storing it in the msg variable
	#
	set msg [encoding convertfrom utf-8 "[gets $ch]"]
	if {"$msg" eq ""} {
		if {[eof $ch]} {killext $ch}

		return -1
	}

	set msg [split "$msg" "\t"]
	if {[llength $msg] < 2} {return}

	set id [lindex $msg 0]

	set type [lindex $msg 1]
	if {$type eq ""} {return}

	# Every message has, at most, the id and message type fields.
	set rmsg [dict create]
	dict set rmsg id $id
	dict set rmsg type $type

	#
	# Oh, an extension has something to say.
	#
	switch -exact -nocase -- $type {
		ack {
			if {[llength $msg] < 3} {return}
			if {$id eq ""} {return}
			if {![dict exists $acks "$id$ch"]} {return}

			# We agree!
			#
			# Check if an "ack" for the given id-ch couple
			# exists. If we find it, we can remove it. The meaning
			# is that the transaction is complete.
			#
			if {[dict get $acks "$id$ch" chan $ch type] eq "handshake"} {
				dict unset acks "$id$ch"
			}
		} nack {
			if {[llength $msg] < 3} {return}
			if {$id eq ""} {return}
			if {![dict exists $acks "$id$ch"]} {return}

			#
			# Uff... can't you get it right?
			#
			if {[dict get $acks "$id$ch" chan $ch type] eq "handshake"} {
				log console [lindex $msg 2]

				dict unset acks "$id$ch"
			}
		} handshake {
			if {[lindex $msg 0] eq ""} {return}
			if {[lindex $msg 2] eq ""} {return}
			if {[lindex $msg 3] eq ""} {return}
			if {[lindex $msg 4] eq ""} {return}

			after cancel [dict get $exts $ch timeout]

			#
			# See if we can get along.
			#
			# The current protocol version MUST be
			# retro-compatible.
			#
			set exproto [lindex $msg 2]
			if {$exproto > $::extproto} {
				nack $ch $id "protocol version not supported"

				return
			}

			# If everything is fine, accept the handshake
			ack $ch $id

			#
			# Store the various fields in the extensions
			# dictionary.
			#
			dict set exts $ch name [lindex $msg 3]
			dict set exts $ch extversion [lindex $msg 4]
			dict set exts $ch caps [split [lindex $msg 5]]
			dict set exts $ch loaded 1

			#
			# In this case, we return the message for the
			# benefit of the extensions dialog to be
			# updated right after an extension is loaded.
			#
			dict set rmsg name [lindex $msg 3]
			dict set rmsg version [lindex $msg 4]
			dict set rmsg path [dict get $exts $ch path]
			dict set rmsg caps [dict get $exts $ch caps]
		} filter {
			if {[llength $msg] < 3} {return}
			if {![dict exists $exts $ch loaded]} {return}
			if {[lindex $msg 0] eq ""} {return}
			if {[lindex $msg 2] eq ""} {return}

			#
			# Somebody doesn't like to receive too
			# many letters.
			#
			# Store the new filter definition in the
			# filters list for the extension.
			#
			if {[dict exists $exts $ch filter]} {
				set lst [dict get $exts $ch filter]
				lappend lst [lindex $msg 2]
			} else {
				set lst [::list [lindex $msg 2]]
			}

			dict set exts $ch filter $lst

			#
			# Confirm the request.
			#
			ack $ch $id
		} irc {
			if {[llength $msg] < 13} {return}
			if {![dict exists $exts $ch loaded]} {return}
			if {[lindex $msg 8] eq ""} {return}
			if {[lindex $msg 9] eq ""} {return}
			if {[lindex $msg 11] eq ""} {return}
			if {[lindex $msg 12] == 0} {return}

			#
			# Prepare the message to be returned to the
			# upper layer.
			#
			dict set rmsg timestamp [lindex $msg 2]
			dict set rmsg cid [lindex $msg 3]
			dict set rmsg user [lindex $msg 4]
			dict set rmsg level [lindex $msg 5]
			dict set rmsg focus [lindex $msg 6]
			dict set rmsg status [lindex $msg 7]
			dict set rmsg network [lindex $msg 8]
			dict set rmsg channel [lindex $msg 9]
			dict set rmsg tags [decodetags [lindex $msg 10]]
			dict set rmsg command [string tolower [lindex $msg 11]]
			dict set rmsg args [lrange $msg 12 end]

			#
			# Message loop check
			#
			if {[isloop $ch $rmsg]} {
				dict set rmsg loop 1
			} else {
				dict set rmsg loop 0
			}
		} default {
			return
		}
	}

	$cb $rmsg

	return 0
}

#
# The loop detection for irc messages works as follows:
#
# - The extension does not have any filter in place. It means that
# it can accept any kind of message. In this case, looping is possible.
#
# - If there is a filter in place but not for the specific message type,
# looping is impossible.
#
# - If a filter is in place for the specific message type and the specific
# command, looping is possible.
#
# - If a filter is in place only for the specified message type, it means
# looping is possible
#
proc ::exts::isloop {fd msg} {
	variable exts

	if {![dict exists $exts $fd filter]} {return 1}

	set filter [dict get $exts $fd filter]
	set type [dict get $msg type]

	if {[lsearch -nocase -exact $filter $type] == -1} {return 0}

	if {[dict exists $msg command]} {
		set cmd [dict get $msg command]
		if {[lsearch -nocase -exact $filter $cmd] != -1} {return 1}
	} else {
		return 1
	}

	return 0
}

proc ::exts::encodetags {tag} {
	regsub "\t" "$tag" {\t}
}

proc ::exts::decodetags {text} {
	set tags [dict create]

	#
	# For every tag value, we decode the literals '\t' to a real
	# tab character.
	#
	foreach tag [split [regsub {\\t} "$text" "\t"] ";"] {
		set kv [split $tag "="]

		if {[llength $kv] == 1} {
			dict set tags [lindex $kv 0] ""
		} else {
			dict set tags [lindex $kv 0] [lindex $kv 1]
		}
	}

	return $tags
}

proc ::exts::writemsg {msg} {
	variable exts

	#
	# Prepare the message to send. Of course, the number of fields
	# depends on the message type. The final string is stored in
	# the strmsg variable.
	#
	switch -exact -- [dict get $msg type] {
		plumb {
			set strmsg [format "%s\tplumb\t%s\t%s\t%s\t%s\r" [newid] \
			    [dict get $msg cid] [dict get $msg network] \
			    [dict get $msg channel] [dict get $msg data]]
		} irc {
			set tags {}

			#
			# If tags are present, add them to the message string.
			#
			if {[dict exists $msg tags]} {
				dict for {key val} [dict get $msg tags] {
					if {[string length $val] > 0} {
						lappend tags [format "%s=%s" \
						    $key [encodetags $val]]
					} else {
						lappend tags "$key"
					}
				}
			}

			set strmsg [format "%s\tirc\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\r" \
			    [newid] [dict get $msg timestamp] \
			    [dict get $msg cid] [dict get $msg nick] \
			    [dict get $msg level] [dict get $msg focus] [dict get $msg status] \
			    [dict get $msg network] [dict get $msg channel] \
			    [join $tags ";"] [dict get $msg command] \
			    [concat [dict get $msg args]]]
		} default {
			return 0
		}
	}

	#
	# Send the message to every extension that can accept it,
	# based on it's filter policies.
	#
	dict for {ch ext} $exts {
		if {![dict get $ext loaded]} {continue}

		if {[dict exists $ext filter]} {
			set filter [dict get $exts $ch filter]
			set type [dict get $msg type]

			if {[lsearch -nocase -exact $filter $type] == -1} {
				continue
			}

			if {"$type" eq "irc"} {
				set cmd [dict get $msg command]

				if {[lsearch -nocase -exact $filter $cmd] == -1} {
					continue
				}
			}
		}

		write $ch $strmsg
	}

	return 0
}

proc ::exts::load {path cb} {
	variable exts

	if {![file isfile $path]} {return}

	#
	# If the extension is already loaded, just change the callback.
	#
	dict for {fd ext} $exts {
		if {"[dict get $ext path]" eq "$path"} {
			if {![dict get $ext loaded]} {
				return -1
			}

			fileevent $fd readable "exts readmsg $fd $cb"

			return 0
		}
	}

	#
	# Start the extension subprocess.
	#
	if {![catch {set fd [open "|$path" r+]} errstr]} {
		#
		# Use the callback if it has data in it's standard output.
		#
		fileevent $fd readable "exts readmsg $fd $cb"

		#
		# Avoid potential blocking
		#
		fconfigure $fd -blocking 0

		dict set exts $fd path $path
		dict set exts $fd loaded 0

		#
		# If the extension doesn't complete the handshake in
		# time ($::exttimeoutms), we will kill it and report the
		# problem.
		#
		dict set exts $fd timeout [after $::exttimeoutms \
		    "::exts::killext $fd \"$path failed handshake\""]

		#
		# Start the handshake sequence.
		#
		handshake $fd

		return $fd
	} else {
		log console "::exts::load: $errstr"

		return -1
	}
}

proc ::exts::ack {ch id {msg "ok"}} {
	set msg [format "%s\tack\t%s\r" $id $msg]

	write $ch $msg
}

proc ::exts::nack {ch id {msg "ko"}} {
	set msg [format "%s\tnack\t%s\r" $id $msg]

	write $ch $msg
}

proc ::exts::handshake {ch} {
	variable acks

	set id [newid]

	set msg [format "%s\thandshake\t%s\t%s\t%s\r" $id $::extproto \
	    $::progname $::version]

	dict set acks "$id$ch" chan $ch type handshake

	write $ch $msg
}

proc ::exts::write {ch msg} {
	if {[catch {puts $ch [encoding convertto utf-8 $msg]; flush $ch} errstr]} {
		log console $errstr
	}
}

proc ::exts::killbyname {name} {
	variable exts

	dict for {ch ext} $exts {
		if {"[dict get $exts $ch name]" eq "$name"} {
			killext $ch

			return
		}
	}
}

proc ::exts::bootstrapped {} {
	variable exts

	#
	# Check if all the extensions loaded at init, completed the
	# the bootstrap process. If an extension exited due to a
	# problem, it's removed from the list.
	#
	# If the bootstrap process is still ongoing, schedule the check
	# again.
	#
	dict for {ch ext} [dict get $exts] {
		if {[dict get $ext loaded] == 0} {
			after $::bootpollingms "exts bootstrapped"

			return
		}
	}

	#
	# Write down the list of loaded extensions.
	#
	# This complete the bootstrap process.
	#
	if {![catch {set fd [open $::cfgdir/extensions.conf w 0640]} errstr]} {
		dict for {ch ext} [dict get $exts] {
			puts $fd [dict get $ext path]
		}

		close $fd
	} else {
		log console "::exts::bootstrapped: $errstr"
	}

	#
	# Bootstrap is done, proceed with the application's init.
	# We are vwaiting on ::extsloaded.
	#
	set ::extsloaded 1
}

proc ::exts::killext {ch {message ""}} {
	variable exts
	variable acks

	if {![dict exists $exts $ch]} {return 0}

	if {"$message" ne ""} {log console $message}

	#
	# Make the extension's channel not readable
	#
	fileevent $ch readable {}

	#
	# Unload the extension from the main dictionary.
	#
	dict unset exts $ch

	#
	# Remove any outstanding ack of the killed extension.
	#
	dict for {id ack} $acks {
		if {"[dict get $ack chan]" eq "$ch"} {
			dict unset acks "$id"
		}
	}

	#
	# Remove the extension from the init list.
	#
	if {![catch {set fd [open $::cfgdir/extensions.conf w 0640]} errstr]} {
		dict for {des ext} [dict get $exts] {
			puts $fd [dict get $ext path]
		}

		close $fd
	} else {
		log console "::exts::killext: $errstr"
	}

	#
	# Close the communication with the extension process.
	#
	if {[catch {close $ch} errstr]} {
		log console "::exts::killext: $errstr"
	}
}
