// Copyright 2016-2022, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package report

import (
	"encoding/json"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sync"
	"time"

	"github.com/hashicorp/hcl/v2"

	"github.com/pulumi/pulumi/pkg/v3/codegen/hcl2/syntax"
	hcl2 "github.com/pulumi/pulumi/pkg/v3/codegen/pcl"
	"github.com/pulumi/pulumi/pkg/v3/version"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/env"
)

var ExportTargetDir = env.String("CODEGEN_REPORT_DIR",
	"The directory to generate a codegen report in")

type GenerateProgramFn func(*hcl2.Program) (map[string][]byte, hcl.Diagnostics, error)

type Reporter interface {
	io.Closer
	// Report a call to GenerateProgram.
	Report(title, language string, files []*syntax.File, diags hcl.Diagnostics, err error)
	Summary() Summary
}

func New(name, version string) Reporter {
	return &reporter{
		data: Summary{
			Name:    name,
			Version: version,
		},
	}
}

type Summary struct {
	Stats
	Name          string               `json:"name"`
	Version       string               `json:"version"`
	ReportVersion string               `json:"reportVersion"`
	Languages     map[string]*Language `json:"languages"`
}

type Stats struct {
	NumConversions int
	Successes      int
}

type Language struct {
	Stats

	// A mapping from Error:(title:occurrences)
	Warnings map[string]map[string]int `json:"warning,omitempty"`
	Errors   map[string]map[string]int `json:"errors,omitempty"`

	// A mapping from between titles and Go errors (as opposed to diag errors)
	GoErrors map[string]string `json:"goerrors,omitempty"`

	// A mapping from title:files
	Files map[string][]File `json:"files,omitempty"`
}

type File struct {
	Name string `json:"name,omitempty"`
	Body string `json:"body,omitempty"`
}

type reporter struct {
	data     Summary
	reported bool
	m        sync.Mutex
}

func (s *Stats) update(succeed bool) {
	s.NumConversions++
	if succeed {
		s.Successes++
	}
}

func (r *reporter) getLanguage(lang string) *Language {
	if r.data.Languages == nil {
		r.data.Languages = map[string]*Language{}
	}
	l, ok := r.data.Languages[lang]
	if !ok {
		l = new(Language)
		r.data.Languages[lang] = l
	}
	return l
}

func WrapGen(reporter Reporter, title, language string, files []*syntax.File, f GenerateProgramFn) GenerateProgramFn {
	return func(p *hcl2.Program) (m map[string][]byte, diags hcl.Diagnostics, err error) {
		defer func() {
			reporter.Report(title, language, files, diags, err)
		}()
		m, diags, err = f(p)
		return m, diags, err
	}
}

func (r *reporter) Report(title, language string, files []*syntax.File, diags hcl.Diagnostics, err error) {
	r.m.Lock()
	defer r.m.Unlock()
	if panicVal := recover(); panicVal != nil {
		if panicErr, ok := panicVal.(error); ok {
			err = fmt.Errorf("panic: %w", panicErr)
		} else {
			err = fmt.Errorf("panic: %v", panicVal)
		}
	}
	failed := diags.HasErrors() || err != nil
	r.data.Stats.update(!failed)
	lang := r.getLanguage(language)
	lang.Stats.update(!failed)

	if failed {
		var txts []File
		for _, file := range files {
			txts = append(txts, File{
				Name: file.Name,
				Body: string(file.Bytes),
			})
		}
		if lang.Files == nil {
			lang.Files = map[string][]File{}
		}
		lang.Files[title] = txts
	}
	if err != nil {
		err := fmt.Sprintf("error: %v", err)
		if lang.GoErrors == nil {
			lang.GoErrors = map[string]string{}
		}
		lang.GoErrors[title] = err
	}

	incr := func(m *map[string]map[string]int, key string) {
		if (*m) == nil {
			*m = map[string]map[string]int{}
		}
		if (*m)[key] == nil {
			(*m)[key] = map[string]int{}
		}
		(*m)[key][title]++
	}

	for _, diag := range diags {
		switch diag.Severity {
		case hcl.DiagError:
			incr(&lang.Errors, diag.Error())
		case hcl.DiagWarning:
			incr(&lang.Warnings, diag.Error())
		case hcl.DiagInvalid:
			msg := fmt.Sprintf("invalid diag: %v", diag)
			incr(&lang.Errors, msg)
		}
	}
}

// Fetch the summary to report on.
//
// Calling this function disables automatic reporting.
func (r *reporter) Summary() Summary {
	if r == nil {
		return Summary{ReportVersion: version.Version}
	}
	r.m.Lock()
	defer r.m.Unlock()
	r.reported = true
	return r.summary()
}

func (r *reporter) summary() Summary {
	r.data.ReportVersion = version.Version
	return r.data
}

// If an env var is set to specify where we should write our results to, and if no other
// program has looked at our results, we write out our results to a file.
func (r *reporter) Close() error {
	return r.DefaultExport()
}

// Run the default export behavior on the current report.
func (r *reporter) DefaultExport() error {
	r.m.Lock()
	defer r.m.Unlock()
	dir, ok := ExportTargetDir.Underlying()
	if !ok || r.reported {
		return nil
	}
	r.reported = true
	return r.defaultExport(dir)
}

func (r *reporter) defaultExport(dir string) error {
	if dir == "" {
		err := fmt.Errorf("%q set to the empty string", ExportTargetDir.Var().Name())
		fmt.Fprintln(os.Stderr, err.Error())
		return err
	}

	if info, err := os.Stat(dir); os.IsNotExist(err) {
		err := os.MkdirAll(dir, 0o700)
		if err != nil {
			return err
		}
	} else if err != nil {
		return err
	} else if !info.IsDir() {
		err := fmt.Errorf("expected %q to be a directory or empty, found a file", dir)
		fmt.Fprintln(os.Stderr, err.Error())
		return err
	}

	name := fmt.Sprintf("%s-%s.json", r.data.Name, time.Now().Format("2006-01-02-15:04:05"))
	path := filepath.Join(dir, name)
	data, err := json.MarshalIndent(r.summary(), "", "    ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0o600)
}