package api

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

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

// R-199 (hub v0.94.0) — the box-authenticated retrieval of a host's OWN sealed identity blob.
// This is a new surface on the most sensitive object in the system; these tests exist to pin the
// three properties that make it defensible: it is self-scoped, it is honest when there is nothing to
// serve, and it is never silent.

func seedEscrowedHost(t *testing.T, st *store.Store, hostID, customerID, apiKey string, identity []byte) {
	t.Helper()
	if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: apiKey}); err != nil {
		t.Fatal(err)
	}
	if _, _, err := st.SaveHostEscrow(hostID, []byte("k-escrow"), "fp", "zero_knowledge", "2026-08-04T11:00:00Z", "SHA"); err != nil {
		t.Fatal(err)
	}
	if len(identity) > 0 {
		if err := st.SaveHostDRBundle(hostID, identity, `{}`); err != nil {
			t.Fatal(err)
		}
	}
}

// The happy path: a host reads back exactly the bytes it uploaded, verbatim.
func TestEscrowGet_ServesOwnBlobVerbatim(t *testing.T) {
	h, st, _ := newTestHandler(t)
	identity := []byte("\x00\x01age-wrapped-identity-bundle\xff")
	seedEscrowedHost(t, st, "h1", "c1", "HKEY", identity)

	rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "")
	if rr.Code != http.StatusOK {
		t.Fatalf("GET escrow = %d, want 200 (%s)", rr.Code, rr.Body.String())
	}
	var out struct {
		HostID  string `json:"host_id"`
		Present bool   `json:"present"`
		B64     string `json:"identity_escrow_b64"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
		t.Fatal(err)
	}
	if !out.Present || out.HostID != "h1" {
		t.Fatalf("unexpected envelope: %+v", out)
	}
	got, err := base64.StdEncoding.DecodeString(out.B64)
	if err != nil {
		t.Fatal(err)
	}
	if string(got) != string(identity) {
		t.Fatal("the served blob is not the stored blob — the hub must return ciphertext verbatim")
	}
}

// Scenario C — a box can fetch ONLY its own blob.
// RED-PROOF: delete the `!isGlobal && authHostID != pathHostID` check in handleHostEscrowGet →
// cross-host retrieval succeeds → this FAILS. Without that line, any host key is a fleet-wide reader
// of every customer's sealed bundle.
func TestEscrowGet_CrossHostRefused(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedEscrowedHost(t, st, "hostA", "custA", "KEY-A", []byte("A-identity"))
	seedEscrowedHost(t, st, "hostB", "custB", "KEY-B", []byte("B-identity-SECRET"))

	rr := do(h, http.MethodGet, "/hosts/hostB/escrow", "KEY-A", "")
	if rr.Code != http.StatusForbidden {
		t.Fatalf("host A reading host B's blob = %d, want 403 — a cross-host read of a sealed bundle "+
			"must be impossible on every code path (body: %s)", rr.Code, rr.Body.String())
	}
	if b := rr.Body.String(); len(b) > 0 && (contains(b, "B-identity") || contains(b, base64.StdEncoding.EncodeToString([]byte("B-identity-SECRET")))) {
		t.Fatal("the refusal body leaked the other host's blob")
	}
	// Unauthenticated is refused too, and does not leak which hosts exist.
	if rr := do(h, http.MethodGet, "/hosts/hostB/escrow", "", ""); rr.Code != http.StatusUnauthorized {
		t.Fatalf("unauthenticated GET = %d, want 401", rr.Code)
	}
	// The global operator key MAY read any — the same asymmetry the PUT has.
	if rr := do(h, http.MethodGet, "/hosts/hostB/escrow", globalKey, ""); rr.Code != http.StatusOK {
		t.Fatalf("global key GET = %d, want 200", rr.Code)
	}
}

func contains(hay, needle string) bool {
	return len(needle) > 0 && len(hay) >= len(needle) && (func() bool {
		for i := 0; i+len(needle) <= len(hay); i++ {
			if hay[i:i+len(needle)] == needle {
				return true
			}
		}
		return false
	})()
}

// Scenario D — a host with no sealed bundle gets a CLEAN answer: 200 with present=false. Not a 404
// (indistinguishable from an unknown host), not an empty 200 without the flag (indistinguishable
// from a zero-length blob). Both of those read as a fault to a caller whose situation is simply
// "no ceremony has run yet".
func TestEscrowGet_NoBlobIsCleanNone(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedEscrowedHost(t, st, "h1", "c1", "HKEY", nil) // K-escrow only, no identity blob

	rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "")
	if rr.Code != http.StatusOK {
		t.Fatalf("no-blob GET = %d, want 200 with present=false", rr.Code)
	}
	var out struct {
		Present bool   `json:"present"`
		B64     string `json:"identity_escrow_b64"`
	}
	json.Unmarshal(rr.Body.Bytes(), &out)
	if out.Present || out.B64 != "" {
		t.Fatalf("a host with no bundle must report present=false and no bytes, got %+v", out)
	}
	// An unknown host is a DIFFERENT answer — the two must not collapse into one.
	if rr := do(h, http.MethodGet, "/hosts/nope/escrow", globalKey, ""); rr.Code != http.StatusNotFound {
		t.Fatalf("unknown host = %d, want 404 (distinct from a known host with no bundle)", rr.Code)
	}
}

// Scenario E — every successful retrieval leaves a durable record naming the host.
// RED-PROOF: remove the SaveEvent call in handleHostEscrowGet → no event → this FAILS. A silent
// capability on this object is the whole reason §8.2's trade is acceptable at all.
func TestEscrowGet_LeavesARecord(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedEscrowedHost(t, st, "h1", "c1", "HKEY", []byte("age-wrapped"))

	if rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", ""); rr.Code != http.StatusOK {
		t.Fatalf("GET = %d", rr.Code)
	}
	ev, err := st.GetLatestEventByType("c1", eventEscrowBlobServed)
	if err != nil {
		t.Fatal(err)
	}
	if ev == nil {
		t.Fatal("R-199: a sealed recovery blob was served and NOTHING recorded it — the audit row is " +
			"the mitigation that makes this endpoint's trade acceptable")
	}
	if ev.Severity != "warning" {
		t.Errorf("severity = %q, want warning: info is an intentional non-notify, so the operator would "+
			"never hear that the recovery path was used", ev.Severity)
	}
	if !contains(ev.Message, "h1") {
		t.Errorf("the record must name the host, got %q", ev.Message)
	}
	// The record must not carry the blob itself — it is an audit row, not a second copy.
	if contains(ev.Message, "age-wrapped") || contains(ev.DetailsJSON, "age-wrapped") ||
		contains(ev.DetailsJSON, base64.StdEncoding.EncodeToString([]byte("age-wrapped"))) {
		t.Errorf("the audit record embedded the blob: msg=%q details=%q", ev.Message, ev.DetailsJSON)
	}
	// A "no blob" answer is NOT a retrieval and must not raise one.
	seedEscrowedHost(t, st, "h2", "c2", "KEY2", nil)
	do(h, http.MethodGet, "/hosts/h2/escrow", "KEY2", "")
	if ev, _ := st.GetLatestEventByType("c2", eventEscrowBlobServed); ev != nil {
		t.Fatal("a present=false answer served no blob and must raise no retrieval record")
	}
}

// Operator-tier by construction, registered in the same commit that mints the type.
func TestEscrowBlobServed_IsOperatorOnly(t *testing.T) {
	if !notify.IsOperatorOnly(eventEscrowBlobServed) {
		t.Fatalf("%s is not registered operator-only — a customer would receive operator-grade English "+
			"about their sealed recovery bundle being handed out", eventEscrowBlobServed)
	}
}

// Scenario G — the operator-driven DR path is UNTOUCHED: same gate, same behaviour. The new sibling
// must not loosen it, and must not share it.
func TestEscrowGet_OperatorDRPathUnchanged(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedEscrowedHost(t, st, "h1", "c1", "HKEY", []byte("age-wrapped"))

	// re-enroll without recovery mode: still refused.
	if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "HKEY", `{"new_api_key":"x"}`); rr.Code != http.StatusForbidden {
		t.Fatalf("re-enroll without recovery mode = %d, want 403 — the new endpoint must not loosen it", rr.Code)
	}
	// restore-directive without recovery mode: still refused.
	if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden {
		t.Fatalf("restore-directive without recovery mode = %d, want 403", rr.Code)
	}
	// …and arming it still works, i.e. the old path is functional, not merely closed.
	if err := st.SetRecoveryMode("h1", time.Now().UTC().Add(10*time.Minute)); err != nil {
		t.Fatal(err)
	}
	if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusOK {
		t.Fatalf("restore-directive WITH recovery mode = %d, want 200", rr.Code)
	}
	// The new endpoint does NOT depend on recovery mode (that is the §8.2 trade, made explicit here so
	// a future change to escrowSelfServiceRetrieval is visible as a test change).
	if err := st.ClearRecoveryMode("h1"); err != nil {
		t.Fatal(err)
	}
	if rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", ""); rr.Code != http.StatusOK {
		t.Fatalf("self-service escrow GET with recovery mode OFF = %d, want 200 (escrowSelfServiceRetrieval=true)", rr.Code)
	}
}
