package web

// Network card (hub v0.85.0) — the host's addresses + its WireGuard allocation.
//
// The report fixture below is the REAL wire: it is the `addresses` block copied out of
// `felhom-agent --selftest=hub` on demo-felhom running agent 0.119.0 on 2026-07-31. Testing against
// a hand-written shape would have proved only that the parser matches my own idea of the format.
//
// Every test drives ServeHTTP, so a route/template gate that never renders is visible here.

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

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

// liveReportJSON is demo-felhom's real report body, trimmed to the parsed fields.
const liveReportJSON = `{
  "host": {"cpu_percent": 4.0, "memory_percent": 30.0, "disk_percent": 20.0},
  "addresses": [
    {"iface": "tailscale0", "cidr": "100.70.170.35/32"},
    {"iface": "tailscale0", "cidr": "fd7a:115c:a1e0::5236:aa24/128"},
    {"iface": "vmbr0", "cidr": "192.168.0.162/24"},
    {"iface": "wg-felhom", "cidr": "10.77.0.2/32"}
  ]
}`

// seedNetHost creates a host with a report and (optionally) a WG peer allocation.
func seedNetHost(t *testing.T, st *store.Store, hostID, agentVersion, reportJSON, wgIP string) {
	t.Helper()
	if err := st.UpsertHost(&store.Host{
		HostID: hostID, CustomerID: "c-" + hostID, APIKey: "k-" + hostID, AgentVersion: agentVersion,
	}); err != nil {
		t.Fatal(err)
	}
	if reportJSON != "" {
		if err := st.SaveHostReport(hostID, "c-"+hostID, []byte(reportJSON), store.HostReportDenorm{
			AgentVersion: agentVersion,
		}); err != nil {
			t.Fatal(err)
		}
	}
	if wgIP != "" {
		if err := st.SetWGEndpoint(&store.WGEndpoint{
			EndpointID: "ep0", DNSName: "ep0.example", WGPort: 51820,
			ServerPubkey: "srv", TunnelSubnet: "10.77.0.0/24", PBSTunnelIP: "10.77.0.1",
		}); err != nil {
			t.Fatal(err)
		}
		if _, _, err := t3AddPeer(st, hostID, wgIP); err != nil {
			t.Fatal(err)
		}
	}
}

// t3AddPeer binds a peer at a SPECIFIC address (the allocator picks the lowest free one, so the
// fixture drives it to the address the live box actually holds).
func t3AddPeer(st *store.Store, hostID, wantIP string) (string, bool, error) {
	// Allocate sequentially until the wanted address is the one handed out; the subnet is /24 and
	// the fixtures ask for .2/.3, so this costs a couple of rows.
	for i := 0; i < 8; i++ {
		ip, existed, err := st.AddWGPeer("pk-"+hostID+"-"+string(rune('a'+i)), hostID, "test")
		if err != nil {
			return "", false, err
		}
		if ip == wantIP {
			return ip, existed, nil
		}
	}
	return "", false, nil
}

func getHostPage(t *testing.T, s *Server, cookie *http.Cookie, hostID string) string {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/hosts/"+hostID, nil)
	req.AddCookie(cookie)
	rr := httptest.NewRecorder()
	s.RequireAuth(http.HandlerFunc(s.ServeHTTP)).ServeHTTP(rr, req)
	if rr.Code != http.StatusOK {
		t.Fatalf("host page = %d, want 200", rr.Code)
	}
	return rr.Body.String()
}

// --- A: the LAN address reaches the page. This is the whole point of the feature. ---
// RED-PROOF A: delete the `"Network": network` key from hostDetailData → red.
func TestNetwork_A_LANAddressRendered(t *testing.T) {
	s, st, _ := newRevealServer(t)
	cookie, _ := newRevealSession(t, s)
	seedNetHost(t, st, "demo-felhom-8363b5", "0.119.0", liveReportJSON, "10.77.0.2")

	body := getHostPage(t, s, cookie, "demo-felhom-8363b5")

	if !strings.Contains(body, "192.168.0.162/24") {
		t.Fatal("the LAN address is not on the host page — the feature shows nothing")
	}
	if !strings.Contains(body, "vmbr0") {
		t.Error("the interface carrying the LAN address is not named")
	}
	if !strings.Contains(body, "Network") {
		t.Error("no Network card")
	}
	// The tailnet address is a real address and is shown too, labelled by its interface.
	if !strings.Contains(body, "100.70.170.35/32") || !strings.Contains(body, "tailscale0") {
		t.Error("the tailnet address was dropped")
	}
}

// --- B: WireGuard is TWO facts — the hub's allocation, and whether the box confirms it ---
// RED-PROOF B: make hostNetwork set WGConfirmed = true unconditionally → the C case below goes red.
func TestNetwork_B_WireGuardAllocatedAndConfirmed(t *testing.T) {
	s, st, _ := newRevealServer(t)
	cookie, _ := newRevealSession(t, s)
	seedNetHost(t, st, "demo-felhom-8363b5", "0.119.0", liveReportJSON, "10.77.0.2")

	body := getHostPage(t, s, cookie, "demo-felhom-8363b5")

	if !strings.Contains(body, "10.77.0.2") {
		t.Fatal("the WireGuard address is not on the page")
	}
	if !strings.Contains(body, "confirmed") {
		t.Error("a box that reports holding its allocated address is not marked confirmed")
	}
	// The WG address must NOT also appear as an ordinary address row — one fact, one place.
	if strings.Contains(body, "wg-felhom") {
		t.Error("the WireGuard address was repeated in the address table instead of its own row")
	}
}

