package httpd

import (
	"archive/zip"
	"bytes"
	"crypto/ecdsa"
	"crypto/elliptic"
	"crypto/md5"
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"crypto/x509/pkix"
	"encoding/hex"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"log"
	"math/big"
	"mime/multipart"
	"net"
	"net/http"
	"os"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/gin-contrib/cors"
	"github.com/gin-contrib/sessions"
	"github.com/gin-contrib/sessions/memstore"
	"github.com/gin-gonic/autotls"
	"github.com/gin-gonic/gin"
	"golang.org/x/crypto/acme/autocert"

	"github.com/reimage-ai/broker/catalog"
	"github.com/reimage-ai/broker/contests"
	"github.com/reimage-ai/broker/feed"
	"github.com/reimage-ai/broker/firebase"
	"github.com/reimage-ai/broker/intro"
	"github.com/reimage-ai/broker/job"
	"github.com/reimage-ai/broker/nsfw"
	"github.com/reimage-ai/broker/queue"
	"github.com/reimage-ai/broker/revenuecat"
	"github.com/reimage-ai/broker/shop"
)

var engine *gin.Engine
var cookieName = "reimagecookie"
var filesDir = "/files"

// '_' is excluded because it delimits the intro doc ID — see intro.GetIntro.
var introParamRegex = regexp.MustCompile(`^[a-z0-9-]{1,64}$`)

// GinInit initializes gin (httpd)
func GinInit(selfcert bool, logfilePath string) {
	var err error
	syscall.Umask(000)

	// disable debugging with release mode flag
	gin.SetMode(gin.ReleaseMode)

	engine = gin.New()

	engine.Use(cors.New(cors.Config{
		AllowMethods:     []string{"GET", "POST", "OPTIONS", "PUT"},
		AllowHeaders:     []string{"Origin", "Content-Length", "Content-Type", "User-Agent", "Referrer", "Host", "Token", "Authorization"},
		ExposeHeaders:    []string{"Content-Length"},
		AllowCredentials: true,
		AllowAllOrigins:  true,
		//AllowOrigins: []string{"https://editor.remodelai.app", "http://127.0.0.1:8080", "http://localhost:8080"},
		//AllowOriginFunc: func(origin string) bool { return true },
		MaxAge: 86400,
	}))

	engine.Use(gin.Recovery())

	if logfilePath != "" {
		engine.Use(apacheLogger(logfilePath))
	}
	// limit upload size to 50MB
	engine.Use(func(c *gin.Context) {
		c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(50<<20))
	})

	//store := cookie.NewStore([]byte("secret"))
	//engine.Use(sessions.Sessions(cookieName, store))
	store := memstore.NewStore([]byte("secret"))
	engine.Use(sessions.Sessions(cookieName, store))

	engine.GET("/", func(c *gin.Context) { c.AbortWithStatus(403) })

	engine.GET("/api/v1/ping", ping)
	engine.GET("/api/v1/motd", motd)
	engine.GET("/api/v1/prefs", prefs)
	engine.GET("/api/v1/intro", introEndpoint)
	engine.POST("/api/v1/submit", submit)

	// job creation
	engine.POST("/api/v1/interior-remodel", interiorRemodel)
	engine.POST("/api/v1/exterior-remodel", exteriorRemodel)
	engine.POST("/api/v1/wall", wall)
	engine.POST("/api/v1/roof", roof)
	engine.POST("/api/v1/staging", staging)
	engine.POST("/api/v1/unstaging", unstaging)
	engine.POST("/api/v1/floor", floor)
	engine.POST("/api/v1/landscaping", landscaping)
	engine.POST("/api/v1/reskin", reskin)
	engine.POST("/api/v1/remove", remove)
	engine.POST("/api/v1/replace", replace)
	engine.POST("/api/v1/edit", edit)
	engine.POST("/api/v1/paint", paint)
	engine.POST("/api/v1/place", place)
	engine.POST("/api/v1/img2glb", img2glb)
	engine.POST("/api/v1/upscale", upscaleAccurate)
	engine.POST("/api/v1/upscale-accurate", upscaleAccurate)
	engine.POST("/api/v1/conditioner", conditioner)
	engine.POST("/api/v1/classification", classification)
	engine.POST("/api/v1/seg", seg)
	engine.POST("/api/v1/shop", shopcall)
	engine.POST("/api/v1/depth-extractor", depthExtractor)

	// account management
	engine.GET("/api/v1/account-properties", accountProperties)
	engine.GET("/api/v1/clear-cache", clearCache)
	engine.POST("/api/v1/update-fcm-tokens", updateFCMTokens)

	// product catalog (auth-only, no entitlement check — free read)
	engine.GET("/api/v1/catalog", catalogEndpoint)

	// contests (auth-only, no entitlement check — free read)
	engine.GET("/api/v1/contests", contestsEndpoint)

	// contests (auth-only, no entitlement check — free read)
	engine.GET("/api/v1/feed", feedEndpoint)

	// job status/outputs
	engine.GET("/api/v1/job/:id/status", jobGetStatus)
	engine.GET("/api/v1/job/:id/results", jobGetOutputs)         // old name - deprecated
	engine.GET("/api/v1/job/:id/results/:output", jobGetOutputs) // old name - deprecated
	engine.GET("/api/v1/job/:id/outputs", jobGetOutputs)
	engine.GET("/api/v1/job/:id/outputs/:output", jobGetOutputs)
	engine.GET("/api/v1/job/:id/outputszip", jobGetOutputsZip)

	// test API
	// engine.POST("/api/v1/test/:job_name/create_job", testCreateJob)

	// API to share shop images publicly
	engine.GET("/shopimage/*path", getShopImage)

	// private worker API
	engine.GET("/job/:name/waitzip", jobWaitZip)
	engine.POST("/job/:id/update", jobUpdate)
	engine.POST("/job/:id/requeue", jobRequeue)
	engine.POST("/notify", notify)
	engine.GET("/stats", stats)

	go func() {
		if !selfcert {
			m := autocert.Manager{
				Prompt:     autocert.AcceptTOS,
				HostPolicy: autocert.HostWhitelist("api.reimage.io", "broker.reimage.io", "broker-0.reimage.io", "broker-1.reimage.io", "broker-2.reimage.io", "broker-3.reimage.io"),
				Cache:      autocert.DirCache("/var/lib/reimage/broker-cert/"),
			}

			log.Printf("Starting HTTPD\n")
			err = autotls.RunWithManager(engine, &m)
		} else {
			cert, key := getConfiguredCert()
			uri := ":" + strconv.FormatInt(443, 10)

			log.Printf("Starting HTTPD\n")
			err = engine.RunTLS(uri, cert, key)
		}

		if err != nil {
			log.Printf("Failed to start HTTPD: %v\n", err)
		}
		log.Printf("HTTPD exit!\n")
	}()
}

// logger function to access.log
func apacheLogger(logfilePath string) gin.HandlerFunc {
	apacheLogFile, err := os.OpenFile(logfilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		log.Printf("Error creating log file: %s\n", err.Error())
	}

	return func(c *gin.Context) {
		c.Next()

		if apacheLogFile == nil {
			return
		}

		email := getEmail(c)
		timeFormatted := time.Now().Format("02/Jan/2006 15:04:05")
		requestLine := fmt.Sprintf("%s %s %s", c.Request.Method, c.Request.URL.Path, c.Request.Proto)
		agent := c.Request.Header.Get("User-Agent")
		str := fmt.Sprintf("%-15s %s - [%s] \"%s\" %d %d \"-\" \"%s\"\n", c.ClientIP(), email, timeFormatted, requestLine, 0, 0, agent)

		_, err := apacheLogFile.WriteString(str)
		if err != nil {
			log.Printf("Log Error: %s\n", err.Error())
		}
	}
}

// abort is a helper function to return an error
func abort(c *gin.Context, err error, extra any) {
	if extra == nil {
		log.Printf("Error: %s [%v] %s \n", err.Error(), c.FullPath(), clientInformation(c))
	} else {
		log.Printf("Error: %s (%v) [%v] %s\n", err.Error(), extra, c.FullPath(), clientInformation(c))
	}
	//debug.PrintStack()
	c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
}

// ping returns the ping
func ping(c *gin.Context) {
	// older version of the app do not provide auth when calling ping
	// auth should be enabled at some point in the future - dmorris 2025-11-14

	// var err error
	// err, _, _ = checkEntitlement(c, computeTokenName(stringValue(c, "app")), 0.0, 0.0)
	// if err != nil {
	// 	c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
	// 	return
	// }

	c.IndentedJSON(http.StatusOK, map[string]any{
		"ping": "OK",
	})
}

// prefs returns the app prefs
func prefs(c *gin.Context) {
	// no auth required
	prefs := map[string]any{
		"targetDimension":  1024, // deprecated
		"targetPixelCount": 1024 * 1024,
	}
	c.IndentedJSON(http.StatusOK, prefs)
}

// notify just prints a message
func notify(c *gin.Context) {
	if checkPrivateAPIAuth(c) != nil {
		return
	}

	query := c.Request.URL.Query()
	if query["message"] != nil {
		message := query["message"][0]
		log.Printf("NOTIFY   %-15s %-50s\n", workerName(c), message)
	}

	c.IndentedJSON(http.StatusOK, gin.H{"status": "OK"})
}

