package emitter

import (
	"strings"
	"testing"

	"git.cloudinit.dev/coreci/orca/internal/jobspec"
)

func TestSystemdEmitter_RenderJob(t *testing.T) {
	spec := &jobspec.WorkloadSpec{
		Kind:    "Job",
		Name:    "backup",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/bin/rsync -a /src /dst"},
	}
	node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
	files, err := SystemdEmitter{}.Render(spec, node)
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	if len(files) != 1 {
		t.Fatalf("got %d files, want 1", len(files))
	}
	f := files[0]
	wantPath := "/etc/systemd/system/orca-v1-backup.service"
	if f.Path != wantPath {
		t.Errorf("Path = %q, want %q", f.Path, wantPath)
	}
	wantContent := "[Service]\nExecStart=/usr/bin/rsync -a /src /dst\n"
	if f.Content != wantContent {
		t.Errorf("Content = %q, want %q", f.Content, wantContent)
	}
	if f.Mode != "0644" {
		t.Errorf("Mode = %q, want 0644", f.Mode)
	}
}

func TestSystemdEmitter_RenderService(t *testing.T) {
	// The full service emitter (Traefik route + health checks) lands in
	// P02; here we only prove the systemd side renders for a Service
	// kind with a process runtime.
	spec := &jobspec.WorkloadSpec{
		Kind:    "Service",
		Name:    "web",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/usr/local/bin/httpd -f"},
	}
	node := &Node{Hostname: "node-1", Runtime: []string{"process"}}
	files, err := SystemdEmitter{}.Render(spec, node)
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	if len(files) != 1 {
		t.Fatalf("got %d files, want 1", len(files))
	}
	if files[0].Path != "/etc/systemd/system/orca-v1-web.service" {
		t.Errorf("Path = %q, want /etc/systemd/system/orca-v1-web.service", files[0].Path)
	}
	if !strings.Contains(files[0].Content, "ExecStart=/usr/local/bin/httpd -f") {
		t.Errorf("Content = %q, want it to contain the ExecStart line", files[0].Content)
	}
}

func TestSystemdEmitter_EmptyCommandError(t *testing.T) {
	spec := &jobspec.WorkloadSpec{
		Kind:    "Job",
		Name:    "x",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: ""},
	}
	_, err := SystemdEmitter{}.Render(spec, &Node{})
	if err == nil {
		t.Fatal("expected error for empty command, got nil")
	}
	if !strings.Contains(err.Error(), "command is empty") {
		t.Errorf("error = %q, want 'command is empty'", err.Error())
	}
}

func TestSystemdEmitter_WhitespaceCommandError(t *testing.T) {
	spec := &jobspec.WorkloadSpec{
		Kind:    "Job",
		Name:    "x",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "   "},
	}
	_, err := SystemdEmitter{}.Render(spec, &Node{})
	if err == nil {
		t.Fatal("expected error for whitespace-only command, got nil")
	}
	if !strings.Contains(err.Error(), "command is empty") {
		t.Errorf("error = %q, want 'command is empty'", err.Error())
	}
}

func TestSystemdEmitter_NilSpec(t *testing.T) {
	v := SystemdEmitter{}
	if _, err := v.Render(nil, &Node{}); err == nil {
		t.Fatal("expected error for nil spec")
	}
}

func TestSystemdEmitter_EmptyName(t *testing.T) {
	spec := &jobspec.WorkloadSpec{
		Kind:    "Job",
		Name:    "  ",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/x"},
	}
	_, err := SystemdEmitter{}.Render(spec, &Node{})
	if err == nil {
		t.Fatal("expected error for empty name")
	}
	if !strings.Contains(err.Error(), "name is empty") {
		t.Errorf("error = %q, want 'name is empty'", err.Error())
	}
}

func TestSystemdEmitter_NilRuntime(t *testing.T) {
	spec := &jobspec.WorkloadSpec{Kind: "Job", Name: "x"}
	_, err := SystemdEmitter{}.Render(spec, &Node{})
	if err == nil {
		t.Fatal("expected error for nil runtime")
	}
	if !strings.Contains(err.Error(), "runtime block is nil") {
		t.Errorf("error = %q, want 'runtime block is nil'", err.Error())
	}
}

