.PHONY: install build dev run clean test test-webapp check check-boundary deps tidy dist release install-full models help server-status server-restart sidecar sidecar-clean dice-mcp dice-mcp-clean pixel-matrix-live lora-style-setup lora-style-prepare lora-style-train tauri-deps tauri-dev tauri-dist tauri-check hub-kratos-push hub-setup hub-deploy hub-logs hub-down

# Version is stamped into the binary rather than read from config/version.go, so
# a release build always reports what it actually is. Falls back to the source
# default when git has no tag to offer (a plain source tarball, say).
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)

# The central EchoBark hub a shipped client points at for identity, discovery and
# job brokering, and the marketing site it links for the desktop download. Both
# empty by default so a source build stays standalone; set for release builds:
#   make dist MESH_HUB=https://hub.maylyn.cloud WEBSITE_URL=https://app.maylyn.cloud
MESH_HUB ?=
WEBSITE_URL ?=
LDFLAGS := -s -w -X maylyn/config.Version=$(VERSION) -X maylyn/config.DefaultMeshHub=$(MESH_HUB)
ifneq ($(strip $(WEBSITE_URL)),)
LDFLAGS += -X maylyn/config.websiteURL=$(WEBSITE_URL)
endif

# Dev builds additionally record where they were built from, which is what lets
# them rebuild themselves on launch (see backend/devrebuild.go) instead of
# quietly serving stale code until someone reads a vet warning.
#
# DEV_LDFLAGS is used by `build` and `install` ONLY. `dist` and `release` keep
# plain LDFLAGS on purpose: a shipped binary must never try to rebuild itself
# from a path that exists only on the machine that built it.
BUILD_STAMP := $(shell date -u +%Y%m%d-%H%M%S)
# BuildStamp makes two builds of the same commit distinguishable. Without it
# `git describe` returns the identical "-dirty" string for every rebuild, so the
# UI could not tell a stale running process from an updated one.
DEV_LDFLAGS := $(LDFLAGS) -X maylyn/config.SourceDir=$(CURDIR) -X maylyn/config.BuildStamp=$(BUILD_STAMP)

# CGO off is what makes cross-compilation work at all: it is why a Linux box can
# emit a working Windows and macOS binary without a cross-toolchain. Nothing in
# Maylyn needs cgo — the clipboard package falls back to external tools (xclip,
# pbcopy, OSC 52) when its native backend is unavailable.
export CGO_ENABLED=0

# Every target we ship. Keep in sync with the release matrix in the README.
PLATFORMS := \
	linux/amd64 \
	linux/arm64 \
	darwin/amd64 \
	darwin/arm64 \
	windows/amd64 \
	windows/arm64

help:
	@echo "Maylyn $(VERSION)"
	@echo
	@echo "  make install       build + install to GOBIN (current platform)"
	@echo "  make install-full  toolchain + deps + build + llama.cpp check + models"
	@echo "  make models        download models listed in models.json"
	@echo "  make sidecar       rebuild the embedded deepagents sidecar (needs node)"
	@echo "  make dice-mcp      install deps for the local dice MCP server (needs node)"
	@echo
	@echo "  make build         build ./maylyn for this platform"
	@echo "  make server-status show exactly which binary is serving port 8787"
	@echo "  make server-restart install current build and restart maylyn-serve.service"
	@echo "  make dist          cross-compile every platform into ./dist"
	@echo "  make release       dist + checksums + archives"
	@echo
	@echo "  make check         build, vet, and test (Go with -race, plus webapp JS)"
	@echo "  make deps          download and tidy Go modules"
	@echo "  make pixel-matrix-live         run 9-arm live pixel option matrix"
	@echo "  make lora-style-setup          install local sd-scripts trainer env"
	@echo "  make lora-style-prepare        build dataset from licensed sprite sheets"
	@echo "  make lora-style-train          train a local style LoRA"

# ── dependencies ─────────────────────────────────────────────────────────────

deps:
	go mod download all
	go mod tidy

tidy: deps

# ── deepagents sidecar ───────────────────────────────────────────────────────
# The agent loop runs in deepagents (npm), bundled to one file and embedded in
# the Go binary. The bundle is CHECKED IN at backend/assets/sidecar.js so a
# fresh clone builds without Node; this target regenerates it and is only
# needed after editing sidecar/src.
#
# It is deliberately NOT a prerequisite of build/dist. Making every Go build
# depend on npm would put a Node toolchain in the path of `go build ./...`,
# which is exactly the property CGO_ENABLED=0 exists to protect.

