// 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 holds all models that are needed across packages.
// We place these models in their own package so as to avoid circular
// dependencies between packages (which is a compile error).
package models

import (
	"fmt"
	"net/url"
	paths "path"
	"regexp"
	"strings"
	"time"

	"github.com/runatlantis/atlantis/server/logging"

	"github.com/pkg/errors"
)

type PullReqStatus struct {
	ApprovalStatus ApprovalStatus
	Mergeable      bool
}

// Repo is a VCS repository.
type Repo struct {
	// FullName is the owner and repo name separated
	// by a "/", ex. "runatlantis/atlantis", "gitlab/subgroup/atlantis",
	// "Bitbucket Server/atlantis", "azuredevops/project/atlantis".
	FullName string
	// Owner is just the repo owner, ex. "runatlantis" or "gitlab/subgroup"
	// or azuredevops/project. This may contain /'s in the case of GitLab
	// subgroups or Azure DevOps Team Projects. This may contain spaces in
	// the case of Bitbucket Server.
	Owner string
	// Name is just the repo name, ex. "atlantis". This will never have
	// /'s in it.
	Name string
	// CloneURL is the full HTTPS url for cloning with username and token string
	// ex. "https://username:token@github.com/atlantis/atlantis.git".
	CloneURL string
	// SanitizedCloneURL is the full HTTPS url for cloning with the password
	// redacted.
	// ex. "https://user:<redacted>@github.com/atlantis/atlantis.git".
	SanitizedCloneURL string
	// VCSHost is where this repo is hosted.
	VCSHost VCSHost
}

// ID returns the atlantis ID for this repo.
// ID is in the form: {vcs hostname}/{repoFullName}.
func (r Repo) ID() string {
	return fmt.Sprintf("%s/%s", r.VCSHost.Hostname, r.FullName)
}

// NewRepo constructs a Repo object. repoFullName is the owner/repo form,
// cloneURL can be with or without .git at the end
// ex. https://github.com/runatlantis/atlantis.git OR
//     https://github.com/runatlantis/atlantis
func NewRepo(vcsHostType VCSHostType, repoFullName string, cloneURL string, vcsUser string, vcsToken string) (Repo, error) {
	if repoFullName == "" {
		return Repo{}, errors.New("repoFullName can't be empty")
	}
	if cloneURL == "" {
		return Repo{}, errors.New("cloneURL can't be empty")
	}

	// Azure DevOps doesn't work with .git suffix on clone URLs
	if !strings.HasSuffix(cloneURL, ".git") && vcsHostType != AzureDevops {
		cloneURL += ".git"
	}

	cloneURLParsed, err := url.Parse(cloneURL)
	if err != nil {
		return Repo{}, errors.Wrap(err, "invalid clone url")
	}

	// Ensure the Clone URL is for the same repo to avoid something malicious.
	// We skip this check for Bitbucket Server because its format is different
	// and because the caller in that case actually constructs the clone url
	// from the repo name and so there's no point checking if they match.
	// Azure DevOps also does not require .git at the end of clone urls.
	if vcsHostType != BitbucketServer && vcsHostType != AzureDevops {
		expClonePath := fmt.Sprintf("/%s.git", repoFullName)
		if expClonePath != cloneURLParsed.Path {
			return Repo{}, fmt.Errorf("expected clone url to have path %q but had %q", expClonePath, cloneURLParsed.Path)
		}
	}

	// We url encode because we're using them in a URL and weird characters can
	// mess up git.
	cloneURL = strings.Replace(cloneURL, " ", "%20", -1)
	escapedVCSUser := url.QueryEscape(vcsUser)
	escapedVCSToken := url.QueryEscape(vcsToken)
	auth := fmt.Sprintf("%s:%s@", escapedVCSUser, escapedVCSToken)
	redactedAuth := fmt.Sprintf("%s:<redacted>@", escapedVCSUser)

	// Construct clone urls with http and https auth. Need to do both
	// because Bitbucket supports http.
	authedCloneURL := strings.Replace(cloneURL, "https://", "https://"+auth, -1)
	authedCloneURL = strings.Replace(authedCloneURL, "http://", "http://"+auth, -1)
	sanitizedCloneURL := strings.Replace(cloneURL, "https://", "https://"+redactedAuth, -1)
	sanitizedCloneURL = strings.Replace(sanitizedCloneURL, "http://", "http://"+redactedAuth, -1)

	// Get the owner and repo names from the full name.
	owner, repo := SplitRepoFullName(repoFullName)
	if owner == "" || repo == "" {
		return Repo{}, fmt.Errorf("invalid repo format %q, owner %q or repo %q was empty", repoFullName, owner, repo)
	}
	// Only GitLab and AzureDevops repos can have /'s in their owners.
	// This is for GitLab subgroups and Azure DevOps Team Projects.
	if strings.Contains(owner, "/") && vcsHostType != Gitlab && vcsHostType != AzureDevops {
		return Repo{}, fmt.Errorf("invalid repo format %q, owner %q should not contain any /'s", repoFullName, owner)
	}
	if strings.Contains(repo, "/") {
		return Repo{}, fmt.Errorf("invalid repo format %q, repo %q should not contain any /'s", repoFullName, owner)
	}

	return Repo{
		FullName:          repoFullName,
		Owner:             owner,
		Name:              repo,
		CloneURL:          authedCloneURL,
		SanitizedCloneURL: sanitizedCloneURL,
		VCSHost: VCSHost{
			Type:     vcsHostType,
			Hostname: cloneURLParsed.Hostname(),
		},
	}, nil
}

