// Copyright 2023 PingCAP, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package syncload

import (
	stderrors "errors"
	"math/rand"
	"runtime"
	"time"

	"github.com/pingcap/errors"
	"github.com/pingcap/failpoint"
	"github.com/pingcap/tidb/pkg/config"
	"github.com/pingcap/tidb/pkg/infoschema"
	"github.com/pingcap/tidb/pkg/kv"
	"github.com/pingcap/tidb/pkg/meta/model"
	"github.com/pingcap/tidb/pkg/metrics"
	"github.com/pingcap/tidb/pkg/parser/mysql"
	"github.com/pingcap/tidb/pkg/sessionctx"
	"github.com/pingcap/tidb/pkg/sessionctx/stmtctx"
	"github.com/pingcap/tidb/pkg/sessionctx/variable"
	"github.com/pingcap/tidb/pkg/statistics"
	"github.com/pingcap/tidb/pkg/statistics/handle/storage"
	statstypes "github.com/pingcap/tidb/pkg/statistics/handle/types"
	"github.com/pingcap/tidb/pkg/types"
	"github.com/pingcap/tidb/pkg/util"
	"github.com/pingcap/tidb/pkg/util/intest"
	"github.com/pingcap/tidb/pkg/util/logutil"
	"go.uber.org/zap"
	"golang.org/x/sync/singleflight"
)

// RetryCount is the max retry count for a sync load task.
const RetryCount = 2

// GetSyncLoadConcurrencyByCPU returns the concurrency of sync load by CPU.
func GetSyncLoadConcurrencyByCPU() int {
	core := runtime.GOMAXPROCS(0)
	if core <= 8 {
		return 5
	} else if core <= 16 {
		return 6
	} else if core <= 32 {
		return 8
	}
	return 10
}

type statsSyncLoad struct {
	statsHandle statstypes.StatsHandle
	is          infoschema.InfoSchema
	StatsLoad   statstypes.StatsLoad
}

var globalStatsSyncLoadSingleFlight singleflight.Group

// NewStatsSyncLoad creates a new StatsSyncLoad.
func NewStatsSyncLoad(is infoschema.InfoSchema, statsHandle statstypes.StatsHandle) statstypes.StatsSyncLoad {
	s := &statsSyncLoad{statsHandle: statsHandle, is: is}
	cfg := config.GetGlobalConfig()
	s.StatsLoad.NeededItemsCh = make(chan *statstypes.NeededItemTask, cfg.Performance.StatsLoadQueueSize)
	s.StatsLoad.TimeoutItemsCh = make(chan *statstypes.NeededItemTask, cfg.Performance.StatsLoadQueueSize)
	return s
}

type statsWrapper struct {
	colInfo *model.ColumnInfo
	idxInfo *model.IndexInfo
	col     *statistics.Column
	idx     *statistics.Index
}

// SendLoadRequests send neededColumns requests
func (s *statsSyncLoad) SendLoadRequests(sc *stmtctx.StatementContext, neededHistItems []model.StatsLoadItem, timeout time.Duration) error {
	remainedItems := s.removeHistLoadedColumns(neededHistItems)

	failpoint.Inject("assertSyncLoadItems", func(val failpoint.Value) {
		if sc.OptimizeTracer != nil {
			count := val.(int)
			if len(remainedItems) != count {
				panic("remained items count wrong")
			}
		}
	})
	if len(remainedItems) <= 0 {
		return nil
	}
	sc.StatsLoad.Timeout = timeout
	sc.StatsLoad.NeededItems = remainedItems
	sc.StatsLoad.ResultCh = make([]<-chan singleflight.Result, 0, len(remainedItems))
	for _, item := range remainedItems {
		localItem := item
		resultCh := globalStatsSyncLoadSingleFlight.DoChan(localItem.Key(), func() (any, error) {
			timer := time.NewTimer(timeout)
			defer timer.Stop()
			task := &statstypes.NeededItemTask{
				Item:      localItem,
				ToTimeout: time.Now().Local().Add(timeout),
				ResultCh:  make(chan stmtctx.StatsLoadResult, 1),
			}
			select {
			case s.StatsLoad.NeededItemsCh <- task:
				metrics.SyncLoadDedupCounter.Inc()
				select {
				case <-timer.C:
					return nil, errors.New("sync load took too long to return")
				case result, ok := <-task.ResultCh:
					intest.Assert(ok, "task.ResultCh cannot be closed")
					return result, nil
				}
			case <-timer.C:
				return nil, errors.New("sync load stats channel is full and timeout sending task to channel")
			}
		})
		sc.StatsLoad.ResultCh = append(sc.StatsLoad.ResultCh, resultCh)
	}
	sc.StatsLoad.LoadStartTime = time.Now()
	return nil
}

