package api

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

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

// a stand-in for the opaque R-wrapped blob — the hub treats it as ciphertext it cannot read.
var opaqueBlob = []byte("\x00\x01OPAQUE-pbs-scrypt-keyfile-bytes\xff\xfe")

func escrowBody(blob []byte) string {
	b, _ := json.Marshal(map[string]string{
		"blob_b64":        base64.StdEncoding.EncodeToString(blob),
		"key_fingerprint": "ab:cd:ef",
		"posture":         "zero_knowledge",
		"created_at":      "2026-06-10T05:00:00Z",
	})
	return string(b)
}

func TestHandleHostEscrow_StoresOpaqueBlobVerbatim(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})

	rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody(opaqueBlob))
	if rr.Code != http.StatusOK {
		t.Fatalf("PUT escrow = %d, want 200 (%s)", rr.Code, rr.Body.String())
	}
	got, err := st.GetHostEscrow("h1")
	if err != nil || got == nil {
		t.Fatalf("GetHostEscrow: %v / %v", got, err)
	}
	// the hub stored the OPAQUE bytes verbatim (it never decrypts / transforms them).
	if !reflect.DeepEqual(got.Blob, opaqueBlob) {
		t.Fatalf("stored blob != uploaded blob (hub must keep ciphertext verbatim)")
	}
	if got.KeyFingerprint != "ab:cd:ef" || got.Posture != "zero_knowledge" {
		t.Errorf("metadata not stored: %+v", got)
	}
}

func TestHandleHostEscrow_LastWriteWins(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("first")))
	rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("second-rotated")))
	if rr.Code != http.StatusOK {
		t.Fatalf("rotation PUT = %d", rr.Code)
	}
	got, _ := st.GetHostEscrow("h1")
	if string(got.Blob) != "second-rotated" {
		t.Fatalf("rotation must be last-write-wins, got %q", got.Blob)
	}
}

func TestHandleHostEscrow_AuthRejected(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"})

	// absent / wrong key → 401
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "", escrowBody(opaqueBlob)); rr.Code != http.StatusUnauthorized {
		t.Errorf("no key: got %d want 401", rr.Code)
	}
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "WRONG", escrowBody(opaqueBlob)); rr.Code != http.StatusUnauthorized {
		t.Errorf("wrong key: got %d want 401", rr.Code)
	}
	// h2's key writing h1's escrow → 403 (a host may only write its own)
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY2", escrowBody(opaqueBlob)); rr.Code != http.StatusForbidden {
		t.Errorf("host_id mismatch: got %d want 403", rr.Code)
	}
	// and nothing was stored for h1 by the rejected attempts.
	if got, _ := st.GetHostEscrow("h1"); got != nil {
		t.Errorf("rejected attempts must not store anything, got %+v", got)
	}
}

func TestHandleHostEscrow_BadBody(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", `{"blob_b64":""}`); rr.Code != http.StatusBadRequest {
		t.Errorf("empty blob: got %d want 400", rr.Code)
	}
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", `{"blob_b64":"!!!not base64!!!"}`); rr.Code != http.StatusBadRequest {
		t.Errorf("bad base64: got %d want 400", rr.Code)
	}
}

// TestEscrowUploadContract pins the wire shape that MUST match the agent's emit struct
// (felhom-agent escrowUploadRequest). Cross-repo, no shared module — this is the hub half of the
// contract guard; the agent has the mirror in its own test.
func TestEscrowUploadContract(t *testing.T) {
	b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t", ResticPwSHA256: "h"})
	var m map[string]any
	json.Unmarshal(b, &m)
	got := make([]string, 0, len(m))
	for k := range m {
		got = append(got, k)
	}
	sort.Strings(got)
	want := []string{"blob_b64", "created_at", "key_fingerprint", "posture", "restic_pw_sha256"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("escrow wire contract drift: got %v want %v (must match the agent emit struct)", got, want)
	}
}

// SLICE 3 — the upload's restic_pw_sha256 is stored (and "" stays NULL-safe on legacy rows).
func TestHandleHostEscrow_StoresResticPwHash(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
	body, _ := json.Marshal(map[string]any{
		"blob_b64":         base64.StdEncoding.EncodeToString(opaqueBlob),
		"key_fingerprint":  "fp",
		"posture":          "zero_knowledge",
		"created_at":       "2026-07-09T20:00:00Z",
		"restic_pw_sha256": "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4",
	})
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", string(body)); rr.Code != http.StatusOK {
		t.Fatalf("PUT = %d", rr.Code)
	}
	got, _ := st.GetHostEscrow("h1")
	if got.ResticPwSHA256 != "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4" {
		t.Fatalf("hash not stored: %q", got.ResticPwSHA256)
	}
	// legacy upload without the field → stored empty, read back NULL-safe as ""
	if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("legacy"))); rr.Code != http.StatusOK {
		t.Fatalf("legacy PUT = %d", rr.Code)
	}
	got2, err := st.GetHostEscrow("h1")
	if err != nil || got2.ResticPwSHA256 != "" {
		t.Fatalf("legacy upload must read back with an empty hash: %q err=%v", got2.ResticPwSHA256, err)
	}
}

// SLICE 3 — the report ACK carries the escrow status object for the customer; omitted when no escrow row.
func TestReportACK_EscrowStatus(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.UpsertHost(&store.Host{HostID: "hv1", CustomerID: "cust-e", APIKey: "HK"})

	// no escrow row → the ACK has NO escrow key (Scenario C: stays pending silently controller-side)
	rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"cust-e"}`)
	if rr.Code != http.StatusOK {
		t.Fatalf("report = %d", rr.Code)
	}
	var ack map[string]any
	json.Unmarshal(rr.Body.Bytes(), &ack)
	if _, present := ack["escrow"]; present {
		t.Fatal("no escrow row → the ACK must omit the escrow object")
	}

	// escrow row with identity blob + hash → the ACK carries all three fields
	if _, _, err := st.SaveHostEscrow("hv1", []byte("k-blob"), "fp", "zero_knowledge", "2026-07-09T20:00:00Z", "abc123"); err != nil {
		t.Fatal(err)
	}
	if err := st.SaveHostDRBundle("hv1", []byte("identity-blob"), "{}"); err != nil {
		t.Fatal(err)
	}
	rr2 := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"cust-e"}`)
	var ack2 struct {
		Escrow struct {
			IdentityBlobPresent bool   `json:"identity_blob_present"`
			ResticPwSHA256      string `json:"restic_pw_sha256"`
			CreatedAt           string `json:"created_at"`
		} `json:"escrow"`
	}
	if err := json.Unmarshal(rr2.Body.Bytes(), &ack2); err != nil {
		t.Fatal(err)
	}
	if !ack2.Escrow.IdentityBlobPresent || ack2.Escrow.ResticPwSHA256 != "abc123" || ack2.Escrow.CreatedAt == "" {
		t.Fatalf("ACK escrow status wrong: %+v (body=%s)", ack2.Escrow, rr2.Body.String())
	}
}
