package middleware

import (
	"crypto/sha512"
	"encoding/hex"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/owncloud/ocis/v2/ocis-pkg/log"
	"github.com/owncloud/ocis/v2/services/proxy/pkg/config"
	"github.com/owncloud/ocis/v2/services/proxy/pkg/user/backend"
	"github.com/owncloud/ocis/v2/services/proxy/pkg/userroles"
	revactx "github.com/owncloud/reva/v2/pkg/ctx"
	microstore "go-micro.dev/v4/store"
	"golang.org/x/crypto/pbkdf2"
)

const (
	_paramOCSignature  = "OC-Signature"
	_paramOCCredential = "OC-Credential" // #nosec G101
	_paramOCDate       = "OC-Date"
	_paramOCExpires    = "OC-Expires"
	_paramOCVerb       = "OC-Verb"
	_paramOCAlgo       = "OC-Algo"
)

var (
	_requiredParams = [...]string{
		_paramOCSignature,
		_paramOCCredential,
		_paramOCDate,
		_paramOCExpires,
		_paramOCVerb,
	}
)

// SignedURLAuthenticator is the authenticator responsible for authenticating signed URL requests.
type SignedURLAuthenticator struct {
	Logger             log.Logger
	PreSignedURLConfig config.PreSignedURL
	UserProvider       backend.UserBackend
	UserRoleAssigner   userroles.UserRoleAssigner
	Store              microstore.Store
	Now                func() time.Time
}

func (m SignedURLAuthenticator) shouldServe(req *http.Request) bool {
	if !m.PreSignedURLConfig.Enabled {
		return false
	}
	return req.URL.Query().Get(_paramOCSignature) != ""
}

func (m SignedURLAuthenticator) validate(req *http.Request) (err error) {
	query := req.URL.Query()

	if err := m.allRequiredParametersArePresent(query); err != nil {
		return err
	}

	if err := m.requestMethodMatches(req.Method, query); err != nil {
		return err
	}

	if err := m.requestMethodIsAllowed(req.Method); err != nil {
		return err
	}

	if err = m.urlIsExpired(query); err != nil {
		return err
	}

	if err := m.signatureIsValid(req); err != nil {
		return err
	}

	return nil
}

func (m SignedURLAuthenticator) allRequiredParametersArePresent(query url.Values) (err error) {
	// check if required query parameters exist in given request query parameters
	// OC-Signature - the computed signature - server will verify the request upon this REQUIRED
	// OC-Credential - defines the user scope (shall we use the owncloud user id here - this might leak internal data ....) REQUIRED
	// OC-Date - defined the date the url was signed (ISO 8601 UTC) REQUIRED
	// OC-Expires - defines the expiry interval in seconds (between 1 and 604800 = 7 days) REQUIRED
	// TODO OC-Verb - defines for which http verb the request is valid - defaults to GET OPTIONAL
	for _, p := range _requiredParams {
		if query.Get(p) == "" {
			return fmt.Errorf("required %s parameter not found", p)
		}
	}

	return nil
}

func (m SignedURLAuthenticator) requestMethodMatches(meth string, query url.Values) (err error) {
	// check if given url query parameter OC-Verb matches given request method
	if !strings.EqualFold(meth, query.Get(_paramOCVerb)) {
		return errors.New("required OC-Verb parameter did not match request method")
	}

	return nil
}

func (m SignedURLAuthenticator) requestMethodIsAllowed(meth string) (err error) {
	//  check if given request method is allowed
	methodIsAllowed := false
	for _, am := range m.PreSignedURLConfig.AllowedHTTPMethods {
		if strings.EqualFold(meth, am) {
			methodIsAllowed = true
			break
		}
	}

	if !methodIsAllowed {
		return errors.New("request method is not listed in PreSignedURLConfig AllowedHTTPMethods")
	}

	return nil
}

func (m SignedURLAuthenticator) urlIsExpired(query url.Values) (err error) {
	// check if url is expired by checking if given date (OC-Date) + expires in seconds (OC-Expires) is after now
	validFrom, err := time.Parse(time.RFC3339, query.Get(_paramOCDate))
	if err != nil {
		return err
	}

	requestExpiry, err := time.ParseDuration(query.Get(_paramOCExpires) + "s")
	if err != nil {
		return err
	}

	validTo := validFrom.Add(requestExpiry)
	if !(m.Now().Before(validTo)) {
		return errors.New("URL is expired")
	}

	return nil
}

