// Copyright (C) 2023-2024  Umorpha Systems
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"

	sd "git.lukeshu.com/go/libsystemd/sd_daemon"
)

// Main entrypoint /////////////////////////////////////////////////////////////

// Standard exit codes defined by [LSB].
//
// [LSB]: http://refspecs.linuxbase.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/iniscrptact.html
const (
	ExitFailure         = 1
	ExitInvalidArgument = 2
)

type ExitError struct {
	Err  error
	Code int
}

// Error implements the [error] interface.
func (e *ExitError) Error() string { return e.Err.Error() }

// Unwrap implements the interface for [errors.Unwrap].
func (e *ExitError) Unwrap() error { return e.Err }

// main is the function that is called by Go itself when the program
// starts.
func main() {
	if err := MainWithError(os.Args[1:]); err != nil {
		fmt.Fprintf(os.Stderr, "%s: error: %v\n", os.Args[0], err)
		var ecode *ExitError
		if errors.As(err, &ecode) {
			if ecode.Code == ExitInvalidArgument {
				fmt.Fprintf(os.Stderr, "Try '%s --help' for more information.\n", os.Args[0])
			}
			os.Exit(ecode.Code)
		}
		os.Exit(1)
	}
}

func usage(argparser *flag.FlagSet) {
	fmt.Printf(`Usage: %s [OPTIONS]
http daemon - the dynamic parts of the umorpha.io website

OPTIONS:
`, argparser.Name())
	argparser.SetOutput(os.Stdout)
	argparser.PrintDefaults()
}

// MAinWithError is the "main" function, but unlike the actual [main]
// function, it returns an error; this is a useful thing for 2 reasons:
//
//  1. Convenience/consistency: When you encounter an error in Go,
//     most of the time the correct thing to do is to return it.  If
//     you're writing code in a function that doesn't return an error,
//     then you can't do that, and it means you have to start doing
//     things that 90% of the time are bad practices.
//
//  2. I haven't written tests for avatard, but if I did: When writing
//     tests, it is often useful to have an exported "main" function
//     that does not call [os.Exit].
func MainWithError(argStrs []string) error {
	argparser := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
	argHelp := argparser.Bool("help",
		false, "show this help text")
	argSocket := argparser.String("socket",
		"tcp::8080", "the socket to listen on; a \"family:address\" pair, where the\n"+
			"\"family\" is one of \"tcp\", \"tcp4\", \"tcp6\", \"unix\", \"unixpacket\"\n"+
			"(the usual Go net.Listen() network names), or \"fd\" (to listen\n"+
			"on an already-open file descriptor).  The \"address\" part for\n"+
			"the \"fd\" family is one of\n"+
			"  1. a file descriptor number (or the special names \"stdin\",\n"+
			"     \"stdout\", or \"stderr\" to refer to 0, 1, or 2\n"+
			"     respectively),\n"+
			"  2. \"systemd:N\" to refer to the Nth file descriptor passed in\n"+
			"     via systemd (or \"systemd\" as shorthand for \"systemd:0\")\n"+
			"     (see sd_listend_fds(3)),\n"+
			"  3. \"systemd:NAME\" to refer to a named file descriptor passed\n"+
			"     in via systemd (see sd_listend_fds_with_names(3)).\n")

	argStaticDir := argparser.String("static-dir", "./static",
		"Where to find static files")
	argDataDir := argparser.String("data-dir", "./data",
		"Where to store uploads")

	argparser.SetOutput(io.Discard)
	err := argparser.Parse(argStrs)
	if *argHelp {
		usage(argparser)
		return nil
	}
	if err != nil {
		return &ExitError{
			Err:  err,
			Code: ExitInvalidArgument,
		}
	}
	if argparser.NArg() > 0 {
		return &ExitError{
			Err:  fmt.Errorf("unexpected positional arguments: %q", argparser.Args()),
			Code: ExitInvalidArgument,
		}
	}

	stype, saddr, ok := strings.Cut(*argSocket, ":")
	if !ok {
		return &ExitError{
			Err:  fmt.Errorf("invalid address: %q", *argSocket),
			Code: ExitInvalidArgument,
		}
	}

	sock, err := netListen(stype, saddr)
	if err != nil {
		return err

	}

	srv := &server{
		DataDir:     *argDataDir,
		UploadTypes: map[string]struct{}{},
	}

	router := http.NewServeMux()
	router.Handle("/", http.FileServer(http.Dir(*argStaticDir)))
	router.Handle("/api/webflow/form", HTTPHandler(srv.ServeForm))
	router.Handle("/api/webflow/sign-file", HTTPHandler(srv.ServeSignFile))

	log.Printf("Serving on %v...", sock.Addr())
	return http.Serve(sock, router)
}

