package com.sonymusic

import java.nio.file.Files
import java.nio.file.Paths

/**
 * Utility class for handling common monorepo functionality in Jenkins pipelines.
 */
class MonorepoUtils implements Serializable {
    static instance = null

    /**
     * Jenkins pipeline steps context.
     */
    Script steps

    /**
     * Used to specify a base path under which to automatically discover projects.
     */
    String projectBasePath

    /**
     * List of projects to exclude when automatically discovering projects via projectBasePath.
     */
    List<String> excludedProjects

    /**
     * Override the projects to build instead of determining them based on SCM changes.
     */
    Set<String> projectsToBuild

    /**
     * Map of project paths to arbitrary project-specific configuration.
     */
    Map<String, Object> projectConfig = [:]

    /**
     * Configures whether project paths should be treated as relative to projectBasePath
     */
    boolean useRelativePaths = true

    /**
     * Map of project dependencies where the key is the dependency path and the value is a list of regex patterns
     * matching projects that depend on that dependency.
     */
    Map<String, List<String>> projectDependencies = [:]

    /**
     * Get the singleton instance of MonorepoUtils.
     * @param args Map of arguments to initialize the instance.
     * @return MonorepoUtils instance.
     */
    static getInstance(Map args) {
        def steps = args.steps

        if (steps == null) {
            throw new IllegalArgumentException("[${MonorepoUtils.class.simpleName}]: 'steps' is not defined.")
        }

        steps.lock("MonorepoUtils-${steps.env.BUILD_TAG}") {
            if (instance == null) {
                instance = new MonorepoUtils(args)
                instance.setup()
            }
        }

        return instance
    }

    /**
     * Determines projects to build based on the provided configuration and SCM changes.
     */
    def setup() {
        if (projectsToBuild == null) {
            if (projectBasePath == null && projectConfig.isEmpty()) {
                throw new IllegalArgumentException("[${MonorepoUtils.class.simpleName}]: Either 'projectBasePath' or 'projectConfig' must be provided")
            }

            projectsToBuild = []

            // Allocate a Jenkins node to determine the modified projects if not provided
            steps.node {
                log("Determining modified projects...")
                steps.cleanWs()
                def scmVars = steps.checkout(steps.scm)

                def projectsToCheck = []

                if (projectBasePath) {
                    // If project base path is provided, automatically find all projects under that path
                    projectsToCheck += discoverProjects()
                }

                // Add in any additional projects from the provided project configuration map
                projectsToCheck += projectConfig.keySet()
                // Remove duplicates
                projectsToCheck = projectsToCheck.collect { it.toString() } as Set

                def modifiedPaths = steps.getModifiedPaths branchName: scmVars.BRANCH_NAME,
                    baseCommit: scmVars.GIT_PREVIOUS_SUCCESSFUL_COMMIT

                projectsToBuild += getModifiedProjects(projectsToCheck, modifiedPaths)

                if (projectDependencies) {
                    projectsToBuild += getProjectsWithModifiedDependencies(projectsToCheck, modifiedPaths)
                }
            }
        }
        else if ('*' in projectsToBuild && projectBasePath) {
            steps.node {
                steps.cleanWs()
                def scmVars = steps.checkout(steps.scm)
                projectsToBuild = discoverProjects().collect { relativizeProjectPath(it) }
            }
        }

        if (projectsToBuild) {
            projectsToBuild = projectsToBuild.sort()
            log("Projects to build: ${projectsToBuild}")
            steps.currentBuild.description = projectsToBuild.join('<br>')
        } else {
            log("No modified projects detected.")
        }
    }

