/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 search_common

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"time"

	tagcommon "github.com/apache/answer/internal/service/tag_common"
	"github.com/apache/answer/plugin"

	"github.com/apache/answer/pkg/htmltext"

	"github.com/apache/answer/internal/base/data"
	"github.com/apache/answer/internal/base/handler"
	"github.com/apache/answer/internal/base/reason"
	"github.com/apache/answer/internal/entity"
	"github.com/apache/answer/internal/schema"
	"github.com/apache/answer/internal/service/search_common"
	"github.com/apache/answer/internal/service/unique"
	usercommon "github.com/apache/answer/internal/service/user_common"
	"github.com/apache/answer/pkg/converter"
	"github.com/apache/answer/pkg/obj"
	"github.com/apache/answer/pkg/uid"
	"github.com/segmentfault/pacman/errors"
	"xorm.io/builder"
)

var (
	qFields = []string{
		"`question`.`id`",
		"`question`.`id` as `question_id`",
		"`title`",
		"`parsed_text`",
		"`question`.`created_at` as `created_at`",
		"`user_id`",
		"`vote_count`",
		"`answer_count`",
		"CASE WHEN `accepted_answer_id` > 0 THEN 2 ELSE 0 END as `accepted`",
		"`question`.`status` as `status`",
		"`post_update_time`",
	}
	aFields = []string{
		"`answer`.`id` as `id`",
		"`question_id`",
		"`question`.`title` as `title`",
		"`answer`.`parsed_text` as `parsed_text`",
		"`answer`.`created_at` as `created_at`",
		"`answer`.`user_id` as `user_id`",
		"`answer`.`vote_count` as `vote_count`",
		"0 as `answer_count`",
		"`adopted` as `accepted`",
		"`answer`.`status` as `status`",
		"`answer`.`created_at` as `post_update_time`",
	}
)

// searchRepo tag repository
type searchRepo struct {
	data         *data.Data
	userCommon   *usercommon.UserCommon
	uniqueIDRepo unique.UniqueIDRepo
	tagCommon    *tagcommon.TagCommonService
}

// NewSearchRepo new repository
func NewSearchRepo(
	data *data.Data,
	uniqueIDRepo unique.UniqueIDRepo,
	userCommon *usercommon.UserCommon,
	tagCommon *tagcommon.TagCommonService,
) search_common.SearchRepo {
	return &searchRepo{
		data:         data,
		uniqueIDRepo: uniqueIDRepo,
		userCommon:   userCommon,
		tagCommon:    tagCommon,
	}
}