// The drift case that motivates splitting the two facts: the hub allocated a peer the box does not
// hold. Rendering the allocation alone would show this as healthy.
func TestNetwork_C_AllocatedButBoxDoesNotHoldIt(t *testing.T) {
	s, st, _ := newRevealServer(t)
	cookie, _ := newRevealSession(t, s)
	// The report carries NO 10.77.0.x address — the peer exists only in the hub.
	report := `{"addresses":[{"iface":"vmbr0","cidr":"192.168.0.50/24"}]}`
	seedNetHost(t, st, "drifted-host", "0.119.0", report, "10.77.0.2")

	body := getHostPage(t, s, cookie, "drifted-host")

	if !strings.Contains(body, "not confirmed by the box") {
		t.Fatal("an allocated-but-unheld WireGuard peer renders as healthy — the drift is invisible")
	}
	if strings.Contains(body, ">confirmed<") {
		t.Error("the page claims confirmation the box never gave")
	}
}

// --- D: an OLD agent means UNKNOWN, not "no addresses" (presence is not result) ---
// RED-PROOF D: drop the AgentTooOld branch from hostNetwork → the page renders the generic
// "no routable address" line and this goes red.
func TestNetwork_D_OldAgentSaysUnknownNotNone(t *testing.T) {
	s, st, _ := newRevealServer(t)
	cookie, _ := newRevealSession(t, s)
	// 0.118.1 predates addresses[] — it sends no such key at all.
	seedNetHost(t, st, "old-agent-host", "0.118.1", `{"host":{"cpu_percent":1}}`, "10.77.0.2")

	body := getHostPage(t, s, cookie, "old-agent-host")

	if !strings.Contains(body, "does not report its addresses") {
		t.Fatal("an old agent's silence is not explained")
	}
	if !strings.Contains(body, "unknown") {
		t.Error("the page must say UNKNOWN — an absent field and an empty result are different facts")
	}
	if strings.Contains(body, "reports no routable address") {
		t.Fatal("an old agent renders as 'this host has no addresses', which is a false statement")
	}
	// The allocation is still shown — the hub knows it regardless of the agent's version.
	if !strings.Contains(body, "10.77.0.2") {
		t.Error("the WireGuard allocation vanished just because the agent is old")
	}
}

// --- E: a host with no WG peer says so, rather than rendering a blank ---
func TestNetwork_E_NoPeerAllocated(t *testing.T) {
	s, st, _ := newRevealServer(t)
	cookie, _ := newRevealSession(t, s)
	seedNetHost(t, st, "no-peer-host", "0.119.0", liveReportJSON, "")

	body := getHostPage(t, s, cookie, "no-peer-host")

	if !strings.Contains(body, "no peer allocated") {
		t.Error("a host without a WireGuard peer does not say so")
	}
	// With no allocation the WG address cannot be split out, so it stays an ordinary row — still
	// visible, never silently dropped.
	if !strings.Contains(body, "10.77.0.2/32") {
		t.Error("the box's wg address disappeared when the hub had no allocation to match it against")
	}
}

// --- F: never-reported host — the card must not claim anything ---
func TestNetwork_F_NoReportYet(t *testing.T) {
	s, st, _ := newRevealServer(t)
	cookie, _ := newRevealSession(t, s)
	seedNetHost(t, st, "fresh-host", "0.119.0", "", "")

	body := getHostPage(t, s, cookie, "fresh-host")

	if !strings.Contains(body, "Waiting for the first host report") {
		t.Error("a never-reported host does not say it is waiting")
	}
	if strings.Contains(body, "reports no routable address") {
		t.Error("a host that never reported is described as having reported no addresses")
	}
}

// --- G: the parser, against the real wire ---
func TestParseHostAddresses_RealWire(t *testing.T) {
	got := parseHostAddresses(liveReportJSON)
	if len(got) != 4 {
		t.Fatalf("want 4 addresses from the live report, got %d: %+v", len(got), got)
	}
	// The bare IP must be split off the CIDR — it is what the WG comparison keys on.
	var found bool
	for _, a := range got {
		if a.Iface == "vmbr0" {
			found = true
			if a.CIDR != "192.168.0.162/24" || a.IP != "192.168.0.162" {
				t.Errorf("vmbr0 parsed wrong: %+v", a)
			}
		}
	}
	if !found {
		t.Error("vmbr0 missing from the parse")
	}
	// Degradations: neither an empty body nor a malformed one may panic or invent rows.
	if got := parseHostAddresses(""); len(got) != 0 {
		t.Errorf("empty body produced %d rows", len(got))
	}
	if got := parseHostAddresses("{not json"); len(got) != 0 {
		t.Errorf("malformed body produced %d rows", len(got))
	}
	if got := parseHostAddresses(`{"host":{}}`); got == nil {
		t.Error("a report without addresses returned nil rather than an empty slice")
	}
}
