// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.

package install

import (
	"bufio"
	"errors"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/siderolabs/go-blockdevice/blockdevice"
	"github.com/siderolabs/go-blockdevice/blockdevice/partition/gpt"
	"github.com/siderolabs/go-retry/retry"

	"github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
	"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/board"
	"github.com/siderolabs/talos/internal/pkg/mount"
	"github.com/siderolabs/talos/internal/pkg/partition"
	"github.com/siderolabs/talos/pkg/machinery/constants"
)

// Manifest represents the instructions for preparing all block devices
// for an installation.
type Manifest struct {
	PartitionOptions  *runtime.PartitionOptions
	Devices           map[string]Device
	Targets           map[string][]*Target
	LegacyBIOSSupport bool
}

// Device represents device options.
type Device struct {
	Device string

	ResetPartitionTable bool
	Zero                bool

	SkipOverlayMountsCheck bool
}

// NewManifest initializes and returns a Manifest.
//
//nolint:gocyclo
func NewManifest(label string, sequence runtime.Sequence, bootPartitionFound bool, opts *Options) (manifest *Manifest, err error) {
	if label == "" {
		return nil, fmt.Errorf("a label is required, got \"\"")
	}

	manifest = &Manifest{
		Devices:           map[string]Device{},
		Targets:           map[string][]*Target{},
		LegacyBIOSSupport: opts.LegacyBIOSSupport,
	}

	if opts.Board != constants.BoardNone {
		var b runtime.Board

		b, err = board.NewBoard(opts.Board)
		if err != nil {
			return nil, err
		}

		manifest.PartitionOptions = b.PartitionOptions()
	}

	// TODO: legacy, to support old Talos initramfs, assume force if boot partition not found
	if !bootPartitionFound {
		opts.Force = true
	}

	if !opts.Force && opts.Zero {
		return nil, fmt.Errorf("zero option can't be used without force")
	}

	if !opts.Force && !bootPartitionFound {
		return nil, fmt.Errorf("install with preserve is not supported if existing boot partition was not found")
	}

	// Verify that the target device(s) can satisfy the requested options.

	if sequence != runtime.SequenceUpgrade {
		if err = VerifyEphemeralPartition(opts); err != nil {
			return nil, fmt.Errorf("failed to prepare ephemeral partition: %w", err)
		}

		if err = VerifyBootPartition(opts); err != nil {
			return nil, fmt.Errorf("failed to prepare boot partition: %w", err)
		}
	}

	skipOverlayMountsCheck, err := shouldSkipOverlayMountsCheck(sequence)
	if err != nil {
		return nil, err
	}

	manifest.Devices[opts.Disk] = Device{
		Device: opts.Disk,

		ResetPartitionTable: opts.Force,
		Zero:                opts.Zero,

		SkipOverlayMountsCheck: skipOverlayMountsCheck,
	}

	// Initialize any slices we need. Note that a boot partition is not
	// required.

	if manifest.Targets[opts.Disk] == nil {
		manifest.Targets[opts.Disk] = []*Target{}
	}

	efiTarget := EFITarget(opts.Disk, nil)
	biosTarget := BIOSTarget(opts.Disk, nil)

	var bootTarget *Target

	if opts.Bootloader {
		bootTarget = BootTarget(opts.Disk, &Target{
			PreserveContents: bootPartitionFound,
			Assets: []*Asset{
				{
					Source:      fmt.Sprintf(constants.KernelAssetPath, opts.Arch),
					Destination: filepath.Join(constants.BootMountPoint, label, constants.KernelAsset),
				},
				{
					Source:      fmt.Sprintf(constants.InitramfsAssetPath, opts.Arch),
					Destination: filepath.Join(constants.BootMountPoint, label, constants.InitramfsAsset),
				},
			},
		})
	}

	metaTarget := MetaTarget(opts.Disk, &Target{
		PreserveContents: bootPartitionFound,
	})

	stateTarget := StateTarget(opts.Disk, &Target{
		PreserveContents: bootPartitionFound,
		FormatOptions: &partition.FormatOptions{
			FileSystemType: partition.FilesystemTypeNone,
		},
	})

	ephemeralTarget := EphemeralTarget(opts.Disk, NoFilesystem)

	targets := []*Target{efiTarget, biosTarget, bootTarget, metaTarget, stateTarget, ephemeralTarget}

	if !opts.Force {
		for _, target := range targets {
			target.Force = false
			target.Skip = true
		}
	}

	for _, target := range targets {
		if target == nil {
			continue
		}

		manifest.Targets[target.Device] = append(manifest.Targets[target.Device], target)
	}

	return manifest, nil
}

// Execute partitions and formats all disks in a manifest.
func (m *Manifest) Execute() (err error) {
	for dev, targets := range m.Targets {
		if err = m.executeOnDevice(m.Devices[dev], targets); err != nil {
			return err
		}
	}

	return nil
}

