package com.github.btakita.agentdoc

import com.intellij.openapi.diagnostic.Logger
import java.io.File
import java.time.Instant
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong

internal object RunAgentDocAttemptLedger {
    private val LOG = Logger.getInstance(RunAgentDocAttemptLedger::class.java)
    private const val ATTEMPT_DIAGNOSTICS_DIR = ".agent-doc/state/editor-route-attempts"
    private const val MAX_EVENT_LINES = 80
    private val sequence = AtomicLong(0)

    /// `#jbedtledger`: every stage used to do mkdirs + a full readLines + a full
    /// writeText synchronously on whatever thread called it, and five stages run
    /// on the EDT during Run Agent Doc. That is blocking file I/O on the UI
    /// thread, per action.
    ///
    /// A SINGLE worker, not a pool: the ledger is an append-ordered event log, so
    /// concurrent writers would interleave events and corrupt the ordering the
    /// diagnostics exist to show. Daemon so it never holds IDE shutdown.
    private val ledgerWriter = Executors.newSingleThreadExecutor { runnable ->
        Thread(runnable, "agent-doc-run-attempt-ledger").apply { isDaemon = true }
    }
    private val active = mutableMapOf<String, Attempt>()

    internal data class Attempt(
        val id: String,
        val cwd: String,
        val relativePath: String,
        val filePath: String,
        val focusedFile: String?,
        val startedAtMillis: Long,
        val previousAttemptId: String?,
    ) {
        val routeKey: String = RunAgentDocAttemptLedger.routeKey(cwd, relativePath)

        fun isCurrent(): Boolean = RunAgentDocAttemptLedger.isCurrent(this)

        fun record(stage: String, command: List<String>? = null, error: String? = null) {
            RunAgentDocAttemptLedger.record(this, stage, command, error)
        }

        fun recordIfCurrent(stage: String, command: List<String>? = null, error: String? = null) {
            if (isCurrent()) {
                record(stage, command, error)
            }
        }

        fun finishIfCurrent(stage: String, command: List<String>? = null, error: String? = null) {
            RunAgentDocAttemptLedger.finishIfCurrent(this, stage, command, error)
        }
    }

    fun begin(cwd: String, relativePath: String, filePath: String, focusedFile: String?): Attempt {
        val now = System.currentTimeMillis()
        val key = routeKey(cwd, relativePath)
        val attempt = Attempt(
            id = "${now}-${sequence.incrementAndGet()}",
            cwd = cwd,
            relativePath = relativePath,
            filePath = filePath,
            focusedFile = focusedFile,
            startedAtMillis = now,
            previousAttemptId = null,
        )
        val superseded = synchronized(active) {
            val previous = active.put(key, attempt)
            previous
        }
        if (superseded != null) {
            writeStage(
                attempt = superseded,
                stage = "superseded",
                command = null,
                error = "replaced_by=${attempt.id}",
                onlyIfCurrent = false,
            )
        }
        val latest = if (superseded == null) {
            attempt
        } else {
            attempt.copy(previousAttemptId = superseded.id)
        }
        synchronized(active) {
            active[key] = latest
        }
        latest.record("clicked")
        return latest
    }

    internal fun routeKey(cwd: String, relativePath: String): String = "$cwd::$relativePath"

    internal fun attemptDiagnosticsFile(cwd: String, relativePath: String): File {
        val diagnosticsDir = File(cwd, ATTEMPT_DIAGNOSTICS_DIR)
        val routeHash = Integer.toUnsignedString(routeKey(cwd, relativePath).hashCode(), 16)
        val sanitized = relativePath
            .replace('\\', '/')
            .replace(Regex("[^A-Za-z0-9._/-]+"), "_")
            .replace("/", "__")
            .ifBlank { "route-attempt" }
        return File(diagnosticsDir, "$sanitized-$routeHash.txt")
    }

    private fun isCurrent(attempt: Attempt): Boolean =
        synchronized(active) { active[attempt.routeKey]?.id == attempt.id }

    private fun finishIfCurrent(
        attempt: Attempt,
        stage: String,
        command: List<String>? = null,
        error: String? = null,
    ) {
        if (!isCurrent(attempt)) {
            return
        }
        writeStage(attempt, stage, command, error, onlyIfCurrent = true)
        synchronized(active) {
            if (active[attempt.routeKey]?.id == attempt.id) {
                active.remove(attempt.routeKey)
            }
        }
    }

    /// Block until every queued ledger write has been flushed.
    ///
    /// `#jbedtledger`: stage writes are asynchronous so they never block the EDT,
    /// which means a caller that writes a stage and immediately reads the file
    /// back races the writer. Tests need a deterministic barrier rather than a
    /// sleep (`#wsflake2` — timing-based tests are what made the suite
    /// unreliable). Not part of the production path; nothing on the action path
    /// waits for the ledger.
    @JvmStatic
    internal fun awaitPendingWrites(timeoutMillis: Long = 5_000) {
        val latch = java.util.concurrent.CountDownLatch(1)
        ledgerWriter.execute { latch.countDown() }
        latch.await(timeoutMillis, java.util.concurrent.TimeUnit.MILLISECONDS)
    }

    private fun record(
        attempt: Attempt,
        stage: String,
        command: List<String>? = null,
        error: String? = null,
    ) {
        writeStage(attempt, stage, command, error, onlyIfCurrent = false)
    }

