#!/usr/bin/env python3
# Regenerates the "Predicate reference" section of README.md.
#
#   make && ./util/gen_reference.py > /tmp/ref.md
#
# Signatures, arity, ISO and evaluable flags come from `help/0` in the
# freshly built tpl, so the table cannot drift from the build. The
# grouping comes from which source file declares each builtin, since
# module_help/1 only attributes the library predicates (3 modules, 131
# of 548) and leaves the C builtins unattributed.
#
# Descriptions are deliberately absent: only 31 of the 145 ':- help/2'
# declarations in library/ carry a desc(...), so there is no prose to
# emit for most entries. When that changes this script should grow a
# third column rather than the README growing by hand.

import re, os, sys, glob, json, subprocess, collections

TPL = os.environ.get('TPL', './tpl')

CAT = {
 'bif_predicates.c':'Core & terms', 'bif_control.c':'Control',
 'bif_functions.c':'Arithmetic', 'bif_streams.c':'Streams & I/O',
 'bif_format.c':'Formatting', 'bif_database.c':'Database',
 'bif_sort.c':'Sorting', 'bif_maps.c':'Maps', 'bif_bboard.c':'Blackboard',
 'bif_atts.c':'Attributed variables', 'bif_tabling.c':'Tabling',
 'bif_threads.c':'Threads', 'bif_tasks.c':'Coroutining',
 'bif_net.c':'Networking', 'bif_os.c':'Operating system',
 'bif_posix.c':'POSIX time', 'bif_sregex.c':'Regular expressions',
 'bif_csv.c':'CSV', 'bif_ffi.c':'Foreign function interface',
}

def declarations():
	decl = {}
	for f in glob.glob('src/*.c'):
		for l in open(f, errors='ignore'):
			m = re.match(r'\s*\{"([^"]+)",\s*(\d+),', l)
			if m: decl.setdefault((m.group(1), int(m.group(2))), os.path.basename(f))
	for f in glob.glob('library/*.pl'):
		for l in open(f, errors='ignore'):
			# The head is an atom for a zero-arity predicate and a
			# compound otherwise, so the character after the name says
			# which: '(' opens an argument list, ',' means there is none.
			m = re.search(r':-\s*help\(([a-z_]\w*)\s*([(,])', l)
			if not m: continue
			if m.group(2) == ',':
				decl.setdefault((m.group(1), 0), 'library/' + os.path.basename(f))
				continue
			args = l[l.index('(', l.index(m.group(1))) + 1:]
			depth, i = 1, 0
			for i, ch in enumerate(args):
				if ch == '(': depth += 1
				elif ch == ')':
					depth -= 1
					if depth == 0: break
			inner = args[:i]
			ar = 0 if not inner.strip() else inner.count(',') + 1
			decl.setdefault((m.group(1), ar), 'library/' + os.path.basename(f))
	return decl

def category(src):
	if src in CAT: return CAT[src]
	if src.startswith('library/'): return 'library(' + src[8:-3] + ')'
	return 'Other'

def anchor(s):
	return re.sub(r'[^a-z0-9\- ]', '', s.lower()).replace(' ', '-')

