package origin

import (
	"fmt"
	"net/url"
	"strings"

	"golang.org/x/net/publicsuffix"
)

// BrowserSessionTopology is the deployment topology an ambient first-party browser
// session requires. It is evaluated from configuration only; nothing here reads a
// request, because public authority must not be inferred from an arbitrary Host
// header.
type BrowserSessionTopology struct {
	// Ready reports whether a cookie-only browser session may be served at all.
	// A false value is a configuration verdict, not a request outcome.
	Ready bool

	// Reason is an operator-visible, non-secret explanation. It is set whenever
	// Ready is false, and names what to change.
	Reason string

	// FrontendOrigin and PublicAPIOrigin are the canonical origins the verdict was
	// computed from. An empty API base declares the relative same-origin mode, in
	// which case PublicAPIOrigin equals FrontendOrigin.
	FrontendOrigin  string
	PublicAPIOrigin string

	// SameOrigin reports that no cross-origin request is involved, so no CORS
	// answer is required at all.
	SameOrigin bool

	// RequiresCredentialedCORS reports that the two origins differ but share a
	// schemeful site. Serving this topology additionally requires an exact
	// Access-Control-Allow-Origin for FrontendOrigin plus
	// Access-Control-Allow-Credentials: true. Wildcard credentialed CORS is never
	// valid, so a deployment that cannot echo the exact origin is not ready.
	RequiresCredentialedCORS bool

	// LoopbackDevelopment reports that the verdict used the loopback development
	// exception rather than the production rule.
	LoopbackDevelopment bool
}

// EvaluateBrowserSessionTopology decides whether a configuration may serve an
// ambient first-party browser session.
//
// The rule is a schemeful-site comparison, not a host comparison: cross-origin is
// not the same as cross-site. HTTPS app.example.com and HTTPS api.example.com are
// different origins that share a schemeful site, and a SameSite=Strict cookie is
// sent in that topology; app.example.com and api.example.net do not, and the cookie
// is withheld.
//
// The registrable domain comes from a maintained public-suffix list, never from
// string suffix matching: comparing suffixes would treat two unrelated sites under a
// multi-label public suffix as one site, and would treat a public suffix itself as a
// registrable domain.
//
// This function decides readiness only. It never downgrades the cookie to
// SameSite=None to make a cross-site topology work, and it never falls back to
// browser-readable token storage; an unsatisfied topology means the browser session
// stays off and the operator changes the deployment.
func EvaluateBrowserSessionTopology(frontendURL, apiBaseURL string) BrowserSessionTopology {
	frontend, ok := topologyOrigin(frontendURL)
	if !ok {
		return BrowserSessionTopology{
			Reason: "FRONTEND_URL is not a canonical http(s) origin; set it to the exact origin serving the first-party UI",
		}
	}

	// An empty API base declares the existing relative/same-origin mode. Only an
	// explicitly configured base may move public authority to another origin.
	apiOrigin := frontend
	if strings.TrimSpace(apiBaseURL) != "" {
		apiOrigin, ok = topologyOrigin(apiBaseURL)
		if !ok {
			return BrowserSessionTopology{
				FrontendOrigin: frontend,
				Reason:         "API_BASE_URL is set but is not a canonical http(s) origin; leave it empty for same-origin serving or set the exact public API origin",
			}
		}
	}

	verdict := BrowserSessionTopology{FrontendOrigin: frontend, PublicAPIOrigin: apiOrigin}

	frontendScheme, frontendHost := splitOrigin(frontend)
	apiScheme, apiHost := splitOrigin(apiOrigin)

	frontendLoopback := isLoopbackHost(hostnameOf(frontendHost))
	apiLoopback := isLoopbackHost(hostnameOf(apiHost))

	switch {
	case frontendLoopback && apiLoopback:
		// Loopback development: the same loopback hostname and scheme, with ports
		// free, so a separate dev server port is allowed. A same-origin proxy is
		// still preferable, but this is a real supported topology.
		verdict.LoopbackDevelopment = true
		if frontendScheme != apiScheme {
			verdict.Reason = fmt.Sprintf(
				"loopback development requires one scheme; frontend is %s and the public API is %s",
				frontendScheme, apiScheme)
			return verdict
		}
		if hostnameOf(frontendHost) != hostnameOf(apiHost) {
			verdict.Reason = fmt.Sprintf(
				"loopback development requires the same loopback hostname; frontend is %s and the public API is %s",
				hostnameOf(frontendHost), hostnameOf(apiHost))
			return verdict
		}
		verdict.Ready = true
		verdict.SameOrigin = frontend == apiOrigin
		verdict.RequiresCredentialedCORS = !verdict.SameOrigin
		return verdict

	case frontendLoopback != apiLoopback:
		// One side loopback and the other not is neither the production topology nor
		// the development exception.
		verdict.Reason = "a loopback origin cannot be paired with a non-loopback origin; use one topology or the other"
		return verdict
	}

	// Production rule: both sides must be HTTPS. An ambient session cookie is
	// Secure, so a plaintext member of the pair cannot carry it.
	if frontendScheme != "https" || apiScheme != "https" {
		verdict.Reason = fmt.Sprintf(
			"a browser session requires HTTPS on both origins; frontend is %s and the public API is %s",
			frontendScheme, apiScheme)
		return verdict
	}

	if frontend == apiOrigin {
		verdict.Ready = true
		verdict.SameOrigin = true
		return verdict
	}

	frontendSite, err := registrableDomain(hostnameOf(frontendHost))
	if err != nil {
		verdict.Reason = fmt.Sprintf("cannot derive a registrable domain for the frontend host: %v", err)
		return verdict
	}
	apiSite, err := registrableDomain(hostnameOf(apiHost))
	if err != nil {
		verdict.Reason = fmt.Sprintf("cannot derive a registrable domain for the public API host: %v", err)
		return verdict
	}

	if frontendSite != apiSite {
		verdict.Reason = fmt.Sprintf(
			"frontend site %q and public API site %q are different schemeful sites, so a SameSite=Strict session cookie is withheld; move the API behind the frontend origin, use a same-site API domain, or add a same-site gateway",
			frontendSite, apiSite)
		return verdict
	}

	verdict.Ready = true
	verdict.RequiresCredentialedCORS = true
	return verdict
}

