package hackernews

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

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

func errTreeBudgetExceeded() error {
	return runner.Errorf(
		runner.ErrorUpstreamFailure, "comment tree assembly exceeded its invocation budget", nil)
}

// The runtime executor binds exactly one origin, so the package keeps one
// client per public API: the Firebase client is the executor's dispatcher
// client, and the Algolia-backed search handlers read the Algolia client
// directly, ignoring the dispatched one. Both origins are public constants,
// so construction can only fail on programmer error and startup fails closed.
var (
	firebaseClient = mustRESTClient(FirebaseBaseURL)
	algoliaClient  = mustRESTClient(AlgoliaBaseURL)
)

// mustRESTClient builds the fixed-origin provider client.
func mustRESTClient(baseURL string) *runner.RESTClient {
	client, err := runner.NewRESTClient(runner.RESTOptions{BaseURL: baseURL})
	if err != nil {
		panic("hackernews: build REST client for " + baseURL + ": " + err.Error())
	}
	return client
}

// Handlers returns the Action dispatch table. The keys must exactly match the
// Definition catalog; bootstrap verifies the coverage.
func Handlers() map[string]runner.RESTHandler {
	return map[string]runner.RESTHandler{
		ActionGetAskStories:     storyFeedHandler("/askstories.json", false),
		ActionGetBestStories:    storyFeedHandler("/beststories.json", true),
		ActionGetItem:           handleGetItem,
		ActionGetItemWithID:     handleGetItemWithID,
		ActionGetJobStories:     storyFeedHandler("/jobstories.json", false),
		ActionGetLatestPosts:    handleGetLatestPosts,
		ActionGetMaxItemID:      handleGetMaxItemID,
		ActionGetNewStories:     storyFeedHandler("/newstories.json", true),
		ActionGetShowStories:    storyFeedHandler("/showstories.json", false),
		ActionGetTopStories:     storyFeedHandler("/topstories.json", true),
		ActionGetUpdates:        handleGetUpdates,
		ActionGetUser:           handleGetUser,
		ActionGetUserByUsername: handleGetUserByUsername,
		ActionSearchPosts:       handleSearchPosts,
	}
}

func handleGetItem(
	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
	}
	itemID, err := positiveInteger(parsed["id"], "id")
	if err != nil {
		return nil, err
	}
	// Firebase answers 200 with null for deleted or unknown IDs; the raw
	// nullable item is the Action output.
	return client.Call(ctx, credentials, runner.RESTRequest{
		Path:  itemPath(itemID),
		Query: readPrettyQuery(parsed),
	})
}

func handleGetItemWithID(
	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
	}
	itemID, err := positiveInteger(parsed["item_id"], "item_id")
	if err != nil {
		return nil, err
	}
	// The wall-clock budget bounds the whole invocation — including any
	// single in-flight request — on top of the walk-agentteam deadline checks. It
	// derives from the caller's context, so deadline provenance stays honest:
	// only an expiry of the tree's own budget is reported as the budget error,
	// while an earlier caller deadline or cancellation and the REST client's
	// per-request timeout keep their original errors and classifications.
	callerCtx := ctx
	treeCtx, cancel := context.WithTimeout(ctx, maxTreeDuration)
	defer cancel()
	envelope, err := assembleItemTree(treeCtx, client, credentials, itemID, parsed, newTreeBudget())
	if err != nil {
		if treeBudgetExpired(treeCtx, callerCtx) {
			return nil, errTreeBudgetExceeded()
		}
		return nil, err
	}
	// The last window: assembly can return just as the budget expires, so the
	// tree's own deadline is checked once more before the envelope marshals.
	if treeBudgetExpired(treeCtx, callerCtx) {
		return nil, errTreeBudgetExceeded()
	}
	return marshalTreeOutput(envelope)
}

// treeBudgetExpired reports whether the tree's own wall-clock budget expired:
// the derived tree context missed its deadline while the caller's context is
// still live. A caller deadline that fired first surfaces in treeCtx as
// DeadlineExceeded too (the derived context inherits the parent's cancel
// reason), so the caller check is what keeps the two apart. Everything else —
// caller cancellation (context.Canceled) and the REST client's per-request
// timeout, whose wrapped context.DeadlineExceeded never expires treeCtx —
// keeps its original error, already classified by the runtime client.
func treeBudgetExpired(treeCtx, callerCtx context.Context) bool {
	return treeCtx.Err() == context.DeadlineExceeded && callerCtx.Err() == nil
}

