package nix

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
	"sync"
	"syscall"
	"time"

	"tangled.org/core/api/tangled"
	"tangled.org/core/spindle/config"
	"tangled.org/core/spindle/engine"
	"tangled.org/core/spindle/models"
	"tangled.org/core/spindle/secrets"
)

// eval json is buffered whole before parsing, so it needs a hard ceiling,
// a hostile flake must not oom the executor through stdout
const maxEvalOutputBytes = 32 << 20

// paths inside the bwrap namespace, populated by symlink in baseBwrapArgs
const (
	containerNix     = "/usr/bin/nix"
	containerBash    = "/usr/bin/bash"
	containerPrlimit = "/usr/bin/prlimit"
)

type Engine struct {
	cfg            *config.Config
	slotter        *engine.SemaphoreSlotter
	nixBinPath     string
	bashBinPath    string
	gitBinPath     string
	prlimitBinPath string
	bwrapBinPath   string
	workspaces     sync.Map // keyed by full models.WorkflowId
}

// per-workflow host state, workspaceDir is bound at /workdir and homeDir at
// /home inside the sandbox
type addlFields struct {
	workspaceDir string
	homeDir      string
}

type Step struct {
	name        string
	command     string
	drvPath     string
	kind        models.StepKind
	isDiscovery bool
}

func (s Step) Name() string          { return s.name }
func (s Step) Command() string       { return s.command }
func (s Step) Kind() models.StepKind { return s.kind }

// targetKind names the shape of a flake output without encoding it in an attr
// path string
type targetKind int

const (
	targetSystemOutput   targetKind = iota // packages/checks/devShells.<system>.<name>
	targetDirect                           // formatter/defaultPackage/devShell.<system>
	targetHomeActivation                   // homeConfigurations.<name>.activationPackage
	targetToplevel                         // nixos|darwinConfigurations.<name>.config.system.build.toplevel
)

// the installable and --apply expression resolving a candidate's drvPath.
// the installable is a parent attrset built from trusted constants and the
// validated currentSystem, only the apply lambda sees the output name, as a
// nixStringLit literal looked up with builtins.getAttr
func (c outputCandidate) drvTarget() (installable, apply string, err error) {
	switch c.kind {
	case targetSystemOutput:
		installable, err = parentInstallable(c.category, c.system)
		apply = fmt.Sprintf(systemOutputDrvApply, nixStringLit(c.name))
	case targetDirect:
		installable, err = parentInstallable(c.category, c.system)
		apply = directDrvApply
	case targetHomeActivation:
		installable = ".#homeConfigurations"
		apply = fmt.Sprintf(homeActivationDrvApply, nixStringLit(c.name))
	case targetToplevel:
		installable = ".#" + c.category
		apply = fmt.Sprintf(toplevelDrvApply, nixStringLit(c.name))
	default:
		return "", "", fmt.Errorf("unknown candidate kind %d for %s", c.kind, c.display())
	}
	if err != nil {
		return "", "", err
	}
	return installable, apply, nil
}

// too many outputs is a resource attack, refuse outright
func checkOutputLimit(count, max int) error {
	if count > max {
		return fmt.Errorf("flake exposes %d candidate outputs, over the limit of %d", count, max)
	}
	return nil
}

// a discovered buildable, kept structured end to end: category/system/name go
// to nix as separate string literals, never joined into an attr path
type outputCandidate struct {
	kind     targetKind
	category string
	system   string
	name     string
}

// display is for humans only, nothing parses this back
func (c outputCandidate) display() string {
	switch c.kind {
	case targetSystemOutput:
		return fmt.Sprintf(".#%s.%s.%s", c.category, c.system, c.name)
	case targetDirect:
		return fmt.Sprintf(".#%s.%s", c.category, c.system)
	case targetHomeActivation:
		return fmt.Sprintf(".#homeConfigurations.%s.activationPackage", c.name)
	case targetToplevel:
		return fmt.Sprintf(".#%s.%s.config.system.build.toplevel", c.category, c.name)
	default:
		return ".#<unknown>"
	}
}

