// gomuks - A terminal Matrix client written in Go.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package rooms

import (
	"compress/gzip"
	"encoding/gob"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	sync "github.com/sasha-s/go-deadlock"

	"maunium.net/go/mautrix/event"
	"maunium.net/go/mautrix/id"

	"maunium.net/go/gomuks/debug"
)

// RoomCache contains room state info in a hashmap and linked list.
type RoomCache struct {
	sync.Mutex

	listPath  string
	directory string
	maxSize   int
	maxAge    int64
	getOwner  func() id.UserID
	noUnload  bool

	Map  map[id.RoomID]*Room
	head *Room
	tail *Room
	size int
}

func NewRoomCache(listPath, directory string, maxSize int, maxAge int64, getOwner func() id.UserID) *RoomCache {
	return &RoomCache{
		listPath:  listPath,
		directory: directory,
		maxSize:   maxSize,
		maxAge:    maxAge,
		getOwner:  getOwner,

		Map: make(map[id.RoomID]*Room),
	}
}

func (cache *RoomCache) DisableUnloading() {
	cache.noUnload = true
}

func (cache *RoomCache) EnableUnloading() {
	cache.noUnload = false
}

func (cache *RoomCache) IsEncrypted(roomID id.RoomID) bool {
	room := cache.Get(roomID)
	return room != nil && room.Encrypted
}

func (cache *RoomCache) GetEncryptionEvent(roomID id.RoomID) *event.EncryptionEventContent {
	room := cache.Get(roomID)
	evt := room.GetStateEvent(event.StateEncryption, "")
	if evt == nil {
		return nil
	}
	content, ok := evt.Content.Parsed.(*event.EncryptionEventContent)
	if !ok {
		return nil
	}
	return content
}

func (cache *RoomCache) FindSharedRooms(userID id.UserID) (shared []id.RoomID) {
	// FIXME this disables unloading so TouchNode wouldn't try to double-lock
	cache.DisableUnloading()
	cache.Lock()
	for _, room := range cache.Map {
		if !room.Encrypted {
			continue
		}
		member, ok := room.GetMembers()[userID]
		if ok && member.Membership == event.MembershipJoin {
			shared = append(shared, room.ID)
		}
	}
	cache.Unlock()
	cache.EnableUnloading()
	return
}

func (cache *RoomCache) LoadList() error {
	cache.Lock()
	defer cache.Unlock()

	// Open room list file
	file, err := os.OpenFile(cache.listPath, os.O_RDONLY, 0600)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return fmt.Errorf("failed to open room list file for reading: %w", err)
	}
	defer debugPrintError(file.Close, "Failed to close room list file after reading")

	// Open gzip reader for room list file
	cmpReader, err := gzip.NewReader(file)
	if err != nil {
		return fmt.Errorf("failed to read gzip room list: %w", err)
	}
	defer debugPrintError(cmpReader.Close, "Failed to close room list gzip reader")

	// Open gob decoder for gzip reader
	dec := gob.NewDecoder(cmpReader)
	// Read number of items in list
	var size int
	err = dec.Decode(&size)
	if err != nil {
		return fmt.Errorf("failed to read size of room list: %w", err)
	}

	// Read list
	cache.Map = make(map[id.RoomID]*Room, size)
	for i := 0; i < size; i++ {
		room := &Room{}
		err = dec.Decode(room)
		if err != nil {
			debug.Printf("Failed to decode %dth room list entry: %v", i+1, err)
			continue
		}
		room.path = cache.roomPath(room.ID)
		room.cache = cache
		cache.Map[room.ID] = room
	}
	return nil
}

func (cache *RoomCache) SaveLoadedRooms() {
	cache.Lock()
	cache.clean(false)
	for node := cache.head; node != nil; node = node.prev {
		node.Save()
	}
	cache.Unlock()
}

func (cache *RoomCache) SaveList() error {
	cache.Lock()
	defer cache.Unlock()

	debug.Print("Saving room list...")
	// Open room list file
	file, err := os.OpenFile(cache.listPath, os.O_WRONLY|os.O_CREATE, 0600)
	if err != nil {
		return fmt.Errorf("failed to open room list file for writing: %w", err)
	}
	defer debugPrintError(file.Close, "Failed to close room list file after writing")

	// Open gzip writer for room list file
	cmpWriter := gzip.NewWriter(file)
	defer debugPrintError(cmpWriter.Close, "Failed to close room list gzip writer")

	// Open gob encoder for gzip writer
	enc := gob.NewEncoder(cmpWriter)
	// Write number of items in list
	err = enc.Encode(len(cache.Map))
	if err != nil {
		return fmt.Errorf("failed to write size of room list: %w", err)
	}

	// Write list
	for _, node := range cache.Map {
		err = enc.Encode(node)
		if err != nil {
			debug.Printf("Failed to encode room list entry of %s: %v", node.ID, err)
		}
	}
	debug.Print("Room list saved to", cache.listPath, len(cache.Map), cache.size)
	return nil
}

