// 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 (
	"context"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"path"
	"strings"
	"time"

	"github.com/go-chi/chi"
	uuid "github.com/satori/go.uuid"
	"github.com/tidwall/buntdb"
	"golang.org/x/crypto/bcrypt"
)

type webAdminHandler struct {
	router           chi.Router
	shareStore       ShareStore
	tplError         *template.Template
	tplConfirm       *template.Template
	tplLogin         *template.Template
	tplIndex         *template.Template
	tplUsers         *template.Template
	tplShares        *template.Template
	tplMyShares      *template.Template
	tplShareAddUser  *template.Template
	tplShareAddLogin *template.Template
	tplCreateShare   *template.Template
}

type sessionContext struct {
	h         *webAdminHandler
	w         http.ResponseWriter
	r         *http.Request
	user      User
	baseModel map[string]interface{}
}

const sessionCookieName = "sharedavsession"
const sessionLifetime = 1 * time.Hour

const confirmRequested = 0
const confirmAccepted = 1
const confirmDenied = 2

func newWebAdminHandler(app *app) *webAdminHandler {
	sessionStore, err := buntdb.Open(":memory:")
	if err != nil {
		panic("cannot initialize session store: " + err.Error())
	}

	loadTemplate := func(filename string) *template.Template {
		t, err := template.ParseFiles(
			path.Join("templates", "base.html"),
			path.Join("templates", filename+".html"),
		)
		if err != nil {
			panic(fmt.Sprintf("cannot load template %q: %v", filename, err))
		}
		return t
	}
	loadSingleTemplate := func(filename string) *template.Template {
		t, err := template.ParseFiles(path.Join("templates", filename+".html"))
		if err != nil {
			panic(fmt.Sprintf("cannot load template %q: %v", filename, err))
		}
		return t
	}

	h := &webAdminHandler{
		shareStore:       app.shareStore,
		tplError:         loadTemplate("error"),
		tplConfirm:       loadTemplate("confirm"),
		tplLogin:         loadSingleTemplate("login"),
		tplIndex:         loadTemplate("index"),
		tplUsers:         loadTemplate("users"),
		tplShares:        loadTemplate("shares"),
		tplMyShares:      loadTemplate("my-shares"),
		tplShareAddUser:  loadTemplate("share-add-user"),
		tplShareAddLogin: loadTemplate("share-add-login"),
		tplCreateShare:   loadTemplate("create-share"),
	}

	r := chi.NewRouter()
	r.Use(disableCaching)

	r.Route("/login", func(r chi.Router) {
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)
			sessionContext.RenderPage(h.tplLogin, nil)
		})
		r.Post("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)

			username := r.FormValue("username")
			if strings.ContainsAny(username, "*:") {
				sessionContext.RenderError("Username or password wrong.", "login")
				return
			}

			user, err := app.userStore.GetUser(username)
			if err != nil {
				sessionContext.RenderError("Username or password wrong.", "login")
				return
			}

			if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); err != nil {
				sessionContext.RenderError("Username or password wrong.", "login")
				return
			}

			sessionId := uuid.NewV4()
			if err := sessionStore.Update(func(tx *buntdb.Tx) error {
				_, _, err := tx.Set(sessionId.String(), user.Username, &buntdb.SetOptions{
					Expires: true,
					TTL:     sessionLifetime,
				})
				return err
			}); err != nil {
				log.Printf("error setting session: %v\n", err)
				http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
				return
			}

			cookie := &http.Cookie{
				Name:     sessionCookieName,
				Value:    sessionId.String(),
				Expires:  time.Now().Add(sessionLifetime),
				HttpOnly: true,
			}
			http.SetCookie(w, cookie)

			sessionContext.Redirect("./")
		})
	})

	ar := r.With(authenticated(sessionStore, app.userStore))

	ar.Get("/logout", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)
		sessionCookie, err := r.Cookie(sessionCookieName)
		if err != nil {
			http.Redirect(w, r, "login", http.StatusFound)
			return
		}
		if err := sessionStore.Update(func(tx *buntdb.Tx) error {
			_, err := tx.Delete(sessionCookie.Value)
			return err
		}); err != nil {
			log.Printf("error unsetting session: %v\n", err)
			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
			return
		}
		sessionContext.Redirect("./")
	})
	ar.Get("/", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)
		sessionContext.RenderPage(h.tplIndex, nil)
	})
	ar.Get("/users", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)

		if sessionContext.user.Role != GlobalRoleAdmin {
			sessionContext.Unauthorized()
			return
		}

		users, err := app.userStore.GetUsers()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		sessionContext.RenderPage(h.tplUsers, map[string]interface{}{
			"Users": users,
		})
	})
	ar.Get("/shares", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)

		if sessionContext.user.Role != GlobalRoleAdmin {
			sessionContext.Unauthorized()
			return
		}

		shares, err := app.shareStore.GetShares()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		type shareInfo struct {
			Share
			Users []ShareUser
		}

		shareInfos := make([]shareInfo, len(shares))
		for i := range shares {
			shareInfos[i].Share = shares[i]
			users, err := app.shareStore.GetShareUsers(shares[i])
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			shareInfos[i].Users = users
		}

		sessionContext.RenderPage(h.tplShares, map[string]interface{}{
			"ShareInfos": shareInfos,
		})
	})
	ar.Get("/my-shares", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)

		shares, err := app.shareStore.FindSharesByUser(sessionContext.user.Username)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		type shareInfo struct {
			Share
			IsAdmin bool
			Logins  []Login
			Users   []ShareUser
		}

		shareInfos := make([]shareInfo, len(shares))
		for i := range shares {
			shareInfos[i].Share = shares[i].Share

			shareRole, err := app.shareStore.GetShareAccess(shares[i].Share, sessionContext.user.Username)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			shareInfos[i].IsAdmin = sessionContext.user.Role == GlobalRoleAdmin ||
				shareRole == ShareRoleAdmin

			if shareInfos[i].IsAdmin {
				users, err := app.shareStore.GetShareUsers(shares[i].Share)
				if err != nil {
					http.Error(w, err.Error(), http.StatusInternalServerError)
					return
				}
				shareInfos[i].Users = users
			}

			logins, err := app.shareStore.GetShareLogins(shares[i].Share, sessionContext.user.Username)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			shareInfos[i].Logins = logins
		}
		sessionContext.RenderPage(h.tplMyShares, map[string]interface{}{
			"ShareInfos": shareInfos,
		})
	})
	ar.Route("/share-add-user", func(r chi.Router) {
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)

			share, err := app.shareStore.GetShare(r.URL.Query().Get("share"))
			if err != nil {
				sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
				return
			}

			if !sessionContext.IsAdmin(share) {
				sessionContext.Unauthorized()
				return
			}

			users, err := app.userStore.GetUsers()
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}

			sessionContext.RenderPage(h.tplShareAddUser, map[string]interface{}{
				"ShareId": share.UUID,
				"Users":   users,
			})
		})
		r.Post("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)

			returnURL := "share-add-user?share=" + r.FormValue("share")

			share, err := app.shareStore.GetShare(r.FormValue("share"))
			if err != nil {
				sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
				return
			}

			if !sessionContext.IsAdmin(share) {
				sessionContext.Unauthorized()
				return
			}

			user, err := app.userStore.GetUser(r.FormValue("user"))
			if err == ErrUserNotFound {
				sessionContext.RenderError("User not found.", returnURL)
				return
			} else if err != nil {
				sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
				return
			}

			err = app.shareStore.AddUserToShare(share, user.Username, ShareRole(r.FormValue("role")))
			if err != nil {
				sessionContext.RenderError(template.HTML("Cannot add user to share: "+err.Error()), returnURL)
				return
			}

			sessionContext.Redirect("shares")
		})
	})
	ar.Post("/share-delete-user", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)

		returnURL := r.FormValue("source")

		share, err := app.shareStore.GetShare(r.FormValue("share"))
		if err != nil {
			sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
			return
		}

		if !sessionContext.IsAdmin(share) {
			sessionContext.Unauthorized()
			return
		}

		username := r.FormValue("user")

		message := fmt.Sprintf(`You are about to delete the user &quot;%s&quot; from the share %s.<br/>
This will irrevocably also remove all logins of that user.<br/><br/>
Are you sure you want to continue?`, username, share.Name)
		if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "share-delete-user"); confirmStatus == confirmRequested {
			// We have already rendered. Nothing to do.
			return
		} else if confirmStatus == confirmAccepted {
			err = app.shareStore.RemoveUserFromShare(share, username)
			if err != nil {
				sessionContext.RenderError(template.HTML("Cannot remove user from share: "+err.Error()), returnURL)
				return
			}
		}

		http.Redirect(w, r, returnURL, http.StatusFound)
	})
	ar.Route("/share-add-login", func(r chi.Router) {
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)

			share, err := app.shareStore.GetShare(r.URL.Query().Get("share"))
			if err != nil {
				sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
				return
			}

			if !sessionContext.IsAdmin(share) {
				sessionContext.Unauthorized()
				return
			}

			sessionContext.RenderPage(h.tplShareAddLogin, map[string]interface{}{
				"ShareId": share.UUID,
			})
		})
		r.Post("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)

			returnURL := "share-add-login?share=" + r.FormValue("share")

			share, err := app.shareStore.GetShare(r.FormValue("share"))
			if err != nil {
				sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), returnURL)
				return
			}

			if !sessionContext.IsAdmin(share) {
				sessionContext.Unauthorized()
				return
			}

			pwHash, err := bcrypt.GenerateFromPassword([]byte(r.FormValue("password")), bcrypt.DefaultCost)
			if err != nil {
				sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), returnURL)
				return
			}

			err = app.shareStore.AddLogin(share, sessionContext.user.Username, Login{
				LoginName: r.FormValue("login"),
				Password:  string(pwHash),
				ReadOnly:  r.FormValue("readonly") == "on",
			})
			if err != nil {
				sessionContext.RenderError(template.HTML("Cannot add login: "+err.Error()), returnURL)
				return
			}

			sessionContext.Redirect("my-shares")
		})
	})
	ar.Post("/share-delete-login", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)

		returnURL := "shares"

		share, err := app.shareStore.GetShare(r.FormValue("share"))
		if err != nil {
			sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
			return
		}

		if !sessionContext.IsAdmin(share) {
			sessionContext.Unauthorized()
			return
		}

		loginName := r.FormValue("login")

		message := fmt.Sprintf(`You are about to delete the login &quot;%s&quot;.<br/><br/>
Are you sure you want to continue?`, loginName)
		if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "share-delete-login"); confirmStatus == confirmRequested {
			// We have already rendered. Nothing to do.
			return
		} else if confirmStatus == confirmAccepted {
			err = app.shareStore.RemoveLogin(share, sessionContext.user.Username, loginName)
			if err != nil {
				sessionContext.RenderError(template.HTML("Cannot remove login from share: "+err.Error()), returnURL)
				return
			}
		}

		http.Redirect(w, r, "my-shares", http.StatusFound)
	})
	ar.Route("/create-share", func(r chi.Router) {
		r.Get("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)
			sessionContext.RenderPage(h.tplCreateShare, nil)
		})
		r.Post("/", func(w http.ResponseWriter, r *http.Request) {
			sessionContext := h.buildSessionContext(w, r)

			share, err := app.shareStore.CreateShare()
			if err != nil {
				sessionContext.RenderError(template.HTML("Cannot create share: "+err.Error()), "")
				return
			}

			share.Name = r.FormValue("name")
			share.Description = r.FormValue("description")

			if err := app.shareStore.UpdateShareAttributes(share); err != nil {
				sessionContext.RenderError(template.HTML("Cannot update share: "+err.Error()), "")
				return
			}

			sessionContext.Redirect("shares#share-" + share.UUID.String())
		})
	})
	ar.Post("/delete-share", func(w http.ResponseWriter, r *http.Request) {
		sessionContext := h.buildSessionContext(w, r)

		share, err := app.shareStore.GetShare(r.FormValue("share"))
		if err != nil {
			sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "")
			return
		}

		if !sessionContext.IsAdmin(share) {
			sessionContext.Unauthorized()
			return
		}

		message := fmt.Sprintf(`You are about to delete the share %s (%s).<br/>
This will delete all data permanently.<br/><br/>
Are you sure you want to continue?`, share.UUID, share.Name)
		if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "delete-share"); confirmStatus == confirmRequested {
			// We have already rendered. Nothing to do.
			return
		} else if confirmStatus == confirmAccepted {
			if err := app.shareStore.RemoveShare(share.UUID); err != nil {
				sessionContext.RenderError(template.HTML("Share cannot be removed: "+err.Error()), "shares")
				return
			}
		}
		sessionContext.Redirect("shares")
	})
	h.router = r

	return h
}

