package web

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
	"sourcecraft.dev/bigbes/sr-ht-ecore/login"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"
	"sourcecraft.dev/bigbes/sr-ht-compare/core"
	"sourcecraft.dev/bigbes/sr-ht-compare/gitx"
)

// TestMain seeds core-go's process-global crypto with ecore's fixed test
// keyset, which is what lets logIn below seal a unified-login cookie the login
// middleware can open again.
func TestMain(m *testing.M) {
	ecoretest.InitCrypto()
	os.Exit(m.Run())
}

// ---- fixtures -------------------------------------------------------------

// stubAuthorizer is a fixed-map Authorizer with optional error injection.
type stubAuthorizer struct {
	repos map[string]authz.RepoInfo // key "owner/name"
	my    []authz.RepoInfo
	err   error // when set, every call fails with this (transport-style) error
}

func (s *stubAuthorizer) Repo(_ context.Context, _, owner, name string) (*authz.RepoInfo, error) {
	if s.err != nil {
		return nil, s.err
	}
	owner = strings.TrimPrefix(owner, "~")
	if info, ok := s.repos[owner+"/"+name]; ok {
		return &info, nil
	}
	return nil, core.ErrNotFound
}

func (s *stubAuthorizer) MyRepos(_ context.Context, _ string) ([]authz.RepoInfo, error) {
	if s.err != nil {
		return nil, s.err
	}
	return s.my, nil
}

// panicAuthorizer fails the way a bug fails: not by returning an error, but by
// panicking inside a handler.
type panicAuthorizer struct{ stubAuthorizer }

func (p *panicAuthorizer) Repo(context.Context, string, string, string) (*authz.RepoInfo, error) {
	panic("the authorizer exploded")
}

// gitFixture drives the git CLI to build a bare repo at <root>/~alice/demo:
//
//	c1 (main): add a.txt
//	c2 (main): add b.txt, edit a.txt          <- main HEAD
//	feature off c1: add feature.txt           <- branch "feature"
//
// It returns the repos root and the full SHA of main's HEAD.
func gitFixture(t *testing.T) (root, mainSHA string) {
	t.Helper()
	if _, err := exec.LookPath("git"); err != nil {
		t.Skipf("git not available: %v", err)
	}
	root = t.TempDir()
	work := t.TempDir()

	git := func(date string, args ...string) string {
		cmd := exec.Command("git", args...)
		cmd.Dir = work
		cmd.Env = append(os.Environ(),
			"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
			"GIT_TERMINAL_PROMPT=0", "LC_ALL=C",
			"GIT_AUTHOR_NAME=Alice", "GIT_AUTHOR_EMAIL=alice@example.com",
			"GIT_COMMITTER_NAME=Alice", "GIT_COMMITTER_EMAIL=alice@example.com",
			"GIT_AUTHOR_DATE="+date, "GIT_COMMITTER_DATE="+date,
		)
		out, err := cmd.CombinedOutput()
		require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out)
		return string(out)
	}
	write := func(name, data string) {
		require.NoError(t, os.WriteFile(filepath.Join(work, name), []byte(data), 0o644))
	}

	d1, d2, d3 := "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-03T00:00:00Z"
	git(d1, "init", "-b", "main")
	write("a.txt", "hello\nworld\n")
	git(d1, "add", "a.txt")
	git(d1, "commit", "-m", "add a.txt")
	git(d2, "branch", "feature")
	write("a.txt", "hello\nworld\nmore\n")
	write("b.txt", "bee\n")
	git(d2, "add", "a.txt", "b.txt")
	git(d2, "commit", "-m", "add b, edit a")
	git(d3, "checkout", "feature")
	write("feature.txt", "feature\n")
	git(d3, "add", "feature.txt")
	git(d3, "commit", "-m", "add feature.txt")
	git(d3, "checkout", "main")

	require.NoError(t, os.MkdirAll(filepath.Join(root, "~alice"), 0o755))
	bare := filepath.Join(root, "~alice", "demo")
	git(d3, "clone", "--bare", work, bare)

	mainSHA = strings.TrimSpace(runGit(t, bare, "rev-parse", "main"))
	return root, mainSHA
}

