// jobs_backfill_test.go — handler tests for the two new backfill
// endpoints:
//
//   - POST /api/v1/deployments/{depId}/refresh-watch
//   - GET  /api/v1/deployments/{depId}/components/state
//
// Coverage matrix (matches the GATES list in the issue):
//
//  1. POST /refresh-watch returns 202 + seededAt when the seeder hook
//     fires (happy path: bridge writes Jobs from the informer cache).
//  2. POST /refresh-watch returns 200 + alreadyActive when a watcher
//     is already running for this deployment (idempotent).
//  3. POST /refresh-watch returns 409 watch-not-resumable when the
//     deployment has no kubeconfigPath persisted.
//  4. POST /refresh-watch returns 404 when the deployment id is
//     unknown.
//  5. GET /components/state returns the live informer cache as JSON
//     when a Watcher is attached.
//  6. GET /components/state falls back to dep.Result.ComponentStates
//     when no Watcher is attached.
//  7. GET /components/state returns 404 for an unknown deployment.
package handler

import (
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/go-chi/chi/v5"

	"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/jobs"
	"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner"
)

// newBackfillRouter wires the two new endpoints + a jobs.Store so the
// /refresh-watch handler can write Jobs through the bridge.
func newBackfillRouter(t *testing.T) (*chi.Mux, *jobs.Store, *Handler) {
	t.Helper()
	js, err := jobs.NewStore(t.TempDir())
	if err != nil {
		t.Fatalf("NewStore: %v", err)
	}
	h := NewWithJobsStore(slog.New(slog.NewJSONHandler(io.Discard, nil)), js)
	r := chi.NewRouter()
	r.Post("/api/v1/deployments/{depId}/refresh-watch", h.RefreshWatch)
	r.Get("/api/v1/deployments/{depId}/components/state", h.GetComponentsState)
	r.Get("/api/v1/deployments/{depId}/jobs", h.ListJobs)
	return r, js, h
}

// makeDeploymentForBackfill registers a Deployment with the given
// kubeconfig file path on disk so RefreshWatch's PVC-readability check
// succeeds. Mirrors makeDeploymentWithKubeconfig but skips the
// channel allocation that runPhase1Watch tests need.
func makeDeploymentForBackfill(t *testing.T, h *Handler, id, kubeconfig string) *Deployment {
	t.Helper()
	var path string
	if kubeconfig != "" {
		path = filepath.Join(t.TempDir(), id+".yaml")
		if err := os.WriteFile(path, []byte(kubeconfig), 0o600); err != nil {
			t.Fatalf("write kubeconfig: %v", err)
		}
	}
	dep := &Deployment{
		ID:        id,
		Status:    "ready",
		StartedAt: time.Now(),
		eventsCh:  make(chan provisioner.Event, 256),
		done:      make(chan struct{}),
		Request: provisioner.Request{
			SovereignFQDN: "test." + id + ".example",
			Region:        "fsn1",
		},
		Result: &provisioner.Result{
			SovereignFQDN:  "test." + id + ".example",
			KubeconfigPath: path,
		},
	}
	close(dep.done) // mark as completed so emitWatchEvent's send-side path is safe
	dep.done = make(chan struct{})
	h.deployments.Store(id, dep)
	return dep
}

