package hackernews

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"github.com/stretchr/testify/require"
	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"strings"
	"sync"
	"testing"
)

// stubOrigin is one TLS test double for a public origin; it records every
// request as a canonical "METHOD path?sorted-query" line for wire-contract
// assertions. Handlers must not assert themselves: testify's FailNow may not
// run on the serving goroutine.
type stubOrigin struct {
	server   *httptest.Server
	mutex    sync.Mutex
	requests []string
}

func newStubOrigin(t *testing.T, handler http.HandlerFunc) *stubOrigin {
	t.Helper()
	origin := &stubOrigin{}
	origin.server = httptest.NewTLSServer(http.HandlerFunc(
		func(writer http.ResponseWriter, request *http.Request) {
			line := request.Method + " " + request.URL.Path
			if request.URL.RawQuery != "" {
				line += "?" + request.URL.Query().Encode()
			}
			origin.mutex.Lock()
			origin.requests = append(origin.requests, line)
			origin.mutex.Unlock()
			handler(writer, request)
		}))
	t.Cleanup(origin.server.Close)
	return origin
}

func (origin *stubOrigin) recorded() []string {
	origin.mutex.Lock()
	defer origin.mutex.Unlock()
	return append([]string(nil), origin.requests...)
}

// jsonResponder serves fixed JSON bodies by path; unknown paths 404.
func jsonResponder(bodies map[string]string) http.HandlerFunc {
	return func(writer http.ResponseWriter, request *http.Request) {
		if body, ok := bodies[request.URL.Path]; ok {
			writer.Header().Set("Content-Type", "application/json")
			_, _ = io.WriteString(writer, body)
			return
		}
		http.NotFound(writer, request)
	}
}

// testClient builds the loopback client for one origin. The transport trusts
// the throwaway test certificate; NewRESTClient only binds https origins.
func testClient(t *testing.T, server *httptest.Server, basePath string) *runner.RESTClient {
	t.Helper()
	client, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   server.URL + basePath,
		Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
	})
	require.NoError(t, err)
	return client
}

// installClients swaps the package clients for loopback doubles and restores
// the real ones afterwards. The Firebase client is returned so tests can pass
// it as the executor's dispatched client.
func installClients(
	t *testing.T,
	firebase, algolia *runner.RESTClient,
) *runner.RESTClient {
	t.Helper()
	originalFirebase, originalAlgolia := firebaseClient, algoliaClient
	firebaseClient, algoliaClient = firebase, algolia
	t.Cleanup(func() {
		firebaseClient, algoliaClient = originalFirebase, originalAlgolia
	})
	return firebase
}

// invoke dispatches one Action through the handler table with empty
// credentials, the way the connectionless executor does.
func invoke(
	t *testing.T,
	action string,
	firebase *runner.RESTClient,
	input string,
) (json.RawMessage, error) {
	return invokeContext(t, context.Background(), action, firebase, input)
}

// invokeContext dispatches one Action with an explicit caller context, so
// tests can drive caller-side cancellation and deadlines.
func invokeContext(
	t *testing.T,
	ctx context.Context,
	action string,
	firebase *runner.RESTClient,
	input string,
) (json.RawMessage, error) {
	t.Helper()
	handler, ok := Handlers()[action]
	require.True(t, ok, "action %q must be served", action)
	return handler(ctx, firebase, nil, json.RawMessage(input))
}

func TestDefinitionMatchesPortedCatalog(t *testing.T) {
	definition := Definition()

	require.Equal(t, Domain, definition.Domain)
	require.Equal(t, 1, definition.Version)
	require.Equal(t, "Hacker News", definition.Name)

	names := make([]string, 0, len(definition.Actions))
	for _, action := range definition.Actions {
		names = append(names, action.Name)
		require.Equal(t, "read", action.Safety.Class)
		require.True(t, action.Safety.Idempotent)
		require.False(t, action.Safety.Destructive)
		require.NotNil(t, action.InputSchema)
		require.NotNil(t, action.OutputSchema)
		require.NotNil(t, action.Example)
	}
	require.Equal(t, []string{
		ActionGetAskStories, ActionGetBestStories, ActionGetItem,
		ActionGetItemWithID, ActionGetJobStories, ActionGetLatestPosts,
		ActionGetMaxItemID, ActionGetNewStories, ActionGetShowStories,
		ActionGetTopStories, ActionGetUpdates, ActionGetUser,
		ActionGetUserByUsername, ActionSearchPosts,
	}, names)
}

