package consulcatalog

import (
	"context"
	"fmt"
	"strconv"
	"strings"
	"text/template"
	"time"

	"github.com/cenkalti/backoff/v4"
	"github.com/hashicorp/consul/api"
	"github.com/hashicorp/consul/api/watch"
	"github.com/hashicorp/go-hclog"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	ptypes "github.com/traefik/paerser/types"
	"github.com/traefik/traefik/v3/pkg/config/dynamic"
	"github.com/traefik/traefik/v3/pkg/job"
	"github.com/traefik/traefik/v3/pkg/observability/logs"
	"github.com/traefik/traefik/v3/pkg/provider"
	"github.com/traefik/traefik/v3/pkg/provider/constraints"
	"github.com/traefik/traefik/v3/pkg/safe"
	"github.com/traefik/traefik/v3/pkg/types"
)

// defaultTemplateRule is the default template for the default rule.
const defaultTemplateRule = "Host(`{{ normalize .Name }}`)"

// ProviderName is the Consul Catalog provider name.
const ProviderName = "consulcatalog"

var _ provider.Provider = (*Provider)(nil)

type itemData struct {
	ID         string
	Node       string
	Datacenter string
	Name       string
	Namespace  string
	Address    string
	Port       string
	Status     string
	Labels     map[string]string
	Tags       []string
	ExtraConf  configuration
}

// ProviderBuilder is responsible for constructing namespaced instances of the Consul Catalog provider.
type ProviderBuilder struct {
	Configuration `yaml:",inline" export:"true"`

	Namespaces []string `description:"Sets the namespaces used to discover services (Consul Enterprise only)." json:"namespaces,omitempty" toml:"namespaces,omitempty" yaml:"namespaces,omitempty"`
}

// BuildProviders builds Consul Catalog provider instances for the given namespaces configuration.
func (p *ProviderBuilder) BuildProviders() []*Provider {
	if len(p.Namespaces) == 0 {
		return []*Provider{{
			Configuration: p.Configuration,
			name:          ProviderName,
		}}
	}

	var providers []*Provider
	for _, namespace := range p.Namespaces {
		providers = append(providers, &Provider{
			Configuration: p.Configuration,
			name:          ProviderName + "-" + namespace,
			namespace:     namespace,
		})
	}

	return providers
}

// Configuration represents the Consul Catalog provider configuration.
type Configuration struct {
	Constraints       string          `description:"Constraints is an expression that Traefik matches against the container's labels to determine whether to create any route for that container." json:"constraints,omitempty" toml:"constraints,omitempty" yaml:"constraints,omitempty" export:"true"`
	Endpoint          *EndpointConfig `description:"Consul endpoint settings" json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty" export:"true"`
	Prefix            string          `description:"Prefix for consul service tags." json:"prefix,omitempty" toml:"prefix,omitempty" yaml:"prefix,omitempty" export:"true"`
	RefreshInterval   ptypes.Duration `description:"Interval for check Consul API." json:"refreshInterval,omitempty" toml:"refreshInterval,omitempty" yaml:"refreshInterval,omitempty" export:"true"`
	RequireConsistent bool            `description:"Forces the read to be fully consistent." json:"requireConsistent,omitempty" toml:"requireConsistent,omitempty" yaml:"requireConsistent,omitempty" export:"true"`
	Stale             bool            `description:"Use stale consistency for catalog reads." json:"stale,omitempty" toml:"stale,omitempty" yaml:"stale,omitempty" export:"true"`
	Cache             bool            `description:"Use local agent caching for catalog reads." json:"cache,omitempty" toml:"cache,omitempty" yaml:"cache,omitempty" export:"true"`
	ExposedByDefault  bool            `description:"Expose containers by default." json:"exposedByDefault,omitempty" toml:"exposedByDefault,omitempty" yaml:"exposedByDefault,omitempty" export:"true"`
	DefaultRule       string          `description:"Default rule." json:"defaultRule,omitempty" toml:"defaultRule,omitempty" yaml:"defaultRule,omitempty"`
	ConnectAware      bool            `description:"Enable Consul Connect support." json:"connectAware,omitempty" toml:"connectAware,omitempty" yaml:"connectAware,omitempty" export:"true"`
	ConnectByDefault  bool            `description:"Consider every service as Connect capable by default." json:"connectByDefault,omitempty" toml:"connectByDefault,omitempty" yaml:"connectByDefault,omitempty" export:"true"`
	ServiceName       string          `description:"Name of the Traefik service in Consul Catalog (needs to be registered via the orchestrator or manually)." json:"serviceName,omitempty" toml:"serviceName,omitempty" yaml:"serviceName,omitempty" export:"true"`
	Watch             bool            `description:"Watch Consul API events." json:"watch,omitempty" toml:"watch,omitempty" yaml:"watch,omitempty" export:"true"`
	StrictChecks      []string        `description:"A list of service health statuses to allow taking traffic." json:"strictChecks,omitempty" toml:"strictChecks,omitempty" yaml:"strictChecks,omitempty" export:"true"`
}

