package static

import (
	"encoding/json"
	"fmt"
	"strings"
	"sync"
	"testing"

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

// newTestStore opens an in-memory SQLite store. Mirrors the pattern in
// internal/scheduler/scheduler_test.go.
func newTestStore(t *testing.T) *store.Store {
	t.Helper()
	st, err := store.New(":memory:")
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	t.Cleanup(func() { _ = st.Close() })
	return st
}

func testDeps(t *testing.T) (plugin.Deps, [32]byte) {
	t.Helper()
	st := newTestStore(t)
	var key [32]byte
	for i := range key {
		key[i] = byte(i + 1)
	}
	return plugin.Deps{Store: st, EncKey: key}, key
}

// seedWorkload inserts a minimal workload row so child tables with a
// FK to workloads.id (workload_env, containers via workload_id) accept
// inserts. Returns the row's ID so callers can use the same value for
// child seeds.
func seedWorkload(t *testing.T, st *store.Store, id, name string) string {
	t.Helper()
	w, err := st.CreateWorkload(store.Workload{
		ID:         id,
		Kind:       "plugin",
		Name:       name,
		SourceKind: "static",
	})
	if err != nil {
		t.Fatalf("seed workload: %v", err)
	}
	return w.ID
}

func TestLoadState_ReturnsZeroOnMissingRow(t *testing.T) {
	deps, _ := testDeps(t)
	w := plugin.Workload{ID: t.Name() + "-wid", Name: "x"}

	state, row, err := loadState(deps, w)
	if err != nil {
		t.Fatalf("loadState: %v", err)
	}
	if row != nil {
		t.Errorf("row = %+v, want nil", row)
	}
	if state != (runtimeState{}) {
		t.Errorf("state = %+v, want zero", state)
	}
}

func TestSaveState_CreatesRowOnFirstWrite(t *testing.T) {
	deps, _ := testDeps(t)
	w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}

	err := saveState(deps, w, func(state *runtimeState, row *store.Container) {
		state.Status = "deployed"
		state.LastCommitSHA = "deadbeef"
		row.ContainerID = "ctr-123"
		row.State = "running"
	})
	if err != nil {
		t.Fatalf("saveState: %v", err)
	}

	state, row, err := loadState(deps, w)
	if err != nil {
		t.Fatalf("loadState: %v", err)
	}
	if row == nil {
		t.Fatal("row is nil after save")
	}
	if row.ID != containerRowID(w) {
		t.Errorf("row.ID = %q, want %q", row.ID, containerRowID(w))
	}
	if row.WorkloadID != w.ID {
		t.Errorf("row.WorkloadID = %q, want %q", row.WorkloadID, w.ID)
	}
	if row.WorkloadKind != string(store.WorkloadKindSite) {
		t.Errorf("row.WorkloadKind = %q, want %q", row.WorkloadKind, store.WorkloadKindSite)
	}
	if row.ContainerID != "ctr-123" {
		t.Errorf("row.ContainerID = %q, want %q", row.ContainerID, "ctr-123")
	}
	if state.Status != "deployed" {
		t.Errorf("state.Status = %q, want %q", state.Status, "deployed")
	}
	if state.LastCommitSHA != "deadbeef" {
		t.Errorf("state.LastCommitSHA = %q, want %q", state.LastCommitSHA, "deadbeef")
	}
}

func TestSaveState_PreservesUnknownExtraJSONKeys(t *testing.T) {
	// Future writers (per-face route maps, etc.) extend extra_json with
	// keys runtimeState doesn't know about. The save path must not eat
	// them.
	deps, _ := testDeps(t)
	w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}

	// Seed a row directly with an extra key.
	seedRow := store.Container{
		ID:           containerRowID(w),
		WorkloadID:   w.ID,
		WorkloadKind: string(store.WorkloadKindSite),
		Host:         "local",
		ExtraJSON:    `{"status":"deployed","future_writer_key":"survives"}`,
	}
	if err := deps.Store.UpsertContainer(seedRow); err != nil {
		t.Fatalf("seed: %v", err)
	}

	err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
		state.LastCommitSHA = "newsha"
	})
	if err != nil {
		t.Fatalf("saveState: %v", err)
	}

	_, row, err := loadState(deps, w)
	if err != nil {
		t.Fatalf("loadState: %v", err)
	}
	var got map[string]any
	if err := json.Unmarshal([]byte(row.ExtraJSON), &got); err != nil {
		t.Fatalf("unmarshal extra_json: %v", err)
	}
	if got["future_writer_key"] != "survives" {
		t.Errorf("unknown key dropped: %+v", got)
	}
	if got["last_commit_sha"] != "newsha" {
		t.Errorf("typed field not persisted: %+v", got)
	}
}