// SyncWaitStatsLoad sync waits loading of neededColumns and return false if timeout
func (*statsSyncLoad) SyncWaitStatsLoad(sc *stmtctx.StatementContext) error {
	if len(sc.StatsLoad.NeededItems) <= 0 {
		return nil
	}
	var errorMsgs []string
	defer func() {
		if len(errorMsgs) > 0 {
			logutil.BgLogger().Warn("SyncWaitStatsLoad meets error",
				zap.Strings("errors", errorMsgs))
		}
		sc.StatsLoad.NeededItems = nil
	}()
	resultCheckMap := map[model.TableItemID]struct{}{}
	for _, col := range sc.StatsLoad.NeededItems {
		resultCheckMap[col.TableItemID] = struct{}{}
	}
	timer := time.NewTimer(sc.StatsLoad.Timeout)
	defer timer.Stop()
	for _, resultCh := range sc.StatsLoad.ResultCh {
		select {
		case result, ok := <-resultCh:
			metrics.SyncLoadCounter.Inc()
			if !ok {
				return errors.New("sync load stats channel closed unexpectedly")
			}
			// this error is from statsSyncLoad.SendLoadRequests which start to task and send task into worker,
			// not the stats loading error
			if result.Err != nil {
				errorMsgs = append(errorMsgs, result.Err.Error())
			} else {
				val := result.Val.(stmtctx.StatsLoadResult)
				// this error is from the stats loading error
				if val.HasError() {
					errorMsgs = append(errorMsgs, val.ErrorMsg())
				}
				delete(resultCheckMap, val.Item)
			}
		case <-timer.C:
			metrics.SyncLoadCounter.Inc()
			metrics.SyncLoadTimeoutCounter.Inc()
			return errors.New("sync load stats timeout")
		}
	}
	if len(resultCheckMap) == 0 {
		metrics.SyncLoadHistogram.Observe(float64(time.Since(sc.StatsLoad.LoadStartTime).Milliseconds()))
		return nil
	}
	return nil
}

// removeHistLoadedColumns removed having-hist columns based on neededColumns and statsCache.
func (s *statsSyncLoad) removeHistLoadedColumns(neededItems []model.StatsLoadItem) []model.StatsLoadItem {
	remainedItems := make([]model.StatsLoadItem, 0, len(neededItems))
	for _, item := range neededItems {
		tbl, ok := s.statsHandle.Get(item.TableID)
		if !ok {
			continue
		}
		if item.IsIndex {
			_, loadNeeded := tbl.IndexIsLoadNeeded(item.ID)
			if loadNeeded {
				remainedItems = append(remainedItems, item)
			}
			continue
		}
		_, loadNeeded, _ := tbl.ColumnIsLoadNeeded(item.ID, item.FullLoad)
		if loadNeeded {
			remainedItems = append(remainedItems, item)
		}
	}
	return remainedItems
}

// AppendNeededItem appends needed columns/indices to ch, it is only used for test
func (s *statsSyncLoad) AppendNeededItem(task *statstypes.NeededItemTask, timeout time.Duration) error {
	timer := time.NewTimer(timeout)
	defer timer.Stop()
	select {
	case s.StatsLoad.NeededItemsCh <- task:
	case <-timer.C:
		return errors.New("Channel is full and timeout writing to channel")
	}
	return nil
}

var errExit = errors.New("Stop loading since domain is closed")

