package httpbinding

import (
	"bytes"
	"encoding/base64"
	"fmt"
	"io"
	"math/big"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/aws/smithy-go"
	awsjson "github.com/aws/smithy-go/transport/http/protocol/internal/json"
	"github.com/aws/smithy-go/document"
	httpbinding "github.com/aws/smithy-go/encoding/httpbinding"
	"github.com/aws/smithy-go/traits"
	smithyhttp "github.com/aws/smithy-go/transport/http"
)

// ShapeSerializer routes top-level input struct members to their HTTP binding
// locations. Members without HTTP binding traits delegate to an inner
// serializer for whatever protocol is being used.
//
// ShapeSerializer adds some API surface on top of the normal
// smithy.ShapeSerializer. Specifically it adds [ShapeSerializer.Build] for
// handing REST-protocol payloads, since the actual source of the payload is
// going to vary on a per-operation basis and isn't known until the input's
// Serialize is called. The caller (so, the protocol implementation) should
// set the HTTP request body by calling Build after Serialize.
type ShapeSerializer struct {
	request *smithyhttp.Request
	encoder *httpbinding.Encoder
	input   smithy.ShapeSerializer
	options ShapeSerializerOptions

	// set when an input member is bound via @httpPayload
	httpPayload            []byte
	httpPayloadContentType string

	// set when an input blob is bound via @httpPayload + @streaming
	streamingContentType string

	mapMode    mapBindingMode
	mapPrefix  string
	currentKey string

	listMode     listBindingMode
	listName     string
	listHasItems bool

	noBody           bool
	hasStructPayload bool
}

// ShapeSerializerOptions configures a ShapeSerializer.
type ShapeSerializerOptions struct{}

// NewShapeSerializer creates a ShapeSerializer for the given operation schema
// and request. It handles the initial setup from use of an
// httpbinding.Encoder.
func NewShapeSerializer(op *smithy.Schema, req *smithyhttp.Request, in smithy.ShapeSerializer, opts ...func(*ShapeSerializerOptions)) (*ShapeSerializer, error) {
	httpTrait, ok := smithy.SchemaTrait[*traits.HTTP](op)
	if !ok {
		return nil, fmt.Errorf("no @http trait on op schema")
	}

	req.Method = httpTrait.Method
	path, query := httpbinding.SplitURI(httpTrait.URI)
	enc, err := httpbinding.NewEncoder(path, query, req.Header)
	if err != nil {
		return nil, fmt.Errorf("new encoder: %w", err)
	}

	return &ShapeSerializer{
		request: req,
		encoder: enc,
		input:   in,
	}, nil
}

// Build encodes HTTP binding values into the request and sets the request
// body. The defaultContentType is used for the protocol body (e.g.
// "application/json") when no explicit payload is present.
//
// The body is resolved in the following priority:
//  1. Streaming payload (input implements StreamingInput with non-nil stream)
//  2. Raw payload bytes (blob/string member with @httpPayload)
//  3. Serialized protocol body (e.g. JSON)
//  4. Empty struct payload (struct member with @httpPayload, sends "{}")
func (s *ShapeSerializer) Build(in smithy.Serializable, defaultContentType string) error {
	req := s.request

	built, err := s.encoder.Encode(req.Request)
	if err != nil {
		return fmt.Errorf("encode httpbinding: %w", err)
	}
	req.Request = built

	// (1) streaming payload
	if si, ok := in.(smithy.StreamingInput); ok && si.GetPayloadStream() != nil {
		contentType := s.streamingContentType
		if contentType == "" {
			contentType = "application/octet-stream"
		}
		return s.setBody(si.GetPayloadStream(), contentType)
	}

	var payload []byte
	var contentType string

	// (2) explicit @httpPayload (blob/string)
	if s.httpPayload != nil {
		payload = s.httpPayload
		contentType = s.httpPayloadContentType
	} else { // (3) protocol body
		payload = s.input.Bytes()
		contentType = defaultContentType
	}

	// (4) empty struct @httpPayload
	if len(payload) == 0 && s.hasStructPayload {
		payload = []byte("{}")
		contentType = defaultContentType
	}

	if len(payload) == 0 {
		return nil
	}
	return s.setBody(bytes.NewReader(payload), contentType)
}

