// 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 main

import (
	"bytes"
	"flag"
	"fmt"
	"io/ioutil"
	"log"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/golang/dep"
	"github.com/golang/dep/gps"
	"github.com/golang/dep/gps/verify"
	"github.com/pkg/errors"
)

const checkShortHelp = `Check if imports, Gopkg.toml, and Gopkg.lock are in sync`
const checkLongHelp = `
Check determines if your project is in a good state. If problems are found, it
prints a description of each issue, then exits 1. Passing -q suppresses output.

Flags control which specific checks will be run. By default, dep check verifies
that Gopkg.lock is in sync with Gopkg.toml and the imports in your project's .go
files, and that the vendor directory is in sync with Gopkg.lock. These checks
can be disabled with -skip-lock and -skip-vendor, respectively.

(See https://golang.github.io/dep/docs/ensure-mechanics.html#staying-in-sync for
more information on what it means to be "in sync.")

If your workflow necessitates that you modify the contents of vendor, you can
force check to ignore hash mismatches on a per-project basis by naming
project roots in Gopkg.toml's "noverify" list.
`

type checkCommand struct {
	quiet                bool
	skiplock, skipvendor bool
}

func (cmd *checkCommand) Name() string { return "check" }
func (cmd *checkCommand) Args() string {
	return "[-q] [-skip-lock] [-skip-vendor]"
}
func (cmd *checkCommand) ShortHelp() string { return checkShortHelp }
func (cmd *checkCommand) LongHelp() string  { return checkLongHelp }
func (cmd *checkCommand) Hidden() bool      { return false }

func (cmd *checkCommand) Register(fs *flag.FlagSet) {
	fs.BoolVar(&cmd.skiplock, "skip-lock", false, "Skip checking that imports and Gopkg.toml are in sync with Gopkg.lock")
	fs.BoolVar(&cmd.skipvendor, "skip-vendor", false, "Skip checking that vendor is in sync with Gopkg.lock")
	fs.BoolVar(&cmd.quiet, "q", false, "Suppress non-error output")
}

