package hackernews

import (
	"bytes"
	"context"
	"encoding/json"
	"math"
	"net/url"
	"regexp"
	"strconv"
	"strings"
	"time"

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

const (
	// maxTruncatedTextLength bounds comment text inside the nested tree when
	// truncation is enabled, counted in UTF-16 code units like the
	// baseline's JavaScript slice.
	maxTruncatedTextLength = 500
	// maxStoryWalkSteps bounds the parent walk that resolves a comment's
	// owning story or poll.
	maxStoryWalkSteps = 32
	// rfc3339Millis mirrors JavaScript's toISOString shape used upstream for
	// created_at.
	rfc3339Millis = "2006-01-02T15:04:05.000Z07:00"

	// maxTreeFetches is the hard per-invocation cap on Firebase item fetches
	// one get-item-with-id call may perform, counted across the root fetch,
	// the story-resolution walk, and every recursive child fetch. Upstream
	// bounds the tree only by max_depth/max_children, whose maxima (10 x 100)
	// still fan out to 100^10 fetches; this cap closes that hole.
	maxTreeFetches = 256
	// maxTreeNodes is the hard per-invocation cap on assembled tree nodes.
	// Every assembled node costs exactly one item fetch today, so the fetch
	// cap binds first; the node cap independently bounds the output size in
	// case assembly ever derives nodes from a fetch that returns several.
	maxTreeNodes = 1024
)

// The cumulative tree-walk budgets. The fetch and node caps above bound only
// counts, so a hostile or buggy fixed origin serving ~20KiB items could still
// amortize close to a gigabyte of reads and minutes of wall clock inside 256
// count-legal fetches; these three budgets close that hole and, unlike the
// count caps, fail the invocation outright with errTreeBudgetExceeded instead
// of returning a truncated tree. They are vars rather than consts only so
// tests can shrink them; production never mutates them.
var (
	// maxTreeBytes caps the cumulative upstream response bytes one walk may
	// consume, summed over every fetch including the story-resolution walk.
	maxTreeBytes = 2 << 20
	// maxTreeOutputBytes caps the size of the marshaled get-item-with-id
	// result, checked before the envelope is returned.
	maxTreeOutputBytes = 2 << 20
	// maxTreeDuration caps the wall clock of one get-item-with-id invocation.
	// The walk checks it before every fetch and the handler also bounds the
	// context, so a single hung request cannot outlive the budget either.
	maxTreeDuration = 60 * time.Second
)

// errTreeBudgetExceeded is the stable fail-closed error for the cumulative
// tree budgets (upstream bytes, output size, wall clock): the message is
// static, carries no upstream detail, and the invocation errors rather than
// returning a partial tree the way the count caps do.
func handleGetLatestPosts(
	ctx context.Context,
	_ *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	// The Algolia envelope is the Action output as-is.
	return algoliaEnvelope(ctx, credentials, runner.RESTRequest{
		Path:  "/search_by_date",
		Query: algoliaQuery(parsed, nil),
	})
}

// algoliaEnvelope fetches one Algolia search page and pins the passthrough to
// the declared object envelope: a 2xx body that is not a JSON object (an
// array, null, or a scalar) fails closed instead of violating the output
// contract.
func algoliaEnvelope(
	ctx context.Context,
	credentials runner.Credentials,
	request runner.RESTRequest,
) (json.RawMessage, error) {
	raw, err := algoliaClient.Call(ctx, credentials, request)
	if err != nil {
		return nil, err
	}
	var envelope map[string]any
	decoder := json.NewDecoder(bytes.NewReader(bytes.TrimSpace(raw)))
	decoder.UseNumber()
	if err := decoder.Decode(&envelope); err != nil || envelope == nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "search response was malformed", nil)
	}
	return raw, nil
}

