// SPDX-FileCopyrightText: 2021 Luke Granger-Brown <depot@lukegb.com>
//
// SPDX-License-Identifier: Apache-2.0

package fuphttp

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"mime"
	"net/http"
	"net/url"
	"path"
	"strings"
	"time"

	"github.com/gabriel-vasile/mimetype"
	"github.com/gorilla/mux"
	"gocloud.dev/blob"
	"hg.lukegb.com/lukegb/depot/web/fup/fuphttp/fngen"
)

// parseExpiry parses an expiry string.
// This is one of:
// 1) The empty string - this returns the zero time.
// 2) An explicit RFC3339 time.
// 3) A string accepted by ParseDuration.
func parseExpiry(expStr string) (time.Time, error) {
	if expStr == "" {
		return time.Time{}, nil
	}
	t, err := time.Parse(time.RFC3339, expStr)
	if err == nil {
		return t, nil
	}
	duration, err := time.ParseDuration(expStr)
	if err == nil {
		return time.Now().Add(duration), nil
	}
	return time.Time{}, fmt.Errorf("unable to parse %q as RFC3339 or Go duration", expStr)
}

// metaField returns the metadata field named by name.
// It tries the following, in order, returning the first one:
// 1) HTTP Header Fup-{name}
// 2) POST/PUT body parameter {name}
// 3) Query string parameter {name}
func metaField(r *http.Request, name string) string {
	v := r.Header.Get(fmt.Sprintf("Fup-%s", name))
	if v != "" {
		return v
	}

	return r.FormValue("expiry")
}

var (
	// knownArchives is a list of known non-archiving compression programs.
	// This list is mostly taken from https://www.gnu.org/software/tar/manual/html_node/gzip.html.
	knownArchives = map[string]bool{
		".gz":   true,
		".bz2":  true,
		".Z":    true,
		".lz":   true,
		".lzma": true,
		".tlz":  true,
		".lzo":  true,
		".xz":   true,
		".zst":  true,
	}
)

func splitExtOnce(fn string) (string, string) {
	ext := path.Ext(fn)
	return fn[:len(fn)-len(ext)], ext
}

// fileExt splits a filename into its "prefix" and its "file extension".
// It has special behaviour for known multipart extensions, like tar.gz.
func fileExt(fn string) (string, string) {
	firstPre, firstExt := splitExtOnce(fn)
	if !knownArchives[firstExt] {
		return firstPre, firstExt
	}
	secondPre, secondExt := splitExtOnce(firstPre)
	return secondPre, secondExt + firstExt
}

// UploadResponse is the JSON object returned when an upload completes.
type UploadResponse struct {
	URL        string     `json:"url"`
	DirectURL  string     `json:"direct_url"`
	DisplayURL string     `json:"display_url"`
	Filename   string     `json:"filename"`
	Expiry     *time.Time `json:"expiry"`
}

func (a *Application) upload(rw http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	meta := &Metadata{
		Attributes: &blob.Attributes{},
	}
	var err error
	meta.ExpiresAt, err = parseExpiry(metaField(r, "expiry"))
	if err != nil {
		log.Printf("upload: parsing expiry: %v", err)
		a.badRequest(rw, r, fmt.Errorf("parsing expiry: %v", err))
		return
	}

	var contentType string
	if r.Header.Get("Content-Type") != "" {
		contentType, _, err = mime.ParseMediaType(r.Header.Get("Content-Type"))
		if err != nil {
			log.Printf("upload: parsing content-type header: %v", err)
			a.badRequest(rw, r, fmt.Errorf("parsing content-type %q: %v", r.Header.Get("Content-Type"), err))
			return
		}
	}

	var rdr io.Reader = r.Body
	defer r.Body.Close()

	origContentType := contentType
	var origFilename string
	var origExt string

	if r.Method == "PUT" {
		vars := mux.Vars(r)
		origFilename, origExt = fileExt(vars["filename"])
	} else if contentType == "multipart/form-data" {
		file, hdrs, err := r.FormFile("file")
		if err != nil {
			log.Printf("upload: multipart: failed to get file: %v", err)
			a.badRequest(rw, r, fmt.Errorf("parsing multipart data: %v", err))
			return
		}
		rdr = file
		defer file.Close()

		origFilename, origExt = fileExt(hdrs.Filename)
		origContentType = hdrs.Header.Get("Content-Type")
	} else {
		if r.PostFormValue("content") == "" {
			log.Printf("upload: empty content")
			a.badRequest(rw, r, errors.New("empty content"))
			return
		}

		origContentType = ""
		origExt = r.PostFormValue("extension")
		if origExt == "" {
			origExt = ".txt"
		} else if origExt[0] != '.' {
			origExt = "." + origExt
		}

		rdr = strings.NewReader(r.PostFormValue("content"))
	}

	// Find a mime type for the uploaded file.
	mimeType := origContentType
	fileExt := origExt
	if mimeType == "" && origExt != "" {
		// Try from the file extension instead...
		mimeType = mime.TypeByExtension(origExt)
	}
	if mimeType == "" {
		// We'll need to sniff it...
		buf := make([]byte, 512)
		if _, err := r.Body.Read(buf); err != nil && !errors.Is(err, io.EOF) {
			log.Printf("upload: Read for MIME sniffing: %v", err)
			a.internalError(rw, r)
			return
		}

		m := mimetype.Detect(buf)
		mimeType = m.String()
		if fileExt == "" {
			fileExt = m.Extension()
		}

		rdr = io.MultiReader(bytes.NewReader(buf), rdr)
	}

	// Compute the new filename.
	newName, err := fngen.UniqueName(ctx, a.storageBackend.Exists, origFilename, fileExt, a.filenameGenerator)
	if err != nil {
		log.Printf("upload: UniqueName: %v", err)
		a.internalError(rw, r)
		return
	}

	// Now we build the upload options...
	meta.Attributes.ContentType = mimeType

	wctx, wcancel := context.WithCancel(ctx)
	defer wcancel()
	w, err := a.storageBackend.NewWriter(wctx, newName, meta.WriterOptions())
	if err != nil {
		log.Printf("upload: NewWriter(%q): %v", newName, err)
		return
	}

	if _, err := io.Copy(w, rdr); err != nil {
		log.Printf("upload: Copy for %q: %v", newName, err)
		wcancel()
		w.Close()
		a.internalError(rw, r)
		return
	}

	if err := w.Close(); err != nil {
		log.Printf("upload: Close(%q): %v", newName, err)
		a.internalError(rw, r)
		return
	}

	resp := UploadResponse{
		URL:       a.appURL(url.PathEscape(newName)),
		DirectURL: a.appURL("raw/" + url.PathEscape(newName)),
	}
	if a.useDirectDownload(newName, mimeType) {
		resp.DisplayURL = resp.DirectURL
	} else {
		resp.DisplayURL = resp.URL
	}
	resp.Filename = newName
	if !meta.ExpiresAt.IsZero() {
		resp.Expiry = &meta.ExpiresAt
	}

	// This is technically wrong: we don't implement content negotiation properly. Oh well.
	if strings.ToLower(r.Header.Get("Accept")) != "application/json" {
		http.Redirect(rw, r, resp.DisplayURL, http.StatusSeeOther)
		return
	}

	rw.Header().Set("Content-Type", "application/json; charset=utf-8")
	rw.WriteHeader(http.StatusCreated)

	if err := json.NewEncoder(rw).Encode(&resp); err != nil {
		log.Printf("upload: writing JSON response: %v", err)
		// It's too late to return a proper error :(
	}
}