// -*- Mode: Go; indent-tabs-mode: t -*-

/*
 * Copyright (C) 2021 Canonical Ltd
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License version 3 as
 * published by the Free Software Foundation.
 *
 * 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 <http://www.gnu.org/licenses/>.
 *
 */

package boot

import (
	"fmt"
	"strings"

	"github.com/snapcore/snapd/bootloader"
	"github.com/snapcore/snapd/dirs"
	"github.com/snapcore/snapd/strutil"
)

func dropFromRecoverySystemsList(systemsList []string, systemLabel string) (newList []string, found bool) {
	for idx, sys := range systemsList {
		if sys == systemLabel {
			return append(systemsList[:idx], systemsList[idx+1:]...), true
		}
	}
	return systemsList, false
}

// ClearTryRecoverySystem removes a given candidate recovery system and clears
// the try model in the modeenv state file, then reseals and clears related
// bootloader variables. An empty system label can be passed when the boot
// variables state is inconsistent.
func ClearTryRecoverySystem(dev Device, systemLabel string) error {
	if !dev.HasModeenv() {
		return fmt.Errorf("internal error: recovery systems can only be used on UC20")
	}

	m, err := loadModeenv()
	if err != nil {
		return err
	}
	opts := &bootloader.Options{
		// setup the recovery bootloader
		Role: bootloader.RoleRecovery,
	}
	bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts)
	if err != nil {
		return err
	}

	modified := false
	// we may be repeating the cleanup, in which case the system was already
	// removed from the modeenv and we don't need to rewrite the modeenv
	if updated, found := dropFromRecoverySystemsList(m.CurrentRecoverySystems, systemLabel); found {
		m.CurrentRecoverySystems = updated
		modified = true
	}
	if m.TryModel != "" {
		// recovery system is tried with a matching models
		m.clearTryModel()
		modified = true
	}
	if modified {
		if err := m.Write(); err != nil {
			return err
		}
	}

	// clear both variables, no matter the values they hold
	vars := map[string]string{
		"try_recovery_system":    "",
		"recovery_system_status": "",
	}
	// try to clear regardless of reseal failing
	blErr := bl.SetBootVars(vars)

	// but we still want to reseal, in case the cleanup did not reach this
	// point before
	const expectReseal = true
	resealErr := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal)

	if resealErr != nil {
		return resealErr
	}
	return blErr
}

// SetTryRecoverySystem sets up the boot environment for trying out a recovery
// system with given label in the context of the provided device. The call adds
// the new system to the list of current recovery systems in the modeenv, and
// optionally sets a try model, if the device model is different from the
// current one, which typically can happen during a remodel. Once done, the
// caller should request switching to the given recovery system.
func SetTryRecoverySystem(dev Device, systemLabel string) (err error) {
	if !dev.HasModeenv() {
		return fmt.Errorf("internal error: recovery systems can only be used on UC20")
	}

	m, err := loadModeenv()
	if err != nil {
		return err
	}
	opts := &bootloader.Options{
		// setup the recovery bootloader
		Role: bootloader.RoleRecovery,
	}
	// TODO:UC20: seed may need to be switched to RW
	bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts)
	if err != nil {
		return err
	}

	modified := false
	// we could have rebooted before resealing the keys
	if !strutil.ListContains(m.CurrentRecoverySystems, systemLabel) {
		m.CurrentRecoverySystems = append(m.CurrentRecoverySystems, systemLabel)
		modified = true

	}
	// we either have the current device context, in which case the model
	// will match the current model in the modeenv, or a remodel device
	// context carrying a new model, for which we may need to set the try
	// model in the modeenv
	model := dev.Model()
	if modelUniqueID(model) != modelUniqueID(m.ModelForSealing()) {
		// recovery system is tried with a matching model
		m.setTryModel(model)
		modified = true
	}
	if modified {
		if err := m.Write(); err != nil {
			return err
		}
	}

	defer func() {
		if err == nil {
			return
		}
		if cleanupErr := ClearTryRecoverySystem(dev, systemLabel); cleanupErr != nil {
			err = fmt.Errorf("%v (cleanup failed: %v)", err, cleanupErr)
		}
	}()

	// even when we unexpectedly reboot after updating the bootenv here, we
	// should not boot into the tried system, as the caller must explicitly
	// request that by other means
	vars := map[string]string{
		"try_recovery_system":    systemLabel,
		"recovery_system_status": "try",
	}
	if err := bl.SetBootVars(vars); err != nil {
		return err
	}

	// until the keys are resealed, even if we unexpectedly boot into the
	// tried system, data will still be inaccessible and the system will be
	// considered as nonoperational
	const expectReseal = true
	return resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal)
}

