#!/usr/bin/env -S python3 -B
#
#   ft8follow - watch spots across one or more receivers.
#
#   Copyright (C) 2025 by Matt Roberts.
#   License: GNU GPL3 (www.gnu.org)
#
#

#
#  TODO:
#     1. use 'mycall' to filter out my own transmissions
#     2. use 'mygrid' to calculate and display disatance and bearing
#     3. add to Makefile install targets
#

# system modules
import getopt
import types # for SimpleNamespace
import time
import json
import sys
import os
import io

# local modules
from version import *   # version number string
from parsing import *   # for FT8 parsing utilities

# used to follow the all.txt
import tailfile


# the state table ( call -> stuff )
table = { }

# the expiration value
expire = 24 * 3600 # 1 day

# verbose output flag
verbose = False


#
#  usage()
#
def usage():
	sys.stdout.write("Usage: %s [-c mycall][-g mygrid][-e expire_sec][-d][-v] <file>\n" % os.path.basename(sys.argv[0]))
	sys.exit(1)


#
#  parse_decode(...)
#
def parse_decode(line):
	result = types.SimpleNamespace()
	line = line.upper().strip()
	parts = line.split(maxsplit=7)
	result.when = parts[0]
	result.rf = parts[1]
	result.dir = parts[2]
	result.mode = parts[3]
	result.snr = parts[4]
	result.df = parts[5]
	result.af = parts[6]
	result.raw = parts[7]
	result.cq = False
	result.fr = None
	result.to = None
	result.grid = None
	result.what = None

	# convert WSJT-X style date/time to Unix time
	if '_' in result.when:
		# parse out the date and time substrings
		_date, _time = result.when.split('_')

		# now convert to Unix time
		s = _time[4:6].strip()
		if not s:
			s = '00'
		t = (
			2000 + int(_date[0:2]), # Y
			int(_date[2:4]),        # M
			int(_date[4:6]),        # D
			int(_time[0:2]),        # h
			int(_time[2:4]),        # m
			int(s),                 # s
			-1, -1, 0)
		result.when = int(time.mktime(t)) - time.timezone

	# trim off decoder confidence stuff
	for i in [ 'a1', 'a2', 'a3', 'a4', ' ?' ]:
		if line.endswith(i):
			line = line[:-len(i)].strip()

	# pick apart the message to get calls and frame content
	calls = [ ]
	for i in result.raw.split():
		if i == 'CQ' or i == 'CQDX' or i == 'CQFD':
			result.cq = True
		elif iscall(i):
			calls.append(i)
		elif isgrid(i):
			result.grid = i
			result.what = i
		elif isreport(i) or isroger(i) or is73(i):
			result.what = i
	if len(calls) == 1:
		if result.cq:
			result.fr = calls[0]
		else:
			result.to = calls[0]
	elif len(calls) == 2:
		result.to = calls[0]
		result.fr = calls[1]

	# done
	return result


