package jobspec

import (
	"fmt"
	"strconv"
	"strings"
)

// WorkloadSpec is the unified canonical jobspec populated by both the
// Markdown frontmatter parser (canonical path, R-013/R-014) and the HCL
// legacy adapter (REQ-064, REQ-090). It is the single shape consumed by
// downstream phases (P0c schemas, P01 transport). The Markdown body
// after the closing `---` is preserved verbatim in Body (R-015
// byte-exact preservation is a load-bearing invariant enforced by the
// fuzz harness in markdown_fuzz_test.go).
type WorkloadSpec struct {
	SpecVersion string
	Kind        string
	Name        string
	Runtime     *RuntimeBlock
	Count       int
	Ports       []PortSpec
	Env         map[string]string
	Secrets     []string
	Volumes     []VolumeSpec
	Body        string

	// Kind-specific blocks consumed by the P0c schema validators
	// (internal/spec/schema). P02 populates Restart, Update, Service,
	// Health, Constraints, Affinity, Lifecycle from the Markdown
	// frontmatter (the rest are still populated by later phases).

	// Restart is the restart policy block. Required for Service and
	// DaemonSet; optional for Job (defaults to never/on-failure).
	// P02 populates it from the `restart:` frontmatter block.
	Restart *RestartBlock

	// Schedule is the schedule block. For Job it carries an optional
	// cron string; for DaemonSet it carries the placement mode
	// (every-node/matching/mandatory). Populated by P05 (scheduler
	// skeleton) and the DaemonSet phase.
	Schedule *ScheduleBlock

	// Update is the rolling/canary update stanza. Required for
	// Service. P02 populates it from the `update:` frontmatter block;
	// the rolling/canary semantics land in P03.
	Update *UpdateBlock

	// Service is the service block (Traefik route definition). For
	// Service kind it is implied; Job and DaemonSet do not carry a
	// Traefik route by default (D-175). P02 populates it from the
	// `service:` frontmatter block.
	Service *ServiceBlock

	// Health is the health-check block. Required for Service (Traefik
	// routing depends on it). P02 populates it from the `health:`
	// frontmatter block (R-012).
	Health *HealthBlock

	// Constraints is the CEL expression list for placement. P02
	// populates it from the `constraints:` frontmatter array; P05
	// consumes it for the CLI-side scheduler (REQ-083).
	Constraints []string

	// Affinity is the affinity rule list for placement. P02 populates
	// it from the `affinity:` frontmatter array; P05 consumes it.
	Affinity []AffinityRule

	// Lifecycle is the lifecycle hook block (pre_stop, post_start).
	// P02 populates it from the `lifecycle:` frontmatter block; P04
	// wires it into the systemd unit (ExecStop / ExecStartPost).
	Lifecycle *LifecycleBlock

	// Timeout is an optional execution timeout (duration string) for
	// Job. Populated by P04.
	Timeout string

	// Tasks is the task-group list for multi-process services (P06,
	// PRD §9.1). When non-empty, the alloc runs one systemd unit per
	// task (`orca-v1-alloc-<alloc-id>-<task-name>.service`) all
	// grouped under a single `<alloc-id>.target`. When nil/empty,
	// the alloc is a single-process alloc driven by the top-level
	// Runtime block (backward compat). Tasks that omit their own
	// runtime inherit the top-level Runtime as the per-group default.
	Tasks []TaskGroupTask
}

// TaskGroupTask is a single task within a task group (P06, PRD §9.1).
// Each task has its own runtime (a wasm task + a process sidecar is
// allowed), its own command, and an optional env overlay. When
// Runtime is nil, the task inherits the top-level
// WorkloadSpec.Runtime (the per-group default).
type TaskGroupTask struct {
	Name    string
	Runtime *RuntimeBlock
	Env     map[string]string
	Command string
}

// RuntimeBlock is a minimal runtime abstraction surface populated by the
// Markdown parser. The full runtime abstraction lands in P07; for now
// only the one_of/image/command fields are parsed and stored (REQ-064).
type RuntimeBlock struct {
	OneOf   string
	Image   string
	Command string
}

// RestartBlock is the restart policy block. Mode is one of never,
// on-failure, service (REQ-074 schema validators). P02 populates it from
// the frontmatter `restart:` block.
type RestartBlock struct {
	Mode       string
	MaxRetries int
	Delay      string
}