SIDECAR_SRC := $(wildcard sidecar/src/*.ts) sidecar/build.mjs sidecar/package.json
SIDECAR_OUT := backend/assets/sidecar.js

sidecar: $(SIDECAR_OUT)

$(SIDECAR_OUT): $(SIDECAR_SRC)
	@command -v node >/dev/null || { echo "node is required to rebuild the sidecar"; exit 1; }
	@cd sidecar && npm ci --silent && ./node_modules/.bin/tsc --noEmit -p tsconfig.json && node build.mjs
	@cp sidecar/dist/sidecar.js $(SIDECAR_OUT)
	@echo "Embedded sidecar updated: $$(du -h $(SIDECAR_OUT) | cut -f1)"

sidecar-clean:
	@rm -rf sidecar/dist sidecar/node_modules

# ── dice MCP server ──────────────────────────────────────────────────────────
# mcp/dice-wrapper is a standalone stdio MCP server that backs the tabletop
# specialist's mcp_dice_call tool (see specialist/specialist.go). Maylyn does
# not launch it directly: it is spawned from an entry in the MCP config that
# mcpConfigPath() resolves to $MAYLYN_BASE_DIR/mcp.json (tools/mcp_bridge.go).
#
# Its node_modules is NOT checked in, matching the sidecar. Run this once
# before adding it to mcp.json. Nothing in `go build` depends on it.

dice-mcp:
	@command -v node >/dev/null || { echo "node is required for the dice MCP server"; exit 1; }
	@cd mcp/dice-wrapper && npm ci --silent
	@echo "Dice MCP server ready: mcp/dice-wrapper/server.js"

dice-mcp-clean:
	@rm -rf mcp/dice-wrapper/node_modules

# ── local build ──────────────────────────────────────────────────────────────

# Reports where the binary landed and whether that directory is on PATH.
# Silence here is how an old install ends up shadowing a new build for days:
# every step appears to succeed while the bare `maylyn` keeps running stale code.
install:
	@go install -ldflags "$(DEV_LDFLAGS)" .
	@dir="$$(go env GOBIN)"; [ -n "$$dir" ] || dir="$$(go env GOPATH)/bin"; \
	echo "Installed $(VERSION) to $$dir/maylyn"; \
	case ":$$PATH:" in \
	  *":$$dir:"*) echo "  on PATH - 'maylyn' now runs $(VERSION)" ;; \
	  *) echo "  WARNING: $$dir is NOT on your PATH - add it, or 'maylyn' will run some other copy" ;; \
	esac

# Builds ./maylyn for local use. That is NOT what a bare `maylyn` runs — use
# `make install` for that; `maylyn vet` reports when the two disagree.
build:
	go build -ldflags "$(DEV_LDFLAGS)" -o ./maylyn .

dev:
	go run ./cmd/dev

server-status:
	@command -v curl >/dev/null || { echo "curl is required"; exit 1; }
	@systemctl --user --no-pager --plain status maylyn-serve.service 2>/dev/null | sed -n '1,12p' || true
	@curl --fail --silent http://127.0.0.1:8787/health | jq . || { echo "No Maylyn server answered on 127.0.0.1:8787"; exit 1; }

server-restart: install
	@command -v maylyn >/dev/null 2>&1 || { echo "maylyn is not on your PATH after install; restart with: systemctl --user restart maylyn-serve.service"; exit 1; }
	@maylyn server-restart

run: build
	./maylyn

# ── full install: deps, toolchain checks, models ─────────────────────────────
# The script is the source of truth; the make target is just the friendly name.

install-full:
	@./scripts/install.sh

models:
	@./scripts/install.sh --models

# ── verification ─────────────────────────────────────────────────────────────

# The race detector is the one thing here that genuinely needs cgo, so it opts
# back in locally rather than dropping the CGO_ENABLED=0 default that makes
# cross-compilation work.
check:
	go build ./...
	go vet ./...
	@$(MAKE) --no-print-directory check-boundary
	CGO_ENABLED=1 go test -race ./...
	@$(MAKE) --no-print-directory test-webapp

test:
	go test ./...
	@$(MAKE) --no-print-directory test-webapp

# The execution-boundary guard. Layer 1 of the invariant is the compiler
# (tools.Tool's execution funcs are unexported); this is layer 2 — the part a
# compiler cannot see, which is that only the control plane may call the
# chokepoint. Runs in under a second and belongs in front of the test suite, so
# a bypass fails before anyone waits on -race.
check-boundary:
	@./scripts/check_tool_exec.sh

# The web client's controllers carry 50 node:test cases that nothing used to
# run — not `make check`, not CI. They passed only because someone remembered
# to invoke node by hand. Skipped (not failed) when node is absent, so a Go-only
# clone still builds; CI has node, so there it really runs.
test-webapp:
	@command -v node >/dev/null 2>&1 || { echo "skipping webapp tests: node not found"; exit 0; }
	@node --test backend/webapp/tests/*.test.js

# ── live matrix validation ────────────────────────────────────────────────────

# Runs the apples-to-apples nine-arm render matrix from tools/live_option_matrix_test.go.
# MAYLYN_AB_REF is optional; if set, the palette-reference arm uses that image.
pixel-matrix-live:
	@MAYLYN_LIVE_RENDER=1 MAYLYN_AB_REF="$(MAYLYN_AB_REF)" go test ./tools -run TestLiveOptionMatrix -count=1 -v

# ── local LoRA style pipeline ────────────────────────────────────────────────

# Required vars:
#   SHEETS_DIR=path/to/licensed/sprite_sheets
# Optional vars:
#   DATASET_DIR=artifacts/lora_dataset/style_sheets
#   RUN_NAME=style_lora_v1
#   TRIGGER=stylepx
#   TILE_SIZE=auto
#   RESOLUTION=auto
#   EPOCHS=10
#   BATCH=1
#   RANK=16
#   ALPHA=16

lora-style-setup:
	@./scripts/lora_style_setup.sh

lora-style-prepare:
	@test -n "$(SHEETS_DIR)" || (echo "SHEETS_DIR is required" && exit 2)
	@./scripts/lora_style_prepare.sh \
		--sheets-dir "$(SHEETS_DIR)" \
		--dataset-dir "$(if $(DATASET_DIR),$(DATASET_DIR),artifacts/lora_dataset/style_sheets)" \
		--trigger "$(if $(TRIGGER),$(TRIGGER),stylepx)" \
		--tile-size "$(if $(TILE_SIZE),$(TILE_SIZE),auto)" \
		--resolution "$(if $(RESOLUTION),$(RESOLUTION),auto)"

lora-style-train:
	@test -n "$(DATASET_DIR)" || (echo "DATASET_DIR is required" && exit 2)
	@test -n "$(RUN_NAME)" || (echo "RUN_NAME is required" && exit 2)
	@./scripts/lora_style_train.sh \
		--dataset-dir "$(DATASET_DIR)" \
		--name "$(RUN_NAME)" \
		--trigger "$(if $(TRIGGER),$(TRIGGER),stylepx)" \
		--epochs "$(if $(EPOCHS),$(EPOCHS),10)" \
		--batch-size "$(if $(BATCH),$(BATCH),1)" \
		--rank "$(if $(RANK),$(RANK),16)" \
		--alpha "$(if $(ALPHA),$(ALPHA),16)" \
		--resolution "$(if $(RESOLUTION),$(RESOLUTION),auto)"

# ── cross-compilation ────────────────────────────────────────────────────────
# One binary per platform in ./dist. Windows gets .exe; everything else doesn't.

dist: clean-dist
	@mkdir -p dist
	@for platform in $(PLATFORMS); do \
		os=$${platform%/*}; arch=$${platform#*/}; \
		out="dist/maylyn_$${os}_$${arch}"; \
		if [ "$$os" = "windows" ]; then out="$$out.exe"; fi; \
		printf '  %-16s' "$$os/$$arch"; \
		if GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags "$(LDFLAGS)" -o "$$out" . ; then \
			echo "$$(du -h "$$out" | cut -f1)"; \
		else \
			echo "FAILED"; exit 1; \
		fi; \
	done
	@echo "\nBuilt $(VERSION) into ./dist"

