1	// Package runner executes PowerShell deployment scripts and streams log output.
2	package runner
3
4	import (
5		"bufio"
6		"context"
7		"encoding/json"
8		"fmt"
9		"os"
10		"os/exec"
11		"path/filepath"
12		"strings"
13		"time"
14	)
15
16	// Step describes a single deployment step.
17	type Step struct {
18		ID         string // e.g. "adminAccount"
19		Num        string // display number e.g. "00"
20		Name       string
21		ScriptName string // e.g. "00-admin-account.ps1"
22		Enabled    bool
23	}
24
25	// AllSteps returns the ordered list of deployment steps.
26	func AllSteps() []Step {
27		return []Step{
28			{ID: "adminAccount",    Num: "00", Name: "Admin ucet",              ScriptName: "00-admin-account.ps1"},
29			{ID: "bloatware",       Num: "01", Name: "Bloatware removal",        ScriptName: "01-bloatware.ps1"},
30			{ID: "software",        Num: "02", Name: "Software (winget)",         ScriptName: "02-software.ps1"},
31			{ID: "systemRegistry",  Num: "03", Name: "System Registry (HKLM)",   ScriptName: "03-system-registry.ps1"},
32			{ID: "defaultProfile",  Num: "04", Name: "Default Profile",          ScriptName: "04-default-profile.ps1"},
33			{ID: "personalization", Num: "05", Name: "Personalizace",             ScriptName: "05-personalization.ps1"},
34			{ID: "scheduledTasks",  Num: "06", Name: "Scheduled Tasks",           ScriptName: "06-scheduled-tasks.ps1"},
35			{ID: "backinfo",        Num: "07", Name: "BackInfo",                  ScriptName: "07-backinfo.ps1"},
36			{ID: "activation",      Num: "08", Name: "Windows aktivace",          ScriptName: "08-activation.ps1"},
37			{ID: "dellUpdate",      Num: "11", Name: "Dell Command | Update",      ScriptName: "11-dell-update.ps1"},
38			{ID: "network",         Num: "09", Name: "Network discovery",         ScriptName: "10-network.ps1"},
39			{ID: "pcIdentity",      Num: "10", Name: "PC identita",               ScriptName: "09-pc-identity.ps1"},
40		}
41	}
42
43	// Feature is a single toggleable sub-item within a deployment step.
44	type Feature struct {
45		ID    string
46		Label string
47	}
48
49	// StepFeatures returns per-step feature lists. Steps absent from this map
50	// have no sub-features and are controlled at the step level only.
51	func StepFeatures() map[string][]Feature {
52		return map[string][]Feature{
53			"software": {
54				{ID: "wingetInstalls", Label: "Instalace SW ze seznamu (winget)"},
55				{ID: "pdfDefault",     Label: "Adobe Reader jako vychozi PDF"},
56				{ID: "ateraAgent",     Label: "Atera RMM agent"},
57			},
58			"systemRegistry": {
59				{ID: "systemTweaks",      Label: "Windows tweaky (Widgets, GameDVR, Recall...)"},
60				{ID: "edgePolicies",      Label: "Edge policies (tlacitka, vyhledavac, telemetrie)"},
61				{ID: "oneDriveUninstall", Label: "OneDrive uninstall (consumer pre-install)"},
62				{ID: "powercfg",          Label: "Nastaveni napajeni (timeout AC/DC)"},
63				{ID: "proxyDisable",      Label: "Zakaz WPAD proxy auto-detect"},
64			},
65			"defaultProfile": {
66				{ID: "taskbarTweaks",   Label: "Taskbar – zarovnani, tlacitka, layout XML"},
67				{ID: "startMenuTweaks", Label: "Start menu – cisteni pinu, Bing, Copilot"},
68				{ID: "explorerTweaks",  Label: "Explorer – pripony, LaunchTo, ShowRecent"},
69			},
70			"dellUpdate": {
71				{ID: "drivers", Label: "Dell drivery + firmware"},
72				{ID: "bios",    Label: "Dell BIOS update"},
73			},
74		}
75	}
76
77	// SelectableItem is a single toggleable row in the TUI checklist.
78	// It represents either a whole step (FeatureID == "") or a specific feature.
79	type SelectableItem struct {
80		Key       string // "stepID" or "stepID.featureID"
81		StepID    string
82		FeatureID string // empty for step-level items
83		Label     string
84		Num       string
85	}
86
87	// AllSelectableItems returns the flat ordered list of all TUI toggle rows.
88	// Steps with features are expanded to individual feature rows.
89	// Steps without features appear as a single step-level row.
90	func AllSelectableItems() []SelectableItem {
91		steps    := AllSteps()
92		features := StepFeatures()
93		var items []SelectableItem
94		for _, s := range steps {
95			feats, hasFeatures := features[s.ID]
96			if !hasFeatures {
97				items = append(items, SelectableItem{
98					Key:    s.ID,
99					StepID: s.ID,
100					Label:  s.Num + " – " + s.Name,
101					Num:    s.Num,
102				})
103			} else {
104				for _, f := range feats {
105					items = append(items, SelectableItem{
106						Key:       s.ID + "." + f.ID,
107						StepID:    s.ID,
108						FeatureID: f.ID,
109						Label:     s.Num + " – " + f.Label,
110						Num:       s.Num,
111					})
112				}
113			}
114		}
115		return items
116	}
117
118	// RunConfig holds runtime parameters passed to each script.
119	type RunConfig struct {
120		ScriptsDir  string
121		ConfigPath  string
122		LogFile     string
123		ProfileType string
124	}
125
126	// Result is the outcome of a single step.
127	type Result struct {
128		Step    Step
129		Status  string // "OK", "ERROR", "SKIPPED"
130		Elapsed time.Duration
131	}
132
133	// LogLine is a single output line from a running script.
134	type LogLine struct {
135		StepID string
136		Text   string
137		Level  string // INFO, OK, ERROR, WARN, STEP - parsed from [LEVEL] prefix
138	}
139
140	// Runner executes deployment steps sequentially.
141	type Runner struct {
142		cfg      RunConfig
143		onLog    func(LogLine)
144		onResult func(Result)
145		cancel   context.CancelFunc
146	}
147
148	// New creates a Runner. onLog is called for each output line, onResult after each step.
149	func New(cfg RunConfig, onLog func(LogLine), onResult func(Result)) *Runner {
150		return &Runner{cfg: cfg, onLog: onLog, onResult: onResult}
151	}
152
153	// Run executes enabled steps sequentially. Blocks until done or context cancelled.
154	func (r *Runner) Run(ctx context.Context, steps []Step) []Result {
155		ctx, cancel := context.WithCancel(ctx)
156		r.cancel = cancel
157		defer cancel()
158
159		// Write config JSON to temp file so scripts can read it
160		cfgArg := r.cfg.ConfigPath
161
162		var results []Result
163		for _, step := range steps {
164			if !step.Enabled {
165				res := Result{Step: step, Status: "SKIPPED"}
166				r.onResult(res)
167				results = append(results, res)
168				continue
169			}
170
171			start := time.Now()
172			err := r.runScript(ctx, step, cfgArg)
173			elapsed := time.Since(start)
174
175			status := "OK"
176			if err != nil {
177				if ctx.Err() != nil {
178					status = "CANCELLED"
179				} else {
180					status = "ERROR"
181				}
182			}
183
184			res := Result{Step: step, Status: status, Elapsed: elapsed}
185			r.onResult(res)
186			results = append(results, res)
187
188			if ctx.Err() != nil {
189				break
190			}
191		}
192		return results
193	}
194
195	// Stop cancels the running deployment.
196	func (r *Runner) Stop() {
197		if r.cancel != nil {
198			r.cancel()
199		}
200	}
201
202	func (r *Runner) runScript(ctx context.Context, step Step, cfgArg string) error {
203		scriptPath := filepath.Join(r.cfg.ScriptsDir, step.ScriptName)
204
205		// Build argument list
206		args := []string{
207			"-NonInteractive",
208			"-ExecutionPolicy", "Bypass",
209			"-File", scriptPath,
210			"-LogFile", r.cfg.LogFile,
211		}
212
213		// Pass config object as JSON string (script reads it inline)
214		if cfgArg != "" {
215			args = append(args, "-Config", fmt.Sprintf("(Get-Content '%s' | ConvertFrom-Json)", cfgArg))
216		}
217
218		// ProfileType for step 04
219		if step.ID == "defaultProfile" && r.cfg.ProfileType != "" {
220			args = append(args, "-ProfileType", r.cfg.ProfileType)
221		}
222
223		cmd := exec.CommandContext(ctx, "powershell.exe", args...)
224
225		stdout, err := cmd.StdoutPipe()
226		if err != nil {
227			return err
228		}
229		cmd.Stderr = cmd.Stdout // merge stderr into stdout
230
231		if err := cmd.Start(); err != nil {
232			return err
233		}
234
235		scanner := bufio.NewScanner(stdout)
236		for scanner.Scan() {
237			line := scanner.Text()
238			r.onLog(LogLine{
239				StepID: step.ID,
240				Text:   line,
241				Level:  parseLevel(line),
242			})
243		}
244

... [90 lines truncated] ...


Go typy jsou hotové. Teď zároveň — tui.go + všechny čtyři PS scripty:


Edit

	// form-bound values (huh writes here via pointer)
	pcName          string
	pcDesc          string
	productKey      string
	profileType     string
	selectedStepIDs []string

	// form-bound values (huh writes here via pointer)
	pcName           string
	pcDesc           string
	productKey       string
	profileType      string
	selectedItemKeys []string


The file /opt/xetup/internal/tui/tui.go has been updated successfully.

	// form-bound values (huh writes here via pointer)
	pcName           string
	pcDesc           string
	productKey       string
	profileType      string
	selectedItemKeys []string

// Package tui implements the interactive terminal UI for xetup.
// Phase 1 – configuration form (huh embedded in bubbletea).
// Phase 2 – live log view while PowerShell scripts run.
// Phase 3 – final summary.
package tui

import (
	"context"
	"fmt"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

// --------------------------------------------------------------------------
// Phase
// --------------------------------------------------------------------------

type phase int

const (
	phaseForm phase = iota
	phaseRun
	phaseDone
)

// --------------------------------------------------------------------------
// Async message types (runner → bubbletea)
// --------------------------------------------------------------------------

type logMsg    runner.LogLine
type resultMsg runner.Result
type doneMsg   []runner.Result

// --------------------------------------------------------------------------
// Model
// --------------------------------------------------------------------------

// Model is the top-level bubbletea model.
type Model struct {
	phase  phase
	form   *huh.Form
	runCfg runner.RunConfig
	cfg    config.Config

	// form-bound values (huh writes here via pointer)
	pcName          string
	pcDesc          string
	productKey      string
	profileType     string
	selectedStepIDs []string

	// runner state
	r      *runner.Runner
	cancel context.CancelFunc
	msgCh  chan tea.Msg

	logs    []runner.LogLine
	results []runner.Result

	// terminal dimensions
	width  int
	height int
}

// NewModel returns the initial model pre-populated from cfg.
func NewModel(cfg config.Config, runCfg runner.RunConfig) Model {
	m := Model{
		cfg:         cfg,
		runCfg:      runCfg,
		pcName:      cfg.Deployment.PCName,
		pcDesc:      cfg.Deployment.PCDescription,
		productKey:  cfg.Activation.ProductKey,
		profileType: cfg.Deployment.ProfileType,
		width:       80,
		height:      24,
	}
	m.form = buildForm(&m)
	return m
}

// --------------------------------------------------------------------------
// Form construction
// --------------------------------------------------------------------------

func buildForm(m *Model) *huh.Form {
	allSteps := runner.AllSteps()
	opts := make([]huh.Option[string], len(allSteps))
	for i, s := range allSteps {
		opts[i] = huh.NewOption(s.Num+" - "+s.Name, s.ID).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky k provedeni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedStepIDs),
		),
	)
}