func runGit(t *testing.T, dir string, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
	out, err := cmd.CombinedOutput()
	require.NoErrorf(t, err, "git %s:\n%s", strings.Join(args, " "), out)
	return string(out)
}

// testServer wires a Server (fixture repo + given authorizer) behind the same
// middleware the cmd layer installs, and returns the handler.
func testServer(t *testing.T, root string, az authz.Authorizer) http.Handler {
	t.Helper()
	// The synthetic instance of ecoretest, with the two keys this service adds
	// to it: the bare-repository root gitx reads, and a non-production
	// environment so the banner is on the page TestChromeIsRendered inspects.
	conf := ecoretest.Config(configSection,
		ecoretest.Set("git.sr.ht", "repos", root),
		ecoretest.Set("sr.ht", "environment", "development"),
	)
	srv, err := New(conf, az)
	require.NoError(t, err, "New")

	r := chi.NewRouter()
	r.Use(config.Middleware(conf, "compare.sr.ht"))
	r.Use(login.Optional())
	srv.Register(r)
	return r
}

// logIn seals a unified-login cookie for the given user onto a request. It is
// not called login because that is the package that opens the cookie again.
func logIn(req *http.Request, user string) {
	payload, _ := json.Marshal(map[string]string{"name": user})
	req.AddCookie(&http.Cookie{Name: login.CookieName, Value: string(crypto.Encrypt(payload))})
}

func demoAuthorizer() *stubAuthorizer {
	return &stubAuthorizer{
		repos: map[string]authz.RepoInfo{
			"alice/demo": {ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
		},
		my: []authz.RepoInfo{
			{ID: 1, Name: "demo", Description: "the demo repo", Visibility: "PUBLIC"},
			{ID: 2, Name: "secret", Description: "", Visibility: "PRIVATE"},
		},
	}
}

func get(t *testing.T, h http.Handler, target string, user string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, target, nil)
	if user != "" {
		logIn(req, user)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

// ---- tests ----------------------------------------------------------------

func TestComparePage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/main...feature", "")
	require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String())

	body := rec.Body.String()
	assert.Contains(t, body, `id="compare-data"`, "missing compare-data script")
	assert.Contains(t, body, `data-diff-wrap`, "missing long-line wrapping control")
	assert.Contains(t, body, `src="/static/`+bundleName(t)+`"`, "missing hashed bundle script tag")

	cd := extractCompareData(t, body)
	assert.Equal(t, "compare", cd.Mode)
	assert.Equal(t, jsonSpec{Base: "main", Head: "feature", ThreeDot: true}, cd.Spec)

	// feature adds feature.txt relative to the merge base (c1), and paths carry
	// no a/ or b/ diff prefix.
	found := false
	for _, f := range cd.Files {
		if f.Path == "feature.txt" {
			found = true
		}
		assert.NotRegexp(t, `^[ab]/`, f.Path, "file path has a diff prefix")
	}
	assert.Truef(t, found, "feature.txt not in files: %+v", cd.Files)
}

func TestTwoDotVsThreeDot(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	two := extractCompareData(t, get(t, h, "/~alice/demo/compare/main..feature", "").Body.String())
	assert.False(t, two.Spec.ThreeDot, "main..feature parsed as three-dot")

	three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String())
	assert.True(t, three.Spec.ThreeDot, "main...feature parsed as two-dot")
}

func TestComparePatchRoute(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/main...feature.patch", "")
	require.Equal(t, http.StatusOK, rec.Code)
	assert.True(t, strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain"),
		"content-type = %q, want text/plain", rec.Header().Get("Content-Type"))
	assert.Contains(t, rec.Body.String(), "diff --git", "patch body missing diff header")
}

