package cmd

import (
	"bufio"
	"encoding/json"
	"fmt"
	"io"
	"os"
	"strings"

	"github.com/golang/protobuf/ptypes/duration"
	"github.com/linkerd/linkerd2/controller/api/util"
	pb "github.com/linkerd/linkerd2/controller/gen/public"
	"github.com/linkerd/linkerd2/pkg/addr"
	"github.com/linkerd/linkerd2/pkg/k8s"
	"github.com/linkerd/linkerd2/pkg/protohttp"
	"github.com/linkerd/linkerd2/pkg/tap"
	log "github.com/sirupsen/logrus"
	"github.com/spf13/cobra"
	"google.golang.org/grpc/codes"
)

type renderTapEventFunc func(*pb.TapEvent, string) string

type tapOptions struct {
	namespace     string
	toResource    string
	toNamespace   string
	maxRps        float32
	scheme        string
	method        string
	authority     string
	path          string
	output        string
	labelSelector string
}

type endpoint struct {
	IP       string            `json:"ip"`
	Port     uint32            `json:"port"`
	Metadata map[string]string `json:"metadata"`
}

type streamID struct {
	Base   uint32 `json:"base"`
	Stream uint64 `json:"stream"`
}

type metadata interface {
	isMetadata()
}

type metadataStr struct {
	Name     string `json:"name"`
	ValueStr string `json:"valueStr"`
}

func (*metadataStr) isMetadata() {}

type metadataBin struct {
	Name     string `json:"name"`
	ValueBin []byte `json:"valueBin"`
}

func (*metadataBin) isMetadata() {}

type requestInitEvent struct {
	ID        *streamID  `json:"id"`
	Method    string     `json:"method"`
	Scheme    string     `json:"scheme"`
	Authority string     `json:"authority"`
	Path      string     `json:"path"`
	Headers   []metadata `json:"headers"`
}

type responseInitEvent struct {
	ID               *streamID          `json:"id"`
	SinceRequestInit *duration.Duration `json:"sinceRequestInit"`
	HTTPStatus       uint32             `json:"httpStatus"`
	Headers          []metadata         `json:"headers"`
}

type responseEndEvent struct {
	ID                *streamID          `json:"id"`
	SinceRequestInit  *duration.Duration `json:"sinceRequestInit"`
	SinceResponseInit *duration.Duration `json:"sinceResponseInit"`
	ResponseBytes     uint64             `json:"responseBytes"`
	Trailers          []metadata         `json:"trailers"`
	GrpcStatusCode    uint32             `json:"grpcStatusCode"`
	ResetErrorCode    uint32             `json:"resetErrorCode,omitempty"`
}

// Private type used for displaying JSON encoded tap events
type tapEvent struct {
	Source            *endpoint          `json:"source"`
	Destination       *endpoint          `json:"destination"`
	RouteMeta         map[string]string  `json:"routeMeta"`
	ProxyDirection    string             `json:"proxyDirection"`
	RequestInitEvent  *requestInitEvent  `json:"requestInitEvent,omitempty"`
	ResponseInitEvent *responseInitEvent `json:"responseInitEvent,omitempty"`
	ResponseEndEvent  *responseEndEvent  `json:"responseEndEvent,omitempty"`
}

func newTapOptions() *tapOptions {
	return &tapOptions{
		namespace:     "default",
		toResource:    "",
		toNamespace:   "",
		maxRps:        maxRps,
		scheme:        "",
		method:        "",
		authority:     "",
		path:          "",
		output:        "",
		labelSelector: "",
	}
}

func (o *tapOptions) validate() error {
	if o.output == "" || o.output == wideOutput || o.output == jsonOutput {
		return nil
	}

	return fmt.Errorf("output format \"%s\" not recognized", o.output)
}