type errInconsistentRecoverySystemState struct {
	why string
}

func (e *errInconsistentRecoverySystemState) Error() string { return e.why }
func IsInconsistentRecoverySystemState(err error) bool {
	_, ok := err.(*errInconsistentRecoverySystemState)
	return ok
}

// InitramfsIsTryingRecoverySystem, typically called while in initramfs of
// recovery mode system, checks whether the boot variables indicate that the
// given recovery system is only being tried. When the state of boot variables
// is inconsistent, eg. status indicates that a recovery system is to be tried,
// but the label is unset, a specific error which can be tested with
// IsInconsystemRecoverySystemState() is returned.
func InitramfsIsTryingRecoverySystem(currentSystemLabel string) (bool, error) {
	opts := &bootloader.Options{
		// setup the recovery bootloader
		Role: bootloader.RoleRecovery,
	}
	bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts)
	if err != nil {
		return false, err
	}

	vars, err := bl.GetBootVars("try_recovery_system", "recovery_system_status")
	if err != nil {
		return false, err
	}

	status := vars["recovery_system_status"]
	switch status {
	case "":
		// not trying any recovery systems right now
		return false, nil
	case "try", "tried":
		// both are valid options, where tried may indicate there was an
		// unexpected reboot somewhere along the path of getting back to
		// the run system
	default:
		return false, &errInconsistentRecoverySystemState{
			why: fmt.Sprintf("unexpected recovery system status %q", status),
		}
	}

	trySystem := vars["try_recovery_system"]
	if trySystem == "" {
		// XXX: could we end up with one variable set and the other not?
		return false, &errInconsistentRecoverySystemState{
			why: fmt.Sprintf("try recovery system is unset but status is %q", status),
		}
	}

	if trySystem == currentSystemLabel {
		// we are running a recovery system indicated in the boot
		// variables, which may or may not be considered good at this
		// point, nonetheless we are in recover mode and thus consider
		// the system as being tried

		// note, with status set to 'tried', we may be back to the
		// tried system again, most likely due to an unexpected reboot
		// when coming back to run mode
		return true, nil
	}
	// we may still be running an actual recovery system if such mode was
	// requested
	return false, nil
}

type TryRecoverySystemOutcome int

const (
	TryRecoverySystemOutcomeFailure TryRecoverySystemOutcome = iota
	TryRecoverySystemOutcomeSuccess
	// TryRecoverySystemOutcomeInconsistent indicates that the booted try
	// recovery system state was incorrect and corresponding boot variables
	// need to be cleared
	TryRecoverySystemOutcomeInconsistent
	// TryRecoverySystemOutcomeNoneTried indicates a state in which no
	// recovery system has been tried
	TryRecoverySystemOutcomeNoneTried
)

