package web

// Customer RESET (v0.61.0) orchestration red-proofs. The load-bearing contracts:
//   - Scenario A: RESET refuses while ANY host row exists — 409, ZERO side effects (no journal row).
//   - Ruling 1: destroying retained escrow custody (M>0) requires the SEPARATE ack — missing → 400,
//     nothing purged.
//   - Typed-id gate: a wrong confirm_id → 400, nothing purged.
//   - Happy path: external legs FIRST, DB purge LAST; identity + basic config SURVIVE; provenance +
//     events SURVIVE; journal completes.
//   - Partial failure (external FIRST): a failing external leg leaves the DB UNPURGED and the journal
//     retained (resumable) — a re-run converges.

import (
	"errors"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

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

// seedResettable seeds a customer with a full operational footprint but NO host (the reset
// precondition). Returns the store. offsiteJSON is the config_json (with an offsite descriptor).
func seedResettable(t *testing.T, st *store.Store, customerID string) {
	t.Helper()
	cfgJSON := `{"offsite":{"enabled":true,"type":"shared","host":"u1-sub3.your-storagebox.de","user":"u1-sub3","port":23,"repo_path":"/home/felhom","quota_gb":100,"host_fingerprint":"SHA256:abc"}}`
	if err := st.SaveCustomerConfig(&store.CustomerConfig{
		CustomerID: customerID, CustomerName: "Teszt", Domain: customerID + ".example",
		Email: "t@example.com", RetrievalPassword: "pw", APIKey: "capi", ConfigJSON: cfgJSON,
	}); err != nil {
		t.Fatalf("seed config: %v", err)
	}
	// A superseded escrow blob retained via F-14 provenance (host deleted, blob kept).
	hostID := customerID + "-01"
	if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: "hapi"}); err != nil {
		t.Fatalf("seed host: %v", err)
	}
	if _, _, err := st.SaveHostEscrow(hostID, []byte("blobA"), "fpA", "posture", "2026-01-01T00:00:00Z", "shaA"); err != nil {
		t.Fatalf("seed escrow A: %v", err)
	}
	if _, _, err := st.SaveHostEscrow(hostID, []byte("blobB"), "fpB", "posture", "2026-01-02T00:00:00Z", "shaB"); err != nil {
		t.Fatalf("seed escrow B: %v", err)
	}
	if err := st.DeleteHost(hostID, true); err != nil { // demotes current → retained; records host_deletions
		t.Fatalf("delete host (demote): %v", err)
	}
	if err := st.SaveOneTimeSecret(customerID, "one-time-pw"); err != nil {
		t.Fatalf("seed one-time secret: %v", err)
	}
	if err := st.SaveDRRecipeHostHalf(customerID, hostID, 1, []byte("half")); err != nil {
		t.Fatalf("seed dr recipe: %v", err)
	}
	if _, err := st.RotateClaimCode(customerID, "$2a$10$hashhashhashhashhashha"); err != nil {
		t.Fatalf("seed claim: %v", err)
	}
}

func postReset(t *testing.T, s *Server, customerID string, form url.Values) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest("POST", "/configs/"+customerID+"/reset", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr := httptest.NewRecorder()
	s.handleCustomerReset(rr, req, customerID)
	return rr
}

func superseded(t *testing.T, st *store.Store, customerID string) int {
	t.Helper()
	inv, err := st.CustomerResetInventory(customerID)
	if err != nil {
		t.Fatalf("inventory: %v", err)
	}
	return inv.SupersededBlobs
}

func TestCustomerReset_RefusesWhileHostsExist(t *testing.T) {
	s, st := newTestServer(t)
	s.SetTenantSync(&fakeTenancy{})
	seedResettable(t, st, "acme")
	// Re-add a live host: RESET must refuse (ruling 3).
	if err := st.UpsertHost(&store.Host{HostID: "acme-live", CustomerID: "acme", APIKey: "h"}); err != nil {
		t.Fatalf("re-add host: %v", err)
	}

	rr := postReset(t, s, "acme", url.Values{"confirm_id": {"acme"}, "escrow_ack": {"1"}})
	if rr.Code != http.StatusConflict {
		t.Fatalf("reset-with-host = %d, want 409", rr.Code)
	}
	// ZERO side effects: no journal row, secret intact, blobs intact, claim intact.
	if cr, _ := st.LatestCustomerReset("acme"); cr != nil {
		t.Errorf("a journal row was opened despite the refusal: %+v", cr)
	}
	if inv, _ := st.CustomerResetInventory("acme"); !inv.OneTimeSecretPresent || !inv.ClaimPresent || inv.SupersededBlobs == 0 {
		t.Errorf("refused reset still mutated state: %+v", inv)
	}
}

// Red-proof (a): the escrow-custody ack gate. M>0 and no ack → 400, nothing purged. Dropping the
// `!escrowAck` guard would let the reset proceed and destroy the retained blobs → this FAILS.
func TestCustomerReset_EscrowAckRequired(t *testing.T) {
	s, st := newTestServer(t)
	s.SetTenantSync(&fakeTenancy{})
	seedResettable(t, st, "acme")
	if superseded(t, st, "acme") == 0 {
		t.Fatal("precondition: expected retained blobs to gate on")
	}

	rr := postReset(t, s, "acme", url.Values{"confirm_id": {"acme"}}) // no escrow_ack
	if rr.Code != http.StatusBadRequest {
		t.Fatalf("reset without escrow ack = %d, want 400", rr.Code)
	}
	if cr, _ := st.LatestCustomerReset("acme"); cr != nil {
		t.Errorf("journal opened despite the ack refusal: %+v", cr)
	}
	if superseded(t, st, "acme") == 0 {
		t.Error("retained blobs were destroyed despite the missing ack")
	}
}

