#!/bin/sh
# tdo: typed items (bug, problem, task, chantier) as one Markdown file each under TODO/,
# every field validated against a schema. The schema lives in the awk programs below.
# POSIX sh + awk; runs unchanged under the ksh of OpenBSD and the sh of macOS.

# ksh imports the caller's PATH over its default and never restores it
PATH=/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin
export PATH

# every length is a byte count of a UTF-8 text, whatever the locale; the editor gets the caller locale back
LC_ORIG_SET=${LC_ALL+x}
LC_ORIG=${LC_ALL-}
LC_ALL=C
export LC_ALL

VERSION=1.0
NL='
'
TAB=$(printf '\t')

ROOT=
START=
TODO=
TODAY=
WORK=
CAND=
MAN=
ERRS=
SELALL=0
SELLIST=
EXCL=
ITEM=
ITEMLOG=
ITEMCLOSED=0
CDIR=

# ---------------------------------------------------------------- awk: shared library
# qt() quotes with %c: the programs are single-quoted shell words and hold no apostrophe.
AWK_LIB='
function qt(s) { return sprintf("%c%s%c", 39, s, 39) }
# clen: characters of a UTF-8 text = bytes that are not continuation bytes, same under any locale (LC_ALL=C)
function clen(s) { gsub(/[\200-\277]/, "", s); return length(s) }
function enumbad(v, list,   a, n, i, out) {
	n = split(list, a, " ")
	for (i = 1; i <= n; i++) {
		if (a[i] == v) return ""
		out = out (i > 1 ? ", " : "") a[i]
	}
	return "bad value " qt(v) ", expected one of: " out
}
function datebad(s,   y, m, d, dim) {
	if (s !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/)
		return "bad date " qt(s) ", expected YYYY-MM-DD"
	y = substr(s, 1, 4) + 0; m = substr(s, 6, 2) + 0; d = substr(s, 9, 2) + 0
	if (y < 1970 || y > 2099) return "year " substr(s, 1, 4) " is out of range, expected 1970 to 2099"
	if (m < 1 || m > 12) return "month " substr(s, 6, 2) " is out of range, expected 01 to 12"
	dim = substr("312831303130313130313031", 2 * m - 1, 2) + 0
	if (m == 2 && y % 4 == 0 && (y % 100 != 0 || y % 400 == 0)) dim = 29
	if (d < 1 || d > dim) return "day " substr(s, 9, 2) " is out of range for " substr(s, 1, 7) ", expected 01 to " dim
	return ""
}
function durbad(v) {
	if (v !~ /^[1-9][0-9]*[hd]$/ || clen(v) > 5)
		return "bad value " qt(v) ", expected <n>h or <n>d with n from 1 to 9999, no leading zero"
	return ""
}
function idbad(v) {
	if (clen(v) > 40) return "too long (" clen(v) " characters, maximum 40)"
	if (v !~ /^[a-z0-9][a-z0-9-]*$/) return "bad id " qt(v) ", expected [a-z0-9][a-z0-9-]{0,39}"
	return ""
}
function creatorbad(v,   p, name, rest, addr, n, parts, loc, dom) {
	p = index(v, "<")
	if (p < 2 || substr(v, p - 1, 1) != " ") return "expected " qt("Name <local@domain.tld>")
	name = substr(v, 1, p - 2); rest = substr(v, p + 1)
	if (name == "" || clen(name) > 80) return "name must be 1 to 80 characters"
	if (name ~ /[<>]/) return "name must not contain < or >"
	if (name ~ /^ / || name ~ / $/) return "name has a leading or trailing space"
	if (rest !~ />$/) return "expected " qt("Name <local@domain.tld>")
	addr = substr(rest, 1, length(rest) - 1)
	if (addr ~ /[<> ]/) return "address must not contain a space, < or >"
	n = split(addr, parts, "@")
	if (n != 2) return "address must have exactly one @"
	loc = parts[1]; dom = parts[2]
	if (loc == "" || dom == "") return "address needs a local part and a domain"
	if (dom !~ /[.]/ || dom ~ /^[.]/ || dom ~ /[.]$/ || dom ~ /[.][.]/) return "domain part needs a dot between two labels"
	return ""
}
'