func TestSaveState_ClearingTypedFieldRemovesKey(t *testing.T) {
	// runtimeStateKeys are stripped from the generic map before merge so
	// clearing a field to "" actually drops the key — not shadowed by a
	// stale carry-over.
	deps, _ := testDeps(t)
	w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}

	if err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
		state.LastError = "something broke"
	}); err != nil {
		t.Fatalf("seed save: %v", err)
	}

	if err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
		state.LastError = ""
	}); err != nil {
		t.Fatalf("clear save: %v", err)
	}

	_, row, err := loadState(deps, w)
	if err != nil {
		t.Fatalf("loadState: %v", err)
	}
	if strings.Contains(row.ExtraJSON, "last_error") {
		t.Errorf("last_error key not removed after clear: %s", row.ExtraJSON)
	}
}

func TestSaveState_RecoversFromInvalidExtraJSON(t *testing.T) {
	// loadState/saveState log and fall back to zero state when
	// extra_json is malformed — they must not panic or refuse the save.
	deps, _ := testDeps(t)
	w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}

	// UpsertContainer now validates extra_json at the boundary, so this
	// test seeds a valid row first and corrupts it via raw SQL to
	// simulate a pre-existing bad row from an upgrade / external edit.
	if err := deps.Store.UpsertContainer(store.Container{
		ID:           containerRowID(w),
		WorkloadID:   w.ID,
		WorkloadKind: string(store.WorkloadKindSite),
		Host:         "local",
		ExtraJSON:    `{}`,
	}); err != nil {
		t.Fatalf("seed row: %v", err)
	}
	if _, err := deps.Store.DB().Exec(
		`UPDATE containers SET extra_json = ? WHERE id = ?`,
		`{not json`, containerRowID(w),
	); err != nil {
		t.Fatalf("corrupt extra_json: %v", err)
	}

	err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
		state.Status = "recovered"
	})
	if err != nil {
		t.Fatalf("saveState: %v", err)
	}

	state, row, err := loadState(deps, w)
	if err != nil {
		t.Fatalf("loadState: %v", err)
	}
	if state.Status != "recovered" {
		t.Errorf("state.Status = %q, want %q", state.Status, "recovered")
	}
	// Recovery must rewrite extra_json as valid JSON — the prior
	// "{not json" garbage shouldn't survive past the save.
	var sanity map[string]any
	if err := json.Unmarshal([]byte(row.ExtraJSON), &sanity); err != nil {
		t.Errorf("recovered extra_json is not valid JSON: %q (%v)", row.ExtraJSON, err)
	}
}

func TestSaveState_ConcurrentWritesDoNotLoseUpdates(t *testing.T) {
	// The per-workload mutex exists to serialize the read-modify-write
	// of containers.extra_json. Without it, two parallel saveState
	// callers can both read the SAME prior state, each apply their
	// mutate, and the second writer's UpsertContainer overwrites the
	// first's contribution — a lost update.
	//
	// To exercise that, every writer reads state.LastError, APPENDS its
	// own marker (separated by comma), and writes the result. With the
	// mutex held, all N markers appear in the final string. Without it,
	// fewer than N appear because some reads see a stale empty/short
	// LastError and overwrite a later concurrent writer's accumulation.
	//
	// Note: SQLite UpsertContainer is atomic on its own, so a torn-row
	// write (ContainerID from A + extra_json from B) cannot happen at
	// the storage layer. The race is purely the Go-side read-modify-
	// write that surrounds it.
	deps, _ := testDeps(t)
	w := plugin.Workload{ID: t.Name() + "-wid", Name: "site"}

	const writers = 20
	var wg sync.WaitGroup
	for i := 0; i < writers; i++ {
		i := i
		wg.Add(1)
		go func() {
			defer wg.Done()
			marker := fmt.Sprintf("w%02d", i)
			if err := saveState(deps, w, func(state *runtimeState, _ *store.Container) {
				if state.LastError == "" {
					state.LastError = marker
				} else {
					state.LastError = state.LastError + "," + marker
				}
			}); err != nil {
				t.Errorf("saveState: %v", err)
			}
		}()
	}
	wg.Wait()

	state, _, err := loadState(deps, w)
	if err != nil {
		t.Fatalf("loadState: %v", err)
	}

	// Every launched writer must appear exactly once in the accumulated
	// LastError. A missing marker means saveState lost an update
	// — the symptom the mutex exists to prevent.
	for i := 0; i < writers; i++ {
		want := fmt.Sprintf("w%02d", i)
		if !strings.Contains(state.LastError, want) {
			t.Errorf("missing marker %q in final state.LastError = %q (writer's update was lost)",
				want, state.LastError)
		}
		if c := strings.Count(state.LastError, want); c != 1 {
			t.Errorf("marker %q appears %d times in %q (want exactly 1)", want, c, state.LastError)
		}
	}
}

