package jobspec

import (
	"strings"
	"testing"
)

func TestParseMarkdown_FullFrontmatter(t *testing.T) {
	body := "# Hello\n\nThis is the body.\n\nTrailing newline preserved.\n"
	input := "---\n" +
		"orca-spec-version: \"1\"\n" +
		"kind: Job\n" +
		"name: my-job\n" +
		"count: 3\n" +
		"---\n" +
		body
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.SpecVersion != "1" {
		t.Errorf("SpecVersion = %q, want %q", spec.SpecVersion, "1")
	}
	if spec.Kind != "Job" {
		t.Errorf("Kind = %q, want %q", spec.Kind, "Job")
	}
	if spec.Name != "my-job" {
		t.Errorf("Name = %q, want %q", spec.Name, "my-job")
	}
	if spec.Count != 3 {
		t.Errorf("Count = %d, want 3", spec.Count)
	}
	if spec.Body != body {
		t.Errorf("Body = %q, want %q (byte-exact, R-015)", spec.Body, body)
	}
}

func TestParseMarkdown_BodyByteExactTrailingNewline(t *testing.T) {
	cases := []struct {
		name string
		body string
	}{
		{"with_trailing_newline", "# Title\n\nbody\n"},
		{"with_double_trailing_newline", "# Title\n\nbody\n\n"},
		{"no_trailing_newline", "# Title\n\nbody"},
		{"empty_body_with_newline", "\n"},
		{"only_newlines", "\n\n\n"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			input := "---\nkind: Job\nname: x\n---\n" + tc.body
			spec, err := ParseMarkdown([]byte(input))
			if err != nil {
				t.Fatalf("ParseMarkdown: %v", err)
			}
			if spec.Body != tc.body {
				t.Errorf("Body byte-exact mismatch (R-015):\n got = %q\nwant = %q", spec.Body, tc.body)
			}
		})
	}
}

func TestParseMarkdown_NoFrontmatter(t *testing.T) {
	input := "# Just a body\n\nNo frontmatter here."
	_, err := ParseMarkdown([]byte(input))
	if err == nil {
		t.Fatal("expected error for missing frontmatter, got nil")
	}
	if !strings.Contains(err.Error(), "frontmatter") {
		t.Errorf("error = %q, want it to contain 'frontmatter'", err.Error())
	}
}

func TestParseMarkdown_EmptyFrontmatter(t *testing.T) {
	input := "---\n---\n\nbody"
	_, err := ParseMarkdown([]byte(input))
	if err == nil {
		t.Fatal("expected error for empty frontmatter, got nil")
	}
	if !strings.Contains(err.Error(), "empty frontmatter") {
		t.Errorf("error = %q, want it to contain 'empty frontmatter'", err.Error())
	}
}

func TestParseMarkdown_UnknownKind(t *testing.T) {
	input := "---\nkind: CronJob\nname: x\n---\nbody\n"
	_, err := ParseMarkdown([]byte(input))
	if err == nil {
		t.Fatal("expected error for unknown kind, got nil")
	}
	if !strings.Contains(err.Error(), "not one of") {
		t.Errorf("error = %q, want it to contain 'not one of'", err.Error())
	}
}

func TestParseMarkdown_MissingName(t *testing.T) {
	input := "---\nkind: Job\n---\nbody\n"
	_, err := ParseMarkdown([]byte(input))
	if err == nil {
		t.Fatal("expected error for missing name, got nil")
	}
	if !strings.Contains(err.Error(), "missing name") {
		t.Errorf("error = %q, want it to contain 'missing name'", err.Error())
	}
}

func TestParseMarkdown_MissingKind(t *testing.T) {
	input := "---\nname: x\n---\nbody\n"
	_, err := ParseMarkdown([]byte(input))
	if err == nil {
		t.Fatal("expected error for missing kind, got nil")
	}
	if !strings.Contains(err.Error(), "missing kind") {
		t.Errorf("error = %q, want it to contain 'missing kind'", err.Error())
	}
}

