package main

// In-memory async-job system for DayZ Workshop mod installs.
//
// Why: a single mod can be 2+ GB. Tying the install to the POST /dayz/mods
// request means the browser (and any curl client) disconnects long before
// steamcmd finishes — and when the request context cancels we'd kill the
// download. This package-local job runner lets POST return immediately with
// a job_id, then the UI polls GET /dayz/mods/jobs while steamcmd writes
// progress lines into the job's log buffer. Jobs survive browser-close but
// not controller-restart (by design: steamcmd's on-disk chunk hashes resume
// after a restart, so the worst case is one more retry).

import (
	"sort"
	"sync"
	"time"
)

// dayzModJobPhase is the coarse-grained status a user cares about. We avoid
// exposing internal attempt counters as "phase" — those live as side data.
type dayzModJobPhase string

const (
	phaseQueued      dayzModJobPhase = "queued"
	phaseDownloading dayzModJobPhase = "downloading" // steamcmd is running
	phaseLinking     dayzModJobPhase = "linking"     // agent is creating symlink + copying bikeys
	phaseFinalizing  dayzModJobPhase = "finalizing"  // writing panel-mods.txt
	phaseDone        dayzModJobPhase = "done"
	phaseError       dayzModJobPhase = "error"
	phaseLoginNeeded dayzModJobPhase = "login_needed" // cached Steam token expired
)

// dayzModJob is one mod-install's state. All reads/writes MUST go through
// the helper methods which take the mutex.
type dayzModJob struct {
	ID         string
	InstanceID string
	WorkshopID string
	FolderName string
	ServerMod  bool

	mu sync.Mutex

	Phase   dayzModJobPhase
	Attempt int // 1-based; current or final SteamCMD attempt

	// Rolling log of the most recent N lines from steamcmd + agent. UI
	// renders the tail in a small mono block under the progress bar.
	Lines []string
	// Percent is best-effort from steamcmd's "progress: N.NN" lines; if
	// we can't parse it, UI shows an indeterminate bar.
	Percent float64

	StartedAt time.Time
	EndedAt   time.Time

	// Populated on terminal phases.
	Error           string // empty unless Phase == error/login_needed
	Permanent       bool   // true when retrying would definitely fail again (not-found, access-denied, stale-token)
	Title           string // from GetPublishedFileDetails
	Size            int64
	Updated         int64
	BikeysCopied    []string
	ResolvedModPath string
}

const dayzModJobLineBufCap = 200 // tail lines retained

func (j *dayzModJob) snapshot() map[string]any {
	j.mu.Lock()
	defer j.mu.Unlock()
	lines := append([]string(nil), j.Lines...)
	terminal := j.Phase == phaseDone || j.Phase == phaseError || j.Phase == phaseLoginNeeded
	// can_retry: the UI's Retry button is only useful when trying again
	// MIGHT succeed. Login-needed is a special case handled by auto-popping
	// the sign-in modal, not by the Retry button.
	canRetry := j.Phase == phaseError && !j.Permanent
	return map[string]any{
		"id":                j.ID,
		"instance_id":       j.InstanceID,
		"workshop_id":       j.WorkshopID,
		"folder_name":       j.FolderName,
		"server_mod":        j.ServerMod,
		"phase":             string(j.Phase),
		"attempt":           j.Attempt,
		"lines":             lines,
		"percent":           j.Percent,
		"started_at":        j.StartedAt.UTC().Format(time.RFC3339),
		"ended_at":          jobTimeOrZero(j.EndedAt),
		"error":             j.Error,
		"permanent":         j.Permanent,
		"can_retry":         canRetry,
		"title":             j.Title,
		"size":              j.Size,
		"updated":           j.Updated,
		"bikeys_copied":     j.BikeysCopied,
		"resolved_mod_path": j.ResolvedModPath,
		"terminal":          terminal,
	}
}

func jobTimeOrZero(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format(time.RFC3339)
}

// setPhase is the basic state-machine step. Also updates EndedAt on terminal.
func (j *dayzModJob) setPhase(p dayzModJobPhase) {
	j.mu.Lock()
	defer j.mu.Unlock()
	j.Phase = p
	switch p {
	case phaseDone, phaseError, phaseLoginNeeded:
		if j.EndedAt.IsZero() {
			j.EndedAt = time.Now()
		}
	}
}

func (j *dayzModJob) setError(msg string, permanent bool) {
	j.mu.Lock()
	j.Error = msg
	j.Permanent = permanent
	j.Phase = phaseError
	if j.EndedAt.IsZero() {
		j.EndedAt = time.Now()
	}
	j.mu.Unlock()
}

func (j *dayzModJob) setLoginNeeded(reason string) {
	j.mu.Lock()
	j.Error = reason
	j.Phase = phaseLoginNeeded
	if j.EndedAt.IsZero() {
		j.EndedAt = time.Now()
	}
	j.mu.Unlock()
}

