package wang.yaojia.webterm.api.routes

import wang.yaojia.webterm.api.models.CommitResult
import wang.yaojia.webterm.api.models.CreateWorktreeResult
import wang.yaojia.webterm.api.models.GitLogResult
import wang.yaojia.webterm.api.models.GitWriteOutcome
import wang.yaojia.webterm.api.models.HookDecision
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.LossyDecode
import wang.yaojia.webterm.api.models.PrStatus
import wang.yaojia.webterm.api.models.ProjectDetail
import wang.yaojia.webterm.api.models.ProjectInfo
import wang.yaojia.webterm.api.models.PruneWorktreesResult
import wang.yaojia.webterm.api.models.PushResult
import wang.yaojia.webterm.api.models.RemoveWorktreeResult
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.models.StageResult
import wang.yaojia.webterm.api.models.UiConfig
import wang.yaojia.webterm.api.models.UiPrefs
import wang.yaojia.webterm.api.models.decodeGitError
import wang.yaojia.webterm.api.models.decodeGitPayload
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
import wang.yaojia.webterm.wire.TimelineEvent
import java.util.UUID

/**
 * Typed client for the server's HTTP surface (12 frozen routes). Pure — consumes [HttpTransport]
 * by interface (no OkHttp/Android); `:transport-okhttp` (A7) provides the real impl, the
 * `:test-support` fake queues canned responses.
 *
 * **Origin 铁律 (CSWSH, plan §4.3):** only the guarded (state-changing) routes stamp
 * `Origin: endpoint.originHeader`; the read-only GETs never do. Stamping lives in ONE place
 * ([ApiRoute.toHttpRequest]) and the value is single-point-derived by [HostEndpoint].
 *
 * The server is UNTRUSTED at this boundary: bodies decode tolerantly (malformed entries dropped),
 * statuses map to explicit [ApiClientError]s, and nothing here crashes on bad input.
 */
