// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

/*
 * The healthcheck package attempts to allow generic checks of arbitrary
 * engines, while providing a common framework with some performance
 * efficiencies in mind.
 *
 * The core of this package is the Executor context; a caller would
 * provision a set of checks, an API client, and a configuration,
 * which the executor would use to decide which checks to execute
 * and how.
 *
 * Checks are based around a series of remote paths that are fetched by
 * the client; these are broken into two categories: static paths, which
 * can always be fetched; and dynamic paths, which the check fetches based
 * on earlier results.
 *
 * For instance, a basic PKI CA lifetime check will have static fetch against
 * the list of CAs, and a dynamic fetch, using that earlier list, to fetch the
 * PEMs of all CAs.
 *
 * This allows health checks to share data: many PKI checks will need the
 * issuer list and so repeatedly fetching this may result in a performance
 * impact.
 */

package healthcheck

import (
	"context"
	"fmt"
	"strings"

	"github.com/hashicorp/vault/api"
	"github.com/hashicorp/vault/sdk/logical"
)

type Executor struct {
	Client         *api.Client
	Mount          string
	DefaultEnabled bool

	Config map[string]map[string]interface{}

	Resources map[string]map[logical.Operation]*PathFetch

	Checkers []Check
}

func NewExecutor(client *api.Client, mount string) *Executor {
	return &Executor{
		Client:         client,
		DefaultEnabled: true,
		Mount:          mount,
		Config:         make(map[string]map[string]interface{}),
		Resources:      make(map[string]map[logical.Operation]*PathFetch),
	}
}

func (e *Executor) AddCheck(c Check) {
	e.Checkers = append(e.Checkers, c)
}

func (e *Executor) BuildConfig(external map[string]interface{}) error {
	merged := e.Config

	for index, checker := range e.Checkers {
		name := checker.Name()
		if _, present := merged[name]; name == "" || present {
			return fmt.Errorf("bad checker %v: name is empty or already present: %v", index, name)
		}

		// Fetch the default configuration; if the check returns enabled
		// status, verify it matches our expectations (in the event it should
		// be disabled by default), otherwise, add it in.
		config := checker.DefaultConfig()
		enabled, present := config["enabled"]
		if !present {
			config["enabled"] = e.DefaultEnabled
		} else if enabled.(bool) && !e.DefaultEnabled {
			config["enabled"] = e.DefaultEnabled
		}

		// Now apply any external config for this check.
		if econfig, present := external[name]; present {
			for param, evalue := range econfig.(map[string]interface{}) {
				if _, ok := config[param]; !ok {
					// Assumption: default configs have all possible
					// configuration options. This external config has
					// an unknown option, so we want to error out.
					return fmt.Errorf("unknown configuration option for %v: %v", name, param)
				}

				config[param] = evalue
			}
		}

		// Now apply it and save it.
		if err := checker.LoadConfig(config); err != nil {
			return fmt.Errorf("error saving merged config for %v: %w", name, err)
		}
		merged[name] = config
	}

	return nil
}

func (e *Executor) Execute() (map[string][]*Result, error) {
	ret := make(map[string][]*Result)
	for _, checker := range e.Checkers {
		if !checker.IsEnabled() {
			continue
		}

		if err := checker.FetchResources(e); err != nil {
			return nil, fmt.Errorf("failed to fetch resources %v: %w", checker.Name(), err)
		}

		results, err := checker.Evaluate(e)
		if err != nil {
			return nil, fmt.Errorf("failed to evaluate %v: %w", checker.Name(), err)
		}

		if results == nil {
			results = []*Result{}
		}

		for _, result := range results {
			result.Endpoint = e.templatePath(result.Endpoint)
			result.StatusDisplay = ResultStatusNameMap[result.Status]
		}

		ret[checker.Name()] = results
	}

	return ret, nil
}

func (e *Executor) templatePath(path string) string {
	return strings.ReplaceAll(path, "{{mount}}", e.Mount)
}

