package container

import (
	"bufio"
	"fmt"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"syscall"

	"github.com/pkg/errors"
	"github.com/warewulf/warewulf/internal/pkg/util"
	"github.com/warewulf/warewulf/internal/pkg/wwlog"
)

const passwdPath = "/etc/passwd"
const groupPath = "/etc/group"

// SyncUids updates the /etc/passwd and /etc/group files in the
// container identified by containerName by installing the equivalent
// files from the host and appending names only in the
// container. Files in the container are updated to match the new
// numeric id assignments.
//
// If showOnly is true, the container is not actually updated, but
// relevant log entries and output are generated.
//
// Any I/O errors are returned unmodified.
//
// A conflict arises if the container has an entry with the same id as
// an entry in the host and the host does not have an entry with the
// same name. In this case, an error is returned.
func SyncUids(containerName string, showOnly bool) error {
	containerPath := RootFsDir(containerName)
	containerPasswdPath := path.Join(containerPath, passwdPath)
	containerGroupPath := path.Join(containerPath, groupPath)

	passwdSync := make(syncDB)
	if err := passwdSync.readFromHost(passwdPath); err != nil {
		return err
	}
	if err := passwdSync.readFromContainer(containerPasswdPath); err != nil {
		return err
	}
	if err := passwdSync.checkConflicts(); err != nil {
		return err
	}
	if err := passwdSync.findUserFiles(containerPath); err != nil {
		return err
	}

	groupSync := make(syncDB)
	if err := groupSync.readFromHost(groupPath); err != nil {
		return err
	}
	if err := groupSync.readFromContainer(containerGroupPath); err != nil {
		return err
	}
	if err := groupSync.checkConflicts(); err != nil {
		return err
	}
	if err := groupSync.findGroupFiles(containerPath); err != nil {
		return err
	}

	passwdSync.log("passwd")
	groupSync.log("group")

	if showOnly {
		if passwdSync.needsSync() || groupSync.needsSync() {
			wwlog.Info("uid/gid not synced: run `wwctl container syncuser --write %s`", containerName)
		} else {
			wwlog.Info("uid/gid already synced")
		}
	} else {
		if err := passwdSync.chownUserFiles(); err != nil {
			return err
		}
		if err := groupSync.chownGroupFiles(); err != nil {
			return err
		}

		if err := passwdSync.update(containerPasswdPath, passwdPath); err != nil {
			return err
		}
		if err := groupSync.update(containerGroupPath, groupPath); err != nil {
			return err
		}
		wwlog.Info("uid/gid synced for container %s", containerName)
	}

	return nil
}

// A syncDB maps user or group names to syncInfo instances, which
// correlate IDs between host and container and track affected
// files. This can be used for either /etc/passwd or /etc/group IDs.
type syncDB map[string]syncInfo

// checkConflicts inspects a syncDB map for irreconcilable
// conflicts. A conflict arises if the container has an entry with the
// same id as an entry in the host and the host does not have an entry
// with the same name.
func (db syncDB) checkConflicts() error {
	for nameInContainer, containerIds := range db {
		if !containerIds.inContainer() || containerIds.inHost() {
			continue
		}

		for nameInHost, hostIds := range db {
			if hostIds.HostID == containerIds.ContainerID {
				errorMsg := fmt.Sprintf("id(%v) collision: host(%s) container(%s)", containerIds.ContainerID, nameInHost, nameInContainer)
				wwlog.Warn(errorMsg)
				wwlog.Warn("add %s to host to resolve conflict", nameInContainer)
				return errors.New(errorMsg)
			}
		}
	}
	return nil
}

// log generates debug and verbose logs inspecting a syncDB map.
//
// The provided prefix is prepended to log entries to provide context
// for the given syncDB map. (e.g., to differentiate between a user or
// group map)
func (db syncDB) log(prefix string) {
	var onlyContainer, onlyHost, match, differ []string
	for name, ids := range db {
		wwlog.Debug("%s: %s: host(%v) container(%v) files: %v", prefix, name, ids.HostID, ids.ContainerID, ids.ContainerFiles)
		if ids.onlyContainer() {
			onlyContainer = append(onlyContainer, name)
		}
		if ids.onlyHost() {
			onlyHost = append(onlyHost, name)
		}
		if ids.match() {
			match = append(match, name)
		}
		if ids.differ() {
			differ = append(differ, name)
		}
	}

	wwlog.Verbose("%s: container only: %v", prefix, onlyContainer)
	wwlog.Verbose("%s: host only: %v", prefix, onlyHost)
	wwlog.Verbose("%s: match: %v", prefix, match)
	wwlog.Verbose("%s: differ: %v", prefix, differ)
}