func TestHandlersServeExactlyTheDefinitionActions(t *testing.T) {
	handlers := Handlers()
	definition := Definition()
	require.Equal(t, len(definition.Actions), len(handlers))
	for _, action := range definition.Actions {
		_, served := handlers[action.Name]
		require.True(t, served, "action %q must have a handler", action.Name)
	}
}

// TestItemIDAcceptanceSetIsOneAcrossSchemaAndHandler pins the unified item-ID
// acceptance set: the schema's string variant and the handler guard accept
// exactly the canonical spelling — one to sixteen digits with no leading zero
// and no bare "0" — bounded by the handler to [1, Number.MAX_SAFE_INTEGER].
// The only divergence left is the safe-integer ceiling, which a length-bounded
// pattern cannot express and the handler guard enforces as the authority.
func TestItemIDAcceptanceSetIsOneAcrossSchemaAndHandler(t *testing.T) {
	require.Equal(t, "^[1-9][0-9]{0,15}$", itemIDStringPattern)
	properties := getItemWithIDInputSchema()["properties"].(map[string]any)
	variants := properties["item_id"].(map[string]any)["anyOf"].([]any)
	pattern, ok := variants[1].(map[string]any)["pattern"].(string)
	require.True(t, ok, "the item_id union must carry the string variant")
	require.Equal(t, itemIDStringPattern, pattern)

	firebase := newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		writer.Header().Set("Content-Type", "application/json")
		fmt.Fprintf(writer, `{"id":42,"type":"comment","text":"c","time":1175714200}`)
	})
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	cases := []struct {
		value     string
		patternOK bool
		handlerOK bool
	}{
		{"1", true, true},
		{"8863", true, true},
		{"0", false, false},
		{"01", false, false},
		{"9007199254740991", true, true},
		{"1234567890123456", true, true},
		// 16 digits past MAX_SAFE_INTEGER: pattern-admitted only because the
		// length bound cannot express the ceiling; the handler rejects them.
		{"9007199254740992", true, false},
		{"9999999999999999", true, false},
		{"12345678901234567", false, false},
		{"", false, false},
		{"12x3", false, false},
	}
	for _, testCase := range cases {
		if testCase.patternOK {
			require.Regexp(t, pattern, testCase.value)
		} else {
			require.NotRegexp(t, pattern, testCase.value)
		}
		_, err := invoke(t, ActionGetItemWithID, client, fmt.Sprintf(
			`{"item_id":%q,"max_depth":0,"max_children":0,"truncate_text":true}`, testCase.value))
		if testCase.handlerOK {
			require.NoError(t, err, "item_id %q must be accepted", testCase.value)
			continue
		}
		require.Error(t, err, "item_id %q must be rejected", testCase.value)
		require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
		require.Equal(t, "item_id must be a positive integer", err.Error())
	}

	// The shared guard tightens get-item's id identically: "0" and "01" —
	// already schema-rejected there since only the integer variant is
	// declared — no longer pass the handler's string branch either.
	_, err := invoke(t, ActionGetItem, client, `{"id":"01"}`)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
	_, err = invoke(t, ActionGetItem, client, `{"id":"0"}`)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func TestExtensionRegistersConnectionlessBuiltin(t *testing.T) {
	ext, err := Extension()
	require.NoError(t, err)

	require.Equal(t, Revision, ext.Implementation.Revision)
	require.Equal(t, registry.ConnectionNone, ext.Implementation.ConnectionPolicy)
	require.Equal(t, runner.AuthNone, ext.Implementation.Auth.Kind)
	require.Empty(t, ext.Implementation.Auth.Fields)
	require.Nil(t, ext.Implementation.ValidateCredentials)
	require.Equal(t, "http", ext.Implementation.Executor.Strategy())
	lister, ok := ext.Implementation.Executor.(runner.ActionLister)
	require.True(t, ok, "the REST executor must enumerate its actions")
	require.ElementsMatch(t, lister.ActionNames(),
		definitionActionNames(Definition()))

	require.Equal(t, "app_hackernews", ext.Distribution.AppID)
	require.Equal(t, "hackernews", ext.Distribution.AppSlug)
	require.Equal(t, Domain, ext.Distribution.DefaultName)
	require.True(t, ext.Distribution.DefaultEnabled)
	require.Equal(t, "/app-icons/hackernews.svg", ext.Distribution.IconURL)

	// The full registration path proves the catalog contract: kebab-case
	// names, compilable Draft 2020-12 schemas, and examples valid against
	// their input schemas.
	registered, err := registry.New().RegisterBuiltinWithOptions(
		ext.Definition,
		ext.Implementation.Executor,
		Revision,
		registry.BuiltinOptions{ConnectionPolicy: registry.ConnectionNone},
	)
	require.NoError(t, err)
	require.Equal(t, Domain, registered.Definition.Domain)
	require.Equal(t, registry.ConnectionNone, registered.ConnectionPolicy)
}