# ---------------------------------------------------------------- awk: validator
# Reads the manifest (kind, real path, logical path, newline count, selected: tab separated),
# builds the id and domain tables from the whole tree, checks the selected entries.
AWK_CHECK='
function err(p, ln, fld, why) { nerr++; printf "%s:%d: %s: %s\n", p, ln, fld, why }
function hv(k) { return (k in H) ? H[k] : "" }
function slurp(path,   r, s) {
	N = 0
	while ((r = (getline s < path)) > 0) L[++N] = s
	close(path)
	return (r == 0)
}
function readdomains(path,   r, s, c) {
	hasdom = 1
	while ((r = (getline s < path)) > 0) {
		if (s ~ /^- [a-z0-9][a-z0-9-]*: /) {
			c = index(s, ":")
			DOM[substr(s, 3, c - 3)] = 1
			DOMLIST = DOMLIST (DOMLIST == "" ? "" : " ") substr(s, 3, c - 3)
		}
	}
	close(path)
}
function chk_file(i, p,   k) {
	if (N == 0) { err(p, 1, "file", "empty file"); return 0 }
	if (nlc[i] + 0 < N) err(p, N, "file", "no newline at end of file")
	else if (L[N] == "") err(p, N, "file", "blank line at end of file")
	for (k = 1; k <= N; k++) {
		if (index(L[k], "\r")) err(p, k, "file", "carriage return")
		else if (L[k] ~ /[\001-\010\013\014\016-\037\177]/) err(p, k, "file", "control character")
	}
	return 1
}
function fieldbad(key, v,   t, c) {
	if (key == "id") return idbad(v)
	if (key == "type") return enumbad(v, "bug problem task chantier")
	if (key == "domain") {
		if (DOMLIST == "") return "no domain is defined, see TODO/domains.md"
		return enumbad(v, DOMLIST)
	}
	if (key == "status") return enumbad(v, "open in-progress blocked ready merged closed wontfix")
	if (key == "severity") return enumbad(v, "low medium high critical")
	if (key == "provenance") return enumbad(v, "verified context")
	if (key == "title") {
		if (clen(v) < 10) return "too short (" clen(v) " characters, minimum 10)"
		if (clen(v) > 100) return "too long (" clen(v) " characters, maximum 100)"
		return ""
	}
	if (key == "branch") {
		if (v == "-" || v == "worktree-" hv("id")) return ""
		return "bad value " qt(v) ", expected " qt("worktree-" hv("id")) " or " qt("-")
	}
	if (key == "blocked-by") {
		if (v == "maintainer") return ""
		if (v ~ /^item:/) {
			t = idbad(substr(v, 6))
			return (t == "") ? "" : "bad item id: " t
		}
		if (v ~ /^external:/) {
			t = substr(v, 10)
			if (clen(t) < 1 || clen(t) > 80) return "external text must be 1 to 80 characters"
			return ""
		}
		return "bad value " qt(v) ", expected item:<id>, maintainer or external:<text>"
	}
	if (key == "creator") return creatorbad(v)
	if (key == "contact") {
		if (clen(v) < 1 || clen(v) > 100) return "must be 1 to 100 characters"
		return ""
	}
	if (key == "opened" || key == "updated") return datebad(v)
	if (key == "estimate" || key == "actual") return durbad(v)
	return ""
}
function evbad(s,   c, t, tag, rest) {
	if (s !~ /^- \[/) return "expected " qt("- [verified:SOURCE] text") ", " qt("- [inferred] text") ", " qt("- [observed:YYYY-MM-DD] text") " or " qt("- [measured:YYYY-MM-DD] text")
	c = index(s, "]")
	if (c == 0) return "unterminated tag"
	tag = substr(s, 4, c - 4); rest = substr(s, c + 1)
	if (rest !~ /^ [^ ]/) return "text must follow the tag after one space"
	if (tag == "inferred") return ""
	if (tag ~ /^verified:/) return (clen(tag) > 9) ? "" : "empty source in " qt("verified:")
	if (tag ~ /^(observed|measured):/) {
		t = datebad(substr(tag, index(tag, ":") + 1))
		return (t == "") ? "" : t
	}
	return "unknown tag " qt(tag) ", expected verified:SOURCE, inferred, observed:DATE or measured:DATE"
}
function chk_item(i,   p, k, hend, bs, s, c, key, rest, val, last, nm, T, S, why, kk, ok, st, pre, cur, fence, descnb, lastord, name, d, other, lt, np2) {
	p = logi[i]
	for (k in H) delete H[k]
	for (k in HL) delete HL[k]
	for (k in seen) delete seen[k]
	if (!chk_file(i, p)) return
	nm = p; sub(/^.*\//, "", nm); sub(/[.]md$/, "", nm)
	hend = N + 1
	for (k = 1; k <= N; k++) if (L[k] == "") { hend = k; break }
	bs = hend + 1
	last = 0
	for (k = 1; k < hend; k++) {
		s = L[k]
		if (s ~ /^## /) { err(p, k, "header", "missing blank line before the body"); bs = k; hend = k; break }
		c = index(s, ":")
		if (c == 0) { err(p, k, "header", "expected " qt("key: value")); continue }
		key = substr(s, 1, c - 1); rest = substr(s, c + 1)
		if (!(key in KIDX)) { err(p, k, "header", "unknown key " qt(key) ", expected one of: " KEYLIST); continue }
		if (key in H) { err(p, k, "header", "duplicate key " qt(key)); continue }
		if (KIDX[key] < last) err(p, k, "header", "key " qt(key) " is out of order, expected order: " KEYLIST)
		else last = KIDX[key]
		if (index(s, "\t")) { err(p, k, key, "tab in header"); continue }
		if (rest == "" || rest == " ") { err(p, k, key, "empty value"); continue }
		if (substr(rest, 1, 1) != " ") { err(p, k, key, "expected one space after the colon"); continue }
		val = substr(rest, 2)
		if (substr(val, 1, 1) == " ") { err(p, k, key, "more than one space after the colon"); continue }
		if (substr(val, length(val), 1) == " ") { err(p, k, key, "trailing space"); continue }
		H[key] = val; HL[key] = k
	}
	for (k = 1; k <= nk; k++) {
		key = KEY[k]
		if (key in H) {
			why = fieldbad(key, H[key])
			if (why != "") { err(p, HL[key], key, why); BAD[key] = 1 } else BAD[key] = 0
		} else BAD[key] = 1
	}
	if (("id" in H) && !BAD["id"]) {
		if (H["id"] != nm) err(p, HL["id"], "id", qt(H["id"]) " does not match the file name " qt(nm ".md"))
		if (nm == "domains" || nm == "closed") err(p, HL["id"], "id", qt(nm) " is a reserved id")
	}
	if (np[nm] > 1) {
		other = (pth[nm, 1] == p) ? pth[nm, 2] : pth[nm, 1]
		err(p, 1, "id", "id " qt(nm) " is also used by " other)
	}
	for (k = 1; k <= nreq; k++) if (!(REQ[k] in H)) err(p, 1, REQ[k], "required key is missing")
	T = hv("type"); if (BAD["type"] || !(T in TYPES)) T = ""
	S = hv("status"); if (BAD["status"] || !(S in STATUSES)) S = ""
	if (T != "") {
		if (T == "bug" || T == "problem") {
			if (!("severity" in H)) err(p, 1, "severity", "required for type " qt(T))
		} else if ("severity" in H) err(p, HL["severity"], "severity", "forbidden for type " qt(T))
		if (T == "chantier") {
			if (!("branch" in H)) err(p, 1, "branch", "required for type " qt(T))
		} else if ("branch" in H) err(p, HL["branch"], "branch", "forbidden for type " qt(T))
		if (T != "chantier" && T != "task" && ("estimate" in H)) err(p, HL["estimate"], "estimate", "forbidden for type " qt(T))
	}
	if ("actual" in H && !("estimate" in H)) err(p, HL["actual"], "actual", "requires estimate")
	if (S != "") {
		if ((S == "ready" || S == "merged") && T != "" && T != "chantier") err(p, HL["status"], "status", qt(S) " is valid only for type " qt("chantier"))
		if ((S == "closed" || S == "wontfix") && kind[i] != "closed") err(p, HL["status"], "status", "status " qt(S) " requires the file under TODO/closed/")
		if (S != "closed" && S != "wontfix" && kind[i] == "closed") err(p, HL["status"], "status", "status " qt(S) " requires the file directly under TODO/")
		if (S == "blocked" && !("blocked-by" in H)) err(p, 1, "blocked-by", "required when status is " qt("blocked"))
		if (S != "blocked" && ("blocked-by" in H)) err(p, HL["blocked-by"], "blocked-by", "only valid when status is " qt("blocked"))
		if ((S == "merged" || S == "closed") && ("estimate" in H) && !("actual" in H)) err(p, 1, "actual", "required when status is " qt(S) " and estimate is set")
	}
	if (("blocked-by" in H) && !BAD["blocked-by"] && H["blocked-by"] ~ /^item:/) {
		lt = substr(H["blocked-by"], 6)
		if (lt == nm) err(p, HL["blocked-by"], "blocked-by", "item " qt(lt) " is the item itself")
		else if (!(lt in np)) err(p, HL["blocked-by"], "blocked-by", "item " qt(lt) " does not exist")
	}
	if (("opened" in H) && ("updated" in H) && !BAD["opened"] && !BAD["updated"] && H["updated"] < H["opened"])
		err(p, HL["updated"], "updated", "before opened (" H["opened"] ")")
	cur = ""; fence = ""; descnb = 0; lastord = 0; pre = 0
	for (k in seen) delete seen[k]
	for (k = bs; k <= N; k++) {
		s = L[k]
		if (fence == "" && s ~ /^## /) {
			name = substr(s, 4)
			if (!(name in SIDX)) { err(p, k, "body", "unknown section " qt("## " name) ", expected one of: " SECLIST); cur = "?"; continue }
			if (name in seen) { err(p, k, "body", "duplicate section " qt(name)); cur = "?"; continue }
			seen[name] = k
			if (SIDX[name] < lastord) err(p, k, "body", "section " qt(name) " is out of order, expected order: " SECLIST)
			else lastord = SIDX[name]
			cur = name
			continue
		}
		if (cur == "") {
			if (s != "" && !pre) { err(p, k, "body", "text before the first section"); pre = 1 }
			continue
		}
		if (cur == "?") continue
		if (cur == "Description" || cur == "Notes") {
			if (s ~ /^```/ || s ~ /^~~~/) {
				c = substr(s, 1, 1)
				if (fence == "") fence = c; else if (fence == c) fence = ""
			}
			if (cur == "Description" && s !~ /^[ \t]*$/) descnb = 1
			continue
		}
		if (s == "") continue
		if (index(s, "\t")) { err(p, k, cur, "tab character"); continue }
		if (cur == "Todo") {
			if (s !~ /^- \[[ x]\] [^ ]/) err(p, k, cur, "expected " qt("- [ ] text") " or " qt("- [x] text"))
		} else if (cur == "Decisions") {
			if (s !~ /^- [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] (maintainer|agent): [^ ]/) err(p, k, cur, "expected " qt("- YYYY-MM-DD maintainer|agent: text"))
			else { why = datebad(substr(s, 3, 10)); if (why != "") err(p, k, cur, why) }
		} else if (cur == "Evidence") {
			why = evbad(s)
			if (why != "") err(p, k, cur, why)
		}
	}
	if (fence != "") err(p, N, "body", "unclosed code fence")
	if (T == "bug" || T == "problem") {
		if (!("Description" in seen)) err(p, 1, "body", "Description is required for type " qt(T))
		else if (!descnb) err(p, seen["Description"], "body", "Description is empty")
	}
}
function chk_domains(i,   p, k, s, c, id, desc, cnt) {
	p = logi[i]
	if (!chk_file(i, p)) return
	if (L[1] != "# Domains") err(p, 1, "header", "first line must be " qt("# Domains"))
	if (N < 2 || L[2] != "") err(p, (N < 2 ? 1 : 2), "header", "expected a blank line after the title")
	cnt = 0
	for (k in dseen) delete dseen[k]
	for (k = 3; k <= N; k++) {
		s = L[k]
		if (s == "") { if (k < N) err(p, k, "domains", "blank line inside the list"); continue }
		if (index(s, "\t")) { err(p, k, "domains", "tab character"); continue }
		if (s !~ /^- [a-z0-9][a-z0-9-]*: /) { err(p, k, "domains", "expected " qt("- <id>: <description>")); continue }
		cnt++
		c = index(s, ":"); id = substr(s, 3, c - 3); desc = substr(s, c + 2)
		if (clen(id) > 30) err(p, k, "domains", "id " qt(id) " is too long (" clen(id) " characters, maximum 30)")
		if (id in dseen) err(p, k, "domains", "duplicate domain " qt(id))
		dseen[id] = 1
		if (desc == "") err(p, k, "domains", "empty description")
		else if (substr(desc, 1, 1) == " ") err(p, k, "domains", "more than one space after the colon")
		else if (substr(desc, length(desc), 1) == " ") err(p, k, "domains", "trailing space")
		else if (clen(desc) > 100) err(p, k, "domains", "description too long (" clen(desc) " characters, maximum 100)")
	}
	if (cnt == 0) err(p, 1, "domains", "no domain is listed")
}
BEGIN {
	nk = split("id type domain status title severity branch blocked-by provenance creator contact opened updated estimate actual", KEY, " ")
	for (i = 1; i <= nk; i++) { KIDX[KEY[i]] = i; KEYLIST = KEYLIST (i > 1 ? ", " : "") KEY[i] }
	nreq = split("id type domain status title provenance creator opened updated", REQ, " ")
	ns = split("Description Todo Decisions Evidence Notes", SEC, " ")
	for (i = 1; i <= ns; i++) { SIDX[SEC[i]] = i; SECLIST = SECLIST (i > 1 ? ", " : "") SEC[i] }
	split("bug problem task chantier", a, " "); for (i in a) TYPES[a[i]] = 1
	split("open in-progress blocked ready merged closed wontfix", a, " "); for (i in a) STATUSES[a[i]] = 1
	MAN = ENVIRON["TDOX_MAN"]; ALL = ENVIRON["TDOX_ALL"] + 0
	n = 0
	while ((getline ln < MAN) > 0) {
		split(ln, f, "\t")
		n++; kind[n] = f[1]; real[n] = f[2]; logi[n] = f[3]; nlc[n] = f[4]; sel[n] = f[5]
	}
	close(MAN)
	for (i = 1; i <= n; i++) {
		if (kind[i] == "open" || kind[i] == "closed") {
			nm = logi[i]; sub(/^.*\//, "", nm); sub(/[.]md$/, "", nm)
			np[nm]++; pth[nm, np[nm]] = logi[i]
		}
		if (kind[i] == "domains") readdomains(real[i])
	}
	for (i = 1; i <= n; i++) {
		if (sel[i] != 1) continue
		p = logi[i]
		if (kind[i] == "symlink") { err(p, 1, "file", "symbolic link"); continue }
		if (kind[i] == "unexpected") { err(p, 1, "file", "unexpected file"); continue }
		if (!slurp(real[i])) { err(p, 1, "file", "cannot read"); continue }
		if (kind[i] == "domains") chk_domains(i); else chk_item(i)
	}
	if (ALL && !hasdom) err("TODO/domains.md", 1, "file", "missing")
	exit (nerr > 0)
}
'

# ---------------------------------------------------------------- awk: header edit
# Applies TDOX_CHG (key TAB value, empty value removes the key) and sets updated.
AWK_SET='
BEGIN {
	nk = split("id type domain status title severity branch blocked-by provenance creator contact opened updated estimate actual", KEY, " ")
	CHG = ENVIRON["TDOX_CHG"]; TODAY = ENVIRON["TDOX_TODAY2"]
	while ((getline ln < CHG) > 0) { t = index(ln, "\t"); k = substr(ln, 1, t - 1); chg[k] = substr(ln, t + 1); hc[k] = 1 }
	close(CHG)
}
{ L[++N] = $0 }
END {
	hend = N + 1
	for (k = 1; k <= N; k++) if (L[k] == "") { hend = k; break }
	for (k = 1; k < hend; k++) { c = index(L[k], ": "); if (c) H[substr(L[k], 1, c - 1)] = substr(L[k], c + 2) }
	for (k in hc) { if (chg[k] == "") delete H[k]; else H[k] = chg[k] }
	H["updated"] = TODAY
	for (i = 1; i <= nk; i++) if (KEY[i] in H) print KEY[i] ": " H[KEY[i]]
	for (k = hend; k <= N; k++) print L[k]
}
'

# ---------------------------------------------------------------- awk: body edit
# TDOX_MODE=append puts TDOX_LINE in section TDOX_SECT, tick flips Todo line TDOX_N, mark prints its
# box (space or x), count prints the number of Todo lines. The input is a file that already passed the validator.
AWK_DOC='
BEGIN {
	MODE = ENVIRON["TDOX_MODE"]; SECT = ENVIRON["TDOX_SECT"]; LINE = ENVIRON["TDOX_LINE"]
	TODAY = ENVIRON["TDOX_TODAY2"]; TN = ENVIRON["TDOX_N"] + 0
	ns = split("Description Todo Decisions Evidence Notes", SEC, " ")
	for (i = 1; i <= ns; i++) SIDX[SEC[i]] = i
}
{ L[++N] = $0 }
function emit(   j) { for (j = 1; j <= nx; j++) print X[j] }
END {
	hend = N + 1
	for (k = 1; k <= N; k++) if (L[k] == "") { hend = k; break }
	fence = ""; nh = 0
	for (k = hend + 1; k <= N; k++) {
		s = L[k]
		if (fence == "" && s ~ /^## /) { nh++; hname[nh] = substr(s, 4); hline[nh] = k; continue }
		if (s ~ /^```/ || s ~ /^~~~/) { c = substr(s, 1, 1); if (fence == "") fence = c; else if (fence == c) fence = "" }
	}
	for (j = 1; j <= nh; j++) eline[j] = (j < nh) ? hline[j + 1] - 1 : N
	if (MODE == "count" || MODE == "tick" || MODE == "mark") {
		cnt = 0
		for (j = 1; j <= nh; j++) if (hname[j] == "Todo")
			for (k = hline[j] + 1; k <= eline[j]; k++) if (L[k] ~ /^- \[[ x]\] /) {
				cnt++
				if (MODE == "mark" && cnt == TN) { print substr(L[k], 4, 1); exit 0 }
				if (MODE == "tick" && cnt == TN) L[k] = "- [x] " substr(L[k], 7)
			}
		if (MODE == "count") { print cnt; exit 0 }
	}
	for (k = 1; k < hend; k++) if (L[k] ~ /^updated: /) L[k] = "updated: " TODAY
	ia = 0; ib = 0; nx = 0
	if (MODE == "append") {
		found = 0
		for (j = 1; j <= nh; j++) if (hname[j] == SECT) found = j
		if (found) {
			last = hline[found]
			for (k = hline[found] + 1; k <= eline[found]; k++) if (L[k] != "") last = k
			if (last > hline[found]) { ia = last; X[++nx] = LINE }
			else { ia = last; X[++nx] = ""; X[++nx] = LINE }
		} else {
			pos = 0
			for (j = 1; j <= nh; j++) if (SIDX[hname[j]] > SIDX[SECT]) { pos = hline[j]; break }
			if (pos) {
				ib = pos
				if (L[pos - 1] != "") X[++nx] = ""
				X[++nx] = "## " SECT; X[++nx] = ""; X[++nx] = LINE; X[++nx] = ""
			} else {
				ib = N + 1
				if (N >= 1 && L[N] != "") X[++nx] = ""
				X[++nx] = "## " SECT; X[++nx] = ""; X[++nx] = LINE
			}
		}
	}
	for (k = 1; k <= N; k++) {
		if (k == ib) emit()
		print L[k]
		if (k == ia) emit()
	}
	if (ib == N + 1) emit()
}
'

# ---------------------------------------------------------------- awk: list rows
AWK_ROWS='
function emit() {
	if (FN == "") return
	if (FS_ != "" && st != FS_) return
	if (FD_ != "" && dm != FD_) return
	if (FT_ != "" && ty != FT_) return
	printf "%s\t%s\t%s\t%s\t%s\n", id, ty, st, dm, ti
}
BEGIN { FS_ = ENVIRON["TDOX_FS"]; FD_ = ENVIRON["TDOX_FD"]; FT_ = ENVIRON["TDOX_FT"] }
FNR == 1 { emit(); FN = FILENAME; id = ty = st = dm = ti = ""; inh = 1 }
inh && $0 == "" { inh = 0 }
inh && /^id: / { id = substr($0, 5) }
inh && /^type: / { ty = substr($0, 7) }
inh && /^status: / { st = substr($0, 9) }
inh && /^domain: / { dm = substr($0, 9) }
inh && /^title: / { ti = substr($0, 8) }
END { emit() }
'

# ---------------------------------------------------------------- helpers
cleanup() {
	[ -n "$CAND" ] && rm -f "$CAND"
	[ -n "$WORK" ] && rm -rf "$WORK"
	return 0
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

fail() { printf 'tdo: %s\n' "$*" >&2; exit 1; }

usage_err() {
	printf 'tdo: %s\n' "$*" >&2
	printf 'usage: tdo [-C dir] command [argument ...] (see tdo -h)\n' >&2
	exit 2
}

usage() {
	cat <<'EOF'
usage: tdo [-C dir] init
       tdo [-C dir] add <type> -d <domain> -t <title> [-s <severity>] [-b <file>|-b -]
                [--id <id>] [--branch <ref>] [--creator "<Name <mail>>"] [--contact <text>]
                [--provenance verified|context] [--estimate <dur>] [--status <status>]
                [--blocked-by <ref>]
       tdo [-C dir] set <id> <key>=<value> [<key>=<value> ...]
       tdo [-C dir] append <id> todo <text>
       tdo [-C dir] append <id> decision [--by maintainer|agent] <text>
       tdo [-C dir] append <id> evidence <verified:SOURCE|inferred|observed|measured> <text>
       tdo [-C dir] append <id> note <text>
       tdo [-C dir] tick <id> <n>
       tdo [-C dir] show <id>
       tdo [-C dir] list [-s <status>] [-d <domain>] [-T <type>] [--all]
       tdo [-C dir] check [--index] [<path> ...]
       tdo [-C dir] close <id> [wontfix]
       tdo [-C dir] domain list | add <id> <description> | rm <id>
       tdo [-C dir] edit <id>
       tdo -V | --version
       tdo -h | --help
EOF
}

need_arg() { [ $# -ge 2 ] || usage_err "option $1 needs an argument"; }

mkwork() {
	[ -n "$WORK" ] && return 0
	WORK=$(mktemp -d "${TMPDIR:-/tmp}/tdo.XXXXXXXXXX") || fail "cannot create a temporary directory"
}

# no control character, tab included, in a value that lands on one header line
cntrl_bad() {
	case $1 in
	*"$NL"*|*[[:cntrl:]]*) return 0 ;;
	esac
	return 1
}

trim() {
	TRIMMED=$1
	while :; do
		case $TRIMMED in
		' '*) TRIMMED=${TRIMMED# } ;;
		*' ') TRIMMED=${TRIMMED% } ;;
		*) break ;;
		esac
	done
}

id_arg() {
	case $1 in
	''|-*|*[!a-z0-9-]*) usage_err "bad id '$1', expected [a-z0-9][a-z0-9-]{0,39}" ;;
	esac
	[ ${#1} -le 40 ] || usage_err "bad id '$1', too long (${#1} characters, maximum 40)"
}

init_today() {
	if [ -n "${TDO_TODAY:-}" ]; then
		_bad=$(TDOX_V=$TDO_TODAY awk "$AWK_LIB"'BEGIN { print datebad(ENVIRON["TDOX_V"]) }')
		[ -z "$_bad" ] || usage_err "TDO_TODAY: $_bad"
		TODAY=$TDO_TODAY
	else
		TODAY=$(date +%Y-%m-%d)
	fi
}

find_root() {
	if [ -n "${TDO_ROOT:-}" ]; then
		[ -d "$TDO_ROOT" ] || fail "TDO_ROOT is not a directory: $TDO_ROOT"
		ROOT=$(cd "$TDO_ROOT" && pwd -P) || fail "cannot enter TDO_ROOT: $TDO_ROOT"
		START=$ROOT
		return 0
	fi
	[ -d "${CDIR:-.}" ] || fail "no such directory: ${CDIR:-.}"
	START=$(cd "${CDIR:-.}" && pwd -P) || fail "cannot enter ${CDIR:-.}"
	_d=$START
	while :; do
		if [ -e "$_d/.git" ] || [ -e "$_d/.got" ]; then ROOT=$_d; return 0; fi
		[ "$_d" = / ] && break
		_d=${_d%/*}
		[ -n "$_d" ] || _d=/
	done
	fail "no repository root found (no .git or .got above $START)"
}

require_todo() {
	TODO=$ROOT/TODO
	[ -L "$TODO" ] && fail "$TODO is a symbolic link"
	[ -d "$TODO" ] || fail "no TODO directory at $ROOT (run: tdo init)"
	if [ "$1" = strict ]; then
		[ -L "$TODO/closed" ] && fail "$TODO/closed is a symbolic link"
		[ -L "$TODO/domains.md" ] && fail "$TODO/domains.md is a symbolic link"
	fi
	return 0
}

# ---------------------------------------------------------------- manifest
is_sel() {
	[ "$SELALL" = 1 ] && return 0
	case "$NL$SELLIST$NL" in
	*"$NL$1$NL"*) return 0 ;;
	esac
	return 1
}

man_add() { # kind real logical selected
	_nl=
	case $1 in
	open|closed|domains) _nl=$(wc -l < "$2" | tr -d ' ') ;;
	esac
	printf '%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$_nl" "$4" >> "$MAN"
}

walk_one() { # file logical level
	case "$NL$EXCL$NL" in
	*"$NL$2$NL"*) return 0 ;;
	esac
	_sel=0
	is_sel "$2" && _sel=1
	_name=${1##*/}
	if [ -L "$1" ]; then man_add symlink "$1" "$2" "$_sel"; return 0; fi
	if [ -d "$1" ]; then
		if [ "$3" = top ] && [ "$_name" = closed ]; then
			walk_closed "$1"
		else
			man_add unexpected "$1" "$2" "$_sel"
		fi
		return 0
	fi
	if [ ! -f "$1" ]; then man_add unexpected "$1" "$2" "$_sel"; return 0; fi
	case $_name in
	*.md) ;;
	*) man_add unexpected "$1" "$2" "$_sel"; return 0 ;;
	esac
	case ${_name%.md} in
	''|-*|*[!a-z0-9-]*) man_add unexpected "$1" "$2" "$_sel"; return 0 ;;
	esac
	if [ "$3" = closed ]; then
		man_add closed "$1" "$2" "$_sel"
	elif [ "$_name" = domains.md ]; then
		man_add domains "$1" "$2" "$_sel"
	else
		man_add open "$1" "$2" "$_sel"
	fi
}

walk_closed() {
	for _f in "$1"/* "$1"/.[!.]* "$1"/..?*; do
		[ -e "$_f" ] || [ -L "$_f" ] || continue
		walk_one "$_f" "TODO/closed/${_f##*/}" closed
	done
}

walk_fs() {
	mkwork
	MAN=$WORK/manifest
	: > "$MAN"
	for _f in "$TODO"/* "$TODO"/.[!.]* "$TODO"/..?*; do
		[ -e "$_f" ] || [ -L "$_f" ] || continue
		walk_one "$_f" "TODO/${_f##*/}" top
	done
}