// ScheduleBlock is the scheduling block. For Job, Cron is an optional
// cron expression. For DaemonSet, Mode is one of every-node, matching,
// mandatory (REQ-074). Populated by P05 and the DaemonSet phase.
type ScheduleBlock struct {
	Mode string
	Cron string
}

// UpdateBlock is the rolling/canary update stanza. Required for Service.
// P02 populates it from the frontmatter `update:` block; the
// rolling/canary/blue-green semantics land in P03.
type UpdateBlock struct {
	Strategy        string
	MaxSurge        int
	MaxParallel     int
	MinHealthyTime  string
	HealthyDeadline string
	Canary          string
	AutoPromote     bool
}

// ServiceBlock is the Traefik route definition. For Service it is
// implied (Traefik route YES); Job and DaemonSet do not carry one by
// default (D-175). P02 populates it from the frontmatter `service:`
// block.
type ServiceBlock struct {
	Host    string
	RouteID string
	Name    string
	Port    int
	Bind    string
}

// HealthBlock is the health-check block. P02 populates it from the
// frontmatter `health:` block (R-012). The Traefik emitter (REQ-077)
// renders it as the service's health-check stanza; ServiceValidator
// requires it for Traefik routing.
type HealthBlock struct {
	CheckType          string
	Interval           string
	Timeout            string
	UnhealthyThreshold int
}

// AffinityRule is a single affinity entry: target CEL expression +
// integer weight. P02 populates it from the `affinity:` frontmatter
// array; P05 consumes it for the CLI-side scheduler (REQ-083).
type AffinityRule struct {
	Target string
	Weight int
}

// LifecycleBlock is the lifecycle hook block. PreStop and PostStart
// are command lists run before stop / after start. P02 populates it
// from the `lifecycle:` frontmatter block; P04 wires it into the
// systemd unit (ExecStop / ExecStartPost).
type LifecycleBlock struct {
	PreStop   []string
	PostStart []string
}

// PortSpec is a minimal port binding entry. HostIP is optional.
type PortSpec struct {
	Name     string
	HostPort int
	Port     int
	Protocol string
	HostIP   string
}

// VolumeSpec is a minimal volume mount entry. Fields are stored raw
// pending the P0c schema work (REQ-074).
type VolumeSpec struct {
	Name     string
	Type     string
	Source   string
	Target   string
	ReadOnly bool
}

// validKinds is the set of workload kinds accepted by the parser per
// R-012. Unknown kinds are rejected.
var validKinds = map[string]bool{
	"Job":       true,
	"Service":   true,
	"DaemonSet": true,
}

// ParseMarkdown parses a Markdown jobspec with YAML frontmatter into a
// *WorkloadSpec (R-013 canonical format, R-014 frontmatter). The body
// after the closing `---` is preserved verbatim in result.Body
// (R-015 byte-exact, including trailing newlines, CRLF, and BOM in the
// body). The frontmatter parser is a minimal hand-rolled YAML-ish
// key:value reader — gopkg.in/yaml.v3 is intentionally not added (same
// approach as internal/config/markdown.go and internal/ns/parse.go).
//
// For .yaml/.yml files (no Markdown body), the dispatcher calls this
// with the whole file treated as frontmatter and Body left empty (see
// dispatch.go).
func ParseMarkdown(data []byte) (*WorkloadSpec, error) {
	content := string(data)
	block, body, ok := splitFrontmatter(content)
	if !ok {
		return nil, fmt.Errorf("parse markdown: missing frontmatter delimiters")
	}
	if strings.TrimSpace(block) == "" {
		return nil, fmt.Errorf("parse markdown: empty frontmatter")
	}
	spec, err := parseFrontmatterBlock(block)
	if err != nil {
		return nil, err
	}
	spec.Body = body
	if err := validateWorkload(spec); err != nil {
		return nil, err
	}
	return spec, nil
}

