package handlers_test

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"thermograph/frontend/internal/handlers"
)

// newStaticDir builds a throwaway static dir with the SPA shells and assets
// the tests poke at.
func newStaticDir(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	shell := "<!doctype html>\n<html><head>\n<title>t</title>\n" +
		`<meta property="og:url" content="__ORIGIN__/calendar">` +
		"\n</head><body>__ORIGIN__</body></html>\n"
	for _, f := range []string{"calendar.html", "score.html", "compare.html", "legend.html", "subscriptions.html"} {
		if err := os.WriteFile(filepath.Join(dir, f), []byte(shell), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	// day.html deliberately missing: the missing-shell test uses /day.
	if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('hi')\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.Mkdir(filepath.Join(dir, "icons"), 0o755); err != nil {
		t.Fatal(err)
	}
	return dir
}

func newMux(t *testing.T, base string, opt func(*handlers.Options)) *http.ServeMux {
	t.Helper()
	o := handlers.Options{Base: base, StaticDir: newStaticDir(t)}
	if opt != nil {
		opt(&o)
	}
	mux := http.NewServeMux()
	handlers.New(o).Register(mux)
	return mux
}

func get(mux *http.ServeMux, method, target string, hdr map[string]string) *httptest.ResponseRecorder {
	r := httptest.NewRequest(method, target, nil)
	for k, v := range hdr {
		r.Header.Set(k, v)
	}
	w := httptest.NewRecorder()
	mux.ServeHTTP(w, r)
	return w
}

// --- SPA shells (app.py's _page) ---------------------------------------------

func TestShellServesWithOriginFilledIn(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	w := get(mux, "GET", "http://example.com/thermograph/calendar", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	if ct := w.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" {
		t.Errorf("content-type = %q", ct)
	}
	body := w.Body.String()
	if strings.Contains(body, "__ORIGIN__") {
		t.Error("__ORIGIN__ placeholder not substituted")
	}
	if !strings.Contains(body, `content="http://example.com/thermograph/calendar"`) {
		t.Errorf("origin prefix missing from og:url: %s", body)
	}
	if w.Header().Get("ETag") == "" {
		t.Error("no ETag on shell response")
	}

	// HEAD serves the same route with an empty body (methods=["GET","HEAD"]).
	h := get(mux, "HEAD", "http://example.com/thermograph/calendar", nil)
	if h.Code != http.StatusOK || h.Body.Len() != 0 {
		t.Errorf("HEAD: status = %d, body = %d bytes", h.Code, h.Body.Len())
	}
}

func TestShellETagExactMatch304(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	first := get(mux, "GET", "http://example.com/thermograph/legend", nil)
	etag := first.Header().Get("ETag")
	if etag == "" {
		t.Fatal("no ETag")
	}

	// Exact match -> 304 with the ETag and no body (app.py's _not_modified).
	w := get(mux, "GET", "http://example.com/thermograph/legend", map[string]string{"If-None-Match": etag})
	if w.Code != http.StatusNotModified {
		t.Fatalf("status = %d, want 304", w.Code)
	}
	if w.Header().Get("ETag") != etag {
		t.Error("304 must carry the ETag")
	}
	if w.Body.Len() != 0 {
		t.Error("304 must have no body")
	}

	// The Python compared the raw header — a list containing the ETag does
	// NOT match (unlike the content pages' comma-set semantics).
	w = get(mux, "GET", "http://example.com/thermograph/legend",
		map[string]string{"If-None-Match": `W/"other", ` + etag})
	if w.Code != http.StatusOK {
		t.Errorf("list If-None-Match: status = %d, want 200 (exact-match semantics)", w.Code)
	}
}

func TestShellPerOriginMemo(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	a := get(mux, "GET", "http://a.example/thermograph/score", nil)
	b := get(mux, "GET", "http://b.example/thermograph/score", nil)
	if !strings.Contains(a.Body.String(), "http://a.example/thermograph") {
		t.Error("first origin not substituted")
	}
	if !strings.Contains(b.Body.String(), "http://b.example/thermograph") {
		t.Error("second origin not substituted (memo leaked across origins?)")
	}
	if a.Header().Get("ETag") == b.Header().Get("ETag") {
		t.Error("different origins must get different ETags")
	}
}

func TestShellForwardedHeadersWinOverHost(t *testing.T) {
	// The proxied topology: Host is the internal hop, X-Forwarded-* is the
	// browser-facing origin — the forwarded values must win (content._origin).
	mux := newMux(t, "/thermograph", nil)
	w := get(mux, "GET", "http://frontend:8080/thermograph/compare", map[string]string{
		"X-Forwarded-Proto": "https",
		"X-Forwarded-Host":  "thermograph.org",
	})
	if !strings.Contains(w.Body.String(), "https://thermograph.org/thermograph") {
		t.Errorf("forwarded origin not used: %s", w.Body.String())
	}
}

func TestShellHeadVerifyInjection(t *testing.T) {
	mux := newMux(t, "/thermograph", func(o *handlers.Options) {
		o.GoogleVerify = `g"tok&en`
		o.BingVerify = "bing-tok"
	})
	w := get(mux, "GET", "http://example.com/thermograph/calendar", nil)
	body := w.Body.String()
	want := "<head>\n  " +
		`<meta name="google-site-verification" content="g&#34;tok&amp;en">` + "\n  " +
		`<meta name="msvalidate.01" content="bing-tok">`
	if !strings.Contains(body, want) {
		t.Errorf("verification metas not injected after <head>:\n%s", body)
	}
}

func TestShellMissingFileIs500(t *testing.T) {
	mux := newMux(t, "/thermograph", nil) // day.html not written
	w := get(mux, "GET", "http://example.com/thermograph/day", nil)
	if w.Code != http.StatusInternalServerError {
		t.Errorf("status = %d, want 500", w.Code)
	}
}

// --- static assets (app.py's _CachedStaticFiles mount) -----------------------

func TestStaticAssetServes(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	w := get(mux, "GET", "http://example.com/thermograph/app.js", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("status = %d", w.Code)
	}
	if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=300" {
		t.Errorf("Cache-Control = %q", cc)
	}
	if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
		t.Errorf("content-type = %q", ct)
	}

	// Conditional refetch: the stat-derived ETag answers with an empty 304
	// that still carries the Cache-Control window.
	etag := w.Header().Get("ETag")
	if etag == "" {
		t.Fatal("no ETag on static response")
	}
	c := get(mux, "GET", "http://example.com/thermograph/app.js", map[string]string{"If-None-Match": etag})
	if c.Code != http.StatusNotModified {
		t.Fatalf("conditional status = %d, want 304", c.Code)
	}
	if cc := c.Header().Get("Cache-Control"); cc != "public, max-age=300" {
		t.Errorf("304 Cache-Control = %q", cc)
	}
}

