package dockerfile

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"github.com/alexei/tinyforge/internal/workload/plugin"
)

// ── Source interface plumbing ───────────────────────────────────────

func TestSource_Kind(t *testing.T) {
	if (&source{}).Kind() != "dockerfile" {
		t.Fatalf("Kind = %q, want \"dockerfile\"", (&source{}).Kind())
	}
}

func TestSource_Registered_AtInit(t *testing.T) {
	// init() runs once on import; we just verify the registry returns
	// our concrete kind. A failure here is a regression of the global
	// plugin.RegisterSource path or our package-level init.
	got, err := plugin.GetSource("dockerfile")
	if err != nil {
		t.Fatalf("GetSource(dockerfile): %v", err)
	}
	if got.Kind() != "dockerfile" {
		t.Fatalf("registered source has wrong kind: %q", got.Kind())
	}
}

func TestSource_SchemaSample_RoundTrips(t *testing.T) {
	s := (&source{}).SchemaSample()
	raw, err := json.Marshal(s)
	if err != nil {
		t.Fatalf("marshal sample: %v", err)
	}
	if err := (&source{}).Validate(raw); err != nil {
		t.Fatalf("Validate(sample) = %v, want nil", err)
	}
}

// ── Validate ────────────────────────────────────────────────────────

func TestValidate_RejectsEmpty(t *testing.T) {
	if err := (&source{}).Validate(nil); err == nil {
		t.Fatal("expected error on empty config, got nil")
	}
}

func TestValidate_RejectsMissingRepo(t *testing.T) {
	cases := []Config{
		{RepoName: "x", Port: 80},                  // owner missing
		{RepoOwner: "y", Port: 80},                 // name missing
		{RepoOwner: "  ", RepoName: "x", Port: 80}, // owner whitespace-only
	}
	for i, c := range cases {
		raw, _ := json.Marshal(c)
		if err := (&source{}).Validate(raw); err == nil {
			t.Errorf("case %d: expected error, got nil", i)
		}
	}
}

func TestValidate_RejectsBadPort(t *testing.T) {
	for _, port := range []int{0, -1, 70000} {
		raw, _ := json.Marshal(Config{RepoOwner: "a", RepoName: "b", Port: port})
		if err := (&source{}).Validate(raw); err == nil {
			t.Errorf("port %d: expected error, got nil", port)
		}
	}
}

func TestValidate_RejectsPathEscape(t *testing.T) {
	cases := []Config{
		{RepoOwner: "a", RepoName: "b", Port: 80, DockerfilePath: "/etc/passwd"},
		{RepoOwner: "a", RepoName: "b", Port: 80, DockerfilePath: "../../etc/passwd"},
		{RepoOwner: "a", RepoName: "b", Port: 80, ContextPath: "../../"},
		{RepoOwner: "a", RepoName: "b", Port: 80, ContextPath: "/etc"},
	}
	for i, c := range cases {
		raw, _ := json.Marshal(c)
		if err := (&source{}).Validate(raw); err == nil {
			t.Errorf("case %d: expected path-escape rejection, got nil", i)
		}
	}
}

func TestValidate_AcceptsValid(t *testing.T) {
	raw, _ := json.Marshal(Config{
		RepoOwner:      "owner",
		RepoName:       "repo",
		Port:           8080,
		DockerfilePath: "docker/Dockerfile",
		ContextPath:    "services/api",
	})
	if err := (&source{}).Validate(raw); err != nil {
		t.Fatalf("Validate(valid) = %v", err)
	}
}

// ── Naming helpers ──────────────────────────────────────────────────

func TestNaming_SameNameDifferentIDs_NoCollision(t *testing.T) {
	a := plugin.Workload{ID: "aaaaaaaa-rest", Name: "svc"}
	b := plugin.Workload{ID: "bbbbbbbb-rest", Name: "svc"}
	if containerNameFor(a) == containerNameFor(b) {
		t.Errorf("container names collide: %q", containerNameFor(a))
	}
	if imageTagFor(a) == imageTagFor(b) {
		t.Errorf("image tags collide: %q", imageTagFor(a))
	}
}

func TestNaming_ShortIDsPassThrough(t *testing.T) {
	w := plugin.Workload{ID: "abc", Name: "tiny"}
	if !strings.HasSuffix(containerNameFor(w), "-abc") {
		t.Errorf("container name lost short id: %q", containerNameFor(w))
	}
}

// ── Context + Dockerfile resolution ─────────────────────────────────

func TestResolveContextDir_Empty_ReturnsRoot(t *testing.T) {
	dir := t.TempDir()
	got, err := resolveContextDir(dir, "")
	if err != nil {
		t.Fatalf("resolveContextDir: %v", err)
	}
	if real, _ := filepath.EvalSymlinks(dir); got != real && got != dir {
		t.Errorf("got %q, want %q (or symlink-resolved equivalent)", got, dir)
	}
}

