// Package runner executes PowerShell deployment scripts and streams log output.
package runner

import (
	"bufio"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"strings"
	"time"
)

// ErrNeedsReboot is returned by runScript when a PowerShell script exits with
// code 9, signalling that it completed successfully but requires a system
// reboot before deployment can continue.
var ErrNeedsReboot = errors.New("reboot required")

// Step describes a single deployment step.
type Step struct {
	ID         string // e.g. "adminAccount"
	Num        string // display number e.g. "00"
	Name       string
	ScriptName string // e.g. "00-admin-account.ps1"
	Enabled    bool
}

// AllSteps returns the ordered list of deployment steps.
func AllSteps() []Step {
	return []Step{
		{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
		{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},
		{ID: "software",        Num: "02", Name: "Software (winget)",         ScriptName: "02-software.ps1"},
		{ID: "systemRegistry",  Num: "03", Name: "System Registry (HKLM)",   ScriptName: "03-system-registry.ps1"},
		{ID: "defaultProfile",  Num: "04", Name: "Default Profile",          ScriptName: "04-default-profile.ps1"},
		{ID: "personalization", Num: "05", Name: "Personalizace",             ScriptName: "05-personalization.ps1"},
		{ID: "scheduledTasks",  Num: "06", Name: "Scheduled Tasks",           ScriptName: "06-scheduled-tasks.ps1"},
		{ID: "backinfo",        Num: "07", Name: "BackInfo",                  ScriptName: "07-backinfo.ps1"},
		{ID: "activation",      Num: "08", Name: "Windows aktivace",          ScriptName: "08-activation.ps1"},
		{ID: "pcIdentity",      Num: "09", Name: "PC identita",               ScriptName: "09-pc-identity.ps1"},
		{ID: "network",         Num: "10", Name: "Network discovery",         ScriptName: "10-network.ps1"},
		{ID: "dellUpdate",      Num: "11", Name: "Dell Command | Update",      ScriptName: "11-dell-update.ps1"},
		{ID: "windowsUpdate",   Num: "12", Name: "Windows Update",            ScriptName: "12-windows-update.ps1"},
	}
}

// Feature is a single toggleable sub-item within a deployment step.
type Feature struct {
	ID    string
	Label string
}

// StepFeatures returns per-step feature lists. Steps absent from this map
// have no sub-features and are controlled at the step level only.
func StepFeatures() map[string][]Feature {
	return map[string][]Feature{
		"software": {
			{ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"},
			{ID: "pdfDefault",     Label: "Adobe Reader jako vychozi PDF"},
			{ID: "ateraAgent",     Label: "Atera RMM agent"},
		},
		"systemRegistry": {
			{ID: "systemTweaks",      Label: "Windows tweaky (Widgets, GameDVR, Recall...)"},
			{ID: "edgePolicies",      Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"},
			{ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"},
			{ID: "powercfg",          Label: "Nastaveni napajeni (timeout AC/DC)"},
			{ID: "proxyDisable",      Label: "Zakaz WPAD proxy auto-detect"},
		},
		"defaultProfile": {
			{ID: "taskbarTweaks",   Label: "Taskbar – zarovnani, tlacitka, layout XML"},
			{ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"},
			{ID: "explorerTweaks",  Label: "Explorer – pripony, LaunchTo, ShowRecent"},
		},
		"dellUpdate": {
			{ID: "drivers", Label: "Dell drivery + firmware"},
			{ID: "bios",    Label: "Dell BIOS update"},
		},
	}
}

// SelectableItem is a single toggleable row in the TUI checklist.
// It represents either a whole step (FeatureID == "") or a specific feature.
type SelectableItem struct {
	Key       string // "stepID" or "stepID.featureID"
	StepID    string
	FeatureID string // empty for step-level items
	Label     string
	Num       string
}

// AllSelectableItems returns the flat ordered list of all TUI toggle rows.
// Steps with features are expanded to individual feature rows.
// Steps without features appear as a single step-level row.
func AllSelectableItems() []SelectableItem {
	steps    := AllSteps()
	features := StepFeatures()
	var items []SelectableItem
	for _, s := range steps {
		feats, hasFeatures := features[s.ID]
		if !hasFeatures {
			items = append(items, SelectableItem{
				Key:    s.ID,
				StepID: s.ID,
				Label:  s.Num + " – " + s.Name,
				Num:    s.Num,
			})
		} else {
			for _, f := range feats {
				items = append(items, SelectableItem{
					Key:       s.ID + "." + f.ID,
					StepID:    s.ID,
					FeatureID: f.ID,
					Label:     s.Num + " – " + f.Label,
					Num:       s.Num,
				})
			}
		}
	}
	return items
}

// RunConfig holds runtime parameters passed to each script.
type RunConfig struct {
	ScriptsDir  string
	ConfigPath  string
	LogFile     string
	ProfileType string
}

// Result is the outcome of a single step.
type Result struct {
	Step        Step
	Status      string // "OK", "ERROR", "SKIPPED", "CANCELLED"
	Elapsed     time.Duration
	NeedsReboot bool // true when the script exited with code 9
}

// LogLine is a single output line from a running script.
type LogLine struct {
	StepID string
	Text   string
	Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
}

// Runner executes deployment steps sequentially.
type Runner struct {
	cfg         RunConfig
	onLog       func(LogLine)
	onStepStart func(Step)
	onResult    func(Result)
	cancel      context.CancelFunc
}

// New creates a Runner. onLog is called for each output line, onResult after each step.
// onStepStart (optional) is called immediately before a step's script is launched.
func New(cfg RunConfig, onLog func(LogLine), onStepStart func(Step), onResult func(Result)) *Runner {
	return &Runner{cfg: cfg, onLog: onLog, onStepStart: onStepStart, onResult: onResult}
}

// Run executes enabled steps sequentially. Blocks until done or context cancelled.
func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
	ctx, cancel := context.WithCancel(ctx)
	r.cancel = cancel
	defer cancel()

	// Write config JSON to temp file so scripts can read it
	cfgArg := r.cfg.ConfigPath

	var results []Result
	for _, step := range steps {
		if !step.Enabled {
			res := Result{Step: step, Status: "SKIPPED"}
			r.onResult(res)
			results = append(results, res)
			continue
		}

		if r.onStepStart != nil {
			r.onStepStart(step)
		}
		start := time.Now()
		err := r.runScript(ctx, step, cfgArg)
		elapsed := time.Since(start)

		status := "OK"
		needsReboot := false
		if err != nil {
			if errors.Is(err, ErrNeedsReboot) {
				needsReboot = true
				// status remains "OK": step completed, reboot is just required to continue
			} else if ctx.Err() != nil {
				status = "CANCELLED"
			} else {
				status = "ERROR"
			}
		}

		res := Result{Step: step, Status: status, Elapsed: elapsed, NeedsReboot: needsReboot}
		r.onResult(res)
		results = append(results, res)

		if ctx.Err() != nil || needsReboot {
			break
		}
	}
	return results
}

// Stop cancels the running deployment.
func (r *Runner) Stop() {
	if r.cancel != nil {
		r.cancel()
	}
}

func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
	scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)

	// Build argument list
	args := []string{
		"-NonInteractive",
		"-ExecutionPolicy", "Bypass",
		"-File", scriptPath,
		"-LogFile", r.cfg.LogFile,
	}

	// Pass config object as JSON string (script reads it inline)
	if cfgArg != "" {
		args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
	}

	// ProfileType for step 04
	if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
		args = append(args, "-ProfileType", r.cfg.ProfileType)
	}

	cmd := exec.CommandContext(ctx, "powershell.exe", args...)
	hideWindow(cmd) // prevent PS console window from appearing over the GUI

	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return err
	}
	cmd.Stderr = cmd.Stdout // merge stderr into stdout

	if err := cmd.Start(); err != nil {
		return err
	}

	scanner := bufio.NewScanner(stdout)
	for scanner.Scan() {
		line := scanner.Text()
		if skipPSNoiseLine(line) {
			continue
		}
		r.onLog(LogLine{
			StepID: step.ID,
			Text:   line,
			Level:  parseLevel(line),
		})
	}

	waitErr := cmd.Wait()
	if waitErr != nil {
		var exitErr *exec.ExitError
		if errors.As(waitErr, &exitErr) && exitErr.ExitCode() == 9 {
			return ErrNeedsReboot
		}
	}
	return waitErr
}