func newCmdTap() *cobra.Command {
	options := newTapOptions()

	cmd := &cobra.Command{
		Use:   "tap [flags] (RESOURCE)",
		Short: "Listen to a traffic stream",
		Long: `Listen to a traffic stream.

  The RESOURCE argument specifies the target resource(s) to tap:
  (TYPE [NAME] | TYPE/NAME)

  Examples:
  * cronjob/my-cronjob
  * deploy
  * deploy/my-deploy
  * deploy my-deploy
  * ds/my-daemonset
  * job/my-job
  * ns/my-ns
  * rs
  * rs/my-replicaset
  * sts
  * sts/my-statefulset

  Valid resource types include:
  * cronjobs
  * daemonsets
  * deployments
  * jobs
  * namespaces
  * pods
  * replicasets
  * replicationcontrollers
  * statefulsets
  * services (only supported as a --to resource)`,
		Example: `  # tap the web deployment in the default namespace
  linkerd tap deploy/web

  # tap the web-dlbvj pod in the default namespace
  linkerd tap pod/web-dlbvj

  # tap the test namespace, filter by request to prod namespace
  linkerd tap ns/test --to ns/prod`,
		Args:      cobra.RangeArgs(1, 2),
		ValidArgs: util.ValidTargets,
		RunE: func(cmd *cobra.Command, args []string) error {
			requestParams := util.TapRequestParams{
				Resource:      strings.Join(args, "/"),
				Namespace:     options.namespace,
				ToResource:    options.toResource,
				ToNamespace:   options.toNamespace,
				MaxRps:        options.maxRps,
				Scheme:        options.scheme,
				Method:        options.method,
				Authority:     options.authority,
				Path:          options.path,
				Extract:       options.output == jsonOutput,
				LabelSelector: options.labelSelector,
			}

			err := options.validate()
			if err != nil {
				return fmt.Errorf("validation error when executing tap command: %v", err)
			}

			req, err := util.BuildTapByResourceRequest(requestParams)
			if err != nil {
				return err
			}

			k8sAPI, err := k8s.NewAPI(kubeconfigPath, kubeContext, impersonate, impersonateGroup, 0)
			if err != nil {
				return err
			}

			return requestTapByResourceFromAPI(os.Stdout, k8sAPI, req, options)
		},
	}

	cmd.PersistentFlags().StringVarP(&options.namespace, "namespace", "n", options.namespace,
		"Namespace of the specified resource")
	cmd.PersistentFlags().StringVar(&options.toResource, "to", options.toResource,
		"Display requests to this resource")
	cmd.PersistentFlags().StringVar(&options.toNamespace, "to-namespace", options.toNamespace,
		"Sets the namespace used to lookup the \"--to\" resource; by default the current \"--namespace\" is used")
	cmd.PersistentFlags().Float32Var(&options.maxRps, "max-rps", options.maxRps,
		"Maximum requests per second to tap.")
	cmd.PersistentFlags().StringVar(&options.scheme, "scheme", options.scheme,
		"Display requests with this scheme")
	cmd.PersistentFlags().StringVar(&options.method, "method", options.method,
		"Display requests with this HTTP method")
	cmd.PersistentFlags().StringVar(&options.authority, "authority", options.authority,
		"Display requests with this :authority")
	cmd.PersistentFlags().StringVar(&options.path, "path", options.path,
		"Display requests with paths that start with this prefix")
	cmd.PersistentFlags().StringVarP(&options.output, "output", "o", options.output,
		fmt.Sprintf("Output format. One of: \"%s\", \"%s\"", wideOutput, jsonOutput))
	cmd.PersistentFlags().StringVarP(&options.labelSelector, "selector", "l", options.labelSelector,
		"Selector (label query) to filter on, supports '=', '==', and '!='")

	return cmd
}

func requestTapByResourceFromAPI(w io.Writer, k8sAPI *k8s.KubernetesAPI, req *pb.TapByResourceRequest, options *tapOptions) error {
	reader, body, err := tap.Reader(k8sAPI, req, 0)
	if err != nil {
		return err
	}
	defer body.Close()

	return writeTapEventsToBuffer(w, reader, req, options)
}

