package hotnews

import (
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/extension/registry"
	"github.com/tidbcloud/AgentTeam/internal/extension/runner"
)

// stubOrigin is one TLS test double for the pinned 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
	agents   []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.agents = append(origin.agents, request.UserAgent())
			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...)
}

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

// statusResponder serves one fixed body with a forced status for every path.
func statusResponder(status int, body string) http.HandlerFunc {
	return func(writer http.ResponseWriter, _ *http.Request) {
		writer.Header().Set("Content-Type", "application/json")
		writer.WriteHeader(status)
		_, _ = io.WriteString(writer, body)
	}
}

// testClient builds the loopback client for the double with the production
// User-Agent, so the wire assertions exercise the same header propagation the
// executor's dispatched client performs. The transport trusts the throwaway
// test certificate; NewRESTClient only binds https origins.
func testClient(t *testing.T, server *httptest.Server) *runner.RESTClient {
	t.Helper()
	client, err := runner.NewRESTClient(runner.RESTOptions{
		BaseURL:   server.URL + "/api",
		Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
		UserAgent: hotnewsUserAgent,
	})
	require.NoError(t, err)
	return client
}

// invoke dispatches one Action through the handler table with empty input
// credentials, the way the connectionless executor does.
func invoke(
	t *testing.T,
	ctx context.Context,
	action string,
	client *runner.RESTClient,
	input string,
) (json.RawMessage, error) {
	t.Helper()
	root := filepath.Join(t.TempDir(), "cache")
	cache := newFeedCache(
		root,
		[]string{root},
		feedCacheTTL,
		func() time.Time { return time.Date(2026, 8, 17, 0, 0, 0, 0, time.UTC) },
	)
	return invokeWithCache(t, ctx, action, client, input, cache)
}

func invokeWithCache(
	t *testing.T,
	ctx context.Context,
	action string,
	client *runner.RESTClient,
	input string,
	cache *feedCache,
) (json.RawMessage, error) {
	t.Helper()
	handler, ok := handlersWithCache(cache)[action]
	require.True(t, ok, "action %q must be served", action)
	return handler(ctx, client, nil, json.RawMessage(input))
}

func TestDefinitionMatchesServedCatalog(t *testing.T) {
	t.Parallel()

	definition := Definition()
	require.Equal(t, "hotnews", definition.Domain)
	require.Equal(t, "HotNews", definition.Name)
	require.Len(t, definition.Actions, 2)
	served := handlersWithCache(productionFeedCache)
	for _, action := range definition.Actions {
		require.Containsf(t, served, action.Name,
			"catalog action %q must have a handler", action.Name)
		require.Equal(t, "read", action.Safety.Class)
		require.NotNil(t, action.InputSchema)
		require.NotNil(t, action.OutputSchema)
		require.Equal(t,
			"https://json-schema.org/draft/2020-12/schema",
			action.InputSchema["$schema"])
		require.Equal(t,
			"https://json-schema.org/draft/2020-12/schema",
			action.OutputSchema["$schema"])
	}
	require.Len(t, served, len(definition.Actions),
		"no handler may exist outside the catalog")
}

func TestPlatformEnumMatchesCatalogAndHandler(t *testing.T) {
	t.Parallel()

	ids := platformIDs()
	require.Len(t, ids, len(platforms))
	require.Equal(t, []any{
		"baidu", "bilibili-hot-search", "cls-hot", "douyin", "douban",
		"ifeng", "producthunt", "tencent-hot", "tieba",
		"wallstreetcn-hot", "weibo",
	}, ids)
	for _, removed := range []string{
		"hackernews", "thepaper", "toutiao", "zhihu",
	} {
		require.NotContains(t, ids, removed)
	}
	for _, added := range []string{"douban", "tencent-hot"} {
		require.Contains(t, ids, added)
	}
	for _, entry := range platforms {
		found, ok := platformByID(entry.id)
		require.True(t, ok)
		require.Equal(t, entry, found)
		require.NotEmpty(t, found.name)
		require.NotEmpty(t, found.expectedDomain)
	}
	_, ok := platformByID("nonexistent")
	require.False(t, ok)
}