// skipPSNoiseLine returns true for PowerShell stderr noise that clutters the log:
// multi-line error blocks (At line:N, CategoryInfo, FullyQualifiedErrorId, etc.),
// blank lines, and VERBOSE: prefix lines already handled by Write-Log.
func skipPSNoiseLine(line string) bool {
	trimmed := strings.TrimSpace(line)
	if trimmed == "" {
		return true
	}
	for _, prefix := range []string{
		"At line:",
		"+ CategoryInfo",
		"+ FullyQualifiedErrorId",
		"+ PositionMessage",
		"VERBOSE:",
		"DEBUG:",
	} {
		if strings.HasPrefix(trimmed, prefix) {
			return true
		}
	}
	// PS error continuation lines start with spaces + "+" or "~"
	if len(trimmed) > 0 && (trimmed[0] == '+' || strings.HasPrefix(trimmed, "~")) {
		return true
	}
	return false
}

// parseLevel extracts the log level from lines formatted as "[HH:mm:ss] [LEVEL] message".
func parseLevel(line string) string {
	if strings.Contains(line, "] [OK]") {
		return "OK"
	}
	if strings.Contains(line, "] [ERROR]") {
		return "ERROR"
	}
	if strings.Contains(line, "] [WARN]") {
		return "WARN"
	}
	if strings.Contains(line, "] [STEP]") {
		return "STEP"
	}
	return "INFO"
}