// --------------------------------------------------------------------------
// bubbletea interface
// --------------------------------------------------------------------------

// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
	return m.form.Init()
}

// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height

	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c":
			if m.cancel != nil {
				m.cancel()
			}
			return m, tea.Quit
		}
		if m.phase == phaseDone {
			return m, tea.Quit
		}

	// --- async runner messages ---

	case logMsg:
		m.logs = append(m.logs, runner.LogLine(msg))
		return m, readMsg(m.msgCh)

	case resultMsg:
		m.results = append(m.results, runner.Result(msg))
		return m, readMsg(m.msgCh)

	case doneMsg:
		m.results = []runner.Result(msg)
		m.phase = phaseDone
		return m, nil
	}

	// --- form phase ---
	if m.phase == phaseForm {
		form, cmd := m.form.Update(msg)
		if f, ok := form.(*huh.Form); ok {
			m.form = f
		}
		switch m.form.State {
		case huh.StateCompleted:
			return m.startRun()
		case huh.StateAborted:
			return m, tea.Quit
		}
		return m, cmd
	}

	return m, nil
}

// View implements tea.Model.
func (m Model) View() string {
	switch m.phase {
	case phaseForm:
		return m.viewForm()
	case phaseRun:
		return m.viewRun()
	case phaseDone:
		return m.viewDone()
	}
	return ""
}

// --------------------------------------------------------------------------
// Phase transitions
// --------------------------------------------------------------------------

// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// build step list with Enabled flag from the user's checklist
	selected := make(map[string]bool, len(m.selectedStepIDs))
	for _, id := range m.selectedStepIDs {
		selected[id] = true
	}
	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = selected[s.ID]
		steps[i] = s
	}

	// set up channel and start runner
	ch := make(chan tea.Msg, 200)
	m.msgCh = ch
	m.phase = phaseRun

	ctx, cancel := context.WithCancel(context.Background())
	m.cancel = cancel

	rc := m.runCfg
	r := runner.New(
		rc,
		func(l runner.LogLine) { ch <- logMsg(l) },
		func(res runner.Result) { ch <- resultMsg(res) },
	)
	m.r = r

	go func() {
		results := r.Run(ctx, steps)
		ch <- doneMsg(results)
	}()

	return m, readMsg(ch)
}

// readMsg returns a tea.Cmd that blocks until the next message arrives on ch.
func readMsg(ch <-chan tea.Msg) tea.Cmd {
	return func() tea.Msg {
		return <-ch
	}
}

// --------------------------------------------------------------------------
// Styles
// --------------------------------------------------------------------------

var (
	sTitle  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#58a6ff"))
	sOK     = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043"))
	sError  = lipgloss.NewStyle().Foreground(lipgloss.Color("#da3633"))
	sWarn   = lipgloss.NewStyle().Foreground(lipgloss.Color("#d29922"))
	sStep   = lipgloss.NewStyle().Foreground(lipgloss.Color("#58a6ff"))
	sMuted  = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
	sBold   = lipgloss.NewStyle().Bold(true)
)

// --------------------------------------------------------------------------
// Views
// --------------------------------------------------------------------------

func (m Model) viewForm() string {
	header := sTitle.Render("xetup") + "  " + sMuted.Render("Windows deployment – vyplnte konfiguraci")
	return header + "\n\n" + m.form.View()
}

func (m Model) viewRun() string {
	allSteps := runner.AllSteps()
	doneCount := len(m.results)
	totalCount := len(allSteps)

	var sb strings.Builder

	// header bar
	sb.WriteString(sTitle.Render("xetup") + "  ")
	if doneCount < totalCount {
		s := allSteps[doneCount]
		sb.WriteString(sBold.Render(fmt.Sprintf("krok %d/%d", doneCount+1, totalCount)))
		sb.WriteString("  " + s.Num + " - " + s.Name)
	} else {
		sb.WriteString(sBold.Render("dokoncuji..."))
	}
	sb.WriteByte('\n')
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)))
	sb.WriteByte('\n')

	// log lines – show last N to fit terminal
	maxLines := m.height - 5
	if maxLines < 5 {
		maxLines = 5
	}
	start := 0
	if len(m.logs) > maxLines {
		start = len(m.logs) - maxLines
	}
	for _, l := range m.logs[start:] {
		sb.WriteString(styledLogLine(l))
		sb.WriteByte('\n')
	}

	sb.WriteString(sMuted.Render("\nCtrl+C pro zastaveni"))
	return sb.String()
}

func (m Model) viewDone() string {
	var sb strings.Builder

	sb.WriteString(sTitle.Render("xetup  ") + sBold.Render("hotovo") + "\n")
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)) + "\n\n")

	okCount, errCount, skipCount := 0, 0, 0
	for _, res := range m.results {
		var prefix string
		switch res.Status {
		case "OK":
			okCount++
			prefix = sOK.Render("  OK      ")
		case "ERROR":
			errCount++
			prefix = sError.Render("  ERROR   ")
		default:
			skipCount++
			prefix = sMuted.Render("  SKIPPED ")
		}
		sb.WriteString(prefix + res.Step.Num + " - " + res.Step.Name)
		if res.Elapsed > 0 {
			sb.WriteString(sMuted.Render(fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))))
		}
		sb.WriteByte('\n')
	}

	sb.WriteString("\n" + strings.Repeat("─", clamp(m.width, 20, 90)) + "\n")
	summary := fmt.Sprintf("OK: %d  CHYBY: %d  PRESKOCENO: %d", okCount, errCount, skipCount)
	if errCount > 0 {
		sb.WriteString(sError.Render(summary))
	} else {
		sb.WriteString(sOK.Render(summary))
	}
	sb.WriteString(sMuted.Render("\n\nStisknete libovolnou klavesu pro ukonceni..."))
	return sb.String()
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func styledLogLine(l runner.LogLine) string {
	switch l.Level {
	case "OK":
		return sOK.Render(l.Text)
	case "ERROR":
		return sError.Render(l.Text)
	case "WARN":
		return sWarn.Render(l.Text)
	case "STEP":
		return sStep.Render(l.Text)
	default:
		return sMuted.Render(l.Text)
	}
}

func clamp(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}


func buildForm(m *Model) *huh.Form {
	allSteps := runner.AllSteps()
	opts := make([]huh.Option[string], len(allSteps))
	for i, s := range allSteps {
		opts[i] = huh.NewOption(s.Num+" - "+s.Name, s.ID).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky k provedeni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedStepIDs),
		),
	)
}

func buildForm(m *Model) *huh.Form {
	allItems := runner.AllSelectableItems()
	opts := make([]huh.Option[string], len(allItems))
	for i, item := range allItems {
		opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky a nastaveni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedItemKeys),
		),
	)
}


func buildForm(m *Model) *huh.Form {
	allItems := runner.AllSelectableItems()
	opts := make([]huh.Option[string], len(allItems))
	for i, item := range allItems {
		opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky a nastaveni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedItemKeys),
		),
	)
}

// Package tui implements the interactive terminal UI for xetup.
// Phase 1 – configuration form (huh embedded in bubbletea).
// Phase 2 – live log view while PowerShell scripts run.
// Phase 3 – final summary.
package tui

import (
	"context"
	"fmt"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

// --------------------------------------------------------------------------
// Phase
// --------------------------------------------------------------------------

type phase int

const (
	phaseForm phase = iota
	phaseRun
	phaseDone
)

// --------------------------------------------------------------------------
// Async message types (runner → bubbletea)
// --------------------------------------------------------------------------

type logMsg    runner.LogLine
type resultMsg runner.Result
type doneMsg   []runner.Result

// --------------------------------------------------------------------------
// Model
// --------------------------------------------------------------------------

// Model is the top-level bubbletea model.
type Model struct {
	phase  phase
	form   *huh.Form
	runCfg runner.RunConfig
	cfg    config.Config

	// form-bound values (huh writes here via pointer)
	pcName           string
	pcDesc           string
	productKey       string
	profileType      string
	selectedItemKeys []string

	// runner state
	r      *runner.Runner
	cancel context.CancelFunc
	msgCh  chan tea.Msg

	logs    []runner.LogLine
	results []runner.Result

	// terminal dimensions
	width  int
	height int
}

// NewModel returns the initial model pre-populated from cfg.
func NewModel(cfg config.Config, runCfg runner.RunConfig) Model {
	m := Model{
		cfg:         cfg,
		runCfg:      runCfg,
		pcName:      cfg.Deployment.PCName,
		pcDesc:      cfg.Deployment.PCDescription,
		productKey:  cfg.Activation.ProductKey,
		profileType: cfg.Deployment.ProfileType,
		width:       80,
		height:      24,
	}
	m.form = buildForm(&m)
	return m
}

// --------------------------------------------------------------------------
// Form construction
// --------------------------------------------------------------------------

func buildForm(m *Model) *huh.Form {
	allSteps := runner.AllSteps()
	opts := make([]huh.Option[string], len(allSteps))
	for i, s := range allSteps {
		opts[i] = huh.NewOption(s.Num+" - "+s.Name, s.ID).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky k provedeni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedStepIDs),
		),
	)
}

// --------------------------------------------------------------------------
// bubbletea interface
// --------------------------------------------------------------------------

// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
	return m.form.Init()
}

// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height

	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c":
			if m.cancel != nil {
				m.cancel()
			}
			return m, tea.Quit
		}
		if m.phase == phaseDone {
			return m, tea.Quit
		}

	// --- async runner messages ---

	case logMsg:
		m.logs = append(m.logs, runner.LogLine(msg))
		return m, readMsg(m.msgCh)

	case resultMsg:
		m.results = append(m.results, runner.Result(msg))
		return m, readMsg(m.msgCh)

	case doneMsg:
		m.results = []runner.Result(msg)
		m.phase = phaseDone
		return m, nil
	}

	// --- form phase ---
	if m.phase == phaseForm {
		form, cmd := m.form.Update(msg)
		if f, ok := form.(*huh.Form); ok {
			m.form = f
		}
		switch m.form.State {
		case huh.StateCompleted:
			return m.startRun()
		case huh.StateAborted:
			return m, tea.Quit
		}
		return m, cmd
	}

	return m, nil
}

// View implements tea.Model.
func (m Model) View() string {
	switch m.phase {
	case phaseForm:
		return m.viewForm()
	case phaseRun:
		return m.viewRun()
	case phaseDone:
		return m.viewDone()
	}
	return ""
}

// --------------------------------------------------------------------------
// Phase transitions
// --------------------------------------------------------------------------

// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// build step list with Enabled flag from the user's checklist
	selected := make(map[string]bool, len(m.selectedStepIDs))
	for _, id := range m.selectedStepIDs {
		selected[id] = true
	}
	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = selected[s.ID]
		steps[i] = s
	}

	// set up channel and start runner
	ch := make(chan tea.Msg, 200)
	m.msgCh = ch
	m.phase = phaseRun

	ctx, cancel := context.WithCancel(context.Background())
	m.cancel = cancel

	rc := m.runCfg
	r := runner.New(
		rc,
		func(l runner.LogLine) { ch <- logMsg(l) },
		func(res runner.Result) { ch <- resultMsg(res) },
	)
	m.r = r

	go func() {
		results := r.Run(ctx, steps)
		ch <- doneMsg(results)
	}()

	return m, readMsg(ch)
}

// readMsg returns a tea.Cmd that blocks until the next message arrives on ch.
func readMsg(ch <-chan tea.Msg) tea.Cmd {
	return func() tea.Msg {
		return <-ch
	}
}

// --------------------------------------------------------------------------
// Styles
// --------------------------------------------------------------------------

var (
	sTitle  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#58a6ff"))
	sOK     = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043"))
	sError  = lipgloss.NewStyle().Foreground(lipgloss.Color("#da3633"))
	sWarn   = lipgloss.NewStyle().Foreground(lipgloss.Color("#d29922"))
	sStep   = lipgloss.NewStyle().Foreground(lipgloss.Color("#58a6ff"))
	sMuted  = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
	sBold   = lipgloss.NewStyle().Bold(true)
)

// --------------------------------------------------------------------------
// Views
// --------------------------------------------------------------------------

func (m Model) viewForm() string {
	header := sTitle.Render("xetup") + "  " + sMuted.Render("Windows deployment – vyplnte konfiguraci")
	return header + "\n\n" + m.form.View()
}

func (m Model) viewRun() string {
	allSteps := runner.AllSteps()
	doneCount := len(m.results)
	totalCount := len(allSteps)

	var sb strings.Builder

	// header bar
	sb.WriteString(sTitle.Render("xetup") + "  ")
	if doneCount < totalCount {
		s := allSteps[doneCount]
		sb.WriteString(sBold.Render(fmt.Sprintf("krok %d/%d", doneCount+1, totalCount)))
		sb.WriteString("  " + s.Num + " - " + s.Name)
	} else {
		sb.WriteString(sBold.Render("dokoncuji..."))
	}
	sb.WriteByte('\n')
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)))
	sb.WriteByte('\n')

	// log lines – show last N to fit terminal
	maxLines := m.height - 5
	if maxLines < 5 {
		maxLines = 5
	}
	start := 0
	if len(m.logs) > maxLines {
		start = len(m.logs) - maxLines
	}
	for _, l := range m.logs[start:] {
		sb.WriteString(styledLogLine(l))
		sb.WriteByte('\n')
	}

	sb.WriteString(sMuted.Render("\nCtrl+C pro zastaveni"))
	return sb.String()
}

func (m Model) viewDone() string {
	var sb strings.Builder

	sb.WriteString(sTitle.Render("xetup  ") + sBold.Render("hotovo") + "\n")
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)) + "\n\n")

	okCount, errCount, skipCount := 0, 0, 0
	for _, res := range m.results {
		var prefix string
		switch res.Status {
		case "OK":
			okCount++
			prefix = sOK.Render("  OK      ")
		case "ERROR":
			errCount++
			prefix = sError.Render("  ERROR   ")
		default:
			skipCount++
			prefix = sMuted.Render("  SKIPPED ")
		}
		sb.WriteString(prefix + res.Step.Num + " - " + res.Step.Name)
		if res.Elapsed > 0 {
			sb.WriteString(sMuted.Render(fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))))
		}
		sb.WriteByte('\n')
	}

	sb.WriteString("\n" + strings.Repeat("─", clamp(m.width, 20, 90)) + "\n")
	summary := fmt.Sprintf("OK: %d  CHYBY: %d  PRESKOCENO: %d", okCount, errCount, skipCount)
	if errCount > 0 {
		sb.WriteString(sError.Render(summary))
	} else {
		sb.WriteString(sOK.Render(summary))
	}
	sb.WriteString(sMuted.Render("\n\nStisknete libovolnou klavesu pro ukonceni..."))
	return sb.String()
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func styledLogLine(l runner.LogLine) string {
	switch l.Level {
	case "OK":
		return sOK.Render(l.Text)
	case "ERROR":
		return sError.Render(l.Text)
	case "WARN":
		return sWarn.Render(l.Text)
	case "STEP":
		return sStep.Render(l.Text)
	default:
		return sMuted.Render(l.Text)
	}
}

func clamp(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

-  opts := make([]huh.Option[string], len(allSteps))

-    opts[i] = huh.NewOption(s.Num+" - "+s.Name, s.ID).Selected(true)

+  allItems := runner.AllSelectableItems()

+  opts := make([]huh.Option[string], len(allItems))

+    opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)

-        Title("Kroky k provedeni (mezernikem odzaskrtnout)").

+        Title("Kroky a nastaveni (mezernikem odzaskrtnout)").


// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// build step list with Enabled flag from the user's checklist
	selected := make(map[string]bool, len(m.selectedStepIDs))
	for _, id := range m.selectedStepIDs {
		selected[id] = true
	}
	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = selected[s.ID]
		steps[i] = s
	}

// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// Index selected keys for fast lookup
	selectedSet := make(map[string]bool, len(m.selectedItemKeys))
	for _, key := range m.selectedItemKeys {
		selectedSet[key] = true
	}

	// Build Features map: feature enabled = its composite key was selected
	features := make(config.Features)
	for _, item := range runner.AllSelectableItems() {
		if item.FeatureID == "" {
			continue
		}
		if features[item.StepID] == nil {
			features[item.StepID] = make(map[string]bool)
		}
		features[item.StepID][item.FeatureID] = selectedSet[item.Key]
	}
	m.cfg.Features = features

	// Determine which steps are enabled:
	// - step with features: enabled if at least one feature selected
	// - step without features: enabled if step key selected
	stepEnabled := make(map[string]bool)
	stepFeaturesMap := runner.StepFeatures()
	for _, item := range runner.AllSelectableItems() {
		if item.FeatureID == "" {
			stepEnabled[item.StepID] = selectedSet[item.Key]
		} else if selectedSet[item.Key] {
			// any feature selected enables the step
			_ = stepFeaturesMap
			stepEnabled[item.StepID] = true
		}
	}

	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = stepEnabled[s.ID]
		steps[i] = s
	}


// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// Index selected keys for fast lookup
	selectedSet := make(map[string]bool, len(m.selectedItemKeys))
	for _, key := range m.selectedItemKeys {
		selectedSet[key] = true
	}

	// Build Features map: feature enabled = its composite key was selected
	features := make(config.Features)
	for _, item := range runner.AllSelectableItems() {
		if item.FeatureID == "" {
			continue
		}
		if features[item.StepID] == nil {
			features[item.StepID] = make(map[string]bool)
		}
		features[item.StepID][item.FeatureID] = selectedSet[item.Key]
	}
	m.cfg.Features = features

	// Determine which steps are enabled:
	// - step with features: enabled if at least one feature selected
	// - step without features: enabled if step key selected
	stepEnabled := make(map[string]bool)
	stepFeaturesMap := runner.StepFeatures()
	for _, item := range runner.AllSelectableItems() {
		if item.FeatureID == "" {
			stepEnabled[item.StepID] = selectedSet[item.Key]
		} else if selectedSet[item.Key] {
			// any feature selected enables the step
			_ = stepFeaturesMap
			stepEnabled[item.StepID] = true
		}
	}

	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = stepEnabled[s.ID]
		steps[i] = s
	}

// Package tui implements the interactive terminal UI for xetup.
// Phase 1 – configuration form (huh embedded in bubbletea).
// Phase 2 – live log view while PowerShell scripts run.
// Phase 3 – final summary.
package tui

import (
	"context"
	"fmt"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

// --------------------------------------------------------------------------
// Phase
// --------------------------------------------------------------------------

type phase int

const (
	phaseForm phase = iota
	phaseRun
	phaseDone
)

// --------------------------------------------------------------------------
// Async message types (runner → bubbletea)
// --------------------------------------------------------------------------

type logMsg    runner.LogLine
type resultMsg runner.Result
type doneMsg   []runner.Result

// --------------------------------------------------------------------------
// Model
// --------------------------------------------------------------------------

// Model is the top-level bubbletea model.
type Model struct {
	phase  phase
	form   *huh.Form
	runCfg runner.RunConfig
	cfg    config.Config

	// form-bound values (huh writes here via pointer)
	pcName           string
	pcDesc           string
	productKey       string
	profileType      string
	selectedItemKeys []string

	// runner state
	r      *runner.Runner
	cancel context.CancelFunc
	msgCh  chan tea.Msg

	logs    []runner.LogLine
	results []runner.Result

	// terminal dimensions
	width  int
	height int
}

// NewModel returns the initial model pre-populated from cfg.
func NewModel(cfg config.Config, runCfg runner.RunConfig) Model {
	m := Model{
		cfg:         cfg,
		runCfg:      runCfg,
		pcName:      cfg.Deployment.PCName,
		pcDesc:      cfg.Deployment.PCDescription,
		productKey:  cfg.Activation.ProductKey,
		profileType: cfg.Deployment.ProfileType,
		width:       80,
		height:      24,
	}
	m.form = buildForm(&m)
	return m
}

// --------------------------------------------------------------------------
// Form construction
// --------------------------------------------------------------------------

func buildForm(m *Model) *huh.Form {
	allItems := runner.AllSelectableItems()
	opts := make([]huh.Option[string], len(allItems))
	for i, item := range allItems {
		opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky a nastaveni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedItemKeys),
		),
	)
}

// --------------------------------------------------------------------------
// bubbletea interface
// --------------------------------------------------------------------------

// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
	return m.form.Init()
}

// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height

	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c":
			if m.cancel != nil {
				m.cancel()
			}
			return m, tea.Quit
		}
		if m.phase == phaseDone {
			return m, tea.Quit
		}

	// --- async runner messages ---

	case logMsg:
		m.logs = append(m.logs, runner.LogLine(msg))
		return m, readMsg(m.msgCh)

	case resultMsg:
		m.results = append(m.results, runner.Result(msg))
		return m, readMsg(m.msgCh)

	case doneMsg:
		m.results = []runner.Result(msg)
		m.phase = phaseDone
		return m, nil
	}

	// --- form phase ---
	if m.phase == phaseForm {
		form, cmd := m.form.Update(msg)
		if f, ok := form.(*huh.Form); ok {
			m.form = f
		}
		switch m.form.State {
		case huh.StateCompleted:
			return m.startRun()
		case huh.StateAborted:
			return m, tea.Quit
		}
		return m, cmd
	}

	return m, nil
}

// View implements tea.Model.
func (m Model) View() string {
	switch m.phase {
	case phaseForm:
		return m.viewForm()
	case phaseRun:
		return m.viewRun()
	case phaseDone:
		return m.viewDone()
	}
	return ""
}

// --------------------------------------------------------------------------
// Phase transitions
// --------------------------------------------------------------------------

// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// build step list with Enabled flag from the user's checklist
	selected := make(map[string]bool, len(m.selectedStepIDs))
	for _, id := range m.selectedStepIDs {
		selected[id] = true
	}
	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = selected[s.ID]
		steps[i] = s
	}

	// set up channel and start runner
	ch := make(chan tea.Msg, 200)
	m.msgCh = ch
	m.phase = phaseRun

	ctx, cancel := context.WithCancel(context.Background())
	m.cancel = cancel

	rc := m.runCfg
	r := runner.New(
		rc,
		func(l runner.LogLine) { ch <- logMsg(l) },
		func(res runner.Result) { ch <- resultMsg(res) },
	)
	m.r = r

	go func() {
		results := r.Run(ctx, steps)
		ch <- doneMsg(results)
	}()

	return m, readMsg(ch)
}

// readMsg returns a tea.Cmd that blocks until the next message arrives on ch.
func readMsg(ch <-chan tea.Msg) tea.Cmd {
	return func() tea.Msg {
		return <-ch
	}
}

// --------------------------------------------------------------------------
// Styles
// --------------------------------------------------------------------------

var (
	sTitle  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#58a6ff"))
	sOK     = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043"))
	sError  = lipgloss.NewStyle().Foreground(lipgloss.Color("#da3633"))
	sWarn   = lipgloss.NewStyle().Foreground(lipgloss.Color("#d29922"))
	sStep   = lipgloss.NewStyle().Foreground(lipgloss.Color("#58a6ff"))
	sMuted  = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
	sBold   = lipgloss.NewStyle().Bold(true)
)

// --------------------------------------------------------------------------
// Views
// --------------------------------------------------------------------------

func (m Model) viewForm() string {
	header := sTitle.Render("xetup") + "  " + sMuted.Render("Windows deployment – vyplnte konfiguraci")
	return header + "\n\n" + m.form.View()
}

func (m Model) viewRun() string {
	allSteps := runner.AllSteps()
	doneCount := len(m.results)
	totalCount := len(allSteps)

	var sb strings.Builder

	// header bar
	sb.WriteString(sTitle.Render("xetup") + "  ")
	if doneCount < totalCount {
		s := allSteps[doneCount]
		sb.WriteString(sBold.Render(fmt.Sprintf("krok %d/%d", doneCount+1, totalCount)))
		sb.WriteString("  " + s.Num + " - " + s.Name)
	} else {
		sb.WriteString(sBold.Render("dokoncuji..."))
	}
	sb.WriteByte('\n')
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)))
	sb.WriteByte('\n')

	// log lines – show last N to fit terminal
	maxLines := m.height - 5
	if maxLines < 5 {
		maxLines = 5
	}
	start := 0
	if len(m.logs) > maxLines {
		start = len(m.logs) - maxLines
	}
	for _, l := range m.logs[start:] {
		sb.WriteString(styledLogLine(l))
		sb.WriteByte('\n')
	}

	sb.WriteString(sMuted.Render("\nCtrl+C pro zastaveni"))
	return sb.String()
}

func (m Model) viewDone() string {
	var sb strings.Builder

	sb.WriteString(sTitle.Render("xetup  ") + sBold.Render("hotovo") + "\n")
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)) + "\n\n")

	okCount, errCount, skipCount := 0, 0, 0
	for _, res := range m.results {
		var prefix string
		switch res.Status {
		case "OK":
			okCount++
			prefix = sOK.Render("  OK      ")
		case "ERROR":
			errCount++
			prefix = sError.Render("  ERROR   ")
		default:
			skipCount++
			prefix = sMuted.Render("  SKIPPED ")
		}
		sb.WriteString(prefix + res.Step.Num + " - " + res.Step.Name)
		if res.Elapsed > 0 {
			sb.WriteString(sMuted.Render(fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))))
		}
		sb.WriteByte('\n')
	}

	sb.WriteString("\n" + strings.Repeat("─", clamp(m.width, 20, 90)) + "\n")
	summary := fmt.Sprintf("OK: %d  CHYBY: %d  PRESKOCENO: %d", okCount, errCount, skipCount)
	if errCount > 0 {
		sb.WriteString(sError.Render(summary))
	} else {
		sb.WriteString(sOK.Render(summary))
	}
	sb.WriteString(sMuted.Render("\n\nStisknete libovolnou klavesu pro ukonceni..."))
	return sb.String()
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func styledLogLine(l runner.LogLine) string {
	switch l.Level {
	case "OK":
		return sOK.Render(l.Text)
	case "ERROR":
		return sError.Render(l.Text)
	case "WARN":
		return sWarn.Render(l.Text)
	case "STEP":
		return sStep.Render(l.Text)
	default:
		return sMuted.Render(l.Text)
	}
}

func clamp(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}

-  // build step list with Enabled flag from the user's checklist

-  selected := make(map[string]bool, len(m.selectedStepIDs))

-  for _, id := range m.selectedStepIDs {

+  // Index selected keys for fast lookup

+  selectedSet := make(map[string]bool, len(m.selectedItemKeys))

+  for _, key := range m.selectedItemKeys {

+  // Build Features map: feature enabled = its composite key was selected

+  for _, item := range runner.AllSelectableItems() {

+      features[item.StepID] = make(map[string]bool)

+    features[item.StepID][item.FeatureID] = selectedSet[item.Key]

+  // Determine which steps are enabled:

+  // - step with features: enabled if at least one feature selected

+  // - step without features: enabled if step key selected

+  stepFeaturesMap := runner.StepFeatures()

+      stepEnabled[item.StepID] = selectedSet[item.Key]

+      // any feature selected enables the step


Teď přidat import `config` do tui.go a paralelně upravit všechny čtyři skripty:


	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	_ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible


	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	_ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible

+  _ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible


Bash

Check build after tui.go changes


(Bash completed with no output)


2:// Phase 1 – configuration form (huh embedded in bubbletea).
7:import (
17:	"git.xetup.x9.cz/x9/xetup/internal/config"
18:	"git.xetup.x9.cz/x9/xetup/internal/runner"
19:	_ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible
35:// Async message types (runner → bubbletea)
38:type logMsg    runner.LogLine
39:type resultMsg runner.Result
40:type doneMsg   []runner.Result
50:	runCfg runner.RunConfig
51:	cfg    config.Config
60:	// runner state
61:	r      *runner.Runner
65:	logs    []runner.LogLine
66:	results []runner.Result

[Showing results with pagination = limit: 15]


Edit

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	_ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"


	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
	_ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible

-  _ "git.xetup.x9.cz/x9/xetup/internal/config" // ensure Features type is visible


Teď paralelně — všechny čtyři PS scripty. Přidám helper `Get-Feature` a obalím bloky:


function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
}

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
}

function Get-Feature {
    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
    try {
        if ($null -eq $Cfg) { return $Default }
        $stepFeatures = $Cfg.features.$StepID
        if ($null -eq $stepFeatures) { return $Default }
        $val = $stepFeatures.$FeatureID
        if ($null -eq $val) { return $Default }
        return [bool]$val
    } catch { return $Default }
}


The file /opt/xetup/scripts/02-software.ps1 has been updated successfully.

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
}

function Get-Feature {
    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
    try {
        if ($null -eq $Cfg) { return $Default }
        $stepFeatures = $Cfg.features.$StepID
        if ($null -eq $stepFeatures) { return $Default }
        $val = $stepFeatures.$FeatureID
        if ($null -eq $val) { return $Default }
        return [bool]$val
    } catch { return $Default }
}

<#
.SYNOPSIS
    Installs standard business software via winget, sets Adobe PDF default, and installs Atera RMM agent.

.DESCRIPTION
    Uses winget to install the standard X9.cz MSP software bundle. Checks winget
    availability before running. Each install is logged. After Adobe Acrobat Reader,
    temporarily stops the UCPD driver (User Choice Protection Driver, present since
    Win11 23H2 / Win10 22H2 update) to allow the HKCR file association write, sets
    .pdf -> AcroRd32, then restarts UCPD. Atera RMM agent is installed for MSP
    monitoring, remote access, and ticketing integration.

.ITEMS
    7-zip-7zip-7zip: Installs 7-Zip (winget ID: 7zip.7zip). Used for archive management. Silent install with --accept-package-agreements --accept-source-agreements flags required for unattended deployment.
    adobe-acrobat-reader-64-bit-adobe-acroba: Installs Adobe Acrobat Reader DC 64-bit (Adobe.Acrobat.Reader.64-bit). Required as the default PDF viewer to prevent Edge from handling PDFs in browser mode, which limits functionality.
    openvpn-connect-openvpntechnologies-open: Installs OpenVPN Connect client. Used for client VPN access when the client network requires a VPN. The ovpn profile and credentials are configured separately per client.
    atera-agent-install: Atera RMM agent installed via msiexec /qn. Download: Invoke-WebRequest from https://x9.servicedesk.atera.com/api/utils/agent-install/windows/?cid=31&aeid=50b72e7113e54a63ac76b96c54c7e337. Agent enables MSP monitoring, remote access, and ticketing integration with the Atera dashboard.
    adobe-pdf-default-pdf-acrord32-po-instal: Sets .pdf -> AcroRd32 file association after Acrobat install via HKCR (system-wide, no UserChoice hash issue). UCPD driver is stopped immediately before the write and restarted after to ensure the association persists across Edge updates.
    ucpd-sys-kernel-driver-od-feb-2024-bloku: UCPD.sys (User Choice Protection Driver) is stopped before the PDF association write and restarted after. Pattern: Stop-Service ucpd -> set HKCR\.pdf -> Start-Service ucpd. Implemented in this script.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
}

# -----------------------------------------------------------------------
# Check winget availability
# -----------------------------------------------------------------------
Write-Log "Checking winget availability" -Level INFO

$winget = Get-Command winget -ErrorAction SilentlyContinue
if (-not $winget) {
    # Try to find winget in known locations
    $wingetPaths = @(
        "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
        "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller*\winget.exe"
    )
    foreach ($p in $wingetPaths) {
        $found = Get-Item $p -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($found) { $winget = $found.FullName; break }
    }
}

if (-not $winget) {
    Write-Log "winget not found - software installation skipped" -Level ERROR
    exit 1
}

Write-Log "winget found: $($winget.Source -or $winget)" -Level OK

# Accept agreements upfront
& winget source update --accept-source-agreements 2>&1 | Out-Null

