package gitlab

import (
	"fmt"
	"net/http"
	"net/url"
	"strings"

	"github.com/porter-dev/porter/api/server/shared/commonutils"
	"github.com/porter-dev/porter/api/server/shared/config"
	"github.com/porter-dev/porter/internal/oauth"
	"github.com/porter-dev/porter/internal/repository"
	"github.com/xanzy/go-gitlab"
	"gopkg.in/yaml.v2"
)

type GitlabCI struct {
	ServerURL   string
	GitRepoPath string
	GitBranch   string

	Repo repository.Repository

	ProjectID     uint
	ClusterID     uint
	UserID        uint
	IntegrationID uint

	PorterConf       *config.Config
	ReleaseName      string
	ReleaseNamespace string
	FolderPath       string
	PorterToken      string

	defaultGitBranch  string
	pID               string
	gitlabInstanceURL string
}

func (g *GitlabCI) Setup() error {
	client, err := g.getClient()
	if err != nil {
		return err
	}

	g.pID = g.GitRepoPath

	err = g.setGitlabDefaultBranch(client)

	if err != nil {
		return err
	}

	err = g.createGitlabSecret(client)

	if err != nil {
		return err
	}

	jobName := getGitlabStageJobName(g.ReleaseName)

	ciFile, resp, err := client.RepositoryFiles.GetRawFile(g.pID, ".gitlab-ci.yml", &gitlab.GetRawFileOptions{
		Ref: gitlab.String(g.GitBranch),
	})

	if resp.StatusCode == http.StatusNotFound {
		// create .gitlab-ci.yml
		contentsMap := make(map[string]interface{})
		contentsMap["stages"] = []string{
			jobName,
		}
		contentsMap[jobName] = g.getCIJob(jobName)

		contentsYAML, _ := yaml.Marshal(contentsMap)

		_, _, err = client.RepositoryFiles.CreateFile(g.pID, ".gitlab-ci.yml", &gitlab.CreateFileOptions{
			Branch:        gitlab.String(g.GitBranch),
			AuthorName:    gitlab.String("Porter Bot"),
			AuthorEmail:   gitlab.String("contact@getporter.dev"),
			Content:       gitlab.String(string(contentsYAML)),
			CommitMessage: gitlab.String("Create .gitlab-ci.yml file"),
		})

		if err != nil {
			return fmt.Errorf("error creating .gitlab-ci.yml file: %w", err)
		}
	} else if err != nil {
		return fmt.Errorf("error getting .gitlab-ci.yml file: %w", err)
	} else {
		// update .gitlab-ci.yml if needed

		// to preserve the order of the YAML, we use a MapSlice
		ciFileContentsMap := yaml.MapSlice{}
		err = yaml.Unmarshal(ciFile, &ciFileContentsMap)

		if err != nil {
			return fmt.Errorf("error unmarshalling existing .gitlab-ci.yml: %w", err)
		}

		var stagesInt []interface{}
		stagesIdx := -1

		for idx, elem := range ciFileContentsMap {
			if key, ok := elem.Key.(string); ok {
				if key == "stages" {
					stages, ok := elem.Value.([]interface{})

					if !ok {
						return fmt.Errorf("error converting stages to interface slice")
					}

					stagesInt = stages
					stagesIdx = idx

					break
				}
			} else {
				return fmt.Errorf("invalid key '%v' in .gitlab-ci.yml", elem.Key)
			}
		}

		// two cases can happen here:
		// 1: "stages" exists
		// 2: "stages" does not exist

		if stagesIdx >= 0 { // 1: "stages" exists
			stageExists := false

			for _, stage := range stagesInt {
				stageStr, ok := stage.(string)
				if !ok {
					return fmt.Errorf("error converting from interface to string")
				}

				if stageStr == jobName {
					stageExists = true
					break
				}
			}

			if !stageExists {
				stagesInt = append(stagesInt, jobName)

				ciFileContentsMap[stagesIdx] = yaml.MapItem{
					Key:   "stages",
					Value: stagesInt,
				}
			}
		} else { // 2: "stages" does not exist
			stagesInt = append(stagesInt, jobName)

			ciFileContentsMap = append(ciFileContentsMap, yaml.MapItem{
				Key:   "stages",
				Value: stagesInt,
			})
		}

		ciFileContentsMap = append(ciFileContentsMap, yaml.MapItem{
			Key:   jobName,
			Value: g.getCIJob(jobName),
		})

		contentsYAML, err := yaml.Marshal(ciFileContentsMap)
		if err != nil {
			return fmt.Errorf("error marshalling contents of .gitlab-ci.yml while updating to add porter job")
		}

		_, _, err = client.RepositoryFiles.UpdateFile(g.pID, ".gitlab-ci.yml", &gitlab.UpdateFileOptions{
			Branch:        gitlab.String(g.GitBranch),
			AuthorName:    gitlab.String("Porter Bot"),
			AuthorEmail:   gitlab.String("contact@getporter.dev"),
			Content:       gitlab.String(string(contentsYAML)),
			CommitMessage: gitlab.String("Update .gitlab-ci.yml file"),
		})

		if err != nil {
			return fmt.Errorf("error updating .gitlab-ci.yml file to add porter job: %w", err)
		}
	}

	return nil
}

