// Command deployforge is a manifest-driven, idempotent check-then-install
// step runner: the mechanism underneath "guided deployment recipes" like
// Ansible or Chocolatey-style provisioning, minimal and dependency-free.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"strings"
	"time"
)

// Step is one entry in a deployment manifest: a named unit of work with a
// "check" command (does this already look done?) and an "install" command
// (make it done).
type Step struct {
	Name    string `json:"name"`
	Check   string `json:"check"`
	Install string `json:"install"`
}

// Manifest is a deployment recipe: an ordered list of steps.
type Manifest struct {
	Name  string `json:"name"`
	Steps []Step `json:"steps"`
}

// logEntry is one JSON-lines record appended to the --log file for apply.
type logEntry struct {
	Name            string `json:"name"`
	Verdict         string `json:"verdict"`
	CheckExitCode   int    `json:"check_exit_code"`
	InstallExitCode *int   `json:"install_exit_code"`
	Timestamp       string `json:"timestamp"`
	DryRun          bool   `json:"dry_run"`
}

func main() {
	if len(os.Args) < 2 {
		// 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.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "apply":
		cmdApply(os.Args[2:])
	case "validate":
		cmdValidate(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "deployforge: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `deployforge - manifest-driven, idempotent check-then-install step runner

Usage:
  deployforge apply <manifest.json> [--dry-run] [--log run.log]
  deployforge validate <manifest.json>
  deployforge help

Commands:
  apply       Run a deployment manifest: for each step, run its "check"
              command; if it already succeeds the step is skipped, otherwise
              the "install" command runs to make it so. Stops at the first
              failed step (fail-fast) unless --dry-run is given.
  validate    Parse and sanity-check a manifest without running anything.

Flags for apply:
  --dry-run       Preview what would happen; never runs install commands,
                  and does not stop early on a step that would fail.
  --log <file>    Append a JSON-lines record of every step's outcome to
                  <file> (real runs and dry runs alike).

Run "deployforge apply -h" or "deployforge validate -h" for more detail.
`)
}

// reorderFlags works around a quirk of the standard "flag" package: it stops
// parsing flags at the first positional argument, so "-flag" placed after a
// positional arg would otherwise be treated as a positional argument itself.
// This moves all recognized flags (and their values, for flags that take
// one) before any positional arguments so fs.Parse sees them correctly
// regardless of where the user put them on the command 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 applyUsage() {
	fmt.Fprint(os.Stderr, `Usage: deployforge apply <manifest.json> [--dry-run] [--log run.log]

Runs each step of the manifest in order:
  1. Run the step's "check" command. If it exits 0, the step is already
     satisfied: it is skipped and reported "SKIP (already satisfied)".
  2. Otherwise, run the step's "install" command (unless --dry-run):
       - exit 0   -> reported "INSTALLED"
       - non-zero -> reported "FAILED", and deployforge stops processing
                     further steps (fail-fast: a failed step likely means
                     later steps that depend on it would fail too) and
                     exits non-zero.
  3. With --dry-run, install commands never run. Each unsatisfied step is
     reported "WOULD INSTALL (check failed)" and the preview continues
     through every remaining step regardless of outcome, since nothing is
     actually being changed.

Flags:
  --dry-run       Preview only; never runs install commands.
  --log <file>    Append a JSON-lines record of each step's outcome
                  (name, verdict, check_exit_code, install_exit_code,
                  timestamp, dry_run) to <file>.
`)
}

func validateUsage() {
	fmt.Fprint(os.Stderr, `Usage: deployforge validate <manifest.json>

Parses the manifest and checks that:
  - it is valid JSON
  - every step has a non-empty name, check, and install
  - no two steps share the same name

Nothing is executed. Reports OK, or lists every problem found and exits
with a non-zero status.
`)
}

func cmdApply(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" {
			applyUsage()
			return
		}
	}

	fs := flag.NewFlagSet("apply", flag.ExitOnError)
	dryRun := fs.Bool("dry-run", false, "preview steps without installing anything")
	logPath := fs.String("log", "", "append a JSON-lines log of step outcomes to this file")
	fs.Usage = applyUsage
	args = reorderFlags(args, map[string]bool{"log": true})
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "deployforge apply: missing manifest file path")
		applyUsage()
		os.Exit(1)
	}
	manifestPath := positional[0]

	manifest, err := loadManifest(manifestPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "deployforge apply: %v\n", err)
		os.Exit(1)
	}

	if problems := validateManifest(manifest); len(problems) > 0 {
		fmt.Fprintln(os.Stderr, "deployforge apply: manifest is invalid:")
		for _, p := range problems {
			fmt.Fprintf(os.Stderr, "  - %s\n", p)
		}
		os.Exit(1)
	}

	var logFile *os.File
	if *logPath != "" {
		f, err := os.OpenFile(*logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
		if err != nil {
			fmt.Fprintf(os.Stderr, "deployforge apply: cannot open log file %q: %v\n", *logPath, err)
			os.Exit(1)
		}
		defer f.Close()
		logFile = f
	}

	mode := ""
	if *dryRun {
		mode = " (dry run)"
	}
	fmt.Printf("Applying manifest %q%s: %d step(s)\n\n", manifest.Name, mode, len(manifest.Steps))

	satisfied, installed, failed, wouldInstall := 0, 0, 0, 0
	stoppedEarly := false

	for _, step := range manifest.Steps {
		checkExit, _ := runShellCommand(step.Check)
		ts := time.Now().UTC().Format(time.RFC3339)

		var verdict string
		var installExitPtr *int

		if checkExit == 0 {
			verdict = "SKIP (already satisfied)"
			satisfied++
		} else if *dryRun {
			verdict = "WOULD INSTALL (check failed)"
			wouldInstall++
		} else {
			installExit, _ := runShellCommand(step.Install)
			installExitPtr = &installExit
			if installExit == 0 {
				verdict = "INSTALLED"
				installed++
			} else {
				verdict = "FAILED"
				failed++
			}
		}

		fmt.Printf("[%s] %s\n", step.Name, verdict)

		if logFile != nil {
			writeLogEntry(logFile, step.Name, verdict, checkExit, installExitPtr, ts, *dryRun)
		}

		if !*dryRun && verdict == "FAILED" {
			stoppedEarly = true
			break
		}
	}

	fmt.Println()
	if *dryRun {
		fmt.Printf("Summary: %d already satisfied, %d would install\n", satisfied, wouldInstall)
	} else {
		fmt.Printf("Summary: %d already satisfied, %d installed, %d failed\n", satisfied, installed, failed)
	}

	if stoppedEarly {
		fmt.Fprintln(os.Stderr, "deployforge apply: stopped after a failed step (fail-fast); remaining steps were not attempted")
		os.Exit(1)
	}
}

func cmdValidate(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" {
			validateUsage()
			return
		}
	}

	fs := flag.NewFlagSet("validate", flag.ExitOnError)
	fs.Usage = validateUsage
	args = reorderFlags(args, map[string]bool{})
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}

	positional := fs.Args()
	if len(positional) < 1 {
		fmt.Fprintln(os.Stderr, "deployforge validate: missing manifest file path")
		validateUsage()
		os.Exit(1)
	}

	manifest, err := loadManifest(positional[0])
	if err != nil {
		fmt.Fprintf(os.Stderr, "deployforge validate: %v\n", err)
		os.Exit(1)
	}

	problems := validateManifest(manifest)
	if len(problems) == 0 {
		fmt.Printf("OK: manifest %q is valid (%d step(s))\n", manifest.Name, len(manifest.Steps))
		return
	}

	fmt.Printf("INVALID: manifest %q has %d problem(s):\n", manifest.Name, len(problems))
	for _, p := range problems {
		fmt.Printf("  - %s\n", p)
	}
	os.Exit(1)
}

func loadManifest(path string) (*Manifest, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, fmt.Errorf("manifest file %q does not exist", path)
		}
		return nil, fmt.Errorf("cannot read manifest file %q: %w", path, err)
	}
	var m Manifest
	if err := json.Unmarshal(data, &m); err != nil {
		return nil, fmt.Errorf("manifest file %q is not valid JSON: %w", path, err)
	}
	return &m, nil
}

// validateManifest checks structural sanity of a manifest and returns a
// human-readable problem description for each issue found. An empty slice
// means the manifest is valid.
func validateManifest(m *Manifest) []string {
	var problems []string

	if len(m.Steps) == 0 {
		problems = append(problems, "manifest has no steps")
	}

	seen := make(map[string]bool, len(m.Steps))
	for i, s := range m.Steps {
		label := fmt.Sprintf("step %d", i+1)
		if s.Name != "" {
			label = fmt.Sprintf("step %d (%q)", i+1, s.Name)
		}

		if s.Name == "" {
			problems = append(problems, fmt.Sprintf("%s: missing \"name\"", label))
		} else if seen[s.Name] {
			problems = append(problems, fmt.Sprintf("%s: duplicate step name %q", label, s.Name))
		} else {
			seen[s.Name] = true
		}

		if strings.TrimSpace(s.Check) == "" {
			problems = append(problems, fmt.Sprintf("%s: missing \"check\"", label))
		}
		if strings.TrimSpace(s.Install) == "" {
			problems = append(problems, fmt.Sprintf("%s: missing \"install\"", label))
		}
	}

	return problems
}

// runShellCommand runs cmdStr through the OS-appropriate shell and returns
// its exit code. A negative exit code indicates the command could not be
// started at all (e.g. no shell available); the error is otherwise ignored
// by callers because a non-zero exit from a check/install command is a
// normal, expected outcome, not a program error.
func runShellCommand(cmdStr string) (int, error) {
	var cmd *exec.Cmd
	if runtime.GOOS == "windows" {
		cmd = exec.Command("cmd", "/C", cmdStr)
	} else {
		cmd = exec.Command("sh", "-c", cmdStr)
	}
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr

	err := cmd.Run()
	if err == nil {
		return 0, nil
	}
	if exitErr, ok := err.(*exec.ExitError); ok {
		return exitErr.ExitCode(), nil
	}
	return -1, err
}

func writeLogEntry(f *os.File, name, verdict string, checkExit int, installExit *int, ts string, dryRun bool) {
	entry := logEntry{
		Name:            name,
		Verdict:         verdict,
		CheckExitCode:   checkExit,
		InstallExitCode: installExit,
		Timestamp:       ts,
		DryRun:          dryRun,
	}
	b, err := json.Marshal(entry)
	if err != nil {
		return
	}
	f.Write(b)
	f.Write([]byte("\n"))
}
