package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.intOrNull
import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.routes.ApiClient
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpTransport
import java.net.URI

/**
 * # DiffViewModel (A24) — the read-only staged/unstaged git-diff presenter.
 *
 * Fetches `GET /projects/diff?path=&staged=1|0` (plan §4.2), tolerantly decodes the untrusted
 * [DiffResult], and **flattens** files→hunks→lines into ONE ordered [DiffRow] list a `LazyColumn`
 * renders (plan §5 A24, §1 "Diff viewer"). All rendering is inert monospaced text (plan §8) —
 * this presenter carries only the pre-classified line kinds; colouring lives in `DiffScreen`.
 *
 * ### Why the diff route/model live HERE (not in `:api-client`)
 * `:api-client`'s frozen 12-route surface (A8) does not include the diff route, and A24 owns only the
 * `:app` diff files. So the diff DTOs + tolerant decode + the RO request are self-contained in this
 * module, consuming only the frozen [HttpTransport]/[HostEndpoint] seam — the same untrusted-boundary
 * discipline `:api-client` uses (unknown keys ignored, malformed file/hunk/line dropped one by one,
 * nothing throws on bad server input).
 *
 * ### `staged` is the STRING `"1"`/`"0"` (NOT a boolean)
 * The server matches `req.query['staged'] === '1'` (`src/server.ts`), so the query value MUST be the
 * literal `"1"` (staged) or `"0"` (working tree) — a `true`/`false` would silently read as working-tree
 * on the server. [diffUrl] encodes exactly that; the JVM test asserts the exact query value.
 *
 * ### A plain presenter (JVM-testable, mirrors [PairingViewModel]/[GateViewModel])
 * Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` with no `Dispatchers.Main`. The
 * screen calls [bind] with a lifecycle scope, then [refresh]; the Working/Staged toggle calls
 * [selectStaged]. Each (re)load cancels the in-flight fetch so a fast toggle never races.
 */