// read reads fileName (an /etc/passwd or /etc/group file) into a
// syncDB map. Since the name and numerical id for both files are
// stored at fields 0 and 2, the same function can read both files.
//
// fromContainer identifies whether the given fileName includes
// entries from the host or the container.
func (db syncDB) read(fileName string, fromContainer bool) error {
	wwlog.Debug("read %s (container: %t)", fileName, fromContainer)
	if file, err := os.Open(fileName); err != nil {
		return err
	} else {
		defer file.Close()
		fileScanner := bufio.NewScanner(file)
		for fileScanner.Scan() {
			line := fileScanner.Text()
			fields := strings.Split(line, ":")
			if len(fields) != 7 {
				wwlog.Debug("malformed line in passwd: %s", line)
				continue
			}
			name := fields[0]
			if name == "" {
				continue
			}

			id, err := strconv.Atoi(fields[2])
			if err != nil {
				wwlog.Warn("Ignoring line %s (parse error)", line)
				continue
			}

			entry, ok := db[name]
			if !ok {
				entry = syncInfo{HostID: -1, ContainerID: -1}
			}

			if fromContainer {
				if entry.ContainerID == -1 {
					entry.ContainerID = id
				} else if entry.ContainerID != id {
					wwlog.Warn("Ignoring container id(%v) for %s from %s after existing value %v", id, name, fileName, entry.ContainerID)
					continue
				}
			} else {
				if entry.HostID == -1 {
					entry.HostID = id
				} else if entry.HostID != id {
					wwlog.Warn("Ignoring host id(%v) for %s from %s after existing value %v", id, name, fileName, entry.HostID)
					continue
				}
			}

			db[name] = entry
		}
		return nil
	}
}

// readFromHost reads fileName into a syncDB map.
//
// Equivalent to read(fileName, false)
func (db syncDB) readFromHost(fileName string) error { return db.read(fileName, false) }

// readFromContainer reads fileName into a syncDB map.
//
// Equivalent to read(fileName, true)
func (db syncDB) readFromContainer(fileName string) error { return db.read(fileName, true) }

// findFiles finds files under containerPath that are owned by each
// tracked container ID.
//
// If byGid is true, files with a matching gid are
// recorded. Otherwise, files with a matching uid are recorded.
func (db syncDB) findFiles(containerPath string, byGid bool) error {
	for name, ids := range db {
		if err := ids.findFiles(containerPath, byGid); err != nil {
			return err
		}
		if len(ids.ContainerFiles) > 0 {
			wwlog.Debug("files for %s (%v -> %v, gid: %v): %v", name, ids.ContainerID, ids.HostID, byGid, ids.ContainerFiles)
		}
		db[name] = ids
	}
	return nil
}

// findUserFiles is equivalent to findFiles(containerPath, false)
func (db syncDB) findUserFiles(containerPath string) error { return db.findFiles(containerPath, false) }

// findGroupFiles is equivalent to findFiles(containerPath, true)
func (db syncDB) findGroupFiles(containerPath string) error { return db.findFiles(containerPath, true) }

// chownFiles updates found files (see findFiles) to reflect ownership
// using host ids.
//
// If byGid is true, file gids are updated. Otherwise, file uids are
// updated.
func (db syncDB) chownFiles(byGid bool) error {
	for _, ids := range db {
		if err := ids.chownFiles(byGid); err != nil {
			return err
		}
	}
	return nil
}

// chownUserFiles is equivalent to chownFiles(false)
func (db syncDB) chownUserFiles() error { return db.chownFiles(false) }

// chownUserFiles is equivalent to chownFiles(true)
func (db syncDB) chownGroupFiles() error { return db.chownFiles(true) }

// getOnlyContainerLines returns the lines from fileName (either an
// /etc/passwd or /etc/group file) for names that occur only in the
// container.
//
// These lines are added to the respective file from the host when
// updating the container.
func (db syncDB) getOnlyContainerLines(fileName string) ([]string, error) {
	file, err := os.Open(fileName)
	if err != nil {
		return nil, err
	}
	defer file.Close()
	fileScanner := bufio.NewScanner(file)

	var lines []string
	for fileScanner.Scan() {
		line := fileScanner.Text()
		fields := strings.Split(line, ":")
		for name, ids := range db {
			if fields[0] == name {
				if ids.onlyContainer() {
					lines = append(lines, line)
				}
				break
			}
		}
	}
	wwlog.Debug("file: %s, entries only in container: %v", fileName, lines)
	return lines, nil
}