// splitFrontmatter splits the file content into the YAML frontmatter
// block and the verbatim body that follows the closing `---`. A leading
// UTF-8 BOM is stripped from the frontmatter scan (R-015: BOM is not
// preserved in the frontmatter, but a BOM inside the body would be
// preserved because the body is verbatim). Returns (block, body, ok).
// ok is false when no opening `---` delimiter is present, or no closing
// `---` delimiter is found, or the block is empty after the opening
// delimiter (handled by caller).
func splitFrontmatter(content string) (block, body string, ok bool) {
	// Strip a leading UTF-8 BOM if present (EF BB BF). Only the
	// frontmatter scan is BOM-stripped; the body is byte-exact, so a BOM
	// appearing inside the body is preserved verbatim.
	stripped := content
	if strings.HasPrefix(stripped, "\uFEFF") {
		stripped = stripped[len("\uFEFF"):]
	}
	// Trim leading horizontal whitespace and newlines before the
	// opening delimiter. We do NOT trim trailing — body must be exact.
	trimmed := strings.TrimLeft(stripped, "\r\n\t ")
	if !strings.HasPrefix(trimmed, "---") {
		return "", "", false
	}
	// The opening delimiter must be on its own line: `---` optionally
	// followed by a line terminator.
	rest := trimmed[3:]
	// The opening `---` must be followed by a newline or end-of-file
	// (a `---foo` prefix is not a valid delimiter).
	if len(rest) > 0 && rest[0] != '\n' && rest[0] != '\r' {
		return "", "", false
	}
	rest = strings.TrimLeft(rest, "\r\n")

	// Find the closing delimiter line. The closing `---` must be on its
	// own line: preceded by a newline (or at the start of `rest`) and
	// followed by a newline or end-of-file.
	idx := findClosingDelimiter(rest)
	if idx < 0 {
		return "", "", false
	}
	block = rest[:idx]
	// Body is everything after the closing `---` line. The closing
	// delimiter line itself (including its trailing newline) is NOT
	// part of the body. We compute the byte offset in the original
	// `content` so the body is byte-exact (R-015).
	afterClose := rest[idx:]
	// afterClose starts with `---`. Strip the delimiter line.
	delimLen := 3
	// Account for an optional trailing `...` or spaces on the delimiter
	// line — the delimiter is `---` followed by anything up to and
	// including the line terminator. Body starts after the newline.
	// Find the end of the delimiter line.
	newlineIdx := strings.IndexAny(afterClose, "\r\n")
	var bodyStart int
	if newlineIdx < 0 {
		// Closing `---` is the last line: body is empty.
		bodyStart = len(afterClose)
	} else {
		// Consume the delimiter line and its line terminator(s).
		bodyStart = newlineIdx
		// Strip a single CRLF or LF.
		if strings.HasPrefix(afterClose[bodyStart:], "\r\n") {
			bodyStart += 2
		} else {
			bodyStart += 1
		}
	}
	body = afterClose[bodyStart:]
	_ = delimLen
	return block, body, true
}

// findClosingDelimiter returns the byte index in `rest` where the
// closing `---` delimiter line begins, or -1 if none is found. The
// delimiter must be on its own line: either at the start of `rest` or
// preceded by a newline, and followed by a newline or end-of-file.
func findClosingDelimiter(rest string) int {
	// Special case: closing delimiter at the very start (frontmatter
	// block is empty). The opening `---` is immediately followed by the
	// closing `---`. We require the opening to be its own line, so the
	// closing at index 0 means the opening had no body — invalid (empty
	// frontmatter handled by caller). We still report it; caller
	// rejects empty block.
	for i := 0; i < len(rest); i++ {
		if rest[i] != '\n' {
			continue
		}
		// Candidate: line after this newline starts with `---`.
		j := i + 1
		if j+3 <= len(rest) && rest[j] == '-' && rest[j+1] == '-' && rest[j+2] == '-' {
			// Must be followed by newline, CRLF, or end-of-file.
			end := j + 3
			if end == len(rest) {
				return j
			}
			if rest[end] == '\n' || rest[end] == '\r' {
				return j
			}
		}
	}
	// Final candidate: closing delimiter at the very start of rest
	// (immediately after the opening delimiter + its newline). This
	// happens when frontmatter is empty: `---\n---\n`. We already trim
	// leading newlines off `rest`, so if rest itself starts with `---`
	// AND it's a closing delimiter (followed by newline/EOF), it is the
	// empty-frontmatter case.
	if strings.HasPrefix(rest, "---") {
		end := 3
		if end == len(rest) {
			return 0
		}
		if rest[end] == '\n' || rest[end] == '\r' {
			return 0
		}
	}
	return -1
}