func writeTapEventsToBuffer(w io.Writer, tapByteStream *bufio.Reader, req *pb.TapByResourceRequest, options *tapOptions) error {
	var err error
	switch options.output {
	case "":
		err = renderTapEvents(tapByteStream, w, renderTapEvent, "")
	case wideOutput:
		resource := req.GetTarget().GetResource().GetType()
		err = renderTapEvents(tapByteStream, w, renderTapEvent, resource)
	case jsonOutput:
		err = renderTapEvents(tapByteStream, w, renderTapEventJSON, "")
	}
	if err != nil {
		return err
	}

	return nil
}

func renderTapEvents(tapByteStream *bufio.Reader, w io.Writer, render renderTapEventFunc, resource string) error {
	for {
		log.Debug("Waiting for data...")
		event := pb.TapEvent{}
		err := protohttp.FromByteStreamToProtocolBuffers(tapByteStream, &event)
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			break
		}
		_, err = fmt.Fprintln(w, render(&event, resource))
		if err != nil {
			return err
		}
	}

	return nil
}

// renderTapEvent renders a Public API TapEvent to a string.
func renderTapEvent(event *pb.TapEvent, resource string) string {
	dst := dst(event)
	src := src(event)

	proxy := "???"
	tls := ""
	switch event.GetProxyDirection() {
	case pb.TapEvent_INBOUND:
		proxy = "in " // A space is added so it aligns with `out`.
		tls = src.tlsStatus()
	case pb.TapEvent_OUTBOUND:
		proxy = "out"
		tls = dst.tlsStatus()
	default:
		// Too old for TLS.
	}

	flow := fmt.Sprintf("proxy=%s %s %s tls=%s",
		proxy,
		src.formatAddr(),
		dst.formatAddr(),
		tls,
	)

	// If `resource` is non-empty, then
	resources := ""
	if resource != "" {
		resources = fmt.Sprintf(
			"%s%s%s",
			src.formatResource(resource),
			dst.formatResource(resource),
			routeLabels(event),
		)
	}

	switch ev := event.GetHttp().GetEvent().(type) {
	case *pb.TapEvent_Http_RequestInit_:
		return fmt.Sprintf("req id=%d:%d %s :method=%s :authority=%s :path=%s%s",
			ev.RequestInit.GetId().GetBase(),
			ev.RequestInit.GetId().GetStream(),
			flow,
			ev.RequestInit.GetMethod().GetRegistered().String(),
			ev.RequestInit.GetAuthority(),
			ev.RequestInit.GetPath(),
			resources,
		)

	case *pb.TapEvent_Http_ResponseInit_:
		return fmt.Sprintf("rsp id=%d:%d %s :status=%d latency=%dµs%s",
			ev.ResponseInit.GetId().GetBase(),
			ev.ResponseInit.GetId().GetStream(),
			flow,
			ev.ResponseInit.GetHttpStatus(),
			ev.ResponseInit.GetSinceRequestInit().GetNanos()/1000,
			resources,
		)

	case *pb.TapEvent_Http_ResponseEnd_:
		switch eos := ev.ResponseEnd.GetEos().GetEnd().(type) {
		case *pb.Eos_GrpcStatusCode:
			return fmt.Sprintf(
				"end id=%d:%d %s grpc-status=%s duration=%dµs response-length=%dB%s",
				ev.ResponseEnd.GetId().GetBase(),
				ev.ResponseEnd.GetId().GetStream(),
				flow,
				codes.Code(eos.GrpcStatusCode),
				ev.ResponseEnd.GetSinceResponseInit().GetNanos()/1000,
				ev.ResponseEnd.GetResponseBytes(),
				resources,
			)

		case *pb.Eos_ResetErrorCode:
			return fmt.Sprintf(
				"end id=%d:%d %s reset-error=%+v duration=%dµs response-length=%dB%s",
				ev.ResponseEnd.GetId().GetBase(),
				ev.ResponseEnd.GetId().GetStream(),
				flow,
				eos.ResetErrorCode,
				ev.ResponseEnd.GetSinceResponseInit().GetNanos()/1000,
				ev.ResponseEnd.GetResponseBytes(),
				resources,
			)

		default:
			return fmt.Sprintf("end id=%d:%d %s duration=%dµs response-length=%dB%s",
				ev.ResponseEnd.GetId().GetBase(),
				ev.ResponseEnd.GetId().GetStream(),
				flow,
				ev.ResponseEnd.GetSinceResponseInit().GetNanos()/1000,
				ev.ResponseEnd.GetResponseBytes(),
				resources,
			)
		}

	default:
		return fmt.Sprintf("unknown %s", flow)
	}
}

