// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//go:build linux
// +build linux

package mounter

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/trace"
	"k8s.io/kubernetes/pkg/volume/util/hostutil"
	mountutils "k8s.io/mount-utils"
	utilexec "k8s.io/utils/exec"
	"sigs.k8s.io/controller-runtime/pkg/log"

	"local-csi-driver/internal/pkg/retry"
)

// Mock generation:
//
// The `MockMounter` struct generated by mockgen is failing the type check with:
//
//	*attacher.MockMounter does not implement "k8s.io/mount-utils".Interface (missing canSafelySkipMountPointCheck method)
//
// It's resolved by adding `mount.Interface` to the struct:
//
//	type MockMounter struct {
//	    mount.Interface
//	    ctrl     *gomock.Controller
//	    recorder *MockMounterMockRecorder
//	}
//
// Automatic generation of the mounter mock is DISABLED so the patched struct
// doesn't get overwritten.
//
// To regenerate:
//
//  1. Remove DISABLED from the `go:generate` comment below.
//  2. Run `make generate`.
//  3. Patch the struct in `mock_mounter.go` as described above.
//  4. Re-add the DISABLED to the `go:generate` comment.
//
// The `MockAttacher` generation works as expected.
//
//go:generate mockgen -copyright_file ../../../hack/mockgen_copyright.txt -package=mounter -mock_names=Interface=MockMounter -destination=mock_mounter.go -source=mounter.go Interface

// Interface defines the actions that an attacher must implement.
type Interface interface {
	mountutils.Interface

	FormatAndMountSensitiveWithFormatOptions(source string, target string, fstype string, options []string, sensitiveOptions []string, formatOptions []string) error
	GetBlockSizeBytes(mountPath string) (int64, error)
	FileExists(path string) (bool, error)
	MakeFile(path string) error
	MakeDir(path string) error
	PathIsDevice(string) (bool, error)
	PathExists(path string) (bool, error)
	CleanupMountPoint(path string) error
	CleanupStagingDir(ctx context.Context, path string) error
}

type HostUtil interface {
	PathIsDevice(string) (bool, error)
}

type Mounter struct {
	*mountutils.SafeFormatAndMount
	hostUtil HostUtil
	tracer   trace.Tracer
}

func New(tp trace.TracerProvider) *Mounter {
	return &Mounter{
		&mountutils.SafeFormatAndMount{
			Interface: mountutils.NewWithoutSystemd(""),
			Exec:      utilexec.New(),
		},
		hostutil.NewHostUtil(),
		tp.Tracer("localdisk.csi.acstor.io/internal/csi/mounter"),
	}
}

func (r *Mounter) PathIsDevice(path string) (bool, error) {
	return r.hostUtil.PathIsDevice(path)
}

func (r *Mounter) PathExists(path string) (bool, error) {
	return mountutils.PathExists(path)
}

func (r *Mounter) CleanupMountPoint(path string) error {
	return mountutils.CleanupMountPoint(path, r, true /* extensiveMountPointCheck */)
}

func (r *Mounter) IsMountPoint(path string) (bool, error) {
	return r.SafeFormatAndMount.IsMountPoint(path)
}

func (r *Mounter) GetBlockSizeBytes(devicePath string) (int64, error) {
	output, err := r.Exec.Command("blockdev", "--getsize64", devicePath).Output()
	if err != nil {
		return 0, fmt.Errorf("error when getting size of block volume at path %s: output: %s, err: %v", devicePath, string(output), err)
	}
	strOut := strings.TrimSpace(string(output))
	gotSizeBytes, err := strconv.ParseInt(strOut, 10, 64)
	if err != nil {
		return 0, fmt.Errorf("failed to parse size %s into int a size", strOut)
	}
	return gotSizeBytes, nil
}

func (r *Mounter) MakeFile(path string) error {
	file, err := os.Create(path)
	if err != nil {
		if !os.IsExist(err) {
			return err
		}
	}

	if err := file.Close(); err != nil {
		return err
	}
	return nil
}

func (r *Mounter) MakeDir(path string) error {
	if err := os.MkdirAll(path, os.FileMode(0755)); err != nil {
		return fmt.Errorf("failed to create directory %s: %w", path, err)
	}
	return nil
}

