package commands

import (
	"errors"
	"fmt"
	"io"
	"runtime/debug"
	"strings"

	versioncmp "github.com/hashicorp/go-version"
	cmds "github.com/ipfs/go-ipfs-cmds"
	version "github.com/ipfs/kubo"
	"github.com/ipfs/kubo/config"
	"github.com/ipfs/kubo/core"
	"github.com/ipfs/kubo/core/commands/cmdenv"
	"github.com/libp2p/go-libp2p-kad-dht/fullrt"
	peer "github.com/libp2p/go-libp2p/core/peer"
	pstore "github.com/libp2p/go-libp2p/core/peerstore"
)

const (
	versionNumberOptionName         = "number"
	versionCommitOptionName         = "commit"
	versionRepoOptionName           = "repo"
	versionAllOptionName            = "all"
	versionCheckThresholdOptionName = "min-percent"
)

var VersionCmd = &cmds.Command{
	Helptext: cmds.HelpText{
		Tagline:          "Show IPFS version information.",
		ShortDescription: "Returns the current version of IPFS and exits.",
	},
	Subcommands: map[string]*cmds.Command{
		"deps":  depsVersionCommand,
		"check": checkVersionCommand,
	},

	Options: []cmds.Option{
		cmds.BoolOption(versionNumberOptionName, "n", "Only show the version number."),
		cmds.BoolOption(versionCommitOptionName, "Show the commit hash."),
		cmds.BoolOption(versionRepoOptionName, "Show repo version."),
		cmds.BoolOption(versionAllOptionName, "Show all version information"),
	},
	// must be permitted to run before init
	Extra: CreateCmdExtras(SetDoesNotUseRepo(true), SetDoesNotUseConfigAsInput(true)),
	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		return cmds.EmitOnce(res, version.GetVersionInfo())
	},
	Encoders: cmds.EncoderMap{
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, version *version.VersionInfo) error {
			all, _ := req.Options[versionAllOptionName].(bool)
			if all {
				ver := version.Version
				if version.Commit != "" {
					ver += "-" + version.Commit
				}
				out := fmt.Sprintf("Kubo version: %s\n"+
					"Repo version: %s\nSystem version: %s\nGolang version: %s\n",
					ver, version.Repo, version.System, version.Golang)
				fmt.Fprint(w, out)
				return nil
			}

			commit, _ := req.Options[versionCommitOptionName].(bool)
			commitTxt := ""
			if commit && version.Commit != "" {
				commitTxt = "-" + version.Commit
			}

			repo, _ := req.Options[versionRepoOptionName].(bool)
			if repo {
				fmt.Fprintln(w, version.Repo)
				return nil
			}

			number, _ := req.Options[versionNumberOptionName].(bool)
			if number {
				fmt.Fprintln(w, version.Version+commitTxt)
				return nil
			}

			fmt.Fprintf(w, "ipfs version %s%s\n", version.Version, commitTxt)
			return nil
		}),
	},
	Type: version.VersionInfo{},
}

type Dependency struct {
	Path       string
	Version    string
	ReplacedBy string
	Sum        string
}

const pkgVersionFmt = "%s@%s"

var depsVersionCommand = &cmds.Command{
	Helptext: cmds.HelpText{
		Tagline: "Shows information about dependencies used for build.",
		ShortDescription: `
Print out all dependencies and their versions.`,
	},
	Type: Dependency{},

	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		info, ok := debug.ReadBuildInfo()
		if !ok {
			return errors.New("no embedded dependency information")
		}
		toDependency := func(mod *debug.Module) (dep Dependency) {
			dep.Path = mod.Path
			dep.Version = mod.Version
			dep.Sum = mod.Sum
			if repl := mod.Replace; repl != nil {
				dep.ReplacedBy = fmt.Sprintf(pkgVersionFmt, repl.Path, repl.Version)
			}
			return
		}
		if err := res.Emit(toDependency(&info.Main)); err != nil {
			return err
		}
		for _, dep := range info.Deps {
			if err := res.Emit(toDependency(dep)); err != nil {
				return err
			}
		}
		return nil
	},
	Encoders: cmds.EncoderMap{
		cmds.Text: cmds.MakeTypedEncoder(func(req *cmds.Request, w io.Writer, dep Dependency) error {
			fmt.Fprintf(w, pkgVersionFmt, dep.Path, dep.Version)
			if dep.ReplacedBy != "" {
				fmt.Fprintf(w, " => %s", dep.ReplacedBy)
			}
			fmt.Fprintf(w, "\n")
			return nil
		}),
	},
}

const DefaultMinimalVersionFraction = 0.05 // 5%

type VersionCheckOutput struct {
	UpdateAvailable    bool
	RunningVersion     string
	GreatestVersion    string
	PeersSampled       int
	WithGreaterVersion int
}

