package thumbs

import (
	"context"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"time"

	"github.com/segmentio/encoding/json"

	"github.com/grafana/grafana/pkg/api/response"
	"github.com/grafana/grafana/pkg/infra/log"
	"github.com/grafana/grafana/pkg/infra/serverlock"
	"github.com/grafana/grafana/pkg/models"
	"github.com/grafana/grafana/pkg/registry"
	"github.com/grafana/grafana/pkg/services/dashboards"
	"github.com/grafana/grafana/pkg/services/featuremgmt"
	"github.com/grafana/grafana/pkg/services/guardian"
	"github.com/grafana/grafana/pkg/services/live"
	"github.com/grafana/grafana/pkg/services/rendering"
	"github.com/grafana/grafana/pkg/services/sqlstore"
	"github.com/grafana/grafana/pkg/setting"
	"github.com/grafana/grafana/pkg/web"
)

type Service interface {
	registry.ProvidesUsageStats
	Run(ctx context.Context) error
	Enabled() bool
	GetImage(c *models.ReqContext)
	GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig

	// from dashboard page
	SetImage(c *models.ReqContext) // form post
	UpdateThumbnailState(c *models.ReqContext)

	// Must be admin
	StartCrawler(c *models.ReqContext) response.Response
	StopCrawler(c *models.ReqContext) response.Response
	CrawlerStatus(c *models.ReqContext) response.Response
}

type thumbService struct {
	scheduleOptions            crawlerScheduleOptions
	renderer                   dashRenderer
	renderingService           rendering.Service
	thumbnailRepo              thumbnailRepo
	lockService                *serverlock.ServerLockService
	features                   featuremgmt.FeatureToggles
	store                      sqlstore.Store
	crawlLockServiceActionName string
	log                        log.Logger
	canRunCrawler              bool
	settings                   setting.DashboardPreviewsSettings
	dashboardService           dashboards.DashboardService
}

type crawlerScheduleOptions struct {
	crawlInterval    time.Duration
	tickerInterval   time.Duration
	maxCrawlDuration time.Duration
	crawlerMode      CrawlerMode
	thumbnailKind    models.ThumbnailKind
	themes           []models.Theme
	auth             CrawlerAuth
}

func ProvideService(cfg *setting.Cfg, features featuremgmt.FeatureToggles,
	lockService *serverlock.ServerLockService, renderService rendering.Service,
	gl *live.GrafanaLive, store *sqlstore.SQLStore, authSetupService CrawlerAuthSetupService,
	dashboardService dashboards.DashboardService) Service {
	if !features.IsEnabled(featuremgmt.FlagDashboardPreviews) {
		return &dummyService{}
	}
	logger := log.New("previews_service")

	thumbnailRepo := newThumbnailRepo(store)

	canRunCrawler := true

	authSetupStarted := time.Now()
	crawlerAuth, err := authSetupService.Setup(context.Background())

	if err != nil {
		logger.Error("Crawler auth setup failed", "err", err, "crawlerAuthSetupTime", time.Since(authSetupStarted))
		canRunCrawler = false
	} else {
		logger.Info("Crawler auth setup complete", "crawlerAuthSetupTime", time.Since(authSetupStarted))
	}

	t := &thumbService{
		renderingService:           renderService,
		renderer:                   newSimpleCrawler(renderService, gl, thumbnailRepo, cfg, cfg.DashboardPreviews),
		thumbnailRepo:              thumbnailRepo,
		store:                      store,
		features:                   features,
		lockService:                lockService,
		crawlLockServiceActionName: "dashboard-crawler",
		log:                        logger,
		canRunCrawler:              canRunCrawler,
		settings:                   cfg.DashboardPreviews,
		scheduleOptions: crawlerScheduleOptions{
			tickerInterval:   5 * time.Minute,
			crawlInterval:    cfg.DashboardPreviews.SchedulerInterval,
			maxCrawlDuration: cfg.DashboardPreviews.MaxCrawlDuration,
			crawlerMode:      CrawlerModeThumbs,
			thumbnailKind:    models.ThumbnailKindDefault,
			themes:           []models.Theme{models.ThemeDark, models.ThemeLight},
			auth:             crawlerAuth,
		},
		dashboardService: dashboardService,
	}

	return t
}

