// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package files

import (
	"errors"
	"fmt"
	"io"
	"math"
	"os"
	"strconv"
	"time"

	"code.vikunja.io/api/pkg/config"
	"code.vikunja.io/api/pkg/db"
	"code.vikunja.io/api/pkg/log"
	"code.vikunja.io/api/pkg/metrics"
	"code.vikunja.io/api/pkg/modules/keyvalue"

	"code.vikunja.io/api/pkg/web"
	"github.com/c2h5oh/datasize"
	"github.com/gabriel-vasile/mimetype"
	"xorm.io/xorm"
)

// File holds all information about a file
type File struct {
	ID   int64  `xorm:"bigint autoincr not null unique pk" json:"id"`
	Name string `xorm:"text not null" json:"name"`
	Mime string `xorm:"text null" json:"mime"`
	Size uint64 `xorm:"bigint not null" json:"size"`

	Created     time.Time `xorm:"created" json:"created"`
	CreatedByID int64     `xorm:"bigint not null" json:"-"`

	File io.ReadCloser `xorm:"-" json:"-"`
	// This ReadCloser is only used for migration purposes. Use with care!
	// There is currentlc no better way of doing this.
	FileContent []byte `xorm:"-" json:"-"`
}

// TableName is the table name for the files table
func (*File) TableName() string {
	return "files"
}

func (f *File) fileID() string {
	return strconv.FormatInt(f.ID, 10)
}

// LoadFileByID returns a file by its ID
func (f *File) LoadFileByID() (err error) {
	f.File, err = storage.Open(f.fileID())
	return
}

// LoadFileMetaByID loads everything about a file without loading the actual file
func (f *File) LoadFileMetaByID() (err error) {
	exists, err := x.Where("id = ?", f.ID).Get(f)
	if !exists {
		return ErrFileDoesNotExist{FileID: f.ID}
	}
	return
}

// Create creates a new file from an FileHeader
func Create(f io.ReadSeeker, realname string, realsize uint64, a web.Auth) (file *File, err error) {
	mime, err := mimetype.DetectReader(f)
	if err != nil {
		return nil, fmt.Errorf("failed to detect mime type: %w", err)
	}
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		return nil, fmt.Errorf("failed to seek after mime detection: %w", err)
	}
	return CreateWithMime(f, realname, realsize, a, mime.String())
}

// CreateWithSession creates a new file using an existing session to avoid nested transactions
func CreateWithSession(s *xorm.Session, f io.ReadSeeker, realname string, realsize uint64, a web.Auth) (file *File, err error) {
	mime, err := mimetype.DetectReader(f)
	if err != nil {
		return nil, fmt.Errorf("failed to detect mime type: %w", err)
	}
	if _, err := f.Seek(0, io.SeekStart); err != nil {
		return nil, fmt.Errorf("failed to seek after mime detection: %w", err)
	}
	return CreateWithMimeAndSession(s, f, realname, realsize, a, mime.String(), true)
}

// CreateWithMime creates a new file from an FileHeader and sets its mime type
func CreateWithMime(f io.ReadSeeker, realname string, realsize uint64, a web.Auth, mime string) (file *File, err error) {
	s := db.NewSession()
	defer s.Close()

	file, err = CreateWithMimeAndSession(s, f, realname, realsize, a, mime, true)
	if err != nil {
		_ = s.Rollback()
		return
	}

	return file, s.Commit()
}

// measureReaderSize returns the byte length of r and leaves r seeked to
// 0, so the size we check matches what storage backends will write
// (they also Seek(0, io.SeekStart) before reading).
func measureReaderSize(r io.ReadSeeker) (uint64, error) {
	if _, err := r.Seek(0, io.SeekStart); err != nil {
		return 0, err
	}
	end, err := r.Seek(0, io.SeekEnd)
	if err != nil {
		return 0, err
	}
	if _, err := r.Seek(0, io.SeekStart); err != nil {
		return 0, err
	}
	if end < 0 {
		return 0, fmt.Errorf("reader end %d is negative", end)
	}
	return uint64(end), nil
}

func CreateWithMimeAndSession(s *xorm.Session, f io.ReadSeeker, realname string, realsize uint64, a web.Auth, mime string, checkFileSizeLimit bool) (file *File, err error) {
	// Authoritative size comes from the reader, not the caller: the
	// migration import path accepts attacker-controlled metadata
	// (GHSA-qh78-rvg3-cv54) and several other callers pass stale values.
	measured, mErr := measureReaderSize(f)
	if mErr != nil {
		return nil, fmt.Errorf("failed to measure file size: %w", mErr)
	}

	if checkFileSizeLimit {
		// Overflow-safe: exabyte-range configs would wrap uint64.
		maxMB := config.GetMaxFileSizeInMBytes()
		var maxBytes uint64
		if maxMB > math.MaxUint64/uint64(datasize.MB) {
			maxBytes = math.MaxUint64
		} else {
			maxBytes = maxMB * uint64(datasize.MB)
		}
		if measured > maxBytes {
			return nil, ErrFileIsTooLarge{Size: measured}
		}
	}

	// Surface buggy callers that lie about size.
	if realsize != 0 && realsize != measured {
		log.Debugf("files.Create: caller-supplied size %d does not match measured size %d for %q",
			realsize, measured, realname)
	}

	// We first insert the file into the db to get it's ID
	file = &File{
		Name:        realname,
		Size:        measured,
		CreatedByID: a.GetID(),
		Mime:        mime,
	}

	_, err = s.Insert(file)
	if err != nil {
		return
	}

	// Save the file to storage with its new ID as path
	err = file.Save(f)
	return
}

// Delete removes a file from the DB and the file system.
// The caller is responsible for committing or rolling back the session.
func (f *File) Delete(s *xorm.Session) (err error) {
	deleted, err := s.Where("id = ?", f.ID).Delete(&File{})
	if err != nil {
		return err
	}
	if deleted == 0 {
		return ErrFileDoesNotExist{FileID: f.ID}
	}

	err = storage.Remove(f.fileID())
	if err != nil {
		var perr *os.PathError
		if errors.As(err, &perr) {
			// Don't fail when removing the file failed
			log.Errorf("Error deleting file %d: %s", f.ID, err)
			return nil
		}

		return err
	}

	return keyvalue.DecrBy(metrics.FilesCountKey, 1)
}

// Save saves a file to storage
func (f *File) Save(fcontent io.ReadSeeker) error {
	err := storage.Write(f.fileID(), fcontent, f.Size)
	if err != nil {
		return fmt.Errorf("failed to save file: %w", err)
	}
	return keyvalue.IncrBy(metrics.FilesCountKey, 1)
}
