// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU 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 General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.

// Package grow is a utility for growing storage.
package grow

import (
	"context"
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/purpleidea/mgmt/util"
)

const (
	// DevDir is where linux system devices are found.
	DevDir = "/dev/"
)

// Grow is a utility that grows the underlying partition, luks device (if
// encrypted) and then finally the partition. It makes many assumptions about
// the luks device being encrypted with an empty password, and that nobody uses
// lvm anymore. This utility is useful when provisioning new machines which
// don't get their maximum disk utilization by default. (All the Fedora machines
// whether physical or virtual seem to have this problem.)
//
// This whole utility should only be run by the Run entrypoint if you want to
// receive the benefit of the various options, such as Noop and Done. If you
// bypass those then you're on your own.
//
// Patches are welcome to expand the functionality of this software, however it
// was intended as the minimally viable piece of reliable code to solve the
// specific grow problem commonly seen, without adding unused features.
type Grow struct {
	// Noop is set to true to prevent any actual grow operation.
	Noop bool

	// Done specifies a file path to create once the operations complete
	// successfully. If this file exists, these operations won't run.
	Done string

	// Debug represents if we're running in debug mode or not.
	Debug bool

	// Logf is a logger which should be used.
	Logf func(format string, v ...interface{})
}

// Run the whole sequence. This is probably the only function you want to run.
func (obj *Grow) Run(ctx context.Context, mount string) (reterr error) {
	if obj.Done != "" {
		if !strings.HasPrefix(obj.Done, "/") {
			return fmt.Errorf("done must be absolute")
		}
		if strings.HasSuffix(obj.Done, "/") {
			return fmt.Errorf("done must be a file")
		}

		_, err := os.Stat(obj.Done)
		if err != nil && !os.IsNotExist(err) {
			return err // permission err
		}
		if err == nil { // exists!
			obj.Logf("done mode")
			return nil
		}
		defer func() {
			// Store error in case we have a problem here.
			reterr = os.WriteFile(obj.Done, []byte(fmt.Sprintf("%s\n", mount)), 0600)
		}()
	}

	fsList, err := obj.RunFindMnt(ctx, mount)
	if err != nil {
		return err
	}

	if len(fsList) == 0 {
		return fmt.Errorf("no mount found")
	}
	if len(fsList) != 1 {
		return fmt.Errorf("ambiguous mount found, expected one")
	}

	if target := fsList[0].Target; target != mount {
		return fmt.Errorf("unexpected target: %s", target)
	}

	fsType := fsList[0].FsType

	if err := obj.IsValidFsType(fsType); err != nil {
		return err
	}

	// If we're using LUKS, this will be something like:
	// /dev/mapper/luks-<UUID> but keep in mind that `cryptsetup isLuks` on
	// that device will be FALSE because that's the unencrypted
	// device-mapper device, not the actual encrypted block device.
	source := fsList[0].Source
	if source == "" {
		return fmt.Errorf("empty source")
	}
	if source == "/" {
		return fmt.Errorf("invalid source")
	}

	// Source is probably a device mapper device. Let's resolve it.
	// /usr/bin/realpath --relative-to /dev /dev/mapper/luks-<UUID>
	//
	// It might just be something like /dev/sda3 which means this is a noop.
	if !strings.HasPrefix(source, "/") {
		return fmt.Errorf("source is not absolute")
	}
	dev, err := filepath.EvalSymlinks(source)
	if err != nil {
		return err
	}

	// This should output something like /dev/dm-0 or maybe not if it's not
	// using LUKS.

	if !strings.HasPrefix(dev, DevDir) {
		return fmt.Errorf("unexpected resolved link of: %s", dev)
	}

	// This is now dm-0 if we're using luks/device mapper or maybe sda3.

	luks := "" // assume false
	if err := obj.IsDeviceMapper(dev); err == nil {

		// XXX: Is there a better way to resolve device-mapper? I know I
		// can cryptsetup status directly on the /dev/mapper/luks-<UUID>
		// but I'd like a more flexible approach where I do it stepwise.
		dm := strings.TrimPrefix(dev, DevDir)
		s := fmt.Sprintf("/sys/block/%s/slaves/", dm)
		dirs, err := os.ReadDir(s) // ([]DirEntry, error)
		if err != nil {
			return err
		}
		if l := len(dirs); l != 1 {
			return fmt.Errorf("unexpected number of device-mapper slaves: %d", l)
		}

		//luks = source // this would work
		luks = dev // this feels more accurate

		// This is the underlying device. You could also get this from
		// cryptsetup status $dev or $source but since the cryptsetup
		// command seems to be a bit sloppy in what it expects, we just
		// look up the device mapper stuff in the kernel /sys/ dirs...
		dev = DevDir + dirs[0].Name() // eg: /dev/ + nvme0n1p3
	}

	if obj.Debug {
		obj.Logf("source: %s", source)
		obj.Logf("luks: %s", luks)
		obj.Logf("dev: %s", dev)
	}

	if obj.Noop {
		obj.Logf("noop mode")
	}

	if err := obj.GrowPart(ctx, dev); err != nil {
		return err
	}

	if luks != "" {
		if err := obj.GrowLUKS(ctx, luks); err != nil {
			return err
		}
	}

	if err := obj.GrowFs(ctx, source, fsType); err != nil {
		return err
	}

	return nil
}

