// Package payload provides bounded, lossless continuation reads for Workflow
// artifacts. It deliberately keeps the transport result small without ever
// cutting a semantic JSON value in half: callers receive an exact text/JSON
// slice and an opaque cursor for the next slice.
package payload

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strings"
	"unicode/utf8"
)

const (
	// InlineBytes is small enough for a default Workflow response to leave room
	// for identity, status, and error metadata under the Exec result budget.
	InlineBytes = 3 << 10
	// MaxChunkBytes is the fixed bounded size of one explicit continuation read.
	// It stays below Desktop's per-string result fallback so one page never
	// requires generic semantic truncation. There is no separate default: an
	// omitted limit uses this same fixed contract.
	MaxChunkBytes = 32 << 10

	// Selector limits keep explicit continuation requests cheap even when they
	// arrive through a non-Desktop Agent-runtime implementation. They also keep
	// the resource identity embedded in an opaque cursor bounded.
	MaxSectionBytes        = 64
	MaxPathBytes           = 512
	MaxCursorBytes         = 2048
	MaxContentVersionBytes = 128
	MaxContentDigestBytes  = sha256.Size * 2

	maxCursorPayload = 1536
	cursorVersion    = 1
)

// Ref describes a payload omitted from a compact/default response. Cursor is
// scoped to the exact authenticated resource and section; it must be passed
// back unchanged to the owning read action.
type Ref struct {
	Section  string `json:"section"`
	Path     string `json:"path,omitempty"`
	Encoding string `json:"encoding"`
	Bytes    int    `json:"bytes"`
	Cursor   string `json:"cursor"`
}

// Snapshot is the authoritative immutable identity of the bytes being read.
// Version is a domain revision (for example a draft revision); Digest is the
// resource's full-content digest. ValueDigest is used internally when a JSON
// pointer narrows the full document before paging.
type Snapshot struct {
	Version     string
	Digest      string
	ValueDigest string
}

// Identity is the authenticated resource scope shared by every payload read.
// Keeping it as one value prevents call sites from accidentally swapping the
// plan/version/run/step positional slots; authorization still happens in each
// owning API before PageBytesWithSnapshot is called.
type Identity struct {
	Kind        string
	WorkspaceID string
	PlanID      string
	VersionID   string
	RunID       string
	StepID      string
}

// ErrContentChanged is stable so transports can classify a continuation as a
// conflict without exposing the changed payload or its details.
var ErrContentChanged = errors.New("payload content changed")

// Page is one lossless bounded payload slice. Value is exact UTF-8 text. For
// encoding=json it is a slice of the canonical JSON text, not a parsed or
// truncated JSON value; clients concatenate pages before decoding it.
type Page struct {
	Section    string `json:"section"`
	Path       string `json:"path,omitempty"`
	Encoding   string `json:"encoding"`
	Value      string `json:"value"`
	Cursor     string `json:"cursor,omitempty"`
	NextCursor string `json:"nextCursor,omitempty"`
	Bytes      int    `json:"bytes"`
	TotalBytes int    `json:"totalBytes"`
	Complete   bool   `json:"complete"`
}

// Cursor is an authenticated-resource selector, not a bearer capability. Its
// digest/version and coordinates are checked against the caller-provided
// Identity and authoritative bytes on every page.
type Cursor struct {
	Version        int    `json:"v"`
	Kind           string `json:"kind"`
	Workspace      string `json:"workspaceId"`
	PlanID         string `json:"planId,omitempty"`
	VersionID      string `json:"versionId,omitempty"`
	RunID          string `json:"runId,omitempty"`
	StepID         string `json:"stepId,omitempty"`
	Section        string `json:"section"`
	Path           string `json:"path,omitempty"`
	Offset         int    `json:"offset"`
	ContentVersion string `json:"contentVersion,omitempty"`
	ContentDigest  string `json:"contentDigest"`
}

