// Copyright 2020 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 golang

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"runtime"
	"strings"

	"golang.org/x/tools/gopls/internal/cache"
	"golang.org/x/tools/gopls/internal/protocol"
	"golang.org/x/tools/internal/event"
)

// CompilerOptDetails invokes the Go compiler with the "-json=0,dir"
// flag on the packages and tests in the specified directory, parses
// its log of optimization decisions, and returns them as a set of
// diagnostics.
func CompilerOptDetails(ctx context.Context, snapshot *cache.Snapshot, pkgDir protocol.DocumentURI) (map[protocol.DocumentURI][]*cache.Diagnostic, error) {
	outDir, err := os.MkdirTemp("", fmt.Sprintf("gopls-%d.details", os.Getpid()))
	if err != nil {
		return nil, err
	}
	defer func() {
		if err := os.RemoveAll(outDir); err != nil {
			event.Error(ctx, "cleaning details dir", err)
		}
	}()

	outDirURI := protocol.URIFromPath(outDir)
	// details doesn't handle Windows URIs in the form of "file:///C:/...",
	// so rewrite them to "file://C:/...". See golang/go#41614.
	if !strings.HasPrefix(outDir, "/") {
		outDirURI = protocol.DocumentURI(strings.Replace(string(outDirURI), "file:///", "file://", 1))
	}

	// We use "go test -c" not "go build" as it covers all three packages
	// (p, "p [p.test]", "p_test [p.test]") in the directory, if they exist.
	inv, cleanupInvocation, err := snapshot.GoCommandInvocation(cache.NoNetwork, pkgDir.Path(), "test", []string{
		"-c",
		"-vet=off", // weirdly -c doesn't disable vet
		fmt.Sprintf("-gcflags=-json=0,%s", outDirURI), // JSON schema version 0
		fmt.Sprintf("-o=%s", cond(runtime.GOOS == "windows", "NUL", "/dev/null")),
		".",
	})
	if err != nil {
		return nil, err
	}
	defer cleanupInvocation()
	_, err = snapshot.View().GoCommandRunner().Run(ctx, *inv)
	if err != nil {
		return nil, err
	}
	files, err := findJSONFiles(outDir)
	if err != nil {
		return nil, err
	}
	reports := make(map[protocol.DocumentURI][]*cache.Diagnostic)
	var parseError error
	for _, fn := range files {
		uri, diagnostics, err := parseDetailsFile(fn)
		if err != nil {
			// expect errors for all the files, save 1
			parseError = err
		}
		fh := snapshot.FindFile(uri)
		if fh == nil {
			continue
		}
		if pkgDir != fh.URI().Dir() {
			// Filter compiler diagnostics to the requested directory.
			// https://github.com/golang/go/issues/42198
			// sometimes the detail diagnostics generated for files
			// outside the package can never be taken back.
			continue
		}
		reports[fh.URI()] = diagnostics
	}
	return reports, parseError
}

// parseDetailsFile parses the file written by the Go compiler which contains a JSON-encoded protocol.Diagnostic.
func parseDetailsFile(filename string) (protocol.DocumentURI, []*cache.Diagnostic, error) {
	buf, err := os.ReadFile(filename)
	if err != nil {
		return "", nil, err
	}
	var (
		uri         protocol.DocumentURI
		i           int
		diagnostics []*cache.Diagnostic
	)
	type metadata struct {
		File string `json:"file,omitempty"`
	}
	for dec := json.NewDecoder(bytes.NewReader(buf)); dec.More(); {
		// The first element always contains metadata.
		if i == 0 {
			i++
			m := new(metadata)
			if err := dec.Decode(m); err != nil {
				return "", nil, err
			}
			if !strings.HasSuffix(m.File, ".go") {
				continue // <autogenerated>
			}
			uri = protocol.URIFromPath(m.File)
			continue
		}
		d := new(protocol.Diagnostic)
		if err := dec.Decode(d); err != nil {
			return "", nil, err
		}
		if d.Source != "go compiler" {
			continue
		}
		d.Tags = []protocol.DiagnosticTag{} // must be an actual slice
		msg := d.Code.(string)
		if msg != "" {
			// Typical message prefixes gathered by grepping the source of
			// cmd/compile for literal arguments in calls to logopt.LogOpt.
			// (It is not a well defined set.)
			//
			// - canInlineFunction
			// - cannotInlineCall
			// - cannotInlineFunction
			// - copy
			// - escape
			// - escapes
			// - isInBounds
			// - isSliceInBounds
			// - iteration-variable-to-{heap,stack}
			// - leak
			// - loop-modified-{range,for}
			// - nilcheck
			msg = fmt.Sprintf("%s(%s)", msg, d.Message)
		}

		// zeroIndexedRange subtracts 1 from the line and
		// range, because the compiler output neglects to
		// convert from 1-based UTF-8 coordinates to 0-based UTF-16.
		// (See GOROOT/src/cmd/compile/internal/logopt/log_opts.go.)
		// TODO(rfindley): also translate UTF-8 to UTF-16.
		zeroIndexedRange := func(rng protocol.Range) protocol.Range {
			return protocol.Range{
				Start: protocol.Position{
					Line:      rng.Start.Line - 1,
					Character: rng.Start.Character - 1,
				},
				End: protocol.Position{
					Line:      rng.End.Line - 1,
					Character: rng.End.Character - 1,
				},
			}
		}

		var related []protocol.DiagnosticRelatedInformation
		for _, ri := range d.RelatedInformation {
			related = append(related, protocol.DiagnosticRelatedInformation{
				Location: protocol.Location{
					URI:   ri.Location.URI,
					Range: zeroIndexedRange(ri.Location.Range),
				},
				Message: ri.Message,
			})
		}
		diagnostic := &cache.Diagnostic{
			URI:      uri,
			Range:    zeroIndexedRange(d.Range),
			Message:  msg,
			Severity: d.Severity,
			Source:   cache.CompilerOptDetailsInfo, // d.Source is always "go compiler" as of 1.16, use our own
			Tags:     d.Tags,
			Related:  related,
		}
		diagnostics = append(diagnostics, diagnostic)
		i++
	}
	return uri, diagnostics, nil
}

func findJSONFiles(dir string) ([]string, error) {
	ans := []string{}
	f := func(path string, fi os.FileInfo, _ error) error {
		if fi.IsDir() {
			return nil
		}
		if strings.HasSuffix(path, ".json") {
			ans = append(ans, path)
		}
		return nil
	}
	err := filepath.Walk(dir, f)
	return ans, err
}