public class DiffViewModel(
    private val fetcher: DiffFetcher,
    private val path: String,
    /** Guarded git-write seam (stage/commit/push). Null → the diff is inert read-only (no buttons). */
    private val writer: GitWriteGateway? = null,
) {
    private val _uiState = MutableStateFlow(DiffUiState(canWrite = writer != null))

    /** The single snapshot `DiffScreen` renders from. */
    public val uiState: StateFlow<DiffUiState> = _uiState.asStateFlow()

    private var scope: CoroutineScope? = null
    private var job: Job? = null
    private var writeJob: Job? = null

    /** Bind the scope loads launch into (the screen passes a lifecycle scope) and kick the first load. */
    public fun bind(scope: CoroutineScope) {
        this.scope = scope
        reload()
    }

    /** Switch between the working-tree (`staged=false`) and staged (`staged=true`) diff; re-fetches.
     *  No-op in base mode (the toggle is hidden there — the server ignores `staged` when `base` is set). */
    public fun selectStaged(staged: Boolean) {
        if (_uiState.value.base != null) return
        if (_uiState.value.staged == staged) return
        _uiState.value = _uiState.value.copy(staged = staged)
        reload()
    }

    /**
     * Enter/leave base mode: a non-blank [rev] diffs HEAD against that base (staged toggle suppressed,
     * git-write disabled — parity with public/diff.ts); null/blank returns to the working/staged view.
     */
    public fun setBase(rev: String?) {
        val next = rev?.trim()?.takeIf { it.isNotEmpty() }
        if (_uiState.value.base == next) return
        _uiState.value = _uiState.value.copy(base = next, writeBanner = null)
        reload()
    }

    /** Re-fetch the current view (pull-to-refresh / retry after an error). */
    public fun refresh() {
        reload()
    }

    /** Dismiss the git-write result banner. */
    public fun clearWriteBanner() {
        _uiState.value = _uiState.value.copy(writeBanner = null)
    }

    private fun reload() {
        val scope = scope ?: return
        job?.cancel()
        val staged = _uiState.value.staged
        val base = _uiState.value.base
        _uiState.value = _uiState.value.copy(phase = DiffPhase.LOADING)
        job = scope.launch {
            // Rethrow cancellation (a superseding load) so a stale fetch can't overwrite fresh state.
            val outcome = try {
                Result.success(fetcher.fetch(path, staged, base))
            } catch (cancel: CancellationException) {
                throw cancel
            } catch (error: Throwable) {
                Result.failure(error)
            }
            val current = _uiState.value
            _uiState.value = outcome.fold(
                onSuccess = { result ->
                    current.copy(
                        phase = if (result.files.isEmpty()) DiffPhase.EMPTY else DiffPhase.LOADED,
                        rows = flattenDiff(result),
                        truncated = result.truncated,
                    )
                },
                onFailure = { current.copy(phase = DiffPhase.ERROR, rows = emptyList(), truncated = false) },
            )
        }
    }

    // ── Git write (working/staged mode only — never in base mode, plan §Security/Edge cases) ──────

    /** Stage (`staged=true`) or unstage a single file, then re-fetch so the view reflects the index. */
    public fun toggleStage(newPath: String, staged: Boolean) {
        runWrite { writer!!.gitStage(path, listOf(newPath), staged) }
    }

    /** Commit the staged changes with [message]. An empty message is rejected client-side (no I/O). */
    public fun commit(message: String) {
        if (message.isBlank()) {
            _uiState.value = _uiState.value.copy(writeBanner = DiffWriteBanner(DiffCopy.COMMIT_EMPTY, isError = true))
            return
        }
        runWrite { writer!!.gitCommit(path, message) }
    }

    /** Push the current branch to its upstream (tighter server-side rate limit; never auto-retried). */
    public fun push() {
        runWrite { writer!!.gitPush(path) }
    }

    /**
     * Shared guarded-write runner: guards on write availability + base mode, sets [DiffUiState.writing],
     * maps the [GitWriteOutcome] to a banner, and re-fetches the diff on success. Serialized via a single
     * [writeJob] so a rapid double-tap never races.
     */
    private fun <T> runWrite(op: suspend () -> GitWriteOutcome<T>) {
        val scope = scope ?: return
        if (writer == null || _uiState.value.base != null || _uiState.value.writing) return
        writeJob?.cancel()
        _uiState.value = _uiState.value.copy(writing = true, writeBanner = null)
        writeJob = scope.launch {
            val outcome = try {
                Result.success(op())
            } catch (cancel: CancellationException) {
                throw cancel
            } catch (error: Throwable) {
                Result.failure(error)
            }
            val banner = outcome.fold(
                onSuccess = { bannerFor(it) },
                onFailure = { DiffWriteBanner(DiffCopy.writeFailed(errorDetail(it)), isError = true) },
            )
            _uiState.value = _uiState.value.copy(writing = false, writeBanner = banner)
            if (!banner.isError) reload() // refresh the diff after a successful write
        }
    }

    private fun <T> bannerFor(outcome: GitWriteOutcome<T>): DiffWriteBanner = when (outcome) {
        is GitWriteOutcome.Ok -> DiffWriteBanner(DiffCopy.okBanner(outcome.payload), isError = false)
        is GitWriteOutcome.Rejected -> DiffWriteBanner(outcome.message ?: DiffCopy.WRITE_REJECTED, isError = true)
        GitWriteOutcome.RateLimited -> DiffWriteBanner(DiffCopy.RATE_LIMITED, isError = true)
    }

    // A thrown ApiClientError's message IS its userMessage (super(userMessage)); transport errors carry
    // their own message — so message is already the display copy.
    private fun errorDetail(error: Throwable): String = error.message ?: error.toString()
}

/** The guarded git-write seam DiffViewModel drives (stage/commit/push). Prod: [ApiClientGitWriteGateway]. */
public interface GitWriteGateway {
    public suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult>
    public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult>
    public suspend fun gitPush(path: String): GitWriteOutcome<PushResult>
}

/** Production [GitWriteGateway] delegating to a per-host [ApiClient] (Origin stamped in :api-client). */
public class ApiClientGitWriteGateway(private val api: ApiClient) : GitWriteGateway {
    override suspend fun gitStage(path: String, files: List<String>, stage: Boolean): GitWriteOutcome<StageResult> =
        api.gitStage(path, files, stage)

    override suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
        api.gitCommit(path, message)

    override suspend fun gitPush(path: String): GitWriteOutcome<PushResult> = api.gitPush(path)
}

/** A one-line git-write result banner. [isError] drives the colour token (green ok / red failure). */
public data class DiffWriteBanner(val message: String, val isError: Boolean)