func TestCommitPage(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "")
	require.Equalf(t, http.StatusOK, rec.Code, "body:\n%s", rec.Body.String())
	assert.Contains(t, rec.Body.String(), `data-diff-wrap`, "missing long-line wrapping control")

	cd := extractCompareData(t, rec.Body.String())
	assert.Equal(t, "commit", cd.Mode)
	// c2 modifies a.txt and adds b.txt.
	assert.NotEmpty(t, cd.Files, "commit page has no files")
}

func TestCommitPatchRoute(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/commit/"+mainSHA+".patch", "")
	require.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Body.String(), "diff --git", "commit patch missing diff header")
}

func TestUnknownRepoIs404(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/nope/compare/main...feature", "")
	assert.Equal(t, http.StatusNotFound, rec.Code)
}

func TestPrivateRepoInvisibleIs404(t *testing.T) {
	// Authorizer reports the repo as not-found (visibility hidden) even though
	// the bare repo exists on disk.
	root, _ := gitFixture(t)
	h := testServer(t, root, &stubAuthorizer{repos: map[string]authz.RepoInfo{}})

	rec := get(t, h, "/~alice/demo", "")
	assert.Equal(t, http.StatusNotFound, rec.Code)
}

func TestAuthorizerTransportErrorIs500(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, &stubAuthorizer{err: errors.New("graphql unreachable")})

	rec := get(t, h, "/~alice/demo", "")
	assert.Equal(t, http.StatusInternalServerError, rec.Code,
		"a transport error must not be reported as 404")
}

func TestBadRefIs400(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/..bad", "")
	assert.Equal(t, http.StatusBadRequest, rec.Code)
}

func TestIndexAnonymous(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/", "")
	require.Equal(t, http.StatusOK, rec.Code)

	body := rec.Body.String()
	assert.Contains(t, body, `action="/jump"`, "anonymous index missing jump form")
	assert.Contains(t, body, "return_to=", "login URL missing return_to")
}

func TestIndexLoggedIn(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/", "bigbes")
	require.Equal(t, http.StatusOK, rec.Code)

	body := rec.Body.String()
	assert.Contains(t, body, "/~bigbes/demo", "logged-in index missing repo link from MyRepos")
	assert.Contains(t, body, `<small class="pull-right">private</small>`,
		"logged-in index missing the visibility label on the event card")
}

// TestChromeIsRendered checks that the layout really draws the shared partials
// of sr-ht-ecore — the brand's red service label and the login block. What the
// switcher contains and how it is ordered is ecore's business and ecore's test;
// this one only asserts that this service's layout invokes the chrome at all,
// which is the wiring a bad merge here would break.
func TestChromeIsRendered(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	anon := get(t, h, "/", "").Body.String()
	assert.Contains(t, anon, `<span class="text-danger">compare</span>`,
		"the brand's service label is missing")
	assert.Contains(t, anon, "Log in", "an anonymous viewer must be offered the login")
	// The environment is "development" in the test config, so the banner shows.
	assert.Contains(t, anon, "DEVELOPMENT ENVIRONMENT", "missing the non-production banner")

	viewer := get(t, h, "/", "bigbes").Body.String()
	assert.Contains(t, viewer, "Logged in as", "the login block does not name the viewer")
	assert.Contains(t, viewer, "https://hub.example/~bigbes",
		"the profile link should prefer hub's ~username page")
	assert.Contains(t, viewer, "https://todo.example", "the switcher is missing a sibling service")
}

// TestDiffPagesAreFullBleed pins the one chrome decision compare makes for
// itself: the diff views ask for the full window, because a side-by-side diff in
// the centered container is a column of code half the page wide.
func TestDiffPagesAreFullBleed(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	for _, tc := range []struct {
		name, target, want string
	}{
		{"index", "/", `<div class="container">`},
		{"repo", "/~alice/demo", `<div class="container">`},
		{"compare", "/~alice/demo/compare/main...feature", `<div class="container-fluid">`},
		{"commit", "/~alice/demo/commit/" + mainSHA, `<div class="container-fluid">`},
	} {
		t.Run(tc.name, func(t *testing.T) {
			rec := get(t, h, tc.target, "")
			require.Equal(t, http.StatusOK, rec.Code)
			assert.Contains(t, rec.Body.String(), tc.want)
		})
	}
}

