package mfilesclient

import (
	"context"
	"crypto/rand"
	"encoding/binary"
	"errors"
	"io"
	"strings"

	"google.golang.org/grpc"
	"google.golang.org/grpc/metadata"
)

// Call metadata. gRPC base64-encodes "-bin" values on the wire; in Go they are raw bytes in a
// string.
const (
	headerRemoteCall      = "mfiles-is-remote-call"       // "true" from every external client
	headerActivityID      = "mfiles-activity-id-bin"      // 16 random bytes per call, for tracing
	headerSessionID       = "mfiles-session-id-bin"       // LogIn's session_id, unchanged
	headerMetadataVersion = "mfiles-metadata-version-bin" // trailer: 4-byte little-endian int32
	headerCookie          = "cookie"
	headerSetCookie       = "set-cookie"
)

// [START metadata]

// withMetadata adds the M-Files call metadata to an outgoing call: the remote-call flag, a new
// activity ID, the session ID once logged in, and the cookies the server has set.
func (c *Client) withMetadata(ctx context.Context) context.Context {
	activityID := make([]byte, 16)
	_, _ = rand.Read(activityID)
	pairs := []string{headerRemoteCall, "true", headerActivityID, string(activityID)}
	if session := c.sessionID(); len(session) > 0 {
		pairs = append(pairs, headerSessionID, string(session))
	}
	if cookies := c.cookieHeader(); cookies != "" {
		pairs = append(pairs, headerCookie, cookies)
	}
	return metadata.AppendToOutgoingContext(ctx, pairs...)
}

// unaryInterceptor adds the metadata to every unary call, keeps the cookies and the metadata
// version the server returns, and turns a failure into *Error.
func (c *Client) unaryInterceptor(ctx context.Context, method string, req, reply any,
	cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
	var header, trailer metadata.MD
	opts = append(opts, grpc.Header(&header), grpc.Trailer(&trailer))
	err := invoker(c.withMetadata(ctx), method, req, reply, cc, opts...)
	c.readResponseMetadata(header, trailer)
	return decodeError(err, trailer)
}

// [END metadata]

// streamInterceptor does the same for streaming calls.
func (c *Client) streamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn,
	method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
	stream, err := streamer(c.withMetadata(ctx), desc, cc, method, opts...)
	if err != nil {
		return nil, decodeError(err, nil)
	}
	return &clientStream{ClientStream: stream, client: c, serverStreams: desc.ServerStreams}, nil
}

// clientStream reads the response metadata when the stream ends.
type clientStream struct {
	grpc.ClientStream
	client        *Client
	serverStreams bool
}

func (s *clientStream) RecvMsg(m any) error {
	err := s.ClientStream.RecvMsg(m)
	if err == nil && s.serverStreams {
		return nil // more messages may follow
	}
	// The call has ended: a single response has arrived, or the stream is over.
	header, _ := s.ClientStream.Header()
	trailer := s.ClientStream.Trailer()
	s.client.readResponseMetadata(header, trailer)
	if err == nil || errors.Is(err, io.EOF) {
		return err
	}
	return decodeError(err, trailer)
}

// readResponseMetadata keeps the cookies and the metadata structure version the server returned.
func (c *Client) readResponseMetadata(header, trailer metadata.MD) {
	for _, md := range []metadata.MD{header, trailer} {
		c.storeCookies(md.Get(headerSetCookie))
		if values := md.Get(headerMetadataVersion); len(values) > 0 && len(values[0]) == 4 {
			c.setMetadataStructureVersion(int32(binary.LittleEndian.Uint32([]byte(values[0]))))
		}
	}
}

// storeCookies keeps the name and value of each set-cookie header. M-Files Cloud pins a client
// to one server instance with the cookie mfilesmsm; send it back on every later call.
func (c *Client) storeCookies(setCookies []string) {
	if len(setCookies) == 0 {
		return
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.cookies == nil {
		c.cookies = map[string]string{}
	}
	for _, setCookie := range setCookies {
		nameValue, _, _ := strings.Cut(setCookie, ";")
		if name, value, ok := strings.Cut(strings.TrimSpace(nameValue), "="); ok && name != "" {
			c.cookies[name] = value
		}
	}
}

// cookieHeader is the value of the cookie header: name=value pairs separated by "; ".
func (c *Client) cookieHeader() string {
	c.mu.Lock()
	defer c.mu.Unlock()
	pairs := make([]string, 0, len(c.cookies))
	for name, value := range c.cookies {
		pairs = append(pairs, name+"="+value)
	}
	return strings.Join(pairs, "; ")
}
