// Copyright 2017 HootSuite Media Inc.
//
// 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.
// Modified hereafter by contributors to runatlantis/atlantis.

package models_test

import (
	"fmt"
	"testing"

	"github.com/runatlantis/atlantis/server/events/models"
	"github.com/runatlantis/atlantis/server/events/vcs"
	. "github.com/runatlantis/atlantis/testing"
)

func TestNewRepo_EmptyRepoFullName(t *testing.T) {
	_, err := models.NewRepo(models.Github, "", "https://github.com/notowner/repo.git", "u", "p")
	ErrEquals(t, "repoFullName can't be empty", err)
}

func TestNewRepo_EmptyCloneURL(t *testing.T) {
	_, err := models.NewRepo(models.Github, "owner/repo", "", "u", "p")
	ErrEquals(t, "cloneURL can't be empty", err)
}

func TestNewRepo_InvalidCloneURL(t *testing.T) {
	_, err := models.NewRepo(models.Github, "owner/repo", ":", "u", "p")
	ErrEquals(t, "invalid clone url: parse \":.git\": missing protocol scheme", err)
}

func TestNewRepo_CloneURLWrongRepo(t *testing.T) {
	_, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/notowner/repo.git", "u", "p")
	ErrEquals(t, `expected clone url to have path "/owner/repo.git" but had "/notowner/repo.git"`, err)
}

func TestNewRepo_EmptyAzureDevopsProject(t *testing.T) {
	_, err := models.NewRepo(models.AzureDevops, "", "https://dev.azure.com/notowner/project/_git/repo", "u", "p")
	ErrEquals(t, "repoFullName can't be empty", err)
}

// For bitbucket server we don't validate the clone URL because the callers
// are actually constructing it.
func TestNewRepo_CloneURLBitbucketServer(t *testing.T) {
	repo, err := models.NewRepo(models.BitbucketServer, "owner/repo", "http://mycorp.com:7990/scm/at/atlantis-example.git", "u", "p")
	Ok(t, err)
	Equals(t, models.Repo{
		FullName:          "owner/repo",
		Owner:             "owner",
		Name:              "repo",
		CloneURL:          "http://u:p@mycorp.com:7990/scm/at/atlantis-example.git",
		SanitizedCloneURL: "http://u:<redacted>@mycorp.com:7990/scm/at/atlantis-example.git",
		VCSHost: models.VCSHost{
			Hostname: "mycorp.com",
			Type:     models.BitbucketServer,
		},
	}, repo)
}

// If the clone URL contains a space, NewRepo() should encode it
func TestNewRepo_CloneURLContainsSpace(t *testing.T) {
	repo, err := models.NewRepo(models.AzureDevops, "owner/project space/repo", "https://dev.azure.com/owner/project space/repo", "u", "p")
	Ok(t, err)
	Equals(t, repo.CloneURL, "https://u:p@dev.azure.com/owner/project%20space/repo")
	Equals(t, repo.SanitizedCloneURL, "https://u:<redacted>@dev.azure.com/owner/project%20space/repo")

	repo, err = models.NewRepo(models.BitbucketCloud, "owner/repo space", "https://bitbucket.org/owner/repo space", "u", "p")
	Ok(t, err)
	Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo%20space.git")
	Equals(t, repo.SanitizedCloneURL, "https://u:<redacted>@bitbucket.org/owner/repo%20space.git")
}

func TestNewRepo_FullNameWrongFormat(t *testing.T) {
	cases := []struct {
		repoFullName string
		expErr       string
	}{
		{
			"owner/repo/extra",
			`invalid repo format "owner/repo/extra", owner "owner/repo" should not contain any /'s`,
		},
		{
			"/",
			`invalid repo format "/", owner "" or repo "" was empty`,
		},
		{
			"//",
			`invalid repo format "//", owner "" or repo "" was empty`,
		},
		{
			"///",
			`invalid repo format "///", owner "" or repo "" was empty`,
		},
		{
			"a/",
			`invalid repo format "a/", owner "" or repo "" was empty`,
		},
		{
			"/b",
			`invalid repo format "/b", owner "" or repo "b" was empty`,
		},
	}
	for _, c := range cases {
		t.Run(c.repoFullName, func(t *testing.T) {
			cloneURL := fmt.Sprintf("https://github.com/%s.git", c.repoFullName)
			_, err := models.NewRepo(models.Github, c.repoFullName, cloneURL, "u", "p")
			ErrEquals(t, c.expErr, err)
		})
	}
}

