package boilerplate

import (
	"bytes"
	"fmt"
	"html/template"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"

	"code.justin.tv/kdkly/boilerplate-gen/boilerplate/internal/boilertpl"
	"code.justin.tv/kdkly/gokdkly"

	"github.com/pkg/errors"
)

const (
	// MantaFileDirName is the name of the subdirectory of the repository where Manta configuration files (one per build
	// variant) will be generated.  Other files in this directory (including e.g. Manta configuration files for removed
	// build configurations) will be removed when boilerplate is regenerated.
	MantaFileDirName = ".manta.json.d"
)

var (
	// SuggestRemovalFiles is a list of paths, relative to the root of the repository; if files exist at these paths,
	// `boilerplate-gen` will print a warning asking the user to consider removing the file.  These are files that
	// commonly exist in repositories that *do not* use `boilerplate-gen` but that are unused in repositories that *do*
	// use it.
	SuggestRemovalFiles = []string{
		".manta.json",
	}
)

// A ConfigBundle describes a project and all of its build variants.
type ConfigBundle struct {
	Global   *GlobalConfig
	Variants []*VariantConfig
}

// GlobalConfig describes configuration that applies to all of a project's build variants.
type GlobalConfig struct {
	Go GlobalConfigGo

	ProjectName   string // must be the "owner/repo" on git-aws
	FQProjectName string

	PrimaryVariant *VariantConfig

	JenkinsBuildTestJobName  string
	JenkinsSaveArtifact      bool
	JenkinsSaveDirtyArtifact bool   // typically same value as JenkinsSaveArtifact
	JenkinsDeployJobName     string // unset for projects without a deployment job (i.e. library projects)
	DeployArtifactName       string // typically same as ProjectName

	BinaryProduct bool // true for daemon, utility -- anything where binaries will be produced

	CourierEnabled    bool
	CourierDeployPath string
}

// GlobalConfigGo describes configuration that is specific to Go and applies to all of a project's build variants.
type GlobalConfigGo struct {
	LintGolintDisabled             bool
	LintGolintExcludeGenerated     bool // ignore '*.pb.go'
	LintErrcheckIgnoreTests        bool
	UnsafeDataRaceDetectorDisabled bool
}

// VariantConfig describes a single build variant.
//
// TODO: Variant should support platform, Go build tags, Go build-time configuration (e.g. framepointer support),
//       ... other things?
//
type VariantConfig struct {
	Go VariantConfigGo

	MantaBaseImage  string
	MantaFilePath   string
	MantaOutputPath string

	BuildTargetName string
	BuildTargetDeps []string
}

// GlobalConfigGo describes configuration that is specific to Go and a single build variant.
type VariantConfigGo struct {
	// XXX: binary mode not implemented yet.
	Binaries   bool
	BinaryHash string
	Version    string
	Repository string
	Tags       []string
}

// singletonTemplateParams is given to all templates that are *not* instantiated on a per-build-variant basis.
type singletonTemplateParams struct {
	Global   *GlobalConfig
	Variants []*VariantConfig
}

// variantTemplateParams is given to all templates that are instantiated for each build variant.
type variantTemplateParams struct {
	Global  *GlobalConfig
	Variant *VariantConfig
}

// XXX: Automatically detect repository name.
func detectProjectName() (string, error) {
	repoRoot, err := gokdkly.GetRepoRoot("")
	if err != nil {
		return "", errors.Wrap(err, "failed to look for enclosing repository")
	}
	if repoRoot == "" {
		return "", nil
	}
	gopath := os.Getenv("GOPATH")
	if gopath == "" {
		return "", fmt.Errorf("got empty GOPATH")
	}
	if !strings.HasPrefix(repoRoot, gopath+"/") {
		return "", fmt.Errorf("repository does not appear to be within GOPATH")
	}
	repoImportPath, err := filepath.Rel(filepath.Join(gopath, "src"), repoRoot)
	if err != nil {
		return "", errors.Wrap(err, "failed to compute import path")
	}
	projectName := strings.TrimPrefix(repoImportPath, "code.justin.tv/")
	if projectName == repoImportPath {
		// XXX: Ideally, we'd also support other import paths.
		return "", fmt.Errorf("project does not appear to have a code.justin.tv import path")
	}
	return projectName, nil
}