func TestSystemdEmitter_UnitNamePrefix(t *testing.T) {
	// The orca-v1- prefix is load-bearing for the dual-write window
	// (REQ-090, I-C-006): the v0.8 daemon writes `orca-<job>.service`
	// and the v0.9 SSH-push path writes `orca-v1-<spec.Name>.service`,
	// so the two never collide. This test guards against accidental
	// removal of the prefix.
	spec := &jobspec.WorkloadSpec{
		Kind:    "Job",
		Name:    "dual-write-safety",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/true"},
	}
	files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n"})
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	if !strings.HasPrefix(files[0].Path, "/etc/systemd/system/orca-v1-") {
		t.Errorf("Path = %q, want it to start with /etc/systemd/system/orca-v1- (REQ-090)", files[0].Path)
	}
	if !strings.HasSuffix(files[0].Path, ".service") {
		t.Errorf("Path = %q, want it to end with .service", files[0].Path)
	}
	// Explicitly assert the full expected unit name to lock the contract.
	want := "/etc/systemd/system/orca-v1-dual-write-safety.service"
	if files[0].Path != want {
		t.Errorf("Path = %q, want %q", files[0].Path, want)
	}
	// Sanity: the prefix is exactly "orca-v1-", not "orca-v0" or "orca".
	if unitNamePrefix != "orca-v1-" {
		t.Errorf("unitNamePrefix = %q, want orca-v1-", unitNamePrefix)
	}
}

func TestSystemdEmitter_TaskGroupTwoTasks(t *testing.T) {
	// P06: a task group with two tasks renders one unit per task plus
	// a grouping target unit. Each per-task unit is
	// `orca-v1-alloc-<alloc-id>-<task-name>.service`, carries
	// `PartOf=orca-v1-alloc-<alloc-id>.target`, and is
	// `WantedBy=multi-user.target`. The target unit lists every
	// per-task unit under Wants=.
	spec := &jobspec.WorkloadSpec{
		Kind: "Service",
		Name: "web",
		Tasks: []jobspec.TaskGroupTask{
			{
				Name:    "app",
				Command: "/usr/bin/httpd -f",
				Runtime: &jobspec.RuntimeBlock{OneOf: "process"},
			},
			{
				Name:    "sidecar",
				Command: "/bin/wasm-runner sidecar.wasm",
				Runtime: &jobspec.RuntimeBlock{OneOf: "wasm"},
			},
		},
	}
	files, err := SystemdEmitter{}.Render(spec, &Node{Hostname: "n1"})
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	// 2 per-task units + 1 target unit.
	if len(files) != 3 {
		t.Fatalf("got %d files, want 3 (2 per-task units + 1 target)", len(files))
	}
	wantApp := "/etc/systemd/system/orca-v1-alloc-web-app.service"
	wantSide := "/etc/systemd/system/orca-v1-alloc-web-sidecar.service"
	wantTarget := "/etc/systemd/system/orca-v1-alloc-web.target"
	paths := make(map[string]*File, len(files))
	for i := range files {
		paths[files[i].Path] = &files[i]
	}
	if _, ok := paths[wantApp]; !ok {
		t.Errorf("missing per-task unit %q; got paths %v", wantApp, filePaths(files))
	}
	if _, ok := paths[wantSide]; !ok {
		t.Errorf("missing per-task unit %q; got paths %v", wantSide, filePaths(files))
	}
	if _, ok := paths[wantTarget]; !ok {
		t.Errorf("missing target unit %q; got paths %v", wantTarget, filePaths(files))
	}
	if _, ok := paths[wantTarget]; !ok {
		t.Errorf("missing target unit %q; got paths %v", wantTarget, filePaths(files))
	}
	// Verify PartOf relations and ExecStart on per-task units.
	app := paths[wantApp]
	if !strings.Contains(app.Content, "PartOf=orca-v1-alloc-web.target") {
		t.Errorf("app unit missing PartOf=orca-v1-alloc-web.target\n%s", app.Content)
	}
	if !strings.Contains(app.Content, "ExecStart=/usr/bin/httpd -f") {
		t.Errorf("app unit missing ExecStart=/usr/bin/httpd -f\n%s", app.Content)
	}
	if !strings.Contains(app.Content, "WantedBy=multi-user.target") {
		t.Errorf("app unit missing WantedBy=multi-user.target\n%s", app.Content)
	}
	side := paths[wantSide]
	if !strings.Contains(side.Content, "PartOf=orca-v1-alloc-web.target") {
		t.Errorf("sidecar unit missing PartOf=orca-v1-alloc-web.target\n%s", side.Content)
	}
	if !strings.Contains(side.Content, "ExecStart=/bin/wasm-runner sidecar.wasm") {
		t.Errorf("sidecar unit missing ExecStart\n%s", side.Content)
	}
	// Verify the target unit Wants= both per-task units.
	target := paths[wantTarget]
	if !strings.Contains(target.Content, "Wants=orca-v1-alloc-web-app.service") {
		t.Errorf("target missing Wants=...app.service\n%s", target.Content)
	}
	if !strings.Contains(target.Content, "Wants=orca-v1-alloc-web-sidecar.service") {
		t.Errorf("target missing Wants=...sidecar.service\n%s", target.Content)
	}
}