# the staged blobs of TODO/ stand for the tree
walk_index() {
	command -v git > /dev/null 2>&1 || fail "check --index needs git"
	mkwork
	MAN=$WORK/manifest
	: > "$MAN"
	git -c core.fsmonitor=false -C "$ROOT" ls-files -s -z -- TODO > "$WORK/staged.z" || fail "git ls-files failed"
	tr '\000' '\n' < "$WORK/staged.z" > "$WORK/staged"
	_n=0
	while IFS= read -r _line; do
		[ -n "$_line" ] || continue
		_meta=${_line%%"$TAB"*}
		_path=${_line#*"$TAB"}
		_mode=${_meta%% *}
		_rest=${_meta#* }
		_sha=${_rest%% *}
		_stage=${_rest#* }
		_sel=0
		is_sel "$_path" && _sel=1
		_n=$((_n + 1))
		_name=${_path##*/}
		_kind=unexpected
		case $_mode in
		120000) _kind=symlink ;;
		100644|100755)
			if [ "$_stage" = 0 ]; then
				case $_path in
				TODO/domains.md) _kind=domains ;;
				TODO/closed/*.md)
					case ${_path#TODO/closed/} in */*) ;; *) _kind=closed ;; esac ;;
				TODO/*.md)
					case ${_path#TODO/} in */*) ;; *) _kind=open ;; esac ;;
				esac
				case ${_name%.md} in
				''|-*|*[!a-z0-9-]*) [ "$_kind" = domains ] || _kind=unexpected ;;
				esac
			fi ;;
		esac
		case $_kind in
		symlink|unexpected) _real=/dev/null ;;
		*)
			_real=$WORK/blob.$_n
			git -c core.fsmonitor=false -C "$ROOT" cat-file blob "$_sha" > "$_real" || fail "git cat-file failed for $_path"
			;;
		esac
		man_add "$_kind" "$_real" "$_path" "$_sel"
	done < "$WORK/staged"
}

