// 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 server handles the web server and executing commands that come in
// via webhooks.
package server

import (
	"context"
	"crypto/tls"
	"embed"
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"os/signal"
	"path/filepath"
	"slices"
	"sort"
	"strings"
	"syscall"
	"time"

	"github.com/go-playground/validator/v10"
	"github.com/mitchellh/go-homedir"
	tally "github.com/uber-go/tally/v4"
	prometheus "github.com/uber-go/tally/v4/prometheus"
	"github.com/urfave/negroni/v3"

	cfg "github.com/runatlantis/atlantis/server/core/config"
	"github.com/runatlantis/atlantis/server/core/config/valid"
	"github.com/runatlantis/atlantis/server/core/db"
	"github.com/runatlantis/atlantis/server/core/redis"
	"github.com/runatlantis/atlantis/server/core/terraform/tfclient"
	"github.com/runatlantis/atlantis/server/jobs"
	"github.com/runatlantis/atlantis/server/metrics"
	"github.com/runatlantis/atlantis/server/scheduled"

	"github.com/gorilla/mux"
	"github.com/pkg/errors"
	"github.com/runatlantis/atlantis/server/controllers"
	events_controllers "github.com/runatlantis/atlantis/server/controllers/events"
	"github.com/runatlantis/atlantis/server/controllers/web_templates"
	"github.com/runatlantis/atlantis/server/controllers/websocket"
	"github.com/runatlantis/atlantis/server/core/locking"
	"github.com/runatlantis/atlantis/server/core/runtime"
	"github.com/runatlantis/atlantis/server/core/runtime/policy"
	"github.com/runatlantis/atlantis/server/core/terraform"
	"github.com/runatlantis/atlantis/server/events"
	"github.com/runatlantis/atlantis/server/events/command"
	"github.com/runatlantis/atlantis/server/events/models"
	"github.com/runatlantis/atlantis/server/events/vcs"
	"github.com/runatlantis/atlantis/server/events/vcs/bitbucketcloud"
	"github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver"
	"github.com/runatlantis/atlantis/server/events/vcs/gitea"
	"github.com/runatlantis/atlantis/server/events/webhooks"
	"github.com/runatlantis/atlantis/server/logging"
)

const (
	// LockViewRouteName is the named route in mux.Router for the lock view.
	// The route can be retrieved by this name, ex:
	//   mux.Router.Get(LockViewRouteName)
	LockViewRouteName = "lock-detail"
	// LockViewRouteIDQueryParam is the query parameter needed to construct the lock view
	// route. ex:
	//   mux.Router.Get(LockViewRouteName).URL(LockViewRouteIDQueryParam, "my id")
	LockViewRouteIDQueryParam = "id"
	// ProjectJobsViewRouteName is the named route in mux.Router for the log stream view.
	ProjectJobsViewRouteName = "project-jobs-detail"
	// binDirName is the name of the directory inside our data dir where
	// we download binaries.
	BinDirName = "bin"
	// terraformPluginCacheDir is the name of the dir inside our data dir
	// where we tell terraform to cache plugins and modules.
	TerraformPluginCacheDirName = "plugin-cache"
)

// Server runs the Atlantis web server.
type Server struct {
	AtlantisVersion                string
	AtlantisURL                    *url.URL
	Router                         *mux.Router
	Port                           int
	PostWorkflowHooksCommandRunner *events.DefaultPostWorkflowHooksCommandRunner
	PreWorkflowHooksCommandRunner  *events.DefaultPreWorkflowHooksCommandRunner
	CommandRunner                  *events.DefaultCommandRunner
	Logger                         logging.SimpleLogging
	StatsScope                     tally.Scope
	StatsReporter                  tally.BaseStatsReporter
	StatsCloser                    io.Closer
	Locker                         locking.Locker
	ApplyLocker                    locking.ApplyLocker
	VCSEventsController            *events_controllers.VCSEventsController
	GithubAppController            *controllers.GithubAppController
	LocksController                *controllers.LocksController
	StatusController               *controllers.StatusController
	JobsController                 *controllers.JobsController
	APIController                  *controllers.APIController
	IndexTemplate                  web_templates.TemplateWriter
	LockDetailTemplate             web_templates.TemplateWriter
	ProjectJobsTemplate            web_templates.TemplateWriter
	ProjectJobsErrorTemplate       web_templates.TemplateWriter
	SSLCertFile                    string
	SSLKeyFile                     string
	CertLastRefreshTime            time.Time
	KeyLastRefreshTime             time.Time
	SSLCert                        *tls.Certificate
	Drainer                        *events.Drainer
	WebAuthentication              bool
	WebUsername                    string
	WebPassword                    string
	ProjectCmdOutputHandler        jobs.ProjectCommandOutputHandler
	ScheduledExecutorService       *scheduled.ExecutorService
	DisableGlobalApplyLock         bool
}

// Config holds config for server that isn't passed in by the user.
type Config struct {
	AllowForkPRsFlag          string
	AtlantisURLFlag           string
	AtlantisVersion           string
	DefaultTFDistributionFlag string
	DefaultTFVersionFlag      string
	RepoConfigJSONFlag        string
	SilenceForkPRErrorsFlag   string
}

// WebhookConfig is nested within UserConfig. It's used to configure webhooks.
type WebhookConfig struct {
	// Event is the type of event we should send this webhook for, ex. apply.
	Event string `mapstructure:"event"`
	// WorkspaceRegex is a regex that is used to match against the workspace
	// that is being modified for this event. If the regex matches, we'll
	// send the webhook, ex. "production.*".
	WorkspaceRegex string `mapstructure:"workspace-regex"`
	// BranchRegex is a regex that is used to match against the base branch
	// that is being modified for this event. If the regex matches, we'll
	// send the webhook, ex. "main.*".
	BranchRegex string `mapstructure:"branch-regex"`
	// Kind is the type of webhook we should send, ex. slack or http.
	Kind string `mapstructure:"kind"`
	// Channel is the channel to send this webhook to. It only applies to
	// slack webhooks. Should be without '#'.
	Channel string `mapstructure:"channel"`
	// URL is the URL where to deliver this webhook. It only applies to
	// http webhooks.
	URL string `mapstructure:"url"`
}

//go:embed static
var staticAssets embed.FS

