// Command quickflow runs shared, parameterised workflows from a team library
// and records every execution in an append-only run ledger.
package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

const version = "1.0.0"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (identical across the tool line).
// ---------------------------------------------------------------------------

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------------------
// Workflow model
// ---------------------------------------------------------------------------

// Param is a declared, validated input to a workflow.
type Param struct {
	Name        string   `json:"name"`
	Type        string   `json:"type"`
	Required    bool     `json:"required"`
	Default     any      `json:"default,omitempty"`
	Values      []string `json:"values,omitempty"`
	Description string   `json:"description,omitempty"`
}

// Step is one command executed by a workflow, with explicit argv.
type Step struct {
	Name            string   `json:"name"`
	Command         string   `json:"command"`
	Args            []string `json:"args,omitempty"`
	ContinueOnError bool     `json:"continue_on_error,omitempty"`
}

// Workflow is one shareable, parameterised template from the library.
type Workflow struct {
	Name        string  `json:"name"`
	Description string  `json:"description,omitempty"`
	Params      []Param `json:"params,omitempty"`
	Steps       []Step  `json:"steps,omitempty"`

	file string // source path, not part of the on-disk format
	size int64
}

// Problem is a structural defect found in a library file.
type Problem struct {
	File    string `json:"file"`
	Message string `json:"message"`
}

var validTypes = map[string]bool{"string": true, "int": true, "bool": true, "enum": true}

// ---------------------------------------------------------------------------
// Library loading
// ---------------------------------------------------------------------------

// loadLibrary reads every *.json file in dir. It returns the workflows that
// parsed cleanly plus every structural problem found, so `validate` can report
// all of them at once instead of stopping at the first.
func loadLibrary(dir string) ([]Workflow, []Problem, error) {
	info, err := os.Stat(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil, fmt.Errorf("library directory %q does not exist", dir)
		}
		return nil, nil, fmt.Errorf("cannot read library %q: %v", dir, err)
	}
	if !info.IsDir() {
		return nil, nil, fmt.Errorf("library path %q is not a directory", dir)
	}
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, nil, fmt.Errorf("cannot read library %q: %v", dir, err)
	}

	var files []string
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
			continue
		}
		files = append(files, filepath.Join(dir, e.Name()))
	}
	sort.Strings(files)

	var workflows []Workflow
	var problems []Problem
	seen := map[string]string{} // workflow name -> first file that declared it

	for _, path := range files {
		data, err := os.ReadFile(path)
		if err != nil {
			problems = append(problems, Problem{path, fmt.Sprintf("cannot read file: %v", err)})
			continue
		}
		var wf Workflow
		dec := json.NewDecoder(bytes.NewReader(data))
		dec.UseNumber()
		if err := dec.Decode(&wf); err != nil {
			problems = append(problems, Problem{path, fmt.Sprintf("malformed JSON: %v", err)})
			continue
		}
		wf.file = path
		wf.size = int64(len(data))

		probs := checkWorkflow(wf)
		if wf.Name != "" {
			if first, dup := seen[wf.Name]; dup {
				probs = append(probs, Problem{path,
					fmt.Sprintf("duplicate workflow name %q (already declared in %s)", wf.Name, filepath.Base(first))})
			} else {
				seen[wf.Name] = path
			}
		}
		problems = append(problems, probs...)
		if len(probs) == 0 {
			workflows = append(workflows, wf)
		}
	}
	sort.Slice(workflows, func(i, j int) bool { return workflows[i].Name < workflows[j].Name })
	return workflows, problems, nil
}