// ReferenceWithSnapshot creates a resource-bound cursor for a compact value.
// Callers that know the authoritative revision should provide it; callers
// without raw bytes can at least provide a digest from their immutable store.
func ReferenceWithSnapshot(identity Identity, section, path, encoding string, size int, snapshot Snapshot) (*Ref, error) {
	identity.Kind = strings.TrimSpace(identity.Kind)
	identity.WorkspaceID = strings.TrimSpace(identity.WorkspaceID)
	identity.PlanID = strings.TrimSpace(identity.PlanID)
	identity.VersionID = strings.TrimSpace(identity.VersionID)
	identity.RunID = strings.TrimSpace(identity.RunID)
	identity.StepID = strings.TrimSpace(identity.StepID)
	section, path = strings.TrimSpace(section), strings.TrimSpace(path)
	canonicalPath, err := CanonicalPath(path)
	if err != nil {
		return nil, err
	}
	path = canonicalPath
	if identity.Kind == "" || identity.WorkspaceID == "" {
		return nil, fmt.Errorf("payload read scope is incomplete")
	}
	if err := ValidateReadSelector(section, path, ""); err != nil {
		return nil, err
	}
	snapshot, err = normalizeSnapshot(snapshot, nil)
	if err != nil {
		return nil, err
	}
	if snapshot.Digest == "" {
		return nil, fmt.Errorf("payload snapshot digest is required")
	}
	refCursor, err := EncodeCursor(Cursor{
		Version: cursorVersion, Kind: identity.Kind, Workspace: identity.WorkspaceID,
		PlanID: identity.PlanID, VersionID: identity.VersionID,
		RunID: identity.RunID, StepID: identity.StepID,
		Section: section, Path: path, Offset: 0,
		ContentVersion: snapshot.Version, ContentDigest: snapshot.Digest,
	})
	if err != nil {
		return nil, err
	}
	return &Ref{Section: section, Path: path, Encoding: encoding, Bytes: size, Cursor: refCursor}, nil
}

// InlineWithSnapshot preserves the compact response shape while binding any
// continuation ref to the supplied immutable revision and raw-byte digest.
func InlineWithSnapshot(identity Identity, section, path, encoding string, raw []byte, snapshot Snapshot) ([]byte, *Ref, error) {
	normalized, err := normalizeSnapshot(snapshot, raw)
	if err != nil {
		return nil, nil, err
	}
	snapshot = normalized
	if len(raw) <= InlineBytes {
		return append([]byte(nil), raw...), nil, nil
	}
	ref, err := ReferenceWithSnapshot(identity, section, path, encoding, len(raw), snapshot)
	if err != nil {
		return nil, nil, err
	}
	return nil, ref, nil
}

// ValidateReadSelector applies the bounded public continuation selector
// contract. Callers may use it before doing any path parsing so an oversized
// JSON-pointer cannot turn an otherwise bounded read into an unbounded one.
func ValidateReadSelector(section, path, cursor string) error {
	if strings.TrimSpace(section) == "" {
		return fmt.Errorf("payload section is required")
	}
	if len(section) > MaxSectionBytes || len(path) > MaxPathBytes || len(cursor) > MaxCursorBytes {
		return fmt.Errorf("payload read selector is too large")
	}
	return nil
}

// PageBytes returns one bounded page and binds continuations to the digest of
// raw. Callers with a domain revision should use PageBytesWithSnapshot.
func PageBytes(identity Identity, section, path, encoding string, raw []byte, cursorValue string, limit int) (Page, error) {
	return PageBytesWithSnapshot(identity, section, path, encoding, raw,
		Snapshot{Digest: ContentDigest(raw)}, cursorValue, limit)
}