// NewServer returns a new server. If there are issues starting the server or
// its dependencies an error will be returned. This is like the main() function
// for the server CLI command because it injects all the dependencies.
func NewServer(userConfig UserConfig, config Config) (*Server, error) {
	logging.SuppressDefaultLogging()
	logger, err := logging.NewStructuredLoggerFromLevel(userConfig.ToLogLevel())

	if err != nil {
		return nil, err
	}

	var supportedVCSHosts []models.VCSHostType
	var githubClient vcs.IGithubClient
	var githubAppEnabled bool
	var githubConfig vcs.GithubConfig
	var githubCredentials vcs.GithubCredentials
	var gitlabClient *vcs.GitlabClient
	var bitbucketCloudClient *bitbucketcloud.Client
	var bitbucketServerClient *bitbucketserver.Client
	var azuredevopsClient *vcs.AzureDevopsClient
	var giteaClient *gitea.GiteaClient

	policyChecksEnabled := false
	if userConfig.EnablePolicyChecksFlag {
		logger.Info("Policy Checks are enabled")
		policyChecksEnabled = true
	}

	allowCommands, err := userConfig.ToAllowCommandNames()
	if err != nil {
		return nil, err
	}
	disableApply := true
	for _, allowCommand := range allowCommands {
		if allowCommand == command.Apply {
			disableApply = false
			break
		}
	}

	parserValidator := &cfg.ParserValidator{}

	globalCfg := valid.NewGlobalCfgFromArgs(
		valid.GlobalCfgArgs{
			PolicyCheckEnabled: userConfig.EnablePolicyChecksFlag,
		})
	if userConfig.RepoConfig != "" {
		globalCfg, err = parserValidator.ParseGlobalCfg(userConfig.RepoConfig, globalCfg)
		if err != nil {
			return nil, errors.Wrapf(err, "parsing %s file", userConfig.RepoConfig)
		}
	} else if userConfig.RepoConfigJSON != "" {
		globalCfg, err = parserValidator.ParseGlobalCfgJSON(userConfig.RepoConfigJSON, globalCfg)
		if err != nil {
			return nil, errors.Wrapf(err, "parsing --%s", config.RepoConfigJSONFlag)
		}
	}

	statsScope, statsReporter, closer, err := metrics.NewScope(globalCfg.Metrics, logger, userConfig.StatsNamespace)

	if err != nil {
		return nil, errors.Wrapf(err, "instantiating metrics scope")
	}

	if userConfig.GithubUser != "" || userConfig.GithubAppID != 0 {
		if userConfig.GithubAllowMergeableBypassApply {
			githubConfig = vcs.GithubConfig{
				AllowMergeableBypassApply: true,
			}
		}
		supportedVCSHosts = append(supportedVCSHosts, models.Github)
		if userConfig.GithubUser != "" {
			githubCredentials = &vcs.GithubUserCredentials{
				User:      userConfig.GithubUser,
				Token:     userConfig.GithubToken,
				TokenFile: userConfig.GithubTokenFile,
			}
		} else if userConfig.GithubAppID != 0 && userConfig.GithubAppKeyFile != "" {
			privateKey, err := os.ReadFile(userConfig.GithubAppKeyFile)
			if err != nil {
				return nil, err
			}
			githubCredentials = &vcs.GithubAppCredentials{
				AppID:          userConfig.GithubAppID,
				InstallationID: userConfig.GithubAppInstallationID,
				Key:            privateKey,
				Hostname:       userConfig.GithubHostname,
				AppSlug:        userConfig.GithubAppSlug,
			}
			githubAppEnabled = true
		} else if userConfig.GithubAppID != 0 && userConfig.GithubAppKey != "" {
			githubCredentials = &vcs.GithubAppCredentials{
				AppID:          userConfig.GithubAppID,
				InstallationID: userConfig.GithubAppInstallationID,
				Key:            []byte(userConfig.GithubAppKey),
				Hostname:       userConfig.GithubHostname,
				AppSlug:        userConfig.GithubAppSlug,
			}
			githubAppEnabled = true
		}

		var err error
		rawGithubClient, err := vcs.NewGithubClient(userConfig.GithubHostname, githubCredentials, githubConfig, userConfig.MaxCommentsPerCommand, logger)
		if err != nil {
			return nil, err
		}

		githubClient = vcs.NewInstrumentedGithubClient(rawGithubClient, statsScope, logger)
	}
	if userConfig.GitlabUser != "" {
		supportedVCSHosts = append(supportedVCSHosts, models.Gitlab)
		var err error

		gitlabGroupAllowlistChecker, err := command.NewTeamAllowlistChecker(userConfig.GitlabGroupAllowlist)
		if err != nil {
			return nil, err
		}

		gitlabGroups := slices.Concat(gitlabGroupAllowlistChecker.AllTeams(), globalCfg.PolicySets.AllTeams())
		slices.Sort(gitlabGroups)
		gitlabClient, err = vcs.NewGitlabClient(userConfig.GitlabHostname, userConfig.GitlabToken, slices.Compact(gitlabGroups), logger)
		if err != nil {
			return nil, err
		}
	}
	if userConfig.BitbucketUser != "" {
		if userConfig.BitbucketBaseURL == bitbucketcloud.BaseURL {
			supportedVCSHosts = append(supportedVCSHosts, models.BitbucketCloud)
			bitbucketCloudClient = bitbucketcloud.NewClient(
				http.DefaultClient,
				userConfig.BitbucketUser,
				userConfig.BitbucketToken,
				userConfig.AtlantisURL)
		} else {
			supportedVCSHosts = append(supportedVCSHosts, models.BitbucketServer)
			var err error
			bitbucketServerClient, err = bitbucketserver.NewClient(
				http.DefaultClient,
				userConfig.BitbucketUser,
				userConfig.BitbucketToken,
				userConfig.BitbucketBaseURL,
				userConfig.AtlantisURL)
			if err != nil {
				return nil, errors.Wrapf(err, "setting up Bitbucket Server client")
			}
		}
	}
	if userConfig.AzureDevopsUser != "" {
		supportedVCSHosts = append(supportedVCSHosts, models.AzureDevops)

		var err error
		azuredevopsClient, err = vcs.NewAzureDevopsClient(userConfig.AzureDevOpsHostname, userConfig.AzureDevopsUser, userConfig.AzureDevopsToken)
		if err != nil {
			return nil, err
		}
	}
	if userConfig.GiteaToken != "" {
		supportedVCSHosts = append(supportedVCSHosts, models.Gitea)

		giteaClient, err = gitea.NewClient(userConfig.GiteaBaseURL, userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaPageSize, logger)
		if err != nil {
			fmt.Println("error setting up gitea client", "error", err)
			return nil, errors.Wrapf(err, "setting up Gitea client")
		} else {
			logger.Info("gitea client configured successfully")
		}
	}

	var supportedVCSHostsStr []string
	for _, host := range supportedVCSHosts {
		supportedVCSHostsStr = append(supportedVCSHostsStr, host.String())
	}

	logger.Info("Supported VCS Hosts: %s", strings.Join(supportedVCSHostsStr, ", "))

	home, err := homedir.Dir()
	if err != nil {
		return nil, errors.Wrap(err, "getting home dir to write ~/.git-credentials file")
	}

	if userConfig.WriteGitCreds {
		if userConfig.GithubUser != "" {
			if err := vcs.WriteGitCreds(userConfig.GithubUser, userConfig.GithubToken, userConfig.GithubHostname, home, logger, false); err != nil {
				return nil, err
			}
		}
		if userConfig.GitlabUser != "" {
			if err := vcs.WriteGitCreds(userConfig.GitlabUser, userConfig.GitlabToken, userConfig.GitlabHostname, home, logger, false); err != nil {
				return nil, err
			}
		}
		if userConfig.BitbucketUser != "" {
			// The default BitbucketBaseURL is https://api.bitbucket.org which can't actually be used for git
			// so we override it here only if it's that to be bitbucket.org
			bitbucketBaseURL := userConfig.BitbucketBaseURL
			if bitbucketBaseURL == "https://api.bitbucket.org" {
				bitbucketBaseURL = "bitbucket.org"
			}
			if err := vcs.WriteGitCreds(userConfig.BitbucketUser, userConfig.BitbucketToken, bitbucketBaseURL, home, logger, false); err != nil {
				return nil, err
			}
		}
		if userConfig.AzureDevopsUser != "" {
			if err := vcs.WriteGitCreds(userConfig.AzureDevopsUser, userConfig.AzureDevopsToken, "dev.azure.com", home, logger, false); err != nil {
				return nil, err
			}
		}
		if userConfig.GiteaUser != "" {
			if err := vcs.WriteGitCreds(userConfig.GiteaUser, userConfig.GiteaToken, userConfig.GiteaBaseURL, home, logger, false); err != nil {
				return nil, err
			}
		}
	}

	// default the project files used to generate the module index to the autoplan-file-list if autoplan-modules is true
	// but no files are specified
	if userConfig.AutoplanModules && userConfig.AutoplanModulesFromProjects == "" {
		userConfig.AutoplanModulesFromProjects = userConfig.AutoplanFileList
	}

	var webhooksConfig []webhooks.Config
	for _, c := range userConfig.Webhooks {
		config := webhooks.Config{
			Channel:        c.Channel,
			BranchRegex:    c.BranchRegex,
			Event:          c.Event,
			Kind:           c.Kind,
			WorkspaceRegex: c.WorkspaceRegex,
			URL:            c.URL,
		}
		webhooksConfig = append(webhooksConfig, config)
	}
	webhookHeaders, err := userConfig.ToWebhookHttpHeaders()
	if err != nil {
		return nil, errors.Wrap(err, "parsing webhook http headers")
	}
	webhooksManager, err := webhooks.NewMultiWebhookSender(
		webhooksConfig,
		webhooks.Clients{
			Slack: webhooks.NewSlackClient(userConfig.SlackToken),
			Http:  &webhooks.HttpClient{Client: http.DefaultClient, Headers: webhookHeaders},
		},
	)
	if err != nil {
		return nil, errors.Wrap(err, "initializing webhooks")
	}
	vcsClient := vcs.NewClientProxy(githubClient, gitlabClient, bitbucketCloudClient, bitbucketServerClient, azuredevopsClient, giteaClient)
	commitStatusUpdater := &events.DefaultCommitStatusUpdater{Client: vcsClient, StatusName: userConfig.VCSStatusName}

	binDir, err := mkSubDir(userConfig.DataDir, BinDirName)

	if err != nil {
		return nil, err
	}

	cacheDir, err := mkSubDir(userConfig.DataDir, TerraformPluginCacheDirName)

	if err != nil {
		return nil, err
	}

	parsedURL, err := ParseAtlantisURL(userConfig.AtlantisURL)
	if err != nil {
		return nil, errors.Wrapf(err, "parsing --%s flag %q", config.AtlantisURLFlag, userConfig.AtlantisURL)
	}

	underlyingRouter := mux.NewRouter()
	router := &Router{
		AtlantisURL:               parsedURL,
		LockViewRouteIDQueryParam: LockViewRouteIDQueryParam,
		LockViewRouteName:         LockViewRouteName,
		ProjectJobsViewRouteName:  ProjectJobsViewRouteName,
		Underlying:                underlyingRouter,
	}

	var projectCmdOutputHandler jobs.ProjectCommandOutputHandler

	if userConfig.TFEToken != "" && !userConfig.TFELocalExecutionMode {
		// When TFE is enabled and using remote execution mode log streaming is not necessary.
		projectCmdOutputHandler = &jobs.NoopProjectOutputHandler{}
	} else {
		projectCmdOutput := make(chan *jobs.ProjectCmdOutputLine)
		projectCmdOutputHandler = jobs.NewAsyncProjectCommandOutputHandler(
			projectCmdOutput,
			logger,
		)
	}

	distribution := terraform.NewDistribution(userConfig.DefaultTFDistribution)

	terraformClient, err := tfclient.NewClient(
		logger,
		distribution,
		binDir,
		cacheDir,
		userConfig.TFEToken,
		userConfig.TFEHostname,
		userConfig.DefaultTFVersion,
		config.DefaultTFVersionFlag,
		userConfig.TFDownloadURL,
		userConfig.TFDownload,
		userConfig.UseTFPluginCache,
		projectCmdOutputHandler)
	// The flag.Lookup call is to detect if we're running in a unit test. If we
	// are, then we don't error out because we don't have/want terraform
	// installed on our CI system where the unit tests run.
	if err != nil && flag.Lookup("test.v") == nil {
		return nil, errors.Wrap(err, fmt.Sprintf("initializing %s", userConfig.DefaultTFDistribution))
	}
	markdownRenderer := events.NewMarkdownRenderer(
		gitlabClient.SupportsCommonMark(),
		userConfig.DisableApplyAll,
		disableApply,
		userConfig.DisableMarkdownFolding,
		userConfig.DisableRepoLocking,
		userConfig.EnableDiffMarkdownFormat,
		userConfig.MarkdownTemplateOverridesDir,
		userConfig.ExecutableName,
		userConfig.HideUnchangedPlanComments,
		userConfig.QuietPolicyChecks,
	)

	var lockingClient locking.Locker
	var applyLockingClient locking.ApplyLocker
	var backend locking.Backend

	switch dbtype := userConfig.LockingDBType; dbtype {
	case "redis":
		logger.Info("Utilizing Redis DB")
		backend, err = redis.New(userConfig.RedisHost, userConfig.RedisPort, userConfig.RedisPassword, userConfig.RedisTLSEnabled, userConfig.RedisInsecureSkipVerify, userConfig.RedisDB)
		if err != nil {
			return nil, err
		}
	case "boltdb":
		logger.Info("Utilizing BoltDB")
		backend, err = db.New(userConfig.DataDir)
		if err != nil {
			return nil, err
		}
	}

	noOpLocker := locking.NewNoOpLocker()
	if userConfig.DisableRepoLocking {
		logger.Info("Repo Locking is disabled")
		lockingClient = noOpLocker
	} else {
		lockingClient = locking.NewClient(backend)
	}
	disableGlobalApplyLock := false
	if userConfig.DisableGlobalApplyLock {
		disableGlobalApplyLock = true
	}

	applyLockingClient = locking.NewApplyClient(backend, disableApply, disableGlobalApplyLock)
	workingDirLocker := events.NewDefaultWorkingDirLocker()

	var workingDir events.WorkingDir = &events.FileWorkspace{
		DataDir:          userConfig.DataDir,
		CheckoutMerge:    userConfig.CheckoutStrategy == "merge",
		CheckoutDepth:    userConfig.CheckoutDepth,
		GithubAppEnabled: githubAppEnabled,
	}

	scheduledExecutorService := scheduled.NewExecutorService(
		statsScope,
		logger,
	)

	// provide fresh tokens before clone from the GitHub Apps integration, proxy workingDir
	if githubAppEnabled {
		if !userConfig.WriteGitCreds {
			return nil, errors.New("Github App requires --write-git-creds to support cloning")
		}
		workingDir = &events.GithubAppWorkingDir{
			WorkingDir:     workingDir,
			Credentials:    githubCredentials,
			GithubHostname: userConfig.GithubHostname,
		}

		githubAppTokenRotator := vcs.NewGithubTokenRotator(logger, githubCredentials, userConfig.GithubHostname, "x-access-token", home)
		tokenJd, err := githubAppTokenRotator.GenerateJob()
		if err != nil {
			return nil, errors.Wrap(err, "could not write credentials")
		}
		scheduledExecutorService.AddJob(tokenJd)
	}

	if userConfig.GithubUser != "" && userConfig.GithubTokenFile != "" && userConfig.WriteGitCreds {
		githubTokenRotator := vcs.NewGithubTokenRotator(logger, githubCredentials, userConfig.GithubHostname, userConfig.GithubUser, home)
		tokenJd, err := githubTokenRotator.GenerateJob()
		if err != nil {
			return nil, errors.Wrap(err, "could not write credentials")
		}
		scheduledExecutorService.AddJob(tokenJd)
	}

	projectLocker := &events.DefaultProjectLocker{
		Locker:     lockingClient,
		NoOpLocker: noOpLocker,
		VCSClient:  vcsClient,
	}
	deleteLockCommand := &events.DefaultDeleteLockCommand{
		Locker:           lockingClient,
		WorkingDir:       workingDir,
		WorkingDirLocker: workingDirLocker,
		Backend:          backend,
	}

	pullClosedExecutor := events.NewInstrumentedPullClosedExecutor(
		statsScope,
		logger,
		&events.PullClosedExecutor{
			Locker:                   lockingClient,
			WorkingDir:               workingDir,
			Backend:                  backend,
			PullClosedTemplate:       &events.PullClosedEventTemplate{},
			LogStreamResourceCleaner: projectCmdOutputHandler,
			VCSClient:                vcsClient,
		},
	)

	eventParser := &events.EventParser{
		GithubUser:         userConfig.GithubUser,
		GithubToken:        userConfig.GithubToken,
		GithubTokenFile:    userConfig.GithubTokenFile,
		GitlabUser:         userConfig.GitlabUser,
		GitlabToken:        userConfig.GitlabToken,
		GiteaUser:          userConfig.GiteaUser,
		GiteaToken:         userConfig.GiteaToken,
		AllowDraftPRs:      userConfig.PlanDrafts,
		BitbucketUser:      userConfig.BitbucketUser,
		BitbucketToken:     userConfig.BitbucketToken,
		BitbucketServerURL: userConfig.BitbucketBaseURL,
		AzureDevopsUser:    userConfig.AzureDevopsUser,
		AzureDevopsToken:   userConfig.AzureDevopsToken,
	}
	commentParser := events.NewCommentParser(
		userConfig.GithubUser,
		userConfig.GitlabUser,
		userConfig.GiteaUser,
		userConfig.BitbucketUser,
		userConfig.AzureDevopsUser,
		userConfig.ExecutableName,
		allowCommands,
	)
	defaultTfDistribution := terraformClient.DefaultDistribution()
	defaultTfVersion := terraformClient.DefaultVersion()
	pendingPlanFinder := &events.DefaultPendingPlanFinder{}
	runStepRunner := &runtime.RunStepRunner{
		TerraformExecutor:       terraformClient,
		DefaultTFDistribution:   defaultTfDistribution,
		DefaultTFVersion:        defaultTfVersion,
		TerraformBinDir:         terraformClient.TerraformBinDir(),
		ProjectCmdOutputHandler: projectCmdOutputHandler,
	}
	drainer := &events.Drainer{}
	statusController := &controllers.StatusController{
		Logger:          logger,
		Drainer:         drainer,
		AtlantisVersion: config.AtlantisVersion,
	}
	preWorkflowHooksCommandRunner := &events.DefaultPreWorkflowHooksCommandRunner{
		VCSClient:        vcsClient,
		GlobalCfg:        globalCfg,
		WorkingDirLocker: workingDirLocker,
		WorkingDir:       workingDir,
		PreWorkflowHookRunner: runtime.DefaultPreWorkflowHookRunner{
			OutputHandler: projectCmdOutputHandler,
		},
		CommitStatusUpdater: commitStatusUpdater,
		Router:              router,
	}
	postWorkflowHooksCommandRunner := &events.DefaultPostWorkflowHooksCommandRunner{
		VCSClient:        vcsClient,
		GlobalCfg:        globalCfg,
		WorkingDirLocker: workingDirLocker,
		WorkingDir:       workingDir,
		PostWorkflowHookRunner: runtime.DefaultPostWorkflowHookRunner{
			OutputHandler: projectCmdOutputHandler,
		},
		CommitStatusUpdater: commitStatusUpdater,
		Router:              router,
	}
	projectCommandBuilder := events.NewInstrumentedProjectCommandBuilder(
		logger,
		policyChecksEnabled,
		parserValidator,
		&events.DefaultProjectFinder{},
		vcsClient,
		workingDir,
		workingDirLocker,
		globalCfg,
		pendingPlanFinder,
		commentParser,
		userConfig.SkipCloneNoChanges,
		userConfig.EnableRegExpCmd,
		userConfig.Automerge,
		userConfig.ParallelPlan,
		userConfig.ParallelApply,
		userConfig.AutoplanModulesFromProjects,
		userConfig.AutoplanFileList,
		userConfig.RestrictFileList,
		userConfig.SilenceNoProjects,
		userConfig.IncludeGitUntrackedFiles,
		userConfig.AutoDiscoverModeFlag,
		statsScope,
		terraformClient,
	)

	showStepRunner, err := runtime.NewShowStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion)

	if err != nil {
		return nil, errors.Wrap(err, "initializing show step runner")
	}

	policyCheckStepRunner, err := runtime.NewPolicyCheckStepRunner(
		defaultTfDistribution,
		defaultTfVersion,
		policy.NewConfTestExecutorWorkflow(logger, binDir, &policy.ConfTestGoGetterVersionDownloader{}),
	)

	if err != nil {
		return nil, errors.Wrap(err, "initializing policy check step runner")
	}

	applyRequirementHandler := &events.DefaultCommandRequirementHandler{
		WorkingDir: workingDir,
	}

	projectCommandRunner := &events.DefaultProjectCommandRunner{
		VcsClient:        vcsClient,
		Locker:           projectLocker,
		LockURLGenerator: router,
		Logger:           logger,
		InitStepRunner: &runtime.InitStepRunner{
			TerraformExecutor:     terraformClient,
			DefaultTFDistribution: defaultTfDistribution,
			DefaultTFVersion:      defaultTfVersion,
		},
		PlanStepRunner:        runtime.NewPlanStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion, commitStatusUpdater, terraformClient),
		ShowStepRunner:        showStepRunner,
		PolicyCheckStepRunner: policyCheckStepRunner,
		ApplyStepRunner: &runtime.ApplyStepRunner{
			TerraformExecutor:     terraformClient,
			DefaultTFDistribution: defaultTfDistribution,
			DefaultTFVersion:      defaultTfVersion,
			CommitStatusUpdater:   commitStatusUpdater,
			AsyncTFExec:           terraformClient,
		},
		RunStepRunner: runStepRunner,
		EnvStepRunner: &runtime.EnvStepRunner{
			RunStepRunner: runStepRunner,
		},
		MultiEnvStepRunner: &runtime.MultiEnvStepRunner{
			RunStepRunner: runStepRunner,
		},
		VersionStepRunner: &runtime.VersionStepRunner{
			TerraformExecutor: terraformClient,
			DefaultTFVersion:  defaultTfVersion,
		},
		ImportStepRunner:          runtime.NewImportStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion),
		StateRmStepRunner:         runtime.NewStateRmStepRunner(terraformClient, defaultTfDistribution, defaultTfVersion),
		WorkingDir:                workingDir,
		Webhooks:                  webhooksManager,
		WorkingDirLocker:          workingDirLocker,
		CommandRequirementHandler: applyRequirementHandler,
	}

	dbUpdater := &events.DBUpdater{
		Backend: backend,
	}

	pullUpdater := &events.PullUpdater{
		HidePrevPlanComments: userConfig.HidePrevPlanComments,
		VCSClient:            vcsClient,
		MarkdownRenderer:     markdownRenderer,
	}

	autoMerger := &events.AutoMerger{
		VCSClient:       vcsClient,
		GlobalAutomerge: userConfig.Automerge,
	}

	projectOutputWrapper := &events.ProjectOutputWrapper{
		JobMessageSender:     projectCmdOutputHandler,
		ProjectCommandRunner: projectCommandRunner,
		JobURLSetter:         jobs.NewJobURLSetter(router, commitStatusUpdater),
	}
	instrumentedProjectCmdRunner := events.NewInstrumentedProjectCommandRunner(
		statsScope,
		projectOutputWrapper,
	)

	policyCheckCommandRunner := events.NewPolicyCheckCommandRunner(
		dbUpdater,
		pullUpdater,
		commitStatusUpdater,
		instrumentedProjectCmdRunner,
		userConfig.ParallelPoolSize,
		userConfig.SilenceVCSStatusNoProjects,
		userConfig.QuietPolicyChecks,
	)

	pullReqStatusFetcher := vcs.NewPullReqStatusFetcher(vcsClient, userConfig.VCSStatusName, strings.Split(userConfig.IgnoreVCSStatusNames, ","))
	planCommandRunner := events.NewPlanCommandRunner(
		userConfig.SilenceVCSStatusNoPlans,
		userConfig.SilenceVCSStatusNoProjects,
		vcsClient,
		pendingPlanFinder,
		workingDir,
		commitStatusUpdater,
		projectCommandBuilder,
		instrumentedProjectCmdRunner,
		dbUpdater,
		pullUpdater,
		policyCheckCommandRunner,
		autoMerger,
		userConfig.ParallelPoolSize,
		userConfig.SilenceNoProjects,
		backend,
		lockingClient,
		userConfig.DiscardApprovalOnPlanFlag,
		pullReqStatusFetcher,
	)

	applyCommandRunner := events.NewApplyCommandRunner(
		vcsClient,
		userConfig.DisableApplyAll,
		applyLockingClient,
		commitStatusUpdater,
		projectCommandBuilder,
		instrumentedProjectCmdRunner,
		autoMerger,
		pullUpdater,
		dbUpdater,
		backend,
		userConfig.ParallelPoolSize,
		userConfig.SilenceNoProjects,
		userConfig.SilenceVCSStatusNoProjects,
		pullReqStatusFetcher,
	)

	approvePoliciesCommandRunner := events.NewApprovePoliciesCommandRunner(
		commitStatusUpdater,
		projectCommandBuilder,
		instrumentedProjectCmdRunner,
		pullUpdater,
		dbUpdater,
		userConfig.SilenceNoProjects,
		userConfig.SilenceVCSStatusNoPlans,
		vcsClient,
	)

	unlockCommandRunner := events.NewUnlockCommandRunner(
		deleteLockCommand,
		vcsClient,
		userConfig.SilenceNoProjects,
		userConfig.DisableUnlockLabel,
	)

	versionCommandRunner := events.NewVersionCommandRunner(
		pullUpdater,
		projectCommandBuilder,
		projectOutputWrapper,
		userConfig.ParallelPoolSize,
		userConfig.SilenceNoProjects,
	)

	importCommandRunner := events.NewImportCommandRunner(
		pullUpdater,
		pullReqStatusFetcher,
		projectCommandBuilder,
		instrumentedProjectCmdRunner,
		userConfig.SilenceNoProjects,
	)

	stateCommandRunner := events.NewStateCommandRunner(
		pullUpdater,
		projectCommandBuilder,
		instrumentedProjectCmdRunner,
	)

	commentCommandRunnerByCmd := map[command.Name]events.CommentCommandRunner{
		command.Plan:            planCommandRunner,
		command.Apply:           applyCommandRunner,
		command.ApprovePolicies: approvePoliciesCommandRunner,
		command.Unlock:          unlockCommandRunner,
		command.Version:         versionCommandRunner,
		command.Import:          importCommandRunner,
		command.State:           stateCommandRunner,
	}

	var teamAllowlistChecker command.TeamAllowlistChecker
	if globalCfg.TeamAuthz.Command != "" {
		teamAllowlistChecker = &events.ExternalTeamAllowlistChecker{
			Command:                     globalCfg.TeamAuthz.Command,
			ExtraArgs:                   globalCfg.TeamAuthz.Args,
			ExternalTeamAllowlistRunner: &runtime.DefaultExternalTeamAllowlistRunner{},
		}
	} else if userConfig.GitlabUser != "" {
		teamAllowlistChecker, err = command.NewTeamAllowlistChecker(userConfig.GitlabGroupAllowlist)
		if err != nil {
			return nil, err
		}
	} else {
		teamAllowlistChecker, err = command.NewTeamAllowlistChecker(userConfig.GithubTeamAllowlist)
		if err != nil {
			return nil, err
		}
	}

	varFileAllowlistChecker, err := events.NewVarFileAllowlistChecker(userConfig.VarFileAllowlist)
	if err != nil {
		return nil, err
	}

	commandRunner := &events.DefaultCommandRunner{
		VCSClient:                      vcsClient,
		GithubPullGetter:               githubClient,
		GitlabMergeRequestGetter:       gitlabClient,
		AzureDevopsPullGetter:          azuredevopsClient,
		GiteaPullGetter:                giteaClient,
		CommentCommandRunnerByCmd:      commentCommandRunnerByCmd,
		EventParser:                    eventParser,
		FailOnPreWorkflowHookError:     userConfig.FailOnPreWorkflowHookError,
		Logger:                         logger,
		GlobalCfg:                      globalCfg,
		StatsScope:                     statsScope.SubScope("cmd"),
		AllowForkPRs:                   userConfig.AllowForkPRs,
		AllowForkPRsFlag:               config.AllowForkPRsFlag,
		SilenceForkPRErrors:            userConfig.SilenceForkPRErrors,
		SilenceForkPRErrorsFlag:        config.SilenceForkPRErrorsFlag,
		DisableAutoplan:                userConfig.DisableAutoplan,
		DisableAutoplanLabel:           userConfig.DisableAutoplanLabel,
		Drainer:                        drainer,
		PreWorkflowHooksCommandRunner:  preWorkflowHooksCommandRunner,
		PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner,
		PullStatusFetcher:              backend,
		TeamAllowlistChecker:           teamAllowlistChecker,
		VarFileAllowlistChecker:        varFileAllowlistChecker,
		CommitStatusUpdater:            commitStatusUpdater,
	}
	repoAllowlist, err := events.NewRepoAllowlistChecker(userConfig.RepoAllowlist)
	if err != nil {
		return nil, err
	}
	locksController := &controllers.LocksController{
		AtlantisVersion:    config.AtlantisVersion,
		AtlantisURL:        parsedURL,
		Locker:             lockingClient,
		ApplyLocker:        applyLockingClient,
		Logger:             logger,
		VCSClient:          vcsClient,
		LockDetailTemplate: web_templates.LockTemplate,
		WorkingDir:         workingDir,
		WorkingDirLocker:   workingDirLocker,
		Backend:            backend,
		DeleteLockCommand:  deleteLockCommand,
	}

	wsMux := websocket.NewMultiplexor(
		logger,
		controllers.JobIDKeyGenerator{},
		projectCmdOutputHandler,
		userConfig.WebsocketCheckOrigin,
	)

	jobsController := &controllers.JobsController{
		AtlantisVersion:          config.AtlantisVersion,
		AtlantisURL:              parsedURL,
		Logger:                   logger,
		ProjectJobsTemplate:      web_templates.ProjectJobsTemplate,
		ProjectJobsErrorTemplate: web_templates.ProjectJobsErrorTemplate,
		Backend:                  backend,
		WsMux:                    wsMux,
		KeyGenerator:             controllers.JobIDKeyGenerator{},
		StatsScope:               statsScope.SubScope("api"),
	}

	apiController := &controllers.APIController{
		APISecret:                      []byte(userConfig.APISecret),
		Locker:                         lockingClient,
		Logger:                         logger,
		Parser:                         eventParser,
		ProjectCommandBuilder:          projectCommandBuilder,
		ProjectPlanCommandRunner:       instrumentedProjectCmdRunner,
		ProjectApplyCommandRunner:      instrumentedProjectCmdRunner,
		FailOnPreWorkflowHookError:     userConfig.FailOnPreWorkflowHookError,
		PreWorkflowHooksCommandRunner:  preWorkflowHooksCommandRunner,
		PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner,
		RepoAllowlistChecker:           repoAllowlist,
		Scope:                          statsScope.SubScope("api"),
		VCSClient:                      vcsClient,
		WorkingDir:                     workingDir,
		WorkingDirLocker:               workingDirLocker,
		CommitStatusUpdater:            commitStatusUpdater,
	}

	eventsController := &events_controllers.VCSEventsController{
		CommandRunner:                   commandRunner,
		PullCleaner:                     pullClosedExecutor,
		Parser:                          eventParser,
		CommentParser:                   commentParser,
		Logger:                          logger,
		Scope:                           statsScope,
		ApplyDisabled:                   disableApply,
		GithubWebhookSecret:             []byte(userConfig.GithubWebhookSecret),
		GithubRequestValidator:          &events_controllers.DefaultGithubRequestValidator{},
		GitlabRequestParserValidator:    &events_controllers.DefaultGitlabRequestParserValidator{},
		GitlabWebhookSecret:             []byte(userConfig.GitlabWebhookSecret),
		RepoAllowlistChecker:            repoAllowlist,
		SilenceAllowlistErrors:          userConfig.SilenceAllowlistErrors,
		EmojiReaction:                   userConfig.EmojiReaction,
		ExecutableName:                  userConfig.ExecutableName,
		SupportedVCSHosts:               supportedVCSHosts,
		VCSClient:                       vcsClient,
		BitbucketWebhookSecret:          []byte(userConfig.BitbucketWebhookSecret),
		AzureDevopsWebhookBasicUser:     []byte(userConfig.AzureDevopsWebhookUser),
		AzureDevopsWebhookBasicPassword: []byte(userConfig.AzureDevopsWebhookPassword),
		AzureDevopsRequestValidator:     &events_controllers.DefaultAzureDevopsRequestValidator{},
		GiteaWebhookSecret:              []byte(userConfig.GiteaWebhookSecret),
	}
	githubAppController := &controllers.GithubAppController{
		AtlantisURL:         parsedURL,
		Logger:              logger,
		GithubSetupComplete: githubAppEnabled,
		GithubHostname:      userConfig.GithubHostname,
		GithubOrg:           userConfig.GithubOrg,
	}

	server := &Server{
		AtlantisVersion:                config.AtlantisVersion,
		AtlantisURL:                    parsedURL,
		Router:                         underlyingRouter,
		Port:                           userConfig.Port,
		PostWorkflowHooksCommandRunner: postWorkflowHooksCommandRunner,
		PreWorkflowHooksCommandRunner:  preWorkflowHooksCommandRunner,
		CommandRunner:                  commandRunner,
		Logger:                         logger,
		StatsScope:                     statsScope,
		StatsReporter:                  statsReporter,
		StatsCloser:                    closer,
		Locker:                         lockingClient,
		ApplyLocker:                    applyLockingClient,
		VCSEventsController:            eventsController,
		GithubAppController:            githubAppController,
		LocksController:                locksController,
		JobsController:                 jobsController,
		StatusController:               statusController,
		APIController:                  apiController,
		IndexTemplate:                  web_templates.IndexTemplate,
		LockDetailTemplate:             web_templates.LockTemplate,
		ProjectJobsTemplate:            web_templates.ProjectJobsTemplate,
		ProjectJobsErrorTemplate:       web_templates.ProjectJobsErrorTemplate,
		SSLKeyFile:                     userConfig.SSLKeyFile,
		SSLCertFile:                    userConfig.SSLCertFile,
		DisableGlobalApplyLock:         userConfig.DisableGlobalApplyLock,
		Drainer:                        drainer,
		ProjectCmdOutputHandler:        projectCmdOutputHandler,
		WebAuthentication:              userConfig.WebBasicAuth,
		WebUsername:                    userConfig.WebUsername,
		WebPassword:                    userConfig.WebPassword,
		ScheduledExecutorService:       scheduledExecutorService,
	}

	validate := validator.New(validator.WithRequiredStructEnabled())

	err = validate.Struct(server)
	if err != nil {
		return nil, err
	} else {
		return server, nil
	}
}

