// Copyright 2020 The Matrix.org Foundation C.I.C.
//
// 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 routing

import (
	"context"
	"math/rand"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"sync"

	"github.com/matrix-org/dendrite/clientapi/api"
	"github.com/matrix-org/dendrite/clientapi/httputil"
	"github.com/matrix-org/dendrite/clientapi/jsonerror"
	roomserverAPI "github.com/matrix-org/dendrite/roomserver/api"
	"github.com/matrix-org/dendrite/setup/config"
	"github.com/matrix-org/gomatrixserverlib"
	"github.com/matrix-org/util"
)

var (
	cacheMu          sync.Mutex
	publicRoomsCache []gomatrixserverlib.PublicRoom
)

type PublicRoomReq struct {
	Since  string `json:"since,omitempty"`
	Limit  int16  `json:"limit,omitempty"`
	Filter filter `json:"filter,omitempty"`
	Server string `json:"server,omitempty"`
}

type filter struct {
	SearchTerms string `json:"generic_search_term,omitempty"`
}

// GetPostPublicRooms implements GET and POST /publicRooms
func GetPostPublicRooms(
	req *http.Request, rsAPI roomserverAPI.RoomserverInternalAPI,
	extRoomsProvider api.ExtraPublicRoomsProvider,
	federation *gomatrixserverlib.FederationClient,
	cfg *config.ClientAPI,
) util.JSONResponse {
	var request PublicRoomReq
	if fillErr := fillPublicRoomsReq(req, &request); fillErr != nil {
		return *fillErr
	}

	serverName := gomatrixserverlib.ServerName(request.Server)

	if serverName != "" && serverName != cfg.Matrix.ServerName {
		res, err := federation.GetPublicRooms(req.Context(), serverName, int(request.Limit), request.Since, false, "")
		if err != nil {
			util.GetLogger(req.Context()).WithError(err).Error("failed to get public rooms")
			return jsonerror.InternalServerError()
		}
		return util.JSONResponse{
			Code: http.StatusOK,
			JSON: res,
		}
	}

	response, err := publicRooms(req.Context(), request, rsAPI, extRoomsProvider)
	if err != nil {
		util.GetLogger(req.Context()).WithError(err).Errorf("failed to work out public rooms")
		return jsonerror.InternalServerError()
	}
	return util.JSONResponse{
		Code: http.StatusOK,
		JSON: response,
	}
}

func publicRooms(
	ctx context.Context, request PublicRoomReq, rsAPI roomserverAPI.RoomserverInternalAPI, extRoomsProvider api.ExtraPublicRoomsProvider,
) (*gomatrixserverlib.RespPublicRooms, error) {

	response := gomatrixserverlib.RespPublicRooms{
		Chunk: []gomatrixserverlib.PublicRoom{},
	}
	var limit int16
	var offset int64
	limit = request.Limit
	if limit == 0 {
		limit = 50
	}
	offset, err := strconv.ParseInt(request.Since, 10, 64)
	// ParseInt returns 0 and an error when trying to parse an empty string
	// In that case, we want to assign 0 so we ignore the error
	if err != nil && len(request.Since) > 0 {
		util.GetLogger(ctx).WithError(err).Error("strconv.ParseInt failed")
		return nil, err
	}
	err = nil

	var rooms []gomatrixserverlib.PublicRoom
	if request.Since == "" {
		rooms = refreshPublicRoomCache(ctx, rsAPI, extRoomsProvider)
	} else {
		rooms = getPublicRoomsFromCache()
	}

	response.TotalRoomCountEstimate = len(rooms)

	rooms = filterRooms(rooms, request.Filter.SearchTerms)

	chunk, prev, next := sliceInto(rooms, offset, limit)
	if prev >= 0 {
		response.PrevBatch = "T" + strconv.Itoa(prev)
	}
	if next >= 0 {
		response.NextBatch = "T" + strconv.Itoa(next)
	}
	if chunk != nil {
		response.Chunk = chunk
	}
	return &response, err
}

func filterRooms(rooms []gomatrixserverlib.PublicRoom, searchTerm string) []gomatrixserverlib.PublicRoom {
	if searchTerm == "" {
		return rooms
	}

	normalizedTerm := strings.ToLower(searchTerm)

	result := make([]gomatrixserverlib.PublicRoom, 0)
	for _, room := range rooms {
		if strings.Contains(strings.ToLower(room.Name), normalizedTerm) ||
			strings.Contains(strings.ToLower(room.Topic), normalizedTerm) ||
			strings.Contains(strings.ToLower(room.CanonicalAlias), normalizedTerm) {
			result = append(result, room)
		}
	}

	return result
}

