package hostsetup

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

type fakeRunner struct {
	calls    [][]string
	missing  map[string]bool // name+arg0 lookups that should return error to simulate absence
	failures map[string]error
}

func newFakeRunner() *fakeRunner {
	return &fakeRunner{
		missing:  map[string]bool{},
		failures: map[string]error{},
	}
}

func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
	rec := append([]string{name}, args...)
	f.calls = append(f.calls, rec)
	key := strings.Join(rec, " ")
	if err, ok := f.failures[key]; ok {
		return nil, err
	}
	// getent/id lookups: return error to simulate "not found" if marked missing
	if name == "getent" || name == "id" {
		if f.missing[key] {
			return nil, &exitErr{}
		}
	}
	return nil, nil
}

type exitErr struct{}

func (exitErr) Error() string { return "not found" }

func (f *fakeRunner) called(name string, args ...string) bool {
	want := append([]string{name}, args...)
	for _, c := range f.calls {
		if len(c) != len(want) {
			continue
		}
		match := true
		for i := range c {
			if c[i] != want[i] {
				match = false
				break
			}
		}
		if match {
			return true
		}
	}
	return false
}

func testSpec(dir string) AppSpec {
	return AppSpec{
		Name:           "iop-test",
		Description:    "iop test",
		DefaultConfig:  filepath.Join(dir, "etc", "iop", "test.yaml"),
		DefaultDataDir: filepath.Join(dir, "var", "lib", "iop"),
		DefaultUnit:    filepath.Join(dir, "etc", "systemd", "system", "iop-test.service"),
		ConfigTemplate: "key: value\n",
	}
}

func baseOpts(runner CommandRunner) SetupOptions {
	return SetupOptions{
		BinaryPath: "/usr/local/bin/iop-test",
		Enable:     true,
		Start:      true,
		Runner:     runner,
		Getuid:     func() int { return 0 },
	}
}

func TestRunDryRunDoesNotWriteFiles(t *testing.T) {
	dir := t.TempDir()
	spec := testSpec(dir)
	runner := newFakeRunner()
	opts := baseOpts(runner)
	opts.DryRun = true

	var out bytes.Buffer
	if err := Run(context.Background(), spec, opts, &out); err != nil {
		t.Fatalf("Run: %v", err)
	}

	if _, err := os.Stat(spec.DefaultConfig); !os.IsNotExist(err) {
		t.Fatalf("config should not exist after dry-run: err=%v", err)
	}
	if _, err := os.Stat(spec.DefaultUnit); !os.IsNotExist(err) {
		t.Fatalf("unit should not exist after dry-run: err=%v", err)
	}
	if len(runner.calls) != 0 {
		t.Fatalf("dry-run should not invoke runner, got %v", runner.calls)
	}
	if !strings.Contains(out.String(), "unit preview") {
		t.Fatalf("dry-run output missing unit preview: %s", out.String())
	}
	if !strings.Contains(out.String(), "config preview") {
		t.Fatalf("dry-run output missing config preview: %s", out.String())
	}
}

func TestRunWritesMissingConfigAndUnit(t *testing.T) {
	dir := t.TempDir()
	spec := testSpec(dir)
	runner := newFakeRunner()
	// user/group do not exist yet
	runner.missing["getent group iop"] = true
	runner.missing["id -u iop"] = true

	opts := baseOpts(runner)

	var out bytes.Buffer
	if err := Run(context.Background(), spec, opts, &out); err != nil {
		t.Fatalf("Run: %v", err)
	}

	cfg, err := os.ReadFile(spec.DefaultConfig)
	if err != nil {
		t.Fatalf("read config: %v", err)
	}
	if string(cfg) != spec.ConfigTemplate {
		t.Fatalf("config content mismatch: %q", cfg)
	}
	info, err := os.Stat(spec.DefaultConfig)
	if err != nil {
		t.Fatalf("stat config: %v", err)
	}
	if info.Mode().Perm() != 0o600 {
		t.Fatalf("config mode: want 0600 got %o", info.Mode().Perm())
	}

	unit, err := os.ReadFile(spec.DefaultUnit)
	if err != nil {
		t.Fatalf("read unit: %v", err)
	}
	if !strings.Contains(string(unit), "ExecStart=/usr/local/bin/iop-test serve --config "+spec.DefaultConfig) {
		t.Fatalf("unit ExecStart wrong: %s", unit)
	}
	uinfo, err := os.Stat(spec.DefaultUnit)
	if err != nil {
		t.Fatalf("stat unit: %v", err)
	}
	if uinfo.Mode().Perm() != 0o644 {
		t.Fatalf("unit mode: want 0644 got %o", uinfo.Mode().Perm())
	}

	if _, err := os.Stat(spec.DefaultDataDir); err != nil {
		t.Fatalf("data dir missing: %v", err)
	}

	if !runner.called("groupadd", "--system", "iop") {
		t.Fatalf("groupadd not called: %v", runner.calls)
	}
	if !runner.called("useradd", "--system", "--gid", "iop", "--home-dir", spec.DefaultDataDir, "--shell", "/usr/sbin/nologin", "iop") {
		t.Fatalf("useradd not called: %v", runner.calls)
	}
	if !runner.called("chown", "-R", "iop:iop", spec.DefaultDataDir) {
		t.Fatalf("chown data dir not called: %v", runner.calls)
	}
	if !runner.called("chown", "iop:iop", spec.DefaultConfig) {
		t.Fatalf("chown config not called: %v", runner.calls)
	}
	if !runner.called("systemctl", "daemon-reload") {
		t.Fatalf("daemon-reload not called")
	}
	if !runner.called("systemctl", "enable", "iop-test") {
		t.Fatalf("enable not called")
	}
	if !runner.called("systemctl", "start", "iop-test") {
		t.Fatalf("start not called")
	}
}