// ExtractScripts unpacks embedded scripts to a temp directory.
// Returns the directory path. Caller is responsible for cleanup.
func ExtractScripts(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
	entries, err := fs.ReadDir("scripts")
	if err != nil {
		return fmt.Errorf("read embedded scripts: %w", err)
	}
	scriptsDir := filepath.Join(tmpDir, "scripts")
	if err := os.MkdirAll(scriptsDir, 0755); err != nil {
		return err
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		// embed.FS always uses forward slashes regardless of OS
		data, err := fs.ReadFile(path.Join("scripts", e.Name()))
		if err != nil {
			return err
		}
		if err := os.WriteFile(filepath.Join(scriptsDir, e.Name()), data, 0644); err != nil {
			return err
		}
	}
	return nil
}

// ExtractAssets unpacks embedded assets to tmpDir/assets.
func ExtractAssets(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, tmpDir string) error {
	return extractDir(fs, "assets", tmpDir)
}

func extractDir(fs interface{ ReadDir(string) ([]os.DirEntry, error); ReadFile(string) ([]byte, error) }, src, dstBase string) error {
	entries, err := fs.ReadDir(src)
	if err != nil {
		return err
	}
	dst := filepath.Join(dstBase, filepath.FromSlash(src))
	if err := os.MkdirAll(dst, 0755); err != nil {
		return err
	}
	for _, e := range entries {
		// embed.FS always uses forward slashes regardless of OS
		srcPath := path.Join(src, e.Name())
		dstPath := filepath.Join(dstBase, filepath.FromSlash(srcPath))
		if e.IsDir() {
			if err := extractDir(fs, srcPath, dstBase); err != nil {
				return err
			}
			continue
		}
		data, err := fs.ReadFile(srcPath)
		if err != nil {
			return err
		}
		if err := os.WriteFile(dstPath, data, 0644); err != nil {
			return err
		}
	}
	return nil
}

// StepsByIDs returns steps from AllSteps() whose IDs are in the given list,
// preserving the canonical AllSteps() order.
func StepsByIDs(ids []string) []Step {
	want := make(map[string]bool, len(ids))
	for _, id := range ids {
		want[id] = true
	}
	var result []Step
	for _, s := range AllSteps() {
		if want[s.ID] {
			result = append(result, s)
		}
	}
	return result
}

// StepByID returns the Step with the given ID, and true if found.
func StepByID(id string) (Step, bool) {
	for _, s := range AllSteps() {
		if s.ID == id {
			return s, true
		}
	}
	return Step{}, false
}

// WriteConfig serialises cfg to a temp JSON file and returns its path.
func WriteConfig(cfg interface{}, tmpDir string) (string, error) {
	path := filepath.Join(tmpDir, "config-runtime.json")
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return "", err
	}
	return path, os.WriteFile(path, data, 0644)
}


//go:build !windows

// Package prereboot ensures the reboot-resume infrastructure is in place
// before xetup triggers a system restart mid-deployment.
package prereboot

// StablePath is the platform stub for the stable binary path.
const StablePath = "/tmp/xetup-resume"

// TaskName is the scheduled task that re-launches xetup after each reboot.
const TaskName = "X9-Resume"

// IsAdminx9 always returns false on non-Windows platforms.
func IsAdminx9() bool { return false }

// Prepare is a no-op on non-Windows platforms.
func Prepare() error { return nil }

// Cleanup is a no-op on non-Windows platforms.
func Cleanup() {}


//go:build !windows

package state

import "os"

func statePath() string {
	if h, err := os.UserHomeDir(); err == nil {
		return h + "/xetup-state.json"
	}
	return "/tmp/xetup-state.json"
}


AskUserQuestion CronCreate CronDelete CronList EnterPlanMode EnterWorktree ExitPlanMode ExitWorktree Monitor NotebookEdit RemoteTrigger TaskCreate TaskGet TaskList TaskOutput TaskStop TaskUpdate WebFetch WebSearch


Bash

