// Copyright The Prometheus 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 puppetdb

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"path"
	"strconv"
	"strings"
	"time"

	"github.com/grafana/regexp"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/common/config"
	"github.com/prometheus/common/model"
	"github.com/prometheus/common/promslog"
	"github.com/prometheus/common/version"

	"github.com/prometheus/prometheus/discovery"
	"github.com/prometheus/prometheus/discovery/refresh"
	"github.com/prometheus/prometheus/discovery/targetgroup"
)

const (
	pdbLabel            = model.MetaLabelPrefix + "puppetdb_"
	pdbLabelQuery       = pdbLabel + "query"
	pdbLabelCertname    = pdbLabel + "certname"
	pdbLabelResource    = pdbLabel + "resource"
	pdbLabelType        = pdbLabel + "type"
	pdbLabelTitle       = pdbLabel + "title"
	pdbLabelExported    = pdbLabel + "exported"
	pdbLabelTags        = pdbLabel + "tags"
	pdbLabelFile        = pdbLabel + "file"
	pdbLabelEnvironment = pdbLabel + "environment"
	pdbLabelParameter   = pdbLabel + "parameter_"
	separator           = ","
)

var (
	// DefaultSDConfig is the default PuppetDB SD configuration.
	DefaultSDConfig = SDConfig{
		RefreshInterval:  model.Duration(60 * time.Second),
		Port:             80,
		HTTPClientConfig: config.DefaultHTTPClientConfig,
	}
	matchContentType = regexp.MustCompile(`^(?i:application\/json(;\s*charset=("utf-8"|utf-8))?)$`)
	userAgent        = version.PrometheusUserAgent()
)

func init() {
	discovery.RegisterConfig(&SDConfig{})
}

// SDConfig is the configuration for PuppetDB based discovery.
type SDConfig struct {
	HTTPClientConfig  config.HTTPClientConfig `yaml:",inline"`
	RefreshInterval   model.Duration          `yaml:"refresh_interval,omitempty"`
	URL               string                  `yaml:"url"`
	Query             string                  `yaml:"query"`
	IncludeParameters bool                    `yaml:"include_parameters"`
	Port              int                     `yaml:"port"`
}

// NewDiscovererMetrics implements discovery.Config.
func (*SDConfig) NewDiscovererMetrics(_ prometheus.Registerer, rmi discovery.RefreshMetricsInstantiator) discovery.DiscovererMetrics {
	return &puppetdbMetrics{
		refreshMetrics: rmi,
	}
}

// Name returns the name of the Config.
func (*SDConfig) Name() string { return "puppetdb" }

// NewDiscoverer returns a Discoverer for the Config.
func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) {
	return NewDiscovery(c, opts)
}

// SetDirectory joins any relative file paths with dir.
func (c *SDConfig) SetDirectory(dir string) {
	c.HTTPClientConfig.SetDirectory(dir)
}

// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *SDConfig) UnmarshalYAML(unmarshal func(any) error) error {
	*c = DefaultSDConfig
	type plain SDConfig
	err := unmarshal((*plain)(c))
	if err != nil {
		return err
	}
	if c.URL == "" {
		return errors.New("URL is missing")
	}
	parsedURL, err := url.Parse(c.URL)
	if err != nil {
		return err
	}
	if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
		return errors.New("URL scheme must be 'http' or 'https'")
	}
	if parsedURL.Host == "" {
		return errors.New("host is missing in URL")
	}
	if c.Query == "" {
		return errors.New("query missing")
	}
	return c.HTTPClientConfig.Validate()
}

// Discovery provides service discovery functionality based
// on PuppetDB resources.
type Discovery struct {
	*refresh.Discovery
	url               string
	query             string
	port              int
	includeParameters bool
	client            *http.Client
}

// NewDiscovery returns a new PuppetDB discovery for the given config.
func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) {
	m, ok := opts.Metrics.(*puppetdbMetrics)
	if !ok {
		return nil, errors.New("invalid discovery metrics type")
	}

	if opts.Logger == nil {
		opts.Logger = promslog.NewNopLogger()
	}

	client, err := config.NewClientFromConfig(conf.HTTPClientConfig, "http")
	if err != nil {
		return nil, err
	}
	client.Timeout = time.Duration(conf.RefreshInterval)

	u, err := url.Parse(conf.URL)
	if err != nil {
		return nil, err
	}
	u.Path = path.Join(u.Path, "pdb/query/v4")

	d := &Discovery{
		url:               u.String(),
		port:              conf.Port,
		query:             conf.Query,
		includeParameters: conf.IncludeParameters,
		client:            client,
	}

	d.Discovery = refresh.NewDiscovery(
		refresh.Options{
			Logger:              opts.Logger,
			Mech:                "puppetdb",
			SetName:             opts.SetName,
			Interval:            time.Duration(conf.RefreshInterval),
			RefreshF:            d.refresh,
			MetricsInstantiator: m.refreshMetrics,
		},
	)
	return d, nil
}

func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) {
	body := struct {
		Query string `json:"query"`
	}{d.query}
	bodyBytes, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest(http.MethodPost, d.url, bytes.NewBuffer(bodyBytes))
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", userAgent)
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")

	resp, err := d.client.Do(req.WithContext(ctx))
	if err != nil {
		return nil, err
	}
	defer func() {
		io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
	}()

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("server returned HTTP status %s", resp.Status)
	}

	if ct := resp.Header.Get("Content-Type"); !matchContentType.MatchString(ct) {
		return nil, fmt.Errorf("unsupported content type %s", resp.Header.Get("Content-Type"))
	}

	b, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	var resources []Resource

	if err := json.Unmarshal(b, &resources); err != nil {
		return nil, err
	}

	tg := &targetgroup.Group{
		// Use a pseudo-URL as source.
		Source: d.url + "?query=" + d.query,
	}

	for _, resource := range resources {
		labels := model.LabelSet{
			pdbLabelQuery:       model.LabelValue(d.query),
			pdbLabelCertname:    model.LabelValue(resource.Certname),
			pdbLabelResource:    model.LabelValue(resource.Resource),
			pdbLabelType:        model.LabelValue(resource.Type),
			pdbLabelTitle:       model.LabelValue(resource.Title),
			pdbLabelExported:    model.LabelValue(strconv.FormatBool(resource.Exported)),
			pdbLabelFile:        model.LabelValue(resource.File),
			pdbLabelEnvironment: model.LabelValue(resource.Environment),
		}

		addr := net.JoinHostPort(resource.Certname, strconv.FormatUint(uint64(d.port), 10))
		labels[model.AddressLabel] = model.LabelValue(addr)

		if len(resource.Tags) > 0 {
			// We surround the separated list with the separator as well. This way regular expressions
			// in relabeling rules don't have to consider tag positions.
			tags := separator + strings.Join(resource.Tags, separator) + separator
			labels[pdbLabelTags] = model.LabelValue(tags)
		}

		// Parameters are not included by default. This should only be enabled
		// on select resources as it might expose secrets on the Prometheus UI
		// for certain resources.
		if d.includeParameters {
			for k, v := range resource.Parameters.toLabels() {
				labels[pdbLabelParameter+k] = v
			}
		}

		tg.Targets = append(tg.Targets, labels)
	}

	return []*targetgroup.Group{tg}, nil
}
