package wang.yaojia.webterm.api.routes

import kotlinx.serialization.Serializable
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.ModelJson
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.wire.HttpMethod
import java.util.UUID

/**
 * Builders for the frozen endpoints (12 routes, verified against `src/http/…` + iOS `Endpoints` /
 * `Prefs` / `Projects` / `ApnsToken`). The apns-token pair is ported as the Android **fcm-token**
 * pair (this client uses FCM, not APNs/VAPID).
 *
 * RO (no Origin): `GET /live-sessions` · `.../:id/preview` · `.../:id/events` · `GET /config/ui`
 *                 · `GET /projects` · `GET /projects/detail?path=` · `GET /prefs`
 * G  (Origin byte-equal): `DELETE /live-sessions/:id` · `POST /hook/decision` · `PUT /prefs`
 *                 · `POST|DELETE /push/fcm-token`
 *
 * KNOWN WIRE-PARITY GAP (intentional, not drift): the `POST|DELETE /push/fcm-token` pair is AHEAD
 * of the server. Its server route is delivered by plan task **A33** (`src/push/fcm.ts` +
 * `/push/fcm-token`), which is still PENDING — so FCM push is non-functional against the current
 * server until A33 lands. The client builders exist now so the token lifecycle is ready the moment
 * the server route ships; do not "fix" this as a mismatch.
 */
internal object Endpoints {
    // ── RO ───────────────────────────────────────────────────────────────────────────────

    fun liveSessions(): ApiRoute =
        ApiRoute(HttpMethod.GET, "/live-sessions", OriginPolicy.READ_ONLY)

    fun preview(id: UUID): ApiRoute =
        ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/preview", OriginPolicy.READ_ONLY)

    fun events(id: UUID): ApiRoute =
        ApiRoute(HttpMethod.GET, "/live-sessions/${pathId(id)}/events", OriginPolicy.READ_ONLY)

    fun uiConfig(): ApiRoute =
        ApiRoute(HttpMethod.GET, "/config/ui", OriginPolicy.READ_ONLY)

    fun projects(): ApiRoute =
        ApiRoute(HttpMethod.GET, "/projects", OriginPolicy.READ_ONLY)

    /**
     * `GET /projects/detail?path=` — RO. The ONE place `path` gets percent-encoded, with a strict
     * RFC 3986 unreserved-only set (deliberately stricter than URL-query-allowed: a bare `+` decodes
     * to a SPACE in Express's qs parser, and `&`/`=` would split the parameter).
     */
    fun projectDetail(path: String): ApiRoute =
        ApiRoute(
            HttpMethod.GET,
            "/projects/detail",
            OriginPolicy.READ_ONLY,
            percentEncodedQuery = "path=${percentEncode(path)}",
        )

    fun getPrefs(): ApiRoute =
        ApiRoute(HttpMethod.GET, "/prefs", OriginPolicy.READ_ONLY)

    /** `GET /projects/pr?path=` — RO PR + CI status. `path` strict-percent-encoded (as detail). */
    fun projectPr(path: String): ApiRoute =
        ApiRoute(
            HttpMethod.GET,
            "/projects/pr",
            OriginPolicy.READ_ONLY,
            percentEncodedQuery = "path=${percentEncode(path)}",
        )

    /**
     * `GET /projects/log?path=[&n=<int>]` — RO recent-commit log. `n` is clamped client-side to
     * `1..GIT_LOG_MAX` (the server re-clamps regardless); a null/out-of-range `n` omits the param.
     */
    fun projectLog(path: String, n: Int?): ApiRoute {
        val query = StringBuilder("path=").append(percentEncode(path))
        if (n != null) {
            val clamped = n.coerceIn(1, GIT_LOG_MAX)
            query.append("&n=").append(clamped)
        }
        return ApiRoute(HttpMethod.GET, "/projects/log", OriginPolicy.READ_ONLY, percentEncodedQuery = query.toString())
    }

    // ── G ────────────────────────────────────────────────────────────────────────────────

    fun killSession(id: UUID): ApiRoute =
        ApiRoute(HttpMethod.DELETE, "/live-sessions/${pathId(id)}", OriginPolicy.GUARDED)

    /** Body is exactly `{ sessionId, decision, token }`. `token` is single-use (push payload only) —
     *  callers must never persist/log it. */
    fun hookDecision(sessionId: UUID, decision: HookDecision, token: String): ApiRoute {
        val body = ModelJson.encodeToString(
            HookDecisionBody.serializer(),
            HookDecisionBody(pathId(sessionId), decision.wire, token),
        ).encodeToByteArray()
        return ApiRoute(HttpMethod.POST, "/hook/decision", OriginPolicy.GUARDED, body = body)
    }

