package twitchclient

import (
	"bytes"
	"context"
	"crypto/tls"
	"net"
	"net/http/httptrace"
	"strings"
	"time"
	"unicode"
)

// Statter defines the minimal interface necessary to record metrics.
type Statter interface {
	Inc(stat string, delta int64, rate float32) error
	TimingDuration(stat string, delta time.Duration, rate float32) error
}

type noopStatter struct{}

func (noopStatter) Inc(string, int64, float32) error                    { return nil }
func (noopStatter) TimingDuration(string, time.Duration, float32) error { return nil }

type getConnectionData struct {
	getConnectionStart time.Time
}

type dialData struct {
	dialStart time.Time
}

type dnsData struct {
	hostStat string
	start    time.Time
}

type tlsData struct {
	tlsHandshakeStart time.Time
}

func withStats(ctx context.Context, stats Statter, dnsPrefix string, logger Logger, sampleRate float32) context.Context {
	dnsHookData := dnsData{}
	tlsHookData := tlsData{}
	dialHookData := dialData{}
	getConnectionHookData := getConnectionData{}

	clientTrace := &httptrace.ClientTrace{
		GetConn: hookGetConn(logger, &dnsHookData, &getConnectionHookData),
		GotConn: hookGotConn(stats, &dnsHookData, &getConnectionHookData, sampleRate),

		DNSStart: hookDNSStart(&dnsHookData),
		DNSDone:  hookDNSDone(stats, dnsPrefix, &dnsHookData, sampleRate),

		ConnectStart: hookConnectStart(&dialHookData),
		ConnectDone:  hookConnectDone(stats, &dnsHookData, &dialHookData, sampleRate),

		WroteRequest: hookWroteRequest(stats, &dnsHookData),

		TLSHandshakeStart: hookTLSHandshakeStart(&tlsHookData),
		TLSHandshakeDone:  hookTLSHandshakeDone(stats, &dnsHookData, &tlsHookData, sampleRate),
	}

	return httptrace.WithClientTrace(ctx, clientTrace)
}

func hookGetConn(logger Logger, dns *dnsData, getConnectionData *getConnectionData) func(hostPort string) {
	return func(hostPort string) {
		getConnectionData.getConnectionStart = time.Now()

		host, _, err := net.SplitHostPort(hostPort)
		if err != nil {
			logger.Log(err)
			return
		}
		dns.hostStat = sanitizeStat(host)
	}
}

func hookGotConn(stats Statter, dns *dnsData, getConnectionData *getConnectionData, sampleRate float32) func(httptrace.GotConnInfo) {
	return func(trace httptrace.GotConnInfo) {
		if getConnectionData == nil || dns == nil || getConnectionData.getConnectionStart.IsZero() || dns.hostStat == "" {
			return // do not attempt to send inaccurate stats if hookGetConn or hookDNSStart was not run.
		}

		duration := time.Since(getConnectionData.getConnectionStart)
		stat := statKey("get_connection", dns.hostStat, "success")
		_ = stats.TimingDuration(stat, duration, sampleRate)

		if trace.Reused {
			stat = statKey("get_connection", dns.hostStat, "reused")
			_ = stats.Inc(stat, 1, sampleRate)
		}

		if trace.WasIdle {
			stat = statKey("get_connection", dns.hostStat, "idle_time")
			_ = stats.TimingDuration(stat, trace.IdleTime, sampleRate)

			stat = statKey("get_connection", dns.hostStat, "was_idle")
			_ = stats.Inc(stat, 1, sampleRate)
		}
	}
}

func hookTLSHandshakeStart(tlsData *tlsData) func() {
	return func() {
		tlsData.tlsHandshakeStart = time.Now()
	}
}

func hookTLSHandshakeDone(stats Statter, dns *dnsData, tlsData *tlsData, sampleRate float32) func(tls.ConnectionState, error) {
	return func(state tls.ConnectionState, err error) {
		if dns == nil || tlsData == nil || tlsData.tlsHandshakeStart.IsZero() || dns.hostStat == "" {
			return // do not attempt to send inaccurate stats if hookTLSHandshakeStart or hookDNSStart was not run.
		}

		duration := time.Since(tlsData.tlsHandshakeStart)
		if err != nil {
			stat := statKey("tls_handshake", dns.hostStat, "failure")
			_ = stats.Inc(stat, 1, 1)
			return
		}

		stat := statKey("tls_handshake", dns.hostStat, "success")
		_ = stats.TimingDuration(stat, duration, sampleRate)
	}
}

func hookConnectStart(dialData *dialData) func(network, addr string) {
	return func(network, addr string) {
		dialData.dialStart = time.Now()
	}
}

func hookConnectDone(stats Statter, dns *dnsData, dialData *dialData, sampleRate float32) func(network, addr string, err error) {
	return func(network, addr string, err error) {
		if dialData == nil || dns == nil || dialData.dialStart.IsZero() || dns.hostStat == "" {
			return // do not attempt to send inaccurate stats if hookConnectStart or hookDNSStart was not run.
		}

		if err != nil {
			stat := statKey("dial", dns.hostStat, "failure")
			_ = stats.Inc(stat, 1, 1)
			return
		}

		duration := time.Since(dialData.dialStart)
		stat := statKey("dial", dns.hostStat, "success")
		_ = stats.TimingDuration(stat, duration, sampleRate)
	}
}

func hookWroteRequest(stats Statter, dns *dnsData) func(httptrace.WroteRequestInfo) {
	return func(trace httptrace.WroteRequestInfo) {
		if trace.Err != nil {
			if dns == nil || dns.hostStat == "" {
				return // do not attempt to send inaccurate stats if hookDNSStart was not run.
			}

			stat := statKey("req_write", dns.hostStat, "failure")
			_ = stats.Inc(stat, 1, 1)
		}
	}
}

func hookDNSStart(dns *dnsData) func(httptrace.DNSStartInfo) {
	return func(trace httptrace.DNSStartInfo) {
		dns.start = time.Now()
		dns.hostStat = sanitizeStat(trace.Host)
	}
}

func hookDNSDone(stats Statter, dnsPrefix string, dns *dnsData, sampleRate float32) func(httptrace.DNSDoneInfo) {
	return func(trace httptrace.DNSDoneInfo) {
		if dns == nil || dns.start.IsZero() || dns.hostStat == "" {
			return // do not attempt to send inaccurate stats if hookDNSStart was not run.
		}
		duration := time.Since(dns.start)

		if trace.Err != nil {
			stat := statKey(dnsPrefix, dns.hostStat, "failure")
			_ = stats.Inc(stat, 1, 1)
		} else {
			stat := statKey(dnsPrefix, dns.hostStat, "success")
			_ = stats.TimingDuration(stat, duration, sampleRate)
		}

		if trace.Coalesced {
			stat := statKey(dnsPrefix, dns.hostStat, "coalesced")
			_ = stats.Inc(stat, 1, sampleRate)
		}
	}
}

// statKey efficiently concatenates the stats with dots.
func statKey(nodes ...string) string {
	return strings.Join(nodes, ".")
}

// sanitizeStat formats and sanitizes a stat key. Example:
// sanitizeStat("http://192.0.0.1") returns "http___192_0_0_1"
func sanitizeStat(stat string) string {
	buf := &bytes.Buffer{}
	for _, r := range stat {
		if unicode.In(r, unicode.Letter, unicode.Number, unicode.Hyphen) {
			buf.WriteRune(r)
		} else {
			buf.WriteByte('_')
		}
	}
	return buf.String()
}
