package main

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io/fs"
	"log"
	"os"
	"os/exec"
	"os/user"
	"path/filepath"
	"regexp"
	"runtime"
	"strings"

	"github.com/mappu/miqt/docker"
)

// glob2regex converts the glob pattern into a regexp.
// It only supports `-` as a special character meaning 'anything'.
// The resulting regex is unanchored i.e. can match anywhere within a target string.
func glob2regex(pattern string) *regexp.Regexp {
	parts := strings.Split(pattern, `-`)
	for i, p := range parts {
		parts[i] = regexp.QuoteMeta(p)
	}

	return regexp.MustCompile(strings.Join(parts, `.*`))
}

// shasum returns the hex sha256 of a byte slice.
func shasum(data []byte) string {
	hashdata := sha256.Sum256(data)
	return hex.EncodeToString(hashdata[:])
}

// usage displays how to use miqt-docker and then exits the process.
func usage(dockerfiles []fs.DirEntry) {
	fmt.Fprintf(os.Stderr, "Usage: %s ENVIRONMENT COMMAND...\n", filepath.Base(os.Args[0]))
	fmt.Fprintln(os.Stderr, "")
	fmt.Fprintln(os.Stderr, "Environment variables:")
	fmt.Fprintln(os.Stderr, "- DOCKER       Override the path to docker")
	fmt.Fprintln(os.Stderr, "")
	fmt.Fprintln(os.Stderr, "Available container environments (use * for partial match):")

	for _, ff := range dockerfiles {
		fmt.Fprintf(os.Stderr, "- %s\n", strings.TrimSuffix(ff.Name(), `.Dockerfile`))
	}

	os.Exit(1)
}