run_validate() { # all-flag
	ERRS=$WORK/errors
	TDOX_MAN=$MAN TDOX_ALL=$1 awk "$AWK_LIB$AWK_CHECK" > "$ERRS"
	[ ! -s "$ERRS" ]
}

print_errors() {
	awk 'NR <= 50 { print } END { if (NR > 50) printf "tdo: %d more errors\n", NR - 50 }' "$ERRS" >&2
}

# validate one candidate file standing for logical path $2 (kind $1) in place of $3
validate_candidate() { # kind candidate logical replaced
	SELALL=0; SELLIST=; EXCL=$4
	walk_fs
	man_add "$1" "$2" "$3" 1
	run_validate 0
}

validate_existing() { # logical
	SELALL=0; SELLIST=$1; EXCL=
	walk_fs
	run_validate 0
}

find_item() {
	ID=$1
	ITEM=
	case $1 in
	domains|closed) fail "no item '$1'" ;;
	esac
	_a=$TODO/$1.md
	_b=$TODO/closed/$1.md
	[ -L "$_a" ] && fail "$_a is a symbolic link"
	[ -L "$_b" ] && fail "$_b is a symbolic link"
	if [ -f "$_a" ] && [ -f "$_b" ]; then fail "id '$1' exists twice, in TODO/ and TODO/closed/"; fi
	if [ -f "$_a" ]; then ITEM=$_a; ITEMLOG=TODO/$1.md; ITEMCLOSED=0
	elif [ -f "$_b" ]; then ITEM=$_b; ITEMLOG=TODO/closed/$1.md; ITEMCLOSED=1
	else fail "no item '$1'"; fi
}