func buildVariantConfig(goCfg *ConfigFileGoConfig) (*VariantConfig, error) {

	// e.g. "go1.7.1" or "go1234567"
	goVersionSlug := goCfg.Version

	return &VariantConfig{
		Go: VariantConfigGo{
			Binaries: false, // XXX: not impl
			// BinaryHash  // XXX: not impl
			Version: goCfg.Version,
			// Repository  // XXX: not yet impl
			Tags: []string{"netgo"}, // XXX: not yet impl
		},
		MantaBaseImage:  "ubuntu:precise", // XXX: not impl; make this per-variant configurable?
		MantaFilePath:   filepath.Join(MantaFileDirName, fmt.Sprintf("%s.json", goVersionSlug)),
		MantaOutputPath: filepath.Join(".manta-output.d", goVersionSlug),
		BuildTargetName: goVersionSlug,
		BuildTargetDeps: []string{},
	}, nil
}

func ConfigFromFile(cf *ConfigFile) (*ConfigBundle, error) {
	var err error

	projectName := cf.Project.Name
	if projectName == "" {
		projectName, err = detectProjectName()
		if err != nil {
			return nil, errors.Wrap(err, "error while detecting project name")
		}
		if projectName == "" {
			return nil, fmt.Errorf("failed to detect project name")
		}
	}

	jenkinsJobName := strings.Replace(projectName, "/", "-", -1)
	bundle := &ConfigBundle{
		Global: &GlobalConfig{
			ProjectName:             projectName,
			FQProjectName:           fmt.Sprintf("code.justin.tv/%s", projectName),
			JenkinsBuildTestJobName: jenkinsJobName,
			JenkinsDeployJobName:    jenkinsJobName + "-deploy",

			Go: GlobalConfigGo{
				LintGolintDisabled:             !cf.Golang.Linters.Golint.Enabled,
				LintGolintExcludeGenerated:     cf.Golang.Linters.Golint.ExcludeGenerated,
				LintErrcheckIgnoreTests:        cf.Golang.Linters.Errcheck.IgnoreTests,
				UnsafeDataRaceDetectorDisabled: !cf.Golang.Unsafe.DataRaceDetector,
			},
		},
		Variants: []*VariantConfig{},
	}

	// TODO: Might we sometimes want to save artifacts for utilities?

	projectType := cf.Project.Type
	switch projectType {
	case "library", "utility":
		if cf.Courier.DeployPath != "" {
			return nil, fmt.Errorf("cannot enable Courier deployment for a %s project", projectType)
		}
	case "daemon":
		// XXX: Should support other deployment mechanisms.
		if cf.Courier.DeployPath == "" {
			return nil, fmt.Errorf("must specify Courier deployment path for a daemon project")
		}
		bundle.Global.JenkinsSaveArtifact = true
		bundle.Global.JenkinsSaveDirtyArtifact = true
		bundle.Global.CourierEnabled = true
		bundle.Global.CourierDeployPath = cf.Courier.DeployPath
		bundle.Global.DeployArtifactName = bundle.Global.ProjectName
	default:
		return nil, fmt.Errorf("unexpected project type: %v", projectType)
	}

	switch projectType {
	case "utility", "daemon":
		bundle.Global.BinaryProduct = true
	}

	for _, goCfg := range cf.Golang.Configurations {
		variantConfig, err := buildVariantConfig(goCfg)
		if err != nil {
			return nil, errors.Wrap(err, "failed to build variant configuration")
		}
		if goCfg.Primary {
			if bundle.Global.PrimaryVariant != nil {
				return nil, errors.Wrap(err, "cannot mark more than one variant as primary")
			}
			bundle.Global.PrimaryVariant = variantConfig
		}
		bundle.Variants = append(bundle.Variants, variantConfig)
	}

	// TODO: auto-select latest version?
	if bundle.Global.PrimaryVariant == nil {
		return nil, errors.Wrap(err, "must mark exactly one variant as primary")
	}

	// 	Repository: "https://github.com/golang/go",

	return bundle, nil
}

