package main

import (
	// Internal
	"notes2/authentication"
	"notes2/html_template"
	appUser "notes2/user"

	// Standard
	"bufio"
	"context"
	"embed"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"path"
	"regexp"
	"strconv"
	"strings"
	"text/template"
	"time"
)

const VERSION = "v31"
const CONTEXT_USER = 1
const SYNC_PAGINATION = 200

var (
	FlagGenerate       bool
	FlagLoremIpsum     bool
	FlagDev            bool
	FlagConfig         string
	FlagCreateUser     string
	FlagChangePassword string
	FlagVersion        bool
	Webengine          HTMLTemplate.Engine
	config             Config
	Log                *slog.Logger
	AuthManager        authentication.Manager
	RxpBearerToken     *regexp.Regexp

	//go:embed views
	ViewFS embed.FS

	//go:embed static
	StaticFS embed.FS

	//go:embed sql
	SqlFS embed.FS
)

func init() { // {{{
	// Configuration filename to use with a somewhat sane default.
	cfgDir, err := os.UserConfigDir()
	if err != nil {
		panic(err)
	}
	cfgFilename := path.Join(cfgDir, "notes2.json")

	flag.StringVar(&FlagConfig, "config", cfgFilename, "Configuration file")
	flag.BoolVar(&FlagDev, "dev", false, "Use local files instead of embedded files")
	flag.BoolVar(&FlagGenerate, "generate", false, "Generate test data")
	flag.BoolVar(&FlagLoremIpsum, "lorem-ipsum", false, "Replace all G- nodes with lorem ipsum paragraphs")
	flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
	flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
	flag.BoolVar(&FlagVersion, "version", false, "Print version and exit")
	flag.Parse()

	RxpBearerToken = regexp.MustCompile("(?i)^\\s*Bearer\\s+(.*?)\\s*$")
} // }}}
func initLog() { // {{{
	opts := slog.HandlerOptions{}
	opts.Level = slog.LevelInfo
	Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
} // }}}
func main() { // {{{
	if FlagVersion {
		fmt.Println(VERSION)
		return
	}

	initLog()
	Log.Info("application", "version", VERSION)

	err := readConfig(FlagConfig)
	if err != nil {
		Log.Error("config", "error", err)
		os.Exit(1)
	}

	// dbschema uses the embedded SQL files to keep the database schema up to date.
	err = initDB()
	if err != nil {
		Log.Error("database", "error", err)
		os.Exit(1)
	}

	// The session manager contains authentication, authorization and session settings.
	AuthManager, err = authentication.NewManager(db, Log, config.JWT.Secret, config.JWT.ExpireDays)

	// Generate test data?
	if FlagGenerate {
		err := TestData()
		if err != nil {
			fmt.Printf("%s\n", err)
			return
		}
		return
	}

	if FlagLoremIpsum {
		err := LoremIpsum()
		if err != nil {
			fmt.Printf("%s\n", err)
			return
		}
		return
	}

	// A new user?
	if FlagCreateUser != "" {
		createNewUser(FlagCreateUser)
		return
	}

	// Forgotten the password?
	if FlagChangePassword != "" {
		changePassword(FlagChangePassword)
		return
	}

	// The webengine takes layouts, pages and components and renders them into HTML.
	Webengine, err = HTMLTemplate.NewEngine(ViewFS, StaticFS, FlagDev)
	if err != nil {
		panic(err)
	}

	http.HandleFunc("/", rootHandler)
	http.HandleFunc("/login", pageLogin)
	http.HandleFunc("/sync", pageSync)
	http.HandleFunc("/offline", pageOffline)

	http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
	http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
	http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))

	http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
	http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
	http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
	http.HandleFunc("/sync/file/upload/{uuid}", authenticated(actionSyncFileUpload))

	http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
	http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
	http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))

	http.HandleFunc("/file/count", authenticated(actionFileCount))
	http.HandleFunc("/file/server_uuids/{offset}", authenticated(actionFileServerUUIDs))
	http.HandleFunc("/file/uuid/{uuid}", authenticated(actionFile))
	http.HandleFunc("/file/uuid/{uuid}/metadata", authenticated(actionFileMetadata))

	http.HandleFunc("/service_worker.js", pageServiceWorker)

	listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
	Log.Info("webserver", "listen_address", listen)
	http.ListenAndServe(listen, nil)
} // }}}

