package web

// v0.48.0 edit-a — the standalone customer edit page merged into the customer page's Edit tab
// (config_form_body embedded; Block/Delete relocated to a danger zone) plus the redirect-anchor
// contract of the surrounding POST actions. Groups A + B of the task's test plan.

import (
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

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

// editPanel slices the rendered customer page down to the Edit tab's panel (from its opening div
// to the next panel) so containment assertions are panel-scoped, not page-scoped.
func editPanel(t *testing.T, html string) string {
	t.Helper()
	start := strings.Index(html, `tab-panel" data-tab="edit"`)
	if start < 0 {
		t.Fatal("Edit tab panel missing")
	}
	end := strings.Index(html[start:], `tab-panel" data-tab="backup"`)
	if end < 0 {
		t.Fatal("Backup panel (the Edit panel's terminator) missing")
	}
	return html[start : start+end]
}

// Group A — the Edit tab renders the full mutation surface and the header is cleaned.
func TestCustomerEditTab_RendersMutationSurface(t *testing.T) {
	s, st := newTestServer(t)
	if err := st.SaveCustomerConfig(&store.CustomerConfig{
		CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu", Email: "a@acme.hu",
		RetrievalPassword: "pw", APIKey: "k", Status: "active",
		ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`,
	}); err != nil {
		t.Fatal(err)
	}
	if err := st.SaveReport("c1", []byte(tabsTestReportJSON)); err != nil {
		t.Fatal(err)
	}

	html := renderCustomerPage(t, s, "c1")
	panel := editPanel(t, html)

	// The panel carries the config form + the three sibling cards.
	for _, want := range []string{
		`action="/configs/c1/edit"`, // the embedded config form
		"Controller Update",
		"Geo-korlátozás",
		"Danger zone",
		`action="/customers/c1/block"`,
		`action="/configs/c1/delete"`,
		`name="_csrf"`, // CSRF field inside the form
	} {
		if !strings.Contains(panel, want) {
			t.Errorf("Edit panel missing %q", want)
		}
	}
	// Stored override values populate the embedded form (values come from Config/ConfigJSON).
	if !strings.Contains(panel, `value="stored-tunnel-token"`) {
		t.Error("embedded form does not render the stored cf_tunnel_token override")
	}
	// The named trap: the floor/geo/danger forms must be SIBLINGS after </form>, never nested
	// inside the config <form> (invalid HTML; breaks the formaction sub-buttons). Between the
	// config form's opening tag and its first </form> no other <form may open.
	formStart := strings.Index(panel, `action="/configs/c1/edit"`)
	formEnd := strings.Index(panel[formStart:], "</form>")
	if formEnd < 0 {
		t.Fatal("config form never closes")
	}
	if inner := panel[formStart : formStart+formEnd]; strings.Contains(inner, "<form") {
		t.Error("a form is NESTED inside the config <form> — floor/geo/danger must be siblings")
	}

	// Header cleaned: the Edit link is gone, and Block/Delete exist EXACTLY once on the whole
	// page (in the Edit panel). RED-PROOF: restoring the old header buttons makes the counts 2
	// and this FAILS — a naive Contains would not catch the duplicate danger zone.
	if strings.Contains(html, `href="/configs/c1/edit"`) {
		t.Error("header still links to the retired standalone edit page")
	}
	if got := strings.Count(html, `action="/customers/c1/block"`); got != 1 {
		t.Errorf("Block form count = %d, want exactly 1 (in the danger zone)", got)
	}
	if got := strings.Count(html, `action="/configs/c1/delete"`); got != 1 {
		t.Errorf("Delete form count = %d, want exactly 1 (in the danger zone)", got)
	}
}

// A blocked customer's danger zone offers Unblock instead of Block (the old header conditional).
func TestCustomerEditTab_BlockedShowsUnblock(t *testing.T) {
	s, st := newTestServer(t)
	if err := st.SaveCustomerConfig(&store.CustomerConfig{
		CustomerID: "cb", CustomerName: "Blocked", Domain: "b.hu",
		RetrievalPassword: "pw", APIKey: "k",
	}); err != nil {
		t.Fatal(err)
	}
	// SaveCustomerConfig does not persist Status — blocking is its own state transition.
	if err := st.SetCustomerConfigStatus("cb", "blocked"); err != nil {
		t.Fatal(err)
	}
	html := renderCustomerPage(t, s, "cb")
	panel := editPanel(t, html)
	if !strings.Contains(panel, `action="/customers/cb/unblock"`) {
		t.Error("blocked customer's danger zone missing the Unblock form")
	}
	if strings.Contains(html, `action="/customers/cb/block"`) {
		t.Error("blocked customer must not offer Block")
	}
}

// Group B1 — the standalone GET edit page 302s to the Edit tab.
func TestConfigEditForm_RedirectsToEditTab(t *testing.T) {
	s, st := newTestServer(t)
	if err := st.SaveCustomerConfig(&store.CustomerConfig{
		CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
		RetrievalPassword: "pw", APIKey: "k", Status: "active",
	}); err != nil {
		t.Fatal(err)
	}
	rr := httptest.NewRecorder()
	s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/c1/edit", nil), "c1")
	if rr.Code != http.StatusFound {
		t.Fatalf("status = %d, want 302", rr.Code)
	}
	if loc := rr.Header().Get("Location"); loc != "/customers/c1#tab=edit" {
		t.Errorf("Location = %q, want /customers/c1#tab=edit", loc)
	}
	// Unknown customer still 404s (no open redirect for garbage ids).
	rr = httptest.NewRecorder()
	s.handleConfigEditForm(rr, httptest.NewRequest("GET", "/configs/nope/edit", nil), "nope")
	if rr.Code != http.StatusNotFound {
		t.Errorf("unknown customer edit = %d, want 404", rr.Code)
	}
}

// Group B2 — the create flow (IsNew) still renders standalone chrome around the SAME shared
// body sub-template, and the create round-trip is unchanged.
func TestConfigNewForm_StandaloneUnchanged(t *testing.T) {
	s, st := newTestServer(t)
	rr := httptest.NewRecorder()
	s.handleConfigNewForm(rr, httptest.NewRequest("GET", "/configs/new", nil))
	if rr.Code != 200 {
		t.Fatalf("new form status = %d", rr.Code)
	}
	html := rr.Body.String()
	for _, want := range []string{"Add Customer", `action="/configs/new"`, `id="customer_id"`, "Create Configuration"} {
		if !strings.Contains(html, want) {
			t.Errorf("new form missing %q", want)
		}
	}

	form := url.Values{"customer_id": {"newcust"}, "customer_name": {"New"}, "domain": {"new.hu"}}
	req := httptest.NewRequest("POST", "/configs/new", strings.NewReader(form.Encode()))
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	rr = httptest.NewRecorder()
	s.handleConfigCreate(rr, req)
	if rr.Code != http.StatusSeeOther {
		t.Fatalf("create = %d, want 303: %s", rr.Code, rr.Body.String())
	}
	if loc := rr.Header().Get("Location"); loc != "/customers/newcust?flash=created" {
		t.Errorf("create Location = %q", loc)
	}
	if cfg, _ := st.GetCustomerConfig("newcust"); cfg == nil {
		t.Error("created config not stored")
	}
}

// Group B3 — THE load-bearing correctness test: a validation error re-renders the STANDALONE
// form with the SUBMITTED values preserved (overrides pass-through), never the stored ones.
// RED-PROOF (run once during v0.48.0): passing nil overrides in handleConfigUpdate's error path
// makes the builder fall back to the STORED ConfigJSON → "typed-tunnel-token" disappears and
// "stored-tunnel-token" renders → this test FAILS.
func TestConfigUpdate_ValidationErrorPreservesTypedValues(t *testing.T) {
	s, st := newTestServer(t)
	if err := st.SaveCustomerConfig(&store.CustomerConfig{
		CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
		RetrievalPassword: "pw", APIKey: "k", Status: "active",
		ConfigJSON: `{"infrastructure":{"cf_tunnel_token":"stored-tunnel-token"}}`,
	}); err != nil {
		t.Fatal(err)
	}

	// Invalid: customer_name cleared; the operator also typed a NEW tunnel token.
	rr := httptest.NewRecorder()
	s.handleConfigUpdate(rr, postForm("/configs/c1/edit",
		"customer_name=&domain=acme.hu&cf_tunnel_token=typed-tunnel-token"), "c1")
	if rr.Code != 200 {
		t.Fatalf("validation error = %d, want 200 (standalone re-render)", rr.Code)
	}
	html := rr.Body.String()
	if !strings.Contains(html, "Display Name and Domain are required.") {
		t.Error("error message missing from the re-render")
	}
	if !strings.Contains(html, `value="typed-tunnel-token"`) {
		t.Error("SUBMITTED value lost on the error re-render (values reset to stored)")
	}
	if strings.Contains(html, "stored-tunnel-token") {
		t.Error("stored value rendered instead of the submitted one")
	}
	// Nothing was saved.
	cfg, _ := st.GetCustomerConfig("c1")
	if cfg.CustomerName != "Acme" || !strings.Contains(cfg.ConfigJSON, "stored-tunnel-token") {
		t.Error("invalid submit mutated the stored config")
	}
}

// Group B4/B5 — redirect anchors, table-driven over the handlers whose buttons live on the
// customer page: post-action lands back on the tab the action came from.
func TestCustomerActions_RedirectAnchors(t *testing.T) {
	newProvisioned := func(t *testing.T) (*Server, *store.Store) {
		s, st := newTestServer(t)
		s.SetOffsiteProvisioner(&offsite.Provisioner{
			API: hetznerapi.NewFake(), Store: st, Scanner: webTestScanner{},
			PoolBoxID: 611714, Location: "fsn1", Logger: log.New(io.Discard, "", 0),
		})
		cfg := &store.CustomerConfig{
			CustomerID: "c1", CustomerName: "Acme", Domain: "acme.hu",
			RetrievalPassword: "pw", APIKey: "k", Status: "active", ConfigJSON: "{}",
			DRTier: true, // offsite requires the DR tier since v0.51.0 (the F-6 coupling)
		}
		// Provision the shared offsite tier through the real applyOffsite leg so reissue/freeze
		// find exactly one labelled sub-account.
		r := postForm("/configs/c1/edit", "dr_tier=on&offsite_enabled=on&offsite_type=shared&offsite_quota_gb=50")
		if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
			t.Fatalf("provision: %v", err)
		}
		if err := st.SaveCustomerConfig(cfg); err != nil {
			t.Fatal(err)
		}
		return s, st
	}

	cases := []struct {
		name    string
		invoke  func(t *testing.T, s *Server, rr *httptest.ResponseRecorder)
		blocked bool // seed Status=blocked (for unblock)
		wantLoc string
	}{
		{"update", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleConfigUpdate(rr, postForm("/configs/c1/edit", "customer_name=Acme&domain=acme.hu"), "c1")
		}, false, "/customers/c1?flash=updated#tab=edit"},
		{"block", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleBlockCustomer(rr, postForm("/customers/c1/block", ""), "c1")
		}, false, "/customers/c1?flash=blocked#tab=edit"},
		{"unblock", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleUnblockCustomer(rr, postForm("/customers/c1/unblock", ""), "c1")
		}, true, "/customers/c1?flash=unblocked#tab=edit"},
		{"offsite-reissue", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleOffsiteReissue(rr, postForm("/configs/c1/offsite-reissue", ""), "c1")
		}, false, "/customers/c1?flash=offsite_reissued#tab=edit"},
		{"offsite-freeze", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleOffsiteFreeze(rr, postForm("/configs/c1/offsite-freeze", ""), "c1", true)
		}, false, "/customers/c1?flash=offsite_frozen#tab=edit"},
		{"regen-password lands on Setup (its card lives there)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleConfigRegenPassword(rr, postForm("/configs/c1/regen-password", ""), "c1")
		}, false, "/customers/c1?flash=password_regenerated#tab=setup"},
		// v0.69.0 (R-25b): the shallow delete became the guided cascade — same route, same
		// anchor-free landing, but the three acks + typed id + host-count are now required.
		{"delete stays anchor-free (leaves the page)", func(t *testing.T, s *Server, rr *httptest.ResponseRecorder) {
			s.handleCustomerDelete(rr, postForm("/configs/c1/delete",
				"ack_hosts=1&ack_reset=1&ack_purge=1&confirm_id=c1&expect_hosts=0"), "c1")
		}, false, "/configs?flash=deleted"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			s, st := newProvisioned(t)
			if tc.blocked {
				if err := st.SetCustomerConfigStatus("c1", "blocked"); err != nil {
					t.Fatal(err)
				}
			}
			rr := httptest.NewRecorder()
			tc.invoke(t, s, rr)
			if rr.Code != http.StatusSeeOther {
				t.Fatalf("status = %d, want 303: %s", rr.Code, rr.Body.String())
			}
			if loc := rr.Header().Get("Location"); loc != tc.wantLoc {
				t.Errorf("Location = %q, want %q", loc, tc.wantLoc)
			}
		})
	}
}
