package origin_test

import (
	"strings"
	"testing"

	"github.com/pingcap-inc/nexuscrm-api-host/origin"
)

func TestBrowserSessionTopologyAcceptsSameOriginAndSameSite(t *testing.T) {
	cases := map[string]struct {
		frontend, api           string
		wantSameOrigin          bool
		wantCredentialedCORS    bool
		wantLoopbackDevelopment bool
	}{
		"relative same-origin serving": {
			frontend: "https://crm.example.com", api: "",
			wantSameOrigin: true,
		},
		"explicit identical origins": {
			frontend: "https://crm.example.com", api: "https://crm.example.com",
			wantSameOrigin: true,
		},
		"different subdomains share a schemeful site": {
			frontend: "https://app.example.com", api: "https://api.example.com",
			wantCredentialedCORS: true,
		},
		"apex and subdomain share a schemeful site": {
			frontend: "https://example.com", api: "https://api.example.com",
			wantCredentialedCORS: true,
		},
		"loopback with differing ports": {
			frontend: "http://localhost:5173", api: "http://localhost:8080",
			wantCredentialedCORS: true, wantLoopbackDevelopment: true,
		},
		"loopback same origin": {
			frontend: "http://localhost:5173", api: "http://localhost:5173",
			wantSameOrigin: true, wantLoopbackDevelopment: true,
		},
	}
	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			got := origin.EvaluateBrowserSessionTopology(tc.frontend, tc.api)
			if !got.Ready {
				t.Fatalf("not ready: %s", got.Reason)
			}
			if got.SameOrigin != tc.wantSameOrigin {
				t.Fatalf("SameOrigin = %v, want %v", got.SameOrigin, tc.wantSameOrigin)
			}
			if got.RequiresCredentialedCORS != tc.wantCredentialedCORS {
				t.Fatalf("RequiresCredentialedCORS = %v, want %v", got.RequiresCredentialedCORS, tc.wantCredentialedCORS)
			}
			if got.LoopbackDevelopment != tc.wantLoopbackDevelopment {
				t.Fatalf("LoopbackDevelopment = %v, want %v", got.LoopbackDevelopment, tc.wantLoopbackDevelopment)
			}
			// A same-origin topology needs no CORS answer at all.
			if got.SameOrigin && got.RequiresCredentialedCORS {
				t.Fatal("a same-origin topology must not require a CORS answer")
			}
		})
	}
}

func TestBrowserSessionTopologyFailsClosed(t *testing.T) {
	cases := map[string]struct {
		frontend, api string
		wantReason    string
	}{
		"cross-site registrable domains": {
			frontend: "https://app.example.com", api: "https://api.example.net",
			wantReason: "different schemeful sites",
		},
		"plaintext frontend in production": {
			frontend: "http://app.example.com", api: "https://api.example.com",
			wantReason: "requires HTTPS on both origins",
		},
		"plaintext api in production": {
			frontend: "https://app.example.com", api: "http://api.example.com",
			wantReason: "requires HTTPS on both origins",
		},
		"loopback paired with a public origin": {
			frontend: "http://localhost:5173", api: "https://api.example.com",
			wantReason: "cannot be paired with a non-loopback origin",
		},
		"loopback hostnames differ": {
			frontend: "http://localhost:5173", api: "http://127.0.0.1:8080",
			wantReason: "same loopback hostname",
		},
		"missing frontend origin": {
			frontend: "", api: "https://api.example.com",
			wantReason: "FRONTEND_URL is not a canonical",
		},
		"frontend is not absolute": {
			frontend: "app.example.com", api: "",
			wantReason: "FRONTEND_URL is not a canonical",
		},
		"api base is not an origin": {
			frontend: "https://app.example.com", api: "not-a-url",
			wantReason: "API_BASE_URL is set but is not a canonical",
		},
		"public suffix has no registrable domain": {
			// Both are public suffixes, so neither is a site an operator controls.
			frontend: "https://com", api: "https://co.uk",
			wantReason: "registrable domain",
		},
	}
	for name, tc := range cases {
		t.Run(name, func(t *testing.T) {
			got := origin.EvaluateBrowserSessionTopology(tc.frontend, tc.api)
			if got.Ready {
				t.Fatal("want the topology gate to fail closed")
			}
			if got.Reason == "" {
				t.Fatal("a failed gate must give the operator a reason")
			}
			if !strings.Contains(got.Reason, tc.wantReason) {
				t.Fatalf("reason = %q, want it to mention %q", got.Reason, tc.wantReason)
			}
			// A rejected topology never reports a servable mode.
			if got.SameOrigin || got.RequiresCredentialedCORS {
				t.Fatal("a rejected topology must not report a servable mode")
			}
		})
	}
}