func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { // {{{
	return func(w http.ResponseWriter, r *http.Request) {
		failed := func(err error) {
			j, _ := json.Marshal(struct {
				OK         bool
				Error      string
				AuthFailed bool
			}{false, err.Error(), true})
			w.Write(j)
		}

		// The Bearer token is extracted.
		authHeader := r.Header.Get("Authorization")

		if authHeader == "" {
			authHeader = "Bearer " + r.URL.Query().Get("jwt")
		}

		authParts := RxpBearerToken.FindStringSubmatch(authHeader)
		if len(authParts) != 2 {
			failed(fmt.Errorf("Authorization missing or invalid"))
			return
		}
		token := authParts[1]

		// Token signature is verified with the application secret key.
		claims, err := AuthManager.VerifyToken(token)
		if err != nil {
			failed(err)
			return
		}

		// User object is added to the context for the next handler.
		user := appUser.NewUser(claims)
		r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))

		Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
		fn(w, r)
	}
} // }}}

func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
	// All URLs not specifically handled are routed to this function.
	// Everything going here should be a static resource.
	if r.URL.Path == "/" {
		page := NewPage("notes2")

		err := Webengine.Render(page, w, r)
		if err != nil {
			w.Write([]byte(err.Error()))
			return
		}
		return
	}

	Webengine.StaticResource(w, r)
} // }}}
func httpError(w http.ResponseWriter, err error) { // {{{
	j, _ := json.Marshal(struct {
		OK    bool
		Error string
	}{false, err.Error()})
	w.Write(j)
} // }}}

func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
	w.Header().Add("Content-Type", "text/javascript; charset=utf-8")

	var tmpl *template.Template
	var err error
	if FlagDev {
		tmpl, err = template.ParseFiles("static/service_worker.js")
	} else {
		tmpl, err = template.ParseFS(StaticFS, "static/service_worker.js")
	}
	if err != nil {
		w.Write([]byte(err.Error()))
		return
	}

	err = tmpl.Execute(w, struct {
		VERSION string
		DevMode bool
	}{VERSION, FlagDev})
	if err != nil {
		w.Write([]byte(err.Error()))
		return
	}
} // }}}
func pageOffline(w http.ResponseWriter, r *http.Request) { // {{{
	page := NewPage("offline")

	err := Webengine.Render(page, w, r)
	if err != nil {
		w.Write([]byte(err.Error()))
		return
	}
} // }}}
func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
	page := NewPage("login")

	err := Webengine.Render(page, w, r)
	if err != nil {
		w.Write([]byte(err.Error()))
		return
	}
} // }}}
func pageSync(w http.ResponseWriter, r *http.Request) { // {{{
	page := NewPage("sync")

	err := Webengine.Render(page, w, r)
	if err != nil {
		w.Write([]byte(err.Error()))
		return
	}
} // }}}

