package watch_graph //nolint:staticcheck

import (
	"context"
	"fmt"
	"log/slog"
	"regexp"
	"slices"
	"strings"
	"time"
	"unique"

	"github.com/fvbommel/sortorder"
	"github.com/google/cel-go/common/types"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/v18/internal/tool/logz"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/util/sets"
	"k8s.io/client-go/discovery"
)

const (
	discoveryPollPeriod = time.Minute
)

var (
	isStableRegex = regexp.MustCompile(`^v\d+$`)
)

type gvrInfoFiltered struct {
	gvrInfo
	version string
	kind    string
}

type wgDiscovery struct {
	log         *slog.Logger
	queries     []any // Elements are QueryInclude or QueryExclude
	discoClient discovery.AggregatedDiscoveryInterface
	onDiscovery func(gvrInfos)
	onWarn      func(Warning)
}

func (d *wgDiscovery) Run(ctx context.Context) *Error {
	err := d.doDiscovery(ctx)
	if err != nil {
		return err
	}
	t := time.NewTicker(discoveryPollPeriod)
	defer t.Stop()
	done := ctx.Done()
	for {
		select {
		case <-done:
			return nil
		case <-t.C:
			err = d.doDiscovery(ctx)
			if err != nil {
				return err
			}
		}
	}
}

func (d *wgDiscovery) doDiscovery(ctx context.Context) *Error {
	partial := false
	_, resources, err := discovery.ServerGroupsAndResources(d.discoClient)
	switch err.(type) { //nolint:errorlint
	case nil: // no error
	case *discovery.ErrGroupDiscoveryFailed: // partial data
		d.onWarn(Warning{
			Type:    DiscoFailedWarnType,
			Message: fmt.Sprintf("discovery partially failed: %v", err),
		})
		partial = true
	default: // something is wrong
		d.onWarn(Warning{
			Type:    DiscoFailedWarnType,
			Message: fmt.Sprintf("discovery failed: %v", err),
		})
		return nil
	}

	infos, gk2info, gks, err := d.filterDiscoveredResources(ctx, resources)
	if err != nil {
		return &Error{
			Message: err.Error(),
			Code:    InvalidArgument,
		}
	}
	d.onDiscovery(gvrInfos{
		infos:     infos,
		gk2info:   gk2info,
		gks:       gks,
		isPartial: partial,
	})
	return nil
}

// All returned errors are fatal errors.
func (d *wgDiscovery) filterDiscoveredResources(ctx context.Context, resources []*metav1.APIResourceList) (
	map[unique.Handle[schema.GroupVersionResource]]gvrInfo,
	map[schema.GroupKind]gkInfo,
	sets.Set[schema.GroupKind],
	error) {
	gr2infos := map[schema.GroupResource][]gvrInfoFiltered{} // group+resource -> []infos for the resources of various versions in this group
	gks := sets.Set[schema.GroupKind]{}
	for _, resList := range resources {
		err := d.filterDiscoveredGV(ctx, resList, gr2infos, gks)
		if err != nil {
			return nil, nil, nil, err
		}
	}

	infos := make(map[unique.Handle[schema.GroupVersionResource]]gvrInfo, len(gr2infos))
	gk2info := make(map[schema.GroupKind]gkInfo, len(gr2infos))
	for gr, inf := range gr2infos {
		selectedInf := selectVersion(inf)
		gvr := unique.Make(gr.WithVersion(selectedInf.version))
		infos[gvr] = selectedInf.gvrInfo
		gk := schema.GroupKind{
			Group: gr.Group,
			Kind:  selectedInf.kind,
		}
		gk2info[gk] = gkInfo{
			gvr:        gvr,
			namespaced: selectedInf.namespaced,
		}
	}

	return infos, gk2info, gks, nil
}

func (d *wgDiscovery) filterDiscoveredGV(ctx context.Context, resList *metav1.APIResourceList,
	gr2infos map[schema.GroupResource][]gvrInfoFiltered, gks sets.Set[schema.GroupKind]) error {
	gv, err := schema.ParseGroupVersion(resList.GroupVersion)
	if err != nil {
		return err
	}
	d.log.Debug("Filtering discovered resources for group/version", logz.GV(gv))
nextResource:
	for i := range resList.APIResources {
		res := &resList.APIResources[i] // APIResource is a huge struct. Avoid copying it.
		if isSubresource(res.Name) {
			d.log.Debug("Skipping subresource", logz.GVR(gv.WithResource(res.Name)))
			continue
		}
		gks.Insert(schema.GroupKind{
			Group: gv.Group,
			Kind:  res.Kind,
		})
		if !isWatchable(res) {
			d.log.Debug("Skipping non-watchable resource", logz.GVR(gv.WithResource(res.Name)))
			continue
		}
		vars := map[string]any{
			"group":      gv.Group,
			"version":    gv.Version,
			"resource":   res.Name,
			"namespaced": res.Namespaced,
		}
		for pos, query := range d.queries {
			switch q := query.(type) {
			case QueryInclude:
				val, _, err := q.ResourceSelectorExpression.ContextEval(ctx, vars)
				if err != nil {
					return fmt.Errorf("queries[%d] include expression: %w", pos, err)
				}
				if val.(types.Bool) { // match! this GVR is included.
					gr := schema.GroupResource{
						Group:    gv.Group,
						Resource: res.Name,
					}
					gr2infos[gr] = append(gr2infos[gr], gvrInfoFiltered{
						gvrInfo: gvrInfo{
							object:     q.Object,
							namespaced: res.Namespaced,
						},
						version: gv.Version,
						kind:    res.Kind,
					})
					continue nextResource //nolint: gocritic
				}
			case QueryExclude:
				val, _, err := q.ResourceSelectorExpression.ContextEval(ctx, vars)
				if err != nil {
					return fmt.Errorf("queries[%d] exclude expression: %w", pos, err)
				}
				if val.(types.Bool) { // match! this GVR is excluded.
					continue nextResource
				}
			default:
				return fmt.Errorf("unknown query type: %T", q) // should never happen
			}
			// no match, try next query
		}
		// no queries matched, ignoring this resource
	}
	return nil
}

func selectVersion(inf []gvrInfoFiltered) gvrInfoFiltered {
	selected := inf[0] // we know it's not empty
	if len(inf) == 1 {
		return selected
	}
	selectedIsStable := isStableVersion(selected.version)

	for _, maybeNewSelected := range inf[1:] {
		maybeNewSelectedIsStable := isStableVersion(maybeNewSelected.version)
		switch {
		case !selectedIsStable && maybeNewSelectedIsStable: // unstable -> stable upgrade
			selected = maybeNewSelected
			selectedIsStable = true
			continue
		case selectedIsStable && !maybeNewSelectedIsStable: // ignore unstable when stable is selected
			continue
		default: // compare stable vs stable or unstable vs unstable
			if sortorder.NaturalLess(selected.version, maybeNewSelected.version) {
				selected = maybeNewSelected
				selectedIsStable = maybeNewSelectedIsStable
			}
		}
	}
	return selected
}

func isSubresource(name string) bool {
	return strings.IndexByte(name, '/') != -1
}

func isStableVersion(ver string) bool {
	return isStableRegex.MatchString(ver)
}

func isWatchable(res *metav1.APIResource) bool {
	return slices.Contains(res.Verbs, "watch") && slices.Contains(res.Verbs, "list")
}