// fillPublicRoomsReq fills the Limit, Since and Filter attributes of a GET or POST request
// on /publicRooms by parsing the incoming HTTP request
// Filter is only filled for POST requests
func fillPublicRoomsReq(httpReq *http.Request, request *PublicRoomReq) *util.JSONResponse {
	if httpReq.Method != "GET" && httpReq.Method != "POST" {
		return &util.JSONResponse{
			Code: http.StatusMethodNotAllowed,
			JSON: jsonerror.NotFound("Bad method"),
		}
	}
	if httpReq.Method == "GET" {
		limit, err := strconv.Atoi(httpReq.FormValue("limit"))
		// Atoi returns 0 and an error when trying to parse an empty string
		// In that case, we want to assign 0 so we ignore the error
		if err != nil && len(httpReq.FormValue("limit")) > 0 {
			util.GetLogger(httpReq.Context()).WithError(err).Error("strconv.Atoi failed")
			return &util.JSONResponse{
				Code: 400,
				JSON: jsonerror.BadJSON("limit param is not a number"),
			}
		}
		request.Limit = int16(limit)
		request.Since = httpReq.FormValue("since")
		request.Server = httpReq.FormValue("server")
	} else {
		resErr := httputil.UnmarshalJSONRequest(httpReq, request)
		if resErr != nil {
			return resErr
		}
		request.Server = httpReq.FormValue("server")
	}

	// strip the 'T' which is only required because when sytest does pagination tests it stops
	// iterating when !prev_batch which then fails if prev_batch==0, so add arbitrary text to
	// make it truthy not falsey.
	request.Since = strings.TrimPrefix(request.Since, "T")
	return nil
}

// sliceInto returns a subslice of `slice` which honours the since/limit values given.
//
//    0  1  2  3  4  5  6   index
//   [A, B, C, D, E, F, G]  slice
//
//   limit=3          => A,B,C (prev='', next='3')
//   limit=3&since=3  => D,E,F (prev='0', next='6')
//   limit=3&since=6  => G     (prev='3', next='')
//
//  A value of '-1' for prev/next indicates no position.
func sliceInto(slice []gomatrixserverlib.PublicRoom, since int64, limit int16) (subset []gomatrixserverlib.PublicRoom, prev, next int) {
	prev = -1
	next = -1

	if since > 0 {
		prev = int(since) - int(limit)
	}
	nextIndex := int(since) + int(limit)
	if len(slice) > nextIndex { // there are more rooms ahead of us
		next = nextIndex
	}

	// apply sanity caps
	if since < 0 {
		since = 0
	}
	if nextIndex > len(slice) {
		nextIndex = len(slice)
	}

	subset = slice[since:nextIndex]
	return
}

func refreshPublicRoomCache(
	ctx context.Context, rsAPI roomserverAPI.RoomserverInternalAPI, extRoomsProvider api.ExtraPublicRoomsProvider,
) []gomatrixserverlib.PublicRoom {
	cacheMu.Lock()
	defer cacheMu.Unlock()
	var extraRooms []gomatrixserverlib.PublicRoom
	if extRoomsProvider != nil {
		extraRooms = extRoomsProvider.Rooms()
	}

	var queryRes roomserverAPI.QueryPublishedRoomsResponse
	err := rsAPI.QueryPublishedRooms(ctx, &roomserverAPI.QueryPublishedRoomsRequest{}, &queryRes)
	if err != nil {
		util.GetLogger(ctx).WithError(err).Error("QueryPublishedRooms failed")
		return publicRoomsCache
	}
	pubRooms, err := roomserverAPI.PopulatePublicRooms(ctx, queryRes.RoomIDs, rsAPI)
	if err != nil {
		util.GetLogger(ctx).WithError(err).Error("PopulatePublicRooms failed")
		return publicRoomsCache
	}
	publicRoomsCache = []gomatrixserverlib.PublicRoom{}
	publicRoomsCache = append(publicRoomsCache, pubRooms...)
	publicRoomsCache = append(publicRoomsCache, extraRooms...)
	publicRoomsCache = dedupeAndShuffle(publicRoomsCache)

	// sort by total joined member count (big to small)
	sort.SliceStable(publicRoomsCache, func(i, j int) bool {
		return publicRoomsCache[i].JoinedMembersCount > publicRoomsCache[j].JoinedMembersCount
	})
	return publicRoomsCache
}

func getPublicRoomsFromCache() []gomatrixserverlib.PublicRoom {
	cacheMu.Lock()
	defer cacheMu.Unlock()
	return publicRoomsCache
}

func dedupeAndShuffle(in []gomatrixserverlib.PublicRoom) []gomatrixserverlib.PublicRoom {
	// de-duplicate rooms with the same room ID. We can join the room via any of these aliases as we know these servers
	// are alive and well, so we arbitrarily pick one (purposefully shuffling them to spread the load a bit)
	var publicRooms []gomatrixserverlib.PublicRoom
	haveRoomIDs := make(map[string]bool)
	rand.Shuffle(len(in), func(i, j int) {
		in[i], in[j] = in[j], in[i]
	})
	for _, r := range in {
		if haveRoomIDs[r.RoomID] {
			continue
		}
		haveRoomIDs[r.RoomID] = true
		publicRooms = append(publicRooms, r)
	}
	return publicRooms
}
