package main

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"io/ioutil"
	"log"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"runtime"
	"time"

	"github.com/gin-contrib/pprof"
	"github.com/gin-contrib/static"
	"github.com/gin-gonic/gin"
	"github.com/lithammer/shortuuid/v3"
	"gopkg.in/ini.v1"
)

// Username is JWT!
type User struct {
	UserID   string `json:"id"`
	Username string `json:"username"`
	Password string `json:"password"`
}

type appContext struct {
	// defaults         *Config
	config           *ini.File
	config_path      string
	configBase_path  string
	configBase       map[string]interface{}
	data_path        string
	local_path       string
	cssFile          string
	bsVersion        int
	jellyfinLogin    bool
	users            []User
	invalidTokens    []string
	jf               *Jellyfin
	authJf           *Jellyfin
	ombi             *Ombi
	datePattern      string
	timePattern      string
	storage          Storage
	validator        Validator
	email            Emailer
	info, debug, err *log.Logger
	host             string
	port             int
	version          string
	quit             chan os.Signal
}

func GenerateSecret(length int) (string, error) {
	bytes := make([]byte, length)
	_, err := rand.Read(bytes)
	if err != nil {
		return "", err
	}
	return base64.URLEncoding.EncodeToString(bytes), err
}

func setGinLogger(router *gin.Engine, debugMode bool) {
	if debugMode {
		router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
			return fmt.Sprintf("[GIN/DEBUG] %s: %s(%s) => %d in %s; %s\n",
				param.TimeStamp.Format("15:04:05"),
				param.Method,
				param.Path,
				param.StatusCode,
				param.Latency,
				func() string {
					if param.ErrorMessage != "" {
						return "Error: " + param.ErrorMessage
					}
					return ""
				}(),
			)
		}))
	} else {
		router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
			return fmt.Sprintf("[GIN] %s(%s) => %d\n",
				param.Method,
				param.Path,
				param.StatusCode,
			)
		}))
	}
}

var PLATFORM string = runtime.GOOS

