// Copyright 2019 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"
	"fmt"
	"io"
	"strings"

	"golang.org/x/exp/apidiff"
	"golang.org/x/mod/module"
	"golang.org/x/mod/semver"
	"golang.org/x/tools/go/packages"
)

// report describes the differences in the public API between two versions
// of a module.
type report struct {
	// base contains information about the "old" module version being compared
	// against. base.version may be "none", indicating there is no base version
	// (for example, if this is the first release). base.version may not be "".
	base moduleInfo

	// release contains information about the version of the module to release.
	// The version may be set explicitly with -version or suggested using
	// suggestVersion, in which case release.versionInferred is true.
	release moduleInfo

	// packages is a list of package reports, describing the differences
	// for individual packages, sorted by package path.
	packages []packageReport

	// versionInvalid explains why the proposed or suggested version is not valid.
	versionInvalid *versionMessage

	// haveCompatibleChanges is true if there are any backward-compatible
	// changes in non-internal packages.
	haveCompatibleChanges bool

	// haveIncompatibleChanges is true if there are any backward-incompatible
	// changes in non-internal packages.
	haveIncompatibleChanges bool

	// haveBaseErrors is true if there were errors loading packages
	// in the base version.
	haveBaseErrors bool

	// haveReleaseErrors is true if there were errors loading packages
	// in the release version.
	haveReleaseErrors bool
}

// Text formats and writes a report to w. The report lists errors, compatible
// changes, and incompatible changes in each package. If releaseVersion is set,
// it states whether releaseVersion is valid (and why). If releaseVersion is not
// set, it suggests a new version.
func (r *report) Text(w io.Writer) error {
	buf := &bytes.Buffer{}
	for _, p := range r.packages {
		if err := p.Text(buf); err != nil {
			return err
		}
	}

	if r.base.versionInferred {
		fmt.Fprintf(buf, "Inferred base version: %s\n", r.base.version)
	} else if r.base.versionQuery != "" {
		fmt.Fprintf(buf, "Base version: %s (%s)\n", r.base.version, r.base.versionQuery)
	}

	if len(r.release.diagnostics) > 0 {
		for _, d := range r.release.diagnostics {
			fmt.Fprintln(buf, d)
		}
	} else if r.versionInvalid != nil {
		fmt.Fprintln(buf, r.versionInvalid)
	} else if r.release.versionInferred {
		if r.release.tagPrefix == "" {
			fmt.Fprintf(buf, "Suggested version: %s\n", r.release.version)
		} else {
			fmt.Fprintf(buf, "Suggested version: %[1]s (with tag %[2]s%[1]s)\n", r.release.version, r.release.tagPrefix)
		}
	} else {
		if r.release.tagPrefix == "" {
			fmt.Fprintf(buf, "%s is a valid semantic version for this release.\n", r.release.version)
		} else {
			fmt.Fprintf(buf, "%[1]s (with tag %[2]s%[1]s) is a valid semantic version for this release\n", r.release.version, r.release.tagPrefix)
		}
	}

	if r.versionInvalid == nil && r.haveBaseErrors {
		fmt.Fprintln(buf, "Errors were found in the base version. Some API changes may be omitted.")
	}

	_, err := io.Copy(w, buf)
	return err
}

func (r *report) addPackage(p packageReport) {
	r.packages = append(r.packages, p)
	if len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 {
		// Only count compatible and incompatible changes if there were no errors.
		// When there are errors, definitions may be missing, and fixes may appear
		// incompatible when they are not. Changes will still be reported, but
		// they won't affect version validation or suggestions.
		for _, c := range p.Changes {
			if !c.Compatible && len(p.releaseErrors) == 0 {
				r.haveIncompatibleChanges = true
			} else if c.Compatible && len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 {
				r.haveCompatibleChanges = true
			}
		}
	}
	if len(p.baseErrors) > 0 {
		r.haveBaseErrors = true
	}
	if len(p.releaseErrors) > 0 {
		r.haveReleaseErrors = true
	}
}

// validateVersion checks whether r.release.version is valid.
// If r.release.version is not valid, an error is returned explaining why.
// r.release.version must be set.
func (r *report) validateVersion() {
	if r.release.version == "" {
		panic("validateVersion called without version")
	}
	setNotValid := func(format string, args ...interface{}) {
		r.versionInvalid = &versionMessage{
			message: fmt.Sprintf("%s is not a valid semantic version for this release.", r.release.version),
			reason:  fmt.Sprintf(format, args...),
		}
	}

	if r.haveReleaseErrors {
		if r.haveReleaseErrors {
			setNotValid("Errors were found in one or more packages.")
			return
		}
	}

	// TODO(jayconrod): link to documentation for all of these errors.

	// Check that the major version matches the module path.
	_, suffix, ok := module.SplitPathVersion(r.release.modPath)
	if !ok {
		setNotValid("%s: could not find version suffix in module path", r.release.modPath)
		return
	}
	if suffix != "" {
		if suffix[0] != '/' && suffix[0] != '.' {
			setNotValid("%s: unknown module path version suffix: %q", r.release.modPath, suffix)
			return
		}
		pathMajor := suffix[1:]
		major := semver.Major(r.release.version)
		if pathMajor != major {
			setNotValid(`The major version %s does not match the major version suffix
in the module path: %s`, major, r.release.modPath)
			return
		}
	} else if major := semver.Major(r.release.version); major != "v0" && major != "v1" {
		setNotValid(`The module path does not end with the major version suffix /%s,
which is required for major versions v2 or greater.`, major)
		return
	}

	// Check that compatible / incompatible changes are consistent.
	if semver.Major(r.base.version) == "v0" {
		return
	}
	if r.haveIncompatibleChanges {
		setNotValid("There are incompatible changes.")
		return
	}
	if r.haveCompatibleChanges && semver.MajorMinor(r.base.version) == semver.MajorMinor(r.release.version) {
		setNotValid(`There are compatible changes, but the minor version is not incremented
over the base version (%s).`, r.base.version)
		return
	}
}