// checkWorkflow reports every structural defect in a parsed workflow.
func checkWorkflow(wf Workflow) []Problem {
	var probs []Problem
	add := func(format string, a ...any) {
		probs = append(probs, Problem{wf.file, fmt.Sprintf(format, a...)})
	}
	if strings.TrimSpace(wf.Name) == "" {
		add("workflow has no \"name\"")
	}
	declared := map[string]bool{}
	for i, p := range wf.Params {
		label := p.Name
		if label == "" {
			label = fmt.Sprintf("#%d", i+1)
			add("param %s has no \"name\"", label)
		}
		if declared[p.Name] && p.Name != "" {
			add("param %q is declared twice", p.Name)
		}
		declared[p.Name] = true
		if p.Type == "" {
			add("param %q has no \"type\" (want string, int, bool or enum)", label)
		} else if !validTypes[p.Type] {
			add("param %q has unknown type %q (want string, int, bool or enum)", label, p.Type)
		}
		if p.Type == "enum" && len(p.Values) == 0 {
			add("param %q is an enum but declares no \"values\"", label)
		}
		if p.Default != nil {
			def, ok := scalarString(p.Default)
			if !ok {
				add("param %q has a non-scalar default", label)
			} else if err := checkValue(p, def); err != nil {
				add("param %q default is invalid: %v", label, err)
			}
			if p.Required {
				add("param %q is required but also declares a default", label)
			}
		}
	}
	if len(wf.Steps) == 0 {
		add("workflow has no steps")
	}
	for i, s := range wf.Steps {
		label := s.Name
		if label == "" {
			label = fmt.Sprintf("#%d", i+1)
		}
		if strings.TrimSpace(s.Command) == "" {
			add("step %s has no \"command\"", label)
		}
		for _, ref := range placeholders(append([]string{s.Command}, s.Args...)) {
			if !declared[ref] {
				add("step %s references undeclared parameter {{%s}}", label, ref)
			}
		}
	}
	return probs
}

// placeholders returns the distinct {{name}} references in the given strings.
func placeholders(in []string) []string {
	var out []string
	seen := map[string]bool{}
	for _, s := range in {
		rest := s
		for {
			i := strings.Index(rest, "{{")
			if i < 0 {
				break
			}
			j := strings.Index(rest[i:], "}}")
			if j < 0 {
				break
			}
			name := strings.TrimSpace(rest[i+2 : i+j])
			if name != "" && !seen[name] {
				seen[name] = true
				out = append(out, name)
			}
			rest = rest[i+j+2:]
		}
	}
	return out
}

// scalarString renders a JSON scalar as the string used for substitution.
func scalarString(v any) (string, bool) {
	switch t := v.(type) {
	case string:
		return t, true
	case bool:
		return strconv.FormatBool(t), true
	case json.Number:
		return t.String(), true
	case float64:
		return strconv.FormatFloat(t, 'f', -1, 64), true
	case nil:
		return "", true
	}
	return "", false
}

// ---------------------------------------------------------------------------
// Parameter resolution and validation
// ---------------------------------------------------------------------------

func checkValue(p Param, raw string) error {
	switch p.Type {
	case "int":
		if _, err := strconv.Atoi(strings.TrimSpace(raw)); err != nil {
			return fmt.Errorf("expects type int, got %q", raw)
		}
	case "bool":
		switch strings.ToLower(strings.TrimSpace(raw)) {
		case "true", "false", "1", "0", "yes", "no":
		default:
			return fmt.Errorf("expects type bool (true/false), got %q", raw)
		}
	case "enum":
		for _, v := range p.Values {
			if v == raw {
				return nil
			}
		}
		return fmt.Errorf("must be one of: %s (got %q)", strings.Join(p.Values, ", "), raw)
	}
	return nil
}