func New(cfg *config.Config) (*Engine, error) {
	// every one of these is required inside the sandbox, fail fast at startup
	// rather than mid-pipeline
	bwrapPath, err := executablePath("bwrap")
	if err != nil {
		return nil, err
	}
	nixPath, err := executablePath("nix")
	if err != nil {
		return nil, err
	}
	bashPath, err := executablePath("bash")
	if err != nil {
		return nil, err
	}
	gitPath, err := executablePath("git")
	if err != nil {
		return nil, err
	}
	prlimitPath, err := executablePath("prlimit")
	if err != nil {
		return nil, err
	}

	return &Engine{
		cfg:            cfg,
		slotter:        engine.NewSemaphoreSlotter(cfg.NixPipelines.MaxConcurrentWorkflows),
		nixBinPath:     nixPath,
		bashBinPath:    bashPath,
		gitBinPath:     gitPath,
		prlimitBinPath: prlimitPath,
		bwrapBinPath:   bwrapPath,
	}, nil
}

// resolves to the real binary so the sandbox symlinks survive any wrapper
// symlinks on the host PATH
func executablePath(name string) (string, error) {
	path, err := exec.LookPath(name)
	if err != nil {
		return "", fmt.Errorf("%s executable not found: %w", name, err)
	}
	if resolved, err := filepath.EvalSymlinks(path); err == nil {
		path = resolved
	}
	return path, nil
}

func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) {
	// the nix engine owns the step list, so the only valid manifest keys are
	// the generic workflow ones (engine/when/clone), anything else, including
	// user steps, is a structural error
	if err := engine.DescribeManifestError(twf.Raw, struct{}{}); err != nil {
		return nil, err
	}

	wf := &models.Workflow{Name: twf.Name, Data: addlFields{}}
	if tpl.TriggerMetadata != nil {
		if cloneStep := models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev); cloneStep.Command() != "" {
			wf.Steps = append(wf.Steps, cloneStep)
		}
	}
	wf.Steps = append(wf.Steps, Step{
		name:        "Evaluate flake outputs",
		command:     "nix flake metadata --json .; nix eval --apply <discovery> --json",
		kind:        models.StepKindSystem,
		isDiscovery: true,
	})
	return wf, nil
}

func (e *Engine) AcquireWorkflowSlot(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) (engine.WorkflowSlot, error) {
	return e.slotter.AcquireWorkflowSlot(ctx, wid, wf)
}

func (e *Engine) SetupWorkflow(_ context.Context, wid models.WorkflowId, wf *models.Workflow, _ models.WorkflowLogger) error {
	workDirBase := e.cfg.NixPipelines.WorkDirBase
	if workDirBase == "" {
		workDirBase = os.TempDir()
	}

	workspaceDir, err := os.MkdirTemp(workDirBase, "spindle-nix-workspace-"+wid.String()+"-*")
	if err != nil {
		return fmt.Errorf("creating host workspace directory: %w", err)
	}
	homeDir, err := os.MkdirTemp(workDirBase, "spindle-nix-home-"+wid.String()+"-*")
	if err != nil {
		os.RemoveAll(workspaceDir)
		return fmt.Errorf("creating host home directory: %w", err)
	}
	homeTmpDir := filepath.Join(homeDir, "tmp")
	if err := os.MkdirAll(homeTmpDir, 0o755); err != nil {
		os.RemoveAll(workspaceDir)
		os.RemoveAll(homeDir)
		return fmt.Errorf("creating host home tmp directory: %w", err)
	}

	// the sandbox runs as the configured unprivileged user, so hand it
	// ownership of its writable dirs when we have the power to
	if os.Geteuid() == 0 {
		uid := e.cfg.NixPipelines.SandboxUid
		gid := e.cfg.NixPipelines.SandboxGid
		for _, dir := range []string{workspaceDir, homeDir, homeTmpDir} {
			if err := os.Chown(dir, uid, gid); err != nil {
				os.RemoveAll(workspaceDir)
				os.RemoveAll(homeDir)
				return fmt.Errorf("chowning %s to sandbox uid/gid: %w", dir, err)
			}
		}
	}

	addl := addlFields{workspaceDir: workspaceDir, homeDir: homeDir}
	wf.Data = addl
	e.workspaces.Store(wid, addl)
	return nil
}

