// Package services — InviteService — colleague invitations.
//
// Sends one branded invitation email and records the row in
// paliad.invitations. Rate limiting lives here (not in the handler) so any
// future caller — CLI, admin UI, bulk importer — inherits the same 10/day
// cap without re-implementing it.
//
// The limiter is in-memory on purpose: invitations are rare, process
// restarts are rare, and the consequence of a small bypass during a restart
// (a user might get an 11th invite slot) is negligible. A distributed
// limiter would be overkill here.
package services

import (
	"context"
	"errors"
	"fmt"
	"net/mail"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
	"github.com/jmoiron/sqlx"
)

// InviteRateLimit caps how many invitations a single user may send within
// InviteRateWindow. Task brief defines 10/day.
const (
	InviteRateLimit  = 10
	InviteRateWindow = 24 * time.Hour
)

// Sentinel errors. Handlers map these to HTTP status codes.
var (
	ErrInviteRateLimited   = errors.New("invitation rate limit exceeded")
	ErrInviteInvalidEmail  = errors.New("invalid recipient email")
	ErrInviteDomainBlocked = errors.New("recipient domain not allowed")
)

// InviteService wires the invitation flow. Allowed domains are checked via
// the supplied function so the handler-level whitelist stays the single
// source of truth (we don't want two separate lists drifting apart).
type InviteService struct {
	db             *sqlx.DB
	mail           *MailService
	allowedDomains func() []string
	baseURL        string

	mu     sync.Mutex
	sentBy map[uuid.UUID][]time.Time
	clock  func() time.Time
}

// NewInviteService wires the service. allowedDomains is the same function
// used by the auth handler so the list stays consistent. baseURL is
// prepended to register links in emails.
func NewInviteService(db *sqlx.DB, mail *MailService, allowedDomains func() []string, baseURL string) *InviteService {
	if baseURL == "" {
		baseURL = "https://paliad.de"
	}
	if allowedDomains == nil {
		allowedDomains = func() []string { return nil }
	}
	return &InviteService{
		db:             db,
		mail:           mail,
		allowedDomains: allowedDomains,
		baseURL:        baseURL,
		sentBy:         map[uuid.UUID][]time.Time{},
		clock:          func() time.Time { return time.Now() },
	}
}

// InviteInput is the payload a caller passes to Send.
type InviteInput struct {
	ToEmail string
	Message string
}

// Send validates, enforces the rate limit, dispatches the email, and writes
// the audit row. Returns the persisted invitation ID on success.
func (s *InviteService) Send(ctx context.Context, fromUserID uuid.UUID, inviter Inviter, in InviteInput) (uuid.UUID, error) {
	to := strings.TrimSpace(in.ToEmail)
	if _, err := mail.ParseAddress(to); err != nil {
		return uuid.Nil, ErrInviteInvalidEmail
	}
	if !s.domainAllowed(to) {
		return uuid.Nil, ErrInviteDomainBlocked
	}

	if !s.allowInvite(fromUserID) {
		return uuid.Nil, ErrInviteRateLimited
	}

	msg := strings.TrimSpace(in.Message)

	lang := "de"
	if inviter.Lang == "en" {
		lang = "en"
	}

	if err := s.mail.SendTemplate(TemplateData{
		To:   to,
		Lang: lang,
		Name: "invitation",
		Data: map[string]any{
			"InviterName":  inviter.DisplayName,
			"InviterEmail": inviter.Email,
			"ToEmail":      to,
			"Message":      msg,
			"RegisterURL":  s.baseURL + "/login",
		},
	}); err != nil {
		return uuid.Nil, fmt.Errorf("send invitation: %w", err)
	}

	// Audit row written after the send so an SMTP failure doesn't leave a
	// phantom "sent" record. Rate-limit slots are burned regardless — a
	// user who triggers repeated SMTP errors still counts against the cap,
	// keeping worst-case resource use bounded.
	id, dbErr := s.insertRow(ctx, fromUserID, to, msg)
	if dbErr != nil {
		return uuid.Nil, fmt.Errorf("log invitation: %w", dbErr)
	}
	return id, nil
}

// Inviter carries the sender's display-facing fields so the service doesn't
// need to look them up. The handler fetches the paliad.users row and passes
// it through.
type Inviter struct {
	DisplayName string
	Email       string
	Lang        string
}

func (s *InviteService) domainAllowed(email string) bool {
	domains := s.allowedDomains()
	if len(domains) == 0 {
		// If the configured whitelist is empty, we decline. Fail-closed is
		// safer than accidentally exposing invitations to random domains.
		return false
	}
	parts := strings.SplitN(email, "@", 2)
	if len(parts) != 2 {
		return false
	}
	got := strings.ToLower(parts[1])
	for _, d := range domains {
		if strings.ToLower(d) == got {
			return true
		}
	}
	return false
}

// allowInvite both checks and reserves the rate-limit slot in one atomic op,
// so two concurrent calls can't both see "9 used" and both go through.
func (s *InviteService) allowInvite(userID uuid.UUID) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	now := s.clock()
	cutoff := now.Add(-InviteRateWindow)

	// Compact: drop stamps older than the window. Sent[] stays short
	// (at most InviteRateLimit live entries per user).
	seen := s.sentBy[userID]
	kept := seen[:0]
	for _, t := range seen {
		if t.After(cutoff) {
			kept = append(kept, t)
		}
	}
	if len(kept) >= InviteRateLimit {
		s.sentBy[userID] = kept
		return false
	}
	kept = append(kept, now)
	s.sentBy[userID] = kept
	return true
}

// RemainingToday reports the unused portion of the rate limit, for surfacing
// a "3 invitations left today" hint on the UI. Read-only; safe to call from
// a handler that only wants to inspect the counter.
func (s *InviteService) RemainingToday(userID uuid.UUID) int {
	s.mu.Lock()
	defer s.mu.Unlock()
	cutoff := s.clock().Add(-InviteRateWindow)
	live := 0
	for _, t := range s.sentBy[userID] {
		if t.After(cutoff) {
			live++
		}
	}
	rem := InviteRateLimit - live
	if rem < 0 {
		return 0
	}
	return rem
}

func (s *InviteService) insertRow(ctx context.Context, fromUserID uuid.UUID, toEmail, message string) (uuid.UUID, error) {
	var id uuid.UUID
	err := s.db.GetContext(ctx, &id,
		`INSERT INTO paliad.invitations (from_user_id, to_email, message)
		 VALUES ($1, $2, $3)
		 RETURNING id`,
		fromUserID, toEmail, message,
	)
	return id, err
}

