package nativememory

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/go-git/go-git/v5"
	"github.com/go-git/go-git/v5/plumbing/object"
	"github.com/stretchr/testify/require"
)

type fakeRemote struct {
	calls   []Checkpoint
	errors  []error
	results []Result
}

func (r *fakeRemote) PublishCheckpoint(_ context.Context, _ string, input Checkpoint) (Result, error) {
	r.calls = append(r.calls, input)
	index := len(r.calls) - 1
	if index < len(r.errors) && r.errors[index] != nil {
		return Result{}, r.errors[index]
	}
	if index < len(r.results) {
		return r.results[index], nil
	}
	return Result{
		VersionID: "ctv_next", OperationID: input.OperationID, ReplicaID: input.ReplicaID,
		Generation: input.Generation, CoveredFromSequence: input.CoveredFromSequence,
		CoveredThroughSequence: input.CoveredThroughSequence,
	}, nil
}

func (r *fakeRemote) OpenVersionArchive(context.Context, string, string) (io.ReadCloser, error) {
	return nil, errors.New("not implemented")
}

func TestSyncPreservesFrozenOutboxAcrossFailureRestartAndNewEdit(t *testing.T) {
	root, first := newLocalJournal(t, map[string]string{"MEMORY.md": "# first\n"})
	now := time.Date(2026, 8, 22, 10, 0, 0, 0, time.UTC)
	remote := &fakeRemote{errors: []error{errors.New("quota exceeded")}}
	statePath := filepath.Join(t.TempDir(), "native.json")
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 4, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, Now: func() time.Time { return now }, MinInterval: time.Second})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(first))

	_, err = syncer.Publish(context.Background())
	require.EqualError(t, err, "quota exceeded")
	require.True(t, syncer.Pending())
	require.Len(t, remote.calls, 1)
	frozen := remote.calls[0]
	require.EqualValues(t, 1, frozen.CoveredFromSequence)
	require.EqualValues(t, 1, frozen.CoveredThroughSequence)
	require.Equal(t, "# first\n", fileContent(t, frozen.Files, "MEMORY.md"))

	second := commitFiles(t, root, map[string]string{"MEMORY.md": "# second\n", "notes/new.md": "new\n"})
	require.NoError(t, syncer.RecordLocalRevision(second))
	now = now.Add(time.Second)

	restarted, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, Now: func() time.Time { return now }, MinInterval: time.Second})
	require.NoError(t, err)
	result, err := restarted.Publish(context.Background())
	require.NoError(t, err)
	require.Equal(t, "ctv_next", result.VersionID)
	require.Len(t, remote.calls, 2)
	require.Equal(t, frozen.OperationID, remote.calls[1].OperationID)
	require.Equal(t, frozen.CoveredThroughSequence, remote.calls[1].CoveredThroughSequence)
	require.Equal(t, "# first\n", fileContent(t, remote.calls[1].Files, "MEMORY.md"), "retry must publish the frozen immutable revision")
	require.True(t, restarted.Pending(), "the newer local tail remains pending after the frozen range is acknowledged")

	now = now.Add(time.Second)
	result, err = restarted.Publish(context.Background())
	require.NoError(t, err)
	require.EqualValues(t, 2, remote.calls[2].CoveredFromSequence)
	require.EqualValues(t, 2, remote.calls[2].CoveredThroughSequence)
	require.Equal(t, "ctv_next", remote.calls[2].ExpectedVersionID)
	require.Equal(t, "# second\n", fileContent(t, remote.calls[2].Files, "MEMORY.md"))
	require.False(t, restarted.Pending())
}