// SearchContents search question and answer data
func (sr *searchRepo) SearchContents(ctx context.Context, words []string, tagIDs [][]string, userID string, votes int, page, pageSize int, order string) (resp []*schema.SearchResult, total int64, err error) {
	words = filterWords(words)

	var (
		b     *builder.Builder
		ub    *builder.Builder
		qfs   = qFields
		afs   = aFields
		argsQ = []any{}
		argsA = []any{}
	)

	if order == "relevance" {
		if len(words) > 0 {
			qfs, argsQ = addRelevanceField([]string{"title", "original_text"}, words, qfs)
			afs, argsA = addRelevanceField([]string{"`answer`.`original_text`"}, words, afs)
		} else {
			order = "newest"
		}
	}

	b = builder.MySQL().Select(qfs...).From("`question`")
	ub = builder.MySQL().Select(afs...).From("`answer`").
		LeftJoin("`question`", "`question`.id = `answer`.question_id")

	b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).
		And(builder.Eq{"`question`.`show`": entity.QuestionShow})
	ub.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).
		And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).
		And(builder.Eq{"`question`.`show`": entity.QuestionShow})

	argsQ = append(argsQ, entity.QuestionStatusDeleted, entity.QuestionShow)
	argsA = append(argsA, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow)

	likeConQ := builder.NewCond()
	likeConA := builder.NewCond()
	for _, word := range words {
		likeConQ = likeConQ.Or(builder.Like{"title", word}).
			Or(builder.Like{"original_text", word})
		argsQ = append(argsQ, "%"+word+"%")
		argsQ = append(argsQ, "%"+word+"%")

		likeConA = likeConA.Or(builder.Like{"`answer`.original_text", word})
		argsA = append(argsA, "%"+word+"%")
	}

	b.Where(likeConQ)
	ub.Where(likeConA)

	// check tag
	for ti, tagID := range tagIDs {
		ast := "tag_rel" + strconv.Itoa(ti)
		b.Join("INNER", "tag_rel as "+ast, "question.id = "+ast+".object_id").
			And(builder.Eq{
				ast + ".status": entity.TagRelStatusAvailable,
			}).
			And(builder.In(ast+".tag_id", tagID))
		ub.Join("INNER", "tag_rel as "+ast, "question_id = "+ast+".object_id").
			And(builder.Eq{
				ast + ".status": entity.TagRelStatusAvailable,
			}).
			And(builder.In(ast+".tag_id", tagID))
		argsQ = append(argsQ, entity.TagRelStatusAvailable)
		argsA = append(argsA, entity.TagRelStatusAvailable)
		for _, t := range tagID {
			argsQ = append(argsQ, t)
			argsA = append(argsA, t)
		}
	}

	// check user
	if userID != "" {
		b.Where(builder.Eq{"question.user_id": userID})
		ub.Where(builder.Eq{"answer.user_id": userID})
		argsQ = append(argsQ, userID)
		argsA = append(argsA, userID)
	}

	// check vote
	if votes == 0 {
		b.Where(builder.Eq{"question.vote_count": votes})
		ub.Where(builder.Eq{"answer.vote_count": votes})
		argsQ = append(argsQ, votes)
		argsA = append(argsA, votes)
	} else if votes > 0 {
		b.Where(builder.Gte{"question.vote_count": votes})
		ub.Where(builder.Gte{"answer.vote_count": votes})
		argsQ = append(argsQ, votes)
		argsA = append(argsA, votes)
	}

	// b = b.Union("all", ub)
	ubSQL, _, err := ub.ToSQL()
	if err != nil {
		return
	}
	bSQL, _, err := b.ToSQL()
	if err != nil {
		return
	}
	sql := fmt.Sprintf("(%s UNION ALL %s)", bSQL, ubSQL)

	countSQL, _, err := builder.MySQL().Select("count(*) total").From(sql, "c").ToSQL()
	if err != nil {
		return
	}

	startNum := (page - 1) * pageSize
	querySQL, _, err := builder.MySQL().Select("*").From(sql, "t").OrderBy(sr.parseOrder(ctx, order)).Limit(pageSize, startNum).ToSQL()
	if err != nil {
		return
	}

	queryArgs := []any{}
	countArgs := []any{}

	queryArgs = append(queryArgs, querySQL)
	queryArgs = append(queryArgs, argsQ...)
	queryArgs = append(queryArgs, argsA...)

	countArgs = append(countArgs, countSQL)
	countArgs = append(countArgs, argsQ...)
	countArgs = append(countArgs, argsA...)

	res, err := sr.data.DB.Context(ctx).Query(queryArgs...)
	if err != nil {
		return
	}

	tr, err := sr.data.DB.Context(ctx).Query(countArgs...)
	if len(tr) != 0 {
		total = converter.StringToInt64(string(tr[0]["total"]))
	}
	if err != nil {
		err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
		return
	} else {
		resp, err = sr.parseResult(ctx, res, words)
		return
	}
}

