package hotnews

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"net/url"
	"strconv"
	"strings"

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

// The runtime executor binds exactly one origin, so the package keeps the one
// client for the public newsnow API. The origin's access policy rejects the
// shared provider User-Agent with 403 and only admits Mozilla-prefixed ones,
// so the client carries the conventional bot-style declaration instead of the
// browser mask the upstream TrendRadar fetcher sends. The origin is a public
// constant, so construction can only fail on programmer error and startup
// fails closed.
var client = mustRESTClient(BaseURL)

// hotnewsUserAgent names this Extension behind the Mozilla compatibility
// prefix the origin's access policy requires; it never carries credentials or
// caller identity.
const hotnewsUserAgent = "Mozilla/5.0 (compatible; agentteam-hotnews-extension/1.0)"

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

func handlersWithCache(cache *feedCache) map[string]runner.RESTHandler {
	return map[string]runner.RESTHandler{
		ActionGetTrending: func(
			ctx context.Context,
			dispatched *runner.RESTClient,
			credentials runner.Credentials,
			input json.RawMessage,
		) (json.RawMessage, error) {
			return handleGetTrending(ctx, dispatched, credentials, input, cache)
		},
		ActionListPlatforms: handleListPlatforms,
	}
}

// handleGetTrending reads one platform's feed and returns the newsnow cache
// envelope verbatim. The wire request mirrors the upstream TrendRadar
// fetcher: GET /s?id=<platform>&latest with no credential.
func handleGetTrending(
	ctx context.Context,
	dispatched *runner.RESTClient,
	credentials runner.Credentials,
	input json.RawMessage,
	cache *feedCache,
) (json.RawMessage, error) {
	parsed, err := decodeInput(input)
	if err != nil {
		return nil, err
	}
	id, ok := parsed["platform_id"].(string)
	if !ok {
		return nil, runner.Errorf(
			runner.ErrorValidation, "platform_id is required", nil)
	}
	catalog, cataloged := platformByID(id)
	if !cataloged {
		return nil, runner.Errorf(
			runner.ErrorValidation, "platform_id is not a cataloged platform", nil)
	}
	return cache.get(ctx, dispatched, credentials, catalog)
}

// handleListPlatforms serves the static platform directory; no upstream call.
func handleListPlatforms(
	_ context.Context,
	_ *runner.RESTClient,
	_ runner.Credentials,
	input json.RawMessage,
) (json.RawMessage, error) {
	if _, err := decodeInput(input); err != nil {
		return nil, err
	}
	entries := make([]map[string]string, 0, len(platforms))
	for _, entry := range platforms {
		entries = append(entries, map[string]string{
			"id":   entry.id,
			"name": entry.name,
		})
	}
	encoded, err := json.Marshal(map[string]any{"platforms": entries})
	if err != nil {
		return nil, runner.Errorf(
			runner.ErrorUpstreamFailure, "platform directory was not encodable", err)
	}
	return encoded, nil
}

// decodeInput parses the validated input envelope into a shallow object.
func decodeInput(input json.RawMessage) (map[string]any, error) {
	trimmed := bytes.TrimSpace(input)
	if len(trimmed) == 0 {
		return map[string]any{}, nil
	}
	var parsed map[string]any
	decoder := json.NewDecoder(bytes.NewReader(trimmed))
	decoder.UseNumber()
	if err := decoder.Decode(&parsed); err != nil || parsed == nil {
		return nil, runner.Errorf(
			runner.ErrorValidation, "action input was malformed", nil)
	}
	return parsed, nil
}

