/*
 * Copyright (c) 2023 Gilles Chehade <gilles@poolp.org>
 *
 * Permission to use, copy, modify, and distribute this software for any
 * purpose with or without fee is hereby granted, provided that the above
 * copyright notice and this permission notice appear in all copies.
 *
 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 */

package importer

import (
	"context"
	"fmt"
	"io/fs"
	"os"
	"os/user"
	"path/filepath"
	"strconv"
	"strings"
	"sync"

	"github.com/PlakarKorp/kloset/connectors"
	"github.com/PlakarKorp/kloset/connectors/importer"
	"github.com/PlakarKorp/kloset/exclude"
	"github.com/PlakarKorp/kloset/location"
)

type FSImporter struct {
	opts       *connectors.Options
	rootDir    string
	rootIsFile bool
	realpath   string

	excludes *exclude.RuleSet

	uidToName map[uint64]string
	gidToName map[uint64]string
	mu        sync.RWMutex

	noxattr   bool
	nocrossfs bool
	devno     uint64
}

type file struct {
	path string
	info fs.FileInfo
}

func init() {
	importer.Register("fs", location.FLAG_LOCALFS, NewFSImporter)
}

func NewFSImporter(appCtx context.Context, opts *connectors.Options, name string, config map[string]string) (importer.Importer, error) {
	location := config["location"]
	rootDir := strings.TrimPrefix(location, name+"://")

	if !filepath.IsAbs(rootDir) {
		return nil, fmt.Errorf("not an absolute path %s", location)
	}

	rootDir = filepath.Clean(rootDir)

	nocrossfs, _ := strconv.ParseBool(config["dont_traverse_fs"])

	realpath, wasFile, devno, err := realpathFollow(rootDir)
	if err != nil {
		return nil, err
	}

	excludes := exclude.NewRuleSet()
	if err := excludes.AddRulesFromArray(opts.Excludes); err != nil {
		return nil, fmt.Errorf("failed to setup exclude rules: %w", err)
	}

	return &FSImporter{
		opts:       opts,
		rootDir:    rootDir,
		rootIsFile: wasFile,
		realpath:   realpath,
		excludes:   excludes,
		uidToName:  make(map[uint64]string),
		gidToName:  make(map[uint64]string),
		noxattr:    opts.NoXattr,
		nocrossfs:  nocrossfs,
		devno:      devno,
	}, nil
}

func (p *FSImporter) Origin() string {
	return p.opts.Hostname
}

func (p *FSImporter) Type() string {
	return "fs"
}

func (p *FSImporter) Flags() location.Flags {
	return location.FLAG_LOCALFS
}

func (p *FSImporter) Import(ctx context.Context, records chan<- *connectors.Record, results <-chan *connectors.Result) error {
	defer close(records)
	return p.walkDir_walker(ctx, records, p.opts.MaxConcurrency)
}

func (f *FSImporter) walkDir_walker(ctx context.Context, records chan<- *connectors.Record, numWorkers int) error {
	jobs := make(chan file, numWorkers*4) // Buffered channel to feed paths to workers
	var wg sync.WaitGroup
	for range numWorkers {
		wg.Add(1)
		go f.walkDir_worker(jobs, records, &wg)
	}

	// Add prefix directories first
	walkDir_addPrefixDirectories(filepath.Dir(f.realpath), records)
	if f.realpath != f.rootDir {
		walkDir_addPrefixDirectories(f.rootDir, records)
	}

	err := filepath.WalkDir(f.realpath, func(path string, d fs.DirEntry, err error) error {
		if ctx.Err() != nil {
			return err
		}

		if err != nil {
			records <- connectors.NewError(path, err)
			return nil
		}

		if path != "/" {
			if f.excludes.IsExcluded(path, d.IsDir()) {
				// SkipDir on a non-directory also skips later siblings,
				// which would silently drop files re-included by a
				// later negation rule. See PlakarKorp/plakar#2120.
				if d.IsDir() {
					return filepath.SkipDir
				}
				return nil
			}
		}

		info, err := d.Info()
		if err != nil {
			records <- connectors.NewError(path, err)
			return nil
		}

		if d.IsDir() && f.nocrossfs {
			same := isSameFs(f.devno, info)
			if !same {
				return filepath.SkipDir
			}
		}

		jobs <- file{path: path, info: info}
		return nil
	})

	close(jobs)
	wg.Wait()
	return err
}

func (p *FSImporter) lookupIDs(uid, gid uint64) (uname, gname string) {
	p.mu.RLock()
	defer p.mu.RUnlock()

	if name, ok := p.uidToName[uid]; !ok {
		if u, err := user.LookupId(fmt.Sprint(uid)); err == nil {
			uname = u.Username

			p.mu.RUnlock()
			p.mu.Lock()
			p.uidToName[uid] = uname
			p.mu.Unlock()
			p.mu.RLock()
		}
	} else {
		uname = name
	}

	if name, ok := p.gidToName[gid]; !ok {
		if g, err := user.LookupGroupId(fmt.Sprint(gid)); err == nil {
			gname = g.Name

			p.mu.RUnlock()
			p.mu.Lock()
			p.gidToName[gid] = gname
			p.mu.Unlock()
			p.mu.RLock()
		}
	} else {
		gname = name
	}

	return
}

func realpathFollow(path string) (resolved string, wasFile bool, dev uint64, err error) {
	info, err := os.Lstat(path)
	if err != nil {
		return
	}

	if info.Mode()&os.ModeDir != 0 {
		dev = dirDevice(info)
	} else {
		wasFile = true
	}

	if info.Mode()&os.ModeSymlink != 0 {
		realpath, err := os.Readlink(path)
		if err != nil {
			return "", false, 0, err
		}

		if !filepath.IsAbs(realpath) {
			realpath = filepath.Join(filepath.Dir(path), realpath)
		}
		path = realpath
	}

	return path, wasFile, dev, nil
}

func (p *FSImporter) Ping(ctx context.Context) error {
	return nil
}

func (p *FSImporter) Close(ctx context.Context) error {
	return nil
}

func (p *FSImporter) Root() string {
	path := p.rootDir
	if p.rootIsFile {
		path = filepath.Dir(path)
	}
	return toslash(path)
}

// convert paths to the internal format.  For unix nothing changes,
// for windows we apply some edits:
// C:\User\Omar\Plakar -> C:/User/Omar/Plakar -> /C:/User/Omar/Plakar
func toslash(p string) string {
	p = filepath.ToSlash(p)
	if !strings.HasPrefix(p, "/") {
		p = "/" + p
	}
	return p
}
