/*
 * Copyright © 2018-2021 Musing Studio LLC.
 *
 * This file is part of WriteFreely.
 *
 * WriteFreely is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License, included
 * in the LICENSE file in this source code package.
 */

package writefreely

import (
	"database/sql"
	"fmt"
	"html/template"
	"net/http"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/gorilla/mux"
	"github.com/writeas/impart"
	"github.com/writeas/web-core/auth"
	"github.com/writeas/web-core/log"
	"github.com/writeas/web-core/passgen"
	"github.com/writefreely/writefreely/appstats"
	"github.com/writefreely/writefreely/config"
)

var (
	appStartTime = time.Now()
	sysStatus    systemStatus
)

const adminUsersPerPage = 30

type systemStatus struct {
	Uptime       string
	NumGoroutine int

	// General statistics.
	MemAllocated string // bytes allocated and still in use
	MemTotal     string // bytes allocated (even if freed)
	MemSys       string // bytes obtained from system (sum of XxxSys below)
	Lookups      uint64 // number of pointer lookups
	MemMallocs   uint64 // number of mallocs
	MemFrees     uint64 // number of frees

	// Main allocation heap statistics.
	HeapAlloc    string // bytes allocated and still in use
	HeapSys      string // bytes obtained from system
	HeapIdle     string // bytes in idle spans
	HeapInuse    string // bytes in non-idle span
	HeapReleased string // bytes released to the OS
	HeapObjects  uint64 // total number of allocated objects

	// Low-level fixed-size structure allocator statistics.
	//	Inuse is bytes used now.
	//	Sys is bytes obtained from system.
	StackInuse  string // bootstrap stacks
	StackSys    string
	MSpanInuse  string // mspan structures
	MSpanSys    string
	MCacheInuse string // mcache structures
	MCacheSys   string
	BuckHashSys string // profiling bucket hash table
	GCSys       string // GC metadata
	OtherSys    string // other system allocations

	// Garbage collector statistics.
	NextGC       string // next run in HeapAlloc time (bytes)
	LastGC       string // last run in absolute time (ns)
	PauseTotalNs string
	PauseNs      string // circular buffer of recent GC pause times, most recent at [(NumGC+255)%256]
	NumGC        uint32
}

type inspectedCollection struct {
	CollectionObj
	Followers int
	LastPost  string
}

type instanceContent struct {
	ID      string
	Type    string
	Title   sql.NullString
	Content string
	Updated time.Time
}

type AdminPage struct {
	UpdateAvailable bool
}

func NewAdminPage(app *App) *AdminPage {
	ap := &AdminPage{}
	if app.updates != nil {
		ap.UpdateAvailable = app.updates.AreAvailableNoCheck()
	}
	return ap
}

func (c instanceContent) UpdatedFriendly() template.HTML {
	/*
		// TODO: accept a locale in this method and use that for the format
		var loc monday.Locale = monday.LocaleEnUS
		return monday.Format(u.Created, monday.DateTimeFormatsByLocale[loc], loc)
	*/
	if c.Updated.IsZero() {
		return "<em>Never</em>"
	}
	return template.HTML(c.Updated.Format("January 2, 2006, 3:04 PM"))
}

func handleViewAdminDash(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	p := struct {
		*UserPage
		*AdminPage
		Message string

		UsersCount, CollectionsCount, PostsCount int64
	}{
		UserPage:  NewUserPage(app, r, u, "Admin", nil),
		AdminPage: NewAdminPage(app),
		Message:   r.FormValue("m"),
	}

	// Get user stats
	p.UsersCount = app.db.GetAllUsersCount()
	var err error
	p.CollectionsCount, err = app.db.GetTotalCollections()
	if err != nil {
		return err
	}
	p.PostsCount, err = app.db.GetTotalPosts()
	if err != nil {
		return err
	}

	showUserPage(w, "admin", p)
	return nil
}

func handleViewAdminMonitor(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	updateAppStats()
	p := struct {
		*UserPage
		*AdminPage
		SysStatus systemStatus
		Config    config.AppCfg

		Message, ConfigMessage string
	}{
		UserPage:  NewUserPage(app, r, u, "Admin", nil),
		AdminPage: NewAdminPage(app),
		SysStatus: sysStatus,
		Config:    app.cfg.App,

		Message:       r.FormValue("m"),
		ConfigMessage: r.FormValue("cm"),
	}

	showUserPage(w, "monitor", p)
	return nil
}

func handleViewAdminSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	p := struct {
		*UserPage
		*AdminPage
		Config config.AppCfg

		Message, ConfigMessage string
	}{
		UserPage:  NewUserPage(app, r, u, "Admin", nil),
		AdminPage: NewAdminPage(app),
		Config:    app.cfg.App,

		Message:       r.FormValue("m"),
		ConfigMessage: r.FormValue("cm"),
	}

	showUserPage(w, "app-settings", p)
	return nil
}

func handleViewAdminUsers(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	p := struct {
		*UserPage
		*AdminPage
		Config  config.AppCfg
		Message string
		Flashes []string

		Users      *[]User
		CurPage    int
		TotalUsers int64
		TotalPages []int
	}{
		UserPage:  NewUserPage(app, r, u, "Users", nil),
		AdminPage: NewAdminPage(app),
		Config:    app.cfg.App,
		Message:   r.FormValue("m"),
	}

	p.Flashes, _ = getSessionFlashes(app, w, r, nil)
	p.TotalUsers = app.db.GetAllUsersCount()
	ttlPages := p.TotalUsers / adminUsersPerPage
	p.TotalPages = []int{}
	for i := 1; i <= int(ttlPages); i++ {
		p.TotalPages = append(p.TotalPages, i)
	}

	var err error
	p.CurPage, err = strconv.Atoi(r.FormValue("p"))
	if err != nil || p.CurPage < 1 {
		p.CurPage = 1
	} else if p.CurPage > int(ttlPages) {
		p.CurPage = int(ttlPages)
	}

	p.Users, err = app.db.GetAllUsers(uint(p.CurPage))
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get users: %v", err)}
	}

	showUserPage(w, "users", p)
	return nil
}

func handleViewAdminUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	username := vars["username"]
	if username == "" {
		return impart.HTTPError{http.StatusFound, "/admin/users"}
	}

	p := struct {
		*UserPage
		*AdminPage
		Config  config.AppCfg
		Message string

		User        *User
		Colls       []inspectedCollection
		LastPost    string
		NewPassword string
		TotalPosts  int64
		ClearEmail  string
	}{
		AdminPage: NewAdminPage(app),
		Config:    app.cfg.App,
		Message:   r.FormValue("m"),
		Colls:     []inspectedCollection{},
	}

	var err error
	p.User, err = app.db.GetUserForAuth(username)
	if err != nil {
		if err == ErrUserNotFound {
			return err
		}
		log.Error("Could not get user: %v", err)
		return impart.HTTPError{http.StatusInternalServerError, err.Error()}
	}

	flashes, _ := getSessionFlashes(app, w, r, nil)
	for _, flash := range flashes {
		if strings.HasPrefix(flash, "SUCCESS: ") {
			p.NewPassword = strings.TrimPrefix(flash, "SUCCESS: ")
			p.ClearEmail = p.User.EmailClear(app.keys)
		}
	}
	p.UserPage = NewUserPage(app, r, u, p.User.Username, nil)
	p.TotalPosts = app.db.GetUserPostsCount(p.User.ID)
	lp, err := app.db.GetUserLastPostTime(p.User.ID)
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's last post time: %v", err)}
	}
	if lp != nil {
		p.LastPost = lp.Format("January 2, 2006, 3:04 PM")
	}

	colls, err := app.db.GetCollections(p.User, app.cfg.App.Host)
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user's collections: %v", err)}
	}
	for _, c := range *colls {
		ic := inspectedCollection{
			CollectionObj: CollectionObj{Collection: c},
		}

		if app.cfg.App.Federation {
			folls, err := app.db.GetAPFollowers(&c)
			if err == nil {
				// TODO: handle error here (at least log it)
				ic.Followers = len(*folls)
			}
		}

		app.db.GetPostsCount(&ic.CollectionObj, true)

		lp, err := app.db.GetCollectionLastPostTime(c.ID)
		if err != nil {
			log.Error("Didn't get last post time for collection %d: %v", c.ID, err)
		}
		if lp != nil {
			ic.LastPost = lp.Format("January 2, 2006, 3:04 PM")
		}

		p.Colls = append(p.Colls, ic)
	}

	showUserPage(w, "view-user", p)
	return nil
}