func (hs *thumbService) GetUsageStats(ctx context.Context) map[string]interface{} {
	s := hs.getDashboardPreviewsSetupSettings(ctx)

	stats := make(map[string]interface{})

	if s.SystemRequirements.Met {
		stats["stats.dashboard_previews.system_req_met.count"] = 1
	}

	if s.ThumbnailsExist {
		stats["stats.dashboard_previews.thumbnails_exist.count"] = 1
	}

	return stats
}

func (hs *thumbService) Enabled() bool {
	return hs.features.IsEnabled(featuremgmt.FlagDashboardPreviews)
}

func (hs *thumbService) parseImageReq(c *models.ReqContext, checkSave bool) *previewRequest {
	params := web.Params(c.Req)

	kind, err := models.ParseThumbnailKind(params[":kind"])
	if err != nil {
		c.JSON(400, map[string]string{"error": "invalid size"})
		return nil
	}

	theme, err := models.ParseTheme(params[":theme"])
	if err != nil {
		c.JSON(400, map[string]string{"error": "invalid theme"})
		return nil
	}

	req := &previewRequest{
		OrgID: c.OrgId,
		UID:   params[":uid"],
		Theme: theme,
		Kind:  kind,
	}

	if len(req.UID) < 1 {
		c.JSON(400, map[string]string{"error": "missing UID"})
		return nil
	}

	// Check permissions and status
	status := hs.getStatus(c, req.UID, checkSave)
	if status != 200 {
		c.JSON(status, map[string]string{"error": fmt.Sprintf("code: %d", status)})
		return nil
	}
	return req
}

type updateThumbnailStateRequest struct {
	State models.ThumbnailState `json:"state" binding:"Required"`
}

func (hs *thumbService) UpdateThumbnailState(c *models.ReqContext) {
	req := hs.parseImageReq(c, false)
	if req == nil {
		return // already returned value
	}

	var body = &updateThumbnailStateRequest{}

	err := web.Bind(c.Req, body)
	if err != nil {
		hs.log.Error("Error parsing update thumbnail state request", "dashboardUid", req.UID, "err", err.Error())
		c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
		return
	}

	err = hs.thumbnailRepo.updateThumbnailState(c.Req.Context(), body.State, models.DashboardThumbnailMeta{
		DashboardUID: req.UID,
		OrgId:        req.OrgID,
		Theme:        req.Theme,
		Kind:         models.ThumbnailKindDefault,
	})

	if err != nil {
		hs.log.Error("Error when trying to update thumbnail state", "dashboardUid", req.UID, "err", err.Error(), "newState", body.State)
		c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
		return
	}

	hs.log.Info("Updated dashboard thumbnail state", "dashboardUid", req.UID, "theme", req.Theme, "newState", body.State)
	c.JSON(http.StatusOK, map[string]string{"success": "true"})
}

func (hs *thumbService) GetImage(c *models.ReqContext) {
	req := hs.parseImageReq(c, false)
	if req == nil {
		return // already returned value
	}

	res, err := hs.thumbnailRepo.getThumbnail(c.Req.Context(), models.DashboardThumbnailMeta{
		DashboardUID: req.UID,
		OrgId:        req.OrgID,
		Theme:        req.Theme,
		Kind:         models.ThumbnailKindDefault,
	})

	if errors.Is(err, models.ErrDashboardThumbnailNotFound) {
		c.Resp.WriteHeader(404)
		return
	}

	if err != nil || res == nil {
		hs.log.Error("Error when retrieving thumbnail", "dashboardUid", req.UID, "err", err.Error())
		c.JSON(500, map[string]string{"dashboardUID": req.UID, "error": "unknown"})
		return
	}

	currentEtag := fmt.Sprintf("%d", res.Updated.Unix())
	c.Resp.Header().Set("ETag", currentEtag)

	previousEtag := c.Req.Header.Get("If-None-Match")
	if previousEtag == currentEtag {
		c.Resp.WriteHeader(http.StatusNotModified)
		return
	}

	c.Resp.Header().Set("Content-Type", res.MimeType)
	if _, err := c.Resp.Write(res.Image); err != nil {
		hs.log.Error("Error writing to response", "dashboardUid", req.UID, "err", err)
	}
}