// resolveParams merges --set values with declared defaults, validating as it
// goes. It reports every problem it finds rather than only the first.
func resolveParams(wf Workflow, set map[string]string, order []string) (map[string]string, []string) {
	declared := map[string]Param{}
	var names []string
	for _, p := range wf.Params {
		declared[p.Name] = p
		names = append(names, p.Name)
	}

	var errs []string
	for _, k := range order {
		if _, ok := declared[k]; !ok {
			known := "none"
			if len(names) > 0 {
				known = strings.Join(names, ", ")
			}
			errs = append(errs, fmt.Sprintf("unknown parameter %q: workflow %q does not declare it (declared parameters: %s)", k, wf.Name, known))
		}
	}

	resolved := map[string]string{}
	for _, p := range wf.Params {
		raw, supplied := set[p.Name]
		if !supplied {
			if p.Default != nil {
				def, _ := scalarString(p.Default)
				resolved[p.Name] = def
				continue
			}
			if p.Required {
				hint := p.Type
				if p.Type == "enum" {
					hint = fmt.Sprintf("enum: one of %s", strings.Join(p.Values, ", "))
				}
				errs = append(errs, fmt.Sprintf("missing required parameter %q (%s): supply it with --set %s=<value>", p.Name, hint, p.Name))
				continue
			}
			resolved[p.Name] = ""
			continue
		}
		if err := checkValue(p, raw); err != nil {
			errs = append(errs, fmt.Sprintf("parameter %q %v", p.Name, err))
			continue
		}
		resolved[p.Name] = raw
	}
	return resolved, errs
}

// substitute replaces every {{name}} reference with its resolved value. The
// result is used as a literal argv element; it is never handed to a shell.
func substitute(s string, params map[string]string) string {
	out := s
	for k, v := range params {
		out = strings.ReplaceAll(out, "{{"+k+"}}", v)
	}
	return out
}

// ---------------------------------------------------------------------------
// Run records and ledger
// ---------------------------------------------------------------------------

type stepResult struct {
	Name     string   `json:"name"`
	Command  string   `json:"command"`
	Args     []string `json:"args"`
	Status   string   `json:"status"` // ok, failed, skipped, would-run
	ExitCode int      `json:"exit_code"`
	Error    string   `json:"error,omitempty"`
}

type runRecord struct {
	Time     string            `json:"time"`
	Workflow string            `json:"workflow"`
	Library  string            `json:"library"`
	Params   map[string]string `json:"params"`
	Steps    []stepResult      `json:"steps"`
	Result   string            `json:"result"`
	Duration int64             `json:"duration_ms"`
	Tool     string            `json:"tool"`
}

func appendLedger(path string, rec runRecord) error {
	line, err := json.Marshal(rec)
	if err != nil {
		return err
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return err
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	_, err = f.Write(append(line, '\n'))
	return err
}

// ---------------------------------------------------------------------------
// Display helpers
// ---------------------------------------------------------------------------

// shellQuote renders an argv element for human-readable display only. Quoting
// here is cosmetic: nothing produced by this function is ever executed.
func shellQuote(s string) string {
	if s == "" {
		return "''"
	}
	safe := true
	for _, r := range s {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
			continue
		}
		if strings.ContainsRune("_@%+=:,./-", r) {
			continue
		}
		safe = false
		break
	}
	if safe {
		return s
	}
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

func displayArgv(cmd string, args []string) string {
	parts := []string{shellQuote(cmd)}
	for _, a := range args {
		parts = append(parts, shellQuote(a))
	}
	return strings.Join(parts, " ")
}

func paramSummary(p Param) string {
	b := p.Type
	if p.Type == "enum" {
		b += "(" + strings.Join(p.Values, "|") + ")"
	}
	if p.Required {
		b += ", required"
	} else if p.Default != nil {
		def, _ := scalarString(p.Default)
		b += fmt.Sprintf(", default=%s", strconv.Quote(def))
	} else {
		b += ", optional"
	}
	return b
}

func emitJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

func fail(format string, a ...any) {
	fmt.Fprintf(os.Stderr, "quickflow: "+format+"\n", a...)
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

func usage(w *os.File) {
	fmt.Fprintf(w, `quickflow %s - shared, parameterised workflow library with a run ledger

USAGE
  quickflow list      --library <dir> [--json]
  quickflow describe  <name> --library <dir> [--json]
  quickflow run       <name> --library <dir> [--set key=value ...] [--apply]
                             [--ledger <run.jsonl>] [--json]
  quickflow validate  --library <dir>
  quickflow help | -h | --help

COMMANDS
  list       List every workflow in the shared library with its parameters.
  describe   Show one workflow in full: parameters and steps.
  run        Validate parameters, substitute them into each step, then run.
             DRY RUN BY DEFAULT - nothing executes without --apply.
  validate   Report every structural problem in the library at once.

FLAGS
  --library <dir>    Directory of workflow .json files (required).
  --set key=value    Supply a declared parameter. Repeatable.
  --apply            Actually execute the steps. Without it, run is a dry run.
  --ledger <path>    Append the run record to this JSONL file (default
                     <library>/quickflow-ledger.jsonl). Applied runs only.
  --json             Machine-readable output.

SAFETY
  Steps run via an explicit argv (command plus argument list). Parameter values
  are never passed through a shell, so metacharacters such as ; $() and
  backticks are delivered to the command as literal text.

EXIT STATUS
  0 success   1 usage error, validation failure, or a failed step
`, version)
}

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage(os.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		os.Exit(0)
	case "--version", "-version", "version":
		fmt.Println("quickflow " + version)
		os.Exit(0)
	}

	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usage(os.Stdout)
			os.Exit(0)
		}
	}

	switch cmd {
	case "list":
		cmdList(rest)
	case "describe":
		cmdDescribe(rest)
	case "run":
		cmdRun(rest)
	case "validate":
		cmdValidate(rest)
	default:
		fmt.Fprintf(os.Stderr, "quickflow: unknown command %q\n\n", cmd)
		usage(os.Stderr)
		os.Exit(1)
	}
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = func() { usage(os.Stderr) }
	return fs
}