// assembleItemTree runs one budgeted tree assembly and returns the result
// envelope; the handler wraps it with the wall-clock context and the output
// size check.
func assembleItemTree(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	itemID int64,
	parsed map[string]any,
	budget *treeBudget,
) (map[string]any, error) {
	// The assembly budget spans the whole invocation: the root fetch, the
	// story-resolution walk, and every recursive child fetch and node.
	root, found, err := fetchFirebaseItem(ctx, client, credentials, itemID, budget)
	if err != nil {
		return nil, err
	}
	// The output contract requires every emitted node to carry an id and a
	// declared type, so a root that cannot satisfy it is reported missing
	// rather than emitted as a schema-violating item.
	if !found || !validTreeItem(root) {
		return map[string]any{
			"found":            false,
			"error_message":    "item not found: " + strconv.FormatInt(itemID, 10),
			"budget_exhausted": budget.exhausted,
		}, nil
	}
	storyID, err := resolveStoryID(ctx, client, credentials, root, budget)
	if err != nil {
		return nil, err
	}
	item, err := buildTreeItem(ctx, client, credentials, root, treeBuildOptions{
		storyID:      storyID,
		maxDepth:     optionalInteger(parsed["max_depth"], 2),
		maxChildren:  optionalInteger(parsed["max_children"], 10),
		truncateText: optionalBoolean(parsed["truncate_text"], true),
	}, budget)
	if err != nil {
		return nil, err
	}
	return map[string]any{
		"found":            true,
		"item":             item,
		"budget_exhausted": budget.exhausted,
	}, nil
}

// marshalTreeOutput renders one get-item-with-id envelope inside the output
// size budget: a marshaled result past the cap fails the invocation instead
// of returning an oversized payload.
func marshalTreeOutput(envelope map[string]any) (json.RawMessage, error) {
	output, err := json.Marshal(envelope)
	if err != nil {
		return nil, err
	}
	if len(output) > maxTreeOutputBytes {
		return nil, errTreeBudgetExceeded()
	}
	return output, nil
}

type treeBuildOptions struct {
	storyID      *int64
	maxDepth     int
	maxChildren  int
	truncateText bool
}

// treeBudget is the aggregate assembly budget for one get-item-with-id
// invocation: hard caps on total item fetches, total assembled nodes,
// cumulative upstream bytes, and wall clock. All caps apply regardless of the
// caller's max_depth/max_children parameters — parameters can only narrow the
// expansion below the caps, never widen past them. The two count caps
// truncate honestly: the first boundary the expansion hits is reported as
// budget_exhausted on the Action output. The cumulative caps (bytes and
// deadline) fail the invocation outright via errTreeBudgetExceeded.
type treeBudget struct {
	fetchesRemaining int
	nodesRemaining   int
	bytesRemaining   int64
	deadline         time.Time
	exhausted        bool
}

// newTreeBudget opens one invocation's budget at the package caps.
func newTreeBudget() *treeBudget {
	return &treeBudget{
		fetchesRemaining: maxTreeFetches,
		nodesRemaining:   maxTreeNodes,
		bytesRemaining:   int64(maxTreeBytes),
		deadline:         time.Now().Add(maxTreeDuration),
	}
}

// chargeFetch spends one item-fetch slot, reporting false once the allowance
// is gone; a refusal flags the invocation as budget-exhausted so the output
// can mark the cut honestly.
func (budget *treeBudget) chargeFetch() bool {
	if budget.fetchesRemaining <= 0 {
		budget.exhausted = true
		return false
	}
	budget.fetchesRemaining--
	return true
}

// chargeDeadline fails closed once the invocation's wall-clock budget is
// spent; the walk checks it before every fetch.
func (budget *treeBudget) chargeDeadline() error {
	if time.Now().After(budget.deadline) {
		return errTreeBudgetExceeded()
	}
	return nil
}

// chargeBytes spends n raw bytes of the cumulative upstream-body allowance —
// the wire size actually read, not the trimmed payload — and fails closed
// once the walk has read past the cap.
func (budget *treeBudget) chargeBytes(n int64) error {
	budget.bytesRemaining -= n
	if budget.bytesRemaining < 0 {
		return errTreeBudgetExceeded()
	}
	return nil
}