// SearchQuestions search question data
func (sr *searchRepo) SearchQuestions(ctx context.Context, words []string, tagIDs [][]string, notAccepted bool, views, answers int, page, pageSize int, order string) (resp []*schema.SearchResult, total int64, err error) {
	words = filterWords(words)
	var (
		qfs  = qFields
		args = []any{}
	)
	if order == "relevance" {
		if len(words) > 0 {
			qfs, args = addRelevanceField([]string{"title", "original_text"}, words, qfs)
		} else {
			order = "newest"
		}
	}

	b := builder.MySQL().Select(qfs...).From("question")

	b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
	args = append(args, entity.QuestionStatusDeleted, entity.QuestionShow)

	likeConQ := builder.NewCond()
	for _, word := range words {
		likeConQ = likeConQ.Or(builder.Like{"title", word}).
			Or(builder.Like{"original_text", word})
		args = append(args, "%"+word+"%")
		args = append(args, "%"+word+"%")
	}
	b.Where(likeConQ)

	// check tag
	for ti, tagID := range tagIDs {
		ast := "tag_rel" + strconv.Itoa(ti)
		b.Join("INNER", "tag_rel as "+ast, "question.id = "+ast+".object_id").
			And(builder.Eq{
				ast + ".status": entity.TagRelStatusAvailable,
			}).
			And(builder.In(ast+".tag_id", tagID))
		args = append(args, entity.TagRelStatusAvailable)
		for _, t := range tagID {
			args = append(args, t)
		}
	}

	// check need filter has not accepted
	if notAccepted {
		b.And(builder.Eq{"accepted_answer_id": 0})
		args = append(args, 0)
	}

	// check views
	if views > -1 {
		b.And(builder.Gte{"view_count": views})
		args = append(args, views)
	}

	// check answers
	if answers == 0 {
		b.And(builder.Eq{"answer_count": answers})
		args = append(args, answers)
	} else if answers > 0 {
		b.And(builder.Gte{"answer_count": answers})
		args = append(args, answers)
	}

	if answers == 0 {
		b.And(builder.Eq{"answer_count": 0})
		args = append(args, 0)
	} else if answers > 0 {
		b.And(builder.Gte{"answer_count": answers})
		args = append(args, answers)
	}

	queryArgs := []any{}
	countArgs := []any{}

	countSQL, _, err := builder.MySQL().Select("count(*) total").From(b, "c").ToSQL()
	if err != nil {
		return
	}

	startNum := (page - 1) * pageSize
	querySQL, _, err := b.OrderBy(sr.parseOrder(ctx, order)).Limit(pageSize, startNum).ToSQL()
	if err != nil {
		return
	}
	queryArgs = append(queryArgs, querySQL)
	queryArgs = append(queryArgs, args...)

	countArgs = append(countArgs, countSQL)
	countArgs = append(countArgs, args...)

	res, err := sr.data.DB.Context(ctx).Query(queryArgs...)
	if err != nil {
		return
	}

	tr, err := sr.data.DB.Context(ctx).Query(countArgs...)
	if err != nil {
		return
	}

	if len(tr) != 0 {
		total = converter.StringToInt64(string(tr[0]["total"]))
	}
	resp, err = sr.parseResult(ctx, res, words)
	if err != nil {
		err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
	}
	return
}