func (hs *thumbService) GetDashboardPreviewsSetupSettings(c *models.ReqContext) dashboardPreviewsSetupConfig {
	return hs.getDashboardPreviewsSetupSettings(c.Req.Context())
}

func (hs *thumbService) getDashboardPreviewsSetupSettings(ctx context.Context) dashboardPreviewsSetupConfig {
	systemRequirements := hs.getSystemRequirements()
	thumbnailsExist, err := hs.thumbnailRepo.doThumbnailsExist(ctx)

	if err != nil {
		return dashboardPreviewsSetupConfig{
			SystemRequirements: systemRequirements,
			ThumbnailsExist:    false,
		}
	}

	return dashboardPreviewsSetupConfig{
		SystemRequirements: systemRequirements,
		ThumbnailsExist:    thumbnailsExist,
	}
}

func (hs *thumbService) getSystemRequirements() dashboardPreviewsSystemRequirements {
	res, err := hs.renderingService.HasCapability(rendering.ScalingDownImages)
	if err != nil {
		hs.log.Error("Error when verifying dashboard previews system requirements thumbnail", "err", err.Error())
		return dashboardPreviewsSystemRequirements{
			Met: false,
		}
	}

	if !res.IsSupported {
		return dashboardPreviewsSystemRequirements{
			Met:                                false,
			RequiredImageRendererPluginVersion: res.SemverConstraint,
		}
	}

	return dashboardPreviewsSystemRequirements{
		Met: true,
	}
}

// Hack for now -- lets you upload images explicitly
func (hs *thumbService) SetImage(c *models.ReqContext) {
	req := hs.parseImageReq(c, false)
	if req == nil {
		return // already returned value
	}

	r := c.Req

	// Parse our multipart form, 10 << 20 specifies a maximum
	// upload of 10 MB files.
	err := r.ParseMultipartForm(10 << 20)
	if err != nil {
		c.JSON(400, map[string]string{"error": "invalid upload size"})
		return
	}

	// FormFile returns the first file for the given key `myFile`
	// it also returns the FileHeader so we can get the Filename,
	// the Header and the size of the file
	file, handler, err := r.FormFile("file")
	if err != nil {
		c.JSON(400, map[string]string{"error": "missing multi-part form field named 'file'"})
		fmt.Println("error", err)
		return
	}
	defer func() {
		_ = file.Close()
	}()
	hs.log.Info("Uploaded File: %+v\n", handler.Filename)
	hs.log.Info("File Size: %+v\n", handler.Size)
	hs.log.Info("MIME Header: %+v\n", handler.Header)

	fileBytes, err := ioutil.ReadAll(file)
	if err != nil {
		fmt.Println(err)
		c.JSON(400, map[string]string{"error": "error reading file"})
		return
	}

	_, err = hs.thumbnailRepo.saveFromBytes(c.Req.Context(), fileBytes, getMimeType(handler.Filename), models.DashboardThumbnailMeta{
		DashboardUID: req.UID,
		OrgId:        req.OrgID,
		Theme:        req.Theme,
		Kind:         req.Kind,
	}, models.DashboardVersionForManualThumbnailUpload)

	if err != nil {
		c.JSON(400, map[string]string{"error": "error saving thumbnail file"})
		fmt.Println("error", err)
		return
	}

	c.JSON(http.StatusOK, map[string]int{"OK": len(fileBytes)})
}

func (hs *thumbService) StartCrawler(c *models.ReqContext) response.Response {
	body, err := io.ReadAll(c.Req.Body)
	if err != nil {
		return response.Error(500, "error reading bytes", err)
	}
	cmd := &crawlCmd{}
	err = json.Unmarshal(body, cmd)
	if err != nil {
		return response.Error(500, "error parsing bytes", err)
	}
	if cmd.Mode == "" {
		cmd.Mode = CrawlerModeThumbs
	}

	go hs.runOnDemandCrawl(context.Background(), cmd.Theme, cmd.Mode, models.ThumbnailKindDefault, rendering.AuthOpts{
		OrgID:   c.OrgId,
		UserID:  c.UserId,
		OrgRole: c.OrgRole,
	})

	status, err := hs.renderer.Status()
	if err != nil {
		return response.Error(500, "error starting", err)
	}

	return response.JSON(http.StatusOK, status)
}

