#!/bin/sh
# check: run the checks CI runs, in the order CI runs them.
#
#   scripts/check          everything (core, ui, lint, actions, vuln)
#   scripts/check core     gofmt, go vet, go fix -diff, go test -race,
#                          CGO_ENABLED=0 build
#   scripts/check ui       the web UI's own tests (node --test)
#   scripts/check lint     golangci-lint
#   scripts/check actions  zizmor, over this repository
#   scripts/check vuln     govulncheck
#   scripts/check --db …   run a throwaway PostgreSQL for the store tests
#
# CI's test job runs `scripts/check core`, so those five stay identical by
# construction. The lint, actions and vuln jobs run their tools directly —
# lint via the golangci-lint action, which installs a released binary
# instead of building one from source, and actions via zizmor's own — so
# this script takes both pinned versions from ci.yaml rather than keeping
# a second copy of either.
#
# The store integration test is skipped unless a PostgreSQL is reachable.
# `--db` starts one on port 55433 and removes it on exit — in Docker for
# CI's own pgvector/pgvector:pg17, or, where Docker cannot serve one, as a
# throwaway cluster built from the machine's own PostgreSQL programs.
# Without `--db`, an already-exported OCHAKAI_TEST_DATABASE_URL is used as
# is.
set -eu

cd "$(git rev-parse --show-toplevel)"

want_db=no
skipped_store=no
skipped_ui=no
skipped_actions=no
db_note=""
steps=""
for arg in "$@"; do
	case $arg in
	--db) want_db=yes ;;
	core | ui | lint | actions | vuln) steps="$steps $arg" ;;
	-h | --help)
		awk 'NR > 1 && !/^#/ {exit} NR > 1 {sub(/^# ?/, ""); print}' "$0"
		exit 0
		;;
	*)
		echo "check: unknown argument: $arg" >&2
		exit 2
		;;
	esac
done
[ -n "$steps" ] || steps="core ui lint actions vuln"

step() { printf '\n\033[1m==> %s\033[0m\n' "$*" >&2; }

start_db() {
	if [ -n "${OCHAKAI_TEST_DATABASE_URL:-}" ]; then
		echo "check: OCHAKAI_TEST_DATABASE_URL is already set — using it, not starting a container" >&2
		return
	fi
	OCHAKAI_TEST_DATABASE_URL='postgres://t:t@localhost:55433/t?sslmode=disable'
	export OCHAKAI_TEST_DATABASE_URL

	# Reuse whatever already answers on the port. Running this twice, or
	# alongside a database left up from an earlier session, should not be
	# an error — and stopping someone else's container is worse.
	if nc -z localhost 55433 >/dev/null 2>&1; then
		step "reusing the PostgreSQL already on port 55433"
		return
	fi

	# Docker first: it is the one that runs the image CI runs. When it
	# cannot — no daemon, or a network that will not let the image be
	# pulled — a cluster built from the machine's own PostgreSQL is a
	# closer approximation than skipping the store tests entirely.
	start_db_docker && return 0
	start_db_local && return 0
	echo "check: --db needs Docker able to pull pgvector/pgvector:pg17, or PostgreSQL server programs with pgvector installed, or a PostgreSQL already on port 55433" >&2
	exit 2
}

start_db_docker() {
	command -v docker >/dev/null 2>&1 || return 1
	docker info >/dev/null 2>&1 || {
		echo "check: the docker CLI is here but its daemon is not answering" >&2
		return 1
	}
	step "starting PostgreSQL for the store tests (docker, pgvector/pgvector:pg17)"
	cid=$(docker run -d --rm -p 55433:5432 --name ochakai-test-pg \
		-e POSTGRES_USER=t -e POSTGRES_PASSWORD=t -e POSTGRES_DB=t \
		pgvector/pgvector:pg17) || return 1
	# shellcheck disable=SC2064 # $cid is meant to expand now, not at exit
	trap "docker stop $cid >/dev/null" EXIT INT TERM
	i=0
	until docker exec "$cid" pg_isready -U t >/dev/null 2>&1; do
		i=$((i + 1))
		[ "$i" -lt 30 ] || {
			echo "check: PostgreSQL did not become ready" >&2
			exit 1
		}
		sleep 1
	done
}