// parseFrontmatterBlock parses a minimal YAML-ish frontmatter block into
// a *WorkloadSpec (without Body, which is filled by the caller).
//
// Supported shapes:
//
//	kind: Job
//	name: my-job
//	count: 3
//	runtime:
//	  one_of: process
//	  image: docker.io/nginx:latest
//	  command: /bin/sh -c
//	ports:
//	  - name: http
//	    port: 8080
//	    host_port: 80
//	    protocol: tcp
//	env:
//	  FOO: bar
//	  BAR:
//	    from: secret:my-secret
//	secrets:
//	  - db-password
//	volumes:
//	  - name: data
//	    type: host
//	    source: /data
//	    target: /data
//	    read_only: true
//
// Comments (# ...) and blank lines are ignored. Quoted scalar values
// ("..." or '...') are unwrapped. No flow collections except the
// inline-array form for `secrets`. Multi-line block scalars (|, >) are
// not supported — by design, to avoid adding a YAML dependency for this
// small surface.
func parseFrontmatterBlock(block string) (*WorkloadSpec, error) {
	// Count defaults to 1 for Job/Service and 0 for DaemonSet. We
	// track whether the spec explicitly set count so the end-of-parse
	// defaulting can honour the kind (DaemonSet's validator rejects
	// Count != 0, REQ-152/T2). countSet flips true on the first
	// `count:` key seen.
	spec := &WorkloadSpec{}
	var countSet bool
	lines := strings.Split(block, "\n")

	type section int
	const (
		secNone section = iota
		secRuntime
		secPorts
		secEnv
		secSecrets
		secVolumes
		secRestart
		secUpdate
		secService
		secHealth
		secLifecycle
		secAffinity
		secConstraints
		secTasks
		secTaskEnv
		secTaskRuntime
		secSchedule
	)
	cur := secNone
	var curPort *PortSpec
	var curVol *VolumeSpec
	var curAffinity *AffinityRule
	var lifecycleCur string
	var curTask *TaskGroupTask
	var taskIndent int
	var taskFieldIndent int

	flushPort := func() {
		if curPort != nil {
			spec.Ports = append(spec.Ports, *curPort)
			curPort = nil
		}
	}
	flushVol := func() {
		if curVol != nil {
			spec.Volumes = append(spec.Volumes, *curVol)
			curVol = nil
		}
	}
	flushAffinity := func() {
		if curAffinity != nil {
			spec.Affinity = append(spec.Affinity, *curAffinity)
			curAffinity = nil
		}
	}
	flushTask := func() {
		if curTask != nil {
			spec.Tasks = append(spec.Tasks, *curTask)
			curTask = nil
		}
	}
	// taskSubBlock returns the sub-section to switch to when the
	// given `key: value` line opens a nested block under a task
	// (`env:` → secTaskEnv, `runtime:` → secTaskRuntime). Returns
	// secTasks for non-block keys (no switch).
	taskSubBlock := func(kvLine string) section {
		key, _, ok := splitKV(kvLine)
		if !ok {
			return secTasks
		}
		switch key {
		case "env":
			return secTaskEnv
		case "runtime":
			return secTaskRuntime
		}
		return secTasks
	}

	for lineNo, raw := range lines {
		line := stripComment(raw)
		if strings.TrimSpace(line) == "" {
			continue
		}
		indent := countIndent(line)
		trimmed := strings.TrimSpace(line)

		if indent == 0 {
			// Flush any pending nested entry before switching sections.
			flushPort()
			flushVol()
			flushAffinity()
			flushTask()
			cur = secNone

			key, val, ok := splitKV(trimmed)
			if !ok {
				return nil, fmt.Errorf("parse markdown: line %d: malformed key:value", lineNo+1)
			}
			switch key {
			case "orca-spec-version":
				spec.SpecVersion = unquote(val)
			case "kind":
				spec.Kind = unquote(val)
			case "name":
				spec.Name = unquote(val)
			case "count":
				if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
					spec.Count = n
					countSet = true
				} else {
					return nil, fmt.Errorf("parse markdown: line %d: count: %v", lineNo+1, err)
				}
			case "runtime":
				spec.Runtime = &RuntimeBlock{}
				if strings.TrimSpace(val) != "" {
					// Inline value (unusual); ignore — runtime is a block.
				}
				cur = secRuntime
			case "ports":
				cur = secPorts
			case "env":
				spec.Env = map[string]string{}
				cur = secEnv
			case "secrets":
				if strings.TrimSpace(val) != "" {
					arr, err := parseStringArray(val)
					if err != nil {
						return nil, fmt.Errorf("parse markdown: line %d: secrets: %w", lineNo+1, err)
					}
					spec.Secrets = append(spec.Secrets, arr...)
					cur = secNone
				} else {
					cur = secSecrets
				}
			case "volumes":
				cur = secVolumes
			case "restart":
				spec.Restart = &RestartBlock{}
				cur = secRestart
			case "update":
				spec.Update = &UpdateBlock{}
				cur = secUpdate
			case "service":
				spec.Service = &ServiceBlock{}
				cur = secService
			case "health":
				spec.Health = &HealthBlock{}
				cur = secHealth
			case "lifecycle":
				spec.Lifecycle = &LifecycleBlock{}
				cur = secLifecycle
			case "constraints":
				if strings.TrimSpace(val) != "" {
					arr, err := parseStringArray(val)
					if err != nil {
						return nil, fmt.Errorf("parse markdown: line %d: constraints: %w", lineNo+1, err)
					}
					spec.Constraints = append(spec.Constraints, arr...)
					cur = secNone
				} else {
					cur = secConstraints
				}
			case "affinity":
				if strings.TrimSpace(val) != "" {
					// Inline form not supported for affinity objects;
					// require the block form. Ignore inline values.
					cur = secNone
				} else {
					cur = secAffinity
				}
			case "schedule":
				spec.Schedule = &ScheduleBlock{}
				if strings.TrimSpace(val) != "" {
					// Inline value (unusual); ignore — schedule is a block.
				}
				cur = secSchedule
			case "timeout":
				spec.Timeout = unquote(val)
				cur = secNone
			case "tasks":
				cur = secTasks
				taskIndent = -1
				taskFieldIndent = -1
			default:
				// Unknown top-level key are ignored (forward-compat).
				cur = secNone
			}
			continue
		}

		// Indented line: a nested entry under the current section.
		switch cur {
		case secRuntime:
			if spec.Runtime == nil {
				spec.Runtime = &RuntimeBlock{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "one_of":
				spec.Runtime.OneOf = unquote(val)
			case "image":
				spec.Runtime.Image = unquote(val)
			case "command":
				spec.Runtime.Command = unquote(val)
			}
		case secPorts:
			if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
				flushPort()
				p := PortSpec{}
				curPort = &p
				rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
				if rest != "" {
					applyPortKV(curPort, rest)
				}
			} else if curPort != nil {
				applyPortKV(curPort, trimmed)
			}
		case secEnv:
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			if val == "" {
				// Nested mapping under env (e.g. `BAR:\n  from: ...`).
				// Store the raw string for now (REQ-064: store raw).
				spec.Env[key] = ""
			} else if strings.HasPrefix(val, "{") && strings.HasSuffix(val, "}") {
				// Inline object form: `BAR: {from: "secret:..."}`.
				// Store the raw object string for now.
				spec.Env[key] = val
			} else {
				spec.Env[key] = unquote(val)
			}
		case secSecrets:
			if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
				item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
				if item != "" {
					spec.Secrets = append(spec.Secrets, unquote(item))
				}
			}
		case secVolumes:
			if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
				flushVol()
				v := VolumeSpec{}
				curVol = &v
				rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
				if rest != "" {
					applyVolumeKV(curVol, rest)
				}
			} else if curVol != nil {
				applyVolumeKV(curVol, trimmed)
			}
		case secRestart:
			if spec.Restart == nil {
				spec.Restart = &RestartBlock{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "mode":
				spec.Restart.Mode = unquote(val)
			case "attempts", "max_retries":
				if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
					spec.Restart.MaxRetries = n
				}
			case "delay":
				spec.Restart.Delay = unquote(val)
			}
		case secUpdate:
			if spec.Update == nil {
				spec.Update = &UpdateBlock{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "strategy":
				spec.Update.Strategy = unquote(val)
			case "max_parallel":
				if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
					spec.Update.MaxParallel = n
				}
			case "max_surge":
				if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
					spec.Update.MaxSurge = n
				}
			case "min_healthy_time":
				spec.Update.MinHealthyTime = unquote(val)
			case "healthy_deadline":
				spec.Update.HealthyDeadline = unquote(val)
			case "canary":
				spec.Update.Canary = unquote(val)
			case "auto_promote":
				spec.Update.AutoPromote = parseBool(val)
			}
		case secService:
			if spec.Service == nil {
				spec.Service = &ServiceBlock{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "name":
				spec.Service.Name = unquote(val)
			case "port":
				if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
					spec.Service.Port = n
				}
			case "bind":
				spec.Service.Bind = unquote(val)
			case "host":
				spec.Service.Host = unquote(val)
			case "route_id":
				spec.Service.RouteID = unquote(val)
			}
		case secHealth:
			if spec.Health == nil {
				spec.Health = &HealthBlock{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "check_type":
				spec.Health.CheckType = unquote(val)
			case "interval":
				spec.Health.Interval = unquote(val)
			case "timeout":
				spec.Health.Timeout = unquote(val)
			case "unhealthy_threshold":
				if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
					spec.Health.UnhealthyThreshold = n
				}
			}
		case secLifecycle:
			if spec.Lifecycle == nil {
				spec.Lifecycle = &LifecycleBlock{}
			}
			// pre_stop / post_start are string arrays. The block form
			// is:
			//   lifecycle:
			//     pre_stop:
			//       - cmd1
			//       - cmd2
			//     post_start:
			//       - cmd3
			// We track which sub-list we are appending to via a local
			// cursor that is reset on every top-level section change.
			key, val, ok := splitKV(trimmed)
			if !ok {
				// Could be a list item under pre_stop/post_start.
				if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
					item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
					if item != "" && lifecycleCur != "" {
						appendLifecycleCmd(spec.Lifecycle, lifecycleCur, unquote(item))
					}
				}
				continue
			}
			switch key {
			case "pre_stop", "post_start":
				lifecycleCur = key
				if strings.TrimSpace(val) != "" {
					// Inline list form: `pre_stop: [cmd1, cmd2]`.
					arr, err := parseStringArray(val)
					if err == nil {
						for _, s := range arr {
							appendLifecycleCmd(spec.Lifecycle, key, s)
						}
					}
					lifecycleCur = ""
				}
			default:
				lifecycleCur = ""
			}
		case secAffinity:
			if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
				flushAffinity()
				r := AffinityRule{}
				curAffinity = &r
				rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
				if rest != "" {
					applyAffinityKV(curAffinity, rest)
				}
			} else if curAffinity != nil {
				applyAffinityKV(curAffinity, trimmed)
			}
		case secConstraints:
			if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
				item := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
				if item != "" {
					spec.Constraints = append(spec.Constraints, unquote(item))
				}
			}
		case secSchedule:
			if spec.Schedule == nil {
				spec.Schedule = &ScheduleBlock{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "mode":
				spec.Schedule.Mode = unquote(val)
			case "cron":
				spec.Schedule.Cron = unquote(val)
			}
		case secTasks:
			// Tasks is a list of task objects. A `- ` at the list
			// indent opens a new task; deeper-indented lines belong
			// to the current task's fields (name, command) or
			// nested sub-blocks (runtime, env).
			if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
				if taskIndent < 0 {
					taskIndent = indent
					taskFieldIndent = indent + 2
				}
				if indent == taskIndent {
					flushTask()
					t := TaskGroupTask{}
					curTask = &t
					rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
					if rest != "" {
						if applyTaskKV(curTask, rest) {
							cur = taskSubBlock(rest)
						}
					}
					continue
				}
			}
			if curTask != nil {
				if applyTaskKV(curTask, trimmed) {
					cur = taskSubBlock(trimmed)
				}
			}
		case secTaskEnv:
			if curTask == nil {
				cur = secTasks
				continue
			}
			// Pop back to the task field level when the indent
			// returns to taskFieldIndent (the next sibling
			// field or a new `- ` list item). The line is then
			// reprocessed as a task field.
			if taskFieldIndent > 0 && indent <= taskFieldIndent {
				cur = secTasks
				if indent == taskIndent && (strings.HasPrefix(trimmed, "- ") || trimmed == "-") {
					flushTask()
					t := TaskGroupTask{}
					curTask = &t
					rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
					if rest != "" {
						if applyTaskKV(curTask, rest) {
							cur = taskSubBlock(rest)
						}
					}
					continue
				}
				if applyTaskKV(curTask, trimmed) {
					cur = taskSubBlock(trimmed)
				}
				continue
			}
			if curTask.Env == nil {
				curTask.Env = map[string]string{}
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			if val == "" {
				curTask.Env[key] = ""
			} else if strings.HasPrefix(val, "{") && strings.HasSuffix(val, "}") {
				curTask.Env[key] = val
			} else {
				curTask.Env[key] = unquote(val)
			}
		case secTaskRuntime:
			if curTask == nil || curTask.Runtime == nil {
				cur = secTasks
				continue
			}
			// Pop back to the task field level (see secTaskEnv).
			if taskFieldIndent > 0 && indent <= taskFieldIndent {
				cur = secTasks
				if indent == taskIndent && (strings.HasPrefix(trimmed, "- ") || trimmed == "-") {
					flushTask()
					t := TaskGroupTask{}
					curTask = &t
					rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-"))
					if rest != "" {
						if applyTaskKV(curTask, rest) {
							cur = taskSubBlock(rest)
						}
					}
					continue
				}
				if applyTaskKV(curTask, trimmed) {
					cur = taskSubBlock(trimmed)
				}
				continue
			}
			key, val, ok := splitKV(trimmed)
			if !ok {
				continue
			}
			switch key {
			case "one_of":
				curTask.Runtime.OneOf = unquote(val)
			case "image":
				curTask.Runtime.Image = unquote(val)
			case "command":
				curTask.Runtime.Command = unquote(val)
			}
		}
	}
	flushPort()
	flushVol()
	flushAffinity()
	flushTask()
	// Count defaulting: 1 for Job/Service, 0 for DaemonSet. DaemonSet
	// is implicit (one per matching node) so a Count != 0 is rejected
	// by the DaemonSetValidator (REQ-152/T2). Only default when the
	// spec did not explicitly set count.
	if !countSet {
		switch spec.Kind {
		case "DaemonSet":
			spec.Count = 0
		default:
			spec.Count = 1
		}
	}
	return spec, nil
}

