// Copyright (c) 2020, Andreas Schneider
// All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//     * Redistributions of source code must retain the above copyright
//       notice, this list of conditions and the following disclaimer.
//     * Redistributions in binary form must reproduce the above copyright
//       notice, this list of conditions and the following disclaimer in the
//       documentation and/or other materials provided with the distribution.
//     * Neither the name of the <organization> nor the
//       names of its contributors may be used to endorse or promote products
//       derived from this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"strings"

	uuid "github.com/satori/go.uuid"
	"github.com/tidwall/buntdb"
)

type UserStore interface {
	AddUser(user User) (err error)
	GetUser(username string) (user User, err error)
	GetUsers() ([]User, error)
	UpdateUser(user User) error
	RemoveUser(username string) (err error)
}

type ShareStore interface {
	CreateShare() (Share, error)
	UpdateShareAttributes(share Share) error
	RemoveShare(id uuid.UUID) error
	AddUserToShare(share Share, username string, role ShareRole) error
	RemoveUserFromShare(share Share, username string) error
	AddLogin(share Share, username string, login Login) error
	RemoveLogin(share Share, username string, loginName string) error

	GetShares() ([]Share, error)

	FindShareByLogin(username, loginName string) (LoginShare, error)
	FindSharesByUser(username string) ([]UserShare, error)
}

var _ UserStore = (*DBStore)(nil)
var _ ShareStore = (*DBStore)(nil)

type GlobalRole string

const (
	GlobalRoleUser  GlobalRole = "user"
	GlobalRoleAdmin GlobalRole = "admin"
)

type ShareRole string

const (
	ShareRoleReader ShareRole = "reader"
	ShareRoleWriter ShareRole = "writer"
	ShareRoleAdmin  ShareRole = "admin"
)

type User struct {
	Username string
	Password string
	Role     GlobalRole
}

type Share struct {
	UUID        uuid.UUID
	Name        string
	Description string
}

type ShareUser struct {
	Username string
	Role     ShareRole
}

type Login struct {
	LoginName string
	Password  string
	ReadOnly  bool
}

// View on a share from the perspective of a user of that share.
type UserShare struct {
	Share
	Role ShareRole
}

// View on a share from the perspective of a specific login (with associated user).
type LoginShare struct {
	Share
	ShareUser
	Login
}

const userPrefix = "user:"
const sharePrefix = "share:"
const loginSharePrefix = "loginshare:"
const shareuserPrefix = "shareuser:"
const shareloginPrefix = "sharelogin:"

type DBStore struct {
	db *buntdb.DB
}

func (u User) key() string {
	return userPrefix + u.Username
}

func (s Share) key() string {
	return sharePrefix + s.UUID.String()
}

func (s Share) userKey(username string) string {
	return fmt.Sprintf("%s%s:%s", shareuserPrefix, s.UUID.String(), username)
}

func (s Share) loginKey(username, loginName string) string {
	return fmt.Sprintf("%s%s:%s:%s", shareloginPrefix, s.UUID.String(), username, loginName)
}

func NewDBStore(filename string) (*DBStore, error) {
	db, err := buntdb.Open(filename)
	if err != nil {
		return nil, err
	}

	return &DBStore{db}, nil
}

func (store *DBStore) Close() error {
	if err := store.db.Shrink(); err != nil {
		return err
	}
	return store.db.Close()
}

var ErrUserExists = errors.New("user already exists")
var ErrUserNotFound = errors.New("user not found")
var ErrInvalidUsername = errors.New("invalid username")

func (u User) merge(updates User) (User, error) {
	merged := u

	if updates.Password != "" {
		merged.Password = updates.Password
	}
	if updates.Role != "" {
		merged.Role = updates.Role
	}

	return merged, nil
}

var ErrShareNotFound = errors.New("share not found")
var ErrLoginNotFound = errors.New("login not found")
var ErrLoginDuplicate = errors.New("login already exists")