func TestParseMarkdown_EachValidKind(t *testing.T) {
	cases := []string{"Job", "Service", "DaemonSet"}
	for _, kind := range cases {
		t.Run(kind, func(t *testing.T) {
			input := "---\nkind: " + kind + "\nname: x\n---\nbody\n"
			spec, err := ParseMarkdown([]byte(input))
			if err != nil {
				t.Fatalf("ParseMarkdown: %v", err)
			}
			if spec.Kind != kind {
				t.Errorf("Kind = %q, want %q", spec.Kind, kind)
			}
		})
	}
}

func TestParseMarkdown_EnvScalarAndObject(t *testing.T) {
	input := "---\n" +
		"kind: Job\n" +
		"name: x\n" +
		"env:\n" +
		"  FOO: bar\n" +
		"  BAZ: \"qux\"\n" +
		"  SECRET_REF:\n" +
		"    from: \"secret:db-password\"\n" +
		"  INLINE: {from: \"secret:token\"}\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if got := spec.Env["FOO"]; got != "bar" {
		t.Errorf("env[FOO] = %q, want %q", got, "bar")
	}
	if got := spec.Env["BAZ"]; got != "qux" {
		t.Errorf("env[BAZ] = %q, want %q", got, "qux")
	}
	if got := spec.Env["INLINE"]; got != `{from: "secret:token"}` {
		t.Errorf("env[INLINE] = %q, want the raw object string", got)
	}
	if _, ok := spec.Env["SECRET_REF"]; !ok {
		t.Errorf("env[SECRET_REF] missing; nested from: stored as empty string")
	}
}

func TestParseMarkdown_PortsArray(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"ports:\n" +
		"  - name: http\n" +
		"    port: 8080\n" +
		"    host_port: 80\n" +
		"    protocol: tcp\n" +
		"  - name: https\n" +
		"    port: 8443\n" +
		"    host_port: 443\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Ports) != 2 {
		t.Fatalf("Ports = %d, want 2", len(spec.Ports))
	}
	if spec.Ports[0].Name != "http" || spec.Ports[0].Port != 8080 || spec.Ports[0].HostPort != 80 || spec.Ports[0].Protocol != "tcp" {
		t.Errorf("Ports[0] = %+v", spec.Ports[0])
	}
	if spec.Ports[1].Name != "https" || spec.Ports[1].Port != 8443 || spec.Ports[1].HostPort != 443 {
		t.Errorf("Ports[1] = %+v", spec.Ports[1])
	}
}

func TestParseMarkdown_VolumesArray(t *testing.T) {
	input := "---\n" +
		"kind: Job\n" +
		"name: x\n" +
		"volumes:\n" +
		"  - name: data\n" +
		"    type: host\n" +
		"    source: /data\n" +
		"    target: /data\n" +
		"    read_only: true\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Volumes) != 1 {
		t.Fatalf("Volumes = %d, want 1", len(spec.Volumes))
	}
	v := spec.Volumes[0]
	if v.Name != "data" || v.Type != "host" || v.Source != "/data" || v.Target != "/data" || !v.ReadOnly {
		t.Errorf("Volumes[0] = %+v", v)
	}
}

func TestParseMarkdown_RuntimeBlock(t *testing.T) {
	input := "---\n" +
		"kind: Job\n" +
		"name: x\n" +
		"runtime:\n" +
		"  one_of: process\n" +
		"  image: docker.io/nginx:latest\n" +
		"  command: /bin/sh -c 'echo hi'\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Runtime == nil {
		t.Fatal("Runtime is nil")
	}
	if spec.Runtime.OneOf != "process" {
		t.Errorf("Runtime.OneOf = %q, want %q", spec.Runtime.OneOf, "process")
	}
	if spec.Runtime.Image != "docker.io/nginx:latest" {
		t.Errorf("Runtime.Image = %q, want %q", spec.Runtime.Image, "docker.io/nginx:latest")
	}
	if spec.Runtime.Command != "/bin/sh -c 'echo hi'" {
		t.Errorf("Runtime.Command = %q, want %q", spec.Runtime.Command, "/bin/sh -c 'echo hi'")
	}
}