func TestRefreshWatch_202OnSeededHappyPath(t *testing.T) {
	r, js, h := newBackfillRouter(t)
	h.dynamicFactory = fakeDynamicFactoryFromObjects(
		makeReadyHR("bp-cilium"),
		makeReadyHR("bp-cert-manager"),
		makeReadyHR("bp-flux"),
	)
	// Tight timeout so the watcher returns quickly; the seed hook
	// fires before the watch loop terminates anyway.
	h.phase1WatchTimeout = 2 * time.Second
	h.refreshWatchSeedTimeout = 5 * time.Second
	h.phase1MinBootstrapKitHRs = 3 // 3 HRs in the fake → terminate-on-all-done

	dep := makeDeploymentForBackfill(t, h, "dep-refresh-202", "fake-kubeconfig: bytes")

	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/"+dep.ID+"/refresh-watch", nil))

	if rec.Code != http.StatusAccepted {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	var body struct {
		Watching   bool   `json:"watching"`
		SeededAt   string `json:"seededAt"`
		Components []struct {
			AppID  string `json:"appId"`
			Status string `json:"status"`
		} `json:"components"`
	}
	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if !body.Watching {
		t.Errorf("watching=false in 202 response")
	}
	if body.SeededAt == "" {
		t.Errorf("seededAt missing")
	}
	if len(body.Components) != 3 {
		t.Errorf("expected 3 components, got %d", len(body.Components))
	}

	// The bridge must have written one leaf Job per HR via the seeder
	// hook plus the synthesised bootstrap-kit parent group (4 total).
	// All three leaves are HelmStateInstalled in the fake fixture so
	// the rolled-up group status reaches succeeded as well.
	got, err := js.ListJobs(dep.ID)
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 4 {
		t.Errorf("expected 4 jobs after seed (3 leaves + 1 group), got %d (%+v)", len(got), got)
	}
	for _, j := range got {
		if j.Status != jobs.StatusSucceeded {
			t.Errorf("%s: status=%q want succeeded", j.JobName, j.Status)
		}
	}
}

func TestRefreshWatch_200WhenAlreadyActive(t *testing.T) {
	r, _, h := newBackfillRouter(t)
	h.dynamicFactory = fakeDynamicFactoryFromObjects(
		makeReadyHR("bp-cilium"),
	)
	h.phase1WatchTimeout = 2 * time.Second
	h.refreshWatchSeedTimeout = 5 * time.Second
	h.phase1MinBootstrapKitHRs = 1

	dep := makeDeploymentForBackfill(t, h, "dep-refresh-200", "fake-kubeconfig: bytes")

	// Drive the first refresh — establishes liveWatcher.
	rec1 := httptest.NewRecorder()
	r.ServeHTTP(rec1, httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/"+dep.ID+"/refresh-watch", nil))
	if rec1.Code != http.StatusAccepted {
		t.Fatalf("first call status=%d body=%s", rec1.Code, rec1.Body.String())
	}

	// Wait until liveWatcher is non-nil (the goroutine sets it
	// before returning to the response writer, but the watcher's
	// Watch loop may also clear it once terminated). The
	// already-active branch needs liveWatcher != nil at read time;
	// poll briefly to give the system a deterministic check.
	deadline := time.Now().Add(time.Second)
	for time.Now().Before(deadline) {
		dep.mu.Lock()
		w := dep.liveWatcher
		dep.mu.Unlock()
		if w != nil {
			break
		}
		time.Sleep(10 * time.Millisecond)
	}

	// Second call: with a 1-HR fake, the watcher terminates fast.
	// Either we hit the already-active branch (200) or the watcher
	// has already finished and we get a fresh 202. Both are valid;
	// the assertion is that idempotency-of-call doesn't 5xx and
	// returns one of those codes.
	rec2 := httptest.NewRecorder()
	r.ServeHTTP(rec2, httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/"+dep.ID+"/refresh-watch", nil))
	if rec2.Code != http.StatusOK && rec2.Code != http.StatusAccepted {
		t.Fatalf("second call status=%d body=%s want 200|202", rec2.Code, rec2.Body.String())
	}
}

func TestRefreshWatch_409WhenNoKubeconfig(t *testing.T) {
	r, _, h := newBackfillRouter(t)

	dep := &Deployment{
		ID:        "dep-no-kc",
		Status:    "ready",
		StartedAt: time.Now(),
		eventsCh:  make(chan provisioner.Event, 4),
		done:      make(chan struct{}),
		Result:    &provisioner.Result{ /* no KubeconfigPath */ },
	}
	close(dep.done)
	dep.done = make(chan struct{})
	h.deployments.Store(dep.ID, dep)

	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/dep-no-kc/refresh-watch", nil))
	if rec.Code != http.StatusConflict {
		t.Fatalf("status=%d body=%s want 409", rec.Code, rec.Body.String())
	}
}

func TestRefreshWatch_404UnknownDeployment(t *testing.T) {
	r, _, _ := newBackfillRouter(t)
	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/dep-does-not-exist/refresh-watch", nil))
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status=%d body=%s want 404", rec.Code, rec.Body.String())
	}
}

func TestRefreshWatch_503WhenJobsStoreNil(t *testing.T) {
	// Build a Handler with no jobs store — emulates the CI-fallback
	// path where /var/lib is read-only.
	h := NewWithPDM(silentLogger(), &fakePDM{})
	r := chi.NewRouter()
	r.Post("/api/v1/deployments/{depId}/refresh-watch", h.RefreshWatch)
	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/dep-x/refresh-watch", nil))
	if rec.Code != http.StatusServiceUnavailable {
		t.Errorf("status=%d want 503", rec.Code)
	}
}

func TestComponentsState_LiveWatcherShape(t *testing.T) {
	r, _, h := newBackfillRouter(t)
	h.dynamicFactory = fakeDynamicFactoryFromObjects(
		makeReadyHR("bp-cilium"),
		makeReadyHR("bp-cert-manager"),
	)
	h.phase1WatchTimeout = 5 * time.Second
	h.refreshWatchSeedTimeout = 5 * time.Second
	h.phase1MinBootstrapKitHRs = 99 // never terminates → liveWatcher persists

	dep := makeDeploymentForBackfill(t, h, "dep-state-live", "fake-kubeconfig: bytes")

	// Kick a /refresh-watch so liveWatcher is wired. We don't care
	// about the response code — we care that liveWatcher gets
	// stamped before the watcher's Watch loop terminates.
	r.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost,
		"/api/v1/deployments/"+dep.ID+"/refresh-watch", nil))

	// Poll until liveWatcher is non-nil (the goroutine stamps it
	// before serving the 202).
	deadline := time.Now().Add(2 * time.Second)
	for time.Now().Before(deadline) {
		dep.mu.Lock()
		w := dep.liveWatcher
		dep.mu.Unlock()
		if w != nil {
			break
		}
		time.Sleep(10 * time.Millisecond)
	}

	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
		"/api/v1/deployments/"+dep.ID+"/components/state", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	var body struct {
		Watching   bool `json:"watching"`
		Components []struct {
			AppID           string `json:"appId"`
			Status          string `json:"status"`
			HelmReleaseName string `json:"helmReleaseName"`
			Namespace       string `json:"namespace"`
		} `json:"components"`
	}
	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if !body.Watching {
		t.Errorf("watching=false")
	}
	if len(body.Components) != 2 {
		t.Errorf("expected 2 components, got %d", len(body.Components))
	}
	for _, c := range body.Components {
		if c.HelmReleaseName == "" || c.AppID == "" || c.Namespace == "" {
			t.Errorf("component shape incomplete: %+v", c)
		}
	}
}