var checkVersionCommand = &cmds.Command{
	Helptext: cmds.HelpText{
		Tagline: "Checks Kubo version against connected peers.",
		ShortDescription: `
This command uses the libp2p identify protocol to check the 'AgentVersion'
of connected peers and see if the Kubo version we're running is outdated.

Peers with an AgentVersion that doesn't start with 'kubo/' are ignored.
'UpdateAvailable' is set to true only if the 'min-fraction' criteria are met.

The 'ipfs daemon' does the same check regularly and logs when a new version
is available. You can stop these regular checks by setting
Version.SwarmCheckEnabled:false in the config.
`,
	},
	Options: []cmds.Option{
		cmds.IntOption(versionCheckThresholdOptionName, "t", "Percentage (1-100) of sampled peers with the new Kubo version needed to trigger an update warning.").WithDefault(config.DefaultSwarmCheckPercentThreshold),
	},
	Type: VersionCheckOutput{},

	Run: func(req *cmds.Request, res cmds.ResponseEmitter, env cmds.Environment) error {
		nd, err := cmdenv.GetNode(env)
		if err != nil {
			return err
		}

		if !nd.IsOnline {
			return ErrNotOnline
		}

		minPercent, _ := req.Options[versionCheckThresholdOptionName].(int64)
		output, err := DetectNewKuboVersion(nd, minPercent)
		if err != nil {
			return err
		}

		if err := cmds.EmitOnce(res, output); err != nil {
			return err
		}
		return nil
	},
}

// DetectNewKuboVersion observers kubo version reported by other peers via
// libp2p identify protocol and notifies when threshold fraction of seen swarm
// is running updated Kubo. It is used by RPC and CLI at 'ipfs version check'
// and also periodically when 'ipfs daemon' is running.
func DetectNewKuboVersion(nd *core.IpfsNode, minPercent int64) (VersionCheckOutput, error) {
	ourVersion, err := versioncmp.NewVersion(version.CurrentVersionNumber)
	if err != nil {
		return VersionCheckOutput{}, fmt.Errorf("could not parse our own version %q: %w",
			version.CurrentVersionNumber, err)
	}
	// MAJOR.MINOR.PATCH without any suffix
	ourVersion = ourVersion.Core()

	greatestVersionSeen := ourVersion
	totalPeersSampled := 1 // Us (and to avoid division-by-zero edge case)
	withGreaterVersion := 0

	recordPeerVersion := func(agentVersion string) {
		// We process the version as is it assembled in GetUserAgentVersion
		segments := strings.Split(agentVersion, "/")
		if len(segments) < 2 {
			return
		}
		if segments[0] != "kubo" {
			return
		}
		versionNumber := segments[1] // As in our CurrentVersionNumber

		peerVersion, err := versioncmp.NewVersion(versionNumber)
		if err != nil {
			// Do not error on invalid remote versions, just ignore
			return
		}

		// Ignore prerelases and development releases (-dev, -rcX)
		if peerVersion.Metadata() != "" || peerVersion.Prerelease() != "" {
			return
		}

		// MAJOR.MINOR.PATCH without any suffix
		peerVersion = peerVersion.Core()

		// Valid peer version number
		totalPeersSampled += 1
		if ourVersion.LessThan(peerVersion) {
			withGreaterVersion += 1
		}
		if peerVersion.GreaterThan(greatestVersionSeen) {
			greatestVersionSeen = peerVersion
		}
	}

	processPeerstoreEntry := func(id peer.ID) {
		if v, err := nd.Peerstore.Get(id, "AgentVersion"); err == nil {
			recordPeerVersion(v.(string))
		} else if errors.Is(err, pstore.ErrNotFound) { // ignore noop
		} else { // a bug, usually.
			log.Errorw("failed to get agent version from peerstore", "error", err)
		}
	}

	// Amino DHT client keeps information about previously seen peers
	if nd.DHTClient != nd.DHT && nd.DHTClient != nil {
		client, ok := nd.DHTClient.(*fullrt.FullRT)
		if !ok {
			return VersionCheckOutput{}, errors.New("could not perform version check due to missing or incompatible DHT configuration")
		}
		for _, p := range client.Stat() {
			processPeerstoreEntry(p)
		}
	} else if nd.DHT != nil && nd.DHT.WAN != nil {
		for _, pi := range nd.DHT.WAN.RoutingTable().GetPeerInfos() {
			processPeerstoreEntry(pi.Id)
		}
	} else if nd.DHT != nil && nd.DHT.LAN != nil {
		for _, pi := range nd.DHT.LAN.RoutingTable().GetPeerInfos() {
			processPeerstoreEntry(pi.Id)
		}
	} else {
		return VersionCheckOutput{}, errors.New("could not perform version check due to missing or incompatible DHT configuration")
	}

	if minPercent < 1 || minPercent > 100 {
		if minPercent == 0 {
			minPercent = config.DefaultSwarmCheckPercentThreshold
		} else {
			return VersionCheckOutput{}, errors.New("Version.SwarmCheckPercentThreshold must be between 1 and 100")
		}
	}

	minFraction := float64(minPercent) / 100.0

	// UpdateAvailable flag is set only if minFraction was reached
	greaterFraction := float64(withGreaterVersion) / float64(totalPeersSampled)

	// Gathered metric are returned every time
	return VersionCheckOutput{
		UpdateAvailable:    (greaterFraction >= minFraction),
		RunningVersion:     ourVersion.String(),
		GreatestVersion:    greatestVersionSeen.String(),
		PeersSampled:       totalPeersSampled,
		WithGreaterVersion: withGreaterVersion,
	}, nil
}
