package deliberi

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"tangled.org/core/api/org_tangled"
)

const collaboratorInvitesByBody = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.oyster.cafe","repo":"did:plc:scallop","addedBy":"did:plc:boltless","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}`

func serving(t *testing.T, path, body string) *bobbinClient {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path != path {
			t.Errorf("path = %q, want %q", r.URL.Path, path)
			http.NotFound(w, r)
			return
		}
		if r.URL.Query().Get("subject") == "" {
			t.Error("subject param missing; bobbin requires it")
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, body)
	}))
	t.Cleanup(srv.Close)
	return newBobbinClient(srv.URL)
}

func TestListCollaboratorInvitesByDecodesBobbinsBody(t *testing.T) {
	c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, collaboratorInvitesByBody)

	answer, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet")
	if err != nil {
		t.Fatalf("ListCollaboratorInvitesBy: %v", err)
	}
	want := InviteOffer{
		Uri:       "at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet",
		KnotDid:   "did:web:knot.oyster.cafe",
		RepoDid:   "did:plc:scallop",
		AddedBy:   "did:plc:boltless",
		CreatedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
	}
	if len(answer.Offers) != 1 || answer.Offers[0] != want {
		t.Fatalf("offers = %+v, want one %+v", answer.Offers, want)
	}
}

func TestPartialAnswerListsWhatBobbinCouldntRead(t *testing.T) {
	const body = `{"items":[],"pending":["did:web:cold.knot","did:web:other.knot"],"truncated":true}`
	c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)

	answer, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet")
	if err != nil {
		t.Fatalf("ListCollaboratorInvitesBy: %v", err)
	}
	if len(answer.Pending) != 2 {
		t.Fatalf("pending = %v, want both knots", answer.Pending)
	}
	if !answer.Truncated {
		t.Fatal("truncated dropped in decode, so we'd read cut list as whole offer set")
	}
}

func TestUnreadableCreatedAtFailsListing(t *testing.T) {
	const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.example","repo":"did:plc:scallop","addedBy":"did:plc:akshay","createdAt":"whenever"}]}`
	c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)

	if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
		t.Fatal("we can't stamp rows from timestamps that won't read")
	}
}

func TestOfferMissingItsKnotFailsListing(t *testing.T) {
	const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","repo":"did:plc:scallop","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}`
	c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)

	if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
		t.Fatal("offers missing their knot can't be checked against pending, so accepting one would make its absence look like withdrawal")
	}
}

func TestOfferMissingItsRepoFailsListing(t *testing.T) {
	const body = `{"items":[{"uri":"at://did:plc:scallop/sh.tangled.repo.collaboratorInvite/did:plc:limpet","knot":"did:web:knot.example","addedBy":"did:plc:akshay","createdAt":"2026-06-01T00:00:00.000000Z"}],"pending":[],"truncated":false}`
	c := serving(t, "/xrpc/"+org_tangled.RepoListCollaboratorInvitesByNSID, body)

	if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
		t.Fatal("a repo-less offer has nothing to link or accept against, so the row would be unusable")
	}
}

func TestWarming503FailsListing(t *testing.T) {
	const warming = `{"error":"Warming","message":"invite index is still warming, so it can't list offers yet"}`
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusServiceUnavailable)
		fmt.Fprint(w, warming)
	}))
	t.Cleanup(srv.Close)

	c := newBobbinClient(srv.URL)
	if _, err := c.ListCollaboratorInvitesBy(context.Background(), "did:plc:limpet"); err == nil {
		t.Fatal("503 decoded as empty listing, so reconcile would read every offer as withdrawn")
	}
}

func TestRepoOwnerReadsManifestDeclaration(t *testing.T) {
	// the manifest lives in the repo's own account, so the uri authority is the
	// repo did and must not be mistaken for the owner
	const body = `{"uri":"at://did:plc:therepo/org.tangled.repo.manifest/self","cid":"bafyfake","knot":"did:web:knot.example",` +
		`"value":{"$type":"org.tangled.repo.manifest","createdAt":"2026-01-01T00:00:00Z","declaration":{"owner":"did:plc:bob","name":"my-repo"}}}`
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got := r.URL.Query().Get("repoDid"); got != "did:plc:therepo" {
			t.Errorf("repoDid param = %q", got)
		}
		w.Header().Set("Content-Type", "application/json")
		fmt.Fprint(w, body)
	}))
	t.Cleanup(srv.Close)

	owner, name, err := newBobbinClient(srv.URL).RepoOwner(context.Background(), "did:plc:therepo")
	if err != nil {
		t.Fatalf("RepoOwner: %v", err)
	}
	if owner != "did:plc:bob" || name != "my-repo" {
		t.Fatalf("owner/name = %q/%q, want did:plc:bob/my-repo", owner, name)
	}
}
