package files

import (
	"bufio"
	"fmt"
	"io/fs"
	"net/http"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"syscall"
	"time"

	"github.com/1Panel-dev/1Panel/agent/app/repo"
	"github.com/1Panel-dev/1Panel/agent/buserr"

	"github.com/spf13/afero"
)

var binaryPreviewMimeTypes = map[string]struct{}{
	"application/pdf":               {},
	"application/zip":               {},
	"application/gzip":              {},
	"application/x-gzip":            {},
	"application/x-7z-compressed":   {},
	"application/x-rar-compressed":  {},
	"application/x-bzip2":           {},
	"application/x-xz":              {},
	"application/x-tar":             {},
	"application/java-archive":      {},
	"application/msword":            {},
	"application/vnd.ms-excel":      {},
	"application/vnd.ms-powerpoint": {},
	"application/vnd.openxmlformats-officedocument.wordprocessingml.document":   {},
	"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":         {},
	"application/vnd.openxmlformats-officedocument.presentationml.presentation": {},
}

var binaryPreviewMimePrefixes = []string{
	"image/",
	"audio/",
	"video/",
	"application/vnd.ms-",
	"application/vnd.openxmlformats-officedocument.",
	"application/vnd.oasis.opendocument.",
}

var binaryPreviewExtensions = map[string]struct{}{
	".pdf":  {},
	".zip":  {},
	".gz":   {},
	".bz2":  {},
	".xz":   {},
	".tar":  {},
	".tgz":  {},
	".rar":  {},
	".7z":   {},
	".war":  {},
	".doc":  {},
	".docx": {},
	".xls":  {},
	".xlsx": {},
	".ppt":  {},
	".pptx": {},
	".jpg":  {},
	".jpeg": {},
	".png":  {},
	".bmp":  {},
	".gif":  {},
	".tiff": {},
	".ico":  {},
	".webp": {},
	".svg":  {},
	".mp3":  {},
	".wav":  {},
	".wma":  {},
	".ape":  {},
	".acc":  {},
	".ogg":  {},
	".flac": {},
	".mp4":  {},
	".webm": {},
	".mov":  {},
	".wmv":  {},
	".mkv":  {},
	".avi":  {},
	".flv":  {},
}

type FileInfo struct {
	Fs           afero.Fs    `json:"-"`
	Path         string      `json:"path"`
	Name         string      `json:"name"`
	User         string      `json:"user"`
	Group        string      `json:"group"`
	Uid          string      `json:"uid"`
	Gid          string      `json:"gid"`
	Extension    string      `json:"extension"`
	Content      string      `json:"content"`
	Size         int64       `json:"size"`
	IsDir        bool        `json:"isDir"`
	IsSymlink    bool        `json:"isSymlink"`
	IsHidden     bool        `json:"isHidden"`
	LinkPath     string      `json:"linkPath"`
	Type         string      `json:"type"`
	Mode         string      `json:"mode"`
	MimeType     string      `json:"mimeType"`
	UpdateTime   time.Time   `json:"updateTime"`
	ModTime      time.Time   `json:"modTime"`
	FileMode     os.FileMode `json:"-"`
	Items        []*FileInfo `json:"items"`
	ItemTotal    int         `json:"itemTotal"`
	FavoriteID   uint        `json:"favoriteID"`
	ShareCode    string      `json:"shareCode"`
	IsDetail     bool        `json:"isDetail"`
	IsAppendOnly bool        `json:"isAppendOnly"`
	IsImmutable  bool        `json:"isImmutable"`
}

type FileOption struct {
	Path       string `json:"path"`
	Search     string `json:"search"`
	ContainSub bool   `json:"containSub"`
	Expand     bool   `json:"expand"`
	Dir        bool   `json:"dir"`
	ShowHidden bool   `json:"showHidden"`
	Page       int    `json:"page"`
	PageSize   int    `json:"pageSize"`
	SortBy     string `json:"sortBy"`
	SortOrder  string `json:"sortOrder"`
	IsDetail   bool   `json:"isDetail"`
}

type FileSearchInfo struct {
	Path string `json:"path"`
	fs.FileInfo
}

