package authz

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync/atomic"
	"testing"
	"time"

	"git.sr.ht/~sircmpwn/core-go/config"
	"git.sr.ht/~sircmpwn/core-go/crypto"
	"github.com/vaughan0/go-ini"

	"go.bigb.es/sourcehut-compare/core"
)

// authNameFromRequest asserts the incoming request bears an "Internal <token>"
// Authorization header that decrypts to InternalAuth JSON, and returns the Name
// field (empty for anonymous). This mirrors what git.sr.ht's receiver does.
func authNameFromRequest(t *testing.T, r *http.Request) string {
	t.Helper()
	h := r.Header.Get("Authorization")
	token, ok := strings.CutPrefix(h, "Internal ")
	if !ok {
		t.Fatalf("Authorization header = %q, want Internal prefix", h)
	}
	payload := crypto.DecryptWithExpiration([]byte(token), time.Hour)
	if payload == nil {
		t.Fatalf("internal auth token did not decrypt")
	}
	var auth struct {
		Name     string `json:"name"`
		ClientID string `json:"client_id"`
	}
	if err := json.Unmarshal(payload, &auth); err != nil {
		t.Fatalf("unmarshal internal auth: %v", err)
	}
	return auth.Name
}

// ctxFor returns a context carrying config that points git.sr.ht's API origin at
// url, reusing the crypto keys established in TestMain.
func ctxFor(url string) context.Context {
	conf := ini.File{
		"sr.ht":         testConf.Section("sr.ht"),
		"webhooks":      testConf.Section("webhooks"),
		"compare.sr.ht": ini.Section{"origin": "http://localhost"},
		"git.sr.ht":     ini.Section{"api-origin": url},
	}
	return config.Context(context.Background(), conf, "compare.sr.ht")
}