func (g *GitlabCI) Cleanup() error {
	client, err := g.getClient()
	if err != nil {
		return err
	}

	g.pID = g.GitRepoPath

	err = g.setGitlabDefaultBranch(client)

	if err != nil {
		return err
	}

	err = g.deleteGitlabSecret(client)

	if err != nil {
		return err
	}

	jobName := getGitlabStageJobName(g.ReleaseName)

	ciFile, resp, err := client.RepositoryFiles.GetRawFile(g.pID, ".gitlab-ci.yml", &gitlab.GetRawFileOptions{
		Ref: gitlab.String(g.GitBranch),
	})

	if resp.StatusCode == http.StatusNotFound {
		return nil
	} else if err != nil {
		return fmt.Errorf("error getting .gitlab-ci.yml file: %w", err)
	}

	ciFileContentsMap := yaml.MapSlice{}
	err = yaml.Unmarshal(ciFile, &ciFileContentsMap)

	if err != nil {
		return fmt.Errorf("error unmarshalling existing .gitlab-ci.yml: %w", err)
	}

	var stagesInt []interface{}
	stagesIdx := -1

	for idx, elem := range ciFileContentsMap {
		if key, ok := elem.Key.(string); ok {
			if key == "stages" {
				stages, ok := elem.Value.([]interface{})

				if !ok {
					return fmt.Errorf("error converting stages to interface slice")
				}

				stagesInt = stages
				stagesIdx = idx

				break
			}
		} else {
			return fmt.Errorf("invalid key '%v' in .gitlab-ci.yml", elem.Key)
		}
	}

	if stagesIdx >= 0 { // "stages" exists
		var newStages []string

		for _, stage := range stagesInt {
			stageStr, ok := stage.(string)
			if !ok {
				return fmt.Errorf("error converting from interface to string")
			}

			if stageStr != jobName {
				newStages = append(newStages, stageStr)
			}
		}

		ciFileContentsMap[stagesIdx] = yaml.MapItem{
			Key:   "stages",
			Value: newStages,
		}
	}

	newCIFileContentsMap := yaml.MapSlice{}

	for _, elem := range ciFileContentsMap {
		if key, ok := elem.Key.(string); ok {
			if key != jobName {
				newCIFileContentsMap = append(newCIFileContentsMap, elem)
			}
		} else {
			return fmt.Errorf("invalid key '%v' in .gitlab-ci.yml", elem.Key)
		}
	}

	contentsYAML, err := yaml.Marshal(newCIFileContentsMap)
	if err != nil {
		return fmt.Errorf("error unmarshalling contents of .gitlab-ci.yml while updating to remove porter job")
	}

	_, _, err = client.RepositoryFiles.UpdateFile(g.pID, ".gitlab-ci.yml", &gitlab.UpdateFileOptions{
		Branch:        gitlab.String(g.GitBranch),
		AuthorName:    gitlab.String("Porter Bot"),
		AuthorEmail:   gitlab.String("contact@getporter.dev"),
		Content:       gitlab.String(string(contentsYAML)),
		CommitMessage: gitlab.String("Update .gitlab-ci.yml file"),
	})

	if err != nil {
		return fmt.Errorf("error updating .gitlab-ci.yml file to remove porter job: %w", err)
	}

	return nil
}

