/*
Copyright 2023 ceph-csi authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package healthchecker

import (
	"fmt"
	"os"
	"path/filepath"
	"sync"
)

// CheckerType describes the type of health-check that needs to be done.
type CheckerType uint64

const (
	// StatCheckerType uses the stat() syscall to validate volume health.
	StatCheckerType = iota
	// FileCheckerType writes and reads a timestamp to a file for checking the
	// volume health.
	FileCheckerType
)

// Manager provides the API for getting the health status of a volume. The main
// usage is requesting the health status by volumeID.
//
// When the Manager detects that a new volumeID is used for checking, a new
// instance of a ConditionChecker is created for the volumeID on the given
// path, and started.
//
// Once the volumeID is not active anymore (when NodeUnstageVolume is called),
// the ConditionChecker needs to be stopped, which can be done by
// Manager.StopChecker().
type Manager interface {
	// StartChecker starts a health-checker of the requested type for the
	// volumeID using the path. The path usually is the publishTargetPath, and
	// a unique path for this checker. If the path can be used by multiple
	// containers, use the StartSharedChecker function instead.
	StartChecker(volumeID, path string, ct CheckerType) error

	// StartSharedChecker starts a health-checker of the requested type for the
	// volumeID using the path. The path usually is the stagingTargetPath, and
	// can be used for multiple containers.
	StartSharedChecker(volumeID, path string, ct CheckerType) error

	StopChecker(volumeID, path string)
	StopSharedChecker(volumeID string)

	// IsHealthy locates the checker for the volumeID and path. If no checker
	// is found, `true` is returned together with an error message.
	// When IsHealthy runs into an internal error, it is assumed that the
	// volume is healthy. Only when it is confirmed that the volume is
	// unhealthy, `false` is returned together with an error message.
	IsHealthy(volumeID, path string) (bool, error)
}

// ConditionChecker describes the interface that a health status reporter needs
// to implement. It is used internally by the Manager only.
type ConditionChecker interface {
	// start runs a the health checking function in a new go routine.
	start()

	// stop terminates a the health checking function that runs in a go
	// routine.
	stop()

	// isHealthy returns the status of the volume, without blocking.
	isHealthy() (bool, error)
}

type healthCheckManager struct {
	checkers sync.Map // map[volumeID]ConditionChecker
}

func NewHealthCheckManager() Manager {
	return &healthCheckManager{
		checkers: sync.Map{},
	}
}

func (hcm *healthCheckManager) StartSharedChecker(volumeID, path string, ct CheckerType) error {
	return hcm.createChecker(volumeID, path, ct, true)
}

func (hcm *healthCheckManager) StartChecker(volumeID, path string, ct CheckerType) error {
	return hcm.createChecker(volumeID, path, ct, false)
}

func (hcm *healthCheckManager) StopSharedChecker(volumeID string) {
	hcm.StopChecker(volumeID, "")
}

func (hcm *healthCheckManager) StopChecker(volumeID, path string) {
	old, ok := hcm.checkers.LoadAndDelete(fallbackKey(volumeID, path))
	if !ok {
		// nothing was loaded, nothing to do
		return
	}

	// 'old' was loaded, cast it to ConditionChecker
	cc, ok := old.(ConditionChecker)
	if !ok {
		// failed to cast, should not be possible
		return
	}
	cc.stop()
}

func (hcm *healthCheckManager) IsHealthy(volumeID, path string) (bool, error) {
	// load the 'old' ConditionChecker if it exists
	old, ok := hcm.checkers.Load(volumeID)
	if !ok {
		// try fallback which include an optional (unique) path (usually publishTargetPath)
		old, ok = hcm.checkers.Load(fallbackKey(volumeID, path))
		if !ok {
			return true, fmt.Errorf("no ConditionChecker for volume-id: %s", volumeID)
		}
	}

	// 'old' was loaded, cast it to ConditionChecker
	cc, ok := old.(ConditionChecker)
	if !ok {
		return true, fmt.Errorf("failed to cast cc to ConditionChecker for volume-id %q", volumeID)
	}

	return cc.isHealthy()
}

// createChecker decides based on the CheckerType what checker to start for
// the volume.
func (hcm *healthCheckManager) createChecker(volumeID, path string, ct CheckerType, shared bool) error {
	switch ct {
	case FileCheckerType:
		return hcm.startFileChecker(volumeID, path, shared)
	case StatCheckerType:
		return hcm.startStatChecker(volumeID, path, shared)
	}

	return nil
}

// startFileChecker initializes the fileChecker and starts it.
func (hcm *healthCheckManager) startFileChecker(volumeID, path string, shared bool) error {
	workdir := filepath.Join(path, ".csi")
	err := os.Mkdir(workdir, 0o750)
	if err != nil && !os.IsExist(err) {
		return fmt.Errorf("failed to created workdir %q for health-checker: %w", workdir, err)
	}

	cc := newFileChecker(workdir)

	return hcm.startChecker(cc, volumeID, path, shared)
}

// startStatChecker initializes the statChecker and starts it.
func (hcm *healthCheckManager) startStatChecker(volumeID, path string, shared bool) error {
	cc := newStatChecker(path)

	return hcm.startChecker(cc, volumeID, path, shared)
}

// startChecker adds the checker to its map and starts it.
// Shared checkers are key'd by their volumeID, whereas non-shared checkers
// are key'd by theit volumeID+path.
func (hcm *healthCheckManager) startChecker(cc ConditionChecker, volumeID, path string, shared bool) error {
	key := volumeID
	if !shared {
		// if it is non-shared checker, try to use the path as well.
		key = fallbackKey(volumeID, path)
	}

	// load the 'old' ConditionChecker if it exists, otherwise store 'cc'
	old, ok := hcm.checkers.LoadOrStore(key, cc)
	if ok {
		// 'old' was loaded, cast it to ConditionChecker
		_, ok = old.(ConditionChecker)
		if !ok {
			return fmt.Errorf("failed to cast cc to ConditionChecker for volume-id %q", volumeID)
		}
	} else {
		// 'cc' was stored, start it only once
		cc.start()
	}

	return nil
}

// fallbackKey returns the key for a checker in the map. If the path is empty,
// it is assumed that the key'd checked is shared.
func fallbackKey(volumeID, path string) string {
	if path == "" {
		return volumeID
	}

	return fmt.Sprintf("%s:%s", volumeID, path)
}