# -----------------------------------------------------------------------
# Install packages from config
# -----------------------------------------------------------------------
if (-not $Config -or -not $Config.software -or -not $Config.software.install) {
    Write-Log "No software list in config - skipping installs" -Level WARN
} else {
    foreach ($pkg in $Config.software.install) {
        Write-Log "Installing $($pkg.name) ($($pkg.wingetId))" -Level INFO
        $result = & winget install --id $pkg.wingetId `
            --silent `
            --accept-package-agreements `
            --accept-source-agreements `
            --disable-interactivity `
            2>&1

        $exitCode = $LASTEXITCODE
        if ($exitCode -eq 0) {
            Write-Log "  Installed OK: $($pkg.name)" -Level OK
        } elseif ($exitCode -eq -1978335189) {
            # 0x8A150011 = already installed
            Write-Log "  Already installed: $($pkg.name)" -Level OK
        } else {
            Write-Log "  Failed: $($pkg.name) (exit $exitCode)" -Level ERROR
            Write-Log "  Output: $($result -join ' ')" -Level ERROR
        }
    }
}

# -----------------------------------------------------------------------
# Set Adobe Reader as default PDF app
# -----------------------------------------------------------------------
$forcePdf = $true
if ($Config -and $Config.pdfDefault) {
    $forcePdf = [bool]$Config.pdfDefault.forceAdobeReader
}

if ($forcePdf) {
    Write-Log "Setting Adobe Reader as default PDF app" -Level INFO

    # Stop UCPD driver before writing file association.
    # UCPD.sys (User Choice Protection Driver) blocks UserChoice registry writes
    # on Win11 23H2+ and some Win10 22H2 builds. Stopping it temporarily allows
    # the HKCR association to be written reliably.
    $ucpdStopped = $false
    $ucpdSvc = Get-Service -Name "ucpd" -ErrorAction SilentlyContinue
    if ($ucpdSvc) {
        try {
            Stop-Service -Name "ucpd" -Force -ErrorAction Stop
            $ucpdStopped = $true
            Write-Log "  UCPD driver stopped" -Level OK
        }
        catch {
            Write-Log "  Could not stop UCPD: $_ (association may not persist on some builds)" -Level WARN
        }
    }

    # Find Adobe PDF viewer executable (Acrobat DC or Reader DC)
    $acroPaths = @(
        "$env:ProgramFiles\Adobe\Acrobat DC\Acrobat\Acrobat.exe"
        "${env:ProgramFiles(x86)}\Adobe\Acrobat DC\Acrobat\Acrobat.exe"
        "${env:ProgramFiles(x86)}\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"
        "$env:ProgramFiles\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"
        "${env:ProgramFiles(x86)}\Adobe\Reader\Reader\AcroRd32.exe"
    )
    $acroExe = $acroPaths | Where-Object { Test-Path $_ } | Select-Object -First 1

    if (-not $acroExe) {
        Write-Log "  Adobe PDF viewer not found - PDF default not set" -Level WARN
    } else {
        Write-Log "  Found: $acroExe" -Level INFO

        # Set file type association via HKCR (system-wide, requires admin)
        $progId = "AcroExch.Document.DC"
        $openCmd = "`"$acroExe`" `"%1`""

        # HKCR\.pdf -> progId
        if (-not (Test-Path "HKCR:\.pdf")) {
            New-Item -Path "HKCR:\.pdf" -Force | Out-Null
        }
        Set-ItemProperty -Path "HKCR:\.pdf" -Name "(Default)" -Value $progId

        # HKCR\AcroExch.Document.DC\shell\open\command
        $cmdPath = "HKCR:\$progId\shell\open\command"
        if (-not (Test-Path $cmdPath)) {
            New-Item -Path $cmdPath -Force | Out-Null
        }
        Set-ItemProperty -Path $cmdPath -Name "(Default)" -Value $openCmd

        Write-Log "  PDF default set to AcroRd32 (HKCR)" -Level OK
    }

    # Restart UCPD after writing association
    if ($ucpdStopped) {
        try {
            Start-Service -Name "ucpd" -ErrorAction SilentlyContinue
            Write-Log "  UCPD driver restarted" -Level OK
        }
        catch {
            Write-Log "  Could not restart UCPD: $_" -Level WARN
        }
    }
}

# -----------------------------------------------------------------------
# Install Atera RMM Agent
# -----------------------------------------------------------------------
Write-Log "Installing Atera RMM Agent" -Level INFO

$ateraUrl = "https://x9.servicedesk.atera.com/api/utils/agent-install/windows/?cid=31&aeid=50b72e7113e54a63ac76b96c54c7e337"
$ateraMsi = "$env:TEMP\AteraAgent.msi"

try {
    Write-Log "  Downloading Atera agent..." -Level INFO
    Invoke-WebRequest -Uri $ateraUrl -OutFile $ateraMsi -UseBasicParsing -ErrorAction Stop
    Write-Log "  Download complete" -Level OK

    $msiResult = & msiexec /i $ateraMsi /qn 2>&1
    Start-Sleep -Seconds 5

    $ateraExe = "$env:ProgramFiles\ATERA Networks\AteraAgent\AteraAgent.exe"
    if (Test-Path $ateraExe) {
        Write-Log "  Atera agent installed" -Level OK
    } else {
        Write-Log "  Atera agent install may have failed - binary not found at expected path" -Level WARN
        Write-Log "  msiexec output: $($msiResult -join ' ')" -Level WARN
    }
}
catch {
    Write-Log "  Atera agent download/install failed: $_" -Level ERROR
}
finally {
    Remove-Item $ateraMsi -Force -ErrorAction SilentlyContinue
}

Write-Log "Step 2 complete" -Level OK

+    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)

+        if ($null -eq $Cfg) { return $Default }

+        $stepFeatures = $Cfg.features.$StepID

+        if ($null -eq $stepFeatures) { return $Default }

+        $val = $stepFeatures.$FeatureID

+        if ($null -eq $val) { return $Default }


zapsat a rovnou udělat, prosím .... jak to uděláš? Sekce ve scriptu nebo rozsekání scriptů na "atomické" jednotky?


Conversation compacted


This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.

Summary:
## 1. Primary Request and Intent

The session covered several explicit requests:

1. **Go TUI launcher** — Complete `internal/tui/tui.go` and `cmd/xetup/main.go` (from prior session summary). Cross-compile to `xetup.exe` for Windows.
2. **Spec page "nové nastavení" section** — Add a section allowing anyone to post new automation requests to Forgejo issue #15.
3. **Dell Command | Update** — Integrate automatic driver/BIOS/firmware updates for all Dell machines into the deployment. User clarified: use Dell Command | Update (not SupportAssist), install via winget, run silently during setup, BIOS staged for restart.
4. **Download link on landing page** — Direct download link for `xetup.exe` on `xetup.x9.cz` homepage.
5. **Dell step in wrong section** — User reported "jo - akorát ten dell command update máš v 'plánované kroky'" — fixed by moving step 11 card to "Stavajici kroky" section.
6. **Granular per-feature toggles in TUI** — User: "bude ta konfigurace v TUI možná per řádek?" then: "jen abych třeba mohl jsem vypnout 'Vytvorit lokalni ucet adminx9'... jde mi o tu granulární možnost vypínání/zapínání nastavení". Then: "zapsat a rovnou udělat, prosím... jak to uděláš? Sekce ve scriptu nebo rozsekání scriptů na 'atomické' jednotky?" — Decision: **sections within scripts** using `if (Get-Feature ...)` blocks, not atomic file splitting.

---

## 2. Key Technical Concepts

- **bubbletea + huh embedded TUI**: Three-phase model (phaseForm → phaseRun → phaseDone); huh form embedded in bubbletea via `form.Update(msg)` + `form.State`
- **go:embed**: Root-level `embed.go` (package `xetup`) with `//go:embed scripts` and `//go:embed assets`; imported by `cmd/xetup/main.go` as `xetupembed`
- **Runner goroutine + channel**: `chan tea.Msg` fed by `onLog`/`onResult` callbacks; `readMsg(ch)` tea.Cmd chains to keep streaming logs
- **Dell Command | Update CLI**: `winget install Dell.CommandUpdate.Universal --silent`; `dcu-cli.exe /applyUpdates -silent -reboot=disable`; exit codes 0/1/5 = success; BIOS updates staged and complete on next restart
- **Forgejo releases API**: `GET /forgejo-api/repos/x9/xetup/releases?limit=1` → `assets[].browser_download_url`; landing page JS downloads dynamically
- **Granular feature toggles architecture**: `config.json` gets `"features": { "stepID": { "featureID": true } }`; `Get-Feature` PS helper reads these; `config.go` gets `Features` type; `runner.go` gets `Feature`/`StepFeatures()`/`SelectableItem`/`AllSelectableItems()`; TUI MultiSelect shows flat list of all features
- **Flat selectable items in TUI**: Steps with sub-features expand to individual rows keyed as `stepID.featureID`; steps without features keyed as `stepID`
- **Section-based script features**: NOT atomic file splitting; `if (Get-Feature $Config "stepID" "featureID")` blocks inside each PS script

---

## 3. Files and Code Sections

### `/opt/xetup/embed.go` (NEW)
Root-level package `xetup` that exposes embedded FS for `cmd/xetup`:
```go
package xetup

import "embed"

//go:embed scripts
var Scripts embed.FS

//go:embed assets
var Assets embed.FS
```

### `/opt/xetup/internal/tui/tui.go` (NEW + MODIFIED in current session)
Three-phase bubbletea model. Key structures:
```go
type Model struct {
    phase  phase
    form   *huh.Form
    runCfg runner.RunConfig
    cfg    config.Config
    pcName           string
    pcDesc           string
    productKey       string
    profileType      string
    selectedItemKeys []string  // was selectedStepIDs, now composite keys
    r      *runner.Runner
    cancel context.CancelFunc
    msgCh  chan tea.Msg
    logs    []runner.LogLine
    results []runner.Result
    width  int
    height int
}
```

`buildForm` now uses `runner.AllSelectableItems()`:
```go
func buildForm(m *Model) *huh.Form {
    allItems := runner.AllSelectableItems()
    opts := make([]huh.Option[string], len(allItems))
    for i, item := range allItems {
        opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
    }
    // Group 1: pcName, pcDesc, productKey, profileType
    // Group 2: MultiSelect with all items (title "Kroky a nastaveni")
}
```

Updated `startRun()` parses feature keys:
```go
selectedSet := make(map[string]bool, len(m.selectedItemKeys))
for _, key := range m.selectedItemKeys { selectedSet[key] = true }

// Build Features map
features := make(config.Features)
for _, item := range runner.AllSelectableItems() {
    if item.FeatureID == "" { continue }
    if features[item.StepID] == nil { features[item.StepID] = make(map[string]bool) }
    features[item.StepID][item.FeatureID] = selectedSet[item.Key]
}
m.cfg.Features = features

// Step enable/disable
stepEnabled := make(map[string]bool)
for _, item := range runner.AllSelectableItems() {
    if item.FeatureID == "" {
        stepEnabled[item.StepID] = selectedSet[item.Key]
    } else if selectedSet[item.Key] {
        stepEnabled[item.StepID] = true
    }
}
```

### `/opt/xetup/cmd/xetup/main.go` (NEW)
```go
import (
    xetupembed "git.xetup.x9.cz/x9/xetup"
    "git.xetup.x9.cz/x9/xetup/internal/config"
    "git.xetup.x9.cz/x9/xetup/internal/runner"
    "git.xetup.x9.cz/x9/xetup/internal/tui"
)
// Extracts scripts+assets to tmpDir, writes runtime config JSON, launches TUI
runCfg := runner.RunConfig{
    ScriptsDir: filepath.Join(tmpDir, "scripts"),
    ConfigPath: cfgRuntimePath,
    LogFile:    `C:\Windows\Setup\Scripts\Deploy.log`,
    ProfileType: cfg.Deployment.ProfileType,
}
```