func NewFileInfo(op FileOption) (*FileInfo, error) {
	var appFs = afero.NewOsFs()

	info, err := appFs.Stat(op.Path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, buserr.New("ErrLinkPathNotFound")
		}
		return nil, err
	}

	file := &FileInfo{
		Fs:        appFs,
		Path:      op.Path,
		Name:      info.Name(),
		IsDir:     info.IsDir(),
		FileMode:  info.Mode(),
		ModTime:   info.ModTime(),
		Size:      info.Size(),
		IsSymlink: IsSymlink(info.Mode()),
		Extension: filepath.Ext(info.Name()),
		IsHidden:  IsHidden(op.Path),
		Mode:      fmt.Sprintf("%04o", info.Mode().Perm()),
		User:      GetUsername(info.Sys().(*syscall.Stat_t).Uid),
		Uid:       strconv.FormatUint(uint64(info.Sys().(*syscall.Stat_t).Uid), 10),
		Gid:       strconv.FormatUint(uint64(info.Sys().(*syscall.Stat_t).Gid), 10),
		Group:     GetGroup(info.Sys().(*syscall.Stat_t).Gid),
		MimeType:  GetMimeType(op.Path),
		IsDetail:  op.IsDetail,
	}
	favoriteRepo := repo.NewIFavoriteRepo()
	favorite, _ := favoriteRepo.GetFirst(favoriteRepo.WithByPath(op.Path))
	if favorite.ID > 0 {
		file.FavoriteID = favorite.ID
	}
	file.IsAppendOnly, file.IsImmutable = getFileAttributes(op.Path)

	if file.IsSymlink {
		linkPath := GetSymlink(op.Path)
		if !filepath.IsAbs(linkPath) {
			dir := filepath.Dir(op.Path)
			var err error
			linkPath, err = filepath.Abs(filepath.Join(dir, linkPath))
			if err != nil {
				return nil, err
			}
		}
		file.LinkPath = linkPath
		targetInfo, err := appFs.Stat(linkPath)
		if err != nil {
			file.IsDir = false
			file.Mode = "-"
			file.User = "-"
			file.Group = "-"
		} else {
			file.IsDir = targetInfo.IsDir()
		}
		file.Extension = filepath.Ext(file.LinkPath)
	}
	if op.Expand {
		if err := handleExpansion(file, op); err != nil {
			return nil, err
		}
	}
	return file, nil
}

func getFileAttributes(filePath string) (bool, bool) {
	output, err := exec.Command("lsattr", "-d", "--", filePath).Output()
	if err != nil {
		return false, false
	}
	fields := strings.Fields(string(output))
	if len(fields) == 0 {
		return false, false
	}
	return strings.Contains(fields[0], "a"), strings.Contains(fields[0], "i")
}

func handleExpansion(file *FileInfo, op FileOption) error {
	if file.IsDir {
		return file.listChildren(op)
	}

	if !file.IsDetail {
		return file.getContent()
	}

	return nil
}

func (f *FileInfo) search(search string, count int) (files []FileSearchInfo, total int, err error) {
	cmd := exec.Command("find", f.Path, "-name", fmt.Sprintf("*%s*", search))
	output, err := cmd.StdoutPipe()
	if err != nil {
		return
	}
	if err = cmd.Start(); err != nil {
		return
	}
	defer func() {
		_ = cmd.Wait()
		_ = cmd.Process.Kill()
	}()

	scanner := bufio.NewScanner(output)
	for scanner.Scan() {
		line := scanner.Text()
		info, err := os.Stat(line)
		if err != nil {
			continue
		}
		total++
		if total > count {
			continue
		}
		files = append(files, FileSearchInfo{
			Path:     line,
			FileInfo: info,
		})
	}
	if err = scanner.Err(); err != nil {
		return
	}
	return
}

func sortFileList(list []FileSearchInfo, sortBy, sortOrder string) {
	switch sortBy {
	case "name":
		if sortOrder == "ascending" {
			sort.Slice(list, func(i, j int) bool {
				return list[i].Name() < list[j].Name()
			})
		} else {
			sort.Slice(list, func(i, j int) bool {
				return list[i].Name() > list[j].Name()
			})
		}
	case "size":
		if sortOrder == "ascending" {
			sort.Slice(list, func(i, j int) bool {
				return list[i].Size() < list[j].Size()
			})
		} else {
			sort.Slice(list, func(i, j int) bool {
				return list[i].Size() > list[j].Size()
			})
		}
	case "modTime":
		if sortOrder == "ascending" {
			sort.Slice(list, func(i, j int) bool {
				return list[i].ModTime().Before(list[j].ModTime())
			})
		} else {
			sort.Slice(list, func(i, j int) bool {
				return list[i].ModTime().After(list[j].ModTime())
			})
		}
	}
}

