// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package verify

import (
	"fmt"
	"math/bits"
	"strings"
	"testing"

	"github.com/golang/dep/gps"
)

func contains(haystack []string, needle string) bool {
	for _, str := range haystack {
		if str == needle {
			return true
		}
	}
	return false
}

func (dd DeltaDimension) String() string {
	var parts []string

	for dd != 0 {
		index := bits.TrailingZeros32(uint32(dd))
		dd &= ^(1 << uint(index))

		switch DeltaDimension(1 << uint(index)) {
		case InputImportsChanged:
			parts = append(parts, "input imports")
		case ProjectAdded:
			parts = append(parts, "project added")
		case ProjectRemoved:
			parts = append(parts, "project removed")
		case SourceChanged:
			parts = append(parts, "source changed")
		case VersionChanged:
			parts = append(parts, "version changed")
		case RevisionChanged:
			parts = append(parts, "revision changed")
		case PackagesChanged:
			parts = append(parts, "packages changed")
		case PruneOptsChanged:
			parts = append(parts, "pruneopts changed")
		case HashVersionChanged:
			parts = append(parts, "hash version changed")
		case HashChanged:
			parts = append(parts, "hash digest changed")
		}
	}

	return strings.Join(parts, ", ")
}

func TestLockDelta(t *testing.T) {
	fooversion := gps.NewVersion("v1.0.0").Pair("foorev1")
	bazversion := gps.NewVersion("v2.0.0").Pair("bazrev1")
	transver := gps.NewVersion("v0.5.0").Pair("transrev1")
	l := safeLock{
		i: []string{"foo.com/bar", "baz.com/qux"},
		p: []gps.LockedProject{
			newVerifiableProject(mkPI("foo.com/bar"), fooversion, []string{".", "subpkg"}),
			newVerifiableProject(mkPI("baz.com/qux"), bazversion, []string{".", "other"}),
			newVerifiableProject(mkPI("transitive.com/dependency"), transver, []string{"."}),
		},
	}

	var dup lockTransformer = func(l safeLock) safeLock {
		return l.dup()
	}

	tt := map[string]struct {
		lt      lockTransformer
		delta   DeltaDimension
		checkfn func(*testing.T, LockDelta)
	}{
		"ident": {
			lt: dup,
		},
		"added import": {
			lt:    dup.addII("other.org"),
			delta: InputImportsChanged,
		},
		"added import 2x": {
			lt:    dup.addII("other.org").addII("andsomethingelse.com/wowie"),
			delta: InputImportsChanged,
			checkfn: func(t *testing.T, ld LockDelta) {
				if !contains(ld.AddedImportInputs, "other.org") {
					t.Error("first added input import missing")
				}
				if !contains(ld.AddedImportInputs, "andsomethingelse.com/wowie") {
					t.Error("first added input import missing")
				}
			},
		},
		"removed import": {
			lt:    dup.rmII("baz.com/qux"),
			delta: InputImportsChanged,
			checkfn: func(t *testing.T, ld LockDelta) {
				if !contains(ld.RemovedImportInputs, "baz.com/qux") {
					t.Error("removed input import missing")
				}
			},
		},
		"add project": {
			lt:    dup.addDumbProject("madeup.org"),
			delta: ProjectAdded,
		},
		"remove project": {
			lt:    dup.rmProject("foo.com/bar"),
			delta: ProjectRemoved,
		},
		"remove last project": {
			lt:    dup.rmProject("transitive.com/dependency"),
			delta: ProjectRemoved,
		},
		"all": {
			lt:    dup.addII("other.org").rmII("baz.com/qux").addDumbProject("zebrafun.org").rmProject("foo.com/bar"),
			delta: InputImportsChanged | ProjectRemoved | ProjectAdded,
		},
		"remove all projects and imports": {
			lt:    dup.rmII("baz.com/qux").rmII("foo.com/bar").rmProject("baz.com/qux").rmProject("foo.com/bar").rmProject("transitive.com/dependency"),
			delta: InputImportsChanged | ProjectRemoved,
		},
	}

	for name, fix := range tt {
		fix := fix
		t.Run(name, func(t *testing.T) {
			fixl := fix.lt(l)
			ld := DiffLocks(l, fixl)

			if !ld.Changed(AnyChanged) && fix.delta != 0 {
				t.Errorf("Changed() reported false when expecting some dimensions to be changed: %s", fix.delta)
			} else if ld.Changed(AnyChanged) && fix.delta == 0 {
				t.Error("Changed() reported true when expecting no changes")
			}
			if ld.Changed(AnyChanged & ^fix.delta) {
				t.Errorf("Changed() reported true when checking along not-expected dimensions: %s", ld.Changes() & ^fix.delta)
			}

			gotdelta := ld.Changes()
			if fix.delta & ^gotdelta != 0 {
				t.Errorf("wanted change in some dimensions that were unchanged: %s", fix.delta & ^gotdelta)
			}
			if gotdelta & ^fix.delta != 0 {
				t.Errorf("did not want change in some dimensions that were changed: %s", gotdelta & ^fix.delta)
			}

			if fix.checkfn != nil {
				fix.checkfn(t, ld)
			}
		})
	}
}