func (hs *thumbService) StopCrawler(c *models.ReqContext) response.Response {
	msg, err := hs.renderer.Stop()
	if err != nil {
		return response.Error(500, "error starting", err)
	}
	return response.JSON(http.StatusOK, msg)
}

func (hs *thumbService) CrawlerStatus(c *models.ReqContext) response.Response {
	msg, err := hs.renderer.Status()
	if err != nil {
		return response.Error(500, "error starting", err)
	}
	return response.JSON(http.StatusOK, msg)
}

// Ideally this service would not require first looking up the full dashboard just to bet the id!
func (hs *thumbService) getStatus(c *models.ReqContext, uid string, checkSave bool) int {
	dashboardID, err := hs.getDashboardId(c, uid)
	if err != nil {
		return 404
	}

	guardian := guardian.New(c.Req.Context(), dashboardID, c.OrgId, c.SignedInUser)
	if checkSave {
		if canSave, err := guardian.CanSave(); err != nil || !canSave {
			return 403 // forbidden
		}
		return 200
	}

	if canView, err := guardian.CanView(); err != nil || !canView {
		return 403 // forbidden
	}

	return 200 // found and OK
}

func (hs *thumbService) getDashboardId(c *models.ReqContext, uid string) (int64, error) {
	query := models.GetDashboardQuery{Uid: uid, OrgId: c.OrgId}

	if err := hs.dashboardService.GetDashboard(c.Req.Context(), &query); err != nil {
		return 0, err
	}

	return query.Result.Id, nil
}

func (hs *thumbService) runOnDemandCrawl(parentCtx context.Context, theme models.Theme, mode CrawlerMode, kind models.ThumbnailKind, authOpts rendering.AuthOpts) {
	if !hs.canRunCrawler {
		return
	}

	crawlerCtx, cancel := context.WithTimeout(parentCtx, hs.scheduleOptions.maxCrawlDuration)
	defer cancel()

	// wait for at least a minute after the last completed run
	interval := time.Minute
	err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, interval, func(ctx context.Context) {
		if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, mode, theme, kind); err != nil {
			hs.log.Error("On demand crawl error", "mode", mode, "theme", theme, "kind", kind, "userId", authOpts.UserID, "orgId", authOpts.OrgID, "orgRole", authOpts.OrgRole)
		}
	})

	if err != nil {
		hs.log.Error("On demand crawl lock error", "err", err)
	}
}

func (hs *thumbService) runScheduledCrawl(parentCtx context.Context) {
	crawlerCtx, cancel := context.WithTimeout(parentCtx, hs.scheduleOptions.maxCrawlDuration)
	defer cancel()

	err := hs.lockService.LockAndExecute(crawlerCtx, hs.crawlLockServiceActionName, hs.scheduleOptions.crawlInterval, func(ctx context.Context) {
		for _, theme := range hs.scheduleOptions.themes {
			if err := hs.renderer.Run(crawlerCtx, hs.scheduleOptions.auth, hs.scheduleOptions.crawlerMode, theme, hs.scheduleOptions.thumbnailKind); err != nil {
				hs.log.Error("Scheduled crawl error", "theme", theme, "kind", hs.scheduleOptions.thumbnailKind, "err", err)
			}
		}
	})

	if err != nil {
		hs.log.Error("Scheduled crawl lock error", "err", err)
	}
}

func (hs *thumbService) Run(ctx context.Context) error {
	if !hs.canRunCrawler {
		return nil
	}

	gc := time.NewTicker(hs.scheduleOptions.tickerInterval)

	for {
		select {
		case <-gc.C:
			go hs.runScheduledCrawl(ctx)
		case <-ctx.Done():
			hs.log.Debug("Grafana is shutting down - stopping dashboard crawler")
			gc.Stop()

			return nil
		}
	}
}
