/*
The webservice package is used to provide a webservice with sessions:

	const VERSION = "v1"

	var logger *slog.Logger

	func sqlProvider(dbname string, version int) (sql []byte, found bool) {
		var err error
		sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version))
		if err != nil {
			return
		}
		found = true
		return
	}


	func init() {
		opts := slog.HandlerOptions{}
		logger = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
	}

	func main() {
		service, err := webservice.New("/etc/some/webservice.yaml", VERSION, logger)
		if err != nil {
			logger.Error("application", "error", err)
			os.Exit(1)
		}

		service.SetDatabase(sqlProvider)
		service.SetAuthenticationHandler(authenticate)
		service.SetAuthorizationHandler(authorize)
		service.Register("/foo", true, true, foo)
		service.Register("/bar", true, false, bar)
		err = service.Start()
		if err != nil {
			logger.Error("webserver", "error", err)
			os.Exit(1)
		}
	}
*/
package webservice

import (
	// Internal
	"git.gibonuddevalla.se/go/webservice/config"
	"git.gibonuddevalla.se/go/webservice/database"
	"git.gibonuddevalla.se/go/webservice/session"
	"git.gibonuddevalla.se/go/webservice/ws_conn_manager"

	// Standard
	"bufio"
	"embed"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"log/slog"
	"net/http"
	"os"
	"regexp"
	"strings"
)

const VERSION = "v0.2.17"

type HttpHandler func(http.ResponseWriter, *http.Request)

type ErrorHandler func(err error, code string, w http.ResponseWriter)
type ServiceError struct {
	OK    bool
	Code  string
	Error string
}

type Service struct {
	logger              *slog.Logger
	sessions            map[string]*session.T
	Config              config.Config
	Db                  *database.T
	Version             string
	WsConnectionManager ws_conn_manager.ConnectionManager

	errorHandler          ErrorHandler
	authenticationHandler AuthenticationHandler
	authorizationHandler  AuthorizationHandler

	staticSubFs              fs.FS
	useStaticDirectory       bool
	staticDirectory          string
	staticEmbeddedFileserver http.Handler
	staticLocalFileserver    http.Handler
}

type ServiceHandler func(http.ResponseWriter, *http.Request, *session.T)

func New(configFilename, version string, logger *slog.Logger) (service *Service, err error) { // {{{
	service = new(Service)

	service.Config, err = config.New(configFilename)
	if err != nil {
		return
	}
	logger.Debug("config", "config", service.Config)

	service.Version = version
	service.logger = logger
	service.sessions = make(map[string]*session.T, 128)
	service.errorHandler = service.defaultErrorHandler
	service.authenticationHandler = service.defaultAuthenticationHandler
	service.authorizationHandler = service.defaultAuthorizationHandler
	service.WsConnectionManager = ws_conn_manager.NewConnectionManager(service.logger, service.Config.Websocket.Domains)

	service.Register("/_session/new", false, false, service.sessionNew)
	service.Register("/_session/authenticate", true, false, service.sessionAuthenticate)
	service.Register("/_session/retrieve", true, false, service.sessionRetrieve)

	http.HandleFunc("/_ws", service.websocketHandler)
	http.HandleFunc("/_ws/css_update", service.cssUpdateHandler)

	http.HandleFunc("/_js/", service.staticJSHandler)

	return
} // }}}

func (service *Service) defaultAuthenticationHandler(req AuthenticationRequest, sess *session.T, alreadyAuthenticated bool) (resp AuthenticationResponse, err error) { // {{{
	resp.Authenticated = alreadyAuthenticated
	service.logger.Info("webservice", "op", "authentication", "username", req.Username, "authenticated", resp.Authenticated)
	return
} // }}}
func (service *Service) defaultAuthorizationHandler(sess *session.T, r *http.Request) (resp bool, err error) { // {{{
	resp = true
	service.logger.Debug("webservice", "op", "authorization", "session", sess.UUID, "request", r.URL.String(), "authorized", resp)
	return
} // }}}
func (service *Service) defaultErrorHandler(err error, code string, w http.ResponseWriter) { // {{{
	service.logger.Error("webservice", "error", err)
	errMsg := ServiceError{}
	errMsg.OK = false
	errMsg.Code = code
	errMsg.Error = err.Error()
	errJSON, _ := json.Marshal(errMsg)
	w.Write(errJSON)
} // }}}