// childFits reports whether one more child — one item fetch plus the node it
// assembles into — still fits the budget, flagging exhaustion when it does
// not.
func (budget *treeBudget) childFits() bool {
	if budget.fetchesRemaining <= 0 || budget.nodesRemaining <= 0 {
		budget.exhausted = true
		return false
	}
	return true
}

// spendNode consumes one assembled-node slot. Each node is spent exactly
// once, by the buildTreeItem call that assembles it; the parent's childFits
// gate reserves the slot before the child is fetched.
func (budget *treeBudget) spendNode() {
	budget.nodesRemaining--
}

// buildTreeItem assembles one bounded nested comment-tree node. Children are
// fetched sequentially in upstream order; upstream fans each level out
// concurrently, but the assembled tree and its truncation flags are identical.
// Expansion stops at the first budget boundary: the node keeps the children
// already fetched, reports children_truncated, and the invocation reports
// budget_exhausted. Copied passthrough fields are type-guarded so a hostile
// null or mixed-type value is dropped rather than emitted against the declared
// node contract.
func buildTreeItem(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	item map[string]any,
	options treeBuildOptions,
	budget *treeBudget,
) (map[string]any, error) {
	budget.spendNode()
	childIDs := integerIDs(item["kids"])
	storyID := options.storyID
	if storyID == nil && isStoryOrPoll(item) {
		if number, ok := item["id"].(json.Number); ok {
			if id, ok := safeIntegerValue(number); ok {
				storyID = &id
			}
		}
	}
	node := map[string]any{}
	copyField(node, item, "id", "id")
	copyField(node, item, "type", "type")
	copyStringField(node, item, "url", "url")
	if text, ok := item["text"].(string); ok && text != "" {
		node["text"] = maybeTruncateText(text, options.truncateText)
	}
	copyStringField(node, item, "title", "title")
	copyStringField(node, item, "by", "author")
	copySafeIntegerField(node, item, "score", "points")
	if entries, ok := item["parts"].([]any); ok {
		node["options"] = integerIDs(entries)
	}
	if storyID != nil {
		node["story_id"] = *storyID
	}
	copySafeIntegerField(node, item, "parent", "parent_id")
	if number, ok := item["time"].(json.Number); ok {
		if seconds, ok := safeIntegerValue(number); ok {
			node["created_at"] = time.Unix(seconds, 0).UTC().Format(rfc3339Millis)
			node["created_at_i"] = number
		}
	}

	if len(childIDs) == 0 {
		return node, nil
	}
	if options.maxDepth <= 0 {
		node["children"] = []map[string]any{}
		node["children_shown"] = 0
		node["max_depth_reached"] = true
		node["children_truncated"] = false
		node["total_children_count"] = len(childIDs)
		return node, nil
	}

	limit := options.maxChildren
	if limit < 0 {
		limit = 0
	}
	if limit > len(childIDs) {
		limit = len(childIDs)
	}
	limitedIDs := childIDs[:limit]
	children := make([]map[string]any, 0, len(limitedIDs))
	budgetStopped := false
	for _, childID := range limitedIDs {
		// The budget gates every additional child, so the root's own kid list
		// is bounded too: once either allowance is spent, expansion stops at
		// this boundary and the node reports the cut.
		if !budget.childFits() {
			budgetStopped = true
			break
		}
		child, found, err := fetchFirebaseItem(ctx, client, credentials, childID, budget)
		if err != nil {
			return nil, err
		}
		// A kid that cannot satisfy the node contract — missing, not an
		// object, or without a safe-integral id and declared type — is skipped
		// without charging a node, exactly like a non-integer kid ID; the
		// fetch and bytes it cost are still charged.
		if !found || !validTreeItem(child) {
			continue
		}
		childNode, err := buildTreeItem(ctx, client, credentials, child, treeBuildOptions{
			storyID:      storyID,
			maxDepth:     options.maxDepth - 1,
			maxChildren:  options.maxChildren,
			truncateText: options.truncateText,
		}, budget)
		if err != nil {
			return nil, err
		}
		children = append(children, childNode)
	}
	node["children"] = children
	node["children_shown"] = len(children)
	node["max_depth_reached"] = false
	node["children_truncated"] = len(childIDs) > len(limitedIDs) || budgetStopped
	node["total_children_count"] = len(childIDs)
	return node, nil
}