func (h *webAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	h.router.ServeHTTP(w, r)
}

func (h *webAdminHandler) buildSessionContext(w http.ResponseWriter, r *http.Request) *sessionContext {
	sessionContext := &sessionContext{
		h:         h,
		w:         w,
		r:         r,
		baseModel: map[string]interface{}{},
	}
	sessionUser := userFromContext(r)
	if sessionUser != nil {
		sessionContext.user = *sessionUser
		sessionContext.baseModel["SessionUser"] = sessionUser
	}
	return sessionContext
}

func (s *sessionContext) Redirect(target string) {
	http.Redirect(s.w, s.r, target, http.StatusFound)
}

func (s *sessionContext) RenderPage(tmpl *template.Template, model map[string]interface{}) {
	effectiveModel := map[string]interface{}{}
	for k, v := range s.baseModel {
		effectiveModel[k] = v
	}
	for k, v := range model {
		effectiveModel[k] = v
	}
	if err := tmpl.Execute(s.w, effectiveModel); err != nil {
		http.Error(s.w, err.Error(), http.StatusInternalServerError)
	}
}

func (s *sessionContext) RenderError(msg template.HTML, returnURL string) {
	model := map[string]interface{}{
		"ErrorMessage": msg,
		"ReturnURL":    returnURL,
	}
	s.RenderPage(s.h.tplError, model)
}

