package pragma.custom

import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.request.header
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.content.TextContent
import io.ktor.http.ContentType
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Base64
import java.util.UUID
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import pragma.OperatorSession
import pragma.PragmaNode
import pragma.applicationRequire
import pragma.config.ConfigBackendModeFactory
import pragma.config.ConfigHandler
import pragma.config.EncryptedString
import pragma.config.ServiceConfig
import pragma.custom.CustomContentGithubRpc.CommitContentToGithubOperatorV1Request
import pragma.custom.CustomContentGithubRpc.CommitContentToGithubOperatorV1Response
import pragma.custom.CustomContentGithubRpc.CustomContentGithubServiceApplicationError
import pragma.custom.CustomContentGithubRpc.IsCustomContentGithubServiceConfiguredOperatorV1Request
import pragma.custom.CustomContentGithubRpc.IsCustomContentGithubServiceConfiguredOperatorV1Response
import pragma.gateway.ShardHelper
import pragma.rpcs.PragmaRPC
import pragma.rpcs.RoutingMethod
import pragma.rpcs.SessionType
import pragma.services.DistributedService
import pragma.services.PragmaService
import pragma.settings.BackendType
import pragma.utils.PragmaSerializationUtils.jsonMapper

@Suppress("UNUSED_PARAMETER")
@PragmaService(backendTypes = [BackendType.GAME])
class CustomContentGithubService(
    pragmaNode: PragmaNode,
    instanceId: UUID,
    private val shardHelper: ShardHelper = ShardHelper(),
    private val httpClient: HttpClient = HttpClient(CIO) { expectSuccess = false },
) : DistributedService(pragmaNode, instanceId), ConfigHandler<CustomContentGithubServiceConfig> {
    override var logger: Logger = LoggerFactory.getLogger(CustomContentGithubService::class.java)
    private lateinit var config: CustomContentGithubServiceConfig

    companion object {
        private val SHA_PATTERN = Regex("^[0-9a-f]{7,40}$")
    }

    override suspend fun onConfigChanged(serviceConfig: CustomContentGithubServiceConfig) {
        config = serviceConfig
    }

    @PragmaRPC(
        sessionType = SessionType.OPERATOR,
        routingMethod = RoutingMethod.SINGLETON,
        permission = "custom_content.github.commit"
    )
    suspend fun isCustomContentGithubServiceConfiguredOperatorV1(
        session: OperatorSession,
        request: IsCustomContentGithubServiceConfiguredOperatorV1Request
    ): IsCustomContentGithubServiceConfiguredOperatorV1Response {
        val configured = isFieldSet(config.repositoryUrl) &&
            isFieldSet(config.contentRepoPath) &&
            isFieldSet(config.githubTokenSecret.getDecryptedValue())
        return IsCustomContentGithubServiceConfiguredOperatorV1Response.newBuilder()
            .setConfigured(configured)
            .build()
    }

    private fun isFieldSet(value: String): Boolean = value.isNotBlank() && value != "UNCONFIGURED"

    @PragmaRPC(
        sessionType = SessionType.OPERATOR,
        routingMethod = RoutingMethod.SINGLETON,
        permission = "custom_content.github.commit"
    )
    suspend fun commitContentToGithubOperatorV1(
        session: OperatorSession,
        request: CommitContentToGithubOperatorV1Request
    ): CommitContentToGithubOperatorV1Response {
        logger.debug(
            "commitContentToGithubOperatorV1 invoked by operator pragmaId=${session.pragmaId}: " +
                "prTitle='${request.prTitle}', prBody-len=${request.prBody.length}"
        )

        val token = config.githubTokenSecret.getDecryptedValue()
        applicationRequire(token.isNotBlank() && token != "UNCONFIGURED") {
            logger.warn("Aborting commit: githubTokenSecret is not configured")
            CustomContentGithubServiceApplicationError.newBuilder()
                .setMessage("CustomContentGithubService.githubTokenSecret is not configured")
                .build()
        }
        applicationRequire(config.contentRepoPath.isNotBlank() && config.contentRepoPath != "UNCONFIGURED") {
            logger.warn("Aborting commit: contentRepoPath is not configured")
            CustomContentGithubServiceApplicationError.newBuilder()
                .setMessage("CustomContentGithubService.contentRepoPath is not configured (e.g. 'platform/simplegame/content')")
                .build()
        }
        logger.debug(
            "Effective config: repositoryUrl='${config.repositoryUrl}', branchName='${config.branchName}', " +
                "localDevFallbackBranch='${config.localDevFallbackBranch}', contentRepoPath='${config.contentRepoPath}'"
        )

        val repo = parseRepo(config.repositoryUrl)
        logger.debug("Target repo resolved as ${repo.owner}/${repo.name}")
        val baseRef = resolveBaseRef(repo, token)

        val files = collectContentFiles()
        applicationRequire(files.isNotEmpty()) {
            CustomContentGithubServiceApplicationError.newBuilder()
                .setMessage("No content files found under ${pragmaNode.contentDataDirectory}/{src,package}")
                .build()
        }

        val timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"))
        val tempBranch = "pragma-content-$timestamp"
        val commitMessage = request.prTitle.ifBlank { "Content update $timestamp" }

        val commitSha = createCommit(repo, token, baseRef.sha, files, commitMessage)
        createBranchRef(repo, token, tempBranch, commitSha)
        val prUrl = openPullRequest(repo, token, baseRef.branch, tempBranch, commitMessage, request.prBody)

        logger.debug(
            "Content commit complete: PR=$prUrl, headBranch=$tempBranch, commit=$commitSha, " +
                "base=${baseRef.branch}@${baseRef.sha}"
        )

        return CommitContentToGithubOperatorV1Response.newBuilder()
            .setPullRequestUrl(prUrl)
            .setBranchName(tempBranch)
            .setCommitSha(commitSha)
            .setBaseSha(baseRef.sha)
            .build()
    }

    private data class Repo(val owner: String, val name: String)

    private fun parseRepo(url: String): Repo {
        // Accept https://github.com/owner/repo(.git)? and git@github.com:owner/repo(.git)?
        val cleaned = url.trim().removeSuffix("/").removeSuffix(".git")
        val match = Regex("""^(?:https?://[^/]+/|git@[^:]+:)([^/]+)/([^/]+)$""").find(cleaned)
        applicationRequire(match != null) {
            CustomContentGithubServiceApplicationError.newBuilder()
                .setMessage("Unrecognized repositoryUrl format: $url")
                .build()
        }
        return Repo(match.groupValues[1], match.groupValues[2])
    }

    private data class BaseRef(val branch: String, val sha: String)

    /**
     * Resolves the branch to base the content PR on (the PR's `base`) and the SHA to parent
     * the new commit on.
     *
     * Branch priority:
     *   1. [CustomContentGithubServiceConfig.branchName] if explicitly configured
     *   2. Branch parsed from the deployed Content artifact version
     *      (`<timestamp>_<pragmaVersion>_<branch>_<gitsha>`)
     *   3. [CustomContentGithubServiceConfig.localDevFallbackBranch]
     *
     * SHA: the SHA embedded in the Content artifact version if present, otherwise the tip of
     * the resolved branch. Using the artifact SHA pins the commit to exactly what's deployed,
     * avoiding accidentally overwriting changes that landed on the branch after deployment.
     */
    private suspend fun resolveBaseRef(repo: Repo, token: String): BaseRef {
        val branch = resolveBaseBranch()
        val version = shardHelper.getVersionForArtifact(ShardHelper.ArtifactType.Content)
        val artifactSha = extractShaFromVersion(version)

        val sha = if (artifactSha != null) {
            logger.debug("Basing content commit on artifact-reported sha $artifactSha (version $version); PR base branch '$branch'")
            artifactSha
        } else {
            val refJson = github(repo, token, HttpMethod.Get, "/git/refs/heads/$branch")
            val tipSha = refJson.get("object").get("sha").asText()
            logger.debug("Basing content commit on tip of '$branch' ($tipSha) on ${repo.owner}/${repo.name}")
            tipSha
        }
        return BaseRef(branch, sha)
    }

    private fun resolveBaseBranch(): String {
        if (config.branchName.isNotBlank() && config.branchName != "UNCONFIGURED") {
            logger.debug("Base branch from config.branchName='${config.branchName}'")
            return config.branchName
        }
        val version = shardHelper.getVersionForArtifact(ShardHelper.ArtifactType.Content)
        extractBranchFromVersion(version)?.let {
            logger.debug("Base branch parsed from content artifact version '$version' -> '$it'")
            return it
        }

        val fallback = config.localDevFallbackBranch
        applicationRequire(fallback.isNotBlank() && fallback != "UNCONFIGURED") {
            CustomContentGithubServiceApplicationError.newBuilder()
                .setMessage(
                    "Cannot determine base branch: branchName is unset, content artifact version " +
                        "'$version' has no parseable branch, and localDevFallbackBranch is also unset"
                )
                .build()
        }
        logger.warn("Falling back to localDevFallbackBranch='$fallback' (branchName unset, version='$version' unparseable)")
        return fallback
    }

    /**
     * Parses the branch token from a content artifact version of the form
     * `<timestamp>_<pragmaVersion>_<branch>_<gitsha>`. Branch names may contain underscores so
     * the SHA token is identified from the right and everything between the version and the SHA
     * is treated as the branch. Returns null for "local-dev" or unparseable versions.
     */
    private fun extractBranchFromVersion(version: String): String? {
        if (version == "local-dev") return null
        val parts = version.split("_")
        val shaIdx = parts.indexOfLast { it.matches(SHA_PATTERN) }
        // Need at least timestamp(0), pragmaVersion(1), branch(>=2), sha(last).
        if (shaIdx < 3) {
            logger.warn("Content artifact version '$version' is not in expected '<timestamp>_<pragmaVersion>_<branch>_<gitsha>' form; cannot extract branch")
            return null
        }
        return parts.subList(2, shaIdx).joinToString("_")
    }

    private fun extractShaFromVersion(version: String): String? {
        if (version == "local-dev") return null
        val sha = version.split("_").asReversed().firstOrNull { it.matches(SHA_PATTERN) }
        if (sha == null) {
            logger.warn("Content artifact version '$version' contains no git-sha-looking token; will fall back to branch tip")
        }
        return sha
    }

    private data class ContentFile(val repoPath: String, val bytes: ByteArray)

    private fun collectContentFiles(): List<ContentFile> {
        val contentRoot = Paths.get(pragmaNode.contentDataDirectory)
        logger.debug("Scanning content under $contentRoot")
        val files = mutableListOf<ContentFile>()
        listOf("src", "package").forEach { subdir ->
            val root = contentRoot.resolve(subdir)
            if (!Files.isDirectory(root)) {
                logger.debug("Content subdir '$subdir' not found at $root; skipping")
                return@forEach
            }
            val before = files.size
            Files.walk(root).use { stream ->
                stream.filter { Files.isRegularFile(it) }.forEach { file ->
                    files.add(ContentFile(repoPathFor(file, subdir, root), Files.readAllBytes(file)))
                }
            }
            logger.debug("Collected ${files.size - before} files from '$subdir'")
        }
        val totalBytes = files.sumOf { it.bytes.size.toLong() }
        logger.debug("Collected ${files.size} content files ($totalBytes bytes) under $contentRoot")
        return files
    }

    /**
     * Maps a local content file path to a repo-relative path under [CustomContentGithubServiceConfig.contentRepoPath].
     * `<contentDataDirectory>/src/foo.json` → `<contentRepoPath>/src/foo.json`.
     */
    private fun repoPathFor(file: Path, subdir: String, root: Path): String {
        val relative = root.relativize(file).toString().replace('\\', '/')
        val prefix = config.contentRepoPath.trim().trim('/')
        return "$prefix/$subdir/$relative"
    }

    /**
     * Creates an atomic commit on GitHub using the Git Data API:
     *  1. Create one blob per file (base64).
     *  2. Create a tree referencing the base commit's tree, overlaying our blobs.
     *  3. Create a commit pointing at the new tree, parented to baseSha.
     */
    private suspend fun createCommit(
        repo: Repo, token: String, baseSha: String, files: List<ContentFile>, message: String,
    ): String {
        logger.debug("Fetching base commit $baseSha on ${repo.owner}/${repo.name}")
        // Use the Repos commits API (`/repos/{owner}/{repo}/commits/{ref}`): it accepts short SHAs
        // and refs, unlike the Git Data API's `/git/commits/{sha}` which requires the full 40-char SHA.
        // Artifact version strings carry 7-char abbreviations, so we resolve through this endpoint
        // and read the full SHA back for the subsequent Git Data POSTs.
        val baseCommit = github(repo, token, HttpMethod.Get, "/commits/$baseSha")
        val fullBaseSha = baseCommit.get("sha").asText()
        val baseTreeSha = baseCommit.get("commit").get("tree").get("sha").asText()
        logger.debug("Base commit resolved: sha=$fullBaseSha tree=$baseTreeSha")

        val treeEntries = jsonMapper.createArrayNode()
        files.forEachIndexed { idx, file ->
            val blobBody = jsonMapper.createObjectNode()
                .put("content", Base64.getEncoder().encodeToString(file.bytes))
                .put("encoding", "base64")
            val blob = github(repo, token, HttpMethod.Post, "/git/blobs", blobBody)
            val blobSha = blob.get("sha").asText()
            logger.debug("[${idx + 1}/${files.size}] Created blob $blobSha for ${file.repoPath} (${file.bytes.size} bytes)")
            treeEntries.add(
                jsonMapper.createObjectNode()
                    .put("path", file.repoPath)
                    .put("mode", "100644")
                    .put("type", "blob")
                    .put("sha", blobSha)
            )
        }

        val treeBody = jsonMapper.createObjectNode()
            .put("base_tree", baseTreeSha)
            .set<ObjectNode>("tree", treeEntries)
        logger.debug("Creating tree on base $baseTreeSha with ${files.size} entries")
        val tree = github(repo, token, HttpMethod.Post, "/git/trees", treeBody)
        val treeSha = tree.get("sha").asText()

        val commitBody = jsonMapper.createObjectNode()
            .put("message", message)
            .put("tree", treeSha)
            .set<ObjectNode>("parents", jsonMapper.createArrayNode().add(fullBaseSha) as ArrayNode)
        logger.debug("Creating commit: tree=$treeSha parent=$fullBaseSha messageLen=${message.length}")
        val commit = github(repo, token, HttpMethod.Post, "/git/commits", commitBody)
        val commitSha = commit.get("sha").asText()
        logger.debug("Created commit $commitSha on ${repo.owner}/${repo.name}")
        return commitSha
    }

    private suspend fun createBranchRef(repo: Repo, token: String, branchName: String, commitSha: String) {
        logger.debug("Creating branch ref refs/heads/$branchName -> $commitSha")
        val body = jsonMapper.createObjectNode()
            .put("ref", "refs/heads/$branchName")
            .put("sha", commitSha)
        github(repo, token, HttpMethod.Post, "/git/refs", body)
        logger.debug("Created branch '$branchName' pointing at $commitSha on ${repo.owner}/${repo.name}")
    }

    private suspend fun openPullRequest(
        repo: Repo, token: String, baseBranch: String, headBranch: String, title: String, body: String,
    ): String {
        logger.debug("Opening PR: base='$baseBranch' head='$headBranch' titleLen=${title.length} bodyLen=${body.length}")
        val payload = jsonMapper.createObjectNode()
            .put("title", title)
            .put("head", headBranch)
            .put("base", baseBranch)
            .put("body", body)
        val pr = github(repo, token, HttpMethod.Post, "/pulls", payload)
        val prUrl = pr.get("html_url").asText()
        logger.debug("Opened PR $prUrl ($headBranch -> $baseBranch)")
        return prUrl
    }

    private suspend fun github(
        repo: Repo, token: String, method: HttpMethod, path: String, body: JsonNode? = null,
    ): JsonNode {
        val url = "https://api.github.com/repos/${repo.owner}/${repo.name}$path"
        logger.debug("GitHub request: ${method.value} $path (bodyPresent=${body != null})")
        val response = httpClient.request(url) {
            this.method = method
            header("Authorization", "Bearer $token")
            header("Accept", "application/vnd.github+json")
            header("X-GitHub-Api-Version", "2022-11-28")
            header("User-Agent", "pragma-content-github-service")
            if (body != null) {
                setBody(TextContent(jsonMapper.writeValueAsString(body), ContentType.Application.Json))
            }
        }
        val text = response.bodyAsText()
        if (!response.status.isAcceptable()) {
            // Log here so the failure is visible server-side; the applicationRequire below
            // converts this into an application-error response which the platform may not log.
            logger.warn(
                "GitHub ${method.value} $path failed with ${response.status.value} ${response.status.description}: ${truncate(text, 500)}"
            )
        } else {
            logger.debug("GitHub ${method.value} $path -> ${response.status.value}")
        }
        applicationRequire(response.status.isAcceptable()) {
            CustomContentGithubServiceApplicationError.newBuilder()
                .setMessage("GitHub ${method.value} $path failed: ${response.status.value} ${response.status.description} - ${truncate(text, 500)}")
                .build()
        }
        return jsonMapper.readTree(text)
    }

    private fun truncate(s: String, max: Int): String = if (s.length <= max) s else s.take(max) + "... (${s.length - max} more chars)"

    private fun HttpStatusCode.isAcceptable(): Boolean = value in 200..299
}

