package meilisearch

import (
	"context"
	"fmt"
	"net/http"
	"strconv"
	"time"

	"github.com/golang-jwt/jwt/v4"
)

type meilisearch struct {
	client *client
}

// New create new service manager for operating on meilisearch
func New(host string, options ...Option) ServiceManager {
	defOpt := defaultMeiliOpt

	for _, opt := range options {
		opt(defOpt)
	}

	return &meilisearch{
		client: newClient(
			defOpt.client,
			host,
			defOpt.apiKey,
			clientConfig{
				contentEncoding:          defOpt.contentEncoding.encodingType,
				encodingCompressionLevel: defOpt.contentEncoding.level,
				disableRetry:             defOpt.disableRetry,
				retryOnStatus:            defOpt.retryOnStatus,
				maxRetries:               defOpt.maxRetries,
			},
		),
	}
}

// Connect create service manager and check connection with meilisearch
func Connect(host string, options ...Option) (ServiceManager, error) {
	meili := New(host, options...)

	if !meili.IsHealthy() {
		return nil, ErrConnectingFailed
	}

	return meili, nil
}

func (m *meilisearch) GetServiceReader() ServiceReader {
	return m
}

func (m *meilisearch) GetTaskManager() TaskManager {
	return m
}

func (m *meilisearch) GetTaskReader() TaskReader {
	return m
}

func (m *meilisearch) GetKeyManager() KeyManager {
	return m
}

func (m *meilisearch) GetKeyReader() KeyReader {
	return m
}

func (m *meilisearch) Index(uid string) IndexManager {
	return newIndex(m.client, uid)
}

func (m *meilisearch) GetIndex(indexID string) (*IndexResult, error) {
	return m.GetIndexWithContext(context.Background(), indexID)
}

func (m *meilisearch) GetIndexWithContext(ctx context.Context, indexID string) (*IndexResult, error) {
	return newIndex(m.client, indexID).FetchInfoWithContext(ctx)
}

func (m *meilisearch) GetRawIndex(uid string) (map[string]interface{}, error) {
	return m.GetRawIndexWithContext(context.Background(), uid)
}

func (m *meilisearch) GetRawIndexWithContext(ctx context.Context, uid string) (map[string]interface{}, error) {
	resp := map[string]interface{}{}
	req := &internalRequest{
		endpoint:            "/indexes/" + uid,
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        &resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetRawIndex",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) ListIndexes(param *IndexesQuery) (*IndexesResults, error) {
	return m.ListIndexesWithContext(context.Background(), param)
}

func (m *meilisearch) ListIndexesWithContext(ctx context.Context, param *IndexesQuery) (*IndexesResults, error) {
	resp := new(IndexesResults)
	req := &internalRequest{
		endpoint:            "/indexes",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        &resp,
		withQueryParams:     map[string]string{},
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetIndexes",
	}
	if param != nil && param.Limit != 0 {
		req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10)
	}
	if param != nil && param.Offset != 0 {
		req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10)
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}

	for i := range resp.Results {
		resp.Results[i].IndexManager = newIndex(m.client, resp.Results[i].UID)
	}

	return resp, nil
}

func (m *meilisearch) GetRawIndexes(param *IndexesQuery) (map[string]interface{}, error) {
	return m.GetRawIndexesWithContext(context.Background(), param)
}

func (m *meilisearch) GetRawIndexesWithContext(ctx context.Context, param *IndexesQuery) (map[string]interface{}, error) {
	resp := map[string]interface{}{}
	req := &internalRequest{
		endpoint:            "/indexes",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        &resp,
		withQueryParams:     map[string]string{},
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetRawIndexes",
	}
	if param != nil && param.Limit != 0 {
		req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10)
	}
	if param != nil && param.Offset != 0 {
		req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10)
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) CreateIndex(config *IndexConfig) (*TaskInfo, error) {
	return m.CreateIndexWithContext(context.Background(), config)
}

func (m *meilisearch) CreateIndexWithContext(ctx context.Context, config *IndexConfig) (*TaskInfo, error) {
	request := &CreateIndexRequest{
		UID:        config.Uid,
		PrimaryKey: config.PrimaryKey,
	}
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/indexes",
		method:              http.MethodPost,
		contentType:         contentTypeJSON,
		withRequest:         request,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusAccepted},
		functionName:        "CreateIndex",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) DeleteIndex(uid string) (*TaskInfo, error) {
	return m.DeleteIndexWithContext(context.Background(), uid)
}