func (e *Engine) WorkflowTimeout() time.Duration {
	if e.cfg.NixPipelines.WorkflowTimeout > 0 {
		return e.cfg.NixPipelines.WorkflowTimeout
	}
	return 5 * time.Minute
}

func (e *Engine) DestroyWorkflow(_ context.Context, wid models.WorkflowId) error {
	val, ok := e.workspaces.LoadAndDelete(wid)
	if !ok {
		// nothing stored: setup never ran or destroy already happened
		return nil
	}
	addl := val.(addlFields)
	return errors.Join(os.RemoveAll(addl.workspaceDir), os.RemoveAll(addl.homeDir))
}

// the entire environment the sandboxed process sees, nothing leaks in from
// the executor
func (e *Engine) buildEnv() []string {
	return []string{
		"HOME=/home",
		"TMPDIR=/home/tmp",
		"NIX_REMOTE=daemon",
		"PATH=/usr/bin:/bin",
	}
}

func (e *Engine) baseBwrapArgs(addl addlFields) []string {
	args := []string{
		"--die-with-parent",
		"--new-session",
		"--unshare-all",
		"--share-net", // builds fetch from the network, the nix daemon gates what matters
		"--ro-bind", "/nix/store", "/nix/store",
	}

	// evaluation and builds both talk to the host nix daemon
	if _, err := os.Stat("/nix/var/nix/daemon-socket"); err == nil {
		args = append(args, "--ro-bind", "/nix/var/nix/daemon-socket", "/nix/var/nix/daemon-socket")
	}

	args = append(args,
		"--proc", "/proc",
		"--dev", "/dev",
		"--tmpfs", "/tmp",
		"--bind", addl.workspaceDir, "/workdir",
		"--bind", addl.homeDir, "/home",
		"--dir", "/etc",
	)

	// /etc stays synthetic: only the resolved files networking/TLS actually
	// need, bound one at a time. no blanket /etc, no /run
	for _, f := range []string{
		"/etc/resolv.conf",
		"/etc/nsswitch.conf",
		"/etc/hosts",
		"/etc/ssl/certs/ca-certificates.crt",
		"/etc/ssl/certs/ca-bundle.crt",
	} {
		resolved, err := filepath.EvalSymlinks(f)
		if err != nil {
			continue
		}
		if _, err := os.Stat(resolved); err != nil {
			continue
		}
		if dir := filepath.Dir(f); dir != "/etc" && dir != "/" {
			args = append(args, "--dir", dir)
		}
		args = append(args, "--ro-bind", resolved, f)
	}

	args = append(args,
		"--dir", "/usr",
		"--dir", "/usr/bin",
		"--dir", "/bin",
		"--symlink", e.nixBinPath, containerNix,
		"--symlink", e.bashBinPath, containerBash,
		"--symlink", e.bashBinPath, "/bin/sh",
		"--symlink", e.gitBinPath, "/usr/bin/git",
		"--symlink", e.prlimitBinPath, containerPrlimit,
		"--chdir", "/workdir",
		"--",
	)

	return args
}

// shared stdout+stderr budget, whichever stream blows the limit truncates the
// output and kills the child
type maxLogWriter struct {
	mu       sync.Mutex
	written  int64
	limit    int64
	cancel   context.CancelFunc
	exceeded bool
}

type limitedStreamWriter struct {
	parent *maxLogWriter
	out    io.Writer
}

func (w *limitedStreamWriter) Write(p []byte) (int, error) {
	return w.parent.write(w.out, p)
}