func (e *Executor) FetchIfNotFetched(op logical.Operation, rawPath string) (*PathFetch, error) {
	path := e.templatePath(rawPath)

	byOp, present := e.Resources[path]
	if present && byOp != nil {
		result, present := byOp[op]
		if present && result != nil {
			return result, result.FetchSurfaceError()
		}
	}

	// Must not exist in cache; create it.
	if byOp == nil {
		e.Resources[path] = make(map[logical.Operation]*PathFetch)
	}

	ret := &PathFetch{
		Operation:   op,
		Path:        path,
		ParsedCache: make(map[string]interface{}),
	}

	data := map[string][]string{}
	if op == logical.ListOperation {
		data["list"] = []string{"true"}
	} else if op != logical.ReadOperation {
		return nil, fmt.Errorf("unknown operation: %v on %v", op, path)
	}

	// client.ReadRaw* methods require a manual timeout override
	ctx, cancel := context.WithTimeout(context.Background(), e.Client.ClientTimeout())
	defer cancel()

	response, err := e.Client.Logical().ReadRawWithDataWithContext(ctx, path, data)
	ret.Response = response
	if err != nil {
		ret.FetchError = err
	} else {
		// Not all secrets will parse correctly. Sometimes we really want
		// to fetch a raw endpoint, sometimes we're run with a bad mount
		// or missing permissions.
		secret, secretErr := e.Client.Logical().ParseRawResponseAndCloseBody(response, err)
		if secretErr != nil {
			ret.SecretParseError = secretErr
		} else {
			ret.Secret = secret
		}
	}

	e.Resources[path][op] = ret
	return ret, ret.FetchSurfaceError()
}

type PathFetch struct {
	Operation        logical.Operation
	Path             string
	Response         *api.Response
	FetchError       error
	Secret           *api.Secret
	SecretParseError error
	ParsedCache      map[string]interface{}
}

func (p *PathFetch) IsOK() bool {
	return p.FetchError == nil && p.Response != nil
}

func (p *PathFetch) IsSecretOK() bool {
	return p.IsOK() && p.SecretParseError == nil && p.Secret != nil
}

func (p *PathFetch) FetchSurfaceError() error {
	if p.IsOK() || p.IsSecretPermissionsError() || p.IsUnsupportedPathError() || p.IsMissingResource() || p.Is404NotFound() {
		return nil
	}

	if strings.Contains(p.FetchError.Error(), "route entry not found") {
		return fmt.Errorf("Error making API request: was a bad mount given?\n\nOperation: %v\nPath: %v\nOriginal Error:\n%w", p.Operation, p.Path, p.FetchError)
	}

	return p.FetchError
}

func (p *PathFetch) IsSecretPermissionsError() bool {
	return !p.IsOK() && strings.Contains(p.FetchError.Error(), "permission denied")
}

func (p *PathFetch) IsUnsupportedPathError() bool {
	return !p.IsOK() && strings.Contains(p.FetchError.Error(), "unsupported path")
}

func (p *PathFetch) IsMissingResource() bool {
	return !p.IsOK() && strings.Contains(p.FetchError.Error(), "unable to find")
}

func (p *PathFetch) Is404NotFound() bool {
	return !p.IsOK() && strings.HasSuffix(strings.TrimSpace(p.FetchError.Error()), "Code: 404. Errors:")
}

type Check interface {
	Name() string
	IsEnabled() bool

	DefaultConfig() map[string]interface{}
	LoadConfig(config map[string]interface{}) error

	FetchResources(e *Executor) error

	Evaluate(e *Executor) ([]*Result, error)
}

type ResultStatus int

const (
	ResultNotApplicable ResultStatus = iota
	ResultOK
	ResultInformational
	ResultWarning
	ResultCritical
	ResultInvalidVersion
	ResultInsufficientPermissions
)

var ResultStatusNameMap = map[ResultStatus]string{
	ResultNotApplicable:           "not_applicable",
	ResultOK:                      "ok",
	ResultInformational:           "informational",
	ResultWarning:                 "warning",
	ResultCritical:                "critical",
	ResultInvalidVersion:          "invalid_version",
	ResultInsufficientPermissions: "insufficient_permissions",
}

var NameResultStatusMap = map[string]ResultStatus{
	"not_applicable":           ResultNotApplicable,
	"ok":                       ResultOK,
	"informational":            ResultInformational,
	"warning":                  ResultWarning,
	"critical":                 ResultCritical,
	"invalid_version":          ResultInvalidVersion,
	"insufficient_permissions": ResultInsufficientPermissions,
}

type Result struct {
	Status        ResultStatus `json:"status_code"`
	StatusDisplay string       `json:"status"`
	Endpoint      string       `json:"endpoint,omitempty"`
	Message       string       `json:"message,omitempty"`
}