/** User-visible git-write copy (Chinese named constants; server strings are surfaced verbatim/inert). */
public object DiffCopy {
    public const val COMMIT_EMPTY: String = "请填写提交信息。"
    public const val WRITE_REJECTED: String = "操作被服务器拒绝。"
    public const val RATE_LIMITED: String = "操作过于频繁，服务器已限流，请稍后再试。"

    public fun writeFailed(detail: String): String = "Git 操作失败：$detail"

    /** Success banner keyed off the payload type (short sha / branch→remote / staged count). */
    public fun okBanner(payload: Any?): String = when (payload) {
        is StageResult -> if (payload.staged) "已暂存 ${payload.count} 个文件" else "已取消暂存 ${payload.count} 个文件"
        is CommitResult -> if (payload.commit.isEmpty()) "已提交" else "已提交 ${payload.commit}"
        is PushResult -> "已推送 ${payload.branch ?: "分支"} → ${payload.remote ?: "远端"}"
        else -> "操作完成"
    }
}

/** The load phase the screen renders (loading spinner / empty / error / list). */
public enum class DiffPhase { IDLE, LOADING, LOADED, EMPTY, ERROR }

/** The immutable snapshot the diff screen renders. */
public data class DiffUiState(
    /** `false` = working tree, `true` = staged (index). Drives the `staged=1|0` query. Ignored in base mode. */
    val staged: Boolean = false,
    /** Non-null = base mode: diff HEAD against this revision (staged toggle + git-write suppressed). */
    val base: String? = null,
    val phase: DiffPhase = DiffPhase.IDLE,
    /** files→hunks→lines flattened into one ordered list (empty until loaded). */
    val rows: List<DiffRow> = emptyList(),
    /** Server capped the diff (too large) — the screen shows a truncation notice. */
    val truncated: Boolean = false,
    /** True once a git-write is in flight — the screen disables the write controls. */
    val writing: Boolean = false,
    /** The last git-write result (ok/failure), or null. Dismissed via [DiffViewModel.clearWriteBanner]. */
    val writeBanner: DiffWriteBanner? = null,
    /** Whether git-write controls are offered at all (a writer gateway was supplied). */
    val canWrite: Boolean = false,
) {
    /** Stage/commit/push are offered only in working/staged mode with a writer bound (never base mode). */
    val writeEnabled: Boolean get() = canWrite && base == null
}

// ── The flattened lazy-list model (files → hunks → lines, in order) ──────────────────────────────

/** One rendered row. A sealed hierarchy so each kind paints with its own token (plan §1/§8). */
public sealed interface DiffRow {
    /** Stable, monotonic id — the `LazyColumn` item key (row identity survives recomposition). */
    public val id: Long
}

/** A per-file header: the display path plus its `+added/-removed` numstat and status. [stagePath] is the
 *  file's `newPath` used verbatim for `git add`/`restore` (the display [path] may be an `old → new` rename). */
public data class DiffFileHeaderRow(
    override val id: Long,
    val path: String,
    val stagePath: String,
    val status: String,
    val added: Int,
    val removed: Int,
) : DiffRow

/** A hunk header line (`@@ -a,b +c,d @@`) — the wire `hunk` kind. */
public data class DiffHunkHeaderRow(override val id: Long, val header: String) : DiffRow

/** One diff line, pre-classified by the server (added/removed/context/meta). */
public data class DiffLineRow(override val id: Long, val kind: DiffLineKind, val text: String) : DiffRow

/** A binary file has no hunks — a single "Binary file" marker row. */
public data class DiffBinaryRow(override val id: Long) : DiffRow

/**
 * Flatten a [DiffResult] into the ordered render list: for each file a [DiffFileHeaderRow], then —
 * for a binary file a single [DiffBinaryRow], otherwise each hunk's [DiffHunkHeaderRow] followed by
 * its [DiffLineRow]s. Order is preserved exactly (files, then hunks within a file, then lines within
 * a hunk). Pure — JVM-tested.
 */
public fun flattenDiff(result: DiffResult): List<DiffRow> {
    val rows = ArrayList<DiffRow>()
    var id = 0L
    for (file in result.files) {
        rows.add(DiffFileHeaderRow(id++, headerPath(file), file.newPath, file.status, file.added, file.removed))
        if (file.binary) {
            rows.add(DiffBinaryRow(id++))
            continue
        }
        for (hunk in file.hunks) {
            rows.add(DiffHunkHeaderRow(id++, hunk.header))
            for (line in hunk.lines) {
                rows.add(DiffLineRow(id++, line.kind, line.text))
            }
        }
    }
    return rows
}