### `/opt/xetup/internal/runner/runner.go` (MODIFIED)
Added after `AllSteps()`:
```go
type Feature struct { ID string; Label string }

func StepFeatures() map[string][]Feature {
    return map[string][]Feature{
        "software":        {{ID:"wingetInstalls",...},{ID:"pdfDefault",...},{ID:"ateraAgent",...}},
        "systemRegistry":  {{ID:"systemTweaks",...},{ID:"edgePolicies",...},{ID:"oneDriveUninstall",...},{ID:"powercfg",...},{ID:"proxyDisable",...}},
        "defaultProfile":  {{ID:"taskbarTweaks",...},{ID:"startMenuTweaks",...},{ID:"explorerTweaks",...}},
        "dellUpdate":      {{ID:"drivers",...},{ID:"bios",...}},
    }
}

type SelectableItem struct {
    Key       string  // "stepID" or "stepID.featureID"
    StepID    string
    FeatureID string  // empty for step-level
    Label     string
    Num       string
}

func AllSelectableItems() []SelectableItem  // expands feature steps; passthrough for others
```

### `/opt/xetup/internal/config/config.go` (MODIFIED)
```go
type Features map[string]map[string]bool

type Config struct {
    Deployment   Deployment      `json:"deployment"`
    AdminAccount AdminAccount    `json:"adminAccount"`
    Activation   Activation      `json:"activation"`
    Software     Software        `json:"software"`
    Steps        map[string]bool `json:"steps"`
    Features     Features        `json:"features"`
}
// DefaultConfig() now includes full Features defaults for all 4 steps
```

### `/opt/xetup/scripts/11-dell-update.ps1` (NEW)
Detects Dell, installs DCU Universal via winget, runs `/applyUpdates -silent -reboot=disable`. Get-Feature helper will be added to split into `drivers` and `bios` sub-features.

### `/opt/xetup/scripts/02-software.ps1` (PARTIALLY MODIFIED)
`Get-Feature` helper has been added after `Write-Log`. Still needed: wrap wingetInstalls / pdfDefault / ateraAgent blocks.

```powershell
function Get-Feature {
    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
    try {
        if ($null -eq $Cfg) { return $Default }
        $stepFeatures = $Cfg.features.$StepID
        if ($null -eq $stepFeatures) { return $Default }
        $val = $stepFeatures.$FeatureID
        if ($null -eq $val) { return $Default }
        return [bool]$val
    } catch { return $Default }
}
```

### `/opt/xetup/scripts/03-system-registry.ps1` (PENDING modification)
Need to add `Get-Feature` + wrap 5 blocks: `systemTweaks`, `edgePolicies`, `oneDriveUninstall`, `powercfg`, `proxyDisable`.

### `/opt/xetup/scripts/04-default-profile.ps1` (PENDING modification)
Need to add `Get-Feature` + wrap 3 blocks inside try{}: `taskbarTweaks`, `startMenuTweaks`, `explorerTweaks`.

### `/opt/xetup/scripts/11-dell-update.ps1` (PENDING modification)
Need to add `Get-Feature` + split `/applyUpdates` call to use `-updateType=driver,firmware` when `drivers=true` and add `bios` when `bios=true`.

### `/opt/xetup/web/spec/index.html` (MODIFIED)
- Step 11 Dell card moved from "Nove kroky (planovane)" to "Stavajici kroky" section
- Added "Nove nastaveni" section at bottom with JS widget for issue #15
- Updated arch-xetup: 5 items flag-done
- Added sidebar links, STEP_SCRIPT map entry for step-dell

### `/opt/xetup/web/index.html` (MODIFIED)
Added download strip that fetches Forgejo releases API:
```javascript
fetch(API + '/repos/' + REPO + '/releases?limit=1', { headers: HEADS })
  .then(r => r.json())
  .then(releases => {
    const asset = (rel.assets || []).find(a => a.name === 'xetup.exe');
    link.href = asset.browser_download_url;
    meta.textContent = rel.tag_name + ' · ' + sizeMB + ' MB · Windows x64';
    strip.style.display = '';
  })
```

### `/opt/xetup/web/data/descriptions.json`
Regenerated via `python3 tools/extract-docs.py` after adding 11-dell-update.ps1. Now: 13 scripts, 80 items.

---

## 4. Errors and Fixes

- **Dell step in wrong spec section**: User reported it was under "Plánované kroky". Fixed by moving the HTML `<div class="step" id="step-dell">` block to before `<p class="section-label">Nove kroky (planovane)</p>`.
- **Duplicate import in tui.go**: Accidentally added `_ "git.xetup.x9.cz/x9/xetup/internal/config"` as a blank import while `config` was already imported normally. Removed immediately.
- **Forgejo at wrong port**: Initially tried `localhost:3000`, got "Connection refused". Fixed to `localhost:3100`.
- **`_ = stepFeaturesMap` in startRun**: `stepFeaturesMap` was declared but the refactored loop didn't use it. Left as `_ = stepFeaturesMap` to suppress compiler error — this is a minor code smell that should be cleaned up (just remove the declaration).

---

## 5. Problem Solving

- **embed.FS path resolution**: Go `//go:embed` paths are relative to source file. Solved by putting `embed.go` at module root (`/opt/xetup/`) so `scripts` and `assets` dirs are directly reachable.
- **Flat feature list in TUI**: huh MultiSelect doesn't support grouped items. Solved by using composite keys (`stepID.featureID`) in a flat list, with step number as prefix for visual grouping.
- **Step enable/disable with sub-features**: A step with features is enabled if ANY of its feature keys is in the selected set. Step-level toggle items (for steps without sub-features) use plain `stepID` key.
- **BIOS update safety during deployment**: `-reboot=disable` prevents mid-deployment restart; BIOS completes on the final restart triggered by computer rename (step 09).

---

## 6. All User Messages

1. *(Session resumed from summary — no initial message)*
2. "jo - akorát ten dell command update máš v 'plánované kroky'"
3. "A chtěl bych někde na landing page přímý odkaz ke stažení exe.."
4. "bude ta konfigurace v TUI možná per řádek? Bylo by to fajn ... (=nejen per sekce)"
5. "jak to bude vypadat je jedno. jen abych třeba mohl jsem vypnout 'Vytvorit lokalni ucet adminx9' - i když to asi bude fungovat tak, že pokud existuje, tak ho to vytvářet nebude, žejo? Ale jdem mi o tu granulární možnost vypínání/zapínání nastavení"
6. "zapsat a rovnou udělat, prosím .... jak to uděláš? Sekce ve scriptu nebo rozsekání scriptů na 'atomické' jednotky?"

---

## 7. Pending Tasks

1. **02-software.ps1** — Wrap three blocks with `Get-Feature`:
   - `if (Get-Feature $Config "software" "wingetInstalls")` around winget installs
   - `if (Get-Feature $Config "software" "pdfDefault")` around PDF default + UCPD
   - `if (Get-Feature $Config "software" "ateraAgent")` around Atera install

2. **03-system-registry.ps1** — Add `Get-Feature` helper + wrap 5 feature blocks: `systemTweaks`, `edgePolicies`, `oneDriveUninstall`, `powercfg`, `proxyDisable`

3. **04-default-profile.ps1** — Add `Get-Feature` helper + wrap 3 feature blocks inside try{}: `taskbarTweaks`, `startMenuTweaks`, `explorerTweaks`. Also add missing `explorerTweaks` code (ShowRecent=0, ShowFrequent=0, FullPath=1 in CabinetState)

4. **11-dell-update.ps1** — Add `Get-Feature` + split update types:
   ```powershell
   $updateTypes = @()
   if (Get-Feature $Config "dellUpdate" "drivers") { $updateTypes += "driver"; $updateTypes += "firmware" }
   if (Get-Feature $Config "dellUpdate" "bios")    { $updateTypes += "bios" }
   # then & $dcuCli /applyUpdates -silent -reboot=disable -updateType=($updateTypes -join ",")
   ```

5. **Remove `_ = stepFeaturesMap`** from tui.go startRun (minor cleanup)

6. **Run `python3 tools/extract-docs.py`** after script changes

7. **`go build ./...` + cross-compile** to verify clean build

8. **Git commit + push + docker restart**

9. **Update spec page** — Add feature toggle rows to step cards for 02, 03, 04, 11

---

## 8. Current Work

Immediately before the summary request, the last completed action was adding the `Get-Feature` PowerShell helper function to `02-software.ps1` after the `Write-Log` function definition. The edit was confirmed successful.

The next immediate step would have been wrapping the three feature blocks in `02-software.ps1`:
1. Wrap winget installs section (lines ~65-88) in `if (Get-Feature $Config "software" "wingetInstalls") { ... }`
2. Wrap PDF default section (lines ~92-163) in `if (Get-Feature $Config "software" "pdfDefault") { ... }`
3. Wrap Atera agent section (lines ~167-194) in `if (Get-Feature $Config "software" "ateraAgent") { ... }`

Then move on to 03, 04, 11.

---

## 9. Optional Next Step

Continue wrapping feature blocks in `02-software.ps1` — specifically the three `if (Get-Feature ...)` wrappers for `wingetInstalls`, `pdfDefault`, and `ateraAgent`. User's explicit request was: "zapsat a rovnou udělat, prosím .... jak to uděláš? Sekce ve scriptu nebo rozsekání scriptů na 'atomické' jednotky?" — and the chosen approach was sections within scripts. The `Get-Feature` helper is already in place in 02-software.ps1; now wrap the logical blocks. Then proceed to 03, 04, 11 in the same pattern.

If you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: /home/dev/.claude/projects/-opt-xetup/b64c8c21-dc81-41a5-a66f-f0f6667e34d7.jsonl
Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.


<#
.SYNOPSIS
    Installs standard business software via winget, sets Adobe PDF default, and installs Atera RMM agent.

.DESCRIPTION
    Uses winget to install the standard X9.cz MSP software bundle. Checks winget
    availability before running. Each install is logged. After Adobe Acrobat Reader,
    temporarily stops the UCPD driver (User Choice Protection Driver, present since
    Win11 23H2 / Win10 22H2 update) to allow the HKCR file association write, sets
    .pdf -> AcroRd32, then restarts UCPD. Atera RMM agent is installed for MSP
    monitoring, remote access, and ticketing integration.

.ITEMS
    7-zip-7zip-7zip: Installs 7-Zip (winget ID: 7zip.7zip). Used for archive management. Silent install with --accept-package-agreements --accept-source-agreements flags required for unattended deployment.
    adobe-acrobat-reader-64-bit-adobe-acroba: Installs Adobe Acrobat Reader DC 64-bit (Adobe.Acrobat.Reader.64-bit). Required as the default PDF viewer to prevent Edge from handling PDFs in browser mode, which limits functionality.
    openvpn-connect-openvpntechnologies-open: Installs OpenVPN Connect client. Used for client VPN access when the client network requires a VPN. The ovpn profile and credentials are configured separately per client.
    atera-agent-install: Atera RMM agent installed via msiexec /qn. Download: Invoke-WebRequest from https://x9.servicedesk.atera.com/api/utils/agent-install/windows/?cid=31&aeid=50b72e7113e54a63ac76b96c54c7e337. Agent enables MSP monitoring, remote access, and ticketing integration with the Atera dashboard.
    adobe-pdf-default-pdf-acrord32-po-instal: Sets .pdf -> AcroRd32 file association after Acrobat install via HKCR (system-wide, no UserChoice hash issue). UCPD driver is stopped immediately before the write and restarted after to ensure the association persists across Edge updates.
    ucpd-sys-kernel-driver-od-feb-2024-bloku: UCPD.sys (User Choice Protection Driver) is stopped before the PDF association write and restarted after. Pattern: Stop-Service ucpd -> set HKCR\.pdf -> Start-Service ucpd. Implemented in this script.