func TestTrendingOutputSchemaMatchesLiveItemShapes(t *testing.T) {
	t.Parallel()

	output := trendingOutputSchema()
	require.Equal(t, true, output["additionalProperties"])
	properties := output["properties"].(map[string]any)
	require.Equal(t, []any{"success", "cache"},
		properties["status"].(map[string]any)["enum"])
	require.Equal(t, platformIDs(),
		properties["id"].(map[string]any)["enum"])

	updatedTime := properties["updatedTime"].(map[string]any)
	require.Equal(t, 0, updatedTime["minimum"])
	require.Equal(t, maxSafeInteger, updatedTime["maximum"])

	items := properties["items"].(map[string]any)
	item := items["items"].(map[string]any)
	require.Equal(t, true, item["additionalProperties"])
	itemProperties := item["properties"].(map[string]any)
	require.ElementsMatch(t, []string{"string", "integer", "null"},
		schemaVariantTypes(t, itemProperties["id"].(map[string]any)))
	for _, member := range []string{"title", "url", "mobileUrl"} {
		require.ElementsMatch(t, []string{"string", "null"},
			schemaVariantTypes(t, itemProperties[member].(map[string]any)))
	}
	require.ElementsMatch(t, []string{"object", "null"},
		schemaVariantTypes(t, itemProperties["extra"].(map[string]any)))
}

func schemaVariantTypes(t *testing.T, schema map[string]any) []string {
	t.Helper()
	variants, ok := schema["anyOf"].([]any)
	require.True(t, ok)
	types := make([]string, 0, len(variants))
	for _, rawVariant := range variants {
		variant, ok := rawVariant.(map[string]any)
		require.True(t, ok)
		value, ok := variant["type"].(string)
		require.True(t, ok)
		types = append(types, value)
	}
	return types
}

func TestGetTrendingEnforcesEachPlatformDomain(t *testing.T) {
	t.Parallel()

	cases := []struct {
		id             string
		expectedDomain string
	}{
		{id: "baidu", expectedDomain: "baidu.com"},
		{id: "bilibili-hot-search", expectedDomain: "bilibili.com"},
		{id: "cls-hot", expectedDomain: "cls.cn"},
		{id: "douyin", expectedDomain: "douyin.com"},
		{id: "douban", expectedDomain: "douban.com"},
		{id: "ifeng", expectedDomain: "ifeng.com"},
		{id: "producthunt", expectedDomain: "producthunt.com"},
		{id: "tencent-hot", expectedDomain: "qq.com"},
		{id: "tieba", expectedDomain: "baidu.com"},
		{id: "wallstreetcn-hot", expectedDomain: "wallstreetcn.com"},
		{id: "weibo", expectedDomain: "weibo.com"},
	}
	for _, testCase := range cases {
		testCase := testCase
		t.Run(testCase.id, func(t *testing.T) {
			t.Parallel()

			validFeed := fmt.Sprintf(
				`{"status":"success","id":%q,"updatedTime":1,`+
					`"items":[{"url":"https://news.%s/topic"}]}`,
				testCase.id, testCase.expectedDomain)
			validOrigin := newStubOrigin(t,
				statusResponder(http.StatusOK, validFeed))
			_, err := invoke(t, context.Background(), ActionGetTrending,
				testClient(t, validOrigin.server),
				fmt.Sprintf(`{"platform_id":%q}`, testCase.id))
			require.NoError(t, err)

			invalidFeed := fmt.Sprintf(
				`{"status":"success","id":%q,"updatedTime":1,`+
					`"items":[{"url":"https://unrelated.example/topic"}]}`,
				testCase.id)
			invalidOrigin := newStubOrigin(t,
				statusResponder(http.StatusOK, invalidFeed))
			_, err = invoke(t, context.Background(), ActionGetTrending,
				testClient(t, invalidOrigin.server),
				fmt.Sprintf(`{"platform_id":%q}`, testCase.id))
			require.Error(t, err)
			require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		})
	}
}

