package quiesce

import (
	"context"
	"fmt"
	"io"
	"log"
	"path/filepath"
	"strings"
	"sync"
	"testing"
	"time"
)

// R-82 Slice B — one quiesce window, two tiers.
//
// Two properties are load-bearing and neither is provable by "no error was returned":
//   1. On the both-due night there is EXACTLY ONE stop/start pair. Two would mean two app outages
//      for one night's work, undoing the whole argument for weekly-over-daily.
//   2. A new controller against an OLD agent still TAKES A BACKUP. The hollow version of that test
//      asserts "no error" while silently skipping the backup — the exact failure it exists to catch.

// ---- fakes -------------------------------------------------------------------------------

// NOTE: fakeStacks comes from quiesce_test.go (same package) — reused rather than duplicated.
// Counts come from len(stoppedNames()) / len(startedNames()).

// tierBackend is a multi-tier fake agent. phases[target] is the phase sequence returned by
// successive BackupStatusFor calls for that tier.
type tierBackend struct {
	mu            sync.Mutex
	tiers         []BackupTier
	tiersErr      error
	dueSet        map[string]bool
	phases        map[string][]string
	phaseIdx      map[string]int
	started       []string // targets StartBackupFor/StartBackup was called with, in order
	untargetedDue bool
	startErrOn    string
	// stacks (optional) lets a start sample how many restarts have happened SO FAR — the direct
	// way to assert "the app had not resumed when this tier started".
	stacks        *fakeStacks
	startsAtStart []int
}

func newTierBackend() *tierBackend {
	return &tierBackend{
		dueSet: map[string]bool{}, phases: map[string][]string{}, phaseIdx: map[string]int{},
	}
}

func (b *tierBackend) Tiers(context.Context) ([]BackupTier, error) {
	if b.tiersErr != nil {
		return nil, b.tiersErr
	}
	return b.tiers, nil
}
// DueFor returns a nil age with an EMPTY age_state — i.e. the pre-v0.105.0 (legacy) shape, which
// keeps every suite written before R-88 Part 2 asserting exactly the behaviour it always did.
func (b *tierBackend) DueFor(_ context.Context, target string) (bool, *int64, string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.dueSet[target], nil, "", nil
}
func (b *tierBackend) StartBackupFor(_ context.Context, target string) (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.startErrOn == target {
		return "", fmt.Errorf("simulated start failure on %s", target)
	}
	b.started = append(b.started, target)
	if b.stacks != nil {
		b.startsAtStart = append(b.startsAtStart, len(b.stacks.startedNames()))
	}
	return "job-" + target, nil
}
func (b *tierBackend) BackupStatusFor(_ context.Context, target string) (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	seq := b.phases[target]
	i := b.phaseIdx[target]
	if i < len(seq) {
		b.phaseIdx[target]++
		return seq[i], nil
	}
	if len(seq) > 0 {
		return seq[len(seq)-1], nil
	}
	return phaseDone, nil
}

// The untargeted (pre-R-82) surface.
func (b *tierBackend) Due(context.Context) (bool, *int64, error) {
	return b.untargetedDue, nil, nil
}
func (b *tierBackend) StartBackup(context.Context) (string, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.started = append(b.started, "(untargeted)")
	return "job-untargeted", nil
}
func (b *tierBackend) BackupStatus(context.Context) (string, error) { return phaseDone, nil }

func (b *tierBackend) restartsWhenEachTierStarted() []int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return append([]int(nil), b.startsAtStart...)
}

func (b *tierBackend) startedTargets() []string {
	b.mu.Lock()
	defer b.mu.Unlock()
	return append([]string(nil), b.started...)
}

func newTierLoop(t *testing.T, be Backend, st *fakeStacks, logTo io.Writer) *Loop {
	t.Helper()
	if logTo == nil {
		logTo = io.Discard
	}
	return New(Options{
		Backend:    be,
		Stacks:     st,
		MarkerPath: filepath.Join(t.TempDir(), "quiesce-state.json"),
		StatusPoll: time.Millisecond,
		MaxQuiesce: 30 * time.Second,
		Logger:     log.New(logTo, "", 0),
	})
}

// ---- RED-PROOF 3 — the both-due night ----------------------------------------------------

