// Package clipboard 实现单条覆写式的剪贴板同步：DB 立即写入（GET 永远反映
// 最新真相），SSE 广播按 generation 计数节流——同窗口内的连续 PUT 只让最后
// 一次广播出去，避免下游设备被中间值抖动。
package clipboard

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"sync"
	"time"

	"commilitia.net/cdrop/internal/db"
	"commilitia.net/cdrop/internal/hub"
)

// originFutureToleranceMs caps how far ahead of server time a client's origin_ts
// may be. Beyond it we assume a runaway clock and clamp to server-now, so one
// mis-set device can't set an origin_ts no honest device could beat for a while.
const originFutureToleranceMs = 5 * 60 * 1000

const (
	// ContentTypeText 是 MVP 阶段唯一允许写入的 content_type。前端 HTML→text
	// fallback 在客户端完成，后端只接 text/plain。
	ContentTypeText = "text/plain"

	// EventType 是 SSE 推送给在线设备的事件名（brief §5 风格的 namespace:verb）。
	EventType = "clipboard:update"
)

// Errors that the HTTP layer needs to distinguish.
var (
	ErrUnsupportedType = errors.New("unsupported content_type")
	ErrTooLarge        = errors.New("content exceeds limit")
)

// Hub is the subset of *hub.Hub that the service needs. Declared as interface
// so unit tests can swap in a fake.
type Hub interface {
	Broadcast(userID string, ev hub.Event)
}

// Querier 是 sqlc 生成的 *db.Queries 的子集，便于测试桩替换。
type Querier interface {
	UpsertClipboard(ctx context.Context, arg db.UpsertClipboardParams) (int64, error)
	GetClipboard(ctx context.Context, userID string) (db.ClipboardState, error)
	ClearExpiredClipboards(ctx context.Context, updatedAt int64) (int64, error)
}

// Service 持有内存里的 pending 表，每个 userID 对应一个最新待广播的 generation。
type Service struct {
	q      Querier
	hub    Hub
	max    int
	window time.Duration
	ttl    time.Duration // 0 = 内容不过期

	mu      sync.Mutex
	pending map[string]uint64 // userID → 当前最新 generation
	nextGen uint64
}

// New constructs a Service. window 是稳定窗口（PUT 收到后等待无新写入的时长，
// 到期才广播）；maxBytes 是单次写入上限（UTF-8 字节）；ttl 是云端内容存活时长
// （0 = 不过期）——见 Get 的惰性过期与 RunSweeper 的主动清除。
func New(q Querier, h Hub, maxBytes int, window, ttl time.Duration) *Service {
	if maxBytes <= 0 {
		maxBytes = 65536
	}
	if window <= 0 {
		window = 3 * time.Second
	}
	return &Service{
		q:       q,
		hub:     h,
		max:     maxBytes,
		window:  window,
		ttl:     ttl,
		pending: map[string]uint64{},
	}
}

// MaxBytes 暴露上限给 HTTP 层做请求体校验。
func (s *Service) MaxBytes() int { return s.max }

// State 是 GET 返回 / SSE 广播 payload 的统一形状。Version 是变更探测的核心——
// 每用户严格单调递增；OriginTs 是冲突收敛的 LWW 键（源设备复制时刻 ms）；客户端
// 据 Version 判变更、据 OriginTs 决定回写与否。
type State struct {
	Content      string `json:"content"`
	ContentType  string `json:"content_type"`
	SourceDevice string `json:"source_device"`
	UpdatedAt    int64  `json:"updated_at"`
	Version      int64  `json:"version"`
	OriginTs     int64  `json:"origin_ts"`
}

// Get 读取当前持久化的剪贴板状态；err == sql.ErrNoRows 时表示用户尚未写入。
// 内容超过 TTL 即视为已过期，返回空状态（与「尚未写入」等价、ETag 0），绝不
// 回吐过期内容；实际的 DB 清除交给 RunSweeper。
func (s *Service) Get(ctx context.Context, userID string) (*State, error) {
	row, err := s.q.GetClipboard(ctx, userID)
	if err != nil {
		return nil, err
	}
	st := rowToState(row)
	if s.expired(st.Content, st.UpdatedAt) {
		return &State{ContentType: ContentTypeText, UpdatedAt: 0}, nil
	}
	return st, nil
}

// expired 判断一条内容是否超过 TTL。ttl<=0 或内容为空时永不过期。
func (s *Service) expired(content string, updatedAt int64) bool {
	if s.ttl <= 0 || content == "" {
		return false
	}
	return time.Now().Unix()-updatedAt >= int64(s.ttl.Seconds())
}

