package api

import (
	"encoding/json"
	"net/http"
	"testing"

	"github.com/alexei/tinyforge/internal/store"
)

// =============================================================================
// GET /api/workloads/{id}/runtime-state
// =============================================================================

func TestGetWorkloadRuntimeState_NotFound_404(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodGet, "/api/workloads/does-not-exist/runtime-state", nil)
	if resp.StatusCode != http.StatusNotFound {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

func TestGetWorkloadRuntimeState_NonStaticSource_ReturnsBareKind(t *testing.T) {
	e := newAPITestEnv(t)
	wl, err := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindProject),
		Name:         "img-app",
		SourceKind:   "image",
		SourceConfig: `{"image":"nginx:1.25"}`,
	})
	if err != nil {
		t.Fatalf("seed: %v", err)
	}
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got runtimeStatePayload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if got.SourceKind != "image" {
		t.Errorf("SourceKind = %q, want image", got.SourceKind)
	}
	if got.HasState {
		t.Errorf("HasState = true, want false for non-static source")
	}
}

func TestGetWorkloadRuntimeState_StaticSourceNeverDeployed_HasStateFalse(t *testing.T) {
	e := newAPITestEnv(t)
	wl, err := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindSite),
		Name:         "pages",
		SourceKind:   "static",
		SourceConfig: `{"provider":"gitea"}`,
	})
	if err != nil {
		t.Fatalf("seed: %v", err)
	}
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got runtimeStatePayload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if got.HasState {
		t.Errorf("HasState = true, want false (never deployed)")
	}
}

func TestGetWorkloadRuntimeState_StaticSourceDeployed_DecodesExtraJSON(t *testing.T) {
	e := newAPITestEnv(t)
	wl, err := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindSite),
		Name:         "pages",
		SourceKind:   "static",
		SourceConfig: `{"provider":"gitea"}`,
	})
	if err != nil {
		t.Fatalf("seed workload: %v", err)
	}
	extra, _ := json.Marshal(map[string]any{
		"status":          "deployed",
		"last_commit_sha": "abc1234",
		"last_sync_at":    "2026-05-16T10:00:00Z",
		"last_error":      "",
		// An unknown key — confirms decoding is lenient.
		"unknown_future_field": "ignored",
	})
	if err := e.store.UpsertContainer(store.Container{
		ID:           wl.ID + ":site",
		WorkloadID:   wl.ID,
		WorkloadKind: string(store.WorkloadKindSite),
		Host:         "local",
		ContainerID:  "abcdef1234",
		State:        "running",
		ExtraJSON:    string(extra),
	}); err != nil {
		t.Fatalf("seed container: %v", err)
	}

	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got runtimeStatePayload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if !got.HasState {
		t.Fatalf("HasState = false, want true")
	}
	if got.ContainerID != "abcdef1234" || got.State != "running" {
		t.Errorf("container fields = (%q,%q), want (abcdef1234, running)", got.ContainerID, got.State)
	}
	if got.Status != "deployed" || got.LastCommitSHA != "abc1234" || got.LastSyncAt == "" {
		t.Errorf("runtime fields = %+v, want deployed/abc1234/non-empty", got)
	}
}

func TestGetWorkloadRuntimeState_MalformedExtraJSON_ReturnsContainerFieldsOnly(t *testing.T) {
	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindSite),
		Name:         "pages",
		SourceKind:   "static",
		SourceConfig: `{"provider":"gitea"}`,
	})
	// Seed a row with a valid extra_json first, then corrupt it via raw
	// SQL. Prior to the write-side validateExtraJSON guard this test
	// could pass a malformed string straight to UpsertContainer; the
	// guard now rejects that at the boundary, which is the correct
	// behaviour. The reader resilience this test verifies remains
	// relevant for pre-existing bad rows from upgrades or external
	// manipulation, so we still produce one via direct SQL.
	if err := e.store.UpsertContainer(store.Container{
		ID:           wl.ID + ":site",
		WorkloadID:   wl.ID,
		WorkloadKind: string(store.WorkloadKindSite),
		Host:         "local",
		ContainerID:  "abc",
		State:        "running",
		ExtraJSON:    `{}`,
	}); err != nil {
		t.Fatalf("seed: %v", err)
	}
	if _, err := e.store.DB().Exec(
		`UPDATE containers SET extra_json = ? WHERE id = ?`,
		`{this is not json`, wl.ID+":site",
	); err != nil {
		t.Fatalf("corrupt extra_json: %v", err)
	}
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200 (decode is non-fatal)", resp.StatusCode)
	}
	var got runtimeStatePayload
	_ = decodeEnvelope(t, resp, &got)
	if !got.HasState || got.ContainerID != "abc" {
		t.Errorf("expected HasState + container id present, got %+v", got)
	}
	if got.Status != "" || got.LastCommitSHA != "" {
		t.Errorf("expected typed fields empty on decode failure, got %+v", got)
	}
}