func TestBuildEnv_PlainValues(t *testing.T) {
	deps, _ := testDeps(t)
	wid := seedWorkload(t, deps.Store, "wid-plain", "site")

	for _, e := range []store.WorkloadEnv{
		{WorkloadID: wid, Key: "FOO", Value: "1"},
		{WorkloadID: wid, Key: "BAR", Value: "two"},
	} {
		if _, err := deps.Store.SetWorkloadEnv(e); err != nil {
			t.Fatalf("seed env: %v", err)
		}
	}

	got := plugin.BuildWorkloadEnv(deps, wid, "", "static source")
	gotSet := map[string]bool{}
	for _, line := range got {
		gotSet[line] = true
	}
	want := []string{"BAR=two", "FOO=1"}
	if len(got) != len(want) {
		t.Fatalf("buildEnv returned %d, want %d: %v", len(got), len(want), got)
	}
	for _, w := range want {
		if !gotSet[w] {
			t.Errorf("buildEnv missing %q; got %v", w, got)
		}
	}
}

func TestBuildEnv_DecryptsEncryptedValues(t *testing.T) {
	deps, key := testDeps(t)
	wid := seedWorkload(t, deps.Store, "wid-encrypted", "site")

	ciphertext, err := crypto.Encrypt(key, "supersecret")
	if err != nil {
		t.Fatalf("encrypt: %v", err)
	}

	if _, err := deps.Store.SetWorkloadEnv(store.WorkloadEnv{
		WorkloadID: wid, Key: "TOKEN", Value: ciphertext, Encrypted: true,
	}); err != nil {
		t.Fatalf("seed encrypted env: %v", err)
	}

	got := plugin.BuildWorkloadEnv(deps, wid, "", "static source")
	if len(got) != 1 {
		t.Fatalf("buildEnv returned %d, want 1: %v", len(got), got)
	}
	if got[0] != "TOKEN=supersecret" {
		t.Errorf("buildEnv[0] = %q, want %q", got[0], "TOKEN=supersecret")
	}
}

func TestBuildEnv_SkipsRowsThatFailToDecrypt(t *testing.T) {
	// The whole deploy must not fail when one rotated key misses a single
	// env entry — the row is logged and skipped while siblings pass through.
	deps, key := testDeps(t)
	wid := seedWorkload(t, deps.Store, "wid-mixed", "site")

	good, err := crypto.Encrypt(key, "decryptable")
	if err != nil {
		t.Fatalf("encrypt: %v", err)
	}

	for _, e := range []store.WorkloadEnv{
		{WorkloadID: wid, Key: "AAA_GOOD", Value: good, Encrypted: true},
		// Garbage ciphertext flagged encrypted: cannot decrypt.
		{WorkloadID: wid, Key: "BBB_BAD", Value: "deadbeef-not-hex-or-aes", Encrypted: true},
		// Plain row should still pass through untouched.
		{WorkloadID: wid, Key: "CCC_PLAIN", Value: "raw"},
	} {
		if _, err := deps.Store.SetWorkloadEnv(e); err != nil {
			t.Fatalf("seed: %v", err)
		}
	}

	got := plugin.BuildWorkloadEnv(deps, wid, "", "static source")
	// Expect AAA_GOOD and CCC_PLAIN; BBB_BAD silently skipped. Check by
	// set membership so the assertion doesn't depend on ListWorkloadEnv
	// preserving any particular order.
	gotSet := map[string]bool{}
	for _, line := range got {
		gotSet[line] = true
	}
	want := []string{"AAA_GOOD=decryptable", "CCC_PLAIN=raw"}
	if len(got) != len(want) {
		t.Fatalf("buildEnv returned %d, want %d: %v", len(got), len(want), got)
	}
	for _, w := range want {
		if !gotSet[w] {
			t.Errorf("buildEnv missing %q; got %v", w, got)
		}
	}
	// The bad row must be fully absent — not even as a key with empty value.
	for _, line := range got {
		if strings.HasPrefix(line, "BBB_BAD=") {
			t.Errorf("bad-ciphertext row leaked into output: %q", line)
		}
		if strings.Contains(line, "deadbeef-not-hex-or-aes") {
			t.Errorf("bad ciphertext leaked into output: %q", line)
		}
	}
}

func TestBuildEnv_EmptyOnMissingWorkload(t *testing.T) {
	deps, _ := testDeps(t)
	got := plugin.BuildWorkloadEnv(deps, "wid-no-env", "", "static source")
	if len(got) != 0 {
		t.Errorf("buildEnv returned %d, want 0: %v", len(got), got)
	}
}

func TestBuildEnv_StoreFailurePropagatesAsEmpty(t *testing.T) {
	// buildEnv logs and returns nil when ListWorkloadEnv fails (closed
	// store). The deploy continues without env rather than fataling.
	//
	// Open the store directly without the t.Cleanup-registered Close so
	// we can close it inside the test and avoid a double-close at end.
	st, err := store.New(":memory:")
	if err != nil {
		t.Fatalf("open store: %v", err)
	}
	if err := st.Close(); err != nil {
		t.Fatalf("close store: %v", err)
	}
	deps := plugin.Deps{Store: st}

	got := plugin.BuildWorkloadEnv(deps, "anything", "", "static source")
	if len(got) != 0 {
		t.Errorf("buildEnv returned %d, want 0 on store failure: %v", len(got), got)
	}
}
