package rushlink

import (
	"crypto/subtle"
	"fmt"
	"log"
	"mime/multipart"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"

	"gitea.hashru.nl/dsprenkels/rushlink/internal/db"
	"github.com/google/uuid"
	"github.com/gorilla/mux"
	"github.com/pkg/errors"
	bolt "go.etcd.io/bbolt"
)

type viewPaste uint

const (
	_ viewPaste = 1 << iota
	viewNoRedirect
	viewShowMeta
)

const cookieDeleteToken = "owner_token"

type canDelete uint

const (
	canDeleteUndef canDelete = iota
	canDeleteYes
	canDeleteNo
)

func (cd *canDelete) Bool() bool {
	return *cd == canDeleteYes
}

func (cd *canDelete) String() string {
	switch *cd {
	case canDeleteUndef:
		return "undefined"
	case canDeleteYes:
		return "correct"
	case canDeleteNo:
		return "invalid"
	default:
		panic("unreachable")
	}
}

func (rl *rushlink) staticGetHandler(w http.ResponseWriter, r *http.Request) {
	rl.renderStatic(w, r, mux.Vars(r)["path"])
}

func (rl *rushlink) indexGetHandler(w http.ResponseWriter, r *http.Request) {
	rl.render(w, r, "index", map[string]interface{}{})
}

func (rl *rushlink) viewPasteHandler(w http.ResponseWriter, r *http.Request) {
	rl.viewPasteHandlerFlags(w, r, 0)
}

func (rl *rushlink) viewPasteHandlerNoRedirect(w http.ResponseWriter, r *http.Request) {
	rl.viewPasteHandlerFlags(w, r, viewNoRedirect)
}

func (rl *rushlink) viewPasteHandlerMeta(w http.ResponseWriter, r *http.Request) {
	rl.viewPasteHandlerFlags(w, r, viewShowMeta)
}

func (rl *rushlink) viewPasteHandlerFlags(w http.ResponseWriter, r *http.Request, flags viewPaste) {
	vars := mux.Vars(r)
	key := vars["key"]
	var p *db.Paste
	var fu *db.FileUpload
	if err := rl.db.Bolt.View(func(tx *bolt.Tx) error {
		var err error
		p, err = db.GetPaste(tx, key)
		if err != nil {
			return err
		}
		if p != nil && p.Type == db.PasteTypeFileUpload {
			var id uuid.UUID
			copy(id[:], p.Content)
			fu, err = db.GetFileUpload(tx, id)
			if err != nil {
				return err
			}
		}
		return nil
	}); err != nil {
		panic(err)
	}

	if p == nil {
		rl.renderError(w, r, http.StatusNotFound, "url key not found in the database")
		return
	}

	rl.viewPasteHandlerInner(w, r, flags, p, fu)
}

func (rl *rushlink) viewPasteHandlerInner(w http.ResponseWriter, r *http.Request, flags viewPaste, p *db.Paste, fu *db.FileUpload) {
	if flags&viewShowMeta != 0 {
		rl.viewPasteHandlerInnerMeta(w, r, p, fu)
		return
	}

	switch p.State {
	case db.PasteStatePresent:
		switch p.Type {
		case db.PasteTypeFileUpload:
			if fu == nil {
				panic(fmt.Sprintf("file for id %v does not exist in database\n", string(p.Content)))
			}
			rl.viewFileUploadHandler(w, r, fu)
			return
		case db.PasteTypeRedirect:
			if flags&viewNoRedirect == 0 {
				http.Redirect(w, r, p.RedirectURL().String(), http.StatusSeeOther)
			}
			return
		default:
			panic("paste type unsupported")
		}

	case db.PasteStateDeleted:
		rl.renderError(w, r, http.StatusGone, "paste has been deleted\n")
		return
	default:
		panic(errors.Errorf("invalid paste.State (%v) for key '%v'", p.State, p.Key))
	}
}