func TestParseMarkdown_SecretsInlineArray(t *testing.T) {
	input := "---\nkind: Job\nname: x\nsecrets: [\"db-password\", \"api-token\"]\n---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Secrets) != 2 {
		t.Fatalf("Secrets = %d, want 2", len(spec.Secrets))
	}
	if spec.Secrets[0] != "db-password" || spec.Secrets[1] != "api-token" {
		t.Errorf("Secrets = %v, want [db-password api-token]", spec.Secrets)
	}
}

func TestParseMarkdown_SecretsBlockArray(t *testing.T) {
	input := "---\n" +
		"kind: Job\n" +
		"name: x\n" +
		"secrets:\n" +
		"  - db-password\n" +
		"  - api-token\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Secrets) != 2 {
		t.Fatalf("Secrets = %d, want 2", len(spec.Secrets))
	}
	if spec.Secrets[0] != "db-password" || spec.Secrets[1] != "api-token" {
		t.Errorf("Secrets = %v, want [db-password api-token]", spec.Secrets)
	}
}

func TestParseMarkdown_CRLFBodyPreserved(t *testing.T) {
	body := "# Title\r\n\r\nCRLF body.\r\n"
	input := "---\r\nkind: Job\r\nname: x\r\n---\r\n" + body
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Body != body {
		t.Errorf("CRLF body not preserved (R-015):\n got = %q\nwant = %q", spec.Body, body)
	}
}

func TestParseMarkdown_BOMStrippedFromFrontmatter(t *testing.T) {
	body := "# body\n"
	input := "\uFEFF" + "---\nkind: Job\nname: x\n---\n" + body
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Kind != "Job" {
		t.Errorf("Kind = %q, want Job (BOM should be stripped from frontmatter scan)", spec.Kind)
	}
	if spec.Body != body {
		t.Errorf("Body = %q, want %q", spec.Body, body)
	}
}

func TestParseMarkdown_BodyWithCodeFenceContainingDashes(t *testing.T) {
	body := "```bash\n" +
		"echo '---'\n" +
		"echo '--- end ---'\n" +
		"```\n"
	input := "---\nkind: Job\nname: x\n---\n" + body
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Body != body {
		t.Errorf("Body with code-fence --- not preserved (R-015):\n got = %q\nwant = %q", spec.Body, body)
	}
}

func TestParseMarkdown_OnlyClosingSeparator(t *testing.T) {
	input := "no opening\n---\nbody\n"
	_, err := ParseMarkdown([]byte(input))
	if err == nil {
		t.Fatal("expected error for input with only closing separator, got nil")
	}
}

func TestParseMarkdown_QuotedValues(t *testing.T) {
	input := "---\nkind: \"Job\"\nname: 'my-job'\n---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Kind != "Job" {
		t.Errorf("Kind = %q, want Job (double-quoted)", spec.Kind)
	}
	if spec.Name != "my-job" {
		t.Errorf("Name = %q, want my-job (single-quoted)", spec.Name)
	}
}

func TestParseMarkdown_CountDefault(t *testing.T) {
	input := "---\nkind: Job\nname: x\n---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Count != 1 {
		t.Errorf("Count default = %d, want 1", spec.Count)
	}
}

func TestParseMarkdown_UnknownKeyIgnored(t *testing.T) {
	input := "---\nkind: Job\nname: x\nfuture_field: value\n---\nbody\n"
	_, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown should ignore unknown keys: %v", err)
	}
}

func TestParseMarkdown_RestartBlock(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"restart:\n" +
		"  mode: service\n" +
		"  attempts: 5\n" +
		"  delay: 3s\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Restart == nil {
		t.Fatal("Restart is nil")
	}
	if spec.Restart.Mode != "service" {
		t.Errorf("Restart.Mode = %q, want service", spec.Restart.Mode)
	}
	if spec.Restart.MaxRetries != 5 {
		t.Errorf("Restart.MaxRetries = %d, want 5", spec.Restart.MaxRetries)
	}
	if spec.Restart.Delay != "3s" {
		t.Errorf("Restart.Delay = %q, want 3s", spec.Restart.Delay)
	}
}