func (s *sessionContext) IsAdmin(share Share) bool {
	if s.user.Role != GlobalRoleAdmin {
		shareRole, err := s.h.shareStore.GetShareAccess(share, s.user.Username)
		if err != nil || shareRole != ShareRoleAdmin {
			return false
		}
	}
	return true
}

func (s *sessionContext) Unauthorized() {
	http.Error(s.w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
}

func (s *sessionContext) RequestConfirmation(msg template.HTML, returnURL string) int {
	if s.r.FormValue("_yes") != "" {
		return confirmAccepted
	} else if s.r.FormValue("_no") != "" {
		return confirmDenied
	} else {
		fields := map[string]string{}
		for k, v := range s.r.Form {
			fields[k] = v[0]
		}
		model := map[string]interface{}{
			"URL":     returnURL,
			"Message": msg,
			"Fields":  fields,
		}

		s.RenderPage(s.h.tplConfirm, model)
		return confirmRequested
	}
}

func disableCaching(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
		w.Header().Set("Pragma", "no-cache")
		w.Header().Set("Expires", "0")

		next.ServeHTTP(w, r)
	})
}

func authenticated(sessionStore *buntdb.DB, userStore UserStore) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			sessionCookie, err := r.Cookie(sessionCookieName)
			if err != nil {
				http.Redirect(w, r, "login", http.StatusFound)
				return
			}
			var username string
			if err := sessionStore.View(func(tx *buntdb.Tx) error {
				val, err := tx.Get(sessionCookie.Value)
				if err != nil {
					return err
				}

				username = val
				return nil
			}); err != nil {
				http.Redirect(w, r, "login", http.StatusFound)
				return
			}

			user, err := userStore.GetUser(username)
			if err != nil {
				http.Redirect(w, r, "login", http.StatusFound)
				return
			}

			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", &user)))
		})
	}
}

func userFromContext(r *http.Request) *User {
	if user, ok := r.Context().Value("user").(*User); ok {
		return user
	} else {
		return nil
	}
}