func main() {
	fmt.Printf("jfa-go version: %s (%s)\n", VERSION, COMMIT)
	// app encompasses essentially all useful functions.
	app := new(appContext)

	/*
		set default config, data and local paths
		also, confusing naming here. data_path is not the internal 'data' directory, rather the users .config/jfa-go folder.
		local_path is the internal 'data' directory.
	*/
	userConfigDir, _ := os.UserConfigDir()
	app.data_path = filepath.Join(userConfigDir, "jfa-go")
	app.config_path = filepath.Join(app.data_path, "config.ini")
	executable, _ := os.Executable()
	app.local_path = filepath.Join(filepath.Dir(executable), "data")

	app.info = log.New(os.Stdout, "[INFO] ", log.Ltime)
	app.err = log.New(os.Stdout, "[ERROR] ", log.Ltime|log.Lshortfile)

	dataPath := flag.String("data", app.data_path, "alternate path to data directory.")
	configPath := flag.String("config", app.config_path, "alternate path to config file.")
	host := flag.String("host", "", "alternate address to host web ui on.")
	port := flag.Int("port", 0, "alternate port to host web ui on.")
	debug := flag.Bool("debug", false, "Enables debug logging and exposes pprof.")

	flag.Parse()

	// attempt to apply command line flags correctly
	if app.config_path == *configPath && app.data_path != *dataPath {
		app.data_path = *dataPath
		app.config_path = filepath.Join(app.data_path, "config.ini")
	} else if app.config_path != *configPath && app.data_path == *dataPath {
		app.config_path = *configPath
	} else {
		app.config_path = *configPath
		app.data_path = *dataPath
	}

	// env variables are necessary because syscall.Exec for self-restarts doesn't doesn't work with arguments for some reason.

	if v := os.Getenv("JFA_CONFIGPATH"); v != "" {
		app.config_path = v
	}
	if v := os.Getenv("JFA_DATAPATH"); v != "" {
		app.data_path = v
	}

	os.Setenv("JFA_CONFIGPATH", app.config_path)
	os.Setenv("JFA_DATAPATH", app.data_path)

	var firstRun bool
	if _, err := os.Stat(app.data_path); os.IsNotExist(err) {
		os.Mkdir(app.data_path, 0700)
	}
	if _, err := os.Stat(app.config_path); os.IsNotExist(err) {
		firstRun = true
		dConfigPath := filepath.Join(app.local_path, "config-default.ini")
		var dConfig *os.File
		dConfig, err = os.Open(dConfigPath)
		if err != nil {
			app.err.Fatalf("Couldn't find default config file \"%s\"", dConfigPath)
		}
		defer dConfig.Close()
		var nConfig *os.File
		nConfig, err := os.Create(app.config_path)
		if err != nil {
			app.err.Fatalf("Couldn't open config file for writing: \"%s\"", app.config_path)
		}
		defer nConfig.Close()
		_, err = io.Copy(nConfig, dConfig)
		if err != nil {
			app.err.Fatalf("Couldn't copy default config. To do this manually, copy\n%s\nto\n%s", dConfigPath, app.config_path)
		}
		app.info.Printf("Copied default configuration to \"%s\"", app.config_path)
	}

	var debugMode bool
	var address string
	if app.loadConfig() != nil {
		app.err.Fatalf("Failed to load config file \"%s\"", app.config_path)
	}
	app.version = app.config.Section("jellyfin").Key("version").String()
	// read from config...
	debugMode = app.config.Section("ui").Key("debug").MustBool(false)
	// then from flag
	if *debug {
		debugMode = true
	}
	if debugMode {
		app.info.Println("WARNING: Don't use debug mode in production, as it exposes pprof on the network.")
		app.debug = log.New(os.Stdout, "[DEBUG] ", log.Ltime|log.Lshortfile)
	} else {
		app.debug = log.New(ioutil.Discard, "", 0)
	}

	if !firstRun {
		app.host = app.config.Section("ui").Key("host").String()
		app.port = app.config.Section("ui").Key("port").MustInt(8056)

		if *host != app.host && *host != "" {
			app.host = *host
		}
		if *port != app.port && *port > 0 {
			app.port = *port
		}

		if h := os.Getenv("JFA_HOST"); h != "" {
			app.host = h
			if p := os.Getenv("JFA_PORT"); p != "" {
				var port int
				_, err := fmt.Sscan(p, &port)
				if err == nil {
					app.port = port
				}
			}
		}

		address = fmt.Sprintf("%s:%d", app.host, app.port)

		app.debug.Printf("Loaded config file \"%s\"", app.config_path)

		if app.config.Section("ui").Key("bs5").MustBool(false) {
			app.cssFile = "bs5-jf.css"
			app.bsVersion = 5
		} else {
			app.cssFile = "bs4-jf.css"
			app.bsVersion = 4
		}

		app.debug.Println("Loading storage")

		// app.storage.invite_path = filepath.Join(app.data_path, "invites.json")
		app.storage.invite_path = app.config.Section("files").Key("invites").String()
		app.storage.loadInvites()
		// app.storage.emails_path = filepath.Join(app.data_path, "emails.json")
		app.storage.emails_path = app.config.Section("files").Key("emails").String()
		app.storage.loadEmails()
		// app.storage.policy_path = filepath.Join(app.data_path, "user_template.json")
		app.storage.policy_path = app.config.Section("files").Key("user_template").String()
		app.storage.loadPolicy()
		// app.storage.configuration_path = filepath.Join(app.data_path, "user_configuration.json")
		app.storage.policy_path = app.config.Section("files").Key("user_configuration").String()
		app.storage.loadConfiguration()
		// app.storage.displayprefs_path = filepath.Join(app.data_path, "user_displayprefs.json")
		app.storage.displayprefs_path = app.config.Section("files").Key("user_displayprefs").String()
		app.storage.loadDisplayprefs()

		if app.config.Section("ombi").Key("enabled").MustBool(false) {
			app.storage.ombi_path = app.config.Section("files").Key("ombi_template").String()
			app.storage.loadOmbiTemplate()
			app.ombi = newOmbi(
				app.config.Section("ombi").Key("server").String(),
				app.config.Section("ombi").Key("api_key").String(),
				true,
			)
		}

		app.configBase_path = filepath.Join(app.local_path, "config-base.json")
		config_base, _ := ioutil.ReadFile(app.configBase_path)
		json.Unmarshal(config_base, &app.configBase)

		themes := map[string]string{
			"Jellyfin (Dark)":   fmt.Sprintf("bs%d-jf.css", app.bsVersion),
			"Bootstrap (Light)": fmt.Sprintf("bs%d.css", app.bsVersion),
			"Custom CSS":        "",
		}
		if val, ok := themes[app.config.Section("ui").Key("theme").String()]; ok {
			app.cssFile = val
		}
		app.debug.Printf("Using css file \"%s\"", app.cssFile)
		secret, err := GenerateSecret(16)
		if err != nil {
			app.err.Fatal(err)
		}
		os.Setenv("JFA_SECRET", secret)
		app.jellyfinLogin = true
		if val, _ := app.config.Section("ui").Key("jellyfin_login").Bool(); !val {
			app.jellyfinLogin = false
			user := User{}
			user.UserID = shortuuid.New()
			user.Username = app.config.Section("ui").Key("username").String()
			user.Password = app.config.Section("ui").Key("password").String()
			app.users = append(app.users, user)
		} else {
			app.debug.Println("Using Jellyfin for authentication")
		}

		server := app.config.Section("jellyfin").Key("server").String()
		app.jf, _ = newJellyfin(server, "jfa-go", app.version, "hrfee-arch", "hrfee-arch")
		var status int
		_, status, err = app.jf.authenticate(app.config.Section("jellyfin").Key("username").String(), app.config.Section("jellyfin").Key("password").String())
		if status != 200 || err != nil {
			app.err.Fatalf("Failed to authenticate with Jellyfin @ %s: Code %d", server, status)
		}
		app.info.Printf("Authenticated with %s", server)
		app.authJf, _ = newJellyfin(server, "jfa-go", app.version, "auth", "auth")

		app.loadStrftime()

		validatorConf := ValidatorConf{
			"characters":           app.config.Section("password_validation").Key("min_length").MustInt(0),
			"uppercase characters": app.config.Section("password_validation").Key("upper").MustInt(0),
			"lowercase characters": app.config.Section("password_validation").Key("lower").MustInt(0),
			"numbers":              app.config.Section("password_validation").Key("number").MustInt(0),
			"special characters":   app.config.Section("password_validation").Key("special").MustInt(0),
		}
		if !app.config.Section("password_validation").Key("enabled").MustBool(false) {
			for key := range validatorConf {
				validatorConf[key] = 0
			}
		}
		app.validator.init(validatorConf)

		app.email.init(app)

		inviteDaemon := NewRepeater(time.Duration(60*time.Second), app)
		go inviteDaemon.Run()

		if app.config.Section("password_resets").Key("enabled").MustBool(false) {
			go app.StartPWR()
		}
	} else {
		debugMode = false
		address = "0.0.0.0:8056"
	}

	app.info.Println("Loading routes")
	if debugMode {
		gin.SetMode(gin.DebugMode)
	} else {
		gin.SetMode(gin.ReleaseMode)
	}
	router := gin.New()

	setGinLogger(router, debugMode)

	router.Use(gin.Recovery())
	router.Use(static.Serve("/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
	router.LoadHTMLGlob(filepath.Join(app.local_path, "templates", "*"))
	router.NoRoute(app.NoRouteHandler)
	if debugMode {
		app.debug.Println("Loading pprof")
		pprof.Register(router)
	}
	if !firstRun {
		router.GET("/", app.AdminPage)
		router.GET("/getToken", app.getToken)
		router.POST("/newUser", app.NewUser)
		router.Use(static.Serve("/invite/", static.LocalFile(filepath.Join(app.local_path, "static"), false)))
		router.GET("/invite/:invCode", app.InviteProxy)
		api := router.Group("/", app.webAuth())
		router.POST("/logout", app.Logout)
		api.POST("/generateInvite", app.GenerateInvite)
		api.GET("/getInvites", app.GetInvites)
		api.POST("/setNotify", app.SetNotify)
		api.POST("/deleteInvite", app.DeleteInvite)
		api.GET("/getUsers", app.GetUsers)
		api.POST("/modifyUsers", app.ModifyEmails)
		api.POST("/setDefaults", app.SetDefaults)
		api.GET("/getConfig", app.GetConfig)
		api.POST("/modifyConfig", app.ModifyConfig)
		if app.config.Section("ombi").Key("enabled").MustBool(false) {
			api.GET("/getOmbiUsers", app.OmbiUsers)
			api.POST("/setOmbiDefaults", app.SetOmbiDefaults)
		}
		app.info.Printf("Starting router @ %s", address)
	} else {
		windows := false
		if PLATFORM == "windows" {
			windows = true
		}
		router.GET("/", func(gc *gin.Context) {
			gc.HTML(200, "setup.html", gin.H{
				"windows": windows,
			})
		})
		router.POST("/testJF", app.TestJF)
		router.POST("/modifyConfig", app.ModifyConfig)
		app.info.Printf("Loading setup @ %s", address)
	}

	srv := &http.Server{
		Addr:    address,
		Handler: router,
	}
	go func() {
		if err := srv.ListenAndServe(); err != nil {
			app.err.Printf("Failure serving: %s", err)
		}
	}()
	app.quit = make(chan os.Signal)
	signal.Notify(app.quit, os.Interrupt)
	<-app.quit
	app.info.Println("Shutting down...")

	cntx, cancel := context.WithTimeout(context.Background(), time.Second*5)
	defer cancel()
	if err := srv.Shutdown(cntx); err != nil {
		app.err.Fatalf("Server shutdown error: %s", err)
	}
}