// warnProblems tells the user a library has defects without blocking commands
// that only need the healthy workflows.
func warnProblems(problems []Problem) {
	if len(problems) == 0 {
		return
	}
	fmt.Fprintf(os.Stderr, "quickflow: warning: %d problem(s) in the library; run 'quickflow validate' for details\n", len(problems))
}

func mustLoad(dir string) ([]Workflow, []Problem) {
	if strings.TrimSpace(dir) == "" {
		fmt.Fprintln(os.Stderr, "quickflow: --library <dir> is required")
		usage(os.Stderr)
		os.Exit(1)
	}
	wfs, problems, err := loadLibrary(dir)
	if err != nil {
		fail("%v", err)
	}
	return wfs, problems
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

func cmdList(args []string) {
	fs := newFlagSet("list")
	lib := fs.String("library", "", "workflow library directory")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"library": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		fmt.Fprintf(os.Stderr, "quickflow: list takes no positional arguments (got %q)\n", fs.Arg(0))
		usage(os.Stderr)
		os.Exit(1)
	}

	wfs, problems := mustLoad(*lib)
	if len(wfs) == 0 {
		warnProblems(problems)
		fail("no usable workflows found in library %q", *lib)
	}

	if *asJSON {
		type outParam struct {
			Name        string   `json:"name"`
			Type        string   `json:"type"`
			Required    bool     `json:"required"`
			Default     any      `json:"default"`
			Values      []string `json:"values,omitempty"`
			Description string   `json:"description,omitempty"`
		}
		type outWF struct {
			Name        string     `json:"name"`
			Description string     `json:"description"`
			File        string     `json:"file"`
			Steps       int        `json:"step_count"`
			Params      []outParam `json:"params"`
		}
		out := struct {
			Library   string    `json:"library"`
			Count     int       `json:"count"`
			Workflows []outWF   `json:"workflows"`
			Problems  []Problem `json:"problems,omitempty"`
		}{Library: *lib, Count: len(wfs), Workflows: []outWF{}, Problems: problems}
		for _, w := range wfs {
			ow := outWF{w.Name, w.Description, w.file, len(w.Steps), []outParam{}}
			for _, p := range w.Params {
				var def any
				if p.Default != nil {
					s, _ := scalarString(p.Default)
					def = s
				}
				ow.Params = append(ow.Params, outParam{p.Name, p.Type, p.Required, def, p.Values, p.Description})
			}
			out.Workflows = append(out.Workflows, ow)
		}
		if err := emitJSON(out); err != nil {
			fail("%v", err)
		}
		warnProblems(problems)
		return
	}

	fmt.Printf("Workflow library: %s (%d workflow(s))\n", *lib, len(wfs))
	for _, w := range wfs {
		fmt.Println()
		fmt.Printf("  %s\n", w.Name)
		if w.Description != "" {
			fmt.Printf("    %s\n", w.Description)
		}
		fmt.Printf("    file: %s (%s), steps: %d\n", filepath.Base(w.file), humanBytes(w.size), len(w.Steps))
		if len(w.Params) == 0 {
			fmt.Printf("    params: (none)\n")
			continue
		}
		fmt.Printf("    params:\n")
		for _, p := range w.Params {
			fmt.Printf("      %-14s %s\n", p.Name, paramSummary(p))
		}
	}
	warnProblems(problems)
}