// SetDefaults sets the default values.
func (c *Configuration) SetDefaults() {
	c.Endpoint = &EndpointConfig{}
	c.RefreshInterval = ptypes.Duration(15 * time.Second)
	c.Prefix = "traefik"
	c.ExposedByDefault = true
	c.DefaultRule = defaultTemplateRule
	c.ServiceName = "traefik"
	c.StrictChecks = defaultStrictChecks()
}

// Provider is the Consul Catalog provider implementation.
type Provider struct {
	Configuration

	name              string
	namespace         string
	client            *api.Client
	defaultRuleTpl    *template.Template
	certChan          chan *connectCert
	watchServicesChan chan struct{}
}

// EndpointConfig holds configurations of the endpoint.
type EndpointConfig struct {
	Address          string                  `description:"The address of the Consul server" json:"address,omitempty" toml:"address,omitempty" yaml:"address,omitempty"`
	Scheme           string                  `description:"The URI scheme for the Consul server" json:"scheme,omitempty" toml:"scheme,omitempty" yaml:"scheme,omitempty"`
	DataCenter       string                  `description:"Data center to use. If not provided, the default agent data center is used" json:"datacenter,omitempty" toml:"datacenter,omitempty" yaml:"datacenter,omitempty"`
	Token            string                  `description:"Token is used to provide a per-request ACL token which overrides the agent's default token" json:"token,omitempty" toml:"token,omitempty" yaml:"token,omitempty" loggable:"false"`
	TLS              *types.ClientTLS        `description:"Enable TLS support." json:"tls,omitempty" toml:"tls,omitempty" yaml:"tls,omitempty" export:"true"`
	HTTPAuth         *EndpointHTTPAuthConfig `description:"Auth info to use for http access" json:"httpAuth,omitempty" toml:"httpAuth,omitempty" yaml:"httpAuth,omitempty" export:"true"`
	EndpointWaitTime ptypes.Duration         `description:"WaitTime limits how long a Watch will block. If not provided, the agent default values will be used" json:"endpointWaitTime,omitempty" toml:"endpointWaitTime,omitempty" yaml:"endpointWaitTime,omitempty" export:"true"`
}

// EndpointHTTPAuthConfig holds configurations of the authentication.
type EndpointHTTPAuthConfig struct {
	Username string `description:"Basic Auth username" json:"username,omitempty" toml:"username,omitempty" yaml:"username,omitempty" loggable:"false"`
	Password string `description:"Basic Auth password" json:"password,omitempty" toml:"password,omitempty" yaml:"password,omitempty" loggable:"false"`
}

// Init the provider.
func (p *Provider) Init() error {
	defaultRuleTpl, err := provider.MakeDefaultRuleTemplate(p.DefaultRule, nil)
	if err != nil {
		return fmt.Errorf("error while parsing default rule: %w", err)
	}

	p.defaultRuleTpl = defaultRuleTpl
	p.certChan = make(chan *connectCert, 1)
	p.watchServicesChan = make(chan struct{}, 1)

	// In case they didn't initialize Provider with BuildProviders.
	if p.name == "" {
		p.name = ProviderName
	}

	return nil
}