func TestParseMarkdown_RestartBlockMaxRetriesAlias(t *testing.T) {
	// max_retries is the canonical key; attempts is an accepted alias.
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"restart:\n" +
		"  mode: on-failure\n" +
		"  max_retries: 3\n" +
		"  delay: 1s\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Restart == nil || spec.Restart.MaxRetries != 3 {
		t.Fatalf("Restart.MaxRetries = %d, want 3 (max_retries alias)", spec.Restart.MaxRetries)
	}
}

func TestParseMarkdown_UpdateBlock(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"update:\n" +
		"  strategy: canary\n" +
		"  max_parallel: 2\n" +
		"  min_healthy_time: 30s\n" +
		"  healthy_deadline: 5m\n" +
		"  canary: 10%\n" +
		"  auto_promote: true\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Update == nil {
		t.Fatal("Update is nil")
	}
	if spec.Update.Strategy != "canary" {
		t.Errorf("Update.Strategy = %q, want canary", spec.Update.Strategy)
	}
	if spec.Update.MaxParallel != 2 {
		t.Errorf("Update.MaxParallel = %d, want 2", spec.Update.MaxParallel)
	}
	if spec.Update.MinHealthyTime != "30s" {
		t.Errorf("Update.MinHealthyTime = %q, want 30s", spec.Update.MinHealthyTime)
	}
	if spec.Update.HealthyDeadline != "5m" {
		t.Errorf("Update.HealthyDeadline = %q, want 5m", spec.Update.HealthyDeadline)
	}
	if spec.Update.Canary != "10%" {
		t.Errorf("Update.Canary = %q, want 10%%", spec.Update.Canary)
	}
	if !spec.Update.AutoPromote {
		t.Errorf("Update.AutoPromote = false, want true")
	}
}

func TestParseMarkdown_ServiceBlock(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"service:\n" +
		"  name: web\n" +
		"  port: 8080\n" +
		"  bind: 127.0.0.1\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Service == nil {
		t.Fatal("Service is nil")
	}
	if spec.Service.Name != "web" {
		t.Errorf("Service.Name = %q, want web", spec.Service.Name)
	}
	if spec.Service.Port != 8080 {
		t.Errorf("Service.Port = %d, want 8080", spec.Service.Port)
	}
	if spec.Service.Bind != "127.0.0.1" {
		t.Errorf("Service.Bind = %q, want 127.0.0.1", spec.Service.Bind)
	}
}

func TestParseMarkdown_HealthBlock(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"health:\n" +
		"  check_type: http\n" +
		"  interval: 10s\n" +
		"  timeout: 2s\n" +
		"  unhealthy_threshold: 3\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Health == nil {
		t.Fatal("Health is nil")
	}
	if spec.Health.CheckType != "http" {
		t.Errorf("Health.CheckType = %q, want http", spec.Health.CheckType)
	}
	if spec.Health.Interval != "10s" {
		t.Errorf("Health.Interval = %q, want 10s", spec.Health.Interval)
	}
	if spec.Health.Timeout != "2s" {
		t.Errorf("Health.Timeout = %q, want 2s", spec.Health.Timeout)
	}
	if spec.Health.UnhealthyThreshold != 3 {
		t.Errorf("Health.UnhealthyThreshold = %d, want 3", spec.Health.UnhealthyThreshold)
	}
}

func TestParseMarkdown_ConstraintsInlineArray(t *testing.T) {
	// Inline flow-array form: the parser does NOT unescape YAML
	// escapes (consistent with the secrets inline parser). Use
	// single-quoted scalars inside the flow array so the CEL strings
	// are preserved verbatim.
	input := "---\nkind: Service\nname: web\nconstraints: ['node.role == \"web\"', 'region == \"us\"']\n---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Constraints) != 2 {
		t.Fatalf("Constraints = %d, want 2", len(spec.Constraints))
	}
	if spec.Constraints[0] != `node.role == "web"` {
		t.Errorf("Constraints[0] = %q", spec.Constraints[0])
	}
	if spec.Constraints[1] != `region == "us"` {
		t.Errorf("Constraints[1] = %q", spec.Constraints[1])
	}
}