func TestHealthz(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/healthz", "")
	require.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Body.String(), "ok")
}

// TestHeadIsServedWhereGetIs pins what chimw.GetHead registers. chi's Get alone
// left every read route answering `curl -I` — and every uptime probe, and every
// cache revalidating what it holds — with a 405 plus a rendered error page from
// a path whose whole job is to be cheap to ask about.
//
// The status has to be the GET's, on every route: a HEAD that answered 200 where
// the GET answers 404 would be the private-repo leak the 404 exists to prevent.
func TestHeadIsServedWhereGetIs(t *testing.T) {
	root, mainSHA := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	for _, target := range []string{
		"/",
		"/healthz",
		"/~alice/demo",
		"/~alice/demo/compare/main...feature",
		"/~alice/demo/commit/" + mainSHA,
	} {
		req := httptest.NewRequest(http.MethodHead, target, nil)
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		assert.Equalf(t, http.StatusOK, rec.Code, "HEAD %s", target)
	}

	// And a hidden repository is a 404 to HEAD exactly as it is to GET.
	req := httptest.NewRequest(http.MethodHead, "/~alice/nope", nil)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	assert.Equal(t, http.StatusNotFound, rec.Code, "HEAD on a hidden repo")
}

// TestMethodNotAllowedIsAPage covers the refusal this service did not render
// before chimw.RenderRefusals: it installed chi's 404 alone, so a method the
// router does not serve produced net/http's plain-text 405 — the one answer on
// the instance that did not look like the service it came from.
//
// The probe is OPTIONS rather than a write method, and the reason is worth
// keeping: the group's middleware does run before chi answers a 405, so an
// unsafe method that carries no Origin is refused 403 by the same-origin guard
// and never reaches this handler at all. OPTIONS is safe, passes the guard, and
// is registered nowhere.
func TestMethodNotAllowedIsAPage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	req := httptest.NewRequest(http.MethodOptions, "/healthz", nil)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)

	require.Equal(t, http.StatusMethodNotAllowed, rec.Code)
	assert.Contains(t, rec.Body.String(), pages.MethodMessage)
	assert.Contains(t, rec.Body.String(), "navbar-brand", "the 405 has no chrome")
}

func TestStaticBundleAndCSS(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	bundle := bundleName(t)
	rec := get(t, h, "/static/"+bundle, "")
	require.Equalf(t, http.StatusOK, rec.Code, "%s", bundle)
	assert.Contains(t, rec.Header().Get("Content-Type"), "javascript")
	assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable",
		"a hashed bundle must be cacheable forever")

	css := cssName(t)
	rec = get(t, h, "/static/"+css, "")
	require.Equalf(t, http.StatusOK, rec.Code, "%s", css)
	assert.Contains(t, rec.Header().Get("Cache-Control"), "immutable",
		"a hashed stylesheet must be cacheable forever")
	assert.Empty(t, rec.Header().Get("Vary"),
		"an asset served identically to everybody must not be declared to vary on Cookie")
}

// TestFaviconIsInline pins that the icon costs no request and no file. It used
// to be /static/logo.svg — this service's copy of the brand circle chrome now
// inlines as a data: URI, which is also the only value html/template will let
// through a href without rewriting it to #ZgotmplZ.
func TestFaviconIsInline(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/", "")
	require.Equal(t, http.StatusOK, rec.Code)
	// html/template writes the '+' of "svg+xml" as &#43;, which is why the
	// prefix this looks for stops short of it.
	assert.Contains(t, rec.Body.String(), `<link rel="icon" href="data:image/svg`)
	assert.NotContains(t, rec.Body.String(), "#ZgotmplZ",
		"a data: URI that arrived as a plain string would have been rewritten")
	assert.NotContains(t, rec.Body.String(), "logo.svg")

	assert.Equal(t, http.StatusNotFound, get(t, h, "/static/logo.svg", "").Code,
		"the shipped copy of the icon is gone")
}

