package main

import (
	"context"
	"log"
	"net"
	"net/url"
	"os"
	"sort"
	"strings"

	"github.com/warmbly/warmbly/internal/config"
	"github.com/warmbly/warmbly/internal/notify"
)

// insecureDefaults are the working secret values shipped in docker-compose.yml
// and the Makefile so `docker compose up` boots with no .env. They are public
// in this repository, so a deployment still running on one is not protected by
// it at all: a forged AUTH_SECRET JWT is accepted by the realtime service, and
// the local KMS master key unwraps every organization DEK offline.
//
// The map is keyed by env var so the check is exact rather than a heuristic.
var insecureDefaults = map[string]string{
	"AUTH_SECRET":                "local-dev-auth-secret-minimum-32-characters-long",
	"KMS_LOCAL_MASTER_KEY":       "Xr0JA7gqF2POy29a7MRByyqddivTNt8WOyKsOXklazk=",
	"CREDENTIALS_ENCRYPTION_KEY": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
	"INTERNAL_API_TOKEN":         "local-dev-internal-token",
	"SECRET_KEY_BASE":            "local-development-secret-key-base-minimum-64-characters-for-phoenix",
}

// checkSecrets refuses to start a deployment that reaches the network while
// still using a published default secret. Local development is exempt: that is
// what the defaults exist for.
//
// ALLOW_INSECURE_DEFAULTS=true downgrades the refusal to a warning, for an
// operator who genuinely wants a throwaway instance on a trusted LAN.
// It returns the offenders so the health page can keep reporting them for as
// long as they are in use, not just once at boot.
func checkSecrets() []string {
	var offenders []string
	for env, def := range insecureDefaults {
		if os.Getenv(env) == def {
			offenders = append(offenders, env)
		}
	}
	if len(offenders) == 0 {
		return nil
	}
	sort.Strings(offenders)

	list := strings.Join(offenders, ", ")
	if isDevEnv() || strings.EqualFold(os.Getenv("ALLOW_INSECURE_DEFAULTS"), "true") {
		log.Printf("Warning: using the published default value for %s. Anyone can read these from the Warmbly repository. Generate real values before this instance is reachable by other people (make gen-key).", list)
		return offenders
	}

	log.Fatalf("Refusing to start: %s still hold the published default value from docker-compose.yml. "+
		"Those defaults are public, so they provide no protection. Generate real values (make gen-key) and put them in .env, "+
		"or set ALLOW_INSECURE_DEFAULTS=true if this instance is genuinely disposable.", list)
	return offenders
}

func isDevEnv() bool {
	env := strings.ToLower(strings.TrimSpace(os.Getenv("APP_ENV")))
	return env == "" || env == "dev" || env == "development" || env == "local"
}

// passkeysUsableFor reports whether WebAuthn can work on this deployment's
// origin. Passkeys require a secure context and an RP ID that is a real
// domain, so a plain-http LAN address fails in the browser with an opaque
// error. Detecting it here lets GET /auth/config hide the button instead.
func passkeysUsableFor(appURL string) bool {
	if appURL == "" {
		return false
	}
	u, err := url.Parse(appURL)
	if err != nil {
		return false
	}
	if u.Scheme == "https" {
		return true
	}
	// http is a secure context only on loopback.
	host := u.Hostname()
	return host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasSuffix(host, ".localhost")
}

// ssoRedirectURL is where a provider sends the browser back. The explicit
// override wins; otherwise it derives from the backend's public base, because
// the callback is served by the API and not by the dashboard.
func ssoRedirectURL(explicit, provider string) string {
	if v := strings.TrimSpace(explicit); v != "" {
		return v
	}
	base := strings.TrimRight(os.Getenv("API_PUBLIC_URL"), "/")
	if base == "" {
		return ""
	}
	// The routes are registered on /v1, not /api/v1: there is no /api prefix.
	return base + "/v1/auth/" + provider + "/callback"
}

func oidcRedirectURL() string { return ssoRedirectURL(os.Getenv("OIDC_REDIRECT_URL"), "oidc") }

// warnSSORedirectOrigin fires on the mistake that produces a working OAuth
// client and a broken button: a redirect URI on the dashboard origin, which
// serves no callback route.
func warnSSORedirectOrigin(provider, redirectURL string) {
	if redirectURL == "" {
		return
	}
	u, err := url.Parse(redirectURL)
	if err != nil {
		return
	}
	if strings.Contains(u.Path, "/v1/auth/") {
		return
	}
	app, aerr := url.Parse(config.AppBaseURL())
	if aerr != nil || app.Host == "" || app.Host != u.Host {
		return
	}
	log.Printf("Warning: the %s sign-in redirect URI %s points at the dashboard, which serves no callback. It should be %s (your API_PUBLIC_URL), registered at the provider.",
		provider, redirectURL, ssoRedirectURL("", provider))
}

// oauthPublicBaseURL is the base every mailbox-connect redirect_uri is built
// from. It has to be the address the provider will send a browser back to,
// which is API_PUBLIC_URL, the same value oidcRedirectURL derives from and the
// one .env.example already documents as `<API_PUBLIC_URL>/addresses/...`.
//
// It is emphatically NOT API_HOST: that is the listener's bind address, which
// stays 0.0.0.0:8080 in a container. Sent as a redirect_uri it is not even an
// absolute URI, so the provider rejects the request outright.
func oauthPublicBaseURL(bindAddr string) string {
	if v := strings.TrimRight(strings.TrimSpace(os.Getenv("API_PUBLIC_URL")), "/"); v != "" {
		return v
	}
	return browsableBaseFromBindAddr(bindAddr)
}

// browsableBaseFromBindAddr turns a listener address into a URL a browser on
// the same host can actually open, so a stock local install that never set
// API_PUBLIC_URL still produces an absolute redirect_uri.
func browsableBaseFromBindAddr(addr string) string {
	addr = strings.TrimSpace(addr)
	if addr == "" {
		return ""
	}
	// Already a URL (someone put a real base in API_HOST): keep it as-is.
	if strings.Contains(addr, "://") {
		return strings.TrimRight(addr, "/")
	}

	host, port, err := net.SplitHostPort(addr)
	if err != nil {
		host, port = addr, ""
	}
	// A wildcard bind is reachable from the host itself as localhost.
	switch host {
	case "", "0.0.0.0", "::", "[::]":
		host = "localhost"
	}
	if port == "" {
		return "http://" + host
	}
	return "http://" + net.JoinHostPort(host, port)
}

// splitList parses a comma-separated env list.
func splitList(v string) []string {
	out := []string{}
	for _, part := range strings.Split(v, ",") {
		if trimmed := strings.TrimSpace(part); trimmed != "" {
			out = append(out, trimmed)
		}
	}
	return out
}

func mailTransportKind(t *notify.Transport) string {
	if t == nil {
		return ""
	}
	return t.Kind
}

// warnDeploymentURLs surfaces the configuration mistakes that silently break
// auth once an operator moves off localhost, each of which previously appeared
// only as a failure in the browser.
func warnDeploymentURLs(ctx context.Context, appURL string) {
	if appURL == "" {
		log.Printf("Warning: APP_URL is not set. Password reset and team invitation emails will link to %s, which is almost certainly not this deployment.", config.AppBaseURL())
		return
	}
	if !passkeysUsableFor(appURL) {
		log.Printf("Warning: APP_URL is %s. Passkeys need a secure context, so they are disabled: browsers refuse WebAuthn on plain http outside localhost, and an IP address cannot be a relying-party ID. Put the dashboard behind HTTPS to enable them.", appURL)
	}
}
