package de.wechselbild.wallpaper

import android.content.Context
import android.graphics.BitmapFactory
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.security.KeyStore
import java.security.MessageDigest
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import org.json.JSONArray
import org.json.JSONObject

object ImmichClient {
  private const val PREFS = "wechselbild"
  private const val KEY_SERVER_URL = "immich_server_url"
  private const val KEY_USER_NAME = "immich_user_name"
  private const val KEY_API_KEY_DATA = "immich_api_key_data"
  private const val KEY_API_KEY_IV = "immich_api_key_iv"
  private const val KEY_ALIAS = "wallpaperflow_immich_api_key"
  private const val CACHE_MAX_FILES = 3
  private const val CACHE_MAX_BYTES = 256L * 1024 * 1024
  private const val DOWNLOAD_MAX_BYTES = 128L * 1024 * 1024
  private val assetIdPattern = Regex("^[0-9a-fA-F-]{36}$")
  private val thumbnailPool = Executors.newFixedThreadPool(4)
  private val importProgress = AtomicReference(ImportProgress())

  private data class Credentials(val serverUrl: String, val apiKey: String)
  private data class ImportProgress(
    val active: Boolean = false,
    val completed: Int = 0,
    val total: Int = 0,
    val bytesDownloaded: Long = 0,
    val bytesTotal: Long = 0,
  )