func TestResolveContextDir_Subfolder_OK(t *testing.T) {
	dir := t.TempDir()
	sub := filepath.Join(dir, "api")
	if err := os.MkdirAll(sub, 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	got, err := resolveContextDir(dir, "api")
	if err != nil {
		t.Fatalf("resolveContextDir: %v", err)
	}
	if !strings.HasSuffix(got, "api") {
		t.Errorf("got %q, expected suffix 'api'", got)
	}
}

func TestResolveContextDir_NonexistentSubfolder(t *testing.T) {
	dir := t.TempDir()
	if _, err := resolveContextDir(dir, "missing"); err == nil {
		t.Fatal("expected error for missing subfolder")
	}
}

func TestResolveContextDir_RejectsEscape(t *testing.T) {
	dir := t.TempDir()
	// resolveContextDir is the second wall — Validate is the first.
	// We pass an absolute escape via a synthesized symlink. Even if
	// the user bypasses Validate (e.g. by direct DB edit), this must
	// still reject.
	outside := t.TempDir()
	link := filepath.Join(dir, "escape")
	if err := os.Symlink(outside, link); err != nil {
		t.Skipf("symlink unsupported in this environment: %v", err)
	}
	if _, err := resolveContextDir(dir, "escape"); err == nil {
		t.Fatal("expected escape-path rejection")
	}
}

func TestVerifyDockerfileExists_Present(t *testing.T) {
	dir := t.TempDir()
	if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch\n"), 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	if err := verifyDockerfileExists(dir, ""); err != nil {
		t.Fatalf("verifyDockerfileExists(default) = %v, want nil", err)
	}
}

func TestVerifyDockerfileExists_Missing(t *testing.T) {
	dir := t.TempDir()
	if err := verifyDockerfileExists(dir, ""); err == nil {
		t.Fatal("expected error for missing Dockerfile")
	}
}

func TestVerifyDockerfileExists_CustomPath(t *testing.T) {
	dir := t.TempDir()
	if err := os.MkdirAll(filepath.Join(dir, "docker"), 0o755); err != nil {
		t.Fatalf("mkdir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(dir, "docker", "Dockerfile.prod"), []byte("FROM scratch\n"), 0o644); err != nil {
		t.Fatalf("write: %v", err)
	}
	if err := verifyDockerfileExists(dir, "docker/Dockerfile.prod"); err != nil {
		t.Fatalf("verifyDockerfileExists(custom) = %v, want nil", err)
	}
}

func TestVerifyDockerfileExists_RejectsAbsolutePath(t *testing.T) {
	dir := t.TempDir()
	if err := verifyDockerfileExists(dir, "/etc/passwd"); err == nil {
		t.Fatal("expected error for absolute dockerfile path")
	}
}

// ── Sanitiser ───────────────────────────────────────────────────────

func TestSanitizeError_RedactsToken(t *testing.T) {
	tok := "ghp_supersecret"
	got := sanitizeError("401 from gitea token="+tok+" ok", tok)
	if strings.Contains(got, tok) {
		t.Errorf("token leaked: %q", got)
	}
	if !strings.Contains(got, "[REDACTED]") {
		t.Errorf("missing [REDACTED] marker: %q", got)
	}
}

func TestSanitizeError_CollapsesWhitespace(t *testing.T) {
	got := sanitizeError("a\nb\rc\td", "")
	if strings.ContainsAny(got, "\n\r\t") {
		t.Errorf("did not collapse: %q", got)
	}
}

func TestSanitizeError_TruncatesUTF8Safe(t *testing.T) {
	// 1000 copies of a 2-byte rune = 2000 bytes, well over the 240
	// cap. Output must remain valid UTF-8 (no torn rune at the cap).
	long := strings.Repeat("é", 1000)
	got := sanitizeError(long, "")
	if !strings.HasSuffix(got, "…") {
		t.Errorf("missing ellipsis: %q", got)
	}
	// Walk the result: every byte should be either an ASCII char or
	// part of a complete UTF-8 sequence. utf8.ValidString is the
	// canonical guard but a simple "ends on rune boundary" check
	// suffices for this fixture.
	if !isValidUTF8Slice([]byte(got)) {
		t.Errorf("truncation produced broken UTF-8: %q", got)
	}
}

func isValidUTF8Slice(b []byte) bool {
	for i := 0; i < len(b); {
		switch {
		case b[i] < 0x80:
			i++
		case b[i] < 0xC0:
			return false // continuation byte at sequence start
		case b[i] < 0xE0:
			if i+1 >= len(b) {
				return false
			}
			i += 2
		case b[i] < 0xF0:
			if i+2 >= len(b) {
				return false
			}
			i += 3
		default:
			if i+3 >= len(b) {
				return false
			}
			i += 4
		}
	}
	return true
}

// ── State row ID ────────────────────────────────────────────────────

func TestContainerRowID_Deterministic(t *testing.T) {
	w := plugin.Workload{ID: "abcd1234-rest"}
	a := containerRowID(w)
	b := containerRowID(w)
	if a != b {
		t.Errorf("containerRowID not deterministic: %q vs %q", a, b)
	}
	if !strings.HasSuffix(a, ":dockerfile") {
		t.Errorf("containerRowID missing suffix: %q", a)
	}
}