// getDockerRunArgsForGlob returns a []string array of all the {busywork} arguments
// for a `docker {run -e -v ...} go build` command.
// It does glob matching for the target container, and builds it if it does not yet exist.
func getDockerRunArgsForGlob(dockerfiles []fs.DirEntry, containerNameGlob string, isatty bool) ([]string, error) {

	requestEnvironment := glob2regex(containerNameGlob)
	var match string
	for _, ff := range dockerfiles {
		if !requestEnvironment.MatchString(ff.Name()) {
			continue
		}

		match = ff.Name()
		// continue searching for a later match with higher version number
	}

	if match == "" {
		return nil, fmt.Errorf("No available environment matches the request %q\n", containerNameGlob)
	}

	if !(match == os.Args[1] || match == os.Args[1]+`.Dockerfile`) {
		// An inexact/glob match was involved. Show what it was
		log.Printf("Selecting dockerfile: %s", match)
	}

	dockerFileContent, err := docker.ReadFile(match)
	if err != nil {
		return nil, err // shouldn't happen
	}

	dockerfileHash := shasum(dockerFileContent)[:8] // First 8 characters of content hash

	// Check to see if this dockerfile has already been turned into an image

	containerName := `miqt-docker/` + strings.TrimSuffix(match, `.Dockerfile`)

	_, err = dockerFindImage(containerName, dockerfileHash)
	if err != nil {
		if err != os.ErrNotExist {
			return nil, err // real error
		}

		log.Printf("No matching docker image, creating...")
		err = dockerBuild(dockerFileContent, containerName, dockerfileHash)
		if err != nil {
			return nil, err
		}

		// Search again
		_, err = dockerFindImage(containerName, dockerfileHash)
		if err != nil {
			return nil, fmt.Errorf("Failed to build container for %s:%s: %w", containerName, dockerfileHash, err) // Any error now is a real error
		}
	}

	// Container match found - clean up older containers for the same tag

	allContainers, err := dockerListImages()
	if err != nil {
		return nil, err
	}
	for _, ctr := range allContainers {
		if ctr.Repository == containerName &&
			!(ctr.Tag == dockerfileHash || ctr.Tag == "latest") {
			log.Printf("Removing previous version container %s:%s ...", containerName, ctr.Tag)
			rmCmd := dockerCommand(`image`, `rm`, containerName+`:`+ctr.Tag)
			rmCmd.Stdout = os.Stdout
			rmCmd.Stderr = os.Stderr
			err = rmCmd.Run()
			if err != nil {
				log.Printf("Warning: Failed to remove previous container: %v", err.Error())
				// log and continue
			}
		}
	}

	// Container match found - safe to run our command

	fullCommand := []string{"run", "--rm", "-i"}

	if isatty {
		fullCommand = append(fullCommand, "-t")
	}

	if runtime.GOOS != "windows" {
		userinfo, err := user.Current()
		if err != nil {
			log.Panic(err)
		}

		fullCommand = append(fullCommand, `--user`, userinfo.Uid+`:`+userinfo.Gid)
	}

	// Find the GOMODCACHE and GOCACHE to populate mapped volumes
	gomodcache, err := exec.Command(`go`, `env`, `GOMODCACHE`).Output()
	if err != nil {
		return nil, fmt.Errorf("Finding GOMODCACHE: %w", err)
	}
	if gomodcache_sz := strings.TrimSpace(string(gomodcache)); len(gomodcache_sz) > 0 {
		_ = os.MkdirAll(gomodcache_sz, 0755) // Might not exist if no Go modules have been used yet

		fullCommand = append(fullCommand, `-v`, gomodcache_sz+`:/go/pkg/mod`, `-e`, `GOMODCACHE=/go/pkg/mod`)
	}

	gocache, err := exec.Command(`go`, `env`, `GOCACHE`).Output()
	if err != nil {
		return nil, fmt.Errorf("Finding GOCACHE: %w", err)
	}
	if gocache_sz := strings.TrimSpace(string(gocache)); len(gocache_sz) > 0 {
		_ = os.MkdirAll(gocache_sz, 0755) // Might not exist if no Go packages have been built yet

		fullCommand = append(fullCommand, `-v`, gocache_sz+`:/.cache/go-build`, `-e`, `GOCACHE=/.cache/go-build`)
	}

	// We need to bind-mount probably not just the current working directory,
	// but upwards to the root git repo / go.mod file / go.work file (whichever
	// is highest)

	var parentPaths []string
	gomod, err := exec.Command(`go`, `env`, `GOMOD`).Output()
	if err != nil {
		return nil, fmt.Errorf("Finding GOMOD: %w", err)
	}
	if gomod_sz := strings.TrimSpace(string(gomod)); len(gomod_sz) > 0 {
		parentPaths = append(parentPaths, gomod_sz)
	}

	gowork, err := exec.Command(`go`, `env`, `GOWORK`).Output()
	if err != nil {
		return nil, fmt.Errorf("Finding GOWORK: %w", err)
	}
	if gowork_sz := strings.TrimSpace(string(gowork)); len(gowork_sz) > 0 {
		parentPaths = append(parentPaths, gowork_sz)
	}

	gitroot, err := exec.Command(`git`, `rev-parse`, `--show-toplevel`).Output()
	if err != nil {
		// Maybe this isn't a git repository? Git is optional anyway, there are hg/bzr users
		// Don't panic
	} else {
		if gitroot_sz := strings.TrimSpace(string(gitroot)); len(gitroot_sz) > 0 {
			parentPaths = append(parentPaths, gitroot_sz)
		}
	}

	cwd, err := os.Getwd()
	if err != nil {
		return nil, err
	}

	parentPaths = append(parentPaths, cwd) // It's an option too

	basedir, err := highestCommonParent(parentPaths)
	if err != nil {
		return nil, err
	}

	relCwd, err := filepath.Rel(basedir, cwd)
	if err != nil {
		return nil, err
	}

	fullCommand = append(fullCommand, `-v`, basedir+`:/src`, `-w`, filepath.Join(`/src`, relCwd))

	// Final standard docker commands

	fullCommand = append(fullCommand, containerName+`:`+dockerfileHash) // , `/bin/bash`, `-c`)

	return fullCommand, nil
}

func main() {

	dockerfiles, err := docker.Dockerfiles.ReadDir(`.`)
	if err != nil {
		log.Fatal(err)
	}

	if len(os.Args) < 3 {
		usage(dockerfiles)
	}

	taskArgs, taskOp, taskAllowTty, err := evaluateTask(os.Args[2:])
	if err != nil {
		log.Fatal(err)
	}

	var cmd *exec.Cmd
	if os.Args[1] == "native" {

		if taskArgs[0] == `/bin/bash` && runtime.GOOS == "windows" {
			log.Fatal("This command can't be used in 'native' mode on Windows.")
		}

		cmd = exec.Command(taskArgs[0], taskArgs[1:]...) // n.b. [1:] may be an empty slice

	} else {
		dockerArgs, err := getDockerRunArgsForGlob(dockerfiles, os.Args[1], taskAllowTty && isatty())
		if err != nil {
			log.Fatal(err)
		}

		dockerArgs = append(dockerArgs, taskArgs...)
		cmd = dockerCommand(dockerArgs...)
	}

	cmd.Stdin = os.Stdin
	cmd.Stderr = os.Stderr
	cmd.Stdout = os.Stdout
	taskOp(cmd)
	err = cmd.Run()
	if err != nil {
		log.Fatal(err)
	}
}