// Provide allows the consul catalog provider to provide configurations to traefik using the given configuration channel.
func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error {
	var err error
	p.client, err = createClient(p.namespace, p.Endpoint)
	if err != nil {
		return fmt.Errorf("failed to create consul client: %w", err)
	}

	pool.GoCtx(func(routineCtx context.Context) {
		logger := log.Ctx(routineCtx).With().Str(logs.ProviderName, p.name).Logger()
		ctxLog := logger.WithContext(routineCtx)

		operation := func() error {
			ctx, cancel := context.WithCancel(ctxLog)

			// When the operation terminates, we want to clean up the
			// goroutines in watchConnectTLS and watchServices.
			defer cancel()

			errChan := make(chan error, 2)

			if p.ConnectAware {
				go func() {
					if err := p.watchConnectTLS(ctx); err != nil {
						errChan <- fmt.Errorf("failed to watch connect certificates: %w", err)
					}
				}()
			}

			var certInfo *connectCert

			// If we are running in connect aware mode then we need to
			// make sure that we obtain the certificates before starting
			// the service watcher, otherwise a connect enabled service
			// that gets resolved before the certificates are available
			// will cause an error condition.
			if p.ConnectAware && !certInfo.isReady() {
				logger.Info().Msg("Waiting for Connect certificate before building first configuration")
				select {
				case <-ctx.Done():
					return nil

				case err = <-errChan:
					return err

				case certInfo = <-p.certChan:
				}
			}

			// get configuration at the provider's startup.
			if err = p.loadConfiguration(ctx, certInfo, configurationChan); err != nil {
				return fmt.Errorf("failed to get consul catalog data: %w", err)
			}

			go func() {
				// Periodic refreshes.
				if !p.Watch {
					repeatSend(ctx, time.Duration(p.RefreshInterval), p.watchServicesChan)
					return
				}

				if err := p.watchServices(ctx); err != nil {
					errChan <- fmt.Errorf("failed to watch services: %w", err)
				}
			}()

			for {
				select {
				case <-ctx.Done():
					return nil

				case err = <-errChan:
					return err

				case certInfo = <-p.certChan:
				case <-p.watchServicesChan:
				}

				if err = p.loadConfiguration(ctx, certInfo, configurationChan); err != nil {
					return fmt.Errorf("failed to refresh consul catalog data: %w", err)
				}
			}
		}

		notify := func(err error, time time.Duration) {
			logger.Error().Err(err).Msgf("Provider error, retrying in %s", time)
		}

		err := backoff.RetryNotify(safe.OperationWithRecover(operation), backoff.WithContext(job.NewBackOff(backoff.NewExponentialBackOff()), ctxLog), notify)
		if err != nil {
			logger.Error().Err(err).Msg("Cannot retrieve data")
		}
	})

	return nil
}

// Namespace returns the namespace of the ConsulCatalog provider.
func (p *Provider) Namespace() string {
	return p.namespace
}

func (p *Provider) loadConfiguration(ctx context.Context, certInfo *connectCert, configurationChan chan<- dynamic.Message) error {
	data, err := p.getConsulServicesData(ctx)
	if err != nil {
		return err
	}

	configurationChan <- dynamic.Message{
		ProviderName:  p.name,
		Configuration: p.buildConfiguration(ctx, data, certInfo),
	}

	return nil
}

func (p *Provider) getConsulServicesData(ctx context.Context) ([]itemData, error) {
	// The query option "Filter" is not supported by /catalog/services.
	// https://www.consul.io/api/catalog.html#list-services
	opts := &api.QueryOptions{AllowStale: p.Stale, RequireConsistent: p.RequireConsistent, UseCache: p.Cache}
	opts = opts.WithContext(ctx)

	serviceNames, _, err := p.client.Catalog().Services(opts)
	if err != nil {
		return nil, err
	}

	var data []itemData
	for name, tags := range serviceNames {
		logger := log.Ctx(ctx).With().Str("serviceName", name).Logger()

		extraConf, err := p.getExtraConf(tagsToNeutralLabels(tags, p.Prefix))
		if err != nil {
			logger.Error().Err(err).Msg("Skip service")
			continue
		}

		if !extraConf.Enable {
			logger.Debug().Msg("Filtering disabled item")
			continue
		}

		matches, err := constraints.MatchTags(tags, p.Constraints)
		if err != nil {
			logger.Error().Err(err).Msg("Error matching constraint expressions")
			continue
		}

		if !matches {
			logger.Debug().Msgf("Container pruned by constraint expressions: %q", p.Constraints)
			continue
		}

		if !p.ConnectAware && extraConf.ConsulCatalog.Connect {
			logger.Debug().Msg("Filtering out Connect aware item, Connect support is not enabled")
			continue
		}

		consulServices, statuses, err := p.fetchService(ctx, name, extraConf.ConsulCatalog.Connect)
		if err != nil {
			return nil, err
		}

		for _, consulService := range consulServices {
			address := consulService.Service.Address
			if address == "" {
				address = consulService.Node.Address
			}

			namespace := consulService.Service.Namespace
			if namespace == "" {
				namespace = "default"
			}

			status, exists := statuses[consulService.Node.ID+consulService.Service.ID]
			if !exists {
				status = api.HealthAny
			}

			item := itemData{
				ID:         consulService.Service.ID,
				Node:       consulService.Node.Node,
				Datacenter: consulService.Node.Datacenter,
				Namespace:  namespace,
				Name:       name,
				Address:    address,
				Port:       strconv.Itoa(consulService.Service.Port),
				Labels:     tagsToNeutralLabels(consulService.Service.Tags, p.Prefix),
				Tags:       consulService.Service.Tags,
				Status:     status,
			}

			extraConf, err := p.getExtraConf(item.Labels)
			if err != nil {
				log.Ctx(ctx).Error().Err(err).Msgf("Skip item %s", item.Name)
				continue
			}
			item.ExtraConf = extraConf

			data = append(data, item)
		}
	}

	return data, nil
}