// Start creates the routes and starts serving traffic.
func (s *Server) Start() error {
	s.Router.HandleFunc("/", s.Index).Methods("GET").MatcherFunc(func(r *http.Request, rm *mux.RouteMatch) bool {
		return r.URL.Path == "/" || r.URL.Path == "/index.html"
	})
	s.Router.HandleFunc("/healthz", s.Healthz).Methods("GET")
	s.Router.HandleFunc("/status", s.StatusController.Get).Methods("GET")
	s.Router.PathPrefix("/static/").Handler(http.FileServer(http.FS(staticAssets)))
	s.Router.HandleFunc("/events", s.VCSEventsController.Post).Methods("POST")
	s.Router.HandleFunc("/api/plan", s.APIController.Plan).Methods("POST")
	s.Router.HandleFunc("/api/apply", s.APIController.Apply).Methods("POST")
	s.Router.HandleFunc("/api/locks", s.APIController.ListLocks).Methods("GET")
	s.Router.HandleFunc("/github-app/exchange-code", s.GithubAppController.ExchangeCode).Methods("GET")
	s.Router.HandleFunc("/github-app/setup", s.GithubAppController.New).Methods("GET")
	s.Router.HandleFunc("/locks", s.LocksController.DeleteLock).Methods("DELETE").Queries("id", "{id:.*}")
	s.Router.HandleFunc("/lock", s.LocksController.GetLock).Methods("GET").
		Queries(LockViewRouteIDQueryParam, fmt.Sprintf("{%s}", LockViewRouteIDQueryParam)).Name(LockViewRouteName)
	s.Router.HandleFunc("/jobs/{job-id}", s.JobsController.GetProjectJobs).Methods("GET").Name(ProjectJobsViewRouteName)
	s.Router.HandleFunc("/jobs/{job-id}/ws", s.JobsController.GetProjectJobsWS).Methods("GET")

	r, ok := s.StatsReporter.(prometheus.Reporter)
	if ok {
		s.Router.Handle(s.CommandRunner.GlobalCfg.Metrics.Prometheus.Endpoint, r.HTTPHandler())
	}
	if !s.DisableGlobalApplyLock {
		s.Router.HandleFunc("/apply/lock", s.LocksController.LockApply).Methods("POST").Queries()
		s.Router.HandleFunc("/apply/unlock", s.LocksController.UnlockApply).Methods("DELETE").Queries()
	}

	n := negroni.New(&negroni.Recovery{
		Logger:     log.New(os.Stdout, "", log.LstdFlags),
		PrintStack: false,
		StackAll:   false,
		StackSize:  1024 * 8,
	}, NewRequestLogger(s))
	n.UseHandler(s.Router)

	defer s.Logger.Flush()

	// Ensure server gracefully drains connections when stopped.
	stop := make(chan os.Signal, 1)
	// Stop on SIGINTs and SIGTERMs.
	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)

	go s.ScheduledExecutorService.Run()

	go func() {
		s.ProjectCmdOutputHandler.Handle()
	}()

	tlsConfig := &tls.Config{GetCertificate: s.GetSSLCertificate, MinVersion: tls.VersionTLS12}

	server := &http.Server{Addr: fmt.Sprintf(":%d", s.Port), Handler: n, TLSConfig: tlsConfig, ReadHeaderTimeout: 10 * time.Second}
	go func() {
		s.Logger.Info("Atlantis started - listening on port %v", s.Port)

		var err error
		if s.SSLCertFile != "" && s.SSLKeyFile != "" {
			err = server.ListenAndServeTLS("", "")
		} else {
			err = server.ListenAndServe()
		}

		if err != nil && err != http.ErrServerClosed {
			s.Logger.Err(err.Error())
		}
	}()
	<-stop

	s.Logger.Warn("Received interrupt. Waiting for in-progress operations to complete")
	s.waitForDrain()

	// flush stats before shutdown
	if err := s.StatsCloser.Close(); err != nil {
		s.Logger.Err(err.Error())
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := server.Shutdown(ctx); err != nil {
		return fmt.Errorf("while shutting down: %s", err)
	}
	return nil
}