func TestSyncRecoversCommitSidecarCrashAndCoalescesLocalVersions(t *testing.T) {
	root, first := newLocalJournal(t, map[string]string{"MEMORY.md": "one\n"})
	remote := &fakeRemote{}
	statePath := filepath.Join(t.TempDir(), "native.json")
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 1, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, MinInterval: time.Nanosecond})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(first))

	second := commitFiles(t, root, map[string]string{"MEMORY.md": "two\n"})
	third := commitFiles(t, root, map[string]string{"MEMORY.md": "three\n"})
	// Simulate a process crash after both Git commits but before either sidecar
	// update. Startup observes HEAD and safely coalesces the unseen tail.
	restarted, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, MinInterval: time.Nanosecond})
	require.NoError(t, err)
	require.NoError(t, restarted.RecordLocalRevision(third))
	require.NotEqual(t, second, third)

	_, err = restarted.Publish(context.Background())
	require.NoError(t, err)
	require.Len(t, remote.calls, 1)
	require.EqualValues(t, 1, remote.calls[0].CoveredFromSequence)
	require.EqualValues(t, 2, remote.calls[0].CoveredThroughSequence)
	require.Equal(t, "three\n", fileContent(t, remote.calls[0].Files, "MEMORY.md"))
}

func TestSyncRetriesSameLocalRevisionAfterSidecarPersistFailure(t *testing.T) {
	root, revision := newLocalJournal(t, map[string]string{"MEMORY.md": "durable retry\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 1, ReplicaID: "replica_1"}
	fail := false
	persist := func(path string, next state) error {
		if fail {
			return errors.New("injected sidecar failure")
		}
		return writeState(path, next)
	}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, persistState: persist})
	require.NoError(t, err)
	fail = true
	require.EqualError(t, syncer.RecordLocalRevision(revision), "injected sidecar failure")
	require.False(t, syncer.Pending(), "failed persistence must not publish the next state in memory")

	fail = false
	require.NoError(t, syncer.RecordLocalRevision(revision), "the same revision must retry the sidecar write")
	restarted, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote})
	require.NoError(t, err)
	require.True(t, restarted.Pending())
	_, err = restarted.Publish(context.Background())
	require.NoError(t, err)
}

func TestSyncRetriesPublishAckAfterSidecarPersistFailure(t *testing.T) {
	root, revision := newLocalJournal(t, map[string]string{"MEMORY.md": "publish once\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 1, ReplicaID: "replica_1"}
	now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)
	failAck := false
	persist := func(path string, next state) error {
		if failAck && next.AcknowledgedSequence == 1 && next.Pending == nil {
			return errors.New("injected ack sidecar failure")
		}
		return writeState(path, next)
	}
	syncer, err := Open(Options{
		Root: root, StatePath: statePath, Binding: binding, Remote: remote,
		Now: func() time.Time { return now }, MinInterval: time.Second, persistState: persist,
	})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(revision))
	failAck = true
	_, err = syncer.Publish(context.Background())
	require.EqualError(t, err, "injected ack sidecar failure")
	require.True(t, syncer.Pending(), "failed ack persistence must retain the frozen operation in memory")
	require.Len(t, remote.calls, 1)
	frozenOperation := remote.calls[0].OperationID

	failAck = false
	now = now.Add(time.Second)
	_, err = syncer.Publish(context.Background())
	require.NoError(t, err)
	require.Len(t, remote.calls, 2)
	require.Equal(t, frozenOperation, remote.calls[1].OperationID, "retry must reuse the exact durable operation")
	restartedBinding := binding
	restartedBinding.ContentVersionID = "ctv_next"
	restartedBinding.AcknowledgedSequence = 1
	restarted, err := Open(Options{Root: root, StatePath: statePath, Binding: restartedBinding, Remote: remote})
	require.NoError(t, err)
	require.False(t, restarted.Pending())
}