func (p *Provider) fetchService(ctx context.Context, name string, connectEnabled bool) ([]*api.ServiceEntry, map[string]string, error) {
	var tagFilter string
	if !p.ExposedByDefault {
		tagFilter = p.Prefix + ".enable=true"
	}

	opts := &api.QueryOptions{AllowStale: p.Stale, RequireConsistent: p.RequireConsistent, UseCache: p.Cache}
	opts = opts.WithContext(ctx)

	healthFunc := p.client.Health().Service
	if connectEnabled {
		healthFunc = p.client.Health().Connect
	}

	consulServices, _, err := healthFunc(name, tagFilter, false, opts)
	if err != nil {
		return nil, nil, err
	}

	// Index status by service and node so it can be retrieved from a CatalogService even if the health and services
	// are not in sync.
	statuses := make(map[string]string)
	for _, health := range consulServices {
		if health.Service == nil || health.Node == nil {
			continue
		}

		statuses[health.Node.ID+health.Service.ID] = health.Checks.AggregatedStatus()
	}

	return consulServices, statuses, err
}

// watchServices watches for update events of the services list and statuses,
// and transmits them to the caller through the p.watchServicesChan.
func (p *Provider) watchServices(ctx context.Context) error {
	servicesWatcher, err := watch.Parse(map[string]any{"type": "services"})
	if err != nil {
		return fmt.Errorf("failed to create services watcher plan: %w", err)
	}

	servicesWatcher.HybridHandler = func(_ watch.BlockingParamVal, _ any) {
		select {
		case <-ctx.Done():
		case p.watchServicesChan <- struct{}{}:
		default:
			// Event chan is full, discard event.
		}
	}

	checksWatcher, err := watch.Parse(map[string]any{"type": "checks"})
	if err != nil {
		return fmt.Errorf("failed to create checks watcher plan: %w", err)
	}

	checksWatcher.HybridHandler = func(_ watch.BlockingParamVal, _ any) {
		select {
		case <-ctx.Done():
		case p.watchServicesChan <- struct{}{}:
		default:
			// Event chan is full, discard event.
		}
	}

	logger := hclog.New(&hclog.LoggerOptions{
		Name:       "consulcatalog",
		Level:      hclog.LevelFromString(log.Logger.GetLevel().String()),
		JSONFormat: true,
		Output:     logs.NoLevel(log.Logger, zerolog.DebugLevel),
	})

	errChan := make(chan error, 2)

	defer func() {
		servicesWatcher.Stop()
		checksWatcher.Stop()
	}()

	go func() {
		errChan <- servicesWatcher.RunWithClientAndHclog(p.client, logger)
	}()

	go func() {
		errChan <- checksWatcher.RunWithClientAndHclog(p.client, logger)
	}()

	select {
	case <-ctx.Done():
		return nil

	case err = <-errChan:
		return fmt.Errorf("services or checks watcher terminated: %w", err)
	}
}