func (store *DBStore) AddUser(user User) (err error) {
	if strings.Contains(user.Username, ":") {
		return ErrInvalidUsername
	}
	if err = store.db.Update(func(tx *buntdb.Tx) error {
		userBytes, err := json.Marshal(user)
		if err != nil {
			return fmt.Errorf("cannot marshal user: %w", err)
		}

		if _, exists, err := tx.Set(user.key(), string(userBytes), nil); err != nil {
			return err
		} else if exists {
			return ErrUserExists
		}

		return nil
	}); err != nil {
		return err
	}
	return nil
}

func (store *DBStore) GetUser(username string) (user User, err error) {
	if strings.Contains(username, ":") {
		return user, ErrInvalidUsername
	}
	if err := store.db.View(func(tx *buntdb.Tx) error {
		user.Username = username
		if val, err := tx.Get(user.key()); err != nil && err != buntdb.ErrNotFound {
			return err
		} else if err == buntdb.ErrNotFound {
			return ErrUserNotFound
		} else if err := json.Unmarshal([]byte(val), &user); err != nil {
			return fmt.Errorf("cannot unmarshal user: %w", err)
		}

		return nil
	}); err != nil {
		return user, err
	}
	return user, nil
}

func (store *DBStore) GetUsers() (users []User, err error) {
	err = store.db.View(func(tx *buntdb.Tx) error {
		var processingError error

		if err := tx.AscendKeys(userPrefix+"*", func(key, value string) bool {
			var user User

			if err := json.Unmarshal([]byte(value), &user); err != nil {
				processingError = err
				return false
			}

			// Just in case ...
			user.Username = strings.TrimPrefix(key, userPrefix)

			users = append(users, user)
			return true
		}); err != nil {
			return err
		}

		return processingError
	})

	return users, err
}

func (store *DBStore) UpdateUser(user User) error {
	if strings.Contains(user.Username, ":") {
		return ErrInvalidUsername
	}
	return store.db.Update(func(tx *buntdb.Tx) error {
		var existingUser User

		if val, err := tx.Get(user.key()); err != nil && err != buntdb.ErrNotFound {
			return err
		} else if err == buntdb.ErrNotFound {
			return ErrUserNotFound
		} else if err := json.Unmarshal([]byte(val), &existingUser); err != nil {
			return fmt.Errorf("cannot unmarshal user: %w", err)
		}

		mergedUser, err := existingUser.merge(user)
		if err != nil {
			return fmt.Errorf("cannot merge user: %w", err)
		}
		mergedUser.Username = user.Username

		userBytes, err := json.Marshal(mergedUser)
		if err != nil {
			return fmt.Errorf("cannot marshal user: %w", err)
		}

		_, _, err = tx.Set(mergedUser.key(), string(userBytes), nil)

		return err
	})
}

func (store *DBStore) RemoveUser(username string) (err error) {
	if strings.Contains(username, ":") {
		return ErrInvalidUsername
	}
	return store.db.Update(func(tx *buntdb.Tx) error {
		user := User{Username: username}
		if _, err := tx.Delete(user.key()); err == buntdb.ErrNotFound {
			return ErrUserNotFound
		} else if err != nil {
			return err
		}
		return nil
	})
}

func (store *DBStore) CreateShare() (Share, error) {
	share := Share{
		UUID: uuid.NewV4(),
	}

	if err := store.db.Update(func(tx *buntdb.Tx) error {
		b, err := json.Marshal(share)
		if err != nil {
			return fmt.Errorf("cannot marshal share: %w", err)
		}
		if _, _, err := tx.Set(share.key(), string(b), nil); err != nil {
			return err
		}
		return nil
	}); err != nil {
		return Share{}, err
	}

	return share, nil
}

