// SPDX-License-Identifier: GPL-3.0-or-later

package dockersd

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"strconv"
	"strings"
	"time"

	"github.com/netdata/netdata/go/plugins/logger"
	"github.com/netdata/netdata/go/plugins/pkg/confopt"
	"github.com/netdata/netdata/go/plugins/plugin/agent/discovery/sd/model"
	"github.com/netdata/netdata/go/plugins/plugin/framework/dyncfg"
	"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/dockerhost"

	typesContainer "github.com/moby/moby/api/types/container"
	docker "github.com/moby/moby/client"
)

func NewDiscoverer(cfg Config) (*Discoverer, error) {
	return newDiscoverer(
		cfg,
		logger.New().With(
			slog.String("component", "service discovery"),
			slog.String("discoverer", "docker"),
		),
	)
}

func newDiscoverer(cfg Config, log *logger.Logger) (*Discoverer, error) {
	d := &Discoverer{
		Logger:    log,
		cfgSource: cfg.Source,
		newDockerClient: func(addr string) (dockerClient, error) {
			return docker.New(docker.WithHost(addr))
		},
		addr:           docker.DefaultDockerHost,
		listInterval:   time.Second * 60,
		timeout:        time.Second * 2,
		seenTggSources: make(map[string]bool),
		started:        make(chan struct{}),
	}

	if cfg.Timeout.Duration() > 0 {
		d.timeout = cfg.Timeout.Duration()
	}
	if cfg.Address != "" {
		d.addr = cfg.Address
	} else if addr := dockerhost.FromEnv(); addr != "" {
		d.Info("using docker host from environment")
		d.addr = addr
	}

	return d, nil
}

type Config struct {
	Source string `yaml:"-" json:"-"`

	Address string           `yaml:"address,omitempty" json:"address,omitempty"`
	Timeout confopt.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"`
}

type (
	Discoverer struct {
		*logger.Logger
		model.Base

		dockerClient    dockerClient
		newDockerClient func(addr string) (dockerClient, error)
		addr            string

		cfgSource string

		listInterval   time.Duration
		timeout        time.Duration
		seenTggSources map[string]bool // [targetGroup.Source]

		started chan struct{}
	}
	dockerClient interface {
		ContainerList(context.Context, docker.ContainerListOptions) (docker.ContainerListResult, error)
		Close() error
	}
)

func (d *Discoverer) String() string {
	return "sd:docker"
}

func (d *Discoverer) Test(ctx context.Context) error {
	if d == nil || ctx == nil {
		return fmt.Errorf("invalid docker discovery test")
	}
	client, err := d.newDockerClient(d.addr)
	if err != nil {
		return dyncfg.NewPublicError(
			"the configured Docker endpoint is invalid",
			fmt.Errorf("create docker client: %w", err),
		)
	}
	defer func() { _ = client.Close() }()

	testCtx, cancel := context.WithTimeout(ctx, d.timeout)
	defer cancel()
	if _, err := client.ContainerList(testCtx, docker.ContainerListOptions{Limit: 1}); err != nil {
		cause := fmt.Errorf("list docker containers: %w", err)
		switch {
		case errors.Is(testCtx.Err(), context.DeadlineExceeded):
			return dyncfg.NewPublicError(
				"the configured Docker endpoint did not respond before the timeout",
				cause,
			)
		case docker.IsErrConnectionFailed(err):
			return dyncfg.NewPublicError(
				"cannot connect to the configured Docker endpoint",
				cause,
			)
		default:
			return dyncfg.NewPublicError(
				"cannot query containers from the configured Docker endpoint",
				cause,
			)
		}
	}
	return nil
}

func (d *Discoverer) Discover(ctx context.Context, in chan<- []model.TargetGroup) {
	d.Info("instance is started")
	defer func() { d.cleanup(); d.Info("instance is stopped") }()

	close(d.started)

	if d.dockerClient == nil {
		client, err := d.newDockerClient(d.addr)
		if err != nil {
			d.Errorf("error on creating docker client: %v", err)
			return
		}
		d.dockerClient = client
	}

	if err := d.listContainers(ctx, in); err != nil {
		d.Error(err)
		return
	}

	tk := time.NewTicker(d.listInterval)
	defer tk.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-tk.C:
			if err := d.listContainers(ctx, in); err != nil {
				d.Warning(err)
			}
		}
	}
}

func (d *Discoverer) listContainers(ctx context.Context, in chan<- []model.TargetGroup) error {
	listCtx, cancel := context.WithTimeout(ctx, d.timeout)
	defer cancel()

	result, err := d.dockerClient.ContainerList(listCtx, docker.ContainerListOptions{})
	if err != nil {
		return err
	}
	containers := result.Items

	var tggs []model.TargetGroup
	seen := make(map[string]bool)

	for _, cntr := range containers {
		if tgg := d.buildTargetGroup(cntr); tgg != nil {
			tggs = append(tggs, tgg)
			seen[tgg.Source()] = true
		}
	}

	for src := range d.seenTggSources {
		if !seen[src] {
			tggs = append(tggs, &targetGroup{source: src})
		}
	}
	d.seenTggSources = seen

	select {
	case <-ctx.Done():
	case in <- tggs:
	}

	return nil
}

func (d *Discoverer) buildTargetGroup(cntr typesContainer.Summary) model.TargetGroup {
	if len(cntr.Names) == 0 || cntr.NetworkSettings == nil || len(cntr.NetworkSettings.Networks) == 0 {
		return nil
	}

	tgg := &targetGroup{
		source: cntrSource(cntr),
	}
	if d.cfgSource != "" {
		tgg.source += fmt.Sprintf(",%s", d.cfgSource)
	}

	for netDriver, network := range cntr.NetworkSettings.Networks {
		ipAddress := ""
		if network.IPAddress.IsValid() {
			ipAddress = network.IPAddress.String()
		}
		// container with network mode host will be discovered by local-listeners
		for _, port := range cntr.Ports {
			publicPortIP := ""
			if port.IP.IsValid() {
				publicPortIP = port.IP.String()
			}
			tgt := &target{
				ID:            cntr.ID,
				Name:          strings.TrimPrefix(cntr.Names[0], "/"),
				Image:         cntr.Image,
				Command:       cntr.Command,
				Labels:        model.MapAny(cntr.Labels),
				PrivatePort:   strconv.Itoa(int(port.PrivatePort)),
				PublicPort:    strconv.Itoa(int(port.PublicPort)),
				PublicPortIP:  publicPortIP,
				PortProtocol:  port.Type,
				NetworkMode:   cntr.HostConfig.NetworkMode,
				NetworkDriver: netDriver,
				IPAddress:     ipAddress,
			}
			tgt.Address = net.JoinHostPort(tgt.IPAddress, tgt.PrivatePort)

			hash, err := model.CalcHash(tgt)
			if err != nil {
				continue
			}

			tgt.hash = hash

			tgg.targets = append(tgg.targets, tgt)
		}
	}

	return tgg
}

func (d *Discoverer) cleanup() {
	if d.dockerClient != nil {
		_ = d.dockerClient.Close()
	}
}

func cntrSource(cntr typesContainer.Summary) string {
	name := strings.TrimPrefix(cntr.Names[0], "/")
	return fmt.Sprintf("discoverer=docker,container=%s,image=%s", name, cntr.Image)
}