// TODO: Should `outputs` use paths relative to repoPath instead of absolute paths?
func (cb *ConfigBundle) WriteBoilerplate(repoPath string) error {
	outputs := make(map[string][]byte)

	// Create a Manta file for each variant.
	for _, vc := range cb.Variants {
		tpl := boilertpl.MantaJSON

		buf := new(bytes.Buffer)
		if err := tpl.Execute(buf, &variantTemplateParams{Global: cb.Global, Variant: vc}); err != nil {
			return errors.Wrapf(err, "failed to evaluate template for file: %v", vc.MantaFilePath)
		}

		outputs[filepath.Join(repoPath, vc.MantaFilePath)] = buf.Bytes()
	}

	// Evaluate each of the singleton templates.
	for _, x := range []struct {
		path string
		tpl  *template.Template
	}{
		{path: boilertpl.ScriptCommonPath, tpl: boilertpl.ScriptCommon},
		{path: boilertpl.ScriptCIPath, tpl: boilertpl.ScriptCI},
		{path: boilertpl.ScriptLintPath, tpl: boilertpl.ScriptLint},
		{path: boilertpl.ScriptBuildPath, tpl: boilertpl.ScriptBuild},
		{path: boilertpl.ScriptTestPath, tpl: boilertpl.ScriptTest},
		{path: boilertpl.ScriptGoPath, tpl: boilertpl.ScriptGo},
		{path: boilertpl.ScriptToolsPath, tpl: boilertpl.ScriptTools},
		{path: boilertpl.MantaMakefilePath, tpl: boilertpl.MantaMakefile},
		{path: boilertpl.JenkinsGroovyPath, tpl: boilertpl.JenkinsGroovy},
	} {
		buf := new(bytes.Buffer)
		if err := x.tpl.Execute(buf, &singletonTemplateParams{Global: cb.Global, Variants: cb.Variants}); err != nil {
			return errors.Wrapf(err, "failed to evaluate template for file: %v", x.path)
		}
		outputs[filepath.Join(repoPath, x.path)] = buf.Bytes()
	}

	// Templates have all been evaluated sucessfully; actually write files to disk.
	for outputPath, data := range outputs {
		outputDir := filepath.Dir(outputPath)
		if err := os.MkdirAll(outputDir, os.ModePerm); err != nil {
			return errors.Wrapf(err, "failed to create directory: %v", outputDir)
		}

		if err := ioutil.WriteFile(outputPath, data, os.ModePerm); err != nil {
			return errors.Wrapf(err, "failed to write file: %v", outputPath)
		}
	}

	// Clean up any leftovers from old configurations.
	for _, dirPath := range []string{filepath.Join(repoPath, MantaFileDirName)} {
		dir, err := os.Open(dirPath)
		if err != nil {
			return errors.Wrapf(err, "failed to open directory: %v", dirPath)
		}
		dirEntries, err := dir.Readdir(0)
		if err != nil {
			return errors.Wrapf(err, "failed to list directory: %v", dirPath)
		}
		for _, entry := range dirEntries {
			entryAbsPath := filepath.Join(dirPath, entry.Name())
			entryRelPath, err := filepath.Rel(repoPath, entryAbsPath)
			if err != nil {
				return errors.Wrapf(err, "failed to find relative path for file: %v", entryAbsPath)
			}
			if !entry.Mode().IsRegular() {
				return fmt.Errorf("would remove file, but it is not a regular file: %v", entryRelPath)
			}
			if _, ok := outputs[entryAbsPath]; !ok {
				fmt.Printf("removing file: %v\n", entryRelPath)
				if err := os.Remove(entryAbsPath); err != nil {
					return errors.Wrapf(err, "failed to unlink file: %v", entryRelPath)
				}
			}
		}
	}

	// Warn if common files that are *not* generated by this boilerplate-gen configuration exist.
	for _, relPath := range SuggestRemovalFiles {
		_, err := os.Stat(filepath.Join(repoPath, relPath))
		if !(err != nil && os.IsNotExist(err)) {
			fmt.Printf("[WARN] perhaps you should remove this file: %v", relPath)
		}
	}

	return nil
}
