package main

import (
	"errors"
	"flag"
	"fmt"
	"image"
	"io"
	"log"
	"math/rand"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"time"

	"github.com/OneOfOne/xxhash"
	"github.com/disintegration/imaging"
	"github.com/kouhin/envflag"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
	etag "github.com/pablor21/echo-etag/v4"

	"image/color"
	"image/jpeg"
	_ "image/jpeg"
)

type tagSize struct {
	x int
	y int
}

var frameCount = 1337
var frameFiles = []string{}
var reSampler = imaging.Lanczos

func hashFile(fileName string) uint64 {
	h := xxhash.New64()
	f, err := os.Open(fileName)
	if err != nil {
		log.Fatal(err)
	}
	defer f.Close()
	io.Copy(h, f)
	return h.Sum64()
}

func imageToNRGBA(input image.Image) image.NRGBA {
	out := image.NewNRGBA(input.Bounds())
	for x := 0; x < input.Bounds().Dx(); x++ {
		for y := 0; y < input.Bounds().Dy(); y++ {
			out.Set(x, y, input.At(x, y))
		}
	}
	return *out

}

func NRGBAToImage(input image.NRGBA) image.Image {
	out := image.NewRGBA(input.Bounds())
	for x := 0; x < input.Bounds().Dx(); x++ {
		for y := 0; y < input.Bounds().Dy(); y++ {
			out.Set(x, y, input.At(x, y))
		}
	}
	return out
}

func autoCropLR(input image.NRGBA) image.NRGBA {
	notOneColorLine := false
	LeftColumn := 0
	RightColumn := input.Bounds().Dy()
	for x := 0; x < input.Bounds().Dx(); x++ {
		startingColour := input.At(x, 0)
		for y := 0; y < input.Bounds().Dy(); y++ {
			if input.At(x, y) != startingColour {
				notOneColorLine = true
				log.Printf("Autocrop found first non-identical line at %d ", x)
				break
			}
		}
		if notOneColorLine {
			LeftColumn = x
			break
		}
	}
	notOneColorLine = false
	for x := input.Bounds().Dx(); x >= 0; x-- {
		startingColour := input.At(x, 0)
		for y := 0; y < input.Bounds().Dy(); y++ {
			if input.At(x, y) != startingColour {
				notOneColorLine = true
				log.Printf("Autocrop found last non-identical line at %d ", x)
				break
			}
		}
		if notOneColorLine {
			RightColumn = x
			break
		}
	}
	rect := image.Rect(LeftColumn, 0, RightColumn, input.Bounds().Dy())
	log.Printf("Autocrop used rect %d,%d,%d,%d ", LeftColumn, 0, RightColumn, input.Bounds().Dy())
	return *imaging.Crop(input.SubImage(input.Bounds()), rect)
}

func autoCropTB(input image.NRGBA) image.NRGBA {
	notOneColorLine := false
	TopColumn := 0
	BottomColumn := input.Bounds().Dx()
	for y := 0; y < input.Bounds().Dy(); y++ {
		startingColour := input.At(y, 0)
		for x := 0; x < input.Bounds().Dx(); x++ {
			if input.At(x, y) != startingColour {
				notOneColorLine = true
				log.Printf("Autocrop found first non-identical line at %d ", y)
				break
			}
		}
		if notOneColorLine {
			TopColumn = y
			break
		}
	}
	notOneColorLine = false
	for y := input.Bounds().Dy(); y >= 0; y-- {
		startingColour := input.At(y, 0)
		for x := 0; x < input.Bounds().Dx(); x++ {
			if input.At(x, y) != startingColour {
				notOneColorLine = true
				log.Printf("Autocrop found last non-identical line at %d ", y)
				break
			}
		}
		if notOneColorLine {
			BottomColumn = y
			break
		}
	}
	rect := image.Rect(0, TopColumn, input.Bounds().Dx(), BottomColumn)
	log.Printf("Autocrop used rect %d,%d,%d,%d ", 0, TopColumn, input.Bounds().Dx(), BottomColumn)
	return *imaging.Crop(input.SubImage(input.Bounds()), rect)
}

