TaigaClient.kt

package de.werkbaum.integration.taiga

import org.springframework.core.ParameterizedTypeReference
import org.springframework.http.MediaType
import org.springframework.http.client.JdkClientHttpRequestFactory
import org.springframework.stereotype.Service
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import java.net.URLEncoder
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.concurrent.Callable
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Semaphore

/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
class TaigaNotConfiguredException :
    RuntimeException("Keine Taiga-Instanz konfiguriert (werkbaum.taiga.api-url)")

/** Taiga nicht erreichbar oder mit unbrauchbarer Antwort (502). */
class TaigaUnavailableException(message: String, cause: Throwable? = null) :
    RuntimeException(message, cause)

/**
 * Taiga hat mit einem Fehlerstatus geantwortet. 4xx wird durchgereicht
 * (Taiga meldet z. B. falsche Zugangsdaten als 400), 5xx wird zu 502 —
 * ein fremder Serverfehler ist aus Client-Sicht „Upstream kaputt“.
 */
class TaigaUpstreamException(val status: Int, message: String) : RuntimeException(message)

data class TaigaSessionData(
    val authToken: String,
    val userId: Long,
    val username: String,
    val fullName: String?,
)

data class TaigaProjectData(val id: Long, val name: String, val slug: String)

data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)

/**
 * Der gelesene Stand eines Tickets (D91-Nachtrag 6). Status und Zuständiger
 * kommen aus Taigas `*_extra_info`-Blöcken und sind **nullbar**: Liefert die
 * Instanz sie nicht mit, fehlt die Zeile im Knoten-Fenster — geraten wird
 * nicht.
 */
data class TaigaTicketDetailData(
    val id: Long,
    val ref: Long,
    val subject: String,
    val status: String?,
    val statusClosed: Boolean?,
    val assignee: String?,
    /** Taigas optimistische Sperre; geht beim Schreiben unverändert zurück. */
    val version: Long?,
)

/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */
data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)

/**
 * Eine Ref der Bulk-Abfrage (D91-Nachtrag 10): `key` ist die Werkbaum-Ref
 * (`US-123`), unter der die Antwort zurueckgeht; `task` und `nr` sind das
 * zerlegte Praefix — der Typ steht in der Ref, genau dafuer schreibt
 * Werkbaum ihn (SPEC par. 11).
 */
data class TaigaBulkRefData(val key: String, val task: Boolean, val nr: Long)

/** Eine Anfrage, die schon der Proxy ablehnt (400) — kein Taiga-Fehler. */
class TaigaBadRequestException(message: String) : RuntimeException(message)

/**
 * Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
 * Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
 *
 * Das Token kommt je Aufruf vom Browser herein und geht als
 * `Authorization: Bearer …` hinaus; der Server **speichert nichts** und
 * **loggt keine Request-Bodies** (der Auth-Endpunkt sieht das Passwort nur
 * im Durchflug). Die Antworten werden als Maps gelesen und auf die schmalen
 * Datenklassen abgebildet — so hängt nichts an Taigas übrigen Feldern.
 */
@Service
class TaigaClient(private val properties: TaigaProperties) {

    /* Der Faecher der Bulk-Abfrage: virtuelle Threads (praktisch kostenlos,
       D76-Nachtrag 5), die Semaphore deckelt die GLEICHZEITIGEN Anfragen an
       die fremde Instanz — Parallelitaet ja, Hammer nein. */
    private val bulkPool = Executors.newVirtualThreadPerTaskExecutor()

    private val rest: RestClient = RestClient.builder()
        .requestFactory(
            JdkClientHttpRequestFactory(
                HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build()
            ).apply { setReadTimeout(Duration.ofSeconds(20)) }
        )
        .build()