func TestRunDoesNotOverwriteExistingConfig(t *testing.T) {
	dir := t.TempDir()
	spec := testSpec(dir)
	runner := newFakeRunner()
	opts := baseOpts(runner)

	if err := os.MkdirAll(filepath.Dir(spec.DefaultConfig), 0o755); err != nil {
		t.Fatal(err)
	}
	existing := "preserved: true\n"
	if err := os.WriteFile(spec.DefaultConfig, []byte(existing), 0o600); err != nil {
		t.Fatal(err)
	}

	var out bytes.Buffer
	if err := Run(context.Background(), spec, opts, &out); err != nil {
		t.Fatalf("Run: %v", err)
	}

	got, err := os.ReadFile(spec.DefaultConfig)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != existing {
		t.Fatalf("config was overwritten: %q", got)
	}

	// With OverwriteConfig=true, it should overwrite.
	opts.OverwriteConfig = true
	if err := Run(context.Background(), spec, opts, &out); err != nil {
		t.Fatalf("Run overwrite: %v", err)
	}
	got, err = os.ReadFile(spec.DefaultConfig)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != spec.ConfigTemplate {
		t.Fatalf("overwrite did not apply: %q", got)
	}
}

func TestRunCreatesTraversableParentForNestedDataDir(t *testing.T) {
	dir := t.TempDir()
	spec := testSpec(dir)
	// Mimic real Edge/Node layout: leaf nested under a shared parent.
	spec.DefaultDataDir = filepath.Join(dir, "var", "lib", "iop", "edge")
	runner := newFakeRunner()
	runner.missing["getent group iop"] = true
	runner.missing["id -u iop"] = true
	opts := baseOpts(runner)

	var out bytes.Buffer
	if err := Run(context.Background(), spec, opts, &out); err != nil {
		t.Fatalf("Run: %v", err)
	}

	parent := filepath.Join(dir, "var", "lib", "iop")
	info, err := os.Stat(parent)
	if err != nil {
		t.Fatalf("stat parent: %v", err)
	}
	// World-traversable (at least the +x bit for other) so the iop service user
	// can traverse the shared parent into its leaf data dir.
	if info.Mode().Perm()&0o001 == 0 {
		t.Fatalf("parent %s not traversable by others: mode=%o", parent, info.Mode().Perm())
	}

	leaf, err := os.Stat(spec.DefaultDataDir)
	if err != nil {
		t.Fatalf("stat leaf: %v", err)
	}
	if leaf.Mode().Perm() != 0o750 {
		t.Fatalf("leaf data dir mode: want 0750 got %o", leaf.Mode().Perm())
	}

	if !runner.called("chown", "-R", "iop:iop", spec.DefaultDataDir) {
		t.Fatalf("chown -R should target leaf data dir, calls=%v", runner.calls)
	}
}

