package authorize

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"maps"
	"net/http"
	"net/http/httptest"
	"net/url"
	"sort"
	"strings"

	envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
	envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
	envoy_type_v3 "github.com/envoyproxy/go-control-plane/envoy/type/v3"
	"github.com/tniswong/go.rfcx/rfc7231"
	"google.golang.org/genproto/googleapis/rpc/status"
	"google.golang.org/grpc/codes"

	"github.com/pomerium/pomerium/authorize/evaluator"
	"github.com/pomerium/pomerium/internal/httputil"
	"github.com/pomerium/pomerium/internal/log"
	"github.com/pomerium/pomerium/internal/urlutil"
	"github.com/pomerium/pomerium/pkg/policy/criteria"
	"github.com/pomerium/pomerium/pkg/telemetry/requestid"
	"github.com/pomerium/pomerium/pkg/webauthnutil"
)

func (a *Authorize) handleResult(
	ctx context.Context,
	in *envoy_service_auth_v3.CheckRequest,
	request *evaluator.Request,
	result *evaluator.Result,
) (*envoy_service_auth_v3.CheckResponse, error) {
	// If a client certificate is required, but the client did not provide a
	// valid certificate, deny right away. Do not redirect to authenticate.
	if invalidClientCertReason(result.Deny.Reasons) {
		return a.handleResultDenied(ctx, in, request, result, result.Deny.Reasons)
	}

	// when the user is unauthenticated it means they haven't
	// logged in yet, so redirect to authenticate
	if result.Allow.Reasons.Has(criteria.ReasonUserUnauthenticated) ||
		result.Deny.Reasons.Has(criteria.ReasonUserUnauthenticated) {
		return a.requireLoginResponse(ctx, in, request)
	}

	// when the user's device is unauthenticated it means they haven't
	// registered a webauthn device yet, so redirect to the webauthn flow
	if result.Allow.Reasons.Has(criteria.ReasonDeviceUnauthenticated) ||
		result.Deny.Reasons.Has(criteria.ReasonDeviceUnauthenticated) {
		return a.requireWebAuthnResponse(ctx, in, request, result)
	}

	// if there's a deny, the result is denied using the deny reasons.
	if result.Deny.Value {
		return a.handleResultDenied(ctx, in, request, result, result.Deny.Reasons)
	}

	// if there's an allow, the result is allowed.
	if result.Allow.Value {
		return a.handleResultAllowed(ctx, in, result)
	}

	// otherwise, the result is denied using the allow reasons.
	return a.handleResultDenied(ctx, in, request, result, result.Allow.Reasons)
}

func (a *Authorize) handleResultAllowed(
	_ context.Context,
	_ *envoy_service_auth_v3.CheckRequest,
	result *evaluator.Result,
) (*envoy_service_auth_v3.CheckResponse, error) {
	return a.okResponse(result.Headers), nil
}

func (a *Authorize) handleResultDenied(
	ctx context.Context,
	in *envoy_service_auth_v3.CheckRequest,
	request *evaluator.Request,
	result *evaluator.Result,
	reasons criteria.Reasons,
) (*envoy_service_auth_v3.CheckResponse, error) {
	denyStatusCode := int32(http.StatusForbidden)
	denyStatusText := http.StatusText(http.StatusForbidden)

	switch {
	case reasons.Has(criteria.ReasonDeviceUnauthenticated):
		return a.requireWebAuthnResponse(ctx, in, request, result)
	case reasons.Has(criteria.ReasonDeviceUnauthorized):
		denyStatusCode = httputil.StatusDeviceUnauthorized
		denyStatusText = httputil.DetailsText(httputil.StatusDeviceUnauthorized)
	case reasons.Has(criteria.ReasonRouteNotFound):
		denyStatusCode = http.StatusNotFound
		denyStatusText = httputil.DetailsText(http.StatusNotFound)
	case invalidClientCertReason(reasons):
		denyStatusCode = httputil.StatusInvalidClientCertificate
		denyStatusText = httputil.DetailsText(httputil.StatusInvalidClientCertificate)
	}

	return a.deniedResponse(ctx, in, denyStatusCode, denyStatusText, nil)
}

func invalidClientCertReason(reasons criteria.Reasons) bool {
	return reasons.Has(criteria.ReasonClientCertificateRequired) ||
		reasons.Has(criteria.ReasonInvalidClientCertificate)
}

func (a *Authorize) okResponse(headers http.Header) *envoy_service_auth_v3.CheckResponse {
	return &envoy_service_auth_v3.CheckResponse{
		Status: &status.Status{Code: int32(codes.OK), Message: "OK"},
		HttpResponse: &envoy_service_auth_v3.CheckResponse_OkResponse{
			OkResponse: &envoy_service_auth_v3.OkHttpResponse{
				Headers: toEnvoyHeaders(headers),
			},
		},
	}
}