func TestGetTrendingAcceptsNormalizedExpectedDomains(t *testing.T) {
	t.Parallel()

	boundaryLabel := strings.Repeat("a", 63)
	maximumHostname := strings.Join([]string{
		strings.Repeat("a", 60),
		strings.Repeat("b", 60),
		strings.Repeat("c", 60),
		strings.Repeat("d", 60),
	}, ".") + ".weibo.com"
	feed := fmt.Sprintf(
		`{"status":"success","id":"weibo","updatedTime":1,`+
			`"items":[{"url":"https://WEIBO.COM./topic"},`+
			`{"mobileUrl":"https://NEWS-1.UPDATES.WEIBO.COM./topic"},`+
			`{"url":"https://%s.weibo.com/topic"},`+
			`{"url":"https://%s/topic"},`+
			`{"url":"https://weibo.com:1/topic"},`+
			`{"mobileUrl":"https://news.weibo.com:65535/topic"}]}`,
		boundaryLabel, maximumHostname)
	origin := newStubOrigin(t, statusResponder(http.StatusOK, feed))

	output, err := invoke(t, context.Background(), ActionGetTrending,
		testClient(t, origin.server), `{"platform_id":"weibo"}`)
	require.NoError(t, err)
	require.Equal(t, feed, string(output))
}

func TestGetTrendingRejectsInvalidSubdomainLabels(t *testing.T) {
	t.Parallel()

	cases := map[string]string{
		"empty prefix":         ".weibo.com",
		"empty middle label":   "foo..weibo.com",
		"underscore":           "foo_bar.weibo.com",
		"leading hyphen":       "-foo.weibo.com",
		"trailing hyphen":      "foo-.weibo.com",
		"non-ASCII":            "café.weibo.com",
		"overlong label":       strings.Repeat("a", 64) + ".weibo.com",
		"overlong whole host":  strings.Repeat("a.", 123) + "weibo.com",
		"empty port":           "weibo.com:",
		"zero port":            "weibo.com:0",
		"port above range":     "weibo.com:99999",
		"two trailing dots":    "foo.weibo.com..",
		"empty trailing label": "foo..weibo.com.",
	}
	for name, hostname := range cases {
		name, hostname := name, hostname
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			feed := fmt.Sprintf(
				`{"status":"success","id":"weibo","updatedTime":1,`+
					`"items":[{"url":"https://%s/topic"}]}`,
				hostname)
			origin := newStubOrigin(t, statusResponder(http.StatusOK, feed))
			_, err := invoke(t, context.Background(), ActionGetTrending,
				testClient(t, origin.server), `{"platform_id":"weibo"}`)
			require.Error(t, err)
			require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		})
	}
}

func TestGetTrendingAcceptsSemanticIntegerLexicals(t *testing.T) {
	t.Parallel()

	cases := map[string]struct {
		updatedTime string
		itemID      string
	}{
		"trailing zero": {
			updatedTime: "1.0",
			itemID:      "2.0",
		},
		"exponent": {
			updatedTime: "1e3",
			itemID:      "2e3",
		},
		"safe maximum exponent and arbitrary item magnitude": {
			updatedTime: "9.007199254740991e15",
			itemID:      "1e1000000",
		},
		"cancelling exponent": {
			updatedTime: "1000e-3",
			itemID:      "-1000e-3",
		},
		"zero with huge exponents": {
			updatedTime: "0e-1000000",
			itemID:      "-0e1000000",
		},
	}
	for name, testCase := range cases {
		name, testCase := name, testCase
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			feed := `{"status":"success","id":"weibo","updatedTime":` +
				testCase.updatedTime + `,"items":[{"id":` +
				testCase.itemID + `}]}`
			origin := newStubOrigin(t, statusResponder(http.StatusOK, feed))
			output, err := invoke(t, context.Background(), ActionGetTrending,
				testClient(t, origin.server), `{"platform_id":"weibo"}`)
			require.NoError(t, err)
			require.Equal(t, feed, string(output))
		})
	}
}