    /**
     * Execute the provided steps for each modified project in parallel.
     *
     * @param args Map of arguments for customizing the context in which the steps are run.
     * @param stepsToRun Closure containing the steps to execute for each modified project.
     */
    def withModifiedProjects(Map args = [:], Closure stepsToRun) {
        def params = Utils.validateParams(MonorepoUtils.class.simpleName, args, [
            checkout: [type: Boolean, required: false, defaultValue: false],
            allocateAgent: [type: Boolean, required: false, defaultValue: true],
            agentLabel: [type: String, required: false],
        ])

        if (!params.allocateAgent && params.checkout) {
            throw new IllegalArgumentException("[${MonorepoUtils.class.simpleName}]: 'checkout' cannot be true when 'allocateAgent' is false.")
        }

        if (stepsToRun.maximumNumberOfParameters == 1) {
            // Adapt to single-parameter closure by passing only the project path
            def originalSteps = stepsToRun
            stepsToRun = { projectPath, config ->
                originalSteps(projectPath)
            }
        }

        if (params.checkout) {
            // Wrap steps with checkout if requested
            def originalSteps = stepsToRun
            stepsToRun = { projectPath, config ->
                steps.cleanWs()
                def scmVars = steps.checkout(steps.scm)
                // Set SCM vars as environment variables to replicate default checkout functionality
                scmVars.each { k, v ->
                    steps.env."${k}" = v
                }
                originalSteps(projectPath, config)
            }
        }

        if (params.allocateAgent) {
            // Wrap steps with Jenkins agent allocation if requested
            def originalSteps = stepsToRun
            stepsToRun = { projectPath, config ->
                if (params.agentLabel) {
                    steps.node(params.agentLabel) {
                        originalSteps(projectPath, config)
                    }
                } else {
                    steps.node {
                        originalSteps(projectPath, config)
                    }
                }
            }

        }

        steps.parallel(projectsToBuild.collectEntries { projectPath ->
            return [(projectPath): { stepsToRun(projectPath, projectConfig[projectPath]) }]
        })
    }

    private def log(message) {
        steps.echo "[${MonorepoUtils.class.simpleName}] ${message}"
    }

    private List<String> discoverProjects() {
        log("Finding projects relative to provided base path: ${projectBasePath}")

        def projects = []

        steps.dir(projectBasePath) {
            def files = steps.findFiles()
            projects += files.findAll { it.directory }
                .collect { Utils.stripTrailingSlash(it.path) }
                .findAll { !excludedProjects?.contains(it) }
                .collect { Paths.get("${projectBasePath}/${it}") }
                .findAll { !Files.isHidden(it) }
                .collect { it.normalize().toString() }
        }
        if (projects.isEmpty()) {
            throw new IllegalArgumentException("[${MonorepoUtils.class.simpleName}]: No projects found in the specified base path: '${projectBasePath}'")
        }

        return projects
    }

    private List<String> getModifiedProjects(Collection<String> projectsToCheck, Collection<String> modifiedPaths) {
        log("Checking for modifications in projects: ${projectsToCheck}")

        def modifiedProjects = []
        projectsToCheck.each { project ->
            if (modifiedPaths.any { it.startsWith(Utils.ensureTrailingSlash(project)) }) {
                log("Detected modifications in project: ${project}")
                modifiedProjects << relativizeProjectPath(project)
            }
        }
        return modifiedProjects
    }

    private List<String> getProjectsWithModifiedDependencies(Collection<String> projectsToCheck, Collection<String> modifiedPaths) {
        log("Checking for modifications in project dependencies")

        def projectsWithModifiedDependencies = []
        projectDependencies.each { dependency, dependents ->
            if (modifiedPaths.any { it.startsWith(Utils.ensureTrailingSlash(dependency)) }) {
                log("Detected modifications in dependency: ${dependency}")
                projectsToCheck.findAll { project ->
                    dependents.any { project == it || project =~ it }
                }.each { project ->
                    log("Adding in project ${project} as it depends on modified dependency ${dependency}")
                    projectsWithModifiedDependencies << relativizeProjectPath(project)
                }
            }
        }
        return projectsWithModifiedDependencies
    }

    private String relativizeProjectPath(String projectPath) {
        // If project base path is provided and useRelativePaths is true, strip base path from the project path
        if (projectBasePath && useRelativePaths) {
            return Utils.stripPrefix(projectPath, "${projectBasePath}/")
        }

        return projectPath
    }
}
