package ads

import (
	"context"
	"fmt"
	"regexp"
	"strings"
	"time"

	"github.com/google/uuid"
)

// Operator-input bounds and the display-timing clamps. The timings are clamped
// (not rejected) on update so the client rotator can never be driven into a
// broken state by a typo in the console.
const (
	maxCampaignName = 80
	maxMessageBody  = 500

	minHoldMs = 3000   // 3s — below this the fade transition would dominate
	maxHoldMs = 600000 // 10 min

	maxEdgePauseMs = 60000

	minScrollPxPerSec = 5
	maxScrollPxPerSec = 1000

	maxFadeMs = 5000
)

// Service is the domain layer over Store: it validates operator input, enforces
// the default campaign's invariants, clamps the display timings, and assembles
// the resolved rotation feed (ActiveSet) for a viewer.
type Service struct {
	store *Store
}

// NewService constructs a Service over store.
func NewService(store *Store) *Service { return &Service{store: store} }

// ActiveSet returns the resolved rotation feed for a viewer in language lang
// (en/ru) together with the global display timings: the currently-active
// campaigns, each with its GCD-reduced show weight and its messages resolved to
// lang, ready for the client's weighted round-robin. The bool result reports
// whether the feed is urgent — an urgent feed is shown to every viewer
// regardless of eligibility (the caller skips the eligibility gate for it).
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
	campaigns, err := s.store.ActiveCampaigns(ctx)
	if err != nil {
		return nil, Timings{}, false, err
	}
	timings, err := s.store.Settings(ctx)
	if err != nil {
		return nil, Timings{}, false, err
	}
	set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
	return set, timings, urgent, nil
}

// ListCampaigns returns every campaign with its messages, for the admin console.
func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) {
	return s.store.ListCampaigns(ctx)
}

// Campaign returns one campaign with its messages, or ErrNotFound.
func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
	return s.store.Campaign(ctx, id)
}

// CreateCampaign validates and creates a new time-limited campaign (never the
// default) and returns its id.
func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
	name, err := validName(c.Name)
	if err != nil {
		return uuid.Nil, err
	}
	if err := validWeight(c.Weight); err != nil {
		return uuid.Nil, err
	}
	if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
		return uuid.Nil, err
	}
	all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
	if err != nil {
		return uuid.Nil, err
	}
	return s.store.CreateCampaign(ctx, Campaign{
		Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
		OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
	})
}

// UpdateCampaign validates and updates a campaign. The default campaign keeps its
// weight (nominal), enabled flag and (empty) window — only its name is editable;
// any submitted weight/window/enabled are ignored for it.
func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
	existing, err := s.store.Campaign(ctx, c.ID)
	if err != nil {
		return err
	}
	name, err := validName(c.Name)
	if err != nil {
		return err
	}
	upd := Campaign{ID: existing.ID, Name: name}
	if existing.IsDefault {
		upd.Weight = existing.Weight
		upd.Enabled = true
		upd.StartsAt = nil
		upd.EndsAt = nil
		// The default (house) campaign stays plain: no colour overrides, never urgent.
	} else {
		if err := validWeight(c.Weight); err != nil {
			return err
		}
		if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
			return err
		}
		all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
		if err != nil {
			return err
		}
		upd.Weight = c.Weight
		upd.Enabled = c.Enabled
		upd.StartsAt = c.StartsAt
		upd.EndsAt = c.EndsAt
		upd.OverrideAll = all
		upd.OverrideDark = dark
		upd.Urgent = c.Urgent
	}
	return s.store.UpdateCampaign(ctx, upd)
}

// DeleteCampaign removes a campaign, refusing the perpetual default.
func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
	existing, err := s.store.Campaign(ctx, id)
	if err != nil {
		return err
	}
	if existing.IsDefault {
		return ErrDefaultImmutable
	}
	return s.store.DeleteCampaign(ctx, id)
}

// AddMessage validates a bilingual message and appends it to a campaign.
func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) {
	en, ru, err := validBodies(bodyEn, bodyRu)
	if err != nil {
		return uuid.Nil, err
	}
	c, err := s.store.Campaign(ctx, campaignID)
	if err != nil {
		return uuid.Nil, err
	}
	return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
}

// EditMessage validates and updates the bilingual bodies of a message that
// belongs to campaignID (its position is unchanged). It returns ErrNotFound when
// the message is not part of that campaign, so a mismatched campaign/message pair
// never edits an unrelated campaign's message.
func (s *Service) EditMessage(ctx context.Context, campaignID, messageID uuid.UUID, bodyEn, bodyRu string) error {
	en, ru, err := validBodies(bodyEn, bodyRu)
	if err != nil {
		return err
	}
	c, err := s.store.Campaign(ctx, campaignID)
	if err != nil {
		return err
	}
	if !campaignOwnsMessage(c, messageID) {
		return ErrNotFound
	}
	return s.store.UpdateMessageBodies(ctx, messageID, en, ru)
}

