package hackernews

// Comment-tree behavior and budget coverage for get-item-with-id: bounded
// assembly, story resolution, adversarial origins, whitespace evasion, and
// count/byte/output/wall-clock budgets with honest deadline provenance.

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

func TestGetItemWithIDBuildsBoundedCommentTree(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/1000.json": `{"id":1000,"type":"story","by":"alice","score":240,` +
			`"title":"Show HN: AgentTeam","url":"https://example.com/agentteam",` +
			`"time":1175714200,"kids":[1001,1002,1003,1004]}`,
		"/v0/item/1001.json": `{"id":1001,"type":"comment","by":"bob","parent":1000,` +
			`"text":"` + strings.Repeat("a", 600) + `","time":1175714300,"kids":[1005]}`,
		"/v0/item/1002.json": `{"id":1002,"type":"comment","by":"carol","parent":1000,` +
			`"text":"short","time":1175714400}`,
		"/v0/item/1005.json": `{"id":1005,"type":"comment","by":"dave","parent":1001,` +
			`"text":"deep","time":1175714500,"kids":[1006]}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":"1000","max_depth":2,"max_children":2,"truncate_text":true}`)
	require.NoError(t, err)

	at := func(seconds int64) string {
		return time.Unix(seconds, 0).UTC().Format(rfc3339Millis)
	}
	expected := fmt.Sprintf(`{
		"found": true,
		"budget_exhausted": false,
		"item": {
			"id": 1000,
			"type": "story",
			"url": "https://example.com/agentteam",
			"title": "Show HN: AgentTeam",
			"author": "alice",
			"points": 240,
			"story_id": 1000,
			"created_at": %q,
			"created_at_i": 1175714200,
			"children": [
				{
					"id": 1001,
					"type": "comment",
					"text": %q,
					"author": "bob",
					"story_id": 1000,
					"parent_id": 1000,
					"created_at": %q,
					"created_at_i": 1175714300,
					"children": [
						{
							"id": 1005,
							"type": "comment",
							"text": "deep",
							"author": "dave",
							"story_id": 1000,
							"parent_id": 1001,
							"created_at": %q,
							"created_at_i": 1175714500,
							"children": [],
							"children_shown": 0,
							"max_depth_reached": true,
							"children_truncated": false,
							"total_children_count": 1
						}
					],
					"children_shown": 1,
					"max_depth_reached": false,
					"children_truncated": false,
					"total_children_count": 1
				},
				{
					"id": 1002,
					"type": "comment",
					"text": "short",
					"author": "carol",
					"story_id": 1000,
					"parent_id": 1000,
					"created_at": %q,
					"created_at_i": 1175714400
				}
			],
			"children_shown": 2,
			"max_depth_reached": false,
			"children_truncated": true,
			"total_children_count": 4
		}
	}`,
		at(1175714200), strings.Repeat("a", 500), at(1175714300),
		at(1175714500), at(1175714400))
	require.JSONEq(t, expected, string(output))

	// Children are fetched in upstream order: the root, then each limited
	// child depth-first (1005 below 1001), never the truncated 1003/1004.
	require.Equal(t, []string{
		"GET /v0/item/1000.json",
		"GET /v0/item/1001.json",
		"GET /v0/item/1005.json",
		"GET /v0/item/1002.json",
	}, firebase.recorded())
}

func TestGetItemWithIDKeepsFullTextAtZeroDepthWithoutStory(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/3000.json": `{"id":3000,"type":"comment","by":"erin",` +
			`"text":"` + strings.Repeat("b", 600) + `","time":1175714200,"kids":[3001]}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":3000,"max_depth":0,"max_children":10,"truncate_text":false}`)
	require.NoError(t, err)

	expected := fmt.Sprintf(`{
		"found": true,
		"budget_exhausted": false,
		"item": {
			"id": 3000,
			"type": "comment",
			"text": %q,
			"author": "erin",
			"created_at": %q,
			"created_at_i": 1175714200,
			"children": [],
			"children_shown": 0,
			"max_depth_reached": true,
			"children_truncated": false,
			"total_children_count": 1
		}
	}`, strings.Repeat("b", 600), time.Unix(1175714200, 0).UTC().Format(rfc3339Millis))
	require.JSONEq(t, expected, string(output))
	require.Equal(t, []string{"GET /v0/item/3000.json"}, firebase.recorded())
}

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

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

