package connector_callout

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/url"
	"strings"
	"unicode"
)

// PaginatedReader is a stateful iterator over cursor-paginated responses.
// Obtain via Engine.Paginate; call Next in a loop until bool is false or
// err is non-nil.
type PaginatedReader struct {
	engine      *Engine
	req         CalloutRequest
	spec        PageSpec
	policy      RetryPolicy
	cursor      string
	started     bool
	done        bool
	seenCursors map[string]struct{}
}

// Paginate returns a PaginatedReader that walks cursor-based pages.
// The reader calls Do for each page; pagination stops when NextCursorJSONPath
// resolves to an empty/missing value in the response body. The reader stops
// with ErrInvalidResponse if the upstream returns a repeated cursor (defense
// against pagination loops and stable-cursor metadata errors).
//
// Paginate validates spec at entry and returns ErrInvalidPageSpec when
// NextCursorJSONPath or CursorRequestParam is empty (cursor pagination
// requires both: the path to extract the next cursor from the response body,
// and the query parameter to carry it to the next request).
// Each dotted segment of NextCursorJSONPath must be non-empty (no
// leading/trailing dots, no consecutive dots, no whitespace-only segments,
// no bracket-syntax array indices like "paging[0].next" — object keys only).
// Query-parameter names (CursorRequestParam, PageSizeRequestParam) must not
// contain internal whitespace; url.Values.Set("aft er", v) encodes to
// "aft+er=v" on the wire and the server never sees the configured name.
func (e *Engine) Paginate(ctx context.Context, req CalloutRequest, spec PageSpec, policy RetryPolicy) (*PaginatedReader, error) {
	// Normalize leading/trailing whitespace before validation so that padded
	// values (" paging.next ") are accepted and used trimmed downstream,
	// rather than passing the non-empty check with whitespace that then
	// produces incorrect JSON path lookups or query parameter names.
	spec.NextCursorJSONPath = strings.TrimSpace(spec.NextCursorJSONPath)
	spec.CursorRequestParam = strings.TrimSpace(spec.CursorRequestParam)
	spec.PageSizeRequestParam = strings.TrimSpace(spec.PageSizeRequestParam)
	if spec.NextCursorJSONPath == "" {
		return nil, fmt.Errorf("%w: NextCursorJSONPath required", ErrInvalidPageSpec)
	}
	if spec.CursorRequestParam == "" {
		return nil, fmt.Errorf("%w: CursorRequestParam required", ErrInvalidPageSpec)
	}
	if err := validateDottedJSONPath(spec.NextCursorJSONPath); err != nil {
		return nil, fmt.Errorf("%w: NextCursorJSONPath %s", ErrInvalidPageSpec, err)
	}
	if err := validateQueryParamName(spec.CursorRequestParam); err != nil {
		return nil, fmt.Errorf("%w: CursorRequestParam %s", ErrInvalidPageSpec, err)
	}
	if spec.PageSizeValue > 0 && spec.PageSizeRequestParam != "" {
		if err := validateQueryParamName(spec.PageSizeRequestParam); err != nil {
			return nil, fmt.Errorf("%w: PageSizeRequestParam %s", ErrInvalidPageSpec, err)
		}
	}
	return &PaginatedReader{
		engine: e,
		req:    req,
		spec:   spec,
		policy: policy,
	}, nil
}

// Next fetches the next page. Returns (resp, true, nil) while more pages exist,
// (resp, false, nil) on the last page, and ({}, false, err) on error.
func (pr *PaginatedReader) Next(ctx context.Context) (CalloutResponse, bool, error) {
	if pr.done {
		return CalloutResponse{}, false, nil
	}

	// Build query params with current cursor.
	reqCopy := pr.req
	params := make(url.Values, len(pr.req.QueryParams)+2)
	for k, vs := range pr.req.QueryParams {
		params[k] = append([]string(nil), vs...)
	}
	if pr.started && pr.cursor != "" {
		params.Set(pr.spec.CursorRequestParam, pr.cursor)
	}
	if pr.spec.PageSizeValue > 0 && pr.spec.PageSizeRequestParam != "" {
		params.Set(pr.spec.PageSizeRequestParam, fmt.Sprintf("%d", pr.spec.PageSizeValue))
	}
	reqCopy.QueryParams = params
	pr.started = true

	resp, err := pr.engine.Do(ctx, reqCopy, pr.policy)
	if err != nil {
		pr.done = true
		return resp, false, err
	}

	// Parse next cursor from JSON body.
	next, err := extractJSONPath(resp.Body, pr.spec.NextCursorJSONPath)
	if err != nil {
		pr.done = true
		return resp, false, fmt.Errorf("%w: parse cursor path %q: %v", ErrInvalidResponse, pr.spec.NextCursorJSONPath, err)
	}
	if next != "" {
		if pr.seenCursors == nil {
			pr.seenCursors = make(map[string]struct{}, 8)
		}
		if _, seen := pr.seenCursors[next]; seen {
			pr.done = true
			return resp, false, fmt.Errorf("%w: pagination cursor %q repeated — upstream did not advance", ErrInvalidResponse, next)
		}
		pr.seenCursors[next] = struct{}{}
	}
	pr.cursor = next
	if next == "" {
		pr.done = true
		return resp, false, nil
	}
	return resp, true, nil
}

// Close is a no-op provided for symmetry with io.Closer callers.
func (pr *PaginatedReader) Close() {}