func (service *Service) SetLogger(logger *slog.Logger) { // {{{
	service.logger = logger
} // }}}
func (service *Service) SetErrorHandler(h ErrorHandler) { // {{{
	service.errorHandler = h
} // }}}
func (service *Service) SetAuthenticationHandler(h AuthenticationHandler) { // {{{
	service.authenticationHandler = h
} // }}}
func (service *Service) SetAuthorizationHandler(h AuthorizationHandler) { // {{{
	service.authorizationHandler = h
} // }}}
func (service *Service) SetStaticFS(staticFS embed.FS, directory string) (err error) { // {{{
	service.staticSubFs, err = fs.Sub(staticFS, directory)
	if err != nil {
		return
	}
	service.staticEmbeddedFileserver = http.FileServer(http.FS(service.staticSubFs))
	return
} // }}}
func (service *Service) SetStaticDirectory(directory string, useDirectory bool) { // {{{
	service.useStaticDirectory = useDirectory
	service.staticDirectory = directory
	service.staticLocalFileserver = http.FileServer(http.Dir(directory))
} // }}}

func (service *Service) SetDatabase(sqlProv database.SqlProvider) { // {{{
	service.Db = database.New(service.Config.Database)
	service.Db.SetLogger(service.logger)
	service.Db.SetSQLProvider(sqlProv)
	return
} // }}}
func (service *Service) Register(path string, requireSession, requireAuthentication bool, handler ServiceHandler) { // {{{
	http.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
		var sess *session.T
		var found bool
		var authorized bool
		var err error

		if requireAuthentication && !requireSession {
			requireSession = true
		}

		if requireSession {
			headerSessionUUID, err := sessionUUID(r)
			if err != nil {
				service.errorHandler(fmt.Errorf("Header X-Session-ID missing"), "001-0000", w)
				return
			}

			sess, found = service.RetrieveSession(headerSessionUUID)
			if !found {
				service.errorHandler(fmt.Errorf("Session '%s' not found", headerSessionUUID), "001-0001", w)
				return
			}
		}

		if requireAuthentication {
			if !sess.Authenticated {
				service.errorHandler(fmt.Errorf("Session '%s' not authenticated", sess.UUID), "001-0002", w)
				return
			}

			authorized, err = service.authorizationHandler(sess, r)
			if err != nil {
				service.errorHandler(err, "001-F001", w)
				return
			}
			if !authorized {
				service.errorHandler(fmt.Errorf("Session '%s' not authorized for %s", sess.UUID, r.URL.String()), "001-0003", w)
				return
			}
		}

		service.logger.Info("webserver", "op", "request", "path", r.URL.String())
		handler(w, r, sess)
	})
} // }}}
func (service *Service) InitDatabaseConnection() (err error) { // {{{
	err = service.Db.Upgrade()
	if err != nil {
		return
	}

	err = service.Db.Connect()
	if err != nil {
		return
	}
	return
} // }}}
func (service *Service) CreateUser(username, password, name string) (userID int64, err error) { // {{{
	if service.Db != nil {
		err = service.InitDatabaseConnection()
		if err != nil {
			return
		}
	}

	userID, err = service.Db.CreateUser(username, password, name)
	return
} // }}}
func (service *Service) CreateUserPrompt() { // {{{
	var err error
	var username, name, password string
	reader := bufio.NewReader(os.Stdin)

	fmt.Printf("Username: ")
	username, _ = reader.ReadString('\n')
	username = strings.TrimSpace(username)

	fmt.Printf("Name: ")
	name, _ = reader.ReadString('\n')
	name = strings.TrimSpace(name)

	fmt.Printf("Password: ")
	password, _ = reader.ReadString('\n')
	password = strings.TrimSpace(password)

	_, err = service.CreateUser(username, password, name)
	if err != nil {
		service.logger.Error("application", "error", err)
		os.Exit(1)
	}
} // }}}
func (service *Service) Start() (err error) { // {{{
	if service.Db != nil {
		err = service.InitDatabaseConnection()
		if err != nil {
			return
		}
	}

	go service.WsConnectionManager.BroadcastLoop()

	listen := fmt.Sprintf("%s:%d", service.Config.Network.Address, service.Config.Network.Port)
	service.logger.Info("webserver", "listen", listen)
	err = http.ListenAndServe(listen, nil)
	return
} // }}}