// EnsureNextBootToRunModeWithTryRecoverySystemOutcome, typically called while
// in initramfs, updates the boot environment to indicate an outcome of trying
// out a recovery system and sets the system up to boot into run mode. It is up
// to the caller to ensure the status is updated for the right recovery system,
// typically by calling InitramfsIsTryingRecoverySystem beforehand.
func EnsureNextBootToRunModeWithTryRecoverySystemOutcome(outcome TryRecoverySystemOutcome) error {
	opts := &bootloader.Options{
		// setup the recovery bootloader
		Role: bootloader.RoleRecovery,
	}
	// TODO:UC20: seed may need to be switched to RW
	bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts)
	if err != nil {
		return err
	}
	vars := map[string]string{
		// always going to back to run mode
		"snapd_recovery_mode":    "run",
		"snapd_recovery_system":  "",
		"recovery_system_status": "try",
	}
	switch outcome {
	case TryRecoverySystemOutcomeFailure:
		// already set up for this scenario
	case TryRecoverySystemOutcomeSuccess:
		vars["recovery_system_status"] = "tried"
	case TryRecoverySystemOutcomeInconsistent:
		// there may be an unexpected status, or the tried system label
		// is unset, in either case, clear the status
		vars["recovery_system_status"] = ""
	}
	return bl.SetBootVars(vars)
}

func observeSuccessfulSystems(m *Modeenv) (*Modeenv, error) {
	// updates happen in run mode only
	if m.Mode != "run" {
		return m, nil
	}

	// compatibility scenario, no good systems are tracked in modeenv yet,
	// and there is a single entry in the current systems list
	if len(m.GoodRecoverySystems) == 0 && len(m.CurrentRecoverySystems) == 1 {
		newM, err := m.Copy()
		if err != nil {
			return nil, err
		}
		newM.GoodRecoverySystems = []string{m.CurrentRecoverySystems[0]}
		return newM, nil
	}
	return m, nil
}

// InspectTryRecoverySystemOutcome obtains a tried recovery system status. When
// no recovery system has been tried, the outcome will be
// TryRecoverySystemOutcomeNoneTried. The caller is responsible for clearing the
// bootenv once the status bas been properly acted on.
func InspectTryRecoverySystemOutcome(dev Device) (outcome TryRecoverySystemOutcome, label string, err error) {
	opts := &bootloader.Options{
		// setup the recovery bootloader
		Role: bootloader.RoleRecovery,
	}
	// TODO:UC20: seed may need to be switched to RW
	bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts)
	if err != nil {
		return TryRecoverySystemOutcomeFailure, "", err
	}

	vars, err := bl.GetBootVars("try_recovery_system", "recovery_system_status")
	if err != nil {
		return TryRecoverySystemOutcomeFailure, "", err
	}
	status := vars["recovery_system_status"]
	trySystem := vars["try_recovery_system"]

	outcome = TryRecoverySystemOutcomeFailure
	switch {
	case status == "" && trySystem == "":
		// simplest case, not trying a system
		return TryRecoverySystemOutcomeNoneTried, "", nil
	case status != "try" && status != "tried":
		// system label is set, but the status is unexpected status
		return TryRecoverySystemOutcomeInconsistent, "", &errInconsistentRecoverySystemState{
			why: fmt.Sprintf("unexpected recovery system status %q", status),
		}
	case trySystem == "":
		// no system set, but we have status
		return TryRecoverySystemOutcomeInconsistent, "", &errInconsistentRecoverySystemState{
			why: fmt.Sprintf("try recovery system is unset but status is %q", status),
		}
	case status == "tried":
		// check that try_recovery_system ended up in the modeenv's
		// CurrentRecoverySystems
		m, err := ReadModeenv("")
		if err != nil {
			return TryRecoverySystemOutcomeFailure, trySystem, err
		}

		found := false
		for _, sys := range m.CurrentRecoverySystems {
			if sys == trySystem {
				found = true
			}
		}
		if !found {
			return TryRecoverySystemOutcomeFailure, trySystem, &errInconsistentRecoverySystemState{
				why: fmt.Sprintf("recovery system %q was tried, but is not present in the modeenv CurrentRecoverySystems", trySystem),
			}
		}

		outcome = TryRecoverySystemOutcomeSuccess
	}

	return outcome, trySystem, nil
}