// update replaces containerPath with hostPath and adds lines from
// getOnlyContainerLines to the end of containerPath. This is used to
// replace /etc/passwd (or /etc/group) in the container with the
// equivalent file from the host while retaining names unique to the
// container.
func (db syncDB) update(containerPath string, hostPath string) error {
	wwlog.Debug("update %s from %s)", containerPath, hostPath)
	if lines, err := db.getOnlyContainerLines(containerPath); err != nil {
		return err
	} else {
		if err := os.Remove(containerPath); err != nil {
			return err
		}
		if err := util.CopyFile(hostPath, containerPath); err != nil {
			return err
		}
		if err := util.AppendLines(containerPath, lines); err != nil {
			return err
		}
		return nil
	}
}

// needsSync returns true if the syncDB map indicates that ids between
// the container and host are out-of-sync.
func (db syncDB) needsSync() bool {
	for name, ids := range db {
		if ids.onlyHost() {
			wwlog.Debug("sync required: %s only in host", name)
			return true
		}
		if ids.differ() {
			wwlog.Debug("sync required: %s is %v in host and %v in container", name, ids.HostID, ids.ContainerID)
			return true
		}
		if len(ids.ContainerFiles) > 0 {
			wwlog.Debug("sync required: %v files to update for %s", len(ids.ContainerFiles), name)
		}
	}
	return false
}

// sycncInfo correlates the numerical id of a name on the host
// (HostID) and the container (ContainerID), along with the files in
// the container that are owned by that name. This allows affected
// files to be updated when the HostID is applied to the container.
type syncInfo struct {
	HostID         int      `access:"r,w"`
	ContainerID    int      `access:"r,w"`
	ContainerFiles []string `access:"r,w"`
}

// inHost returns true if info has a record of an id for this name in
// the host.
func (info *syncInfo) inHost() bool {
	return info.HostID != -1
}

// inContainer returns true if info has a record of an id for this
// name in the container.
func (info *syncInfo) inContainer() bool {
	return info.ContainerID != -1
}

// onlyHost returns true if info has a record of an id for this name
// in the host and not in the container.
func (info *syncInfo) onlyHost() bool {
	return info.inHost() && !info.inContainer()
}

// onlyHost returns true if info has a record of an id for this name
// in the container and not in the host.
func (info *syncInfo) onlyContainer() bool {
	return info.inContainer() && !info.inHost()
}

// match returns true if info represents a name that exists with the
// same numerical id in both the host and the container.
func (info *syncInfo) match() bool {
	return info.inContainer() && info.inHost() && info.HostID == info.ContainerID
}

// differ returns true if info represents a name that exists in both
// the host and the container but with different numerical ids.
func (info *syncInfo) differ() bool {
	return info.inContainer() && info.inHost() && info.HostID != info.ContainerID
}

// findFiles walks containerPath to find files owned by the name
// represented by info and records them in info.ContainerFiles.
//
// If byGid is true, the file's gid is checked; otherwise, the file's
// uid is checked.
func (info *syncInfo) findFiles(containerPath string, byGid bool) error {
	var containerFiles []string
	if info.inHost() && !info.match() {
		if err := filepath.Walk(containerPath, func(filePath string, fileInfo fs.FileInfo, err error) error {
			if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
				var id int
				if byGid {
					id = int(stat.Gid)
				} else {
					id = int(stat.Uid)
				}
				if id == info.ContainerID {
					containerFiles = append(containerFiles, filePath)
				}
			}
			return nil
		}); err != nil {
			return err
		}
	}
	info.ContainerFiles = containerFiles
	return nil
}

// chownFiles updates the files recorded in info.ContainerFiles to use
// the numerical IDs from the host.
//
// If byGid is true, the file's gid is updated; otherwise, the file's
// uid is updated.
func (info *syncInfo) chownFiles(byGid bool) error {
	for _, file := range info.ContainerFiles {
		if fileInfo, err := os.Stat(file); err != nil {
			return err
		} else {
			if fileInfo.IsDir() || fileInfo.Mode().IsRegular() {
				var newUid, newGid int
				if byGid {
					newUid = -1
					newGid = info.HostID
				} else {
					newUid = info.HostID
					newGid = -1
				}
				if err := os.Chown(file, newUid, newGid); err != nil {
					return err
				}
			}
		}
	}
	return nil
}
