package api

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

	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// R-311 (v0.103.0) — GET /hosts/<id>/escrow/retained.
//
// WHY THESE TESTS EXIST. `ListSupersededEscrow` had zero production callers for nineteen days; the
// retention it reads was proven on 2026-08-12 to hold the right material and to open a store the box
// itself could not, while the customer was told their (correct) code opened nothing. These tests pin
// the endpoint that ends that, and — more importantly — they pin the two things it must NOT do:
// serve a package that cannot be opened, and let one host read another's.

type retainedResp struct {
	HostID          string `json:"host_id"`
	Count           int    `json:"count"`
	UnopenableCount int    `json:"unopenable_count"`
	TruncatedCount  int    `json:"truncated_count"`
	Packages        []struct {
		Index             int    `json:"index"`
		SupersededAt      string `json:"superseded_at"`
		KeyFingerprint    string `json:"key_fingerprint"`
		IdentityEscrowB64 string `json:"identity_escrow_b64"`
	} `json:"packages"`
}

func getRetained(t *testing.T, h *Handler, hostID, key string) (int, retainedResp) {
	t.Helper()
	rr := do(h, http.MethodGet, "/hosts/"+hostID+"/escrow/retained", key, "")
	var out retainedResp
	if rr.Code == http.StatusOK {
		if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
			t.Fatalf("decode retained response: %v (body %s)", err, rr.Body.String())
		}
	}
	return rr.Code, out
}

// seedSuperseded drives the REAL supersession path (a PUT sealing a different password demotes the
// current row) rather than inserting into host_escrow_superseded directly — so what the test proves
// includes the demote wiring, not just this handler's SELECT.
func seedSuperseded(t *testing.T, h *Handler, hostID, key string, gens []struct{ sha, identity string }) {
	t.Helper()
	for i, g := range gens {
		if rr := do(h, http.MethodPut, "/hosts/"+hostID+"/escrow", key, escrowBodyWithHash([]byte("k"), g.sha, g.identity)); rr.Code != http.StatusOK {
			t.Fatalf("seed PUT %d = %d (%s)", i, rr.Code, rr.Body.String())
		}
	}
}

// THE ONE THAT MATTERS: a retained package that carries key material is served, and the caller can
// tell it apart from the current row.
//
// RED-PROOF: change the handler's SELECT source from ListSupersededEscrow to GetHostDRBundle (i.e.
// serve the CURRENT row) → the served blob becomes "age-gen3" → this FAILS on the body comparison,
// which is exactly the confusion the endpoint exists to end.
func TestRetainedEscrowGet_ServesRetainedPackages(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{
		{"SHA_GEN1", "age-gen1"},
		{"SHA_GEN2", "age-gen2"},
		{"SHA_GEN3", "age-gen3"}, // current after this
	})

	code, out := getRetained(t, h, "h1", "HKEY")
	if code != http.StatusOK {
		t.Fatalf("GET retained = %d, want 200", code)
	}
	if out.Count != 2 || len(out.Packages) != 2 {
		t.Fatalf("want 2 retained packages, got count=%d len=%d", out.Count, len(out.Packages))
	}
	if out.UnopenableCount != 0 {
		t.Errorf("unopenable_count = %d, want 0 (every seeded row carried an identity blob)", out.UnopenableCount)
	}
	// The retained packages must be the SUPERSEDED generations, never the current one. If this ever
	// serves gen3 the endpoint is answering the wrong question and the customer is misled again.
	got := map[string]bool{}
	for _, p := range out.Packages {
		b, err := base64.StdEncoding.DecodeString(p.IdentityEscrowB64)
		if err != nil {
			t.Fatalf("package %d: identity blob is not base64: %v", p.Index, err)
		}
		got[string(b)] = true
		if p.SupersededAt == "" {
			t.Errorf("package %d has no superseded_at — the screen needs a date to name the package", p.Index)
		}
	}
	if !got["age-gen1"] || !got["age-gen2"] {
		t.Errorf("served packages = %v, want the two SUPERSEDED generations (age-gen1, age-gen2)", got)
	}
	if got["age-gen3"] {
		t.Error("the CURRENT package was served as retained — that is the wrong-package confusion this endpoint exists to end")
	}
}