func TestGetItemWithIDResolvesStoryThroughCommentChain(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/2000.json": `{"id":2000,"type":"comment","by":"u1","parent":1500,` +
			`"text":"root comment","time":1000000000,"kids":[2001]}`,
		"/v0/item/1500.json": `{"id":1500,"type":"comment","by":"u2","parent":1000,` +
			`"text":"mid comment","time":1000000001}`,
		"/v0/item/1000.json": `{"id":1000,"type":"story","by":"alice",` +
			`"title":"Owning story","time":1000000002}`,
		"/v0/item/2001.json": `{"id":2001,"type":"comment","by":"u3","parent":2000,` +
			`"text":"reply","time":1000000003}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":2000,"max_depth":1,"max_children":10,"truncate_text":true}`)
	require.NoError(t, err)

	var payload struct {
		Found           bool `json:"found"`
		BudgetExhausted bool `json:"budget_exhausted"`
		Item            struct {
			StoryID  *int64 `json:"story_id"`
			Children []struct {
				StoryID *int64 `json:"story_id"`
			} `json:"children"`
		} `json:"item"`
	}
	require.NoError(t, json.Unmarshal(output, &payload))
	require.True(t, payload.Found)
	require.False(t, payload.BudgetExhausted)
	require.NotNil(t, payload.Item.StoryID)
	require.EqualValues(t, 1000, *payload.Item.StoryID)
	require.Len(t, payload.Item.Children, 1)
	require.NotNil(t, payload.Item.Children[0].StoryID)
	require.EqualValues(t, 1000, *payload.Item.Children[0].StoryID)

	// The parent walk visits 1500 then 1000 once, then the single child.
	require.Equal(t, []string{
		"GET /v0/item/2000.json",
		"GET /v0/item/1500.json",
		"GET /v0/item/1000.json",
		"GET /v0/item/2001.json",
	}, firebase.recorded())
}

// TestGetItemWithIDSkipsUnsafeParentIDs pins the parent-walk filter: the
// 32-hop story resolution only follows safe-integer parent ids — the range
// the declared parent/story_id outputs admit — so a hostile origin
// advertising out-of-range or non-numeric parents simply leaves the story
// unresolved instead of being fetched.
func TestGetItemWithIDSkipsUnsafeParentIDs(t *testing.T) {
	for _, parent := range []string{
		`9007199254740993`, // integer past MAX_SAFE_INTEGER
		`1.5`,              // fractional
		`"1000"`,           // string, not a JSON number
	} {
		firebase := newStubOrigin(t, jsonResponder(map[string]string{
			"/v0/item/7.json": `{"id":7,"type":"comment","by":"u","parent":` + parent +
				`,"text":"c","time":1175714200}`,
		}))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

		output, err := invoke(t, ActionGetItemWithID, client, `{"item_id":7}`)
		require.NoError(t, err, "parent %s must not fail the walk", parent)

		var payload struct {
			Item struct {
				StoryID *int64 `json:"story_id"`
			} `json:"item"`
		}
		require.NoError(t, json.Unmarshal(output, &payload))
		require.Nil(t, payload.Item.StoryID,
			"parent %s must leave the story unresolved", parent)
		// The root alone is fetched; the parent is never followed.
		require.Equal(t, []string{"GET /v0/item/7.json"}, firebase.recorded())
	}
}