func (rl *rushlink) viewFileUploadHandler(w http.ResponseWriter, r *http.Request, fu *db.FileUpload) {
	filePath := fu.Path(rl.fs)
	file, err := os.Open(filePath)
	if err != nil {
		if os.IsNotExist(err) {
			log.Printf("error: '%v' should exist according to the database, but it doesn't", filePath)
			rl.renderError(w, r, http.StatusNotFound, "file not found")
			return
		}
		// unexpected error
		panic(err)
	}

	var modtime time.Time
	info, err := file.Stat()
	if err != nil {
		log.Printf("error: %v", errors.Wrapf(err, "could not stat file '%v'", filePath))
	} else {
		modtime = info.ModTime()
	}

	// Provide the real filename to the client (to be used in Ctrl+S etc.)
	quotedName := strings.ReplaceAll(fu.FileName, "\"", "\\\"")
	w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", quotedName))

	// We use http.ServeContent (instead of http.ServeFile) because we cannot
	// use http.ServeFile together with the assertion that the file exists,
	// without introducing a TOCTOU flaw.
	http.ServeContent(w, r, fu.FileName, modtime, file)
}

func (rl *rushlink) viewPasteHandlerInnerMeta(w http.ResponseWriter, r *http.Request, p *db.Paste, fu *db.FileUpload) {
	var cd canDelete
	deleteToken := getDeleteTokenFromRequest(r)
	if deleteToken != "" {
		if subtle.ConstantTimeCompare([]byte(deleteToken), []byte(p.DeleteToken)) == 1 {
			cd = canDeleteYes
		} else {
			cd = canDeleteNo
		}
	}

	var fileExt string
	if fu != nil {
		fileExt = fu.Ext()
	}
	data := map[string]interface{}{
		"Paste":         p,
		"FileExt":       fileExt,
		"CanDelete":     cd,
		"CanDeleteBool": cd.Bool(),
	}
	rl.render(w, r, "pasteMeta", data)
	return
}

func (rl *rushlink) viewCreateSuccess(w http.ResponseWriter, r *http.Request, p *db.Paste, fu *db.FileUpload) {
	var fileExt string
	if fu != nil {
		fileExt = fu.Ext()
	}
	data := map[string]interface{}{
		"Paste":         p,
		"FileExt":       fileExt,
		"CanDelete":     canDeleteYes,
		"CanDeleteBool": true,
	}
	rl.render(w, r, "pasteMeta", data)
	return
}

func (rl *rushlink) newPasteHandler(w http.ResponseWriter, r *http.Request) {
	file, fileHeader, err := r.FormFile("file")
	if err == nil {
		rl.newFileUploadPasteHandler(w, r, file, *fileHeader)
		return
	} else if err == http.ErrMissingFile {
		// Fallthrough
	} else {
		msg := fmt.Sprintf("could not parse form: %v\n", err)
		rl.renderError(w, r, http.StatusBadRequest, msg)
		return
	}

	shorten := r.FormValue("shorten")
	if shorten != "" {
		rl.newRedirectPasteHandler(w, r, shorten)
		return
	}

	rl.renderError(w, r, http.StatusBadRequest, "no 'file' and no 'shorten' fields given in form\n")
}

func (rl *rushlink) newFileUploadPasteHandler(w http.ResponseWriter, r *http.Request, file multipart.File, header multipart.FileHeader) {
	var fu *db.FileUpload
	var paste *db.Paste
	if err := rl.db.Bolt.Update(func(tx *bolt.Tx) error {
		var err error
		fu, err = db.NewFileUpload(rl.fs, file, header.Filename)
		if err != nil {
			panic(errors.Wrap(err, "creating fileUpload"))
		}
		if err := fu.Save(tx); err != nil {
			panic(errors.Wrap(err, "saving fileUpload in db"))
		}

		paste, err = shortenFileUploadID(tx, fu.ID)
		return err
	}); err != nil {
		panic(err)
	}
	rl.viewCreateSuccess(w, r, paste, fu)
}

