package usecase

import (
	"context"
	"encoding/json"
	"fmt"
	"strconv"
	"strings"
	"sync"
	"time"

	"apps/backend/internal/module/job/domain"

	"github.com/google/uuid"
)

// Notifier — 任務狀態寫入鈴鐺（同一 job upsert；含進度）
type Notifier interface {
	// NotifyJobState templateType/status/summary/percent；progress 與 terminal 都走這條
	NotifyJobState(ctx context.Context, ownerUID int64, jobID, templateType, status, summary string, percent int) error
}

type Service struct {
	Repo              domain.Repository
	Notifier          Notifier
	LeaseDuration     time.Duration
	HeartbeatInterval time.Duration
	// MaxActiveAccountScrapes caps how many persona_analyze_account jobs
	// (real headless-Chromium scrape; CPU-heavy on the shared prod host) a
	// single owner may have pending/queued/running at once. <=0 disables
	// the check.
	MaxActiveAccountScrapes int

	mu         sync.Mutex
	workerID   string
	heartbeats map[string]context.CancelFunc
}

// DefaultMaxActiveAccountScrapes — one in-flight browser scrape per member;
// keeps the 1-vCPU prod host from being flooded by many personas being
// analyzed back-to-back (see deploy/prod/README.md).
const DefaultMaxActiveAccountScrapes = 1

func New(repo domain.Repository) *Service {
	return &Service{
		Repo: repo, LeaseDuration: domain.DefaultLeaseDuration,
		HeartbeatInterval:       domain.DefaultHeartbeat,
		MaxActiveAccountScrapes: DefaultMaxActiveAccountScrapes,
		heartbeats:              make(map[string]context.CancelFunc),
	}
}

func (s *Service) leaseDuration() time.Duration {
	if s.LeaseDuration > 0 {
		return s.LeaseDuration
	}
	return domain.DefaultLeaseDuration
}

func (s *Service) leaseOwner() string {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.workerID
}

func (s *Service) update(ctx context.Context, j *domain.Job) error {
	if owner := s.leaseOwner(); owner != "" {
		j.LeaseExpiresAt = domain.NowNano() + int64(s.leaseDuration())
		return s.Repo.UpdateOwned(ctx, j, owner)
	}
	return s.Repo.Update(ctx, j)
}

func (s *Service) startHeartbeat(ctx context.Context, jobID, workerID string) {
	heartbeatCtx, cancel := context.WithCancel(ctx)
	s.mu.Lock()
	if previous := s.heartbeats[jobID]; previous != nil {
		previous()
	}
	s.heartbeats[jobID] = cancel
	s.mu.Unlock()

	go func() {
		interval := s.HeartbeatInterval
		if interval <= 0 {
			interval = domain.DefaultHeartbeat
		}
		ticker := time.NewTicker(interval)
		defer ticker.Stop()
		for {
			select {
			case <-heartbeatCtx.Done():
				return
			case <-ticker.C:
				expiresAt := domain.NowNano() + int64(s.leaseDuration())
				if err := s.Repo.RenewLease(heartbeatCtx, jobID, workerID, expiresAt); err != nil {
					s.stopHeartbeat(jobID)
					return
				}
			}
		}
	}()
}

func (s *Service) stopHeartbeat(jobID string) {
	s.mu.Lock()
	cancel := s.heartbeats[jobID]
	delete(s.heartbeats, jobID)
	s.mu.Unlock()
	if cancel != nil {
		cancel()
	}
}

