package web

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"testing"

	"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)

// v0.151.0 — the /sharing/status CONTRACT (DIAG-sharing-2026-07-20.md, S-1 + S-4 core).
//
// The endpoint carries two channels that mean different things to the client:
//
//	phase   — the ensure JOB, and `running` on it is an EDGE the client answers with location.reload()
//	running — the service LEVEL, true whenever the container is up
//
// v0.147.0 coerced idle→running on the PHASE channel so liveness could never be contradicted. Every
// steady-state page load then reported a terminal edge on its first poll and reloaded — forever,
// ~1.2s apart, for every customer with sharing enabled. These tests pin the separation from both
// sides: a live container must NOT manufacture a phase (A), and a real bring-up must be reported
// exactly ONCE (B) while the non-reloading phases stay put (C).

// statusServer returns a Server whose liveness probe answers `running` and whose ensure slot starts
// empty — the steady state of every box that has had sharing on for more than a moment.
func statusServer(t *testing.T, running bool) *Server {
	t.Helper()
	s := testPageServer(t)
	s.stackMgr.SetSambaRunProbe(func() bool { return running })
	return s
}

func getStatus(t *testing.T, s *Server) (phase string, isRunning bool) {
	t.Helper()
	rec := httptest.NewRecorder()
	s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil))
	if rec.Code != http.StatusOK {
		t.Fatalf("status: got HTTP %d, want 200", rec.Code)
	}
	var env struct {
		OK   bool `json:"ok"`
		Data struct {
			Phase   string `json:"phase"`
			Error   string `json:"error"`
			Running bool   `json:"running"`
		} `json:"data"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
		t.Fatalf("decode %q: %v", rec.Body.String(), err)
	}
	if !env.OK {
		t.Fatalf("envelope not ok: %s", rec.Body.String())
	}
	return env.Data.Phase, env.Data.Running
}

// Scenario A — a live container alone must never produce an edge. THE loop test: with the v0.147.0
// coercion in place this fails on the very first call, which is exactly when the customer's page
// reloaded.
func TestSharingStatus_SteadyStateProducesNoEdge(t *testing.T) {
	s := statusServer(t, true)
	if job := s.sambaEnsure.snapshot(); job != nil {
		t.Fatalf("precondition: ensure slot must be empty, got phase %q", job.Phase)
	}
	for i := 1; i <= 2; i++ {
		phase, running := getStatus(t, s)
		if phase != sambaPhaseIdle {
			t.Errorf("call %d: phase = %q, want %q — a live container must not manufacture a job edge (this IS the reload loop)", i, phase, sambaPhaseIdle)
		}
		if !running {
			t.Errorf("call %d: running = false, want true", i)
		}
	}
}

// The b5d78d1 REGRESSION test. The coercion was defensive, not a fix for a named repro: its comment
// read „a stale idle/running job must never contradict reality: liveness wins on a fresh page". That
// duty is real and it survives — discharged by the `running` field, which is where a LEVEL belongs.
// This is the test that would catch someone deleting the coercion's intent along with its code.
func TestSharingStatus_LivenessStillReportedWithoutAnyJob(t *testing.T) {
	for _, alive := range []bool{true, false} {
		s := statusServer(t, alive)
		phase, running := getStatus(t, s)
		if running != alive {
			t.Errorf("SambaRunning()=%v: running = %v, want %v — liveness must reach the client with no job in memory", alive, running, alive)
		}
		if phase != sambaPhaseIdle {
			t.Errorf("SambaRunning()=%v: phase = %q, want %q", alive, phase, sambaPhaseIdle)
		}
	}
}

// Scenario B — a REAL bring-up: one edge, then silence. Without serve-once the loop returns after
// every future image update, because the finished job outlives the reload it triggered.
func TestSharingStatus_RealBringUpServedExactlyOnce(t *testing.T) {
	s := statusServer(t, true)
	s.sambaEnsure.set(&sambaEnsureJob{Phase: sambaPhaseRunning})

	phase, running := getStatus(t, s)
	if phase != sambaPhaseRunning {
		t.Fatalf("first call: phase = %q, want %q — the genuine success edge must survive", phase, sambaPhaseRunning)
	}
	if !running {
		t.Error("first call: running = false, want true")
	}

	// The page the first response caused to reload polls again. It must find nothing.
	phase, running = getStatus(t, s)
	if phase != sambaPhaseIdle {
		t.Errorf("second call: phase = %q, want %q — a re-served success re-arms the reload (post-bring-up loop)", phase, sambaPhaseIdle)
	}
	if !running {
		t.Error("second call: running = false, want true — the LEVEL must outlive the consumed edge")
	}
}

// An in-flight job must never be consumed out from under itself: the goroutine sets the terminal
// phase BEFORE its deferred release(), and eating it in that window loses the success the customer
// is waiting on.
func TestSharingStatus_RunningNotConsumedWhileInFlight(t *testing.T) {
	s := statusServer(t, true)
	if !s.sambaEnsure.acquire(&sambaEnsureJob{Phase: sambaPhaseRunning}) {
		t.Fatal("acquire refused")
	}
	if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
		t.Fatalf("in-flight: phase = %q, want %q", phase, sambaPhaseRunning)
	}
	if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
		t.Errorf("in-flight second call: phase = %q, want %q — consumed while the slot was still held", phase, sambaPhaseRunning)
	}
	s.sambaEnsure.release()
	if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
		t.Errorf("after release: phase = %q, want %q — the edge must be served once, now", phase, sambaPhaseRunning)
	}
	if phase, _ := getStatus(t, s); phase != sambaPhaseIdle {
		t.Errorf("after release, second call: phase = %q, want %q", phase, sambaPhaseIdle)
	}
}

// Scenario C — serve-once applies to `running` ONLY. `failed` and `needs_password` are durable
// explanations whose client path stops the timer and shows a card with NO reload, so stickiness is
// informative and cannot loop; in-flight phases must survive being polled.
func TestSharingStatus_NonEdgePhasesStaySticky(t *testing.T) {
	for _, phase := range []string{sambaPhaseFailed, sambaPhaseNeedsPassword, sambaPhasePulling, sambaPhaseStarting} {
		s := statusServer(t, true)
		s.sambaEnsure.set(&sambaEnsureJob{Phase: phase, Error: "boom"})
		for i := 1; i <= 3; i++ {
			if got, _ := getStatus(t, s); got != phase {
				t.Errorf("%s call %d: phase = %q, want it to persist", phase, i, got)
			}
		}
	}
}

// The JSON contract itself: keys and shape are what sharing.html's untouched poll reads.
func TestSharingStatus_EnvelopeShapeUnchanged(t *testing.T) {
	s := statusServer(t, true)
	rec := httptest.NewRecorder()
	s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil))
	var env struct {
		OK   bool                       `json:"ok"`
		Data map[string]json.RawMessage `json:"data"`
	}
	if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"phase", "error", "running"} {
		if _, ok := env.Data[k]; !ok {
			t.Errorf("data.%s missing — the client contract changed", k)
		}
	}
	if len(env.Data) != 3 {
		t.Errorf("data has %d keys, want exactly 3", len(env.Data))
	}
}

// A nil stack manager must not panic the poll (the handler's own liveness guard).
func TestSharingStatus_NilStackManagerIsQuiet(t *testing.T) {
	s := &Server{logger: log.New(io.Discard, "", 0), cfg: &config.Config{}}
	if phase, running := getStatus(t, s); phase != sambaPhaseIdle || running {
		t.Errorf("nil stackMgr: phase=%q running=%v, want idle/false", phase, running)
	}
}