func (a *Authorize) deniedResponse(
	ctx context.Context,
	in *envoy_service_auth_v3.CheckRequest,
	code int32, reason string, headers http.Header,
) (*envoy_service_auth_v3.CheckResponse, error) {
	if headers == nil {
		headers = make(http.Header)
	}

	var respBody []byte

	hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
	userAgent := getHeader(hdrs, "User-Agent")
	switch {
	case strings.Contains(userAgent, "kubernetes/"):
		message := reason
		var statusReason string
		switch code {
		case http.StatusUnauthorized:
			statusReason = "Unauthorized"
		case http.StatusForbidden:
			statusReason = "Forbidden"
		case http.StatusNotFound:
			statusReason = "NotFound"
		case httputil.StatusDeviceUnauthorized, httputil.StatusInvalidClientCertificate:
			statusReason = "Unauthorized"
			message = httputil.DetailsText(int(code))
		default:
			statusReason = "" // StatusReasonUnknown
		}
		respBody, _ = json.Marshal(map[string]any{
			"apiVersion": "v1",
			"kind":       "Status",
			"status":     "Failure",    // one of "Success" or "Failure"
			"message":    message,      // user-facing message
			"reason":     statusReason, // must correspond to k8s StatusReason strings
			"code":       code,         // http code
		})
		headers.Set("Content-Type", "application/json")
	case getCheckRequestURL(in).Path == "/robots.txt":
		code = 200
		respBody = []byte("User-agent: *\nDisallow: /")
		headers.Set("Content-Type", "text/plain")
	case isJSONWebRequest(in):
		respBody, _ = json.Marshal(map[string]any{
			"error":      reason,
			"request_id": requestid.FromContext(ctx),
		})
		headers.Set("Content-Type", "application/json")
	case isGRPCRequest(in):
		return deniedResponseForGRPC(code, reason, headers), nil
	case isGRPCWebRequest(in):
		return deniedResponseForGRPCWeb(code, reason, headers), nil
	default:
		// create a http response writer recorder
		w := httptest.NewRecorder()
		r := getHTTPRequestFromCheckRequest(in)

		// build the user info / debug endpoint
		debugEndpoint, _ := a.userInfoEndpointURL(in) // if there's an error, we just wont display it

		// run the request through our go error handler
		httpErr := httputil.HTTPError{
			Status:          int(code),
			Err:             errors.New(reason),
			DebugURL:        debugEndpoint,
			RequestID:       requestid.FromContext(ctx),
			BrandingOptions: a.currentOptions.Load().BrandingOptions,
		}
		httpErr.ErrorResponse(ctx, w, r)

		// transpose the go http response writer into a envoy response
		resp := w.Result()
		defer resp.Body.Close()

		var err error
		respBody, err = io.ReadAll(resp.Body)
		if err != nil {
			log.Ctx(ctx).Error().Err(err).Msg("error executing error template")
			return nil, err
		}
		for k, vs := range resp.Header {
			headers[k] = vs
		}
	}

	return mkDeniedCheckResponse(code, headers, string(respBody)), nil
}

func (a *Authorize) requireLoginResponse(
	ctx context.Context,
	in *envoy_service_auth_v3.CheckRequest,
	request *evaluator.Request,
) (*envoy_service_auth_v3.CheckResponse, error) {
	options := a.currentOptions.Load()
	state := a.state.Load()

	if !a.shouldRedirect(in) {
		return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
	}

	idp, err := options.GetIdentityProviderForPolicy(request.Policy)
	if err != nil {
		return nil, err
	}

	// always assume https scheme
	checkRequestURL := getCheckRequestURL(in)
	checkRequestURL.Scheme = "https"
	var signInURLQuery url.Values

	headers := http.Header{}
	if id := in.GetAttributes().GetRequest().GetHttp().GetHeaders()["traceparent"]; id != "" {
		signInURLQuery = url.Values{}
		signInURLQuery.Add("pomerium_traceparent", id)
	}
	redirectTo, err := state.authenticateFlow.AuthenticateSignInURL(
		ctx, signInURLQuery, &checkRequestURL, idp.GetId())
	if err != nil {
		return nil, err
	}
	headers["Location"] = []string{redirectTo}

	return a.deniedResponse(ctx, in, http.StatusFound, "Login", headers)
}