// watchConnectTLS watches for updates of the root certificate or the leaf
// certificate, and transmits them to the caller via p.certChan.
func (p *Provider) watchConnectTLS(ctx context.Context) error {
	leafChan := make(chan keyPair)
	leafWatcher, err := watch.Parse(map[string]any{
		"type":    "connect_leaf",
		"service": p.ServiceName,
	})
	if err != nil {
		return fmt.Errorf("failed to create leaf cert watcher plan: %w", err)
	}
	leafWatcher.HybridHandler = leafWatcherHandler(ctx, leafChan)

	rootsChan := make(chan caRootList)
	rootsWatcher, err := watch.Parse(map[string]any{
		"type": "connect_roots",
	})
	if err != nil {
		return fmt.Errorf("failed to create roots cert watcher plan: %w", err)
	}
	rootsWatcher.HybridHandler = rootsWatchHandler(ctx, rootsChan)

	hclogger := hclog.New(&hclog.LoggerOptions{
		Name:       "consulcatalog",
		Level:      hclog.LevelFromString(log.Logger.GetLevel().String()),
		JSONFormat: true,
		Output:     logs.NoLevel(log.Logger, zerolog.DebugLevel),
	})

	errChan := make(chan error, 2)

	defer func() {
		leafWatcher.Stop()
		rootsWatcher.Stop()
	}()

	go func() {
		errChan <- leafWatcher.RunWithClientAndHclog(p.client, hclogger)
	}()

	go func() {
		errChan <- rootsWatcher.RunWithClientAndHclog(p.client, hclogger)
	}()

	var (
		certInfo *connectCert
		leafCert keyPair
		caRoots  caRootList
	)

	for {
		select {
		case <-ctx.Done():
			return nil

		case err := <-errChan:
			return fmt.Errorf("leaf or roots watcher terminated: %w", err)

		case caRoots = <-rootsChan:
		case leafCert = <-leafChan:
		}

		newCertInfo := &connectCert{
			trustDomain: caRoots.trustDomain,
			root:        caRoots.roots,
			leaf:        leafCert,
		}
		if newCertInfo.isReady() && !newCertInfo.equals(certInfo) {
			log.Ctx(ctx).Debug().Msgf("Updating connect certs for service %s", p.ServiceName)

			certInfo = newCertInfo

			select {
			case <-ctx.Done():
			case p.certChan <- newCertInfo:
			}
		}
	}
}

// includesHealthStatus returns true if the status passed in exists in the configured StrictChecks configuration. Statuses are case insensitive.
func (p *Provider) includesHealthStatus(status string) bool {
	for _, s := range p.StrictChecks {
		// If the "any" status is included, assume all health checks are included
		if strings.EqualFold(s, api.HealthAny) {
			return true
		}

		if strings.EqualFold(s, status) {
			return true
		}
	}
	return false
}

type caRootList struct {
	trustDomain string
	roots       []string
}

func rootsWatchHandler(ctx context.Context, dest chan<- caRootList) func(watch.BlockingParamVal, any) {
	return func(_ watch.BlockingParamVal, raw any) {
		if raw == nil {
			log.Ctx(ctx).Error().Msg("Root certificate watcher called with nil")
			return
		}

		v, ok := raw.(*api.CARootList)
		if !ok || v == nil {
			log.Ctx(ctx).Error().Msg("Invalid result for root certificate watcher")
			return
		}

		roots := make([]string, 0, len(v.Roots))
		for _, root := range v.Roots {
			roots = append(roots, root.RootCertPEM)
		}

		select {
		case <-ctx.Done():
		case dest <- caRootList{trustDomain: v.TrustDomain, roots: roots}:
		}
	}
}

type keyPair struct {
	cert string
	key  string
}

func leafWatcherHandler(ctx context.Context, dest chan<- keyPair) func(watch.BlockingParamVal, any) {
	return func(_ watch.BlockingParamVal, raw any) {
		if raw == nil {
			log.Ctx(ctx).Error().Msg("Leaf certificate watcher called with nil")
			return
		}

		v, ok := raw.(*api.LeafCert)
		if !ok || v == nil {
			log.Ctx(ctx).Error().Msg("Invalid result for leaf certificate watcher")
			return
		}

		kp := keyPair{
			cert: v.CertPEM,
			key:  v.PrivateKeyPEM,
		}

		select {
		case <-ctx.Done():
		case dest <- kp:
		}
	}
}

func createClient(namespace string, endpoint *EndpointConfig) (*api.Client, error) {
	config := api.Config{
		Address:    endpoint.Address,
		Scheme:     endpoint.Scheme,
		Datacenter: endpoint.DataCenter,
		WaitTime:   time.Duration(endpoint.EndpointWaitTime),
		Token:      endpoint.Token,
		Namespace:  namespace,
	}

	if endpoint.HTTPAuth != nil {
		config.HttpAuth = &api.HttpBasicAuth{
			Username: endpoint.HTTPAuth.Username,
			Password: endpoint.HTTPAuth.Password,
		}
	}

	if endpoint.TLS != nil {
		config.TLSConfig = api.TLSConfig{
			Address:            endpoint.Address,
			CAFile:             endpoint.TLS.CA,
			CertFile:           endpoint.TLS.Cert,
			KeyFile:            endpoint.TLS.Key,
			InsecureSkipVerify: endpoint.TLS.InsecureSkipVerify,
		}
	}

	return api.NewClient(&config)
}

func repeatSend(ctx context.Context, interval time.Duration, c chan<- struct{}) {
	ticker := time.NewTicker(interval)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return

		case <-ticker.C:
			select {
			case <-ctx.Done():
				return

			case c <- struct{}{}:
			default:
				// Chan is full, discard event.
			}
		}
	}
}