# ── Tauri desktop shell ─────────────────────────────────────────────────────
# The Tauri app in src-tauri/ replaces the Electron shell. The Go backend is
# bundled as a sidecar; src-tauri/scripts/prepare-backend.js stages the matching
# `make dist` output. Needs the Tauri CLI (`cargo install tauri-cli --version
# '^2'`) and, on Linux, the system libraries `make tauri-deps` prints.
TAURI := $(HOME)/.cargo/bin/cargo-tauri

tauri-deps:
	@echo "Linux build dependencies (Debian/Ubuntu/Mint):"
	@echo "  sudo apt-get install -y libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \\"
	@echo "    libsoup-3.0-dev librsvg2-dev libayatana-appindicator3-dev patchelf"
	@echo "Tauri CLI:  cargo install tauri-cli --version '^2' --locked"

tauri-dev: dist
	@cd src-tauri && $(TAURI) tauri dev

# Fast local iteration: build ONLY the host backend + a DEBUG desktop binary
# (no installer bundling, no release LTO), stage the sidecar next to the binary,
# stop any running instance and launch. A webapp-only edit is ~15s because Rust
# sees no change; a src-tauri/ edit is a debug recompile, still far quicker than
# the release profile the installers use.
#
#   make desktop        one build + launch
#   make desktop-watch  rebuild + relaunch on every save (needs `entr`)
#
DESKTOP_HOST_TRIPLE ?= $(shell rustc -vV 2>/dev/null | sed -n 's/^host: //p')
DESKTOP_BIN := src-tauri/target/debug/maylyn-desktop
# Plain LDFLAGS, not DEV_LDFLAGS: the BuildStamp in DEV_LDFLAGS changes every
# build, which changes the staged sidecar, which forces cargo to recompile the
# shell even when nothing else moved.
desktop:
	@echo "→ building host backend"
	@go build -trimpath -ldflags "$(LDFLAGS)" -o dist/maylyn_host .
	@cmp -s dist/maylyn_host src-tauri/binaries/maylyn-backend-$(DESKTOP_HOST_TRIPLE) 2>/dev/null \
		|| cp -f dist/maylyn_host src-tauri/binaries/maylyn-backend-$(DESKTOP_HOST_TRIPLE)
	@echo "→ building desktop shell (debug, $(DESKTOP_HOST_TRIPLE))"
	@cd src-tauri && MAYLYN_BACKEND_BIN=$(CURDIR)/dist/maylyn_host TAURI_BACKEND_TRIPLE=$(DESKTOP_HOST_TRIPLE) \
		$(TAURI) tauri build --debug --no-bundle
	@cp -f dist/maylyn_host $(dir $(DESKTOP_BIN))maylyn-backend
	@echo "→ stopping any running instance"
	@# SIGTERM, then wait for a clean exit so the single-instance DBus name and
	@# port 8799 are released before relaunch. Racing this is what surfaces as
	@# "org.freedesktop.DBus.Error.NoReply" on the next start.
	@pkill -TERM -x maylyn-desktop 2>/dev/null || true
	@pkill -TERM -x maylyn-backend 2>/dev/null || true
	@i=0; while pgrep -x maylyn-desktop >/dev/null 2>&1 && [ $$i -lt 40 ]; do sleep 0.25; i=$$((i+1)); done
	@pkill -KILL -x maylyn-desktop 2>/dev/null || true
	@pkill -KILL -x maylyn-backend 2>/dev/null || true
	@sleep 1
	@echo "→ launching $(DESKTOP_BIN)"
	@setsid $(DESKTOP_BIN) >/tmp/maylyn-desktop.log 2>&1 < /dev/null &
	@echo "   logs: tail -f /tmp/maylyn-desktop.log"