// suggestVersion suggests a new version consistent with observed changes.
func (r *report) suggestVersion() {
	setNotValid := func(format string, args ...interface{}) {
		r.versionInvalid = &versionMessage{
			message: "Cannot suggest a release version.",
			reason:  fmt.Sprintf(format, args...),
		}
	}
	setVersion := func(v string) {
		r.release.version = v
		r.release.versionInferred = true
	}

	if r.haveReleaseErrors || r.haveBaseErrors {
		setNotValid("Errors were found.")
		return
	}

	var major, minor, patch, pre string
	if r.base.version != "none" {
		var err error
		major, minor, patch, pre, _, err = parseVersion(r.base.version)
		if err != nil {
			panic(fmt.Sprintf("could not parse base version: %v", err))
		}
	}

	if r.haveIncompatibleChanges && r.base.version != "none" && pre == "" && major != "0" {
		setNotValid("Incompatible changes were detected.")
		return
		// TODO(jayconrod): briefly explain how to prepare major version releases
		// and link to documentation.
	}

	if r.base.version == "none" {
		if _, pathMajor, ok := module.SplitPathVersion(r.release.modPath); !ok {
			panic(fmt.Sprintf("could not parse module path %q", r.release.modPath))
		} else if pathMajor == "" {
			setVersion("v0.1.0")
		} else {
			setVersion(pathMajor[1:] + ".0.0")
		}
		return
	}

	if pre != "" {
		// suggest non-prerelease version
	} else if r.haveCompatibleChanges || (r.haveIncompatibleChanges && major == "0") {
		minor = incDecimal(minor)
		patch = "0"
	} else {
		patch = incDecimal(patch)
	}
	setVersion(fmt.Sprintf("v%s.%s.%s", major, minor, patch))
}

// isSuccessful returns true the module appears to be safe to release at the
// proposed or suggested version.
func (r *report) isSuccessful() bool {
	return len(r.release.diagnostics) == 0 && r.versionInvalid == nil
}

type versionMessage struct {
	message, reason string
}

func (m versionMessage) String() string {
	return m.message + "\n" + m.reason + "\n"
}

// incDecimal returns the decimal string incremented by 1.
func incDecimal(decimal string) string {
	// Scan right to left turning 9s to 0s until you find a digit to increment.
	digits := []byte(decimal)
	i := len(digits) - 1
	for ; i >= 0 && digits[i] == '9'; i-- {
		digits[i] = '0'
	}
	if i >= 0 {
		digits[i]++
	} else {
		// digits is all zeros
		digits[0] = '1'
		digits = append(digits, '0')
	}
	return string(digits)
}

type packageReport struct {
	apidiff.Report
	path                      string
	baseErrors, releaseErrors []packages.Error
}

func (p *packageReport) Text(w io.Writer) error {
	if len(p.Changes) == 0 && len(p.baseErrors) == 0 && len(p.releaseErrors) == 0 {
		return nil
	}
	buf := &bytes.Buffer{}
	fmt.Fprintf(buf, "%s\n%s\n", p.path, strings.Repeat("-", len(p.path)))
	if len(p.baseErrors) > 0 {
		fmt.Fprintf(buf, "errors in base version:\n")
		for _, e := range p.baseErrors {
			fmt.Fprintf(buf, "\t%v\n", e)
		}
		buf.WriteByte('\n')
	}
	if len(p.releaseErrors) > 0 {
		fmt.Fprintf(buf, "errors in release version:\n")
		for _, e := range p.releaseErrors {
			fmt.Fprintf(buf, "\t%v\n", e)
		}
		buf.WriteByte('\n')
	}
	if len(p.Changes) > 0 {
		if err := p.Report.Text(buf); err != nil {
			return err
		}
		buf.WriteByte('\n')
	}
	_, err := io.Copy(w, buf)
	return err
}

// parseVersion returns the major, minor, and patch numbers, prerelease text,
// and metadata for a given version.
//
// TODO(jayconrod): extend semver to do this and delete this function.
func parseVersion(vers string) (major, minor, patch, pre, meta string, err error) {
	if !strings.HasPrefix(vers, "v") {
		return "", "", "", "", "", fmt.Errorf("version %q does not start with 'v'", vers)
	}
	base := vers[1:]
	if i := strings.IndexByte(base, '+'); i >= 0 {
		meta = base[i+1:]
		base = base[:i]
	}
	if i := strings.IndexByte(base, '-'); i >= 0 {
		pre = base[i+1:]
		base = base[:i]
	}
	parts := strings.Split(base, ".")
	if len(parts) != 3 {
		return "", "", "", "", "", fmt.Errorf("version %q should have three numbers", vers)
	}
	major, minor, patch = parts[0], parts[1], parts[2]
	return major, minor, patch, pre, meta, nil
}