#
#  process_line(line)
#
def process_line(line):
	global table, expire, verbose

	# process expiration
	now = time.time()
	if expire > 0:
		old = [ ]
		for call in table.keys():
			if table[call].updated <= (now - expire):
				old.append(call)
		if verbose and old:
			sys.stderr.write("DEBUG: Purging %d old records of %d total.\n" % (len(old), len(table)))
		for call in old:
			del table[call]

	# break the record into its components
	d = parse_decode(line)

	# skip certain frames
	if not d.fr:
		return
	if d.dir != 'RX':
		return

	new = False
	updated = False
	node = None
	d.updated = now
	call = d.fr
	if call and call[0] == '<' and call[-1] == '>':
		call = call[1:-1]
	if call == '...':
		return
	when = int(d.when)

	# clean up the processed node
	del(d.raw)
	del(d.dir)
	del(d.what)
	del(d.when)
	del(d.cq)
	del(d.to)
	del(d.fr)
	del(d.af)

	# clean up the call
	if '/' in call:
		callparts = call.split('/')
		if len(callparts) == 3:
			callparts = callparts[:-1]
		toss = [ 'R', 'AG', 'AE', 'QRP', 'DX' ]
		if callparts[-1] in toss:
			callparts = callparts[:-1]
		call = '/'.join(callparts)
	if not call:
		return

	# add new entry
	if not call in table.keys():
		new = True
		node = table[call] = d
		node.rf = [ node.rf ]

	# overlay updates
	if not new:
		node = table[call]
		if d.grid and not node.grid:
			node.grid = d.grid
			updated = True
		if d.rf not in node.rf:
			node.rf.append(d.rf)
			updated = True
		if d.mode and d.mode != node.mode:
			node.mode = d.mode
			updated = True
		node.snr = d.snr
		node.df = d.df
	
	# print the call if it is new or updated
	if new or updated:
		#
		#  TODO: add more fields:
		#        + distance and bearing
		#        + extract base call
		#        + convert freq to band
		#
		sys.stdout.write("%s %10s %8s%6s%7s%6s\n" % (
			time.strftime("%H:%M:%S", time.gmtime(when)),
			call,
			node.rf[-1],
			node.mode,
			node.grid if node.grid else "",
			"New" if new else ""))


#
#  process_error(ex)
#
def process_error(ex):
	sys.stderr.write("Error: %s\n" % str(ex))


#
#  main()
#
def main():
	global expire, verbose

	# verbose flag
	verbose = False

	# read the command line
	optlist, cmdline = getopt.getopt(sys.argv[1:], 'c:g:dve:')
	for opt in optlist:
		if opt[0] == '-d':
			verbose = True
		elif opt[0] == '-e':
			expire = int(opt[1])
		elif opt[0] == '-v':
			sys.stdout.write("ft8follow version %s\n" % GetModemVersion())
			return 0
		elif opt[0] == '-c':
			config.mycall = opt[1].upper()
		elif opt[0] == '-g':
			config.mygrid = opt[1].upper()[0:4] # first four digits only

	# make sure file was given
	if not cmdline or len(cmdline) != 1:
		usage()

	# load previous state if available
	fn = os.path.join(os.path.expanduser('~'), '.cache', 'ft8follow.json')
	if os.path.isfile(fn):
		j = None
		with io.open(fn, 'r') as f:
			j = json.load(f)
		for call in j.keys():
			item = j[call]
			table[call] = types.SimpleNamespace(**item)

	# config
	config = types.SimpleNamespace()
	config.mycall = ''
	config.mygrid = ''

	# load settings
	lines = [ ]
	fn = os.path.join(os.path.expanduser('~'), '.ft8followrc')
	if os.path.exists(fn):
		with io.open(fn, 'r') as f:
			lines = f.readlines()

	# parse settings
	for line in lines:
		parts = line.split()
		if len(parts) == 2:
			if parts[0] == 'MYCALL':
				config.mycall = parts[1]
			elif parts[0] == 'MYGRID':
				config.mygrid = parts[1]

	# watch the file
	try:
		tailfile.tailfile(cmdline[0], callback=process_line, ecallback=process_error)
	except KeyboardInterrupt:
		pass # just eat Control-C

	# save configuration
	fn = os.path.join(os.path.expanduser('~'), '.ft8followrc')
	with io.open(fn, 'w') as f:
		f.write("MYCALL %s\n" % config.mycall)
		f.write("MYGRID %s\n" % config.mygrid)
	
	# convert the cache into simple types
	for call in table.keys():
		if type(table[call]) is types.SimpleNamespace:
			table[call] = vars(table[call])

	# save the cache to file
	cache_dir = os.path.join(os.path.expanduser('~'), '.cache')
	if not os.path.isdir(cache_dir):
		try:
			os.mkdir(cache_dir, 0o700)
		except:
			sys.exit(0)
	fn = os.path.join(os.path.expanduser('~'), '.cache', 'ft8follow.json')
	with io.open(fn, 'w') as f:
		json.dump(table, f, indent=4)

	# done
	sys.exit(0)


#
#  entry point
#
if __name__ == '__main__':
	main()

# EOF