func TestParseMarkdown_ConstraintsBlockArray(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"constraints:\n" +
		"  - node.role == \"web\"\n" +
		"  - region == \"us\"\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Constraints) != 2 {
		t.Fatalf("Constraints = %d, want 2", len(spec.Constraints))
	}
	if spec.Constraints[0] != `node.role == "web"` {
		t.Errorf("Constraints[0] = %q", spec.Constraints[0])
	}
	if spec.Constraints[1] != `region == "us"` {
		t.Errorf("Constraints[1] = %q", spec.Constraints[1])
	}
}

func TestParseMarkdown_AffinityBlock(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"affinity:\n" +
		"  - target: node.role == \"web\"\n" +
		"    weight: 100\n" +
		"  - target: region == \"us\"\n" +
		"    weight: 50\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Affinity) != 2 {
		t.Fatalf("Affinity = %d, want 2", len(spec.Affinity))
	}
	if spec.Affinity[0].Target != `node.role == "web"` {
		t.Errorf("Affinity[0].Target = %q", spec.Affinity[0].Target)
	}
	if spec.Affinity[0].Weight != 100 {
		t.Errorf("Affinity[0].Weight = %d, want 100", spec.Affinity[0].Weight)
	}
	if spec.Affinity[1].Target != `region == "us"` {
		t.Errorf("Affinity[1].Target = %q", spec.Affinity[1].Target)
	}
	if spec.Affinity[1].Weight != 50 {
		t.Errorf("Affinity[1].Weight = %d, want 50", spec.Affinity[1].Weight)
	}
}

func TestParseMarkdown_LifecycleBlock(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"lifecycle:\n" +
		"  pre_stop:\n" +
		"    - /bin/sh -c 'sleep 5'\n" +
		"    - /usr/local/bin/drain.sh\n" +
		"  post_start:\n" +
		"    - /usr/local/bin/warm-cache.sh\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Lifecycle == nil {
		t.Fatal("Lifecycle is nil")
	}
	if len(spec.Lifecycle.PreStop) != 2 {
		t.Fatalf("PreStop = %d, want 2", len(spec.Lifecycle.PreStop))
	}
	if spec.Lifecycle.PreStop[0] != "/bin/sh -c 'sleep 5'" {
		t.Errorf("PreStop[0] = %q", spec.Lifecycle.PreStop[0])
	}
	if spec.Lifecycle.PreStop[1] != "/usr/local/bin/drain.sh" {
		t.Errorf("PreStop[1] = %q", spec.Lifecycle.PreStop[1])
	}
	if len(spec.Lifecycle.PostStart) != 1 {
		t.Fatalf("PostStart = %d, want 1", len(spec.Lifecycle.PostStart))
	}
	if spec.Lifecycle.PostStart[0] != "/usr/local/bin/warm-cache.sh" {
		t.Errorf("PostStart[0] = %q", spec.Lifecycle.PostStart[0])
	}
}

func TestParseMarkdown_LifecycleInlineArray(t *testing.T) {
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"lifecycle:\n" +
		"  pre_stop: [\"/bin/true\"]\n" +
		"  post_start: [\"/bin/warmup\", \"/bin/check\"]\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Lifecycle == nil {
		t.Fatal("Lifecycle is nil")
	}
	if len(spec.Lifecycle.PreStop) != 1 || spec.Lifecycle.PreStop[0] != "/bin/true" {
		t.Errorf("PreStop = %v, want [/bin/true]", spec.Lifecycle.PreStop)
	}
	if len(spec.Lifecycle.PostStart) != 2 {
		t.Fatalf("PostStart = %v, want 2 entries", spec.Lifecycle.PostStart)
	}
	if spec.Lifecycle.PostStart[0] != "/bin/warmup" || spec.Lifecycle.PostStart[1] != "/bin/check" {
		t.Errorf("PostStart = %v, want [/bin/warmup /bin/check]", spec.Lifecycle.PostStart)
	}
}