// stats returns the queue stats
func stats(c *gin.Context) {
	if checkPrivateAPIAuth(c) != nil {
		return
	}

	c.IndentedJSON(http.StatusOK, queue.CurrentState())
}

// motd returns the message of the day (used for alerts is UI)
func motd(c *gin.Context) {
	dat, err := os.ReadFile("/tmp/broker-motd.txt")
	// if file not found, just display nothing (this is normal)
	if err != nil {
		resp := map[string]any{
			"display": false,
		}
		c.IndentedJSON(http.StatusOK, resp)
		return
	}
	// if the file is found, display the contents in the message
	resp := map[string]any{
		"display": true,
		"text":    string(dat),
	}
	c.IndentedJSON(http.StatusOK, resp)
}

// jobWaitZip returns the job as a zip if one is found
// this is to avoid the round trips associated with pulling the job images/assets
func jobWaitZip(c *gin.Context) {
	if checkPrivateAPIAuth(c) != nil {
		return
	}

	var err error

	priority := 0
	timeoutMs := 5000
	var hints []string = nil

	query := c.Request.URL.Query()
	if query["priority"] != nil {
		priority, err = strconv.Atoi(query["priority"][0])
		if err != nil {
			abort(c, err, query["priority"][0])
			return
		}
	}
	if query["timeout"] != nil {
		timeoutMs, err = strconv.Atoi(query["timeout"][0])
		if err != nil {
			abort(c, err, query["timeout"][0])
			return
		}
	}
	if query["hints"] != nil {
		hintsStr := query["hints"][0]
		hints = strings.Split(hintsStr, ",")
	}

	name := c.Param("name")
	j, err := queue.Wait(c, name, priority, timeoutMs, workerName(c), hints)
	if err != nil {
		abort(c, err, nil)
		return
	}

	if j == nil {
		c.Status(http.StatusNoContent)
		return
	}

	json, err := json.Marshal(j)
	if err != nil {
		abort(c, fmt.Errorf("Failed to marshall json"), err)
		return
	}

	log.Printf("Job %s: %s | assigned to %s | priority: %d\n", j.FullName(), name, workerName(c), priority)
	c.Writer.Header().Set("Content-type", "application/octet-stream")
	c.Stream(func(w io.Writer) bool {
		var err error
		ar := zip.NewWriter(w)

		job_file, err := ar.Create("job.json")
		if err != nil {
			abort(c, fmt.Errorf("Failed to create job.json"), err)
			return false
		}
		_, err = job_file.Write(json)
		if err != nil {
			abort(c, fmt.Errorf("Failed to job file"), err)
			return false
		}

		// if input is a string and valid file path, include the file
		for k, v := range j.Inputs {
			if stringvalue, ok := v.(string); ok {
				// some user inputs can technically be paths "." "/etc/passwd" etc.
				// only count strings that have start with fileDir as paths
				if !strings.HasPrefix(stringvalue, filesDir) {
					continue
				}
				if _, err := os.Stat(stringvalue); err == nil {
					file, err := os.Open(stringvalue)
					if err != nil {
						abort(c, fmt.Errorf("Missing file"), err)
						return false
					}
					archive_file, err := ar.Create(k)
					if err != nil {
						abort(c, fmt.Errorf("Failed to create archive"), err)
						return false
					}
					io.Copy(archive_file, file)
				}
			}
		}
		c.Writer.Header().Set("Content-Disposition", "attachment; filename='filename.zip'")
		ar.Close()
		return false
	})
	c.IndentedJSON(http.StatusOK, j)
}

// jobUpdate returns the job status
func jobUpdate(c *gin.Context) {
	if checkPrivateAPIAuth(c) != nil {
		return
	}

	var err error

	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Printf("Error: jobUpdate: job not found: %v\n", c.Param("id"))
		abort(c, fmt.Errorf("Job not found"), c.Param("id"))
		return
	}

	j := job.GetJob(uint64(id))
	if j == nil {
		log.Printf("Error: jobUpdate: job not found: %v\n", id)
		abort(c, fmt.Errorf("Job not found"), id)
		return
	}

	form, err := c.MultipartForm()
	if err != nil {
		abort(c, fmt.Errorf("Invalid form data"), err)
		return
	}
	if form == nil {
		abort(c, fmt.Errorf("Missing form data"), nil)
		return
	}

	// post files should hold any output files if the job is complete
	// if found, lookup the save locations and save them
	for key, value := range c.Request.MultipartForm.File {
		contentType := "application/octet-stream"
		suffix := "output"
		if strings.Contains(key, "image") {
			contentType = "image/png"
			suffix = "png"
		} else {
			contentType = "application/octet-stream"
			suffix = "bin"
		}

		var rf = job.OutputFile{
			Filename:    fmt.Sprintf("%s/%s/results/%v.%v.%v", filesDir, j.Name, j.ID, key, suffix),
			Savename:    fmt.Sprintf("%v.%v", key, suffix),
			ContentType: contentType,
		}
		j.Outputs[key] = rf
		if err := c.SaveUploadedFile(value[0], rf.Filename); err != nil {
			log.Printf("Failed to write file %v\n", err)
			abort(c, fmt.Errorf("Server error"), err)
			return
		}
	}

	// post should have one text value of "json" holding the job json
	for key, value := range c.Request.PostForm {
		if key == "json" {
			var jsonObj map[string]any
			err = json.Unmarshal([]byte(value[0]), &jsonObj)
			if err != nil {
				abort(c, err, nil)
				return
			}
			status, _ := jsonObj["status"].(string)

			if outputs, ok := jsonObj["outputs"].(map[string]any); ok {
				for k, v := range outputs {
					if _, ok := j.Outputs[k]; !ok {
						j.Outputs[k] = v
					}
				}
			}

			// The comfy service can sometimes report back the workflow that was used to complete this job
			// If so, if the job showed as a matching hint when it was sent to the worker
			// it should have used the matching workflow - if it does not log it!
			workflow, _ := jsonObj["workflow"].(string)
			// if workflow != "" && j.Hint != "" && j.HintMatched {
			// 	log.Printf("workflow/hint match!: %s != %s\n", workflow, j.Hint)
			// }
			if workflow != "" && j.Hint != "" && j.HintMatched && workflow != j.Hint {
				log.Printf("ALERT: workflow/hint mismatch!: %s != %s (job: %v)\n", workflow, j.Hint, j.ID)
			}

			elapsedTime := j.UpdateStatus(status, j.Outputs)
			c.IndentedJSON(http.StatusOK, gin.H{"status": "OK", "elapsedTime": elapsedTime})
			saveJob(j)
		} else {
			log.Printf("Error: Unknown post key in jobUpdate: %s\n", key)
			abort(c, fmt.Errorf("Invalid post"), id)
		}
	}

}

// jobRequeue requeues the job for another worker to handle
// this usually happens because a worker has been interrupted or is quitting
func jobRequeue(c *gin.Context) {
	if checkPrivateAPIAuth(c) != nil {
		return
	}

	var err error

	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		log.Printf("Error: jobRequeue: job not found: %v\n", c.Param("id"))
		abort(c, fmt.Errorf("Job not found"), c.Param("id"))
		return
	}

	j := job.GetJob(uint64(id))
	if j == nil {
		log.Printf("Error: jobRequeue: job not found: %v\n", id)
		abort(c, fmt.Errorf("Job not found"), id)
		return
	}
	if j.Completed() {
		log.Printf("Job %s: Alert: Cannot requeue completed job %v\n", j.FullName(), workerName(c))
		abort(c, fmt.Errorf("Job complete"), id)
		return
	}

	log.Printf("Job %s: Alert: job has been requeued at the request of worker %v\n", j.FullName(), workerName(c))

	j.UpdateStatus("queued", nil)
	queue.Put(j.Queue, j)

	c.IndentedJSON(http.StatusOK, gin.H{"status": "OK"})
}

// jobGetStatus returns the job status
func jobGetStatus(c *gin.Context) {
	idstr := c.Param("id")
	id, err := strconv.ParseUint(idstr, 10, 64)
	if err != nil {
		abort(c, err, idstr)
		return
	}

	j := job.GetJob(id)
	if j == nil {
		log.Printf("Error: jobGetStatus: job not found: %v\n", id)
		abort(c, fmt.Errorf("Job not found"), id)
		return
	}

	query := c.Request.URL.Query()
	timeout := 1.0
	if query["timeout"] != nil {
		if timeout, err = strconv.ParseFloat(query["timeout"][0], 64); err != nil {
			abort(c, err, query["timeout"][0])
			return
		}
	}
	if !j.Completed() && timeout > 0.0 {
		j.WaitForCompletion(timeout)
	}

	status := j.GetStatusJson()
	if status["status"] == "queued" {
		status["queueLength"], _ = queue.QueueLength(j.GetAssignedQueue())
	}
	if status["status"] == "complete" || status["status"] == "error" {
		output_names := []string{}
		output_values := make(map[string]any)
		for k, v := range j.Outputs {
			output_names = append(output_names, k)
			if _, ok := v.(job.OutputFile); ok {
				output_values[k] = "file"
			} else {
				output_values[k] = v
			}
		}

		status["outputs"] = output_values
		status["output_names"] = output_names
		status["output_values"] = output_values
	}

	c.IndentedJSON(http.StatusOK, status)
}