// SweepExpired 清除所有超过 TTL 的剪贴板内容（content / source_device 置空），
// 返回清除的行数。ttl<=0 时为 no-op。
func (s *Service) SweepExpired(ctx context.Context) (int64, error) {
	if s.ttl <= 0 {
		return 0, nil
	}
	cutoff := time.Now().Unix() - int64(s.ttl.Seconds())
	return s.q.ClearExpiredClipboards(ctx, cutoff)
}

// SweepInterval 是 sweeper 唤醒周期。
const SweepInterval = 30 * time.Second

// RunSweeper 阻塞直到 ctx 取消，周期性清除过期剪贴板内容。ttl<=0 时
// SweepExpired 为 no-op；调用方应据 cfg.ClipboardTTLSec>0 决定是否启动。
func RunSweeper(ctx context.Context, svc *Service) {
	t := time.NewTicker(SweepInterval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			n, err := svc.SweepExpired(ctx)
			if err != nil {
				slog.Error("clipboard sweeper: clear expired failed", "err", err)
				continue
			}
			if n > 0 {
				slog.Info("clipboard sweeper: cleared expired content", "count", n)
			}
		}
	}
}

// Put 接收一次写入：校验 → 立即 UPSERT → bump generation → 定时器到期才广播。
// 同 userID 在窗口期内的多次 Put 只让最后一次广播。
// Put writes a clipboard update tagged with originTs (the source device's copy
// time, ms). It is accepted only when originTs strictly exceeds the stored one
// (LWW); a delayed / out-of-order older copy is rejected with accepted=false and
// the current winning State returned (so the caller can reconcile). Only an
// accepted write bumps the version and schedules a broadcast.
func (s *Service) Put(
	ctx context.Context,
	userID, contentType, content, sourceDevice string,
	originTs int64,
) (st *State, accepted bool, err error) {
	if contentType != ContentTypeText {
		return nil, false, fmt.Errorf("%w: %q", ErrUnsupportedType, contentType)
	}
	if len(content) > s.max {
		return nil, false, fmt.Errorf("%w: %d > %d", ErrTooLarge, len(content), s.max)
	}

	now := time.Now()
	if nowMs := now.UnixMilli(); originTs > nowMs+originFutureToleranceMs {
		originTs = nowMs // clamp a runaway-future clock to server-now
	}

	version, err := s.q.UpsertClipboard(ctx, db.UpsertClipboardParams{
		UserID:       userID,
		ContentType:  contentType,
		Content:      &content,
		SourceDevice: &sourceDevice,
		UpdatedAt:    now.Unix(),
		OriginTs:     originTs,
	})
	if errors.Is(err, sql.ErrNoRows) {
		// LWW rejected: a newer origin_ts already won. Return the current winner
		// (no broadcast) so the caller pulls / reconciles instead of clobbering.
		cur, gerr := s.Get(ctx, userID)
		if gerr != nil {
			return nil, false, gerr
		}
		return cur, false, nil
	}
	if err != nil {
		return nil, false, fmt.Errorf("upsert clipboard: %w", err)
	}

	st = &State{
		Content:      content,
		ContentType:  contentType,
		SourceDevice: sourceDevice,
		UpdatedAt:    now.Unix(),
		Version:      version,
		OriginTs:     originTs,
	}

	s.mu.Lock()
	s.nextGen++
	gen := s.nextGen
	s.pending[userID] = gen
	s.mu.Unlock()

	time.AfterFunc(s.window, func() { s.maybeCommit(userID, gen) })
	return st, true, nil
}

// maybeCommit 由定时器回调进入。只有当 pending[userID] 仍然是入参 gen 时（即
// 期间没有更新写入）才真正广播；否则视为被新写入覆盖，直接丢弃。
func (s *Service) maybeCommit(userID string, gen uint64) {
	s.mu.Lock()
	cur, ok := s.pending[userID]
	if !ok || cur != gen {
		s.mu.Unlock()
		return
	}
	delete(s.pending, userID)
	s.mu.Unlock()

	// 重新读 DB 拿权威 state——窗口结束时 DB 已是 gen 对应的内容（中间被
	// 后续覆盖的可能性已被前面的 generation 检查排除）。
	row, err := s.q.GetClipboard(context.Background(), userID)
	if err != nil {
		slog.Error("clipboard commit: get state failed", "err", err, "user", userID)
		return
	}
	s.hub.Broadcast(userID, hub.Event{
		Type: EventType,
		Data: rowToState(row),
	})
}

func rowToState(row db.ClipboardState) *State {
	st := &State{
		ContentType: row.ContentType,
		UpdatedAt:   row.UpdatedAt,
		Version:     row.Version,
		OriginTs:    row.OriginTs,
	}
	if row.Content != nil {
		st.Content = *row.Content
	}
	if row.SourceDevice != nil {
		st.SourceDevice = *row.SourceDevice
	}
	return st
}