// waitForDrain blocks until draining is complete.
func (s *Server) waitForDrain() {
	drainComplete := make(chan bool, 1)
	go func() {
		s.Drainer.ShutdownBlocking()
		drainComplete <- true
	}()
	ticker := time.NewTicker(5 * time.Second)
	for {
		select {
		case <-drainComplete:
			s.Logger.Info("All in-progress operations complete, shutting down")
			return
		case <-ticker.C:
			s.Logger.Info("Waiting for in-progress operations to complete, current in-progress ops: %d", s.Drainer.GetStatus().InProgressOps)
		}
	}
}

// Index is the / route.
func (s *Server) Index(w http.ResponseWriter, _ *http.Request) {
	locks, err := s.Locker.List()
	if err != nil {
		w.WriteHeader(http.StatusServiceUnavailable)
		fmt.Fprintf(w, "Could not retrieve locks: %s", err)
		return
	}

	var lockResults []web_templates.LockIndexData
	for id, v := range locks {
		lockURL, _ := s.Router.Get(LockViewRouteName).URL("id", url.QueryEscape(id))
		lockResults = append(lockResults, web_templates.LockIndexData{
			// NOTE: must use .String() instead of .Path because we need the
			// query params as part of the lock URL.
			LockPath:      lockURL.String(),
			RepoFullName:  v.Project.RepoFullName,
			LockedBy:      v.Pull.Author,
			PullNum:       v.Pull.Num,
			Path:          v.Project.Path,
			Workspace:     v.Workspace,
			Time:          v.Time,
			TimeFormatted: v.Time.Format("2006-01-02 15:04:05"),
		})
	}

	applyCmdLock, err := s.ApplyLocker.CheckApplyLock()
	s.Logger.Debug("Apply Lock: %v", applyCmdLock)
	if err != nil {
		w.WriteHeader(http.StatusServiceUnavailable)
		fmt.Fprintf(w, "Could not retrieve global apply lock: %s", err)
		return
	}

	applyLockData := web_templates.ApplyLockData{
		Time:                   applyCmdLock.Time,
		Locked:                 applyCmdLock.Locked,
		GlobalApplyLockEnabled: applyCmdLock.GlobalApplyLockEnabled,
		TimeFormatted:          applyCmdLock.Time.Format("2006-01-02 15:04:05"),
	}
	//Sort by date - newest to oldest.
	sort.SliceStable(lockResults, func(i, j int) bool { return lockResults[i].Time.After(lockResults[j].Time) })

	err = s.IndexTemplate.Execute(w, web_templates.IndexData{
		Locks:            lockResults,
		PullToJobMapping: preparePullToJobMappings(s),
		ApplyLock:        applyLockData,
		AtlantisVersion:  s.AtlantisVersion,
		CleanedBasePath:  s.AtlantisURL.Path,
	})
	if err != nil {
		s.Logger.Err(err.Error())
	}
}