// PageBytesWithSnapshot validates the authoritative snapshot before returning
// any bytes. A continuation whose revision or digest no longer matches is a
// stable conflict and never reveals payload content.
func PageBytesWithSnapshot(identity Identity, section, path, encoding string, raw []byte, snapshot Snapshot, cursorValue string, limit int) (Page, error) {
	identity.Kind = strings.TrimSpace(identity.Kind)
	identity.WorkspaceID = strings.TrimSpace(identity.WorkspaceID)
	identity.PlanID = strings.TrimSpace(identity.PlanID)
	identity.VersionID = strings.TrimSpace(identity.VersionID)
	identity.RunID = strings.TrimSpace(identity.RunID)
	identity.StepID = strings.TrimSpace(identity.StepID)
	section, path, cursorValue = strings.TrimSpace(section), strings.TrimSpace(path), strings.TrimSpace(cursorValue)
	if err := ValidateReadSelector(section, path, cursorValue); err != nil {
		return Page{}, err
	}
	canonicalPath, err := CanonicalPath(path)
	if err != nil {
		return Page{}, err
	}
	path = canonicalPath
	if identity.Kind == "" || identity.WorkspaceID == "" {
		return Page{}, fmt.Errorf("payload read scope is incomplete")
	}
	snapshot, err = normalizeSnapshot(snapshot, raw)
	if err != nil {
		return Page{}, err
	}
	if limit == 0 {
		limit = MaxChunkBytes
	}
	if limit < 1 || limit > MaxChunkBytes {
		return Page{}, fmt.Errorf("payload limit must be between 1 and %d", MaxChunkBytes)
	}
	value := Cursor{Version: cursorVersion, Kind: identity.Kind, Workspace: identity.WorkspaceID,
		PlanID: identity.PlanID, VersionID: identity.VersionID,
		RunID: identity.RunID, StepID: identity.StepID,
		Section: section, Path: path, Offset: 0,
		ContentVersion: snapshot.Version, ContentDigest: snapshot.Digest}
	if cursorValue != "" {
		decoded, err := DecodeCursor(cursorValue)
		if err != nil {
			return Page{}, err
		}
		if decoded.Version != cursorVersion || decoded.Kind != value.Kind ||
			decoded.Workspace != value.Workspace || decoded.PlanID != value.PlanID ||
			decoded.VersionID != value.VersionID || decoded.RunID != value.RunID ||
			decoded.StepID != value.StepID || decoded.Section != value.Section ||
			decoded.Path != value.Path {
			return Page{}, fmt.Errorf("payload cursor does not belong to this read")
		}
		if decoded.ContentVersion != value.ContentVersion || decoded.ContentDigest != value.ContentDigest {
			return Page{}, fmt.Errorf("%w; restart the read", ErrContentChanged)
		}
		if decoded.Offset < 0 || decoded.Offset > len(raw) {
			return Page{}, fmt.Errorf("payload cursor does not belong to this read")
		}
		value.Offset = decoded.Offset
	}
	end := value.Offset + limit
	if end > len(raw) {
		end = len(raw)
	}
	// JSON/source payloads are UTF-8. Never split a multi-byte rune because
	// converting an invalid slice to a Go string would replace bytes and make
	// concatenation lossy.
	for end < len(raw) && end > value.Offset && raw[end]&0xc0 == 0x80 {
		end--
	}
	if end == value.Offset && end < len(raw) {
		_, size := utf8.DecodeRune(raw[value.Offset:])
		if size < 1 {
			size = 1
		}
		end = value.Offset + size
		if end > len(raw) {
			end = len(raw)
		}
	}
	page := Page{
		Section: section, Path: path, Encoding: encoding,
		Value: string(raw[value.Offset:end]), Cursor: cursorValue,
		Bytes: end - value.Offset, TotalBytes: len(raw), Complete: end == len(raw),
	}
	if !page.Complete {
		next, err := EncodeCursor(Cursor{Version: cursorVersion, Kind: identity.Kind, Workspace: identity.WorkspaceID,
			PlanID: identity.PlanID, VersionID: identity.VersionID,
			RunID: identity.RunID, StepID: identity.StepID,
			Section: section, Path: path, Offset: end,
			ContentVersion: snapshot.Version, ContentDigest: snapshot.Digest})
		if err != nil {
			return Page{}, err
		}
		page.NextCursor = next
	}
	return page, nil
}