func TestParseMarkdown_FullServiceSpec(t *testing.T) {
	// A complete Service spec exercising every P02-parsed block together.
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"count: 3\n" +
		"runtime:\n" +
		"  one_of: process\n" +
		"  command: /usr/bin/httpd\n" +
		"ports:\n" +
		"  - name: http\n" +
		"    port: 8080\n" +
		"restart:\n" +
		"  mode: service\n" +
		"  attempts: 5\n" +
		"  delay: 2s\n" +
		"update:\n" +
		"  strategy: rolling\n" +
		"  max_parallel: 1\n" +
		"  auto_promote: false\n" +
		"service:\n" +
		"  name: web\n" +
		"  port: 8080\n" +
		"health:\n" +
		"  check_type: http\n" +
		"  interval: 5s\n" +
		"  timeout: 1s\n" +
		"  unhealthy_threshold: 2\n" +
		"constraints:\n" +
		"  - node.role == \"web\"\n" +
		"affinity:\n" +
		"  - target: zone == \"a\"\n" +
		"    weight: 80\n" +
		"lifecycle:\n" +
		"  post_start:\n" +
		"    - /bin/ready.sh\n" +
		"---\n# body\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Restart == nil || spec.Restart.Mode != "service" {
		t.Errorf("Restart not parsed: %+v", spec.Restart)
	}
	if spec.Update == nil || spec.Update.Strategy != "rolling" {
		t.Errorf("Update not parsed: %+v", spec.Update)
	}
	if spec.Service == nil || spec.Service.Port != 8080 {
		t.Errorf("Service not parsed: %+v", spec.Service)
	}
	if spec.Health == nil || spec.Health.CheckType != "http" {
		t.Errorf("Health not parsed: %+v", spec.Health)
	}
	if len(spec.Constraints) != 1 {
		t.Errorf("Constraints = %v", spec.Constraints)
	}
	if len(spec.Affinity) != 1 || spec.Affinity[0].Weight != 80 {
		t.Errorf("Affinity = %v", spec.Affinity)
	}
	if spec.Lifecycle == nil || len(spec.Lifecycle.PostStart) != 1 {
		t.Errorf("Lifecycle not parsed: %+v", spec.Lifecycle)
	}
	if spec.Body != "# body\n" {
		t.Errorf("Body = %q, want %q (R-015)", spec.Body, "# body\n")
	}
}

func TestParseMarkdown_TasksBlock(t *testing.T) {
	// P06: a task group with two tasks, each carrying its own runtime
	// and command. The parser must populate spec.Tasks with two
	// entries preserving name, runtime (one_of/image/command), and
	// the task-level command.
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"tasks:\n" +
		"  - name: app\n" +
		"    runtime:\n" +
		"      one_of: process\n" +
		"      image: docker.io/nginx:latest\n" +
		"      command: /usr/bin/httpd -f\n" +
		"    command: /usr/bin/httpd -f\n" +
		"  - name: sidecar\n" +
		"    runtime:\n" +
		"      one_of: wasm\n" +
		"      command: /bin/wasm-runner sidecar.wasm\n" +
		"    command: /bin/wasm-runner sidecar.wasm\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Tasks) != 2 {
		t.Fatalf("Tasks = %d, want 2", len(spec.Tasks))
	}
	app := spec.Tasks[0]
	if app.Name != "app" {
		t.Errorf("Tasks[0].Name = %q, want app", app.Name)
	}
	if app.Runtime == nil {
		t.Fatal("Tasks[0].Runtime is nil")
	}
	if app.Runtime.OneOf != "process" {
		t.Errorf("Tasks[0].Runtime.OneOf = %q, want process", app.Runtime.OneOf)
	}
	if app.Runtime.Image != "docker.io/nginx:latest" {
		t.Errorf("Tasks[0].Runtime.Image = %q", app.Runtime.Image)
	}
	if app.Runtime.Command != "/usr/bin/httpd -f" {
		t.Errorf("Tasks[0].Runtime.Command = %q", app.Runtime.Command)
	}
	if app.Command != "/usr/bin/httpd -f" {
		t.Errorf("Tasks[0].Command = %q", app.Command)
	}
	side := spec.Tasks[1]
	if side.Name != "sidecar" {
		t.Errorf("Tasks[1].Name = %q, want sidecar", side.Name)
	}
	if side.Runtime == nil || side.Runtime.OneOf != "wasm" {
		t.Errorf("Tasks[1].Runtime = %+v, want one_of=wasm", side.Runtime)
	}
	if side.Command != "/bin/wasm-runner sidecar.wasm" {
		t.Errorf("Tasks[1].Command = %q", side.Command)
	}
}