hget() { # file key
	awk -v k="$2" '$0 == "" { exit } index($0, k ": ") == 1 { print substr($0, length(k) + 3); exit }' "$1"
}

new_cand() { # directory
	CAND=$(mktemp "$1/.tdo.XXXXXXXXXX") || fail "cannot create a temporary file in $1"
	chmod 0644 "$CAND"
}

closed_dir() {
	[ -d "$TODO/closed" ] || mkdir "$TODO/closed" || fail "cannot create $TODO/closed"
}

# validate CAND as the item $ID and put it in place of $ITEM
commit_item() {
	_st=$(hget "$CAND" status)
	case $_st in
	closed|wontfix) _kind=closed; _dest=$TODO/closed/$ID.md; _destlog=TODO/closed/$ID.md ;;
	*) _kind=open; _dest=$TODO/$ID.md; _destlog=TODO/$ID.md ;;
	esac
	if validate_candidate "$_kind" "$CAND" "$_destlog" "$ITEMLOG"; then
		:
	else
		print_errors
		exit 1
	fi
	mv "$CAND" "$_dest" || fail "cannot write $_dest"
	CAND=
	[ "$_dest" = "$ITEM" ] || rm -f "$ITEM"
	return 0
}

# edits start from a valid file
require_valid_item() {
	if ! validate_existing "$ITEMLOG"; then
		print_errors
		fail "$ITEMLOG is invalid, fix it first (tdo edit $ID)"
	fi
}

dest_dir_for() { # status
	case $1 in
	closed|wontfix) closed_dir; DESTDIR_=$TODO/closed ;;
	*) DESTDIR_=$TODO ;;
	esac
}

# ---------------------------------------------------------------- commands
cmd_init() {
	[ $# -eq 0 ] || usage_err "init: unexpected argument $1"
	find_root
	TODO=$ROOT/TODO
	[ -L "$TODO" ] && fail "$TODO is a symbolic link"
	if [ -e "$TODO" ] && [ ! -d "$TODO" ]; then fail "$TODO exists and is not a directory"; fi
	[ -d "$TODO" ] || mkdir "$TODO" || fail "cannot create $TODO"
	[ -L "$TODO/domains.md" ] && fail "$TODO/domains.md is a symbolic link"
	if [ ! -e "$TODO/domains.md" ]; then
		new_cand "$TODO"
		printf '# Domains\n\n- general: Anything that has no domain of its own yet\n' > "$CAND"
		mv "$CAND" "$TODO/domains.md" || fail "cannot write $TODO/domains.md"
		CAND=
	fi
	return 0
}

read_body() { # file or -
	mkwork
	if [ "$1" = - ]; then
		cat > "$WORK/body.raw" || fail "cannot read standard input"
	else
		[ ! -d "$1" ] && [ -r "$1" ] || fail "-b: cannot read '$1'"
		cat "$1" > "$WORK/body.raw" || fail "-b: cannot read '$1'"
	fi
	awk '{ L[NR] = $0 } END {
		s = 1; e = NR
		while (s <= e && L[s] ~ /^[ \t]*$/) s++
		while (e >= s && L[e] ~ /^[ \t]*$/) e--
		for (i = s; i <= e; i++) print L[i]
	}' "$WORK/body.raw" > "$WORK/body"
}