func (m *meilisearch) DeleteIndexWithContext(ctx context.Context, uid string) (*TaskInfo, error) {
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/indexes/" + uid,
		method:              http.MethodDelete,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusAccepted},
		functionName:        "DeleteIndex",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) MultiSearch(queries *MultiSearchRequest) (*MultiSearchResponse, error) {
	return m.MultiSearchWithContext(context.Background(), queries)
}

func (m *meilisearch) MultiSearchWithContext(ctx context.Context, queries *MultiSearchRequest) (*MultiSearchResponse, error) {
	resp := new(MultiSearchResponse)

	for i := 0; i < len(queries.Queries); i++ {
		queries.Queries[i].validate()
	}

	req := &internalRequest{
		endpoint:            "/multi-search",
		method:              http.MethodPost,
		contentType:         contentTypeJSON,
		withRequest:         queries,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "MultiSearch",
	}

	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}

	return resp, nil
}

func (m *meilisearch) CreateKey(request *Key) (*Key, error) {
	return m.CreateKeyWithContext(context.Background(), request)
}

func (m *meilisearch) CreateKeyWithContext(ctx context.Context, request *Key) (*Key, error) {
	parsedRequest := convertKeyToParsedKey(*request)
	resp := new(Key)
	req := &internalRequest{
		endpoint:            "/keys",
		method:              http.MethodPost,
		contentType:         contentTypeJSON,
		withRequest:         &parsedRequest,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusCreated},
		functionName:        "CreateKey",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) GetKey(identifier string) (*Key, error) {
	return m.GetKeyWithContext(context.Background(), identifier)
}