func autoCrop(input image.NRGBA, size tagSize, cropType string) image.NRGBA {
	if cropType == "LR" {
		return autoCropLR(input)
	} else if cropType == "TB" {
		return autoCropTB(input)
	} else if cropType == "requestSize" {
		if size.x > size.y {
			return autoCropLR(input)
		} else if size.x < size.y {
			return autoCropTB(input)
		}
	}
	return autoCropTB(autoCropLR(input))
}

func resizeImageFile(fileName string, size tagSize, bwBack bool, outDir string, cropType string) string {
	reader, err := os.Open(fileName)
	if err != nil {
		log.Fatal(err)
	}
	defer reader.Close()
	im, _, err := image.Decode(reader)
	if err != nil {
		log.Fatal(err)
	}
	var file *os.File
	if outDir == "%TEMP%" {
		// temp dir cannot be reliably cached, we should just continue as if we have to recreate the file.
		file, err = os.CreateTemp("", "resized") // empty string means create file in temporary directory.
		if err != nil {
			log.Fatal(err)
		}
	} else {
		outpath := filepath.Join(outDir, strconv.Itoa(size.x)+"x"+strconv.Itoa(size.y))
		if err != nil {
			log.Fatal(err)
		}
		err := os.MkdirAll(outpath, os.ModePerm)
		if err != nil {
			log.Fatal(err)
		}
		outFileName := filepath.Join(outpath, filepath.Base(fileName))
		if _, err = os.Stat(outFileName); !errors.Is(err, os.ErrNotExist) {
			// file exists (silly go has no erros.IsNot)
			log.Printf("File `%s` exists, not doing image manipulation stuff.", outFileName)
			return outFileName
		}
		file, err = os.Create(outFileName)
		if err != nil {
			log.Fatal(err)
		}
	}
	defer file.Close()
	croppedImage := autoCrop(imageToNRGBA(im), size, cropType)
	var resizedImage image.NRGBA
	if (croppedImage.Bounds().Dx() < size.x) && (croppedImage.Bounds().Dy() < size.y) {
		log.Printf("Image is smaller in both dimentions than requested (image %dx%d, requested %d,%d), scaling up", croppedImage.Bounds().Dx(), croppedImage.Bounds().Dy(), size.x, size.y)
		if croppedImage.Bounds().Dx() > croppedImage.Bounds().Dy() {
			resizedImage = *imaging.Resize(NRGBAToImage(croppedImage), size.x, 0, reSampler)
		} else {
			resizedImage = *imaging.Resize(NRGBAToImage(croppedImage), 0, size.y, reSampler)
		}
	} else {
		log.Printf("Image is bigger or perfect in at least one dimention than requested (image %dx%d, requested %d,%d), fitting (scale down with ratio preserve)", croppedImage.Bounds().Dx(), croppedImage.Bounds().Dy(), size.x, size.y)
		resizedImage = *imaging.Fit(NRGBAToImage(croppedImage), size.x, size.y, reSampler)
	}
	log.Printf("Deciding if to center (image %dx%d, requested %d,%d)", resizedImage.Bounds().Dx(), resizedImage.Bounds().Dy(), size.x, size.y)
	if (resizedImage.Bounds().Dx() < size.x) || (resizedImage.Bounds().Dy() < size.y) {
		if resizedImage.Bounds().Dx() == size.x {
			log.Printf("Centering frame in Y (%d == %d so therefore %d must be < %d)", resizedImage.Bounds().Dx(), size.x, resizedImage.Bounds().Dy(), size.y)
		} else {
			log.Printf("Centering frame in Y (%d != %d so therefore %d must be < %d)", resizedImage.Bounds().Dx(), size.x, resizedImage.Bounds().Dx(), size.x)
		}

		upLeft := image.Point{0, 0}
		lowRight := image.Point{size.x, size.y}
		emptyImage := image.NewRGBA(image.Rectangle{upLeft, lowRight})
		fillColour := resizedImage.At(0, 0)
		topLeftRed, topLeftGreen, topLeftBlue, _ := fillColour.RGBA()
		luminance, _, _ := color.RGBToYCbCr(uint8(topLeftRed), uint8(topLeftGreen), uint8(topLeftBlue))
		if bwBack {
			fillColour = color.White
			if luminance < 128 {
				fillColour = color.Black
			}
		}
		for x := 0; x < size.x; x++ {
			for y := 0; y < size.y; y++ {
				emptyImage.Set(x, y, fillColour)
				//emptyImage.Set(x, y, color.RGBA{255, 0, 0, 255})
			}
		}
		point := image.Point{0, 0}
		if resizedImage.Bounds().Dx() == size.x {
			point.Y = (size.y - resizedImage.Bounds().Dy()) / 2
		} else {
			point.X = (size.x - resizedImage.Bounds().Dx()) / 2
		}
		outputImage := imaging.Overlay(emptyImage, NRGBAToImage(resizedImage), point, 1)
		resizedImage = *outputImage
	}
	if err = jpeg.Encode(file, NRGBAToImage(resizedImage), nil); err != nil {
		log.Printf("failed to encode: %v", err)
	}
	if err != nil {
		log.Fatal(err)
	}
	return file.Name()
}