// TestStaticListingRefused pins what the hand-rolled static route published
// before this service adopted assets.Handler: an http.FileServer answers a
// directory with a listing, so /static/ was the whole inventory of the binary —
// every vendored artefact and the hashed names that fingerprint the build — as
// a public, hour-cacheable page.
func TestStaticListingRefused(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	for _, target := range []string{"/static/", "/static/nothing-here.css"} {
		t.Run(target, func(t *testing.T) {
			rec := get(t, h, target, "")
			assert.Equal(t, http.StatusNotFound, rec.Code)
			// A Go file-server listing is a list of <a href="name">name</a>; the
			// stylesheet's own <link> in the chrome is not, which is why the
			// entry and not the name is what this looks for.
			assert.NotContains(t, rec.Body.String(), `<a href="`+bundleName(t)+`">`,
				"the response is a directory listing")
			// And it is a page, not net/http's plain-text dead end.
			assert.Contains(t, rec.Body.String(), "navbar-brand")
		})
	}
}

// TestPanicIsAnErrorPage checks the recovery middleware through the wiring
// Register actually installs, not through a router assembled by the test: a
// panicking handler must produce this service's chrome-wrapped 500 rather than
// the dropped connection net/http answers a panic with.
//
// It also pins where the report goes. ecore's middleware logs the panic through
// slog's *default* logger, so a service that never calls slog.SetDefault sends
// its stack traces to Go's plain stderr handler; the capture below is the same
// seam initLogging writes to in the cmd layer.
func TestPanicIsAnErrorPage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, &panicAuthorizer{})

	var captured bytes.Buffer
	previous := slog.Default()
	slog.SetDefault(slog.New(slog.NewTextHandler(&captured, nil)))
	t.Cleanup(func() { slog.SetDefault(previous) })

	rec := get(t, h, "/~alice/demo", "")
	require.Equal(t, http.StatusInternalServerError, rec.Code)
	assert.Contains(t, rec.Body.String(), pages.InternalMessage)
	assert.Contains(t, rec.Body.String(), "navbar-brand", "the 500 has no chrome")
	assert.NotContains(t, rec.Body.String(), "the authorizer exploded",
		"the panic value must not reach the viewer")

	logged := captured.String()
	assert.Contains(t, logged, "the authorizer exploded", "the panic was not reported")
	assert.Contains(t, logged, "/~alice/demo", "the report does not name the request")
}

// TestPagesAreNotCacheable pins the policy every page behind the login cookie
// carries: these URLs say nothing about the viewer, so a cache with no
// instruction would hand one account's page to the next request.
func TestPagesAreNotCacheable(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/", "bigbes")
	require.Equal(t, http.StatusOK, rec.Code)
	assert.Equal(t, "private, no-store", rec.Header().Get("Cache-Control"))
	assert.Equal(t, "Cookie, Authorization", rec.Header().Get("Vary"))
}

// TestNotFoundIsAPage checks that a URL the router does not have lands on the
// shared error page rather than chi's plain-text 404, and that it says the
// instance's standard sentence — the same one a repository the viewer may not
// see produces, which is what keeps the two indistinguishable.
func TestNotFoundIsAPage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	for name, target := range map[string]string{
		"unrouted":    "/no/such/page",
		"hidden repo": "/~alice/nope",
	} {
		t.Run(name, func(t *testing.T) {
			rec := get(t, h, target, "")
			require.Equal(t, http.StatusNotFound, rec.Code)
			assert.Contains(t, rec.Body.String(), pages.NotFoundMessage)
			assert.Contains(t, rec.Body.String(), "navbar-brand", "the 404 has no chrome")
		})
	}
}

