SHELL := /usr/bin/env bash

GO ?= go
GOFMT ?= gofmt
INSTALL ?= install
PREFIX ?= $(HOME)/.local
BINDIR ?= $(PREFIX)/bin
LIBDIR ?= $(PREFIX)/lib
DESTDIR ?=
BUILD_DIR ?= build
BUILD_BIN_DIR ?= $(BUILD_DIR)/bin
BUILD_MANUAL_DIR ?= $(BUILD_DIR)/manual
BANGER_BIN ?= $(BUILD_BIN_DIR)/banger
BANGERD_BIN ?= $(BUILD_BIN_DIR)/bangerd
VSOCK_AGENT_BIN ?= $(BUILD_BIN_DIR)/banger-vsock-agent
BINARIES := $(BANGER_BIN) $(BANGERD_BIN) $(VSOCK_AGENT_BIN)
GO_SOURCES := $(shell find cmd internal -type f -name '*.go' | sort)
# BUILD_INPUTS is everything that can change a binary's bytes: Go sources
# plus embedded assets (catalog.json, future static files). Listing
# everything is cheaper than missing a rebuild — go's own cache absorbs
# any redundant invocations.
BUILD_INPUTS := $(shell find cmd internal -type f | sort)
SHELL_SOURCES := $(shell find scripts -type f -name '*.sh' | sort)
SMOKE_DIR := $(BUILD_DIR)/smoke
SMOKE_BIN_DIR := $(SMOKE_DIR)/bin
SMOKE_COVER_DIR := $(SMOKE_DIR)/covdata
SMOKE_XDG_DIR := $(SMOKE_DIR)/xdg
VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --verify HEAD 2>/dev/null || echo unknown)
BUILT_AT ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
GO_LDFLAGS := -X banger/internal/buildinfo.Version=$(VERSION) -X banger/internal/buildinfo.Commit=$(COMMIT) -X banger/internal/buildinfo.BuiltAt=$(BUILT_AT)

.DEFAULT_GOAL := help

# `make smoke-one` requires SCENARIO=. Validate before any prerequisite
# (notably smoke-build) so a typo'd invocation doesn't pay for a Go
# rebuild before learning it's wrong.
ifneq (,$(filter smoke-one,$(MAKECMDGOALS)))
ifndef SCENARIO
$(error smoke-one needs SCENARIO=name (see `make smoke-list` for names))
endif
endif

.PHONY: help build banger bangerd test fmt tidy clean install uninstall lint lint-go lint-shell coverage coverage-html coverage-total coverage-combined coverage-combined-html smoke smoke-build smoke-list smoke-one smoke-coverage-html smoke-clean smoke-fresh install-hooks

help:
	@printf '%s\n' \
	  'Targets:' \
	  '  make build           Build ./build/bin/banger, ./build/bin/bangerd, and ./build/bin/banger-vsock-agent' \
	  '  make install         Build and install banger, bangerd, and the companion vsock helper' \
	  '  make uninstall       Stop the daemon and remove installed binaries (leaves user state by default)' \
	  '  make test            Run go test ./...' \
	  '  make coverage        Run tests with coverage; print per-package + total' \
	  '  make coverage-html   Open a browsable per-line HTML report (writes coverage.html)' \
	  '  make coverage-total  Print just the total statement coverage (for scripts/CI)' \
	  '  make coverage-combined  Merge unit-test + smoke covdata; print per-package + total' \
	  '  make coverage-combined-html  HTML report of the merged unit+smoke coverage' \
	  '  make lint            Run gofmt + go vet + shellcheck (errors)' \
	  '  make fmt             Format Go sources under cmd/ and internal/' \
	  '  make tidy            Run go mod tidy' \
	  '  make clean           Remove built Go binaries and coverage artefacts' \
	  '  make smoke           Build instrumented binaries, run the supported systemd smoke suite, report coverage (needs KVM + sudo)' \
	  '  make smoke JOBS=N    Override parallelism (default: nproc, capped at 8). JOBS=1 forces serial.' \
	  '  make smoke-list      Print the list of smoke scenarios (no build, no install)' \
	  '  make smoke-one SCENARIO=NAME  Run a single smoke scenario (still does the install preamble; comma-separated for several)' \
	  '  make smoke-fresh     smoke-clean + smoke — purges stale smoke-owned installs before a clean supported-path run' \
	  '  make smoke-coverage-html  HTML coverage report from the last smoke run' \
	  '  make smoke-clean     Remove the smoke build tree and purge any stale smoke-owned system install' \
	  '  make install-hooks   Point core.hooksPath at .githooks (lint + test + build run on every commit)'

