package services

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// ErrInvalidTag is returned for any tag the rules below reject. Handlers map
// it to 400 — a malformed tag is the caller's mistake, not a server fault.
var ErrInvalidTag = errors.New("invalid tag")

const (
	maxTagKeyLen   = 32
	maxTagValueLen = 64
	maxTagsPerHost = 20
	// Reserved for tags the agent may derive from inventory later. Refusing
	// it now means a user tag written today can never collide with a system
	// tag invented tomorrow.
	sysTagPrefix = "sys:"
)

func validTagRunes(s string) bool {
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z':
		case r >= '0' && r <= '9':
		case r == '-' || r == '_':
		default:
			return false
		}
	}
	return true
}

// ValidateTags enforces the shape of a whole tag map. It lives in the service
// layer rather than a handler so that every write path — the tags endpoint,
// server create, anything added later — agrees on what a valid tag is.
func ValidateTags(tags map[string]string) error {
	if len(tags) > maxTagsPerHost {
		return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost)
	}
	for k, v := range tags {
		if strings.HasPrefix(k, sysTagPrefix) {
			return fmt.Errorf("%w: keys beginning %q are reserved", ErrInvalidTag, sysTagPrefix)
		}
		if k == "" || len(k) > maxTagKeyLen || !validTagRunes(k) {
			return fmt.Errorf("%w: key %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagKeyLen)
		}
		if v == "" || len(v) > maxTagValueLen || !validTagRunes(v) {
			return fmt.Errorf("%w: value for %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagValueLen)
		}
	}
	return nil
}

// ParseTagFilters turns repeated ?tag=key:value query values into a map.
//
// A malformed filter is an error rather than a silently ignored value: a
// filter that matches nothing and a filter that is nonsense look identical in
// a list, and only one of them is the caller's fault.
func ParseTagFilters(raw []string) (map[string]string, error) {
	out := make(map[string]string, len(raw))
	for _, r := range raw {
		k, v, found := strings.Cut(r, ":")
		if !found {
			return nil, fmt.Errorf("%w: filter %q must be key:value", ErrInvalidTag, r)
		}
		if strings.Contains(v, ":") {
			return nil, fmt.Errorf("%w: filter %q has more than one colon", ErrInvalidTag, r)
		}
		out[k] = v
	}
	if err := ValidateTags(out); err != nil {
		return nil, err
	}
	return out, nil
}

// SetServerTags replaces a server's whole tag map.
//
// Replace rather than patch: a tag set is small enough that sending all of it
// is free, and last-write-wins over a whole map is easier to reason about than
// merge semantics between two people editing the same server.
func SetServerTags(instanceID, serverID string, tags map[string]string) error {
	if err := ValidateTags(tags); err != nil {
		return err
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	res, err := db.Col("servers").UpdateOne(ctx,
		bson.M{"server_id": serverID, "instance_id": instanceID},
		bson.M{"$set": bson.M{"tags": tags}},
	)
	if err != nil {
		return err
	}
	if res.MatchedCount == 0 {
		return mongo.ErrNoDocuments
	}
	return nil
}

// KnownTags returns every key in use in this instance with its distinct
// values, for the UI's pickers. This is an aggregation rather than a
// maintained registry: a tag is a property of a server, not an entity, and a
// registry would need reference counting to know when a tag stopped existing.
func KnownTags(instanceID string) (map[string][]string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	cur, err := db.Col("servers").Find(ctx,
		bson.M{"instance_id": instanceID, "tags": bson.M{"$exists": true}},
		options.Find().SetProjection(bson.M{"tags": 1}),
	)
	if err != nil {
		return nil, err
	}
	defer cur.Close(ctx)

	seen := map[string]map[string]bool{}
	for cur.Next(ctx) {
		var s models.Server
		if err := cur.Decode(&s); err != nil {
			return nil, err
		}
		for k, v := range s.Tags {
			if seen[k] == nil {
				seen[k] = map[string]bool{}
			}
			seen[k][v] = true
		}
	}
	if err := cur.Err(); err != nil {
		return nil, err
	}

	out := make(map[string][]string, len(seen))
	for k, vals := range seen {
		list := make([]string, 0, len(vals))
		for v := range vals {
			list = append(list, v)
		}
		sort.Strings(list)
		out[k] = list
	}
	return out, nil
}