// TestBadSpecKeepsItsOwnMessage is the other half of that rule: a 400 describes
// something the viewer just typed, so it must not be replaced by a house phrase
// that sends them back to the form with nothing to change.
func TestBadSpecKeepsItsOwnMessage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	rec := get(t, h, "/~alice/demo/compare/..bad", "")
	require.Equal(t, http.StatusBadRequest, rec.Code)
	assert.Contains(t, rec.Body.String(), core.ErrBadRef.Error())
}

// TestUnsafeMethodRefused pins the same-origin guard. compare serves no POST
// today, so what this asserts is that the guard is installed at all — the day a
// form arrives it is already covered.
//
// The probe is a path with no route, but any path would do: the group's
// middleware runs before chi answers either of its routing failures, so a POST
// to a GET-only path is refused 403 by the guard exactly as a POST to nothing at
// all is, and neither reaches the 405 or the 404 behind it. The consequence to
// know about is in the first arm — such a request is refused 403 rather than
// answered 404.
func TestUnsafeMethodRefused(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())

	post := func(origin string) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPost, "/no/such/page", nil)
		if origin != "" {
			req.Header.Set("Origin", origin)
		}
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		return rec
	}

	rec := post("")
	assert.Equal(t, http.StatusForbidden, rec.Code,
		"a request carrying neither Origin nor Referer cannot be shown to have come from us")
	assert.Contains(t, rec.Body.String(), csrf.Message)

	rec = post("https://evil.example")
	assert.Equal(t, http.StatusForbidden, rec.Code, "a cross-origin POST must be refused")

	rec = post(ecoretest.Origin(configSection))
	assert.Equal(t, http.StatusNotFound, rec.Code,
		"a same-origin POST passes the guard and meets the router's own answer")
}

// TestCompareJSONNoScriptBreakout verifies a file path containing "</script>"
// cannot break out of the embedded <script> element.
func TestCompareJSONNoScriptBreakout(t *testing.T) {
	patch := &gitx.Patch{Text: "diff --git a/x b/x\n"}
	files := []gitx.FileChange{{Path: "evil</script><script>alert(1)</script>.txt", Status: "A", Additions: 1}}

	html, err := buildCompareJSON("compare", patch, files, jsonSpec{Base: "a", Head: "b"})
	require.NoError(t, err)

	s := string(html)
	// The '<' of "</script>" must be escaped, so no literal "</script" tag can
	// appear to close the embedding element...
	assert.NotContainsf(t, s, "</script", "raw </script in the JSON (breakout possible): %s", s)
	// ...and it must appear in its escaped form instead, proving the marshaler
	// HTML-escaped the '<'.
	assert.Containsf(t, s, "\\u003c/script", "expected an escaped \\u003c/script, got: %s", s)
}

// cssName resolves the content-hashed stylesheet filename (main.min.<hash>.css)
// out of the embedded tree, through the same call the server resolves it with.
func cssName(t *testing.T) string {
	t.Helper()
	return assetName(t, cssGlob)
}

// bundleName resolves the content-hashed frontend bundle filename (bundle.<hash>.js).
func bundleName(t *testing.T) string {
	t.Helper()
	return assetName(t, bundleGlob)
}

func assetName(t *testing.T, glob string) string {
	t.Helper()
	href, err := assets.Resolve(staticFS, glob, assets.DefaultPrefix)
	require.NoError(t, err)
	require.NotEmptyf(t, href, "no artefact matching %s in the embedded tree", glob)
	return strings.TrimPrefix(href, assets.DefaultPrefix)
}

// extractCompareData pulls and decodes the embedded JSON payload from a page.
func extractCompareData(t *testing.T, body string) compareData {
	t.Helper()
	const open = `id="compare-data" type="application/json">`
	i := strings.Index(body, open)
	require.GreaterOrEqualf(t, i, 0, "no compare-data script in body:\n%s", body)

	rest := body[i+len(open):]
	j := strings.Index(rest, "</script>")
	require.GreaterOrEqual(t, j, 0, "compare-data script not closed")

	var cd compareData
	require.NoErrorf(t, json.Unmarshal([]byte(rest[:j]), &cd), "raw: %s", rest[:j])
	return cd
}