    fun login(username: String, password: String): TaigaSessionData {
        val map = exchange {
            rest.post().uri(url("/auth"))
                .contentType(MediaType.APPLICATION_JSON)
                .body(mapOf("type" to properties.authType, "username" to username, "password" to password))
                .retrieve().body(MAP)
        } ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/auth)")
        return TaigaSessionData(
            authToken = str(map, "auth_token"),
            userId = num(map, "id"),
            username = str(map, "username"),
            fullName = map["full_name"] as? String,
        )
    }

    fun projects(token: String, member: Long): List<TaigaProjectData> {
        val list = exchange {
            rest.get().uri(url("/projects?member=$member&order_by=user_order"))
                .header("Authorization", "Bearer $token")
                // Taiga paginiert sonst bei 30 — die Projektliste eines
                // Nutzers soll vollständig sein.
                .header("x-disable-pagination", "1")
                .retrieve().body(LIST)
        } ?: emptyList()
        return list.map { TaigaProjectData(num(it, "id"), str(it, "name"), str(it, "slug")) }
    }

    fun createStory(token: String, project: Long, subject: String): TaigaTicketData =
        create(token, "/userstories", mapOf("project" to project, "subject" to subject))

    fun createTask(token: String, project: Long, subject: String, userStory: Long): TaigaTicketData =
        // Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
        create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))

    /**
     * Ein Ticket über seine **Ref** lesen (D91-Nachtrag 6). Zwei Schritte,
     * weil eine Ref nur je Projekt eindeutig ist: erst der Projekt-Slug zur
     * Id (`/projects/by_slug`), dann `by_ref` am passenden Endpunkt. Beide
     * sind dokumentierte Taiga-Endpunkte — der eine gesparte Umlauf über
     * `project__slug` wäre eine Wette auf eine Filter-Eigenheit gewesen.
     *
     * `task` unterscheidet die beiden Typen; welcher gemeint ist, weiß der
     * Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
     * nur, wohin gefragt wird.
     */
    fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData =
        detailByRef(token, projectId(token, slug), ref, task)

    private fun detailByRef(token: String, project: Long, ref: Long, task: Boolean): TaigaTicketDetailData {
        val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
        val map = exchange {
            rest.get().uri(url("$pfad?project=$project&ref=$ref"))
                .header("Authorization", "Bearer $token")
                .retrieve().body(MAP)
        } ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)")
        return detail(map)
    }

    /**
     * Viele Tickets auf einmal (D91-Nachtrag 10): EIN Aufruf vom Browser, der
     * Proxy fächert in `by_ref`-Einzelabfragen auf — parallel, mit
     * **gedeckelter** Nebenläufigkeit (Höflichkeit gegenüber der fremden
     * Instanz; Backend und Taiga sitzen beim selben Hoster, die Einzelabfrage
     * kostet dort Millisekunden). Die Kosten skalieren mit den Refs im Plan,
     * nie mit der Projektgröße — eine Projekt-Volliste holte bei Tausenden
     * Tickets Megabytes, um fast alles wegzuwerfen.
     *
     * Eine Ref, die es nicht (mehr) gibt (Taiga-404), **fehlt still** in der
     * Antwort — der Rest kommt trotzdem; jeder andere Fehler (401, Projekt
     * unbekannt, Instanz weg) bricht die ganze Anfrage ab, denn er beträfe
     * ohnehin jede einzelne Ref.
     */
    fun tickets(token: String, slug: String, refs: List<TaigaBulkRefData>): Map<String, TaigaTicketDetailData> {
        val project = projectId(token, slug)
        val sem = Semaphore(BULK_CONCURRENCY)
        val futures = refs.map { r ->
            bulkPool.submit(Callable {
                sem.acquire()
                try {
                    r.key to detailByRef(token, project, r.nr, r.task)
                } finally {
                    sem.release()
                }
            })
        }
        val out = LinkedHashMap<String, TaigaTicketDetailData>()
        for (f in futures) {
            try {
                val (key, wert) = f.get()
                out[key] = wert
            } catch (e: ExecutionException) {
                val grund = e.cause
                if (grund is TaigaUpstreamException && grund.status == 404) continue
                throw grund ?: e
            }
        }
        return out
    }

    /**
     * Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach
     * Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint
     * ist, entscheidet der Editor: Er kennt die Abbildung auf die Statusbox
     * (SPEC §4), das Backend parst die Notation nicht (D14).
     */
    fun statuses(token: String, slug: String, task: Boolean): List<TaigaStatusData> {
        val project = projectId(token, slug)
        val pfad = if (task) "/task-statuses" else "/userstory-statuses"
        val list = exchange {
            rest.get().uri(url("$pfad?project=$project"))
                .header("Authorization", "Bearer $token")
                .retrieve().body(LIST)
        } ?: emptyList()
        return list.map {
            TaigaStatusData(num(it, "id"), str(it, "name"), it["is_closed"] as? Boolean)
        }
    }

    /**
     * Den Status eines Tickets setzen (D91-Nachtrag 8). Die `version` kommt
     * vom Client — sie ist die, die er gelesen hat: Taigas optimistische
     * Sperre lehnt das Schreiben ab, wenn jemand dazwischen geändert hat, und
     * der Konflikt wird durchgereicht statt überschrieben (dieselbe Haltung
     * wie beim Live-Editing, D76).
     */
    fun setStatus(
        token: String,
        slug: String,
        ref: Long,
        task: Boolean,
        status: Long,
        version: Long,
    ): TaigaTicketDetailData {
        val id = ticket(token, slug, ref, task).id
        val pfad = if (task) "/tasks/$id" else "/userstories/$id"
        val map = exchange {
            rest.patch().uri(url(pfad))
                .header("Authorization", "Bearer $token")
                .contentType(MediaType.APPLICATION_JSON)
                .body(mapOf("status" to status, "version" to version))
                .retrieve().body(MAP)
        } ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)")
        return detail(map)
    }

    private fun detail(map: Map<String, Any?>) = TaigaTicketDetailData(
        id = num(map, "id"),
        ref = num(map, "ref"),
        subject = str(map, "subject"),
        status = extra(map, "status_extra_info")?.get("name") as? String,
        statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean,
        assignee = extra(map, "assigned_to_extra_info")?.get("full_name_display") as? String,
        version = (map["version"] as? Number)?.toLong(),
    )

    /**
     * Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt
     * vom Client und wird deshalb **kodiert** in die Anfrage gesetzt — sonst
     * hängte ein `&` daran einen weiteren Filter an.
     */
    fun projectId(token: String, slug: String): Long {
        val map = exchange {
            rest.get().uri(url("/projects/by_slug?slug=" + enc(slug)))
                .header("Authorization", "Bearer $token")
                .retrieve().body(MAP)
        } ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/projects/by_slug)")
        return num(map, "id")
    }

    @Suppress("UNCHECKED_CAST")
    private fun extra(m: Map<String, Any?>, key: String): Map<String, Any?>? =
        m[key] as? Map<String, Any?>

    private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
        val map = exchange {
            rest.post().uri(url(path))
                .header("Authorization", "Bearer $token")
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve().body(MAP)
        } ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($path)")
        return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
    }

    private fun enc(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)

    private fun url(path: String): String {
        if (!properties.configured) throw TaigaNotConfiguredException()
        return properties.apiUrl.trimEnd('/') + path
    }

    private fun <T> exchange(call: () -> T): T =
        try {
            call()
        } catch (e: RestClientResponseException) {
            // Der Fehlertext kommt aus Taigas ANTWORT (`_error_message`) —
            // nie aus der Anfrage; Zugangsdaten stehen darin nicht.
            throw TaigaUpstreamException(e.statusCode.value(), errorMessage(e))
        } catch (e: ResourceAccessException) {
            throw TaigaUnavailableException("Taiga-Instanz nicht erreichbar: ${e.message}", e)
        }

    private fun errorMessage(e: RestClientResponseException): String {
        val fromBody = Regex("\"_error_message\"\\s*:\\s*\"([^\"]*)\"")
            .find(e.responseBodyAsString)?.groupValues?.get(1)
        return fromBody ?: "Taiga antwortete mit ${e.statusCode.value()}"
    }

    private fun str(m: Map<String, Any?>, key: String): String =
        m[key] as? String
            ?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")

    private fun num(m: Map<String, Any?>, key: String): Long =
        (m[key] as? Number)?.toLong()
            ?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")

    companion object {
        /** Hoechstens so viele gleichzeitige Upstream-Anfragen je Bulk-Aufruf. */
        private const val BULK_CONCURRENCY = 6
        private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
        private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
    }
}