// passthroughFeed fail-closes the 2xx body onto the declared output contract
// before returning it verbatim: the body must be a JSON object carrying a
// success/cache status, the requested platform ID, a non-negative safe-integer
// updatedTime, and a non-null items array of declared-shape objects. Non-empty
// item links must also pass the requested platform's https/domain guard. A
// body violating any of those is a malformed feed, not a partial result.
func passthroughFeed(raw json.RawMessage, catalog platform) (json.RawMessage, error) {
	malformed := func() error {
		return runner.Errorf(
			runner.ErrorUpstreamFailure, "platform feed was malformed", nil)
	}
	trimmed := bytes.TrimSpace(raw)
	if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
		return nil, malformed()
	}
	if !hasUniqueJSONKeys(trimmed) {
		return nil, malformed()
	}
	var envelope map[string]json.RawMessage
	if err := json.Unmarshal(trimmed, &envelope); err != nil || envelope == nil {
		return nil, malformed()
	}
	for _, member := range []string{"status", "id", "updatedTime", "items"} {
		if _, present := envelope[member]; !present {
			return nil, malformed()
		}
	}
	var status string
	if err := json.Unmarshal(envelope["status"], &status); err != nil ||
		(status != "success" && status != "cache") {
		return nil, malformed()
	}
	var responseID string
	if err := json.Unmarshal(envelope["id"], &responseID); err != nil ||
		responseID != catalog.id {
		return nil, malformed()
	}
	timestamp, isInteger := exactJSONInteger(envelope["updatedTime"])
	if !isInteger || !timestamp.isNonNegativeSafeInteger() {
		return nil, malformed()
	}
	var items []json.RawMessage
	// Unmarshaling JSON null into a slice yields a nil slice without error,
	// so the null feed is rejected explicitly — the declared contract is an
	// items array, never a null.
	if bytes.Equal(bytes.TrimSpace(envelope["items"]), []byte("null")) ||
		json.Unmarshal(envelope["items"], &items) != nil {
		return nil, malformed()
	}
	for _, item := range items {
		if err := validateFeedItem(item, catalog.expectedDomain); err != nil {
			return nil, err
		}
	}
	return trimmed, nil
}

// validateFeedItem enforces the open per-item contract. Unknown members pass
// through, while every present declared member must match its schema type and
// each non-empty link must be an absolute, userinfo-free https URL on the
// cataloged platform domain or a true subdomain.
func validateFeedItem(item json.RawMessage, expectedDomain string) error {
	malformed := func() error {
		return runner.Errorf(
			runner.ErrorUpstreamFailure, "platform feed was malformed", nil)
	}
	var members map[string]json.RawMessage
	if err := json.Unmarshal(item, &members); err != nil || members == nil {
		return malformed()
	}
	if rawID, present := members["id"]; present &&
		!bytes.Equal(bytes.TrimSpace(rawID), []byte("null")) {
		var stringID string
		if json.Unmarshal(rawID, &stringID) != nil {
			if _, isInteger := exactJSONInteger(rawID); !isInteger {
				return malformed()
			}
		}
	}
	if rawTitle, present := members["title"]; present &&
		!bytes.Equal(bytes.TrimSpace(rawTitle), []byte("null")) {
		var title string
		if json.Unmarshal(rawTitle, &title) != nil {
			return malformed()
		}
	}
	for _, member := range []string{"url", "mobileUrl"} {
		rawValue, present := members[member]
		if !present || bytes.Equal(bytes.TrimSpace(rawValue), []byte("null")) {
			continue
		}
		var value string
		if err := json.Unmarshal(rawValue, &value); err != nil {
			return malformed()
		}
		if value == "" {
			continue
		}
		if !validFeedURL(value, expectedDomain) {
			return malformed()
		}
	}
	if rawExtra, present := members["extra"]; present &&
		!bytes.Equal(bytes.TrimSpace(rawExtra), []byte("null")) {
		var extra map[string]json.RawMessage
		if err := json.Unmarshal(rawExtra, &extra); err != nil || extra == nil {
			return malformed()
		}
	}
	return nil
}

// decimalInteger is the compact information needed after an exact integer
// classification. digits has no leading or trailing zeroes; decimalZeroes is
// exact while it can affect the safe-integer comparison and may be saturated
// upward once the value is unambiguously too large. Empty digits means zero.
type decimalInteger struct {
	negative      bool
	digits        []byte
	decimalZeroes int
}