func preparePullToJobMappings(s *Server) []jobs.PullInfoWithJobIDs {

	pullToJobMappings := s.ProjectCmdOutputHandler.GetPullToJobMapping()

	for i := range pullToJobMappings {
		for j := range pullToJobMappings[i].JobIDInfos {
			jobUrl, _ := s.Router.Get(ProjectJobsViewRouteName).URL("job-id", pullToJobMappings[i].JobIDInfos[j].JobID)
			pullToJobMappings[i].JobIDInfos[j].JobIDUrl = jobUrl.String()
			pullToJobMappings[i].JobIDInfos[j].TimeFormatted = pullToJobMappings[i].JobIDInfos[j].Time.Format("2006-01-02 15:04:05")
		}

		//Sort by date - newest to oldest.
		sort.SliceStable(pullToJobMappings[i].JobIDInfos, func(x, y int) bool {
			return pullToJobMappings[i].JobIDInfos[x].Time.After(pullToJobMappings[i].JobIDInfos[y].Time)
		})
	}

	//Sort by repository, project, path, workspace then date.
	sort.SliceStable(pullToJobMappings, func(x, y int) bool {
		if pullToJobMappings[x].Pull.RepoFullName != pullToJobMappings[y].Pull.RepoFullName {
			return pullToJobMappings[x].Pull.RepoFullName < pullToJobMappings[y].Pull.RepoFullName
		}
		if pullToJobMappings[x].Pull.ProjectName != pullToJobMappings[y].Pull.ProjectName {
			return pullToJobMappings[x].Pull.ProjectName < pullToJobMappings[y].Pull.ProjectName
		}
		if pullToJobMappings[x].Pull.Path != pullToJobMappings[y].Pull.Path {
			return pullToJobMappings[x].Pull.Path < pullToJobMappings[y].Pull.Path
		}
		return pullToJobMappings[x].Pull.Workspace < pullToJobMappings[y].Pull.Workspace
	})

	return pullToJobMappings
}