// jobGetOutputs returns the specified job result
func jobGetOutputs(c *gin.Context) {
	idstr := c.Param("id")
	outputstr := c.Param("output")
	id, err := strconv.ParseUint(idstr, 10, 64)

	if err != nil {
		abort(c, err, idstr)
		return
	}

	j := job.GetJob(id)
	if j == nil {
		log.Printf("Error: jobGetOutputs: job not found: %v\n", id)
		abort(c, fmt.Errorf("Job not found"), id)
		return
	}
	if j.GetStatus() != "complete" {
		abort(c, fmt.Errorf("Job not complete"), j.GetStatus())
		return
	}

	if outputstr == "" {
		if len(j.Outputs) != 1 {
			abort(c, fmt.Errorf("Must specify an output"), len(j.Outputs))
			return
		}
		for key := range j.Outputs {
			outputstr = key
			break
		}
	}
	// we actually decrement the token when the outputs are fetched
	// this is to avoid taking a token when errors occur
	if outputstr == "image-0" && j.TokenName != "" && j.TokenCost != 0.0 {
		err, _, _ = checkEntitlement(c, j.TokenName, 0.0, j.TokenCost) // decrement tokens
		if err != nil {
			c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
			return
		}
	}

	var output = j.Outputs[outputstr]
	if rs, ok := output.(job.OutputFile); ok {
		c.Header("Content-Description", "File Transfer")
		c.Header("Content-Transfer-Encoding", "binary")
		c.Header("Content-Disposition", "attachment; filename="+(rs.Savename))
		c.Header("Content-Type", rs.ContentType)
		c.File(rs.Filename)
	} else {
		var strValue string = fmt.Sprintf("%v", output)
		c.String(http.StatusOK, strValue)
	}
}

// jobGetOutputsZip returns all the job outputs
func jobGetOutputsZip(c *gin.Context) {
	idstr := c.Param("id")
	id, err := strconv.ParseUint(idstr, 10, 64)

	if err != nil {
		abort(c, err, idstr)
		return
	}

	j := job.GetJob(id)
	if j == nil {
		log.Printf("Error: jobGetOutputs: job not found: %v\n", id)
		abort(c, fmt.Errorf("Job not found"), id)
		return
	}
	if j.GetStatus() != "complete" {
		abort(c, fmt.Errorf("Job not complete"), j.GetStatus())
		return
	}

	// build a map of all the non-file outputs
	var ouputValuesMap = make(map[string]any)
	for outputName, outputValue := range j.Outputs {
		if _, ok := outputValue.(job.OutputFile); ok {
			ouputValuesMap[outputName] = nil // value will be in zip as file
		} else {
			ouputValuesMap[outputName] = outputValue
		}
	}
	json, err := json.Marshal(ouputValuesMap)
	if err != nil {
		abort(c, fmt.Errorf("Failed to marshall json"), err)
		return
	}

	c.Writer.Header().Set("Content-type", "application/octet-stream")
	c.Stream(func(w io.Writer) bool {
		var err error
		ar := zip.NewWriter(w)

		for outputName, outputValue := range j.Outputs {
			if of, ok := outputValue.(job.OutputFile); ok {

				// we actually decrement the token when the outputs are fetched
				// this is to avoid taking a token when errors occur
				if outputName == "image-0" && j.TokenName != "" {
					err, _, _ = checkEntitlement(c, j.TokenName, 0.0, j.TokenCost) // decrement tokens
					if err != nil {
						c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
						return false
					}
				}

				// do not print/log errors related to writing to stream
				// this happens frequently when the clinet disconnects during download
				// and is normal to receive an 'http2: stream closed' error

				// copy the output file into the archive
				archiveFile, err := ar.Create(outputName)
				if err != nil {
					//abort(c, fmt.Errorf("Failed to create file"), err)
					c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
					return false
				}
				outputFile, err := os.Open(of.Filename)
				if err != nil {
					abort(c, fmt.Errorf("Failed to read output file"), err)
					return false
				}
				_, err = io.Copy(archiveFile, outputFile)
				if err != nil {
					//abort(c, fmt.Errorf("Failed to copy output file"), err)
					c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
					return false
				}
			} else {
				var strvalue string = fmt.Sprintf("%v", outputValue)
				archiveFile, err := ar.Create(outputName)
				if err != nil {
					//abort(c, fmt.Errorf("Failed to create file: %v", outputName), err)
					c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
					return false
				}
				_, err = io.WriteString(archiveFile, strvalue)
				if err != nil {
					//abort(c, fmt.Errorf("Failed to write file: %v", outputName), err)
					c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
					return false
				}
			}
		}

		job_file, err := ar.Create("outputs.json")
		if err != nil {
			//abort(c, fmt.Errorf("Failed to create file: outputs.json"), err)
			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
			return false
		}
		_, err = job_file.Write(json)
		if err != nil {
			//abort(c, fmt.Errorf("Failed to write outputs json"), err)
			c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": err.Error()})
			return false
		}

		c.Writer.Header().Set("Content-Disposition", "attachment; filename='filename.zip'")
		ar.Close()
		return false
	})
}