func definitionActionNames(definition registry.Definition) []string {
	names := make([]string, 0, len(definition.Actions))
	for _, action := range definition.Actions {
		names = append(names, action.Name)
	}
	return names
}

func TestGetItemPassesFirebaseItemThrough(t *testing.T) {
	item := `{"id":8863,"type":"story","by":"pg","score":111,` +
		`"title":"Y Combinator","time":1175714200,"kids":[8952,9224]}`
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/8863.json": item,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItem, client, `{"id":8863}`)
	require.NoError(t, err)
	require.JSONEq(t, item, string(output))
	require.Equal(t, []string{"GET /v0/item/8863.json"}, firebase.recorded())

	// The numeric-string ID cast and the pretty print switch both apply.
	output, err = invoke(t, ActionGetItem, client, `{"id":"8863","print":"pretty"}`)
	require.NoError(t, err)
	require.JSONEq(t, item, string(output))
	require.Equal(t,
		[]string{"GET /v0/item/8863.json", "GET /v0/item/8863.json?print=pretty"},
		firebase.recorded())

	_, err = invoke(t, ActionGetItem, client, `{"print":"pretty"}`)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

// story_ids/count output contract where best/new/top also report the count.
func TestStoryFeedActionsListStoryIDs(t *testing.T) {
	feeds := []struct {
		action   string
		path     string
		body     string
		expected string
	}{
		{ActionGetAskStories, "/v0/askstories.json",
			`[121003,121009]`, `{"story_ids":[121003,121009]}`},
		{ActionGetBestStories, "/v0/beststories.json",
			`[8863,8887,9007199254740991]`,
			`{"story_ids":[8863,8887,9007199254740991],"count":3}`},
		{ActionGetJobStories, "/v0/jobstories.json",
			`[41219872]`, `{"story_ids":[41219872]}`},
		{ActionGetNewStories, "/v0/newstories.json",
			`[1,2,3,4]`, `{"story_ids":[1,2,3,4],"count":4}`},
		{ActionGetShowStories, "/v0/showstories.json",
			`[5771713]`, `{"story_ids":[5771713]}`},
		{ActionGetTopStories, "/v0/topstories.json",
			`[1,2,3,4,5]`, `{"story_ids":[1,2,3,4,5],"count":5}`},
	}

	for _, feed := range feeds {
		t.Run(feed.action, func(t *testing.T) {
			firebase := newStubOrigin(t, jsonResponder(map[string]string{
				feed.path: feed.body,
			}))
			client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

			output, err := invoke(t, feed.action, client, `{}`)
			require.NoError(t, err)

			// The ID list round-trips verbatim, safe-integer edge included, and
			// only the counted feeds add the count.
			require.JSONEq(t, feed.expected, string(output))

			_, err = invoke(t, feed.action, client, `{"print":"pretty"}`)
			require.NoError(t, err)
			require.Equal(t, []string{
				"GET " + feed.path,
				"GET " + feed.path + "?print=pretty",
			}, firebase.recorded())
		})
	}
}

// TestStoryFeedsRejectMalformedFeedBodies covers the reproduced null feed and
// its siblings: the synthesized story_ids envelope must always be an array of
// safe integers, so null, non-array, and non-safe-integer bodies fail closed
// instead of yielding {"story_ids":null,"count":0}.
func TestStoryFeedsRejectMalformedFeedBodies(t *testing.T) {
	for _, body := range []string{`null`, `{"ids":[1]}`, `[1,"x"]`, `[1.5]`, `[9007199254740993]`} {
		firebase := newStubOrigin(t, jsonResponder(map[string]string{
			"/v0/topstories.json": body,
		}))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

		_, err := invoke(t, ActionGetTopStories, client, `{}`)
		require.Error(t, err, "feed body %s must be rejected", body)
		require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		require.Equal(t, "story feed response was malformed", err.Error())
	}

	// An empty array is a valid empty feed, not a malformed one.
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/topstories.json": `[]`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)
	output, err := invoke(t, ActionGetTopStories, client, `{}`)
	require.NoError(t, err)
	require.JSONEq(t, `{"story_ids":[],"count":0}`, string(output))
}

// treeOutputNode is the subset of one assembled node the budget assertions
// need.
type treeOutputNode struct {
	ID                int64            `json:"id"`
	ChildrenShown     int              `json:"children_shown"`
	ChildrenTruncated bool             `json:"children_truncated"`
	MaxDepthReached   bool             `json:"max_depth_reached"`
	Children          []treeOutputNode `json:"children"`
}

// countTreeNodes returns the assembled node count including node itself.
func countTreeNodes(node treeOutputNode) int {
	total := 1
	for _, child := range node.Children {
		total += countTreeNodes(child)
	}
	return total
}

// anyNode reports whether any node in the subtree satisfies predicate.
func anyNode(node treeOutputNode, predicate func(treeOutputNode) bool) bool {
	if predicate(node) {
		return true
	}
	for _, child := range node.Children {
		if anyNode(child, predicate) {
			return true
		}
	}
	return false
}

// itemIDOfPath extracts the numeric item ID from a recorded /v0/item/N.json
// request line.
func itemIDOfPath(t *testing.T, line string) int64 {
	t.Helper()
	path := strings.TrimPrefix(line, "GET /v0/item/")
	id, err := strconv.ParseInt(strings.TrimSuffix(path, ".json"), 10, 64)
	require.NoError(t, err)
	return id
}

// servingItemTree doubles the Firebase origin with a synthetic tree: every
// requested item exists and carries the requested fanout of kids, with the
// root id rendered as the story.
func servingItemTree(t *testing.T, rootID, fanout int64) *stubOrigin {
	t.Helper()
	return newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		raw := strings.TrimSuffix(strings.TrimPrefix(request.URL.Path, "/v0/item/"), ".json")
		id, err := strconv.ParseInt(raw, 10, 64)
		if err != nil {
			http.NotFound(writer, request)
			return
		}
		itemType := "comment"
		if id == rootID {
			itemType = "story"
		}
		kids := make([]string, 0, fanout)
		for index := int64(1); index <= fanout; index++ {
			kids = append(kids, strconv.FormatInt(id*1000+index, 10))
		}
		body := fmt.Sprintf(
			`{"id":%d,"type":%q,"by":"u","text":"c","time":1175714200,"parent":%d,"kids":[%s]}`,
			id, itemType, rootID, strings.Join(kids, ","))
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer, body)
	})
}

