package main

import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"io/ioutil"
	"net/http"
	"net/url"
	"os"
	"path"
	"sort"
	"strings"

	log "github.com/golang/glog"
	"github.com/google/go-querystring/query"
)

var (
	gitlabAccessToken = flag.String("gitlab_access_token", os.Getenv("GITLAB_ACCESS_TOKEN"), "GitLab API Access Token")
	gitlabEndpoint    = flag.String("gitlab_endpoint", "https://hg.lukegb.com", "GitLab API endpoint")
	gitlabProject     = flag.String("gitlab_project", "lukegb/depot", "Project to sync secrets into")
	manifestVariable  = flag.String("manifest_variable", "", "If non-empty, a variable to populate with the path to a [VARIABLE]=[FILENAME] manifest, separated by newlines.")

	varToFileMap map[string]string
)

func init() {
	flag.Var(stringMapVar{&varToFileMap}, "variable_to_file", "[VARIABLE]=[FILENAME] mapping")
}

type stringMapVar struct {
	dst *map[string]string
}

func (v stringMapVar) String() string {
	if v.dst == nil || *v.dst == nil {
		return "nil"
	}
	return fmt.Sprintf("%#v", *v.dst)
}

func (v stringMapVar) Set(s string) error {
	if *v.dst == nil {
		*v.dst = make(map[string]string)
	}
	bits := strings.SplitN(s, "=", 2)
	if len(bits) != 2 {
		return fmt.Errorf("not a KEY=VALUE formatted: %v", s)
	}
	(*v.dst)[bits[0]] = bits[1]
	return nil
}

// GitLabAPI encapsulates access to the GitLab API.
type GitLabAPI struct {
	Client      *http.Client
	AccessToken string
	Endpoint    string
}

func (gl *GitLabAPI) url(subpath string) (string, error) {
	u, err := url.Parse(gl.Endpoint)
	if err != nil {
		return "", fmt.Errorf("parsing endpoint %q: %w", gl.Endpoint, err)
	}
	pv, err := url.PathUnescape(subpath)
	if err != nil {
		return "", fmt.Errorf("unescaping %q: %w", subpath, err)
	}
	u.Path = path.Join(u.Path, pv)
	u.RawPath = path.Join(u.RawPath, subpath)
	return u.String(), nil
}

func (gl *GitLabAPI) do(ctx context.Context, req *http.Request) (*http.Response, error) {
	req = req.WithContext(ctx)
	if gl.AccessToken != "" {
		req.Header.Set("Authorization", "Bearer "+gl.AccessToken)
	}
	resp, err := gl.Client.Do(req)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode >= http.StatusBadRequest {
		resp.Body.Close()
		return nil, fmt.Errorf("%v %v failed (status %v)", req.Method, req.URL, resp.Status)
	}
	return resp, nil
}

func (gl *GitLabAPI) get(ctx context.Context, path string) (*http.Response, error) {
	u, err := gl.url(path)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest("GET", u, nil)
	if err != nil {
		return nil, fmt.Errorf("building GET request for %q: %w", u, err)
	}
	return gl.do(ctx, req)
}