func (s *ShapeSerializer) setBody(body io.Reader, contentType string) error {
	if s.request.Header.Get("Content-Type") == "" {
		s.request.Header.Set("Content-Type", contentType)
	}
	sreq, err := s.request.SetStream(body)
	if err != nil {
		return fmt.Errorf("set stream: %w", err)
	}
	*s.request = *sreq
	return nil
}

type mapBindingMode int

const (
	mapModeNone mapBindingMode = iota
	mapModePrefixHeaders
	mapModeQueryParams
)

type listBindingMode int

const (
	listModeNone listBindingMode = iota
	listModeHeader
	listModeQuery
)

type bind int

const (
	bindBody bind = iota
	bindHeader
	bindHeaderList
	bindQuery
	bindQueryList
	bindLabel
)

func (s *ShapeSerializer) resolveBinding(schema *smithy.Schema) (bind, string) {
	if s.listMode == listModeHeader {
		return bindHeaderList, s.listName
	}
	if s.listMode == listModeQuery {
		return bindQueryList, s.listName
	}
	if name, ok := isHTTPHeader(schema); ok {
		return bindHeader, name
	}
	if isHTTPLabel(schema) {
		return bindLabel, schema.MemberName()
	}
	if q, ok := isHTTPQuery(schema); ok {
		return bindQuery, q.Name
	}
	return bindBody, ""
}

var _ smithy.ShapeSerializer = (*ShapeSerializer)(nil)

// Bytes returns the serialized body bytes.
func (s *ShapeSerializer) Bytes() []byte {
	return s.input.Bytes()
}

func isHTTPHeader(schema *smithy.Schema) (string, bool) {
	h, ok := smithy.SchemaTrait[*traits.HTTPHeader](schema)
	if !ok {
		return "", false
	}
	return http.CanonicalHeaderKey(h.Name), true
}

func isHTTPLabel(schema *smithy.Schema) bool {
	_, ok := smithy.SchemaTrait[*traits.HTTPLabel](schema)
	return ok
}

func isHTTPQuery(schema *smithy.Schema) (*traits.HTTPQuery, bool) {
	return smithy.SchemaTrait[*traits.HTTPQuery](schema)
}

func isHTTPPayload(schema *smithy.Schema) bool {
	_, ok := smithy.SchemaTrait[*traits.HTTPPayload](schema)
	return ok
}

func isHTTPPrefixHeaders(schema *smithy.Schema) (*traits.HTTPPrefixHeaders, bool) {
	ph, ok := smithy.SchemaTrait[*traits.HTTPPrefixHeaders](schema)
	return ph, ok
}

func isHTTPQueryParams(schema *smithy.Schema) bool {
	_, ok := smithy.SchemaTrait[*traits.HTTPQueryParams](schema)
	return ok
}

func hasBodyMembers(schema *smithy.Schema) bool {
	for _, member := range schema.Members() {
		if !isHTTPBound(member) {
			return true
		}
	}
	return false
}

func isHTTPBound(schema *smithy.Schema) bool {
	if _, ok := isHTTPHeader(schema); ok {
		return true
	}
	if _, ok := isHTTPPrefixHeaders(schema); ok {
		return true
	}
	if isHTTPLabel(schema) {
		return true
	}
	if _, ok := isHTTPQuery(schema); ok {
		return true
	}
	if isHTTPQueryParams(schema) {
		return true
	}
	if isHTTPPayload(schema) {
		return true
	}
	return false
}

