/* xmppcb - a lean XMPP AI chatbot in C.
 *
 * Joins MUC rooms, logs messages, and answers @bot commands via an
 * OpenAI-compatible LLM HTTP API. Transport is TLS (STARTTLS for XMPP,
 * HTTPS for the LLM); there is no end-to-end / OMEMO encryption.
 *
 * One dependency: OpenSSL. Everything else is hand-rolled.
 */

#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L
#endif

#include <ctype.h>
#include <dirent.h>
#include <errno.h>
#include <netdb.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <time.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>

#include <openssl/err.h>
#include <openssl/ssl.h>
#include <openssl/x509v3.h>

#include "config.h"

#define NROOMS ((int)(sizeof(ROOMS) / sizeof(ROOMS[0])))

/* buffer sizes */
#define INBUF        65536
#define STANZABUF    66000
#define BODYBUF      16384
#define CTXBUF       24576
#define PROMPTBUF    32768
#define INSTRBUF     8192
#define ESCBUF       98304
#define JSONBUF      131072
#define REQBUF       140000
#define HTTPBUF      98304
#define LLMOUT       8192
#define CMDOUT       4096
#define CTX_FILEBUF  131072
#define CTX_MAXLINES 4000

/* run_session() return codes */
enum { SESS_RETRY = 0, SESS_AUTHFAIL = 1 };

/* ------------------------------------------------------------------ */
/* globals                                                            */
/* ------------------------------------------------------------------ */

static SSL_CTX *g_ctx;
static const char *g_password;
static const char *g_apikey;
static char g_domain[256];
static char g_localpart[128];
static char g_instruction[INSTRBUF];

static char inbuf[INBUF];
static size_t inlen;
static char stanza[STANZABUF];

typedef struct { int fd; SSL *ssl; } Conn;

static void die(const char *msg) {
	fprintf(stderr, "xmppcb: %s\n", msg);
	exit(1);
}

/* ------------------------------------------------------------------ */
/* small utilities                                                    */
/* ------------------------------------------------------------------ */

/* case-insensitive substring search */
static const char *ci_strstr(const char *hay, const char *needle) {
	size_t nl = strlen(needle);
	if (!nl) return hay;
	for (; *hay; hay++)
		if (strncasecmp(hay, needle, nl) == 0)
			return hay;
	return NULL;
}