// A retained row with NO identity blob (every pre-v0.93.0 row) must be WITHHELD and COUNTED. Serving
// it would have the agent try a package that cannot open anything, and would let the screen claim an
// earlier package is recoverable on exactly the boxes hurt by the original defect.
//
// RED-PROOF: delete the `if len(e.IdentityBlob) == 0 { unopenable++; continue }` guard → the empty
// row is served as a package → Count becomes 2 and UnopenableCount 0 → this FAILS twice.
func TestRetainedEscrowGet_WithholdsAndCountsUnopenableRows(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	// gen1 carries NO identity blob — the pre-v0.93.0 shape.
	seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{
		{"SHA_GEN1", ""},
		{"SHA_GEN2", "age-gen2"},
		{"SHA_GEN3", "age-gen3"},
	})

	code, out := getRetained(t, h, "h1", "HKEY")
	if code != http.StatusOK {
		t.Fatalf("GET retained = %d, want 200", code)
	}
	if out.Count != 1 {
		t.Errorf("count = %d, want 1 (only gen2 carries material)", out.Count)
	}
	if out.UnopenableCount != 1 {
		t.Errorf("unopenable_count = %d, want 1 — the caller cannot derive this and needs it to explain "+
			"why a correct old code opens nothing on such a box", out.UnopenableCount)
	}
	for _, p := range out.Packages {
		if p.IdentityEscrowB64 == "" {
			t.Error("a package with an empty identity blob was served — it can never open anything")
		}
	}
}

// SELF-SCOPE. The current-row GET has this and a retained read is strictly more material, so the same
// asymmetry must hold: a host key reads only its own.
//
// RED-PROOF: remove the `!isGlobal && authHostID != pathHostID` branch → h2's key reads h1's packages
// → this FAILS with 200.
func TestRetainedEscrowGet_SelfScoped(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY1"})
	st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"})
	seedSuperseded(t, h, "h1", "HKEY1", []struct{ sha, identity string }{
		{"SHA_GEN1", "age-gen1"},
		{"SHA_GEN2", "age-gen2"},
	})

	rr := do(h, http.MethodGet, "/hosts/h1/escrow/retained", "HKEY2", "")
	if rr.Code != http.StatusForbidden {
		t.Fatalf("h2 reading h1's retained packages = %d, want 403 — a host key must never be a fleet-wide reader", rr.Code)
	}
}

// A host that has never re-sealed gets a clean, empty answer — not a 404 and not a fault. "No retained
// package" is an ordinary situation and must be distinguishable from "something is broken", or the
// agent's try-loop cannot tell them apart either.
//
// RED-PROOF: make the handler 404 on an empty list → this FAILS with 404.
func TestRetainedEscrowGet_NoneIsACleanEmptyAnswer(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBodyWithHash([]byte("k"), "SHA1", "age-1")); rr.Code != http.StatusOK {
		t.Fatalf("seed PUT = %d", rr.Code)
	}

	code, out := getRetained(t, h, "h1", "HKEY")
	if code != http.StatusOK {
		t.Fatalf("GET retained on a never-superseded host = %d, want 200", code)
	}
	if out.Count != 0 || len(out.Packages) != 0 || out.UnopenableCount != 0 {
		t.Errorf("want an empty answer, got count=%d packages=%d unopenable=%d", out.Count, len(out.Packages), out.UnopenableCount)
	}
}

// The retained route must not be reachable without a key at all.
//
// RED-PROOF: drop the checkAuthHost block → this FAILS with 200.
func TestRetainedEscrowGet_RequiresAuth(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	if rr := do(h, http.MethodGet, "/hosts/h1/escrow/retained", "", ""); rr.Code != http.StatusUnauthorized {
		t.Fatalf("unauthenticated retained GET = %d, want 401", rr.Code)
	}
}

// ROUTING: /escrow and /escrow/retained must stay two different answers. A prefix "tidy-up" that
// collapsed them would route retained reads to the current row — silently the wrong package.
//
// RED-PROOF: change the retained case's suffix to "/escrow" (so the earlier case wins) → the retained
// route returns the CURRENT-row shape, which has no "packages" key → this FAILS.
func TestRetainedEscrowGet_IsADistinctRouteFromTheCurrentRow(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	seedSuperseded(t, h, "h1", "HKEY", []struct{ sha, identity string }{
		{"SHA_GEN1", "age-gen1"},
		{"SHA_GEN2", "age-gen2"},
	})

	cur := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "")
	var curBody map[string]any
	if err := json.Unmarshal(cur.Body.Bytes(), &curBody); err != nil {
		t.Fatalf("decode current-row body: %v", err)
	}
	if _, hasPackages := curBody["packages"]; hasPackages {
		t.Error("the CURRENT-row route grew a packages key — the two routes have been merged")
	}
	if _, hasPresent := curBody["present"]; !hasPresent {
		t.Error("the current-row route lost its present flag")
	}

	_, ret := getRetained(t, h, "h1", "HKEY")
	if ret.Count != 1 {
		t.Fatalf("retained route count = %d, want 1 — it is answering the current row's question", ret.Count)
	}
}