// ---------------------------------------------------------------------------
// describe
// ---------------------------------------------------------------------------

func findWorkflow(wfs []Workflow, name string) (Workflow, bool) {
	for _, w := range wfs {
		if w.Name == name {
			return w, true
		}
	}
	return Workflow{}, false
}

func knownNames(wfs []Workflow) string {
	var names []string
	for _, w := range wfs {
		names = append(names, w.Name)
	}
	if len(names) == 0 {
		return "(none)"
	}
	return strings.Join(names, ", ")
}

func cmdDescribe(args []string) {
	fs := newFlagSet("describe")
	lib := fs.String("library", "", "workflow library directory")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"library": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() != 1 {
		fmt.Fprintln(os.Stderr, "quickflow: describe needs exactly one workflow name")
		usage(os.Stderr)
		os.Exit(1)
	}
	name := fs.Arg(0)

	wfs, problems := mustLoad(*lib)
	if len(wfs) == 0 {
		warnProblems(problems)
		fail("no usable workflows found in library %q", *lib)
	}
	w, ok := findWorkflow(wfs, name)
	if !ok {
		warnProblems(problems)
		fail("no workflow named %q in library %q (available: %s)", name, *lib, knownNames(wfs))
	}

	if *asJSON {
		type outParam struct {
			Name        string   `json:"name"`
			Type        string   `json:"type"`
			Required    bool     `json:"required"`
			Default     any      `json:"default"`
			Values      []string `json:"values,omitempty"`
			Description string   `json:"description,omitempty"`
		}
		type outStep struct {
			Name            string   `json:"name"`
			Command         string   `json:"command"`
			Args            []string `json:"args"`
			ContinueOnError bool     `json:"continue_on_error"`
		}
		out := struct {
			Name        string     `json:"name"`
			Description string     `json:"description"`
			File        string     `json:"file"`
			SizeBytes   int64      `json:"size_bytes"`
			Params      []outParam `json:"params"`
			Steps       []outStep  `json:"steps"`
		}{Name: w.Name, Description: w.Description, File: w.file, SizeBytes: w.size,
			Params: []outParam{}, Steps: []outStep{}}
		for _, p := range w.Params {
			var def any
			if p.Default != nil {
				s, _ := scalarString(p.Default)
				def = s
			}
			out.Params = append(out.Params, outParam{p.Name, p.Type, p.Required, def, p.Values, p.Description})
		}
		for _, s := range w.Steps {
			a := s.Args
			if a == nil {
				a = []string{}
			}
			out.Steps = append(out.Steps, outStep{s.Name, s.Command, a, s.ContinueOnError})
		}
		if err := emitJSON(out); err != nil {
			fail("%v", err)
		}
		return
	}

	fmt.Printf("Workflow: %s\n", w.Name)
	if w.Description != "" {
		fmt.Printf("Summary:  %s\n", w.Description)
	}
	fmt.Printf("Source:   %s (%s)\n", w.file, humanBytes(w.size))
	fmt.Println()
	fmt.Printf("Parameters (%d)\n", len(w.Params))
	if len(w.Params) == 0 {
		fmt.Println("  (none)")
	}
	for _, p := range w.Params {
		req := "optional"
		if p.Required {
			req = "REQUIRED"
		}
		fmt.Printf("  %s\n", p.Name)
		fmt.Printf("    type:        %s\n", p.Type)
		fmt.Printf("    required:    %s\n", req)
		if p.Default != nil {
			def, _ := scalarString(p.Default)
			fmt.Printf("    default:     %s\n", strconv.Quote(def))
		} else {
			fmt.Printf("    default:     (none)\n")
		}
		if p.Type == "enum" {
			fmt.Printf("    allowed:     %s\n", strings.Join(p.Values, ", "))
		}
		if p.Description != "" {
			fmt.Printf("    description: %s\n", p.Description)
		}
	}
	fmt.Println()
	fmt.Printf("Steps (%d)\n", len(w.Steps))
	for i, s := range w.Steps {
		fmt.Printf("  %d. %s\n", i+1, s.Name)
		fmt.Printf("     argv: %s\n", displayArgv(s.Command, s.Args))
		if s.ContinueOnError {
			fmt.Printf("     continue_on_error: true\n")
		}
	}
	fmt.Println()
	fmt.Printf("Run it with:\n  quickflow run %s --library %s", w.Name, *lib)
	for _, p := range w.Params {
		if p.Required {
			fmt.Printf(" --set %s=<%s>", p.Name, p.Type)
		}
	}
	fmt.Printf(" --apply\n")
}