// SubLoadWorker loads hist data for each column
func (s *statsSyncLoad) SubLoadWorker(sctx sessionctx.Context, exit chan struct{}, exitWg *util.WaitGroupEnhancedWrapper) {
	defer func() {
		exitWg.Done()
		logutil.BgLogger().Info("SubLoadWorker exited.")
	}()
	// if the last task is not successfully handled in last round for error or panic, pass it to this round to retry
	var lastTask *statstypes.NeededItemTask
	for {
		task, err := s.HandleOneTask(sctx, lastTask, exit)
		lastTask = task
		if err != nil {
			switch err {
			case errExit:
				return
			default:
				// To avoid the thundering herd effect
				// thundering herd effect: Everyone tries to retry a large number of requests simultaneously when a problem occurs.
				r := rand.Intn(500)
				time.Sleep(s.statsHandle.Lease()/10 + time.Duration(r)*time.Microsecond)
				continue
			}
		}
	}
}

// HandleOneTask handles last task if not nil, else handle a new task from chan, and return current task if fail somewhere.
//   - If the task is handled successfully, return nil, nil.
//   - If the task is timeout, return the task and nil. The caller should retry the timeout task without sleep.
//   - If the task is failed, return the task, error. The caller should retry the timeout task with sleep.
func (s *statsSyncLoad) HandleOneTask(sctx sessionctx.Context, lastTask *statstypes.NeededItemTask, exit chan struct{}) (task *statstypes.NeededItemTask, err error) {
	defer func() {
		// recover for each task, worker keeps working
		if r := recover(); r != nil {
			logutil.BgLogger().Error("stats loading panicked", zap.Any("error", r), zap.Stack("stack"))
			err = errors.Errorf("stats loading panicked: %v", r)
		}
	}()
	if lastTask == nil {
		task, err = s.drainColTask(sctx, exit)
		if err != nil {
			if err != errExit {
				logutil.BgLogger().Error("Fail to drain task for stats loading.", zap.Error(err))
			}
			return task, err
		}
	} else {
		task = lastTask
	}
	result := stmtctx.StatsLoadResult{Item: task.Item.TableItemID}
	err = s.handleOneItemTask(task)
	if err == nil {
		task.ResultCh <- result
		return nil, nil
	}
	if !isVaildForRetry(task) {
		result.Error = err
		task.ResultCh <- result
		return nil, nil
	}
	return task, err
}

func isVaildForRetry(task *statstypes.NeededItemTask) bool {
	task.Retry++
	return task.Retry <= RetryCount
}