ls /home/dev/.claude/projects/-opt-xetup/*.jsonl 2>/dev/null | head -5 && ls -lh /home/dev/.claude/projects/-opt-xetup/b64c8c21-dc81-41a5-a66f-f0f6667e34d7.jsonl

Check conversation JSONL files


/home/dev/.claude/projects/-opt-xetup/1b488982-247d-4af2-8dd6-75a38d3f0399.jsonl
/home/dev/.claude/projects/-opt-xetup/2767906b-e21b-4b02-b327-41b8d3676c51.jsonl
/home/dev/.claude/projects/-opt-xetup/5fe1aaca-13a5-4494-83fd-61bbd946f453.jsonl
/home/dev/.claude/projects/-opt-xetup/8e93805b-9ba4-4285-af2e-a21cd8a00974.jsonl
/home/dev/.claude/projects/-opt-xetup/9ff22271-fc02-43f2-83cd-199c4d72b514.jsonl
-rw------- 1 dev dev 9.6M Apr 16 17:20 /home/dev/.claude/projects/-opt-xetup/b64c8c21-dc81-41a5-a66f-f0f6667e34d7.jsonl


Check git state


5e01826 feat: state machine for reboot-resume across Windows Update cycles
9feb7ba feat: WU completion indicators - lock screen + desktop file
3fb6578 fix: create File Explorer.lnk explicitly + autologon for WU rounds
1c7678c docs: add antivirus/curl note under download link
b58441d fix: black BackInfo bg + wrong taskbar accent color
On branch main
Your branch is ahead of 'origin/main' by 1 commit.
  (use "git push" to publish your local commits)

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	.claude/scheduled_tasks.lock
	web/data/deploy.json

nothing added to commit but untracked files present (use "git add" to track)


List all conversation JSONL files with sizes


-rw------- 1 dev dev 5.9K Apr 15 20:56 /home/dev/.claude/projects/-opt-xetup/1b488982-247d-4af2-8dd6-75a38d3f0399.jsonl
-rw------- 1 dev dev 415K Apr 16 08:16 /home/dev/.claude/projects/-opt-xetup/2767906b-e21b-4b02-b327-41b8d3676c51.jsonl
-rw------- 1 dev dev 5.9K Apr 16 07:31 /home/dev/.claude/projects/-opt-xetup/5fe1aaca-13a5-4494-83fd-61bbd946f453.jsonl
-rw------- 1 dev dev  25K Apr 16 08:09 /home/dev/.claude/projects/-opt-xetup/8e93805b-9ba4-4285-af2e-a21cd8a00974.jsonl
-rw------- 1 dev dev 5.7K Apr 16 07:31 /home/dev/.claude/projects/-opt-xetup/9ff22271-fc02-43f2-83cd-199c4d72b514.jsonl
-rw------- 1 dev dev 9.6M Apr 16 17:20 /home/dev/.claude/projects/-opt-xetup/b64c8c21-dc81-41a5-a66f-f0f6667e34d7.jsonl
-rw------- 1 dev dev 6.0K Apr 16 07:31 /home/dev/.claude/projects/-opt-xetup/de707b1a-05ac-4396-b84d-6e78d2d9e89b.jsonl
-rw------- 1 dev dev 5.9K Apr 16 08:09 /home/dev/.claude/projects/-opt-xetup/e618bcf1-ee78-42a9-9fe6-22c6a60b5aa3.jsonl
-rw------- 1 dev dev 244K Apr 16 04:56 /home/dev/.claude/projects/-opt-xetup/ee43cdc4-9008-4af5-9440-49a8851b3491.jsonl


ls /opt/xetup/.claude/ 2>/dev/null || echo "no .claude dir in repo"

Check if .claude dir exists in repo


memory
MEMORY.md
scheduled_tasks.lock


ls -la /opt/xetup/.claude/ && cat /opt/xetup/.claude/scheduled_tasks.lock 2>/dev/null | head -5

Check .claude dir contents and lock file


total 20
drwxr-xr-x  3 dev dev 4096 Apr 16 14:49 .
drwxr-xr-x 15 dev dev 4096 Apr 16 17:10 ..
drwxr-xr-x  2 dev dev 4096 Apr 15 21:05 memory
-rw-r--r--  1 dev dev  652 Apr 15 21:05 MEMORY.md
-rw-r--r--  1 dev dev   93 Apr 16 14:49 scheduled_tasks.lock
{"sessionId":"b64c8c21-dc81-41a5-a66f-f0f6667e34d7","pid":2241521,"acquiredAt":1776343755503}


cat /opt/xetup/.gitignore 2>/dev/null || echo "no .gitignore"

Check .gitignore


# Secrets
.env
*.key

# Logs
*.log

# Windows
Thumbs.db
Desktop.ini

# macOS
.DS_Store

# Temp
*.tmp
*.bak

# Build artifacts
flash.zip

# Large reference files
W11.pdf