// TestStoryFeedActionsListStoryIDs covers the six story-feed Actions: one
// Firebase GET per feed document, the optional pretty query, and the
func TestGetItemWithIDStopsWideTreeAtAssemblyBudget(t *testing.T) {
	firebase := servingItemTree(t, 1, 100)
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":1,"max_depth":10,"max_children":100,"truncate_text":true}`)
	require.NoError(t, err)

	var payload struct {
		BudgetExhausted bool           `json:"budget_exhausted"`
		Item            treeOutputNode `json:"item"`
	}
	require.NoError(t, json.Unmarshal(output, &payload))
	require.True(t, payload.BudgetExhausted)

	// The fetch cap binds first (one fetch per assembled node), so exactly
	// maxTreeFetches requests reach the origin and no more than that many
	// nodes come back.
	require.Len(t, firebase.recorded(), maxTreeFetches)
	require.LessOrEqual(t, countTreeNodes(payload.Item), maxTreeNodes)
	require.Equal(t, maxTreeFetches, countTreeNodes(payload.Item))

	// The root's own kid list is bounded by the budget too, and the cut is
	// marked honestly at the node where expansion stopped.
	require.LessOrEqual(t, payload.Item.ChildrenShown, 100)
	require.True(t, anyNode(payload.Item, func(node treeOutputNode) bool {
		return node.ChildrenTruncated
	}))

	// Every served request is a distinct item fetch, proving the count is the
	// budgeted fetch count rather than one path replayed.
	served := map[int64]bool{}
	for _, line := range firebase.recorded() {
		served[itemIDOfPath(t, line)] = true
	}
	require.Len(t, served, maxTreeFetches)
}

// TestGetItemWithIDStopsDeepChainWithinDefaultBudget sends only the item ID,
// so the default max_depth/max_children apply: a chain longer than the depth
// must stop after the root plus two levels without touching the budget.
func TestGetItemWithIDStopsDeepChainWithinDefaultBudget(t *testing.T) {
	firebase := servingItemTree(t, 6000, 1)
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client, `{"item_id":6000}`)
	require.NoError(t, err)

	var payload struct {
		BudgetExhausted bool           `json:"budget_exhausted"`
		Item            treeOutputNode `json:"item"`
	}
	require.NoError(t, json.Unmarshal(output, &payload))
	require.False(t, payload.BudgetExhausted)

	// Defaults are depth 2: the root, its child, and the grandchild.
	require.Equal(t, []string{
		"GET /v0/item/6000.json",
		"GET /v0/item/6000001.json",
		"GET /v0/item/6000001001.json",
	}, firebase.recorded())
	require.Equal(t, 3, countTreeNodes(payload.Item))
	require.True(t, anyNode(payload.Item, func(node treeOutputNode) bool {
		return node.MaxDepthReached
	}))
}

// testTreeBudget opens a full budget with only the soft count caps narrowed,
// so subtests can exercise one cap at a time without tripping the cumulative
// byte and deadline budgets.
func testTreeBudget(t *testing.T, fetches, nodes int) *treeBudget {
	t.Helper()
	budget := newTreeBudget()
	budget.fetchesRemaining = fetches
	budget.nodesRemaining = nodes
	return budget
}

// TestBuildTreeItemEnforcesFetchAndNodeCaps exercises each budget cap in
// isolation: the integration shapes above can only ever exhaust the fetch
// allowance first, because every assembled node costs exactly one fetch.
func TestBuildTreeItemEnforcesFetchAndNodeCaps(t *testing.T) {
	root := map[string]any{
		"id":   json.Number("1"),
		"type": "story",
		"by":   "alice",
		"time": json.Number("1175714200"),
		"kids": []any{json.Number("2"), json.Number("3"), json.Number("4")},
	}
	kids := map[string]string{
		"/v0/item/2.json": `{"id":2,"type":"comment","by":"bob","time":1175714200}`,
		"/v0/item/3.json": `{"id":3,"type":"comment","by":"carol","time":1175714200}`,
		"/v0/item/4.json": `{"id":4,"type":"comment","by":"dave","time":1175714200}`,
	}

	t.Run("node cap stops the kid list", func(t *testing.T) {
		firebase := newStubOrigin(t, jsonResponder(kids))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)
		budget := testTreeBudget(t, 100, 3)

		node, err := buildTreeItem(context.Background(), client, nil, root,
			treeBuildOptions{maxDepth: 1, maxChildren: 10, truncateText: true}, budget)
		require.NoError(t, err)

		// The root plus two children fill the three-node allowance; the third
		// kid is cut and reported, not fetched.
		require.True(t, budget.exhausted)
		require.Equal(t, 2, node["children_shown"])
		require.Equal(t, true, node["children_truncated"])
		require.Equal(t, 3, node["total_children_count"])
		require.Equal(t, []string{
			"GET /v0/item/2.json", "GET /v0/item/3.json",
		}, firebase.recorded())
	})

	t.Run("fetch cap stops the kid list", func(t *testing.T) {
		firebase := newStubOrigin(t, jsonResponder(kids))
		client := installClients(t, testClient(t, firebase.server, "/v0"), nil)
		budget := testTreeBudget(t, 1, 100)

		node, err := buildTreeItem(context.Background(), client, nil, root,
			treeBuildOptions{maxDepth: 1, maxChildren: 10, truncateText: true}, budget)
		require.NoError(t, err)

		require.True(t, budget.exhausted)
		require.Equal(t, 1, node["children_shown"])
		require.Equal(t, true, node["children_truncated"])
		require.Equal(t, []string{"GET /v0/item/2.json"}, firebase.recorded())
	})
}

