package static

import (
	"encoding/json"
	"strconv"
	"strings"
	"sync"
	"testing"
	"unicode/utf8"

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

func TestIdShort_TruncatesLongID(t *testing.T) {
	got := plugin.IDShort(plugin.Workload{ID: "abcd1234-5678-1234-abcd-deadbeef0000"})
	if got != "abcd1234" {
		t.Fatalf("idShort = %q, want %q", got, "abcd1234")
	}
}

func TestIdShort_ShortIDPassesThrough(t *testing.T) {
	// IDs shorter than 8 chars must not panic on slicing.
	got := plugin.IDShort(plugin.Workload{ID: "abc"})
	if got != "abc" {
		t.Fatalf("idShort = %q, want %q", got, "abc")
	}
}

func TestIdShort_ExactlyEightChars(t *testing.T) {
	got := plugin.IDShort(plugin.Workload{ID: "12345678"})
	if got != "12345678" {
		t.Fatalf("idShort = %q, want %q", got, "12345678")
	}
}

func TestContainerNameFor_Shape(t *testing.T) {
	w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"}
	got := containerNameFor(w)
	if got != "dw-site-mysite-abcd1234" {
		t.Fatalf("containerNameFor = %q, want %q", got, "dw-site-mysite-abcd1234")
	}
}

func TestImageTagFor_Shape(t *testing.T) {
	w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"}
	got := imageTagFor(w)
	if got != "dw-site-mysite-abcd1234:latest" {
		t.Fatalf("imageTagFor = %q, want %q", got, "dw-site-mysite-abcd1234:latest")
	}
}

func TestSiteVolumeKey_Shape(t *testing.T) {
	w := plugin.Workload{ID: "abcd1234-rest", Name: "mysite"}
	got := siteVolumeKey(w)
	if got != "mysite-abcd1234" {
		t.Fatalf("siteVolumeKey = %q, want %q", got, "mysite-abcd1234")
	}
}

func TestNaming_TwoWorkloadsSameNameGetDifferentResources(t *testing.T) {
	// Workload names are not UNIQUE in the schema; the ID short suffix
	// is the only thing keeping two same-named workloads from clobbering
	// each other's container / image / volume.
	a := plugin.Workload{ID: "aaaaaaaa-rest", Name: "site"}
	b := plugin.Workload{ID: "bbbbbbbb-rest", Name: "site"}

	if containerNameFor(a) == containerNameFor(b) {
		t.Errorf("container names collide for same-named workloads: %q", containerNameFor(a))
	}
	if imageTagFor(a) == imageTagFor(b) {
		t.Errorf("image tags collide for same-named workloads: %q", imageTagFor(a))
	}
	if siteVolumeKey(a) == siteVolumeKey(b) {
		t.Errorf("volume keys collide for same-named workloads: %q", siteVolumeKey(a))
	}
}

func TestSanitizeError_Empty(t *testing.T) {
	if got := sanitizeError("", "tok"); got != "" {
		t.Fatalf("sanitizeError(\"\") = %q, want \"\"", got)
	}
}

func TestSanitizeError_CollapsesWhitespace(t *testing.T) {
	got := sanitizeError("line1\nline2\rline3\tline4", "")
	if strings.ContainsAny(got, "\n\r\t") {
		t.Fatalf("sanitizeError did not collapse whitespace: %q", got)
	}
	if got != "line1 line2 line3 line4" {
		t.Fatalf("sanitizeError = %q, want %q", got, "line1 line2 line3 line4")
	}
}

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

func TestSanitizeError_EmptyTokenIsNoOp(t *testing.T) {
	// An empty token must not redact arbitrary substrings (a naive
	// ReplaceAll with "" splits the string by every byte boundary).
	msg := "auth failed"
	got := sanitizeError(msg, "")
	if got != msg {
		t.Fatalf("sanitizeError(msg, \"\") = %q, want %q", got, msg)
	}
}

func TestSanitizeError_TruncatesLongInput(t *testing.T) {
	// 240-byte cap from the implementation.
	long := strings.Repeat("a", 1000)
	got := sanitizeError(long, "")
	if !strings.HasSuffix(got, "…") {
		t.Fatalf("sanitizeError did not append ellipsis: ...%s", got[max(0, len(got)-20):])
	}
	// 240 bytes of "a" plus the three-byte ellipsis rune.
	if len(got) != 240+len("…") {
		t.Fatalf("sanitizeError length = %d, want %d", len(got), 240+len("…"))
	}
}

func TestSanitizeError_MultibyteRuneAtCutoff(t *testing.T) {
	// The truncation slices at byte 240 — if a multi-byte rune straddles
	// that boundary the output ends in a broken rune sequence. The
	// implementation is byte-sliced today so this is more a "guard the
	// expected behavior" test: the function must still produce valid
	// UTF-8 on input that wasn't already broken, OR ship a known
	// fix-needed test if the implementation changes. "é" is 2 bytes
	// (C3 A9); 1000 of them = 2000 bytes well past the cap.
	long := strings.Repeat("é", 1000)
	got := sanitizeError(long, "")
	if !strings.HasSuffix(got, "…") {
		t.Fatalf("sanitizeError did not append ellipsis on multi-byte input: %q", got)
	}
	// Output must remain valid UTF-8 — a torn rune at the cap would
	// fail this check. utf8.ValidString is the canonical guard.
	if !utf8.ValidString(got) {
		t.Errorf("sanitizeError produced invalid UTF-8 at byte cap: %q", got)
	}
}

func TestSanitizeError_ShortInputUnchanged(t *testing.T) {
	got := sanitizeError("short message", "")
	if got != "short message" {
		t.Fatalf("sanitizeError mangled short input: %q", got)
	}
}

func TestContainerRowID_Deterministic(t *testing.T) {
	w := plugin.Workload{ID: "abcd1234-rest"}
	a := containerRowID(w)
	b := containerRowID(w)
	if a != b {
		t.Fatalf("containerRowID not deterministic: %q vs %q", a, b)
	}
	if a != "abcd1234-rest:site" {
		t.Fatalf("containerRowID = %q, want %q", a, "abcd1234-rest:site")
	}
}

func TestSaveLock_FreedWhenIdle(t *testing.T) {
	// After the last holder releases, the reference-counted entry must be
	// removed from the map so the lock table cannot grow without bound.
	// Suffix by t.Name() so the package-global saveLocks map cannot bleed
	// key state between tests (or between -count=N runs).
	key := t.Name() + "-wid"
	lk := acquireSaveLock(key)
	saveLocks.mu.Lock()
	_, present := saveLocks.locks[key]
	saveLocks.mu.Unlock()
	if !present {
		t.Fatal("acquireSaveLock did not register the entry while held")
	}
	releaseSaveLock(key, lk)
	saveLocks.mu.Lock()
	_, stillPresent := saveLocks.locks[key]
	saveLocks.mu.Unlock()
	if stillPresent {
		t.Fatal("releaseSaveLock left the entry behind after the last holder released")
	}
}

func TestSaveLock_DistinctWorkloadsDoNotSerialize(t *testing.T) {
	// Two different workloads must be lockable at the same time. If they
	// shared a mutex the second acquire would block forever (deadlock).
	a := acquireSaveLock(t.Name() + "-a")
	b := acquireSaveLock(t.Name() + "-b")
	releaseSaveLock(t.Name()+"-b", b)
	releaseSaveLock(t.Name()+"-a", a)
}

func TestSaveLock_SerializesConcurrentAcquisitions(t *testing.T) {
	// Goroutines acquiring the same workload's lock must run sequentially.
	// The counter would race past 1 if locking were broken; with the lock,
	// peak in-flight stays at 1.
	key := t.Name() + "-wid"
	var (
		wg      sync.WaitGroup
		mu      sync.Mutex
		counter int
		peak    int
	)
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			lk := acquireSaveLock(key)
			defer releaseSaveLock(key, lk)

			mu.Lock()
			counter++
			if counter > peak {
				peak = counter
			}
			mu.Unlock()

			mu.Lock()
			counter--
			mu.Unlock()
		}()
	}
	wg.Wait()
	if peak != 1 {
		t.Fatalf("acquireSaveLock failed to serialize: peak in-flight = %d, want 1", peak)
	}
}