func TestLockedProjectPropertiesDelta(t *testing.T) {
	fooversion, foorev := gps.NewVersion("v1.0.0"), gps.Revision("foorev1")
	foopair := fooversion.Pair(foorev)
	foovp := VerifiableProject{
		LockedProject: gps.NewLockedProject(mkPI("foo.com/project"), foopair, []string{".", "subpkg"}),
		PruneOpts:     gps.PruneNestedVendorDirs,
		Digest: VersionedDigest{
			HashVersion: HashVersion,
			Digest:      []byte("foobytes"),
		},
	}
	var dup lockedProjectTransformer = func(lp gps.LockedProject) gps.LockedProject {
		return lp.(VerifiableProject).dup()
	}

	tt := map[string]struct {
		lt1, lt2 lockedProjectTransformer
		delta    DeltaDimension
		checkfn  func(*testing.T, LockedProjectPropertiesDelta)
	}{
		"ident": {
			lt1: dup,
		},
		"add pkg": {
			lt1:   dup.addPkg("whatev"),
			delta: PackagesChanged,
		},
		"rm pkg": {
			lt1:   dup.rmPkg("subpkg"),
			delta: PackagesChanged,
		},
		"add and rm pkg": {
			lt1:   dup.rmPkg("subpkg").addPkg("whatev"),
			delta: PackagesChanged,
			checkfn: func(t *testing.T, ld LockedProjectPropertiesDelta) {
				if !contains(ld.PackagesAdded, "whatev") {
					t.Error("added pkg missing from list")
				}
				if !contains(ld.PackagesRemoved, "subpkg") {
					t.Error("removed pkg missing from list")
				}
			},
		},
		"add source": {
			lt1:   dup.setSource("somethingelse"),
			delta: SourceChanged,
		},
		"remove source": {
			lt1:   dup.setSource("somethingelse"),
			lt2:   dup,
			delta: SourceChanged,
		},
		"to rev only": {
			lt1:   dup.setVersion(foorev),
			delta: VersionChanged,
		},
		"from rev only": {
			lt1:   dup.setVersion(foorev),
			lt2:   dup,
			delta: VersionChanged,
		},
		"to new rev only": {
			lt1:   dup.setVersion(gps.Revision("newrev")),
			delta: VersionChanged | RevisionChanged,
		},
		"from new rev only": {
			lt1:   dup.setVersion(gps.Revision("newrev")),
			lt2:   dup,
			delta: VersionChanged | RevisionChanged,
		},
		"version change": {
			lt1:   dup.setVersion(gps.NewVersion("v0.5.0").Pair(foorev)),
			delta: VersionChanged,
		},
		"version change to norev": {
			lt1:   dup.setVersion(gps.NewVersion("v0.5.0")),
			delta: VersionChanged | RevisionChanged,
		},
		"version change from norev": {
			lt1:   dup.setVersion(gps.NewVersion("v0.5.0")),
			lt2:   dup.setVersion(gps.NewVersion("v0.5.0").Pair(foorev)),
			delta: RevisionChanged,
		},
		"to branch": {
			lt1:   dup.setVersion(gps.NewBranch("master").Pair(foorev)),
			delta: VersionChanged,
		},
		"to branch new rev": {
			lt1:   dup.setVersion(gps.NewBranch("master").Pair(gps.Revision("newrev"))),
			delta: VersionChanged | RevisionChanged,
		},
		"to empty prune opts": {
			lt1:   dup.setPruneOpts(0),
			delta: PruneOptsChanged,
		},
		"from empty prune opts": {
			lt1:   dup.setPruneOpts(0),
			lt2:   dup,
			delta: PruneOptsChanged,
		},
		"prune opts change": {
			lt1:   dup.setPruneOpts(gps.PruneNestedVendorDirs | gps.PruneNonGoFiles),
			delta: PruneOptsChanged,
		},
		"empty digest": {
			lt1:   dup.setDigest(VersionedDigest{}),
			delta: HashVersionChanged | HashChanged,
		},
		"to empty digest": {
			lt1:   dup.setDigest(VersionedDigest{}),
			lt2:   dup,
			delta: HashVersionChanged | HashChanged,
		},
		"hash version changed": {
			lt1:   dup.setDigest(VersionedDigest{HashVersion: HashVersion + 1, Digest: []byte("foobytes")}),
			delta: HashVersionChanged,
		},
		"hash contents changed": {
			lt1:   dup.setDigest(VersionedDigest{HashVersion: HashVersion, Digest: []byte("barbytes")}),
			delta: HashChanged,
		},
		"to plain locked project": {
			lt1:   dup.toPlainLP(),
			delta: PruneOptsChanged | HashChanged | HashVersionChanged,
		},
		"from plain locked project": {
			lt1:   dup.toPlainLP(),
			lt2:   dup,
			delta: PruneOptsChanged | HashChanged | HashVersionChanged,
		},
		"all": {
			lt1:   dup.setDigest(VersionedDigest{}).setVersion(gps.NewBranch("master").Pair(gps.Revision("newrev"))).setPruneOpts(gps.PruneNestedVendorDirs | gps.PruneNonGoFiles).setSource("whatever"),
			delta: SourceChanged | VersionChanged | RevisionChanged | PruneOptsChanged | HashChanged | HashVersionChanged,
		},
	}

	for name, fix := range tt {
		fix := fix
		t.Run(name, func(t *testing.T) {
			// Use two patterns for constructing locks to compare: if only lt1
			// is set, use foovp as the first lp and compare with the lt1
			// transforms applied. If lt2 is set, transform foovp with lt1 for
			// the first lp, then transform foovp with lt2 for the second lp.
			var lp1, lp2 gps.LockedProject
			if fix.lt2 == nil {
				lp1 = foovp
				lp2 = fix.lt1(foovp)
			} else {
				lp1 = fix.lt1(foovp)
				lp2 = fix.lt2(foovp)
			}

			lppd := DiffLockedProjectProperties(lp1, lp2)
			if !lppd.Changed(AnyChanged) && fix.delta != 0 {
				t.Errorf("Changed() reporting false when expecting some dimensions to be changed: %s", fix.delta)
			} else if lppd.Changed(AnyChanged) && fix.delta == 0 {
				t.Error("Changed() reporting true when expecting no changes")
			}
			if lppd.Changed(AnyChanged & ^fix.delta) {
				t.Errorf("Changed() reported true when checking along not-expected dimensions: %s", lppd.Changes() & ^fix.delta)
			}

			gotdelta := lppd.Changes()
			if fix.delta & ^gotdelta != 0 {
				t.Errorf("wanted change in some dimensions that were unchanged: %s", fix.delta & ^gotdelta)
			}
			if gotdelta & ^fix.delta != 0 {
				t.Errorf("did not want change in some dimensions that were changed: %s", gotdelta & ^fix.delta)
			}

			if fix.checkfn != nil {
				fix.checkfn(t, lppd)
			}
		})
	}
}