class CustomContentGithubServiceConfig private constructor(type: BackendType) : ServiceConfig<CustomContentGithubServiceConfig>(type) {
    override val description: String = "Config for the Github Service"
    var repositoryUrl: String by types.string("The URL at which the repository")
    var branchName: String by types.string(
        "Optional override for the PR base branch. When unset (blank or 'UNCONFIGURED'), the " +
            "branch is parsed from the Content artifact version, falling back to localDevFallbackBranch."
    )
    var githubTokenSecret: EncryptedString by types.encryptedString("API Github Token secret to be used in interacting with Github.")
    var localDevFallbackBranch: String by types.string("Branch whose tip is used as the base commit when the Content artifact reports 'local-dev'")
    var contentRepoPath: String by types.string(
        "Path inside the repository where content lives (e.g. 'platform/simplegame/content'). " +
            "Files are committed under <contentRepoPath>/src and <contentRepoPath>/package."
    )

    init {
        branchName = "UNCONFIGURED"
        localDevFallbackBranch = "main"
        repositoryUrl = "UNCONFIGURED"
        githubTokenSecret = EncryptedString("UNCONFIGURED")
        contentRepoPath = "UNCONFIGURED"
    }

    companion object : ConfigBackendModeFactory<CustomContentGithubServiceConfig> {
        override fun getFor(type: BackendType): CustomContentGithubServiceConfig {
            return CustomContentGithubServiceConfig(type)
        }
    }
}