func TestGetWorkloadRuntimeState_DockerfileSourceDeployed_DecodesExtraJSON(t *testing.T) {
	e := newAPITestEnv(t)
	wl, err := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindProject),
		Name:         "build-app",
		SourceKind:   "dockerfile",
		SourceConfig: `{"provider":"gitea","port":3000}`,
	})
	if err != nil {
		t.Fatalf("seed workload: %v", err)
	}
	extra, _ := json.Marshal(map[string]any{
		"status":          "deployed",
		"last_commit_sha": "deadbeef",
		"last_sync_at":    "2026-05-23T10:00:00Z",
		"last_error":      "",
	})
	if err := e.store.UpsertContainer(store.Container{
		ID:           wl.ID + ":dockerfile",
		WorkloadID:   wl.ID,
		WorkloadKind: string(store.WorkloadKindBuild),
		Host:         "local",
		ContainerID:  "ffeeddcc",
		State:        "running",
		ExtraJSON:    string(extra),
	}); err != nil {
		t.Fatalf("seed container: %v", err)
	}

	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/runtime-state", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got runtimeStatePayload
	if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" {
		t.Fatalf("envelope error: %q", errMsg)
	}
	if !got.HasState {
		t.Fatalf("HasState = false, want true")
	}
	if got.SourceKind != "dockerfile" {
		t.Errorf("SourceKind = %q, want dockerfile", got.SourceKind)
	}
	if got.ContainerID != "ffeeddcc" || got.State != "running" {
		t.Errorf("container fields = (%q,%q), want (ffeeddcc, running)", got.ContainerID, got.State)
	}
	if got.Status != "deployed" || got.LastCommitSHA != "deadbeef" {
		t.Errorf("runtime fields = %+v, want deployed/deadbeef", got)
	}
}

// =============================================================================
// GET /api/workloads/{id}/storage
// =============================================================================

func TestGetWorkloadStorage_NonStaticSource_EmptyPayload(t *testing.T) {
	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindProject),
		Name:         "img-app",
		SourceKind:   "image",
		SourceConfig: `{"image":"nginx"}`,
	})
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got storageUsagePayload
	_ = decodeEnvelope(t, resp, &got)
	if got.Enabled || got.UsedBytes != 0 {
		t.Errorf("expected empty payload for non-static, got %+v", got)
	}
}

func TestGetWorkloadStorage_StaticDisabled_ReturnsLimitButNoUsage(t *testing.T) {
	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindSite),
		Name:         "pages",
		SourceKind:   "static",
		SourceConfig: `{"provider":"gitea","storage_enabled":false,"storage_limit_mb":0}`,
	})
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got storageUsagePayload
	_ = decodeEnvelope(t, resp, &got)
	if got.Enabled {
		t.Errorf("Enabled = true, want false")
	}
}

func TestGetWorkloadStorage_StaticEnabledNoDockerClient_ReturnsZeroUsage(t *testing.T) {
	// docker is nil in the test env — the handler must still return
	// a valid payload (enabled + limit) without panicking.
	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindSite),
		Name:         "pages",
		SourceKind:   "static",
		SourceConfig: `{"provider":"gitea","storage_enabled":true,"storage_limit_mb":512}`,
	})
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, want 200", resp.StatusCode)
	}
	var got storageUsagePayload
	_ = decodeEnvelope(t, resp, &got)
	if !got.Enabled || got.LimitMB != 512 {
		t.Errorf("got %+v, want enabled=true limit=512", got)
	}
	if got.UsedBytes != 0 {
		t.Errorf("UsedBytes = %d, want 0 (no docker client)", got.UsedBytes)
	}
}

// =============================================================================
// POST /api/workloads/{id}/{stop,start}
// =============================================================================

func TestStopPluginWorkload_NotFound_404(t *testing.T) {
	e := newAPITestEnv(t)
	resp := e.do(t, http.MethodPost, "/api/workloads/missing/stop", nil)
	if resp.StatusCode != http.StatusNotFound {
		_ = decodeEnvelope(t, resp, nil)
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

func TestStopPluginWorkload_NoDockerClient_503(t *testing.T) {
	// The test env passes a nil dockerClient. The handler must refuse
	// with 503 rather than panicking on a nil deref.
	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind: string(store.WorkloadKindSite), Name: "x", SourceKind: "static",
	})
	resp := e.do(t, http.MethodPost, "/api/workloads/"+wl.ID+"/stop", nil)
	if resp.StatusCode != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want 503", resp.StatusCode)
	}
}

func TestStartPluginWorkload_NoDockerClient_503(t *testing.T) {
	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind: string(store.WorkloadKindSite), Name: "x", SourceKind: "static",
	})
	resp := e.do(t, http.MethodPost, "/api/workloads/"+wl.ID+"/start", nil)
	if resp.StatusCode != http.StatusServiceUnavailable {
		t.Fatalf("status = %d, want 503", resp.StatusCode)
	}
}

// =============================================================================
// stripImageTag-style behaviour assertions for the storage probe cache —
// memoization wins on the second call within the TTL window.
// =============================================================================

func TestStorageProbeCache_SecondCallSkipsProbe(t *testing.T) {
	// Clear the cache so a different test order doesn't pre-warm.
	storageProbeMu.Lock()
	storageProbeCache = map[string]storageProbeEntry{}
	storageProbeMu.Unlock()

	e := newAPITestEnv(t)
	wl, _ := e.store.CreateWorkload(store.Workload{
		Kind:         string(store.WorkloadKindSite),
		Name:         "pages",
		SourceKind:   "static",
		SourceConfig: `{"provider":"gitea","storage_enabled":true,"storage_limit_mb":256}`,
	})

	// First call populates the cache (docker is nil, so it short-circuits
	// before the probe and never writes a cache entry — this test is
	// asserting that the no-docker path is well-behaved).
	resp := e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("first call status = %d, want 200", resp.StatusCode)
	}
	resp.Body.Close()

	// Second call should also return 200 — the path is idempotent.
	resp = e.do(t, http.MethodGet, "/api/workloads/"+wl.ID+"/storage", nil)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("second call status = %d, want 200", resp.StatusCode)
	}
	resp.Body.Close()
}
