package query

import (
	"testing"

	"github.com/stretchr/testify/require"

	"github.com/authzed/spicedb/internal/datastore/dsfortesting"
	"github.com/authzed/spicedb/internal/datastore/memdb"
	"github.com/authzed/spicedb/internal/testfixtures"
)

func TestExclusionIterator(t *testing.T) {
	t.Parallel()

	require := require.New(t)
	rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(0, 0, memdb.DisableGC)
	require.NoError(err)

	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)

	ctx := &Context{
		Context:   t.Context(),
		Executor:  LocalExecutor{},
		Datastore: ds,
		Revision:  revision,
	}

	// Create test paths
	path1 := MustPathFromString("document:doc1#viewer@user:alice")
	path2 := MustPathFromString("document:doc2#viewer@user:bob")
	path3 := MustPathFromString("document:doc3#viewer@user:charlie")

	t.Run("Basic Exclusion", func(t *testing.T) {
		t.Parallel()
		// Main set: path1, path2, path3
		// Excluded set: path2
		// Expected result: path1, path3
		mainSet := NewFixedIterator(path1, path2, path3)
		excludedSet := NewFixedIterator(path2)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2", "doc3"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 1, "Should exclude doc2#viewer@user:bob but keep doc1#viewer@user:alice")
		require.Equal(path1, rels[0])
	})

	t.Run("Empty Main Set", func(t *testing.T) {
		t.Parallel()
		// Main set: empty
		// Excluded set: path1
		// Expected result: empty
		mainSet := NewFixedIterator()
		excludedSet := NewFixedIterator(path1)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Empty(rels, "Empty main set should result in empty exclusion")
	})

	t.Run("Empty Excluded Set", func(t *testing.T) {
		t.Parallel()
		// Main set: path1, path2
		// Excluded set: empty
		// Expected result: path1, path2 (nothing to exclude)
		mainSet := NewFixedIterator(path1, path2)
		excludedSet := NewFixedIterator()

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 1, "Should return main set when nothing to exclude")
		require.Equal(path1, rels[0])
	})

	t.Run("No Overlap", func(t *testing.T) {
		t.Parallel()
		// Create relations with truly different endpoints
		mainPath1 := MustPathFromString("document:doc1#viewer@user:alice")
		mainPath2 := MustPathFromString("document:doc2#viewer@user:bob")
		excludePath1 := MustPathFromString("document:doc3#viewer@user:charlie")
		excludePath2 := MustPathFromString("document:doc4#viewer@user:dave")

		// Main set: doc1:alice, doc2:bob
		// Excluded set: doc3:charlie, doc4:dave
		// Expected result: both main relations (no endpoint overlap)
		mainSet := NewFixedIterator(mainPath1, mainPath2)
		excludedSet := NewFixedIterator(excludePath1, excludePath2)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 1, "Should return main relations matching subject when no endpoint overlap")
		require.Equal(mainPath1, rels[0]) // Only alice matches the subject filter
	})

	t.Run("Complete Exclusion", func(t *testing.T) {
		t.Parallel()
		// Main set: path1, path2
		// Excluded set: path1, path2, path3
		// Expected result: empty (all main set relations are excluded)
		mainSet := NewFixedIterator(path1, path2)
		excludedSet := NewFixedIterator(path1, path2, path3)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Empty(rels, "Should return empty when all main relations are excluded")
	})

	t.Run("Partial Exclusion", func(t *testing.T) {
		t.Parallel()
		// Create relations with different relations but same endpoints to test exclusion logic
		pathA := MustPathFromString("document:doc1#viewer@user:alice")
		pathB := MustPathFromString("document:doc2#viewer@user:alice")
		pathC := MustPathFromString("document:doc3#viewer@user:alice")
		pathD := MustPathFromString("document:doc4#viewer@user:alice")

		// Create excluded relations with different relations but same endpoints as pathB and pathD
		excludePathB := MustPathFromString("document:doc2#editor@user:alice") // Same endpoint as pathB
		excludePathD := MustPathFromString("document:doc4#owner@user:alice")  // Same endpoint as pathD

		// Main set: pathA, pathB, pathC, pathD
		// Excluded set: excludePathB (matches doc2:alice), excludePathD (matches doc4:alice)
		// Expected result: pathA, pathC (pathB and pathD excluded due to matching endpoints)
		mainSet := NewFixedIterator(pathA, pathB, pathC, pathD)
		excludedSet := NewFixedIterator(excludePathB, excludePathD)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2", "doc3", "doc4"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 2, "Should exclude relations with endpoints matching excluded set")

		// Check that we got the expected relations
		foundRelA := false
		foundRelC := false
		for _, rel := range rels {
			if rel.Resource.ObjectID == "doc1" {
				foundRelA = true
			}
			if rel.Resource.ObjectID == "doc3" {
				foundRelC = true
			}
		}
		require.True(foundRelA, "Should contain pathA (doc1)")
		require.True(foundRelC, "Should contain pathC (doc3)")
	})

	t.Run("Clone", func(t *testing.T) {
		t.Parallel()
		mainSet := NewFixedIterator(path1, path2)
		excludedSet := NewFixedIterator(path2)

		original := NewExclusion(mainSet, excludedSet)
		cloned := original.Clone()

		require.NotNil(cloned)
		require.IsType(&Exclusion{}, cloned)

		// Test that cloned exclusion works the same as original
		pathSeq, err := cloned.CheckImpl(ctx, NewObjects("document", "doc1", "doc2"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 1, "Cloned exclusion should work the same as original")
		require.Equal(path1, rels[0])
	})

	t.Run("Explain", func(t *testing.T) {
		t.Parallel()
		mainSet := NewFixedIterator(path1)
		excludedSet := NewFixedIterator(path2)

		exclusion := NewExclusion(mainSet, excludedSet)

		explain := exclusion.Explain()
		require.Equal("Exclusion", explain.Info)
		require.Len(explain.SubExplain, 2, "Should have two sub-explanations")

		explainStr := explain.String()
		require.Contains(explainStr, "Exclusion")
		require.Contains(explainStr, "Fixed")
	})
}