func TestExactJSONIntegerAdversarialLexicals(t *testing.T) {
	t.Parallel()

	cases := map[string]struct {
		integer bool
		safe    bool
	}{
		"1.0":              {integer: true, safe: true},
		"1e3":              {integer: true, safe: true},
		"1000e-3":          {integer: true, safe: true},
		"1001e-3":          {integer: false},
		"1e1000000":        {integer: true, safe: false},
		"1e-1000000":       {integer: false},
		"0e1000000":        {integer: true, safe: true},
		"-0e-1000000":      {integer: true, safe: true},
		"-1":               {integer: true, safe: false},
		"9007199254740991": {integer: true, safe: true},
		"9007199254740992": {integer: true, safe: false},
		"1.5":              {integer: false},
		"01":               {integer: false},
		"+1":               {integer: false},
		"1.":               {integer: false},
		"1e":               {integer: false},
		`"1"`:              {integer: false},
	}
	for lexical, testCase := range cases {
		lexical, testCase := lexical, testCase
		t.Run(lexical, func(t *testing.T) {
			t.Parallel()

			integer, accepted := exactJSONInteger(json.RawMessage(lexical))
			require.Equal(t, testCase.integer, accepted)
			if accepted {
				require.Equal(t, testCase.safe,
					integer.isNonNegativeSafeInteger())
			}
		})
	}
}

func TestGetTrendingRejectsDuplicateObjectKeys(t *testing.T) {
	t.Parallel()

	cases := map[string]string{
		"envelope safe then unsafe": `{"status":"success","id":"weibo","id":"baidu",` +
			`"updatedTime":1,"items":[]}`,
		"envelope unsafe then safe": `{"status":"success","id":"baidu","id":"weibo",` +
			`"updatedTime":1,"items":[]}`,
		"item URL safe then unsafe": `{"status":"success","id":"weibo","updatedTime":1,` +
			`"items":[{"url":"https://weibo.com/a","url":"https://evil.example/a"}]}`,
		"item URL unsafe then safe": `{"status":"success","id":"weibo","updatedTime":1,` +
			`"items":[{"url":"https://evil.example/a","url":"https://weibo.com/a"}]}`,
		"escaped-equivalent item URL": `{"status":"success","id":"weibo","updatedTime":1,` +
			`"items":[{"url":"https://evil.example/a","\u0075rl":"https://weibo.com/a"}]}`,
		"nested extra": `{"status":"success","id":"weibo","updatedTime":1,` +
			`"items":[{"extra":{"flag":true,"flag":false}}]}`,
	}
	for name, feed := range cases {
		name, feed := name, feed
		t.Run(name, func(t *testing.T) {
			t.Parallel()

			origin := newStubOrigin(t, statusResponder(http.StatusOK, feed))
			_, err := invoke(t, context.Background(), ActionGetTrending,
				testClient(t, origin.server), `{"platform_id":"weibo"}`)
			require.Error(t, err)
			require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err))
		})
	}
}

func TestGetTrendingRejectsRemovedPlatforms(t *testing.T) {
	t.Parallel()

	client := testClient(t, newStubOrigin(t,
		func(http.ResponseWriter, *http.Request) {}).server)
	for _, id := range []string{"hackernews", "thepaper", "toutiao", "zhihu"} {
		_, err := invoke(t, context.Background(), ActionGetTrending, client,
			fmt.Sprintf(`{"platform_id":%q}`, id))
		require.Error(t, err)
		require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
	}
}