make_slug() {
	SLUG=$(printf '%s' "$1" | LC_ALL=C tr 'A-Z' 'a-z' | LC_ALL=C tr -cs 'a-z0-9' '-')
	SLUG=${SLUG#-}
	SLUG=${SLUG%-}
	SLUG=$(printf '%s' "$SLUG" | cut -c1-40)
	SLUG=${SLUG%-}
}

resolve_creator() {
	if [ -n "$1" ]; then CREATOR=$1; return 0; fi
	if [ -n "${TDO_CREATOR:-}" ]; then CREATOR=$TDO_CREATOR; return 0; fi
	if command -v git > /dev/null 2>&1; then
		_gn=$(git -C "$ROOT" config user.name 2>/dev/null)
		_ge=$(git -C "$ROOT" config user.email 2>/dev/null)
		if [ -n "$_gn" ] && [ -n "$_ge" ]; then CREATOR="$_gn <$_ge>"; return 0; fi
	fi
	fail "no creator: give --creator, set TDO_CREATOR, or set git user.name and user.email"
}

cmd_add() {
	[ $# -ge 1 ] || usage_err "add: missing type"
	a_type=$1; shift
	a_dom=; a_title=; a_sev=; a_body=; a_id=; a_branch=; a_creator=; a_contact=; a_prov=; a_est=; a_status=; a_blocked=
	while [ $# -gt 0 ]; do
		case $1 in
		-d) need_arg "$@"; a_dom=$2; shift 2 ;;
		-t) need_arg "$@"; a_title=$2; shift 2 ;;
		-s) need_arg "$@"; a_sev=$2; shift 2 ;;
		-b) need_arg "$@"; a_body=$2; shift 2 ;;
		--id) need_arg "$@"; a_id=$2; shift 2 ;;
		--branch) need_arg "$@"; a_branch=$2; shift 2 ;;
		--creator) need_arg "$@"; a_creator=$2; shift 2 ;;
		--contact) need_arg "$@"; a_contact=$2; shift 2 ;;
		--provenance) need_arg "$@"; a_prov=$2; shift 2 ;;
		--estimate) need_arg "$@"; a_est=$2; shift 2 ;;
		--status) need_arg "$@"; a_status=$2; shift 2 ;;
		--blocked-by) need_arg "$@"; a_blocked=$2; shift 2 ;;
		-*) usage_err "add: unknown option $1" ;;
		*) usage_err "add: unexpected argument $1" ;;
		esac
	done
	trim "$a_title"; a_title=$TRIMMED
	trim "$a_contact"; a_contact=$TRIMMED
	trim "$a_creator"; a_creator=$TRIMMED
	[ -n "$a_dom" ] || usage_err "add: -d <domain> is required"
	[ -n "$a_title" ] || usage_err "add: -t <title> is required"
	for _v in "$a_type" "$a_dom" "$a_title" "$a_sev" "$a_id" "$a_branch" "$a_creator" "$a_contact" "$a_prov" "$a_est" "$a_status" "$a_blocked"; do
		if cntrl_bad "$_v"; then fail "add: a value holds a control character or a line break"; fi
	done
	find_root
	require_todo strict
	[ -f "$TODO/domains.md" ] || fail "no $TODO/domains.md (run: tdo init)"
	if [ -n "$a_status" ]; then
		case $a_status in
		open|in-progress|blocked) ;;
		*) fail "status '$a_status' is not allowed at creation, expected one of: open, in-progress, blocked" ;;
		esac
	else
		a_status=open
	fi
	if [ -n "$a_id" ]; then
		_why=$(TDOX_V=$a_id awk "$AWK_LIB"'BEGIN { print idbad(ENVIRON["TDOX_V"]) }')
		[ -z "$_why" ] || fail "id: $_why"
		ID=$a_id
	else
		make_slug "$a_title"
		[ -n "$SLUG" ] || fail "cannot derive an id from the title, give --id"
		ID=$SLUG
	fi
	case $ID in
	domains|closed) fail "id '$ID' is reserved, use --id" ;;
	esac
	if [ -e "$TODO/$ID.md" ] || [ -L "$TODO/$ID.md" ] || [ -e "$TODO/closed/$ID.md" ] || [ -L "$TODO/closed/$ID.md" ]; then
		fail "id '$ID' exists, use --id"
	fi
	[ -n "$a_prov" ] || a_prov=context
	resolve_creator "$a_creator"
	if cntrl_bad "$CREATOR"; then fail "add: the creator holds a control character or a line break"; fi
	[ "$a_type" = chantier ] && [ -z "$a_branch" ] && a_branch=worktree-$ID
	have_body=0
	if [ -n "$a_body" ]; then
		read_body "$a_body"
		[ -s "$WORK/body" ] && have_body=1
	fi
	case $a_type in
	bug|problem) [ "$have_body" = 1 ] || fail "Description is required for type '$a_type' (give -b <file> or -b -)" ;;
	esac
	new_cand "$TODO"
	{
		printf 'id: %s\ntype: %s\ndomain: %s\nstatus: %s\ntitle: %s\n' "$ID" "$a_type" "$a_dom" "$a_status" "$a_title"
		[ -n "$a_sev" ] && printf 'severity: %s\n' "$a_sev"
		[ -n "$a_branch" ] && printf 'branch: %s\n' "$a_branch"
		[ -n "$a_blocked" ] && printf 'blocked-by: %s\n' "$a_blocked"
		printf 'provenance: %s\ncreator: %s\n' "$a_prov" "$CREATOR"
		[ -n "$a_contact" ] && printf 'contact: %s\n' "$a_contact"
		printf 'opened: %s\nupdated: %s\n' "$TODAY" "$TODAY"
		[ -n "$a_est" ] && printf 'estimate: %s\n' "$a_est"
		if [ "$have_body" = 1 ]; then
			printf '\n## Description\n\n'
			cat "$WORK/body"
		fi
	} > "$CAND"
	ITEMLOG=
	if validate_candidate open "$CAND" "TODO/$ID.md" ""; then
		:
	else
		print_errors
		exit 1
	fi
	mv "$CAND" "$TODO/$ID.md" || fail "cannot write $TODO/$ID.md"
	CAND=
	printf '%s\n' "$TODO/$ID.md"
}

cmd_set() {
	[ $# -ge 2 ] || usage_err "set: usage: set <id> <key>=<value> [<key>=<value> ...]"
	id_arg "$1"
	_id=$1; shift
	_keys=
	_new_status=
	_have_status=0
	mkwork
	: > "$WORK/chg"
	for _kv in "$@"; do
		case $_kv in
		*=*) ;;
		*) usage_err "set: expected <key>=<value>, got '$_kv'" ;;
		esac
		_k=${_kv%%=*}
		_v=${_kv#*=}
		case $_k in
		status|title|domain|severity|branch|blocked-by|provenance|estimate|actual) ;;
		id|type|creator|contact|opened|updated) fail "set: key '$_k' cannot be changed" ;;
		*) usage_err "set: unknown key '$_k'" ;;
		esac
		case "$NL$_keys$NL" in
		*"$NL$_k$NL"*) usage_err "set: key '$_k' given twice" ;;
		esac
		_keys="$_keys$NL$_k"
		if cntrl_bad "$_v"; then fail "set: $_k: the value holds a control character or a line break"; fi
		[ "$_k" = status ] && { _have_status=1; _new_status=$_v; }
		printf '%s\t%s\n' "$_k" "$_v" >> "$WORK/chg"
	done
	find_root
	require_todo strict
	find_item "$_id"
	require_valid_item
	if [ "$_have_status" = 1 ]; then _st=$_new_status; else _st=$(hget "$ITEM" status); fi
	dest_dir_for "$_st"
	new_cand "$DESTDIR_"
	TDOX_CHG=$WORK/chg TDOX_TODAY2=$TODAY awk "$AWK_SET" "$ITEM" > "$CAND" || fail "set: cannot rewrite $ITEMLOG"
	commit_item
}