func (mw *maxLogWriter) write(w io.Writer, p []byte) (int, error) {
	mw.mu.Lock()
	if mw.exceeded {
		mw.mu.Unlock()
		return 0, fmt.Errorf("log output exceeded the %d byte limit", mw.limit)
	}
	if mw.limit > 0 {
		if remaining := mw.limit - mw.written; int64(len(p)) > remaining {
			mw.exceeded = true
			if mw.cancel != nil {
				mw.cancel()
			}
			mw.written = mw.limit
			var writeErr error
			if remaining > 0 && w != nil {
				_, writeErr = w.Write(p[:remaining])
			}
			mw.mu.Unlock()
			if writeErr != nil {
				return int(remaining), fmt.Errorf("log output exceeded the %d byte limit: %w", mw.limit, writeErr)
			}
			return int(remaining), fmt.Errorf("log output exceeded the %d byte limit", mw.limit)
		}
	}
	mw.written += int64(len(p))
	mw.mu.Unlock()
	if w != nil {
		return w.Write(p)
	}
	return len(p), nil
}

func (e *Engine) maxLogBytes() int64 {
	if e.cfg == nil {
		return 0
	}
	return e.cfg.NixPipelines.MaxLogBytes
}

func (e *Engine) maxEvalMemoryBytes() int64 {
	if e.cfg != nil && e.cfg.NixPipelines.MaxEvalMemoryBytes > 0 {
		return e.cfg.NixPipelines.MaxEvalMemoryBytes
	}
	return 8 << 30
}

func (e *Engine) maxOutputs() int {
	if e.cfg != nil && e.cfg.NixPipelines.MaxOutputs > 0 {
		return e.cfg.NixPipelines.MaxOutputs
	}
	return 100
}

// newSandboxCmd assembles bwrap with the sandbox argv, the returned command
// runs as the configured unprivileged uid/gid when we're root
func (e *Engine) newSandboxCmd(ctx context.Context, addl addlFields, executable string, args []string) *exec.Cmd {
	bwrapArgs := append(e.baseBwrapArgs(addl), executable)
	bwrapArgs = append(bwrapArgs, args...)
	cmd := exec.CommandContext(ctx, e.bwrapBinPath, bwrapArgs...)
	cmd.Env = e.buildEnv()
	if os.Geteuid() == 0 && e.cfg != nil {
		cmd.SysProcAttr = &syscall.SysProcAttr{
			Credential: &syscall.Credential{
				Uid: uint32(e.cfg.NixPipelines.SandboxUid),
				Gid: uint32(e.cfg.NixPipelines.SandboxGid),
			},
		}
	}
	return cmd
}

// runSandbox streams a step's output to the workflow log under the configured
// log cap
func (e *Engine) runSandbox(ctx context.Context, addl addlFields, executable string, args []string, stdout, stderr io.Writer) error {
	subCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	logMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel}
	cmd := e.newSandboxCmd(subCtx, addl, executable, args)
	cmd.Stdout = &limitedStreamWriter{parent: logMgr, out: stdout}
	cmd.Stderr = &limitedStreamWriter{parent: logMgr, out: stderr}

	err := cmd.Run()
	if logMgr.exceeded {
		return fmt.Errorf("step log output exceeded the %d byte limit", logMgr.limit)
	}
	return err
}