func TestPlatformCatalogHasUniqueIDs(t *testing.T) {
	t.Parallel()

	seen := make(map[string]struct{}, len(platforms))
	for _, entry := range platforms {
		_, duplicate := seen[entry.id]
		require.False(t, duplicate, "duplicate platform ID %q", entry.id)
		seen[entry.id] = struct{}{}
	}
}

func TestGetTrendingWireAndVerbatimPassthrough(t *testing.T) {
	t.Parallel()

	feed := " \n\t" + `{"status":"cache","id":"weibo","updatedTime":1786897926603,` +
		`"items":[{"id":"a","title":"a","url":"https://s.weibo.com/a",` +
		`"mobileUrl":"https://s.weibo.com/a","extra":{"icon":{"url":"https://simg.s.weibo.com/x.png","scale":1.5}}},` +
		`{"id":"b","title":"b","url":"","mobileUrl":null,"extra":{}}]}` + "\n "
	origin := newStubOrigin(t, func(writer http.ResponseWriter, _ *http.Request) {
		writer.Header().Set("Content-Type", "application/json")
		_, _ = io.WriteString(writer, feed)
	})
	client := testClient(t, origin.server)

	output, err := invoke(t, context.Background(), ActionGetTrending, client,
		`{"platform_id":"weibo"}`)
	require.NoError(t, err)
	require.Equal(t, strings.TrimSpace(feed), string(output),
		"only surrounding whitespace may be removed from the feed envelope")
	require.Equal(t,
		[]string{"GET /api/s?id=weibo&latest="}, origin.recorded(),
		"the wire request must mirror the upstream fetcher")
	require.Equal(t,
		[]string{hotnewsUserAgent}, origin.recordedUserAgents(),
		"the origin-facing client must carry the policy-compatibile agent")
}

func TestListPlatformsServesCatalog(t *testing.T) {
	t.Parallel()

	origin := newStubOrigin(t, func(writer http.ResponseWriter, _ *http.Request) {
		http.NotFound(writer, nil)
	})
	client := testClient(t, origin.server)

	output, err := invoke(t, context.Background(), ActionListPlatforms, client, `{}`)
	require.NoError(t, err)
	var decoded struct {
		Platforms []map[string]string `json:"platforms"`
	}
	require.NoError(t, json.Unmarshal(output, &decoded))
	require.Len(t, decoded.Platforms, len(platforms))
	require.Equal(t, decoded.Platforms[0]["id"], platforms[0].id)
	require.Equal(t, decoded.Platforms[0]["name"], platforms[0].name)
	require.Empty(t, origin.recorded(),
		"the directory read must not touch the origin")
}

func TestGetTrendingRejectsUnknownPlatform(t *testing.T) {
	t.Parallel()

	origin := newStubOrigin(t, func(writer http.ResponseWriter, _ *http.Request) {
		t.Error("an unknown platform must fail before any upstream call")
	})
	client := testClient(t, origin.server)

	_, err := invoke(t, context.Background(), ActionGetTrending, client,
		`{"platform_id":"nonexistent"}`)
	require.Error(t, err)
	require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
}

func TestGetTrendingRequiresPlatformID(t *testing.T) {
	t.Parallel()

	client := testClient(t, newStubOrigin(t,
		func(http.ResponseWriter, *http.Request) {}).server)
	for _, input := range []string{`{}`, `{"platform_id":42}`, `not-json`} {
		_, err := invoke(t, context.Background(), ActionGetTrending, client, input)
		require.Error(t, err, "input %s must be rejected", input)
		require.Equal(t, runner.ErrorValidation, runner.CodeOf(err))
	}
}