// If the clone url doesn't end with .git, and VCS is not Azure DevOps, it is appended
func TestNewRepo_MissingDotGit(t *testing.T) {
	repo, err := models.NewRepo(models.BitbucketCloud, "owner/repo", "https://bitbucket.org/owner/repo", "u", "p")
	Ok(t, err)
	Equals(t, repo.CloneURL, "https://u:p@bitbucket.org/owner/repo.git")
	Equals(t, repo.SanitizedCloneURL, "https://u:<redacted>@bitbucket.org/owner/repo.git")
}

func TestNewRepo_HTTPAuth(t *testing.T) {
	// When the url has http the auth should be added.
	repo, err := models.NewRepo(models.Github, "owner/repo", "http://github.com/owner/repo.git", "u", "p")
	Ok(t, err)
	Equals(t, models.Repo{
		VCSHost: models.VCSHost{
			Hostname: "github.com",
			Type:     models.Github,
		},
		SanitizedCloneURL: "http://u:<redacted>@github.com/owner/repo.git",
		CloneURL:          "http://u:p@github.com/owner/repo.git",
		FullName:          "owner/repo",
		Owner:             "owner",
		Name:              "repo",
	}, repo)
}

func TestNewRepo_HTTPSAuth(t *testing.T) {
	// When the url has https the auth should be added.
	repo, err := models.NewRepo(models.Github, "owner/repo", "https://github.com/owner/repo.git", "u", "p")
	Ok(t, err)
	Equals(t, models.Repo{
		VCSHost: models.VCSHost{
			Hostname: "github.com",
			Type:     models.Github,
		},
		SanitizedCloneURL: "https://u:<redacted>@github.com/owner/repo.git",
		CloneURL:          "https://u:p@github.com/owner/repo.git",
		FullName:          "owner/repo",
		Owner:             "owner",
		Name:              "repo",
	}, repo)
}

func TestProject_String(t *testing.T) {
	Equals(t, "repofullname=owner/repo path=my/path", (models.Project{
		RepoFullName: "owner/repo",
		Path:         "my/path",
	}).String())
}

func TestNewProject(t *testing.T) {
	cases := []struct {
		repo       string
		path       string
		name       string
		expProject models.Project
	}{
		{
			repo: "foo/bar",
			path: "/",
			name: "",
			expProject: models.Project{
				ProjectName:  "",
				RepoFullName: "foo/bar",
				Path:         ".",
			},
		},
		{
			repo: "baz/foo",
			path: "./another/path",
			name: "somename",
			expProject: models.Project{
				ProjectName:  "somename",
				RepoFullName: "baz/foo",
				Path:         "another/path",
			},
		},
		{
			repo: "baz/foo",
			path: ".",
			name: "somename",
			expProject: models.Project{
				ProjectName:  "somename",
				RepoFullName: "baz/foo",
				Path:         ".",
			},
		},
	}

	for _, c := range cases {
		t.Run(fmt.Sprintf("%s_%s", c.name, c.path), func(t *testing.T) {
			p := models.NewProject(c.repo, c.path, c.name)
			Equals(t, c.expProject, p)
		})
	}
}

func TestVCSHostType_ToString(t *testing.T) {
	cases := []struct {
		vcsType models.VCSHostType
		exp     string
	}{
		{
			models.Github,
			"Github",
		},
		{
			models.Gitlab,
			"Gitlab",
		},
		{
			models.BitbucketCloud,
			"BitbucketCloud",
		},
		{
			models.BitbucketServer,
			"BitbucketServer",
		},
		{
			models.AzureDevops,
			"AzureDevops",
		},
	}

	for _, c := range cases {
		t.Run(c.exp, func(t *testing.T) {
			Equals(t, c.exp, c.vcsType.String())
		})
	}
}