func (g *GitlabCI) getClient() (*gitlab.Client, error) {
	gi, err := g.Repo.GitlabIntegration().ReadGitlabIntegration(g.ProjectID, g.IntegrationID)
	if err != nil {
		return nil, err
	}

	giOAuthInt, err := g.Repo.GitlabAppOAuthIntegration().ReadGitlabAppOAuthIntegration(g.UserID, g.ProjectID, g.IntegrationID)
	if err != nil {
		return nil, err
	}

	oauthInt, err := g.Repo.OAuthIntegration().ReadOAuthIntegration(g.ProjectID, giOAuthInt.OAuthIntegrationID)
	if err != nil {
		return nil, err
	}

	accessToken, _, err := oauth.GetAccessToken(
		oauthInt.SharedOAuthModel,
		commonutils.GetGitlabOAuthConf(g.PorterConf, gi),
		oauth.MakeUpdateGitlabAppOAuthIntegrationFunction(g.ProjectID, giOAuthInt, g.Repo),
	)
	if err != nil {
		return nil, err
	}

	client, err := gitlab.NewOAuthClient(accessToken, gitlab.WithBaseURL(gi.InstanceURL))
	if err != nil {
		return nil, err
	}

	g.gitlabInstanceURL = gi.InstanceURL

	return client, nil
}

func (g *GitlabCI) getCIJob(jobName string) yaml.MapSlice {
	res := yaml.MapSlice{}
	url, _ := url.Parse(g.gitlabInstanceURL)

	res = append(res,
		yaml.MapItem{
			Key: "rules",
			Value: []map[string]string{
				{
					"if": fmt.Sprintf("$CI_COMMIT_BRANCH == \"%s\" && $CI_PIPELINE_SOURCE == \"push\"", g.GitBranch),
				},
			},
		},
	)

	if url.Hostname() == "gitlab.com" || url.Hostname() == "www.gitlab.com" {
		res = append(res,
			yaml.MapItem{
				Key:   "image",
				Value: "docker:latest",
			},
			yaml.MapItem{
				Key: "services",
				Value: []string{
					"docker:dind",
				},
			},
			yaml.MapItem{
				Key: "script",
				Value: []string{
					fmt.Sprintf(
						"docker run --rm --workdir=\"/app\" "+
							"-v /var/run/docker.sock:/var/run/docker.sock "+
							"-v $(pwd):/app "+
							"public.ecr.aws/o1j4x7p4/porter-cli:latest "+
							"update --host \"%s\" --project %d --cluster %d "+
							"--token \"$%s\" --app \"%s\" "+
							"--tag \"$(echo $CI_COMMIT_SHA | cut -c1-7)\" --namespace \"%s\" --stream",
						g.ServerURL, g.ProjectID, g.ClusterID, g.getPorterTokenSecretName(),
						g.ReleaseName, g.ReleaseNamespace,
					),
				},
			},
			yaml.MapItem{
				Key: "tags",
				Value: []string{
					"docker",
				},
			},
		)
	} else {
		res = append(res,
			yaml.MapItem{
				Key: "image",
				Value: map[string]interface{}{
					"name": "public.ecr.aws/o1j4x7p4/porter-cli:latest",
					"entrypoint": []string{
						"",
					},
				},
			},
			yaml.MapItem{
				Key: "script",
				Value: []string{
					fmt.Sprintf(
						"porter update --host \"%s\" --project %d --cluster %d "+
							"--token \"$%s\" --app \"%s\" "+
							"--tag \"$(echo $CI_COMMIT_SHA | cut -c1-7)\" --namespace \"%s\" --stream",
						g.ServerURL, g.ProjectID, g.ClusterID, g.getPorterTokenSecretName(),
						g.ReleaseName, g.ReleaseNamespace,
					),
				},
			},
			yaml.MapItem{
				Key: "tags",
				Value: []string{
					"porter-runner",
				},
			},
		)
	}

	res = append(res,
		yaml.MapItem{
			Key:   "stage",
			Value: jobName,
		},
		yaml.MapItem{
			Key:   "timeout",
			Value: "20 minutes",
		},
		yaml.MapItem{
			Key: "variables",
			Value: map[string]string{
				"GIT_STRATEGY": "clone",
			},
		},
	)

	return res
}