func TestGetTrendingFailsClosedOnMalformedFeeds(t *testing.T) {
	t.Parallel()

	cases := map[string]string{
		"empty body":                        ``,
		"null body":                         `null`,
		"array body":                        `[]`,
		"missing items":                     `{"status":"success","id":"weibo","updatedTime":1}`,
		"null items":                        `{"status":"success","id":"weibo","updatedTime":1,"items":null}`,
		"non-array items":                   `{"status":"success","id":"weibo","updatedTime":1,"items":{}}`,
		"missing status":                    `{"id":"weibo","updatedTime":1,"items":[]}`,
		"non-string status":                 `{"status":7,"id":"weibo","updatedTime":1,"items":[]}`,
		"unknown status":                    `{"status":"stale","id":"weibo","updatedTime":1,"items":[]}`,
		"missing id":                        `{"status":"success","updatedTime":1,"items":[]}`,
		"mismatched id":                     `{"status":"success","id":"baidu","updatedTime":1,"items":[]}`,
		"fractional timestamp":              `{"status":"success","id":"weibo","updatedTime":1.5,"items":[]}`,
		"fractional exponent timestamp":     `{"status":"success","id":"weibo","updatedTime":1e-1,"items":[]}`,
		"cancellation fractional timestamp": `{"status":"success","id":"weibo","updatedTime":1001e-3,"items":[]}`,
		"huge negative exponent timestamp":  `{"status":"success","id":"weibo","updatedTime":1e-1000000,"items":[]}`,
		"string timestamp":                  `{"status":"success","id":"weibo","updatedTime":"1","items":[]}`,
		"negative timestamp":                `{"status":"success","id":"weibo","updatedTime":-1,"items":[]}`,
		"unsafe timestamp":                  `{"status":"success","id":"weibo","updatedTime":9007199254740992,"items":[]}`,
		"unsafe exponent timestamp":         `{"status":"success","id":"weibo","updatedTime":9.007199254740992e15,"items":[]}`,
		"huge exponent timestamp":           `{"status":"success","id":"weibo","updatedTime":1e1000000,"items":[]}`,
		"non-object item":                   `{"status":"success","id":"weibo","updatedTime":1,"items":["x"]}`,
		"null item":                         `{"status":"success","id":"weibo","updatedTime":1,"items":[null]}`,
		"boolean item id":                   `{"status":"success","id":"weibo","updatedTime":1,"items":[{"id":true}]}`,
		"fractional item id":                `{"status":"success","id":"weibo","updatedTime":1,"items":[{"id":1.5}]}`,
		"fractional exponent item id":       `{"status":"success","id":"weibo","updatedTime":1,"items":[{"id":1e-1}]}`,
		"cancellation fractional item id":   `{"status":"success","id":"weibo","updatedTime":1,"items":[{"id":1001e-3}]}`,
		"huge negative exponent item id":    `{"status":"success","id":"weibo","updatedTime":1,"items":[{"id":1e-1000000}]}`,
		"non-string title":                  `{"status":"success","id":"weibo","updatedTime":1,"items":[{"title":1}]}`,
		"array extra":                       `{"status":"success","id":"weibo","updatedTime":1,"items":[{"extra":[]}]}`,
		"http url":                          `{"status":"success","id":"weibo","updatedTime":1,"items":[{"title":"a","url":"http://s.weibo.com/a"}]}`,
		"scheme-relative url":               `{"status":"success","id":"weibo","updatedTime":1,"items":[{"title":"a","url":"//s.weibo.com/a"}]}`,
		"https url without host":            `{"status":"success","id":"weibo","updatedTime":1,"items":[{"title":"a","url":"https:///a"}]}`,
		"http mobileUrl":                    `{"status":"success","id":"weibo","updatedTime":1,"items":[{"title":"a","mobileUrl":"http://s.weibo.com/a"}]}`,
		"non-string url":                    `{"status":"success","id":"weibo","updatedTime":1,"items":[{"title":"a","url":5}]}`,
		"url userinfo":                      `{"status":"success","id":"weibo","updatedTime":1,"items":[{"url":"https://user@s.weibo.com/a"}]}`,
		"wrong url domain":                  `{"status":"success","id":"weibo","updatedTime":1,"items":[{"url":"https://example.com/a"}]}`,
		"prefix lookalike":                  `{"status":"success","id":"weibo","updatedTime":1,"items":[{"url":"https://evilweibo.com/a"}]}`,
		"suffix lookalike":                  `{"status":"success","id":"weibo","updatedTime":1,"items":[{"url":"https://weibo.com.evil.example/a"}]}`,
		"wrong mobile domain":               `{"status":"success","id":"weibo","updatedTime":1,"items":[{"mobileUrl":"https://example.com/a"}]}`,
	}
	for name, body := range cases {
		origin := newStubOrigin(t, statusResponder(http.StatusOK, body))
		client := testClient(t, origin.server)
		_, err := invoke(t, context.Background(), ActionGetTrending, client,
			`{"platform_id":"weibo"}`)
		require.Errorf(t, err, "%s must fail closed", name)
		require.Equal(t, runner.ErrorUpstreamFailure, runner.CodeOf(err),
			"%s must classify as upstream_failure", name)
	}
}

