package quiesce

import (
	"errors"
	"sync"
	"time"
)

// F-A1 — contention is not failure, but unending contention is.
//
// THE BUG THIS EXISTS TO KILL. The agent refuses a backup with HTTP 409 while a restore-test holds
// its R-85 single-flight gate. That refusal is the gate working exactly as designed — nothing is
// broken, the backup simply cannot run this minute. The controller's start path had no 409 branch,
// so it called noteTierFailure: the R-88 breaker armed and `whole_guest_backup_failed` was emailed
// to the operator. Campaign 8 saw it on BOTH boxes in the same minute.
//
// Two comments in the tree asserted the opposite and were wrong:
//   - inflight.go (agent): "A caller that cannot acquire DEFERS to its next cadence" — the backup
//     caller did not defer, it recorded a failure.
//   - quiesce.go: framed the agent's 409 as the thing that PREVENTS "a spurious failure" — on the
//     start path it produced one.
//
// FREQUENCY IS WHY IT MATTERS. Campaign 8's rate was an artifact of compressed cadences, but at real
// cadences a ~12-minute restore-test against a daily backup collides on the order of once per 420
// guest-days — roughly every 4 days on a 100-guest fleet, forever. An alarm that cries wolf on a
// schedule trains the operator to ignore it, which quietly undoes R-97a.
//
// ── THE TRAP THIS DELIBERATELY AVOIDS ────────────────────────────────────────────────────────
//
// "Just ignore 409" would trade a false alarm for a SILENCE PATH: a wedged restore-test that never
// releases the gate would mean the backup never runs and nobody is ever told. That is the exact
// class of fault this whole arc has been closing. So contention DEFERS, and persistent contention
// ALARMS — see contentionAlarmAfter.
//
// ── AND THE SECOND TRAP: APP THRASH ──────────────────────────────────────────────────────────
//
// Removing the failure treatment also removes the R-88 breaker's deferral, which is what had been
// (accidentally) stopping the loop from re-quiescing every 5 minutes. Without a replacement, the
// customer's apps would be stopped and restarted on EVERY poll for the whole duration of a
// restore-test — strictly worse than the bug being fixed. So a contended tier is dropped from the
// due set BEFORE anything is stopped, exactly as R-88 does for failures.

const (
	// contentionRetryAfter is how long a contended tier is skipped before trying again.
	//
	// Measured against the real thing, not picked round: restore-test durations observed on the
	// fleet during Campaign 8 were 1m29s, 6m09s, 7m20s and 12m01s, and the agent's own wait on a
	// LOCAL restore-test task is 10m. 15m therefore clears the realistic case in a single hop while
	// capping app-stop churn during a long restore-test at 4/hour instead of 12/hour.
	//
	// It is deliberately NOT longer: the tier stays DUE throughout, and a needlessly long skip just
	// delays a backup that could have run.
	contentionRetryAfter = 15 * time.Minute

	// contentionAlarmAfter is the point at which contention stops being normal and becomes a fault
	// worth an operator's attention.
	//
	// Bounded by the agent's OWN ceiling rather than by taste: the PBS restore-test restore task is
	// capped at 120 minutes (`config.RestoreTestPBSRestoreTimeout`, default 120m), after which the
	// agent times out and releases the gate itself. Contention lasting longer than that cannot be a
	// legitimate restore-test — something has leaked the gate. 3h = that 120m ceiling plus an hour
	// of margin for teardown and the 5-minute poll granularity, and 15x the longest contention
	// actually observed (12m01s).
	contentionAlarmAfter = 3 * time.Hour
)

// ErrTierBusy is the loop's vocabulary for "the agent refused because a heavy operation is already
// in flight" (HTTP 409). The adapter translates the transport-layer status into this, the same way
// it translates a 404 on /backup/tiers into ErrTiersUnsupported — `internal/quiesce` keeps no
// dependency on `internal/agentapi`.
var ErrTierBusy = errors.New("quiesce: tier busy — a concurrent heavy operation holds the agent")

// contentionState is one tier's current run of refusals.
type contentionState struct {
	since   time.Time // first 409 of this run
	until   time.Time // skip the tier until here
	alarmed bool      // the contentionAlarmAfter signal has already fired for this run
}

// contentionTracker records per-target 409 runs. Like the R-88 breaker it is deliberately
// IN-MEMORY: a controller restart re-attempts immediately, which is the cheap direction to fail —
// forgetting a contention window costs one extra attempt, whereas persisting it could carry a stale
// "busy" verdict across a restart that actually cleared the gate.
type contentionTracker struct {
	mu     sync.Mutex
	states map[string]contentionState
}

func newContentionTracker() *contentionTracker {
	return &contentionTracker{states: map[string]contentionState{}}
}

// note records a refusal for target. It returns how long this run of contention has lasted, and
// whether THIS call is the one that crosses contentionAlarmAfter (true exactly once per run, so the
// operator is told once rather than every retry — the same edge-triggering rule as R-97a).
func (c *contentionTracker) note(target string, now time.Time) (elapsed time.Duration, alarmNow bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	st, ok := c.states[target]
	if !ok || st.since.IsZero() {
		st.since = now
	}
	st.until = now.Add(contentionRetryAfter)
	elapsed = now.Sub(st.since)
	if elapsed >= contentionAlarmAfter && !st.alarmed {
		st.alarmed = true
		alarmNow = true
	}
	c.states[target] = st
	return elapsed, alarmNow
}

// blocked reports whether target is inside its contention skip window, and until when.
func (c *contentionTracker) blocked(target string, now time.Time) (time.Time, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	st, ok := c.states[target]
	if !ok || st.until.IsZero() || !now.Before(st.until) {
		return time.Time{}, false
	}
	return st.until, true
}

// clear ends a run of contention. Returns true if there was one, so the caller can log the recovery
// without narrating every healthy cycle. Called on ANY non-409 outcome — success or a real failure —
// because either proves the gate is no longer holding us.
func (c *contentionTracker) clear(target string) bool {
	c.mu.Lock()
	defer c.mu.Unlock()
	if _, ok := c.states[target]; !ok {
		return false
	}
	delete(c.states, target)
	return true
}

// contendedFor exposes the current run length (tests + diagnosis).
func (c *contentionTracker) contendedFor(target string, now time.Time) time.Duration {
	c.mu.Lock()
	defer c.mu.Unlock()
	st, ok := c.states[target]
	if !ok || st.since.IsZero() {
		return 0
	}
	return now.Sub(st.since)
}
