String APP_NAME = 'Coda'
List<String> AWS_REGIONS = ['us-east-1']
String ECR_ACCOUNT_ID = '086679231553'
String GITHUB_REPOSITORY = 'ows-coda'
String SEARCH_IMAGE_NAME = 'ows-coda-search'
String RUNNER_IMAGE_NAME = 'ows-coda-runner'
String PLATFORM_IMAGE_NAME = 'ows-coda-platform'
String LANGFUSE_WEB_IMAGE_NAME = 'ows-coda-langfuse-web'
String LANGFUSE_WORKER_IMAGE_NAME = 'ows-coda-langfuse-worker'
String SLACK_NOTIFICATIONS_CHANNEL = '#coda-devs'
String QA_ACCOUNT_ID = '437795906767'
String QA_DEPLOYMENT_ROLE = 'prod-jenkins-aws-pipeline-agent'
String QA_SECRETS_ROLE = 'qa-ows-coda-jenkins-secrets-role'

// Auth0
String QA_AUTH0_CLIENT_ID = 'x937kb4f5c3hJRGazmCm5NDrx0hVk8eg'
List<String> VULNERABILITIES_TO_IGNORE = []

pipeline {
    agent any

    options {
        ansiColor('xterm')
        disableConcurrentBuilds(abortPrevious: true)
        timestamps()
    }

    parameters {
        string(name: 'SHARED_LIBRARIES_VERSION', defaultValue: 'master', description: 'The version of the Jenkins shared libraries to use. Can be a branch, tag or Git revision.')
        string(name: 'FORCE_SERVICES', defaultValue: '', description: 'Comma-separated list of services to force-build, skipping change detection. Values: server, platform, search, runner, langfuse, all. Example: "server,search"')
        booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: 'Break glass: skip unit, integration, and functional test stages. Flip to true only for emergency deploys.')
    }

    triggers {
        issueCommentTrigger('(?m)^\\s*retest this please\\s*$')
    }

    stages {
        stage('Load Shared Libraries') {
            steps {
                library "jenkins-global-libraries@${params.SHARED_LIBRARIES_VERSION}"
            }
        }
        stage('Detect Changes and Static Checks') {
            parallel {
                stage('Detect Changes') {
                    steps {
                        script {
                            // ── Manual override: FORCE_SERVICES parameter ──
                            // Comma-separated service names (server, platform, search, runner,
                            // langfuse, all) bypass change detection entirely. Useful for
                            // debugging CI or when detection got it wrong.
                            if (params.FORCE_SERVICES?.trim()) {
                                def forced = params.FORCE_SERVICES.split(',').collect { it.trim().toLowerCase() }
                                def all = forced.contains('all')
                                env.PNPM_FILTER = ''  // empty = run all packages
                                env.SERVER_CHANGED   = (all || forced.contains('server'))   ? 'true' : 'false'
                                env.PLATFORM_CHANGED = (all || forced.contains('platform')) ? 'true' : 'false'
                                env.SEARCH_CHANGED   = (all || forced.contains('search'))   ? 'true' : 'false'
                                env.RUNNER_CHANGED   = (all || forced.contains('runner'))   ? 'true' : 'false'
                                env.LANGFUSE_CHANGED = (all || forced.contains('langfuse')) ? 'true' : 'false'
                                echo "FORCE_SERVICES override: ${params.FORCE_SERVICES}"
                                echo "SERVER_CHANGED=${env.SERVER_CHANGED}, PLATFORM_CHANGED=${env.PLATFORM_CHANGED}, SEARCH_CHANGED=${env.SEARCH_CHANGED}, RUNNER_CHANGED=${env.RUNNER_CHANGED}, LANGFUSE_CHANGED=${env.LANGFUSE_CHANGED}"
                                return  // skip detection
                            }

                            // ── PNPM_FILTER: graph-aware selective testing ──
                            // Computes --filter flags for every package that changed since the
                            // base ref, PLUS all of their transitive dependents.
                            // The filter is passed into Docker via env var; when empty (no
                            // --filter flags) pnpm runs across ALL packages — safe default.
                            //
                            // Base ref: master uses HEAD~1 (squash merge = full PR scope),
                            // PRs use origin/<target> if available, otherwise HEAD~1.
                            env.DIFF_BASE = env.CHANGE_TARGET ? "origin/${env.CHANGE_TARGET}" : 'HEAD~1'

                            def detectOutput = sh(
                                script: '''
                                    bash scripts/detect-changes.sh || { echo "WARNING: detect-changes.sh failed; falling back to full build" >&2; printf 'PNPM_FILTER=\nSERVER_CHANGED=true\nPLATFORM_CHANGED=true\nSEARCH_CHANGED=true\nRUNNER_CHANGED=true\nLANGFUSE_CHANGED=true\n'; }
                                ''',
                                returnStdout: true
                            ).trim()

                            detectOutput.split('\n').each { line ->
                                if (line.startsWith('PNPM_FILTER=')) {
                                    env.PNPM_FILTER = line.substring('PNPM_FILTER='.length())
                                } else if (line.startsWith('SERVER_CHANGED=')) {
                                    env.SERVER_CHANGED = line.substring('SERVER_CHANGED='.length())
                                } else if (line.startsWith('PLATFORM_CHANGED=')) {
                                    env.PLATFORM_CHANGED = line.substring('PLATFORM_CHANGED='.length())
                                } else if (line.startsWith('SEARCH_CHANGED=')) {
                                    env.SEARCH_CHANGED = line.substring('SEARCH_CHANGED='.length())
                                } else if (line.startsWith('RUNNER_CHANGED=')) {
                                    env.RUNNER_CHANGED = line.substring('RUNNER_CHANGED='.length())
                                } else if (line.startsWith('LANGFUSE_CHANGED=')) {
                                    env.LANGFUSE_CHANGED = line.substring('LANGFUSE_CHANGED='.length())
                                }
                            }

                            echo "PNPM_FILTER=${env.PNPM_FILTER}"
                            echo "SERVER_CHANGED=${env.SERVER_CHANGED}, PLATFORM_CHANGED=${env.PLATFORM_CHANGED}, SEARCH_CHANGED=${env.SEARCH_CHANGED}, RUNNER_CHANGED=${env.RUNNER_CHANGED}, LANGFUSE_CHANGED=${env.LANGFUSE_CHANGED}"
                        }
                    }
                }
                stage('Compliance Checks') {
                    steps {
                        complianceChecks()
                    }
                }
                stage('Validate Software Catalog Definition') {
                    steps {
                        datadogSoftwareCatalogValidate()
                    }
                }
                stage('Static Application Security Tests') {
                    steps {
                        sastTests()
                    }
                }
                stage('Validate Workspace Sync') {
                    steps {
                        sh 'bash scripts/validate-workspace-sync.sh'
                    }
                }
            }
        }
        stage('Scan and Lint+Unit Tests') {
            parallel {
                stage('Sonar Scan and Analysis') {
                    steps {
                        echo "Running Sonar scan for ${APP_NAME}"
                        sonarScan project: APP_NAME, language: 'ts'
                    }
                }
                stage('Unit Tests') {
                    when { expression { return !params.SKIP_TESTS } }
                    steps {
                        withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN'),]) {
                            withEcr {
                                sh """
                                PNPM_FILTER='${env.PNPM_FILTER}' \
                                docker compose --project-name coda-test-unit \
                                --profile test-unit up --build \
                                --exit-code-from lint-and-test \
                                --abort-on-container-exit \
                                --remove-orphans \
                                lint-and-test
                                """
                            }
                        }
                    }
                    post {
                        always {
                            sh 'docker compose --project-name coda-test-unit down --volumes --remove-orphans || true'
                        }
                    }
                }
            }
        }
        stage('Build Client for QA') {
            when {
                allOf {
                    expression { env.SERVER_CHANGED == 'true' }
                    anyOf {
                        branch 'master'
                        expression { env.GITHUB_COMMENT =~ 'build docker' }
                        expression { pullRequest.labels.contains('build docker') }
                    }
                }
            }
            steps {
                withSecrets(awsAccountId: QA_ACCOUNT_ID, awsRole: QA_SECRETS_ROLE, secrets: [
                    [id: 'qa/ows-coda/SENTRY_AUTH_TOKEN', environmentVariable: 'SENTRY_AUTH_TOKEN'],
                    [id: 'qa/ows-coda/SENTRY_DSN', environmentVariable: 'SENTRY_DSN'],
                ]) {
                    suiteAppBuild(
                        env: 'qa',
                        detectPackageManager: true,
                        scriptNames: ['build:libs', 'build:client'],
                        envVars: [
                            AUTH0_AUDIENCE: 'https://workstation.qaorch.com/api',
                            AUTH0_CLIENT_ID: QA_AUTH0_CLIENT_ID,
                            AUTH0_DOMAIN: 'qalogin.theorchard.com',
                            SENTRY_AUTH_TOKEN: env.SENTRY_AUTH_TOKEN,
                            SENTRY_DSN: env.SENTRY_DSN,
                            SENTRY_ORG: 'the-orchard',
                            SENTRY_PROJECT: 'qa-ows-coda',
                        ]
                    )
                }
            }
        }
        stage('Create a release') {
            when {
                anyOf {
                    branch 'master'
                    expression { env.GITHUB_COMMENT =~ 'build docker' }
                    expression { pullRequest.labels.contains('build docker') }
                }
            }
            parallel {
                stage('Server') {
                    when { expression { return env.SERVER_CHANGED == 'true' } }
                    steps {
                        withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN')]) {
                            dockerToEcr awsRegions: AWS_REGIONS, ecrAccountId: ECR_ACCOUNT_ID, imageName: GITHUB_REPOSITORY,
                                imageTag: env.GIT_COMMIT, dockerBuildSecrets: [[id: 'GITHUB_NPM_TOKEN', env: 'GITHUB_NPM_TOKEN']], dockerBuildTarget: 'deploy',
                                additionalContexts: ['client-dist': "${env.WORKSPACE}/apps/client/dist"]
                        }
                    }
                }
                stage('Search') {
                    when { expression { return env.SEARCH_CHANGED == 'true' } }
                    steps {
                        withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN')]) {
                            dockerToEcr awsRegions: AWS_REGIONS, ecrAccountId: ECR_ACCOUNT_ID, imageName: SEARCH_IMAGE_NAME,
                                imageTag: env.GIT_COMMIT, dockerBuildSecrets: [[id: 'GITHUB_NPM_TOKEN', env: 'GITHUB_NPM_TOKEN']],
                                dockerBuildTarget: 'deploy-search', dockerBuildFile: 'apps/search/Dockerfile'
                        }
                    }
                }
                stage('Runner') {
                    when { expression { return env.RUNNER_CHANGED == 'true' } }
                    steps {
                        withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN')]) {
                            dockerToEcr awsRegions: AWS_REGIONS, ecrAccountId: ECR_ACCOUNT_ID, imageName: RUNNER_IMAGE_NAME,
                                imageTag: env.GIT_COMMIT, dockerBuildSecrets: [[id: 'GITHUB_NPM_TOKEN', env: 'GITHUB_NPM_TOKEN']],
                                dockerBuildTarget: 'deploy-runner', dockerBuildFile: 'apps/runner/Dockerfile'
                        }
                    }
                }
                stage('Platform') {
                    when { expression { return env.PLATFORM_CHANGED == 'true' } }
                    steps {
                        withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN')]) {
                            dockerToEcr awsRegions: AWS_REGIONS, ecrAccountId: ECR_ACCOUNT_ID, imageName: PLATFORM_IMAGE_NAME,
                                imageTag: env.GIT_COMMIT, dockerBuildSecrets: [[id: 'GITHUB_NPM_TOKEN', env: 'GITHUB_NPM_TOKEN']],
                                dockerBuildTarget: 'deploy-platform', dockerBuildFile: 'apps/platform/Dockerfile'
                        }
                    }
                }
                stage('Langfuse Web') {
                    when { expression { return env.LANGFUSE_CHANGED == 'true' } }
                    steps {
                        dockerToEcr awsRegions: AWS_REGIONS, ecrAccountId: ECR_ACCOUNT_ID, imageName: LANGFUSE_WEB_IMAGE_NAME,
                            imageTag: env.GIT_COMMIT, dockerBuildFile: 'apps/langfuse/Dockerfile.web'
                    }
                }
                stage('Langfuse Worker') {
                    when { expression { return env.LANGFUSE_CHANGED == 'true' } }
                    steps {
                        dockerToEcr awsRegions: AWS_REGIONS, ecrAccountId: ECR_ACCOUNT_ID, imageName: LANGFUSE_WORKER_IMAGE_NAME,
                            imageTag: env.GIT_COMMIT, dockerBuildFile: 'apps/langfuse/Dockerfile.worker'
                    }
                }
            }
        }
        // Advisory-only scan — failBuild:false means CVEs are logged but don't
        // block deployment. Set failBuild:true and populate VULNERABILITIES_TO_IGNORE
        // with accepted CVEs once the team is ready to enforce.
        stage('Scan Docker Image') {
            when {
                branch 'master'
            }
            parallel {
                stage('Server') {
                    when { expression { return env.SERVER_CHANGED == 'true' } }
                    steps {
                        dockerScan awsRegion: AWS_REGIONS[0], ecrAccountId: ECR_ACCOUNT_ID, imageName: GITHUB_REPOSITORY, imageTag: env.GIT_COMMIT,
                            vulnerabilitiesToIgnore: VULNERABILITIES_TO_IGNORE, failBuild: false
                    }
                }
                stage('Search') {
                    when { expression { return env.SEARCH_CHANGED == 'true' } }
                    steps {
                        dockerScan awsRegion: AWS_REGIONS[0], ecrAccountId: ECR_ACCOUNT_ID, imageName: SEARCH_IMAGE_NAME, imageTag: env.GIT_COMMIT,
                            vulnerabilitiesToIgnore: VULNERABILITIES_TO_IGNORE, failBuild: false
                    }
                }
                stage('Runner') {
                    when { expression { return env.RUNNER_CHANGED == 'true' } }
                    steps {
                        dockerScan awsRegion: AWS_REGIONS[0], ecrAccountId: ECR_ACCOUNT_ID, imageName: RUNNER_IMAGE_NAME, imageTag: env.GIT_COMMIT,
                            vulnerabilitiesToIgnore: VULNERABILITIES_TO_IGNORE, failBuild: false
                    }
                }
                stage('Platform') {
                    when { expression { return env.PLATFORM_CHANGED == 'true' } }
                    steps {
                        dockerScan awsRegion: AWS_REGIONS[0], ecrAccountId: ECR_ACCOUNT_ID, imageName: PLATFORM_IMAGE_NAME, imageTag: env.GIT_COMMIT,
                            vulnerabilitiesToIgnore: VULNERABILITIES_TO_IGNORE, failBuild: false
                    }
                }
                stage('Langfuse Web') {
                    when { expression { return env.LANGFUSE_CHANGED == 'true' } }
                    steps {
                        dockerScan awsRegion: AWS_REGIONS[0], ecrAccountId: ECR_ACCOUNT_ID, imageName: LANGFUSE_WEB_IMAGE_NAME, imageTag: env.GIT_COMMIT,
                            vulnerabilitiesToIgnore: VULNERABILITIES_TO_IGNORE, failBuild: false
                    }
                }
                stage('Langfuse Worker') {
                    when { expression { return env.LANGFUSE_CHANGED == 'true' } }
                    steps {
                        dockerScan awsRegion: AWS_REGIONS[0], ecrAccountId: ECR_ACCOUNT_ID, imageName: LANGFUSE_WORKER_IMAGE_NAME, imageTag: env.GIT_COMMIT,
                            vulnerabilitiesToIgnore: VULNERABILITIES_TO_IGNORE, failBuild: false
                    }
                }
            }
        }
        // On master (and build-docker PRs), integration tests use the release
        // images from ECR to validate the exact artifacts that will be deployed.
        // On regular PRs, they fall back to dev-built images.
        stage('Integration Tests') {
            when { expression { return !params.SKIP_TESTS } }
            steps {
                withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN'),]) {
                    withEcr {
                        script {
                            def isMaster = env.BRANCH_NAME == 'master'
                            def isBuildDocker = (env.GITHUB_COMMENT ?: '') =~ 'build docker'
                            try { isBuildDocker = isBuildDocker || pullRequest.labels.contains('build docker') } catch (ignored) {}
                            def useRelease = (isMaster || isBuildDocker) && env.SERVER_CHANGED == 'true' && env.PLATFORM_CHANGED == 'true'

                            def profile = useRelease ? 'test-integration-release' : 'test-integration'
                            def exitService = useRelease ? 'test-integration-release' : 'test-integration'
                            def imageEnv = useRelease
                                ? "SERVER_IMAGE='${ECR_ACCOUNT_ID}.dkr.ecr.${AWS_REGIONS[0]}.amazonaws.com/${GITHUB_REPOSITORY}:${env.GIT_COMMIT}' " +
                                  "PLATFORM_IMAGE='${ECR_ACCOUNT_ID}.dkr.ecr.${AWS_REGIONS[0]}.amazonaws.com/${PLATFORM_IMAGE_NAME}:${env.GIT_COMMIT}'"
                                : ''

                            echo "Integration tests: profile=${profile}, useRelease=${useRelease}"

                            sh """
                            PNPM_FILTER='${env.PNPM_FILTER}' \
                            ${imageEnv} \
                            MYSQL_HOST_PORT=0 S3_HOST_PORT=0 S3_CONSOLE_HOST_PORT=0 \
                            docker compose --env-file .env.docker.test --project-name coda-test-integration \
                            --profile ${profile} up --build \
                            --exit-code-from ${exitService} \
                            --abort-on-container-exit \
                            --remove-orphans \
                            ${exitService}
                            """
                        }
                    }
                }
            }
            post {
                always {
                    sh 'docker compose --project-name coda-test-integration down --volumes --remove-orphans || true'
                }
            }
        }
        stage('Functional Tests') {
            when { expression { return !params.SKIP_TESTS } }
            steps {
                withCredentials([string(credentialsId: 'github_packages_token', variable: 'GITHUB_NPM_TOKEN'),]) {
                    withEcr {
                        sh """
                        PNPM_FILTER='${env.PNPM_FILTER}' \
                        MYSQL_HOST_PORT=0 S3_HOST_PORT=0 S3_CONSOLE_HOST_PORT=0 \
                        docker compose --env-file .env.docker.test --project-name coda-test-functional \
                        --profile test-functional up --build \
                        --exit-code-from test-functional \
                        --abort-on-container-exit \
                        --remove-orphans \
                        test-functional
                        """
                    }
                }
            }
            post {
                always {
                    sh 'docker compose --project-name coda-test-functional down --volumes --remove-orphans || true'
                }
            }
        }
        stage('Deploy to QA') {
            when {
                branch 'master'
            }
            parallel {
                stage('Server') {
                    when { expression { return env.SERVER_CHANGED == 'true' } }
                    steps {
                        fargateDeploy environment: 'qa', awsRegions: AWS_REGIONS, gitCommit: env.GIT_COMMIT, serviceName: GITHUB_REPOSITORY,
                            ecrRegistryAccountId: ECR_ACCOUNT_ID, awsDeploymentTargetAccountId: QA_ACCOUNT_ID, awsDeploymentRoleName: QA_DEPLOYMENT_ROLE
                    }
                }
                stage('Search') {
                    when { expression { return env.SEARCH_CHANGED == 'true' } }
                    steps {
                        fargateDeploy environment: 'qa', awsRegions: AWS_REGIONS, gitCommit: env.GIT_COMMIT, serviceName: SEARCH_IMAGE_NAME,
                            ecrRegistryAccountId: ECR_ACCOUNT_ID, awsDeploymentTargetAccountId: QA_ACCOUNT_ID, awsDeploymentRoleName: QA_DEPLOYMENT_ROLE,
                            updateTimeout: 720, verifyMode: 'TASK_RUNNING'
                    }
                }
                stage('Runner') {
                    when { expression { return env.RUNNER_CHANGED == 'true' } }
                    steps {
                        fargateDeploy environment: 'qa', awsRegions: AWS_REGIONS, gitCommit: env.GIT_COMMIT, serviceName: RUNNER_IMAGE_NAME,
                            ecrRegistryAccountId: ECR_ACCOUNT_ID, awsDeploymentTargetAccountId: QA_ACCOUNT_ID, awsDeploymentRoleName: QA_DEPLOYMENT_ROLE,
                            updateTimeout: 300, verifyMode: 'TASK_RUNNING'
                    }
                }
                stage('Platform') {
                    when { expression { return env.PLATFORM_CHANGED == 'true' } }
                    steps {
                        fargateDeploy environment: 'qa', awsRegions: AWS_REGIONS, gitCommit: env.GIT_COMMIT, serviceName: PLATFORM_IMAGE_NAME,
                            ecrRegistryAccountId: ECR_ACCOUNT_ID, awsDeploymentTargetAccountId: QA_ACCOUNT_ID, awsDeploymentRoleName: QA_DEPLOYMENT_ROLE,
                            updateTimeout: 300, verifyMode: 'TASK_RUNNING'
                    }
                }
                stage('Langfuse Web') {
                    when { expression { return env.LANGFUSE_CHANGED == 'true' } }
                    steps {
                        fargateDeploy environment: 'qa', awsRegions: AWS_REGIONS, gitCommit: env.GIT_COMMIT, serviceName: LANGFUSE_WEB_IMAGE_NAME,
                            ecrRegistryAccountId: ECR_ACCOUNT_ID, awsDeploymentTargetAccountId: QA_ACCOUNT_ID, awsDeploymentRoleName: QA_DEPLOYMENT_ROLE,
                            updateTimeout: 300, verifyMode: 'TASK_RUNNING'
                    }
                }
                stage('Langfuse Worker') {
                    when { expression { return env.LANGFUSE_CHANGED == 'true' } }
                    steps {
                        fargateDeploy environment: 'qa', awsRegions: AWS_REGIONS, gitCommit: env.GIT_COMMIT, serviceName: LANGFUSE_WORKER_IMAGE_NAME,
                            ecrRegistryAccountId: ECR_ACCOUNT_ID, awsDeploymentTargetAccountId: QA_ACCOUNT_ID, awsDeploymentRoleName: QA_DEPLOYMENT_ROLE,
                            updateTimeout: 300, verifyMode: 'TASK_RUNNING'
                    }
                }
            }
        }
    }
    post {
        regression {
            script {
                if (env.BRANCH_NAME == 'master') {
                    slackNotify channel: SLACK_NOTIFICATIONS_CHANNEL
                }
            }
        }
        fixed {
            script {
                if (env.BRANCH_NAME == 'master') {
                    slackNotify channel: SLACK_NOTIFICATIONS_CHANNEL
                }
            }
        }
        cleanup {
            cleanWs()
        }
    }
}