// captureSandbox buffers stdout up to maxOut before anyone parses it, teeing
// stderr to the workflow log. overflowing either budget kills the child
func (e *Engine) captureSandbox(ctx context.Context, addl addlFields, executable string, args []string, maxOut int64, stderrLog io.Writer) ([]byte, error) {
	subCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	buf := new(bytes.Buffer)
	outMgr := &maxLogWriter{limit: maxOut, cancel: cancel}
	errMgr := &maxLogWriter{limit: e.maxLogBytes(), cancel: cancel}

	cmd := e.newSandboxCmd(subCtx, addl, executable, args)
	cmd.Stdout = &limitedStreamWriter{parent: outMgr, out: buf}
	cmd.Stderr = &limitedStreamWriter{parent: errMgr, out: stderrLog}

	err := cmd.Run()
	if outMgr.exceeded {
		return nil, fmt.Errorf("evaluation output exceeded the %d byte limit", maxOut)
	}
	if errMgr.exceeded {
		return nil, fmt.Errorf("evaluation stderr exceeded the %d byte limit", errMgr.limit)
	}
	if err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// every nix evaluation runs under prlimit --as so a hostile flake cannot OOM
// the executor while evaluating. the sandbox HOME is synthetic, so the
// flakes/nix-command features must be requested on the command line instead
// of relying on a nix.conf, builds keep the host daemon's own config
func (e *Engine) evalArgv(nixArgs []string) (string, []string) {
	argv := append([]string{
		fmt.Sprintf("--as=%d", e.maxEvalMemoryBytes()),
		containerNix,
		"--extra-experimental-features", "nix-command flakes",
	}, nixArgs...)
	return containerPrlimit, argv
}

func (e *Engine) captureEval(ctx context.Context, addl addlFields, nixArgs []string, stderrLog io.Writer) ([]byte, error) {
	executable, argv := e.evalArgv(nixArgs)
	return e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, stderrLog)
}

// nixStringLit quotes s as a nix string literal. discovery names reach nix
// only through this: the constant apply templates take parameters as escaped
// literals and look them up with builtins.getAttr, never joined attr paths
func nixStringLit(s string) string {
	r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, `${`, `\${`)
	return `"` + r.Replace(s) + `"`
}

// constant --apply templates against parent installables, parameterized only
// through nixStringLit. every eval targets a whole category attrset, never a
// full output path
const (
	// names inside a category (home/nixos/darwinConfigurations) or per-system
	// category attrset (packages/checks/devShells.<system>)
	attrNamesApply = `attrs: builtins.attrNames attrs`

	// presence probe for a direct output (formatter/defaultPackage/devShell)
	presentApply = `x: true`

	systemOutputDrvApply = `attrs:
let
  output = builtins.getAttr %s attrs;
in
if builtins.isAttrs output && output ? drvPath
then output.drvPath
else throw "flake output is not a derivation"`

	directDrvApply = `output:
if builtins.isAttrs output && output ? drvPath
then output.drvPath
else throw "flake output is not a derivation"`

	homeActivationDrvApply = `attrs:
let
  output = (builtins.getAttr %s attrs).activationPackage;
in
if builtins.isAttrs output && output ? drvPath
then output.drvPath
else throw "home configuration activationPackage is not a derivation"`

	toplevelDrvApply = `attrs:
let
  output = (builtins.getAttr %s attrs).config.system.build.toplevel;
in
if builtins.isAttrs output && output ? drvPath
then output.drvPath
else throw "system configuration toplevel is not a derivation"`
)

// currentSystem comes from nix itself, but it still lands inside an
// installable argv string, so pin it to the charset real system strings use
// before trusting it
var systemPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)

// parentInstallable builds the installable for a whole category
// (home/nixos/darwinConfigurations) or a per-system category attrset
// (packages/checks/devShells/formatter/defaultPackage/devShell)
func parentInstallable(category, system string) (string, error) {
	if system == "" {
		return ".#" + category, nil
	}
	if !systemPattern.MatchString(system) {
		return "", fmt.Errorf("refusing to build an installable from unsafe system string %q", system)
	}
	return ".#" + category + "." + system, nil
}

// nix answers a probe of a category or system attr the flake doesn't define
// with "does not provide attribute" on stderr, like garnix's
// isDoesNotProvideAttributeError, probes treat that as "absent" rather than
// a workflow failure. older nix phrases it as "attribute ... missing"
func isMissingAttrError(stderr string) bool {
	return strings.Contains(stderr, "does not provide attribute") ||
		(strings.Contains(stderr, "error: attribute") && strings.Contains(stderr, "missing"))
}

type flakeLockNode struct {
	Original map[string]any `json:"original"`
	Locked   map[string]any `json:"locked"`
}