// ---------------------------------------------------------------------------
// run
// ---------------------------------------------------------------------------

type setFlag struct {
	values map[string]string
	order  []string
	bad    []string
}

func (s *setFlag) String() string { return "" }

func (s *setFlag) Set(v string) error {
	i := strings.Index(v, "=")
	if i <= 0 {
		s.bad = append(s.bad, v)
		return nil
	}
	k := strings.TrimSpace(v[:i])
	if s.values == nil {
		s.values = map[string]string{}
	}
	s.values[k] = v[i+1:]
	s.order = append(s.order, k)
	return nil
}

func cmdRun(args []string) {
	fs := newFlagSet("run")
	lib := fs.String("library", "", "workflow library directory")
	apply := fs.Bool("apply", false, "actually execute the steps")
	ledger := fs.String("ledger", "", "run ledger JSONL path")
	asJSON := fs.Bool("json", false, "JSON output")
	sets := &setFlag{}
	fs.Var(sets, "set", "key=value parameter (repeatable)")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"library": true, "set": true, "ledger": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() != 1 {
		fmt.Fprintln(os.Stderr, "quickflow: run needs exactly one workflow name")
		usage(os.Stderr)
		os.Exit(1)
	}
	name := fs.Arg(0)
	if len(sets.bad) > 0 {
		for _, b := range sets.bad {
			fmt.Fprintf(os.Stderr, "quickflow: malformed --set %q: expected key=value\n", b)
		}
		os.Exit(1)
	}

	wfs, problems := mustLoad(*lib)
	if len(wfs) == 0 {
		warnProblems(problems)
		fail("no usable workflows found in library %q", *lib)
	}
	w, ok := findWorkflow(wfs, name)
	if !ok {
		warnProblems(problems)
		fail("no workflow named %q in library %q (available: %s)", name, *lib, knownNames(wfs))
	}
	warnProblems(problems)

	params, errs := resolveParams(w, sets.values, sets.order)
	if len(errs) > 0 {
		for _, e := range errs {
			fmt.Fprintf(os.Stderr, "quickflow: %s\n", e)
		}
		fmt.Fprintf(os.Stderr, "quickflow: run 'quickflow describe %s --library %s' to see the declared parameters\n", w.Name, *lib)
		os.Exit(1)
	}

	ledgerPath := *ledger
	if ledgerPath == "" {
		ledgerPath = filepath.Join(*lib, "quickflow-ledger.jsonl")
	}

	// Build the fully substituted argv for every step up front.
	type plannedStep struct {
		step Step
		cmd  string
		args []string
	}
	var plan []plannedStep
	for _, s := range w.Steps {
		a := make([]string, 0, len(s.Args))
		for _, raw := range s.Args {
			a = append(a, substitute(raw, params))
		}
		plan = append(plan, plannedStep{s, substitute(s.Command, params), a})
	}

	start := time.Now()

	if !*apply {
		results := make([]stepResult, 0, len(plan))
		for _, p := range plan {
			results = append(results, stepResult{p.step.Name, p.cmd, p.args, "would-run", 0, ""})
		}
		if *asJSON {
			out := struct {
				Workflow string            `json:"workflow"`
				Library  string            `json:"library"`
				DryRun   bool              `json:"dry_run"`
				Applied  bool              `json:"applied"`
				Params   map[string]string `json:"params"`
				Steps    []stepResult      `json:"steps"`
				Result   string            `json:"result"`
				Ledger   string            `json:"ledger"`
				Note     string            `json:"note"`
			}{w.Name, *lib, true, false, params, results, "dry-run", "",
				"nothing was executed and no ledger entry was written; re-run with --apply"}
			if err := emitJSON(out); err != nil {
				fail("%v", err)
			}
			return
		}
		fmt.Printf("DRY RUN: workflow %q from %s\n", w.Name, *lib)
		fmt.Printf("Resolved parameters:\n")
		printParams(params, w)
		fmt.Printf("\nCommands that WOULD run (%d step(s)):\n", len(plan))
		for i, p := range plan {
			fmt.Printf("  %d. %s\n", i+1, p.step.Name)
			fmt.Printf("     %s\n", displayArgv(p.cmd, p.args))
		}
		fmt.Printf("\nNothing was executed and no ledger entry was written.\n")
		fmt.Printf("Re-run with --apply to execute.\n")
		return
	}

	// --apply: execute each step with an explicit argv. No shell is involved,
	// so parameter values reach the command as literal arguments.
	results := make([]stepResult, 0, len(plan))
	overall := "success"
	stopped := false
	for _, p := range plan {
		if stopped {
			results = append(results, stepResult{p.step.Name, p.cmd, p.args, "skipped", -1, "not run: an earlier step failed"})
			continue
		}
		if !*asJSON {
			fmt.Printf("==> %s\n    %s\n", p.step.Name, displayArgv(p.cmd, p.args))
		}
		res := stepResult{Name: p.step.Name, Command: p.cmd, Args: p.args, Status: "ok"}
		ec, out, err := execStep(p.cmd, p.args)
		res.ExitCode = ec
		if err != nil {
			res.Status = "failed"
			res.Error = err.Error()
			overall = "failed"
		}
		if !*asJSON {
			for _, line := range splitOutput(out) {
				fmt.Printf("    | %s\n", line)
			}
			if res.Status == "failed" {
				fmt.Printf("    step failed (exit %d): %v\n", ec, err)
				if p.step.ContinueOnError {
					fmt.Printf("    continue_on_error is set; continuing\n")
				}
			}
		}
		results = append(results, res)
		if res.Status == "failed" && !p.step.ContinueOnError {
			stopped = true
		}
	}

	rec := runRecord{
		Time:     time.Now().UTC().Format(time.RFC3339Nano),
		Workflow: w.Name,
		Library:  *lib,
		Params:   params,
		Steps:    results,
		Result:   overall,
		Duration: time.Since(start).Milliseconds(),
		Tool:     "quickflow " + version,
	}
	ledgerErr := appendLedger(ledgerPath, rec)
	if ledgerErr != nil {
		fmt.Fprintf(os.Stderr, "quickflow: could not append to ledger %s: %v\n", ledgerPath, ledgerErr)
	}

	if *asJSON {
		out := struct {
			runRecord
			DryRun  bool   `json:"dry_run"`
			Applied bool   `json:"applied"`
			Ledger  string `json:"ledger"`
		}{rec, false, true, ledgerPath}
		if err := emitJSON(out); err != nil {
			fail("%v", err)
		}
	} else {
		fmt.Printf("\nResult: %s (%d step(s), ledger: %s)\n", overall, len(results), ledgerPath)
	}
	if overall != "success" {
		os.Exit(1)
	}
}