// renderTapEventJSON renders a Public API TapEvent to a string in JSON format.
func renderTapEventJSON(event *pb.TapEvent, _ string) string {
	m := mapPublicToDisplayTapEvent(event)
	e, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return fmt.Sprintf("{\"error marshalling JSON\": \"%s\"}", err)
	}
	return fmt.Sprintf("%s", e)
}

// Map public API `TapEvent`s to `displayTapEvent`s
func mapPublicToDisplayTapEvent(event *pb.TapEvent) *tapEvent {
	// Map source endpoint
	sip := addr.PublicIPToString(event.GetSource().GetIp())
	src := &endpoint{
		IP:       sip,
		Port:     event.GetSource().GetPort(),
		Metadata: event.GetSourceMeta().GetLabels(),
	}

	// Map destination endpoint
	dip := addr.PublicIPToString(event.GetDestination().GetIp())
	dst := &endpoint{
		IP:       dip,
		Port:     event.GetDestination().GetPort(),
		Metadata: event.GetDestinationMeta().GetLabels(),
	}

	return &tapEvent{
		Source:            src,
		Destination:       dst,
		RouteMeta:         event.GetRouteMeta().GetLabels(),
		ProxyDirection:    event.GetProxyDirection().String(),
		RequestInitEvent:  getRequestInitEvent(event.GetHttp()),
		ResponseInitEvent: getResponseInitEvent(event.GetHttp()),
		ResponseEndEvent:  getResponseEndEvent(event.GetHttp()),
	}
}

// Attempt to map a `TapEvent_Http_RequestInit event to a `requestInitEvent`
func getRequestInitEvent(pubEv *pb.TapEvent_Http) *requestInitEvent {
	reqI := pubEv.GetRequestInit()
	if reqI == nil {
		return nil
	}
	sid := &streamID{
		Base:   reqI.GetId().GetBase(),
		Stream: reqI.GetId().GetStream(),
	}
	return &requestInitEvent{
		ID:        sid,
		Method:    formatMethod(reqI.GetMethod()),
		Scheme:    formatScheme(reqI.GetScheme()),
		Authority: reqI.GetAuthority(),
		Path:      reqI.GetPath(),
		Headers:   formatHeadersTrailers(reqI.GetHeaders()),
	}
}

func formatMethod(m *pb.HttpMethod) string {
	if x, ok := m.GetType().(*pb.HttpMethod_Registered_); ok {
		return x.Registered.String()
	}
	if s, ok := m.GetType().(*pb.HttpMethod_Unregistered); ok {
		return s.Unregistered
	}
	return ""
}

func formatScheme(s *pb.Scheme) string {
	if x, ok := s.GetType().(*pb.Scheme_Registered_); ok {
		return x.Registered.String()
	}
	if str, ok := s.GetType().(*pb.Scheme_Unregistered); ok {
		return str.Unregistered
	}
	return ""
}

// Attempt to map a `TapEvent_Http_ResponseInit` event to a `responseInitEvent`
func getResponseInitEvent(pubEv *pb.TapEvent_Http) *responseInitEvent {
	resI := pubEv.GetResponseInit()
	if resI == nil {
		return nil
	}
	sid := &streamID{
		Base:   resI.GetId().GetBase(),
		Stream: resI.GetId().GetStream(),
	}
	return &responseInitEvent{
		ID:               sid,
		SinceRequestInit: resI.GetSinceRequestInit(),
		HTTPStatus:       resI.GetHttpStatus(),
		Headers:          formatHeadersTrailers(resI.GetHeaders()),
	}
}