func handleAdminDeleteUser(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	if !u.IsAdmin() {
		return impart.HTTPError{http.StatusForbidden, "Administrator privileges required for this action"}
	}

	vars := mux.Vars(r)
	username := vars["username"]
	confirmUsername := r.PostFormValue("confirm-username")

	if confirmUsername != username {
		return impart.HTTPError{http.StatusBadRequest, "Username was not confirmed"}
	}

	user, err := app.db.GetUserForAuth(username)
	if err == ErrUserNotFound {
		return impart.HTTPError{http.StatusNotFound, fmt.Sprintf("User '%s' was not found", username)}
	} else if err != nil {
		log.Error("get user for deletion: %v", err)
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user with username '%s': %v", username, err)}
	}

	err = app.db.DeleteAccount(user.ID)
	if err != nil {
		log.Error("delete user %s: %v", user.Username, err)
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete user account for '%s': %v", username, err)}
	}

	_ = addSessionFlash(app, w, r, fmt.Sprintf("User \"%s\" was deleted successfully.", username), nil)
	return impart.HTTPError{http.StatusFound, "/admin/users"}
}

func handleAdminToggleUserStatus(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	username := vars["username"]
	if username == "" {
		return impart.HTTPError{http.StatusFound, "/admin/users"}
	}

	user, err := app.db.GetUserForAuth(username)
	if err != nil {
		log.Error("failed to get user: %v", err)
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get user from username: %v", err)}
	}
	if user.IsSilenced() {
		err = app.db.SetUserStatus(user.ID, UserActive)
	} else {
		err = app.db.SetUserStatus(user.ID, UserSilenced)

		// reset the cache to removed silence user posts
		updateTimelineCache(app.timeline, true)
	}
	if err != nil {
		log.Error("toggle user silenced: %v", err)
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not toggle user status: %v", err)}
	}
	return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s#status", username)}
}

func handleAdminResetUserPass(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	username := vars["username"]
	if username == "" {
		return impart.HTTPError{http.StatusFound, "/admin/users"}
	}

	// Generate new random password since none supplied
	pass := passgen.NewWordish()
	hashedPass, err := auth.HashPass([]byte(pass))
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
	}

	userIDVal := r.FormValue("user")
	log.Info("ADMIN: Changing user %s password", userIDVal)
	id, err := strconv.Atoi(userIDVal)
	if err != nil {
		return impart.HTTPError{http.StatusBadRequest, fmt.Sprintf("Invalid user ID: %v", err)}
	}

	err = app.db.ChangePassphrase(int64(id), true, "", hashedPass)
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
	}
	log.Info("ADMIN: Successfully changed.")

	addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: %s", pass), nil)

	return impart.HTTPError{http.StatusFound, fmt.Sprintf("/admin/user/%s", username)}
}

func handleViewAdminPages(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	p := struct {
		*UserPage
		*AdminPage
		Config  config.AppCfg
		Message string

		Pages []*instanceContent
	}{
		UserPage:  NewUserPage(app, r, u, "Pages", nil),
		AdminPage: NewAdminPage(app),
		Config:    app.cfg.App,
		Message:   r.FormValue("m"),
	}

	var err error
	p.Pages, err = app.db.GetInstancePages()
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get pages: %v", err)}
	}

	// Add in default pages
	var hasAbout, hasContact, hasPrivacy bool
	for i, c := range p.Pages {
		if hasAbout && hasContact && hasPrivacy {
			break
		}
		if c.ID == "about" {
			hasAbout = true
			if !c.Title.Valid {
				p.Pages[i].Title = defaultAboutTitle(app.cfg)
			}
		} else if c.ID == "contact" {
			hasContact = true
			if !c.Title.Valid {
				p.Pages[i].Title = defaultContactTitle()
			}
		} else if c.ID == "privacy" {
			hasPrivacy = true
			if !c.Title.Valid {
				p.Pages[i].Title = defaultPrivacyTitle()
			}
		}
	}
	if !hasAbout {
		p.Pages = append(p.Pages, &instanceContent{
			ID:      "about",
			Title:   defaultAboutTitle(app.cfg),
			Content: defaultAboutPage(app.cfg),
			Updated: defaultPageUpdatedTime,
		})
	}
	if !hasContact {
		p.Pages = append(p.Pages, &instanceContent{
			ID:      "contact",
			Title:   defaultContactTitle(),
			Content: defaultContactPage(app),
		})
	}
	if !hasPrivacy {
		p.Pages = append(p.Pages, &instanceContent{
			ID:      "privacy",
			Title:   defaultPrivacyTitle(),
			Content: defaultPrivacyPolicy(app.cfg),
			Updated: defaultPageUpdatedTime,
		})
	}

	showUserPage(w, "pages", p)
	return nil
}