#>
param(
    [object]$Config,
    [string]$LogFile
)

$ErrorActionPreference = "Continue"

function Write-Log {
    param([string]$Message, [string]$Level = "INFO")
    $line = "[$(Get-Date -Format 'HH:mm:ss')] [$Level] $Message"
    Add-Content -Path $LogFile -Value $line -Encoding UTF8
}

function Get-Feature {
    param([object]$Cfg, [string]$StepID, [string]$FeatureID, [bool]$Default = $true)
    try {
        if ($null -eq $Cfg) { return $Default }
        $stepFeatures = $Cfg.features.$StepID
        if ($null -eq $stepFeatures) { return $Default }
        $val = $stepFeatures.$FeatureID
        if ($null -eq $val) { return $Default }
        return [bool]$val
    } catch { return $Default }
}

# -----------------------------------------------------------------------
# Check winget availability
# -----------------------------------------------------------------------
Write-Log "Checking winget availability" -Level INFO

$winget = Get-Command winget -ErrorAction SilentlyContinue
if (-not $winget) {
    # Try to find winget in known locations
    $wingetPaths = @(
        "$env:LOCALAPPDATA\Microsoft\WindowsApps\winget.exe"
        "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller*\winget.exe"
    )
    foreach ($p in $wingetPaths) {
        $found = Get-Item $p -ErrorAction SilentlyContinue | Select-Object -First 1
        if ($found) { $winget = $found.FullName; break }
    }
}

if (-not $winget) {
    Write-Log "winget not found - software installation skipped" -Level ERROR
    exit 1
}

Write-Log "winget found: $($winget.Source -or $winget)" -Level OK

# Accept agreements upfront
& winget source update --accept-source-agreements 2>&1 | Out-Null

# -----------------------------------------------------------------------
# Install packages from config
# -----------------------------------------------------------------------
if (-not $Config -or -not $Config.software -or -not $Config.software.install) {
    Write-Log "No software list in config - skipping installs" -Level WARN
} else {
    foreach ($pkg in $Config.software.install) {
        Write-Log "Installing $($pkg.name) ($($pkg.wingetId))" -Level INFO
        $result = & winget install --id $pkg.wingetId `
            --silent `
            --accept-package-agreements `
            --accept-source-agreements `
            --disable-interactivity `
            2>&1

        $exitCode = $LASTEXITCODE
        if ($exitCode -eq 0) {
            Write-Log "  Installed OK: $($pkg.name)" -Level OK
        } elseif ($exitCode -eq -1978335189) {
            # 0x8A150011 = already installed
            Write-Log "  Already installed: $($pkg.name)" -Level OK
        } else {
            Write-Log "  Failed: $($pkg.name) (exit $exitCode)" -Level ERROR
            Write-Log "  Output: $($result -join ' ')" -Level ERROR
        }
    }
}

# -----------------------------------------------------------------------
# Set Adobe Reader as default PDF app
# -----------------------------------------------------------------------
$forcePdf = $true
if ($Config -and $Config.pdfDefault) {
    $forcePdf = [bool]$Config.pdfDefault.forceAdobeReader
}

if ($forcePdf) {
    Write-Log "Setting Adobe Reader as default PDF app" -Level INFO

    # Stop UCPD driver before writing file association.
    # UCPD.sys (User Choice Protection Driver) blocks UserChoice registry writes
    # on Win11 23H2+ and some Win10 22H2 builds. Stopping it temporarily allows
    # the HKCR association to be written reliably.
    $ucpdStopped = $false
    $ucpdSvc = Get-Service -Name "ucpd" -ErrorAction SilentlyContinue
    if ($ucpdSvc) {
        try {
            Stop-Service -Name "ucpd" -Force -ErrorAction Stop
            $ucpdStopped = $true
            Write-Log "  UCPD driver stopped" -Level OK
        }
        catch {
            Write-Log "  Could not stop UCPD: $_ (association may not persist on some builds)" -Level WARN
        }
    }

    # Find Adobe PDF viewer executable (Acrobat DC or Reader DC)
    $acroPaths = @(
        "$env:ProgramFiles\Adobe\Acrobat DC\Acrobat\Acrobat.exe"
        "${env:ProgramFiles(x86)}\Adobe\Acrobat DC\Acrobat\Acrobat.exe"
        "${env:ProgramFiles(x86)}\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"
        "$env:ProgramFiles\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe"
        "${env:ProgramFiles(x86)}\Adobe\Reader\Reader\AcroRd32.exe"
    )
    $acroExe = $acroPaths | Where-Object { Test-Path $_ } | Select-Object -First 1

    if (-not $acroExe) {
        Write-Log "  Adobe PDF viewer not found - PDF default not set" -Level WARN
    } else {
        Write-Log "  Found: $acroExe" -Level INFO

        # Set file type association via HKCR (system-wide, requires admin)
        $progId = "AcroExch.Document.DC"
        $openCmd = "`"$acroExe`" `"%1`""

        # HKCR\.pdf -> progId
        if (-not (Test-Path "HKCR:\.pdf")) {
            New-Item -Path "HKCR:\.pdf" -Force | Out-Null
        }
        Set-ItemProperty -Path "HKCR:\.pdf" -Name "(Default)" -Value $progId

        # HKCR\AcroExch.Document.DC\shell\open\command
        $cmdPath = "HKCR:\$progId\shell\open\command"
        if (-not (Test-Path $cmdPath)) {
            New-Item -Path $cmdPath -Force | Out-Null
        }
        Set-ItemProperty -Path $cmdPath -Name "(Default)" -Value $openCmd

        Write-Log "  PDF default set to AcroRd32 (HKCR)" -Level OK
    }

    # Restart UCPD after writing association
    if ($ucpdStopped) {
        try {
            Start-Service -Name "ucpd" -ErrorAction SilentlyContinue
            Write-Log "  UCPD driver restarted" -Level OK
        }
        catch {
            Write-Log "  Could not restart UCPD: $_" -Level WARN
        }
    }
}

# -----------------------------------------------------------------------
# Install Atera RMM Agent
# -----------------------------------------------------------------------
Write-Log "Installing Atera RMM Agent" -Level INFO

$ateraUrl = "https://x9.servicedesk.atera.com/api/utils/agent-install/windows/?cid=31&aeid=50b72e7113e54a63ac76b96c54c7e337"
$ateraMsi = "$env:TEMP\AteraAgent.msi"

try {
    Write-Log "  Downloading Atera agent..." -Level INFO
    Invoke-WebRequest -Uri $ateraUrl -OutFile $ateraMsi -UseBasicParsing -ErrorAction Stop
    Write-Log "  Download complete" -Level OK

    $msiResult = & msiexec /i $ateraMsi /qn 2>&1
    Start-Sleep -Seconds 5

    $ateraExe = "$env:ProgramFiles\ATERA Networks\AteraAgent\AteraAgent.exe"
    if (Test-Path $ateraExe) {
        Write-Log "  Atera agent installed" -Level OK
    } else {
        Write-Log "  Atera agent install may have failed - binary not found at expected path" -Level WARN
        Write-Log "  msiexec output: $($msiResult -join ' ')" -Level WARN
    }
}
catch {
    Write-Log "  Atera agent download/install failed: $_" -Level ERROR
}
finally {
    Remove-Item $ateraMsi -Force -ErrorAction SilentlyContinue
}

Write-Log "Step 2 complete" -Level OK


// Package tui implements the interactive terminal UI for xetup.
// Phase 1 – configuration form (huh embedded in bubbletea).
// Phase 2 – live log view while PowerShell scripts run.
// Phase 3 – final summary.
package tui

import (
	"context"
	"fmt"
	"strings"
	"time"

	tea "github.com/charmbracelet/bubbletea"
	"github.com/charmbracelet/huh"
	"github.com/charmbracelet/lipgloss"

	"git.xetup.x9.cz/x9/xetup/internal/config"
	"git.xetup.x9.cz/x9/xetup/internal/runner"
)

// --------------------------------------------------------------------------
// Phase
// --------------------------------------------------------------------------

type phase int

const (
	phaseForm phase = iota
	phaseRun
	phaseDone
)

// --------------------------------------------------------------------------
// Async message types (runner → bubbletea)
// --------------------------------------------------------------------------

type logMsg    runner.LogLine
type resultMsg runner.Result
type doneMsg   []runner.Result

// --------------------------------------------------------------------------
// Model
// --------------------------------------------------------------------------

// Model is the top-level bubbletea model.
type Model struct {
	phase  phase
	form   *huh.Form
	runCfg runner.RunConfig
	cfg    config.Config

	// form-bound values (huh writes here via pointer)
	pcName           string
	pcDesc           string
	productKey       string
	profileType      string
	selectedItemKeys []string

	// runner state
	r      *runner.Runner
	cancel context.CancelFunc
	msgCh  chan tea.Msg

	logs    []runner.LogLine
	results []runner.Result

	// terminal dimensions
	width  int
	height int
}

// NewModel returns the initial model pre-populated from cfg.
func NewModel(cfg config.Config, runCfg runner.RunConfig) Model {
	m := Model{
		cfg:         cfg,
		runCfg:      runCfg,
		pcName:      cfg.Deployment.PCName,
		pcDesc:      cfg.Deployment.PCDescription,
		productKey:  cfg.Activation.ProductKey,
		profileType: cfg.Deployment.ProfileType,
		width:       80,
		height:      24,
	}
	m.form = buildForm(&m)
	return m
}

// --------------------------------------------------------------------------
// Form construction
// --------------------------------------------------------------------------

func buildForm(m *Model) *huh.Form {
	allItems := runner.AllSelectableItems()
	opts := make([]huh.Option[string], len(allItems))
	for i, item := range allItems {
		opts[i] = huh.NewOption(item.Label, item.Key).Selected(true)
	}

	return huh.NewForm(
		huh.NewGroup(
			huh.NewInput().
				Title("Jmeno pocitace").
				Placeholder("napr. NB-KLIENT-01  (prazdne = neprejmenovat)").
				Value(&m.pcName),
			huh.NewInput().
				Title("Popis pocitace").
				Placeholder("napr. PC recepce").
				Value(&m.pcDesc),
			huh.NewInput().
				Title("Product Key").
				Placeholder("prazdne = OA3 / GVLK fallback").
				Value(&m.productKey),
			huh.NewSelect[string]().
				Title("Profil").
				Options(
					huh.NewOption("Default", "default"),
					huh.NewOption("Admin (Explorer, PS, Edge)", "admin"),
					huh.NewOption("User  (Explorer, Edge)", "user"),
				).
				Value(&m.profileType),
		),
		huh.NewGroup(
			huh.NewMultiSelect[string]().
				Title("Kroky a nastaveni (mezernikem odzaskrtnout)").
				Options(opts...).
				Value(&m.selectedItemKeys),
		),
	)
}

// --------------------------------------------------------------------------
// bubbletea interface
// --------------------------------------------------------------------------

// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
	return m.form.Init()
}

// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
	switch msg := msg.(type) {

	case tea.WindowSizeMsg:
		m.width = msg.Width
		m.height = msg.Height

	case tea.KeyMsg:
		switch msg.String() {
		case "ctrl+c":
			if m.cancel != nil {
				m.cancel()
			}
			return m, tea.Quit
		}
		if m.phase == phaseDone {
			return m, tea.Quit
		}

	// --- async runner messages ---

	case logMsg:
		m.logs = append(m.logs, runner.LogLine(msg))
		return m, readMsg(m.msgCh)

	case resultMsg:
		m.results = append(m.results, runner.Result(msg))
		return m, readMsg(m.msgCh)

	case doneMsg:
		m.results = []runner.Result(msg)
		m.phase = phaseDone
		return m, nil
	}

	// --- form phase ---
	if m.phase == phaseForm {
		form, cmd := m.form.Update(msg)
		if f, ok := form.(*huh.Form); ok {
			m.form = f
		}
		switch m.form.State {
		case huh.StateCompleted:
			return m.startRun()
		case huh.StateAborted:
			return m, tea.Quit
		}
		return m, cmd
	}

	return m, nil
}

// View implements tea.Model.
func (m Model) View() string {
	switch m.phase {
	case phaseForm:
		return m.viewForm()
	case phaseRun:
		return m.viewRun()
	case phaseDone:
		return m.viewDone()
	}
	return ""
}

// --------------------------------------------------------------------------
// Phase transitions
// --------------------------------------------------------------------------

// startRun applies form values and kicks off the runner goroutine.
func (m Model) startRun() (Model, tea.Cmd) {
	// persist form values into config
	m.cfg.Deployment.PCName = m.pcName
	m.cfg.Deployment.PCDescription = m.pcDesc
	m.cfg.Activation.ProductKey = m.productKey
	m.cfg.Deployment.ProfileType = m.profileType
	m.runCfg.ProfileType = m.profileType

	// Index selected keys for fast lookup
	selectedSet := make(map[string]bool, len(m.selectedItemKeys))
	for _, key := range m.selectedItemKeys {
		selectedSet[key] = true
	}

	// Build Features map: feature enabled = its composite key was selected
	features := make(config.Features)
	for _, item := range runner.AllSelectableItems() {
		if item.FeatureID == "" {
			continue
		}
		if features[item.StepID] == nil {
			features[item.StepID] = make(map[string]bool)
		}
		features[item.StepID][item.FeatureID] = selectedSet[item.Key]
	}
	m.cfg.Features = features

	// Determine which steps are enabled:
	// - step with features: enabled if at least one feature selected
	// - step without features: enabled if step key selected
	stepEnabled := make(map[string]bool)
	stepFeaturesMap := runner.StepFeatures()
	for _, item := range runner.AllSelectableItems() {
		if item.FeatureID == "" {
			stepEnabled[item.StepID] = selectedSet[item.Key]
		} else if selectedSet[item.Key] {
			// any feature selected enables the step
			_ = stepFeaturesMap
			stepEnabled[item.StepID] = true
		}
	}

	allSteps := runner.AllSteps()
	steps := make([]runner.Step, len(allSteps))
	for i, s := range allSteps {
		s.Enabled = stepEnabled[s.ID]
		steps[i] = s
	}

	// set up channel and start runner
	ch := make(chan tea.Msg, 200)
	m.msgCh = ch
	m.phase = phaseRun

	ctx, cancel := context.WithCancel(context.Background())
	m.cancel = cancel

	rc := m.runCfg
	r := runner.New(
		rc,
		func(l runner.LogLine) { ch <- logMsg(l) },
		func(res runner.Result) { ch <- resultMsg(res) },
	)
	m.r = r

	go func() {
		results := r.Run(ctx, steps)
		ch <- doneMsg(results)
	}()

	return m, readMsg(ch)
}

// readMsg returns a tea.Cmd that blocks until the next message arrives on ch.
func readMsg(ch <-chan tea.Msg) tea.Cmd {
	return func() tea.Msg {
		return <-ch
	}
}

// --------------------------------------------------------------------------
// Styles
// --------------------------------------------------------------------------

var (
	sTitle  = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#58a6ff"))
	sOK     = lipgloss.NewStyle().Foreground(lipgloss.Color("#2ea043"))
	sError  = lipgloss.NewStyle().Foreground(lipgloss.Color("#da3633"))
	sWarn   = lipgloss.NewStyle().Foreground(lipgloss.Color("#d29922"))
	sStep   = lipgloss.NewStyle().Foreground(lipgloss.Color("#58a6ff"))
	sMuted  = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888"))
	sBold   = lipgloss.NewStyle().Bold(true)
)

// --------------------------------------------------------------------------
// Views
// --------------------------------------------------------------------------

func (m Model) viewForm() string {
	header := sTitle.Render("xetup") + "  " + sMuted.Render("Windows deployment – vyplnte konfiguraci")
	return header + "\n\n" + m.form.View()
}

func (m Model) viewRun() string {
	allSteps := runner.AllSteps()
	doneCount := len(m.results)
	totalCount := len(allSteps)

	var sb strings.Builder

	// header bar
	sb.WriteString(sTitle.Render("xetup") + "  ")
	if doneCount < totalCount {
		s := allSteps[doneCount]
		sb.WriteString(sBold.Render(fmt.Sprintf("krok %d/%d", doneCount+1, totalCount)))
		sb.WriteString("  " + s.Num + " - " + s.Name)
	} else {
		sb.WriteString(sBold.Render("dokoncuji..."))
	}
	sb.WriteByte('\n')
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)))
	sb.WriteByte('\n')

	// log lines – show last N to fit terminal
	maxLines := m.height - 5
	if maxLines < 5 {
		maxLines = 5
	}
	start := 0
	if len(m.logs) > maxLines {
		start = len(m.logs) - maxLines
	}
	for _, l := range m.logs[start:] {
		sb.WriteString(styledLogLine(l))
		sb.WriteByte('\n')
	}

	sb.WriteString(sMuted.Render("\nCtrl+C pro zastaveni"))
	return sb.String()
}

func (m Model) viewDone() string {
	var sb strings.Builder

	sb.WriteString(sTitle.Render("xetup  ") + sBold.Render("hotovo") + "\n")
	sb.WriteString(strings.Repeat("─", clamp(m.width, 20, 90)) + "\n\n")

	okCount, errCount, skipCount := 0, 0, 0
	for _, res := range m.results {
		var prefix string
		switch res.Status {
		case "OK":
			okCount++
			prefix = sOK.Render("  OK      ")
		case "ERROR":
			errCount++
			prefix = sError.Render("  ERROR   ")
		default:
			skipCount++
			prefix = sMuted.Render("  SKIPPED ")
		}
		sb.WriteString(prefix + res.Step.Num + " - " + res.Step.Name)
		if res.Elapsed > 0 {
			sb.WriteString(sMuted.Render(fmt.Sprintf("  (%s)", res.Elapsed.Round(time.Second))))
		}
		sb.WriteByte('\n')
	}

	sb.WriteString("\n" + strings.Repeat("─", clamp(m.width, 20, 90)) + "\n")
	summary := fmt.Sprintf("OK: %d  CHYBY: %d  PRESKOCENO: %d", okCount, errCount, skipCount)
	if errCount > 0 {
		sb.WriteString(sError.Render(summary))
	} else {
		sb.WriteString(sOK.Render(summary))
	}
	sb.WriteString(sMuted.Render("\n\nStisknete libovolnou klavesu pro ukonceni..."))
	return sb.String()
}

// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------

func styledLogLine(l runner.LogLine) string {
	switch l.Level {
	case "OK":
		return sOK.Render(l.Text)
	case "ERROR":
		return sError.Render(l.Text)
	case "WARN":
		return sWarn.Render(l.Text)
	case "STEP":
		return sStep.Render(l.Text)
	default:
		return sMuted.Render(l.Text)
	}
}

func clamp(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}


package config

import (
	"encoding/json"
	"os"
	"path/filepath"
)

// Config mirrors config.json structure.
type Config struct {
	Deployment    Deployment             `json:"deployment"`
	AdminAccount  AdminAccount           `json:"adminAccount"`
	Activation    Activation             `json:"activation"`
	Software      Software               `json:"software"`
	Steps         map[string]bool        `json:"steps"`
	Features      Features               `json:"features"`
}

type Deployment struct {
	PCName        string `json:"pcName"`
	PCDescription string `json:"pcDescription"`
	Timezone      string `json:"timezone"`
	ProfileType   string `json:"profileType"` // default | admin | user
}

type AdminAccount struct {
	Username string `json:"username"`
}

type Activation struct {
	ProductKey string `json:"productKey"`
	KMSServer  string `json:"kmsServer"`
}

type SoftwareItem struct {
	Name     string `json:"name"`
	WingetID string `json:"wingetId"`
}

type Software struct {
	Install []SoftwareItem `json:"install"`
}

// Features holds per-step, per-feature toggle flags.
// Keys: stepID -> featureID -> enabled.
// A missing key defaults to true (feature enabled).
type Features map[string]map[string]bool

// DefaultConfig returns a config with sensible defaults.
func DefaultConfig() Config {
	return Config{
		Deployment: Deployment{
			Timezone:    "Central Europe Standard Time",
			ProfileType: "default",
		},
		AdminAccount: AdminAccount{
			Username: "adminx9",
		},
		Activation: Activation{
			ProductKey: "",
		},
		Software: Software{
			Install: []SoftwareItem{
				{Name: "7-Zip", WingetID: "7zip.7zip"},
				{Name: "Adobe Acrobat Reader 64-bit", WingetID: "Adobe.Acrobat.Reader.64-bit"},
				{Name: "OpenVPN Connect", WingetID: "OpenVPNTechnologies.OpenVPNConnect"},
			},
		},
		Steps: map[string]bool{
			"adminAccount":    true,
			"bloatware":       true,
			"software":        true,
			"systemRegistry":  true,
			"defaultProfile":  true,
			"personalization": true,
			"scheduledTasks":  true,
			"backinfo":        true,
			"activation":      true,
			"dellUpdate":      true,
			"network":         true,
			"pcIdentity":      true,
		},
		Features: Features{
			"software": {
				"wingetInstalls": true,
				"pdfDefault":     true,
				"ateraAgent":     true,
			},
			"systemRegistry": {
				"systemTweaks":      true,
				"edgePolicies":      true,
				"oneDriveUninstall": true,
				"powercfg":          true,
				"proxyDisable":      true,
			},
			"defaultProfile": {
				"taskbarTweaks":   true,
				"startMenuTweaks": true,
				"explorerTweaks":  true,
			},
			"dellUpdate": {
				"drivers": true,
				"bios":    true,
			},
		},
	}
}

// Load reads config.json from the given path.
// If the file does not exist, returns DefaultConfig without error.
func Load(path string) (Config, error) {
	cfg := DefaultConfig()
	data, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return cfg, nil
	}
	if err != nil {
		return cfg, err
	}
	if err := json.Unmarshal(data, &cfg); err != nil {
		return cfg, err
	}
	return cfg, nil
}

// Save writes config to the given path (creates directories if needed).
func Save(cfg Config, path string) error {
	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(cfg, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

// ConfigPath returns the default config.json path (next to the executable).
func ConfigPath() string {
	exe, err := os.Executable()
	if err != nil {
		return "config.json"
	}
	return filepath.Join(filepath.Dir(exe), "config.json")
}