package coderd

import (
	"database/sql"
	"errors"
	"fmt"
	"net/http"

	"github.com/sqlc-dev/pqtype"
	"golang.org/x/sync/errgroup"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/database/db2sdk"
	"github.com/coder/coder/v2/coderd/database/dbtime"
	"github.com/coder/coder/v2/coderd/externalauth"
	"github.com/coder/coder/v2/coderd/httpapi"
	"github.com/coder/coder/v2/coderd/httpmw"
	"github.com/coder/coder/v2/codersdk"
)

// @Summary Get external auth by ID
// @ID get-external-auth-by-id
// @Security CoderSessionToken
// @Tags Git
// @Produce json
// @Param externalauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.ExternalAuth
// @Router /external-auth/{externalauth} [get]
func (api *API) externalAuthByID(w http.ResponseWriter, r *http.Request) {
	config := httpmw.ExternalAuthParam(r)
	apiKey := httpmw.APIKey(r)
	ctx := r.Context()

	res := codersdk.ExternalAuth{
		Authenticated:    false,
		Device:           config.DeviceAuth != nil,
		AppInstallURL:    config.AppInstallURL,
		DisplayName:      config.DisplayName,
		AppInstallations: []codersdk.ExternalAuthAppInstallation{},
	}

	link, err := api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
		ProviderID: config.ID,
		UserID:     apiKey.UserID,
	})
	if err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to get external auth link.",
				Detail:  err.Error(),
			})
			return
		}

		httpapi.Write(ctx, w, http.StatusOK, res)
		return
	}
	var eg errgroup.Group
	eg.Go(func() (err error) {
		res.Authenticated, res.User, err = config.ValidateToken(ctx, link.OAuthToken())
		return err
	})
	eg.Go(func() (err error) {
		res.AppInstallations, res.AppInstallable, err = config.AppInstallations(ctx, link.OAuthAccessToken)
		return err
	})
	err = eg.Wait()
	if err != nil {
		httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to validate token.",
			Detail:  err.Error(),
		})
		return
	}
	if res.AppInstallations == nil {
		res.AppInstallations = []codersdk.ExternalAuthAppInstallation{}
	}
	httpapi.Write(ctx, w, http.StatusOK, res)
}

// deleteExternalAuthByID only deletes the link on the Coder side, does not revoke the token on the provider side.
//
// @Summary Delete external auth user link by ID
// @ID delete-external-auth-user-link-by-id
// @Security CoderSessionToken
// @Tags Git
// @Success 200
// @Param externalauth path string true "Git Provider ID" format(string)
// @Router /external-auth/{externalauth} [delete]
func (api *API) deleteExternalAuthByID(w http.ResponseWriter, r *http.Request) {
	config := httpmw.ExternalAuthParam(r)
	apiKey := httpmw.APIKey(r)
	ctx := r.Context()

	err := api.Database.DeleteExternalAuthLink(ctx, database.DeleteExternalAuthLinkParams{
		ProviderID: config.ID,
		UserID:     apiKey.UserID,
	})
	if err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			httpapi.ResourceNotFound(w)
			return
		}
		httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to delete external auth link.",
			Detail:  err.Error(),
		})
		return
	}

	httpapi.Write(ctx, w, http.StatusOK, "OK")
}

