package web

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
	"github.com/fernet/fernet-go"
	"github.com/go-chi/chi/v5"
	"github.com/vaughan0/go-ini"

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

// testConf carries the crypto keys established in TestMain so tests can seal
// unified-login cookies.
var testConf ini.File

func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic("generate fernet key: " + err.Error())
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		panic("generate webhook seed: " + err.Error())
	}
	testConf = ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	}
	crypto.InitCrypto(testConf)
	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
}

// 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()
		if err != nil {
			t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
		}
		return string(out)
	}
	write := func(name, data string) {
		if err := os.WriteFile(filepath.Join(work, name), []byte(data), 0o644); err != nil {
			t.Fatal(err)
		}
	}

	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")

	if err := os.MkdirAll(filepath.Join(root, "~alice"), 0o755); err != nil {
		t.Fatal(err)
	}
	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()
	if err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, 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()
	conf := ini.File{
		"sr.ht": ini.Section{
			"network-key": testConf.Section("sr.ht")["network-key"],
			"site-name":   "sourcehut",
			"environment": "development",
		},
		"webhooks":      ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
		"compare.sr.ht": ini.Section{"origin": "https://compare.example"},
		"meta.sr.ht":    ini.Section{"origin": "https://meta.example"},
		"git.sr.ht":     ini.Section{"origin": "https://git.example", "repos": root},
		// Extra service sections to exercise nav ordering/exclusions.
		"todo.sr.ht":   ini.Section{"origin": "https://todo.example"},
		"builds.sr.ht": ini.Section{"origin": "https://builds.example"},
		"lists.sr.ht":  ini.Section{"origin": "https://lists.example"},
		"paste.sr.ht":  ini.Section{"origin": "https://paste.example"},
		"pages.sr.ht":  ini.Section{"origin": "https://pages.example"},
		"hub.sr.ht":    ini.Section{"origin": "https://hub.example"},
	}
	srv, err := New(conf, az)
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	r := chi.NewRouter()
	r.Use(config.Middleware(conf, "compare.sr.ht"))
	r.Use(authz.Middleware())
	srv.Register(r)
	return r
}