// ContentDigest returns the bounded opaque identity used to detect replacement
// of a live payload between continuation requests.
func ContentDigest(raw []byte) string {
	digest := sha256.Sum256(raw)
	return hex.EncodeToString(digest[:])
}

func normalizeSnapshot(snapshot Snapshot, raw []byte) (Snapshot, error) {
	snapshot.Version = strings.TrimSpace(snapshot.Version)
	snapshot.Digest = strings.TrimSpace(snapshot.Digest)
	snapshot.ValueDigest = strings.TrimSpace(snapshot.ValueDigest)
	if len(snapshot.Version) > MaxContentVersionBytes || len(snapshot.Digest) > MaxContentDigestBytes || len(snapshot.ValueDigest) > MaxContentDigestBytes {
		return Snapshot{}, fmt.Errorf("payload snapshot identity is too large")
	}
	if snapshot.Digest == "" && raw != nil {
		snapshot.Digest = ContentDigest(raw)
	}
	if snapshot.ValueDigest == "" && raw != nil {
		snapshot.ValueDigest = snapshot.Digest
	}
	for _, digest := range []string{snapshot.Digest, snapshot.ValueDigest} {
		if digest == "" {
			continue
		}
		decoded, err := hex.DecodeString(digest)
		if err != nil || len(decoded) != sha256.Size {
			return Snapshot{}, fmt.Errorf("payload snapshot digest is invalid")
		}
	}
	if raw != nil && snapshot.ValueDigest != "" && snapshot.ValueDigest != ContentDigest(raw) {
		return Snapshot{}, fmt.Errorf("%w; restart the read", ErrContentChanged)
	}
	return snapshot, nil
}

// EncodeCursor creates an opaque, resource-bound continuation cursor.
func EncodeCursor(value Cursor) (string, error) {
	if value.Version == 0 {
		value.Version = cursorVersion
	}
	if value.Kind == "" || value.Workspace == "" || value.Section == "" || value.Offset < 0 || value.ContentDigest == "" ||
		len(value.ContentVersion) > MaxContentVersionBytes || len(value.ContentDigest) > MaxContentDigestBytes {
		return "", fmt.Errorf("payload cursor is invalid")
	}
	decodedDigest, err := hex.DecodeString(value.ContentDigest)
	if err != nil || len(decodedDigest) != sha256.Size {
		return "", fmt.Errorf("payload cursor is invalid")
	}
	payload, err := json.Marshal(value)
	if err != nil {
		return "", fmt.Errorf("encode payload cursor: %w", err)
	}
	if len(payload) > maxCursorPayload {
		return "", fmt.Errorf("payload cursor is too large")
	}
	return base64.RawURLEncoding.EncodeToString(payload), nil
}

// DecodeCursor validates syntax and bounds. Resource ownership is checked by
// PageBytes after decoding, so callers cannot reuse a cursor for another ID.
func DecodeCursor(raw string) (Cursor, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" || len(raw) > MaxCursorBytes {
		return Cursor{}, fmt.Errorf("payload cursor is invalid")
	}
	payload, err := base64.RawURLEncoding.DecodeString(raw)
	if err != nil || len(payload) > maxCursorPayload {
		return Cursor{}, fmt.Errorf("payload cursor is invalid")
	}
	decoder := json.NewDecoder(bytes.NewReader(payload))
	decoder.DisallowUnknownFields()
	var value Cursor
	if err := decoder.Decode(&value); err != nil {
		return Cursor{}, fmt.Errorf("payload cursor is invalid")
	}
	if err := decoder.Decode(new(struct{})); err != io.EOF {
		return Cursor{}, fmt.Errorf("payload cursor is invalid")
	}
	if value.Version != cursorVersion || value.Kind == "" || value.Workspace == "" || value.Section == "" || value.Offset < 0 || value.ContentDigest == "" ||
		len(value.ContentVersion) > MaxContentVersionBytes || len(value.ContentDigest) > MaxContentDigestBytes {
		return Cursor{}, fmt.Errorf("payload cursor is invalid")
	}
	if value.ContentDigest != "" {
		decodedDigest, err := hex.DecodeString(value.ContentDigest)
		if err != nil || len(decodedDigest) != sha256.Size {
			return Cursor{}, fmt.Errorf("payload cursor is invalid")
		}
	}
	return value, nil
}