func TestRunFixesExistingNonTraversableParent(t *testing.T) {
	dir := t.TempDir()
	spec := testSpec(dir)
	spec.DefaultDataDir = filepath.Join(dir, "var", "lib", "iop", "edge")

	parent := filepath.Join(dir, "var", "lib", "iop")
	if err := os.MkdirAll(parent, 0o700); err != nil {
		t.Fatal(err)
	}
	if err := os.Chmod(parent, 0o750); err != nil {
		t.Fatal(err)
	}

	runner := newFakeRunner()
	runner.missing["getent group iop"] = true
	runner.missing["id -u iop"] = true
	opts := baseOpts(runner)

	if err := Run(context.Background(), spec, opts, &bytes.Buffer{}); err != nil {
		t.Fatalf("Run: %v", err)
	}

	info, err := os.Stat(parent)
	if err != nil {
		t.Fatalf("stat parent: %v", err)
	}
	if info.Mode().Perm()&0o001 == 0 {
		t.Fatalf("parent %s still not traversable: mode=%o", parent, info.Mode().Perm())
	}
}

func TestRunRejectsUnsafeDataDir(t *testing.T) {
	for _, bad := range []string{"/", "/var", "/var/lib", "/etc", "/usr", "/usr/lib", "/var/log"} {
		t.Run(bad, func(t *testing.T) {
			spec := AppSpec{
				Name:           "iop-test",
				DefaultConfig:  "/tmp/does-not-matter.yaml",
				DefaultDataDir: bad,
				DefaultUnit:    "/tmp/does-not-matter.service",
				ConfigTemplate: "",
			}
			runner := newFakeRunner()
			opts := baseOpts(runner)
			opts.DryRun = true // ensure we don't try to write to those paths anyway
			err := Run(context.Background(), spec, opts, &bytes.Buffer{})
			if err == nil {
				t.Fatalf("expected error for unsafe data dir %q", bad)
			}
			if !strings.Contains(err.Error(), "not an app-specific path") {
				t.Fatalf("unexpected error: %v", err)
			}
			if len(runner.calls) != 0 {
				t.Fatalf("runner should not be invoked for unsafe data dir, got %v", runner.calls)
			}
		})
	}
}

func TestEdgeSpecConfigTemplateUsesServiceWritableLogPath(t *testing.T) {
	tmpl := EdgeSpec().ConfigTemplate
	wants := []string{
		"path: \"/var/lib/iop/edge/logs/edge.log\"",
		"advertise_host:",
		"bootstrap:",
		"artifact_base_url:",
		"control_plane:",
		"wire_addr:",
		"reconnect_interval_sec:",
		"refresh:",
		"enabled: false",
		"listen: \"127.0.0.1:19093\"",
		"metrics:",
		"port: 19092",
	}
	for _, w := range wants {
		if !strings.Contains(tmpl, w) {
			t.Errorf("EdgeSpec().ConfigTemplate missing %q\n---\n%s", w, tmpl)
		}
	}
}

func TestEdgeBundleConfigTemplateKeepsEmptyLogPath(t *testing.T) {
	tmpl := EdgeBundleConfigTemplate()
	wants := []string{
		"path: \"\"",
		"advertise_host:",
		"bootstrap:",
		"artifact_base_url:",
		"control_plane:",
		"wire_addr:",
		"reconnect_interval_sec:",
		"refresh:",
		"enabled: false",
		"listen: \"127.0.0.1:19093\"",
		"metrics:",
		"port: 19092",
	}
	for _, w := range wants {
		if !strings.Contains(tmpl, w) {
			t.Errorf("EdgeBundleConfigTemplate missing %q\n---\n%s", w, tmpl)
		}
	}
	if strings.Contains(tmpl, "/var/lib/iop/edge/logs/edge.log") {
		t.Errorf("bundle template leaked service log path:\n%s", tmpl)
	}
}

func TestRenderUnit(t *testing.T) {
	spec := AppSpec{Name: "iop-edge", Description: "IOP Edge"}
	opts := SetupOptions{
		BinaryPath: "/usr/local/bin/iop-edge",
		ConfigPath: "/etc/iop/edge.yaml",
		DataDir:    "/var/lib/iop/edge",
		User:       "iop",
		Group:      "iop",
	}
	unit := renderUnit(spec, opts)

	for _, want := range []string{
		"[Unit]",
		"Description=IOP Edge",
		"After=network-online.target",
		"[Service]",
		"Type=simple",
		"User=iop",
		"Group=iop",
		"ExecStart=/usr/local/bin/iop-edge serve --config /etc/iop/edge.yaml",
		"WorkingDirectory=/var/lib/iop/edge",
		"Restart=on-failure",
		"[Install]",
		"WantedBy=multi-user.target",
	} {
		if !strings.Contains(unit, want) {
			t.Fatalf("unit missing %q\n%s", want, unit)
		}
	}
}
