package reconcile

import (
	"os"
	"path/filepath"
	"testing"
	"time"
)

func appendRaw(t *testing.T, path, line string) {
	t.Helper()
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600)
	if err != nil {
		t.Fatalf("open for raw append: %v", err)
	}
	defer f.Close()
	if _, err := f.WriteString(line + "\n"); err != nil {
		t.Fatalf("raw append: %v", err)
	}
}

func reopen(t *testing.T, j *Journal, path string) *Journal {
	t.Helper()
	if err := j.Close(); err != nil {
		t.Fatalf("close: %v", err)
	}
	nj, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("reopen: %v", err)
	}
	t.Cleanup(func() { nj.Close() })
	return nj
}

func TestJournal_LifecycleLatestWins(t *testing.T) {
	path := filepath.Join(t.TempDir(), "journal.log")
	j, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { j.Close() })

	now := time.Now().UTC()
	for _, e := range []JournalEntry{
		{OpID: "op1", VMID: 100, Kind: "start", State: OpStarted, At: now},
		{OpID: "op1", VMID: 100, Kind: "start", UPID: "UPID:x:", State: OpTaskRunning, At: now},
		{OpID: "op1", VMID: 100, Kind: "start", UPID: "UPID:x:", State: OpSucceeded, At: now},
	} {
		if err := j.Append(e); err != nil {
			t.Fatalf("append: %v", err)
		}
	}
	got, ok := j.Latest("op1")
	if !ok || got.State != OpSucceeded {
		t.Fatalf("Latest(op1) = %+v ok=%v, want succeeded", got, ok)
	}
	if len(j.InFlight()) != 0 {
		t.Errorf("a succeeded op must not be in-flight: %+v", j.InFlight())
	}
}

func TestJournal_InFlightSurvivesRestart(t *testing.T) {
	path := filepath.Join(t.TempDir(), "journal.log")
	j, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	now := time.Now().UTC()
	// op started + got a task id, but NO terminal record — simulates a crash mid-op.
	mustAppend(t, j, JournalEntry{OpID: "op9", VMID: 100, Kind: "set_config", UPID: "UPID:crash:", State: OpTaskRunning, At: now})

	j2 := reopen(t, j, path)
	inflight := j2.InFlight()
	if len(inflight) != 1 || inflight[0].OpID != "op9" || inflight[0].UPID != "UPID:crash:" {
		t.Fatalf("crash-mid-op should replay as in-flight with its task id, got %+v", inflight)
	}
}

func TestJournal_IdempotencyDedupeAcrossRestart(t *testing.T) {
	path := filepath.Join(t.TempDir(), "journal.log")
	j, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	now := time.Now().UTC()

	const key = "job-abc-123"
	if j.AlreadyApplied(key) {
		t.Fatal("key should not be applied before any record")
	}
	// A one-shot op succeeds carrying an idempotency key.
	mustAppend(t, j, JournalEntry{OpID: "op1", VMID: 100, Kind: "restore", IdempKey: key, State: OpStarted, At: now})
	mustAppend(t, j, JournalEntry{OpID: "op1", VMID: 100, Kind: "restore", IdempKey: key, State: OpSucceeded, At: now})
	if !j.AlreadyApplied(key) {
		t.Fatal("key should be applied after success")
	}

	// Survives a restart (replayed from the log) — a redelivered job must not re-run.
	j2 := reopen(t, j, path)
	if !j2.AlreadyApplied(key) {
		t.Error("idempotency key must survive an agent restart")
	}
	// Empty key is never 'applied'.
	if j2.AlreadyApplied("") {
		t.Error("empty idempotency key must never be considered applied")
	}
}

func TestJournal_FailedKeyNotApplied(t *testing.T) {
	path := filepath.Join(t.TempDir(), "journal.log")
	j, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	t.Cleanup(func() { j.Close() })
	now := time.Now().UTC()
	const key = "job-fail"
	mustAppend(t, j, JournalEntry{OpID: "opF", VMID: 1, Kind: "restore", IdempKey: key, State: OpStarted, At: now})
	mustAppend(t, j, JournalEntry{OpID: "opF", VMID: 1, Kind: "restore", IdempKey: key, State: OpFailed, At: now})
	if j.AlreadyApplied(key) {
		t.Error("a FAILED one-shot op must not mark its key applied (it may be retried)")
	}
}

func TestJournal_SkipsTornTrailingLine(t *testing.T) {
	path := filepath.Join(t.TempDir(), "journal.log")
	j, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	mustAppend(t, j, JournalEntry{OpID: "ok", VMID: 1, Kind: "start", State: OpSucceeded, At: time.Now().UTC()})
	j.Close()
	// Append a torn (partial) JSON line as a crash would leave.
	appendRaw(t, path, `{"op_id":"torn","state":`)

	j2, err := OpenJournal(path)
	if err != nil {
		t.Fatalf("reopen with torn line: %v", err)
	}
	t.Cleanup(func() { j2.Close() })
	if _, ok := j2.Latest("ok"); !ok {
		t.Error("the good record before the torn line must still load")
	}
	if _, ok := j2.Latest("torn"); ok {
		t.Error("the torn line must be skipped")
	}
}

func mustAppend(t *testing.T, j *Journal, e JournalEntry) {
	t.Helper()
	if err := j.Append(e); err != nil {
		t.Fatalf("append: %v", err)
	}
}