// EXACTLY ONE stop/start pair, with BOTH backups inside it. Asserting only "both backups ran"
// would pass against an implementation that quiesces twice — the count is the assertion.
//
// COMPANION RED-PROOF (observed): make runOnce call quiesceAndPollTiers once per due tier
// (a per-tier cycle instead of one window) → stops/starts become 2/2 and this fails with
// "want EXACTLY 1 stop and 1 start ... got stops=2 starts=2". Restored.
func TestBothTiersDue_ExactlyOneQuiesceWindow(t *testing.T) {
	be := newTierBackend()
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	be.dueSet["local"] = true
	be.dueSet["felhom-pbs"] = true
	be.phases["local"] = []string{phaseDone}
	be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}

	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)

	if err := l.runOnce(context.Background()); err != nil {
		t.Fatalf("runOnce: %v", err)
	}
	stops, starts := len(st.stoppedNames()), len(st.startedNames())
	if stops != 1 || starts != 1 {
		t.Fatalf("both-due night must be ONE quiesce window: want EXACTLY 1 stop and 1 start, got stops=%d starts=%d (stopped=%v started=%v)",
			stops, starts, st.stoppedNames(), st.startedNames())
	}
	got := be.startedTargets()
	if len(got) != 2 || got[0] != "local" || got[1] != "felhom-pbs" {
		t.Fatalf("both tiers must back up, primary first: got %v", got)
	}
}

// One tier due → one quiesce, that tier only.
func TestOnlyPBSDue_OneQuiesceThatTierOnly(t *testing.T) {
	be := newTierBackend()
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	be.dueSet["felhom-pbs"] = true
	be.phases["felhom-pbs"] = []string{phaseDone}

	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	stops, starts := len(st.stoppedNames()), len(st.startedNames())
	if stops != 1 || starts != 1 {
		t.Fatalf("want 1/1, got stops=%d starts=%d", stops, starts)
	}
	if got := be.startedTargets(); len(got) != 1 || got[0] != "felhom-pbs" {
		t.Fatalf("only the due tier may back up; got %v", got)
	}
}

// Neither due → no quiesce at all. The app must not be touched.
func TestNoTierDue_NoQuiesce(t *testing.T) {
	be := newTierBackend()
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 0 || starts != 0 {
		t.Fatalf("no tier due must not touch the app; got stops=%d starts=%d", stops, starts)
	}
	if got := be.startedTargets(); len(got) != 0 {
		t.Fatalf("no backup may start; got %v", got)
	}
}

// The app must stay DOWN until the LAST tier snapshots. Resuming after tier 1 would leave the DR
// tier capturing a running app — losing app-consistency on exactly the tier we most want it on.
func TestNonLastTierSnapshot_DoesNotResumeApp(t *testing.T) {
	st := &fakeStacks{running: []string{"immich"}}
	be := newTierBackend()
	be.stacks = st
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	be.dueSet["local"] = true
	be.dueSet["felhom-pbs"] = true
	// The local tier snapshots first, then finishes. The app must NOT come back at its snapshot.
	be.phases["local"] = []string{phaseSnapshotted, phaseSnapshotted, phaseDone}
	be.phases["felhom-pbs"] = []string{phaseSnapshotted, phaseDone}

	l := newTierLoop(t, be, st, nil)
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	// THE assertion: when the SECOND (last) tier started, zero restarts had happened — i.e. the app
	// was still quiesced. Resuming at tier 1's snapshot would leave the DR tier capturing a RUNNING
	// app, losing app-consistency on exactly the tier we most want it on.
	at := be.restartsWhenEachTierStarted()
	if len(at) != 2 {
		t.Fatalf("both tiers must start; sampled %v", at)
	}
	if at[1] != 0 {
		t.Fatalf("the app had ALREADY resumed (%d restarts) when the last tier started — non-last snapshot must not resume", at[1])
	}
	if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
		t.Fatalf("want exactly one stop/start pair; got %d/%d", stops, starts)
	}
}

// ---- RED-PROOF 2 — new controller ↔ OLD agent --------------------------------------------