// authorizeFlakeInputs decides which fetched sources a flake may pull from
// before we evaluate it any further. anything not explicitly permitted here
// is rejected
func authorizeFlakeInputs(metadataBytes []byte, workspaceDir string) error {
	var meta struct {
		Locks struct {
			Root  string                   `json:"root"`
			Nodes map[string]flakeLockNode `json:"nodes"`
		} `json:"locks"`
	}
	if err := json.Unmarshal(metadataBytes, &meta); err != nil {
		return fmt.Errorf("parsing flake metadata JSON: %w", err)
	}

	root := meta.Locks.Root
	if root == "" {
		root = "root"
	}

	for name, node := range meta.Locks.Nodes {
		if name == root {
			continue
		}
		if err := authorizeFlakeInput(name, node, workspaceDir); err != nil {
			return err
		}
	}
	return nil
}

func authorizeFlakeInput(name string, node flakeLockNode, workspaceDir string) error {
	// what the user wrote is authoritative, only indirect (registry) inputs
	// get their fetch source from the lock
	target := node.Original
	if origType, _ := node.Original["type"].(string); origType == "indirect" {
		target = node.Locked
	}
	if target == nil {
		// a follows-only node fetches nothing
		return nil
	}

	typ, _ := target["type"].(string)
	switch typ {
	case "github", "gitlab", "sourcehut", "tarball":
		return nil
	case "path":
		return authorizePathInput(name, target, workspaceDir)
	case "file":
		return authorizeURLInput(name, typ, target, "http", "https")
	case "git", "hg":
		return authorizeURLInput(name, typ, target, "http", "https", "ssh")
	default:
		return fmt.Errorf("flake input %q uses unauthorized type %q", name, typ)
	}
}

// path inputs must stay relative and land inside the checked-out workspace;
// anything absolute or escaping is a sandbox breakout attempt
func authorizePathInput(name string, target map[string]any, workspaceDir string) error {
	p, _ := target["path"].(string)
	if p == "" {
		return fmt.Errorf("flake input %q is a path input without a path", name)
	}
	if filepath.IsAbs(p) {
		return fmt.Errorf("flake input %q uses absolute path %q", name, p)
	}

	canonicalWorkspace, err := filepath.EvalSymlinks(workspaceDir)
	if err != nil {
		return fmt.Errorf("resolving workspace dir: %w", err)
	}
	joined := filepath.Join(canonicalWorkspace, filepath.Clean(p))
	if joined != canonicalWorkspace &&
		!strings.HasPrefix(joined, canonicalWorkspace+string(filepath.Separator)) {
		return fmt.Errorf("flake input %q path %q escapes the workspace", name, p)
	}
	// a lexically-inside path can still escape through a symlink, so re-check
	// the canonical target when it exists, nonexistent paths are created by
	// the fetch inside the sandbox and stay where the lexical check put them
	if canonical, err := filepath.EvalSymlinks(joined); err == nil {
		if canonical != canonicalWorkspace &&
			!strings.HasPrefix(canonical, canonicalWorkspace+string(filepath.Separator)) {
			return fmt.Errorf("flake input %q path %q escapes the workspace through a symlink", name, p)
		}
	}
	return nil
}

func authorizeURLInput(name, typ string, target map[string]any, schemes ...string) error {
	raw, _ := target["url"].(string)
	u, err := url.Parse(raw)
	if err != nil {
		return fmt.Errorf("flake input %q has unparseable url %q: %w", name, raw, err)
	}
	for _, scheme := range schemes {
		if u.Scheme == scheme {
			return nil
		}
	}
	return fmt.Errorf("flake input %q (%s) url %q must use one of: %s", name, typ, raw, strings.Join(schemes, ", "))
}