build: $(BINARIES)

$(BANGER_BIN): $(BUILD_INPUTS) go.mod go.sum
	mkdir -p "$(BUILD_BIN_DIR)"
	$(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(BANGER_BIN)" ./cmd/banger

$(BANGERD_BIN): $(BUILD_INPUTS) go.mod go.sum
	mkdir -p "$(BUILD_BIN_DIR)"
	$(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(BANGERD_BIN)" ./cmd/bangerd

$(VSOCK_AGENT_BIN): $(BUILD_INPUTS) go.mod go.sum
	mkdir -p "$(BUILD_BIN_DIR)"
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(VSOCK_AGENT_BIN)" ./cmd/banger-vsock-agent

test:
	$(GO) test ./...

# Coverage targets use -coverpkg=./... so packages without their own
# tests still get counted when another package exercises them (common
# for daemon/* subpackages). coverage.out is gitignored.
coverage:
	$(GO) test -coverpkg=./... -coverprofile=coverage.out ./...
	@echo ''
	@echo 'Per-package:'
	@$(GO) tool cover -func=coverage.out | awk -F'\t+' '/^total:/ {total=$$NF; next} {pkg=$$1; sub("banger/", "", pkg); sub("/[^/]+:[0-9]+:$$", "", pkg); pkgs[pkg]+=1; covered[pkg]+=$$NF+0} END {for (p in pkgs) printf "  %-40s %.1f%% (avg of %d funcs)\n", p, covered[p]/pkgs[p], pkgs[p] | "sort"; print ""; print "Total statement coverage:", total}'

coverage-html: coverage
	$(GO) tool cover -html=coverage.out -o coverage.html
	@echo 'wrote coverage.html'

coverage-total:
	@$(GO) test -coverpkg=./... -coverprofile=coverage.out ./... >/dev/null 2>&1 && $(GO) tool cover -func=coverage.out | awk '/^total:/ {print $$NF}'

# coverage-combined unions unit-test coverage and smoke coverage into
# one report. Unit tests cover pure-Go logic (error branches, parsing,
# handler wiring); smoke covers the real sudo / firecracker / dm-snap
# paths that unit tests physically can't reach. Separately each tells
# half the story. Merged, this is the single "what's not being
# exercised at all" view.
#
# Requires an up-to-date smoke run (the target depends on smoke-build
# to rebuild instrumented binaries; re-run `make smoke` yourself if
# scenarios changed). Modes must match; smoke uses the default 'set',
# so the unit run below drops the default 'atomic' for alignment.
COMBINED_COVER_DIR := $(BUILD_DIR)/combined
UNIT_COVER_DIR     := $(BUILD_DIR)/unit/covdata
coverage-combined:
	@test -d "$(SMOKE_COVER_DIR)" && test "$$(ls -A $(SMOKE_COVER_DIR) 2>/dev/null)" || { \
	  echo 'no smoke covdata at $(SMOKE_COVER_DIR); run `make smoke` first' >&2; exit 1; \
	}
	rm -rf "$(UNIT_COVER_DIR)" "$(COMBINED_COVER_DIR)"
	mkdir -p "$(UNIT_COVER_DIR)" "$(COMBINED_COVER_DIR)"
	$(GO) test -cover -coverpkg=./... ./... -args -test.gocoverdir="$(abspath $(UNIT_COVER_DIR))" >/dev/null
	$(GO) tool covdata merge -i="$(UNIT_COVER_DIR),$(SMOKE_COVER_DIR)" -o="$(COMBINED_COVER_DIR)"
	$(GO) tool covdata textfmt -i="$(COMBINED_COVER_DIR)" -o="$(BUILD_DIR)/combined.cover.out"
	@echo ''
	@echo 'Per-package (merged unit + smoke):'
	@$(GO) tool cover -func="$(BUILD_DIR)/combined.cover.out" | awk -F'\t+' '/^total:/ {total=$$NF; next} {pkg=$$1; sub("banger/", "", pkg); sub("/[^/]+:[0-9]+:$$", "", pkg); pkgs[pkg]+=1; covered[pkg]+=$$NF+0} END {for (p in pkgs) printf "  %-40s %.1f%% (avg of %d funcs)\n", p, covered[p]/pkgs[p], pkgs[p] | "sort"; print ""; print "Total statement coverage:", total}'

coverage-combined-html: coverage-combined
	$(GO) tool cover -html="$(BUILD_DIR)/combined.cover.out" -o "$(BUILD_DIR)/combined.cover.html"
	@echo 'wrote $(BUILD_DIR)/combined.cover.html'

lint: lint-go lint-shell

lint-go:
	@unformatted="$$($(GOFMT) -l $(GO_SOURCES))"; \
	if [ -n "$$unformatted" ]; then \
	  printf 'gofmt: the following files are not formatted:\n%s\n' "$$unformatted" >&2; \
	  exit 1; \
	fi
	$(GO) vet ./...

lint-shell:
	@command -v shellcheck >/dev/null 2>&1 || { echo 'shellcheck is required for make lint-shell' >&2; exit 1; }
	shellcheck --severity=error $(SHELL_SOURCES)

fmt:
	$(GOFMT) -w $(GO_SOURCES)

tidy:
	$(GO) mod tidy

# Local-only: redirect git's hook lookup at .githooks/ so .githooks/pre-commit
# fires on every `git commit`. Idempotent. Bypass an individual commit with
# `git commit --no-verify`.
install-hooks:
	git config core.hooksPath .githooks
	@echo 'core.hooksPath -> .githooks (run `git config --unset core.hooksPath` to revert)'

clean:
	rm -rf "$(BUILD_BIN_DIR)" coverage.out coverage.html

# Smoke test suite. Builds the three banger binaries with -cover
# instrumentation under $(SMOKE_BIN_DIR), installs them as temporary
# bangerd.service + bangerd-root.service, runs the Go scenarios under
# internal/smoketest (built with -tags=smoke), copies service covdata
# out of /var/lib/banger, then purges the smoke-owned install on exit.
#
# This touches global systemd state. The harness refuses to overwrite a
# pre-existing non-smoke install and drops a marker file under
# /etc/banger so `make smoke-clean` can recover a stale smoke-owned
# install after an interrupted run.
#
# Requires a KVM-capable Linux host with sudo. This is a pre-release
# gate, not CI — the Go unit suite (`make test`) is what runs everywhere.
smoke-build: $(SMOKE_BIN_DIR)/.built

$(SMOKE_BIN_DIR)/.built: $(BUILD_INPUTS) go.mod go.sum
	mkdir -p "$(SMOKE_BIN_DIR)"
	$(GO) build -cover -ldflags '$(GO_LDFLAGS)' -o "$(SMOKE_BIN_DIR)/banger" ./cmd/banger
	$(GO) build -cover -ldflags '$(GO_LDFLAGS)' -o "$(SMOKE_BIN_DIR)/bangerd" ./cmd/bangerd
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build -ldflags '$(GO_LDFLAGS)' -o "$(SMOKE_BIN_DIR)/banger-vsock-agent" ./cmd/banger-vsock-agent
	touch "$@"

# JOBS defaults to nproc; SMOKE_JOBS clamps it at 8. Each parallel slot
# runs a smoke-tuned VM, and over-subscribing the host pushes
# waitForSSH past its 60s deadline. Floored at 1 so JOBS=1 still works.
JOBS ?= $(shell nproc 2>/dev/null || echo 1)
SMOKE_JOBS := $(shell n=$(JOBS); [ $$n -lt 1 ] && n=1; [ $$n -gt 8 ] && n=8; echo $$n)

smoke: smoke-build
	rm -rf "$(SMOKE_COVER_DIR)"
	mkdir -p "$(SMOKE_COVER_DIR)" "$(SMOKE_XDG_DIR)"
	BANGER_SMOKE_BIN_DIR="$(abspath $(SMOKE_BIN_DIR))" \
	BANGER_SMOKE_COVER_DIR="$(abspath $(SMOKE_COVER_DIR))" \
	BANGER_SMOKE_XDG_DIR="$(abspath $(SMOKE_XDG_DIR))" \
	  $(GO) test -tags=smoke -count=1 -v -parallel $(SMOKE_JOBS) -timeout 30m ./internal/smoketest
	@echo ''
	@echo 'Smoke coverage:'
	@$(GO) tool covdata percent -i="$(SMOKE_COVER_DIR)"

# smoke-list parses the test scaffold for scenario names. Cheap: no
# smoke-build dep, no env vars, no test binary spawned.
smoke-list:
	@grep -oE 't\.Run\("[a-z_]+", *test[A-Za-z]+\)' internal/smoketest/smoke_test.go \
	  | sed -E 's/t\.Run\("([a-z_]+)".*/  \1/'

# smoke-one runs one scenario (or a comma-separated list) with the
# install preamble. Comma list becomes a regex alternation so multiple
# scenarios can be selected without invoking go test by hand.
SCENARIO_PATTERN := $(shell echo '$(SCENARIO)' | tr ',' '|')

smoke-one: smoke-build
	rm -rf "$(SMOKE_COVER_DIR)"
	mkdir -p "$(SMOKE_COVER_DIR)" "$(SMOKE_XDG_DIR)"
	BANGER_SMOKE_BIN_DIR="$(abspath $(SMOKE_BIN_DIR))" \
	BANGER_SMOKE_COVER_DIR="$(abspath $(SMOKE_COVER_DIR))" \
	BANGER_SMOKE_XDG_DIR="$(abspath $(SMOKE_XDG_DIR))" \
	  $(GO) test -tags=smoke -count=1 -v -timeout 30m \
	    -run "TestSmoke/.*/($(SCENARIO_PATTERN))$$" \
	    ./internal/smoketest

smoke-coverage-html: smoke
	$(GO) tool covdata textfmt -i="$(SMOKE_COVER_DIR)" -o="$(SMOKE_DIR)/cover.out"
	$(GO) tool cover -html="$(SMOKE_DIR)/cover.out" -o "$(SMOKE_DIR)/cover.html"
	@echo 'wrote $(SMOKE_DIR)/cover.html'

smoke-clean:
	@if sudo test -f /etc/banger/.smoke-owned; then \
	  bin=''; \
	  if [ -x "$(SMOKE_BIN_DIR)/banger" ]; then \
	    bin="$(abspath $(SMOKE_BIN_DIR))/banger"; \
	  elif [ -x "$(BANGER_BIN)" ]; then \
	    bin="$(abspath $(BANGER_BIN))"; \
	  elif [ -x /usr/local/bin/banger ]; then \
	    bin=/usr/local/bin/banger; \
	  fi; \
	  if [ -n "$$bin" ]; then \
	    sudo "$$bin" system uninstall --purge >/dev/null 2>&1 || true; \
	  fi; \
	fi
	rm -rf "$(SMOKE_DIR)"

# smoke-fresh wipes the instrumented build tree, purges any stale
# smoke-owned install, and then runs the supported-path smoke suite
# from scratch.
smoke-fresh: smoke-clean smoke

install: build
	mkdir -p "$(DESTDIR)$(BINDIR)"
	mkdir -p "$(DESTDIR)$(LIBDIR)/banger"
	$(INSTALL) -m 0755 "$(BANGER_BIN)" "$(DESTDIR)$(BINDIR)/banger"
	$(INSTALL) -m 0755 "$(BANGERD_BIN)" "$(DESTDIR)$(BINDIR)/bangerd"
	$(INSTALL) -m 0755 "$(VSOCK_AGENT_BIN)" "$(DESTDIR)$(LIBDIR)/banger/banger-vsock-agent"

# uninstall stops a running daemon (if any) and removes the installed
# binaries. It does NOT touch user data (config, SSH keys, VM state,
# image/kernel caches) — rm -rf those paths manually if wanted; they
# are printed for convenience.
uninstall:
	@if [ -x "$(DESTDIR)$(BINDIR)/banger" ]; then \
	  "$(DESTDIR)$(BINDIR)/banger" daemon stop >/dev/null 2>&1 || true; \
	fi
	rm -f "$(DESTDIR)$(BINDIR)/banger" "$(DESTDIR)$(BINDIR)/bangerd"
	rm -rf "$(DESTDIR)$(LIBDIR)/banger"
	@printf '\nRemoved binaries. User data is preserved at:\n'
	@printf '  ~/.config/banger/       (config, ssh keys)\n'
	@printf '  ~/.local/state/banger/  (VMs, images, kernels, db, logs)\n'
	@printf '  ~/.cache/banger/        (OCI layer cache)\n'
	@printf '\nDelete those paths manually if you want a full purge.\n'