type ApprovalStatus struct {
	IsApproved bool
	ApprovedBy string
	Date       time.Time
}

// PullRequest is a VCS pull request.
// GitLab calls these Merge Requests.
type PullRequest struct {
	// Num is the pull request number or ID.
	Num int
	// HeadCommit is a sha256 that points to the head of the branch that is being
	// pull requested into the base. If the pull request is from Bitbucket Cloud
	// the string will only be 12 characters long because Bitbucket Cloud
	// truncates its commit IDs.
	HeadCommit string
	// URL is the url of the pull request.
	// ex. "https://github.com/runatlantis/atlantis/pull/1"
	URL string
	// HeadBranch is the name of the head branch (the branch that is getting
	// merged into the base).
	HeadBranch string
	// BaseBranch is the name of the base branch (the branch that the pull
	// request is getting merged into).
	BaseBranch string
	// Author is the username of the pull request author.
	Author string
	// State will be one of Open or Closed.
	// Gitlab supports an additional "merged" state but Github doesn't so we map
	// merged to Closed.
	State PullRequestState
	// BaseRepo is the repository that the pull request will be merged into.
	BaseRepo Repo
}

// PullRequestOptions is used to set optional paralmeters for PullRequest
type PullRequestOptions struct {
	// When DeleteSourceBranchOnMerge flag is set to true VCS deletes the source branch after the PR is merged
	// Applied by GitLab & AzureDevops
	DeleteSourceBranchOnMerge bool
}

type PullRequestState int

const (
	OpenPullState PullRequestState = iota
	ClosedPullState
)

type PullRequestEventType int

const (
	OpenedPullEvent PullRequestEventType = iota
	UpdatedPullEvent
	ClosedPullEvent
	OtherPullEvent
)

func (p PullRequestEventType) String() string {
	switch p {
	case OpenedPullEvent:
		return "opened"
	case UpdatedPullEvent:
		return "updated"
	case ClosedPullEvent:
		return "closed"
	case OtherPullEvent:
		return "other"
	}
	return "<missing String() implementation>"
}

// User is a VCS user.
// During an autoplan, the user will be the Atlantis API user.
type User struct {
	Username string
}

// ProjectLock represents a lock on a project.
type ProjectLock struct {
	// Project is the project that is being locked.
	Project Project
	// Pull is the pull request from which the command was run that
	// created this lock.
	Pull PullRequest
	// User is the username of the user that ran the command
	// that created this lock.
	User User
	// Workspace is the Terraform workspace that this
	// lock is being held against.
	Workspace string
	// Time is the time at which the lock was first created.
	Time time.Time
}