func (cache *RoomCache) Touch(roomID id.RoomID) {
	cache.Lock()
	node, ok := cache.Map[roomID]
	if !ok || node == nil {
		cache.Unlock()
		return
	}
	cache.touch(node)
	cache.Unlock()
}

func (cache *RoomCache) TouchNode(node *Room) {
	if cache.noUnload || node.touch+2 > time.Now().Unix() {
		return
	}
	cache.Lock()
	cache.touch(node)
	cache.Unlock()
}

func (cache *RoomCache) touch(node *Room) {
	if node == cache.head {
		return
	}
	debug.Print("Touching", node.ID)
	cache.llPop(node)
	cache.llPush(node)
	node.touch = time.Now().Unix()
}

func (cache *RoomCache) Get(roomID id.RoomID) *Room {
	cache.Lock()
	node := cache.get(roomID)
	cache.Unlock()
	return node
}

func (cache *RoomCache) GetOrCreate(roomID id.RoomID) *Room {
	cache.Lock()
	node := cache.get(roomID)
	if node == nil {
		node = cache.newRoom(roomID)
		cache.llPush(node)
	}
	cache.Unlock()
	return node
}

func (cache *RoomCache) get(roomID id.RoomID) *Room {
	node, ok := cache.Map[roomID]
	if ok && node != nil {
		return node
	}
	return nil
}

func (cache *RoomCache) Put(room *Room) {
	cache.Lock()
	node := cache.get(room.ID)
	if node != nil {
		cache.touch(node)
	} else {
		cache.Map[room.ID] = room
		if room.Loaded() {
			cache.llPush(room)
		}
		node = room
	}
	cache.Unlock()
	node.Save()
}

func (cache *RoomCache) roomPath(roomID id.RoomID) string {
	escapedRoomID := strings.ReplaceAll(strings.ReplaceAll(string(roomID), "%", "%25"), "/", "%2F")
	return filepath.Join(cache.directory, escapedRoomID+".gob.gz")
}

func (cache *RoomCache) Load(roomID id.RoomID) *Room {
	cache.Lock()
	defer cache.Unlock()
	node, ok := cache.Map[roomID]
	if ok {
		return node
	}

	node = NewRoom(roomID, cache)
	node.Load()
	return node
}

func (cache *RoomCache) llPop(node *Room) {
	if node.prev == nil && node.next == nil {
		return
	}
	if node.prev != nil {
		node.prev.next = node.next
	}
	if node.next != nil {
		node.next.prev = node.prev
	}
	if node == cache.tail {
		cache.tail = node.next
	}
	if node == cache.head {
		cache.head = node.prev
	}
	node.next = nil
	node.prev = nil
	cache.size--
}

func (cache *RoomCache) llPush(node *Room) {
	if node.next != nil || node.prev != nil {
		debug.PrintStack()
		debug.Print("Tried to llPush node that is already in stack")
		return
	}
	if node == cache.head {
		return
	}
	if cache.head != nil {
		cache.head.next = node
	}
	node.prev = cache.head
	node.next = nil
	cache.head = node
	if cache.tail == nil {
		cache.tail = node
	}
	cache.size++
	cache.clean(false)
}

func (cache *RoomCache) ForceClean() {
	cache.Lock()
	cache.clean(true)
	cache.Unlock()
}

func (cache *RoomCache) clean(force bool) {
	if cache.noUnload && !force {
		return
	}
	origSize := cache.size
	maxTS := time.Now().Unix() - cache.maxAge
	for cache.size > cache.maxSize {
		if cache.tail.touch > maxTS && !force {
			break
		}
		ok := cache.tail.Unload()
		node := cache.tail
		cache.llPop(node)
		if !ok {
			debug.Print("Unload returned false, pushing node back")
			cache.llPush(node)
		}
	}
	if cleaned := origSize - cache.size; cleaned > 0 {
		debug.Print("Cleaned", cleaned, "rooms")
	}
}

func (cache *RoomCache) Unload(node *Room) {
	cache.Lock()
	defer cache.Unlock()
	cache.llPop(node)
	ok := node.Unload()
	if !ok {
		debug.Print("Unload returned false, pushing node back")
		cache.llPush(node)
	}
}

func (cache *RoomCache) newRoom(roomID id.RoomID) *Room {
	node := NewRoom(roomID, cache)
	cache.Map[node.ID] = node
	return node
}
