package apis

import (
	"bytes"
	"context"
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"maps"
	"net"
	"net/http"
	"path"
	"strings"
	"syscall"
	"time"

	validation "github.com/go-ozzo/ozzo-validation/v4"
	"github.com/pocketbase/dbx"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/tools/auth"
	"github.com/pocketbase/pocketbase/tools/dbutils"
	"github.com/pocketbase/pocketbase/tools/filesystem"
	"github.com/pocketbase/pocketbase/tools/inflector"
	"golang.org/x/oauth2"
)

func recordAuthWithOAuth2(e *core.RequestEvent) error {
	collection, err := findAuthCollection(e)
	if err != nil {
		return err
	}

	if !collection.OAuth2.Enabled {
		return e.ForbiddenError("The collection is not configured to allow OAuth2 authentication.", nil)
	}

	var fallbackAuthRecord *core.Record
	if e.Auth != nil && e.Auth.Collection().Id == collection.Id {
		fallbackAuthRecord = e.Auth
	}

	e.Set(core.RequestEventKeyInfoContext, core.RequestInfoContextOAuth2)

	form := new(recordOAuth2LoginForm)
	form.collection = collection
	if err = e.BindBody(form); err != nil {
		return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
	}

	if form.RedirectUrl != "" && form.RedirectURL == "" {
		e.App.Logger().Warn("[recordAuthWithOAuth2] redirectUrl body param is deprecated and will be removed in the future. Please replace it with redirectURL.")
		form.RedirectURL = form.RedirectUrl
	}

	if err = form.validate(); err != nil {
		return firstApiError(err, e.BadRequestError("An error occurred while loading the submitted data.", err))
	}

	// exchange token for OAuth2 user info and locate existing ExternalAuth rel
	// ---------------------------------------------------------------

	// load provider configuration
	providerConfig, ok := collection.OAuth2.GetProviderConfig(form.Provider)
	if !ok {
		return e.InternalServerError("Missing or invalid provider config.", nil)
	}

	provider, err := providerConfig.InitProvider()
	if err != nil {
		return firstApiError(err, e.InternalServerError("Failed to init provider "+form.Provider, err))
	}

	ctx, cancel := context.WithTimeout(e.Request.Context(), 30*time.Second)
	defer cancel()

	provider.SetContext(ctx)
	provider.SetRedirectURL(form.RedirectURL)

	var opts []oauth2.AuthCodeOption

	if provider.PKCE() {
		opts = append(opts, oauth2.SetAuthURLParam("code_verifier", form.CodeVerifier))
	}

	// fetch token
	token, err := provider.FetchToken(form.Code, opts...)
	if err != nil {
		return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 token.", err))
	}

	// fetch external auth user
	authUser, err := provider.FetchAuthUser(token)
	if err != nil {
		return firstApiError(err, e.BadRequestError("Failed to fetch OAuth2 user.", err))
	}

	// Apple currently returns the user's name only as part of the first redirect data response
	// so we try to assign the [apis.oauth2SubscriptionRedirect] forwarded name.
	if form.Provider == auth.NameApple && authUser.Name == "" {
		nameKey := oauth2RedirectAppleNameStoreKeyPrefix + form.Code
		name, ok := e.App.Store().Get(nameKey).(string)
		if ok {
			e.App.Store().Remove(nameKey)
			authUser.Name = name
		} else {
			e.App.Logger().Debug("Missing or already removed Apple user's name")
		}
	}

	var authRecord *core.Record

	// check for existing relation with the auth collection
	externalAuthRel, err := e.App.FindFirstExternalAuthByExpr(dbx.HashExp{
		"collectionRef": form.collection.Id,
		"provider":      form.Provider,
		"providerId":    authUser.Id,
	})
	if err != nil && !errors.Is(err, sql.ErrNoRows) {
		return e.InternalServerError("Failed OAuth2 relation check.", err)
	}

	switch {
	case err == nil && externalAuthRel != nil:
		authRecord, err = e.App.FindRecordById(form.collection, externalAuthRel.RecordRef())
		if err != nil {
			return err
		}
	case fallbackAuthRecord != nil && fallbackAuthRecord.Collection().Id == form.collection.Id:
		// fallback to the logged auth record (if any)
		authRecord = fallbackAuthRecord
	case authUser.Email != "":
		// look for an existing auth record by the external auth record's email
		authRecord, err = e.App.FindAuthRecordByEmail(form.collection.Id, authUser.Email)
		if err != nil && !errors.Is(err, sql.ErrNoRows) {
			return e.InternalServerError("Failed OAuth2 auth record check.", err)
		}
	}

	// ---------------------------------------------------------------

	event := new(core.RecordAuthWithOAuth2RequestEvent)
	event.RequestEvent = e
	event.Collection = collection
	event.ProviderName = form.Provider
	event.ProviderClient = provider
	event.OAuth2User = authUser
	event.CreateData = form.CreateData
	event.Record = authRecord
	event.IsNewRecord = authRecord == nil

	return e.App.OnRecordAuthWithOAuth2Request().Trigger(event, func(e *core.RecordAuthWithOAuth2RequestEvent) error {
		if err := oauth2Submit(e, externalAuthRel); err != nil {
			return firstApiError(err, e.BadRequestError("Failed to authenticate.", err))
		}

		// @todo revert back to struct after removing the custom auth.AuthUser marshalization
		meta := map[string]any{}
		rawOAuth2User, err := json.Marshal(e.OAuth2User)
		if err != nil {
			return err
		}
		err = json.Unmarshal(rawOAuth2User, &meta)
		if err != nil {
			return err
		}
		meta["isNew"] = e.IsNewRecord

		return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOAuth2, meta)
	})
}