func (f *FileInfo) listChildren(option FileOption) error {
	var (
		files []FileSearchInfo
		err   error
		total int
	)

	if option.Search != "" && option.ContainSub {
		files, total, err = f.search(option.Search, option.Page*option.PageSize)
		if err != nil {
			return err
		}
	} else {
		files, err = f.getFiles(option)
		if err != nil {
			return err
		}
	}

	items, err := f.processFiles(files, option)
	if err != nil {
		return err
	}

	if option.ContainSub {
		f.ItemTotal = total
	}
	start := (option.Page - 1) * option.PageSize
	end := option.PageSize + start
	var result []*FileInfo
	if start < 0 || start > f.ItemTotal || end < 0 || start > end {
		result = items
	} else {
		if end > f.ItemTotal {
			result = items[start:]
		} else {
			result = items[start:end]
		}
	}

	f.Items = result
	return nil
}

func (f *FileInfo) getFiles(option FileOption) ([]FileSearchInfo, error) {
	infos, err := os.ReadDir(f.Path)
	if err != nil {
		return nil, err
	}
	var (
		dirs     []FileSearchInfo
		fileList []FileSearchInfo
	)

	for _, file := range infos {
		fileInfo, err := file.Info()
		if err != nil {
			continue
		}
		info := FileSearchInfo{
			Path:     f.Path,
			FileInfo: fileInfo,
		}
		if file.IsDir() {
			dirs = append(dirs, info)
		} else {
			fileList = append(fileList, info)
		}
	}

	sortFileList(dirs, option.SortBy, option.SortOrder)
	sortFileList(fileList, option.SortBy, option.SortOrder)

	return append(dirs, fileList...), nil
}

func (f *FileInfo) processFiles(files []FileSearchInfo, option FileOption) ([]*FileInfo, error) {
	var items []*FileInfo

	for _, df := range files {
		if shouldSkipFile(df, option) {
			continue
		}

		name, fPath := f.getFilePathAndName(option, df)

		if !option.ShowHidden && IsHidden(name) {
			continue
		}
		f.ItemTotal++

		isSymlink, isInvalidLink := f.checkSymlink(df)

		file := &FileInfo{
			Fs:        f.Fs,
			Name:      name,
			Size:      df.Size(),
			ModTime:   df.ModTime(),
			FileMode:  df.Mode(),
			IsDir:     df.IsDir(),
			IsSymlink: isSymlink,
			IsHidden:  IsHidden(fPath),
			Extension: filepath.Ext(name),
			Path:      fPath,
			Mode:      fmt.Sprintf("%04o", df.Mode().Perm()),
			User:      GetUsername(df.Sys().(*syscall.Stat_t).Uid),
			Group:     GetGroup(df.Sys().(*syscall.Stat_t).Gid),
			Uid:       strconv.FormatUint(uint64(df.Sys().(*syscall.Stat_t).Uid), 10),
			Gid:       strconv.FormatUint(uint64(df.Sys().(*syscall.Stat_t).Gid), 10),
		}
		favoriteRepo := repo.NewIFavoriteRepo()
		favorite, _ := favoriteRepo.GetFirst(favoriteRepo.WithByPath(fPath))
		if favorite.ID > 0 {
			file.FavoriteID = favorite.ID
		}
		if isSymlink {
			linkPath := GetSymlink(fPath)
			if !filepath.IsAbs(linkPath) {
				dir := filepath.Dir(fPath)
				var err error
				linkPath, err = filepath.Abs(filepath.Join(dir, linkPath))
				if err != nil {
					return nil, err
				}
			}
			file.LinkPath = linkPath
			targetInfo, err := file.Fs.Stat(linkPath)
			if err != nil {
				file.IsDir = false
				file.Mode = "-"
				file.User = "-"
				file.Group = "-"
			} else {
				file.IsDir = targetInfo.IsDir()
			}
			file.Extension = filepath.Ext(file.LinkPath)
		}
		if df.Size() > 0 {
			file.MimeType = GetMimeType(fPath)
		}
		if isInvalidLink {
			file.Type = "invalid_link"
		}
		items = append(items, file)
	}

	return items, nil
}