// SearchAnswers search answer data
func (sr *searchRepo) SearchAnswers(ctx context.Context, words []string, tagIDs [][]string, accepted bool, questionID string, page, pageSize int, order string) (resp []*schema.SearchResult, total int64, err error) {
	words = filterWords(words)

	var (
		afs  = aFields
		args = []any{}
	)
	if order == "relevance" {
		if len(words) > 0 {
			afs, args = addRelevanceField([]string{"`answer`.`original_text`"}, words, afs)
		} else {
			order = "newest"
		}
	}

	b := builder.MySQL().Select(afs...).From("`answer`").
		LeftJoin("`question`", "`question`.id = `answer`.question_id")

	b.Where(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).
		And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
	args = append(args, entity.QuestionStatusDeleted, entity.AnswerStatusDeleted, entity.QuestionShow)

	likeConA := builder.NewCond()
	for _, word := range words {
		likeConA = likeConA.Or(builder.Like{"`answer`.original_text", word})
		args = append(args, "%"+word+"%")
	}

	b.Where(likeConA)

	// check tag
	for ti, tagID := range tagIDs {
		ast := "tag_rel" + strconv.Itoa(ti)
		b.Join("INNER", "tag_rel as "+ast, "question_id = "+ast+".object_id").
			And(builder.Eq{
				ast + ".status": entity.TagRelStatusAvailable,
			}).
			And(builder.In(ast+".tag_id", tagID))
		args = append(args, entity.TagRelStatusAvailable)
		for _, t := range tagID {
			args = append(args, t)
		}
	}

	// check limit accepted
	if accepted {
		b.Where(builder.Eq{"adopted": schema.AnswerAcceptedEnable})
		args = append(args, schema.AnswerAcceptedEnable)
	}

	// check question id
	if questionID != "" {
		b.Where(builder.Eq{"question_id": questionID})
		args = append(args, questionID)
	}

	queryArgs := []any{}
	countArgs := []any{}

	countSQL, _, err := builder.MySQL().Select("count(*) total").From(b, "c").ToSQL()
	if err != nil {
		return
	}

	startNum := (page - 1) * pageSize
	querySQL, _, err := b.OrderBy(sr.parseOrder(ctx, order)).Limit(pageSize, startNum).ToSQL()
	if err != nil {
		return
	}

	queryArgs = append(queryArgs, querySQL)
	queryArgs = append(queryArgs, args...)

	countArgs = append(countArgs, countSQL)
	countArgs = append(countArgs, args...)

	res, err := sr.data.DB.Context(ctx).Query(queryArgs...)
	if err != nil {
		return
	}

	tr, err := sr.data.DB.Context(ctx).Query(countArgs...)
	if err != nil {
		return
	}

	total = converter.StringToInt64(string(tr[0]["total"]))
	resp, err = sr.parseResult(ctx, res, words)
	if err != nil {
		err = errors.InternalServer(reason.DatabaseError).WithError(err).WithStack()
	}
	return
}

func (sr *searchRepo) parseOrder(_ context.Context, order string) (res string) {
	switch order {
	case "newest":
		res = "created_at desc"
	case "active":
		res = "post_update_time desc"
	case "score":
		res = "vote_count desc"
	case "relevance":
		res = "relevance desc"
	default:
		res = "created_at desc"
	}
	return
}

// ParseSearchPluginResult parse search plugin result
func (sr *searchRepo) ParseSearchPluginResult(ctx context.Context, sres []plugin.SearchResult, words []string) (resp []*schema.SearchResult, err error) {
	var (
		qres []map[string][]byte
		res  = make([]map[string][]byte, 0)
		b    *builder.Builder
	)
	for _, r := range sres {
		switch r.Type {
		case "question":
			b = builder.MySQL().Select(qFields...).From("question").Where(builder.Eq{"id": r.ID}).
				And(builder.Lt{"`status`": entity.QuestionStatusDeleted})
		case "answer":
			b = builder.MySQL().Select(aFields...).From("answer").LeftJoin("`question`", "`question`.`id` = `answer`.`question_id`").
				Where(builder.Eq{"`answer`.`id`": r.ID}).
				And(builder.Lt{"`question`.`status`": entity.QuestionStatusDeleted}).
				And(builder.Lt{"`answer`.`status`": entity.AnswerStatusDeleted}).And(builder.Eq{"`question`.`show`": entity.QuestionShow})
		}
		qres, err = sr.data.DB.Context(ctx).Query(b)
		if err != nil || len(qres) == 0 {
			continue
		}
		res = append(res, qres[0])
	}
	return sr.parseResult(ctx, res, words)
}