// @Summary Post external auth device by ID
// @ID post-external-auth-device-by-id
// @Security CoderSessionToken
// @Tags Git
// @Param externalauth path string true "External Provider ID" format(string)
// @Success 204
// @Router /external-auth/{externalauth}/device [post]
func (api *API) postExternalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	apiKey := httpmw.APIKey(r)
	config := httpmw.ExternalAuthParam(r)

	var req codersdk.ExternalAuthDeviceExchange
	if !httpapi.Read(ctx, rw, r, &req) {
		return
	}

	if config.DeviceAuth == nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Git auth provider does not support device flow.",
		})
		return
	}

	token, err := config.DeviceAuth.ExchangeDeviceCode(ctx, req.DeviceCode)
	if err != nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Failed to exchange device code.",
			Detail:  err.Error(),
		})
		return
	}

	_, err = api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
		ProviderID: config.ID,
		UserID:     apiKey.UserID,
	})
	if err != nil {
		if !errors.Is(err, sql.ErrNoRows) {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Failed to get external auth link.",
				Detail:  err.Error(),
			})
			return
		}

		_, err = api.Database.InsertExternalAuthLink(ctx, database.InsertExternalAuthLinkParams{
			ProviderID:             config.ID,
			UserID:                 apiKey.UserID,
			CreatedAt:              dbtime.Now(),
			UpdatedAt:              dbtime.Now(),
			OAuthAccessToken:       token.AccessToken,
			OAuthAccessTokenKeyID:  sql.NullString{}, // dbcrypt will set as required
			OAuthRefreshToken:      token.RefreshToken,
			OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will set as required
			OAuthExpiry:            token.Expiry,
			// No extra data from device auth!
			OAuthExtra: pqtype.NullRawMessage{},
		})
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Failed to insert external auth link.",
				Detail:  err.Error(),
			})
			return
		}
	} else {
		_, err = api.Database.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
			ProviderID:             config.ID,
			UserID:                 apiKey.UserID,
			UpdatedAt:              dbtime.Now(),
			OAuthAccessToken:       token.AccessToken,
			OAuthAccessTokenKeyID:  sql.NullString{}, // dbcrypt will update as required
			OAuthRefreshToken:      token.RefreshToken,
			OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
			OAuthExpiry:            token.Expiry,
			OAuthExtra:             pqtype.NullRawMessage{},
		})
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
				Message: "Failed to update external auth link.",
				Detail:  err.Error(),
			})
			return
		}
	}
	httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}

// @Summary Get external auth device by ID.
// @ID get-external-auth-device-by-id
// @Security CoderSessionToken
// @Produce json
// @Tags Git
// @Param externalauth path string true "Git Provider ID" format(string)
// @Success 200 {object} codersdk.ExternalAuthDevice
// @Router /external-auth/{externalauth}/device [get]
func (*API) externalAuthDeviceByID(rw http.ResponseWriter, r *http.Request) {
	config := httpmw.ExternalAuthParam(r)
	ctx := r.Context()

	if config.DeviceAuth == nil {
		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
			Message: "Git auth device flow not supported.",
		})
		return
	}

	deviceAuth, err := config.DeviceAuth.AuthorizeDevice(r.Context())
	if err != nil {
		httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Failed to authorize device.",
			Detail:  err.Error(),
		})
		return
	}

	httpapi.Write(ctx, rw, http.StatusOK, deviceAuth)
}

func (api *API) externalAuthCallback(externalAuthConfig *externalauth.Config) http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		var (
			ctx    = r.Context()
			state  = httpmw.OAuth2(r)
			apiKey = httpmw.APIKey(r)
		)

		extra, err := externalAuthConfig.GenerateTokenExtra(state.Token)
		if err != nil {
			httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
				Message: "Failed to generate token extra.",
				Detail:  err.Error(),
			})
			return
		}
		_, err = api.Database.GetExternalAuthLink(ctx, database.GetExternalAuthLinkParams{
			ProviderID: externalAuthConfig.ID,
			UserID:     apiKey.UserID,
		})
		if err != nil {
			if !errors.Is(err, sql.ErrNoRows) {
				httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
					Message: "Failed to get external auth link.",
					Detail:  err.Error(),
				})
				return
			}

			_, err = api.Database.InsertExternalAuthLink(ctx, database.InsertExternalAuthLinkParams{
				ProviderID:             externalAuthConfig.ID,
				UserID:                 apiKey.UserID,
				CreatedAt:              dbtime.Now(),
				UpdatedAt:              dbtime.Now(),
				OAuthAccessToken:       state.Token.AccessToken,
				OAuthAccessTokenKeyID:  sql.NullString{}, // dbcrypt will set as required
				OAuthRefreshToken:      state.Token.RefreshToken,
				OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will set as required
				OAuthExpiry:            state.Token.Expiry,
				OAuthExtra:             extra,
			})
			if err != nil {
				httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
					Message: "Failed to insert external auth link.",
					Detail:  err.Error(),
				})
				return
			}
		} else {
			_, err = api.Database.UpdateExternalAuthLink(ctx, database.UpdateExternalAuthLinkParams{
				ProviderID:             externalAuthConfig.ID,
				UserID:                 apiKey.UserID,
				UpdatedAt:              dbtime.Now(),
				OAuthAccessToken:       state.Token.AccessToken,
				OAuthAccessTokenKeyID:  sql.NullString{}, // dbcrypt will update as required
				OAuthRefreshToken:      state.Token.RefreshToken,
				OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
				OAuthExpiry:            state.Token.Expiry,
				OAuthExtra:             extra,
			})
			if err != nil {
				httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
					Message: "Failed to update external auth link.",
					Detail:  err.Error(),
				})
				return
			}
		}

		redirect := state.Redirect
		if redirect == "" {
			// This is a nicely rendered screen on the frontend. Passing the query param lets the
			// FE know not to enter the authentication loop again, and instead display an error.
			redirect = fmt.Sprintf("/external-auth/%s?redirected=true", externalAuthConfig.ID)
		}
		http.Redirect(rw, r, redirect, http.StatusTemporaryRedirect)
	}
}