// The agent 404s /backup/tiers. The controller MUST degrade to the untargeted tier, LOG it, and
// STILL TAKE A BACKUP.
//
// The hollow version of this test asserts only "runOnce returned nil" — which passes against an
// implementation that silently skips the backup entirely. The assertion that matters is that a
// backup actually started.
//
// COMPANION RED-PROOF (observed): make resolveDueTiers return (nil, false, nil) on
// ErrTiersUnsupported — i.e. treat "no tier support" as "nothing due" → this test fails with
// "OLD AGENT: a backup MUST still be taken; got started=[]". Restored.
func TestOldAgent_DegradesToUntargetedAndStillBacksUp(t *testing.T) {
	be := newTierBackend()
	be.tiersErr = ErrTiersUnsupported
	be.untargetedDue = true

	st := &fakeStacks{running: []string{"immich"}}
	var logbuf strings.Builder
	l := newTierLoop(t, be, st, &logbuf)

	if err := l.runOnce(context.Background()); err != nil {
		t.Fatalf("runOnce against an old agent must not error: %v", err)
	}
	got := be.startedTargets()
	if len(got) != 1 || got[0] != "(untargeted)" {
		t.Fatalf("OLD AGENT: a backup MUST still be taken via the untargeted path; got started=%v", got)
	}
	if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
		t.Fatalf("old-agent path must still be one quiesce window; got %d/%d", stops, starts)
	}
	if !strings.Contains(logbuf.String(), "predates R-82") {
		t.Fatalf("the degrade must be LOGGED — a silent degrade is indistinguishable from multi-tier working; log:\n%s", logbuf.String())
	}
}

// The degrade line is logged ONCE, not every poll (it is a steady state during a rollout).
func TestOldAgent_DegradeLoggedOnce(t *testing.T) {
	be := newTierBackend()
	be.tiersErr = ErrTiersUnsupported
	be.untargetedDue = false // not due → cheap repeated polls

	var logbuf strings.Builder
	l := newTierLoop(t, be, &fakeStacks{}, &logbuf)
	for i := 0; i < 5; i++ {
		if err := l.runOnce(context.Background()); err != nil {
			t.Fatal(err)
		}
	}
	if n := strings.Count(logbuf.String(), "predates R-82"); n != 1 {
		t.Fatalf("degrade must log exactly once across polls, got %d:\n%s", n, logbuf.String())
	}
}

// A backend that does not implement TieredBackend at all (an older controller build path) uses the
// untargeted route with no probe and no degrade log.
func TestPlainBackend_UsesUntargetedPath(t *testing.T) {
	be := &plainBackend{due: true}
	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	if be.started != 1 {
		t.Fatalf("a plain Backend must still back up; started=%d", be.started)
	}
	if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
		t.Fatalf("want 1/1, got %d/%d", stops, starts)
	}
}

type plainBackend struct {
	due     bool
	started int
}

func (p *plainBackend) Due(context.Context) (bool, *int64, error) { return p.due, nil, nil }
func (p *plainBackend) StartBackup(context.Context) (string, error) {
	p.started++
	return "job", nil
}
func (p *plainBackend) BackupStatus(context.Context) (string, error) { return phaseDone, nil }

// ---- resilience --------------------------------------------------------------------------

// One tier failing to START must not prevent the other tier's backup, and the app must still resume
// exactly once.
func TestOneTierFailsToStart_OtherStillRunsAndAppResumes(t *testing.T) {
	be := newTierBackend()
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	be.dueSet["local"] = true
	be.dueSet["felhom-pbs"] = true
	be.startErrOn = "local"
	be.phases["felhom-pbs"] = []string{phaseDone}

	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)
	err := l.runOnce(context.Background())
	if err == nil {
		t.Fatal("a tier start failure must be reported, not swallowed")
	}
	if got := be.startedTargets(); len(got) != 1 || got[0] != "felhom-pbs" {
		t.Fatalf("the surviving tier must still back up; got %v", got)
	}
	if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
		t.Fatalf("app must resume exactly once even when a tier fails; got %d/%d", stops, starts)
	}
}

// A tier whose due-check errors must not drop the OTHER tier's backup.
func TestDueCheckErrorOnOneTier_OtherTierStillEvaluated(t *testing.T) {
	be := &dueErrBackend{tierBackend: newTierBackend(), errOn: "local"}
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	be.dueSet["felhom-pbs"] = true
	be.phases["felhom-pbs"] = []string{phaseDone}

	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatalf("a single tier's due-check failure must not fail the cycle: %v", err)
	}
	if got := be.startedTargets(); len(got) != 1 || got[0] != "felhom-pbs" {
		t.Fatalf("the healthy tier must still back up; got %v", got)
	}
}