func (s *statsSyncLoad) handleOneItemTask(task *statstypes.NeededItemTask) (err error) {
	se, err := s.statsHandle.SPool().Get()
	if err != nil {
		return err
	}
	sctx := se.(sessionctx.Context)
	sctx.GetSessionVars().StmtCtx.Priority = mysql.HighPriority
	defer func() {
		// recover for each task, worker keeps working
		if r := recover(); r != nil {
			logutil.BgLogger().Error("handleOneItemTask panicked", zap.Any("recover", r), zap.Stack("stack"))
			err = errors.Errorf("stats loading panicked: %v", r)
		}
		if err == nil { // only recycle when no error
			sctx.GetSessionVars().StmtCtx.Priority = mysql.NoPriority
			s.statsHandle.SPool().Put(se)
		} else {
			// Note: Otherwise, the session will be leaked.
			s.statsHandle.SPool().Destroy(se)
		}
	}()
	var skipTypes map[string]struct{}
	val, err := sctx.GetSessionVars().GlobalVarsAccessor.GetGlobalSysVar(variable.TiDBAnalyzeSkipColumnTypes)
	if err != nil {
		logutil.BgLogger().Warn("failed to get global variable", zap.Error(err))
	} else {
		skipTypes = variable.ParseAnalyzeSkipColumnTypes(val)
	}

	item := task.Item.TableItemID
	statsTbl, ok := s.statsHandle.Get(item.TableID)

	if !ok {
		return nil
	}
	is := sctx.GetDomainInfoSchema().(infoschema.InfoSchema)
	tbl, ok := s.statsHandle.TableInfoByID(is, item.TableID)
	if !ok {
		return nil
	}
	tblInfo := tbl.Meta()
	isPkIsHandle := tblInfo.PKIsHandle
	wrapper := &statsWrapper{}
	if item.IsIndex {
		index, loadNeeded := statsTbl.IndexIsLoadNeeded(item.ID)
		if !loadNeeded {
			return nil
		}
		if index != nil {
			wrapper.idxInfo = index.Info
		} else {
			wrapper.idxInfo = tblInfo.FindIndexByID(item.ID)
		}
	} else {
		col, loadNeeded, analyzed := statsTbl.ColumnIsLoadNeeded(item.ID, task.Item.FullLoad)
		if !loadNeeded {
			return nil
		}
		if col != nil {
			wrapper.colInfo = col.Info
		} else {
			// Now, we cannot init the column info in the ColAndIdxExistenceMap when to disable lite-init-stats.
			// so we have to get the column info from the domain.
			wrapper.colInfo = tblInfo.GetColumnByID(item.ID)
		}
		if skipTypes != nil {
			_, skip := skipTypes[types.TypeToStr(wrapper.colInfo.FieldType.GetType(), wrapper.colInfo.FieldType.GetCharset())]
			if skip {
				return nil
			}
		}

		// If this column is not analyzed yet and we don't have it in memory.
		// We create a fake one for the pseudo estimation.
		// Otherwise, it will trigger the sync/async load again, even if the column has not been analyzed.
		if loadNeeded && !analyzed {
			wrapper.col = statistics.EmptyColumn(item.TableID, isPkIsHandle, wrapper.colInfo)
			s.updateCachedItem(tblInfo, item, wrapper.col, wrapper.idx, task.Item.FullLoad)
			return nil
		}
	}
	failpoint.Inject("handleOneItemTaskPanic", nil)
	t := time.Now()
	needUpdate := false
	wrapper, err = s.readStatsForOneItem(sctx, item, wrapper, isPkIsHandle, task.Item.FullLoad)
	if stderrors.Is(err, errGetHistMeta) {
		return nil
	}
	if err != nil {
		return err
	}
	if item.IsIndex {
		if wrapper.idxInfo != nil {
			needUpdate = true
		}
	} else {
		if wrapper.colInfo != nil {
			needUpdate = true
		}
	}
	metrics.ReadStatsHistogram.Observe(float64(time.Since(t).Milliseconds()))
	if needUpdate {
		s.updateCachedItem(tblInfo, item, wrapper.col, wrapper.idx, task.Item.FullLoad)
	}
	return nil
}

var errGetHistMeta = errors.New("fail to get hist meta")

