package web

import (
	"sync"
	"time"
)

// Samba bring-up progress (v0.147.0, feedback slice 4b).
//
// THE PROBLEM: enabling Megosztás on a fresh box ran `ReconcileSamba()` synchronously inside the
// POST handler. On a golden that had not baked felhom-samba, that call is `docker compose up -d`
// pulling ~100MB from a private registry — minutes of an apparently-hung form post, then a redirect
// with a flash reading „Beállítás mentve." whether or not anything had actually come up. Observed
// live, twice. The image is now baked (felhom-agent build-golden.sh, golden >= 0.147.x), but this
// card still covers the pre-0.147 goldens and every future image update.
//
// SHAPE: deliberately the storage-init / netstorage-add one (storage_init_job.go) — detached job,
// single-flight slot, deep-copied snapshot, phase strings the template maps to Hungarian. No new
// framework; a unified async-job feedback layer is a ROADMAP item, not this slice.
//
// State is in-memory and lost on restart, exactly like RestoreOpStatus. That is acceptable here: the
// terminal truth is „is the container running", which the page re-reads from the stack manager on
// every load anyway — the job only explains the WAIT.

type sambaEnsureJob struct {
	Phase     string    `json:"phase"`
	Error     string    `json:"error,omitempty"`
	StartedAt time.Time `json:"started_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

const (
	// sambaPhasePulling — the pinned image is NOT in local Docker storage, so compose will fetch it.
	// This is the phase worth naming: it is the multi-minute one, and the only honest explanation for
	// why nothing appears to happen.
	sambaPhasePulling = "pulling"
	// sambaPhaseStarting — image already local (baked golden / previously pulled): seconds.
	sambaPhaseStarting = "starting"
	// sambaPhaseRunning — terminal success, PROBED (compose up -d exits 0 on a crash-loop, so the
	// job's success condition is container liveness, never the compose exit code).
	sambaPhaseRunning = "running"
	// sambaPhaseNeedsPassword — not a failure: sharing is on but the household password is unset, so
	// reconcile deliberately deploys nothing. The card must say so instead of spinning forever.
	sambaPhaseNeedsPassword = "needs_password"
	sambaPhaseFailed        = "failed"
	sambaPhaseIdle          = "idle"
)

// A pull that has not finished in 15 minutes is not slow, it is broken (the registry is unreachable
// or the disk is full) — end the job so the card can say so rather than spin indefinitely.
const sambaEnsureDeadline = 15 * time.Minute

type sambaEnsureState struct {
	mu      sync.Mutex
	running bool
	cur     *sambaEnsureJob
}

func (s *sambaEnsureState) acquire(job *sambaEnsureJob) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.running {
		return false
	}
	s.running = true
	cp := *job
	s.cur = &cp
	return true
}

func (s *sambaEnsureState) release() {
	s.mu.Lock()
	s.running = false
	s.mu.Unlock()
}

func (s *sambaEnsureState) set(job *sambaEnsureJob) {
	s.mu.Lock()
	cp := *job
	s.cur = &cp
	s.mu.Unlock()
}

// snapshot returns a copy of the last / in-flight job (nil = never ran this process).
func (s *sambaEnsureState) snapshot() *sambaEnsureJob {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.cur == nil {
		return nil
	}
	cp := *s.cur
	return &cp
}

// consumeIfRunning is snapshot() with SERVE-ONCE semantics for the one phase the client reads as an
// edge rather than a level.
//
// `running` means "the bring-up you were watching has finished" — the client answers it by repainting
// the page (a full reload, because the „Állapot" badge is server-rendered). A phase that stays
// `running` in the snapshot therefore re-arms that reload on every subsequent page load: the loop
// S-1 fixed by dropping the idle→running coercion would come straight back after the next REAL
// bring-up, since the finished job outlives it in memory (S-4 core,
// felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md). Reporting it exactly once is what
// makes the edge an edge.
//
// Consumed: terminal `running`, and only while the single-flight slot is free — the job goroutine
// sets the phase before its deferred release(), and eating it inside that window would lose the
// success the customer is waiting for.
//
// NOT consumed: `failed` and `needs_password` (durable explanations — the client stops the timer and
// shows a card, with no reload, so stickiness is informative and cannot loop) and every in-flight
// phase (`pulling`/`starting`, which must survive being polled).
//
// Accepted cost: with two tabs open on /sharing during a bring-up, whichever polls first gets the
// success banner and the other sees plain `idle`. Both then show the true state — `running` is still
// on the level channel and the badge re-renders from the liveness probe either way.
func (s *sambaEnsureState) consumeIfRunning() *sambaEnsureJob {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.cur == nil {
		return nil
	}
	cp := *s.cur
	if !s.running && cp.Phase == sambaPhaseRunning {
		s.cur = nil
	}
	return &cp
}

// startSambaEnsure claims the single-flight slot and launches the detached reconcile. false = one is
// already in flight (a double-submit must not start a second compose up on the same stack dir).
//
// The opening phase is decided BEFORE the work starts, by asking whether the image is already local
// — afterwards the answer is always yes and the card could never truthfully say „letöltés".
func (s *Server) startSambaEnsure() bool {
	now := time.Now().UTC()
	phase := sambaPhaseStarting
	if s.stackMgr != nil && !s.stackMgr.SambaImagePresent() {
		phase = sambaPhasePulling
	}
	job := &sambaEnsureJob{Phase: phase, StartedAt: now, UpdatedAt: now}
	if !s.sambaEnsure.acquire(job) {
		return false
	}
	go s.runSambaEnsureJob(job)
	return true
}

func (s *Server) runSambaEnsureJob(job *sambaEnsureJob) {
	defer s.sambaEnsure.release()

	advance := func(phase, errMsg string) {
		job.Phase = phase
		job.Error = errMsg
		job.UpdatedAt = time.Now().UTC()
		s.sambaEnsure.set(job)
	}

	done := make(chan error, 1)
	go func() { done <- s.stackMgr.ReconcileSamba() }()

	select {
	case err := <-done:
		if err != nil {
			s.logger.Printf("[ERROR] [sharing] samba reconcile failed after %s: %v", time.Since(job.StartedAt).Round(time.Second), err)
			advance(sambaPhaseFailed, err.Error())
			return
		}
	case <-time.After(sambaEnsureDeadline):
		// The reconcile goroutine is left running — compose owns its own lifecycle and killing it
		// mid-pull would leave a partial layer set. We stop REPORTING on it, which is the honest
		// thing the customer needs; a later page load re-probes liveness for the real answer.
		s.logger.Printf("[ERROR] [sharing] samba reconcile still running after %s — giving up on the progress card", sambaEnsureDeadline)
		advance(sambaPhaseFailed, "a megosztási szolgáltatás előkészítése túl sokáig tartott")
		return
	}

	// Reconcile returned nil — but nil ALSO covers "deliberately did nothing". Distinguish the two,
	// because a card that says „fut" while nothing is deployed is the same silence in a new costume.
	if s.settings != nil && !s.settings.GetSMBSettings().UserSet {
		advance(sambaPhaseNeedsPassword, "")
		return
	}
	if !s.stackMgr.SambaRunning() {
		s.logger.Printf("[ERROR] [sharing] samba reconcile reported success but the container is not running")
		advance(sambaPhaseFailed, "a megosztási szolgáltatás nem indult el")
		return
	}
	s.logger.Printf("[INFO] [sharing] samba ready after %s", time.Since(job.StartedAt).Round(time.Second))
	advance(sambaPhaseRunning, "")
}
