package generic

import (
	"context"
	"fmt"
	"io"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/anchore/go-sync"
	"github.com/anchore/syft/internal/unknown"
	"github.com/anchore/syft/syft/artifact"
	"github.com/anchore/syft/syft/file"
	"github.com/anchore/syft/syft/pkg"
)

func Test_Cataloger(t *testing.T) {
	allParsedPaths := make(map[string]bool)
	parser := func(_ context.Context, resolver file.Resolver, env *Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
		allParsedPaths[reader.Path()] = true
		contents, err := io.ReadAll(reader)
		require.NoError(t, err)

		if len(contents) == 0 {
			return nil, nil, nil
		}

		p := pkg.Package{
			Name:      string(contents),
			Locations: file.NewLocationSet(reader.Location),
		}
		r := artifact.Relationship{
			From: p,
			To:   p,
			Type: artifact.ContainsRelationship,
		}

		return []pkg.Package{p}, []artifact.Relationship{r}, nil
	}

	upstream := "some-other-cataloger"

	expectedSelection := []string{"testdata/last/path.txt", "testdata/another-path.txt", "testdata/a-path.txt", "testdata/empty.txt"}
	resolver := file.NewMockResolverForPaths(expectedSelection...)
	cataloger := NewCataloger(upstream).
		WithParserByPath(parser, "testdata/another-path.txt", "testdata/last/path.txt").
		WithParserByGlobs(parser, "**/a-path.txt", "**/empty.txt")

	actualPkgs, relationships, err := cataloger.Catalog(context.Background(), resolver)
	assert.NoError(t, err)

	expectedPkgs := make(map[string]pkg.Package)
	for _, path := range expectedSelection {
		require.True(t, allParsedPaths[path])
		if path == "testdata/empty.txt" {
			continue // note: empty.txt won't become a package
		}
		expectedPkgs[path] = pkg.Package{
			FoundBy: upstream,
			Name:    fmt.Sprintf("%s file contents!", path),
		}
	}

	assert.Len(t, allParsedPaths, len(expectedSelection))
	assert.Len(t, actualPkgs, len(expectedPkgs))
	assert.Len(t, relationships, len(actualPkgs))

	for _, p := range actualPkgs {
		ls := p.Locations.ToSlice()
		require.NotEmpty(t, ls)
		ref := ls[0]
		exP, ok := expectedPkgs[ref.RealPath]
		if !ok {
			t.Errorf("missing expected pkg: ref=%+v", ref)
			continue
		}

		// assigned by the generic cataloger
		if p.FoundBy != exP.FoundBy {
			t.Errorf("bad upstream: %s", p.FoundBy)
		}

		// assigned by the parser
		if exP.Name != p.Name {
			t.Errorf("bad contents mapping: %+v", p.Locations)
		}
	}
}

type spyReturningFileResolver struct {
	m *file.MockResolver
	s *spyingIoReadCloser
}

type spyingIoReadCloser struct {
	rc     io.ReadCloser
	closed bool
}

func newSpyReturningFileResolver(s *spyingIoReadCloser, paths ...string) file.Resolver {
	m := file.NewMockResolverForPaths(paths...)
	return spyReturningFileResolver{
		m: m,
		s: s,
	}
}

func (s *spyingIoReadCloser) Read(p []byte) (n int, err error) {
	return s.rc.Read(p)
}

func (s *spyingIoReadCloser) Close() error {
	s.closed = true
	return s.rc.Close()
}

var _ io.ReadCloser = (*spyingIoReadCloser)(nil)

func (m spyReturningFileResolver) FileContentsByLocation(location file.Location) (io.ReadCloser, error) {
	return m.s, nil
}

func (m spyReturningFileResolver) HasPath(path string) bool {
	return m.m.HasPath(path)
}

func (m spyReturningFileResolver) FilesByPath(paths ...string) ([]file.Location, error) {
	return m.m.FilesByPath(paths...)
}

func (m spyReturningFileResolver) FilesByGlob(patterns ...string) ([]file.Location, error) {
	return m.m.FilesByGlob(patterns...)
}

func (m spyReturningFileResolver) FilesByMIMEType(types ...string) ([]file.Location, error) {
	return m.m.FilesByMIMEType(types...)
}