// -------------------------------------------------------------------

type recordOAuth2LoginForm struct {
	collection *core.Collection

	// Additional data that will be used for creating a new auth record
	// if an existing OAuth2 account doesn't exist.
	CreateData map[string]any `form:"createData" json:"createData"`

	// The name of the OAuth2 client provider (eg. "google")
	Provider string `form:"provider" json:"provider"`

	// The authorization code returned from the initial request.
	Code string `form:"code" json:"code"`

	// The optional PKCE code verifier as part of the code_challenge sent with the initial request.
	CodeVerifier string `form:"codeVerifier" json:"codeVerifier"`

	// The redirect url sent with the initial request.
	RedirectURL string `form:"redirectURL" json:"redirectURL"`

	// @todo
	// deprecated: use RedirectURL instead
	// RedirectUrl will be removed after dropping v0.22 support
	RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
}

func (form *recordOAuth2LoginForm) validate() error {
	return validation.ValidateStruct(form,
		validation.Field(&form.Provider, validation.Required, validation.Length(0, 100), validation.By(form.checkProviderName)),
		validation.Field(&form.Code, validation.Required),
	)
}

func (form *recordOAuth2LoginForm) checkProviderName(value any) error {
	name, _ := value.(string)

	_, ok := form.collection.OAuth2.GetProviderConfig(name)
	if !ok {
		return validation.NewError("validation_invalid_provider", "Provider with name {{.name}} is missing or is not enabled.").
			SetParams(map[string]any{"name": name})
	}

	return nil
}

// @todo evaluate if it is still worth keeping as this exists only for backward-compatibility with pre v0.23 versions
func oldCanAssignUsername(txApp core.App, collection *core.Collection, username string) bool {
	field := collection.Fields.GetByName(collection.OAuth2.MappedFields.Username)
	if field == nil {
		return false
	}

	// ensure that the value matches the pattern of the username field (if text)
	if txtField, ok := field.(*core.TextField); ok && txtField.ValidatePlainValue(username) != nil {
		return false
	}

	// ensure that username is unique
	index, hasUniqueue := dbutils.FindSingleColumnUniqueIndex(collection.Indexes, field.GetName())
	if hasUniqueue {
		// it is not required because collection fields are already sanitized
		// but normalize as an extra precaution in case of a custom validator
		colName := inflector.Columnify(field.GetName())

		var expr dbx.Expression
		if strings.EqualFold(index.Columns[0].Collate, "nocase") {
			// case-insensitive search
			expr = dbx.NewExp("[["+colName+"]] = {:username} COLLATE NOCASE", dbx.Params{"username": username})
		} else {
			expr = dbx.HashExp{colName: username}
		}

		var exists int
		_ = txApp.RecordQuery(collection).Select("(1)").AndWhere(expr).Limit(1).Row(&exists)
		if exists > 0 {
			return false
		}
	}

	return true
}

