package ru.forbion.f7cloud.feature.notes

import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject

/**
 * Клиент публичного Notes API v1 (`/index.php/apps/notes/api/v1`). Стабильный контракт для
 * сторонних клиентов (его же использует официальный Nextcloud Notes Android): ETag на
 * коллекцию и заметку, `If-None-Match` для дешёвого рефреша, `exclude=content` для лёгкого
 * списка, `If-Match` для конфликтов при записи (mail/071, карта app-map/notes.md).
 *
 * `client` инъектируется (в проде — [ru.forbion.f7cloud.core.network.NetworkFactory]
 * .newAuthedClient), что делает клиент юнит-тестируемым через MockWebServer — пишущие
 * сценарии проверяются юнитами, НЕ на боевом forbion (mail/073).
 */
class NotesApiClient(
    private val client: OkHttpClient,
    serverUrl: String,
) {
    private val apiBase = serverUrl.trimEnd('/') + "/index.php/apps/notes/api/v1"

    /** Результат листинга: заметки + ETag коллекции (для последующего If-None-Match). */
    data class ListResult(val notes: List<Note>, val etag: String, val notModified: Boolean = false)

    /**
     * GET /notes — список. [excludeContent] → `exclude=content` (лёгкий список).
     * [pruneBefore] → инкрементальный синк (только изменённые после метки). [ifNoneMatch] —
     * ETag коллекции; при 304 возвращается [ListResult.notModified] = true с пустым списком.
     */
    fun list(
        excludeContent: Boolean = true,
        pruneBefore: Long? = null,
        ifNoneMatch: String? = null,
    ): ListResult {
        val params = buildList {
            if (excludeContent) add("exclude=content")
            if (pruneBefore != null) add("pruneBefore=$pruneBefore")
        }
        val url = apiBase + "/notes" + if (params.isEmpty()) "" else "?" + params.joinToString("&")
        val req = Request.Builder()
            .url(url)
            .header("Accept", "application/json")
            .apply { if (!ifNoneMatch.isNullOrBlank()) header("If-None-Match", ifNoneMatch) }
            .build()
        client.newCall(req).execute().use { resp ->
            if (resp.code == HTTP_NOT_MODIFIED) {
                return ListResult(emptyList(), ifNoneMatch.orEmpty(), notModified = true)
            }
            if (!resp.isSuccessful) error("Notes list HTTP ${resp.code}")
            val body = resp.body?.string().orEmpty()
            val notes = Note.listFromJson(JSONArray(if (body.isBlank()) "[]" else body))
            return ListResult(notes, resp.header("ETag").orEmpty())
        }
    }

    /** GET /notes/{id} — одна заметка (с содержимым). */
    fun get(id: Long): Note {
        val req = Request.Builder()
            .url("$apiBase/notes/$id")
            .header("Accept", "application/json")
            .build()
        client.newCall(req).execute().use { resp ->
            if (!resp.isSuccessful) error("Notes get HTTP ${resp.code}")
            return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
        }
    }

    /** POST /notes — создать заметку. */
    fun create(content: String, category: String = ""): Note {
        val payload = JSONObject().put("content", content)
        if (category.isNotEmpty()) payload.put("category", category)
        val req = Request.Builder()
            .url("$apiBase/notes")
            .header("Accept", "application/json")
            .post(payload.toString().toRequestBody(JSON))
            .build()
        client.newCall(req).execute().use { resp ->
            if (!resp.isSuccessful) error("Notes create HTTP ${resp.code}")
            return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
        }
    }

    /**
     * PUT /notes/{id} с телом `content` и заголовком `If-Match: "<etag>"`. На 412 — конфликт
     * версий: перечитываем серверную версию и бросаем [NoteConflictException] (НЕ молчаливо
     * перезаписываем — mail/071 п.2).
     */
    fun saveContent(note: Note): Note {
        val payload = JSONObject().put("content", note.content)
        val builder = Request.Builder()
            .url("$apiBase/notes/${note.id}")
            .header("Accept", "application/json")
            .put(payload.toString().toRequestBody(JSON))
        if (note.etag.isNotBlank()) builder.header("If-Match", "\"${note.etag}\"")
        client.newCall(builder.build()).execute().use { resp ->
            if (resp.code == HTTP_PRECONDITION_FAILED) {
                val server = runCatching { get(note.id) }.getOrNull()
                throw NoteConflictException(note.id, server)
            }
            if (!resp.isSuccessful) error("Notes save HTTP ${resp.code}")
            return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
        }
    }

    /** PUT /notes/{id} — обновить метаданные (favorite/category/title) без If-Match. */
    private fun patchMeta(id: Long, field: String, value: Any): Note {
        val payload = JSONObject().put(field, value)
        val req = Request.Builder()
            .url("$apiBase/notes/$id")
            .header("Accept", "application/json")
            .put(payload.toString().toRequestBody(JSON))
            .build()
        client.newCall(req).execute().use { resp ->
            if (!resp.isSuccessful) error("Notes patch HTTP ${resp.code}")
            return Note.fromJson(JSONObject(resp.body?.string().orEmpty()))
        }
    }

    fun setFavorite(id: Long, favorite: Boolean): Note = patchMeta(id, "favorite", favorite)

    fun setCategory(id: Long, category: String): Note = patchMeta(id, "category", category)

    fun rename(id: Long, title: String): Note = patchMeta(id, "title", title)

    /** DELETE /notes/{id}. */
    fun delete(id: Long) {
        val req = Request.Builder().url("$apiBase/notes/$id").delete().build()
        client.newCall(req).execute().use { resp ->
            if (!resp.isSuccessful) error("Notes delete HTTP ${resp.code}")
        }
    }

    private companion object {
        val JSON = "application/json; charset=utf-8".toMediaType()
        const val HTTP_NOT_MODIFIED = 304
        const val HTTP_PRECONDITION_FAILED = 412
    }
}