func TestExclusionWithEmptyIterator(t *testing.T) {
	t.Parallel()

	require := require.New(t)
	rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(0, 0, memdb.DisableGC)
	require.NoError(err)

	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)

	ctx := &Context{
		Context:   t.Context(),
		Executor:  LocalExecutor{},
		Datastore: ds,
		Revision:  revision,
	}

	path1 := MustPathFromString("document:doc1#viewer@user:alice")

	t.Run("Empty as Main Set", func(t *testing.T) {
		t.Parallel()
		mainSet := NewEmptyFixedIterator()
		excludedSet := NewFixedIterator(path1)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Empty(rels, "Empty main set should result in empty exclusion")
	})

	t.Run("Empty as Excluded Set", func(t *testing.T) {
		t.Parallel()
		mainSet := NewFixedIterator(path1)
		excludedSet := NewEmptyFixedIterator()

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 1, "Should return main set when excluded set is empty")
		require.Equal(path1, rels[0])
	})
}

func TestExclusionUnimplementedMethods(t *testing.T) {
	t.Parallel()

	require := require.New(t)
	rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(0, 0, memdb.DisableGC)
	require.NoError(err)

	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)

	ctx := &Context{
		Context:   t.Context(),
		Executor:  LocalExecutor{},
		Datastore: ds,
		Revision:  revision,
	}

	path1 := MustPathFromString("document:doc1#viewer@user:alice")
	mainSet := NewFixedIterator(path1)
	excludedSet := NewFixedIterator()

	exclusion := NewExclusion(mainSet, excludedSet)

	t.Run("IterSubjectsImpl Unimplemented", func(t *testing.T) {
		t.Parallel()
		require.Panics(func() {
			_, _ = exclusion.IterSubjectsImpl(ctx, NewObject("document", "doc1"))
		}, "Should panic since method is unimplemented")
	})

	t.Run("IterResourcesImpl Unimplemented", func(t *testing.T) {
		t.Parallel()
		require.Panics(func() {
			_, _ = exclusion.IterResourcesImpl(ctx, NewObject("user", "alice").WithEllipses())
		}, "Should panic since method is unimplemented")
	})
}