func shouldSkipFile(df FileSearchInfo, option FileOption) bool {
	if option.Dir && !df.IsDir() {
		return true
	}

	if option.Search != "" && !option.ContainSub {
		lowerName := strings.ToLower(df.Name())
		lowerSearch := strings.ToLower(option.Search)
		if !strings.Contains(lowerName, lowerSearch) {
			return true
		}
	}

	return false
}

func (f *FileInfo) getFilePathAndName(option FileOption, df FileSearchInfo) (string, string) {
	name := df.Name()
	fPath := path.Join(df.Path, df.Name())

	if option.Search != "" && option.ContainSub {
		fPath = df.Path
		name = strings.TrimPrefix(strings.TrimPrefix(fPath, f.Path), "/")
	}

	return name, fPath
}

func (f *FileInfo) checkSymlink(df FileSearchInfo) (bool, bool) {
	isSymlink := false
	isInvalidLink := false

	if IsSymlink(df.Mode()) {
		isSymlink = true
		info, err := f.Fs.Stat(path.Join(df.Path, df.Name()))
		if err == nil {
			df.FileInfo = info
		} else {
			isInvalidLink = true
		}
	}

	return isSymlink, isInvalidLink
}

func (f *FileInfo) getContent() error {
	if IsBlockDevice(f.FileMode) {
		return buserr.New("ErrFileCanNotRead")
	}
	if f.Size > 10*1024*1024 {
		return buserr.New("ErrFileToLarge")
	}
	if IsBinaryPreviewFile(f.MimeType, f.Extension) {
		return buserr.New("ErrFileCanNotRead")
	}
	afs := &afero.Afero{Fs: f.Fs}
	cByte, err := afs.ReadFile(f.Path)
	if err != nil {
		return nil
	}
	if len(cByte) > 0 && DetectBinary(cByte) {
		return buserr.New("ErrFileCanNotRead")
	}
	f.Content = string(cByte)
	return nil
}

func IsBinaryPreviewFile(mimeType, extension string) bool {
	mimeType = strings.ToLower(strings.TrimSpace(mimeType))
	extension = strings.ToLower(strings.TrimSpace(extension))

	if mimeType == "" || mimeType == "application/octet-stream" {
		_, ok := binaryPreviewExtensions[extension]
		return ok
	}

	if strings.HasPrefix(mimeType, "text/") {
		return false
	}

	for _, prefix := range binaryPreviewMimePrefixes {
		if strings.HasPrefix(mimeType, prefix) {
			return true
		}
	}

	_, ok := binaryPreviewMimeTypes[mimeType]
	if ok {
		return true
	}

	_, ok = binaryPreviewExtensions[extension]
	return ok
}

func DetectBinary(buf []byte) bool {
	mimeType := http.DetectContentType(buf)
	if IsBinaryPreviewFile(mimeType, "") {
		return true
	}
	if !strings.HasPrefix(mimeType, "text/") {
		whiteByte := 0
		n := min(1024, len(buf))
		for i := 0; i < n; i++ {
			if (buf[i] >= 0x20) || buf[i] == 9 || buf[i] == 10 || buf[i] == 13 {
				whiteByte++
			} else if buf[i] <= 6 || (buf[i] >= 14 && buf[i] <= 31) {
				return true
			}
		}
		return whiteByte < 1
	}
	return false
}

func min(x, y int) int {
	if x < y {
		return x
	}
	return y
}

type CompressType string

const (
	Zip      CompressType = "zip"
	Gz       CompressType = "gz"
	Bz2      CompressType = "bz2"
	TarBz2   CompressType = "tar.bz2"
	Tar      CompressType = "tar"
	TarGz    CompressType = "tar.gz"
	Tgz      CompressType = "tgz"
	Xz       CompressType = "xz"
	TarXz    CompressType = "tar.xz"
	SdkZip   CompressType = "sdkZip"
	SdkTarGz CompressType = "sdkTarGz"
	Rar      CompressType = "rar"
	X7z      CompressType = "7z"
)