func TestRepo_Found(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&count, 1)
		if name := authNameFromRequest(t, r); name != "bigbes" {
			t.Errorf("viewer name = %q, want bigbes", name)
		}
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"data":{"user":{"repository":{"id":42,"name":"dotfiles","description":"my configs","visibility":"PUBLIC"}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	info, err := a.Repo(ctxFor(srv.URL), "bigbes", "~bigbes", "dotfiles")
	if err != nil {
		t.Fatalf("Repo error: %v", err)
	}
	want := &RepoInfo{ID: 42, Name: "dotfiles", Description: "my configs", Visibility: "PUBLIC"}
	if *info != *want {
		t.Fatalf("info = %+v, want %+v", *info, *want)
	}
	if got := atomic.LoadInt32(&count); got != 1 {
		t.Fatalf("request count = %d, want 1", got)
	}
}

func TestRepo_AnonymousViewer(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if name := authNameFromRequest(t, r); name != "" {
			t.Errorf("viewer name = %q, want empty for anonymous", name)
		}
		w.Write([]byte(`{"data":{"user":{"repository":{"id":7,"name":"pub","description":"","visibility":"PUBLIC"}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	info, err := a.Repo(ctxFor(srv.URL), "", "bigbes", "pub")
	if err != nil {
		t.Fatalf("Repo error: %v", err)
	}
	if info.ID != 7 || info.Visibility != "PUBLIC" {
		t.Fatalf("info = %+v", *info)
	}
}

func TestRepo_NullRepositoryIsNotFound(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"data":{"user":{"repository":null}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	_, err := a.Repo(ctxFor(srv.URL), "bigbes", "bigbes", "secret")
	if !errors.Is(err, core.ErrNotFound) {
		t.Fatalf("err = %v, want core.ErrNotFound", err)
	}
}

func TestRepo_NullUserIsNotFound(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte(`{"data":{"user":null}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	_, err := a.Repo(ctxFor(srv.URL), "bigbes", "ghost", "repo")
	if !errors.Is(err, core.ErrNotFound) {
		t.Fatalf("err = %v, want core.ErrNotFound", err)
	}
}

func TestRepo_ServerErrorIsNotNotFound(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "boom", http.StatusInternalServerError)
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	_, err := a.Repo(ctxFor(srv.URL), "bigbes", "bigbes", "dotfiles")
	if err == nil {
		t.Fatal("expected error on 500")
	}
	if errors.Is(err, core.ErrNotFound) {
		t.Fatalf("500 mapped to ErrNotFound (should be a transport error): %v", err)
	}
}

func TestRepo_CachesPositive(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&count, 1)
		w.Write([]byte(`{"data":{"user":{"repository":{"id":1,"name":"r","description":"","visibility":"PRIVATE"}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	ctx := ctxFor(srv.URL)
	for i := 0; i < 3; i++ {
		if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err != nil {
			t.Fatalf("call %d: %v", i, err)
		}
	}
	if got := atomic.LoadInt32(&count); got != 1 {
		t.Fatalf("request count = %d, want 1 (cache miss on repeat)", got)
	}
}

func TestRepo_CachesNotFound(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&count, 1)
		w.Write([]byte(`{"data":{"user":{"repository":null}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	ctx := ctxFor(srv.URL)
	for i := 0; i < 3; i++ {
		if _, err := a.Repo(ctx, "bigbes", "bigbes", "gone"); !errors.Is(err, core.ErrNotFound) {
			t.Fatalf("call %d: err = %v, want ErrNotFound", i, err)
		}
	}
	if got := atomic.LoadInt32(&count); got != 1 {
		t.Fatalf("request count = %d, want 1 (not-found not cached)", got)
	}
}

func TestRepo_DoesNotCacheTransportError(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Fail the first request, succeed after: a cached error would keep
		// failing.
		if atomic.AddInt32(&count, 1) == 1 {
			http.Error(w, "boom", http.StatusInternalServerError)
			return
		}
		w.Write([]byte(`{"data":{"user":{"repository":{"id":9,"name":"r","description":"","visibility":"PUBLIC"}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	ctx := ctxFor(srv.URL)
	if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err == nil {
		t.Fatal("expected first call to error")
	}
	info, err := a.Repo(ctx, "bigbes", "bigbes", "r")
	if err != nil {
		t.Fatalf("second call should retry and succeed: %v", err)
	}
	if info.ID != 9 {
		t.Fatalf("info = %+v", *info)
	}
}

func TestRepo_CacheExpires(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&count, 1)
		w.Write([]byte(`{"data":{"user":{"repository":{"id":1,"name":"r","description":"","visibility":"PUBLIC"}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(20 * time.Millisecond)
	ctx := ctxFor(srv.URL)
	if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err != nil {
		t.Fatal(err)
	}
	time.Sleep(40 * time.Millisecond)
	if _, err := a.Repo(ctx, "bigbes", "bigbes", "r"); err != nil {
		t.Fatal(err)
	}
	if got := atomic.LoadInt32(&count); got != 2 {
		t.Fatalf("request count = %d, want 2 after TTL expiry", got)
	}
}

func TestRepo_CacheKeyedByViewer(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&count, 1)
		w.Write([]byte(`{"data":{"user":{"repository":{"id":1,"name":"r","description":"","visibility":"PUBLIC"}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	ctx := ctxFor(srv.URL)
	// Same repo, different viewers must not share a cache entry: git.sr.ht may
	// answer differently per viewer.
	_, _ = a.Repo(ctx, "alice", "bigbes", "r")
	_, _ = a.Repo(ctx, "bob", "bigbes", "r")
	if got := atomic.LoadInt32(&count); got != 2 {
		t.Fatalf("request count = %d, want 2 (per-viewer cache keys)", got)
	}
}

func TestMyRepos_RequiresViewer(t *testing.T) {
	a := NewAuthorizer(time.Minute)
	if _, err := a.MyRepos(context.Background(), ""); err == nil {
		t.Fatal("MyRepos with empty viewer should error")
	}
}

func TestMyRepos_Paginates(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := atomic.AddInt32(&count, 1)
		if n == 1 {
			// First page carries a cursor for the next.
			w.Write([]byte(`{"data":{"me":{"repositories":{"results":[{"id":1,"name":"a","description":"","visibility":"PUBLIC"},{"id":2,"name":"b","description":"","visibility":"PRIVATE"}],"cursor":"next"}}}}`))
			return
		}
		// Second (final) page: null cursor ends pagination.
		w.Write([]byte(`{"data":{"me":{"repositories":{"results":[{"id":3,"name":"c","description":"third","visibility":"UNLISTED"}],"cursor":null}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	repos, err := a.MyRepos(ctxFor(srv.URL), "bigbes")
	if err != nil {
		t.Fatalf("MyRepos error: %v", err)
	}
	if len(repos) != 3 {
		t.Fatalf("got %d repos, want 3", len(repos))
	}
	if repos[2].Name != "c" || repos[2].Visibility != "UNLISTED" {
		t.Fatalf("last repo = %+v", repos[2])
	}
	if got := atomic.LoadInt32(&count); got != 2 {
		t.Fatalf("request count = %d, want 2 pages", got)
	}
}

func TestMyRepos_Uncached(t *testing.T) {
	var count int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		atomic.AddInt32(&count, 1)
		w.Write([]byte(`{"data":{"me":{"repositories":{"results":[{"id":1,"name":"a","description":"","visibility":"PUBLIC"}],"cursor":null}}}}`))
	}))
	defer srv.Close()

	a := NewAuthorizer(time.Minute)
	ctx := ctxFor(srv.URL)
	_, _ = a.MyRepos(ctx, "bigbes")
	_, _ = a.MyRepos(ctx, "bigbes")
	if got := atomic.LoadInt32(&count); got != 2 {
		t.Fatalf("request count = %d, want 2 (MyRepos must not be cached)", got)
	}
}