    private fun writeStage(
        attempt: Attempt,
        stage: String,
        command: List<String>?,
        error: String?,
        onlyIfCurrent: Boolean,
    ) {
        if (onlyIfCurrent && !isCurrent(attempt)) {
            return
        }
        // Everything that reads live state is captured HERE, on the calling
        // thread, so offloading the I/O cannot change what gets recorded.
        // `callerThread` especially: it is the field that reveals EDT usage in
        // the first place, so recording the worker thread instead would destroy
        // the diagnostic this change exists to act on.
        val updatedAtMillis = System.currentTimeMillis()
        val elapsedMillis = updatedAtMillis - attempt.startedAtMillis
        val diagnostics = attemptDiagnosticsFile(attempt.cwd, attempt.relativePath)
        val callerThread = Thread.currentThread().name
        ledgerWriter.execute {
            persistStage(
                attempt = attempt,
                stage = stage,
                command = command,
                error = error,
                updatedAtMillis = updatedAtMillis,
                elapsedMillis = elapsedMillis,
                diagnostics = diagnostics,
                callerThread = callerThread,
            )
        }
    }

    private fun persistStage(
        attempt: Attempt,
        stage: String,
        command: List<String>?,
        error: String?,
        updatedAtMillis: Long,
        elapsedMillis: Long,
        diagnostics: File,
        callerThread: String,
    ) {
        try {
            diagnostics.parentFile?.mkdirs()
            val previousEvents = readEventLines(diagnostics)
            val event = eventLine(
                attempt = attempt,
                stage = stage,
                updatedAtMillis = updatedAtMillis,
                elapsedMillis = elapsedMillis,
                command = command,
                error = error,
                callerThread = callerThread,
            )
            val events = (previousEvents + event).takeLast(MAX_EVENT_LINES)
            diagnostics.writeText(
                buildSnapshot(
                    attempt = attempt,
                    stage = stage,
                    updatedAtMillis = updatedAtMillis,
                    elapsedMillis = elapsedMillis,
                    command = command,
                    error = error,
                    events = events,
                    callerThread = callerThread,
                )
            )
        } catch (e: Exception) {
            LOG.warn("[run] failed to persist Run Agent Doc attempt stage=$stage file=${diagnostics.path}: ${e.message}", e)
        }
    }

    private fun buildSnapshot(
        attempt: Attempt,
        stage: String,
        updatedAtMillis: Long,
        elapsedMillis: Long,
        command: List<String>?,
        error: String?,
        events: List<String>,
        callerThread: String,
    ): String = buildString {
        appendLine("attempt_id=${escape(attempt.id)}")
        appendLine("route_key=${escape(attempt.routeKey)}")
        appendLine("cwd=${escape(attempt.cwd)}")
        appendLine("relative_path=${escape(attempt.relativePath)}")
        appendLine("file_path=${escape(attempt.filePath)}")
        appendLine("focused_file=${escape(attempt.focusedFile.orEmpty())}")
        appendLine("stage=${escape(stage)}")
        appendLine("started_at=${Instant.ofEpochMilli(attempt.startedAtMillis)}")
        appendLine("updated_at=${Instant.ofEpochMilli(updatedAtMillis)}")
        appendLine("elapsed_ms=$elapsedMillis")
        appendLine("plugin_version=${escape(pluginVersion())}")
        appendLine("thread=${escape(callerThread)}")
        appendLine("command=${escape(command?.joinToString(" ").orEmpty())}")
        appendLine("error=${escape(error.orEmpty())}")
        appendLine("previous_attempt_id=${escape(attempt.previousAttemptId.orEmpty())}")
        appendLine("events:")
        for (event in events) {
            appendLine(event)
        }
    }

    private fun eventLine(
        attempt: Attempt,
        stage: String,
        updatedAtMillis: Long,
        elapsedMillis: Long,
        command: List<String>?,
        error: String?,
        callerThread: String,
    ): String {
        val parts = mutableListOf(
            Instant.ofEpochMilli(updatedAtMillis).toString(),
            "attempt_id=${escape(attempt.id)}",
            "stage=${escape(stage)}",
            "elapsed_ms=$elapsedMillis",
            "thread=${escape(callerThread)}",
        )
        if (!command.isNullOrEmpty()) {
            parts.add("command=${escape(command.joinToString(" "))}")
        }
        if (!error.isNullOrBlank()) {
            parts.add("error=${escape(error)}")
        }
        return parts.joinToString(" ")
    }

    private fun readEventLines(diagnostics: File): List<String> {
        if (!diagnostics.isFile) {
            return emptyList()
        }
        return try {
            val lines = diagnostics.readLines()
            val marker = lines.indexOf("events:")
            if (marker < 0) {
                emptyList()
            } else {
                lines.drop(marker + 1).filter { it.isNotBlank() }
            }
        } catch (e: Exception) {
            LOG.warn("[run] failed to read Run Agent Doc attempt history ${diagnostics.path}: ${e.message}", e)
            emptyList()
        }
    }

    // #stale-plugin-detect: prefer the IntelliJ plugin descriptor (patched
    // plugin.xml <version>) so the reported version is real, not "unknown".
    private fun pluginVersion(): String =
        com.intellij.ide.plugins.PluginManager
            .getPluginByClass(RunAgentDocAttemptLedger::class.java)?.version
            ?: RunAgentDocAttemptLedger::class.java.`package`?.implementationVersion
            ?: "unknown"

    private fun escape(value: String): String =
        value.replace("\r", "\\r").replace("\n", "\\n")
}
