package api

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

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

// Recovery-mode arm is global-key-only; re-enroll is REFUSED unless recovery mode is active.
func TestReEnroll_GatedOnRecoveryMode(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "OLDKEY")

	// Re-enroll with recovery mode OFF → 403.
	if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`); rr.Code != http.StatusForbidden {
		t.Fatalf("re-enroll without recovery mode = %d, want 403", rr.Code)
	}
	// Arm recovery mode requires the global key (per-host key refused).
	if rr := do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", "OLDKEY", `{"ttl_seconds":600}`); rr.Code != http.StatusForbidden {
		t.Errorf("per-host arm recovery = %d, want 403", rr.Code)
	}
	if rr := do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`); rr.Code != http.StatusOK {
		t.Fatalf("global arm recovery = %d, want 200", rr.Code)
	}

	// Now re-enroll succeeds, rotates the credential, returns the directive.
	rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`)
	if rr.Code != http.StatusOK {
		t.Fatalf("re-enroll in recovery mode = %d body=%s", rr.Code, rr.Body.String())
	}
	var resp struct {
		APIKeyRotated bool   `json:"api_key_rotated"`
		NewAPIKey     string `json:"new_api_key"`
	}
	json.Unmarshal(rr.Body.Bytes(), &resp)
	if !resp.APIKeyRotated || resp.NewAPIKey != "NEWKEY" {
		t.Errorf("re-enroll resp = %+v, want rotated NEWKEY", resp)
	}
}

// Re-enroll ROTATES the hub credential: the old key no longer authenticates; the new one does.
func TestReEnroll_RevokesOldKey(t *testing.T) {
	h, st, _ := newTestHandler(t)
	st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
	seedHost(t, st, "h1", "c1", "OLDKEY")
	do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`)

	// Before: the OLD key authenticates a host-report.
	if rr := do(h, http.MethodPost, "/host-report", "OLDKEY", validReportBody("h1")); rr.Code != 200 {
		t.Fatalf("pre-rotate host-report with OLD key = %d, want 200", rr.Code)
	}
	// Re-enroll → rotate to NEWKEY.
	if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`); rr.Code != 200 {
		t.Fatalf("re-enroll = %d", rr.Code)
	}
	// After: the OLD key is REVOKED (401), the NEW key works.
	if rr := do(h, http.MethodPost, "/host-report", "OLDKEY", validReportBody("h1")); rr.Code != http.StatusUnauthorized {
		t.Errorf("post-rotate OLD key = %d, want 401 (revoked)", rr.Code)
	}
	if rr := do(h, http.MethodPost, "/host-report", "NEWKEY", validReportBody("h1")); rr.Code != 200 {
		t.Errorf("post-rotate NEW key = %d, want 200", rr.Code)
	}
}

// The restore directive (the opaque blobs) is served ONLY in recovery mode, and expires.
func TestRestoreDirective_GatedAndExpires(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY")
	// Seed a DR bundle: K-escrow row + identity blob + directive.
	st.SaveHostEscrow("h1", []byte("opaque-K-escrow"), "01:36:e9:…", "zero_knowledge", time.Now().UTC().Format(time.RFC3339), "")
	st.SaveHostDRBundle("h1", []byte("opaque-identity"), `{"pbs_repo":"r","tunnel_id":"t","expected_key_fingerprint":"01:36:e9:…"}`)

	// Not in recovery mode → 403.
	if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden {
		t.Fatalf("directive without recovery mode = %d, want 403", rr.Code)
	}
	// Arm recovery mode → served, with both opaque blobs + directive.
	do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`)
	rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", "")
	if rr.Code != 200 {
		t.Fatalf("directive in recovery mode = %d", rr.Code)
	}
	var d struct {
		KEscrowB64        string          `json:"k_escrow_b64"`
		IdentityEscrowB64 string          `json:"identity_escrow_b64"`
		Directive         json.RawMessage `json:"directive"`
	}
	json.Unmarshal(rr.Body.Bytes(), &d)
	kb, _ := base64.StdEncoding.DecodeString(d.KEscrowB64)
	ib, _ := base64.StdEncoding.DecodeString(d.IdentityEscrowB64)
	if string(kb) != "opaque-K-escrow" || string(ib) != "opaque-identity" {
		t.Errorf("served blobs wrong: K=%q identity=%q", kb, ib)
	}

	// Simulate EXPIRY: set recovery_mode_until in the past → directive refused again.
	st.SetRecoveryMode("h1", time.Now().UTC().Add(-time.Minute))
	if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden {
		t.Errorf("expired recovery mode directive = %d, want 403", rr.Code)
	}
}

// Clearing recovery mode (global key) disables re-enroll.
func TestRecoveryMode_Clear(t *testing.T) {
	h, st, _ := newTestHandler(t)
	seedHost(t, st, "h1", "c1", "HKEY")
	do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`)
	if rr := do(h, http.MethodDelete, "/admin/hosts/h1/recovery-mode", globalKey, ""); rr.Code != 200 {
		t.Fatalf("clear recovery = %d", rr.Code)
	}
	if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"X"}`); rr.Code != http.StatusForbidden {
		t.Errorf("re-enroll after clear = %d, want 403", rr.Code)
	}
}