// WriteString implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteString(schema *smithy.Schema, v string) {
	switch s.mapMode {
	case mapModePrefixHeaders:
		s.encoder.SetHeader(http.CanonicalHeaderKey(s.mapPrefix + s.currentKey)).String(v)
		return
	case mapModeQueryParams:
		s.encoder.AddQuery(s.currentKey).String(v)
		return
	}

	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		escaped := v
		if strings.ContainsAny(v, ",\"") {
			escaped = strconv.Quote(v)
		}
		s.encoder.AddHeader(bn).String(escaped)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).String(v)
	case bindHeader:
		if _, ok := smithy.SchemaTrait[*traits.MediaType](schema); ok {
			s.encoder.SetHeader(bn).String(base64.StdEncoding.EncodeToString([]byte(v)))
			return
		}
		s.encoder.SetHeader(bn).String(v)
	case bindLabel:
		s.encoder.SetURI(bn).String(v)
	case bindQuery:
		s.encoder.SetQuery(bn).String(v)
	default:
		if isHTTPPayload(schema) {
			s.httpPayload = []byte(v)
			s.httpPayloadContentType = "text/plain"
			return
		}
		s.input.WriteString(schema, v)
	}
}

// WriteBool implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteBool(schema *smithy.Schema, v bool) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Boolean(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Boolean(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Boolean(v)
	case bindLabel:
		s.encoder.SetURI(bn).Boolean(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Boolean(v)
	default:
		s.input.WriteBool(schema, v)
	}
}

// WriteInt8 implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteInt8(schema *smithy.Schema, v int8) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Byte(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Byte(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Byte(v)
	case bindLabel:
		s.encoder.SetURI(bn).Byte(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Byte(v)
	default:
		s.input.WriteInt8(schema, v)
	}
}

// WriteInt16 implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteInt16(schema *smithy.Schema, v int16) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Short(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Short(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Short(v)
	case bindLabel:
		s.encoder.SetURI(bn).Short(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Short(v)
	default:
		s.input.WriteInt16(schema, v)
	}
}

// WriteInt32 implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteInt32(schema *smithy.Schema, v int32) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Integer(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Integer(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Integer(v)
	case bindLabel:
		s.encoder.SetURI(bn).Integer(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Integer(v)
	default:
		s.input.WriteInt32(schema, v)
	}
}

// WriteInt64 implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteInt64(schema *smithy.Schema, v int64) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Long(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Long(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Long(v)
	case bindLabel:
		s.encoder.SetURI(bn).Long(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Long(v)
	default:
		s.input.WriteInt64(schema, v)
	}
}

// WriteFloat32 implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteFloat32(schema *smithy.Schema, v float32) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Float(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Float(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Float(v)
	case bindLabel:
		s.encoder.SetURI(bn).Float(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Float(v)
	default:
		s.input.WriteFloat32(schema, v)
	}
}

// WriteFloat64 implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteFloat64(schema *smithy.Schema, v float64) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).Double(v)
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).Double(v)
	case bindHeader:
		s.encoder.SetHeader(bn).Double(v)
	case bindLabel:
		s.encoder.SetURI(bn).Double(v)
	case bindQuery:
		s.encoder.SetQuery(bn).Double(v)
	default:
		s.input.WriteFloat64(schema, v)
	}
}

// WriteBlob implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteBlob(schema *smithy.Schema, v []byte) {
	if isHTTPPayload(schema) {
		s.httpPayload = v
		if mt, ok := smithy.SchemaTrait[*traits.MediaType](schema); ok {
			s.httpPayloadContentType = mt.Type
		} else {
			s.httpPayloadContentType = "application/octet-stream"
		}
		return
	}
	if name, ok := isHTTPHeader(schema); ok {
		s.encoder.SetHeader(name).Blob(v)
		return
	}
	s.input.WriteBlob(schema, v)
}

// WriteTime implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteTime(schema *smithy.Schema, v time.Time) {
	bt, bn := s.resolveBinding(schema)
	switch bt {
	case bindHeaderList:
		s.encoder.AddHeader(bn).String(formatTimestamp(schema, "http-date", v))
		s.listHasItems = true
	case bindQueryList:
		s.encoder.AddQuery(bn).String(formatTimestamp(schema, "date-time", v))
	case bindHeader:
		s.encoder.SetHeader(bn).String(formatTimestamp(schema, "http-date", v))
	case bindLabel:
		s.encoder.SetURI(bn).String(formatTimestamp(schema, "date-time", v))
	case bindQuery:
		s.encoder.SetQuery(bn).String(formatTimestamp(schema, "date-time", v))
	default:
		s.input.WriteTime(schema, v)
	}
}