desktop-watch:
	@command -v entr >/dev/null || { echo "install entr: sudo apt install entr"; exit 1; }
	@git ls-files backend/webapp src-tauri/src | entr -r $(MAKE) desktop

# One platform's installer for the host. CI (tauri-release.yml) does the matrix.
tauri-dist: dist
	@cd src-tauri && $(TAURI) tauri build

tauri-check: dist
	@cd src-tauri && cargo fmt --check && cargo clippy -- -D warnings && cargo test

# ── EchoBark hub deployment (Kamal) ─────────────────────────────────────────
# The hub image itself is built and pushed by Kamal (`bin/kamal deploy -d dev`).
# The Kratos accessory image is not — Kamal only builds the app image — so it is
# built and pushed here. Rerun whenever deploy/kratos/* changes.
HUB_KRATOS_IMAGE ?= ghcr.io/devpotionofficial/maylyn-kratos:v1.3.1

hub-kratos-push:
	docker build --platform linux/amd64 -t $(HUB_KRATOS_IMAGE) deploy/kratos
	docker push $(HUB_KRATOS_IMAGE)

# One-liners around bin/kamal so the common flows are discoverable.
hub-setup:
	bin/kamal setup -d dev

hub-deploy: hub-kratos-push
	bin/kamal deploy -d dev

hub-logs:
	bin/kamal app logs -d dev -f

hub-down:
	bin/kamal remove -d dev

# release packages each binary the way that platform expects to receive one —
# .zip for Windows, .tar.gz elsewhere — and emits checksums alongside.
release: dist
	@git archive --format=tar.gz --prefix=maylyn-$(VERSION)/ -o dist/maylyn_source.tar.gz HEAD
	@cd dist && for f in maylyn_*; do \
		case "$$f" in \
			*.exe)   zip -q "$${f%.exe}.zip" "$$f" ;; \
			*.zip|*.tar.gz|*.sha256) continue ;; \
			*)       tar -czf "$$f.tar.gz" "$$f" ;; \
		esac; \
	done
	@cd dist && shasum -a 256 *.tar.gz *.zip > checksums.sha256 2>/dev/null || sha256sum *.tar.gz *.zip > checksums.sha256
	@echo "\nRelease artifacts in ./dist:"
	@ls -1 dist/*.tar.gz dist/*.zip dist/checksums.sha256 2>/dev/null

clean-dist:
	@rm -rf dist

clean: clean-dist
	rm -f ./maylyn