// listUserExternalAuths lists all external auths available to a user and
// their auth links if they exist.
//
// @Summary Get user external auths
// @ID get-user-external-auths
// @Security CoderSessionToken
// @Produce json
// @Tags Git
// @Success 200 {object} codersdk.ExternalAuthLink
// @Router /external-auth [get]
func (api *API) listUserExternalAuths(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	key := httpmw.APIKey(r)

	links, err := api.Database.GetExternalAuthLinksByUserID(ctx, key.UserID)
	if err != nil {
		if httpapi.Is404Error(err) {
			httpapi.ResourceNotFound(rw)
			return
		}
		httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
			Message: "Internal error fetching user's external auths.",
			Detail:  err.Error(),
		})
		return
	}

	// This process of authenticating each external link increases the
	// response time. However, it is necessary to more correctly debug
	// authentication issues.
	// We can do this in parallel if we want to speed it up.
	configs := make(map[string]*externalauth.Config)
	for _, cfg := range api.ExternalAuthConfigs {
		configs[cfg.ID] = cfg
	}
	// Check if the links are authenticated.
	linkMeta := make(map[string]db2sdk.ExternalAuthMeta)
	for i, link := range links {
		if link.OAuthAccessToken != "" {
			cfg, ok := configs[link.ProviderID]
			if ok {
				newLink, valid, err := cfg.RefreshToken(ctx, api.Database, link)
				meta := db2sdk.ExternalAuthMeta{
					Authenticated: valid,
				}
				if err != nil {
					meta.ValidateError = err.Error()
				}
				// Update the link if it was potentially refreshed.
				if err == nil && valid {
					links[i] = newLink
				}
			}
		}
	}

	// Note: It would be really nice if we could cfg.Validate() the links and
	// return their authenticated status. To do this, we would also have to
	// refresh expired tokens too. For now, I do not want to cause the excess
	// traffic on this request, so the user will have to do this with a separate
	// call.
	httpapi.Write(ctx, rw, http.StatusOK, codersdk.ListUserExternalAuthResponse{
		Providers: ExternalAuthConfigs(api.ExternalAuthConfigs),
		Links:     db2sdk.ExternalAuths(links, linkMeta),
	})
}

func ExternalAuthConfigs(auths []*externalauth.Config) []codersdk.ExternalAuthLinkProvider {
	out := make([]codersdk.ExternalAuthLinkProvider, 0, len(auths))
	for _, auth := range auths {
		if auth == nil {
			continue
		}
		out = append(out, ExternalAuthConfig(auth))
	}
	return out
}

func ExternalAuthConfig(cfg *externalauth.Config) codersdk.ExternalAuthLinkProvider {
	return codersdk.ExternalAuthLinkProvider{
		ID:            cfg.ID,
		Type:          cfg.Type,
		Device:        cfg.DeviceAuth != nil,
		DisplayName:   cfg.DisplayName,
		DisplayIcon:   cfg.DisplayIcon,
		AllowRefresh:  !cfg.NoRefresh,
		AllowValidate: cfg.ValidateURL != "",
	}
}