// MoveMessage reorders a message within its campaign by swapping its position
// with the adjacent message in direction dir (-1 up, +1 down). A move past an
// edge is a no-op.
func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error {
	c, err := s.store.Campaign(ctx, campaignID)
	if err != nil {
		return err
	}
	idx := -1
	for i, m := range c.Messages {
		if m.ID == messageID {
			idx = i
			break
		}
	}
	if idx < 0 {
		return ErrNotFound
	}
	j := idx + dir
	if j < 0 || j >= len(c.Messages) {
		return nil // already at the edge
	}
	a, b := c.Messages[idx], c.Messages[j]
	if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil {
		return err
	}
	return s.store.SetMessagePosition(ctx, b.ID, idx)
}

// DeleteMessage removes a message that belongs to campaignID, refusing to remove
// the default campaign's last remaining message (the default must always have a
// creative to show). It returns ErrNotFound when the message is not part of that
// campaign — so a mismatched campaign/message pair can neither delete an
// unrelated campaign's message nor bypass the default's last-message guard.
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
	c, err := s.store.Campaign(ctx, campaignID)
	if err != nil {
		return err
	}
	if !campaignOwnsMessage(c, messageID) {
		return ErrNotFound
	}
	if c.IsDefault && len(c.Messages) <= 1 {
		return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
	}
	return s.store.DeleteMessage(ctx, messageID)
}

// campaignOwnsMessage reports whether messageID is one of the campaign's messages.
func campaignOwnsMessage(c Campaign, messageID uuid.UUID) bool {
	for _, m := range c.Messages {
		if m.ID == messageID {
			return true
		}
	}
	return false
}

// Settings returns the global display timings.
func (s *Service) Settings(ctx context.Context) (Timings, error) {
	return s.store.Settings(ctx)
}

// UpdateSettings clamps every timing into its safe range and stores them.
func (s *Service) UpdateSettings(ctx context.Context, t Timings) error {
	return s.store.UpdateSettings(ctx, clampTimings(t))
}

// validName trims and bounds a campaign name.
func validName(name string) (string, error) {
	name = strings.TrimSpace(name)
	if name == "" {
		return "", fmt.Errorf("%w: the campaign name is required", ErrValidation)
	}
	if len([]rune(name)) > maxCampaignName {
		return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName)
	}
	return name, nil
}

// validWeight bounds a campaign show weight to the 1..100 percent range.
func validWeight(w int) error {
	if w < 1 || w > 100 {
		return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation)
	}
	return nil
}

// validWindow rejects an inverted validity window.
func validWindow(starts, ends *time.Time) error {
	if starts != nil && ends != nil && ends.Before(*starts) {
		return fmt.Errorf("%w: the end must not precede the start", ErrValidation)
	}
	return nil
}

// hexColor matches a "#rrggbb" colour — the format the console's native colour
// input emits and the wire carries.
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)

// validOverrides validates the two optional colour sets together and returns
// their normalised copies (nil when a set is absent), so a caller can store them
// directly.
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
	va, err := validColorSet(all)
	if err != nil {
		return nil, nil, err
	}
	vd, err := validColorSet(dark)
	if err != nil {
		return nil, nil, err
	}
	return va, vd, nil
}

// validColorSet validates one optional colour override: a nil set passes (no
// override); otherwise all three colours must be present and "#rrggbb". It
// returns a trimmed, lower-cased copy.
func validColorSet(cs *ColorSet) (*ColorSet, error) {
	if cs == nil {
		return nil, nil
	}
	bg := strings.TrimSpace(cs.Bg)
	fg := strings.TrimSpace(cs.Fg)
	link := strings.TrimSpace(cs.Link)
	if bg == "" || fg == "" || link == "" {
		return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
	}
	for _, h := range []string{bg, fg, link} {
		if !hexColor.MatchString(h) {
			return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
		}
	}
	return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
}

// validBodies trims and bounds both mandatory language bodies of a message.
func validBodies(bodyEn, bodyRu string) (string, string, error) {
	en := strings.TrimSpace(bodyEn)
	ru := strings.TrimSpace(bodyRu)
	if en == "" || ru == "" {
		return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation)
	}
	if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody {
		return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody)
	}
	return en, ru, nil
}

// clampTimings forces every display timing into its safe range.
func clampTimings(t Timings) Timings {
	return Timings{
		HoldMs:         clampInt(t.HoldMs, minHoldMs, maxHoldMs),
		EdgePauseMs:    clampInt(t.EdgePauseMs, 0, maxEdgePauseMs),
		ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec),
		FadeOutMs:      clampInt(t.FadeOutMs, 0, maxFadeMs),
		GapMs:          clampInt(t.GapMs, 0, maxFadeMs),
		FadeInMs:       clampInt(t.FadeInMs, 0, maxFadeMs),
	}
}

func clampInt(v, lo, hi int) int {
	if v < lo {
		return lo
	}
	if v > hi {
		return hi
	}
	return v
}