// TestGetItemWithIDFailsClosedOnHeavySelfCyclingOrigin replays the review
// repro: a fixed origin answering every item fetch with a ~20KiB item whose
// kid list is one hundred self-references, driven at the parameter maxima
// (max_depth 10 x max_children 100). The fetch and node count caps alone
// would still let a ~5MB result through, so the cumulative byte budget must
// fail the invocation outright instead.
func TestGetItemWithIDFailsClosedOnHeavySelfCyclingOrigin(t *testing.T) {
	title := strings.Repeat("t", 20*1024)
	firebase := 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
		}
		kids := strings.TrimSuffix(
			strings.Repeat(strconv.FormatInt(id, 10)+",", 100), ",")
		writer.Header().Set("Content-Type", "application/json")
		fmt.Fprintf(writer, `{"id":%d,"type":"comment","title":%q,"kids":[%s]}`, id, title, kids)
	})
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":42,"max_depth":10,"max_children":100,"truncate_text":true}`)
	require.Error(t, err)
	require.Nil(t, output)
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
	require.Equal(t, "comment tree assembly exceeded its invocation budget", err.Error())

	// The byte budget stops the walk far below the fetch cap — around
	// maxTreeBytes / 20KiB requests — so no multi-megabyte success escapes.
	served := firebase.recorded()
	require.NotEmpty(t, served)
	require.LessOrEqual(t, len(served), maxTreeFetches)
	require.LessOrEqual(t, len(served), maxTreeBytes/(20*1024)+2)
}

// TestGetItemWithIDFailsClosedOnWhitespacePaddedOrigin closes the padding
// evasion: a hostile origin wrapping a tiny JSON in ~1MiB of leading
// whitespace reads megabytes off the wire while the trimmed payloads — what
// the byte budget used to charge — stay tiny. Charging the raw wire size
// CallWithMetrics reports must fail the invocation at the 2MiB budget after
// just a couple of fetches, never reaching the 256-fetch cap, even though the
// trimmed bodies would have summed to almost nothing.
func TestGetItemWithIDFailsClosedOnWhitespacePaddedOrigin(t *testing.T) {
	padding := strings.Repeat(" ", 1<<20)
	firebase := newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer, padding+
			`{"id":42,"type":"comment","text":"c","time":1175714200,"kids":[42]}`)
	})
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":42,"max_depth":10,"max_children":10,"truncate_text":true}`)
	require.Error(t, err)
	require.Nil(t, output)
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
	require.Equal(t, "comment tree assembly exceeded its invocation budget", err.Error())

	// Two ~1MiB reads already overshoot the 2MiB raw budget, so the walk dies
	// on its second fetch — far below the fetch cap — despite every trimmed
	// payload being a ~60-byte JSON.
	served := firebase.recorded()
	require.Len(t, served, 2)
	require.LessOrEqual(t, len(served), maxTreeFetches)
}