// checkMounts verifies that no active mounts in any mount namespace exist for the device.
//
//nolint:gocyclo
func (m *Manifest) checkMounts(device Device) error {
	matches, err := filepath.Glob("/proc/*/mountinfo")
	if err != nil {
		return err
	}

	for _, path := range matches {
		path := path

		if err = func() error {
			var f *os.File

			f, err = os.Open(path)
			if err != nil {
				// ignore error in case process got removed
				return nil //nolint:nilerr
			}

			defer f.Close() //nolint:errcheck

			scanner := bufio.NewScanner(f)
			for scanner.Scan() {
				fields := strings.Fields(scanner.Text())

				if len(fields) < 2 {
					continue
				}

				if !device.SkipOverlayMountsCheck && fields[len(fields)-2] == "overlay" {
					//nolint:dupword
					// parsing options (last column) in the overlay mount line which looks like:
					// 163 70 0:52 /apid / ro,relatime - overlay overlay rw,lowerdir=/opt,upperdir=/var/system/overlays/opt-diff,workdir=/var/system/overlays/opt-workdir

					options := strings.Split(fields[len(fields)-1], ",")

					for _, option := range options {
						parts := strings.SplitN(option, "=", 2)
						if len(parts) == 2 {
							if strings.HasPrefix(parts[1], "/var/") {
								return fmt.Errorf("found overlay mount in %q: %s", path, scanner.Text())
							}
						}
					}
				}

				if strings.HasPrefix(fields[len(fields)-2], device.Device) {
					return fmt.Errorf("found active mount in %q for %q: %s", path, device.Device, scanner.Text())
				}
			}

			return f.Close()
		}(); err != nil {
			return err
		}
	}

	return nil
}

//nolint:gocyclo,cyclop
func (m *Manifest) executeOnDevice(device Device, targets []*Target) (err error) {
	if err = m.checkMounts(device); err != nil {
		return err
	}

	if err = m.preserveContents(device, targets); err != nil {
		return err
	}

	if device.Zero {
		if err = m.zeroDevice(device); err != nil {
			return err
		}
	}

	var bd *blockdevice.BlockDevice

	if bd, err = blockdevice.Open(device.Device, blockdevice.WithExclusiveLock(true)); err != nil {
		return err
	}

	//nolint:errcheck
	defer bd.Close()

	var pt *gpt.GPT

	created := false

	pt, err = bd.PartitionTable()
	if err != nil {
		if !errors.Is(err, blockdevice.ErrMissingPartitionTable) {
			return err
		}

		log.Printf("creating new partition table on %s", device.Device)

		gptOpts := []gpt.Option{
			gpt.WithMarkMBRBootable(m.LegacyBIOSSupport),
		}

		if m.PartitionOptions != nil {
			gptOpts = append(gptOpts, gpt.WithPartitionEntriesStartLBA(m.PartitionOptions.PartitionsOffset))
		}

		pt, err = gpt.New(bd.Device(), gptOpts...)
		if err != nil {
			return err
		}

		log.Printf("logical/physical block size: %d/%d", pt.Header().LBA.LogicalBlockSize, pt.Header().LBA.PhysicalBlockSize)
		log.Printf("minimum/optimal I/O size: %d/%d", pt.Header().LBA.MinimalIOSize, pt.Header().LBA.OptimalIOSize)

		if err = pt.Write(); err != nil {
			return err
		}

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

		if bd, err = blockdevice.Open(device.Device, blockdevice.WithExclusiveLock(true)); err != nil {
			return err
		}

		defer bd.Close() //nolint:errcheck

		created = true
	}

	if !created {
		if device.ResetPartitionTable {
			log.Printf("resetting partition table on %s", device.Device)

			// TODO: how should it work with zero option above?
			if err = bd.Reset(); err != nil {
				return err
			}
		} else {
			// clean up partitions which are going to be recreated
			keepPartitions := map[string]struct{}{}

			for _, target := range targets {
				if target.Skip {
					keepPartitions[target.Label] = struct{}{}
				}
			}

			// make sure all partitions to be skipped already exist
			missingPartitions := map[string]struct{}{}

			for label := range keepPartitions {
				missingPartitions[label] = struct{}{}
			}

			for _, part := range pt.Partitions().Items() {
				delete(missingPartitions, part.Name)
			}

			if len(missingPartitions) > 0 {
				return fmt.Errorf("some partitions to be skipped are missing: %v", missingPartitions)
			}

			// delete all partitions which are not skipped
			for _, part := range pt.Partitions().Items() {
				if _, ok := keepPartitions[part.Name]; !ok {
					log.Printf("deleting partition %s", part.Name)

					if err = pt.Delete(part); err != nil {
						return err
					}
				}
			}

			if err = pt.Write(); err != nil {
				return err
			}
		}
	}

	pt, err = bd.PartitionTable()
	if err != nil {
		return err
	}

	for i, target := range targets {
		if err = target.Partition(pt, i, bd); err != nil {
			return fmt.Errorf("failed to partition device: %w", err)
		}
	}

	if err = pt.Write(); err != nil {
		return err
	}

	for _, target := range targets {
		target := target

		err = retry.Constant(time.Minute, retry.WithUnits(100*time.Millisecond)).Retry(func() error {
			e := target.Format()
			if e != nil {
				if strings.Contains(e.Error(), "No such file or directory") {
					// workaround problem with partition device not being visible immediately after partitioning
					return retry.ExpectedError(e)
				}

				return e
			}

			return nil
		})

		if err != nil {
			return fmt.Errorf("failed to format device: %w", err)
		}
	}

	return m.restoreContents(targets)
}