// topologyOrigin reduces a configured URL to its browser origin, accepting either
// scheme so the gate can report "requires HTTPS" rather than a generic parse
// failure. A path is dropped, because the origin of a configured UI URL is what a
// browser compares and what a cookie is scoped to.
func topologyOrigin(raw string) (string, bool) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return "", false
	}
	u, err := url.Parse(raw)
	if err != nil || !u.IsAbs() || u.Host == "" || u.User != nil {
		return "", false
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return "", false
	}
	return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host), true
}

// registrableDomain resolves the eTLD+1 through the public-suffix list. A host that
// is itself a public suffix has no registrable domain and cannot be a site.
func registrableDomain(host string) (string, error) {
	if host == "" {
		return "", fmt.Errorf("empty host")
	}
	if isIPLiteral(host) {
		// An IP literal has no registrable domain. Two different IPs are different
		// sites, and the same IP is already the same-origin case.
		return "", fmt.Errorf("host %q is an IP literal, which has no registrable domain", host)
	}
	domain, err := publicsuffix.EffectiveTLDPlusOne(host)
	if err != nil {
		return "", fmt.Errorf("host %q has no registrable domain: %w", host, err)
	}
	return domain, nil
}

func splitOrigin(origin string) (scheme, host string) {
	parts := strings.SplitN(origin, "://", 2)
	if len(parts) != 2 {
		return "", origin
	}
	return parts[0], parts[1]
}

// hostnameOf strips a port and IPv6 brackets, so comparisons are host-based and a
// differing port cannot look like a differing host.
func hostnameOf(host string) string {
	u := url.URL{Host: host}
	return strings.ToLower(u.Hostname())
}

func isLoopbackHost(hostname string) bool {
	switch hostname {
	case "localhost", "127.0.0.1", "::1":
		return true
	default:
		return false
	}
}

func isIPLiteral(host string) bool {
	if strings.Count(host, ":") >= 2 {
		return true // IPv6
	}
	if host == "" {
		return false
	}
	for _, label := range strings.Split(host, ".") {
		if label == "" {
			return false
		}
		for _, r := range label {
			if r < '0' || r > '9' {
				return false
			}
		}
	}
	return strings.Count(host, ".") == 3
}