func TestSystemdEmitter_TaskGroupInheritsTopLevelRuntime(t *testing.T) {
	// P06: a task that omits its own runtime inherits the top-level
	// spec.Runtime as the per-group default. The per-task unit's
	// ExecStart must come from the top-level runtime command when
	// the task has no own command and no own runtime.
	spec := &jobspec.WorkloadSpec{
		Kind:    "Service",
		Name:    "web",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/default"},
		Tasks: []jobspec.TaskGroupTask{
			{Name: "app"},
			{Name: "sidecar", Command: "/bin/override"},
		},
	}
	files, err := SystemdEmitter{}.Render(spec, &Node{})
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	if len(files) != 3 {
		t.Fatalf("got %d files, want 3", len(files))
	}
	appContent := findUnitContent(files, "/etc/systemd/system/orca-v1-alloc-web-app.service")
	if appContent == "" {
		t.Fatalf("missing app unit; paths %v", filePaths(files))
	}
	if !strings.Contains(appContent, "ExecStart=/bin/default") {
		t.Errorf("app unit should inherit top-level command /bin/default\n%s", appContent)
	}
	sideContent := findUnitContent(files, "/etc/systemd/system/orca-v1-alloc-web-sidecar.service")
	if sideContent == "" {
		t.Fatalf("missing sidecar unit; paths %v", filePaths(files))
	}
	if !strings.Contains(sideContent, "ExecStart=/bin/override") {
		t.Errorf("sidecar unit should use its own command /bin/override\n%s", sideContent)
	}
}

func TestSystemdEmitter_TaskGroupNoCommandError(t *testing.T) {
	// P06: a task with no resolvable command (no task.Command, no
	// task.Runtime, no top-level Runtime) is an error.
	spec := &jobspec.WorkloadSpec{
		Kind:  "Service",
		Name:  "web",
		Tasks: []jobspec.TaskGroupTask{{Name: "app"}},
	}
	_, err := SystemdEmitter{}.Render(spec, &Node{})
	if err == nil {
		t.Fatal("expected error for task with no runtime, got nil")
	}
	if !strings.Contains(err.Error(), "no runtime") {
		t.Errorf("error = %q, want 'no runtime'", err.Error())
	}
}

func TestSystemdEmitter_TaskGroupEmptyCommandError(t *testing.T) {
	// P06: a task whose resolved runtime command is empty/whitespace
	// is an error (mirrors the single-process rule).
	spec := &jobspec.WorkloadSpec{
		Kind:    "Service",
		Name:    "web",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "   "},
		Tasks:   []jobspec.TaskGroupTask{{Name: "app"}},
	}
	_, err := SystemdEmitter{}.Render(spec, &Node{})
	if err == nil {
		t.Fatal("expected error for empty command, got nil")
	}
	if !strings.Contains(err.Error(), "command is empty") {
		t.Errorf("error = %q, want 'command is empty'", err.Error())
	}
}

func TestSystemdEmitter_NoTasksBackwardCompat(t *testing.T) {
	// Backward compat: a spec with no Tasks renders exactly one unit
	// (the historical single-process shape).
	spec := &jobspec.WorkloadSpec{
		Kind:    "Job",
		Name:    "backup",
		Runtime: &jobspec.RuntimeBlock{OneOf: "process", Command: "/bin/rsync"},
	}
	files, err := SystemdEmitter{}.Render(spec, &Node{})
	if err != nil {
		t.Fatalf("Render: %v", err)
	}
	if len(files) != 1 {
		t.Fatalf("got %d files, want 1 (backward compat)", len(files))
	}
	if files[0].Path != "/etc/systemd/system/orca-v1-backup.service" {
		t.Errorf("Path = %q, want /etc/systemd/system/orca-v1-backup.service", files[0].Path)
	}
}

func filePaths(files []File) []string {
	out := make([]string, len(files))
	for i, f := range files {
		out[i] = f.Path
	}
	return out
}

func findUnitContent(files []File, path string) string {
	for _, f := range files {
		if f.Path == path {
			return f.Content
		}
	}
	return ""
}