// readStatsForOneItem reads hist for one column/index, TODO load data via kv-get asynchronously
func (*statsSyncLoad) readStatsForOneItem(sctx sessionctx.Context, item model.TableItemID, w *statsWrapper, isPkIsHandle bool, fullLoad bool) (*statsWrapper, error) {
	failpoint.Inject("mockReadStatsForOnePanic", nil)
	failpoint.Inject("mockReadStatsForOneFail", func(val failpoint.Value) {
		if val.(bool) {
			failpoint.Return(nil, errors.New("gofail ReadStatsForOne error"))
		}
	})
	loadFMSketch := config.GetGlobalConfig().Performance.EnableLoadFMSketch
	var hg *statistics.Histogram
	var err error
	isIndexFlag := int64(0)
	hg, lastAnalyzePos, statsVer, flag, err := storage.HistMetaFromStorageWithHighPriority(sctx, &item, w.colInfo)
	if err != nil {
		return nil, err
	}
	if hg == nil {
		logutil.BgLogger().Warn("fail to get hist meta for this histogram, possibly a deleted one", zap.Int64("table_id", item.TableID),
			zap.Int64("hist_id", item.ID), zap.Bool("is_index", item.IsIndex),
		)
		return nil, errGetHistMeta
	}
	if item.IsIndex {
		isIndexFlag = 1
	}
	var cms *statistics.CMSketch
	var topN *statistics.TopN
	var fms *statistics.FMSketch
	if fullLoad {
		if item.IsIndex {
			hg, err = storage.HistogramFromStorageWithPriority(sctx, item.TableID, item.ID, types.NewFieldType(mysql.TypeBlob), hg.NDV, int(isIndexFlag), hg.LastUpdateVersion, hg.NullCount, hg.TotColSize, hg.Correlation, kv.PriorityHigh)
			if err != nil {
				return nil, errors.Trace(err)
			}
		} else {
			hg, err = storage.HistogramFromStorageWithPriority(sctx, item.TableID, item.ID, &w.colInfo.FieldType, hg.NDV, int(isIndexFlag), hg.LastUpdateVersion, hg.NullCount, hg.TotColSize, hg.Correlation, kv.PriorityHigh)
			if err != nil {
				return nil, errors.Trace(err)
			}
		}
		cms, topN, err = storage.CMSketchAndTopNFromStorageWithHighPriority(sctx, item.TableID, isIndexFlag, item.ID, statsVer)
		if err != nil {
			return nil, errors.Trace(err)
		}
		if loadFMSketch {
			fms, err = storage.FMSketchFromStorage(sctx, item.TableID, isIndexFlag, item.ID)
			if err != nil {
				return nil, errors.Trace(err)
			}
		}
	}
	if item.IsIndex {
		idxHist := &statistics.Index{
			Histogram:  *hg,
			CMSketch:   cms,
			TopN:       topN,
			FMSketch:   fms,
			Info:       w.idxInfo,
			StatsVer:   statsVer,
			Flag:       flag,
			PhysicalID: item.TableID,
		}
		if statsVer != statistics.Version0 {
			if fullLoad {
				idxHist.StatsLoadedStatus = statistics.NewStatsFullLoadStatus()
			} else {
				idxHist.StatsLoadedStatus = statistics.NewStatsAllEvictedStatus()
			}
		}
		lastAnalyzePos.Copy(&idxHist.LastAnalyzePos)
		w.idx = idxHist
	} else {
		colHist := &statistics.Column{
			PhysicalID: item.TableID,
			Histogram:  *hg,
			Info:       w.colInfo,
			CMSketch:   cms,
			TopN:       topN,
			FMSketch:   fms,
			IsHandle:   isPkIsHandle && mysql.HasPriKeyFlag(w.colInfo.GetFlag()),
			StatsVer:   statsVer,
		}
		if colHist.StatsAvailable() {
			if fullLoad {
				colHist.StatsLoadedStatus = statistics.NewStatsFullLoadStatus()
			} else {
				colHist.StatsLoadedStatus = statistics.NewStatsAllEvictedStatus()
			}
		}
		w.col = colHist
	}
	return w, nil
}

// drainColTask will hang until a column task can return, and either task or error will be returned.
func (s *statsSyncLoad) drainColTask(sctx sessionctx.Context, exit chan struct{}) (*statstypes.NeededItemTask, error) {
	// select NeededColumnsCh firstly, if no task, then select TimeoutColumnsCh
	for {
		select {
		case <-exit:
			return nil, errExit
		case task, ok := <-s.StatsLoad.NeededItemsCh:
			if !ok {
				return nil, errors.New("drainColTask: cannot read from NeededColumnsCh, maybe the chan is closed")
			}
			// if the task has already timeout, no sql is sync-waiting for it,
			// so do not handle it just now, put it to another channel with lower priority
			if time.Now().After(task.ToTimeout) {
				task.ToTimeout.Add(time.Duration(sctx.GetSessionVars().StatsLoadSyncWait.Load()) * time.Microsecond)
				s.writeToTimeoutChan(s.StatsLoad.TimeoutItemsCh, task)
				continue
			}
			return task, nil
		case task, ok := <-s.StatsLoad.TimeoutItemsCh:
			select {
			case <-exit:
				return nil, errExit
			case task0, ok0 := <-s.StatsLoad.NeededItemsCh:
				if !ok0 {
					return nil, errors.New("drainColTask: cannot read from NeededColumnsCh, maybe the chan is closed")
				}
				// send task back to TimeoutColumnsCh and return the task drained from NeededColumnsCh
				s.writeToTimeoutChan(s.StatsLoad.TimeoutItemsCh, task)
				return task0, nil
			default:
				if !ok {
					return nil, errors.New("drainColTask: cannot read from TimeoutColumnsCh, maybe the chan is closed")
				}
				// NeededColumnsCh is empty now, handle task from TimeoutColumnsCh
				return task, nil
			}
		}
	}
}