// WriteList implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteList(schema *smithy.Schema) {
	if s.mapMode == mapModeQueryParams {
		s.listMode = listModeQuery
		s.listName = s.currentKey
		return
	}
	if name, ok := isHTTPHeader(schema); ok {
		s.listMode = listModeHeader
		s.listName = name
		s.listHasItems = false
		return
	}
	if q, ok := isHTTPQuery(schema); ok {
		s.listMode = listModeQuery
		s.listName = q.Name
		s.listHasItems = false
		return
	}
	s.input.WriteList(schema)
}

// CloseList implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) CloseList() {
	if s.listMode != listModeNone {
		if !s.listHasItems && s.listMode == listModeHeader {
			s.encoder.SetHeader(s.listName).String("")
		}
		s.listMode = listModeNone
		s.listName = ""
		s.listHasItems = false
		return
	}
	s.input.CloseList()
}

// WriteMap implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteMap(schema *smithy.Schema) {
	if ph, ok := isHTTPPrefixHeaders(schema); ok {
		s.mapMode = mapModePrefixHeaders
		s.mapPrefix = ph.Prefix
		return
	}

	if isHTTPQueryParams(schema) {
		s.mapMode = mapModeQueryParams
		return
	}

	s.input.WriteMap(schema)
}

// WriteKey implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteKey(schema *smithy.Schema, key string) {
	switch s.mapMode {
	case mapModePrefixHeaders, mapModeQueryParams:
		s.currentKey = key
	default:
		s.input.WriteKey(schema, key)
	}
}

// CloseMap implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) CloseMap() {
	if s.mapMode != mapModeNone {
		s.mapMode = mapModeNone
		s.mapPrefix = ""
		s.currentKey = ""
		return
	}
	s.input.CloseMap()
}

// WriteStruct implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteStruct(schema *smithy.Schema) {
	if schema.MemberName() != "" { // the root
		if isHTTPPayload(schema) {
			s.hasStructPayload = true
		}
		s.input.WriteStruct(schema)
		return
	}

	for _, m := range schema.Members() {
		if !isHTTPPayload(m) {
			continue
		}

		if _, ok := smithy.SchemaTrait[*traits.Streaming](m); ok {
			s.streamingContentType = "application/octet-stream"
			if mt, ok := smithy.SchemaTrait[*traits.MediaType](m); ok {
				s.streamingContentType = mt.Type
			}
		} else if m.Type() == smithy.ShapeTypeStructure {
			s.hasStructPayload = true
		}
	}

	if !hasBodyMembers(schema) {
		s.noBody = true
		return
	}

	s.input.WriteStruct(schema)
}

// CloseStruct implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) CloseStruct() {
	if s.noBody {
		s.noBody = false
		return
	}
	s.input.CloseStruct()
}

// WriteUnion implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteUnion(schema, variant *smithy.Schema) {
	s.input.WriteUnion(schema, variant)
}

// CloseUnion implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) CloseUnion() {
	s.input.CloseUnion()
}

// WriteNil implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteNil(schema *smithy.Schema) {
	s.input.WriteNil(schema)
}

// WriteBigInt implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteBigInt(schema *smithy.Schema, v *big.Int) {
	s.input.WriteBigInt(schema, v)
}

// WriteBigFloat implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteBigFloat(schema *smithy.Schema, v *big.Float) {
	s.input.WriteBigFloat(schema, v)
}

// WriteDocument implements [smithy.ShapeSerializer].
func (s *ShapeSerializer) WriteDocument(schema *smithy.Schema, v document.Value) {
	if isHTTPPayload(schema) {
		// httpPayload document: serialize to raw bytes for the body.
		doc := awsjson.NewShapeSerializer()
		doc.WriteDocument(schema, v)
		s.httpPayload = doc.Bytes()
		s.httpPayloadContentType = "application/json"
		return
	}
	s.input.WriteDocument(schema, v)
}