// storyFeedHandler is the shared story-ID list Action: one Firebase GET on the
// feed document, answered with the verbatim ID list and, for the feeds
// upstream declares counted, the returned-ID count. Upstream wire names are
// kept verbatim, so the list is story_ids and the counter is count. The
// declared output requires an array of safe integers, so a body that is null,
// not an array, or carries non-safe-integer entries fails closed instead of
// synthesizing a contract-violating envelope.
func storyFeedHandler(feedPath string, includeCount bool) runner.RESTHandler {
	return func(
		ctx context.Context,
		client *runner.RESTClient,
		credentials runner.Credentials,
		input json.RawMessage,
	) (json.RawMessage, error) {
		parsed, err := decodeInput(input)
		if err != nil {
			return nil, err
		}
		// json.Number keeps the large Firebase IDs exact on the way back out.
		var storyIDs []json.Number
		raw, err := client.Call(ctx, credentials, runner.RESTRequest{
			Path:  feedPath,
			Query: readPrettyQuery(parsed),
		})
		if err != nil {
			return nil, err
		}
		if err := json.Unmarshal(bytes.TrimSpace(raw), &storyIDs); err != nil || storyIDs == nil {
			return nil, errMalformedFeed()
		}
		for _, id := range storyIDs {
			if _, ok := safeIntegerValue(id); !ok {
				return nil, errMalformedFeed()
			}
		}
		output := map[string]any{"story_ids": storyIDs}
		if includeCount {
			output["count"] = len(storyIDs)
		}
		return json.Marshal(output)
	}
}

// errMalformedFeed classifies one unusable story-feed body; a null or
// non-array feed is malformed, not an empty list.
func errMalformedFeed() error {
	message := "story feed response was malformed"
	return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
}

func handleGetMaxItemID(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	var maxItemID *json.Number
	if err := client.CallJSON(ctx, credentials, runner.RESTRequest{
		Path:  "/maxitem.json",
		Query: readPrettyQuery(parsed),
	}, &maxItemID); err != nil {
		return nil, err
	}
	// The declared output requires a safe integer, so a null or non-integer
	// counter fails closed instead of emitting max_item_id: null.
	if maxItemID == nil {
		return nil, errMalformedMaxItem()
	}
	if _, ok := safeIntegerValue(*maxItemID); !ok {
		return nil, errMalformedMaxItem()
	}
	return json.Marshal(map[string]any{"max_item_id": maxItemID})
}

// errMalformedMaxItem classifies one unusable max-item counter body.
func errMalformedMaxItem() error {
	message := "max item response was malformed"
	return runner.Errorf(runner.ErrorUpstreamFailure, message, nil)
}

func handleGetUpdates(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	// The updates object (items and profiles) is the Action output as-is.
	return client.Call(ctx, credentials, runner.RESTRequest{
		Path:  "/updates.json",
		Query: readPrettyQuery(parsed),
	})
}

func handleGetUser(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	username, err := requiredString(parsed["username"], "username")
	if err != nil {
		return nil, err
	}
	user, found, err := firebaseObject(ctx, client, credentials,
		userPath(username), readPrettyQuery(parsed))
	if err != nil {
		return nil, err
	}
	if !found {
		return json.RawMessage("null"), nil
	}
	// The declared summary requires username and karma, so a user object
	// without them fails closed instead of emitting a partial envelope; the
	// nullable about passes through, but only when it really is null-or-string
	// — a hostile mixed-type about fails closed like any other malformed user
	// rather than being copied against the declared schema.
	username, ok := user["id"].(string)
	if !ok || strings.TrimSpace(username) == "" || !safeIntegerField(user, "karma") {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "user response was malformed", nil)
	}
	if about, present := user["about"]; present {
		if _, isString := about.(string); !isString && about != nil {
			return nil, runner.Errorf(
				runner.ErrorUpstreamFailure, "user response was malformed", nil)
		}
	}
	summary := map[string]any{}
	copyField(summary, user, "id", "username")
	copyField(summary, user, "karma", "karma")
	copyField(summary, user, "about", "about")
	return json.Marshal(summary)
}

func handleGetUserByUsername(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	username, err := requiredString(parsed["username"], "username")
	if err != nil {
		return nil, err
	}
	// The raw nullable Firebase user object is the Action output.
	return client.Call(ctx, credentials, runner.RESTRequest{
		Path:  userPath(username),
		Query: readPrettyQuery(parsed),
	})
}

func handleSearchPosts(
	ctx context.Context,
	_ *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	queryText, err := requiredString(parsed["query"], "query")
	if err != nil {
		return nil, err
	}
	// The Algolia envelope is the Action output as-is.
	return algoliaEnvelope(ctx, credentials, runner.RESTRequest{
		Path:  "/search",
		Query: algoliaQuery(parsed, &queryText),
	})
}

// treeBuildOptions mirrors the upstream TreeBuildOptions truncation contract.
// budget is the AgentTeam-side addition: the aggregate per-invocation cap shared by
// every recursion level.
func decodeInput(input json.RawMessage) (map[string]any, error) {
	parsed := map[string]any{}
	if len(input) == 0 {
		return parsed, nil
	}
	decoder := json.NewDecoder(bytes.NewReader(input))
	decoder.UseNumber()
	if err := decoder.Decode(&parsed); err != nil {
		return nil, runner.Errorf(runner.ErrorValidation, "action input was not decodable", err)
	}
	return parsed, nil
}