// interioRemodel creates a interior-remodel job
func interiorRemodel(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("interior-remodel", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", false); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "room_type", "room_type", false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	if err = setInputFloat(c, j, "fidelity", "fidelity", false, false); err != nil {
		return
	}
	if err = setInputFloat(c, j, "creativity", "creativity", false, false); err != nil {
		return
	}
	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, maskExists := j.Inputs["mask_image"]
	_, imageExists := j.Inputs["style_image"]
	_, promptExists := j.Inputs["prompt"]

	// We are migrating these jobs to the comfy worker,
	// interior-remodel-unmasked-preset -- comfy
	// interior-remodel-unmasked-prompt -- comfy
	// interior-remodel-unmasked-image -- comfy
	// interior-remodel-masked-preset -- comfy
	// interior-remodel-masked-prompt -- comfy
	// interior-remodel-masked-image -- comfy
	if !maskExists {
		if imageExists {
			j.Hint = "interior-remodel-unmasked-image"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else if promptExists {
			j.Hint = "interior-remodel-unmasked-prompt"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else {
			j.Hint = "interior-remodel-unmasked-preset"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		}
	} else {
		if imageExists {
			j.Hint = "interior-remodel-masked-image"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else if promptExists {
			j.Hint = "interior-remodel-masked-prompt"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else {
			j.Hint = "interior-remodel-masked-preset"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		}
	}

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// exteriorRemodel creates a exterior-remodel job
func exteriorRemodel(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("exterior-remodel", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", false); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputString(c, j, "structure_type", "structure_type", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	if err = setInputFloat(c, j, "fidelity", "fidelity", false, false); err != nil {
		return
	}
	if err = setInputFloat(c, j, "creativity", "creativity", false, false); err != nil {
		return
	}
	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, maskExists := j.Inputs["mask_image"]
	_, imageExists := j.Inputs["style_image"]
	_, promptExists := j.Inputs["prompt"]

	// We are migrating these jobs to the comfy worker,
	// exterior-remodel-unmasked-preset -- comfy
	// exterior-remodel-unmasked-prompt -- comfy
	// exterior-remodel-unmasked-image -- comfy
	// exterior-remodel-masked-preset -- comfy
	// exterior-remodel-masked-prompt -- comfy
	// exterior-remodel-masked-image -- comfy
	if !maskExists {
		if imageExists {
			j.Hint = "exterior-remodel-unmasked-image"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else if promptExists {
			j.Hint = "exterior-remodel-unmasked-prompt"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else {
			j.Hint = "exterior-remodel-unmasked-preset"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		}
	} else {
		if imageExists {
			j.Hint = "exterior-remodel-masked-image"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else if promptExists {
			j.Hint = "exterior-remodel-masked-prompt"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		} else {
			j.Hint = "exterior-remodel-masked-preset"
			j.Inputs["maintain_size"] = "true"
			j.Queue = "comfy"
		}
	}

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// floor creates a floor job
func floor(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("floor", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", false); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}
	j.Inputs["maintain_size"] = "true"

	_, imageExists := j.Inputs["style_image"]
	if imageExists {
		j.Hint = "floor-image"
	} else {
		j.Hint = "floor"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// wall creates a wall job
func wall(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("wall", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", false); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}
	j.Inputs["maintain_size"] = "true"

	_, imageExists := j.Inputs["style_image"]
	if imageExists {
		j.Hint = "wall-image"
	} else {
		j.Hint = "wall"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// roof creates a roof job
func roof(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("roof", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "roof_name", "preset", true); err != nil {
		return
	}
	j.Inputs["roof_name"] = camelCaseToSnakeCase(fmt.Sprint(j.Inputs["roof_name"]))
	j.Inputs["maintain_size"] = "true"

	j.Hint = "roof"

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// staging creates a staging job
func staging(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("staging", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "room_type", "room_type", true); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, imageExists := j.Inputs["style_image"]
	if imageExists {
		j.Hint = "staging-image"
	} else {
		j.Hint = "staging"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// unstaging creates a staging job
func unstaging(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("unstaging", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputFloat(c, j, "strength", "strength", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, promptExists := j.Inputs["prompt"]
	if promptExists {
		j.Hint = "unstaging-prompt"
	} else {
		j.Hint = "unstaging"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// landscaping creates a landscaping job
func landscaping(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("landscaping", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}
	j.Inputs["maintain_size"] = "true"

	_, promptExists := j.Inputs["prompt"]
	_, imageExists := j.Inputs["style_image"]
	if promptExists {
		j.Hint = "landscaping"
	} else if imageExists {
		j.Hint = "landscaping-image"
	} else {
		j.Hint = "landscaping"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// reskin creates a reskin job
func reskin(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("reskin", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, promptExists := j.Inputs["prompt"]
	if promptExists {
		j.Hint = "reskin-prompt"
	} else {
		j.Hint = "reskin-image"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// remove creates a remove job
func remove(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("remove", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", true); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	j.Hint = "remove"

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// replace creates a replace job
func replace(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("replace", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "style_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, promptExists := j.Inputs["prompt"]
	if promptExists {
		j.Hint = "replace-prompt"
	} else {
		j.Hint = "replace-image"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// replace creates a replace job
func edit(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var j *job.Job = nil
	j = job.CreateJob("edit", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = j.Name

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", false); err != nil {
		return
	}
	if err = setInputFile(c, j, "reference_image", "reference_image", "png", false); err != nil {
		return
	}
	// also accept style_image input as reference_image
	if err = setInputFile(c, j, "reference_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	if err = setInputFloat(c, j, "creativity", "creativity", false, false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, referenceExists := j.Inputs["reference_image"]
	_, maskExists := j.Inputs["mask_image"]
	if maskExists {
		if referenceExists {
			j.Hint = "edit-masked-image"
		} else {
			j.Hint = "edit-masked"
		}
	} else {
		if referenceExists {
			j.Hint = "edit-unmasked-image"
		} else {
			j.Hint = "edit-unmasked"
		}
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// paint creates a paint job
func paint(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}
	var j *job.Job = nil

	j = job.CreateJob("paint", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = "paint"

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "mask_image", "mask_image", "png", false); err != nil {
		return
	}
	if err = setInputFile(c, j, "reference_image", "reference_image", "png", false); err != nil {
		return
	}
	// also accept style_image input as reference_image
	if err = setInputFile(c, j, "reference_image", "style_image", "png", false); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputInt(c, j, "color_r", "color_r", false, false); err != nil {
		return
	}
	if err = setInputInt(c, j, "color_g", "color_g", false, false); err != nil {
		return
	}
	if err = setInputInt(c, j, "color_b", "color_b", false, false); err != nil {
		return
	}
	if err = setInputInt(c, j, "batch_size", "batch_size", false, false); err != nil {
		return
	}
	// keep_surface details used to be a string dropdown with auto,on,off
	var value string = c.PostForm("keep_surface_details")
	if strings.ToLower(value) == "on" || strings.ToLower(value) == "auto" || strings.ToLower(value) == "true" || strings.ToLower(value) == "1" {
		j.Inputs["keep_surface_details"] = true
	} else if strings.ToLower(value) == "off" || strings.ToLower(value) == "false" || strings.ToLower(value) == "0" {
		j.Inputs["keep_surface_details"] = false
	}
	if err = setInputBool(c, j, "revise_mask", "revise_mask", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "prompt", "prompt", false); err != nil {
		return
	}
	if err = setInputString(c, j, "location", "location", false); err != nil {
		return
	}
	if err = setInputString(c, j, "room_type", "room_type", false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	allowed, message := nsfw.Allowed(c, j)
	if !allowed {
		c.AbortWithStatusJSON(403, gin.H{"message": message})
		return
	}

	_, promptExists := j.Inputs["prompt"]
	_, colorExists := j.Inputs["color_r"]
	_, referenceImageExists := j.Inputs["reference_image"]
	_, maskImageExists := j.Inputs["mask_image"]
	if promptExists {
		if colorExists {
			j.Hint = "paint-prompt"
		} else {
			// if this is just a prompt with specifying a color - send it to the edit workflow
			j.Inputs["op"] = "edit"
			if maskImageExists {
				if referenceImageExists {
					j.Hint = "edit-masked-image"
				} else {
					j.Hint = "edit-masked"
				}
			} else {
				if referenceImageExists {
					j.Hint = "edit-unmasked-image"
				} else {
					j.Hint = "edit-unmasked"
				}
			}
		}
	} else {
		j.Hint = "paint-mask"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// place creates a place job
func place(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}
	var j *job.Job = nil

	j = job.CreateJob("place", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = "place"

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputString(c, j, "object_name", "object_name", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "object_image", "object_image", "png", true); err != nil {
		return
	}
	if err = setInputFile(c, j, "product_image", "product_image", "png", true); err != nil {
		return
	}
	if err = setInputInt(c, j, "x", "x", false, true); err != nil {
		return
	}
	if err = setInputInt(c, j, "y", "y", false, true); err != nil {
		return
	}
	if err = setInputInt(c, j, "width", "width", false, true); err != nil {
		return
	}
	if err = setInputInt(c, j, "height", "height", false, true); err != nil {
		return
	}
	if err = setInputInt(c, j, "seed", "seed", false, false); err != nil {
		return
	}
	if err = setInputBool(c, j, "keep_direction", "keep_direction", false, false); err != nil {
		return
	}
	if err = setInputString(c, j, "room_type", "room_type", false); err != nil {
		return
	}
	j.Inputs["maintain_size"] = "true"

	j.Hint = "place"
	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// creates an img2glb job
func img2glb(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}
	var j *job.Job = nil

	j = job.CreateJob("img2glb", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = "img2glb"

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}

	j.Hint = "img2glb"
	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// classification creates a remodel classification job
func classification(c *gin.Context) {
	var err error
	var tokenCost = 0.1
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}
	var j *job.Job = job.CreateJob("classification", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}

	var entity string = stringValue(c, "entity")
	if entity == "" {
		abort(c, fmt.Errorf("entity argument required"), nil)
		return
	}
	j.Inputs["op"] = fmt.Sprintf("classify-%s", entity)
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost

	switch entity {
	case "room":
		j.Hint = "classify"
	case "location":
		j.Hint = "classify"
	case "empty":
		j.Hint = "classify-empty"
	}

	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// upscaleAccurate creates an upscale-accurate job
func upscaleAccurate(c *gin.Context) {
	var err error
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), 0.0, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}
	var j *job.Job = nil

	j = job.CreateJob("upscale", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.Inputs["op"] = j.Name
	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}
	if err = setInputInt(c, j, "scale", "scale", true, false); err != nil {
		return
	}

	j.Hint = "upscale"
	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// conditioner creates a job to create a controlnet conditioning image from an image
func conditioner(c *gin.Context) {
	var err error
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), 0.0, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	file, err := c.FormFile("image")
	if err != nil {
		abort(c, fmt.Errorf("Failed to upload image file. Check network connectivity."), err)
		return
	}
	if file == nil {
		abort(c, fmt.Errorf("Failed to upload image file. Check network connectivity."), nil)
		return
	}

	j := job.CreateJob("conditioner", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	imgPath := fmt.Sprintf("%s/%s/%v.input.png", filesDir, j.Name, j.ID)
	j.Inputs["image"] = imgPath

	if err = setInputString(c, j, "op", "op", true); err != nil {
		return
	}

	if err := c.SaveUploadedFile(file, imgPath); err != nil {
		log.Printf("Failed to write file %v\n", err)
		abort(c, fmt.Errorf("Server Error"), err)
		return
	}

	j.Queue = "diffusers"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// seg creates a mask from the segmentation
func seg(c *gin.Context) {
	var err error
	var tokenCost = 0.1
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	file, err := c.FormFile("image")
	if err != nil {
		abort(c, fmt.Errorf("Failed to upload image file. Check network connectivity."), err)
		return
	}
	if file == nil {
		abort(c, fmt.Errorf("Failed to upload image file. Check network connectivity."), nil)
		return
	}

	// create job
	j := job.CreateJob("seg", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	imgPath := fmt.Sprintf("%s/%s/%v.input.png", filesDir, j.Name, j.ID)
	j.Inputs["image"] = imgPath
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost

	if err := setInputString(c, j, "preset", "preset", false); err != nil {
		return
	}
	if err := setInputString(c, j, "mask_size", "mask_size", false); err != nil {
		return
	}
	if err = setInputJsonStringArray(c, j, "classes", "classes", false, false); err != nil {
		return
	}
	if err = setInputJsonStringArray(c, j, "negative_classes", "negative_classes", false, false); err != nil {
		return
	}
	if err = setInputInt(c, j, "expand", "expand", false, false); err != nil {
		return
	}
	if err = setInputJson2DFloatArray(c, j, "points", "points", 2, false, false); err != nil {
		return
	}
	ok := containsOneOfInputs(j.Inputs, []string{"preset", "classes", "points"})
	if !ok {
		abort(c, fmt.Errorf("No parameters provided."), nil)
		return
	}
	j.Inputs["op"] = "seg"

	// write input image file
	if err := c.SaveUploadedFile(file, imgPath); err != nil {
		log.Printf("Failed to write file %v\n", err)
		abort(c, fmt.Errorf("Server Error"), err)
		return
	}

	j.Queue = "diffusers"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// depthExtractor creates a depth-extractor job
func depthExtractor(c *gin.Context) {
	var err error
	var tokenCost = 1.0
	err, uid, hasEntitlement := checkEntitlement(c, computeTokenName(stringValue(c, "app")), tokenCost, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}
	var j *job.Job = nil

	j = job.CreateJob("depth-extractor", stringValue(c, "app"), uid, getEmail(c), hasEntitlement, stringValue(c, "version"))
	j.TokenName = computeTokenName(j.App)
	j.TokenCost = tokenCost
	j.Inputs["op"] = "depth-extractor"

	if err = setInputFile(c, j, "image", "image", "png", true); err != nil {
		return
	}

	j.Hint = "depth-extractor"
	j.Queue = "comfy"

	saveJob(j)
	queue.Put(j.Queue, j)
	c.JSON(http.StatusOK, j)
}

// shop takes an input image, and returns a list of shopping results from google
func shopcall(c *gin.Context) {
	var err error
	err, _, _ = checkEntitlement(c, computeTokenName(stringValue(c, "app")), 0.0, 0.0)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": err.Error()})
		return
	}

	var file *multipart.FileHeader
	file, err = c.FormFile("image")
	if err != nil {
		abort(c, err, nil)
		return
	}
	if file == nil {
		abort(c, fmt.Errorf("Failed to upload image file. Check network connectivity."), nil)
		return
	}
	md5, err := md5sum(file)
	if err != nil {
		abort(c, err, nil)
		return
	}
	path := fmt.Sprintf("%s/shop/%v.png", filesDir, md5)

	if err := c.SaveUploadedFile(file, path); err != nil {
		abort(c, err, nil)
		return
	}

	url := fmt.Sprintf("https://%v/shopimage/%v.png", c.Request.Host, md5)
	if c.Query("test") == "true" || c.PostForm("test") == "true" {
		url = "https://editor.remodelai.app/assets/assets/samples/03-living-room.jpg"
	}

	var languageCode string = stringValue(c, "languageCode")
	if languageCode == "" {
		languageCode = "en-us"
	}
	var countryCode string = stringValue(c, "countryCode")
	if countryCode == "" {
		countryCode = "us"
	}

	err, result := shop.ShopGoogleLensDog(url, countryCode, languageCode)
	if err != nil {
		abort(c, err, nil)
		return
	}

	c.IndentedJSON(http.StatusOK, gin.H{"status": "OK", "results": result})
}

// getFiles gets a file from filesDir
func getShopImage(c *gin.Context) {
	path := c.Param("path")
	c.Header("Content-Type", "image/png")
	filepath := fmt.Sprintf("%s/shop%s", filesDir, path)
	c.File(filepath)
}

// submit submits images from the user to reimage-ai
func submit(c *gin.Context) {
	var err error
	ident := strings.ReplaceAll(time.Now().Format(time.RFC3339), ":", "-")
	_, email := firebase.Email(c.GetHeader("authorization"))
	if email == "" {
		email = "unknown"
	}
	err, uid := firebase.UID(c.GetHeader("authorization"))
	if err != nil {
		abort(c, fmt.Errorf("Invalid auth token"), err)
		return
	}
	ident = ident + "-" + email

	path := fmt.Sprintf("%s/submit/%v", filesDir, ident)

	var jsonMap map[string]any = make(map[string]any)

	//c.Request.ParseForm() // ParseForm doesnt seem to work
	c.PostForm("") // force parsing of form so we can iterate over keys
	for key := range c.Request.PostForm {
		jsonMap[key] = c.PostForm(key)
	}
	jsonMap["email"] = email
	jsonMap["uid"] = uid
	jsonMap["ident"] = ident

	jsonMap["email"] = email
	data, err := json.MarshalIndent(jsonMap, "", " ")
	if err != nil {
		abort(c, err, nil)
		return
	}
	err = os.WriteFile(path+".json", data, 0644)
	if err != nil {
		abort(c, err, nil)
		return
	}

	var imageFile *multipart.FileHeader
	imageFile, err = c.FormFile("image-0")
	if err != nil {
		abort(c, err, nil)
		return
	}
	if imageFile == nil {
		abort(c, fmt.Errorf("Failed to upload image file. Check network connectivity."), nil)
		return
	}
	if err := c.SaveUploadedFile(imageFile, path+".image-0.png"); err != nil {
		abort(c, err, nil)
		return
	}

	// the rest of the images are optional, ignore any errors or ommissions
	for i := 1; i < 10; i++ {
		name := fmt.Sprintf("image-%d", i)
		imageFile, err = c.FormFile(name)
		if err != nil || imageFile == nil {
			break
		}
		if err := c.SaveUploadedFile(imageFile, path+"."+name+".png"); err != nil {
			abort(c, err, nil)
			return
		}
	}

	c.IndentedJSON(http.StatusOK, gin.H{"status": "OK"})
}

// accountProperties returns all the properties of an account (in json)
func accountProperties(c *gin.Context) {
	var err error
	authToken := c.GetHeader("authorization")
	if authToken == "" {
		//abort(c, fmt.Errorf("No auth token"), nil)
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "No auth token"})
		return
	}
	err, email := firebase.Email(authToken)
	if err != nil {
		//abort(c, fmt.Errorf("Invalid auth token"), err)
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "Invalid auth token"})
		return
	}
	if email == "" {
		abort(c, fmt.Errorf("Invalid auth token"), "nil email")
		return
	}
	err, uid := firebase.UID(authToken)
	if err != nil {
		abort(c, fmt.Errorf("Invalid auth token"), err)
		return
	}
	if uid == "" {
		abort(c, fmt.Errorf("Invalid auth token"), "nil UID")
		return
	}

	props := firebase.GetEmailProperties(email)

	// change it to a generic map so we can add other properties
	var jsonObj map[string]any
	s, err := json.Marshal(props)
	if err != nil {
		abort(c, fmt.Errorf("Invalid account data. Failed to encode."), nil)
		return
	}
	err = json.Unmarshal(s, &jsonObj)
	if err != nil {
		abort(c, fmt.Errorf("Invalid account data. Failed to decode."), nil)
		return
	}

	var entitlements = []string{}
	// add pro status
	if revenuecat.HasEntitlement(uid, "remodel") {
		entitlements = append(entitlements, "remodel")
	}
	if revenuecat.HasEntitlement(uid, "paint") {
		entitlements = append(entitlements, "paint")
	}
	if revenuecat.HasEntitlement(uid, "staging") {
		entitlements = append(entitlements, "staging")
	}
	if revenuecat.HasEntitlement(uid, "landscaping") {
		entitlements = append(entitlements, "landscaping")
	}
	if revenuecat.HasEntitlement(uid, "roof") {
		entitlements = append(entitlements, "roof")
	}
	if revenuecat.HasEntitlement(uid, "roomsy") {
		entitlements = append(entitlements, "roomsy")
	}
	jsonObj["entitlements"] = entitlements

	c.JSON(http.StatusOK, jsonObj)
}

// catalogEndpoint returns active catalog products.
// Lightweight auth: verifies Firebase token only — no RevenueCat entitlement check.
// Catalog is a free read; token verification is sufficient.
func catalogEndpoint(c *gin.Context) {
	token := c.GetHeader("authorization")
	if token == "" {
		c.AbortWithStatusJSON(401, gin.H{"message": "missing auth token"})
		return
	}
	err, _ := firebase.VerifyIDToken(token)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": "invalid auth token"})
		return
	}

	products, err := catalog.GetCatalog(firebase.FirestoreClientReimage())
	if err != nil {
		log.Printf("Error: Catalog endpoint error: %v [%s] %s\n", err, c.FullPath(), clientInformation(c))
		c.AbortWithStatusJSON(500, gin.H{"message": "catalog unavailable"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"products": products})
}

// contestsEndpoint returns active catalog products.
// Lightweight auth: verifies Firebase token only — no RevenueCat entitlement check.
// Contests is a free read; token verification is sufficient.
func contestsEndpoint(c *gin.Context) {
	token := c.GetHeader("authorization")
	if token == "" {
		c.AbortWithStatusJSON(401, gin.H{"message": "missing auth token"})
		return
	}
	err, _ := firebase.VerifyIDToken(token)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": "invalid auth token"})
		return
	}

	products, err := contests.GetContests(firebase.FirestoreClientReimage(), computeTokenName(stringValue(c, "app")))
	if err != nil {
		log.Printf("Error: Catalog endpoint error: %v [%s] %s\n", err, c.FullPath(), clientInformation(c))
		c.AbortWithStatusJSON(500, gin.H{"message": "catalog unavailable"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"products": products})
}

// feedEndpoint returns active catalog products.
// Lightweight auth: verifies Firebase token only — no RevenueCat entitlement check.
// Feed is a free read; token verification is sufficient.
func feedEndpoint(c *gin.Context) {
	token := c.GetHeader("authorization")
	if token == "" {
		c.AbortWithStatusJSON(401, gin.H{"message": "missing auth token"})
		return
	}
	err, _ := firebase.VerifyIDToken(token)
	if err != nil {
		c.AbortWithStatusJSON(403, gin.H{"message": "invalid auth token"})
		return
	}

	feed, err := feed.GetFeed(firebase.FirestoreClientReimage(), computeTokenName(stringValue(c, "app")))
	if err != nil {
		log.Printf("Error: Catalog endpoint error: %v [%s] %s\n", err, c.FullPath(), clientInformation(c))
		c.AbortWithStatusJSON(500, gin.H{"message": "catalog unavailable"})
		return
	}
	c.JSON(http.StatusOK, feed)
}

// introEndpoint returns the URL for a pre-generated intro "after" image.
// No auth on purpose — the interactive intro runs pre-signin and the bucket is public.
func introEndpoint(c *gin.Context) {
	// app is part of content identity here (not a billing/entitlement tag),
	// so unknown values must reject rather than default. We still accept
	// the noisy strings the apps send (e.g. "remodel-app") via Contains
	// matching, but a non-match is a hard error.
	rawApp := strings.ToLower(stringValue(c, "app"))
	var app string
	switch {
	case strings.Contains(rawApp, "paint"):
		app = "paint"
	case strings.Contains(rawApp, "staging"):
		app = "staging"
	case strings.Contains(rawApp, "roof"):
		app = "roof"
	case strings.Contains(rawApp, "landscap"):
		app = "landscaping"
	case strings.Contains(rawApp, "roomsy"):
		app = "roomsy"
	case strings.Contains(rawApp, "remodel"):
		app = "remodel"
	default:
		c.AbortWithStatusJSON(400, gin.H{"message": "app required and must be one of: remodel, paint, roof, staging, landscaping, roomsy"})
		return
	}

	subject := stringValue(c, "subject")
	preset := stringValue(c, "preset")
	variationStr := stringValue(c, "variation")
	if subject == "" || preset == "" || variationStr == "" {
		c.AbortWithStatusJSON(400, gin.H{"message": "subject, preset, variation required"})
		return
	}
	if !introParamRegex.MatchString(subject) || !introParamRegex.MatchString(preset) {
		c.AbortWithStatusJSON(400, gin.H{"message": "subject and preset must match [a-z0-9-]{1,64} (lowercase, hyphens for spaces, no underscores)"})
		return
	}
	variation, err := strconv.Atoi(variationStr)
	if err != nil {
		c.AbortWithStatusJSON(400, gin.H{"message": "variation must be an integer"})
		return
	}

	imageURL, err := intro.GetIntro(firebase.FirestoreClientReimage(), app, subject, preset, variation)
	if err != nil {
		if errors.Is(err, intro.ErrNotFound) {
			c.AbortWithStatusJSON(404, gin.H{"message": "intro preview not found"})
			return
		}
		log.Printf("Error: Intro endpoint error: %v [%s] %s\n", err, c.FullPath(), clientInformation(c))
		c.AbortWithStatusJSON(500, gin.H{"message": "intro unavailable"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"image_url": imageURL})
}

// clear Cache for a specific user
func clearCache(c *gin.Context) {
	var err error
	// this might be used when we've changed the number of tokens manually
	// or the user has just purchased and we want to refresh all the state
	authToken := c.GetHeader("authorization")
	if authToken == "" {
		abort(c, fmt.Errorf("No auth token"), nil)
		return
	}
	err, uid := firebase.UID(authToken)
	if err != nil {
		abort(c, fmt.Errorf("Invalid auth token"), err)
		return
	}
	if uid == "" {
		abort(c, fmt.Errorf("Invalid auth token"), "nil UID")
		return
	}
	err, email := firebase.Email(authToken)
	if err != nil {
		abort(c, fmt.Errorf("Invalid auth token"), err)
		return
	}
	if email == "" {
		abort(c, fmt.Errorf("Invalid auth token"), "nil email")
		return
	}

	revenuecat.ClearCache(uid)
	log.Printf("Cleared RevenueCat cache for %s [%s]\n", email, uid)

	firebase.ClearEmailCache(email)
	log.Printf("Cleared FireStore  cache for %s [%s]\n", email, uid)
}

// getConfiguredCert should retrieve configured certificates, or generate a self signed cert for mfw_admin
func getConfiguredCert() (certPath string, keyPath string) {
	certPath = "/repos/certs/api.reimage.io/fullchain.pem"
	keyPath = "/repos/certs/api.reimage.io/privkey.pem"

	if !checkCertKeyValidity(certPath, keyPath) {
		certPath, keyPath = generateSelfSigned()
	}

	return certPath, keyPath
}

// generateSelfSigned will generate a self signed cert into the /tmp/ directory, the cert will be valid for 10 years
// most of the logic taken from here: https://golang.org/src/crypto/tls/generate_cert.go
func generateSelfSigned() (certPath string, keyPath string) {
	var ca *x509.Certificate
	var caPrivKey *ecdsa.PrivateKey
	var certKey *ecdsa.PrivateKey
	var certBytes []byte

	certPath = "/tmp/cert.pem"
	keyPath = "/tmp/cert.key"

	if !checkCertKeyValidity(certPath, keyPath) {
		certPath = "/tmp/cert.pem"
		keyPath = "/tmp/cert.key"

		log.Printf("Create CA key info...\n")
		ca, caPrivKey = createCACert()

		log.Printf("Create Certificate info...\n")
		certBytes, certKey = createCert(ca, caPrivKey)

		log.Printf("Save Certificate info...\n")
		saveCertificates(certPath, certBytes, keyPath, certKey)

		log.Printf("Self signed cert and key generation complete.\n")
	}

	return
}

// checkCertKeyValidity will validate the certpath and keypath files to determine if the input certificate and key is valid
func checkCertKeyValidity(certPath string, keyPath string) bool {
	if info, err := os.Stat(certPath); os.IsNotExist(err) || info.IsDir() {
		return false
	}

	if info, err := os.Stat(keyPath); os.IsNotExist(err) || info.IsDir() {
		return false
	}

	return true
}

// createCACert will generate a ca certificate and private key
func createCACert() (ca *x509.Certificate, caPrivKey *ecdsa.PrivateKey) {
	ca = &x509.Certificate{
		SerialNumber: big.NewInt(2019),
		Subject: pkix.Name{
			CommonName:         "reimage.ai",
			OrganizationalUnit: []string{"IT"},
			Organization:       []string{"ReImage"},
			Locality:           []string{"Minden"},
			Province:           []string{"Nevada"},
			Country:            []string{"US"},
		},
		NotBefore:             time.Now(),
		NotAfter:              time.Now().AddDate(10, 0, 0),
		IsCA:                  true,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
		BasicConstraintsValid: true,
	}

	caPrivKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		log.Printf("Failed to generate P256 private key: %s", err)
	}

	caBytes, err := x509.CreateCertificate(rand.Reader, ca, ca, &caPrivKey.PublicKey, caPrivKey)
	if err != nil {
		log.Printf("Failed to create P256 CA private key: %s", err)
	}

	caPEM := new(bytes.Buffer)
	pem.Encode(caPEM, &pem.Block{
		Type:  "CERTIFICATE",
		Bytes: caBytes,
	})

	caPrivKeyPEM := new(bytes.Buffer)
	caPrivBytes, err := x509.MarshalECPrivateKey(caPrivKey)
	if err != nil {
		log.Printf("Failed to Marshal EC Private key for CA: %s", err)
	}

	pem.Encode(caPrivKeyPEM, &pem.Block{
		Type:  "EC PRIVATE KEY",
		Bytes: caPrivBytes,
	})

	return
}

// createCert will create a certificate (in bytes) and private key and sign using a given CA
func createCert(ca *x509.Certificate, caPrivKey *ecdsa.PrivateKey) (certBytes []byte, privateKey *ecdsa.PrivateKey) {
	var hostIps []net.IP
	var hostnames []string
	domainName := "reimage.ai"
	hostIps = append(hostIps, net.IPv4(127, 0, 0, 1))
	hostnames = append(hostnames, "localhost")

	log.Printf("Generating new private key...\n")
	privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		log.Printf("Failed to generate P256 private key: %s", err)
	}

	log.Printf("Generating new serial number for certificate...\n")
	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
	if err != nil {
		log.Printf("failed to generate serial number: %s", err)
	}

	template := x509.Certificate{
		SerialNumber: serialNumber,
		Subject: pkix.Name{
			CommonName:   string(domainName),
			Organization: []string{domainName},
		},
		NotBefore:             time.Now(),
		NotAfter:              time.Now().AddDate(10, 0, 0),
		KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		BasicConstraintsValid: true,
		IPAddresses:           hostIps,
		DNSNames:              hostnames,
	}

	log.Printf("Creating certificate...\n")
	certBytes, err = x509.CreateCertificate(rand.Reader, &template, ca, privateKey.Public(), caPrivKey)
	if err != nil {
		log.Printf("Failed to create certificate: %v", err)
	}

	return
}

// saveCertificates will save the certificate bytes and certificate private keys into given certPath and keyPath files
func saveCertificates(certPath string, certBytes []byte, keyPath string, certKey *ecdsa.PrivateKey) {

	log.Printf("Writing out %s...\n", certPath)
	certOut, err := os.Create(certPath)
	if err != nil {
		log.Printf("Failed to open %s for writing: %s", certPath, err)
	}
	if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: certBytes}); err != nil {
		log.Printf("failed to write data to %s: %s", certPath, err)
	}
	if err := certOut.Close(); err != nil {
		log.Printf("error closing %s: %s", certPath, err)
	}

	log.Printf("Writing out %s...\n", keyPath)
	keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
	if err != nil {
		log.Printf("failed to open %s for writing: %v", keyPath, err)
		return
	}
	if err := pem.Encode(keyOut, pemBlockForKey(certKey)); err != nil {
		log.Printf("failed to write data to %s: %s", keyPath, err)
	}
	if err := keyOut.Close(); err != nil {
		log.Printf("error closing %s: %s", keyPath, err)
	}
}

// pemBlockForKey will return the pem block begin/end statements for a given private key
func pemBlockForKey(priv any) *pem.Block {
	switch k := priv.(type) {
	case *rsa.PrivateKey:
		return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}
	case *ecdsa.PrivateKey:
		b, err := x509.MarshalECPrivateKey(k)
		if err != nil {
			fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err)
			os.Exit(2)
		}
		return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b}
	default:
		return nil
	}
}

// setInputInt just helper function for setting parameters (string -> int)
func setInputInt(c *gin.Context, j *job.Job, paramsKey string, apiKey string, required bool, ignoreInvalid bool) error {
	var value string
	value = c.Query(apiKey)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(apiKey)
	}
	if value == "" {
		if required {
			err := fmt.Errorf("Missing required parameter %s", apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}
	intval, err := strconv.Atoi(value)
	if err != nil {
		if ignoreInvalid {
			log.Printf("Ignoring invalid query value: %v %v %v\n", paramsKey, value, err)
			return nil
		} else {
			log.Printf("Invalid query value: %v %v %v\n", paramsKey, value, err)
			abort(c, err, nil)
			return err
		}
	} else {
		j.Inputs[paramsKey] = intval
	}
	return nil
}

// setInputString just helper function for setting parameters (string -> int)
func setInputString(c *gin.Context, j *job.Job, paramsKey string, apiKey string, required bool) error {
	var value string
	value = c.Query(apiKey)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(apiKey)
	}
	if value == "" {
		if required {
			err := fmt.Errorf("Missing required parameter %s", apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}

	j.Inputs[paramsKey] = value
	return nil
}

// setInputFloat just helper function for setting parameters (string -> int)
func setInputFloat(c *gin.Context, j *job.Job, paramsKey string, apiKey string, required bool, ignoreInvalid bool) error {
	var value string
	value = c.Query(apiKey)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(apiKey)
	}
	if value == "" {
		if required {
			err := fmt.Errorf("Missing required parameter %s", apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}
	floatval, err := strconv.ParseFloat(value, 32)
	if err != nil {
		if ignoreInvalid {
			log.Printf("Ignoring invalid query value: %v %v %v\n", paramsKey, value, err)
			return nil
		} else {
			log.Printf("Invalid query value: %v %v %v\n", paramsKey, value, err)
			abort(c, err, nil)
			return err
		}
	} else {
		j.Inputs[paramsKey] = floatval
	}
	return nil
}

// setInputBool just helper function for setting parameters (string -> int)
func setInputBool(c *gin.Context, j *job.Job, paramsKey string, apiKey string, required bool, ignoreInvalid bool) error {
	var value string
	value = c.Query(apiKey)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(apiKey)
	}
	if value == "" {
		if required {
			err := fmt.Errorf("Missing required parameter %s", apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}
	valstr := strings.ToLower(value)
	switch valstr {
	case "1", "true":
		j.Inputs[paramsKey] = true
	case "0", "false":
		j.Inputs[paramsKey] = false
	default:
		if ignoreInvalid {
			log.Printf("Ignoring invalid query value: %v %v\n", paramsKey, value)
			return nil
		} else {
			log.Printf("Invalid query value: %v %v\n", paramsKey, value)
			err := fmt.Errorf("Invalid boolean value %v\n", value)
			abort(c, err, nil)
			return err
		}
	}
	return nil
}

// setInputJsonStringArray just helper function for setting parameters (string -> []string)
func setInputJsonStringArray(c *gin.Context, j *job.Job, paramsKey string, apiKey string, required bool, ignoreInvalid bool) error {
	var value string
	value = c.Query(apiKey)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(apiKey)
	}
	if value == "" {
		if required {
			err := fmt.Errorf("Missing required parameter %s", apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}
	var arr []string
	if value != "" {
		err := json.Unmarshal([]byte(value), &arr)
		if err != nil {
			if ignoreInvalid {
				log.Printf("Invalid json value: %v %v\n", paramsKey, value)
				return nil
			} else {
				jsonErr := fmt.Errorf("Invalid json received for parameter '%s'.", paramsKey)
				abort(c, jsonErr, nil)
				return jsonErr
			}
		}
		j.Inputs[paramsKey] = arr
	}
	return nil
}

// setInputJson2DFloatArray just helper function for setting parameters (string -> [][]float64)
func setInputJson2DFloatArray(c *gin.Context, j *job.Job, paramsKey string, apiKey string, size int, required bool, ignoreInvalid bool) error {
	var value string
	value = c.Query(apiKey)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(apiKey)
	}
	if value == "" {
		if required {
			err := fmt.Errorf("Missing required parameter %s", apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}
	var arr [][]float64
	if value != "" {
		err := json.Unmarshal([]byte(value), &arr)
		if err != nil {
			if ignoreInvalid {
				log.Printf("Invalid json value: %v %v\n", paramsKey, value)
				return nil
			} else {
				err = fmt.Errorf("Invalid json received for parameter '%s'.", paramsKey)
				abort(c, err, nil)
				return err
			}
		}
		var invalidSize bool
		if len(arr) == 0 {
			invalidSize = true
		} else if size != len(arr[0]) {
			invalidSize = true
		}
		if invalidSize {
			err = fmt.Errorf("Invalid dimensions for 2D array '%v' '%v'.", paramsKey, value)
			abort(c, err, nil)
			return err
		}
		j.Inputs[paramsKey] = arr
	}
	return nil
}

// setInputFile is a helper function for setting job inputs
func setInputFile(c *gin.Context, j *job.Job, inputsKey string, apiKey string, exten string, required bool) error {
	f, err := c.FormFile(apiKey)
	if err != nil || f == nil {
		if required {
			err := fmt.Errorf("Failed to upload %s file. \"%s\" file required.", apiKey, apiKey)
			abort(c, err, nil)
			return err
		} else {
			return nil
		}
	}
	fPath := fmt.Sprintf("%s/%s/%v-%s.%s", filesDir, j.Name, j.ID, inputsKey, exten)

	j.Inputs[inputsKey] = fPath
	if err := c.SaveUploadedFile(f, fPath); err != nil {
		abort(c, err, nil)
		return err
	}
	return nil
}

// validates that params contains one of the specified keys
func containsOneOfInputs(inputs map[string]any, keys []string) bool {
	for _, query := range keys {
		if _, ok := inputs[query]; ok {
			return true
		}
	}
	return false
}

// check if user is entitled, returns error if not
// requireTokens is the number of required tokens
// decrementTokens is the number of tokens that will be decremented from the account
func checkAccountEntitlement(c *gin.Context, tokenName string, requireTokens float64, decrementTokens float64) (error, string, bool) {
	authToken := c.GetHeader("authorization")
	if authToken == "" {
		log.Printf("Alert: Unauthorized account: No auth token. [%s] %s %s", c.FullPath(), clientInformation(c), tokenName)
		return fmt.Errorf("Login required - no auth token\n"), "", false
	}
	key := authToken

	accountProperties := firebase.GetAccountPropertiesByKey(key)
	if accountProperties == nil {
		log.Printf("Error: Invalid auth token (2): %v", authToken)
		return fmt.Errorf("Invalid auth token (2)\n"), "", false
	}
	if !slices.Contains(accountProperties.Keys, key) {
		log.Printf("Error: Invalid auth token (3): %v", authToken)
		return fmt.Errorf("Invalid auth token (3)\n"), "", false
	}

	if requireTokens > 0 {
		if !accountProperties.Invoicing && accountProperties.TokenCredits < requireTokens {
			log.Printf("Alert: Billing configuration error: %s", accountProperties.Email)
			return fmt.Errorf("Billing configuration error\n"), accountProperties.Email, false
		}
		rateOK := accountProperties.CheckRateLimit(requireTokens)
		if !rateOK {
			log.Printf("Alert: Rate Limit Exceeded: %s", accountProperties.Email)
			return fmt.Errorf("Rate Limit Exceeded\n"), accountProperties.Email, false
		}
	}
	if decrementTokens > 0 {
		accountProperties.IncrementTokenUsage(decrementTokens)
	}
	return nil, accountProperties.Email, true
}

// check if user is entitled, returns error if not
// requireTokens is the number of required tokens
// decrementTokens is the number of tokens that will be decremented from the account
func checkEntitlement(c *gin.Context, tokenName string, requireTokens float64, decrementTokens float64) (error, string, bool) {
	authToken := c.GetHeader("authorization")
	if authToken == "" {
		log.Printf("Alert: Unauthorized user: No auth token. [%s] %s %s", c.FullPath(), clientInformation(c), tokenName)
		return fmt.Errorf("Login required - no auth token\n"), "", false
	}

	// these are account API keys
	if len(authToken) == 32 {
		return checkAccountEntitlement(c, tokenName, requireTokens, decrementTokens)
	}

	// handling of email accounts
	var err error
	err, uid := firebase.UID(authToken)
	if err != nil {
		log.Printf("Alert: Unauthorized user: %v [%s] %s", err, c.FullPath(), clientInformation(c))
		return fmt.Errorf("Login required - invalid auth token\n"), uid, false
	}
	if uid == "" {
		log.Printf("Alert: Unauthorized user: No UID [%s] %s", c.FullPath(), clientInformation(c))
		return fmt.Errorf("Login required - invalid auth token\n"), uid, false
	}
	err, email := firebase.Email(authToken)
	if err != nil {
		log.Printf("Alert: Unauthorized user: %v [%s] %s", err, c.FullPath(), clientInformation(c))
		return fmt.Errorf("Login required - invalid auth token\n"), uid, false
	}
	if email == "" {
		log.Printf("Alert: Unauthorized user: No Email [%s] %s", c.FullPath(), clientInformation(c))
		return fmt.Errorf("Login required - invalid auth token\n"), uid, false
	}
	emailProperties := firebase.GetEmailProperties(email)
	if emailProperties == nil {
		log.Printf("Alert: Unauthorized user: No email properties [%s] %s", c.FullPath(), clientInformation(c))
		return fmt.Errorf("Login required - invalid auth token\n"), uid, false
	}
	if emailProperties.GetBannedPolicy() {
		log.Printf("Alert: Banned user: %s [%s] %s", email, c.FullPath(), clientInformation(c))
		return fmt.Errorf("You have been banned for violating terms and conditions.\n"), uid, false
	}
	if emailProperties.GetBannedTemp() {
		log.Printf("Alert: Temp banned user: %s [%s] %s", email, c.FullPath(), clientInformation(c))
		return fmt.Errorf("Unauthorized usage - please contact us.\n"), uid, false
	}
	pro := revenuecat.HasEntitlement(uid, tokenName)
	if pro {
		return nil, uid, true
	}

	// because email accounts only have whole tokens
	// we only account for calls that require a least a whole token
	// and we only decrement calls that require at least a whole token
	if requireTokens >= 1 {
		tokens := emailProperties.GetTokenCount(tokenName)
		if tokens < 1 {
			// this user does not have entitlement or tokens
			// the app should not allow the user to make requests in this scenario
			// lets flush the local RC and firebase cache and double check values
			revenuecat.ClearCache(uid)
			firebase.ClearEmailCache(email)
			pro = revenuecat.HasEntitlement(uid, tokenName)
			emailProperties := firebase.GetEmailProperties(email)
			if emailProperties != nil {
				tokens = emailProperties.GetTokenCount(tokenName)
			}

			if !pro && tokens < 1 {
				log.Printf("Alert: Unauthorized user: No %s tokens or %s entitlement. %s [%s] %s", tokenName, tokenName, email, c.FullPath(), clientInformation(c))
				return fmt.Errorf("No free generations or %s AI pro subscription found.\nPlease contact support@reimage.ai", tokenName), uid, false
			}
		}
		rateOK := emailProperties.CheckRateLimit(requireTokens)
		if !rateOK {
			log.Printf("Alert: Rate Limit Exceeded: %s [%s] %s", email, c.FullPath(), clientInformation(c))
			return fmt.Errorf("Rate Limit Exceeded\n"), uid, false
		}

	}
	if decrementTokens == 1 {
		emailProperties.DecrementTokenCount(tokenName)
		log.Printf("%s token used: %v [%v] (%v remain)\n", tokenName, email, uid, emailProperties.GetTokenCount(tokenName))
	}

	return nil, uid, false
}

// validate private API calls
func checkPrivateAPIAuth(c *gin.Context) error {
	if !strings.HasPrefix(c.Request.UserAgent(), "python-requests/") {
		log.Printf("Error: Invalid user-agent on private API: %v\n", c.Request.UserAgent())
		c.AbortWithStatus(403)
		return fmt.Errorf("invalid user-agent: %v", c.Request.UserAgent())
	}

	return nil

	// FIXME need real auth
	// also check header
	// authToken := c.GetHeader("authorization")
	// if authToken == "custom_test_5bf07f94" {
	// 	return nil
	// }

	// return fmt.Errorf("invalid auth token: %v", authToken)
}

// save a job to the disk
func saveJob(j *job.Job) {
	jobj, err := j.ToJson()
	if err != nil {
		log.Printf("Error serializing job: %s\n", err.Error())
		return
	}
	js, err := json.MarshalIndent(jobj, "", "  ")
	filename := fmt.Sprintf("%s/%s/%v.json", filesDir, j.Name, j.ID)
	if err != nil {
		log.Printf("Error serializing job: %s\n", err.Error())
		return
	} else {
		err = os.WriteFile(filename, js, 0644)
		if err != nil {
			log.Printf("Error saving job: %s\n", err.Error())
			return
		}
	}
}

// update FCM (firebase cloud messaging) tokens for this client
func updateFCMTokens(c *gin.Context) {

	authToken := c.GetHeader("authorization")
	if authToken == "" {
		//abort(c, fmt.Errorf("Invalid auth token"), "No auth token")
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "No auth token"})
		return
	}
	err, email := firebase.Email(authToken)
	if err != nil {
		//abort(c, fmt.Errorf("Invalid auth token"), "Invalid auth token")
		c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"message": "Invalid auth token"})
		return
	}
	if email == "" {
		abort(c, fmt.Errorf("Invalid auth token"), "nil email")
		return
	}
	emailProperties := firebase.GetEmailProperties(email)
	if emailProperties == nil {
		abort(c, fmt.Errorf("Invalid auth token"), "email not found")
		return
	}

	var newFcmToken = stringValue(c, "fcm_tokens")
	if newFcmToken == "" {
		newFcmToken = stringValue(c, "fcm_token")
	}
	// if newFcmToken == "" {
	// 	abort(c, fmt.Errorf("Invalid FCM token"), "blank")
	// }
	if len(newFcmToken) > 1000 {
		abort(c, fmt.Errorf("Invalid FCM token"), "too long")
	}

	var appName = stringValue(c, "app")

	var tokenName = computeTokenName(appName)
	err = emailProperties.UpdateFcmTokens(newFcmToken, tokenName)

	if err != nil {
		log.Printf("Error updating FCM token: %v", err)
		abort(c, fmt.Errorf("Failed to update FCM token"), nil)
		return
	}

	c.JSON(http.StatusOK, gin.H{"message": "FCM token updated successfully"})
}

// get the string value from the params or post form
func stringValue(c *gin.Context, param string) string {
	var value = c.Query(param)
	// if not in the URL params
	// check in the post form data
	if value == "" {
		value = c.PostForm(param)
	}
	return value
}

// compute the correct token name for a given job
func computeTokenName(appName string) string {
	appName = strings.ToLower(appName)
	if strings.Contains(appName, "paint") {
		return "paint"
	} else if strings.Contains(appName, "staging") {
		return "staging"
	} else if strings.Contains(appName, "roof") {
		return "roof"
	} else if strings.Contains(appName, "landscap") {
		return "landscaping"
	} else if strings.Contains(appName, "roomsy") {
		return "roomsy"
	} else if strings.Contains(appName, "remodel") {
		return "remodel"
	} else {
		// this actually happens frequently when the app is called
		// without specifying app as an optional param
		// just default to remodel
		// log.Printf("WARNING: Unknown app name: %v\n", appName)
		return "remodel"
	}
}

// returns client information for debugging
func clientInformation(c *gin.Context) string {
	app := stringValue(c, "app")
	if app == "" {
		app = "unspecified"
	}
	ver := stringValue(c, "version")
	if ver == "" {
		ver = "unspecified"
	}
	return fmt.Sprintf("(%v,%v)", app, ver)
}

// returns the name of the service worker
func workerName(c *gin.Context) string {
	var ident = stringValue(c, "ident")
	// if ident was specified by the requests
	// use the ident, otherwise fallback to IP
	if ident != "" {
		return ident
	} else {
		return c.ClientIP()
	}
}

// return the of the caller (if possible)
func getEmail(c *gin.Context) string {
	email := ""
	authToken := c.GetHeader("authorization")
	if len(authToken) == 32 {
		accountProperties := firebase.GetAccountPropertiesByKey(authToken)
		if accountProperties != nil {
			email = accountProperties.Email
		} else {
			email = "-"
		}
	} else {
		_, email = firebase.Email(authToken)
		if email == "" {
			email = "-"
		}
	}
	return email
}

// get the md5sum for an uploaded file
func md5sum(file *multipart.FileHeader) (string, error) {
	src, err := file.Open()
	if err != nil {
		return "", err
	}
	defer src.Close()

	hash := md5.New()
	if _, err := io.Copy(hash, src); err != nil {
		return "", err
	}
	return hex.EncodeToString(hash.Sum(nil)), nil
}

var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)")
var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])")

func camelCaseToSnakeCase(str string) string {
	snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}")
	snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}")
	snake = strings.ReplaceAll(snake, " ", "_")
	return strings.ToLower(snake)
}
