// Package apis implements the default PocketBase api services and middlewares.
package apis

import (
	"database/sql"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"net/url"
	"path/filepath"
	"strings"

	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
	"github.com/pocketbase/pocketbase/core"
	"github.com/pocketbase/pocketbase/tools/rest"
	"github.com/pocketbase/pocketbase/ui"
	"github.com/spf13/cast"
)

const trailedAdminPath = "/_/"

// InitApi creates a configured echo instance with registered
// system and app specific routes and middlewares.
func InitApi(app core.App) (*echo.Echo, error) {
	e := echo.New()
	e.Debug = app.IsDebug()
	e.JSONSerializer = &rest.Serializer{
		FieldsParam: "fields",
	}

	// configure a custom router
	e.ResetRouterCreator(func(ec *echo.Echo) echo.Router {
		return echo.NewRouter(echo.RouterConfig{
			UnescapePathParamValues: true,
			AllowOverwritingRoute:   true,
		})
	})

	// default middlewares
	e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
		Skipper: func(c echo.Context) bool {
			// enable by default only for the API routes
			return !strings.HasPrefix(c.Request().URL.Path, "/api/")
		},
	}))
	e.Pre(LoadAuthContext(app))
	e.Use(middleware.Recover())
	e.Use(middleware.Secure())

	// custom error handler
	e.HTTPErrorHandler = func(c echo.Context, err error) {
		if err == nil {
			return // no error
		}

		if c.Response().Committed {
			if app.IsDebug() {
				log.Println("HTTPErrorHandler response was already committed:", err)
			}
			return
		}

		var apiErr *ApiError

		if errors.As(err, &apiErr) {
			if app.IsDebug() && apiErr.RawData() != nil {
				log.Println(apiErr.RawData())
			}
		} else if v := new(echo.HTTPError); errors.As(err, &v) {
			if v.Internal != nil && app.IsDebug() {
				log.Println(v.Internal)
			}
			msg := fmt.Sprintf("%v", v.Message)
			apiErr = NewApiError(v.Code, msg, v)
		} else {
			if app.IsDebug() {
				log.Println(err)
			}

			if errors.Is(err, sql.ErrNoRows) {
				apiErr = NewNotFoundError("", err)
			} else {
				apiErr = NewBadRequestError("", err)
			}
		}

		event := new(core.ApiErrorEvent)
		event.HttpContext = c
		event.Error = apiErr

		// send error response
		hookErr := app.OnBeforeApiError().Trigger(event, func(e *core.ApiErrorEvent) error {
			if c.Response().Committed {
				return nil
			}

			// @see https://github.com/labstack/echo/issues/608
			if e.HttpContext.Request().Method == http.MethodHead {
				return e.HttpContext.NoContent(apiErr.Code)
			}

			return e.HttpContext.JSON(apiErr.Code, apiErr)
		})

		if hookErr == nil {
			if err := app.OnAfterApiError().Trigger(event); err != nil && app.IsDebug() {
				log.Println(hookErr)
			}
		} else if app.IsDebug() {
			// truly rare case; eg. client already disconnected
			log.Println(hookErr)
		}
	}

	// admin ui routes
	bindStaticAdminUI(app, e)

	// default routes
	api := e.Group("/api", eagerRequestInfoCache(app))
	bindSettingsApi(app, api)
	bindAdminApi(app, api)
	bindCollectionApi(app, api)
	bindRecordCrudApi(app, api)
	bindRecordAuthApi(app, api)
	bindFileApi(app, api)
	bindRealtimeApi(app, api)
	bindLogsApi(app, api)
	bindHealthApi(app, api)
	bindBackupApi(app, api)

	// catch all any route
	api.Any("/*", func(c echo.Context) error {
		return echo.ErrNotFound
	}, ActivityLogger(app))

	return e, nil
}