func TestParseMarkdown_TasksBlockWithEnv(t *testing.T) {
	// P06: a task group task carrying an env overlay.
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"tasks:\n" +
		"  - name: app\n" +
		"    command: /usr/bin/httpd\n" +
		"    env:\n" +
		"      LOG_LEVEL: debug\n" +
		"      REGION: us\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Tasks) != 1 {
		t.Fatalf("Tasks = %d, want 1", len(spec.Tasks))
	}
	task := spec.Tasks[0]
	if task.Env == nil {
		t.Fatal("Tasks[0].Env is nil")
	}
	if got := task.Env["LOG_LEVEL"]; got != "debug" {
		t.Errorf("Env[LOG_LEVEL] = %q, want debug", got)
	}
	if got := task.Env["REGION"]; got != "us" {
		t.Errorf("Env[REGION] = %q, want us", got)
	}
}

func TestParseMarkdown_TasksBlockInheritsTopLevelRuntime(t *testing.T) {
	// P06: when a task omits its own runtime, the top-level runtime
	// is the per-group default. The parser must NOT create a task
	// runtime when the task block lacks a `runtime:` sub-block; the
	// emitter/validator resolve the default from spec.Runtime.
	input := "---\n" +
		"kind: Service\n" +
		"name: web\n" +
		"runtime:\n" +
		"  one_of: process\n" +
		"  command: /bin/default\n" +
		"tasks:\n" +
		"  - name: app\n" +
		"    command: /bin/app\n" +
		"  - name: sidecar\n" +
		"    command: /bin/sidecar\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if spec.Runtime == nil || spec.Runtime.OneOf != "process" {
		t.Fatalf("top-level runtime not parsed: %+v", spec.Runtime)
	}
	if len(spec.Tasks) != 2 {
		t.Fatalf("Tasks = %d, want 2", len(spec.Tasks))
	}
	for i, task := range spec.Tasks {
		if task.Runtime != nil {
			t.Errorf("Tasks[%d].Runtime should be nil (inherit top-level), got %+v", i, task.Runtime)
		}
	}
	if spec.Tasks[0].Name != "app" || spec.Tasks[1].Name != "sidecar" {
		t.Errorf("task names = %q, %q", spec.Tasks[0].Name, spec.Tasks[1].Name)
	}
}

func TestParseMarkdown_NoTasksBackwardCompat(t *testing.T) {
	// Backward compat: a spec with no `tasks:` block parses as a
	// single-process alloc; spec.Tasks must be empty/nil.
	input := "---\n" +
		"kind: Job\n" +
		"name: backup\n" +
		"runtime:\n" +
		"  one_of: process\n" +
		"  command: /bin/rsync\n" +
		"---\nbody\n"
	spec, err := ParseMarkdown([]byte(input))
	if err != nil {
		t.Fatalf("ParseMarkdown: %v", err)
	}
	if len(spec.Tasks) != 0 {
		t.Fatalf("Tasks = %d, want 0 (backward compat)", len(spec.Tasks))
	}
	if spec.Runtime == nil || spec.Runtime.Command != "/bin/rsync" {
		t.Errorf("Runtime = %+v, want command=/bin/rsync", spec.Runtime)
	}
}
