// Package ads owns the server-driven advertising banner ("advertising network"):
// operator-managed campaigns, their bilingual messages and the global display
// timings the client's one-line announcement strip rotates through.
//
// A campaign is one placement order with a show weight (an integer percent).
// Simultaneously active campaigns compete for display slots in proportion to
// their weights; the single perpetual default campaign fills the unsold
// remainder up to 100%. The actual rotation runs client-side (a smooth weighted
// round-robin over campaigns, round-robin over a campaign's messages); the server
// only computes the effective weighted, language-resolved set a viewer should
// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible.
package ads

import (
	"errors"
	"time"

	"github.com/google/uuid"
)

// ErrNotFound is returned when a campaign or message does not exist.
var ErrNotFound = errors.New("ads: not found")

// ErrDefaultImmutable is returned when an operation is refused on the perpetual
// default campaign (it cannot be deleted, disabled, windowed, or have its
// weight or default flag changed).
var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way")

// ErrValidation wraps an operator-facing validation failure (a bad weight,
// window or message body).
var ErrValidation = errors.New("ads: validation")

// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
	Bg   string
	Fg   string
	Link string
}

// Campaign is one advertising placement order with its messages.
type Campaign struct {
	ID        uuid.UUID // uuid.Nil on create
	Name      string
	Weight    int // show percent, 1..100; nominal (ignored) for the default
	IsDefault bool
	Enabled   bool
	StartsAt  *time.Time // nil = open-ended start; always nil for the default
	EndsAt    *time.Time // nil = open-ended end; always nil for the default
	Messages  []Message
	// OverrideAll paints the strip on every theme; OverrideDark, when set, further
	// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
	// light ← all ?? token). Both are nil for the default campaign and for a
	// campaign that keeps the neutral theme tokens. Non-default only.
	OverrideAll  *ColorSet
	OverrideDark *ColorSet
	// Urgent forces the banner on every viewer (bypassing eligibility) and, while
	// any urgent campaign is active, suppresses every non-urgent campaign and the
	// default remainder. Non-default only; always false for the default campaign.
	Urgent    bool
	CreatedAt time.Time
	UpdatedAt time.Time
}

// Message is one bilingual creative of a campaign. Both bodies are mandatory;
// the client shows the variant for the viewer's bot (service) language. Position
// orders the messages within a campaign (the round-robin order).
type Message struct {
	ID         uuid.UUID // uuid.Nil on create
	CampaignID uuid.UUID
	Position   int
	BodyEn     string
	BodyRu     string
}

// Timings are the global banner display timings, in milliseconds except
// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and
// ScrollPxPerSec drive the scroll of a message wider than the strip; a
// transition between messages is FadeOutMs then GapMs then FadeInMs.
type Timings struct {
	HoldMs         int
	EdgePauseMs    int
	ScrollPxPerSec int
	FadeOutMs      int
	GapMs          int
	FadeInMs       int
}

// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order, plus the optional colour overrides
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
type ActiveCampaign struct {
	Weight       int
	Messages     []string
	OverrideAll  *ColorSet
	OverrideDark *ColorSet
}

// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang, and reports whether the feed is an urgent one.
// Campaigns outside their validity window, and campaigns with no messages, are
// dropped.
//
// While any active campaign is urgent, the feed is the urgent campaigns alone —
// every non-urgent timed campaign and the default remainder are suppressed for
// the duration (and the caller shows the feed to every viewer, bypassing
// eligibility). Otherwise the default campaign's effective weight is the
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
	var timed []Campaign
	var def *Campaign
	for i := range campaigns {
		c := campaigns[i]
		if len(c.Messages) == 0 {
			continue // a campaign with no creative cannot fill a slot
		}
		if c.IsDefault {
			def = &campaigns[i]
			continue
		}
		if withinWindow(c, now) {
			timed = append(timed, c)
		}
	}
	if urgent := filterUrgent(timed); len(urgent) > 0 {
		out := make([]ActiveCampaign, 0, len(urgent))
		for _, c := range urgent {
			out = append(out, activeFrom(c, lang))
		}
		reduceByGCD(out)
		return out, true
	}
	sumTimed := 0
	for _, c := range timed {
		sumTimed += c.Weight
	}
	out := make([]ActiveCampaign, 0, len(timed)+1)
	for _, c := range timed {
		out = append(out, activeFrom(c, lang))
	}
	if def != nil {
		if dw := 100 - sumTimed; dw > 0 {
			a := activeFrom(*def, lang)
			a.Weight = dw // the default's stored weight is nominal; it fills the remainder
			out = append(out, a)
		}
	}
	reduceByGCD(out)
	return out, false
}

// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
	return ActiveCampaign{
		Weight:       c.Weight,
		Messages:     resolveBodies(c.Messages, lang),
		OverrideAll:  c.OverrideAll,
		OverrideDark: c.OverrideDark,
	}
}

// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
	var out []Campaign
	for _, c := range cs {
		if c.Urgent {
			out = append(out, c)
		}
	}
	return out
}

// ActiveAt reports whether the campaign would rotate at time now: enabled, and
// either the perpetual default or within its validity window. It mirrors the
// filter computeActiveSet applies (a campaign with no messages still reports
// active here — that is a content gap the console surfaces, not an inactive
// campaign).
func (c Campaign) ActiveAt(now time.Time) bool {
	return c.Enabled && (c.IsDefault || withinWindow(c, now))
}

// withinWindow reports whether now lies inside the campaign's validity window. A
// nil bound is open-ended on that side.
func withinWindow(c Campaign, now time.Time) bool {
	if c.StartsAt != nil && now.Before(*c.StartsAt) {
		return false
	}
	if c.EndsAt != nil && now.After(*c.EndsAt) {
		return false
	}
	return true
}

// resolveBodies projects each message to the body for lang ("ru" picks the
// Russian body; anything else picks English), preserving display order.
func resolveBodies(msgs []Message, lang string) []string {
	out := make([]string, len(msgs))
	for i, m := range msgs {
		if lang == "ru" {
			out[i] = m.BodyRu
		} else {
			out[i] = m.BodyEn
		}
	}
	return out
}

// reduceByGCD divides every weight by the greatest common divisor of all weights
// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A
// no-op when the set is empty or already coprime.
func reduceByGCD(cs []ActiveCampaign) {
	g := 0
	for _, c := range cs {
		g = gcd(g, c.Weight)
	}
	if g <= 1 {
		return
	}
	for i := range cs {
		cs[i].Weight /= g
	}
}

// gcd returns the greatest common divisor of a and b (gcd(0, n) == n).
func gcd(a, b int) int {
	for b != 0 {
		a, b = b, a%b
	}
	if a < 0 {
		return -a
	}
	return a
}