// discover evaluates the checked-out flake and appends one build step per
// buildable output to the workflow
func (e *Engine) discover(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, wfLogger models.WorkflowLogger) error {
	val, ok := e.workspaces.Load(wid)
	if !ok {
		return errors.New("no workspace for workflow; SetupWorkflow must run first")
	}
	addl := val.(addlFields)

	stdout := wfLogger.DataWriter(idx, "stdout")
	stderr := wfLogger.DataWriter(idx, "stderr")

	sysBytes, err := e.captureEval(ctx, addl,
		[]string{"eval", "--impure", "--raw", "--expr", "builtins.currentSystem"}, stderr)
	if err != nil {
		return fmt.Errorf("determining currentSystem: %w", err)
	}
	system := strings.TrimSpace(string(sysBytes))
	if system == "" {
		return errors.New("nix reported an empty builtins.currentSystem")
	}
	if !systemPattern.MatchString(system) {
		return fmt.Errorf("nix reported an unsafe currentSystem %q", system)
	}

	metaBytes, err := e.captureEval(ctx, addl, []string{"flake", "metadata", "--json", "."}, stderr)
	if err != nil {
		return fmt.Errorf("reading flake metadata: %w", err)
	}
	if err := authorizeFlakeInputs(metaBytes, addl.workspaceDir); err != nil {
		return fmt.Errorf("authorizing flake inputs: %w", err)
	}

	var candidates []outputCandidate

	for _, category := range []string{"packages", "checks", "devShells"} {
		names, err := e.evalNames(ctx, addl, category, system)
		if err != nil {
			return fmt.Errorf("listing %s for %s: %w", category, system, err)
		}
		for _, name := range names {
			candidates = append(candidates, outputCandidate{
				kind: targetSystemOutput, category: category, system: system, name: name,
			})
		}
	}

	for _, category := range []string{"formatter", "defaultPackage", "devShell"} {
		present, err := e.evalPresent(ctx, addl, category, system)
		if err != nil {
			return fmt.Errorf("checking %s.%s: %w", category, system, err)
		}
		if present {
			candidates = append(candidates, outputCandidate{
				kind: targetDirect, category: category, system: system,
			})
		}
	}

	homeNames, err := e.evalNames(ctx, addl, "homeConfigurations", "")
	if err != nil {
		return fmt.Errorf("listing homeConfigurations: %w", err)
	}
	for _, name := range homeNames {
		candidates = append(candidates, outputCandidate{
			kind: targetHomeActivation, category: "homeConfigurations", name: name,
		})
	}

	for _, category := range []string{"nixosConfigurations", "darwinConfigurations"} {
		names, err := e.evalNames(ctx, addl, category, "")
		if err != nil {
			return fmt.Errorf("listing %s: %w", category, err)
		}
		for _, name := range names {
			candidates = append(candidates, outputCandidate{
				kind: targetToplevel, category: category, name: name,
			})
		}
	}

	if err := checkOutputLimit(len(candidates), e.maxOutputs()); err != nil {
		return err
	}

	for _, cand := range candidates {
		drvPath, err := e.resolveDrvPath(ctx, addl, cand, stderr)
		if err != nil {
			return err
		}
		step := Step{
			name:    "Build " + cand.display(),
			command: fmt.Sprintf("nix build --no-link --print-build-logs %s^*", drvPath),
			drvPath: drvPath,
			kind:    models.StepKindUser,
		}
		wf.Steps = append(wf.Steps, step)
		_, _ = fmt.Fprintln(stdout, step.Name())
	}

	return nil
}

// captureEvalProbe captures stderr instead of teeing it to the workflow log:
// presence/name probes hit attributes the flake may not define, and nix's
// "does not provide attribute" spew would read as a failure to users
func (e *Engine) captureEvalProbe(ctx context.Context, addl addlFields, nixArgs []string) (stdout, stderr []byte, err error) {
	errBuf := new(bytes.Buffer)
	executable, argv := e.evalArgv(nixArgs)
	out, runErr := e.captureSandbox(ctx, addl, executable, argv, maxEvalOutputBytes, errBuf)
	return out, errBuf.Bytes(), runErr
}