// TestGetItemWithIDFailsClosedWhenWallClockBudgetExpires drives a slow origin
// (100ms per fetch) with the wall-clock budget shrunk: the invocation must
// fail with the stable budget error after a handful of fetches rather than
// grinding through every count-legal fetch.
func TestGetItemWithIDFailsClosedWhenWallClockBudgetExpires(t *testing.T) {
	original := maxTreeDuration
	maxTreeDuration = 400 * time.Millisecond
	t.Cleanup(func() { maxTreeDuration = original })

	firebase := newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		<-time.After(100 * time.Millisecond)
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer,
			`{"id":42,"type":"comment","text":"c","time":1175714200,"kids":[42]}`)
	})
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	started := time.Now()
	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":42,"max_depth":10,"max_children":10,"truncate_text":true}`)
	elapsed := time.Since(started)

	require.Error(t, err)
	require.Nil(t, output)
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
	require.Equal(t, "comment tree assembly exceeded its invocation budget", err.Error())
	// The walk stops after a few 100ms fetches, not the 25s+ that 256 of them
	// would need, and never reaches the fetch cap.
	require.LessOrEqual(t, elapsed, 5*time.Second)
	require.LessOrEqual(t, len(firebase.recorded()), maxTreeFetches)
}

// TestGetItemWithIDPropagatesCallerCancellation keeps deadline provenance
// honest: a caller context cancelled mid-walk must surface as
// context.Canceled through the runtime wrapper — never rewritten into the
// tree-budget error, since the tree's own 60s budget never expired.
func TestGetItemWithIDPropagatesCallerCancellation(t *testing.T) {
	firebase := newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		<-time.After(50 * time.Millisecond)
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer,
			`{"id":42,"type":"comment","text":"c","time":1175714200,"kids":[42]}`)
	})
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	callerCtx, cancel := context.WithCancel(context.Background())
	time.AfterFunc(120*time.Millisecond, cancel)

	output, err := invokeContext(t, callerCtx, ActionGetItemWithID, client,
		`{"item_id":42,"max_depth":10,"max_children":10,"truncate_text":true}`)
	require.Error(t, err)
	require.Nil(t, output)
	require.True(t, errors.Is(err, context.Canceled),
		"caller cancellation must propagate as context.Canceled, got: %v", err)
	require.NotEqual(t, "comment tree assembly exceeded its invocation budget", err.Error())
}

// TestGetItemWithIDPropagatesCallerDeadlineExpiry is the masquerade repro: a
// caller deadline firing mid-walk also surfaces as DeadlineExceeded, but the
// tree's own 60s budget is untouched, so the error must keep its original
// upstream-failure classification — the budget error is reserved for the
// tree's own expiry.
func TestGetItemWithIDPropagatesCallerDeadlineExpiry(t *testing.T) {
	firebase := newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		<-time.After(50 * time.Millisecond)
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer,
			`{"id":42,"type":"comment","text":"c","time":1175714200,"kids":[42]}`)
	})
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	callerCtx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
	defer cancel()

	output, err := invokeContext(t, callerCtx, ActionGetItemWithID, client,
		`{"item_id":42,"max_depth":10,"max_children":10,"truncate_text":true}`)
	require.Error(t, err)
	require.Nil(t, output)
	require.True(t, errors.Is(err, context.DeadlineExceeded),
		"the caller deadline must keep its deadline cause, got: %v", err)
	require.NotEqual(t, "comment tree assembly exceeded its invocation budget", err.Error())
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
}