// TestGetItemWithIDStopsWideTreeAtAssemblyBudget drives the parameter maxima
// (max_depth 10 x max_children 100) through a tree where every node has 100
// kids, a shape that would otherwise fan out to 100^10 recursive fetches. The
// invocation budget must bound the requests the test origin actually serves.

// TestGetItemWithIDReportsIncompleteRootItemAsMissing covers the reproduced
// `{}` root: an object that cannot satisfy the node contract is reported
// missing rather than emitted as an item without id and type.
func TestGetItemWithIDReportsIncompleteRootItemAsMissing(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/1.json": `{}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":1,"max_depth":2,"max_children":10,"truncate_text":true}`)
	require.NoError(t, err)
	require.JSONEq(t,
		`{"found":false,"error_message":"item not found: 1","budget_exhausted":false}`,
		string(output))
}

func TestGetLatestPostsQueriesAlgoliaByDate(t *testing.T) {
	envelope := `{"hits":[{"objectID":"1","title":"Show HN: AgentTeam","author":"pg",` +
		`"points":42,"created_at":"2026-08-16T00:00:00Z"}],` +
		`"nbHits":1,"page":0,"nbPages":1,"hitsPerPage":5}`
	algolia := newStubOrigin(t, jsonResponder(map[string]string{
		"/api/v1/search_by_date": envelope,
	}))
	client := installClients(t, nil, testClient(t, algolia.server, "/api/v1"))

	output, err := invoke(t, ActionGetLatestPosts, client, `{}`)
	require.NoError(t, err)
	require.JSONEq(t, envelope, string(output))

	output, err = invoke(t, ActionGetLatestPosts, client,
		`{"page":2,"size":7,"tags":["story","comment"]}`)
	require.NoError(t, err)
	require.JSONEq(t, envelope, string(output))

	// Defaults send page 0 and the size-5 fallback with no tags; the explicit
	// call forwards page, size, and the comma-joined tag filter.
	require.Equal(t, []string{
		"GET /api/v1/search_by_date?hitsPerPage=5&page=0",
		"GET /api/v1/search_by_date?hitsPerPage=7&page=2&tags=story%2Ccomment",
	}, algolia.recorded())
}

func TestSearchPostsQueriesAlgoliaSearch(t *testing.T) {
	envelope := `{"hits":[],"nbHits":0,"page":0,"nbPages":0,"hitsPerPage":20}`
	algolia := newStubOrigin(t, jsonResponder(map[string]string{
		"/api/v1/search": envelope,
	}))
	client := installClients(t, nil, testClient(t, algolia.server, "/api/v1"))

	output, err := invoke(t, ActionSearchPosts, client,
		`{"query":"show hn","page":0,"size":0}`)
	require.NoError(t, err)
	require.JSONEq(t, envelope, string(output))

	// A non-positive size selects Algolia's 20-result page.
	require.Equal(t, []string{
		"GET /api/v1/search?hitsPerPage=20&page=0&query=show+hn",
	}, algolia.recorded())

	_, err = invoke(t, ActionSearchPosts, client, `{"page":0,"size":5}`)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

// TestSearchActionsRejectNonObjectEnvelopes covers the reproduced Algolia
// array: both search Actions declare an object output, so a 2xx body that is
// not a JSON object fails closed instead of passing the violation through.
func TestSearchActionsRejectNonObjectEnvelopes(t *testing.T) {
	cases := []struct {
		action string
		path   string
		body   string
		input  string
	}{
		{ActionGetLatestPosts, "/api/v1/search_by_date", `[]`, `{}`},
		{ActionGetLatestPosts, "/api/v1/search_by_date", `null`, `{}`},
		{ActionSearchPosts, "/api/v1/search", `[]`, `{"query":"agentteam","page":0,"size":5}`},
	}
	for _, testCase := range cases {
		algolia := newStubOrigin(t, jsonResponder(map[string]string{
			testCase.path: testCase.body,
		}))
		client := installClients(t, nil, testClient(t, algolia.server, "/api/v1"))

		_, err := invoke(t, testCase.action, client, testCase.input)
		require.Error(t, err, "body %s must be rejected", testCase.body)
		require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		require.Equal(t, "search response was malformed", err.Error())
	}
}

func TestGetMaxItemIDReadsFirebaseCounter(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/maxitem.json": `424242`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetMaxItemID, client, `{}`)
	require.NoError(t, err)
	require.JSONEq(t, `{"max_item_id":424242}`, string(output))

	_, err = invoke(t, ActionGetMaxItemID, client, `{"print":"pretty"}`)
	require.NoError(t, err)
	require.Equal(t,
		[]string{"GET /v0/maxitem.json", "GET /v0/maxitem.json?print=pretty"},
		firebase.recorded())
}

// TestGetMaxItemIDRejectsMalformedCounter covers the same synthesized-envelope
// rule for the counter: a null or non-integer body fails closed instead of
// emitting {"max_item_id":null} against the declared integer output.
func TestGetMaxItemIDRejectsMalformedCounter(t *testing.T) {
	for _, body := range []string{`null`, `1.5`} {
		firebase := newStubOrigin(t, jsonResponder(map[string]string{
			"/v0/maxitem.json": body,
		}))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

		_, err := invoke(t, ActionGetMaxItemID, client, `{}`)
		require.Error(t, err, "counter body %s must be rejected", body)
		require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		require.Equal(t, "max item response was malformed", err.Error())
	}
}

func TestGetUpdatesPassesFirebaseChangesThrough(t *testing.T) {
	changes := `{"items":[8863,8952],"profiles":["pg","tptacek"]}`
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/updates.json": changes,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetUpdates, client, `{}`)
	require.NoError(t, err)
	require.JSONEq(t, changes, string(output))
	require.Equal(t, []string{"GET /v0/updates.json"}, firebase.recorded())
}

func TestGetUserSummarizesFirebaseUser(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/user/pg.json": `{"id":"pg","karma":123456,"about":"hello",` +
			`"created":1173923444,"submitted":[1,2,3]}`,
		"/v0/user/ghost.json": `null`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetUser, client, `{"username":"pg"}`)
	require.NoError(t, err)
	require.JSONEq(t,
		`{"username":"pg","karma":123456,"about":"hello"}`, string(output))

	// Whitespace is trimmed like upstream's requiredString cast.
	_, err = invoke(t, ActionGetUser, client, `{"username":" pg "}`)
	require.NoError(t, err)
	require.Equal(t,
		[]string{"GET /v0/user/pg.json", "GET /v0/user/pg.json"}, firebase.recorded())

	output, err = invoke(t, ActionGetUser, client, `{"username":"ghost"}`)
	require.NoError(t, err)
	require.Equal(t, "null", string(output))

	_, err = invoke(t, ActionGetUser, client, `{"username":"   "}`)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func TestGetUserByUsernamePassesFirebaseUserThrough(t *testing.T) {
	profile := `{"id":"tptacek","karma":90210,"about":null,` +
		`"created":1173923444,"submitted":[4,5]}`
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/user/tptacek.json": profile,
		"/v0/user/ghost.json":   `null`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetUserByUsername, client,
		`{"username":"tptacek"}`)
	require.NoError(t, err)
	require.JSONEq(t, profile, string(output))

	output, err = invoke(t, ActionGetUserByUsername, client,
		`{"username":"ghost","print":"pretty"}`)
	require.NoError(t, err)
	require.Equal(t, "null", string(output))
	require.Equal(t, []string{
		"GET /v0/user/tptacek.json",
		"GET /v0/user/ghost.json?print=pretty",
	}, firebase.recorded())
}

// actionOutputProperties digs the declared output properties of one Action
// out of the Definition, unwrapping the nullable anyOf wrapper, so tests can
// assert what the catalog really admits without a schema engine.
func actionOutputProperties(t *testing.T, action string) map[string]any {
	t.Helper()
	for _, candidate := range Definition().Actions {
		if candidate.Name != action {
			continue
		}
		schema := candidate.OutputSchema
		if anyOf, ok := schema["anyOf"].([]any); ok && len(anyOf) > 0 {
			schema, _ = anyOf[0].(map[string]any)
		}
		properties, _ := schema["properties"].(map[string]any)
		return properties
	}
	t.Fatalf("action %q is not declared", action)
	return nil
}

// TestGetUserPassesNullableAboutThrough covers the reproduced about:null: the
// summary passes the null through verbatim AND the declared schema admits it.
func TestGetUserPassesNullableAboutThrough(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/user/nobody.json": `{"id":"nobody","karma":0,"about":null,` +
			`"created":1173923444}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetUser, client, `{"username":"nobody"}`)
	require.NoError(t, err)
	require.JSONEq(t, `{"username":"nobody","karma":0,"about":null}`, string(output))

	about, ok := actionOutputProperties(t, ActionGetUser)["about"].(map[string]any)
	require.True(t, ok, "get-user must declare an about property")
	variants, ok := about["anyOf"].([]any)
	require.True(t, ok, "about must be declared as a union")
	require.Contains(t, variants, map[string]any{"type": "null"})

	// The detail passthrough declares about nullable for the same reason.
	detailAbout, ok := actionOutputProperties(t, ActionGetUserByUsername)["about"].(map[string]any)
	require.True(t, ok, "get-user-by-username must declare an about property")
	detailVariants, ok := detailAbout["anyOf"].([]any)
	require.True(t, ok, "the detail about must be declared as a union")
	require.Contains(t, detailVariants, map[string]any{"type": "null"})
}