func TestGetTrendingAcceptsNullableLinkShapes(t *testing.T) {
	t.Parallel()

	feed := `{"status":"success","id":"weibo","updatedTime":2,` +
		`"items":[{"id":1,"title":"a","extra":null},{"id":null,"title":null},` +
		`{"id":"b","title":"b","url":null,"mobileUrl":"","extra":{}},` +
		`{"title":"c","url":"https://www.weibo.com/a","unknown":true}],` +
		`"unknownEnvelope":{"future":true}}`
	origin := newStubOrigin(t, statusResponder(http.StatusOK, feed))
	client := testClient(t, origin.server)

	output, err := invoke(t, context.Background(), ActionGetTrending, client,
		`{"platform_id":"weibo"}`)
	require.NoError(t, err)
	require.Equal(t, feed, string(output))
}

func TestGetTrendingClassifiesHTTPStatuses(t *testing.T) {
	t.Parallel()

	cases := map[int]runner.ErrorCode{
		http.StatusNotFound:            runner.ErrorNotFound,
		http.StatusTooManyRequests:     runner.ErrorRateLimited,
		http.StatusInternalServerError: runner.ErrorUpstreamFailure,
		http.StatusBadGateway:          runner.ErrorUpstreamFailure,
	}
	for status, code := range cases {
		body := `{"error":true,"statusCode":500,"message":"Invalid source id"}`
		origin := newStubOrigin(t, statusResponder(status, body))
		client := testClient(t, origin.server)
		_, err := invoke(t, context.Background(), ActionGetTrending, client,
			`{"platform_id":"weibo"}`)
		require.Errorf(t, err, "HTTP %d must fail", status)
		require.Equal(t, code, runner.CodeOf(err),
			"HTTP %d must classify as %s", status, code)
	}
}

func TestExtensionIsConnectionless(t *testing.T) {
	t.Parallel()

	built, err := Extension()
	require.NoError(t, err)
	require.Equal(t, "hotnews", built.Definition.Domain)
	require.Equal(t, "hotnews-rest-v1", built.Implementation.Revision)
	require.Equal(t, registry.ConnectionNone, built.Implementation.ConnectionPolicy)
	require.Equal(t, runner.AuthNone, built.Implementation.Auth.Kind)
	require.Equal(t, "app_hotnews", built.Distribution.AppID)
	require.Equal(t,
		"Mozilla/5.0 (compatible; agentteam-hotnews-extension/1.0)",
		hotnewsUserAgent,
	)
	require.NotContains(t, hotnewsUserAgent, "Chrome",
		"the declaration must not mask as a specific browser")
	require.Equal(t, "hotnews", built.Distribution.AppSlug)
	require.Equal(t, "hotnews", built.Distribution.DefaultName)
	require.Equal(t, "/app-icons/hotnews.svg", built.Distribution.IconURL)
	require.True(t, built.Distribution.DefaultEnabled)
}