func (store *DBStore) UpdateShareAttributes(share Share) error {
	return store.db.Update(func(tx *buntdb.Tx) error {
		var existingShare Share
		if val, err := tx.Get(share.key()); err == buntdb.ErrNotFound {
			return ErrShareNotFound
		} else if err != nil {
			return err
		} else if err := json.Unmarshal([]byte(val), &existingShare); err != nil {
			return fmt.Errorf("cannot unmarshal share %s: %w", share.UUID, err)
		}

		existingShare.UUID = share.UUID
		existingShare.Name = share.Name
		existingShare.Description = share.Description

		if b, err := json.Marshal(existingShare); err != nil {
			return fmt.Errorf("cannot marshal share %s: %w", share.UUID, err)
		} else if _, _, err := tx.Set(existingShare.key(), string(b), nil); err != nil {
			return err
		}

		return nil
	})
}

func (store *DBStore) RemoveShare(id uuid.UUID) error {
	return store.db.Update(func(tx *buntdb.Tx) error {
		share := Share{UUID: id}

		var userNames []string
		usersPrefix := share.userKey("")
		if err := tx.AscendKeys(usersPrefix+"*", func(key, value string) bool {
			userNames = append(userNames, strings.TrimPrefix(key, usersPrefix))
			return true
		}); err != nil {
			return err
		}

		for _, username := range userNames {
			if err := store.removeUserFromShare(tx, share, username); err != nil {
				return fmt.Errorf("cannot remove user %q from share: %w", username, err)
			}
		}

		if _, err := tx.Delete(share.key()); err == buntdb.ErrNotFound {
			return ErrShareNotFound
		} else {
			return err
		}
	})
}

func (store *DBStore) AddUserToShare(share Share, username string, role ShareRole) error {
	if strings.Contains(username, ":") {
		return ErrInvalidUsername
	}
	return store.db.Update(func(tx *buntdb.Tx) error {
		if _, err := tx.Get(share.key()); err != nil {
			return ErrShareNotFound
		}
		if _, _, err := tx.Set(share.userKey(username), string(role), nil); err != nil {
			return fmt.Errorf("cannot set user: %w", err)
		}
		return nil
	})
}

func (store *DBStore) removeUserFromShare(tx *buntdb.Tx, share Share, username string) error {
	var logins []string
	loginsPrefix := share.loginKey(username, "")
	if err := tx.AscendKeys(loginsPrefix+"*", func(key, value string) bool {
		logins = append(logins, strings.TrimPrefix(key, loginsPrefix))
		return true
	}); err != nil {
		return err
	}

	for _, loginName := range logins {
		shareIdString, err := tx.Delete(loginSharePrefix + username + ":" + loginName)
		if err != nil {
			return fmt.Errorf("cannot remove login ref: %w", err)
		} else if shareIdString != share.UUID.String() {
			return fmt.Errorf("inconsistent login ref %q", username+":"+loginName)
		}

		if _, err := tx.Delete(share.loginKey(username, loginName)); err != nil {
			return fmt.Errorf("cannot remove login %q: %w", loginName, err)
		}
	}

	if _, err := tx.Delete(share.userKey(username)); err != nil {
		return fmt.Errorf("cannot remove user: %w", err)
	}

	return nil
}

func (store *DBStore) RemoveUserFromShare(share Share, username string) error {
	return store.db.Update(func(tx *buntdb.Tx) error {
		return store.removeUserFromShare(tx, share, username)
	})
}

func (store *DBStore) AddLogin(share Share, username string, login Login) error {
	if strings.Contains(username, ":") {
		return ErrInvalidUsername
	}
	return store.db.Update(func(tx *buntdb.Tx) error {
		// Validate model. Share should exist and the user should already be assigned.
		if _, err := tx.Get(share.key()); err != nil {
			return ErrShareNotFound
		}
		if _, err := tx.Get(share.userKey(username)); err != nil {
			return ErrUserNotFound
		}

		// Keep a direct reference from username:login to the share, so we can later on
		// easily validate login attempts. Also this is an easy approach to check if the
		// username:login pair is unique. Otherwise our login scheme wouldn't work the
		// way we expect.
		// If the existing value happens to be our shareId already, we assume we are just
		// updating the existing model. No harm there.
		if existingValue, exists, err := tx.Set(loginSharePrefix+username+":"+login.LoginName, share.UUID.String(), nil); err != nil {
			return fmt.Errorf("cannot set login reference: %w", err)
		} else if exists && existingValue != share.UUID.String() {
			return ErrLoginDuplicate
		}

		// Now simply update the current login information.
		b, err := json.Marshal(login)
		if err != nil {
			return fmt.Errorf("cannot marshal login: %w", err)
		}
		if _, _, err := tx.Set(share.loginKey(username, login.LoginName), string(b), nil); err != nil {
			return fmt.Errorf("cannot set login: %w", err)
		}
		return nil
	})
}