// TestBrowserSessionTopologyUsesPublicSuffixNotStringSuffix is the reason the public
// suffix list is required. Under string suffix matching these pairs compare equal or
// unequal wrongly, and each mistake either withholds a working session or admits a
// cross-site one.
func TestBrowserSessionTopologyUsesPublicSuffixNotStringSuffix(t *testing.T) {
	// Two unrelated registrable domains under a multi-label public suffix. Naive
	// "last two labels" matching sees co.uk == co.uk and would wrongly admit them.
	crossSite := origin.EvaluateBrowserSessionTopology("https://app.alpha.co.uk", "https://api.beta.co.uk")
	if crossSite.Ready {
		t.Fatal("alpha.co.uk and beta.co.uk are different sites and must be rejected")
	}
	if !strings.Contains(crossSite.Reason, "different schemeful sites") {
		t.Fatalf("reason = %q, want a cross-site verdict", crossSite.Reason)
	}

	// The same registrable domain under a multi-label public suffix must be accepted.
	sameSite := origin.EvaluateBrowserSessionTopology("https://app.alpha.co.uk", "https://api.alpha.co.uk")
	if !sameSite.Ready {
		t.Fatalf("app and api under alpha.co.uk share a site: %s", sameSite.Reason)
	}
	if !sameSite.RequiresCredentialedCORS {
		t.Fatal("differing origins on one site require an exact credentialed CORS answer")
	}

	// A plain string-suffix test would also call these the same site, because one
	// host is a literal suffix of the other.
	suffixTrap := origin.EvaluateBrowserSessionTopology("https://app.example.com", "https://notexample.com")
	if suffixTrap.Ready {
		t.Fatal("notexample.com is not the same site as example.com")
	}
}

func TestBrowserSessionTopologyRejectsIPLiteralSites(t *testing.T) {
	// A non-loopback IP literal has no registrable domain, so it cannot be one side
	// of a same-site pair.
	got := origin.EvaluateBrowserSessionTopology("https://203.0.113.10", "https://api.example.com")
	if got.Ready {
		t.Fatal("an IP literal has no site and must not be admitted")
	}
	if !strings.Contains(got.Reason, "registrable domain") {
		t.Fatalf("reason = %q, want it to name the missing registrable domain", got.Reason)
	}
}

func TestBrowserSessionTopologyIgnoresPortWhenComparingSites(t *testing.T) {
	// Ports do not participate in a site comparison, so a non-default API port on
	// the same site stays serviceable.
	got := origin.EvaluateBrowserSessionTopology("https://app.example.com", "https://api.example.com:8443")
	if !got.Ready {
		t.Fatalf("a differing port on the same site must stay ready: %s", got.Reason)
	}
	if !got.RequiresCredentialedCORS {
		t.Fatal("differing origins require an exact credentialed CORS answer")
	}
}

// TestBrowserSessionTopologyCanonicalizesAConfiguredPath pins that a configured UI
// URL carrying a path is reduced to its origin rather than rejected: the origin is
// what a browser compares and what the session cookie is scoped to.
func TestBrowserSessionTopologyCanonicalizesAConfiguredPath(t *testing.T) {
	got := origin.EvaluateBrowserSessionTopology("https://app.example.com/ui/", "")
	if !got.Ready {
		t.Fatalf("not ready: %s", got.Reason)
	}
	if got.FrontendOrigin != "https://app.example.com" {
		t.Fatalf("FrontendOrigin = %q, want the origin without the path", got.FrontendOrigin)
	}
	if got.PublicAPIOrigin != got.FrontendOrigin {
		t.Fatal("an empty API base declares same-origin serving")
	}
}