func TestSplitRepoFullName(t *testing.T) {
	cases := []struct {
		input    string
		expOwner string
		expRepo  string
	}{
		{
			"owner/repo",
			"owner",
			"repo",
		},
		{
			"group/subgroup/owner/repo",
			"group/subgroup/owner",
			"repo",
		},
		{
			"",
			"",
			"",
		},
		{
			"/",
			"",
			"",
		},
		{
			"owner/",
			"",
			"",
		},
		{
			"/repo",
			"",
			"repo",
		},
		{
			"group/subgroup/",
			"",
			"",
		},
	}

	for _, c := range cases {
		t.Run(c.input, func(t *testing.T) {
			owner, repo := models.SplitRepoFullName(c.input)
			Equals(t, c.expOwner, owner)
			Equals(t, c.expRepo, repo)
		})
	}
}

// These test cases should cover the same behavior as TestSplitRepoFullName
// and only produce different output in the AzureDevops case of
// owner/project/repo.
func TestAzureDevopsSplitRepoFullName(t *testing.T) {
	cases := []struct {
		input      string
		expOwner   string
		expRepo    string
		expProject string
	}{
		{
			"owner/repo",
			"owner",
			"repo",
			"",
		},
		{
			"group/subgroup/owner/repo",
			"group/subgroup/owner",
			"repo",
			"",
		},
		{
			"group/subgroup/owner/project/repo",
			"group/subgroup/owner/project",
			"repo",
			"",
		},
		{
			"",
			"",
			"",
			"",
		},
		{
			"/",
			"",
			"",
			"",
		},
		{
			"owner/",
			"",
			"",
			"",
		},
		{
			"/repo",
			"",
			"repo",
			"",
		},
		{
			"group/subgroup/",
			"",
			"",
			"",
		},
		{
			"owner/project/repo",
			"owner",
			"repo",
			"project",
		},
	}

	for _, c := range cases {
		t.Run(c.input, func(t *testing.T) {
			owner, project, repo := vcs.SplitAzureDevopsRepoFullName(c.input)
			Equals(t, c.expOwner, owner)
			Equals(t, c.expProject, project)
			Equals(t, c.expRepo, repo)
		})
	}
}

func TestPlanSuccess_Summary(t *testing.T) {
	cases := []struct {
		input string
		exp   string
	}{
		{
			"Note: Objects have changed outside of Terraform\ndummy\nPlan: 0 to add, 1 to change, 2 to destroy.",
			"\n**Note: Objects have changed outside of Terraform**\nPlan: 0 to add, 1 to change, 2 to destroy.",
		},
		{
			"dummy\nPlan: 100 to add, 111 to change, 222 to destroy.",
			"Plan: 100 to add, 111 to change, 222 to destroy.",
		},
		{
			"dummy\nPlan: 42 to import, 53 to add, 64 to change, 75 to destroy.",
			"Plan: 42 to import, 53 to add, 64 to change, 75 to destroy.",
		},
		{
			"Note: Objects have changed outside of Terraform\ndummy\nNo changes. Infrastructure is up-to-date.",
			"\n**Note: Objects have changed outside of Terraform**\nNo changes. Infrastructure is up-to-date.",
		},
		{
			"dummy\nNo changes. Your infrastructure matches the configuration.",
			"No changes. Your infrastructure matches the configuration.",
		},
	}
	for i, c := range cases {
		t.Run(fmt.Sprintf("summary %d", i), func(t *testing.T) {
			pcs := models.PlanSuccess{
				TerraformOutput: c.input,
			}
			Equals(t, c.exp, pcs.Summary())
		})
	}
}