// CanonicalPath accepts the public convenience forms and returns the one
// JSON-pointer representation shared by live payload and durable artifact
// continuation bindings.
func CanonicalPath(path string) (string, error) {
	parts, err := normalizePointer(strings.TrimSpace(path))
	if err != nil {
		return "", err
	}
	if len(parts) == 0 {
		return "", nil
	}
	for index, part := range parts {
		parts[index] = strings.ReplaceAll(strings.ReplaceAll(part, "~", "~0"), "/", "~1")
	}
	return "/" + strings.Join(parts, "/"), nil
}

// NarrowJSON selects a JSON Pointer from a bounded canonical JSON document.
// Empty path returns the document unchanged. A leading $.foo convenience path
// is accepted for CLI callers and normalized to JSON Pointer semantics.
func NarrowJSON(raw []byte, path string) ([]byte, error) {
	path = strings.TrimSpace(path)
	if path == "" || path == "$" || path == "/" {
		return append([]byte(nil), raw...), nil
	}
	pointer, err := normalizePointer(path)
	if err != nil {
		return nil, err
	}
	var value any
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.UseNumber()
	if err := decoder.Decode(&value); err != nil {
		return nil, fmt.Errorf("payload is not JSON: %w", err)
	}
	if err := decoder.Decode(new(struct{})); err != io.EOF {
		if err == nil {
			return nil, fmt.Errorf("payload contains trailing JSON")
		}
		return nil, fmt.Errorf("payload is not JSON: %w", err)
	}
	for _, segment := range pointer {
		switch current := value.(type) {
		case map[string]any:
			next, ok := current[segment]
			if !ok {
				return nil, fmt.Errorf("payload path %q was not found", path)
			}
			value = next
		case []any:
			var index int
			if segment == "" || (len(segment) > 1 && segment[0] == '0') {
				return nil, fmt.Errorf("payload path %q has an invalid array index", path)
			}
			for _, char := range segment {
				if char < '0' || char > '9' {
					return nil, fmt.Errorf("payload path %q has an invalid array index", path)
				}
				index = index*10 + int(char-'0')
			}
			if index < 0 || index >= len(current) {
				return nil, fmt.Errorf("payload path %q was not found", path)
			}
			value = current[index]
		default:
			return nil, fmt.Errorf("payload path %q traverses a scalar", path)
		}
	}
	selected, err := json.Marshal(value)
	if err != nil {
		return nil, fmt.Errorf("encode payload path %q: %w", path, err)
	}
	return selected, nil
}

func normalizePointer(path string) ([]string, error) {
	if strings.HasPrefix(path, "$.") {
		parts := strings.Split(path[2:], ".")
		for _, part := range parts {
			if part == "" {
				return nil, fmt.Errorf("payload path is invalid")
			}
		}
		return parts, nil
	}
	if path == "" || path == "$" || path == "/" {
		return nil, nil
	}
	if !strings.HasPrefix(path, "/") {
		return nil, fmt.Errorf("payload path must be a JSON Pointer or $.field path")
	}
	parts := strings.Split(path[1:], "/")
	for index, part := range parts {
		part = strings.ReplaceAll(strings.ReplaceAll(part, "~1", "/"), "~0", "~")
		if strings.Contains(part, "~") {
			return nil, fmt.Errorf("payload path is invalid")
		}
		parts[index] = part
	}
	return parts, nil
}