func (r *Mounter) FileExists(path string) (bool, error) {
	if _, err := os.Lstat(path); err != nil {
		if os.IsNotExist(err) {
			return false, nil
		}
		return false, err
	}
	return true, nil
}

// CleanupStagingDir attempts to unmount the staging dir and remove the volume
// path. It fails after 10 attempts.
func (r *Mounter) CleanupStagingDir(ctx context.Context, path string) error {
	ctx, span := r.tracer.Start(ctx, "csi.v1.Mounter/CleanupStagingDir", trace.WithAttributes(
		attribute.String("device.path", path),
	))
	defer span.End()

	log := log.FromContext(ctx)

	// All errors are retriable.
	isRetriable := func(err error) bool {
		return true
	}

	unmount := func() error {
		// If the path is not found/mounted, return nil.
		refs, err := r.FindMountTargets(ctx, path)
		if err != nil {
			return err
		}
		span.AddEvent("discovering mount targets for device path", trace.WithAttributes(
			attribute.String("device.path", path),
			attribute.StringSlice("mount.paths", refs),
		))

		for _, ref := range refs {
			log.V(2).Info("cleaning up staging dir", "stagingPath", ref)
			span.AddEvent("unmounting", trace.WithAttributes(
				attribute.String("mount.path", ref),
			))

			if err := r.cleanupStagingDir(ref); err != nil {
				span.AddEvent("unmount failed", trace.WithAttributes(
					attribute.String("mount.path", ref),
					attribute.String("error", err.Error()),
				))
				return fmt.Errorf("failed to unmount %s: %w", ref, err)
			}

			span.AddEvent("unmount successful", trace.WithAttributes(
				attribute.String("mount.path", ref),
			))
		}

		return nil
	}
	return retry.OnError(retry.WriteBackoff, isRetriable, unmount)
}

// cleanupStagingDir cleans up the staging directory by unmounting and removing
// it and its parent volume path.
func (r *Mounter) cleanupStagingDir(path string) error {
	// Unmount if still mounted.
	isMnt, err := r.IsMountPoint(path)
	if err != nil {
		return fmt.Errorf("failed to check if path %s is a mount point: %w", path, err)
	}
	if isMnt {
		if err := r.Unmount(path); err != nil {
			return fmt.Errorf("failed to unmount path %s: %w", path, err)
		}
	}

	// Remove the staging directory.
	if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed to remove staging dir %s: %v", path, err)
	}

	// Kubelet would normally remove the vol_data.json and parent directory
	// after NodeUnpublishVolume, but it gets skipped since the staging dir is
	// still mounted. We do it here instead.
	volPath := filepath.Dir(path)
	dataFile := volPath + "/vol_data.json"
	if err := os.Remove(dataFile); err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return fmt.Errorf("failed to delete volume data file %s: %v", dataFile, err)
	}

	// Remove now empty volume path.
	if err := os.Remove(volPath); err != nil && !os.IsNotExist(err) {
		return fmt.Errorf("failed to delete volume path %s: %v", volPath, err)
	}

	return nil
}

// FindMountTargets finds all mount targets for the given path using the findmnt
// command.
//
// It returns a slice of strings containing the mount targets or an error if the
// command fails. If no mount targets are found, it returns an empty slice and a
// nil error.
func (r *Mounter) FindMountTargets(ctx context.Context, path string) ([]string, error) {
	var out bytes.Buffer

	cmd := r.Exec.CommandContext(ctx, "findmnt", "--noheadings", "--uniq", "--output", "TARGET", path)
	cmd.SetStdout(&out)
	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("failed to start findmnt command: %w", err)
	}

	if err := cmd.Wait(); err != nil {
		var exitErr *utilexec.ExitErrorWrapper
		if errors.As(err, &exitErr) {
			if exitErr.ExitCode() == 1 {
				return nil, nil // No mount targets found.
			}
		}
		return nil, fmt.Errorf("findmnt failed: %w", err)
	}
	return strings.Split(strings.TrimSpace(out.String()), "\n"), nil
}