func TestComponentsState_FallsBackToPersistedStates(t *testing.T) {
	r, _, h := newBackfillRouter(t)

	dep := &Deployment{
		ID:        "dep-state-fallback",
		Status:    "ready",
		StartedAt: time.Now(),
		eventsCh:  make(chan provisioner.Event, 4),
		done:      make(chan struct{}),
		Result: &provisioner.Result{
			SovereignFQDN: "test.example",
			ComponentStates: map[string]string{
				"cilium":       "installed",
				"cert-manager": "installed",
				"flux":         "failed",
			},
		},
	}
	close(dep.done)
	dep.done = make(chan struct{})
	h.deployments.Store(dep.ID, dep)

	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
		"/api/v1/deployments/dep-state-fallback/components/state", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
	}
	var body struct {
		Watching   bool `json:"watching"`
		Components []struct {
			AppID  string `json:"appId"`
			Status string `json:"status"`
		} `json:"components"`
	}
	if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if body.Watching {
		t.Errorf("watching=true on fallback path")
	}
	if len(body.Components) != 3 {
		t.Errorf("expected 3 components, got %d", len(body.Components))
	}
	statusByApp := map[string]string{}
	for _, c := range body.Components {
		statusByApp[c.AppID] = c.Status
	}
	for app, want := range map[string]string{
		"cilium":       "installed",
		"cert-manager": "installed",
		"flux":         "failed",
	} {
		if statusByApp[app] != want {
			t.Errorf("%s: want %q, got %q", app, want, statusByApp[app])
		}
	}
}

func TestComponentsState_404UnknownDeployment(t *testing.T) {
	r, _, _ := newBackfillRouter(t)
	rec := httptest.NewRecorder()
	r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet,
		"/api/v1/deployments/dep-does-not-exist/components/state", nil))
	if rec.Code != http.StatusNotFound {
		t.Errorf("status=%d want 404", rec.Code)
	}
}