// evalNames lists the outputs inside a category or per-system category
// parent attrset, a flake that doesn't define the category or system yields
// an empty list, not an error
func (e *Engine) evalNames(ctx context.Context, addl addlFields, category, system string) ([]string, error) {
	installable, err := parentInstallable(category, system)
	if err != nil {
		return nil, err
	}
	out, probeErr, err := e.captureEvalProbe(ctx, addl,
		[]string{"eval", installable, "--apply", attrNamesApply, "--json"})
	if err != nil {
		if isMissingAttrError(string(probeErr)) {
			return nil, nil
		}
		return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr)))
	}
	var names []string
	if err := json.Unmarshal(out, &names); err != nil {
		return nil, fmt.Errorf("parsing output names JSON: %w", err)
	}
	sort.Strings(names)
	return names, nil
}

// evalPresent probes a direct output (formatter/defaultPackage/devShell);
// a flake that doesn't define it yields false, not an error
func (e *Engine) evalPresent(ctx context.Context, addl addlFields, category, system string) (bool, error) {
	installable, err := parentInstallable(category, system)
	if err != nil {
		return false, err
	}
	_, probeErr, err := e.captureEvalProbe(ctx, addl,
		[]string{"eval", installable, "--apply", presentApply, "--json"})
	if err != nil {
		if isMissingAttrError(string(probeErr)) {
			return false, nil
		}
		return false, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(probeErr)))
	}
	return true, nil
}

// resolveDrvPath pins a candidate to a concrete store derivation, anything
// that isn't a derivation fails the workflow rather than being skipped
func (e *Engine) resolveDrvPath(ctx context.Context, addl addlFields, cand outputCandidate, stderr io.Writer) (string, error) {
	installable, apply, err := cand.drvTarget()
	if err != nil {
		return "", err
	}

	out, err := e.captureEval(ctx, addl,
		[]string{"eval", installable, "--apply", apply, "--raw"}, stderr)
	if err != nil {
		return "", fmt.Errorf("resolving %s: %w", cand.display(), err)
	}
	drvPath := strings.TrimSpace(string(out))
	if !isValidDrvPath(drvPath) {
		return "", fmt.Errorf("%s resolved to invalid derivation path %q", cand.display(), drvPath)
	}
	return drvPath, nil
}

// the resolved path becomes a build installable, so pin its shape before
// trusting it
func isValidDrvPath(p string) bool {
	return strings.HasPrefix(p, "/nix/store/") &&
		strings.HasSuffix(p, ".drv") &&
		!strings.ContainsAny(p, " \t\n")
}

func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, idx int, _ []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error {
	if idx < 0 || idx >= len(wf.Steps) {
		return fmt.Errorf("step index %d out of range (%d steps)", idx, len(wf.Steps))
	}

	val, ok := e.workspaces.Load(wid)
	if !ok {
		return errors.New("no workspace for workflow; SetupWorkflow must run first")
	}
	addl := val.(addlFields)

	var err error
	switch s := wf.Steps[idx].(type) {
	case models.CloneStep:
		err = e.runSandbox(ctx, addl, containerBash,
			[]string{"-euo", "pipefail", "-c", s.Command()},
			wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr"))
	case Step:
		switch {
		case s.isDiscovery:
			err = e.discover(ctx, wid, wf, idx, wfLogger)
		case s.drvPath != "":
			err = e.runSandbox(ctx, addl, containerNix,
				[]string{"--extra-experimental-features", "nix-command flakes", "build", "--no-link", "--print-build-logs", s.drvPath + "^*"},
				wfLogger.DataWriter(idx, "stdout"), wfLogger.DataWriter(idx, "stderr"))
		default:
			err = fmt.Errorf("nix engine step %q has nothing to run", s.Name())
		}
	default:
		err = fmt.Errorf("unknown step type %T", wf.Steps[idx])
	}

	if err != nil {
		// a cancellation must survive untouched, only a deadline is a timeout
		if ctxErr := ctx.Err(); ctxErr != nil {
			if errors.Is(ctxErr, context.DeadlineExceeded) {
				return fmt.Errorf("%w: %v", engine.ErrTimedOut, ctxErr)
			}
			return ctxErr
		}
		return err
	}
	return nil
}