static size_t utf8_encode(char *d, size_t dsz, unsigned cp) {
	if (cp < 0x80) {
		if (dsz < 1) return 0;
		d[0] = (char)cp;
		return 1;
	}
	if (cp < 0x800) {
		if (dsz < 2) return 0;
		d[0] = (char)(0xC0 | (cp >> 6));
		d[1] = (char)(0x80 | (cp & 0x3F));
		return 2;
	}
	if (cp < 0x10000) {
		if (dsz < 3) return 0;
		d[0] = (char)(0xE0 | (cp >> 12));
		d[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
		d[2] = (char)(0x80 | (cp & 0x3F));
		return 3;
	}
	if (dsz < 4) return 0;
	d[0] = (char)(0xF0 | (cp >> 18));
	d[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
	d[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
	d[3] = (char)(0x80 | (cp & 0x3F));
	return 4;
}

static const char B64[] =
	"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

static size_t b64encode(char *dst, const unsigned char *src, size_t n) {
	size_t o = 0, i = 0;
	while (i + 3 <= n) {
		unsigned v = (src[i] << 16) | (src[i + 1] << 8) | src[i + 2];
		dst[o++] = B64[(v >> 18) & 63];
		dst[o++] = B64[(v >> 12) & 63];
		dst[o++] = B64[(v >> 6) & 63];
		dst[o++] = B64[v & 63];
		i += 3;
	}
	if (n - i == 1) {
		unsigned v = src[i] << 16;
		dst[o++] = B64[(v >> 18) & 63];
		dst[o++] = B64[(v >> 12) & 63];
		dst[o++] = '=';
		dst[o++] = '=';
	} else if (n - i == 2) {
		unsigned v = (src[i] << 16) | (src[i + 1] << 8);
		dst[o++] = B64[(v >> 18) & 63];
		dst[o++] = B64[(v >> 12) & 63];
		dst[o++] = B64[(v >> 6) & 63];
		dst[o++] = '=';
	}
	dst[o] = '\0';
	return o;
}

/* escape text for inclusion in XML element/attribute content */
static void xml_escape(char *dst, size_t dsz, const char *src) {
	size_t o = 0;
	for (; *src; src++) {
		const char *r = NULL;
		switch (*src) {
		case '&':  r = "&amp;";  break;
		case '<':  r = "&lt;";   break;
		case '>':  r = "&gt;";   break;
		case '"':  r = "&quot;"; break;
		case '\'': r = "&apos;"; break;
		}
		if (r) {
			size_t l = strlen(r);
			if (o + l >= dsz) break;
			memcpy(dst + o, r, l);
			o += l;
		} else {
			if (o + 1 >= dsz) break;
			dst[o++] = *src;
		}
	}
	dst[o] = '\0';
}

/* resolve XML entities in src into dst */
static void xml_unescape(char *dst, size_t dsz, const char *src) {
	size_t o = 0;
	while (*src && o + 1 < dsz) {
		if (*src == '&') {
			if (!strncmp(src, "&amp;", 5))  { dst[o++] = '&';  src += 5; continue; }
			if (!strncmp(src, "&lt;", 4))   { dst[o++] = '<';  src += 4; continue; }
			if (!strncmp(src, "&gt;", 4))   { dst[o++] = '>';  src += 4; continue; }
			if (!strncmp(src, "&quot;", 6)) { dst[o++] = '"';  src += 6; continue; }
			if (!strncmp(src, "&apos;", 6)) { dst[o++] = '\''; src += 6; continue; }
			if (src[1] == '#') {
				int base = 10;
				const char *q = src + 2;
				if (*q == 'x' || *q == 'X') { base = 16; q++; }
				char *endp;
				long cp = strtol(q, &endp, base);
				if (endp != q && *endp == ';' && cp > 0) {
					o += utf8_encode(dst + o, dsz - 1 - o, (unsigned)cp);
					src = endp + 1;
					continue;
				}
			}
		}
		dst[o++] = *src++;
	}
	dst[o] = '\0';
}

/* escape a string for use as a JSON string value */
static void json_escape(char *dst, size_t dsz, const char *src) {
	size_t o = 0;
	for (; *src; src++) {
		unsigned char ch = (unsigned char)*src;
		char tmp[8];
		const char *r;
		size_t rl;
		switch (ch) {
		case '"':  r = "\\\""; rl = 2; break;
		case '\\': r = "\\\\"; rl = 2; break;
		case '\n': r = "\\n";  rl = 2; break;
		case '\r': r = "\\r";  rl = 2; break;
		case '\t': r = "\\t";  rl = 2; break;
		case '\b': r = "\\b";  rl = 2; break;
		case '\f': r = "\\f";  rl = 2; break;
		default:
			if (ch < 0x20) {
				snprintf(tmp, sizeof tmp, "\\u%04x", ch);
				r = tmp;
				rl = 6;
			} else {
				tmp[0] = (char)ch;
				r = tmp;
				rl = 1;
			}
		}
		if (o + rl >= dsz) break;
		memcpy(dst + o, r, rl);
		o += rl;
	}
	dst[o] = '\0';
}

/* extract a JSON string value for "key" from json into out */
static int json_get_string(const char *json, const char *key,
                            char *out, size_t osz) {
	char pat[64];
	snprintf(pat, sizeof pat, "\"%s\"", key);
	const char *p = strstr(json, pat);
	if (!p) return 0;
	p += strlen(pat);
	while (*p && *p != ':') p++;
	if (*p != ':') return 0;
	p++;
	while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
	if (*p != '"') return 0;
	p++;
	size_t o = 0;
	while (*p && *p != '"') {
		if (*p == '\\') {
			p++;
			if (!*p) break;
			switch (*p) {
			case 'n': if (o + 1 < osz) out[o++] = '\n'; break;
			case 't': if (o + 1 < osz) out[o++] = '\t'; break;
			case 'r': if (o + 1 < osz) out[o++] = '\r'; break;
			case 'b': if (o + 1 < osz) out[o++] = '\b'; break;
			case 'f': if (o + 1 < osz) out[o++] = '\f'; break;
			case '/': if (o + 1 < osz) out[o++] = '/'; break;
			case '"': if (o + 1 < osz) out[o++] = '"'; break;
			case '\\': if (o + 1 < osz) out[o++] = '\\'; break;
			case 'u': {
				unsigned cp = 0;
				for (int i = 1; i <= 4 && p[i]; i++) {
					char c = p[i];
					cp <<= 4;
					if (c >= '0' && c <= '9') cp |= (unsigned)(c - '0');
					else if (c >= 'a' && c <= 'f') cp |= (unsigned)(c - 'a' + 10);
					else if (c >= 'A' && c <= 'F') cp |= (unsigned)(c - 'A' + 10);
				}
				p += 4;
				if (cp >= 0xD800 && cp <= 0xDBFF &&
				    p[1] == '\\' && p[2] == 'u') {
					unsigned lo = 0;
					for (int i = 3; i <= 6 && p[i]; i++) {
						char c = p[i];
						lo <<= 4;
						if (c >= '0' && c <= '9') lo |= (unsigned)(c - '0');
						else if (c >= 'a' && c <= 'f') lo |= (unsigned)(c - 'a' + 10);
						else if (c >= 'A' && c <= 'F') lo |= (unsigned)(c - 'A' + 10);
					}
					cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
					p += 6;
				}
				o += utf8_encode(out + o, osz - 1 - o, cp);
				break;
			}
			default:
				if (o + 1 < osz) out[o++] = *p;
			}
			p++;
		} else {
			if (o + 1 < osz) out[o++] = *p;
			p++;
		}
	}
	out[o] = '\0';
	return 1;
}

/* decode HTTP chunked transfer encoding */
static void dechunk(const char *src, char *dst, size_t dsz) {
	size_t o = 0;
	while (*src) {
		char *end;
		long sz = strtol(src, &end, 16);
		if (end == src || sz <= 0) break;
		src = strchr(end, '\n');
		if (!src) break;
		src++;
		for (long i = 0; i < sz && *src && o + 1 < dsz; i++)
			dst[o++] = *src++;
		if (*src == '\r') src++;
		if (*src == '\n') src++;
	}
	dst[o] = '\0';
}

static void rtrim(char *s) {
	size_t n = strlen(s);
	while (n && isspace((unsigned char)s[n - 1])) s[--n] = '\0';
}

/* ------------------------------------------------------------------ */
/* minimal XML stanza parsing                                         */
/* ------------------------------------------------------------------ */

/* Length of the first complete XML chunk in b[0..n): an <?xml?> decl,
 * the <stream:stream> open tag, or a full top-level stanza. 0 if more
 * data is needed. */
static size_t xml_chunk_len(const char *b, size_t n) {
	size_t i = 0;
	while (i < n && (b[i] == ' ' || b[i] == '\t' ||
	                 b[i] == '\r' || b[i] == '\n'))
		i++;
	if (i >= n || b[i] != '<') return 0;

	if (i + 1 < n && b[i + 1] == '?') {        /* <?xml ... ?> */
		for (size_t j = i + 2; j + 1 < n; j++)
			if (b[j] == '?' && b[j + 1] == '>')
				return j + 2;
		return 0;
	}

	int depth = 0;
	size_t j = i;
	while (j < n) {
		if (b[j] != '<') { j++; continue; }
		int closing = (j + 1 < n && b[j + 1] == '/');
		char q = 0;
		size_t k = j + 1;
		while (k < n) {
			char ch = b[k];
			if (q) { if (ch == q) q = 0; }
			else if (ch == '\'' || ch == '"') q = ch;
			else if (ch == '>') break;
			k++;
		}
		if (k >= n) return 0;                  /* tag incomplete */
		int selfclose = (b[k - 1] == '/');
		if (depth == 0 && !closing && !selfclose &&
		    k - (j + 1) >= 13 &&
		    memcmp(b + j + 1, "stream:stream", 13) == 0)
			return k + 1;                      /* stream open */
		if (closing) depth--;
		else if (!selfclose) depth++;
		j = k + 1;
		if (depth <= 0) return j;              /* stanza complete */
	}
	return 0;
}

/* name of the root element of s into out */
static void root_tag(const char *s, char *out, size_t osz) {
	size_t o = 0;
	while (*s && *s != '<') s++;
	if (*s == '<') s++;
	while (*s && *s != ' ' && *s != '\t' && *s != '\r' &&
	       *s != '\n' && *s != '>' && *s != '/' && o + 1 < osz)
		out[o++] = *s++;
	out[o] = '\0';
}

/* value of attribute `name` from the root tag of stanza */
static int attr_get(const char *st, const char *name,
                    char *out, size_t osz) {
	out[0] = '\0';
	const char *end = st;
	char q = 0;
	while (*end) {                              /* end of root open tag */
		if (q) { if (*end == q) q = 0; }
		else if (*end == '\'' || *end == '"') q = *end;
		else if (*end == '>') break;
		end++;
	}
	size_t nl = strlen(name);
	for (const char *p = st; p < end; p++) {
		if ((*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') &&
		    p + 1 + nl < end &&
		    strncmp(p + 1, name, nl) == 0) {
			const char *a = p + 1 + nl;
			while (a < end && (*a == ' ' || *a == '\t')) a++;
			if (a < end && *a == '=') {
				a++;
				while (a < end && (*a == ' ' || *a == '\t')) a++;
				if (a < end && (*a == '\'' || *a == '"')) {
					char qq = *a++;
					size_t o = 0;
					while (a < end && *a != qq && o + 1 < osz)
						out[o++] = *a++;
					out[o] = '\0';
					return 1;
				}
			}
		}
	}
	return 0;
}

/* text content of the first <name>...</name> child element */
static int elem_text(const char *st, const char *name,
                     char *out, size_t osz) {
	out[0] = '\0';
	size_t nl = strlen(name);
	const char *p = st;
	while ((p = strchr(p, '<'))) {
		if (strncmp(p + 1, name, nl) == 0) {
			char after = p[1 + nl];
			if (after == ' ' || after == '\t' || after == '\r' ||
			    after == '\n' || after == '>' || after == '/') {
				const char *gt = p + 1;
				char q = 0;
				while (*gt) {
					if (q) { if (*gt == q) q = 0; }
					else if (*gt == '\'' || *gt == '"') q = *gt;
					else if (*gt == '>') break;
					gt++;
				}
				if (*gt != '>') return 0;
				if (gt > p && gt[-1] == '/') return 1;  /* empty */
				const char *txt = gt + 1;
				char close[64];
				snprintf(close, sizeof close, "</%s", name);
				const char *ce = strstr(txt, close);
				if (!ce) return 0;
				size_t len = (size_t)(ce - txt);
				if (len >= osz) len = osz - 1;
				memcpy(out, txt, len);
				out[len] = '\0';
				return 1;
			}
		}
		p++;
	}
	return 0;
}

/* ------------------------------------------------------------------ */
/* connection: TCP + optional TLS                                     */
/* ------------------------------------------------------------------ */

static int conn_write(Conn *c, const char *buf, size_t len) {
	size_t off = 0;
	while (off < len) {
		int n = c->ssl
		        ? SSL_write(c->ssl, buf + off, (int)(len - off))
		        : (int)write(c->fd, buf + off, len - off);
		if (n <= 0) return -1;
		off += (size_t)n;
	}
	return 0;
}

static int conn_puts(Conn *c, const char *s) {
	return conn_write(c, s, strlen(s));
}

static int conn_read_some(Conn *c, char *buf, size_t len) {
	return c->ssl ? SSL_read(c->ssl, buf, (int)len)
	              : (int)read(c->fd, buf, len);
}

static int tcp_connect(const char *host, int port) {
	char portstr[16];
	snprintf(portstr, sizeof portstr, "%d", port);
	struct addrinfo hints, *res, *rp;
	memset(&hints, 0, sizeof hints);
	hints.ai_family = AF_UNSPEC;
	hints.ai_socktype = SOCK_STREAM;
	if (getaddrinfo(host, portstr, &hints, &res) != 0)
		return -1;
	int fd = -1;
	for (rp = res; rp; rp = rp->ai_next) {
		fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
		if (fd < 0) continue;
		if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) break;
		close(fd);
		fd = -1;
	}
	freeaddrinfo(res);
	if (fd >= 0) {
		struct timeval tv = { 60, 0 };
		setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
	}
	return fd;
}

static void tls_init(void) {
	g_ctx = SSL_CTX_new(TLS_client_method());
	if (!g_ctx) die("SSL_CTX_new failed");
	SSL_CTX_set_min_proto_version(g_ctx, TLS1_2_VERSION);
#if TLS_VERIFY
	if (!SSL_CTX_set_default_verify_paths(g_ctx))
		fprintf(stderr, "xmppcb: warning: no system CA certificates\n");
#endif
}

static SSL *tls_wrap(int fd, const char *host) {
	SSL *ssl = SSL_new(g_ctx);
	if (!ssl) return NULL;
	SSL_set_fd(ssl, fd);
	SSL_set_tlsext_host_name(ssl, host);
#if TLS_VERIFY
	SSL_set_verify(ssl, SSL_VERIFY_PEER, NULL);
	X509_VERIFY_PARAM *vp = SSL_get0_param(ssl);
	X509_VERIFY_PARAM_set_hostflags(vp, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS);
	X509_VERIFY_PARAM_set1_host(vp, host, 0);
#endif
	if (SSL_connect(ssl) != 1) {
		fprintf(stderr, "xmppcb: TLS handshake with %s failed\n", host);
		ERR_print_errors_fp(stderr);
		SSL_free(ssl);
		return NULL;
	}
	return ssl;
}

/* ------------------------------------------------------------------ */
/* stanza stream reader                                               */
/* ------------------------------------------------------------------ */

/* Pull one complete XML chunk out of inbuf into out. Returns its
 * length, or 0 if inbuf does not yet hold a full chunk. */
static size_t extract_chunk(char *out, size_t osz) {
	size_t ws = 0;
	while (ws < inlen && (inbuf[ws] == ' ' || inbuf[ws] == '\t' ||
	                      inbuf[ws] == '\r' || inbuf[ws] == '\n'))
		ws++;
	if (ws) {
		memmove(inbuf, inbuf + ws, inlen - ws);
		inlen -= ws;
	}
	size_t L = xml_chunk_len(inbuf, inlen);
	if (L == 0 || L >= osz) return 0;
	memcpy(out, inbuf, L);
	out[L] = '\0';
	memmove(inbuf, inbuf + L, inlen - L);
	inlen -= L;
	return L;
}

/* Blocking: read the next XML chunk. Used during login. */
static int next_chunk(Conn *c, char *out, size_t osz) {
	for (;;) {
		size_t L = extract_chunk(out, osz);
		if (L) return (int)L;
		if (inlen >= sizeof inbuf) return -1;
		int n = conn_read_some(c, inbuf + inlen, sizeof inbuf - inlen);
		if (n <= 0) return -1;
		inlen += (size_t)n;
	}
}

/* Read chunks until one whose root element is `want`. */
static int await_tag(Conn *c, const char *want) {
	char tag[64];
	for (int i = 0; i < 50; i++) {
		if (next_chunk(c, stanza, sizeof stanza) < 0) return 0;
		root_tag(stanza, tag, sizeof tag);
		if (strcmp(tag, want) == 0) return 1;
		if (strcmp(tag, "failure") == 0 ||
		    strcmp(tag, "stream:error") == 0) return 0;
	}
	return 0;
}

/* ------------------------------------------------------------------ */
/* per-room chat history                                              */
/* ------------------------------------------------------------------ */

static void history_path(const char *room, char *out, size_t osz) {
	snprintf(out, osz, "%s/%s.log", HISTORY_DIR, room);
	for (char *p = out + strlen(HISTORY_DIR) + 1; *p; p++)
		if (*p == '/') *p = '_';
}

static void history_append(const char *room, const char *sender,
                           const char *body) {
	char path[512];
	history_path(room, path, sizeof path);
	FILE *f = fopen(path, "a");
	if (!f) return;
	fprintf(f, "%ld\t%s\t", (long)time(NULL), sender);
	for (const char *p = body; *p; p++)
		fputc((*p == '\n' || *p == '\r' || *p == '\t') ? ' ' : *p, f);
	fputc('\n', f);
	fclose(f);
}

/* Build conversation context for the LLM. If cutoff != 0, includes
 * messages at or after that time; otherwise the last HISTORY_CONTEXT
 * messages. Returns 1 if any messages were found. */
static int history_context(const char *room, time_t cutoff,
                           char *out, size_t osz) {
	static char fb[CTX_FILEBUF];
	static char *lines[CTX_MAXLINES];

	char path[512];
	history_path(room, path, sizeof path);
	FILE *f = fopen(path, "rb");
	if (!f) { out[0] = '\0'; return 0; }

	fseek(f, 0, SEEK_END);
	long fsz = ftell(f);
	long start = 0;
	if (fsz > CTX_FILEBUF - 1)
		start = fsz - (CTX_FILEBUF - 1);
	fseek(f, start, SEEK_SET);
	size_t got = fread(fb, 1, CTX_FILEBUF - 1, f);
	fclose(f);
	fb[got] = '\0';

	char *lp = fb;
	if (start > 0) {                       /* drop partial first line */
		char *nl = strchr(fb, '\n');
		if (nl) lp = nl + 1;
	}

	int nl = 0;
	for (char *p = lp; *p && nl < CTX_MAXLINES; ) {
		lines[nl++] = p;
		char *e = strchr(p, '\n');
		if (!e) break;
		*e = '\0';
		p = e + 1;
	}

	int from;
	if (cutoff == 0) {
		from = nl > HISTORY_CONTEXT ? nl - HISTORY_CONTEXT : 0;
	} else {
		from = nl;
		for (int i = 0; i < nl; i++) {
			if (atol(lines[i]) >= (long)cutoff) { from = i; break; }
		}
		if (nl - from > 600) from = nl - 600;
	}
	if (nl - from <= 0) { out[0] = '\0'; return 0; }

	size_t o = 0;
	if (cutoff == 0) {
		o += (size_t)snprintf(out + o, osz - o,
		                      "(last %d messages)\n", nl - from);
	} else {
		char tb[32];
		time_t cc = cutoff;
		strftime(tb, sizeof tb, "%Y-%m-%d %H:%M", localtime(&cc));
		o += (size_t)snprintf(out + o, osz - o, "(since %s)\n", tb);
	}

	for (int i = from; i < nl && o + 1 < osz; i++) {
		char *l = lines[i];
		char *t1 = strchr(l, '\t');
		if (!t1) continue;
		*t1 = '\0';
		char *sender = t1 + 1;
		char *t2 = strchr(sender, '\t');
		if (!t2) continue;
		*t2 = '\0';
		char *body = t2 + 1;
		time_t ts = (time_t)atol(l);
		char tb[24];
		strftime(tb, sizeof tb, "%Y-%m-%d %H:%M", localtime(&ts));
		int w = snprintf(out + o, osz - o, "[%s] %s: %s\n",
		                 tb, sender, body);
		if (w < 0 || (size_t)w >= osz - o) { out[o] = '\0'; break; }
		o += (size_t)w;
	}
	out[o] = '\0';
	return 1;
}

/* ------------------------------------------------------------------ */
/* LLM HTTP call                                                      */
/* ------------------------------------------------------------------ */

/* POST a prompt to the LLM API. On success returns 1 and the reply is
 * in out. On failure returns 0 and out holds an error message. */
static int llm_call(const char *user_prompt, char *out, size_t osz) {
	static char esc_instr[ESCBUF];
	static char esc_prompt[ESCBUF];
	static char jsonbody[JSONBUF];
	static char req[REQBUF];
	static char resp[HTTPBUF];
	static char httpbody[HTTPBUF];

	json_escape(esc_instr, sizeof esc_instr, g_instruction);
	json_escape(esc_prompt, sizeof esc_prompt, user_prompt);

	int blen = snprintf(jsonbody, sizeof jsonbody,
		"{\"model\":\"%s\",\"messages\":["
		"{\"role\":\"system\",\"content\":\"%s\"},"
		"{\"role\":\"user\",\"content\":\"%s\"}],"
		"\"max_completion_tokens\":%d}",
		LLM_MODEL, esc_instr, esc_prompt, LLM_MAX_TOKENS);
	if (blen < 0 || blen >= (int)sizeof jsonbody) {
		snprintf(out, osz, "Error: request too large");
		return 0;
	}

	int fd = tcp_connect(LLM_HOST, LLM_PORT);
	if (fd < 0) {
		snprintf(out, osz, "Error: cannot reach %s", LLM_HOST);
		return 0;
	}
	SSL *ssl = tls_wrap(fd, LLM_HOST);
	if (!ssl) {
		close(fd);
		snprintf(out, osz, "Error: TLS handshake failed");
		return 0;
	}
	Conn lc = { fd, ssl };

	int rlen = snprintf(req, sizeof req,
		"POST %s HTTP/1.1\r\n"
		"Host: %s\r\n"
		"Authorization: Bearer %s\r\n"
		"Content-Type: application/json\r\n"
		"Content-Length: %d\r\n"
		"Connection: close\r\n"
		"\r\n%s",
		LLM_PATH, LLM_HOST, g_apikey, blen, jsonbody);
	if (rlen < 0 || rlen >= (int)sizeof req ||
	    conn_write(&lc, req, (size_t)rlen) < 0) {
		SSL_free(ssl);
		close(fd);
		snprintf(out, osz, "Error: failed to send request");
		return 0;
	}

	size_t rl = 0;
	int n;
	while (rl < sizeof resp - 1 &&
	       (n = conn_read_some(&lc, resp + rl, sizeof resp - 1 - rl)) > 0)
		rl += (size_t)n;
	resp[rl] = '\0';
	SSL_shutdown(ssl);
	SSL_free(ssl);
	close(fd);

	char *hdrend = strstr(resp, "\r\n\r\n");
	if (!hdrend) {
		snprintf(out, osz, "Error: bad HTTP response");
		return 0;
	}
	*hdrend = '\0';
	char *bodyp = hdrend + 4;
	int status = 0;
	sscanf(resp, "%*s %d", &status);

	const char *bd;
	if (ci_strstr(resp, "transfer-encoding: chunked")) {
		dechunk(bodyp, httpbody, sizeof httpbody);
		bd = httpbody;
	} else {
		bd = bodyp;
	}

	if (status != 200) {
		char detail[512];
		if (!json_get_string(bd, "message", detail, sizeof detail))
			snprintf(detail, sizeof detail, "HTTP %d", status);
		snprintf(out, osz, "Error: LLM API: %s", detail);
		return 0;
	}

	const char *m = strstr(bd, "\"message\"");
	if (!json_get_string(m ? m : bd, "content", out, osz) || !out[0]) {
		snprintf(out, osz, "Error: could not parse LLM response");
		return 0;
	}
	return 1;
}

/* ------------------------------------------------------------------ */
/* commands                                                           */
/* ------------------------------------------------------------------ */

static char g_ctxbuf[CTXBUF];
static char g_prompt[PROMPTBUF];
static char g_reply[LLMOUT];
static char g_out[LLMOUT + 64];

/* Parse a period token (1h/1d/2w/1m). Returns a cutoff time, or 0. */
static time_t parse_period(const char *tok) {
	if (!tok || !*tok) return 0;
	long num = 0;
	int any = 0;
	const char *p = tok;
	while (*p >= '0' && *p <= '9') { num = num * 10 + (*p - '0'); p++; any = 1; }
	if (!any || !p[0] || p[1]) return 0;
	long secs;
	switch (*p) {
	case 'h': secs = 3600;        break;
	case 'd': secs = 86400;       break;
	case 'w': secs = 604800;      break;
	case 'm': secs = 86400L * 30; break;
	default: return 0;
	}
	return time(NULL) - num * secs;
}

static const char *help_text(void) {
	static char buf[2048];
	int n = snprintf(buf, sizeof buf,
		"Commands:\n"
		"  " CMD_PREFIX " summarize [period]  - summarise the chat\n"
		"  " CMD_PREFIX " tasks [period]      - extract action items\n"
		"  " CMD_PREFIX " <question>          - ask anything\n"
		"\n"
		"  Period: 1h 1d 2d 1w 2w 1m   (default: last 50 messages)");

	/* append the script commands found in CMD_DIR */
	DIR *d = opendir(CMD_DIR);
	if (d) {
		struct dirent *e;
		int first = 1;
		while ((e = readdir(d)) != NULL && n > 0 && n < (int)sizeof buf - 80) {
			if (e->d_name[0] == '.') continue;
			char path[512];
			struct stat stb;
			snprintf(path, sizeof path, "%s/%s", CMD_DIR, e->d_name);
			if (stat(path, &stb) != 0 || !S_ISREG(stb.st_mode)) continue;
			if (access(path, X_OK) != 0) continue;
			if (first) {
				n += snprintf(buf + n, sizeof buf - (size_t)n,
				              "\n\nScript commands:");
				first = 0;
			}
			n += snprintf(buf + n, sizeof buf - (size_t)n,
			              "\n  @%s", e->d_name);
		}
		closedir(d);
	}
	return buf;
}

static void muc_send(Conn *c, const char *room, const char *text) {
	static char esc[ESCBUF];
	static char msg[ESCBUF + 256];
	xml_escape(esc, sizeof esc, text);
	snprintf(msg, sizeof msg,
	         "<message to='%s' type='groupchat'><body>%s</body></message>",
	         room, esc);
	conn_puts(c, msg);
}

static void cmd_summarize(Conn *c, const char *room, time_t cutoff) {
	if (!history_context(room, cutoff, g_ctxbuf, sizeof g_ctxbuf)) {
		muc_send(c, room, "No messages found for that time period.");
		return;
	}
	snprintf(g_prompt, sizeof g_prompt,
	         "Summarize this group chat concisely. Include key points "
	         "and decisions:\n\n%s", g_ctxbuf);
	if (llm_call(g_prompt, g_reply, sizeof g_reply)) {
		snprintf(g_out, sizeof g_out, "Summary:\n%s", g_reply);
		muc_send(c, room, g_out);
	} else {
		muc_send(c, room, g_reply);
	}
}

static void cmd_tasks(Conn *c, const char *room, time_t cutoff) {
	if (!history_context(room, cutoff, g_ctxbuf, sizeof g_ctxbuf)) {
		muc_send(c, room, "No messages found for that time period.");
		return;
	}
	snprintf(g_prompt, sizeof g_prompt,
	         "Extract all action items and tasks from this conversation. "
	         "For each task include: who is responsible (if mentioned), "
	         "what needs to be done, and any deadlines mentioned. Be "
	         "concise:\n\n%s", g_ctxbuf);
	if (llm_call(g_prompt, g_reply, sizeof g_reply)) {
		snprintf(g_out, sizeof g_out, "Tasks:\n%s", g_reply);
		muc_send(c, room, g_out);
	} else {
		muc_send(c, room, g_reply);
	}
}

static void cmd_ask(Conn *c, const char *room, const char *question) {
	if (!question[0]) {
		muc_send(c, room, "What would you like to ask?");
		return;
	}
	history_context(room, 0, g_ctxbuf, sizeof g_ctxbuf);
	snprintf(g_prompt, sizeof g_prompt,
	         "You are a helpful assistant in a group chat.\n\n"
	         "Recent conversation:\n%s\n\n"
	         "Question: %s\n\n"
	         "Answer concisely and helpfully. If the answer is in the "
	         "conversation, reference it.", g_ctxbuf, question);
	if (llm_call(g_prompt, g_reply, sizeof g_reply))
		muc_send(c, room, g_reply);
	else
		muc_send(c, room, g_reply);
}

/* Parse a @bot command out of a chat message and run it. */
static void handle_command(Conn *c, const char *room, const char *body) {
	while (*body == ' ' || *body == '\t') body++;
	size_t pl = strlen(CMD_PREFIX);
	if (strncasecmp(body, CMD_PREFIX, pl) != 0) return;

	char rest[4096];
	snprintf(rest, sizeof rest, "%s", body + pl);
	char *r = rest;
	while (*r == ' ' || *r == '\t') r++;
	memmove(rest, r, strlen(r) + 1);
	rtrim(rest);
	if (!rest[0]) { muc_send(c, room, help_text()); return; }

	char low[4096];
	size_t i;
	for (i = 0; rest[i]; i++)
		low[i] = (char)tolower((unsigned char)rest[i]);
	low[i] = '\0';

	/* last whitespace-delimited token of `low` */
	int e = (int)strlen(low);
	int s = e;
	while (s > 0 && !isspace((unsigned char)low[s - 1])) s--;
	char lasttok[64];
	int ltl = e - s;
	if (ltl > 63) ltl = 63;
	memcpy(lasttok, low + s, (size_t)ltl);
	lasttok[ltl] = '\0';
	time_t cutoff = parse_period(lasttok);

	char cmd[4096];
	if (cutoff) {
		int ce = s;
		while (ce > 0 && isspace((unsigned char)low[ce - 1])) ce--;
		memcpy(cmd, low, (size_t)ce);
		cmd[ce] = '\0';
	} else {
		snprintf(cmd, sizeof cmd, "%s", low);
	}

	if (!cmd[0] && !cutoff) { muc_send(c, room, help_text()); return; }

	if (!strcmp(cmd, "summarize") || !strcmp(cmd, "summary") ||
	    !strcmp(cmd, "s")) {
		cmd_summarize(c, room, cutoff);
	} else if (!strcmp(cmd, "tasks") || !strcmp(cmd, "task") ||
	           !strcmp(cmd, "t")) {
		cmd_tasks(c, room, cutoff);
	} else if (!strcmp(cmd, "help")) {
		muc_send(c, room, help_text());
	} else if (cmd[0]) {
		char q[4096];
		if (cutoff) {                       /* drop the period token */
			int re = (int)strlen(rest);
			int rs = re;
			while (rs > 0 && !isspace((unsigned char)rest[rs - 1])) rs--;
			while (rs > 0 && isspace((unsigned char)rest[rs - 1])) rs--;
			memcpy(q, rest, (size_t)rs);
			q[rs] = '\0';
		} else {
			snprintf(q, sizeof q, "%s", rest);
		}
		cmd_ask(c, room, q);
	} else {
		muc_send(c, room, help_text());
	}
}

/* ------------------------------------------------------------------ */
/* @scriptname commands                                               */
/* ------------------------------------------------------------------ */

/* Run `path` with `args` passed as a single argv element - no shell is
 * involved, so the user's text cannot be interpreted as a command.
 * Captures combined stdout+stderr into out (NUL-terminated, capped at
 * outsz). The child is SIGKILLed after CMD_TIMEOUT_SEC seconds.
 * Returns 1 if the script exited 0, else 0. */
static int run_script(const char *path, const char *args,
                      char *out, size_t outsz) {
	int pfd[2];
	out[0] = '\0';
	if (pipe(pfd) != 0) {
		snprintf(out, outsz, "cmd: pipe failed");
		return 0;
	}
	pid_t pid = fork();
	if (pid < 0) {
		close(pfd[0]);
		close(pfd[1]);
		snprintf(out, outsz, "cmd: fork failed");
		return 0;
	}
	if (pid == 0) {                       /* child */
		dup2(pfd[1], STDOUT_FILENO);
		dup2(pfd[1], STDERR_FILENO);
		close(pfd[0]);
		close(pfd[1]);
		if (args && *args)
			execl(path, path, args, (char *)NULL);
		else
			execl(path, path, (char *)NULL);
		_exit(127);                       /* exec failed */
	}

	close(pfd[1]);                        /* parent */
	size_t len = 0;
	time_t deadline = time(NULL) + CMD_TIMEOUT_SEC;
	int killed = 0, truncated = 0;

	/* read output until EOF, the buffer fills, or the deadline passes */
	for (;;) {
		fd_set rf;
		FD_ZERO(&rf);
		FD_SET(pfd[0], &rf);
		struct timeval tv = { 1, 0 };
		int s = select(pfd[0] + 1, &rf, NULL, NULL, &tv);
		if (s < 0) {
			if (errno == EINTR) continue;
			break;
		}
		if (s > 0) {
			ssize_t r = read(pfd[0], out + len, outsz - 1 - len);
			if (r > 0) {
				len += (size_t)r;
				if (len >= outsz - 1) { truncated = 1; break; }
			} else {
				break;                    /* EOF or error */
			}
		}
		if (time(NULL) >= deadline) { killed = 1; break; }
	}
	out[len] = '\0';
	if (truncated && outsz > 32)
		memcpy(out + outsz - 16, " ...[cut]", 10);

	/* The child may still be running (timeout, or it outran the buffer).
	 * Kill it, then reap with WNOHANG + a deadline so a child that closed
	 * its output but kept running cannot hang the bot on waitpid(). */
	if (killed || truncated)
		kill(pid, SIGKILL);
	close(pfd[0]);
	int status = 0;
	for (;;) {
		pid_t w = waitpid(pid, &status, WNOHANG);
		if (w != 0) break;                /* reaped, or error */
		if (time(NULL) >= deadline) {
			kill(pid, SIGKILL);
			killed = 1;
		}
		struct timespec ts = { 0, 50000000 };  /* 50 ms */
		nanosleep(&ts, NULL);
	}
	rtrim(out);

	if (killed) {
		snprintf(out, outsz, "cmd: timed out after %d s", CMD_TIMEOUT_SEC);
		return 0;
	}
	if (!out[0])
		snprintf(out, outsz, "(no output)");
	return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}

/* Parse a @scriptname message and run CMD_DIR/<scriptname>, posting its
 * output back to the room. Unknown commands are ignored silently so the
 * bot does not react to @-mentions of people. */
static void handle_script_command(Conn *c, const char *room,
                                  const char *body) {
	while (*body == ' ' || *body == '\t') body++;
	if (*body != '@') return;
	if (strncasecmp(body, CMD_PREFIX, strlen(CMD_PREFIX)) == 0)
		return;                            /* that is the @bot prefix */
	body++;                                /* skip '@' */

	/* command name: alphanumerics, '-' and '_' only (no path traversal) */
	char name[64];
	size_t n = 0;
	while (body[n] && !isspace((unsigned char)body[n])) {
		unsigned char ch = (unsigned char)body[n];
		if (!isalnum(ch) && ch != '-' && ch != '_') return;
		if (n >= sizeof name - 1) return;
		name[n] = (char)ch;
		n++;
	}
	if (n == 0) return;
	name[n] = '\0';

	const char *args = body + n;
	while (*args == ' ' || *args == '\t') args++;

	char path[256];
	snprintf(path, sizeof path, "%s/%s", CMD_DIR, name);
	if (access(path, X_OK) != 0) return;   /* not a command: stay silent */

	fprintf(stderr, "xmppcb: running %s\n", path);
	static char out[CMDOUT];
	run_script(path, args, out, sizeof out);
	muc_send(c, room, out);
}

/* ------------------------------------------------------------------ */
/* stanza dispatch                                                    */
/* ------------------------------------------------------------------ */

static void dispatch(Conn *c, const char *st) {
	char tag[64];
	root_tag(st, tag, sizeof tag);

	if (strcmp(tag, "message") == 0) {
		char type[32];
		attr_get(st, "type", type, sizeof type);
		if (strcmp(type, "groupchat") != 0) return;
		if (strstr(st, "<delay")) return;          /* history replay */

		char from[384];
		if (!attr_get(st, "from", from, sizeof from)) return;
		char *slash = strchr(from, '/');
		if (!slash || !slash[1]) return;           /* room-level msg */
		*slash = '\0';
		const char *room = from;
		const char *nick = slash + 1;
		if (strcmp(nick, XMPP_NICK) == 0) return;  /* our own message */

		char raw[BODYBUF];
		if (!elem_text(st, "body", raw, sizeof raw) || !raw[0]) return;
		char bdy[BODYBUF];
		xml_unescape(bdy, sizeof bdy, raw);

		history_append(room, nick, bdy);
		handle_command(c, room, bdy);
		handle_script_command(c, room, bdy);

	} else if (strcmp(tag, "iq") == 0) {
		if (strstr(st, "urn:xmpp:ping")) {         /* answer pings */
			char id[64], from[384];
			attr_get(st, "id", id, sizeof id);
			attr_get(st, "from", from, sizeof from);
			char reply[512];
			snprintf(reply, sizeof reply,
			         "<iq type='result' to='%s' id='%s'/>", from, id);
			conn_puts(c, reply);
		}
	}
}

/* ------------------------------------------------------------------ */
/* XMPP session                                                       */
/* ------------------------------------------------------------------ */

static void send_stream_header(Conn *c) {
	char buf[512];
	snprintf(buf, sizeof buf,
		"<?xml version='1.0'?>"
		"<stream:stream xmlns='jabber:client' "
		"xmlns:stream='http://etherx.jabber.org/streams' "
		"to='%s' version='1.0'>", g_domain);
	conn_puts(c, buf);
}

static void send_auth(Conn *c) {
	char plain[1024];
	size_t pl = 0;
	plain[pl++] = '\0';
	size_t ll = strlen(g_localpart), pwl = strlen(g_password);
	memcpy(plain + pl, g_localpart, ll); pl += ll;
	plain[pl++] = '\0';
	memcpy(plain + pl, g_password, pwl); pl += pwl;

	char b64[2048];
	b64encode(b64, (unsigned char *)plain, pl);
	char msg[2400];
	snprintf(msg, sizeof msg,
		"<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
		"mechanism='PLAIN'>%s</auth>", b64);
	conn_puts(c, msg);
}

/* Run one full XMPP session. Returns a SESS_* code. */
static int run_session(void) {
	inlen = 0;

	int fd = tcp_connect(XMPP_HOST, XMPP_PORT);
	if (fd < 0) {
		fprintf(stderr, "xmppcb: connect to %s:%d failed\n",
		        XMPP_HOST, XMPP_PORT);
		return SESS_RETRY;
	}
	Conn c = { fd, NULL };

	/* stream + STARTTLS negotiation (plaintext) */
	send_stream_header(&c);
	if (!await_tag(&c, "stream:features")) goto drop;
	if (!strstr(stanza, "starttls")) {
		fprintf(stderr, "xmppcb: server did not offer STARTTLS\n");
		goto drop;
	}
	conn_puts(&c, "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");
	if (!await_tag(&c, "proceed")) goto drop;

	/* upgrade to TLS */
	inlen = 0;
	c.ssl = tls_wrap(fd, g_domain);
	if (!c.ssl) goto drop;

	/* restart stream, then SASL PLAIN */
	send_stream_header(&c);
	if (!await_tag(&c, "stream:features")) goto drop_tls;
	if (!strstr(stanza, "PLAIN")) {
		fprintf(stderr, "xmppcb: server did not offer SASL PLAIN\n");
		goto drop_tls;
	}
	send_auth(&c);
	if (!await_tag(&c, "success")) {
		fprintf(stderr, "xmppcb: authentication failed\n");
		SSL_free(c.ssl);
		close(fd);
		return SESS_AUTHFAIL;
	}

	/* restart stream, then resource bind */
	send_stream_header(&c);
	if (!await_tag(&c, "stream:features")) goto drop_tls;
	conn_puts(&c,
		"<iq type='set' id='bind1'>"
		"<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'>"
		"<resource>xmppcb</resource></bind></iq>");
	if (!await_tag(&c, "iq")) goto drop_tls;

	/* online, join rooms */
	conn_puts(&c, "<presence/>");
	for (int i = 0; i < NROOMS; i++) {
		char p[512];
		snprintf(p, sizeof p,
			"<presence to='%s/%s'>"
			"<x xmlns='http://jabber.org/protocol/muc'>"
			"<history maxstanzas='0'/></x></presence>",
			ROOMS[i], XMPP_NICK);
		conn_puts(&c, p);
		fprintf(stderr, "xmppcb: joining %s\n", ROOMS[i]);
	}
	fprintf(stderr, "xmppcb: online as %s\n", XMPP_JID);

	/* main loop */
	struct pollfd pfd = { fd, POLLIN, 0 };
	for (;;) {
		while (extract_chunk(stanza, sizeof stanza) > 0) {
			if (strncmp(stanza, "</", 2) == 0) goto drop_tls;
			char tag[64];
			root_tag(stanza, tag, sizeof tag);
			if (strcmp(tag, "stream:error") == 0) goto drop_tls;
			dispatch(&c, stanza);
		}
		int pr = poll(&pfd, 1, KEEPALIVE_SEC * 1000);
		if (pr < 0) {
			if (errno == EINTR) continue;
			goto drop_tls;
		}
		if (pr == 0) {                         /* keepalive */
			if (conn_write(&c, " ", 1) < 0) goto drop_tls;
			continue;
		}
		if (inlen >= sizeof inbuf) goto drop_tls;
		int n = conn_read_some(&c, inbuf + inlen, sizeof inbuf - inlen);
		if (n <= 0) goto drop_tls;
		inlen += (size_t)n;
		while (SSL_pending(c.ssl) > 0 && inlen < sizeof inbuf) {
			n = conn_read_some(&c, inbuf + inlen, sizeof inbuf - inlen);
			if (n <= 0) break;
			inlen += (size_t)n;
		}
	}

drop_tls:
	if (c.ssl) { SSL_shutdown(c.ssl); SSL_free(c.ssl); }
	close(fd);
	return SESS_RETRY;
drop:
	close(fd);
	return SESS_RETRY;
}

/* ------------------------------------------------------------------ */
/* main                                                               */
/* ------------------------------------------------------------------ */

int main(void) {
	signal(SIGPIPE, SIG_IGN);

	const char *at = strchr(XMPP_JID, '@');
	if (!at || !at[1]) die("XMPP_JID must be user@domain");
	size_t ll = (size_t)(at - XMPP_JID);
	if (ll == 0) die("XMPP_JID must be user@domain");
	if (ll >= sizeof g_localpart) die("JID localpart too long");
	memcpy(g_localpart, XMPP_JID, ll);
	g_localpart[ll] = '\0';
	snprintf(g_domain, sizeof g_domain, "%s", at + 1);

	g_password = getenv("XMPP_PASSWORD");
	g_apikey = getenv("LLM_API_KEY");
	if (!g_password || !*g_password)
		die("set the XMPP_PASSWORD environment variable");
	if (!g_apikey || !*g_apikey)
		die("set the LLM_API_KEY environment variable");

	FILE *f = fopen(INSTRUCTION_FILE, "rb");
	if (f) {
		size_t n = fread(g_instruction, 1, sizeof g_instruction - 1, f);
		g_instruction[n] = '\0';
		fclose(f);
	} else {
		fprintf(stderr, "xmppcb: warning: %s not found, "
		        "using empty system prompt\n", INSTRUCTION_FILE);
		g_instruction[0] = '\0';
	}

	if (mkdir(HISTORY_DIR, 0700) != 0 && errno != EEXIST)
		die("cannot create history directory");

	tls_init();

	for (;;) {
		int r = run_session();
		if (r == SESS_AUTHFAIL) return 1;
		fprintf(stderr, "xmppcb: disconnected, reconnecting in 5s\n");
		sleep(5);
	}
}