// applyPortKV applies a `key: value` pair to a PortSpec entry.
func applyPortKV(p *PortSpec, s string) {
	key, val, ok := splitKV(s)
	if !ok {
		return
	}
	switch key {
	case "name":
		p.Name = unquote(val)
	case "host_port":
		if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
			p.HostPort = n
		}
	case "port":
		if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
			p.Port = n
		}
	case "protocol":
		p.Protocol = unquote(val)
	case "host_ip":
		p.HostIP = unquote(val)
	}
}

// applyVolumeKV applies a `key: value` pair to a VolumeSpec entry.
func applyVolumeKV(v *VolumeSpec, s string) {
	key, val, ok := splitKV(s)
	if !ok {
		return
	}
	switch key {
	case "name":
		v.Name = unquote(val)
	case "type":
		v.Type = unquote(val)
	case "source":
		v.Source = unquote(val)
	case "target":
		v.Target = unquote(val)
	case "read_only":
		switch strings.ToLower(strings.TrimSpace(unquote(val))) {
		case "true", "yes", "on", "1":
			v.ReadOnly = true
		}
	}
}

// applyAffinityKV applies a `key: value` pair to an AffinityRule entry.
func applyAffinityKV(r *AffinityRule, s string) {
	key, val, ok := splitKV(s)
	if !ok {
		return
	}
	switch key {
	case "target":
		r.Target = unquote(val)
	case "weight":
		if n, err := strconv.Atoi(strings.TrimSpace(unquote(val))); err == nil {
			r.Weight = n
		}
	}
}