func randomNumberOfTheDay() int {
	epoch := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
	today := time.Now().Truncate(24 * time.Hour)
	daysSinceEpoch := today.Sub(epoch)
	log.Printf("Days since 2000-01-01: %d", daysSinceEpoch)
	r := rand.New(rand.NewSource(int64(daysSinceEpoch + 1)))
	return r.Intn(frameCount-1) + 1
}

func rfc2616Midnight() string {
	midnight := time.Now().Truncate(24 * time.Hour)
	return midnight.Format("Mon, 2 Jan 2006 15:04:05 MST")
}

func HeadToGetMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		if c.Request().Method == http.MethodHead {
			// Set the method to GET temporarily to reuse the handler
			c.Request().Method = http.MethodGet

			defer func() {
				c.Request().Method = http.MethodHead
			}() // Restore method after

			// Call the next handler and then clear the response body
			if err := next(c); err != nil {
				if err.Error() == echo.ErrMethodNotAllowed.Error() {
					fullResponseHeaders := c.Response().Header()
					log.Printf("headers set: %v", fullResponseHeaders)
					for k, v := range fullResponseHeaders {
						c.Response().Header().Del(k)
						log.Printf("header: %s (%d): %v", k, len(v), v)
						for _, vv := range v {
							log.Printf("value: %s", vv)
							c.Response().Header().Add(k, vv)
						}
					}
					ret := c.String(http.StatusOK, "HEAD success")
					return ret
				}

				return err
			}
		}

		return next(c)
	}
}

func OncePerDayPlease(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		c.Response().Before(func() {
			c.Response().Header().Del("Last-Modified")
			c.Response().Header().Add("Last-Modified", rfc2616Midnight())
		})

		if err := next(c); err != nil { //exec main process
			c.Error(err)
		}
		return nil
	}
}

func responder(c echo.Context, size tagSize, dataDir *string, outDir *string, bwBack *bool, cropType string) error {
	frameNumber := randomNumberOfTheDay()
	//frameNumber = rand.Intn(frameCount-1) + 1
	log.Printf("Frame number: %d", frameNumber)
	outFileName := resizeImageFile(filepath.Join(*dataDir, frameFiles[frameNumber]), size, *bwBack, *outDir, cropType)
	log.Printf("Serving: %v", outFileName)
	ret := c.File(outFileName)
	if *outDir == "%TEMP%" {
		defer os.Remove(outFileName)
	}
	hash := hashFile(outFileName)
	log.Printf("Setting ETag to: %-20d", hash)
	c.Response().Header().Del("ETag")
	c.Response().Header().Add("ETag", fmt.Sprintf("%-20d", hash))

	return ret
}

func parseSize(c echo.Context, maxWidth int, maxHeight int) tagSize {
	x, err := strconv.ParseUint(c.Param("x"), 10, 32)
	if err != nil {
		panic(err)
	}
	y, err := strconv.ParseUint(c.Param("y"), 10, 32)
	if err != nil {
		panic(err)
	}
	if int(x) > maxWidth {
		panic(errors.New(fmt.Sprintf("Requested X (%d) larger than maximum (%d)", x, maxWidth)))
	}
	if int(y) > maxHeight {
		panic(errors.New(fmt.Sprintf("Requested Y (%d) larger than maximum (%d)", y, maxHeight)))
	}
	return tagSize{int(x), int(y)}
}