// StaticDirectoryHandler is similar to `echo.StaticDirectoryHandler`
// but without the directory redirect which conflicts with RemoveTrailingSlash middleware.
//
// If a file resource is missing and indexFallback is set, the request
// will be forwarded to the base index.html (useful also for SPA).
//
// @see https://github.com/labstack/echo/issues/2211
func StaticDirectoryHandler(fileSystem fs.FS, indexFallback bool) echo.HandlerFunc {
	return func(c echo.Context) error {
		p := c.PathParam("*")

		// escape url path
		tmpPath, err := url.PathUnescape(p)
		if err != nil {
			return fmt.Errorf("failed to unescape path variable: %w", err)
		}
		p = tmpPath

		// fs.FS.Open() already assumes that file names are relative to FS root path and considers name with prefix `/` as invalid
		name := filepath.ToSlash(filepath.Clean(strings.TrimPrefix(p, "/")))

		fileErr := c.FileFS(name, fileSystem)

		if fileErr != nil && indexFallback && errors.Is(fileErr, echo.ErrNotFound) {
			return c.FileFS("index.html", fileSystem)
		}

		return fileErr
	}
}

// bindStaticAdminUI registers the endpoints that serves the static admin UI.
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
	// redirect to trailing slash to ensure that relative urls will still work properly
	e.GET(
		strings.TrimRight(trailedAdminPath, "/"),
		func(c echo.Context) error {
			return c.Redirect(http.StatusTemporaryRedirect, strings.TrimLeft(trailedAdminPath, "/"))
		},
	)

	// serves static files from the /ui/dist directory
	// (similar to echo.StaticFS but with gzip middleware enabled)
	e.GET(
		trailedAdminPath+"*",
		echo.StaticDirectoryHandler(ui.DistDirFS, false),
		installerRedirect(app),
		uiCacheControl(),
		middleware.Gzip(),
	)

	return nil
}

func uiCacheControl() echo.MiddlewareFunc {
	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			// add default Cache-Control header for all Admin UI resources
			// (ignoring the root admin path)
			if c.Request().URL.Path != trailedAdminPath {
				c.Response().Header().Set("Cache-Control", "max-age=1209600, stale-while-revalidate=86400")
			}

			return next(c)
		}
	}
}

const hasAdminsCacheKey = "@hasAdmins"

func updateHasAdminsCache(app core.App) error {
	total, err := app.Dao().TotalAdmins()
	if err != nil {
		return err
	}

	app.Cache().Set(hasAdminsCacheKey, total > 0)

	return nil
}

// installerRedirect redirects the user to the installer admin UI page
// when the application needs some preliminary configurations to be done.
func installerRedirect(app core.App) echo.MiddlewareFunc {
	// keep hasAdminsCacheKey value up-to-date
	app.OnAdminAfterCreateRequest().Add(func(data *core.AdminCreateEvent) error {
		return updateHasAdminsCache(app)
	})

	app.OnAdminAfterDeleteRequest().Add(func(data *core.AdminDeleteEvent) error {
		return updateHasAdminsCache(app)
	})

	return func(next echo.HandlerFunc) echo.HandlerFunc {
		return func(c echo.Context) error {
			// skip redirect checks for non-root level index.html requests
			path := c.Request().URL.Path
			if path != trailedAdminPath && path != trailedAdminPath+"index.html" {
				return next(c)
			}

			hasAdmins := cast.ToBool(app.Cache().Get(hasAdminsCacheKey))

			if !hasAdmins {
				// update the cache to make sure that the admin wasn't created by another process
				if err := updateHasAdminsCache(app); err != nil {
					return err
				}
				hasAdmins = cast.ToBool(app.Cache().Get(hasAdminsCacheKey))
			}

			_, hasInstallerParam := c.Request().URL.Query()["installer"]

			if !hasAdmins && !hasInstallerParam {
				// redirect to the installer page
				return c.Redirect(http.StatusTemporaryRedirect, "?installer#")
			}

			if hasAdmins && hasInstallerParam {
				// clear the installer param
				return c.Redirect(http.StatusTemporaryRedirect, "?")
			}

			return next(c)
		}
	}
}