// TestGetUserGuardsAboutToNullOrString covers the synthesized summary's about
// guard: the declared schema admits only string|null, so a hostile origin
// answering with any other JSON type for about fails closed as a malformed
// user response instead of copying the violation through.
func TestGetUserGuardsAboutToNullOrString(t *testing.T) {
	accepted := []struct{ name, body, expected string }{
		{"null about", `{"id":"u","karma":5,"about":null}`, `{"username":"u","karma":5,"about":null}`},
		{"string about", `{"id":"u","karma":5,"about":"hi"}`, `{"username":"u","karma":5,"about":"hi"}`},
		{"absent about", `{"id":"u","karma":5}`, `{"username":"u","karma":5}`},
	}
	for _, testCase := range accepted {
		t.Run(testCase.name, func(t *testing.T) {
			firebase := newStubOrigin(t, jsonResponder(map[string]string{
				"/v0/user/u.json": testCase.body,
			}))
			client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

			output, err := invoke(t, ActionGetUser, client, `{"username":"u"}`)
			require.NoError(t, err)
			require.JSONEq(t, testCase.expected, string(output))
		})
	}

	for _, body := range []string{
		`{"id":"u","karma":5,"about":123}`,
		`{"id":"u","karma":5,"about":1.5}`,
		`{"id":"u","karma":5,"about":true}`,
		`{"id":"u","karma":5,"about":["hi"]}`,
		`{"id":"u","karma":5,"about":{"text":"hi"}}`,
	} {
		firebase := newStubOrigin(t, jsonResponder(map[string]string{
			"/v0/user/u.json": body,
		}))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

		_, err := invoke(t, ActionGetUser, client, `{"username":"u"}`)
		require.Error(t, err, "about in %s must be rejected", body)
		require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		require.Equal(t, "user response was malformed", err.Error())
	}
}