func TestPlanSuccess_DiffSummary(t *testing.T) {
	cases := []struct {
		input string
		exp   string
	}{
		{
			"Note: Objects have changed outside of Terraform\ndummy\nPlan: 0 to add, 1 to change, 2 to destroy.",
			"Plan: 0 to add, 1 to change, 2 to destroy.",
		},
		{
			"dummy\nPlan: 100 to add, 111 to change, 222 to destroy.",
			"Plan: 100 to add, 111 to change, 222 to destroy.",
		},
		{
			"Note: Objects have changed outside of Terraform\ndummy\nNo changes. Infrastructure is up-to-date.",
			"No changes. Infrastructure is up-to-date.",
		},
		{
			"dummy\nNo changes. Your infrastructure matches the configuration.",
			"No changes. Your infrastructure matches the configuration.",
		},
	}
	for i, c := range cases {
		t.Run(fmt.Sprintf("summary %d", i), func(t *testing.T) {
			pcs := models.PlanSuccess{
				TerraformOutput: c.input,
			}
			Equals(t, c.exp, pcs.DiffSummary())
		})
	}
}

func TestPolicyCheckResults_Summary(t *testing.T) {
	cases := []struct {
		description      string
		policysetResults []models.PolicySetResult
		exp              string
	}{
		{
			description: "test single format with single policy set",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					PolicyOutput:  "20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions",
				},
			},
			exp: "policy set: policy1: 20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions",
		},
		{
			description: "test multiple formats with multiple policy sets",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					PolicyOutput:  "20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions",
				},
				{
					PolicySetName: "policy2",
					PolicyOutput:  "3 tests, 0 passed, 1 warning, 1 failure, 0 exceptions, 1 skipped",
				},
				{
					PolicySetName: "policy3",
					PolicyOutput:  "1 test, 0 passed, 1 warning, 1 failure, 1 exception",
				},
			},
			exp: `policy set: policy1: 20 tests, 19 passed, 2 warnings, 0 failures, 0 exceptions
policy set: policy2: 3 tests, 0 passed, 1 warning, 1 failure, 0 exceptions, 1 skipped
policy set: policy3: 1 test, 0 passed, 1 warning, 1 failure, 1 exception`,
		},
	}
	for _, summary := range cases {
		t.Run(summary.description, func(t *testing.T) {
			pcs := models.PolicyCheckResults{
				PolicySetResults: summary.policysetResults,
			}
			Equals(t, summary.exp, pcs.Summary())
		})
	}
}

// Test PolicyCleared and PolicySummary
func TestPolicyCheckResults_PolicyFuncs(t *testing.T) {
	cases := []struct {
		description      string
		policysetResults []models.PolicySetResult
		policyClearedExp bool
		policySummaryExp string
	}{
		{
			description: "single policy set, not passed",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					Passed:        false,
					ReqApprovals:  1,
				},
			},
			policyClearedExp: false,
			policySummaryExp: "policy set: policy1: requires: 1 approval(s), have: 0.",
		},
		{
			description: "single policy set, passed",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					Passed:        true,
					ReqApprovals:  1,
				},
			},
			policyClearedExp: true,
			policySummaryExp: "policy set: policy1: passed.",
		},
		{
			description: "single policy set, fully approved",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					Passed:        false,
					ReqApprovals:  1,
					CurApprovals:  1,
				},
			},
			policyClearedExp: true,
			policySummaryExp: "policy set: policy1: approved.",
		},
		{
			description: "multiple policy sets, different states.",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					Passed:        false,
					ReqApprovals:  2,
					CurApprovals:  0,
				},
				{
					PolicySetName: "policy2",
					Passed:        false,
					ReqApprovals:  1,
					CurApprovals:  1,
				},
				{
					PolicySetName: "policy3",
					Passed:        true,
					ReqApprovals:  1,
					CurApprovals:  0,
				},
			},
			policyClearedExp: false,
			policySummaryExp: `policy set: policy1: requires: 2 approval(s), have: 0.
policy set: policy2: approved.
policy set: policy3: passed.`,
		},
		{
			description: "multiple policy sets, all cleared.",
			policysetResults: []models.PolicySetResult{
				{
					PolicySetName: "policy1",
					Passed:        false,
					ReqApprovals:  2,
					CurApprovals:  2,
				},
				{
					PolicySetName: "policy2",
					Passed:        false,
					ReqApprovals:  1,
					CurApprovals:  1,
				},
				{
					PolicySetName: "policy3",
					Passed:        true,
					ReqApprovals:  1,
					CurApprovals:  0,
				},
			},
			policyClearedExp: true,
			policySummaryExp: `policy set: policy1: approved.
policy set: policy2: approved.
policy set: policy3: passed.`,
		},
	}
	for _, summary := range cases {
		t.Run(summary.description, func(t *testing.T) {
			pcs := models.PolicyCheckResults{
				PolicySetResults: summary.policysetResults,
			}
			Equals(t, summary.policyClearedExp, pcs.PolicyCleared())
			Equals(t, summary.policySummaryExp, pcs.PolicySummary())
		})
	}
}