func (service *Service) StaticHandler(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
	var err error

	data := struct{ VERSION string }{service.Version}

	// URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version.
	// To get rid of problems with cached content in browser on a new version release,
	// while also not disabling cache altogether.
	if r.URL.Path == "/favicon.ico" {
		service.staticEmbeddedFileserver.ServeHTTP(w, r)
		return
	}

	rxp := regexp.MustCompile("^/(css|images|js|fonts)/v[0-9]+/(.*)$")
	if comp := rxp.FindStringSubmatch(r.URL.Path); comp != nil {
		w.Header().Add("Pragma", "public")
		w.Header().Add("Cache-Control", "max-age=604800")

		r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2])
		p := fmt.Sprintf(service.staticDirectory+"/%s/%s", comp[1], comp[2])

		if service.useStaticDirectory {
			_, err = os.Stat(p)
			if err == nil {
				service.staticLocalFileserver.ServeHTTP(w, r)
			} else {
				service.staticEmbeddedFileserver.ServeHTTP(w, r)
			}
		} else {
			service.staticEmbeddedFileserver.ServeHTTP(w, r)
		}
		return
	}

	// Everything else is run through the template system.
	// For now to get VERSION into files to fix caching.
	//log.Printf("template: %s", r.URL.Path)
	tmpl, err := service.newTemplate(r.URL.Path)
	if err != nil {
		if os.IsNotExist(err) {
			w.WriteHeader(404)
		}
		w.Write([]byte(err.Error()))
		return
	}

	if err = tmpl.Execute(w, data); err != nil {
		w.Write([]byte(err.Error()))
	}
} // }}}
func (service *Service) websocketHandler(w http.ResponseWriter, r *http.Request) { // {{{
	service.logger.Debug("websocket", "op", "connect")
	var err error

	_, err = service.WsConnectionManager.NewConnection(w, r)
	if err != nil {
		service.logger.Error("websocket", "error", err)
		return
	}
} // }}}
func (service *Service) cssUpdateHandler(w http.ResponseWriter, r *http.Request) { // {{{
	service.logger.Debug("websocket", "css", "updated")
	service.WsConnectionManager.Broadcast(struct {
		OK bool
		ID string
		Op string
	}{
		OK: true,
		Op: "css_reload",
	})
} // }}}

func (service *Service) newTemplate(requestPath string) (tmpl *template.Template, err error) { // {{{
	// Append index.html if needed for further reading of the file
	p := requestPath
	if p[len(p)-1] == '/' {
		p += "index.html"
	}

	if p[0:1] == "/" {
		p = p[1:]
	}

	// Try local disk files for faster testing
	if service.useStaticDirectory {
		_, err = os.Stat(service.staticDirectory + "/" + p)
		if err == nil {
			tmpl, err = template.ParseFiles(service.staticDirectory + "/" + p)
			return
		}
	}

	tmpl, err = template.ParseFS(service.staticSubFs, p)
	return
} // }}}
func sessionUUID(r *http.Request) (string, error) { // {{{
	headers := r.Header["X-Session-Id"]
	if len(headers) > 0 {
		return headers[0], nil
	} else {
		cookie, err := r.Cookie("X-Session-ID")
		if err == nil && cookie.Value != "" {
			return cookie.Value, nil
		}
	}
	return "", errors.New("Invalid session")
} // }}}