func (a *Authorize) requireWebAuthnResponse(
	ctx context.Context,
	in *envoy_service_auth_v3.CheckRequest,
	request *evaluator.Request,
	result *evaluator.Result,
) (*envoy_service_auth_v3.CheckResponse, error) {
	opts := a.currentOptions.Load()
	state := a.state.Load()

	// always assume https scheme
	checkRequestURL := getCheckRequestURL(in)
	checkRequestURL.Scheme = "https"

	// If we're already on a webauthn route, return OK.
	// https://github.com/pomerium/pomerium-console/issues/3210
	if checkRequestURL.Path == urlutil.WebAuthnURLPath || checkRequestURL.Path == urlutil.DeviceEnrolledPath {
		return a.okResponse(result.Headers), nil
	}

	if !a.shouldRedirect(in) {
		return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
	}

	q := url.Values{}
	if deviceType, ok := result.Allow.AdditionalData["device_type"].(string); ok {
		q.Set(urlutil.QueryDeviceType, deviceType)
	} else if deviceType, ok := result.Deny.AdditionalData["device_type"].(string); ok {
		q.Set(urlutil.QueryDeviceType, deviceType)
	} else {
		q.Set(urlutil.QueryDeviceType, webauthnutil.DefaultDeviceType)
	}
	q.Set(urlutil.QueryRedirectURI, checkRequestURL.String())
	idp, err := opts.GetIdentityProviderForPolicy(request.Policy)
	if err != nil {
		return nil, err
	}
	q.Set(urlutil.QueryIdentityProviderID, idp.GetId())
	signinURL := urlutil.WebAuthnURL(getHTTPRequestFromCheckRequest(in), &checkRequestURL, state.sharedKey, q)
	return a.deniedResponse(ctx, in, http.StatusFound, "Login", http.Header{
		"Location": {signinURL},
	})
}

func mkDeniedCheckResponse(httpStatusCode int32, headers http.Header, body string) *envoy_service_auth_v3.CheckResponse {
	return &envoy_service_auth_v3.CheckResponse{
		Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
		HttpResponse: &envoy_service_auth_v3.CheckResponse_DeniedResponse{
			DeniedResponse: &envoy_service_auth_v3.DeniedHttpResponse{
				Status: &envoy_type_v3.HttpStatus{
					Code: envoy_type_v3.StatusCode(httpStatusCode),
				},
				Headers: toEnvoyHeaders(headers),
				Body:    body,
			},
		},
	}
}

func mkHeader(k, v string) *envoy_config_core_v3.HeaderValueOption {
	return &envoy_config_core_v3.HeaderValueOption{
		Header: &envoy_config_core_v3.HeaderValue{
			Key:   k,
			Value: v,
		},
		AppendAction: envoy_config_core_v3.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD,
	}
}

func toEnvoyHeaders(headers http.Header) []*envoy_config_core_v3.HeaderValueOption {
	envoyHeaders := make([]*envoy_config_core_v3.HeaderValueOption, 0, len(headers))
	for k, vs := range maps.All(headers) {
		envoyHeaders = append(envoyHeaders, mkHeader(k, strings.Join(vs, ",")))
	}
	sort.Slice(envoyHeaders, func(i, j int) bool {
		return envoyHeaders[i].GetHeader().GetKey() < envoyHeaders[j].GetHeader().GetKey()
	})
	return envoyHeaders
}

// userInfoEndpointURL returns the user info endpoint url which can be used to debug the user's
// session that lives on the authenticate service.
func (a *Authorize) userInfoEndpointURL(in *envoy_service_auth_v3.CheckRequest) (*url.URL, error) {
	opts := a.currentOptions.Load()
	authenticateURL, err := opts.GetAuthenticateURL()
	if err != nil {
		return nil, err
	}
	debugEndpoint := authenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/"})

	r := getHTTPRequestFromCheckRequest(in)
	redirectURL := urlutil.GetAbsoluteURL(r).String()
	if ref := r.Header.Get(httputil.HeaderReferrer); ref != "" {
		redirectURL = ref
	}

	debugEndpoint = debugEndpoint.ResolveReference(&url.URL{
		RawQuery: url.Values{
			urlutil.QueryRedirectURI: {redirectURL},
		}.Encode(),
	})

	return urlutil.NewSignedURL(a.state.Load().sharedKey, debugEndpoint).Sign(), nil
}

func (a *Authorize) shouldRedirect(in *envoy_service_auth_v3.CheckRequest) bool {
	requestHeaders := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
	if requestHeaders == nil {
		return true
	}

	if isGRPCRequest(in) {
		return false
	}

	accept, err := rfc7231.ParseAccept(requestHeaders["accept"])
	if err != nil {
		return true
	}

	mediaType, ok := accept.MostAcceptable([]string{
		"text/html",
		"application/json",
		"text/plain",
		"application/grpc-web-text",
		"application/grpc-web+proto",
		"application/grpc+proto",
	})
	if !ok {
		return true
	}

	return mediaType == "text/html"
}

func isJSONWebRequest(in *envoy_service_auth_v3.CheckRequest) bool {
	hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
	if hdrs == nil {
		return false
	}

	v := getHeader(hdrs, "Accept")
	if v == "" {
		return false
	}

	accept, err := rfc7231.ParseAccept(v)
	if err != nil {
		return false
	}

	mediaType, _ := accept.MostAcceptable([]string{
		"text/html",
		"application/json",
	})
	return mediaType == "application/json"
}

func getHeader(hdrs map[string]string, key string) string {
	for k, v := range hdrs {
		if strings.EqualFold(k, key) {
			return v
		}
	}
	return ""
}