func (m spyReturningFileResolver) FilesByMediaType(types ...string) ([]file.Location, error) {
	return m.m.FilesByMediaType(types...)
}

func (m spyReturningFileResolver) RelativeFileByPath(f file.Location, path string) *file.Location {
	return m.m.RelativeFileByPath(f, path)
}

func (m spyReturningFileResolver) AllLocations(ctx context.Context) <-chan file.Location {
	return m.m.AllLocations(ctx)
}

func (m spyReturningFileResolver) FileMetadataByLocation(location file.Location) (file.Metadata, error) {
	return m.m.FileMetadataByLocation(location)
}

var _ file.Resolver = (*spyReturningFileResolver)(nil)

func TestClosesFileOnParserPanic(t *testing.T) {
	rc := io.NopCloser(strings.NewReader("some string"))
	spy := spyingIoReadCloser{
		rc: rc,
	}
	resolver := newSpyReturningFileResolver(&spy, "testdata/another-path.txt")
	ctx := context.TODO()

	processors := []requester{
		func(resolver file.Resolver, env Environment) []request {
			return []request{
				{
					Location: file.Location{
						LocationData: file.LocationData{
							Coordinates: file.Coordinates{},
							AccessPath:  "/some/access/path",
						},
					},
					Parser: func(context.Context, file.Resolver, *Environment, file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
						panic("panic!")
					},
				},
			}
		},
	}

	c := Cataloger{
		requesters:        processors,
		upstreamCataloger: "unit-test-cataloger",
	}

	_, _, err := c.Catalog(ctx, resolver)
	require.Error(t, err)
	var panicErr sync.PanicError
	require.ErrorAs(t, err, &panicErr)
	assert.Equal(t, "panic!", panicErr.Value)
	require.True(t, spy.closed)
}

func Test_CatalogerWithParserByMediaType(t *testing.T) {
	allParsedPaths := make(map[string]bool)
	parser := func(_ context.Context, resolver file.Resolver, env *Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
		allParsedPaths[reader.Path()] = true
		contents, err := io.ReadAll(reader)
		require.NoError(t, err)

		if len(contents) == 0 {
			return nil, nil, nil
		}

		p := pkg.Package{
			Name:      string(contents),
			Locations: file.NewLocationSet(reader.Location),
		}

		return []pkg.Package{p}, nil, nil
	}

	upstream := "media-type-cataloger"

	// Create locations with test fixtures that exist on disk
	loc1 := file.NewLocation("testdata/a-path.txt")
	loc2 := file.NewLocation("testdata/another-path.txt")

	// Create a mock resolver that maps media types to locations
	resolver := file.NewMockResolverForMediaTypes(map[string][]file.Location{
		"application/vnd.test.model": {loc1, loc2},
	})

	cataloger := NewCataloger(upstream).
		WithParserByMediaType(parser, "application/vnd.test.model")

	actualPkgs, _, err := cataloger.Catalog(context.Background(), resolver)
	assert.NoError(t, err)

	// Verify both files were parsed
	assert.True(t, allParsedPaths["testdata/a-path.txt"], "expected a-path.txt to be parsed")
	assert.True(t, allParsedPaths["testdata/another-path.txt"], "expected another-path.txt to be parsed")

	// Verify packages were created
	assert.Len(t, actualPkgs, 2)

	// Verify FoundBy is set correctly
	for _, p := range actualPkgs {
		assert.Equal(t, upstream, p.FoundBy)
	}
}

func Test_genericCatalogerReturnsErrors(t *testing.T) {
	genericErrorReturning := NewCataloger("error returning").WithParserByGlobs(func(ctx context.Context, resolver file.Resolver, environment *Environment, locationReader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
		return []pkg.Package{
			{
				Name: "some-package-" + locationReader.Path(),
			},
		}, nil, unknown.Newf(locationReader, "unable to read")
	}, "**/*")

	m := file.NewMockResolverForPaths(
		"testdata/a-path.txt",
		"testdata/empty.txt",
	)

	got, _, errs := genericErrorReturning.Catalog(context.TODO(), m)

	// require packages and errors
	require.NotEmpty(t, got)

	unknowns, others := unknown.ExtractCoordinateErrors(errs)
	require.NotEmpty(t, unknowns)
	require.Empty(t, others)
}