func TestExclusionErrorHandling(t *testing.T) {
	t.Parallel()

	require := require.New(t)
	rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(0, 0, memdb.DisableGC)
	require.NoError(err)

	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)

	ctx := &Context{
		Context:   t.Context(),
		Executor:  LocalExecutor{},
		Datastore: ds,
		Revision:  revision,
	}

	path1 := MustPathFromString("document:doc1#viewer@user:alice")

	t.Run("Main Set Error Propagation", func(t *testing.T) {
		t.Parallel()
		// Create a faulty iterator for the main set
		mainSet := NewFaultyIterator(true, false)
		excludedSet := NewFixedIterator(path1)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.Error(err)
		require.Contains(err.Error(), "faulty iterator error")
		require.Nil(pathSeq)
	})

	t.Run("Excluded Set Error Propagation", func(t *testing.T) {
		t.Parallel()
		// Create a normal main set and faulty excluded set
		mainSet := NewFixedIterator(path1)
		excludedSet := NewFaultyIterator(true, false)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.Error(err)
		require.Contains(err.Error(), "faulty iterator error")
		require.Nil(pathSeq)
	})

	t.Run("Main Set Collection Error", func(t *testing.T) {
		t.Parallel()
		// Create an iterator that fails during collection
		mainSet := NewFaultyIterator(false, true)
		excludedSet := NewFixedIterator(path1)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.Error(err)
		require.Contains(err.Error(), "faulty iterator collection error")
		require.Nil(pathSeq)
	})

	t.Run("Excluded Set Collection Error", func(t *testing.T) {
		t.Parallel()
		// Create an iterator that fails during collection
		mainSet := NewFixedIterator(path1)
		excludedSet := NewFaultyIterator(false, true)

		exclusion := NewExclusion(mainSet, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1"), NewObject("user", "alice").WithEllipses())
		require.Error(err)
		require.Contains(err.Error(), "faulty iterator collection error")
		require.Nil(pathSeq)
	})
}

func TestExclusionWithComplexIteratorTypes(t *testing.T) {
	t.Parallel()

	require := require.New(t)
	rawDS, err := dsfortesting.NewMemDBDatastoreForTesting(0, 0, memdb.DisableGC)
	require.NoError(err)

	ds, revision := testfixtures.StandardDatastoreWithData(rawDS, require)

	ctx := &Context{
		Context:   t.Context(),
		Executor:  LocalExecutor{},
		Datastore: ds,
		Revision:  revision,
	}

	// Create test relations
	path1 := MustPathFromString("document:doc1#viewer@user:alice")
	path2 := MustPathFromString("document:doc2#viewer@user:alice")
	path3 := MustPathFromString("document:doc3#viewer@user:alice")
	path4 := MustPathFromString("document:doc4#viewer@user:alice")

	t.Run("Exclusion with Union as Main Set", func(t *testing.T) {
		t.Parallel()
		// Create union iterator as main set
		union := NewUnion()
		union.addSubIterator(NewFixedIterator(path1, path2))
		union.addSubIterator(NewFixedIterator(path3))

		excludedSet := NewFixedIterator(path2) // Exclude path2

		exclusion := NewExclusion(union, excludedSet)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2", "doc3"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 2, "Should return path1 and path3, excluding path2")

		// Check which relations we got
		foundRel1, foundRel3 := false, false
		for _, rel := range rels {
			if rel.Resource.ObjectID == "doc1" {
				foundRel1 = true
			}
			if rel.Resource.ObjectID == "doc3" {
				foundRel3 = true
			}
		}
		require.True(foundRel1, "Should contain path1")
		require.True(foundRel3, "Should contain path3")
	})

	t.Run("Exclusion with Union as Excluded Set", func(t *testing.T) {
		t.Parallel()
		mainSet := NewFixedIterator(path1, path2, path3, path4)

		// Create union iterator as excluded set
		union := NewUnion()
		union.addSubIterator(NewFixedIterator(path2))
		union.addSubIterator(NewFixedIterator(path4))

		exclusion := NewExclusion(mainSet, union)

		pathSeq, err := exclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2", "doc3", "doc4"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 2, "Should return path1 and path3, excluding path2 and path4")

		// Check which relations we got
		foundRel1, foundRel3 := false, false
		for _, rel := range rels {
			if rel.Resource.ObjectID == "doc1" {
				foundRel1 = true
			}
			if rel.Resource.ObjectID == "doc3" {
				foundRel3 = true
			}
		}
		require.True(foundRel1, "Should contain path1")
		require.True(foundRel3, "Should contain path3")
	})

	t.Run("Nested Exclusion", func(t *testing.T) {
		t.Parallel()
		// Create a nested exclusion: (path1 + path2 + path3) - path2 - path3
		innerMainSet := NewFixedIterator(path1, path2, path3)
		innerExcludedSet := NewFixedIterator(path2)
		innerExclusion := NewExclusion(innerMainSet, innerExcludedSet)

		outerExcludedSet := NewFixedIterator(path3)
		outerExclusion := NewExclusion(innerExclusion, outerExcludedSet)

		pathSeq, err := outerExclusion.CheckImpl(ctx, NewObjects("document", "doc1", "doc2", "doc3"), NewObject("user", "alice").WithEllipses())
		require.NoError(err)

		rels, err := CollectAll(pathSeq)
		require.NoError(err)
		require.Len(rels, 1, "Should return only path1 after nested exclusions")
		require.Equal("doc1", rels[0].Resource.ObjectID)
	})
}