func (cmd *checkCommand) Run(ctx *dep.Ctx, args []string) error {
	logger := ctx.Out
	if cmd.quiet {
		logger = log.New(ioutil.Discard, "", 0)
	}

	p, err := ctx.LoadProject()
	if err != nil {
		return err
	}

	sm, err := ctx.SourceManager()
	if err != nil {
		return err
	}

	sm.UseDefaultSignalHandling()
	defer sm.Release()

	var fail bool
	if !cmd.skiplock {
		if p.Lock == nil {
			return errors.New("Gopkg.lock does not exist, cannot check it against imports and Gopkg.toml")
		}

		lsat := verify.LockSatisfiesInputs(p.Lock, p.Manifest, p.RootPackageTree)
		delta := verify.DiffLocks(p.Lock, p.ChangedLock)
		sat, changed := lsat.Satisfied(), delta.Changed(verify.PruneOptsChanged|verify.HashVersionChanged)

		if changed || !sat {
			fail = true
			logger.Println("# Gopkg.lock is out of sync:")
			if !sat {
				logger.Printf("%s\n", sprintLockUnsat(lsat))
			}
			if changed {
				// Sort, for deterministic output.
				var ordered []string
				for pr := range delta.ProjectDeltas {
					ordered = append(ordered, string(pr))
				}
				sort.Strings(ordered)

				for _, pr := range ordered {
					lpd := delta.ProjectDeltas[gps.ProjectRoot(pr)]
					// Only two possible changes right now are prune opts
					// changing or a missing hash digest (for old Gopkg.lock
					// files)
					if lpd.PruneOptsChanged() {
						// Override what's on the lockdiff with the extra info we have;
						// this lets us excise PruneNestedVendorDirs and get the real
						// value from the input param in place.
						old := lpd.PruneOptsBefore & ^gps.PruneNestedVendorDirs
						new := lpd.PruneOptsAfter & ^gps.PruneNestedVendorDirs
						logger.Printf("%s: prune options changed (%s -> %s)\n", pr, old, new)
					}
					if lpd.HashVersionWasZero() {
						logger.Printf("%s: no hash digest in lock\n", pr)
					}
				}
			}
		}
	}

	if !cmd.skipvendor {
		if p.Lock == nil {
			return errors.New("Gopkg.lock does not exist, cannot check vendor against it")
		}

		statuses, err := p.VerifyVendor()
		if err != nil {
			return errors.Wrap(err, "error while verifying vendor")
		}

		if fail {
			logger.Println()
		}

		noverify := make(map[string]bool)
		for _, skip := range p.Manifest.NoVerify {
			noverify[skip] = true
		}

		var vendorfail, hasnoverify bool
		// One full pass through, to see if we need to print the header, and to
		// create an array of names to sort for deterministic output.
		var ordered []string
		for path, status := range statuses {
			ordered = append(ordered, path)

			switch status {
			case verify.DigestMismatchInLock, verify.HashVersionMismatch, verify.EmptyDigestInLock, verify.NotInLock:
				if noverify[path] {
					hasnoverify = true
					continue
				}
				fallthrough
			case verify.NotInTree:
				// NoVerify cannot be used to make dep check ignore the absence
				// of a project entirely.
				if noverify[path] {
					delete(noverify, path)
				}

				fail = true
				if !vendorfail {
					vendorfail = true
				}
			}
		}
		sort.Strings(ordered)

		var vfbuf, novbuf bytes.Buffer
		var bufptr *bytes.Buffer

		fmt.Fprintf(&vfbuf, "# vendor is out of sync:\n")
		fmt.Fprintf(&novbuf, "# out of sync, but ignored, due to noverify in Gopkg.toml:\n")

		for _, pr := range ordered {
			if noverify[pr] {
				bufptr = &novbuf
			} else {
				bufptr = &vfbuf
			}

			status := statuses[pr]
			switch status {
			case verify.NotInTree:
				fmt.Fprintf(bufptr, "%s: missing from vendor\n", pr)
			case verify.NotInLock:
				fi, err := os.Stat(filepath.Join(p.AbsRoot, "vendor", pr))
				if err != nil {
					return errors.Wrap(err, "could not stat file that VerifyVendor claimed existed")
				}
				if fi.IsDir() {
					fmt.Fprintf(bufptr, "%s: unused project\n", pr)
				} else {
					fmt.Fprintf(bufptr, "%s: orphaned file\n", pr)
				}
			case verify.DigestMismatchInLock:
				fmt.Fprintf(bufptr, "%s: hash of vendored tree not equal to digest in Gopkg.lock\n", pr)
			case verify.EmptyDigestInLock:
				fmt.Fprintf(bufptr, "%s: no digest in Gopkg.lock to compare against hash of vendored tree\n", pr)
			case verify.HashVersionMismatch:
				// This will double-print if the hash version is zero, but
				// that's a rare case that really only occurs before the first
				// run with a version of dep >=0.5.0, so it's fine.
				fmt.Fprintf(bufptr, "%s: hash algorithm mismatch, want version %v\n", pr, verify.HashVersion)
			}
		}

		if vendorfail {
			logger.Print(vfbuf.String())
			if hasnoverify {
				logger.Println()
			}
		}
		if hasnoverify {
			logger.Print(novbuf.String())
		}
	}

	if fail {
		return silentfail{}
	}
	return nil
}

func sprintLockUnsat(lsat verify.LockSatisfaction) string {
	var buf bytes.Buffer
	sort.Strings(lsat.MissingImports)
	for _, missing := range lsat.MissingImports {
		fmt.Fprintf(&buf, "%s: imported or required, but missing from Gopkg.lock's input-imports\n", missing)
	}

	sort.Strings(lsat.ExcessImports)
	for _, excess := range lsat.ExcessImports {
		fmt.Fprintf(&buf, "%s: in Gopkg.lock's input-imports, but neither imported nor required\n", excess)
	}

	var ordered []string
	for pr := range lsat.UnmetOverrides {
		ordered = append(ordered, string(pr))
	}
	sort.Strings(ordered)
	for _, pr := range ordered {
		unmatched := lsat.UnmetOverrides[gps.ProjectRoot(pr)]
		fmt.Fprintf(&buf, "%s@%s: not allowed by override %s\n", pr, unmatched.V, unmatched.C)
	}

	ordered = ordered[:0]
	for pr := range lsat.UnmetConstraints {
		ordered = append(ordered, string(pr))
	}
	sort.Strings(ordered)
	for _, pr := range ordered {
		unmatched := lsat.UnmetConstraints[gps.ProjectRoot(pr)]
		fmt.Fprintf(&buf, "%s@%s: not allowed by constraint %s\n", pr, unmatched.V, unmatched.C)
	}
	return strings.TrimSpace(buf.String())
}