func (g *GitlabCI) createGitlabSecret(client *gitlab.Client) error {
	_, resp, err := client.ProjectVariables.GetVariable(g.pID, g.getPorterTokenSecretName(),
		&gitlab.GetProjectVariableOptions{})

	if resp.StatusCode == http.StatusNotFound {
		_, _, err = client.ProjectVariables.CreateVariable(g.pID, &gitlab.CreateProjectVariableOptions{
			Key:    gitlab.String(g.getPorterTokenSecretName()),
			Value:  gitlab.String(g.PorterToken),
			Masked: gitlab.Bool(true),
		})

		if err != nil {
			return fmt.Errorf("error creating porter token variable: %w", err)
		}

		return nil
	} else if err != nil {
		return fmt.Errorf("error getting porter token variable: %w", err)
	}

	_, _, err = client.ProjectVariables.UpdateVariable(g.pID, g.getPorterTokenSecretName(),
		&gitlab.UpdateProjectVariableOptions{
			Value:  gitlab.String(g.PorterToken),
			Masked: gitlab.Bool(true),
		},
	)

	if err != nil {
		return fmt.Errorf("error updating porter token variable: %w", err)
	}

	return nil
}

func (g *GitlabCI) deleteGitlabSecret(client *gitlab.Client) error {
	_, err := client.ProjectVariables.RemoveVariable(g.pID, g.getPorterTokenSecretName(),
		&gitlab.RemoveProjectVariableOptions{})
	if err != nil {
		return fmt.Errorf("error removing porter token variable: %w", err)
	}

	return nil
}

func (g *GitlabCI) getPorterTokenSecretName() string {
	return fmt.Sprintf("PORTER_TOKEN_%d_%s", g.ProjectID, strings.ToLower(strings.ReplaceAll(g.ReleaseName, "-", "_")))
}

func getGitlabStageJobName(releaseName string) string {
	return fmt.Sprintf("porter-%s", strings.ToLower(strings.ReplaceAll(releaseName, "_", "-")))
}

func (g *GitlabCI) setGitlabDefaultBranch(client *gitlab.Client) error {
	opt := &gitlab.ListBranchesOptions{
		ListOptions: gitlab.ListOptions{
			PerPage: 20,
			Page:    1,
		},
	}

	for {
		branches, resp, err := client.Branches.ListBranches(g.pID, opt)
		if err != nil {
			return fmt.Errorf("error fetching list of branches: %w", err)
		}

		for _, branch := range branches {
			if branch.Default {
				g.defaultGitBranch = branch.Name
				return nil
			}
		}
		// Exit the loop when we've seen all pages.
		if resp.NextPage == 0 {
			break
		}

		// Update the page number to get the next page.
		opt.Page = resp.NextPage
	}
	return nil
}