func oauth2Submit(e *core.RecordAuthWithOAuth2RequestEvent, optExternalAuth *core.ExternalAuth) error {
	return e.App.RunInTransaction(func(txApp core.App) error {
		if e.Record == nil {
			// extra check to prevent creating a superuser record via
			// OAuth2 in case the method is used by another action
			if e.Collection.Name == core.CollectionNameSuperusers {
				return errors.New("superusers are not allowed to sign-up with OAuth2")
			}

			payload := maps.Clone(e.CreateData)
			if payload == nil {
				payload = map[string]any{}
			}

			// assign the OAuth2 user email only if the user hasn't submitted one
			// (ignore empty/invalid values for consistency with the OAuth2->existing user update flow)
			if v, _ := payload[core.FieldNameEmail].(string); v == "" {
				payload[core.FieldNameEmail] = e.OAuth2User.Email
			}

			// map known fields (unless the field was explicitly submitted as part of CreateData)
			if _, ok := payload[e.Collection.OAuth2.MappedFields.Id]; !ok && e.Collection.OAuth2.MappedFields.Id != "" {
				payload[e.Collection.OAuth2.MappedFields.Id] = e.OAuth2User.Id
			}
			if _, ok := payload[e.Collection.OAuth2.MappedFields.Name]; !ok && e.Collection.OAuth2.MappedFields.Name != "" {
				payload[e.Collection.OAuth2.MappedFields.Name] = e.OAuth2User.Name
			}
			if _, ok := payload[e.Collection.OAuth2.MappedFields.Username]; !ok &&
				// no explicit username payload value and existing OAuth2 mapping
				e.Collection.OAuth2.MappedFields.Username != "" &&
				// extra checks for backward compatibility with earlier versions
				oldCanAssignUsername(txApp, e.Collection, e.OAuth2User.Username) {
				payload[e.Collection.OAuth2.MappedFields.Username] = e.OAuth2User.Username
			}
			if _, ok := payload[e.Collection.OAuth2.MappedFields.AvatarURL]; !ok &&
				// no explicit avatar payload value and existing OAuth2 mapping
				e.Collection.OAuth2.MappedFields.AvatarURL != "" &&
				// non-empty OAuth2 avatar url
				e.OAuth2User.AvatarURL != "" {
				mappedField := e.Collection.Fields.GetByName(e.Collection.OAuth2.MappedFields.AvatarURL)
				if mappedField != nil && mappedField.Type() == core.FieldTypeFile {
					// download the avatar if the mapped field is a file
					avatarFile, err := func() (*filesystem.File, error) {
						ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
						defer cancel()

						// the extra checks are not required because the OAuth2 APIs are trusted vendor
						// but are here to minimize the impact in case the provider is vulnerable
						return safeFileFromURL(ctx, e.OAuth2User.AvatarURL)
					}()
					if err != nil {
						txApp.Logger().Warn("Failed to retrieve OAuth2 avatar", slog.String("error", err.Error()))
					} else {
						payload[e.Collection.OAuth2.MappedFields.AvatarURL] = avatarFile
					}
				} else {
					// otherwise - assign the url string
					payload[e.Collection.OAuth2.MappedFields.AvatarURL] = e.OAuth2User.AvatarURL
				}
			}

			createdRecord, err := sendOAuth2RecordCreateRequest(txApp, e, payload)
			if err != nil {
				return err
			}

			e.Record = createdRecord

			if e.Record.Email() == e.OAuth2User.Email && !e.Record.Verified() {
				// mark as verified as long as it matches the OAuth2 data (even if the email is empty)
				e.Record.SetVerified(true)
				if err := txApp.Save(e.Record); err != nil {
					return err
				}
			}
		} else {
			var needUpdate bool

			isLoggedAuthRecord := e.Auth != nil &&
				e.Auth.Id == e.Record.Id &&
				e.Auth.Collection().Id == e.Record.Collection().Id

			// prevent pre-hijacking with password auth
			//
			// reset the unverified user password in case the record was precreated by a malicious actor
			if !isLoggedAuthRecord && !e.Record.Verified() {
				needUpdate = true
				e.Record.SetRandomPassword()
			}

			// prevent pre-hijacking with different OAuth2 provider
			//
			// delete all other previous OAuth2 record links for the cases
			// when the user was precreated by malicious OAuth2 auth with custom payload data
			//
			// while this would be also done automatically on unverified -> verified upgrade,
			// doing it manually here ensures that a single unverified record could have
			// max 1 OAuth2 link to prevent further abuse when mixed with other auth flows
			if !e.Record.Verified() {
				err := txApp.DeleteAllExternalAuthsByRecord(e.Record)
				if err != nil {
					return err
				}
				optExternalAuth = nil // clear to allow recreate below
			}

			// update the existing auth record empty email if the data.OAuth2User has one
			// (this is in case previously the auth record was created
			// with an OAuth2 provider that didn't return an email address)
			if e.Record.Email() == "" && e.OAuth2User.Email != "" {
				needUpdate = true
				e.Record.SetEmail(e.OAuth2User.Email)
			}

			// update the existing auth record verified state
			// (only if the auth record doesn't have an email or the auth record email match with the one in data.OAuth2User)
			if !e.Record.Verified() && (e.Record.Email() == "" || e.Record.Email() == e.OAuth2User.Email) {
				needUpdate = true
				e.Record.SetVerified(true)
			}

			if needUpdate {
				if err := txApp.Save(e.Record); err != nil {
					return err
				}
			}
		}

		// create ExternalAuth relation if missing
		if optExternalAuth == nil {
			optExternalAuth = core.NewExternalAuth(txApp)
			optExternalAuth.SetCollectionRef(e.Record.Collection().Id)
			optExternalAuth.SetRecordRef(e.Record.Id)
			optExternalAuth.SetProvider(e.ProviderName)
			optExternalAuth.SetProviderId(e.OAuth2User.Id)

			if err := txApp.Save(optExternalAuth); err != nil {
				return fmt.Errorf("failed to save linked rel: %w", err)
			}
		}

		return nil
	})
}