func handleViewAdminPage(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	slug := vars["slug"]
	if slug == "" {
		return impart.HTTPError{http.StatusFound, "/admin/pages"}
	}

	p := struct {
		*UserPage
		*AdminPage
		Config  config.AppCfg
		Message string

		Banner  *instanceContent
		Content *instanceContent
	}{
		AdminPage: NewAdminPage(app),
		Config:    app.cfg.App,
		Message:   r.FormValue("m"),
	}

	var err error
	// Get pre-defined pages, or select slug
	if slug == "about" {
		p.Content, err = getAboutPage(app)
	} else if slug == "contact" {
		p.Content, err = getContactPage(app)
	} else if slug == "privacy" {
		p.Content, err = getPrivacyPage(app)
	} else if slug == "landing" {
		p.Banner, err = getLandingBanner(app)
		if err != nil {
			return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get banner: %v", err)}
		}
		p.Content, err = getLandingBody(app)
		p.Content.ID = "landing"
	} else if slug == "reader" {
		p.Content, err = getReaderSection(app)
	} else {
		p.Content, err = app.db.GetDynamicContent(slug)
	}
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not get page: %v", err)}
	}
	title := "New page"
	if p.Content != nil {
		title = "Edit " + p.Content.ID
	} else {
		p.Content = &instanceContent{}
	}
	p.UserPage = NewUserPage(app, r, u, title, nil)

	showUserPage(w, "view-page", p)
	return nil
}

func handleAdminUpdateSite(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	vars := mux.Vars(r)
	id := vars["page"]

	// Validate
	if id != "about" && id != "contact" && id != "privacy" && id != "landing" && id != "reader" {
		return impart.HTTPError{http.StatusNotFound, "No such page."}
	}

	var err error
	m := ""
	if id == "landing" {
		// Handle special landing page
		err = app.db.UpdateDynamicContent("landing-banner", "", r.FormValue("banner"), "section")
		if err != nil {
			m = "?m=" + err.Error()
			return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
		}
		err = app.db.UpdateDynamicContent("landing-body", "", r.FormValue("content"), "section")
	} else if id == "reader" {
		// Update sections with titles
		err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "section")
	} else {
		// Update page
		err = app.db.UpdateDynamicContent(id, r.FormValue("title"), r.FormValue("content"), "page")
	}
	if err != nil {
		m = "?m=" + err.Error()
	}
	return impart.HTTPError{http.StatusFound, "/admin/page/" + id + m}
}

func handleAdminUpdateConfig(apper Apper, u *User, w http.ResponseWriter, r *http.Request) error {
	apper.App().cfg.App.SiteName = r.FormValue("site_name")
	apper.App().cfg.App.SiteDesc = r.FormValue("site_desc")
	apper.App().cfg.App.Landing = r.FormValue("landing")
	apper.App().cfg.App.OpenRegistration = r.FormValue("open_registration") == "on"
	apper.App().cfg.App.OpenDeletion = r.FormValue("open_deletion") == "on"
	mul, err := strconv.Atoi(r.FormValue("min_username_len"))
	if err == nil {
		apper.App().cfg.App.MinUsernameLen = mul
	}
	mb, err := strconv.Atoi(r.FormValue("max_blogs"))
	if err == nil {
		apper.App().cfg.App.MaxBlogs = mb
	}
	apper.App().cfg.App.Federation = r.FormValue("federation") == "on"
	apper.App().cfg.App.PublicStats = r.FormValue("public_stats") == "on"
	apper.App().cfg.App.Monetization = r.FormValue("monetization") == "on"
	apper.App().cfg.App.Private = r.FormValue("private") == "on"
	apper.App().cfg.App.LocalTimeline = r.FormValue("local_timeline") == "on"
	if apper.App().cfg.App.LocalTimeline && apper.App().timeline == nil {
		log.Info("Initializing local timeline...")
		initLocalTimeline(apper.App())
	}
	apper.App().cfg.App.UserInvites = r.FormValue("user_invites")
	if apper.App().cfg.App.UserInvites == "none" {
		apper.App().cfg.App.UserInvites = ""
	}
	apper.App().cfg.App.DefaultVisibility = r.FormValue("default_visibility")

	m := "?cm=Configuration+saved."
	err = apper.SaveConfig(apper.App().cfg)
	if err != nil {
		m = "?cm=" + err.Error()
	}
	return impart.HTTPError{http.StatusFound, "/admin/settings" + m + "#config"}
}