// Project represents a Terraform project. Since there may be multiple
// Terraform projects in a single repo we also include Path to the project
// root relative to the repo root.
type Project struct {
	// RepoFullName is the owner and repo name, ex. "runatlantis/atlantis"
	RepoFullName string
	// Path to project root in the repo.
	// If "." then project is at root.
	// Never ends in "/".
	// todo: rename to RepoRelDir to match rest of project once we can separate
	// out how this is saved in boltdb vs. its usage everywhere else so we don't
	// break existing dbs.
	Path string
}

func (p Project) String() string {
	return fmt.Sprintf("repofullname=%s path=%s", p.RepoFullName, p.Path)
}

// Plan is the result of running an Atlantis plan command.
// This model is used to represent a plan on disk.
type Plan struct {
	// Project is the project this plan is for.
	Project Project
	// LocalPath is the absolute path to the plan on disk
	// (versus the relative path from the repo root).
	LocalPath string
}

// NewProject constructs a Project. Use this constructor because it
// sets Path correctly.
func NewProject(repoFullName string, path string) Project {
	path = paths.Clean(path)
	if path == "/" {
		path = "."
	}
	return Project{
		RepoFullName: repoFullName,
		Path:         path,
	}
}

// VCSHost is a Git hosting provider, for example GitHub.
type VCSHost struct {
	// Hostname is the hostname of the VCS provider, ex. "github.com" or
	// "github-enterprise.example.com".
	Hostname string

	// Type is which type of VCS host this is, ex. GitHub or GitLab.
	Type VCSHostType
}

type VCSHostType int

const (
	Github VCSHostType = iota
	Gitlab
	BitbucketCloud
	BitbucketServer
	AzureDevops
)

func (h VCSHostType) String() string {
	switch h {
	case Github:
		return "Github"
	case Gitlab:
		return "Gitlab"
	case BitbucketCloud:
		return "BitbucketCloud"
	case BitbucketServer:
		return "BitbucketServer"
	case AzureDevops:
		return "AzureDevops"
	}
	return "<missing String() implementation>"
}

// SplitRepoFullName splits a repo full name up into its owner and repo
// name segments. If the repoFullName is malformed, may return empty
// strings for owner or repo.
// Ex. runatlantis/atlantis => (runatlantis, atlantis)
//     gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis)
//     azuredevops/project/atlantis => (azuredevops/project, atlantis)
func SplitRepoFullName(repoFullName string) (owner string, repo string) {
	lastSlashIdx := strings.LastIndex(repoFullName, "/")
	if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 {
		return "", ""
	}

	return repoFullName[:lastSlashIdx], repoFullName[lastSlashIdx+1:]
}

// PlanSuccess is the result of a successful plan.
type PlanSuccess struct {
	// TerraformOutput is the output from Terraform of running plan.
	TerraformOutput string
	// LockURL is the full URL to the lock held by this plan.
	LockURL string
	// RePlanCmd is the command that users should run to re-plan this project.
	RePlanCmd string
	// ApplyCmd is the command that users should run to apply this plan.
	ApplyCmd string
	// HasDiverged is true if we're using the checkout merge strategy and the
	// branch we're merging into has been updated since we cloned and merged
	// it.
	HasDiverged bool
}

// Summary extracts one line summary of plan changes from TerraformOutput.
func (p *PlanSuccess) Summary() string {
	note := ""
	r := regexp.MustCompile(`Note: Objects have changed outside of Terraform`)
	if match := r.FindString(p.TerraformOutput); match != "" {
		note = fmt.Sprintf("\n**%s**\n", match)
	}

	r = regexp.MustCompile(`Plan: \d+ to add, \d+ to change, \d+ to destroy.`)
	if match := r.FindString(p.TerraformOutput); match != "" {
		return note + match
	}
	r = regexp.MustCompile(`No changes. (Infrastructure is up-to-date|Your infrastructure matches the configuration).`)
	return note + r.FindString(p.TerraformOutput)
}

