package agentw

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"strconv"
	"time"

	"buf.build/go/protovalidate"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modagent"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/modshared"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/module/workspaces/rpc"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/grpctool"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/httpz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/prototool"
	"google.golang.org/grpc"
)

type tunnelServer struct {
	rpc.UnsafeWorkspacesServer

	log          *slog.Logger
	roundTripper http.RoundTripper
	validator    protovalidate.Validator

	userAgent string
	version   string
	via       string
}

func newTunnelServer(log *slog.Logger, roundTripper http.RoundTripper, validator protovalidate.Validator, userAgent, version, via string) *tunnelServer {
	return &tunnelServer{
		log:          log,
		roundTripper: roundTripper,
		validator:    validator,
		userAgent:    userAgent,
		version:      version,
		via:          via,
	}
}

// TunnelHTTP tunnels grpc to http
func (s *tunnelServer) TunnelHTTP(server grpc.BidiStreamingServer[grpctool.HttpRequest, grpctool.HttpResponse]) error {
	rpcAPI := modshared.RPCAPIFromContext[modagent.RPCAPI](server.Context())
	log := rpcAPI.Log()
	grpc2http := grpctool.InboundGRPCToOutboundHTTP{
		Log: log,
		HandleProcessingError: func(msg string, err error) {
			var ne *net.OpError
			if errors.As(err, &ne) {
				// Errors like connection refused should not be reported to Sentry. Hence, we exit early.
				log.Debug(msg, logz.Error(err))
				return
			}
			rpcAPI.HandleProcessingError(log, msg, err)
		},
		HandleIOError: func(msg string, err error) error {
			return rpcAPI.HandleIOError(log, msg, err)
		},
		HTTPDo: s.makeRequestHTTPDo,
	}
	// TODO: https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues/754
	//       Errors like connection refused should be returned from the target as is.
	//       Currently, grpc2http.Pipe handles these errors and wraps it up.
	return grpc2http.Pipe(server)
}

func (s *tunnelServer) makeRequestHTTPDo(ctx context.Context, h *grpctool.HttpRequest_Header, body io.Reader) (grpctool.DoResponse, error) {
	// 1. Extract port from HTTPHeaderExtra
	var headerExtra rpc.HTTPHeaderExtra
	err := h.Extra.UnmarshalTo(&headerExtra)
	if err != nil {
		return grpctool.DoResponse{}, err
	}
	err = s.validator.Validate(&headerExtra)
	if err != nil {
		return grpctool.DoResponse{}, fmt.Errorf("invalid HTTPHeaderExtra: %w", err)
	}
	// 2. Construct request
	req, err := s.makeRequestNewRequest(ctx, h.Request, body, &headerExtra)
	if err != nil {
		return grpctool.DoResponse{}, err
	}
	// 3. Construct round tripper
	var (
		upgradeRT *httpz.UpgradeRoundTripper
	)
	rt := s.roundTripper
	isUpgrade := h.Request.IsUpgrade()
	if isUpgrade {
		upgradeRT = &httpz.UpgradeRoundTripper{
			Dialer: &net.Dialer{
				Timeout: 30 * time.Second,
			},
		}
		rt = upgradeRT
	}
	// 4. Make a request
	resp, err := rt.RoundTrip(req) //nolint: bodyclose
	if err != nil {
		ctxErr := ctx.Err()
		if ctxErr != nil {
			err = ctxErr // assume request errored out because of context
		}
		return grpctool.DoResponse{}, err
	}
	resp.Header[httpz.ViaHeader] = append(resp.Header[httpz.ViaHeader], fmt.Sprintf("%d.%d %s", resp.ProtoMajor, resp.ProtoMinor, s.userAgent))
	resp.Header[httpz.GitlabAgentVersionHeader] = []string{s.version}
	if isUpgrade {
		return grpctool.DoResponse{
			Resp:        resp,
			UpgradeConn: upgradeRT.Conn,
			ConnReader:  upgradeRT.ConnReader,
		}, nil
	} else {
		return grpctool.DoResponse{
			Resp: resp,
		}, nil
	}
}

func (s *tunnelServer) makeRequestNewRequest(ctx context.Context, requestInfo *prototool.HttpRequest, body io.Reader, headerExtra *rpc.HTTPHeaderExtra) (*http.Request, error) {
	// TODO: https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/issues/755
	//       The server can be listening on an IP which is not localhost.
	u := url.URL{
		Scheme:   "http",
		Host:     net.JoinHostPort("localhost", strconv.FormatUint(uint64(headerExtra.Port), 10)),
		Path:     requestInfo.UrlPath,
		RawQuery: requestInfo.URLQuery().Encode(),
	}

	req, err := http.NewRequestWithContext(ctx, requestInfo.Method, u.String(), body)
	if err != nil {
		return nil, err
	}
	headers := requestInfo.HTTPHeader()
	if headers == nil {
		headers = http.Header{}
	}
	req.Header = headers
	req.Header[httpz.ViaHeader] = append(req.Header[httpz.ViaHeader], s.via)

	return req, nil
}