func (m SignedURLAuthenticator) signatureIsValid(req *http.Request) (err error) {
	c := revactx.ContextMustGetUser(req.Context())
	signingKey, err := m.Store.Read(c.Id.OpaqueId)
	if err != nil {
		m.Logger.Error().Err(err).Msg("could not retrieve signing key")
		return err
	}
	if len(signingKey[0].Value) == 0 {
		m.Logger.Error().Err(err).Msg("signing key empty")
		return err
	}
	u := m.buildUrlToSign(req)
	computedSignature := m.createSignature(u, signingKey[0].Value)
	signatureInURL := req.URL.Query().Get(_paramOCSignature)
	if computedSignature == signatureInURL {
		return nil
	}

	// try a workaround for https://github.com/owncloud/ocis/issues/10180
	// Some reverse proxies might replace $ with %24 in the URL leading to a mismatch in the signature
	u = strings.Replace(u, "$", "%24", 1)
	computedSignature = m.createSignature(u, signingKey[0].Value)
	signatureInURL = req.URL.Query().Get(_paramOCSignature)
	if computedSignature == signatureInURL {
		return nil
	}

	return fmt.Errorf("signature mismatch: expected %s != actual %s", computedSignature, signatureInURL)
}

func (m SignedURLAuthenticator) buildUrlToSign(req *http.Request) string {
	q := req.URL.Query()

	// only params required for signing
	signParameters := make(url.Values)
	signParameters.Add(_paramOCCredential, q.Get(_paramOCCredential))
	signParameters.Add(_paramOCDate, q.Get(_paramOCDate))
	signParameters.Add(_paramOCExpires, q.Get(_paramOCExpires))
	signParameters.Add(_paramOCVerb, q.Get(_paramOCVerb))

	// remaining query params
	q.Del(_paramOCAlgo)
	q.Del(_paramOCCredential)
	q.Del(_paramOCDate)
	q.Del(_paramOCExpires)
	q.Del(_paramOCSignature)
	q.Del(_paramOCVerb)

	url := *req.URL
	if len(q) == 0 {
		url.RawQuery = signParameters.Encode()
	} else {
		url.RawQuery = strings.Join([]string{q.Encode(), signParameters.Encode()}, "&")
	}
	u := url.String()
	if !url.IsAbs() {
		u = "https://" + req.Host + u // TODO where do we get the scheme
	}
	return u
}

func (m SignedURLAuthenticator) createSignature(url string, signingKey []byte) string {
	// the oc10 signature check: $hash = \hash_pbkdf2("sha512", $url, $signingKey, 10000, 64, false);
	// - sets the length of the output string to 64
	// - sets raw output to false ->  if raw_output is FALSE length corresponds to twice the byte-length of the derived key (as every byte of the key is returned as two hexits).
	// TODO change to length 128 in oc10?
	// fo golangs pbkdf2.Key we need to use 32 because it will be encoded into 64 hexits later
	hash := pbkdf2.Key([]byte(url), signingKey, 10000, 32, sha512.New)
	return hex.EncodeToString(hash)
}

// Authenticate implements the authenticator interface to authenticate requests via signed URL auth.
func (m SignedURLAuthenticator) Authenticate(r *http.Request) (*http.Request, bool) {
	if !m.shouldServe(r) {
		return nil, false
	}

	user, _, err := m.UserProvider.GetUserByClaims(r.Context(), "username", r.URL.Query().Get(_paramOCCredential))
	if err != nil {
		m.Logger.Error().
			Err(err).
			Str("authenticator", "signed_url").
			Str("path", r.URL.Path).
			Msg("Could not get user by claim")
		return nil, false
	}

	user, err = m.UserRoleAssigner.ApplyUserRole(r.Context(), user)
	if err != nil {
		m.Logger.Error().
			Err(err).
			Str("authenticator", "signed_url").
			Str("path", r.URL.Path).
			Msg("Could not get user by claim")
		return nil, false
	}

	ctx := revactx.ContextSetUser(r.Context(), user)

	r = r.WithContext(ctx)

	if err := m.validate(r); err != nil {
		m.Logger.Error().
			Err(err).
			Str("authenticator", "signed_url").
			Str("path", r.URL.Path).
			Str("url", r.URL.String()).
			Msg("Could not get user by claim")
		return nil, false
	}

	// TODO: set user in context
	m.Logger.Debug().
		Str("authenticator", "signed_url").
		Str("path", r.URL.Path).
		Msg("successfully authenticated request")
	return r, true
}