type lockTransformer func(safeLock) safeLock

func (lt lockTransformer) compose(lt2 lockTransformer) lockTransformer {
	if lt == nil {
		return lt2
	}
	return func(l safeLock) safeLock {
		return lt2(lt(l))
	}
}

func (lt lockTransformer) addDumbProject(root string) lockTransformer {
	vp := newVerifiableProject(mkPI(root), gps.NewVersion("whatever").Pair("addedrev"), []string{"."})
	return lt.compose(func(l safeLock) safeLock {
		for _, lp := range l.p {
			if lp.Ident().ProjectRoot == vp.Ident().ProjectRoot {
				panic(fmt.Sprintf("%q already in lock", vp.Ident().ProjectRoot))
			}
		}
		l.p = append(l.p, vp)
		return l
	})
}

func (lt lockTransformer) rmProject(pr string) lockTransformer {
	return lt.compose(func(l safeLock) safeLock {
		for k, lp := range l.p {
			if lp.Ident().ProjectRoot == gps.ProjectRoot(pr) {
				l.p = l.p[:k+copy(l.p[k:], l.p[k+1:])]
				return l
			}
		}
		panic(fmt.Sprintf("%q not in lock", pr))
	})
}

func (lt lockTransformer) addII(path string) lockTransformer {
	return lt.compose(func(l safeLock) safeLock {
		for _, impath := range l.i {
			if path == impath {
				panic(fmt.Sprintf("%q already in input imports", impath))
			}
		}
		l.i = append(l.i, path)
		return l
	})
}