// parseResult parse search result, return the data structure
func (sr *searchRepo) parseResult(ctx context.Context, res []map[string][]byte, words []string) (resp []*schema.SearchResult, err error) {
	questionIDs := make([]string, 0)
	userIDs := make([]string, 0)
	resultList := make([]*schema.SearchResult, 0)
	for _, r := range res {
		questionIDs = append(questionIDs, string(r["question_id"]))
		userIDs = append(userIDs, string(r["user_id"]))
		tp, _ := time.ParseInLocation("2006-01-02 15:04:05", string(r["created_at"]), time.Local)

		var ID = string(r["id"])
		var QuestionID = string(r["question_id"])
		if handler.GetEnableShortID(ctx) {
			ID = uid.EnShortID(ID)
			QuestionID = uid.EnShortID(QuestionID)
		}

		object := &schema.SearchObject{
			ID:              ID,
			QuestionID:      QuestionID,
			Title:           string(r["title"]),
			UrlTitle:        htmltext.UrlTitle(string(r["title"])),
			Excerpt:         htmltext.FetchMatchedExcerpt(string(r["parsed_text"]), words, "...", 100),
			CreatedAtParsed: tp.Unix(),
			UserInfo: &schema.SearchObjectUser{
				ID: string(r["user_id"]),
			},
			Tags:        make([]*schema.TagResp, 0),
			VoteCount:   converter.StringToInt(string(r["vote_count"])),
			Accepted:    string(r["accepted"]) == "2",
			AnswerCount: converter.StringToInt(string(r["answer_count"])),
		}

		objectKey, err := obj.GetObjectTypeStrByObjectID(string(r["id"]))
		if err != nil {
			continue
		}

		switch objectKey {
		case "question":
			for k, v := range entity.AdminQuestionSearchStatus {
				if v == converter.StringToInt(string(r["status"])) {
					object.StatusStr = k
					break
				}
			}
		case "answer":
			for k, v := range entity.AdminAnswerSearchStatus {
				if v == converter.StringToInt(string(r["status"])) {
					object.StatusStr = k
					break
				}
			}
		}

		resultList = append(resultList, &schema.SearchResult{
			ObjectType: objectKey,
			Object:     object,
		})
	}

	tagsMap, err := sr.tagCommon.BatchGetObjectTag(ctx, questionIDs)
	if err != nil {
		return nil, err
	}
	userInfoMap, err := sr.userCommon.BatchUserBasicInfoByID(ctx, userIDs)
	if err != nil {
		return nil, err
	}

	for _, item := range resultList {
		tags, ok := tagsMap[item.Object.QuestionID]
		if ok {
			item.Object.Tags = tags
		}
		if userInfo := userInfoMap[item.Object.UserInfo.ID]; userInfo != nil {
			item.Object.UserInfo.Username = userInfo.Username
			item.Object.UserInfo.DisplayName = userInfo.DisplayName
			item.Object.UserInfo.Rank = userInfo.Rank
			item.Object.UserInfo.Status = userInfo.Status
		}
	}
	return resultList, nil
}

func addRelevanceField(searchFields, words, fields []string) (res []string, args []any) {
	relevanceRes := []string{}
	args = []any{}

	for _, searchField := range searchFields {
		var (
			relevance    = "(LENGTH(" + searchField + ") - LENGTH(%s))"
			replacement  = "REPLACE(%s, ?, '')"
			replaceField = searchField
			replaced     string
			argsField    = []any{}
		)

		res = fields
		for i, word := range words {
			if i == 0 {
				argsField = append(argsField, word)
				replaced = fmt.Sprintf(replacement, replaceField)
			} else {
				argsField = append(argsField, word)
				replaced = fmt.Sprintf(replacement, replaced)
			}
		}
		args = append(args, argsField...)

		relevance = fmt.Sprintf(relevance, replaced)
		relevanceRes = append(relevanceRes, relevance)
	}

	res = append(res, "("+strings.Join(relevanceRes, " + ")+") as relevance")
	return
}

func filterWords(words []string) (res []string) {
	for _, word := range words {
		if strings.TrimSpace(word) != "" {
			res = append(res, word)
		}
	}
	return
}