// exactJSONInteger implements JSON Schema's semantic integer type in O(len)
// time and space. It parses the JSON-number grammar directly, then cancels
// fractional places against significand trailing zeroes and the exponent.
// Large exponents stay a bounded scale marker rather than expanding a big
// integer, so arbitrary-magnitude item IDs remain cheap to validate.
func exactJSONInteger(raw json.RawMessage) (decimalInteger, bool) {
	token := trimJSONWhitespace(raw)
	if len(token) == 0 {
		return decimalInteger{}, false
	}
	index := 0
	negative := false
	if token[index] == '-' {
		negative = true
		index++
		if index == len(token) {
			return decimalInteger{}, false
		}
	}

	coefficient := make([]byte, 0, len(token))
	switch {
	case token[index] == '0':
		coefficient = append(coefficient, token[index])
		index++
		if index < len(token) && isASCIIDigit(token[index]) {
			return decimalInteger{}, false
		}
	case token[index] >= '1' && token[index] <= '9':
		for index < len(token) && isASCIIDigit(token[index]) {
			coefficient = append(coefficient, token[index])
			index++
		}
	default:
		return decimalInteger{}, false
	}

	fractionDigits := 0
	if index < len(token) && token[index] == '.' {
		index++
		fractionStart := index
		for index < len(token) && isASCIIDigit(token[index]) {
			coefficient = append(coefficient, token[index])
			fractionDigits++
			index++
		}
		if index == fractionStart {
			return decimalInteger{}, false
		}
	}

	exponent := 0
	if index < len(token) && (token[index] == 'e' || token[index] == 'E') {
		index++
		exponentNegative := false
		if index < len(token) && (token[index] == '+' || token[index] == '-') {
			exponentNegative = token[index] == '-'
			index++
		}
		exponentStart := index
		exponentLimit := len(token) + 32
		for index < len(token) && isASCIIDigit(token[index]) {
			digit := int(token[index] - '0')
			if exponent < exponentLimit {
				if exponent > (exponentLimit-digit)/10 {
					exponent = exponentLimit
				} else {
					exponent = exponent*10 + digit
				}
			}
			index++
		}
		if index == exponentStart {
			return decimalInteger{}, false
		}
		if exponentNegative {
			exponent = -exponent
		}
	}
	if index != len(token) {
		return decimalInteger{}, false
	}

	firstNonZero := 0
	for firstNonZero < len(coefficient) &&
		coefficient[firstNonZero] == '0' {
		firstNonZero++
	}
	if firstNonZero == len(coefficient) {
		return decimalInteger{}, true
	}
	significantEnd := len(coefficient)
	for significantEnd > firstNonZero &&
		coefficient[significantEnd-1] == '0' {
		significantEnd--
	}
	decimalZeroes := exponent - fractionDigits +
		(len(coefficient) - significantEnd)
	if decimalZeroes < 0 {
		return decimalInteger{}, false
	}
	return decimalInteger{
		negative:      negative,
		digits:        coefficient[firstNonZero:significantEnd],
		decimalZeroes: decimalZeroes,
	}, true
}

// isNonNegativeSafeInteger compares the compact decimal representation
// directly with Number.MAX_SAFE_INTEGER, without exponent expansion.
func (integer decimalInteger) isNonNegativeSafeInteger() bool {
	if len(integer.digits) == 0 {
		return true
	}
	maximum := strconv.FormatInt(maxSafeInteger, 10)
	if integer.negative ||
		len(integer.digits) > len(maximum) ||
		integer.decimalZeroes >
			len(maximum)-len(integer.digits) {
		return false
	}
	totalDigits := len(integer.digits) + integer.decimalZeroes
	if totalDigits < len(maximum) {
		return true
	}
	for index := 0; index < len(maximum); index++ {
		digit := byte('0')
		if index < len(integer.digits) {
			digit = integer.digits[index]
		}
		if digit != maximum[index] {
			return digit < maximum[index]
		}
	}
	return true
}