// TestGetUserRejectsUserObjectsWithoutRequiredFields keeps the synthesized
// summary contract-honest: a user object without the declared username and
// karma fails closed instead of emitting a partial envelope.
func TestGetUserRejectsUserObjectsWithoutRequiredFields(t *testing.T) {
	for _, body := range []string{`{}`, `{"karma":5}`, `{"id":"x"}`} {
		firebase := newStubOrigin(t, jsonResponder(map[string]string{
			"/v0/user/broken.json": body,
		}))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

		_, err := invoke(t, ActionGetUser, client, `{"username":"broken"}`)
		require.Error(t, err, "user body %s must be rejected", body)
		require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		require.Equal(t, "user response was malformed", err.Error())
	}
}

func TestMissingResourcesMapToNotFound(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{}))
	algolia := newStubOrigin(t, jsonResponder(map[string]string{}))
	client := installClients(t,
		testClient(t, firebase.server, "/v0"),
		testClient(t, algolia.server, "/api/v1"))

	_, err := invoke(t, ActionGetItem, client, `{"id":404}`)
	require.Equal(t, runner.ErrorNotFound, runner.CodeOf(err))

	_, err = invoke(t, ActionSearchPosts, client, `{"query":"agentteam","page":0,"size":5}`)
	require.Equal(t, runner.ErrorNotFound, runner.CodeOf(err))
}

func TestExecutorInvokesActionsWithoutConnection(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/maxitem.json": `424242`,
	}))
	algolia := newStubOrigin(t, jsonResponder(map[string]string{}))
	client := installClients(t,
		testClient(t, firebase.server, "/v0"),
		testClient(t, algolia.server, "/api/v1"))

	executor := runner.NewRESTExecutor(Domain, client, nil, Handlers())
	result, err := executor.Invoke(context.Background(), registry.BuiltinInvocation{
		WorkspaceID: "wsp_1",
		Action:      ActionGetMaxItemID,
		Input:       json.RawMessage(`{}`),
	})
	require.NoError(t, err)
	require.JSONEq(t, `{"max_item_id":424242}`, string(result.Data))

	_, err = executor.Invoke(context.Background(), registry.BuiltinInvocation{
		Action: "unknown-action",
		Input:  json.RawMessage(`{}`),
	})
	require.Equal(t, runner.ErrorNotFound, runner.CodeOf(err))
}