func mkSubDir(parentDir string, subDir string) (string, error) {
	fullDir := filepath.Join(parentDir, subDir)
	if err := os.MkdirAll(fullDir, 0700); err != nil {
		return "", errors.Wrapf(err, "unable to create dir %q", fullDir)
	}

	return fullDir, nil
}

// Healthz returns the health check response. It always returns a 200 currently.
func (s *Server) Healthz(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.Write(healthzData) // nolint: errcheck
}

var healthzData = []byte(`{
  "status": "ok"
}`)

func (s *Server) GetSSLCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) {
	certStat, err := os.Stat(s.SSLCertFile)
	if err != nil {
		return nil, fmt.Errorf("while getting cert file modification time: %w", err)
	}

	keyStat, err := os.Stat(s.SSLKeyFile)
	if err != nil {
		return nil, fmt.Errorf("while getting key file modification time: %w", err)
	}

	if s.SSLCert == nil || certStat.ModTime() != s.CertLastRefreshTime || keyStat.ModTime() != s.KeyLastRefreshTime {
		cert, err := tls.LoadX509KeyPair(s.SSLCertFile, s.SSLKeyFile)
		if err != nil {
			return nil, fmt.Errorf("while loading tls cert: %w", err)
		}

		s.SSLCert = &cert
		s.CertLastRefreshTime = certStat.ModTime()
		s.KeyLastRefreshTime = keyStat.ModTime()
	}
	return s.SSLCert, nil
}

// ParseAtlantisURL parses the user-passed atlantis URL to ensure it is valid
// and we can use it in our templates.
// It removes any trailing slashes from the path so we can concatenate it
// with other paths without checking.
func ParseAtlantisURL(u string) (*url.URL, error) {
	parsed, err := url.Parse(u)
	if err != nil {
		return nil, err
	}
	if !(parsed.Scheme == "http" || parsed.Scheme == "https") {
		return nil, errors.New("http or https must be specified")
	}
	// We want the path to end without a trailing slash so we know how to
	// use it in the rest of the program.
	parsed.Path = strings.TrimSuffix(parsed.Path, "/")
	return parsed, nil
}