func main() {
	var (
		dataDir       = flag.String("data-dir", filepath.Join(".", "kodata", "frames"), "Data directory, where the images are")
		outDir        = flag.String("output-dir", filepath.Join(".", "output"), "Output directory (used as cache) for resized files - set to `%TEMP%` to disable caching and use temp dir, default is DataDir/output")
		bwBack        = flag.Bool("black-white-back", true, "Fill background with black or white (whichever is closest) - good for eInk, if false, uses top-left pixel of image's colour")
		listenPort    = flag.Int("listen-port", 8080, "Port to listen on")
		listenAddr    = flag.String("listen-addr", "127.0.0.1", "Address to listen on")
		defaultWidth  = flag.Int("default-width", 296, "Default width for /tag endpoint")
		defaultHeight = flag.Int("default-height", 128, "Default height for /tag endpoint")
		maxWidth      = flag.Int("max-width", 1000, "max width for /tag/x/y endpoint")
		maxHeight     = flag.Int("max-height", 1000, "max height for /tag/x/y endpoint")
		autoCropType  = flag.String("autocrop-type", "full", "Type of autocropping to apply (valid options: `full`,`LR`,`TB`,`requestSize`) - Full Autocrop, Left/Right, Top/Bottom and By Request Size, which means landscape will autocrop L/R, portrait T/B and square full")
		resizerType   = flag.String("resizer-type", "full", "Type of resampling to apply (valid options: `Lanczos`,`CatmullRom`,`MitchellNetravali`,`Linear`,`Box`,`NearestNeighbor`) https://pkg.go.dev/github.com/disintegration/imaging#ResampleFilter for details")
	)
	if err := envflag.Parse(); err != nil {
		panic(err)
	}
	var validAutoCropType = false
	for _, t := range [5]string{"full", "LR", "TB", "requestSize"} {
		if *autoCropType == t {
			validAutoCropType = true
		}
	}
	if !validAutoCropType {
		*autoCropType = "full"
	}
	switch *resizerType {
	case "Lanczos":
		reSampler = imaging.Lanczos
	case "CatmullRom":
		reSampler = imaging.CatmullRom
	case "MitchellNetravali":
		reSampler = imaging.MitchellNetravali
	case "Linear":
		reSampler = imaging.Linear
	case "Box":
		reSampler = imaging.Box
	case "NearestNeighbor":
		reSampler = imaging.NearestNeighbor
	case "BSpline":
		reSampler = imaging.BSpline
	case "Bartlett":
		reSampler = imaging.Bartlett
	case "Cosine":
		reSampler = imaging.Cosine
	case "Hamming":
		reSampler = imaging.Hamming
	case "Hann":
		reSampler = imaging.Hann
	case "Hermite":
		reSampler = imaging.Hermite
	case "Welch":
		reSampler = imaging.Welch
	default:
		reSampler = imaging.Lanczos
	}
	var defaultSize = tagSize{*defaultWidth, *defaultHeight}

	entries, err := os.ReadDir(*dataDir)
	if err != nil {
		log.Fatal(err)
	}

	for _, e := range entries {
		frameFiles = append(frameFiles, e.Name())
	}
	frameCount = len(frameFiles)
	log.Printf("Loaded %d frames from %s", frameCount, *dataDir)
	if len(frameFiles) <= 0 {
		panic("Looks like we can't find any files to display!")
	}

	e := echo.New()
	e.Use(OncePerDayPlease)
	e.Use(etag.Etag())
	e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
		StackSize: 1 << 10, // 1 KB
		LogLevel:  1,
	}))
	e.GET("/", func(c echo.Context) error {
		return c.String(http.StatusOK, "Hello, World!")
	})
	e.GET("/tag", func(c echo.Context) error {
		return responder(c, defaultSize, dataDir, outDir, bwBack, *autoCropType)
	})
	e.HEAD("/tag", func(c echo.Context) error {
		return responder(c, defaultSize, dataDir, outDir, bwBack, *autoCropType)
	})
	e.GET("/tag/:x/:y", func(c echo.Context) error {
		size := parseSize(c, *maxWidth, *maxHeight)
		return responder(c, size, dataDir, outDir, bwBack, *autoCropType)
	})
	e.HEAD("/tag/:x/:y", func(c echo.Context) error {
		size := parseSize(c, *maxWidth, *maxHeight)
		return responder(c, size, dataDir, outDir, bwBack, *autoCropType)
	})
	e.Logger.Fatal(e.Start(*listenAddr + ":" + strconv.Itoa(*listenPort)))
}