# pg_bindir prints the programs of a PostgreSQL installed on this machine
# that can serve the store tests, or fails if there is none. pgvector is
# part of the requirement: the tests migrate a vector schema, and a
# cluster that cannot `CREATE EXTENSION vector` fails them for a reason
# that has nothing to do with the change under test.
pg_bindir() {
	dirs=""
	if command -v pg_ctl >/dev/null 2>&1; then
		dirs=$(dirname "$(command -v pg_ctl)")
	fi
	# Debian and its derivatives keep the server programs off PATH.
	for d in /usr/lib/postgresql/*/bin /usr/local/pgsql/bin \
		/opt/homebrew/opt/postgresql@*/bin; do
		if [ -d "$d" ]; then dirs="$dirs $d"; fi
	done
	# Newest first, so a machine with several offers the one closest to
	# the pg17 CI runs.
	# shellcheck disable=SC2086 # $dirs is a list, and meant to split
	for d in $(printf '%s\n' $dirs | sort -r); do
		if [ -x "$d/initdb" ] && [ -x "$d/pg_ctl" ] && [ -x "$d/pg_config" ] &&
			[ -f "$("$d/pg_config" --sharedir)/extension/vector.control" ]; then
			echo "$d"
			return 0
		fi
	done
	return 1
}

# as_pg runs a server program as a user PostgreSQL will accept. It
# refuses to run as root, and root is the usual account in a container.
as_pg() {
	if [ -n "${pg_as:-}" ]; then
		su "$pg_as" -c "$1"
	else
		sh -c "$1"
	fi
}

start_db_local() {
	bin=$(pg_bindir) || {
		echo "check: no PostgreSQL server programs with pgvector on this machine" >&2
		return 1
	}
	dir=$(mktemp -d "${TMPDIR:-/tmp}/ochakai-check-pg.XXXXXX")
	pg_as=
	if [ "$(id -u)" = 0 ]; then
		id postgres >/dev/null 2>&1 || {
			echo "check: running as root, and there is no postgres user to run the server as" >&2
			rm -rf "$dir"
			return 1
		}
		pg_as=postgres
		chown postgres "$dir"
	fi
	step "starting PostgreSQL for the store tests ($("$bin/pg_ctl" --version))"
	# shellcheck disable=SC2064 # the paths are meant to expand now, not at exit
	trap "as_pg \"$bin/pg_ctl -D $dir/data -m immediate -w stop\" >/dev/null 2>&1; rm -rf $dir" EXIT INT TERM
	as_pg "$bin/initdb -D $dir/data -U t --auth=trust --no-sync" >"$dir/initdb.log" 2>&1 || {
		cat "$dir/initdb.log" >&2
		return 1
	}
	# -k keeps the socket in the throwaway directory: the default one
	# belongs to whatever server this machine runs for itself.
	as_pg "$bin/pg_ctl -D $dir/data -l $dir/log -w -o '-p 55433 -k $dir -c listen_addresses=localhost -c fsync=off' start" >/dev/null 2>&1 || {
		cat "$dir/log" >&2
		return 1
	}
	"$bin/createdb" -h localhost -p 55433 -U t t || return 1
	db_note="$("$bin/pg_config" --version), not the pgvector/pgvector:pg17 CI runs"
}

# The version the golangci-lint action installs in CI. Parsed rather than
# duplicated: a pin that lives in two files is a pin that drifts.
linter_version() {
	awk '/golangci-lint-action/ {f = 1}
	     f && $1 == "version:" {print $2; exit}' .github/workflows/ci.yaml
}

# The zizmor version the actions job pins in CI. Same reason as above: a
# pin that lives in two files is a pin that drifts.
zizmor_version() {
	awk '/zizmor-action/ {f = 1}
	     f && $1 == "version:" {print $2; exit}' .github/workflows/ci.yaml
}

# The toolchain to build the tools below with: the version go.mod
# targets, or newer if a tool asks for it. `go run tool@version` picks
# its toolchain from that tool's own go.mod, so on a machine whose Go is
# older than ours golangci-lint gets built with the Go 1.25 it asks for
# — and then refuses to run, because a linter cannot check a module that
# targets a Go newer than the one that built it. CI installs a released
# binary, which is built with a current Go, and never sees this.
tool_toolchain() {
	# The toolchain line when there is one, the go line otherwise: a `go`
	# directive is a minimum like "1.26" and not a toolchain name, so
	# reading it here would ask for a toolchain that does not exist.
	awk '$1 == "toolchain" {print $2 "+auto"; found = 1; exit}
	     $1 == "go" {v = $2}
	     END {if (!found) print "go" v "+auto"}' go.mod
}