func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
	// The purpose of the Client UUID is to avoid
	// sending nodes back once again to a client that
	// just created or modified it.
	user := getUserSession(r)
	changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
	offset, _ := strconv.Atoi(r.PathValue("offset"))

	nodes, maxSeq, moreRowsExist, err := Nodes(user.UserID, offset, uint64(changedFrom), user.ClientUUID)
	if err != nil {
		Log.Error("/sync/from_server", "error", err)
		httpError(w, err)
		return
	}

	j, _ := json.Marshal(struct {
		OK       bool
		Nodes    []Node
		MaxSeq   uint64
		Continue bool
	}{true, nodes, maxSeq, moreRowsExist})
	w.Write(j)
} // }}}
func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
	// The purpose of the Client UUID is to avoid
	// sending nodes back once again to a client that
	// just created or modified it.
	user := getUserSession(r)
	changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))

	count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
	if err != nil {
		Log.Error("/sync/from_server/count", "error", err)
		httpError(w, err)
		return
	}

	j, _ := json.Marshal(struct {
		OK    bool
		Count int
	}{
		true,
		count,
	})
	w.Write(j)
} // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
	user := getUserSession(r)
	var err error

	uuid := r.PathValue("uuid")
	node, err := RetrieveNode(user.UserID, uuid)
	if err != nil {
		responseError(w, err)
		return
	}

	responseData(w, map[string]interface{}{
		"OK":   true,
		"Node": node,
	})
} // }}}
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
	user := getUserSession(r)
	var err error

	uuid := r.PathValue("uuid")
	offset, err := strconv.Atoi(r.PathValue("offset"))
	if err != nil {
		responseError(w, err)
		return
	}

	nodes, hasMore, err := RetrieveNodeHistory(user.UserID, uuid, offset)
	if err != nil {
		responseError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK":      true,
		"Nodes":   nodes,
		"HasMore": hasMore,
	})
} // }}}
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
	user := getUserSession(r)
	var err error

	uuid := r.PathValue("uuid")

	count, err := RetrieveNodeHistoryCount(user.UserID, uuid)
	if err != nil {
		responseError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK":    true,
		"Count": count,
	})
} // }}}
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
	user := getUserSession(r)

	body, _ := io.ReadAll(r.Body)
	var request struct {
		NodeData string
	}
	err := json.Unmarshal(body, &request)
	if err != nil {
		httpError(w, err)
		return
	}

	_, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
	if err != nil {
		Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData)
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK": true,
	})
} // }}}
func actionSyncFileUpload(w http.ResponseWriter, r *http.Request) { // {{{
	uuid := r.PathValue("uuid")
	session := getUserSession(r)

	sentBytes, _ := strconv.Atoi(r.Header.Get("X-File-Sent-Bytes"))
	totalBytes, _ := strconv.Atoi(r.Header.Get("X-File-Total-Bytes"))

	// First block comes with file metadata.
	file := File{}
	if sentBytes == 0 {
		file.UUID = uuid
		file.Name = r.Header.Get("X-File-Name")
		file.Size, _ = strconv.Atoi(r.Header.Get("X-File-Size"))
		file.Type = r.Header.Get("X-File-Type")
		modded, _ := strconv.ParseInt(r.Header.Get("X-File-Modified"), 10, 64)
		file.Modified = time.UnixMilli(modded)
		err := AddFileToDb(session.UserID, &file)
		if err != nil {
			Log.Error("upload", "op", "AddFileToDB", "error", err)
			httpError(w, err)
			return
		}
		fileUUIDCache[uuid] = file.ID
	}

	data, _ := io.ReadAll(r.Body)
	var err error

	switch sentBytes {
	case 0:
		// First block of data.
		Log.Info("file_upload", "status", "new", "user", session.UserID, "uuid", uuid, "total", totalBytes)
		id := fileUUIDCache[uuid]
		err = FileWriteData(session.UserID, id, data, true)

	case totalBytes:
		// Last block of data - sent with 0 length data.
		Log.Info("file_upload", "status", "done", "user", session.UserID, "uuid", uuid, "total", totalBytes)
		id := fileUUIDCache[uuid]
		delete(fileUUIDCache, uuid)
		_, err = db.Exec(`UPDATE public.file SET upload_complete = true WHERE id = $1`, id)

	default:
		id := fileUUIDCache[uuid]
		err = FileWriteData(session.UserID, id, data, false)
	}

	if err != nil {
		Log.Error("upload", "error", err)
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK": true,
	})
} // }}}