cmd_close() {
	[ $# -ge 1 ] && [ $# -le 2 ] || usage_err "close: usage: close <id> [wontfix]"
	_cid=$1
	_cst=closed
	if [ $# -eq 2 ]; then
		[ "$2" = wontfix ] || usage_err "close: unknown argument $2"
		_cst=wontfix
	fi
	cmd_set "$_cid" "status=$_cst"
}

cmd_append() {
	[ $# -ge 3 ] || usage_err "append: usage: append <id> todo|decision|evidence|note <text>"
	id_arg "$1"
	_id=$1; _kind=$2; shift 2
	_by=agent
	_tag=
	case $_kind in
	todo) _sect=Todo ;;
	decision)
		_sect=Decisions
		if [ "$1" = --by ]; then
			[ $# -ge 2 ] || usage_err "option --by needs an argument"
			_by=$2; shift 2
			case $_by in maintainer|agent) ;; *) usage_err "append: --by expects maintainer or agent" ;; esac
		fi ;;
	evidence)
		_sect=Evidence
		_tag=$1; shift
		case $_tag in
		verified:?*) case $_tag in *']'*) usage_err "append: the source of verified: must not contain ]" ;; esac ;;
		inferred|observed|measured) ;;
		*) usage_err "append: evidence tag must be verified:SOURCE, inferred, observed or measured" ;;
		esac ;;
	note) _sect=Notes ;;
	*) usage_err "append: unknown kind '$_kind', expected todo, decision, evidence or note" ;;
	esac
	[ $# -ge 1 ] || usage_err "append: missing text"
	trim "$*"
	_text=$TRIMMED
	[ -n "$_text" ] || usage_err "append: the text is empty"
	if cntrl_bad "$_text"; then fail "append: the text holds a control character or a line break"; fi
	find_root
	require_todo strict
	case $_kind in
	todo) _line="- [ ] $_text" ;;
	decision) _line="- $TODAY $_by: $_text" ;;
	evidence)
		case $_tag in
		observed|measured) _tag="$_tag:$TODAY" ;;
		esac
		_line="- [$_tag] $_text" ;;
	note) _line=$_text ;;
	esac
	find_item "$_id"
	require_valid_item
	if [ "$ITEMCLOSED" = 1 ]; then dest_dir_for closed; else DESTDIR_=$TODO; fi
	new_cand "$DESTDIR_"
	TDOX_MODE=append TDOX_SECT=$_sect TDOX_LINE=$_line TDOX_TODAY2=$TODAY awk "$AWK_DOC" "$ITEM" > "$CAND" || fail "append: cannot rewrite $ITEMLOG"
	commit_item
}

cmd_tick() {
	[ $# -eq 2 ] || usage_err "tick: usage: tick <id> <n>"
	id_arg "$1"
	_id=$1; _n=$2
	case $_n in
	''|*[!0-9]*|0*) usage_err "tick: <n> must be a positive integer" ;;
	esac
	find_root
	require_todo strict
	find_item "$_id"
	require_valid_item
	_cnt=$(TDOX_MODE=count awk "$AWK_DOC" "$ITEM")
	[ "$_n" -le "$_cnt" ] || fail "item '$_id' has $_cnt todo lines, there is no line $_n"
	_mark=$(TDOX_MODE=mark TDOX_N=$_n awk "$AWK_DOC" "$ITEM")
	if [ "$_mark" = x ]; then
		printf 'tdo: line %s of %s is already checked\n' "$_n" "$_id"
		return 0
	fi
	if [ "$ITEMCLOSED" = 1 ]; then dest_dir_for closed; else DESTDIR_=$TODO; fi
	new_cand "$DESTDIR_"
	TDOX_MODE=tick TDOX_N=$_n TDOX_TODAY2=$TODAY awk "$AWK_DOC" "$ITEM" > "$CAND" || fail "tick: cannot rewrite $ITEMLOG"
	commit_item
}

cmd_show() {
	[ $# -eq 1 ] || usage_err "show: usage: show <id>"
	id_arg "$1"
	find_root
	require_todo loose
	find_item "$1"
	cat "$ITEM"
}

collect_rows() { # include-closed
	mkwork
	[ "$1" = 1 ] && [ -L "$TODO/closed" ] && fail "$TODO/closed is a symbolic link"
	: > "$WORK/files"
	for _dir in "$TODO" "$TODO/closed"; do
		[ "$_dir" = "$TODO/closed" ] && [ "$1" != 1 ] && continue
		for _f in "$_dir"/*.md; do
			_b=${_f##*/}
			[ "$_dir" = "$TODO" ] && [ "$_b" = domains.md ] && continue
			[ -L "$_f" ] && fail "$_f is a symbolic link"
			[ -f "$_f" ] || continue
			case ${_b%.md} in ''|-*|*[!a-z0-9-]*) continue ;; esac
			printf '%s\n' "$_f" >> "$WORK/files"
		done
	done
	[ -s "$WORK/files" ] || return 0
	_list=$(cat "$WORK/files")
	set -f
	IFS=$NL
	# shellcheck disable=SC2086
	set -- $_list
	unset IFS
	set +f
	TDOX_FS=$_ls TDOX_FD=$_ld TDOX_FT=$_lt awk "$AWK_ROWS" "$@"
}

cmd_list() {
	_ls=; _ld=; _lt=; _all=0
	while [ $# -gt 0 ]; do
		case $1 in
		-s) need_arg "$@"; _ls=$2; shift 2 ;;
		-d) need_arg "$@"; _ld=$2; shift 2 ;;
		-T) need_arg "$@"; _lt=$2; shift 2 ;;
		--all) _all=1; shift ;;
		-*) usage_err "list: unknown option $1" ;;
		*) usage_err "list: unexpected argument $1" ;;
		esac
	done
	if [ -n "$_ls" ]; then
		_why=$(TDOX_V=$_ls awk "$AWK_LIB"'BEGIN { print enumbad(ENVIRON["TDOX_V"], "open in-progress blocked ready merged closed wontfix") }')
		[ -z "$_why" ] || usage_err "list: -s: $_why"
	fi
	if [ -n "$_lt" ]; then
		_why=$(TDOX_V=$_lt awk "$AWK_LIB"'BEGIN { print enumbad(ENVIRON["TDOX_V"], "bug problem task chantier") }')
		[ -z "$_why" ] || usage_err "list: -T: $_why"
	fi
	find_root
	require_todo loose
	mkwork
	collect_rows "$_all" > "$WORK/rows"
	LC_ALL=C sort -t "$TAB" -k1,1 "$WORK/rows"
}