func TestStaticMissingIs404(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	w := get(mux, "GET", "http://example.com/thermograph/nope.css", nil)
	if w.Code != http.StatusNotFound {
		t.Fatalf("status = %d", w.Code)
	}
	if w.Body.String() != "Not Found" {
		t.Errorf("body = %q, want Starlette's plain Not Found", w.Body.String())
	}
}

func TestStaticIndexHTMLIs404(t *testing.T) {
	// index.html must not linger behind the static mount as indexable
	// duplicate content now that / is server-rendered — and Go's FileServer
	// would 301 it to "./" even when absent, so the custom handler must 404
	// (tests/unit/test_pages.py::test_old_static_index_is_gone).
	mux := newMux(t, "/thermograph", nil)
	w := get(mux, "GET", "http://example.com/thermograph/index.html", nil)
	if w.Code != http.StatusNotFound {
		t.Errorf("status = %d, want 404 (no redirect, no listing)", w.Code)
	}
}

func TestStaticDirectoryIs404(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	for _, target := range []string{
		"http://example.com/thermograph/icons",  // existing directory
		"http://example.com/thermograph/icons/", // trailing slash
		"http://example.com/thermograph/app.js/",
	} {
		if w := get(mux, "GET", target, nil); w.Code != http.StatusNotFound {
			t.Errorf("%s: status = %d, want 404", target, w.Code)
		}
	}
}

func TestStaticTraversalIs404(t *testing.T) {
	dir := newStaticDir(t)
	// A secret OUTSIDE the static dir must be unreachable even when the
	// handler is invoked directly with an uncleaned path (the content
	// package's lazy IndexNow route delegates without a mux in front).
	if err := os.WriteFile(filepath.Join(filepath.Dir(dir), "secret.txt"), []byte("s"), 0o644); err != nil {
		t.Fatal(err)
	}
	static := handlers.New(handlers.Options{Base: "/thermograph", StaticDir: dir}).Static()
	r := httptest.NewRequest("GET", "http://example.com/ignored", nil)
	r.URL.Path = "/thermograph/../secret.txt" // bypass URL parsing/cleaning
	w := httptest.NewRecorder()
	static.ServeHTTP(w, r)
	if w.Code != http.StatusNotFound {
		t.Errorf("status = %d, want 404", w.Code)
	}
}

// --- bare-BASE redirect + root-base topology ---------------------------------

func TestBareBaseRedirects307(t *testing.T) {
	mux := newMux(t, "/thermograph", nil)
	w := get(mux, "GET", "http://example.com/thermograph", nil)
	if w.Code != http.StatusTemporaryRedirect {
		t.Fatalf("status = %d, want 307 (Starlette redirect_slashes)", w.Code)
	}
	if loc := w.Header().Get("Location"); loc != "/thermograph/" {
		t.Errorf("Location = %q", loc)
	}
}

func TestRootBaseTopology(t *testing.T) {
	// The deployed clean-root topology: THERMOGRAPH_BASE=/ -> Base == "".
	mux := newMux(t, "", nil)
	if w := get(mux, "GET", "http://example.com/app.js", nil); w.Code != http.StatusOK {
		t.Errorf("/app.js: status = %d", w.Code)
	}
	w := get(mux, "GET", "http://example.com/calendar", nil)
	if w.Code != http.StatusOK {
		t.Fatalf("/calendar: status = %d", w.Code)
	}
	if !strings.Contains(w.Body.String(), `content="http://example.com/calendar"`) {
		t.Errorf("origin prefix at root base wrong: %s", w.Body.String())
	}
}
