package scheduled

import (
	"encoding/json"
	"time"

	"golang.org/x/sync/errgroup"

	"github.com/pingcap/test-infra/common/model"
	"github.com/pingcap/test-infra/services/tcms/service"
	"github.com/pingcap/test-infra/services/tcms/service/common"
	"github.com/pingcap/test-infra/services/tcms/service/search"
	"github.com/pingcap/test-infra/services/tcms/utils"
	"github.com/pingcap/test-infra/services/tcms/utils/sql"
)

func NewMeilisync(
	db *sql.Database,
	searchSvc service.SearchService,
	caseSvc service.CaseService,
	planSvc service.PlanService,
	planExecSvc service.PlanExecutionService,
) *meilisync {
	return &meilisync{
		db:          db,
		searchSvc:   searchSvc,
		caseSvc:     caseSvc,
		planSvc:     planSvc,
		planExecSvc: planExecSvc,
	}
}

type meilisync struct {
	// FIXME(Thearas): Replace db with service.
	db          *sql.Database
	searchSvc   service.SearchService
	caseSvc     service.CaseService
	planSvc     service.PlanService
	planExecSvc service.PlanExecutionService
}

type meiliDoc interface {
	UpdatedAt_() time.Time
}

func (t *meilisync) Run() {
	ctx := utils.FakeCtx()

	g := errgroup.Group{}

	g.Go(func() (err error) {
		_, err = t.sync(ctx, search.SearchIndexCases, t.loadCases)
		return err
	})

	g.Go(func() (err error) {
		_, err = t.sync(ctx, search.SearchIndexPlans, t.loadPlans)
		return err
	})

	g.Go(func() (err error) {
		_, err = t.sync(ctx, search.SearchIndexPlanExecutions, t.loadPlanExecutions)
		return err
	})

	if err := g.Wait(); err != nil {
		ctx.Log().Errorf(err, "failed to sync meilisearch")
	}
}

// sync syncs columns from tidb to meilisearch which `updated_at > lastSyncAt`.
func (t *meilisync) sync(
	ctx utils.Context,
	index string,
	loadF func(ctx utils.Context, from time.Time) ([]meiliDoc, error),
) (syncedCount int, err error) {
	// get last_sync_at in db
	lastSyncAt, err := t.IndexLastSync(ctx, index)
	if err != nil {
		return
	}

	docs, err := loadF(ctx, lastSyncAt)
	if err != nil {
		return syncedCount, err
	}

	pageSize := len(docs)
	if pageSize > 0 {
		updateID, err := t.searchSvc.AddDocuments(ctx, index, docs)
		if err != nil {
			ctx.Log().Errorf(err, "failed to add document")
			return syncedCount, err
		}

		syncedCount += pageSize
		lastSyncAt = docs[len(docs)-1].UpdatedAt_()
		ctx.Log().With("updateID", updateID, "lastSyncAt", lastSyncAt).Infof("synced %d docs to %s", pageSize, index)

		// Update last_sync_at
		if err = t.UpdateIndexLastSync(ctx, index, lastSyncAt); err != nil {
			return syncedCount, err
		}
	}
	return
}

type caseDoc model.TestCase

func (doc *caseDoc) MarshalJSON() ([]byte, error) {
	type base model.TestCase
	return json.Marshal(&struct {
		*base

		// Since we don't want to omit deletedAt.
		DeletedAt int64 `json:"deletedAt"`
	}{
		base:      (*base)(doc),
		DeletedAt: doc.DeletedAt,
	})
}

func (doc *caseDoc) UpdatedAt_() time.Time { return doc.UpdatedAt }

func (t *meilisync) loadCases(ctx utils.Context, from time.Time) (docs []meiliDoc, err error) {
	const meilisyncPageSize uint = 1000

	res, _, err := t.caseSvc.List(ctx, common.CaseQueryOpts{
		IncludeDeleted: true,
		FromUpdatedAt:  &from,
		AllFields:      true,
		Count:          meilisyncPageSize,
	})
	if err != nil {
		ctx.Log().Errorf(err, "failed to query cases")
		return nil, err
	}

	docs = make([]meiliDoc, len(res))
	for i, doc := range res {
		docs[i] = (*caseDoc)(doc)
	}
	return docs, nil
}

type planDoc model.TestPlan

func (doc *planDoc) MarshalJSON() ([]byte, error) {
	type base model.TestPlan
	return json.Marshal(&struct {
		*base

		// Since we don't want to omit deletedAt.
		DeletedAt int64 `json:"deletedAt"`
	}{
		base:      (*base)(doc),
		DeletedAt: doc.DeletedAt,
	})
}

func (doc *planDoc) UpdatedAt_() time.Time { return doc.UpdatedAt }

func (t *meilisync) loadPlans(ctx utils.Context, from time.Time) (docs []meiliDoc, err error) {
	const meilisyncPageSize uint = 1000

	res, _, err := t.planSvc.List(ctx, common.PlanQueryOpts{
		IncludeDeleted: true,
		FromUpdatedAt:  &from,
		AllFields:      true,
		SortedBy:       common.PlanSortedByUpdatedAt,
		Count:          meilisyncPageSize,
	})
	if err != nil {
		ctx.Log().Errorf(err, "failed to query plans")
		return nil, err
	}

	docs = make([]meiliDoc, len(res))
	for i, doc := range res {
		docs[i] = (*planDoc)(doc)
	}
	return docs, nil
}

type planExecDoc model.TestPlanExecution

func (doc *planExecDoc) UpdatedAt_() time.Time { return doc.UpdatedAt }

func (t *meilisync) loadPlanExecutions(ctx utils.Context, from time.Time) (docs []meiliDoc, err error) {
	const meilisyncPageSize uint = 1000

	res, _, err := t.planExecSvc.List(ctx, common.PlanExecQueryOpts{
		FromUpdatedAt: &from,
		SortedBy:      common.PlanExecSortedByUpdatedAt,
		Count:         meilisyncPageSize,
	})
	if err != nil {
		ctx.Log().Errorf(err, "failed to query plan-execution")
		return nil, err
	}
	docs = make([]meiliDoc, len(res))
	for i, doc := range res {
		docs[i] = (*planExecDoc)(doc)
	}
	return docs, nil
}

func (t *meilisync) IndexLastSync(ctx utils.Context, index string) (time.Time, error) {
	log := ctx.Log().With("index", index)

	var lastSyncAt time.Time
	err := t.db.Get(&lastSyncAt, "SELECT last_sync_at FROM meili_syncs WHERE index_name=?", index)
	if err != nil {
		I970 := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
		if sql.IsErrNoRows(err) {
			_, err := t.db.Exec("INSERT IGNORE INTO meili_syncs (index_name, last_sync_at) VALUES (?, ?)", index, I970)
			if err != nil {
				log.Errorf(err, "failed to insert meili_syncs")
				return time.Time{}, err
			}
			return I970, nil
		}
		log.Errorf(err, "failed to get last_sync_at")
		return time.Time{}, err
	}
	return lastSyncAt, nil
}

func (t *meilisync) UpdateIndexLastSync(ctx utils.Context, index string, lastSyncAt time.Time) error {
	log := ctx.Log().With("index", index, "lastSyncAt", lastSyncAt)

	_, err := t.db.Exec("UPDATE meili_syncs SET last_sync_at=? WHERE index_name=?", lastSyncAt, index)
	if err != nil {
		log.Errorf(err, "failed to update last_sync_at")
		return err
	}
	return nil
}