func (j *dayzModJob) appendLine(s string) {
	j.mu.Lock()
	j.Lines = append(j.Lines, s)
	if len(j.Lines) > dayzModJobLineBufCap {
		// Ring-buffer trim — keep newest.
		j.Lines = j.Lines[len(j.Lines)-dayzModJobLineBufCap:]
	}
	j.mu.Unlock()
}

func (j *dayzModJob) setPercent(pct float64) {
	if pct < 0 {
		pct = 0
	} else if pct > 100 {
		pct = 100
	}
	j.mu.Lock()
	j.Percent = pct
	j.mu.Unlock()
}

func (j *dayzModJob) setAttempt(n int) {
	j.mu.Lock()
	j.Attempt = n
	j.mu.Unlock()
}

func (j *dayzModJob) setResult(title string, size, updated int64) {
	j.mu.Lock()
	j.Title = title
	j.Size = size
	j.Updated = updated
	j.mu.Unlock()
}

func (j *dayzModJob) setAgentResult(bikeys []string, resolvedPath string) {
	j.mu.Lock()
	j.BikeysCopied = append([]string(nil), bikeys...)
	j.ResolvedModPath = resolvedPath
	j.mu.Unlock()
}

// ---- manager ----

type dayzModJobManager struct {
	mu   sync.RWMutex
	jobs map[string]*dayzModJob
}

func newDayzModJobManager() *dayzModJobManager {
	m := &dayzModJobManager{jobs: make(map[string]*dayzModJob)}
	go m.reaperLoop()
	return m
}

func (m *dayzModJobManager) create(instanceID, workshopID, folder string, serverMod bool) *dayzModJob {
	j := &dayzModJob{
		ID:         newCorrelationID("dzmodjob"),
		InstanceID: instanceID,
		WorkshopID: workshopID,
		FolderName: folder,
		ServerMod:  serverMod,
		Phase:      phaseQueued,
		StartedAt:  time.Now(),
	}
	m.mu.Lock()
	m.jobs[j.ID] = j
	m.mu.Unlock()
	return j
}

func (m *dayzModJobManager) get(id string) *dayzModJob {
	m.mu.RLock()
	defer m.mu.RUnlock()
	return m.jobs[id]
}

// remove drops a job from the map. Safe to call on non-terminal jobs —
// the in-flight goroutine keeps running but its state updates will land
// on a detached record that no one else can observe, which is fine (the
// operator asked to dismiss it). We never actually "cancel" the download
// because steamcmd has no safe abort signal short of SIGKILL — which
// would strand partial on-disk chunks that the next retry would pick up
// anyway, so this is a controlled orphan rather than a leak.
func (m *dayzModJobManager) remove(id string) bool {
	m.mu.Lock()
	defer m.mu.Unlock()
	if _, ok := m.jobs[id]; !ok {
		return false
	}
	delete(m.jobs, id)
	return true
}

// listForInstance returns jobs for a given instance, newest first.
func (m *dayzModJobManager) listForInstance(instanceID string) []*dayzModJob {
	m.mu.RLock()
	defer m.mu.RUnlock()
	out := make([]*dayzModJob, 0, len(m.jobs))
	for _, j := range m.jobs {
		if j.InstanceID == instanceID {
			out = append(out, j)
		}
	}
	sort.Slice(out, func(i, k int) bool { return out[i].StartedAt.After(out[k].StartedAt) })
	return out
}

// reaperLoop drops completed jobs after a grace period so the in-memory
// map doesn't grow forever. Also keeps recently-failed jobs around long
// enough that the operator can read the error message (10 min).
func (m *dayzModJobManager) reaperLoop() {
	t := time.NewTicker(1 * time.Minute)
	defer t.Stop()
	for range t.C {
		m.reap()
	}
}

func (m *dayzModJobManager) reap() {
	const (
		doneGrace    = 2 * time.Minute
		errorGrace   = 10 * time.Minute
		orphanCutoff = 2 * time.Hour // stuck non-terminal jobs
	)
	now := time.Now()
	m.mu.Lock()
	defer m.mu.Unlock()
	for id, j := range m.jobs {
		j.mu.Lock()
		phase := j.Phase
		ended := j.EndedAt
		started := j.StartedAt
		j.mu.Unlock()
		switch phase {
		case phaseDone:
			if !ended.IsZero() && now.Sub(ended) > doneGrace {
				delete(m.jobs, id)
			}
		case phaseError, phaseLoginNeeded:
			if !ended.IsZero() && now.Sub(ended) > errorGrace {
				delete(m.jobs, id)
			}
		default:
			// Non-terminal. Reap if we think it's wedged (controller bug
			// or upstream Steam hang past any sensible timeout).
			if now.Sub(started) > orphanCutoff {
				delete(m.jobs, id)
			}
		}
	}
}