// StartDemo — JB-01
func (s *Service) StartDemo(ctx context.Context, ownerUID int64) (*domain.Job, error) {
	now := domain.NowNano()
	j := &domain.Job{
		ID: uuid.NewString(), OwnerUID: ownerUID,
		TemplateType:    domain.TemplateDemo,
		Status:          domain.StatusPending,
		ProgressSummary: "Demo 測試任務已建立",
		ProgressPercent: 0,
		CreatedAt:       now, UpdatedAt: now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	j.Status = domain.StatusQueued
	j.ProgressSummary = "Demo 測試任務 · 等待 worker 領取"
	j.UpdatedAt = domain.NowNano()
	if err := s.Repo.Update(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

// ScheduleScoutScan enqueues an immutable Scout brief for the worker.
func (s *Service) ScheduleScoutScan(ctx context.Context, ownerUID int64, runID, payload string) (*domain.Job, error) {
	if strings.TrimSpace(runID) == "" || strings.TrimSpace(payload) == "" {
		return nil, fmt.Errorf("scout scan run id and payload required")
	}
	now := domain.NowNano()
	j := &domain.Job{ID: uuid.NewString(), OwnerUID: ownerUID, TemplateType: domain.TemplateScoutScan,
		Status: domain.StatusQueued, RefID: runID, Payload: payload,
		ProgressSummary: "海巡已排程 · 等待 worker", CreatedAt: now, UpdatedAt: now}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

// RadarSweepPayload is the immutable brief stored on TemplateRadarSweep jobs.
type RadarSweepPayload struct {
	WatchID string `json:"watch_id"`
	// Day is the UTC calendar day this sweep slot belongs to (YYYY-MM-DD).
	// Same watch + same day never creates a second job (SW-01 dedupe).
	Day string `json:"day"`
}

// RadarSweepRef builds the job RefID for a watch's daily slot.
func RadarSweepRef(watchID, day string) string {
	return strings.TrimSpace(watchID) + ":" + strings.TrimSpace(day)
}

// ScheduleRadarSweep enqueues one radar_sweep job for a watch on the UTC day of runAt.
// If a job for the same watch+day already exists (any status), returns it without inserting.
func (s *Service) ScheduleRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
	watchID = strings.TrimSpace(watchID)
	if ownerUID <= 0 || watchID == "" {
		return nil, domain.ErrForbidden
	}
	if runAt <= 0 {
		runAt = domain.NowNano()
	}
	day := time.Unix(0, runAt).UTC().Format("2006-01-02")
	ref := RadarSweepRef(watchID, day)

	body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
	if err != nil {
		return nil, err
	}
	now := domain.NowNano()
	j := &domain.Job{
		ID:              uuid.NewString(),
		OwnerUID:        ownerUID,
		TemplateType:    domain.TemplateRadarSweep,
		Status:          domain.StatusQueued,
		RefID:           ref,
		Payload:         string(body),
		RunAfter:        runAt,
		ProgressSummary: "雷達巡檢已排程 · 等待 worker",
		ProgressPercent: 0,
		CreatedAt:       now,
		UpdatedAt:       now,
	}
	stored, created, err := s.Repo.InsertUniqueRef(ctx, j)
	if err != nil {
		return nil, err
	}
	// 只有真的建立才通知，否則同一天的重複排程會轟炸使用者。
	if created {
		s.notify(ctx, stored)
	}
	return stored, nil
}

// ScheduleManualRadarSweep starts an extra patrol now.
// Daily jobs are keyed watch+UTC day and must stay idempotent; a finished
// daily job must not swallow 「立即巡邏」. If this watch already has a
// queued/running sweep, that in-flight job is returned instead of stacking.
func (s *Service) ScheduleManualRadarSweep(ctx context.Context, ownerUID int64, watchID string, runAt int64) (*domain.Job, error) {
	watchID = strings.TrimSpace(watchID)
	if ownerUID <= 0 || watchID == "" {
		return nil, domain.ErrForbidden
	}
	if runAt <= 0 {
		runAt = domain.NowNano()
	}
	list, err := s.Repo.ListByOwner(ctx, ownerUID)
	if err != nil {
		return nil, err
	}
	prefix := watchID + ":"
	for _, j := range list {
		if j == nil || j.TemplateType != domain.TemplateRadarSweep {
			continue
		}
		if !strings.HasPrefix(j.RefID, prefix) {
			continue
		}
		if !domain.IsTerminal(j.Status) {
			return j, nil
		}
	}
	day := time.Unix(0, runAt).UTC().Format("2006-01-02")
	ref := watchID + ":manual:" + strconv.FormatInt(runAt, 10)
	body, err := json.Marshal(RadarSweepPayload{WatchID: watchID, Day: day})
	if err != nil {
		return nil, err
	}
	now := domain.NowNano()
	j := &domain.Job{
		ID:              uuid.NewString(),
		OwnerUID:        ownerUID,
		TemplateType:    domain.TemplateRadarSweep,
		Status:          domain.StatusQueued,
		RefID:           ref,
		Payload:         string(body),
		RunAfter:        runAt,
		ProgressSummary: "立即巡邏已排程 · 等待 worker",
		ProgressPercent: 0,
		CreatedAt:       now,
		UpdatedAt:       now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

func (s *Service) List(ctx context.Context, ownerUID int64) ([]*domain.Job, error) {
	return s.Repo.ListByOwner(ctx, ownerUID)
}

// ListTab — 分頁＋分桶（任務頁三頁籤）
func (s *Service) ListTab(ctx context.Context, ownerUID int64, tab string, page, pageSize int) ([]*domain.Job, int64, error) {
	return s.Repo.ListByOwnerTab(ctx, ownerUID, tab, page, pageSize)
}

// Get — JB-09
func (s *Service) Get(ctx context.Context, ownerUID int64, id string) (*domain.Job, error) {
	j, err := s.Repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if j.OwnerUID != ownerUID {
		return nil, domain.ErrForbidden
	}
	return j, nil
}

// Transition — JB-10
func (s *Service) Transition(ctx context.Context, id, to, summary string, percent int) (*domain.Job, error) {
	j, err := s.Repo.FindByID(ctx, id)
	if err != nil {
		return nil, err
	}
	if !domain.CanTransition(j.Status, to) {
		return nil, domain.ErrIllegalStatus
	}
	if percent >= 0 {
		if err := domain.ApplyProgress(j, percent, summary); err != nil {
			return nil, err
		}
	} else if summary != "" {
		j.ProgressSummary = summary
		j.UpdatedAt = domain.NowNano()
	}
	j.Status = to
	if to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled {
		j.CompletedAt = domain.NowNano()
		if to == domain.StatusFailed && j.Error == "" {
			j.Error = summary
			if j.Error == "" {
				j.Error = "failed"
			}
		}
	}
	if err := s.update(ctx, j); err != nil {
		return nil, err
	}
	if domain.IsTerminal(to) {
		s.stopHeartbeat(id)
	}
	if s.Notifier != nil && (to == domain.StatusSucceeded || to == domain.StatusFailed || to == domain.StatusCancelled) {
		pct := j.ProgressPercent
		if to == domain.StatusSucceeded {
			pct = 100
		}
		_ = s.Notifier.NotifyJobState(ctx, j.OwnerUID, j.ID, j.TemplateType, to, j.ProgressSummary, pct)
	}
	return j, nil
}

func (s *Service) ClaimNext(ctx context.Context, workerID string) (*domain.Job, error) {
	workerID = strings.TrimSpace(workerID)
	if workerID == "" {
		return nil, domain.ErrForbidden
	}
	j, err := s.Repo.ClaimNext(ctx, workerID)
	if err != nil {
		return nil, err
	}
	s.mu.Lock()
	s.workerID = workerID
	s.mu.Unlock()
	// claim → running：立刻寫鈴鐺／列表進度（所有模板共用）
	if j.ProgressPercent < 5 {
		j.ProgressPercent = 5
	}
	if j.ProgressSummary == "" || strings.Contains(j.ProgressSummary, "worker 領取") {
		j.ProgressSummary = "已由 worker 領取 · 執行中"
	}
	j.UpdatedAt = domain.NowNano()
	if err := s.Repo.UpdateOwned(ctx, j, workerID); err != nil {
		return nil, err
	}
	s.startHeartbeat(ctx, j.ID, workerID)
	s.notify(ctx, j)
	return j, nil
}

// RunDemoToSuccess advances running job with monotonic progress then succeeded.
// 每一步都 MarkRunningProgress → 鈴鐺／前端可即時看到 %。
func (s *Service) RunDemoToSuccess(ctx context.Context, jobID string) (*domain.Job, error) {
	j, err := s.Repo.FindByID(ctx, jobID)
	if err != nil {
		return nil, err
	}
	// ensure running（未 claim 時）
	if j.Status == domain.StatusPending || j.Status == domain.StatusQueued {
		if !domain.CanTransition(j.Status, domain.StatusRunning) {
			return nil, domain.ErrIllegalStatus
		}
		j.Status = domain.StatusRunning
		j.ProgressSummary = "Demo 測試任務 · 執行中"
		j.ProgressPercent = 5
		j.UpdatedAt = domain.NowNano()
		_ = s.update(ctx, j)
		s.notify(ctx, j)
	}
	if j.Status != domain.StatusRunning {
		return nil, domain.ErrIllegalStatus
	}
	for _, step := range []struct {
		pct int
		sum string
	}{
		{30, "Demo 測試任務 · 暖機中"},
		{70, "Demo 測試任務 · 處理中"},
		{95, "Demo 測試任務 · 收尾中"},
	} {
		if _, err := s.MarkRunningProgress(ctx, jobID, step.pct, step.sum); err != nil {
			return nil, err
		}
	}
	return s.SucceedJob(ctx, jobID, "Demo 測試任務 · 已完成")
}

// ScheduleTokenRenew creates a delayed job to refresh Threads account token (no browser OAuth).
// Previous pending renews for the same account are cancelled.
func (s *Service) ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error {
	if ownerUID <= 0 || accountID == "" {
		return domain.ErrForbidden
	}
	now := domain.NowNano()
	if runAfter <= 0 {
		runAfter = now + domain.DefaultTokenRenewDelayNs
	}
	// 取消沒成功就別排新的，否則同一個帳號會有兩個 renew job 一起跑。
	if err := s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplateThreadsTokenRenew, accountID); err != nil {
		return err
	}
	j := &domain.Job{
		ID: uuid.NewString(), OwnerUID: ownerUID,
		TemplateType:    domain.TemplateThreadsTokenRenew,
		Status:          domain.StatusQueued,
		RefID:           accountID,
		RunAfter:        runAfter,
		ProgressSummary: "定期延長 Threads token（約每 30 天）· 已排程等待執行",
		ProgressPercent: 0,
		CreatedAt:       now, UpdatedAt: now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return err
	}
	// 與其他任務相同：入列就寫一則通知（延遲執行仍會在任務列表可見）
	s.notify(ctx, j)
	return nil
}

// Ensure job.Service implements TokenRenewScheduler (threads package).
var _ interface {
	ScheduleTokenRenew(ctx context.Context, ownerUID int64, accountID string, runAfter int64) error
} = (*Service)(nil)

// PersonaAnalyzeAccountPayload — worker 爬公開貼文並分析
type PersonaAnalyzeAccountPayload struct {
	Username string `json:"username"`
	Lang     string `json:"lang,omitempty"`
}

// PersonaAnalyzeTextPayload — worker 從貼文文字分析
type PersonaAnalyzeTextPayload struct {
	RawText     string `json:"raw_text"`
	SourceLabel string `json:"source_label,omitempty"`
	Lang        string `json:"lang,omitempty"`
}

// cancelPersonaAnalyzeJobs 收掉同一個人設還在排隊的兩種分析，兩者只能有一個在跑，
// 否則兩份結果會互相覆寫成不確定的人設。
func (s *Service) cancelPersonaAnalyzeJobs(ctx context.Context, ownerUID int64, personaID string) error {
	if err := s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeAccount, personaID); err != nil {
		return err
	}
	return s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePersonaAnalyzeText, personaID)
}

// SchedulePersonaAnalyzeAccount — 立刻可領；ref=personaID；同人設舊佇列取消
func (s *Service) SchedulePersonaAnalyzeAccount(ctx context.Context, ownerUID int64, personaID, username, lang string) (*domain.Job, error) {
	if ownerUID <= 0 || personaID == "" {
		return nil, domain.ErrForbidden
	}
	username = strings.TrimPrefix(strings.TrimSpace(username), "@")
	if username == "" {
		return nil, fmt.Errorf("empty username")
	}
	if err := s.cancelPersonaAnalyzeJobs(ctx, ownerUID, personaID); err != nil {
		return nil, err
	}
	if s.MaxActiveAccountScrapes > 0 {
		active, err := s.Repo.CountActiveByOwnerAndTemplate(ctx, ownerUID, domain.TemplatePersonaAnalyzeAccount)
		if err != nil {
			return nil, err
		}
		if active >= int64(s.MaxActiveAccountScrapes) {
			return nil, domain.ErrTooManyActive
		}
	}
	body, _ := json.Marshal(PersonaAnalyzeAccountPayload{Username: username, Lang: lang})
	now := domain.NowNano()
	j := &domain.Job{
		ID: uuid.NewString(), OwnerUID: ownerUID,
		TemplateType:    domain.TemplatePersonaAnalyzeAccount,
		Status:          domain.StatusQueued,
		RefID:           personaID,
		Payload:         string(body),
		ProgressSummary: fmt.Sprintf("人設分析 · 爬取 @%s 公開貼文 · 已排程", username),
		ProgressPercent: 0,
		CreatedAt:       now, UpdatedAt: now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

// ComposeMimicPayload — worker 仿寫輸入／輸出
type ComposeMimicPayload struct {
	SourceText     string `json:"source_text"`
	PersonaID      string `json:"persona_id,omitempty"`
	Direction      string `json:"direction,omitempty"`
	StructureNotes string `json:"structure_notes,omitempty"`
	Lang           string `json:"lang,omitempty"`
	// ResultText 成功後寫回
	ResultText string `json:"result_text,omitempty"`
}

// ScheduleComposeMimic — 立刻可領；仿寫走背景 job，避免 HTTP 120s 逾時
func (s *Service) ScheduleComposeMimic(ctx context.Context, ownerUID int64, sourceText, personaID, direction, structureNotes, lang string) (*domain.Job, error) {
	if ownerUID <= 0 {
		return nil, domain.ErrForbidden
	}
	sourceText = strings.TrimSpace(sourceText)
	if sourceText == "" {
		return nil, fmt.Errorf("empty source")
	}
	// 同使用者只保留一則進行中的仿寫（可選：不 cancel 舊的也可）
	body, _ := json.Marshal(ComposeMimicPayload{
		SourceText: sourceText, PersonaID: strings.TrimSpace(personaID),
		Direction: strings.TrimSpace(direction), StructureNotes: strings.TrimSpace(structureNotes), Lang: lang,
	})
	now := domain.NowNano()
	j := &domain.Job{
		ID: uuid.NewString(), OwnerUID: ownerUID,
		TemplateType:    domain.TemplateComposeMimic,
		Status:          domain.StatusQueued,
		RefID:           strings.TrimSpace(personaID),
		Payload:         string(body),
		ProgressSummary: "仿寫貼文 · 已排程（可離開頁面）",
		ProgressPercent: 0,
		CreatedAt:       now, UpdatedAt: now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

// PlayGenerateScriptPayload — worker 一次產完整互回劇本
type PlayGenerateScriptPayload struct {
	PlayID    string `json:"play_id"`
	OnlyEmpty bool   `json:"only_empty"`
	// 成功後統計
	Filled int `json:"filled,omitempty"`
}

// SchedulePlayGenerateScript — 立刻可領；ref=playID；同劇本舊佇列取消
func (s *Service) SchedulePlayGenerateScript(ctx context.Context, ownerUID int64, playID string, onlyEmpty bool) (*domain.Job, error) {
	if ownerUID <= 0 || strings.TrimSpace(playID) == "" {
		return nil, domain.ErrForbidden
	}
	playID = strings.TrimSpace(playID)
	if err := s.Repo.CancelPendingByRef(ctx, ownerUID, domain.TemplatePlayGenerateScript, playID); err != nil {
		return nil, err
	}
	body, _ := json.Marshal(PlayGenerateScriptPayload{PlayID: playID, OnlyEmpty: onlyEmpty})
	now := domain.NowNano()
	j := &domain.Job{
		ID: uuid.NewString(), OwnerUID: ownerUID,
		TemplateType:    domain.TemplatePlayGenerateScript,
		Status:          domain.StatusQueued,
		RefID:           playID,
		Payload:         string(body),
		ProgressSummary: "劇本 AI 產文 · 已排程（一次產全部步驟，可離開頁面）",
		ProgressPercent: 0,
		CreatedAt:       now, UpdatedAt: now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

// SucceedJobWithPayload — 終態成功並寫回 payload（仿寫結果）
func (s *Service) SucceedJobWithPayload(ctx context.Context, jobID, summary, payload string) (*domain.Job, error) {
	j, err := s.Repo.FindByID(ctx, jobID)
	if err != nil {
		return nil, err
	}
	if !domain.CanTransition(j.Status, domain.StatusSucceeded) {
		return nil, domain.ErrIllegalStatus
	}
	if payload != "" {
		j.Payload = payload
	}
	if err := domain.ApplyProgress(j, 100, summary); err != nil {
		return nil, err
	}
	j.Status = domain.StatusSucceeded
	j.CompletedAt = domain.NowNano()
	j.UpdatedAt = j.CompletedAt
	if err := s.update(ctx, j); err != nil {
		return nil, err
	}
	s.stopHeartbeat(jobID)
	s.notify(ctx, j)
	return j, nil
}

// SchedulePersonaAnalyzeText — 立刻可領；ref=personaID
func (s *Service) SchedulePersonaAnalyzeText(ctx context.Context, ownerUID int64, personaID, rawText, sourceLabel, lang string) (*domain.Job, error) {
	if ownerUID <= 0 || personaID == "" {
		return nil, domain.ErrForbidden
	}
	rawText = strings.TrimSpace(rawText)
	if rawText == "" {
		return nil, fmt.Errorf("empty text")
	}
	if err := s.cancelPersonaAnalyzeJobs(ctx, ownerUID, personaID); err != nil {
		return nil, err
	}
	body, _ := json.Marshal(PersonaAnalyzeTextPayload{
		RawText: rawText, SourceLabel: strings.TrimSpace(sourceLabel), Lang: lang,
	})
	now := domain.NowNano()
	label := strings.TrimSpace(sourceLabel)
	if label == "" {
		label = "手動貼文"
	}
	j := &domain.Job{
		ID: uuid.NewString(), OwnerUID: ownerUID,
		TemplateType:    domain.TemplatePersonaAnalyzeText,
		Status:          domain.StatusQueued,
		RefID:           personaID,
		Payload:         string(body),
		ProgressSummary: fmt.Sprintf("人設分析 · 文字來源「%s」· 已排程", label),
		ProgressPercent: 0,
		CreatedAt:       now, UpdatedAt: now,
	}
	if err := s.Repo.Insert(ctx, j); err != nil {
		return nil, err
	}
	s.notify(ctx, j)
	return j, nil
}

// RunTokenRenew executes threads_token_renew job body (caller provides refresh fn).
func (s *Service) MarkRunningProgress(ctx context.Context, jobID string, pct int, summary string) (*domain.Job, error) {
	j, err := s.Repo.FindByID(ctx, jobID)
	if err != nil {
		return nil, err
	}
	if j.Status != domain.StatusRunning {
		return nil, domain.ErrIllegalStatus
	}
	if err := domain.ApplyProgress(j, pct, summary); err != nil {
		return nil, err
	}
	if err := s.update(ctx, j); err != nil {
		return nil, err
	}
	// 每次有進度就更新鈴鐺（upsert 同一則）
	s.notify(ctx, j)
	return j, nil
}

func (s *Service) notify(ctx context.Context, j *domain.Job) {
	if s.Notifier == nil || j == nil {
		return
	}
	_ = s.Notifier.NotifyJobState(ctx, j.OwnerUID, j.ID, j.TemplateType, j.Status, j.ProgressSummary, j.ProgressPercent)
}

func (s *Service) SucceedJob(ctx context.Context, jobID, summary string) (*domain.Job, error) {
	return s.Transition(ctx, jobID, domain.StatusSucceeded, summary, 100)
}

// Delete — 擁有者手動刪除；執行中不可刪。
func (s *Service) Delete(ctx context.Context, ownerUID int64, id string) error {
	j, err := s.Repo.FindByID(ctx, id)
	if err != nil {
		return err
	}
	if j.OwnerUID != ownerUID {
		return domain.ErrForbidden
	}
	if j.Status == domain.StatusRunning {
		return domain.ErrIllegalStatus
	}
	return s.Repo.Delete(ctx, id)
}

// PurgeExpiredTerminal — 清除終態超過保留期（預設 2 天）的任務。回傳刪除筆數。
func (s *Service) PurgeExpiredTerminal(ctx context.Context) (int64, error) {
	return s.Repo.DeleteTerminalBefore(ctx, domain.TerminalCutoff(domain.NowNano()))
}

// FailJob — JB-04
func (s *Service) FailJob(ctx context.Context, jobID, errMsg string) (*domain.Job, error) {
	j, err := s.Repo.FindByID(ctx, jobID)
	if err != nil {
		return nil, err
	}
	if j.Status == domain.StatusPending || j.Status == domain.StatusQueued {
		j.Status = domain.StatusRunning
		j.UpdatedAt = domain.NowNano()
		_ = s.update(ctx, j)
	}
	if !domain.CanTransition(j.Status, domain.StatusFailed) {
		return nil, domain.ErrIllegalStatus
	}
	j.Status = domain.StatusFailed
	j.Error = errMsg
	if errMsg != "" {
		j.ProgressSummary = "失敗：" + errMsg
		if len([]rune(j.ProgressSummary)) > 200 {
			j.ProgressSummary = string([]rune(j.ProgressSummary)[:200]) + "…"
		}
	} else {
		j.ProgressSummary = "失敗"
	}
	j.CompletedAt = domain.NowNano()
	j.UpdatedAt = j.CompletedAt
	if err := s.update(ctx, j); err != nil {
		return nil, err
	}
	s.stopHeartbeat(jobID)
	s.notify(ctx, j)
	return j, nil
}