func TestSyncFailsClosedOnGenerationChangeAndKeepsJournal(t *testing.T) {
	root, first := newLocalJournal(t, map[string]string{"MEMORY.md": "offline\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 3, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(first))

	changed := binding
	changed.Generation++
	_, err = Open(Options{Root: root, StatePath: statePath, Binding: changed, Remote: remote})
	require.ErrorContains(t, err, "another binding generation")
	require.FileExists(t, statePath)
	require.Equal(t, "offline\n", readFile(t, filepath.Join(root, "MEMORY.md")))
}

func TestSyncAcknowledgesLostResponseButStillAdmitsCurrentRemoteHead(t *testing.T) {
	root, first := newLocalJournal(t, map[string]string{"MEMORY.md": "saved\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{errors: []error{errors.New("response lost")}}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 2, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, MinInterval: time.Nanosecond})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(first))
	_, err = syncer.Publish(context.Background())
	require.EqualError(t, err, "response lost")

	acknowledged := binding
	acknowledged.ContentVersionID = "ctv_committed"
	acknowledged.AcknowledgedSequence = 1
	restarted, err := Open(Options{Root: root, StatePath: statePath, Binding: acknowledged, Remote: remote, MinInterval: time.Nanosecond})
	require.NoError(t, err)
	require.False(t, restarted.Pending())
	require.Equal(t, "ctv_committed", restarted.RemoteRefreshVersion(), "sequence acknowledgement alone must not adopt the active head")
	require.NoError(t, restarted.BeginRemoteRefresh("ctv_committed"))
	remoteRevision := commitFiles(t, root, map[string]string{"MEMORY.md": "exact current cloud\n"})
	require.NoError(t, restarted.CompleteRemoteRefresh("ctv_committed", remoteRevision))
	_, err = restarted.Publish(context.Background())
	require.ErrorIs(t, err, ErrNothingPending)
}

func TestSyncPersistsLostResponseAckButRejectsLaterHeadWithLocalTail(t *testing.T) {
	root, first := newLocalJournal(t, map[string]string{"MEMORY.md": "published\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{errors: []error{errors.New("response lost")}}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 2, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote, MinInterval: time.Nanosecond})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(first))
	_, err = syncer.Publish(context.Background())
	require.EqualError(t, err, "response lost")
	tail := commitFiles(t, root, map[string]string{"MEMORY.md": "new local tail\n"})
	require.NoError(t, syncer.RecordLocalRevision(tail))

	restored := binding
	restored.ContentVersionID = "ctv_restored_after_ack"
	restored.AcknowledgedSequence = 1
	_, err = Open(Options{Root: root, StatePath: statePath, Binding: restored, Remote: remote, MinInterval: time.Nanosecond})
	require.ErrorIs(t, err, ErrRemoteHeadChanged)

	data, err := os.ReadFile(statePath)
	require.NoError(t, err)
	var persisted state
	require.NoError(t, json.Unmarshal(data, &persisted))
	require.EqualValues(t, 1, persisted.AcknowledgedSequence)
	require.EqualValues(t, 2, persisted.LocalSequence)
	require.Nil(t, persisted.Pending, "the exact server acknowledgement is durable even while the later head conflicts")
	require.Equal(t, tail, persisted.LocalRevision)
}

func TestSyncDurablyAdoptsExactRemoteRefreshWithoutOutboundSequence(t *testing.T) {
	root, baseline := newLocalJournal(t, map[string]string{"MEMORY.md": "old cloud\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_old", Generation: 2, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote})
	require.NoError(t, err)
	require.NoError(t, syncer.AdoptRemoteRevision(baseline))

	updated := binding
	updated.ContentVersionID = "ctv_restored"
	refreshing, err := Open(Options{Root: root, StatePath: statePath, Binding: updated, Remote: remote})
	require.NoError(t, err)
	require.Equal(t, "ctv_restored", refreshing.RemoteRefreshVersion())
	require.NoError(t, refreshing.BeginRemoteRefresh("ctv_restored"))

	// Crash after the durable marker: restart must resume only this exact head.
	restarted, err := Open(Options{Root: root, StatePath: statePath, Binding: updated, Remote: remote})
	require.NoError(t, err)
	require.Equal(t, "ctv_restored", restarted.RemoteRefreshVersion())
	restoredRevision := commitFiles(t, root, map[string]string{"MEMORY.md": "restored cloud\n"})
	require.NoError(t, restarted.CompleteRemoteRefresh("ctv_restored", restoredRevision))
	require.Empty(t, restarted.RemoteRefreshVersion())
	require.False(t, restarted.Pending())
	_, err = restarted.Publish(context.Background())
	require.ErrorIs(t, err, ErrNothingPending)
	require.Empty(t, remote.calls)
}

func TestSyncRejectsConcurrentRemoteHeadWhileLocalRangeIsPending(t *testing.T) {
	root, baseline := newLocalJournal(t, map[string]string{"MEMORY.md": "old cloud\n"})
	statePath := filepath.Join(t.TempDir(), "native.json")
	remote := &fakeRemote{}
	binding := Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_old", Generation: 2, ReplicaID: "replica_1"}
	syncer, err := Open(Options{Root: root, StatePath: statePath, Binding: binding, Remote: remote})
	require.NoError(t, err)
	require.NoError(t, syncer.AdoptRemoteRevision(baseline))
	localRevision := commitFiles(t, root, map[string]string{"MEMORY.md": "local pending\n"})
	require.NoError(t, syncer.RecordLocalRevision(localRevision))

	updated := binding
	updated.ContentVersionID = "ctv_concurrent"
	_, err = Open(Options{Root: root, StatePath: statePath, Binding: updated, Remote: remote})
	require.ErrorIs(t, err, ErrRemoteHeadChanged)
	require.Equal(t, "local pending\n", readFile(t, filepath.Join(root, "MEMORY.md")))
}

func TestSyncRejectsUnsupportedTreeWithoutCallingRemote(t *testing.T) {
	root, _ := newLocalJournal(t, map[string]string{"MEMORY.md": "ok\n"})
	require.NoError(t, os.Symlink("MEMORY.md", filepath.Join(root, "linked.md")))
	revision := commitAll(t, root)
	remote := &fakeRemote{}
	syncer, err := Open(Options{
		Root: root, StatePath: filepath.Join(t.TempDir(), "native.json"), Remote: remote,
		Binding: Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 1, ReplicaID: "replica_1"},
	})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(revision))
	_, err = syncer.Publish(context.Background())
	require.ErrorContains(t, err, "unsupported file mode")
	require.Empty(t, remote.calls)
	require.True(t, syncer.Pending())
}

func TestSyncRejectsAmbiguousPathWithoutCallingRemote(t *testing.T) {
	root, revision := newLocalJournal(t, map[string]string{" leading.md": "not normalized\n"})
	remote := &fakeRemote{}
	syncer, err := Open(Options{
		Root: root, StatePath: filepath.Join(t.TempDir(), "native.json"), Remote: remote,
		Binding: Binding{MemoryID: "mem_1", ContentID: "ctn_1", ContentVersionID: "ctv_base", Generation: 1, ReplicaID: "replica_1"},
	})
	require.NoError(t, err)
	require.NoError(t, syncer.RecordLocalRevision(revision))
	_, err = syncer.Publish(context.Background())
	require.ErrorContains(t, err, "unsafe checkpoint path")
	require.Empty(t, remote.calls)
	require.True(t, syncer.Pending())
}

func newLocalJournal(t *testing.T, files map[string]string) (string, string) {
	t.Helper()
	root := t.TempDir()
	_, err := git.PlainInit(root, false)
	require.NoError(t, err)
	return root, commitFiles(t, root, files)
}

func commitFiles(t *testing.T, root string, files map[string]string) string {
	t.Helper()
	for path, content := range files {
		full := filepath.Join(root, filepath.FromSlash(path))
		require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
		require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
	}
	return commitAll(t, root)
}

func commitAll(t *testing.T, root string) string {
	t.Helper()
	repo, err := git.PlainOpen(root)
	require.NoError(t, err)
	worktree, err := repo.Worktree()
	require.NoError(t, err)
	require.NoError(t, worktree.AddWithOptions(&git.AddOptions{All: true}))
	hash, err := worktree.Commit("checkpoint", &git.CommitOptions{Author: &object.Signature{Name: "test", Email: "test@example.com", When: time.Now().UTC()}})
	require.NoError(t, err)
	return hash.String()
}

func fileContent(t *testing.T, files []File, path string) string {
	t.Helper()
	for _, file := range files {
		if file.Path == path {
			return string(file.Content)
		}
	}
	t.Fatalf("file %s not found", path)
	return ""
}

func readFile(t *testing.T, path string) string {
	t.Helper()
	data, err := os.ReadFile(path)
	require.NoError(t, err)
	return string(data)
}