func (rl *rushlink) newPasteHandlerURLEncoded(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
	if err := r.ParseForm(); err != nil {
		next(w, r)
		return
	}
	shorten := r.PostFormValue("shorten")
	if shorten == "" {
		rl.renderError(w, r, http.StatusBadRequest, "no 'shorten' param given\n")
		return
	}
	rl.newRedirectPasteHandler(w, r, shorten)
}

func (rl *rushlink) newRedirectPasteHandler(w http.ResponseWriter, r *http.Request, rawurl string) {
	userURL, err := url.ParseRequestURI(rawurl)
	if err != nil {
		msg := fmt.Sprintf("invalid url (%v): %v", err, rawurl)
		rl.renderError(w, r, http.StatusBadRequest, msg)
		return
	}
	if userURL.Scheme == "" {
		rl.renderError(w, r, http.StatusBadRequest, "invalid url (unspecified scheme)\n")
		return
	}
	if userURL.Host == "" {
		rl.renderError(w, r, http.StatusBadRequest, "invalid url (unspecified host)\n")
		return
	}

	var paste *db.Paste
	if err := rl.db.Bolt.Update(func(tx *bolt.Tx) error {
		var err error
		paste, err = shortenURL(tx, userURL)
		return err
	}); err != nil {
		panic(err)
	}
	rl.viewCreateSuccess(w, r, paste, nil)
}

// Delete a URL from the database
func (rl *rushlink) deletePasteHandler(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	key := vars["key"]

	deleteToken := getDeleteTokenFromRequest(r)
	if deleteToken == "" {
		rl.renderError(w, r, http.StatusBadRequest, "no delete token provided\n")
		return
	}

	var errorCode int
	var paste *db.Paste
	if err := rl.db.Bolt.Update(func(tx *bolt.Tx) error {
		var err error
		paste, err = db.GetPaste(tx, key)
		if err != nil {
			errorCode = http.StatusNotFound
			return err
		}
		if paste.State == db.PasteStateDeleted {
			errorCode = http.StatusGone
			return errors.New("already deleted")
		}
		if subtle.ConstantTimeCompare([]byte(deleteToken), []byte(paste.DeleteToken)) == 0 {
			errorCode = http.StatusForbidden
			return errors.New("invalid delete token")
		}
		if err := paste.Delete(tx, rl.fs); err != nil {
			errorCode = http.StatusInternalServerError
			return err
		}
		return nil
	}); err != nil {
		log.Printf("error: %v\n", err)
		rl.renderError(w, r, errorCode, fmt.Sprintf("error: %v\n", err))
		return
	}
	rl.viewCreateSuccess(w, r, paste, nil)
}

// Add a new fileUpload redirect to the database
//
// Returns the new paste key if the fileUpload was successfully added to the
// database
func shortenFileUploadID(tx *bolt.Tx, id uuid.UUID) (*db.Paste, error) {
	return shorten(tx, db.PasteTypeFileUpload, id[:])
}

// Add a new URL to the database
//
// Returns the new paste key if the url was successfully shortened
func shortenURL(tx *bolt.Tx, userURL *url.URL) (*db.Paste, error) {
	return shorten(tx, db.PasteTypeRedirect, []byte(userURL.String()))
}

// Add a paste (of any kind) to the database with arbitrary content.
func shorten(tx *bolt.Tx, ty db.PasteType, content []byte) (*db.Paste, error) {
	// Generate the paste key
	pasteKey, err := db.GeneratePasteKey(tx)
	if err != nil {
		return nil, errors.Wrap(err, "generating paste key")
	}

	// Also generate a deleteToken
	deleteToken, err := db.GenerateDeleteToken()
	if err != nil {
		return nil, errors.Wrap(err, "generating delete token")
	}

	// Store the new key
	p := db.Paste{
		Type:        ty,
		State:       db.PasteStatePresent,
		Content:     content,
		Key:         pasteKey,
		DeleteToken: deleteToken,
		TimeCreated: time.Now().UTC(),
	}
	if err := p.Save(tx); err != nil {
		return nil, err
	}
	return &p, nil
}

func getDeleteTokenFromRequest(r *http.Request) string {
	return r.URL.Query().Get("deleteToken")
}