// DiffMarkdownFormattedTerraformOutput formats the Terraform output to match diff markdown format
func (p PlanSuccess) DiffMarkdownFormattedTerraformOutput() string {
	diffKeywordRegex := regexp.MustCompile(`(?m)^( +)([-+~])`)
	diffTildeRegex := regexp.MustCompile(`(?m)^~`)

	formattedTerraformOutput := diffKeywordRegex.ReplaceAllString(p.TerraformOutput, "$2$1")
	formattedTerraformOutput = diffTildeRegex.ReplaceAllString(formattedTerraformOutput, "!")

	return formattedTerraformOutput
}

// PolicyCheckSuccess is the result of a successful policy check run.
type PolicyCheckSuccess struct {
	// PolicyCheckOutput is the output from policy check binary(conftest|opa)
	PolicyCheckOutput string
	// LockURL is the full URL to the lock held by this policy check.
	LockURL string
	// RePlanCmd is the command that users should run to re-plan this project.
	RePlanCmd string
	// ApplyCmd is the command that users should run to apply this plan.
	ApplyCmd string
	// HasDiverged is true if we're using the checkout merge strategy and the
	// branch we're merging into has been updated since we cloned and merged
	// it.
	HasDiverged bool
}

type VersionSuccess struct {
	VersionOutput string
}

// PullStatus is the current status of a pull request that is in progress.
type PullStatus struct {
	// Projects are the projects that have been modified in this pull request.
	Projects []ProjectStatus
	// Pull is the original pull request model.
	Pull PullRequest
}

// StatusCount returns the number of projects that have status.
func (p PullStatus) StatusCount(status ProjectPlanStatus) int {
	c := 0
	for _, pr := range p.Projects {
		if pr.Status == status {
			c++
		}
	}
	return c
}

// ProjectStatus is the status of a specific project.
type ProjectStatus struct {
	Workspace   string
	RepoRelDir  string
	ProjectName string
	// Status is the status of where this project is at in the planning cycle.
	Status ProjectPlanStatus
}

// ProjectPlanStatus is the status of where this project is at in the planning
// cycle.
type ProjectPlanStatus int

const (
	// ErroredPlanStatus means that this plan has an error or the apply has an
	// error.
	ErroredPlanStatus ProjectPlanStatus = iota
	// PlannedPlanStatus means that a plan has been successfully generated but
	// not yet applied.
	PlannedPlanStatus
	// ErroredApplyStatus means that a plan has been generated but there was an
	// error while applying it.
	ErroredApplyStatus
	// AppliedPlanStatus means that a plan has been generated and applied
	// successfully.
	AppliedPlanStatus
	// DiscardedPlanStatus means that there was an unapplied plan that was
	// discarded due to a project being unlocked
	DiscardedPlanStatus
	// ErroredPolicyCheckStatus means that there was an unapplied plan that was
	// discarded due to a project being unlocked
	ErroredPolicyCheckStatus
	// PassedPolicyCheckStatus means that there was an unapplied plan that was
	// discarded due to a project being unlocked
	PassedPolicyCheckStatus
)

// String returns a string representation of the status.
func (p ProjectPlanStatus) String() string {
	switch p {
	case ErroredPlanStatus:
		return "plan_errored"
	case PlannedPlanStatus:
		return "planned"
	case ErroredApplyStatus:
		return "apply_errored"
	case AppliedPlanStatus:
		return "applied"
	case DiscardedPlanStatus:
		return "plan_discarded"
	case ErroredPolicyCheckStatus:
		return "policy_check_errored"
	case PassedPolicyCheckStatus:
		return "policy_check_passed"
	default:
		panic("missing String() impl for ProjectPlanStatus")
	}
}

// WorkflowHookCommandContext defines the context for a pre and post worklfow_hooks that will
// be executed before workflows.
type WorkflowHookCommandContext struct {
	// BaseRepo is the repository that the pull request will be merged into.
	BaseRepo Repo
	// HeadRepo is the repository that is getting merged into the BaseRepo.
	// If the pull request branch is from the same repository then HeadRepo will
	// be the same as BaseRepo.
	HeadRepo Repo
	// Log is a logger that's been set up for this context.
	Log logging.SimpleLogging
	// Pull is the pull request we're responding to.
	Pull PullRequest
	// User is the user that triggered this command.
	User User
	// Verbose is true when the user would like verbose output.
	Verbose bool
}