// TestGetItemWithIDKeepsClientTimeoutClassifiedAsUpstreamFailure is the other
// provenance half: the REST client's per-request timeout firing mid-walk
// surfaces with its own upstream-failure classification and deadline cause —
// the tree budget never expired, so the error must not be rewritten into the
// budget error.
func TestGetItemWithIDKeepsClientTimeoutClassifiedAsUpstreamFailure(t *testing.T) {
	firebase := newStubOrigin(t, func(writer http.ResponseWriter, request *http.Request) {
		<-time.After(250 * time.Millisecond)
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer,
			`{"id":42,"type":"comment","text":"c","time":1175714200,"kids":[42]}`)
	})
	shortClient, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   firebase.server.URL + "/v0",
		Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
		Timeout:   50 * time.Millisecond,
	})
	require.NoError(t, err)
	client := installClients(t, shortClient, nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":42,"max_depth":10,"max_children":10,"truncate_text":true}`)
	require.Error(t, err)
	require.Nil(t, output)
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
	require.Equal(t, "provider request failed", err.Error())
	require.NotEqual(t, "comment tree assembly exceeded its invocation budget", err.Error())
	// The cause stays the client's own request deadline, not the tree budget.
	require.True(t, errors.Is(err, context.DeadlineExceeded),
		"the client request timeout must keep its deadline cause, got: %v", err)
}

// TestGetItemWithIDFailsClosedWhenOutputBudgetExceeded shrinks the output size
// cap below one small envelope: the marshaled result itself breaches the
// budget and the invocation fails closed before anything is returned.
func TestGetItemWithIDFailsClosedWhenOutputBudgetExceeded(t *testing.T) {
	original := maxTreeOutputBytes
	maxTreeOutputBytes = 64
	t.Cleanup(func() { maxTreeOutputBytes = original })

	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/42.json": `{"id":42,"type":"comment","text":"c","time":1175714200}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client, `{"item_id":42}`)
	require.Error(t, err)
	require.Nil(t, output)
	require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
	require.Equal(t, "comment tree assembly exceeded its invocation budget", err.Error())
}

// TestGetItemWithIDTruncatesTextByUTF16CodeUnits pins the truncation unit to
// the baseline's JavaScript slice semantics: 500 UTF-16 code units keep 250
// surrogate-pair emojis, and the pair is never split.
func TestGetItemWithIDTruncatesTextByUTF16CodeUnits(t *testing.T) {
	text := strings.Repeat("\U0001F600", 300)
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/5.json": `{"id":5,"type":"comment","text":"` + text + `","time":1175714200}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":5,"max_depth":0,"max_children":10,"truncate_text":true}`)
	require.NoError(t, err)

	var payload struct {
		Item struct {
			Text string `json:"text"`
		} `json:"item"`
	}
	require.NoError(t, json.Unmarshal(output, &payload))
	require.Equal(t, strings.Repeat("\U0001F600", 250), payload.Item.Text)
}

// TestGetItemWithIDSkipsItemsThatCannotSatisfyTheNodeContract covers the
// schema-honesty guards: only kids that decode to objects with a
// safe-integral id and a declared type become nodes, but every kid fetch is
// still made and charged.
func TestGetItemWithIDSkipsItemsThatCannotSatisfyTheNodeContract(t *testing.T) {
	firebase := newStubOrigin(t, jsonResponder(map[string]string{
		"/v0/item/10.json": `{"id":10,"type":"story","by":"a","kids":[11,12,13,14],` +
			`"time":1175714200}`,
		"/v0/item/11.json": `{}`,
		"/v0/item/12.json": `[1,2]`,
		"/v0/item/13.json": `{"id":13,"type":"weird"}`,
		"/v0/item/14.json": `{"id":14,"type":"comment","by":"b","time":1175714300}`,
	}))
	client := installClients(t, testClient(t, firebase.server, "/v0"), nil)

	output, err := invoke(t, ActionGetItemWithID, client,
		`{"item_id":10,"max_depth":1,"max_children":10,"truncate_text":true}`)
	require.NoError(t, err)

	var payload struct {
		Found bool `json:"found"`
		Item  struct {
			Children []map[string]any `json:"children"`
		} `json:"item"`
	}
	require.NoError(t, json.Unmarshal(output, &payload))
	require.True(t, payload.Found)
	require.Len(t, payload.Item.Children, 1)
	require.Equal(t, float64(14), payload.Item.Children[0]["id"])

	require.Equal(t, []string{
		"GET /v0/item/10.json",
		"GET /v0/item/11.json",
		"GET /v0/item/12.json",
		"GET /v0/item/13.json",
		"GET /v0/item/14.json",
	}, firebase.recorded())
}