func updateAppStats() {
	sysStatus.Uptime = appstats.TimeSincePro(appStartTime)

	m := new(runtime.MemStats)
	runtime.ReadMemStats(m)
	sysStatus.NumGoroutine = runtime.NumGoroutine()

	sysStatus.MemAllocated = appstats.FileSize(int64(m.Alloc))
	sysStatus.MemTotal = appstats.FileSize(int64(m.TotalAlloc))
	sysStatus.MemSys = appstats.FileSize(int64(m.Sys))
	sysStatus.Lookups = m.Lookups
	sysStatus.MemMallocs = m.Mallocs
	sysStatus.MemFrees = m.Frees

	sysStatus.HeapAlloc = appstats.FileSize(int64(m.HeapAlloc))
	sysStatus.HeapSys = appstats.FileSize(int64(m.HeapSys))
	sysStatus.HeapIdle = appstats.FileSize(int64(m.HeapIdle))
	sysStatus.HeapInuse = appstats.FileSize(int64(m.HeapInuse))
	sysStatus.HeapReleased = appstats.FileSize(int64(m.HeapReleased))
	sysStatus.HeapObjects = m.HeapObjects

	sysStatus.StackInuse = appstats.FileSize(int64(m.StackInuse))
	sysStatus.StackSys = appstats.FileSize(int64(m.StackSys))
	sysStatus.MSpanInuse = appstats.FileSize(int64(m.MSpanInuse))
	sysStatus.MSpanSys = appstats.FileSize(int64(m.MSpanSys))
	sysStatus.MCacheInuse = appstats.FileSize(int64(m.MCacheInuse))
	sysStatus.MCacheSys = appstats.FileSize(int64(m.MCacheSys))
	sysStatus.BuckHashSys = appstats.FileSize(int64(m.BuckHashSys))
	sysStatus.GCSys = appstats.FileSize(int64(m.GCSys))
	sysStatus.OtherSys = appstats.FileSize(int64(m.OtherSys))

	sysStatus.NextGC = appstats.FileSize(int64(m.NextGC))
	sysStatus.LastGC = fmt.Sprintf("%.1fs", float64(time.Now().UnixNano()-int64(m.LastGC))/1000/1000/1000)
	sysStatus.PauseTotalNs = fmt.Sprintf("%.1fs", float64(m.PauseTotalNs)/1000/1000/1000)
	sysStatus.PauseNs = fmt.Sprintf("%.3fs", float64(m.PauseNs[(m.NumGC+255)%256])/1000/1000/1000)
	sysStatus.NumGC = m.NumGC
}

func adminResetPassword(app *App, u *User, newPass string) error {
	hashedPass, err := auth.HashPass([]byte(newPass))
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not create password hash: %v", err)}
	}

	err = app.db.ChangePassphrase(u.ID, true, "", hashedPass)
	if err != nil {
		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not update passphrase: %v", err)}
	}
	return nil
}

func handleViewAdminUpdates(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
	check := r.URL.Query().Get("check")

	if check == "now" && app.cfg.App.UpdateChecks {
		app.updates.CheckNow()
	}

	p := struct {
		*UserPage
		*AdminPage
		CurReleaseNotesURL    string
		LastChecked           string
		LastChecked8601       string
		LatestVersion         string
		LatestReleaseURL      string
		LatestReleaseNotesURL string
		CheckFailed           bool
	}{
		UserPage:  NewUserPage(app, r, u, "Updates", nil),
		AdminPage: NewAdminPage(app),
	}
	p.CurReleaseNotesURL = wfReleaseNotesURL(p.Version)
	if app.cfg.App.UpdateChecks {
		p.LastChecked = app.updates.lastCheck.Format("January 2, 2006, 3:04 PM")
		p.LastChecked8601 = app.updates.lastCheck.Format("2006-01-02T15:04:05Z")
		p.LatestVersion = app.updates.LatestVersion()
		p.LatestReleaseURL = app.updates.ReleaseURL()
		p.LatestReleaseNotesURL = app.updates.ReleaseNotesURL()
		p.UpdateAvailable = app.updates.AreAvailable()
		p.CheckFailed = app.updates.checkError != nil
	}

	showUserPage(w, "app-updates", p)
	return nil
}
