// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build !android

package taildrop

import (
	"bytes"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"strings"
	"sync"
	"unicode/utf8"
)

var renameMu sync.Mutex

// fsFileOps implements FileOps using the local filesystem rooted at a directory.
// It is used on non-Android platforms.
type fsFileOps struct{ rootDir string }

func init() {
	newFileOps = func(dir string) (FileOps, error) {
		if dir == "" {
			return nil, errors.New("rootDir cannot be empty")
		}
		if err := os.MkdirAll(dir, 0o700); err != nil {
			return nil, fmt.Errorf("mkdir %q: %w", dir, err)
		}
		return fsFileOps{rootDir: dir}, nil
	}
}

func (f fsFileOps) OpenWriter(name string, offset int64, perm os.FileMode) (io.WriteCloser, string, error) {
	path, err := joinDir(f.rootDir, name)
	if err != nil {
		return nil, "", err
	}
	if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return nil, "", err
	}
	fi, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, perm)
	if err != nil {
		return nil, "", err
	}
	if offset != 0 {
		curr, err := fi.Seek(0, io.SeekEnd)
		if err != nil {
			fi.Close()
			return nil, "", err
		}
		if offset < 0 || offset > curr {
			fi.Close()
			return nil, "", fmt.Errorf("offset %d out of range", offset)
		}
		if _, err := fi.Seek(offset, io.SeekStart); err != nil {
			fi.Close()
			return nil, "", err
		}
		if err := fi.Truncate(offset); err != nil {
			fi.Close()
			return nil, "", err
		}
	}
	return fi, path, nil
}

func (f fsFileOps) Remove(name string) error {
	path, err := joinDir(f.rootDir, name)
	if err != nil {
		return err
	}
	return os.Remove(path)
}

// Rename moves the partial file into its final name.
// newName must be a base name (not absolute or containing path separators).
// It will retry up to 10 times, de-dup same-checksum files, etc.
func (f fsFileOps) Rename(oldPath, newName string) (newPath string, err error) {
	var dst string
	if filepath.IsAbs(newName) || strings.ContainsRune(newName, os.PathSeparator) {
		return "", fmt.Errorf("invalid newName %q: must not be an absolute path or contain path separators", newName)
	}

	dst = filepath.Join(f.rootDir, newName)

	if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
		return "", err
	}

	st, err := os.Stat(oldPath)
	if err != nil {
		return "", err
	}
	wantSize := st.Size()

	const maxRetries = 10
	for range maxRetries {
		renameMu.Lock()
		fi, statErr := os.Stat(dst)
		// Atomically rename the partial file as the destination file if it doesn't exist.
		// Otherwise, it returns the length of the current destination file.
		// The operation is atomic.
		if os.IsNotExist(statErr) {
			err = os.Rename(oldPath, dst)
			renameMu.Unlock()
			if err != nil {
				return "", err
			}
			return dst, nil
		}
		if statErr != nil {
			renameMu.Unlock()
			return "", statErr
		}
		gotSize := fi.Size()
		renameMu.Unlock()

		// Avoid the final rename if a destination file has the same contents.
		//
		// Note: this is best effort and copying files from iOS from the Media Library
		// results in processing on the iOS side which means the size and shas of the
		// same file can be different.
		if gotSize == wantSize {
			sumP, err := sha256File(oldPath)
			if err != nil {
				return "", err
			}
			sumD, err := sha256File(dst)
			if err != nil {
				return "", err
			}
			if bytes.Equal(sumP[:], sumD[:]) {
				if err := os.Remove(oldPath); err != nil {
					return "", err
				}
				return dst, nil
			}
		}

		// Choose a new destination filename and try again.
		dst = filepath.Join(filepath.Dir(dst), nextFilename(filepath.Base(dst)))
	}

	return "", fmt.Errorf("too many retries trying to rename %q to %q", oldPath, newName)
}

// sha256File computes the SHA‑256 of a file.
func sha256File(path string) (sum [sha256.Size]byte, _ error) {
	f, err := os.Open(path)
	if err != nil {
		return sum, err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return sum, err
	}
	copy(sum[:], h.Sum(nil))
	return sum, nil
}

func (f fsFileOps) ListFiles() ([]string, error) {
	entries, err := os.ReadDir(f.rootDir)
	if err != nil {
		return nil, err
	}
	var names []string
	for _, e := range entries {
		if e.Type().IsRegular() {
			names = append(names, e.Name())
		}
	}
	return names, nil
}

func (f fsFileOps) Stat(name string) (fs.FileInfo, error) {
	path, err := joinDir(f.rootDir, name)
	if err != nil {
		return nil, err
	}
	return os.Stat(path)
}

func (f fsFileOps) OpenReader(name string) (io.ReadCloser, error) {
	path, err := joinDir(f.rootDir, name)
	if err != nil {
		return nil, err
	}
	return os.Open(path)
}

// joinDir is like [filepath.Join] but returns an error if baseName is too long,
// is a relative path instead of a basename, or is otherwise invalid or unsafe for incoming files.
func joinDir(dir, baseName string) (string, error) {
	if !utf8.ValidString(baseName) ||
		strings.TrimSpace(baseName) != baseName ||
		len(baseName) > 255 {
		return "", ErrInvalidFileName
	}
	// TODO: validate unicode normalization form too? Varies by platform.
	clean := path.Clean(baseName)
	if clean != baseName || clean == "." || clean == ".." {
		return "", ErrInvalidFileName
	}
	for _, r := range baseName {
		if !validFilenameRune(r) {
			return "", ErrInvalidFileName
		}
	}
	if !filepath.IsLocal(baseName) {
		return "", ErrInvalidFileName
	}
	return filepath.Join(dir, baseName), nil
}