type dueErrBackend struct {
	*tierBackend
	errOn string
}

func (d *dueErrBackend) DueFor(ctx context.Context, target string) (bool, *int64, string, error) {
	if target == d.errOn {
		return false, nil, "", fmt.Errorf("simulated due-check failure")
	}
	return d.tierBackend.DueFor(ctx, target)
}

// An agent advertising ZERO tiers must fall back to the untargeted path, not do nothing.
func TestZeroTiersAdvertised_FallsBackNotSilent(t *testing.T) {
	be := newTierBackend()
	be.tiers = nil // advertised, but empty
	be.untargetedDue = true

	st := &fakeStacks{running: []string{"immich"}}
	l := newTierLoop(t, be, st, nil)
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatal(err)
	}
	if got := be.startedTargets(); len(got) != 1 || got[0] != "(untargeted)" {
		t.Fatalf("zero advertised tiers must fall back, never skip; got %v", got)
	}
}

// oldestAge drives the window gate's safety valve: a NEVER-backed-up tier (nil age) must win over
// a fresher sibling, or a stale DR tier could be starved by a healthy local one.
func TestOldestAge(t *testing.T) {
	i := func(v int64) *int64 { return &v }
	if got := oldestAge([]dueTier{{ageSecs: i(10)}, {ageSecs: i(99)}}); got == nil || *got != 99 {
		t.Fatalf("want 99, got %v", got)
	}
	if got := oldestAge([]dueTier{{ageSecs: i(10)}, {ageSecs: nil}}); got != nil {
		t.Fatalf("a never-backed-up tier (nil) must win; got %v", *got)
	}
	if got := oldestAge(nil); got != nil {
		t.Fatalf("empty → nil, got %v", *got)
	}
}

// Operator ruling 2026-07-26: "let the first backup run as long as needed; other backups shouldn't
// start until finished." A first FULL offsite snapshot legitimately runs for hours — far past the
// quiesce bound. When that bound elapses the app resumes (correct), but the remaining tiers must
// NOT start: vzdump still holds the guest lock, so a second start would be refused by the agent
// (409) or fail on the lock and record a spurious failure that leaves the tier permanently due.
//
// COMPANION RED-PROOF (observed): drop the `break` on stillRunning → the second tier starts while
// the first is still going and this fails with
// "the second tier MUST NOT start while the first is still running; started=[local felhom-pbs]".
// Restored.
func TestTierOverrunsQuiesceBound_RemainingTiersDeferred(t *testing.T) {
	be := newTierBackend()
	be.tiers = []BackupTier{{Target: "local", Primary: true}, {Target: "felhom-pbs"}}
	be.dueSet["local"] = true
	be.dueSet["felhom-pbs"] = true
	// The local tier never reaches a terminal phase — it just keeps running, like a long upload.
	be.phases["local"] = []string{phaseRunningForever}

	st := &fakeStacks{running: []string{"immich"}}
	l := New(Options{
		Backend: be, Stacks: st,
		MarkerPath: filepath.Join(t.TempDir(), "q.json"),
		StatusPoll: time.Millisecond,
		MaxQuiesce: 20 * time.Millisecond, // bound elapses almost immediately
		Logger:     log.New(io.Discard, "", 0),
	})
	if err := l.runOnce(context.Background()); err != nil {
		t.Fatalf("an overrun is not an error: %v", err)
	}
	got := be.startedTargets()
	if len(got) != 1 || got[0] != "local" {
		t.Fatalf("the second tier MUST NOT start while the first is still running; started=%v", got)
	}
	// The app must still have come back exactly once (the max-quiesce guard).
	if stops, starts := len(st.stoppedNames()), len(st.startedNames()); stops != 1 || starts != 1 {
		t.Fatalf("the app must resume exactly once on the quiesce bound; got %d/%d", stops, starts)
	}
}

// phaseRunningForever is a non-terminal phase the fake returns indefinitely.
const phaseRunningForever = "running"