//nolint:gocyclo
func (m *Manifest) preserveContents(device Device, targets []*Target) (err error) {
	anyPreserveContents := false

	for _, target := range targets {
		if target.Skip {
			continue
		}

		if target.PreserveContents {
			anyPreserveContents = true

			break
		}
	}

	if !anyPreserveContents {
		// no target to preserve contents, exit early
		return nil
	}

	var bd *blockdevice.BlockDevice

	if bd, err = blockdevice.Open(device.Device); err != nil {
		// failed to open the block device, probably it's damaged?
		log.Printf("warning: skipping preserve contents on %q as block device failed: %s", device.Device, err)

		return nil
	}

	//nolint:errcheck
	defer bd.Close()

	pt, err := bd.PartitionTable()
	if err != nil {
		log.Printf("warning: skipping preserve contents on %q as partition table failed: %s", device.Device, err)

		return nil
	}

	for _, target := range targets {
		if target.Skip {
			continue
		}

		if !target.PreserveContents {
			continue
		}

		var (
			sourcePart     *gpt.Partition
			fileSystemType partition.FileSystemType
			fnmatchFilters []string
		)

		sources := append([]PreserveSource{
			{
				Label:          target.Label,
				FileSystemType: target.FileSystemType,
			},
		}, target.ExtraPreserveSources...)

		for _, source := range sources {
			// find matching existing partition table entry
			for _, part := range pt.Partitions().Items() {
				if part.Name == source.Label {
					sourcePart = part
					fileSystemType = source.FileSystemType
					fnmatchFilters = source.FnmatchFilters

					break
				}
			}
		}

		if sourcePart == nil {
			log.Printf("warning: failed to preserve contents of %q on %q, as source partition wasn't found", target.Label, device.Device)

			continue
		}

		if err = target.SaveContents(device, sourcePart, fileSystemType, fnmatchFilters); err != nil {
			log.Printf("warning: failed to preserve contents of %q on %q: %s", target.Label, device.Device, err)
		}
	}

	return nil
}

func (m *Manifest) restoreContents(targets []*Target) error {
	for _, target := range targets {
		if err := target.RestoreContents(); err != nil {
			return fmt.Errorf("error restoring contents for %q: %w", target.Label, err)
		}
	}

	return nil
}

// SystemMountpoints returns list of system mountpoints for the manifest.
func (m *Manifest) SystemMountpoints(opts ...mount.Option) (*mount.Points, error) {
	mountpoints := mount.NewMountPoints()

	for dev := range m.Targets {
		mp, err := mount.SystemMountPointsForDevice(dev, opts...)
		if err != nil {
			return nil, err
		}

		iter := mp.Iter()
		for iter.Next() {
			mountpoints.Set(iter.Key(), iter.Value())
		}
	}

	return mountpoints, nil
}

// zeroDevice fills the device with zeroes.
func (m *Manifest) zeroDevice(device Device) (err error) {
	var bd *blockdevice.BlockDevice

	log.Printf("wiping %q", device.Device)

	if bd, err = blockdevice.Open(device.Device, blockdevice.WithExclusiveLock(true)); err != nil {
		return err
	}

	defer bd.Close() //nolint:errcheck

	var method string

	if method, err = bd.Wipe(); err != nil {
		return err
	}

	log.Printf("wiped %q with %q", device.Device, method)

	return bd.Close()
}

func shouldSkipOverlayMountsCheck(sequence runtime.Sequence) (bool, error) {
	var skipOverlayMountsCheck bool

	_, err := os.Stat("/.dockerenv")

	switch {
	case err == nil:
		skipOverlayMountsCheck = true
	case os.IsNotExist(err):
		skipOverlayMountsCheck = sequence == runtime.SequenceNoop
	default:
		return false, fmt.Errorf("cannot determine if /.dockerenv exists: %w", err)
	}

	return skipOverlayMountsCheck, nil
}