// login seals a unified-login cookie for the given user onto a request.
func login(req *http.Request, user string) {
	payload, _ := json.Marshal(map[string]string{"name": user})
	req.AddCookie(&http.Cookie{Name: authz.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", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	if !strings.Contains(body, `id="compare-data"`) {
		t.Fatal("missing compare-data script")
	}
	if !strings.Contains(body, `src="/static/bundle.js"`) {
		t.Fatal("missing bundle.js script tag")
	}

	cd := extractCompareData(t, body)
	if cd.Mode != "compare" {
		t.Fatalf("mode = %q, want compare", cd.Mode)
	}
	if cd.Spec.Base != "main" || cd.Spec.Head != "feature" || !cd.Spec.ThreeDot {
		t.Fatalf("spec = %+v, want base=main head=feature threeDot=true", cd.Spec)
	}
	// feature adds feature.txt relative to the merge base (c1).
	found := false
	for _, f := range cd.Files {
		if f.Path == "feature.txt" {
			found = true
			if strings.HasPrefix(f.Path, "a/") || strings.HasPrefix(f.Path, "b/") {
				t.Fatalf("file path has diff prefix: %q", f.Path)
			}
		}
	}
	if !found {
		t.Fatalf("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())
	if two.Spec.ThreeDot {
		t.Fatal("main..feature parsed as three-dot")
	}
	three := extractCompareData(t, get(t, h, "/~alice/demo/compare/main...feature", "").Body.String())
	if !three.Spec.ThreeDot {
		t.Fatal("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", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
		t.Fatalf("content-type = %q, want text/plain", ct)
	}
	if !strings.Contains(rec.Body.String(), "diff --git") {
		t.Fatalf("patch body missing diff header:\n%s", rec.Body.String())
	}
}

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

	rec := get(t, h, "/~alice/demo/commit/"+mainSHA, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	cd := extractCompareData(t, rec.Body.String())
	if cd.Mode != "commit" {
		t.Fatalf("mode = %q, want commit", cd.Mode)
	}
	// c2 modifies a.txt and adds b.txt.
	if len(cd.Files) == 0 {
		t.Fatal("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", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "diff --git") {
		t.Fatal("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", "")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", 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)
	az := &stubAuthorizer{repos: map[string]authz.RepoInfo{}}
	h := testServer(t, root, az)
	rec := get(t, h, "/~alice/demo", "")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
}

func TestAuthorizerTransportErrorIs500(t *testing.T) {
	root, _ := gitFixture(t)
	az := &stubAuthorizer{err: errors.New("graphql unreachable")}
	h := testServer(t, root, az)
	rec := get(t, h, "/~alice/demo", "")
	if rec.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500 (transport error must not be 404)", rec.Code)
	}
}

func TestBadRefIs400(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/~alice/demo/compare/..bad", "")
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", rec.Code)
	}
}

func TestIndexAnonymous(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/", "")
	body := rec.Body.String()
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(body, `action="/jump"`) {
		t.Fatal("anonymous index missing jump form")
	}
	if !strings.Contains(body, "return_to=") {
		t.Fatal("login URL missing return_to")
	}
}

func TestIndexLoggedIn(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/", "bigbes")
	body := rec.Body.String()
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(body, "/~bigbes/demo") {
		t.Fatal("logged-in index missing repo link from MyRepos")
	}
	if !strings.Contains(body, "PRIVATE") {
		t.Fatal("logged-in index missing visibility badge")
	}
}

func TestNavExclusionsAndActive(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	// Nav switcher only renders for a logged-in viewer.
	body := get(t, h, "/", "bigbes").Body.String()

	if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
		t.Fatal("nav missing expected services")
	}
	if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
		t.Fatal("nav must exclude paste/pages")
	}
	// hub is the brand, never a switcher item.
	nav := body[strings.Index(body, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
	if strings.Contains(nav, "hub.example") {
		t.Fatal("hub must not appear in the switcher list")
	}
	if !strings.Contains(nav, `nav-item active`) {
		t.Fatal("compare should be the active nav item")
	}
}

func TestHealthz(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, demoAuthorizer())
	rec := get(t, h, "/healthz", "")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
		t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
	}
}

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

	rec := get(t, srvHandler, "/static/bundle.js", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("bundle.js status = %d", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
		t.Fatalf("bundle.js content-type = %q", ct)
	}

	css := cssName(t)
	rec = get(t, srvHandler, "/static/"+css, "")
	if rec.Code != http.StatusOK {
		t.Fatalf("css status = %d", rec.Code)
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
		t.Fatalf("hashed css cache-control = %q, want immutable", cc)
	}
}

// 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"})
	if err != nil {
		t.Fatal(err)
	}
	s := string(html)
	// The '<' of "</script>" must be escaped, so no literal "</script" tag can
	// appear to close the embedding element.
	if strings.Contains(s, "</script") {
		t.Fatalf("raw </script present in JSON (breakout possible): %s", s)
	}
	// ...and it must appear in its escaped </script form instead, proving
	// the marshaler HTML-escaped the '<'.
	if !strings.Contains(s, "\\u003c/script") {
		t.Fatalf("expected escaped \\u003c/script, got: %s", s)
	}
}

func cssName(t *testing.T) string {
	t.Helper()
	href, err := resolveCSSHref()
	if err != nil {
		t.Fatal(err)
	}
	return strings.TrimPrefix(href, "/static/")
}

// 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)
	if i < 0 {
		t.Fatalf("no compare-data script in body:\n%s", body)
	}
	rest := body[i+len(open):]
	j := strings.Index(rest, "</script>")
	if j < 0 {
		t.Fatal("compare-data script not closed")
	}
	var cd compareData
	if err := json.Unmarshal([]byte(rest[:j]), &cd); err != nil {
		t.Fatalf("decode compare-data: %v\nraw: %s", err, rest[:j])
	}
	return cd
}