// GrowPart runs the "growpart" command from the "cloud-utils-growpart" package.
func (obj *Grow) GrowPart(ctx context.Context, part string) error {
	if part == "" {
		return fmt.Errorf("empty part")
	}

	base, part, err := obj.GrowPartArgs(part)
	if err != nil {
		return err
	}

	if obj.Debug {
		obj.Logf("base: %s", base)
		obj.Logf("part: %s", part)
	}

	cmd, err := exec.LookPath("growpart")
	if err != nil {
		return err
	}
	cmdArgs := []string{base, part}
	obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " "))
	if obj.Noop {
		return nil
	}
	return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts())
}

// GrowLUKS grows the luks device that has an empty password.
func (obj *Grow) GrowLUKS(ctx context.Context, luks string) error {
	if luks == "" {
		return fmt.Errorf("empty luks")
	}

	// XXX: this hack is necessary because cryptsetup is dumb
	empty := "/tmp/empty"
	if !obj.Noop {
		if err := os.WriteFile(empty, []byte{}, 0600); err != nil {
			return err
		}
	}

	cmd, err := exec.LookPath("cryptsetup")
	if err != nil {
		return err
	}
	cmdArgs := []string{"resize", fmt.Sprintf("--key-file=%s", empty), luks}
	obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " "))
	if obj.Noop {
		return nil
	}
	return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts())
}

// GrowFs expands the filesystem while it's online.
func (obj *Grow) GrowFs(ctx context.Context, dev, fsType string) error {
	if dev == "" {
		return fmt.Errorf("empty dev")
	}

	if fsType == "ext4" {
		cmd, err := exec.LookPath("resize2fs")
		if err != nil {
			return err
		}
		cmdArgs := []string{dev}
		obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " "))
		if obj.Noop {
			return nil
		}
		return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts())
	}

	if fsType == "xfs" {
		cmd, err := exec.LookPath("xfs_growfs")
		if err != nil {
			return err
		}
		cmdArgs := []string{dev}
		obj.Logf("cmd: %s %s", cmd, strings.Join(cmdArgs, " "))
		if obj.Noop {
			return nil
		}
		return util.SimpleCmd(ctx, cmd, cmdArgs, obj.cmdOpts())
	}

	// XXX: btrfs filesystem resize max /path

	return obj.IsValidFsType(fsType)
}

// GrowPartArgs get the special args needed for the `growpart` command.
func (obj *Grow) GrowPartArgs(dev string) (string, string, error) {
	if dev == "" {
		return "", "", fmt.Errorf("empty dev")
	}
	if !strings.HasPrefix(dev, DevDir) {
		return "", "", fmt.Errorf("missing prefix for dev in: %s", dev)
	}

	// Simple parsing for /dev/sda3 and friends... Walk backwards until we
	// encounter a non-number, and if it's a weird nvme thing, remove the p.
	i := len(dev) - 1
	for i >= 0 && dev[i] >= '0' && dev[i] <= '9' { // is a number
		i--
	}
	if i == len(dev)-1 {
		return "", "", fmt.Errorf("no partition number found")
	}
	base := dev[:i+1]
	part := dev[i+1:]

	// fancy parsing for /dev/nvme0n1p3 which has the "p".
	if strings.HasPrefix(dev, DevDir+"nvme") && dev[i] == 'p' {
		base = dev[:i]
	}
	if base == "" || part == "" {
		return "", "", fmt.Errorf("unexpected empty result")
	}

	return base, part, nil
}

// IsDeviceMapper runs a simple check to see if we're something like:
// `/dev/dm-0`.
func (obj *Grow) IsDeviceMapper(name string) error {
	if !strings.HasPrefix(name, "/dev/dm-") {
		return fmt.Errorf("missing device mapper prefix")
	}
	s := strings.TrimPrefix(name, "/dev/dm-")

	if _, err := strconv.Atoi(s); err != nil {
		return fmt.Errorf("missing device mapper number")
	}

	return nil
}

// IsValidFsType are the types of filesystems we currently know how to grow.
func (obj *Grow) IsValidFsType(fsType string) error {
	if fsType == "ext4" || fsType == "xfs" {
		return nil
	}
	// TODO: add btrfs support
	//if fsType == "btrfs" {
	//	return nil
	//}

	return fmt.Errorf("can't grow fstype: %s", fsType)
}

// RunFindMnt runs the linux util `findmnt` command.
func (obj *Grow) RunFindMnt(ctx context.Context, p string) ([]*MountInfo, error) {
	cmd, err := exec.LookPath("findmnt")
	if err != nil {
		return nil, err
	}
	cmdArgs := []string{"--json", "--all", "--mountpoint", p}
	b, err := util.SimpleCmdOut(ctx, cmd, cmdArgs, obj.cmdOpts())
	if err != nil {
		return nil, err
	}

	var st FindMnt
	if err := json.Unmarshal(b, &st); err != nil {
		return nil, err
	}

	return st.Filesystems, nil
}

// cmdOpts is just a helper to return the same struct repeatedly.
func (obj *Grow) cmdOpts() *util.SimpleCmdOpts {
	logf := func(format string, v ...interface{}) {
		if !obj.Debug { // ignore the noisy "always on" log messages...
			return
		}
		obj.Logf(format, v...)
	}
	return &util.SimpleCmdOpts{
		Debug: obj.Debug,
		Logf:  logf,
	}
}

// FindMnt is the --json output of the `findmnt` command.
type FindMnt struct {
	Filesystems []*MountInfo `json:"filesystems"`
}

// MountInfo is the type of each entry in the FindMnt Filesystems field.
type MountInfo struct {
	Target  string `json:"target"`
	Source  string `json:"source"`
	FsType  string `json:"fstype"`
	Options string `json:"options"`
}