func TestPullStatus_StatusCount(t *testing.T) {
	ps := models.PullStatus{
		Projects: []models.ProjectStatus{
			{
				Status: models.PlannedPlanStatus,
			},
			{
				Status: models.PlannedPlanStatus,
			},
			{
				Status: models.AppliedPlanStatus,
			},
			{
				Status: models.ErroredApplyStatus,
			},
			{
				Status: models.DiscardedPlanStatus,
			},
			{
				Status: models.ErroredPolicyCheckStatus,
			},
			{
				Status: models.PassedPolicyCheckStatus,
			},
		},
	}

	Equals(t, 2, ps.StatusCount(models.PlannedPlanStatus))
	Equals(t, 1, ps.StatusCount(models.AppliedPlanStatus))
	Equals(t, 1, ps.StatusCount(models.ErroredApplyStatus))
	Equals(t, 0, ps.StatusCount(models.ErroredPlanStatus))
	Equals(t, 1, ps.StatusCount(models.DiscardedPlanStatus))
	Equals(t, 1, ps.StatusCount(models.ErroredPolicyCheckStatus))
	Equals(t, 1, ps.StatusCount(models.PassedPolicyCheckStatus))
}

func TestPlanSuccessStats(t *testing.T) {
	tests := []struct {
		name   string
		output string
		exp    models.PlanSuccessStats
	}{
		{
			"has changes",
			`An execution plan has been generated and is shown below.
					Resource actions are indicated with the following symbols:
					  - destroy
					Terraform will perform the following actions:
					  - null_resource.hi[1]
					Plan: 1 to add, 3 to change, 2 to destroy.`,
			models.PlanSuccessStats{
				Changes: true,

				Add:     1,
				Change:  3,
				Destroy: 2,
			},
		},
		{
			"no changes",
			`An execution plan has been generated and is shown below.
					Resource actions are indicated with the following symbols:
					No changes. Infrastructure is up-to-date.`,
			models.PlanSuccessStats{},
		},
		{
			"changes outside",
			`Note: Objects have changed outside of Terraform
					Terraform detected the following changes made outside of Terraform since the
					last "terraform apply":
					No changes. Your infrastructure matches the configuration.`,
			models.PlanSuccessStats{
				ChangesOutside: true,
			},
		},
		{
			"with imports",
			`Terraform used the selected providers to generate the following execution
			plan. Resource actions are indicated with the following symbols:	
			  + create
			  ~ update in-place
			  - destroy
			Terraform will perform the following actions:
			  - null_resource.hi[1]
			Plan: 42 to import, 31 to add, 20 to change, 1 to destroy.`,
			models.PlanSuccessStats{
				Changes: true,

				Import:  42,
				Add:     31,
				Change:  20,
				Destroy: 1,
			},
		},
		{
			"changes and changes outside",
			`Note: Objects have changed outside of Terraform
					Terraform detected the following changes made outside of Terraform since the
					last "terraform apply":
					An execution plan has been generated and is shown below.
					Resource actions are indicated with the following symbols:
					  - destroy
					Terraform will perform the following actions:
					  - null_resource.hi[1]
					Plan: 3 to add, 0 to change, 1 to destroy.`,
			models.PlanSuccessStats{
				Changes:        true,
				ChangesOutside: true,

				Add:     3,
				Change:  0,
				Destroy: 1,
			},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			s := models.NewPlanSuccessStats(tt.output)
			if s != tt.exp {
				t.Errorf("\nexp: %#v\ngot: %#v", tt.exp, s)
			}
		})
	}
}