func (gl *GitLabAPI) doWithForm(ctx context.Context, method, path string, data url.Values) (*http.Response, error) {
	u, err := gl.url(path)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequest(method, u, strings.NewReader(data.Encode()))
	if err != nil {
		return nil, fmt.Errorf("building %v request for %q: %w", method, u, err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return gl.do(ctx, req)
}

// Ping just tests to see if the connection is successful by retrieving the projects endpoint.
// The response data is ignored, but the status code is checked.
func (gl *GitLabAPI) Ping(ctx context.Context) error {
	resp, err := gl.get(ctx, "/api/v4/projects")
	if err != nil {
		return err
	}
	resp.Body.Close()
	return nil
}

type ProjectVariable struct {
	Key          string `json:"key" url:"key"`
	VariableType string `json:"variable_type" url:"variable_type"`
	Value        string `json:"value" url:"value"`
	Protected    bool   `json:"protected" url:"protected"`
	Masked       bool   `json:"masked" url:"masked"`
}

func (v ProjectVariable) EncodedValue() string {
	return strings.ReplaceAll(v.Value, "$", "$$")
}

func (v ProjectVariable) Values() url.Values {
	data, err := query.Values(v)
	if err != nil {
		panic("query.Values failed unexpectedly")
	}
	data.Set("value", v.EncodedValue())
	return data
}

// Variables gets the list of variables from GitLab.
func (gl *GitLabAPI) Variables(ctx context.Context, project string) (map[string]ProjectVariable, error) {
	resp, err := gl.get(ctx, fmt.Sprintf("/api/v4/projects/%s/variables", url.PathEscape(project)))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var pvs []ProjectVariable
	if err := json.NewDecoder(resp.Body).Decode(&pvs); err != nil {
		return nil, fmt.Errorf("decoding GitLab API response: %w", err)
	}

	out := make(map[string]ProjectVariable, len(pvs))
	for _, s := range pvs {
		s.Value = strings.ReplaceAll(s.Value, "$$", "$")
		out[s.Key] = s
	}
	return out, nil
}

// CreateVariable creates a new project variable.
func (gl *GitLabAPI) CreateVariable(ctx context.Context, project string, varData ProjectVariable) error {
	resp, err := gl.doWithForm(ctx, "POST", fmt.Sprintf("/api/v4/projects/%s/variables", url.PathEscape(project)), varData.Values())
	if err != nil {
		return err
	}
	if _, err := ioutil.ReadAll(resp.Body); err != nil {
		return fmt.Errorf("reading response body to completion: %w", err)
	}
	resp.Body.Close()
	return nil
}

// UpdateVariable updates a project variable.
func (gl *GitLabAPI) UpdateVariable(ctx context.Context, project string, varData ProjectVariable) error {
	resp, err := gl.doWithForm(ctx, "PUT", fmt.Sprintf("/api/v4/projects/%s/variables/%s", url.PathEscape(project), url.PathEscape(varData.Key)), varData.Values())
	if err != nil {
		return err
	}
	if _, err := ioutil.ReadAll(resp.Body); err != nil {
		return fmt.Errorf("reading response body to completion: %w", err)
	}
	resp.Body.Close()
	return nil
}

func main() {
	flag.Parse()
	defer log.Flush()

	ctx := context.Background()

	gitlab := &GitLabAPI{
		Client:      http.DefaultClient,
		AccessToken: *gitlabAccessToken,
		Endpoint:    *gitlabEndpoint,
	}
	if err := gitlab.Ping(ctx); err != nil {
		log.Exitf("gitlab.Ping: %v", err)
	}

	vars, err := gitlab.Variables(ctx, *gitlabProject)
	if err != nil {
		log.Exitf("gitlab.Variables: %v", err)
	}

	createOrReplaceVariable := func(varFuture ProjectVariable) error {
		varCurrent, ok := vars[varFuture.Key]
		switch {
		case !ok:
			log.Infof("%q needs to be created", varFuture.Key)
			return gitlab.CreateVariable(ctx, *gitlabProject, varFuture)
		case varCurrent.Value == varFuture.Value:
			log.Infof("%q up-to-date", varFuture.Key)
			return nil
		default:
			log.Infof("%q needs to be updated", varFuture.Key)
			return gitlab.UpdateVariable(ctx, *gitlabProject, varFuture)
		}
	}

	for varKey, varSource := range varToFileMap {
		varFutureData, err := ioutil.ReadFile(varSource)
		if err != nil {
			log.Exitf("reading secret source file %q: %v", varSource, err)
		}
		if err := createOrReplaceVariable(ProjectVariable{
			Key:          varKey,
			VariableType: "file",
			Value:        string(varFutureData),
			Protected:    true,
		}); err != nil {
			log.Errorf("createOrReplaceVariable(%q, %q): %v", *gitlabProject, varKey, err)
		}
	}
	if *manifestVariable != "" {
		var manifestData []string
		for k, v := range varToFileMap {
			manifestData = append(manifestData, fmt.Sprintf("%s=%s", k, v))
		}
		sort.Strings(manifestData)
		if err := createOrReplaceVariable(ProjectVariable{
			Key:          *manifestVariable,
			VariableType: "file",
			Value:        strings.Join(manifestData, "\n") + "\n",
			Protected:    true,
		}); err != nil {
			log.Errorf("createOrReplaceVariable(%q, %q): %v", *gitlabProject, *manifestVariable, err)
		}
	}
}