// validateDottedJSONPath verifies a dotted JSON path has no leading or
// trailing dot and every segment is non-empty after whitespace trimming.
// Rejects "foo..bar" (empty middle), ".foo" (leading), "foo." (trailing),
// "." (single dot), "foo. .bar" (whitespace-only middle). The empty path
// itself is rejected upstream by the non-empty check in Paginate; this
// helper assumes a non-empty input.
//
// Bracket-syntax array indices such as "paging[0].next" are also rejected:
// extractJSONPath implements a simple object-key walk and would look up the
// literal key "paging[0]" in the root map, fail to find it, and silently
// return "" — pagination stops at page 1 instead of failing closed. JSON
// object keys only; array indices are not supported.
//
// Without this guard, extractJSONPath looks up the empty segment via
// m[""], finds nothing, and silently returns "" — Paginate then stops
// after page 1 instead of failing closed on the malformed spec.
func validateDottedJSONPath(path string) error {
	if strings.HasPrefix(path, ".") {
		return fmt.Errorf("must not start with '.'")
	}
	if strings.HasSuffix(path, ".") {
		return fmt.Errorf("must not end with '.'")
	}
	for i, seg := range strings.Split(path, ".") {
		trimmed := strings.TrimSpace(seg)
		if trimmed == "" {
			return fmt.Errorf("segment %d is empty or whitespace-only", i+1)
		}
		if trimmed != seg {
			return fmt.Errorf("segment %d has leading/trailing whitespace; trim or fix the path", i+1)
		}
		if strings.ContainsAny(seg, "[]") {
			return fmt.Errorf("segment %d contains unsupported character (array/index syntax not supported)", i+1)
		}
	}
	return nil
}

// ValidateDottedJSONPath exposes the package's dotted-path validation rules to
// adjacent runtime/config surfaces that must accept the same path grammar.
func ValidateDottedJSONPath(path string) error {
	return validateDottedJSONPath(path)
}

// validateQueryParamName rejects a query-parameter name whose post-trim value
// contains internal whitespace. url.Values.Set("aft er", cursor) encodes to
// "aft+er=<cursor>" on the wire — the server then never sees the parameter
// under the configured name, so e.g. cursor pagination silently delivers no
// cursor → infinite loop on the caller's side.
//
// Leading/trailing whitespace is already stripped by Paginate before this
// helper is called, so only internal whitespace is checked here.
func validateQueryParamName(name string) error {
	for _, r := range name {
		if unicode.IsSpace(r) {
			return fmt.Errorf("must not contain whitespace")
		}
	}
	return nil
}

// extractJSONPath walks a dotted JSON path (e.g. "paging.next.after") in
// the parsed object and returns the string value, or "" when the key is
// missing, null, empty string, or boolean false (intentional last-page sentinel).
//
// json.Decoder.UseNumber is used instead of json.Unmarshal so that large
// integer cursors (> 2^53) are preserved exactly as their textual JSON
// representation. Without UseNumber, all JSON numbers decode into float64,
// and a 17-digit integer cursor would round-trip as a different value,
// causing the next-page request to carry the wrong cursor → page skip or
// page duplicate.
//
// Fail-closed on unsupported shapes: if a mid-walk segment descends into a
// non-object value, or if the leaf resolves to true/object/array, the
// function returns ErrInvalidResponse so PaginatedReader.Next propagates
// the error instead of silently stopping after page 1.
func extractJSONPath(body []byte, path string) (string, error) {
	if path == "" {
		return "", nil
	}
	var root map[string]any
	dec := json.NewDecoder(bytes.NewReader(body))
	dec.UseNumber() // preserve exact textual numbers; avoid float64 precision loss for large cursors
	if err := dec.Decode(&root); err != nil {
		return "", fmt.Errorf("json decode: %w", err)
	}
	parts := strings.Split(path, ".")
	var current any = root
	for i, part := range parts {
		m, ok := current.(map[string]any)
		if !ok {
			// Mid-path segment expected an object but found a non-object value.
			// This is a misconfigured NextCursorJSONPath or upstream schema drift —
			// fail closed so the caller sees an error rather than silent truncation.
			return "", fmt.Errorf("%w: NextCursorJSONPath segment %d (%q) descends into a non-object value (got %T)", ErrInvalidResponse, i+1, part, current)
		}
		val, exists := m[part]
		if !exists {
			return "", nil // missing key → terminate cleanly
		}
		current = val
	}
	if current == nil {
		return "", nil
	}
	// Convert primitive types to a canonical string representation so
	// pagination cursors that arrive as numbers round-trip correctly.
	// Fail closed on object/array/true — those are not valid cursor tokens
	// and indicate misconfigured NextCursorJSONPath or upstream schema drift.
	switch v := current.(type) {
	case string:
		return v, nil
	case json.Number:
		// UseNumber keeps the exact textual representation; String() returns it
		// verbatim (e.g. "12345678901234567", "2", "2.5") without precision loss.
		return v.String(), nil
	case bool:
		if !v {
			// false is the intentional last-page sentinel used by some APIs
			// (e.g. {"has_more": false}). Treat it as a clean terminator.
			return "", nil
		}
		// true is not a valid cursor token — fail closed.
		return "", fmt.Errorf("%w: cursor leaf resolved to true (not a valid cursor token)", ErrInvalidResponse)
	case map[string]any:
		return "", fmt.Errorf("%w: cursor leaf resolved to object (not a valid cursor token)", ErrInvalidResponse)
	case []any:
		return "", fmt.Errorf("%w: cursor leaf resolved to array (not a valid cursor token)", ErrInvalidResponse)
	default:
		return "", fmt.Errorf("%w: cursor leaf has unsupported type %T", ErrInvalidResponse, current)
	}
}
