// Command webapp is the Web App backend-for-frontend. It serves the embedded
// Vue single-page app and reverse-proxies /api/* to the API Server, so the
// browser only ever talks to this server (same-origin) and all data access
// still flows through the API Server.
//
// /ocpp/* is proxied too, for the chargers rather than the browser. The API
// Server hands a charger the endpoint to dial back on, and derives it from the
// Host of the request that asked — which, since the panel asks through this
// proxy, is this server. Without the route that address answered a WebSocket
// handshake with index.html, so the charger could never connect to the address
// it had been given.
package main

import (
	"embed"
	"io/fs"
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"os"
	"strings"
	"time"
)

//go:embed all:dist
var distFS embed.FS

func getenv(key, def string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return def
}

func main() {
	log.SetFlags(log.LstdFlags | log.Lmsgprefix)
	log.SetPrefix("[web] ")

	loadDotEnv(".env")
	addr := getenv("WEB_ADDR", ":8090")
	apiBase := strings.TrimRight(getenv("API_BASE", "http://localhost:8080"), "/")
	// Whether an inbound X-Forwarded-Proto is believed. The API Server reads
	// that header to decide a charger reached it over TLS, so a client that can
	// set it freely could talk plaintext OCPP into a server configured to demand
	// wss. It is therefore overwritten with this server's own scheme unless the
	// operator says there is a TLS-terminating proxy in front worth trusting.
	trustForwardedProto := getenv("TRUST_FORWARDED_PROTO", "") == "true"

	apiURL, err := url.Parse(apiBase)
	if err != nil {
		log.Fatalf("invalid API_BASE %q: %v", apiBase, err)
	}

	// Reverse proxy: /api/* and /ocpp/* -> API Server (path preserved).
	proxy := httputil.NewSingleHostReverseProxy(apiURL)
	director := proxy.Director
	proxy.Director = func(r *http.Request) {
		director(r)
		// Say how the client reached *this* server. ReverseProxy relays a 101 by
		// hijacking the connection, so the OCPP upgrade survives the hop; what it
		// cannot tell the API Server on its own is the scheme.
		if !trustForwardedProto || r.Header.Get("X-Forwarded-Proto") == "" {
			r.Header.Set("X-Forwarded-Proto", forwardedProto(r))
		}
	}
	proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
		log.Printf("proxy error for %s: %v", r.URL.Path, e)
		if strings.HasPrefix(r.URL.Path, "/ocpp/") {
			// A charger is not reading JSON. Say it plainly and briefly.
			http.Error(w, "api server unavailable", http.StatusBadGateway)
			return
		}
		http.Error(w, `{"error":"api server unavailable"}`, http.StatusBadGateway)
	}

	// Embedded SPA file server.
	sub, err := fs.Sub(distFS, "dist")
	if err != nil {
		log.Fatalf("embed dist: %v", err)
	}
	spa := http.FileServer(http.FS(sub))

	mux := http.NewServeMux()
	mux.Handle("/api/", proxy)
	// The chargers' door. Registered explicitly so the SPA catch-all below never
	// answers a WebSocket handshake with a web page.
	mux.Handle("/ocpp/", proxy)

	// Liveness probe. The API Server polls this for the panel status page (see
	// WEBAPP_URL), and container healthchecks use it. It must be a real route:
	// without one the SPA catch-all below would answer with index.html, which
	// looks healthy even when the proxy target is misconfigured.
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// Serve static assets when they exist; otherwise fall back to index.html
		// so client-side routing works on deep links.
		if r.URL.Path != "/" {
			if f, err := sub.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
				f.Close()
				spa.ServeHTTP(w, r)
				return
			}
		}
		r2 := r.Clone(r.Context())
		r2.URL.Path = "/"
		spa.ServeHTTP(w, r2)
	})

	srv := &http.Server{
		Addr:              addr,
		Handler:           logRequests(mux),
		ReadHeaderTimeout: 10 * time.Second,
	}
	log.Printf("listening on %s (proxying /api and /ocpp -> %s)", addr, apiBase)
	if err := srv.ListenAndServe(); err != nil {
		log.Fatalf("server error: %v", err)
	}
}

// forwardedProto reports the scheme this server was reached on.
func forwardedProto(r *http.Request) string {
	if r.TLS != nil {
		return "https"
	}
	return "http"
}

func logRequests(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
	})
}

// loadDotEnv loads KEY=VALUE pairs from a .env file if present.
func loadDotEnv(path string) {
	data, err := os.ReadFile(path)
	if err != nil {
		return
	}
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		k, v, ok := strings.Cut(line, "=")
		if !ok {
			continue
		}
		k = strings.TrimSpace(k)
		v = strings.Trim(strings.TrimSpace(v), `"'`)
		if _, exists := os.LookupEnv(k); !exists {
			_ = os.Setenv(k, v)
		}
	}
}