func trimJSONWhitespace(raw []byte) []byte {
	start := 0
	for start < len(raw) && isJSONWhitespace(raw[start]) {
		start++
	}
	end := len(raw)
	for end > start && isJSONWhitespace(raw[end-1]) {
		end--
	}
	return raw[start:end]
}

func isJSONWhitespace(character byte) bool {
	return character == ' ' || character == '\t' ||
		character == '\r' || character == '\n'
}

func isASCIIDigit(character byte) bool {
	return character >= '0' && character <= '9'
}

// hasUniqueJSONKeys stream-parses the complete feed before any map decode.
// Object keys are compared after JSON unescaping at every nesting level, so
// duplicate-key first/last parser differentials cannot survive passthrough.
func hasUniqueJSONKeys(raw json.RawMessage) bool {
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.UseNumber()
	if !consumeUniqueJSONValue(decoder) {
		return false
	}
	_, err := decoder.Token()
	return err == io.EOF
}

func consumeUniqueJSONValue(decoder *json.Decoder) bool {
	token, err := decoder.Token()
	if err != nil {
		return false
	}
	delimiter, isDelimiter := token.(json.Delim)
	if !isDelimiter {
		return true
	}
	switch delimiter {
	case '{':
		seen := make(map[string]struct{})
		for decoder.More() {
			keyToken, err := decoder.Token()
			if err != nil {
				return false
			}
			key, ok := keyToken.(string)
			if !ok {
				return false
			}
			if _, duplicate := seen[key]; duplicate {
				return false
			}
			seen[key] = struct{}{}
			if !consumeUniqueJSONValue(decoder) {
				return false
			}
		}
		closing, err := decoder.Token()
		return err == nil && closing == json.Delim('}')
	case '[':
		for decoder.More() {
			if !consumeUniqueJSONValue(decoder) {
				return false
			}
		}
		closing, err := decoder.Token()
		return err == nil && closing == json.Delim(']')
	default:
		return false
	}
}

// validFeedURL applies the catalog's link boundary. Trimming exactly one
// trailing DNS dot preserves the accepted canonical form while still
// rejecting malformed multiple-dot variants and suffix lookalikes.
func validFeedURL(value, expectedDomain string) bool {
	parsed, err := url.Parse(value)
	if err != nil || !parsed.IsAbs() ||
		!strings.EqualFold(parsed.Scheme, "https") ||
		parsed.Host == "" || parsed.User != nil {
		return false
	}
	hostname := strings.TrimSuffix(
		strings.ToLower(parsed.Hostname()), ".")
	if hostname == "" || len(hostname) > 253 || !validURLPort(parsed) {
		return false
	}
	if hostname == expectedDomain {
		return true
	}
	suffix := "." + expectedDomain
	if !strings.HasSuffix(hostname, suffix) {
		return false
	}
	return validDNSLabels(strings.TrimSuffix(hostname, suffix))
}

func validURLPort(parsed *url.URL) bool {
	port := parsed.Port()
	if port == "" {
		return !strings.HasSuffix(parsed.Host, ":")
	}
	numeric, err := strconv.ParseUint(port, 10, 16)
	return err == nil && numeric >= 1
}

// validDNSLabels verifies the subdomain prefix as one or more conventional
// ASCII DNS labels. Empty labels, non-ASCII bytes, edge hyphens, and labels
// longer than 63 bytes are rejected.
func validDNSLabels(prefix string) bool {
	if prefix == "" {
		return false
	}
	for _, label := range strings.Split(prefix, ".") {
		if len(label) == 0 || len(label) > 63 ||
			label[0] == '-' || label[len(label)-1] == '-' {
			return false
		}
		for index := 0; index < len(label); index++ {
			character := label[index]
			if (character < 'a' || character > 'z') &&
				(character < '0' || character > '9') &&
				character != '-' {
				return false
			}
		}
	}
	return true
}