    /** `PUT /prefs` — G. Replaces the whole blob (server echoes a sanitized 200). */
    fun putPrefs(prefs: UiPrefs): ApiRoute =
        ApiRoute(HttpMethod.PUT, "/prefs", OriginPolicy.GUARDED, body = prefs.encodeBody())

    fun registerFcmToken(normalized: String): ApiRoute =
        fcmTokenRoute(HttpMethod.POST, normalized)

    fun unregisterFcmToken(normalized: String): ApiRoute =
        fcmTokenRoute(HttpMethod.DELETE, normalized)

    private fun fcmTokenRoute(method: HttpMethod, normalized: String): ApiRoute {
        val body = ModelJson.encodeToString(
            FcmTokenBody.serializer(),
            FcmTokenBody(normalized),
        ).encodeToByteArray()
        return ApiRoute(method, FCM_TOKEN_PATH, OriginPolicy.GUARDED, body = body)
    }

    private const val FCM_TOKEN_PATH = "/push/fcm-token"

    // ── G: worktree write (create / remove / prune) ────────────────────────────────────────

    /** `POST /projects/worktree` — `{ path, branch[, base] }`. `base` omitted when null. */
    fun createWorktree(path: String, branch: String, base: String?): ApiRoute =
        jsonBodyRoute(
            HttpMethod.POST,
            "/projects/worktree",
            CreateWorktreeBody.serializer(),
            CreateWorktreeBody(path, branch, base),
        )

    /** `DELETE /projects/worktree` — `{ path, worktreePath, force }` (DELETE **with** a JSON body). */
    fun removeWorktree(path: String, worktreePath: String, force: Boolean): ApiRoute =
        jsonBodyRoute(
            HttpMethod.DELETE,
            "/projects/worktree",
            RemoveWorktreeBody.serializer(),
            RemoveWorktreeBody(path, worktreePath, force),
        )

    /** `POST /projects/worktree/prune` — `{ path }`. */
    fun pruneWorktrees(path: String): ApiRoute =
        jsonBodyRoute(HttpMethod.POST, "/projects/worktree/prune", PruneBody.serializer(), PruneBody(path))

    // ── G: git write (stage / commit / push) ───────────────────────────────────────────────

    /** `POST /projects/git/stage` — `{ path, files, stage }`. */
    fun gitStage(path: String, files: List<String>, stage: Boolean): ApiRoute =
        jsonBodyRoute(HttpMethod.POST, "/projects/git/stage", StageBody.serializer(), StageBody(path, files, stage))

    /** `POST /projects/git/commit` — `{ path, message }`. */
    fun gitCommit(path: String, message: String): ApiRoute =
        jsonBodyRoute(HttpMethod.POST, "/projects/git/commit", CommitBody.serializer(), CommitBody(path, message))

    /** `POST /projects/git/push` — `{ path }`. */
    fun gitPush(path: String): ApiRoute =
        jsonBodyRoute(HttpMethod.POST, "/projects/git/push", PushBody.serializer(), PushBody(path))

    /** Build a GUARDED route with a `ModelJson`-encoded JSON body (Origin stamped in [ApiRoute]). */
    private fun <T> jsonBodyRoute(
        method: HttpMethod,
        path: String,
        serializer: kotlinx.serialization.KSerializer<T>,
        value: T,
    ): ApiRoute {
        val body = ModelJson.encodeToString(serializer, value).encodeToByteArray()
        return ApiRoute(method, path, OriginPolicy.GUARDED, body = body)
    }

    /** Mirror of `src/http/git-log.ts` `GIT_LOG_MAX` — the server-side `?n=` clamp ceiling. */
    private const val GIT_LOG_MAX = 50

    /**
     * Server session ids are lowercase `crypto.randomUUID()` strings and `:id` route params are
     * matched as EXACT strings — always serialize lowercase. `UUID.toString()` is already lowercase
     * on the JVM (unlike Swift's uppercase `uuidString`).
     */
    private fun pathId(id: UUID): String = id.toString()

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

    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()
    }

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

    @Serializable
    private data class HookDecisionBody(val sessionId: String, val decision: String, val token: String)

    @Serializable
    private data class FcmTokenBody(val token: String)

    @Serializable
    private data class CreateWorktreeBody(val path: String, val branch: String, val base: String? = null)

    @Serializable
    private data class RemoveWorktreeBody(val path: String, val worktreePath: String, val force: Boolean)

    @Serializable
    private data class PruneBody(val path: String)

    @Serializable
    private data class StageBody(val path: String, val files: List<String>, val stage: Boolean)

    @Serializable
    private data class CommitBody(val path: String, val message: String)

    @Serializable
    private data class PushBody(val path: String)
}