run_core() {
	step "gofmt"
	test -z "$(gofmt -l .)" || {
		gofmt -l .
		exit 1
	}
	step "go vet"
	go vet ./...
	# `go fix` since Go 1.26 is not the pre-1.0 API renamer its name comes
	# from: it runs the modernizers — new(expr), min/max, range-over-int,
	# SplitSeq, wg.Go, slices and maps — and `-diff` prints the patch
	# instead of applying it, exiting non-zero when it is not empty. That
	# is the shape design doc 0035 §3.1 asks of a check: on a clean tree
	# it says nothing, so a finding always means new code written in an
	# older idiom. Unlike golangci-lint there is no version to pin — it
	# ships with the toolchain go.mod already names.
	step "go fix -diff"
	go fix -diff ./... || {
		echo "check: the fixes above are what \`go fix ./...\` would apply" >&2
		exit 1
	}
	step "go test -race"
	if [ -z "${OCHAKAI_TEST_DATABASE_URL:-}" ]; then
		# Named rather than "store tests": four packages need a database,
		# and the run itself cannot say so — `go test ./...` throws away
		# the output of a package that passed, so the per-package count
		# testdb.Report prints reaches -v and failures but not this. The
		# script is the one being watched, so the script says it.
		echo "check: OCHAKAI_TEST_DATABASE_URL unset — the tests that need PostgreSQL will skip: internal/store, internal/service, internal/restapi, internal/mcpserver (see --db)" >&2
		skipped_store=yes
	fi
	# -count=1 for the same reason CI passes it: the store tests talk to
	# Postgres, which the test cache cannot see.
	go test -race -count=1 ./...
	step "CGO_ENABLED=0 go build"
	CGO_ENABLED=0 go build -trimpath ./...
}

# The web UI is ES modules now, and the ones that are pure — the markdown
# renderer, the formatters, the document edits — are held to examples by
# Node's own test runner. No package.json and no node_modules: the point
# of a page with no build step is that its tests need no build either.
#
# The browser smoke beside them (internal/webui/jstest/smoke.mjs) is not
# run here, because it needs a database, a server and a browser; CI's
# `webui` job stands those up and runs it.
run_ui() {
	if ! command -v node >/dev/null 2>&1; then
		step "web UI tests skipped (no node; CI runs them)"
		skipped_ui=yes
		return
	fi
	step "node --test (web UI)"
	node --test "internal/webui/jstest/*.test.js"
}

run_lint() {
	v=$(linter_version)
	case $v in
	v*) ;;
	*)
		echo "check: could not read the golangci-lint version from .github/workflows/ci.yaml" >&2
		exit 1
		;;
	esac
	step "golangci-lint $v"
	GOTOOLCHAIN=$(tool_toolchain) \
		go run "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$v" run ./...
}

# The workflows, audited by the tool CI's `actions` job runs. Offline, so
# this and CI answer the same thing from the same bytes — and over the
# same bytes: both hand it the repository root and let it collect, which
# is more than the workflows — `dependabot.yml` is in the set, and a first
# version of this step that named `.github/workflows` passed locally while
# CI failed on that file. Whatever the tool decides to read, both read
# it.
#
# zizmor is neither a Go tool nor a Node one, so unlike every other step
# here there is no runtime already required for something else to run it
# with. `uvx` is how it is distributed and how CI's action installs it;
# when neither it nor a zizmor on PATH is present this skips and says so,
# the way the web UI step does when there is no node.
run_actions() {
	v=$(zizmor_version)
	case $v in
	[0-9]*) ;;
	*)
		echo "check: could not read the zizmor version from .github/workflows/ci.yaml" >&2
		exit 1
		;;
	esac
	if command -v zizmor >/dev/null 2>&1; then
		step "zizmor $(zizmor --version | awk '{print $2}') (on PATH, not the pinned $v)"
		zizmor --offline .
		return
	fi
	if command -v uvx >/dev/null 2>&1; then
		step "zizmor $v"
		uvx "zizmor@$v" --offline .
		return
	fi
	step "workflow audit skipped (no zizmor and no uvx; CI runs it)"
	skipped_actions=yes
}

run_vuln() {
	step "govulncheck"
	# Unpinned, like CI: a new govulncheck release failing means a new
	# vulnerability, not a new check.
	GOTOOLCHAIN=$(tool_toolchain) \
		go run golang.org/x/vuln/cmd/govulncheck@latest ./...
}

if [ "$want_db" = yes ]; then start_db; fi

for s in $steps; do
	case $s in
	core) run_core ;;
	ui) run_ui ;;
	lint) run_lint ;;
	actions) run_actions ;;
	vuln) run_vuln ;;
	esac
done

# What the last line says is what gets remembered, and CI runs these steps
# against a database while a local run without --db does not. "ok" alone
# would let a run that never touched the store read as the run CI will do
# — and so would a run against a PostgreSQL that is not the one CI runs.
note=""
if [ "$skipped_store" = yes ]; then
	note=" — the tests needing PostgreSQL skipped (rerun with --db; CI runs them)"
elif [ -n "$db_note" ]; then
	note=" — the tests needing PostgreSQL ran against $db_note"
fi
if [ "$skipped_ui" = yes ]; then
	note="$note${note:+;} — web UI tests skipped (install node; CI runs them)"
fi
if [ "$skipped_actions" = yes ]; then
	note="$note${note:+;} — workflow audit skipped (install uv; CI runs it)"
fi
step "ok$note"