func (m *meilisearch) GetKeyWithContext(ctx context.Context, identifier string) (*Key, error) {
	resp := new(Key)
	req := &internalRequest{
		endpoint:            "/keys/" + identifier,
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetKey",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) GetKeys(param *KeysQuery) (*KeysResults, error) {
	return m.GetKeysWithContext(context.Background(), param)
}

func (m *meilisearch) GetKeysWithContext(ctx context.Context, param *KeysQuery) (*KeysResults, error) {
	resp := new(KeysResults)
	req := &internalRequest{
		endpoint:            "/keys",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        resp,
		withQueryParams:     map[string]string{},
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetKeys",
	}
	if param != nil && param.Limit != 0 {
		req.withQueryParams["limit"] = strconv.FormatInt(param.Limit, 10)
	}
	if param != nil && param.Offset != 0 {
		req.withQueryParams["offset"] = strconv.FormatInt(param.Offset, 10)
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) UpdateKey(keyOrUID string, request *Key) (*Key, error) {
	return m.UpdateKeyWithContext(context.Background(), keyOrUID, request)
}

func (m *meilisearch) UpdateKeyWithContext(ctx context.Context, keyOrUID string, request *Key) (*Key, error) {
	parsedRequest := KeyUpdate{Name: request.Name, Description: request.Description}
	resp := new(Key)
	req := &internalRequest{
		endpoint:            "/keys/" + keyOrUID,
		method:              http.MethodPatch,
		contentType:         contentTypeJSON,
		withRequest:         &parsedRequest,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "UpdateKey",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) DeleteKey(keyOrUID string) (bool, error) {
	return m.DeleteKeyWithContext(context.Background(), keyOrUID)
}

func (m *meilisearch) DeleteKeyWithContext(ctx context.Context, keyOrUID string) (bool, error) {
	req := &internalRequest{
		endpoint:            "/keys/" + keyOrUID,
		method:              http.MethodDelete,
		withRequest:         nil,
		withResponse:        nil,
		acceptedStatusCodes: []int{http.StatusNoContent},
		functionName:        "DeleteKey",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return false, err
	}
	return true, nil
}

func (m *meilisearch) GetTask(taskUID int64) (*Task, error) {
	return m.GetTaskWithContext(context.Background(), taskUID)
}

func (m *meilisearch) GetTaskWithContext(ctx context.Context, taskUID int64) (*Task, error) {
	return getTask(ctx, m.client, taskUID)
}

func (m *meilisearch) GetTasks(param *TasksQuery) (*TaskResult, error) {
	return m.GetTasksWithContext(context.Background(), param)
}

func (m *meilisearch) GetTasksWithContext(ctx context.Context, param *TasksQuery) (*TaskResult, error) {
	resp := new(TaskResult)
	req := &internalRequest{
		endpoint:            "/tasks",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        &resp,
		withQueryParams:     map[string]string{},
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetTasks",
	}
	if param != nil {
		encodeTasksQuery(param, req)
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) CancelTasks(param *CancelTasksQuery) (*TaskInfo, error) {
	return m.CancelTasksWithContext(context.Background(), param)
}

func (m *meilisearch) CancelTasksWithContext(ctx context.Context, param *CancelTasksQuery) (*TaskInfo, error) {
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/tasks/cancel",
		method:              http.MethodPost,
		withRequest:         nil,
		withResponse:        &resp,
		withQueryParams:     map[string]string{},
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "CancelTasks",
	}
	if param != nil {
		paramToSend := &TasksQuery{
			UIDS:             param.UIDS,
			IndexUIDS:        param.IndexUIDS,
			Statuses:         param.Statuses,
			Types:            param.Types,
			BeforeEnqueuedAt: param.BeforeEnqueuedAt,
			AfterEnqueuedAt:  param.AfterEnqueuedAt,
			BeforeStartedAt:  param.BeforeStartedAt,
			AfterStartedAt:   param.AfterStartedAt,
		}
		encodeTasksQuery(paramToSend, req)
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) DeleteTasks(param *DeleteTasksQuery) (*TaskInfo, error) {
	return m.DeleteTasksWithContext(context.Background(), param)
}

func (m *meilisearch) DeleteTasksWithContext(ctx context.Context, param *DeleteTasksQuery) (*TaskInfo, error) {
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/tasks",
		method:              http.MethodDelete,
		withRequest:         nil,
		withResponse:        &resp,
		withQueryParams:     map[string]string{},
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "DeleteTasks",
	}
	if param != nil {
		paramToSend := &TasksQuery{
			UIDS:             param.UIDS,
			IndexUIDS:        param.IndexUIDS,
			Statuses:         param.Statuses,
			Types:            param.Types,
			CanceledBy:       param.CanceledBy,
			BeforeEnqueuedAt: param.BeforeEnqueuedAt,
			AfterEnqueuedAt:  param.AfterEnqueuedAt,
			BeforeStartedAt:  param.BeforeStartedAt,
			AfterStartedAt:   param.AfterStartedAt,
			BeforeFinishedAt: param.BeforeFinishedAt,
			AfterFinishedAt:  param.AfterFinishedAt,
		}
		encodeTasksQuery(paramToSend, req)
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) SwapIndexes(param []*SwapIndexesParams) (*TaskInfo, error) {
	return m.SwapIndexesWithContext(context.Background(), param)
}

func (m *meilisearch) SwapIndexesWithContext(ctx context.Context, param []*SwapIndexesParams) (*TaskInfo, error) {
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/swap-indexes",
		method:              http.MethodPost,
		contentType:         contentTypeJSON,
		withRequest:         param,
		withResponse:        &resp,
		acceptedStatusCodes: []int{http.StatusAccepted},
		functionName:        "SwapIndexes",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) WaitForTask(taskUID int64, interval time.Duration) (*Task, error) {
	return waitForTask(context.Background(), m.client, taskUID, interval)
}

func (m *meilisearch) WaitForTaskWithContext(ctx context.Context, taskUID int64, interval time.Duration) (*Task, error) {
	return waitForTask(ctx, m.client, taskUID, interval)
}

func (m *meilisearch) GenerateTenantToken(
	apiKeyUID string,
	searchRules map[string]interface{},
	options *TenantTokenOptions,
) (string, error) {
	// validate the arguments
	if searchRules == nil {
		return "", fmt.Errorf("GenerateTenantToken: The search rules added in the token generation " +
			"must be of type array or object")
	}
	if (options == nil || options.APIKey == "") && m.client.apiKey == "" {
		return "", fmt.Errorf("GenerateTenantToken: The API key used for the token " +
			"generation must exist and be a valid meilisearch key")
	}
	if apiKeyUID == "" || !IsValidUUID(apiKeyUID) {
		return "", fmt.Errorf("GenerateTenantToken: The uid used for the token " +
			"generation must exist and comply to uuid4 format")
	}
	if options != nil && !options.ExpiresAt.IsZero() && options.ExpiresAt.Before(time.Now()) {
		return "", fmt.Errorf("GenerateTenantToken: When the expiresAt field in " +
			"the token generation has a value, it must be a date set in the future")
	}

	var secret string
	if options == nil || options.APIKey == "" {
		secret = m.client.apiKey
	} else {
		secret = options.APIKey
	}

	// For HMAC signing method, the key should be any []byte
	hmacSampleSecret := []byte(secret)

	// Create the claims
	claims := TenantTokenClaims{}
	if options != nil && !options.ExpiresAt.IsZero() {
		claims.RegisteredClaims = jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
		}
	}
	claims.APIKeyUID = apiKeyUID
	claims.SearchRules = searchRules

	// Create a new token object, specifying signing method and the claims
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

	// Sign and get the complete encoded token as a string using the secret
	tokenString, err := token.SignedString(hmacSampleSecret)

	return tokenString, err
}

func (m *meilisearch) GetStats() (*Stats, error) {
	return m.GetStatsWithContext(context.Background())
}

func (m *meilisearch) GetStatsWithContext(ctx context.Context) (*Stats, error) {
	resp := new(Stats)
	req := &internalRequest{
		endpoint:            "/stats",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetStats",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) CreateDump() (*TaskInfo, error) {
	return m.CreateDumpWithContext(context.Background())
}

func (m *meilisearch) CreateDumpWithContext(ctx context.Context) (*TaskInfo, error) {
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/dumps",
		method:              http.MethodPost,
		contentType:         contentTypeJSON,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusAccepted},
		functionName:        "CreateDump",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) Version() (*Version, error) {
	return m.VersionWithContext(context.Background())
}

func (m *meilisearch) VersionWithContext(ctx context.Context) (*Version, error) {
	resp := new(Version)
	req := &internalRequest{
		endpoint:            "/version",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "Version",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) Health() (*Health, error) {
	return m.HealthWithContext(context.Background())
}

func (m *meilisearch) HealthWithContext(ctx context.Context) (*Health, error) {
	resp := new(Health)
	req := &internalRequest{
		endpoint:            "/health",
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "Health",
	}
	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) CreateSnapshot() (*TaskInfo, error) {
	return m.CreateSnapshotWithContext(context.Background())
}

func (m *meilisearch) CreateSnapshotWithContext(ctx context.Context) (*TaskInfo, error) {
	resp := new(TaskInfo)
	req := &internalRequest{
		endpoint:            "/snapshots",
		method:              http.MethodPost,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusAccepted},
		contentType:         contentTypeJSON,
		functionName:        "CreateSnapshot",
	}

	if err := m.client.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func (m *meilisearch) IsHealthy() bool {
	res, err := m.HealthWithContext(context.Background())
	return err == nil && res.Status == "available"
}

func (m *meilisearch) Close() {
	m.client.client.CloseIdleConnections()
}

func getTask(ctx context.Context, cli *client, taskUID int64) (*Task, error) {
	resp := new(Task)
	req := &internalRequest{
		endpoint:            "/tasks/" + strconv.FormatInt(taskUID, 10),
		method:              http.MethodGet,
		withRequest:         nil,
		withResponse:        resp,
		acceptedStatusCodes: []int{http.StatusOK},
		functionName:        "GetTask",
	}
	if err := cli.executeRequest(ctx, req); err != nil {
		return nil, err
	}
	return resp, nil
}

func waitForTask(ctx context.Context, cli *client, taskUID int64, interval time.Duration) (*Task, error) {
	if interval == 0 {
		interval = 50 * time.Millisecond
	}

	// extract closure to get the task and check the status first before the ticker
	fn := func() (*Task, error) {
		getTask, err := getTask(ctx, cli, taskUID)
		if err != nil {
			return nil, err
		}

		if getTask.Status != TaskStatusEnqueued && getTask.Status != TaskStatusProcessing {
			return getTask, nil
		}
		return nil, nil
	}

	// run first before the ticker, we do not want to wait for the first interval
	task, err := fn()
	if err != nil {
		// Return error if it exists
		return nil, err
	}

	// Return task if it exists
	if task != nil {
		return task, nil
	}

	// Create a ticker to check the task status, because our initial check was not successful
	ticker := time.NewTicker(interval)

	// Defer the stop of the ticker, help GC to cleanup
	defer func() {
		// we might want to revist this, go.mod now is 1.16
		// however I still encouter the issue on go 1.22.2
		// there are 2 issues regarding tickers
		// https://go-review.googlesource.com/c/go/+/512355
		// https://github.com/golang/go/issues/61542
		ticker.Stop()
		ticker = nil
	}()

	for {
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-ticker.C:
			task, err := fn()
			if err != nil {
				return nil, err
			}

			if task != nil {
				return task, nil
			}
		}
	}
}