func (store *DBStore) RemoveLogin(share Share, username string, loginName string) error {
	return store.db.Update(func(tx *buntdb.Tx) error {
		if _, err := tx.Get(share.key()); err != nil {
			return ErrShareNotFound
		}

		if shareId, err := tx.Delete(loginSharePrefix + username + ":" + loginName); err != nil {
			return ErrLoginNotFound
		} else if shareId != share.UUID.String() {
			return ErrLoginNotFound
		}

		if _, err := tx.Delete(share.loginKey(username, loginName)); err != nil {
			return ErrLoginNotFound
		}

		return nil
	})
}

func (store *DBStore) GetShares() (shares []Share, err error) {
	err = store.db.View(func(tx *buntdb.Tx) error {
		var processingError error

		if err := tx.AscendKeys(sharePrefix+"*", func(key, value string) bool {
			idString := strings.TrimPrefix(key, sharePrefix)
			share, err := unmarshalShare(idString, value)
			if err != nil {
				processingError = err
				return false
			}
			shares = append(shares, share)
			return true
		}); err != nil {
			return err
		}

		return processingError
	})

	return shares, err
}

func (store *DBStore) FindShareByLogin(username, loginName string) (loginShare LoginShare, err error) {
	err = store.db.View(func(tx *buntdb.Tx) error {
		shareIdString, err := tx.Get(loginSharePrefix + username + ":" + loginName)
		if err == buntdb.ErrNotFound {
			return ErrShareNotFound
		} else if err != nil {
			return err
		}

		sharePayload, err := tx.Get(sharePrefix + shareIdString)
		if err == buntdb.ErrNotFound {
			// TODO warn about inconsistency
			return ErrShareNotFound
		} else if err != nil {
			return err
		}

		share, err := unmarshalShare(shareIdString, sharePayload)
		if err != nil {
			return err
		}

		userRole, err := tx.Get(share.userKey(username))
		if err == buntdb.ErrNotFound {
			// TODO warn about inconsistency
			return ErrShareNotFound
		} else if err != nil {
			return err
		}

		loginPayload, err := tx.Get(share.loginKey(username, loginName))
		if err == buntdb.ErrNotFound {
			// TODO warn about inconsistency
			return ErrShareNotFound
		} else if err != nil {
			return err
		}

		loginShare.Share = share
		loginShare.ShareUser = ShareUser{
			Username: username,
			Role:     ShareRole(userRole),
		}
		if err := json.Unmarshal([]byte(loginPayload), &loginShare.Login); err != nil {
			return fmt.Errorf("cannot unmarshal login: %w", err)
		}
		return nil
	})

	return
}

func (store *DBStore) FindSharesByUser(username string) ([]UserShare, error) {
	panic("implement me")
}

func unmarshalShare(idString, payload string) (Share, error) {
	var share Share
	if err := json.Unmarshal([]byte(payload), &share); err != nil {
		return Share{}, fmt.Errorf("cannot unmarshal share: %w", err)
	}

	// Just in case ...
	if id, err := uuid.FromString(idString); err != nil {
		return Share{}, fmt.Errorf("invalid uuid in db: %q: %w", idString, err)
	} else {
		share.UUID = id
	}

	return share, nil
}