cmd_check() {
	_index=0
	_paths=
	while [ $# -gt 0 ]; do
		case $1 in
		--index) _index=1; shift ;;
		-*) usage_err "check: unknown option $1" ;;
		*) _paths="$_paths$NL$1"; shift ;;
		esac
	done
	find_root
	require_todo loose
	SELLIST=
	SELALL=1
	if [ -n "$_paths" ]; then
		SELALL=0
		_saved=$_paths
		set -f
		IFS=$NL
		# shellcheck disable=SC2086
		set -- $_saved
		unset IFS
		set +f
		for _p in "$@"; do
			[ -n "$_p" ] || continue
			case $_p in /*) _full=$_p ;; *) _full=$START/$_p ;; esac
			_dir=${_full%/*}
			_base=${_full##*/}
			[ -d "$_dir" ] || fail "$_p: no such directory"
			_dd=$(cd "$_dir" && pwd -P) || fail "$_p: cannot enter its directory"
			_full=$_dd/$_base
			case $_full in
			"$ROOT"/TODO/*) ;;
			*) fail "$_p: not under $ROOT/TODO" ;;
			esac
			[ -e "$_full" ] || [ -L "$_full" ] || fail "$_p: no such file"
			[ -d "$_full" ] && [ ! -L "$_full" ] && fail "$_p: is a directory"
			SELLIST="$SELLIST$NL${_full#"$ROOT"/}"
		done
		SELLIST=${SELLIST#"$NL"}
	fi
	EXCL=
	if [ "$_index" = 1 ]; then walk_index; else walk_fs; fi
	_all=1
	[ "$SELALL" = 1 ] || _all=0
	if run_validate "$_all"; then
		return 0
	fi
	print_errors
	exit 1
}

cmd_domain() {
	[ $# -ge 1 ] || usage_err "domain: usage: domain list | add <id> <description> | rm <id>"
	_sub=$1; shift
	case $_sub in
	list) [ $# -eq 0 ] || usage_err "domain list: unexpected argument $1" ;;
	add) [ $# -ge 2 ] || usage_err "domain add: usage: domain add <id> <description>" ;;
	rm) [ $# -eq 1 ] || usage_err "domain rm: usage: domain rm <id>" ;;
	*) usage_err "domain: unknown subcommand '$_sub'" ;;
	esac
	find_root
	require_todo strict
	[ -f "$TODO/domains.md" ] || fail "no $TODO/domains.md (run: tdo init)"
	validate_existing TODO/domains.md || { print_errors; fail "TODO/domains.md is invalid"; }
	case $_sub in
	list)
		awk 'NR > 2 { i = index($0, ":"); printf "%s\t%s\n", substr($0, 3, i - 3), substr($0, i + 2) }' "$TODO/domains.md"
		;;
	add)
		_did=$1; shift
		trim "$*"
		_desc=$TRIMMED
		if cntrl_bad "$_desc"; then fail "domain add: the description holds a control character or a line break"; fi
		_why=$(TDOX_V=$_did awk "$AWK_LIB"'BEGIN { v = ENVIRON["TDOX_V"]; if (clen(v) > 30) print "too long (" clen(v) " characters, maximum 30)"; else if (v !~ /^[a-z0-9][a-z0-9-]*$/) print "bad id " qt(v) ", expected [a-z0-9][a-z0-9-]{0,29}" }')
		[ -z "$_why" ] || fail "domain add: id: $_why"
		new_cand "$TODO"
		{ cat "$TODO/domains.md"; printf -- '- %s: %s\n' "$_did" "$_desc"; } > "$CAND"
		SELALL=0; SELLIST=; EXCL=TODO/domains.md
		walk_fs
		man_add domains "$CAND" TODO/domains.md 1
		if run_validate 0; then
			:
		else
			print_errors
			exit 1
		fi
		mv "$CAND" "$TODO/domains.md" || fail "cannot write $TODO/domains.md"
		CAND=
		;;
	rm)
		_did=$1
		case $_did in ''|-*|*[!a-z0-9-]*) usage_err "domain rm: bad id" ;; esac
		awk -v d="$_did" 'NR > 2 && index($0, "- " d ": ") == 1 { f = 1 } END { exit !f }' "$TODO/domains.md" || fail "no domain '$_did'"
		collect_rows 1 > "$WORK/rows"
		_user=$(awk -F "$TAB" -v d="$_did" '$4 == d { print $1; exit }' "$WORK/rows")
		[ -z "$_user" ] || fail "domain '$_did' is used by item '$_user'"
		_left=$(awk -v d="$_did" 'NR > 2 && index($0, "- " d ": ") != 1 && $0 != ""' "$TODO/domains.md" | wc -l | tr -d ' ')
		[ "$_left" -gt 0 ] || fail "domain '$_did' is the last one, a registry needs at least one"
		new_cand "$TODO"
		awk -v d="$_did" 'index($0, "- " d ": ") != 1' "$TODO/domains.md" > "$CAND"
		SELALL=0; SELLIST=; EXCL=TODO/domains.md
		walk_fs
		man_add domains "$CAND" TODO/domains.md 1
		if run_validate 0; then
			:
		else
			print_errors
			exit 1
		fi
		mv "$CAND" "$TODO/domains.md" || fail "cannot write $TODO/domains.md"
		CAND=
		;;
	esac
}

cmd_edit() {
	[ $# -eq 1 ] || usage_err "edit: usage: edit <id>"
	id_arg "$1"
	_tty=${TDO_TTY:-/dev/tty}
	( : < "$_tty" ) 2>/dev/null || { printf 'tdo: edit needs a terminal\n' >&2; exit 2; }
	find_root
	require_todo strict
	find_item "$1"
	mkwork
	_copy=$WORK/edit.md
	cp "$ITEM" "$_copy" || fail "cannot copy $ITEMLOG"
	exec 3< "$_tty"
	while :; do
		set -f
		# shellcheck disable=SC2086
		if [ -n "$LC_ORIG_SET" ]; then LC_ALL=$LC_ORIG; export LC_ALL; else unset LC_ALL; fi
		${EDITOR:-vi} "$_copy"
		_erc=$?
		LC_ALL=C
		export LC_ALL
		set +f
		[ "$_erc" -eq 0 ] || fail "the editor failed (exit $_erc), $ITEMLOG is unchanged"
		_st=$(hget "$_copy" status)
		case $_st in
		closed|wontfix) _kind=closed; _destlog=TODO/closed/$ID.md ;;
		*) _kind=open; _destlog=TODO/$ID.md ;;
		esac
		if validate_candidate "$_kind" "$_copy" "$_destlog" "$ITEMLOG"; then
			: > "$WORK/chg"
			dest_dir_for "$_st"
			new_cand "$DESTDIR_"
			TDOX_CHG=$WORK/chg TDOX_TODAY2=$TODAY awk "$AWK_SET" "$_copy" > "$CAND" || fail "edit: cannot rewrite the copy"
			commit_item
			return 0
		fi
		print_errors
		while :; do
			printf 'tdo: [r]eopen, [q]uit without saving? ' >&2
			_ans=q
			read -r _ans <&3 || _ans=q
			case $_ans in
			r|R) break ;;
			q|Q) printf '\n' >&2; fail "$ITEMLOG is unchanged" ;;
			esac
		done
	done
}

# ---------------------------------------------------------------- main
while [ $# -gt 0 ]; do
	case $1 in
	-C) need_arg "$@"; CDIR=$2; shift 2 ;;
	-V|--version) printf 'tdo %s\n' "$VERSION"; exit 0 ;;
	-h|--help) usage; exit 0 ;;
	-*) usage_err "unknown option $1" ;;
	*) break ;;
	esac
done
[ $# -ge 1 ] || usage_err "missing command"
CMD=$1
shift
case $CMD in
init|add|set|append|tick|show|list|check|close|domain|edit) ;;
*) usage_err "unknown command '$CMD'" ;;
esac
init_today
case $CMD in
init) cmd_init "$@" ;;
add) cmd_add "$@" ;;
set) cmd_set "$@" ;;
append) cmd_append "$@" ;;
tick) cmd_tick "$@" ;;
show) cmd_show "$@" ;;
list) cmd_list "$@" ;;
check) cmd_check "$@" ;;
close) cmd_close "$@" ;;
domain) cmd_domain "$@" ;;
edit) cmd_edit "$@" ;;
esac
exit 0
