package trace

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/ioutil"
	"net"
	"net/http"
	"sync"
	"time"

	"go.opencensus.io/trace"
)

// Error variables for factory validation.
var (
	ErrLoggerNotProvided = errors.New("logger not provided")
	ErrHostNotProvided   = errors.New("host not provided")
)

// Log provides support for logging inside this package.
// Unfortunately, the opentrace API calls into the ExportSpan
// function directly with no means to pass user defined arguments.
type Log func(format string, v ...interface{})

// Exporter provides support to batch spans and send them
// to the sidecar for processing.
type Exporter struct {
	log          Log               // Handler function for logging.
	host         string            // IP:port of the sidecare consuming the trace data.
	batchSize    int               // Size of the batch of spans before sending.
	sendInterval time.Duration     // Time to send a batch if batch size is not met.
	sendTimeout  time.Duration     // Time to wait for the sidecar to respond on send.
	client       http.Client       // Provides APIs for performing the http send.
	batch        []*trace.SpanData // Maintains the batch of span data to be sent.
	mu           sync.Mutex        // Provide synchronization to access the batch safely.
	timer        *time.Timer       // Signals when the sendInterval is met.
}

// NewExporter creates an exporter for use.
func NewExporter(log Log, host string, batchSize int, sendInterval, sendTimeout time.Duration) (*Exporter, error) {
	if log == nil {
		return nil, ErrLoggerNotProvided
	}
	if host == "" {
		return nil, ErrHostNotProvided
	}

	tr := http.Transport{
		Proxy: http.ProxyFromEnvironment,
		DialContext: (&net.Dialer{
			Timeout:   30 * time.Second,
			KeepAlive: 30 * time.Second,
			DualStack: true,
		}).DialContext,
		MaxIdleConns:          2,
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}

	e := Exporter{
		log:          log,
		host:         host,
		batchSize:    batchSize,
		sendInterval: sendInterval,
		sendTimeout:  sendTimeout,
		client: http.Client{
			Transport: &tr,
		},
		batch: make([]*trace.SpanData, 0, batchSize),
		timer: time.NewTimer(sendInterval),
	}

	return &e, nil
}

// Close sends the remaining spans that have not been sent yet.
func (e *Exporter) Close() (int, error) {
	var sendBatch []*trace.SpanData
	e.mu.Lock()
	{
		sendBatch = e.batch
	}
	e.mu.Unlock()

	err := e.send(sendBatch)
	if err != nil {
		return len(sendBatch), err
	}

	return len(sendBatch), nil
}

// ExportSpan is called by opentracing when spans are created. It implements
// the Exporter interface.
func (e *Exporter) ExportSpan(span *trace.SpanData) {
	sendBatch := e.saveBatch(span)
	if sendBatch != nil {
		go func() {
			e.log("trace : Exporter : ExportSpan : Sending Batch[%d]", len(sendBatch))
			if err := e.send(sendBatch); err != nil {
				e.log("trace : Exporter : ExportSpan : ERROR : %v", err)
			}
		}()
	}
}

// Saves the span data to the batch. If the batch should be sent,
// returns a batch to send.
func (e *Exporter) saveBatch(span *trace.SpanData) []*trace.SpanData {
	var sendBatch []*trace.SpanData

	e.mu.Lock()
	{
		// We want to append this new span to the collection.
		e.batch = append(e.batch, span)

		// Do we need to send the current batch?
		switch {
		case len(e.batch) == e.batchSize:

			// We hit the batch size. Now save the current
			// batch for sending and start a new batch.
			sendBatch = e.batch
			e.batch = make([]*trace.SpanData, 0, e.batchSize)
			e.timer.Reset(e.sendInterval)

		default:

			// We did not hit the batch size but maybe send what
			// we have based on time.
			select {
			case <-e.timer.C:

				// The time has expired so save the current
				// batch for sending and start a new batch.
				sendBatch = e.batch
				e.batch = make([]*trace.SpanData, 0, e.batchSize)
				e.timer.Reset(e.sendInterval)

			// It's not time yet, just move on.
			default:
			}
		}
	}
	e.mu.Unlock()

	return sendBatch
}

// send uses HTTP to send the data to the tracing sidecare for processing.
func (e *Exporter) send(sendBatch []*trace.SpanData) error {
	data, err := json.Marshal(sendBatch)
	if err != nil {
		return err
	}

	req, err := http.NewRequest("POST", e.host, bytes.NewBuffer(data))
	if err != nil {
		return err
	}

	ctx, cancel := context.WithTimeout(req.Context(), e.sendTimeout)
	defer cancel()
	req = req.WithContext(ctx)

	ch := make(chan error)
	go func() {
		resp, err := e.client.Do(req)
		if err != nil {
			ch <- err
			return
		}
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusNoContent {
			data, err := ioutil.ReadAll(resp.Body)
			if err != nil {
				ch <- fmt.Errorf("error on call : status[%s]", resp.Status)
				return
			}
			ch <- fmt.Errorf("error on call : status[%s] : %s", resp.Status, string(data))
			return
		}

		ch <- nil
	}()

	return <-ch
}