func sendOAuth2RecordCreateRequest(txApp core.App, e *core.RecordAuthWithOAuth2RequestEvent, payload map[string]any) (*core.Record, error) {
	ir := &core.InternalRequest{
		Method: http.MethodPost,
		URL:    "/api/collections/" + e.Collection.Name + "/records",
		Body:   payload,
	}

	var createdRecord *core.Record
	response, err := processInternalRequest(txApp, e.RequestEvent, ir, core.RequestInfoContextOAuth2, func(data any) error {
		createdRecord, _ = data.(*core.Record)

		return nil
	})
	if err != nil {
		return nil, err
	}

	if response.Status != http.StatusOK || createdRecord == nil {
		return nil, errors.New("failed to create OAuth2 auth record")
	}

	return createdRecord, nil
}

// -------------------------------------------------------------------

// safeHTTPClient initializes a custom http.Client with extra host checks
// to prevent internal network probing requests
// (aka. disallow loopback, private, multicast, etc. requests).
//
// NB! The host checks are not perfect and there are probably edge cases that are not covered,
// so if you plan using with untrusted user URL, consider performing additional whitelist checks.
//
// @todo Evaluate with the refactoring if worth exporting(+tests) and moving under the security package.
func safeHTTPClient() *http.Client {
	dialer := &net.Dialer{
		// the same options as in http.DefaultTransport.DialContext
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,

		// check the address right after estrablishing the connection to prevent dns rebinding
		Control: func(network, address string, c syscall.RawConn) error {
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				return err
			}

			ip := net.ParseIP(host)

			if ip == nil ||
				ip.IsLoopback() ||
				ip.IsUnspecified() ||
				ip.IsPrivate() ||
				ip.IsLinkLocalUnicast() ||
				ip.IsLinkLocalMulticast() ||
				ip.IsMulticast() {
				return fmt.Errorf("address %q is invalid or resolve to disallowed IP", address)
			}

			return nil
		},
	}

	return &http.Client{
		Timeout: 180 * time.Second, // can be still cancelled with the request context
		Transport: &http.Transport{
			DialContext: dialer.DialContext,
			// the same options as in http.DefaultTransport
			ForceAttemptHTTP2:     true,
			MaxIdleConns:          100,
			IdleConnTimeout:       90 * time.Second,
			TLSHandshakeTimeout:   10 * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
		},
	}
}

// safeFileFromURL downloads the file from the specified url (using safeHTTPClient)
// and creates a new filesystem.File value from its content (limited to DefaultMaxBodySize).
//
// @todo Evaluate with the refactoring if worth exporting/replacing filesystem.NewFileFromURL (or redefine as NewUnsafeFileFromURL).
func safeFileFromURL(ctx context.Context, url string) (*filesystem.File, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, err
	}

	client := safeHTTPClient()

	res, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()

	if res.StatusCode < 200 || res.StatusCode > 399 {
		return nil, fmt.Errorf("failed to download url %s (%d)", url, res.StatusCode)
	}

	body := io.LimitReader(res.Body, DefaultMaxBodySize)

	var buf bytes.Buffer
	if _, err = io.Copy(&buf, body); err != nil {
		return nil, err
	}

	return filesystem.NewFileFromBytes(buf.Bytes(), path.Base(url))
}