func TestSaveLock_ConcurrentMapAccessIsSafe(t *testing.T) {
	// Distinct workloads acquired+released in parallel must not panic on map
	// access — exercises the outer-mutex protection inside acquire/release.
	// Each iteration uses a unique key so the test stresses the insertion +
	// refcount-cleanup paths (the common case for "first deploy" callers).
	prefix := t.Name() + "-"
	var wg sync.WaitGroup
	for i := 0; i < 50; i++ {
		i := i
		wg.Add(1)
		go func() {
			defer wg.Done()
			key := prefix + strconv.Itoa(i)
			lk := acquireSaveLock(key)
			releaseSaveLock(key, lk)
		}()
	}
	wg.Wait()
}

func TestRuntimeState_JSONTagsRoundTrip(t *testing.T) {
	// saveState/loadState rely on these tag names to merge into the
	// generic extra_json map without doubling keys.
	in := runtimeState{
		LastCommitSHA: "deadbeef",
		LastSyncAt:    "2026-05-16T00:00:00Z",
		LastError:     "nope",
		Status:        "deployed",
	}
	b, err := json.Marshal(in)
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}

	// Decode into a generic map and assert it has the exact same key
	// set as runtimeStateKeys. Renaming a JSON tag without updating
	// runtimeStateKeys would break the "clearing a typed field removes
	// the key" invariant; renaming/adding to runtimeStateKeys without
	// changing a tag would silently drop a sibling key from extra_json.
	// Both regressions fail this test.
	gotKeys := map[string]bool{}
	var asMap map[string]json.RawMessage
	if err := json.Unmarshal(b, &asMap); err != nil {
		t.Fatalf("unmarshal to map: %v", err)
	}
	for k := range asMap {
		gotKeys[k] = true
	}
	wantKeys := map[string]bool{}
	for _, k := range runtimeStateKeys {
		wantKeys[k] = true
	}
	if len(gotKeys) != len(wantKeys) {
		t.Errorf("runtimeStateKeys (%d) and JSON tag set (%d) differ in size: %v vs %v",
			len(wantKeys), len(gotKeys), runtimeStateKeys, asMap)
	}
	for k := range wantKeys {
		if !gotKeys[k] {
			t.Errorf("runtimeStateKeys lists %q but JSON output has no such key: %v", k, asMap)
		}
	}
	for k := range gotKeys {
		if !wantKeys[k] {
			t.Errorf("JSON output has %q but runtimeStateKeys does not: %v", k, runtimeStateKeys)
		}
	}

	var out runtimeState
	if err := json.Unmarshal(b, &out); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if out != in {
		t.Fatalf("round-trip mismatch: %+v vs %+v", out, in)
	}
}

func TestRuntimeState_OmitsEmptyFields(t *testing.T) {
	// The struct uses `omitempty` everywhere so a freshly-created site
	// with no recorded sync yet doesn't write a wall of empty strings
	// into extra_json.
	b, err := json.Marshal(runtimeState{})
	if err != nil {
		t.Fatalf("marshal: %v", err)
	}
	if string(b) != "{}" {
		t.Fatalf("zero runtimeState JSON = %q, want %q", string(b), "{}")
	}
}