public class ApiClient(
    public val endpoint: HostEndpoint,
    private val http: HttpTransport,
) {
    // ── RO (read-only — NO Origin header) ──────────────────────────────────────────────────

    /** `GET /live-sessions` — the discovery list every device polls. */
    public suspend fun liveSessions(): List<LiveSessionInfo> {
        val response = perform(Endpoints.liveSessions())
        if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
        return LossyDecode.listOrNull(response.body, LiveSessionInfo.serializer())
            ?: throw ApiClientError.InvalidResponseBody
    }

    /** `GET /live-sessions/:id/preview` — ring-buffer tail for a read-only thumbnail (no attach). */
    public suspend fun preview(id: UUID): SessionPreview {
        val response = perform(Endpoints.preview(id))
        requireOk(response)
        return LossyDecode.objectOrNull(response.body, SessionPreview.serializer())
            ?: throw ApiClientError.InvalidResponseBody
    }

    /** `GET /live-sessions/:id/events` — the activity timeline. A non-array body (timeline capture
     *  disabled) → `[]`; unknown-class entries survive shape-decode and are filtered downstream. */
    public suspend fun events(id: UUID): List<TimelineEvent> {
        val response = perform(Endpoints.events(id))
        requireOk(response)
        return LossyDecode.listOrEmpty(response.body, TimelineEvent.serializer())
    }

    /** `GET /config/ui` — `{ allowAutoMode }`. */
    public suspend fun uiConfig(): UiConfig {
        val response = perform(Endpoints.uiConfig())
        requireOk(response)
        return LossyDecode.objectOrNull(response.body, UiConfig.serializer())
            ?: throw ApiClientError.InvalidResponseBody
    }

    /** `GET /projects` — discovered projects with their running sessions merged in. */
    public suspend fun projects(): List<ProjectInfo> {
        val response = perform(Endpoints.projects())
        if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
        return LossyDecode.listOrNull(response.body, ProjectInfo.serializer())
            ?: throw ApiClientError.InvalidResponseBody
    }

    /** `GET /projects/detail?path=` — branch/worktrees/CLAUDE.md for one project. An empty path is
     *  rejected client-side (mirror of the server's 400) before any network I/O. */
    public suspend fun projectDetail(path: String): ProjectDetail {
        if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
        val response = perform(Endpoints.projectDetail(path))
        return when (response.status) {
            HttpStatus.OK -> LossyDecode.objectOrNull(response.body, ProjectDetail.serializer())
                ?: throw ApiClientError.InvalidResponseBody
            HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
            HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
            HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.ProjectDetailUnavailable
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    /**
     * `GET /projects/pr?path=` — PR + CI status for the project's current branch. The PR *degrade*
     * (gh missing / unauth / no-PR / disabled) is `availability` inside a **200** body, NOT an HTTP
     * status — so every valid git dir returns 200 and the chip renders from [PrStatus.availability].
     * A garbled body degrades to `availability=ERROR` (tolerant decode). 400→path invalid; 404→not a
     * repo. Empty path rejected client-side before any I/O.
     */
    public suspend fun projectPr(path: String): PrStatus {
        if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
        val response = perform(Endpoints.projectPr(path))
        return when (response.status) {
            HttpStatus.OK -> LossyDecode.objectOrNull(response.body, PrStatus.serializer())
                ?: PrStatus() // availability defaults to ERROR — never throw on a bad PR body
            HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
            HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    /**
     * `GET /projects/log?path=[&n=]` — recent commits (list-lossy: malformed commits dropped). 400→
     * path invalid; 404→not a repo; 500→[ApiClientError.GitLogUnavailable]. Empty path rejected
     * client-side before any I/O; `n` is clamped in the route builder.
     */
    public suspend fun projectLog(path: String, n: Int? = null): GitLogResult {
        if (path.isEmpty()) throw ApiClientError.ProjectPathInvalid
        val response = perform(Endpoints.projectLog(path, n))
        return when (response.status) {
            HttpStatus.OK -> LossyDecode.objectOrNull(response.body, GitLogResult.serializer())
                ?: throw ApiClientError.InvalidResponseBody
            HttpStatus.BAD_REQUEST -> throw ApiClientError.ProjectPathInvalid
            HttpStatus.NOT_FOUND -> throw ApiClientError.ProjectNotFound
            HttpStatus.INTERNAL_SERVER_ERROR -> throw ApiClientError.GitLogUnavailable
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    /** `GET /prefs` — the cross-device favourites/collapse blob. A non-object body throws
     *  `InvalidResponseBody` (never silently degrades — an empty-based PUT would wipe the blob). */
    public suspend fun prefs(): UiPrefs {
        val response = perform(Endpoints.getPrefs())
        if (response.status != HttpStatus.OK) throw ApiClientError.UnexpectedStatus(response.status)
        return UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
    }

    // ── G (state-changing — Origin required, byte-equal) ───────────────────────────────────

    /** `DELETE /live-sessions/:id`. 204 = success; 404 = already gone (also success on the server,
     *  but iOS surfaces it as `SessionNotFound` — matched here). */
    public suspend fun killSession(id: UUID) {
        val response = perform(Endpoints.killSession(id))
        when (response.status) {
            HttpStatus.NO_CONTENT -> Unit
            HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
            HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    /** `POST /hook/decision` — resolve a held remote approval with a single-use `token` (push
     *  payload only; NEVER persist it). 403 → stale/mismatched token; 429 → rate-limited. */
    public suspend fun hookDecision(sessionId: UUID, decision: HookDecision, token: String) {
        val response = perform(Endpoints.hookDecision(sessionId, decision, token))
        when (response.status) {
            HttpStatus.NO_CONTENT -> Unit
            HttpStatus.FORBIDDEN -> throw ApiClientError.DecisionRejected
            HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    /** `PUT /prefs` — replace the whole blob. Returns the server's sanitized echo — treat IT as the
     *  new source of truth, not the input. 403 = Origin guard. */
    public suspend fun putPrefs(prefs: UiPrefs): UiPrefs {
        val response = perform(Endpoints.putPrefs(prefs))
        return when (response.status) {
            HttpStatus.OK -> UiPrefs.decode(response.body) ?: throw ApiClientError.InvalidResponseBody
            HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    // ── G: git-write ops (worktree + git stage/commit/push) → GitWriteOutcome ──────────────

    /** `POST /projects/worktree` — create a worktree for `branch` (off optional `base`). */
    public suspend fun createWorktree(path: String, branch: String, base: String? = null): GitWriteOutcome<CreateWorktreeResult> =
        gitWrite(Endpoints.createWorktree(path, branch, base), CreateWorktreeResult.serializer())

    /** `DELETE /projects/worktree` — remove a worktree (409 "uncommitted" unless `force`). */
    public suspend fun removeWorktree(path: String, worktreePath: String, force: Boolean = false): GitWriteOutcome<RemoveWorktreeResult> =
        gitWrite(Endpoints.removeWorktree(path, worktreePath, force), RemoveWorktreeResult.serializer())

    /** `POST /projects/worktree/prune` — reclaim stale worktrees (idempotent). */
    public suspend fun pruneWorktrees(path: String): GitWriteOutcome<PruneWorktreesResult> =
        gitWrite(Endpoints.pruneWorktrees(path), PruneWorktreesResult.serializer())

    /** `POST /projects/git/stage` — stage (`stage=true`) or unstage the given files. */
    public suspend fun gitStage(path: String, files: List<String>, stage: Boolean = true): GitWriteOutcome<StageResult> =
        gitWrite(Endpoints.gitStage(path, files, stage), StageResult.serializer())

    /** `POST /projects/git/commit` — commit the staged changes (empty sha possible). */
    public suspend fun gitCommit(path: String, message: String): GitWriteOutcome<CommitResult> =
        gitWrite(Endpoints.gitCommit(path, message), CommitResult.serializer())

    /** `POST /projects/git/push` — push the current branch to its upstream (tighter rate limit). */
    public suspend fun gitPush(path: String): GitWriteOutcome<PushResult> =
        gitWrite(Endpoints.gitPush(path), PushResult.serializer())

    /**
     * Shared guarded-write dispatch + status mapping (plan §4.3): 200→[GitWriteOutcome.Ok] with the
     * decoded payload; 429→[GitWriteOutcome.RateLimited]; any other 4xx/5xx→[GitWriteOutcome.Rejected]
     * carrying the server's SAFE `error` string (403 is overloaded — Origin-guard AND disabled
     * kill-switch both 403 — so the message, not a typed variant, is surfaced). A non-HTTP status
     * (e.g. an odd 2xx/3xx) is [ApiClientError.UnexpectedStatus].
     */
    private suspend fun <T> gitWrite(route: ApiRoute, serializer: kotlinx.serialization.KSerializer<T>): GitWriteOutcome<T> {
        val response = perform(route)
        return when (response.status) {
            HttpStatus.OK -> GitWriteOutcome.Ok(decodeGitPayload(response.body, serializer))
            HttpStatus.TOO_MANY_REQUESTS -> GitWriteOutcome.RateLimited
            in HttpStatus.CLIENT_ERROR_MIN..HttpStatus.SERVER_ERROR_MAX ->
                GitWriteOutcome.Rejected(response.status, decodeGitError(response.body))
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    /** `POST /push/fcm-token` — register this device's FCM token (idempotent upsert → 204). Invalid
     *  tokens are rejected client-side (`InvalidFcmToken`) before any network I/O. */
    public suspend fun registerFcmToken(token: String) {
        sendFcmToken(token, Endpoints::registerFcmToken)
    }

    /** `DELETE /push/fcm-token` — unregister (idempotent → 204 even for an unknown token). */
    public suspend fun unregisterFcmToken(token: String) {
        sendFcmToken(token, Endpoints::unregisterFcmToken)
    }

    private suspend fun sendFcmToken(token: String, build: (String) -> ApiRoute) {
        val normalized = FcmTokenRule.normalize(token) ?: throw ApiClientError.InvalidFcmToken
        val response = perform(build(normalized))
        when (response.status) {
            HttpStatus.NO_CONTENT -> Unit
            HttpStatus.BAD_REQUEST -> throw ApiClientError.InvalidFcmToken
            HttpStatus.FORBIDDEN -> throw ApiClientError.Forbidden
            HttpStatus.TOO_MANY_REQUESTS -> throw ApiClientError.RateLimited
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }

    // ── Internals ──────────────────────────────────────────────────────────────────────────

    private suspend fun perform(route: ApiRoute): HttpResponse {
        val request = route.toHttpRequest(endpoint) ?: throw ApiClientError.InvalidRequest
        return http.send(request)
    }

    /** 200 → ok; 404 → `SessionNotFound`; anything else → `UnexpectedStatus`. */
    private fun requireOk(response: HttpResponse) {
        when (response.status) {
            HttpStatus.OK -> Unit
            HttpStatus.NOT_FOUND -> throw ApiClientError.SessionNotFound
            else -> throw ApiClientError.UnexpectedStatus(response.status)
        }
    }
}