// applyTaskKV applies a `key: value` pair to the current TaskGroupTask.
// The returned bool reports whether the key opened a nested sub-block
// (`env` or `runtime`); when true the caller switches the parser
// section to the corresponding sub-block handler.
func applyTaskKV(t *TaskGroupTask, s string) (openedSubBlock bool) {
	key, val, ok := splitKV(s)
	if !ok {
		return false
	}
	switch key {
	case "name":
		t.Name = unquote(val)
	case "command":
		t.Command = unquote(val)
	case "env":
		if t.Env == nil {
			t.Env = map[string]string{}
		}
		return true
	case "runtime":
		if t.Runtime == nil {
			t.Runtime = &RuntimeBlock{}
		}
		return true
	}
	return false
}

// appendLifecycleCmd appends a command to the named lifecycle hook list
// (pre_stop or post_start) on the given LifecycleBlock.
func appendLifecycleCmd(lb *LifecycleBlock, name, cmd string) {
	if lb == nil || cmd == "" {
		return
	}
	switch name {
	case "pre_stop":
		lb.PreStop = append(lb.PreStop, cmd)
	case "post_start":
		lb.PostStart = append(lb.PostStart, cmd)
	}
}

// parseBool parses a YAML-ish boolean value (true/yes/on/1 → true). The
// comparison is case-insensitive. Empty and unrecognized values return
// false (forward-compatible with future strict-mode validation).
func parseBool(s string) bool {
	switch strings.ToLower(strings.TrimSpace(unquote(s))) {
	case "true", "yes", "on", "1":
		return true
	}
	return false
}