// resolveStoryID returns the story or poll owning the item: the item itself
// for stories and polls, otherwise the first story or poll found walking the
// comment parents, bounded to 32 hops like upstream. Every hop is charged to
// the invocation budget, so a spent budget simply leaves the story
// unresolved. Only safe-integer parent ids are followed — the range every
// declared ID (parent, story_id) admits — so a hostile origin cannot point
// the walk at out-of-range or malformed parents; such a walk simply stops
// with the story unresolved, never looping.
func resolveStoryID(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	item map[string]any,
	budget *treeBudget,
) (*int64, error) {
	if storyID, ok := storyIDOf(item); ok {
		return &storyID, nil
	}
	if itemType(item) != "comment" {
		return nil, nil
	}
	parentID, ok := integerField(item, "parent")
	if !ok {
		return nil, nil
	}
	for step := 0; step < maxStoryWalkSteps; step++ {
		parent, found, err := fetchFirebaseItem(ctx, client, credentials, parentID, budget)
		if err != nil {
			return nil, err
		}
		if !found {
			return nil, nil
		}
		if storyID, ok := storyIDOf(parent); ok {
			return &storyID, nil
		}
		if itemType(parent) != "comment" {
			return nil, nil
		}
		parentID, ok = integerField(parent, "parent")
		if !ok {
			return nil, nil
		}
	}
	return nil, nil
}

// storyIDOf reads one story-or-poll id as a safe integer, the only shape the
// declared story_id output admits.
func storyIDOf(item map[string]any) (int64, bool) {
	if !isStoryOrPoll(item) {
		return 0, false
	}
	number, ok := item["id"].(json.Number)
	if !ok {
		return 0, false
	}
	return safeIntegerValue(number)
}

// fetchFirebaseItem loads one Firebase item inside the invocation budget;
// found is false for the null body the API returns for deleted or unknown
// IDs, for a fetch the budget refuses, and — matching how the walk already
// skips non-integer kid IDs — for a body that is not a JSON object. The
// wall-clock and cumulative-byte budgets are charged here too, failing the
// invocation closed when either is breached. The byte budget is charged on
// the RAW response size CallWithMetrics reports (before any trimming) and on
// every outcome — the bytes left the origin whether the fetch then succeeded,
// failed, or decoded to nothing — so a hostile origin cannot pad whitespace
// around a small JSON to read past the cap uncharged.
func fetchFirebaseItem(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	itemID int64,
	budget *treeBudget,
) (map[string]any, bool, error) {
	if err := budget.chargeDeadline(); err != nil {
		return nil, false, err
	}
	if !budget.chargeFetch() {
		return nil, false, nil
	}
	raw, bytesRead, err := client.CallWithMetrics(
		ctx, credentials, runner.RESTRequest{Path: itemPath(itemID)})
	if chargeErr := budget.chargeBytes(bytesRead); chargeErr != nil {
		return nil, false, chargeErr
	}
	if err != nil {
		return nil, false, err
	}
	object, found, _ := decodeFirebaseBody(raw)
	return object, found, nil
}

// firebaseObject fetches and decodes one Firebase JSON object; found is false
// for empty or null bodies, and a body that is valid JSON but not an object
// is a classified upstream failure. Numbers decode as json.Number so large
// IDs round-trip exactly.
func firebaseObject(
	ctx context.Context,
	client *runner.RESTClient,
	credentials runner.Credentials,
	path string,
	query url.Values,
) (map[string]any, bool, error) {
	raw, err := client.Call(ctx, credentials, runner.RESTRequest{Path: path, Query: query})
	if err != nil {
		return nil, false, err
	}
	object, found, notObject := decodeFirebaseBody(raw)
	if notObject {
		return nil, false, runner.Errorf(
			runner.ErrorUpstreamFailure, "provider response was not an object", nil)
	}
	return object, found, nil
}

// decodeFirebaseBody decodes one Firebase 2xx body into a number-preserving
// object. found is false for the null or empty body the API returns for
// missing resources; notObject is true when the body is valid JSON but not an
// object, which strict callers classify as an upstream failure while the tree
// walk treats it as one more missing item.
func decodeFirebaseBody(raw json.RawMessage) (object map[string]any, found bool, notObject bool) {
	trimmed := bytes.TrimSpace(raw)
	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
		return nil, false, false
	}
	decoder := json.NewDecoder(bytes.NewReader(trimmed))
	decoder.UseNumber()
	if err := decoder.Decode(&object); err != nil || object == nil {
		return nil, false, true
	}
	return object, true, false
}