func TestCustomerReset_TypedIDMustMatch(t *testing.T) {
	s, st := newTestServer(t)
	s.SetTenantSync(&fakeTenancy{})
	seedResettable(t, st, "acme")

	rr := postReset(t, s, "acme", url.Values{"confirm_id": {"acmee"}, "escrow_ack": {"1"}})
	if rr.Code != http.StatusBadRequest {
		t.Fatalf("reset with wrong confirm_id = %d, want 400", rr.Code)
	}
	if inv, _ := st.CustomerResetInventory("acme"); !inv.OneTimeSecretPresent {
		t.Error("mismatched-id reset still purged state")
	}
}

func TestCustomerReset_HappyPath(t *testing.T) {
	s, st := newTestServer(t)
	fake := &fakeTenancy{deprovisionExisted: true}
	s.SetTenantSync(fake)
	seedResettable(t, st, "acme")

	rr := postReset(t, s, "acme", url.Values{"confirm_id": {"acme"}, "escrow_ack": {"1"}})
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("happy reset = %d (%s), want 303", rr.Code, rr.Body.String())
	}
	// External leg ran.
	if fake.deprovisionCalls != 1 {
		t.Errorf("pbs deprovision calls = %d, want 1", fake.deprovisionCalls)
	}
	// Operational state DIED.
	inv, _ := st.CustomerResetInventory("acme")
	if inv.OneTimeSecretPresent || inv.DRRecipePresent || inv.ClaimPresent || inv.SupersededBlobs != 0 {
		t.Errorf("operational state survived the reset: %+v", inv)
	}
	// Identity + basic config SURVIVE; the offsite tier CHOICE is kept, provisioned fields cleared.
	cfg, _ := st.GetCustomerConfig("acme")
	if cfg == nil {
		t.Fatal("customer_config was destroyed — identity must survive a RESET")
	}
	if cfg.CustomerName != "Teszt" || cfg.Email != "t@example.com" {
		t.Errorf("identity mutated: %+v", cfg)
	}
	if !strings.Contains(cfg.ConfigJSON, `"enabled":true`) || !strings.Contains(cfg.ConfigJSON, `"type":"shared"`) {
		t.Errorf("offsite tier choice was lost: %s", cfg.ConfigJSON)
	}
	if strings.Contains(cfg.ConfigJSON, "your-storagebox.de") || strings.Contains(cfg.ConfigJSON, "u1-sub3") ||
		strings.Contains(cfg.ConfigJSON, "repo_path") || strings.Contains(cfg.ConfigJSON, "host_fingerprint") {
		t.Errorf("provisioned offsite fields survived the reset: %s", cfg.ConfigJSON)
	}
	// Journal completed with every leg recorded ok; provenance + audit event SURVIVE.
	cr, _ := st.LatestCustomerReset("acme")
	if cr == nil || cr.CompletedAt == nil {
		t.Fatalf("journal not finished: %+v", cr)
	}
	for _, leg := range []string{"pbs", "claim", "descriptor", "db_purge"} {
		if cr.Legs[leg] != "ok" {
			t.Errorf("leg %q = %q, want ok (legs=%v)", leg, cr.Legs[leg], cr.Legs)
		}
	}
	if ev, _ := st.GetLatestEventByType("acme", "customer_reset"); ev == nil {
		t.Error("no customer_reset audit event was recorded")
	}
}

// Red-proof (b): partial failure. A failing external leg (PBS) must leave the DB UNPURGED and the
// journal retained — the reset is resumable. Purging before the external legs succeed would erase the
// descriptors that tell a re-run what still needs tearing down → this FAILS.
func TestCustomerReset_PartialFailureIsResumable(t *testing.T) {
	s, st := newTestServer(t)
	fake := &fakeTenancy{err: errors.New("pbs boom")}
	s.SetTenantSync(fake)
	seedResettable(t, st, "acme")

	rr := postReset(t, s, "acme", url.Values{"confirm_id": {"acme"}, "escrow_ack": {"1"}})
	if rr.Code != http.StatusBadGateway {
		t.Fatalf("partial reset = %d, want 502", rr.Code)
	}
	// Nothing purged — the external leg failed FIRST, before any DB mutation.
	inv, _ := st.CustomerResetInventory("acme")
	if !inv.OneTimeSecretPresent || !inv.DRRecipePresent || !inv.ClaimPresent || inv.SupersededBlobs == 0 {
		t.Errorf("DB was purged despite the external leg failing: %+v", inv)
	}
	cfg, _ := st.GetCustomerConfig("acme")
	if !strings.Contains(cfg.ConfigJSON, "your-storagebox.de") {
		t.Errorf("descriptor was cleared despite the failure: %s", cfg.ConfigJSON)
	}
	cr, _ := st.LatestCustomerReset("acme")
	if cr == nil || cr.CompletedAt != nil || cr.Legs["pbs"] != "failed" {
		t.Fatalf("journal should be open with pbs=failed: %+v", cr)
	}

	// Resume: the external leg now succeeds; a re-run converges (idempotent from the top).
	fake.err = nil
	rr2 := postReset(t, s, "acme", url.Values{"confirm_id": {"acme"}, "escrow_ack": {"1"}})
	if rr2.Code != http.StatusSeeOther {
		t.Fatalf("resumed reset = %d (%s), want 303", rr2.Code, rr2.Body.String())
	}
	inv2, _ := st.CustomerResetInventory("acme")
	if inv2.OneTimeSecretPresent || inv2.DRRecipePresent || inv2.ClaimPresent || inv2.SupersededBlobs != 0 {
		t.Errorf("resume did not converge: %+v", inv2)
	}
}