// validateWorkload enforces required fields and kind validity (R-012).
func validateWorkload(spec *WorkloadSpec) error {
	if spec.Kind == "" {
		return fmt.Errorf("parse markdown: missing kind")
	}
	if !validKinds[spec.Kind] {
		return fmt.Errorf("parse markdown: kind %q is not one of Job, Service, DaemonSet", spec.Kind)
	}
	if strings.TrimSpace(spec.Name) == "" {
		return fmt.Errorf("parse markdown: missing name")
	}
	return nil
}

// parseStringArray parses an inline YAML flow-array of scalars, e.g.
// `["a", "b"]` or `['a', 'b']` or `[a, b]`. Empty array `[]` returns nil.
func parseStringArray(val string) ([]string, error) {
	val = strings.TrimSpace(val)
	if val == "" {
		return nil, nil
	}
	if !strings.HasPrefix(val, "[") || !strings.HasSuffix(val, "]") {
		return nil, fmt.Errorf("expected [..] array, got %q", val)
	}
	inner := strings.TrimSpace(val[1 : len(val)-1])
	if inner == "" {
		return nil, nil
	}
	parts := splitFlowItems(inner)
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		out = append(out, unquote(p))
	}
	return out, nil
}

// splitFlowItems splits a comma-separated flow-array body, respecting
// single and double quotes.
func splitFlowItems(s string) []string {
	var out []string
	inSingle := false
	inDouble := false
	start := 0
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch c {
		case '\'':
			if !inDouble {
				inSingle = !inSingle
			}
		case '"':
			if !inSingle {
				inDouble = !inDouble
			}
		case ',':
			if !inSingle && !inDouble {
				out = append(out, s[start:i])
				start = i + 1
			}
		}
	}
	out = append(out, s[start:])
	return out
}

func countIndent(s string) int {
	n := 0
	for _, r := range s {
		if r == ' ' || r == '\t' {
			n++
			continue
		}
		break
	}
	return n
}

func splitKV(s string) (key, val string, ok bool) {
	idx := strings.Index(s, ":")
	if idx < 0 {
		return "", "", false
	}
	key = strings.TrimSpace(s[:idx])
	val = strings.TrimSpace(s[idx+1:])
	if key == "" {
		return "", "", false
	}
	return key, val, true
}

func stripComment(s string) string {
	inSingle := false
	inDouble := false
	for i := 0; i < len(s); i++ {
		c := s[i]
		switch c {
		case '\'':
			if !inDouble {
				inSingle = !inSingle
			}
		case '"':
			if !inSingle {
				inDouble = !inDouble
			}
		case '#':
			if !inSingle && !inDouble {
				if i == 0 || s[i-1] == ' ' || s[i-1] == '\t' {
					return s[:i]
				}
			}
		}
	}
	return s
}

func unquote(s string) string {
	s = strings.TrimSpace(s)
	if len(s) >= 2 {
		if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
			return s[1 : len(s)-1]
		}
	}
	return s
}