func (lt lockTransformer) rmII(path string) lockTransformer {
	return lt.compose(func(l safeLock) safeLock {
		for k, impath := range l.i {
			if path == impath {
				l.i = l.i[:k+copy(l.i[k:], l.i[k+1:])]
				return l
			}
		}
		panic(fmt.Sprintf("%q not in input imports", path))
	})
}

type lockedProjectTransformer func(gps.LockedProject) gps.LockedProject

func (lpt lockedProjectTransformer) compose(lpt2 lockedProjectTransformer) lockedProjectTransformer {
	if lpt == nil {
		return lpt2
	}
	return func(lp gps.LockedProject) gps.LockedProject {
		return lpt2(lpt(lp))
	}
}

func (lpt lockedProjectTransformer) addPkg(path string) lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		for _, pkg := range lp.Packages() {
			if path == pkg {
				panic(fmt.Sprintf("%q already in pkg list", path))
			}
		}

		nlp := gps.NewLockedProject(lp.Ident(), lp.Version(), append(lp.Packages(), path))
		if vp, ok := lp.(VerifiableProject); ok {
			vp.LockedProject = nlp
			return vp
		}
		return nlp
	})
}

func (lpt lockedProjectTransformer) rmPkg(path string) lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		pkglist := lp.Packages()
		for k, pkg := range pkglist {
			if path == pkg {
				pkglist = pkglist[:k+copy(pkglist[k:], pkglist[k+1:])]
				nlp := gps.NewLockedProject(lp.Ident(), lp.Version(), pkglist)
				if vp, ok := lp.(VerifiableProject); ok {
					vp.LockedProject = nlp
					return vp
				}
				return nlp
			}
		}
		panic(fmt.Sprintf("%q not in pkg list", path))
	})
}

func (lpt lockedProjectTransformer) setSource(source string) lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		ident := lp.Ident()
		ident.Source = source
		nlp := gps.NewLockedProject(ident, lp.Version(), lp.Packages())
		if vp, ok := lp.(VerifiableProject); ok {
			vp.LockedProject = nlp
			return vp
		}
		return nlp
	})
}

func (lpt lockedProjectTransformer) setVersion(v gps.Version) lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		nlp := gps.NewLockedProject(lp.Ident(), v, lp.Packages())
		if vp, ok := lp.(VerifiableProject); ok {
			vp.LockedProject = nlp
			return vp
		}
		return nlp
	})
}

func (lpt lockedProjectTransformer) setPruneOpts(po gps.PruneOptions) lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		vp := lp.(VerifiableProject)
		vp.PruneOpts = po
		return vp
	})
}

func (lpt lockedProjectTransformer) setDigest(vd VersionedDigest) lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		vp := lp.(VerifiableProject)
		vp.Digest = vd
		return vp
	})
}

func (lpt lockedProjectTransformer) toPlainLP() lockedProjectTransformer {
	return lpt.compose(func(lp gps.LockedProject) gps.LockedProject {
		if vp, ok := lp.(VerifiableProject); ok {
			return vp.LockedProject
		}
		return lp
	})
}