// netListen is like [net.Listen], but with support for our special
// "fd" address family.
//
// The purpose of doing this is that for development I really want to
// be able to have it listen on a TCP port, but for actual deployment
// I want it to be able to accept an already-opened socket from
// systemd, and so I wrote a reasonably genereral solution.
//
// Did I avoid unnecessary complexity writing special-purpose code, or
// did I introduce it by failing to remember that YAGNI?
func netListen(stype, saddr string) (net.Listener, error) {
	switch stype {
	case "fd":
		switch saddr {
		case "stdin", "0":
			return net.FileListener(os.Stdin)
		case "stdout", "1":
			return net.FileListener(os.Stdout)
		case "stderr", "2":
			return net.FileListener(os.Stderr)
		case "systemd":
			sdFds := sd.ListenFds(true)
			if len(sdFds) == 0 {
				return nil, fmt.Errorf("fd:systemd given, but no systemd file descriptors passed in")
			}
			return net.FileListener(sdFds[0])
		default:
			if fd, _ := strconv.Atoi(saddr); fd > 0 {
				return net.FileListener(os.NewFile(uintptr(fd), fmt.Sprintf("/dev/fd/%d", fd)))
			}
			if name, ok := strings.CutPrefix(saddr, "systemd:"); ok {
				sdFiles := sd.ListenFds(true)
				for _, file := range sdFiles {
					if file.Name() == name {
						return net.FileListener(file)
					}
				}
				if n, err := strconv.Atoi(name); err == nil && n >= 0 && n < len(sdFiles) {
					return net.FileListener(sdFiles[n])
				}
				return nil, fmt.Errorf("does not match any systemd file descriptor: %q", name)
			}
			return nil, fmt.Errorf("invalid file descriptor name: %q", saddr)
		}
	default:
		return net.Listen(stype, saddr)
	}
}

type HTTPError struct {
	Err  error
	Code int
}

// Error implements the [error] interface.
func (e *HTTPError) Error() string { return fmt.Sprintf("HTTP %d: %v", e.Code, e.Err) }

// Unwrap implements the interface for [errors.Unwrap].
func (e *HTTPError) Unwrap() error { return e.Err }

const httpStatusWriteError = -100

func HTTPHandler(fn func(w http.ResponseWriter, r *http.Request) *HTTPError) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		if err := fn(w, r); err != nil {
			if err.Code != httpStatusWriteError {
				w.WriteHeader(err.Code)
				_ = json.NewEncoder(w).Encode(map[string]any{
					"msg":  err.Err.Error(),
					"code": err.Code,
				})
			}
			if err.Code/100 == 5 {
				log.Printf("error: %q: %v", r.URL, err)
			}
		}
	})
}

type server struct {
	DataDir       string
	UploadTypes   map[string]struct{}
	UploadMaxSize int
}

func (o *server) ServeForm(w http.ResponseWriter, r *http.Request) *HTTPError {
	if r.Method != http.MethodPost {
		return &HTTPError{fmt.Errorf("only POST is supported"), http.StatusMethodNotAllowed}
	}
	if err := r.ParseForm(); err != nil {
		return &HTTPError{fmt.Errorf("could not parse POST form: %w", err), http.StatusBadRequest}
	}

	dat := r.PostForm

	ts := time.Now().Unix()
	if err := os.MkdirAll(filepath.Join(o.DataDir, "form"), 0777); err != nil {
		return &HTTPError{err, http.StatusInternalServerError}
	}
	file, err := os.CreateTemp(filepath.Join(o.DataDir, "form"), fmt.Sprintf("form-%v-*.json", ts))
	if err != nil {
		return &HTTPError{err, http.StatusInternalServerError}
	}
	if err := json.NewEncoder(file).Encode(dat); err != nil {
		_ = file.Close()
		return &HTTPError{err, http.StatusInternalServerError}
	}
	if err := file.Close(); err != nil {
		return &HTTPError{err, http.StatusInternalServerError}
	}
	if err := json.NewEncoder(w).Encode(map[string]any{
		"msg":  "ok",
		"code": http.StatusOK,
	}); err != nil {
		return &HTTPError{err, httpStatusWriteError}
	}
	return nil
}

func (o *server) ServeSignFile(w http.ResponseWriter, r *http.Request) *HTTPError {
	if r.Method != http.MethodGet {
		return &HTTPError{fmt.Errorf("only GET is supported"), http.StatusMethodNotAllowed}
	}
	name := r.FormValue("name")
	size, err := strconv.Atoi(r.FormValue("size"))
	if err != nil {
		return &HTTPError{fmt.Errorf("could not parse size=%q: %w", r.FormValue("size"), err), http.StatusBadRequest}
	}

	if _, ok := o.UploadTypes[filepath.Ext(name)]; !ok {
		types := make([]string, 0, len(o.UploadTypes))
		for typ := range o.UploadTypes {
			types = append(types, typ)
		}
		sort.Strings(types)
		return &HTTPError{fmt.Errorf("InvalidFileTypeError: disallowed file type %q, allowed types are: %q", filepath.Ext(name), types), http.StatusBadRequest}
	}
	if size > o.UploadMaxSize {
		return &HTTPError{fmt.Errorf("MaxFileSizeError: %v > %v", size, o.UploadMaxSize), http.StatusBadRequest}
	}

	const TODO = "1234"
	if err := json.NewEncoder(w).Encode(map[string]any{
		"fileId": TODO, // .attr("data-value") gets set to this, but idk the implications of that

		// how to upload the file
		"s3url": "https://umorpha.io/api/webflow/file", // where to POST to
		"postData": map[string]any{ // what to POST with (in addition to the "file" value
			"authorization": TODO,
		},
		"fileName": filepath.Base(name), // what filename to set for the "file" value
	}); err != nil {
		return &HTTPError{err, httpStatusWriteError}
	}
	return nil
}