func printParams(params map[string]string, w Workflow) {
	for _, p := range w.Params {
		fmt.Printf("  %-14s = %s\n", p.Name, strconv.Quote(params[p.Name]))
	}
}

func splitOutput(out string) []string {
	out = strings.TrimRight(out, "\n")
	if out == "" {
		return nil
	}
	return strings.Split(out, "\n")
}

// execStep runs one step. The command and its arguments are passed to the
// operating system as an explicit argv; there is deliberately no shell, so
// characters such as ; | $() and backticks inside parameter values are
// delivered to the program verbatim and are never interpreted.
func execStep(command string, args []string) (int, string, error) {
	c := exec.Command(command, args...)
	var buf bytes.Buffer
	c.Stdout = &buf
	c.Stderr = &buf
	c.Stdin = nil
	err := c.Run()
	if err == nil {
		return 0, buf.String(), nil
	}
	var ee *exec.ExitError
	if errors.As(err, &ee) {
		return ee.ExitCode(), buf.String(), fmt.Errorf("exit status %d", ee.ExitCode())
	}
	return -1, buf.String(), err
}

// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------

func cmdValidate(args []string) {
	fs := newFlagSet("validate")
	lib := fs.String("library", "", "workflow library directory")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(args, map[string]bool{"library": true})); err != nil {
		os.Exit(1)
	}
	if fs.NArg() > 0 {
		fmt.Fprintf(os.Stderr, "quickflow: validate takes no positional arguments (got %q)\n", fs.Arg(0))
		usage(os.Stderr)
		os.Exit(1)
	}
	if strings.TrimSpace(*lib) == "" {
		fmt.Fprintln(os.Stderr, "quickflow: --library <dir> is required")
		usage(os.Stderr)
		os.Exit(1)
	}
	wfs, problems, err := loadLibrary(*lib)
	if err != nil {
		fail("%v", err)
	}

	if *asJSON {
		out := struct {
			Library  string    `json:"library"`
			OK       bool      `json:"ok"`
			Valid    int       `json:"valid_workflows"`
			Problems []Problem `json:"problems"`
		}{*lib, len(problems) == 0 && len(wfs) > 0, len(wfs), problems}
		if out.Problems == nil {
			out.Problems = []Problem{}
		}
		if err := emitJSON(out); err != nil {
			fail("%v", err)
		}
		if !out.OK {
			os.Exit(1)
		}
		return
	}

	fmt.Printf("Validating library: %s\n", *lib)
	fmt.Printf("Valid workflows: %d\n", len(wfs))
	if len(problems) == 0 {
		if len(wfs) == 0 {
			fmt.Fprintf(os.Stderr, "quickflow: library %q contains no workflow files\n", *lib)
			os.Exit(1)
		}
		fmt.Printf("Problems: 0\nOK\n")
		return
	}
	fmt.Printf("Problems: %d\n\n", len(problems))
	byFile := map[string][]string{}
	var order []string
	for _, p := range problems {
		if _, ok := byFile[p.File]; !ok {
			order = append(order, p.File)
		}
		byFile[p.File] = append(byFile[p.File], p.Message)
	}
	for _, f := range order {
		fmt.Printf("%s\n", f)
		for _, m := range byFile[f] {
			fmt.Printf("  - %s\n", m)
		}
	}
	fmt.Fprintf(os.Stderr, "quickflow: %d problem(s) found in %s\n", len(problems), *lib)
	os.Exit(1)
}