/** `old → new` for a real rename, else the new path (mirror of `public/diff.ts` renderDiffFile). */
private fun headerPath(file: DiffFile): String =
    if (file.status == "renamed" && file.oldPath != file.newPath) {
        "${file.oldPath} → ${file.newPath}"
    } else {
        file.newPath
    }

// ── The diff model (plain, non-@Serializable — decoded by hand from the JSON tree) ───────────────

/** The semantic class of one diff line (`src/types.ts` `DiffLineKind`). Unknown → [CONTEXT]. */
public enum class DiffLineKind {
    ADDED,
    REMOVED,
    CONTEXT,
    META,
    HUNK,
    ;

    public companion object {
        /** Map the wire string; an unknown/future kind degrades to [CONTEXT] (mirror of the web default). */
        public fun fromWire(wire: String): DiffLineKind = when (wire) {
            "added" -> ADDED
            "removed" -> REMOVED
            "context" -> CONTEXT
            "meta" -> META
            "hunk" -> HUNK
            else -> CONTEXT
        }
    }
}

public data class DiffLine(val kind: DiffLineKind, val text: String)
public data class DiffHunk(val header: String, val lines: List<DiffLine>)
public data class DiffFile(
    val oldPath: String,
    val newPath: String,
    val status: String,
    val added: Int,
    val removed: Int,
    val binary: Boolean,
    val hunks: List<DiffHunk>,
)

public data class DiffResult(
    val files: List<DiffFile>,
    val staged: Boolean,
    val truncated: Boolean,
    /** Echoed by the server when the diff was against a base revision (`?base=<rev>`); null otherwise. */
    val base: String? = null,
)

/** Tolerant JSON: unknown keys ignored, lenient — the untrusted-server config (mirror of `ModelJson`). */
private val DiffJson: Json = Json {
    ignoreUnknownKeys = true
    isLenient = true
}

/**
 * Decode an untrusted `/projects/diff` body into [DiffResult], dropping malformed files/hunks/lines
 * one by one and keeping the rest (plan §4.2 lossy decode). Never throws: a non-object root or
 * unparseable body yields an empty result.
 */
internal fun decodeDiffResult(bytes: ByteArray): DiffResult {
    val root = runCatching { DiffJson.parseToJsonElement(bytes.decodeToString()) }
        .getOrNull() as? JsonObject
        ?: return DiffResult(emptyList(), staged = false, truncated = false)
    val files = (root["files"] as? JsonArray).orEmpty().mapNotNull(::decodeFile)
    return DiffResult(
        files = files,
        staged = root.bool("staged", false),
        truncated = root.bool("truncated", false),
        base = root.str("base"),
    )
}

/** A file needs a string `newPath` to be renderable; anything else drops it (keeps the rest). */
private fun decodeFile(element: kotlinx.serialization.json.JsonElement): DiffFile? {
    val obj = element as? JsonObject ?: return null
    val newPath = obj.str("newPath") ?: return null
    val hunks = (obj["hunks"] as? JsonArray).orEmpty().mapNotNull(::decodeHunk)
    return DiffFile(
        oldPath = obj.str("oldPath") ?: newPath,
        newPath = newPath,
        status = obj.str("status") ?: "modified",
        added = obj.int("added", 0),
        removed = obj.int("removed", 0),
        binary = obj.bool("binary", false),
        hunks = hunks,
    )
}

/** A hunk needs a string `header`; a malformed hunk is dropped (its siblings survive). */
private fun decodeHunk(element: kotlinx.serialization.json.JsonElement): DiffHunk? {
    val obj = element as? JsonObject ?: return null
    val header = obj.str("header") ?: return null
    val lines = (obj["lines"] as? JsonArray).orEmpty().mapNotNull(::decodeLine)
    return DiffHunk(header = header, lines = lines)
}

/** A line needs both `kind` and `text` as strings; otherwise it is dropped. */
private fun decodeLine(element: kotlinx.serialization.json.JsonElement): DiffLine? {
    val obj = element as? JsonObject ?: return null
    val kind = obj.str("kind") ?: return null
    val text = obj.str("text") ?: return null
    return DiffLine(kind = DiffLineKind.fromWire(kind), text = text)
}