func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
	user := getUserSession(r)
	prefs, err := user.Preferences()
	if err != nil {
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK":          true,
		"Preferences": prefs,
	})
} // }}}
func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
	session := getUserSession(r)

	// Verify the "default" profile is still there.
	var newPrefs map[string]appUser.UserPreferences
	body, _ := io.ReadAll(r.Body)
	err := json.Unmarshal(body, &newPrefs)
	if err != nil {
		httpError(w, err)
		return
	}

	if _, found := newPrefs["default"]; !found {
		httpError(w, fmt.Errorf("'default' profile missing."))
		return
	}

	err = session.SetPreferences(newPrefs)
	if err != nil {
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK": true,
	})
} // }}}

func actionFileCount(w http.ResponseWriter, r *http.Request) { // {{{
	session := getUserSession(r)
	count, err := FileCountForUser(session.UserID)
	if err != nil {
		Log.Error("file_count", "error", err)
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK":    true,
		"Count": count,
	})
} // }}}
func actionFileServerUUIDs(w http.ResponseWriter, r *http.Request) { // {{{
	session := getUserSession(r)
	offset, _ := strconv.Atoi(r.PathValue("offset"))
	uuids, more, err := FileServerUUIDsForUser(session.UserID, offset)
	if err != nil {
		Log.Error("file_server_uuids", "error", err)
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK":            true,
		"UUIDs":         uuids,
		"MoreRowsExist": more,
	})
} // }}}
func actionFile(w http.ResponseWriter, r *http.Request) { // {{{
	uuid := r.PathValue("uuid")
	session := getUserSession(r)
	f, md, err := FileForUser(session.UserID, uuid)
	if err != nil {
		Log.Error("file_metadata", "error", err)
		httpError(w, err)
		return
	}

	w.Header().Add("X-File-Name", md.Name)
	w.Header().Add("X-File-Modified", fmt.Sprintf("%d", md.Modified.UnixMilli()))
	w.Header().Add("Content-Type", md.Type)

	http.ServeContent(w, r, md.Name, md.Modified, f)
} // }}}
func actionFileMetadata(w http.ResponseWriter, r *http.Request) { // {{{
	uuid := r.PathValue("uuid")
	session := getUserSession(r)
	md, err := FileMetadata(session.UserID, uuid)

	if err != nil {
		Log.Error("file_metadata", "error", err)
		httpError(w, err)
		return
	}

	responseData(w, map[string]any{
		"OK":       true,
		"Metadata": md,
	})
} // }}}

func createNewUser(username string) { // {{{
	reader := bufio.NewReader(os.Stdin)

	fmt.Print("\nPassword: ")
	pwd, _ := reader.ReadString('\n')
	pwd = strings.Trim(pwd, "\r\n")

	fmt.Print("Name: ")
	name, _ := reader.ReadString('\n')
	name = strings.Trim(name, "\r\n")

	alreadyExists, err := AuthManager.CreateUser(username, pwd, name)
	if alreadyExists {
		fmt.Printf("\nUser '%s' already exists\n", username)
		return
	}
	if err != nil {
		Log.Error("create_user", "error", err)
		return
	}

	fmt.Printf("\nUser '%s' with username '%s' is created.\n", name, username)
} // }}}
func changePassword(username string) { // {{{
	reader := bufio.NewReader(os.Stdin)

	fmt.Print("\nPassword: ")
	newPwd, _ := reader.ReadString('\n')
	newPwd = strings.Trim(newPwd, "\r\n")

	hasChanged, err := AuthManager.ChangePassword(username, "", newPwd, true)
	if !hasChanged {
		fmt.Printf("Invalid user\n")
		return
	}
	if err != nil {
		Log.Error("change_password", "error", err)
		return
	}

	fmt.Printf("\nPassword changed\n")
} // }}}
func getUserSession(r *http.Request) appUser.UserSession { // {{{
	user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
	user.Db = db
	return user
} // }}}