// PromoteTriedRecoverySystem promotes the provided recovery system to be
// recognized as a good one, and ensures that the system is present in the list
// of good recovery systems and current recovery systems in modeenv. The
// provided list of tried systems should contain the system in question. If the
// system uses encryption, the keys will updated state. If resealing fails, an
// attempt to restore the previous state is made
func PromoteTriedRecoverySystem(dev Device, systemLabel string, triedSystems []string) (err error) {
	if !dev.HasModeenv() {
		return fmt.Errorf("internal error: recovery systems can only be used on UC20")
	}

	if !strutil.ListContains(triedSystems, systemLabel) {
		// system is not among the tried systems
		return fmt.Errorf("system has not been successfully tried")
	}

	m, err := loadModeenv()
	if err != nil {
		return err
	}
	rewriteModeenv := false
	if !strutil.ListContains(m.CurrentRecoverySystems, systemLabel) {
		m.CurrentRecoverySystems = append(m.CurrentRecoverySystems, systemLabel)
		rewriteModeenv = true
	}
	if !strutil.ListContains(m.GoodRecoverySystems, systemLabel) {
		m.GoodRecoverySystems = append(m.GoodRecoverySystems, systemLabel)
		rewriteModeenv = true
	}
	if rewriteModeenv {
		if err := m.Write(); err != nil {
			return err
		}
	}

	const expectReseal = true
	if err := resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal); err != nil {
		if cleanupErr := DropRecoverySystem(dev, systemLabel); cleanupErr != nil {
			err = fmt.Errorf("%v (cleanup failed: %v)", err, cleanupErr)
		}
		return err
	}
	return nil
}

// DropRecoverySystem drops a provided system from the list of good and current
// recovery systems, updates the modeenv and reseals the keys a needed. Note,
// this call *DOES NOT* clear the boot environment variables.
func DropRecoverySystem(dev Device, systemLabel string) error {
	if !dev.HasModeenv() {
		return fmt.Errorf("internal error: recovery systems can only be used on UC20")
	}

	m, err := loadModeenv()
	if err != nil {
		return err
	}

	rewriteModeenv := false
	if updatedGood, found := dropFromRecoverySystemsList(m.GoodRecoverySystems, systemLabel); found {
		m.GoodRecoverySystems = updatedGood
		rewriteModeenv = true
	}
	if updatedCurrent, found := dropFromRecoverySystemsList(m.CurrentRecoverySystems, systemLabel); found {
		m.CurrentRecoverySystems = updatedCurrent
		rewriteModeenv = true
	}
	if rewriteModeenv {
		if err := m.Write(); err != nil {
			return err
		}
	}

	const expectReseal = true
	return resealKeyToModeenv(dirs.GlobalRootDir, m, expectReseal)
}

// MarkRecoveryCapableSystem records a given system as one that we can recover
// from.
func MarkRecoveryCapableSystem(systemLabel string) error {
	opts := &bootloader.Options{
		// setup the recovery bootloader
		Role: bootloader.RoleRecovery,
	}
	// TODO:UC20: seed may need to be switched to RW
	bl, err := bootloader.Find(InitramfsUbuntuSeedDir, opts)
	if err != nil {
		return err
	}
	rbl, ok := bl.(bootloader.RecoveryAwareBootloader)
	if !ok {
		return nil
	}
	vars, err := rbl.GetBootVars("snapd_good_recovery_systems")
	if err != nil {
		return err
	}
	var systems []string
	if vars["snapd_good_recovery_systems"] != "" {
		systems = strings.Split(vars["snapd_good_recovery_systems"], ",")
	}
	// to be consistent with how modeeenv treats good recovery systems, we
	// append the system, also make sure that the system appears last, such
	// that the bootloader may pick the last entry and have a good default
	foundPos := -1
	for idx, sys := range systems {
		if sys == systemLabel {
			foundPos = idx
			break
		}
	}
	if foundPos == -1 {
		// not found in the list
		systems = append(systems, systemLabel)
	} else if foundPos < len(systems)-1 {
		// not a last entry in the list of systems
		systems = append(systems[0:foundPos], systems[foundPos+1:]...)
		systems = append(systems, systemLabel)
	}

	systemsForEnv := strings.Join(systems, ",")
	return rbl.SetBootVars(map[string]string{
		"snapd_good_recovery_systems": systemsForEnv,
	})
}