private fun JsonObject.str(key: String): String? =
    (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content

private fun JsonObject.int(key: String, default: Int): Int =
    (this[key] as? JsonPrimitive)?.intOrNull ?: default

private fun JsonObject.bool(key: String, default: Boolean): Boolean =
    (this[key] as? JsonPrimitive)?.booleanOrNull ?: default

// ── Fetch (RO GET — no Origin header, plan §4.2/§4.3) ────────────────────────────────────────────

/** Fetches + decodes a diff for a project path. Seam so the presenter is driven by a fake in tests. */
public interface DiffFetcher {
    /**
     * @param base when non-null, diff HEAD against this base revision — the server IGNORES [staged]
     *   in base mode (server.ts:831), so callers omit it and the screen hides the Working/Staged toggle.
     * @throws DiffUnavailable on a non-200 status; transport errors propagate.
     */
    public suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult
}

/** A non-200 from the diff route (400 bad path / 404 not a repo / 500 git failed). */
public class DiffUnavailable(public val status: Int) : Exception("diff unavailable: HTTP $status")

/**
 * Real [DiffFetcher]: builds the RO `GET /projects/diff` against [endpoint] and decodes tolerantly.
 * Read-only, so it stamps NO `Origin` header (the CSWSH 铁律 — only guarded routes carry it, plan §4.3).
 */
public class HttpDiffFetcher(
    private val endpoint: HostEndpoint,
    private val http: HttpTransport,
) : DiffFetcher {
    override suspend fun fetch(path: String, staged: Boolean, base: String?): DiffResult {
        val url = diffUrl(endpoint.baseUrl, path, staged, base) ?: throw DiffUnavailable(HTTP_BAD_REQUEST)
        val response = http.send(HttpRequest(method = HttpMethod.GET, url = url))
        if (response.status != HTTP_OK) throw DiffUnavailable(response.status)
        return decodeDiffResult(response.body)
    }
}

private const val HTTP_OK = 200
private const val HTTP_BAD_REQUEST = 400

/**
 * Build `<scheme>://host[:port]/projects/diff?path=<enc>[&staged=1|0][&base=<enc>]` from the dialed
 * base URL, keeping the dialed port verbatim (mirror of `ApiRoute.buildUrl`). In **base mode**
 * ([base] non-null/non-blank) the server ignores `staged` (server.ts:831), so `staged` is OMITTED and
 * `&base=<enc>` is appended (percent-encoded; the server's `isPlausibleRev` rejects junk with a 400).
 * Otherwise `staged` serializes as the literal `"1"`/`"0"` string the server matches with `=== '1'`
 * (NOT a boolean). Returns null if the base URL cannot be parsed. `internal` so the JVM test asserts
 * the exact query value.
 */
internal fun diffUrl(baseUrl: String, path: String, staged: Boolean, base: String? = null): String? {
    val uri = runCatching { URI(baseUrl.trim()) }.getOrNull() ?: return null
    val scheme = uri.scheme?.lowercase() ?: return null
    val host = uri.host ?: return null
    if (host.isEmpty()) return null
    val serializedHost = if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
    val portPart = if (uri.port != -1) ":${uri.port}" else ""
    val prefix = "$scheme://$serializedHost$portPart/projects/diff?path=${percentEncode(path)}"
    val trimmedBase = base?.trim()
    return if (!trimmedBase.isNullOrEmpty()) {
        "$prefix&base=${percentEncode(trimmedBase)}" // base mode: no staged (server ignores it)
    } else {
        "$prefix&staged=${if (staged) "1" else "0"}"
    }
}

/** Strict RFC 3986 unreserved set — everything else percent-encoded over UTF-8 (mirror of Endpoints). */
private val UNRESERVED: Set<Char> =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~".toSet()

private val HEX = "0123456789ABCDEF".toCharArray()

private fun percentEncode(value: String): String {
    val sb = StringBuilder()
    for (byte in value.encodeToByteArray()) {
        val code = byte.toInt() and 0xFF
        val ch = code.toChar()
        if (ch in UNRESERVED) {
            sb.append(ch)
        } else {
            sb.append('%').append(HEX[code ushr 4]).append(HEX[code and 0x0F])
        }
    }
    return sb.toString()
}