def main():
	if not os.path.exists(TPL):
		sys.exit(f"{TPL} not found - run make first")

	# `help` only reports what is loaded, and most libraries are not
	# autoloaded - so their predicates were missing from the reference
	# even when documented. Load the ones that document themselves: a
	# ':- help/2' in the file is the signal that its predicates belong
	# here, and it keeps the FFI wrappers out, where raylib alone would
	# add 700-odd entries nobody wants inline. Best effort, since a few
	# need a shared library that may not be installed.

	libs = sorted(os.path.basename(f)[:-3] for f in glob.glob('library/*.pl')
		if re.search(r'^:-\s*help\(', open(f, errors='ignore').read(), re.M))
	goal = ''.join("catch(use_module(library(%s)),_,true)," % l for l in libs)
	out = subprocess.run([TPL, '-q', '-g', goal + 'help,halt'],
		capture_output=True, text=True).stdout
	decl = declarations()
	rows, seen = [], set()

	for l in out.splitlines():
		m = re.match(r'^([^:]+):\s*(.*)$', l.strip())
		if not m: continue
		pi, rest = m.group(1), m.group(2)
		if pi.startswith('$'): continue			# internal
		iso, ev = '[ISO]' in rest, '[EVALUABLE]' in rest
		sig = rest.replace('[ISO]', '').replace('[EVALUABLE]', '').strip()
		if (pi, sig) in seen: continue			# re-exports list twice
		seen.add((pi, sig))
		name, _, ar = pi.rpartition('/')
		rows.append((pi, sig, iso, ev,
			category(decl.get((name, int(ar)) if ar.isdigit() else ('', 0), ''))))

	groups = collections.OrderedDict()
	for r in sorted(rows, key=lambda r: (r[0].split('/')[0].lower(), r[0])):
		groups.setdefault(r[4], []).append(r)

	order = [c for c in CAT.values() if c in groups] \
		+ sorted(c for c in groups if c.startswith('library(')) \
		+ (['Other'] if 'Other' in groups else [])

	out = []
	p = out.append

	# Setext headings to match the rest of README.md.
	p("Predicate reference")
	p("===================\n")
	p(f"{len(rows)} predicates — {sum(1 for r in rows if r[2])} ISO, "
	  f"{sum(1 for r in rows if r[3])} evaluable. Generated by")
	p("`util/gen_reference.py` from `help/0` in the built binary, so it cannot")
	p("drift from the build. Regenerate on release rather than editing by hand.\n")
	p("Jump to: " + " · ".join(f"[{c}](#{anchor(c)})" for c in order) + "\n")

	for c in order:
		g = groups[c]

		# A real heading per group, not just <summary>: GitHub only
		# generates anchors for headings, so the jump list above needs
		# these to exist or every link in it is dead.

		p(f"### {c}\n")
		p(f'<details markdown="1">')
		p(f"<summary>{len(g)} predicates</summary>\n")
		p("| Predicate | Template | |")
		p("|---|---|---|")
		for pi, sig, iso, ev, _ in g:
			tags = ' '.join(t for t, f in (('ISO', iso), ('evaluable', ev)) if f)
			p(f"| `{pi}` | `{sig}` | {tags} |")
		p("\n</details>\n")

	return '\n'.join(out)

BEGIN = "<!-- BEGIN GENERATED PREDICATE REFERENCE -->"
END   = "<!-- END GENERATED PREDICATE REFERENCE -->"

def splice(path, body):
	"""Replace whatever sits between the markers, in place."""
	txt = open(path).read()
	block = f"{BEGIN}\n\n{body}\n{END}"

	if BEGIN in txt and END in txt:
		pre = txt[:txt.index(BEGIN)]
		post = txt[txt.index(END) + len(END):]
		new_txt, how = pre + block + post, "replaced"
	else:
		# First run: append the markers rather than guessing a location.
		new_txt, how = txt.rstrip('\n') + "\n\n" + block + "\n", \
			"appended (markers were not present)"

	# Only write when something actually changed. GNUmakefile has
	#
	#     tpl: $(OBJECTS) README.md LICENSE
	#
	# so touching README.md forces a relink. Rewriting it unconditionally
	# would mean every `make reference` left the tree needing a rebuild,
	# even when the table came out identical.

	if new_txt == txt:
		return "unchanged"

	open(path, 'w').write(new_txt)
	return how

if __name__ == '__main__':
	body = main()

	if len(sys.argv) > 1 and sys.argv[1] == '--in-place':
		target = sys.argv[2] if len(sys.argv) > 2 else 'README.md'
		print(f"{target}: {splice(target, body)}", file=sys.stderr)
	else:
		print(body)