func itemPath(itemID int64) string {
	return "/item/" + strconv.FormatInt(itemID, 10) + ".json"
}

func userPath(username string) string {
	return "/user/" + url.PathEscape(username) + ".json"
}

// readPrettyQuery mirrors upstream's readPrettyQuery: the Firebase print
// switch only fires on the exact "pretty" literal.
func readPrettyQuery(parsed map[string]any) url.Values {
	if print, ok := parsed["print"].(string); ok && print == "pretty" {
		return url.Values{"print": {"pretty"}}
	}
	return nil
}

// algoliaQuery builds the shared Algolia search query: page defaults to 0,
// tags join with commas when provided, and hitsPerPage mirrors upstream's
// size handling (absent size resolves to the 5 fallback first, then a
// non-positive size becomes 20).
func algoliaQuery(parsed map[string]any, queryText *string) url.Values {
	query := url.Values{}
	if queryText != nil {
		query.Set("query", *queryText)
	}
	query.Set("page", strconv.Itoa(optionalInteger(parsed["page"], 0)))
	query.Set("hitsPerPage", strconv.Itoa(toHitsPerPage(optionalInteger(parsed["size"], 5))))
	if tags, ok := readOptionalTags(parsed["tags"]); ok {
		query.Set("tags", tags)
	}
	return query
}

// readOptionalTags keeps the non-empty string tags and joins them with
// commas; absent, empty, or tag-free arrays send no tags parameter.
func readOptionalTags(value any) (string, bool) {
	entries, ok := value.([]any)
	if !ok || len(entries) == 0 {
		return "", false
	}
	tags := make([]string, 0, len(entries))
	for _, entry := range entries {
		if tag, ok := entry.(string); ok && tag != "" {
			tags = append(tags, tag)
		}
	}
	if len(tags) == 0 {
		return "", false
	}
	return strings.Join(tags, ","), true
}

// toHitsPerPage mirrors upstream: a non-positive size selects Algolia's 20
// results page instead of an invalid value.
func toHitsPerPage(size int) int {
	if size <= 0 {
		return 20
	}
	return size
}

// maybeTruncateText caps text at maxTruncatedTextLength UTF-16 code units —
// the unit the baseline's JavaScript slice(0, 500) counts. A supplementary
// character (anything above the BMP, an emoji for example) costs two units,
// one per surrogate, and a pair is never split, so 500 units keep 250 emojis
// where a rune count would keep 500.
func maybeTruncateText(value string, truncate bool) string {
	if !truncate {
		return value
	}
	units := 0
	for index, char := range value {
		width := utf16CodeUnits(char)
		if units+width > maxTruncatedTextLength {
			return value[:index]
		}
		units += width
	}
	return value
}

// utf16CodeUnits is one rune's width in UTF-16 code units; supplementary
// characters encode as a surrogate pair.
func utf16CodeUnits(char rune) int {
	if char > 0xFFFF {
		return 2
	}
	return 1
}

// safeIntegerValue parses one decoded JSON number as an integer within the
// JavaScript safe range the schemas declare; floats and out-of-range values do
// not qualify.
func safeIntegerValue(number json.Number) (int64, bool) {
	parsed, err := strconv.ParseInt(number.String(), 10, 64)
	if err != nil || parsed < -maxSafeInteger || parsed > maxSafeInteger {
		return 0, false
	}
	return parsed, true
}

// safeIntegerField reports whether one field holds a safe integer.
func safeIntegerField(item map[string]any, key string) bool {
	number, ok := item[key].(json.Number)
	if !ok {
		return false
	}
	_, ok = safeIntegerValue(number)
	return ok
}

// validTreeItem reports whether one fetched item can be emitted as a tree
// node: the declared node contract requires a safe-integral id and one of the
// declared item types on every node, so items lacking either are skipped like
// missing ones — the fetch and bytes they cost are still charged.
func validTreeItem(item map[string]any) bool {
	number, ok := item["id"].(json.Number)
	if !ok {
		return false
	}
	if _, ok := safeIntegerValue(number); !ok {
		return false
	}
	for _, name := range firebaseItemTypes {
		if itemType(item) == name {
			return true
		}
	}
	return false
}

// itemIDStringRegexp is the compiled form of itemIDStringPattern, the single
// accepted item-ID string spelling shared with the input schema.
var itemIDStringRegexp = regexp.MustCompile(itemIDStringPattern)