// Attempt to map a `TapEvent_Http_ResponseEnd` event to a `responseEndEvent`
func getResponseEndEvent(pubEv *pb.TapEvent_Http) *responseEndEvent {
	resE := pubEv.GetResponseEnd()
	if resE == nil {
		return nil
	}
	sid := &streamID{
		Base:   resE.GetId().GetBase(),
		Stream: resE.GetId().GetStream(),
	}
	return &responseEndEvent{
		ID:                sid,
		SinceRequestInit:  resE.GetSinceRequestInit(),
		SinceResponseInit: resE.GetSinceResponseInit(),
		ResponseBytes:     resE.GetResponseBytes(),
		Trailers:          formatHeadersTrailers(resE.GetTrailers()),
		GrpcStatusCode:    resE.GetEos().GetGrpcStatusCode(),
		ResetErrorCode:    resE.GetEos().GetResetErrorCode(),
	}
}

func formatHeadersTrailers(hs *pb.Headers) []metadata {
	var fm []metadata
	for _, h := range hs.GetHeaders() {
		switch h.GetValue().(type) {
		case *pb.Headers_Header_ValueStr:
			fht := &metadataStr{Name: h.GetName(), ValueStr: h.GetValueStr()}
			fm = append(fm, fht)
			continue
		case *pb.Headers_Header_ValueBin:
			fht := &metadataBin{Name: h.GetName(), ValueBin: h.GetValueBin()}
			fm = append(fm, fht)
			continue
		}
	}
	return fm
}

// src returns the source peer of a `TapEvent`.
func src(event *pb.TapEvent) peer {
	return peer{
		address:   event.GetSource(),
		labels:    event.GetSourceMeta().GetLabels(),
		direction: "src",
	}
}

// dst returns the destination peer of a `TapEvent`.
func dst(event *pb.TapEvent) peer {
	return peer{
		address:   event.GetDestination(),
		labels:    event.GetDestinationMeta().GetLabels(),
		direction: "dst",
	}
}

type peer struct {
	address   *pb.TcpAddress
	labels    map[string]string
	direction string
}

// formatAddr formats the peer's TCP address for the `src` or `dst` element in
// the tap output corresponding to this peer.
func (p *peer) formatAddr() string {
	return fmt.Sprintf(
		"%s=%s",
		p.direction,
		addr.PublicAddressToString(p.address),
	)
}

// formatResource returns a label describing what Kubernetes resources the peer
// belongs to. If the peer belongs to a resource of kind `resourceKind`, it will
// return a label for that resource; otherwise, it will fall back to the peer's
// pod name. Additionally, if the resource is not of type `namespace`, it will
// also add a label describing the peer's resource.
func (p *peer) formatResource(resourceKind string) string {
	var s string
	if resourceName, exists := p.labels[resourceKind]; exists {
		kind := resourceKind
		if short := k8s.ShortNameFromCanonicalResourceName(resourceKind); short != "" {
			kind = short
		}
		s = fmt.Sprintf(
			" %s_res=%s/%s",
			p.direction,
			kind,
			resourceName,
		)
	} else if pod, hasPod := p.labels[k8s.Pod]; hasPod {
		s = fmt.Sprintf(" %s_pod=%s", p.direction, pod)
	}
	if resourceKind != k8s.Namespace {
		if ns, hasNs := p.labels[k8s.Namespace]; hasNs {
			s += fmt.Sprintf(" %s_ns=%s", p.direction, ns)
		}
	}
	return s
}

func (p *peer) tlsStatus() string {
	return p.labels["tls"]
}

func routeLabels(event *pb.TapEvent) string {
	out := ""
	for key, val := range event.GetRouteMeta().GetLabels() {
		out = fmt.Sprintf("%s rt_%s=%s", out, key, val)
	}

	return out
}