// writeToTimeoutChan writes in a nonblocking way, and if the channel queue is full, it's ok to drop the task.
func (*statsSyncLoad) writeToTimeoutChan(taskCh chan *statstypes.NeededItemTask, task *statstypes.NeededItemTask) {
	select {
	case taskCh <- task:
	default:
	}
}

// writeToChanWithTimeout writes a task to a channel and blocks until timeout.
func (*statsSyncLoad) writeToChanWithTimeout(taskCh chan *statstypes.NeededItemTask, task *statstypes.NeededItemTask, timeout time.Duration) error {
	timer := time.NewTimer(timeout)
	defer timer.Stop()
	select {
	case taskCh <- task:
	case <-timer.C:
		return errors.New("Channel is full and timeout writing to channel")
	}
	return nil
}

// writeToResultChan safe-writes with panic-recover so one write-fail will not have big impact.
func (*statsSyncLoad) writeToResultChan(resultCh chan stmtctx.StatsLoadResult, rs stmtctx.StatsLoadResult) {
	defer func() {
		if r := recover(); r != nil {
			logutil.BgLogger().Error("writeToResultChan panicked", zap.Any("error", r), zap.Stack("stack"))
		}
	}()
	select {
	case resultCh <- rs:
	default:
	}
}

// updateCachedItem updates the column/index hist to global statsCache.
func (s *statsSyncLoad) updateCachedItem(tblInfo *model.TableInfo, item model.TableItemID, colHist *statistics.Column, idxHist *statistics.Index, fullLoaded bool) (updated bool) {
	s.StatsLoad.Lock()
	defer s.StatsLoad.Unlock()
	// Reload the latest stats cache, otherwise the `updateStatsCache` may fail with high probability, because functions
	// like `GetPartitionStats` called in `fmSketchFromStorage` would have modified the stats cache already.
	tbl, ok := s.statsHandle.Get(item.TableID)
	if !ok {
		return false
	}
	if !tbl.ColAndIdxExistenceMap.Checked() {
		tbl = tbl.Copy()
		for _, col := range tbl.HistColl.GetColSlice() {
			if tblInfo.FindColumnByID(col.ID) == nil {
				tbl.DelCol(col.ID)
			}
		}
		for _, idx := range tbl.HistColl.GetIdxSlice() {
			if tblInfo.FindIndexByID(idx.ID) == nil {
				tbl.DelIdx(idx.ID)
			}
		}
		tbl.ColAndIdxExistenceMap.SetChecked()
	}
	if !item.IsIndex && colHist != nil {
		c := tbl.GetCol(item.ID)
		// - If the stats is fully loaded,
		// - If the stats is meta-loaded and we also just need the meta.
		if c != nil && (c.IsFullLoad() || !fullLoaded) {
			return false
		}
		tbl = tbl.Copy()
		tbl.SetCol(item.ID, colHist)

		// If the column is analyzed we refresh the map for the possible change.
		if colHist.StatsAvailable() {
			tbl.ColAndIdxExistenceMap.InsertCol(item.ID, true)
		}
		// All the objects shares the same stats version. Update it here.
		if colHist.StatsVer != statistics.Version0 {
			tbl.StatsVer = statistics.Version0
		}
		// we have to refresh the map for the possible change to ensure that the map information is not missing.
		tbl.ColAndIdxExistenceMap.InsertCol(item.ID, colHist.StatsAvailable())
	} else if item.IsIndex && idxHist != nil {
		index := tbl.GetIdx(item.ID)
		// - If the stats is fully loaded,
		// - If the stats is meta-loaded and we also just need the meta.
		if index != nil && (index.IsFullLoad() || !fullLoaded) {
			return true
		}
		tbl = tbl.Copy()
		tbl.SetIdx(item.ID, idxHist)
		// If the index is analyzed we refresh the map for the possible change.
		if idxHist.IsAnalyzed() {
			tbl.ColAndIdxExistenceMap.InsertIndex(item.ID, true)
			// All the objects shares the same stats version. Update it here.
			tbl.StatsVer = statistics.Version0
		}
	}
	s.statsHandle.UpdateStatsCache(statstypes.CacheUpdate{
		Updated: []*statistics.Table{tbl},
	})
	return true
}