// positiveInteger mirrors upstream's positiveInteger cast, narrowed to the one
// set the input schemas declare: a JSON integer above zero, or a numeric
// string in the canonical itemIDStringPattern spelling (one to sixteen
// digits, no leading zero), both bounded to the safe integer range.
func positiveInteger(value any, field string) (int64, error) {
	if number, ok := numericValue(value); ok &&
		number == math.Trunc(number) && number > 0 && number <= maxSafeInteger {
		return int64(number), nil
	}
	return 0, runner.Errorf(
		runner.ErrorValidation, field+" must be a positive integer", nil)
}

// numericValue coerces a JSON number or a numeric string. The string branch
// accepts only the canonical itemIDStringPattern spelling — the same set the
// schema's string variant admits — so "0", "01", and any 17-plus-digit or
// non-digit string is rejected identically by schema and handler.
func numericValue(value any) (float64, bool) {
	switch typed := value.(type) {
	case json.Number:
		parsed, err := strconv.ParseFloat(typed.String(), 64)
		return parsed, err == nil
	case string:
		if !itemIDStringRegexp.MatchString(typed) {
			return 0, false
		}
		// The pattern bounds the length to 16 digits, so the parse cannot
		// overflow; the safe-integer ceiling stays positiveInteger's job.
		parsed, err := strconv.ParseInt(typed, 10, 64)
		if err != nil {
			return 0, false
		}
		return float64(parsed), true
	default:
		return 0, false
	}
}

// optionalInteger mirrors upstream's optionalInteger with the nullish
// fallback: only a real JSON integer (zero included) overrides the fallback.
func optionalInteger(value any, fallback int) int {
	if number, ok := value.(json.Number); ok {
		if parsed, err := strconv.ParseInt(number.String(), 10, 64); err == nil {
			return int(parsed)
		}
	}
	return fallback
}

// optionalBoolean mirrors upstream's optionalBoolean with the nullish
// fallback: only a real JSON boolean overrides the fallback.
func optionalBoolean(value any, fallback bool) bool {
	if typed, ok := value.(bool); ok {
		return typed
	}
	return fallback
}

// requiredString mirrors upstream's requiredString cast: a trimmed non-empty
// string.
func requiredString(value any, field string) (string, error) {
	if typed, ok := value.(string); ok {
		if trimmed := strings.TrimSpace(typed); trimmed != "" {
			return trimmed, nil
		}
	}
	return "", runner.Errorf(runner.ErrorValidation, field+" is required", nil)
}

// integerIDs filters an item's kid or part IDs down to safe integers,
// mirroring upstream's Number.isInteger filter; ids beyond the safe range are
// dropped because neither an assembled node nor the options output could
// declare them.
func integerIDs(value any) []int64 {
	entries, ok := value.([]any)
	if !ok {
		return nil
	}
	ids := make([]int64, 0, len(entries))
	for _, entry := range entries {
		if number, ok := entry.(json.Number); ok {
			if id, ok := safeIntegerValue(number); ok {
				ids = append(ids, id)
			}
		}
	}
	return ids
}

func itemType(item map[string]any) string {
	name, _ := item["type"].(string)
	return name
}

func isStoryOrPoll(item map[string]any) bool {
	name := itemType(item)
	return name == "story" || name == "poll"
}

// integerField reads one integer-valued item field as a safe integer — the
// only shape the declared ID outputs admit — so callers following the value
// (the parent walk) can never chase an out-of-range id.
func integerField(item map[string]any, key string) (int64, bool) {
	number, ok := item[key].(json.Number)
	if !ok {
		return 0, false
	}
	return safeIntegerValue(number)
}

// copyField copies one present source field under the output key; absent
// fields stay absent, mirroring upstream's undefined-dropping object builds.
func copyField(target, source map[string]any, key, outputKey string) {
	if value, present := source[key]; present {
		target[outputKey] = value
	}
}

// copyStringField copies one present source field only when it holds a
// string, so a hostile null or mixed-type value cannot be emitted against the
// declared string outputs.
func copyStringField(target, source map[string]any, key, outputKey string) {
	if value, ok := source[key].(string); ok {
		target[outputKey] = value
	}
}

// copySafeIntegerField copies one present source field only when it holds a
// safe integer, for the same reason.
func copySafeIntegerField(target, source map[string]any, key, outputKey string) {
	if number, ok := source[key].(json.Number); ok {
		if _, ok := safeIntegerValue(number); ok {
			target[outputKey] = number
		}
	}
}