  private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)

  private fun normalizeServerUrl(value: String): String {
    val raw = value.trim().trimEnd('/')
    require(raw.isNotBlank()) { "Bitte gib die URL deines Immich-Servers ein." }
    val uri = runCatching { URI(raw) }.getOrNull()
      ?: throw IllegalArgumentException("Die Immich-URL ist ungültig.")
    require(uri.scheme.equals("https", true) || uri.scheme.equals("http", true)) {
      "Die Immich-URL muss mit https:// oder http:// beginnen."
    }
    require(!uri.host.isNullOrBlank()) { "Die Immich-URL enthält keinen gültigen Hostnamen." }
    require(uri.userInfo == null && uri.query == null && uri.fragment == null) { "Die Immich-URL ist ungültig." }
    val basePath = (uri.path ?: "").trimEnd('/')
    val apiPath = if (basePath.endsWith("/api")) basePath else "$basePath/api"
    return URI(uri.scheme.lowercase(), null, uri.host, uri.port, apiPath, null, null).toString().trimEnd('/')
  }

  private fun secretKey(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
    return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
      init(
        KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
          .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
          .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
          .build()
      )
      generateKey()
    }
  }

  private fun encryptApiKey(context: Context, value: String) {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, secretKey()) }
    val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8))
    prefs(context).edit()
      .putString(KEY_API_KEY_DATA, Base64.encodeToString(encrypted, Base64.NO_WRAP))
      .putString(KEY_API_KEY_IV, Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
      .apply()
  }

  private fun decryptApiKey(context: Context): String? = runCatching {
    val preferences = prefs(context)
    val encrypted = Base64.decode(preferences.getString(KEY_API_KEY_DATA, null), Base64.NO_WRAP)
    val iv = Base64.decode(preferences.getString(KEY_API_KEY_IV, null), Base64.NO_WRAP)
    val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
      init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(128, iv))
    }
    String(cipher.doFinal(encrypted), StandardCharsets.UTF_8)
  }.getOrNull()

  private fun credentials(context: Context): Credentials {
    val serverUrl = prefs(context).getString(KEY_SERVER_URL, null).orEmpty()
    val apiKey = decryptApiKey(context).orEmpty()
    check(serverUrl.isNotBlank() && apiKey.isNotBlank()) { "Immich ist noch nicht verbunden." }
    return Credentials(serverUrl, apiKey)
  }

  private fun connectionObject(context: Context): JSObject {
    val preferences = prefs(context)
    val serverUrl = preferences.getString(KEY_SERVER_URL, "").orEmpty()
    return JSObject().apply {
      put("configured", serverUrl.isNotBlank() && !decryptApiKey(context).isNullOrBlank())
      put("serverUrl", serverUrl)
      put("userName", preferences.getString(KEY_USER_NAME, "").orEmpty())
    }
  }

  fun connection(context: Context) = connectionObject(context)

  fun connect(context: Context, serverUrl: String, apiKey: String): JSObject {
    val normalized = normalizeServerUrl(serverUrl)
    require(apiKey.isNotBlank()) { "Bitte gib deinen Immich API-Key ein." }
    val user = requestJson(normalized, apiKey.trim(), "/users/me")
    val userName = user.optString("name").ifBlank { user.optString("email") }.ifBlank { "Immich" }
    encryptApiKey(context, apiKey.trim())
    prefs(context).edit().putString(KEY_SERVER_URL, normalized).putString(KEY_USER_NAME, userName).apply()
    return connectionObject(context)
  }

  fun disconnect(context: Context): JSObject {
    prefs(context).edit()
      .remove(KEY_SERVER_URL)
      .remove(KEY_USER_NAME)
      .remove(KEY_API_KEY_DATA)
      .remove(KEY_API_KEY_IV)
      .apply()
    return connectionObject(context)
  }

  private fun openConnection(
    serverUrl: String,
    apiKey: String,
    path: String,
    method: String = "GET",
    body: String? = null,
  ): HttpURLConnection {
    val connection = URI(serverUrl + path).toURL().openConnection() as HttpURLConnection
    connection.requestMethod = method
    connection.connectTimeout = 15_000
    connection.readTimeout = 60_000
    connection.instanceFollowRedirects = true
    connection.setRequestProperty("Accept", "application/json, image/*, application/octet-stream")
    connection.setRequestProperty("x-api-key", apiKey)
    if (body != null) {
      connection.doOutput = true
      connection.setRequestProperty("Content-Type", "application/json")
      connection.outputStream.use { it.write(body.toByteArray(StandardCharsets.UTF_8)) }
    }
    return connection
  }

  private fun errorMessage(connection: HttpURLConnection): String {
    val detail = runCatching {
      connection.errorStream?.bufferedReader()?.use { it.readText().take(600) }.orEmpty()
    }.getOrDefault("")
    val message = runCatching { JSONObject(detail).optString("message") }.getOrDefault("")
    return when (connection.responseCode) {
      401, 403 -> "Immich hat den API-Key abgelehnt. Prüfe die Berechtigungen des Schlüssels."
      404 -> "Die Immich-API wurde unter dieser URL nicht gefunden."
      else -> message.ifBlank { "Immich antwortet mit HTTP ${connection.responseCode}." }
    }
  }

  private fun requestJson(serverUrl: String, apiKey: String, path: String, method: String = "GET", body: String? = null): JSONObject {
    val connection = openConnection(serverUrl, apiKey, path, method, body)
    try {
      if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
      return JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
    } finally { connection.disconnect() }
  }

  private fun requestArray(serverUrl: String, apiKey: String, path: String): JSONArray {
    val connection = openConnection(serverUrl, apiKey, path)
    try {
      if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
      return JSONArray(connection.inputStream.bufferedReader().use { it.readText() })
    } finally { connection.disconnect() }
  }

  private fun requestBytes(serverUrl: String, apiKey: String, path: String): Pair<ByteArray, String> {
    val connection = openConnection(serverUrl, apiKey, path)
    try {
      if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
      val bytes = connection.inputStream.use { input -> ByteArrayOutputStream().use { output -> input.copyTo(output); output.toByteArray() } }
      return bytes to connection.contentType.orEmpty().substringBefore(';')
    } finally { connection.disconnect() }
  }

  private fun downloadToFile(serverUrl: String, apiKey: String, path: String, target: File, trackProgress: Boolean = true): String {
    val connection = openConnection(serverUrl, apiKey, path)
    try {
      if (connection.responseCode !in 200..299) throw IOException(errorMessage(connection))
      val total = connection.contentLengthLong.coerceAtLeast(0)
      require(total == 0L || total <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
      var downloaded = 0L
      if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = 0, bytesTotal = total) }
      connection.inputStream.use { input ->
        target.outputStream().use { output ->
          val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
          while (true) {
            val count = input.read(buffer)
            if (count < 0) break
            output.write(buffer, 0, count)
            downloaded += count
            require(downloaded <= DOWNLOAD_MAX_BYTES) { "Das Immich-Bild ist zu groß." }
            if (trackProgress) importProgress.updateAndGet { it.copy(bytesDownloaded = downloaded, bytesTotal = total) }
          }
        }
      }
      return connection.contentType.orEmpty().substringBefore(';')
    } catch (error: Exception) {
      target.delete()
      throw error
    } finally { connection.disconnect() }
  }

  private fun thumbnailDataUrl(credentials: Credentials, assetId: String): String = runCatching {
    val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
    val (bytes, contentType) = requestBytes(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=thumbnail")
    val mime = if (contentType.startsWith("image/")) contentType else "image/jpeg"
    "data:$mime;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
  }.getOrDefault("")

  fun albums(context: Context): JSObject {
    val credentials = credentials(context)
    val response = requestArray(credentials.serverUrl, credentials.apiKey, "/albums")
    val albums = JSArray()
    (0 until response.length()).map { response.getJSONObject(it) }
      .sortedBy { it.optString("albumName").lowercase() }
      .forEach { album ->
        albums.put(JSObject().apply {
          put("id", album.getString("id"))
          put("name", album.optString("albumName", "Album"))
          put("assetCount", album.optInt("assetCount", 0))
          put("thumbnailUrl", "")
        })
      }
    return JSObject().apply { put("albums", albums) }
  }

  fun assets(context: Context, albumId: String?, page: Int, size: Int): JSObject {
    val credentials = credentials(context)
    val safePage = page.coerceAtLeast(1)
    val safeSize = size.coerceIn(1, 60)
    val body = JSONObject().apply {
      put("type", "IMAGE")
      put("page", safePage)
      put("size", safeSize)
      put("order", "desc")
      if (!albumId.isNullOrBlank()) put("albumIds", JSONArray().put(albumId))
    }
    val response = requestJson(credentials.serverUrl, credentials.apiKey, "/search/metadata", "POST", body.toString())
      .getJSONObject("assets")
    val jsonItems = response.getJSONArray("items")
    val raw = (0 until jsonItems.length()).map { jsonItems.getJSONObject(it) }
    val thumbnails = thumbnailPool.invokeAll(raw.map { asset -> Callable { thumbnailDataUrl(credentials, asset.getString("id")) } })
    val items = JSArray()
    raw.forEachIndexed { index, asset ->
      items.put(JSObject().apply {
        put("id", asset.getString("id"))
        put("fileName", asset.optString("originalFileName", "Immich image"))
        put("thumbnailUrl", runCatching { thumbnails[index].get() }.getOrDefault(""))
        put("takenAt", asset.optString("localDateTime", asset.optString("fileCreatedAt", "")))
      })
    }
    return JSObject().apply {
      put("items", items)
      put("page", safePage)
      put("hasMore", !response.isNull("nextPage") && response.optString("nextPage").isNotBlank())
    }
  }

  private fun importOne(context: Context, credentials: Credentials, assetId: String) {
    require(assetIdPattern.matches(assetId)) { "Ungültige Immich-Bild-ID." }
    if (WallpaperStore.hasImmich(context, credentials.serverUrl, assetId)) return
    val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
    val temporary = File(context.cacheDir, ".immich-preview-$assetId.download")
    downloadToFile(credentials.serverUrl, credentials.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary)
    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    BitmapFactory.decodeFile(temporary.absolutePath, bounds)
    require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Immich hat keine gültige Vorschau geliefert." }
    try { WallpaperStore.addImmich(context, credentials.serverUrl, assetId, temporary) }
    finally { temporary.delete() }
  }

  private fun cacheDirectory(context: Context) = File(context.cacheDir, "immich-wallpaper-originals").apply { mkdirs() }

  private fun cacheKey(serverUrl: String, assetId: String): String {
    val server = MessageDigest.getInstance("SHA-256").digest(serverUrl.toByteArray(StandardCharsets.UTF_8))
      .take(6).joinToString("") { "%02x".format(it) }
    return "$server-$assetId"
  }

  private fun validImage(file: File): Boolean {
    if (!file.isFile || file.length() <= 0) return false
    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
    BitmapFactory.decodeFile(file.absolutePath, bounds)
    return bounds.outWidth > 0 && bounds.outHeight > 0
  }

  @Synchronized
  fun cachedOriginal(context: Context, serverUrl: String, assetId: String, allowNetwork: Boolean = true): File? {
    val target = File(cacheDirectory(context), "${cacheKey(serverUrl, assetId)}.image")
    if (validImage(target)) {
      target.setLastModified(System.currentTimeMillis())
      return target
    }
    target.delete()
    if (!allowNetwork) return null
    val current = runCatching { credentials(context) }.getOrNull() ?: return null
    if (current.serverUrl != serverUrl || !assetIdPattern.matches(assetId)) return null
    val temporary = File(cacheDirectory(context), ".${cacheKey(serverUrl, assetId)}.download")
    val encoded = URLEncoder.encode(assetId, StandardCharsets.UTF_8.name())
    return try {
      downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/original", temporary, trackProgress = false)
      if (!validImage(temporary)) {
        temporary.delete()
        downloadToFile(serverUrl, current.apiKey, "/assets/$encoded/thumbnail?size=preview", temporary, trackProgress = false)
      }
      if (!validImage(temporary)) return null
      if (!temporary.renameTo(target)) { temporary.copyTo(target, overwrite = true); temporary.delete() }
      target.setLastModified(System.currentTimeMillis())
      trimCache(context, target)
      target
    } catch (_: Exception) {
      temporary.delete()
      null
    }
  }

  @Synchronized
  fun deleteCachedOriginal(context: Context, serverUrl: String, assetId: String) {
    val key = cacheKey(serverUrl, assetId)
    File(cacheDirectory(context), "$key.image").delete()
    File(cacheDirectory(context), ".$key.download").delete()
  }

  private fun trimCache(context: Context, protected: File) {
    val files = cacheDirectory(context).listFiles()?.filter { it.isFile && !it.name.startsWith(".") }?.sortedByDescending { it.lastModified() } ?: return
    var bytes = 0L
    files.forEachIndexed { index, file ->
      bytes += file.length()
      if (file != protected && (index >= CACHE_MAX_FILES || bytes > CACHE_MAX_BYTES)) {
        bytes -= file.length()
        file.delete()
      }
    }
  }

  fun importAssets(context: Context, ids: List<String>): JSObject {
    require(ids.isNotEmpty()) { "Wähle mindestens ein Immich-Bild aus." }
    require(ids.size <= 100) { "Bitte importiere höchstens 100 Bilder auf einmal." }
    val credentials = credentials(context)
    val uniqueIds = ids.distinct()
    importProgress.set(ImportProgress(active = true, total = uniqueIds.size))
    try {
      uniqueIds.forEachIndexed { index, id ->
        importOne(context, credentials, id)
        importProgress.updateAndGet { it.copy(completed = index + 1, bytesDownloaded = 0, bytesTotal = 0) }
      }
      return WallpaperStore.state(context)
    } finally {
      importProgress.updateAndGet { it.copy(active = false) }
    }
  }

  fun importProgress(): JSObject = importProgress.get().let { progress ->
    JSObject().apply {
      put("active", progress.active)
      put("completed", progress.completed)
      put("total", progress.total)
      put("bytesDownloaded", progress.bytesDownloaded)
      put("bytesTotal", progress.bytesTotal)
    }
  }
}
