package com.aletheia.app.data

import android.util.Base64
import com.aletheia.app.model.Book
import com.aletheia.app.model.QBooksBook
import com.aletheia.app.model.CatalogPage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.io.ByteArrayOutputStream
import java.net.ConnectException
import java.net.HttpURLConnection
import java.net.NoRouteToHostException
import java.net.URI
import java.net.SocketTimeoutException
import java.net.URL
import java.net.URLEncoder
import java.net.UnknownHostException
import java.util.Collections
import java.util.LinkedHashMap
import java.util.Locale
import java.util.UUID
import javax.net.ssl.SSLException
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit

class QBooksService(
    private val settingsRepository: SettingsRepository,
    private val catalogCache: CatalogCache
) {
    private var baseUrl: String? = null
    private var username: String? = null
    private var password: String? = null
    private var backend: Backend = Backend.UNKNOWN
    private val coverLimiter = Semaphore(MAX_CONCURRENT_COVERS)
    private val metadataLimiter = Semaphore(MAX_CONCURRENT_METADATA_LOOKUPS)
    private val coverCache: MutableMap<String, ByteArray> = Collections.synchronizedMap(
        object : LinkedHashMap<String, ByteArray>(COVER_CACHE_ENTRIES + 1, 0.75f, true) {
            override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, ByteArray>?): Boolean =
                size > COVER_CACHE_ENTRIES
        }
    )

    fun configure(url: String, username: String?, password: String?) {
        val normalized = normalizeBaseUrl(url)
        val publicCatalog = URI(normalized).host.equals(FLIBUSTA_HOST, ignoreCase = true)
        val normalizedUsername = username?.trim().orEmpty().ifBlank { null }.takeUnless { publicCatalog }
        val normalizedPassword = password?.takeIf { normalizedUsername != null }
        if (baseUrl != normalized || this.username != normalizedUsername || this.password != normalizedPassword) {
            backend = Backend.UNKNOWN
        }
        this.baseUrl = normalized
        this.username = normalizedUsername
        this.password = normalizedPassword
    }

    suspend fun testConnection(url: String, username: String?, password: String?): Result<Unit> = withContext(Dispatchers.IO) {
        runCatching {
            configure(url, username, password)
            when (detectBackend(forceRefresh = true)) {
                Backend.QBOOKS,
                Backend.OPDS -> Result.Success(Unit)
                Backend.UNKNOWN -> Result.Failure(
                    "Сервер доступен, но не вернул поддерживаемый книжный каталог."
                )
            }
        }.getOrElse { throwable ->
            Result.Failure(qBooksConnectionError("Не удалось подключиться к каталогу.", throwable), throwable)
        }
    }

    suspend fun getBooks(
        searchQuery: String = "",
        page: Int = 0,
        pageSize: Int = 20
    ): Result<List<QBooksBook>> = when (val result = getCatalogPage(searchQuery, page, pageSize)) {
        is Result.Success -> Result.Success(result.value.books.take(pageSize.coerceAtLeast(1)))
        is Result.Failure -> result
    }

    suspend fun getCachedCatalogPage(
        searchQuery: String = "",
        page: Int = 0,
        pageSize: Int = 20,
        pageUrl: String? = null
    ): CatalogCache.CachedPage? = withContext(Dispatchers.IO) {
        configureFromSettings()
        catalogCache.readPage(catalogCacheKey(searchQuery, page, pageUrl))?.let { cached ->
            cached.copy(page = cached.page.copy(books = cached.page.books.take(pageSize.coerceAtLeast(1))))
        }
    }

    suspend fun enrichBookMetadata(
        book: QBooksBook,
        includeDetails: Boolean = false
    ): QBooksBook = withContext(Dispatchers.IO) {
        configureFromSettings()
        val cacheKey = bookCacheKey(book.id)
        var enriched = catalogCache.readBook(cacheKey)?.let { cached -> book.mergeMissing(cached) } ?: book
        if (!enriched.needsMetadata(includeDetails)) return@withContext enriched
        if (catalogCache.wasMetadataAttemptedRecently(cacheKey, METADATA_RETRY_AGE_MS)) {
            return@withContext enriched
        }

        metadataLimiter.withPermit {
            enriched = catalogCache.readBook(cacheKey)?.let { cached -> enriched.mergeMissing(cached) } ?: enriched
            if (!enriched.needsMetadata(includeDetails) ||
                catalogCache.wasMetadataAttemptedRecently(cacheKey, METADATA_RETRY_AGE_MS)
            ) {
                return@withPermit enriched
            }

            catalogCache.markMetadataAttempt(cacheKey)
            val query = enriched.title.ifBlank { enriched.author }.trim()
            if (query.isBlank()) return@withPermit enriched
            val match = when (val result = getCatalogPage(query, pageSize = DEFAULT_PAGE_SIZE)) {
                is Result.Success -> findMetadataMatch(enriched, result.value.books)
                is Result.Failure -> null
            }
            if (match != null) enriched = enriched.mergeMissing(match)
            catalogCache.writeBook(cacheKey, enriched)
            enriched
        }
    }

    suspend fun enrichLocalBook(book: Book): Book = withContext(Dispatchers.IO) {
        if (!book.needsCatalogMetadata()) return@withContext book
        configureFromSettings()
        val synthetic = QBooksBook(
            id = book.remoteId ?: "local:${book.fileName}:${book.title}",
            title = book.title,
            author = book.author,
            format = book.format,
            downloadUrl = ""
        )
        var metadata = book.remoteId
            ?.let { catalogCache.readBook(bookCacheKey(it)) }
            ?.let { cached -> synthetic.mergeMissing(cached) }
            ?: synthetic
        metadata = enrichBookMetadata(metadata)
        val cover = book.coverImage ?: metadata.coverImage ?: fetchCoverImage(metadata)
        book.copy(
            title = book.title.ifBlank { metadata.title },
            author = if (book.author.isUnknownAuthor()) metadata.author else book.author,
            coverImage = cover
        )
    }

    suspend fun getCatalogPage(
        searchQuery: String = "",
        page: Int = 0,
        pageSize: Int = 20,
        pageUrl: String? = null
    ): Result<CatalogPage> = withContext(Dispatchers.IO) {
        runCatching {
            configureFromSettings()
            val query = searchQuery.trim()
            val catalogPage = when (detectBackend()) {
                Backend.QBOOKS -> {
                    val books = getBooksFromQBooks(query, page, maxOf(pageSize, DEFAULT_PAGE_SIZE))
                    CatalogPage(
                        books = books,
                        nextPageUrl = if (books.size >= pageSize) "qbooks-page:${page + 1}" else null
                    )
                }
                Backend.OPDS -> getBooksFromOpds(query, page, pageSize, pageUrl)
                Backend.UNKNOWN -> error(
                    "Сервер не поддерживает книжный каталог. Проверьте адрес каталога."
                )
            }
            runCatching {
                catalogCache.writePage(catalogCacheKey(query, page, pageUrl), catalogPage)
                catalogPage.books.forEach { book ->
                    catalogCache.writeBook(bookCacheKey(book.id), book)
                }
            }
            Result.Success(catalogPage)
        }.getOrElse { throwable ->
            Result.Failure(qBooksConnectionError("Не удалось загрузить каталог.", throwable), throwable)
        }
    }

    suspend fun downloadBook(
        book: QBooksBook,
        booksDirectory: File,
        progress: (Int) -> Unit
    ): Result<File> = withContext(Dispatchers.IO) {
        var destination: File? = null
        runCatching {
            configureFromSettings()
            if (!booksDirectory.exists()) {
                booksDirectory.mkdirs()
            }

            val extension = book.format.ifBlank { "epub" }.lowercase(Locale.US)
            val target = File(booksDirectory, "${UUID.randomUUID()}.$extension.part")
            destination = target
            val connection = openDownloadConnection(book.downloadUrl)
            connection.use {
                if (it.responseCode !in 200..299) {
                    return@withContext Result.Failure(qBooksHttpError(it.responseCode))
                }

                val contentLength = it.contentLengthLong
                var downloaded = 0L
                it.inputStream.use { input ->
                    FileOutputStream(target).use { output ->
                        val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                        while (true) {
                            val read = input.read(buffer)
                            if (read == -1) break
                            output.write(buffer, 0, read)
                            downloaded += read
                            if (contentLength > 0L) {
                                progress(((downloaded * 100) / contentLength).toInt().coerceIn(0, 100))
                            }
                        }
                    }
                }
            }
            require(target.length() > 0L) { "Каталог вернул пустой файл книги." }
            progress(100)
            Result.Success(target)
        }.getOrElse { throwable ->
            destination?.takeIf(File::exists)?.delete()
            Result.Failure(qBooksConnectionError("Не удалось скачать книгу.", throwable), throwable)
        }
    }

    suspend fun fetchCoverImage(book: QBooksBook): ByteArray? = withContext(Dispatchers.IO) {
        configureFromSettings()
        coverLimiter.withPermit { fetchCoverImageInternal(book.coverUrl) }
    }

    private fun fetchCoverImageInternal(coverUrl: String?): ByteArray? {
        coverUrl ?: return null
        coverCache[coverUrl]?.let { return it }
        catalogCache.readCover(coverUrl)?.let { cached ->
            coverCache[coverUrl] = cached
            return cached
        }
        return runCatching {
            val connection = openConnection(coverUrl).apply {
                connectTimeout = COVER_TIMEOUT_MS
                readTimeout = COVER_TIMEOUT_MS
            }
            connection.use {
                if (it.responseCode !in 200..299) return null
                if (it.contentLengthLong > MAX_COVER_BYTES) return null
                val image = it.inputStream.use { stream ->
                    val output = ByteArrayOutputStream()
                    val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
                    var remaining = MAX_COVER_BYTES + 1
                    while (remaining > 0) {
                        val read = stream.read(buffer, 0, minOf(buffer.size, remaining))
                        if (read <= 0) break
                        output.write(buffer, 0, read)
                        remaining -= read
                    }
                    output.toByteArray()
                }
                image.takeIf { it.isNotEmpty() && it.size <= MAX_COVER_BYTES }
                    ?.also {
                        coverCache[coverUrl] = it
                        catalogCache.writeCover(coverUrl, it)
                    }
            }
        }.getOrNull()
    }

    private fun openDownloadConnection(url: String): HttpURLConnection {
        var currentUrl = url
        repeat(MAX_DOWNLOAD_REDIRECTS + 1) { redirectCount ->
            val connection = openConnection(currentUrl, followRedirects = false)
            if (connection.responseCode !in REDIRECT_RESPONSE_CODES) {
                return connection
            }

            val location = connection.getHeaderField("Location")
            connection.disconnect()
            require(!location.isNullOrBlank()) { "Каталог вернул перенаправление без адреса назначения." }
            require(redirectCount < MAX_DOWNLOAD_REDIRECTS) { "Слишком много перенаправлений при скачивании книги." }
            currentUrl = CatalogUrlResolver.resolveDownloadRedirect(currentUrl, location)
        }
        error("Не удалось открыть ссылку скачивания книги.")
    }

    private fun configureFromSettings() {
        val url = settingsRepository.getCatalogUrl()
        val user = settingsRepository.getString(
            SettingsRepository.KEY_QBOOKS_USERNAME,
            settingsRepository.getString(SettingsRepository.KEY_LEGACY_CATALOG_USERNAME).orEmpty()
        )
        val pass = settingsRepository.getSecurePassword()
        configure(url, user, pass)
    }

    private fun detectBackend(forceRefresh: Boolean = false): Backend {
        if (!forceRefresh && backend != Backend.UNKNOWN) {
            return backend
        }

        val preferOpds = runCatching {
            URI(requireBaseUrl()).path.trimEnd('/').endsWith("/opds", ignoreCase = true)
        }.getOrDefault(false)

        backend = if (preferOpds) {
            when {
                probeOpds() -> Backend.OPDS
                probeQBooks() -> Backend.QBOOKS
                else -> Backend.UNKNOWN
            }
        } else {
            when {
                probeQBooks() -> Backend.QBOOKS
                probeOpds() -> Backend.OPDS
                else -> Backend.UNKNOWN
            }
        }
        return backend
    }

    private fun probeOpds(): Boolean = runCatching {
        openConnection(requireBaseUrl(), accept = OPDS_ACCEPT).use { connection ->
            if (connection.responseCode !in 200..299) {
                return@use false
            }
            val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
            OpdsCatalogParser.isOpdsFeed(payload)
        }
    }.getOrDefault(false)

    private fun probeQBooks(): Boolean =
        probeSuccessfulResponse(requireBaseUrl() + "/healthz", "application/json") ||
            probeSuccessfulResponse(requireBaseUrl() + "/api/books", "application/json")

    private fun probeSuccessfulResponse(url: String, accept: String): Boolean = runCatching {
        openConnection(url, accept).use { connection -> connection.responseCode in 200..299 }
    }.getOrDefault(false)

    private fun getBooksFromQBooks(query: String, page: Int, pageSize: Int): List<QBooksBook> {
        val url = buildString {
            append(requireBaseUrl())
            append("/api/books")
            if (query.isNotEmpty()) {
                append("?q=")
                append(URLEncoder.encode(query, Charsets.UTF_8.name()))
            }
        }
        val books = openConnection(url, accept = "application/json").use { connection ->
            if (connection.responseCode !in 200..299) {
                error(qBooksHttpError(connection.responseCode))
            }

            val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
            JSONObject(payload)
                .optJSONArray("books")
                ?.let { array ->
                    (0 until array.length()).mapNotNull { index ->
                        array.optJSONObject(index)?.toBook()
                    }
                }
                .orEmpty()
                .drop(page.coerceAtLeast(0) * pageSize.coerceAtLeast(1))
                .take(pageSize.coerceAtLeast(1))
        }
        return books
    }

    private fun getBooksFromOpds(
        query: String,
        page: Int,
        pageSize: Int,
        pageUrl: String?
    ): CatalogPage {
        val url = pageUrl?.takeIf(String::isNotBlank) ?: buildOpdsBooksUrl(query, page.coerceAtLeast(0))
        val parsed = openConnection(url, accept = OPDS_ACCEPT).use { connection ->
            if (connection.responseCode !in 200..299) {
                error(qBooksHttpError(connection.responseCode))
            }

            val payload = connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
            OpdsCatalogParser.parsePage(payload) { pathOrUrl ->
                resolveCatalogLink(url, pathOrUrl)
            }
        }
        return parsed
    }

    private fun buildOpdsBooksUrl(query: String, page: Int): String {
        val root = requireBaseUrl().trimEnd('/')
        val uri = URI(root)
        val isFlibusta = uri.host.equals(FLIBUSTA_HOST, ignoreCase = true) &&
            uri.path.trimEnd('/').equals("/opds", ignoreCase = true)
        val encodedQuery = URLEncoder.encode(query, Charsets.UTF_8.name())

        return when {
            isFlibusta && query.isBlank() -> "$root/new/$page/new"
            isFlibusta -> "$root/search?searchType=books&searchTerm=$encodedQuery&pageNumber=$page"
            query.isBlank() -> root
            else -> "$root/search?searchTerm=$encodedQuery"
        }
    }

    private fun JSONObject.toBook(): QBooksBook {
        val id = optLong("id", -1L)
        val format = optString("format", "").ifBlank { "EPUB" }.lowercase(Locale.US)
        val cover = optString("cover_src").ifBlank { optString("cover_url") }.ifBlank { null }
        val share = optString("share_url").ifBlank { optString("web_url") }.ifBlank { null }
        return QBooksBook(
            id = id.toString(),
            title = optString("title").ifBlank { optString("rel_path").ifBlank { "Без названия" } },
            author = authorsText(),
            format = format,
            downloadUrl = absoluteUrl("/book/$id/download"),
            shareUrl = share?.let(::absoluteUrl),
            coverUrl = cover?.let(::absoluteUrl),
            description = optString("description").ifBlank { null },
            language = optString("language").ifBlank { null },
            publisher = optString("publisher").ifBlank { null },
            published = optString("published").ifBlank { null }
        )
    }

    private fun JSONObject.authorsText(): String {
        val authors = optJSONArray("authors") ?: return "Неизвестный автор"
        return (0 until authors.length())
            .mapNotNull { index -> authors.optString(index).trim().ifBlank { null } }
            .joinToString(", ")
            .ifBlank { "Неизвестный автор" }
    }

    private fun openConnection(
        url: String,
        accept: String = "*/*",
        followRedirects: Boolean = true
    ): HttpURLConnection {
        return (URL(url).openConnection() as HttpURLConnection).apply {
            connectTimeout = TIMEOUT_MS
            readTimeout = TIMEOUT_MS
            instanceFollowRedirects = followRedirects
            requestMethod = "GET"
            setRequestProperty("User-Agent", USER_AGENT)
            setRequestProperty("Accept", accept)
            val user = username
            val pass = password
            val requestHost = runCatching { URI(url).host }.getOrNull()
            val configuredHost = runCatching { URI(requireBaseUrl()).host }.getOrNull()
            if (!user.isNullOrBlank() && requestHost.equals(configuredHost, ignoreCase = true)) {
                val credentials = "$user:${pass.orEmpty()}"
                val encoded = Base64.encodeToString(credentials.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
                setRequestProperty("Authorization", "Basic $encoded")
            }
        }
    }

    private fun normalizeBaseUrl(url: String): String {
        return QBooksUrlPolicy.normalizeBaseUrl(url)
    }

    private fun absoluteUrl(pathOrUrl: String): String {
        if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
            return pathOrUrl
        }
        return CatalogUrlResolver.resolve(requireBaseUrl(), pathOrUrl)
    }

    private fun resolveCatalogLink(feedUrl: String, pathOrUrl: String): String {
        if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
            return pathOrUrl
        }
        return URI(feedUrl).resolve(pathOrUrl).toString()
    }

    private fun catalogCacheKey(searchQuery: String, page: Int, pageUrl: String?): String =
        listOf(
            requireBaseUrl(),
            username.orEmpty(),
            searchQuery.trim().lowercase(Locale.ROOT),
            page.coerceAtLeast(0).toString(),
            pageUrl.orEmpty()
        ).joinToString("\u001f")

    private fun bookCacheKey(bookId: String): String =
        "${requireBaseUrl()}\u001f${username.orEmpty()}\u001f${bookId.trim()}"

    private fun findMetadataMatch(source: QBooksBook, candidates: List<QBooksBook>): QBooksBook? {
        candidates.firstOrNull { it.id == source.id }?.let { return it }
        val normalizedTitle = source.title.normalizedMetadataText()
        if (normalizedTitle.isBlank()) return null
        return candidates
            .filter { it.title.normalizedMetadataText() == normalizedTitle }
            .maxByOrNull { candidate ->
                if (!source.author.isUnknownAuthor() &&
                    candidate.author.normalizedMetadataText() == source.author.normalizedMetadataText()
                ) 2 else 1
            }
    }

    private fun QBooksBook.mergeMissing(candidate: QBooksBook): QBooksBook = copy(
        title = title.ifBlank { candidate.title },
        author = if (author.isUnknownAuthor()) candidate.author else author,
        shareUrl = shareUrl ?: candidate.shareUrl,
        coverUrl = coverUrl ?: candidate.coverUrl,
        coverImage = coverImage ?: candidate.coverImage,
        description = description?.takeIf(String::isNotBlank) ?: candidate.description,
        language = language?.takeIf(String::isNotBlank) ?: candidate.language,
        publisher = publisher?.takeIf(String::isNotBlank) ?: candidate.publisher,
        published = published?.takeIf(String::isNotBlank) ?: candidate.published
    )

    private fun QBooksBook.needsMetadata(includeDetails: Boolean): Boolean =
        title.isBlank() || author.isUnknownAuthor() || coverUrl.isNullOrBlank() ||
            (includeDetails && (
                description.isNullOrBlank() || language.isNullOrBlank() || published.isNullOrBlank() ||
                    shareUrl.isNullOrBlank()
            ))

    private fun Book.needsCatalogMetadata(): Boolean =
        title.isBlank() || author.isUnknownAuthor() || coverImage == null

    private fun String.isUnknownAuthor(): Boolean =
        trim().lowercase(Locale.ROOT) in UNKNOWN_AUTHORS

    private fun String.normalizedMetadataText(): String =
        lowercase(Locale.ROOT)
            .replace(NON_METADATA_CHARACTER, " ")
            .replace(METADATA_WHITESPACE, " ")
            .trim()

    private fun requireBaseUrl(): String = baseUrl ?: error("Каталог не настроен")

    private fun qBooksHttpError(responseCode: Int): String =
        when (responseCode) {
            HttpURLConnection.HTTP_UNAUTHORIZED,
            HttpURLConnection.HTTP_FORBIDDEN -> {
                "Каталог не принял логин или пароль. Проверьте доступ в настройках."
            }
            HttpURLConnection.HTTP_NOT_FOUND -> {
                "Каталог доступен, но нужный API не найден. Проверьте адрес сервера."
            }
            in 500..599 -> {
                "Каталог временно недоступен (HTTP $responseCode). Попробуйте позже."
            }
            else -> {
                "Каталог вернул HTTP $responseCode. Проверьте сервер или повторите попытку."
            }
        }

    private fun qBooksConnectionError(action: String, throwable: Throwable): String {
        val root = throwable.rootCause()
        val hint = when (root) {
            is UnknownHostException -> {
                "Сервер каталога не найден. Проверьте подключение к интернету."
            }
            is SocketTimeoutException -> {
                "Сервер каталога не ответил вовремя. Проверьте сеть или повторите попытку."
            }
            is ConnectException,
            is NoRouteToHostException -> {
                "Не удалось открыть соединение с каталогом. Проверьте сеть."
            }
            is SSLException -> {
                "Защищённое соединение с каталогом не прошло проверку."
            }
            is IllegalArgumentException,
            is IllegalStateException -> {
                root.message?.takeIf { it.isNotBlank() } ?: "Проверьте настройки каталога."
            }
            else -> {
                "Проверьте сеть и доступ к каталогу."
            }
        }
        return "$action $hint"
    }

    private tailrec fun Throwable.rootCause(): Throwable {
        val next = cause ?: return this
        return next.rootCause()
    }

    private inline fun <T> HttpURLConnection.use(block: (HttpURLConnection) -> T): T {
        try {
            return block(this)
        } finally {
            disconnect()
        }
    }

    private enum class Backend {
        UNKNOWN,
        QBOOKS,
        OPDS
    }

    private companion object {
        private const val TIMEOUT_MS = 30_000
        private const val COVER_TIMEOUT_MS = 5_000
        private const val MAX_COVER_BYTES = 1024 * 1024
        private const val MAX_CONCURRENT_COVERS = 4
        private const val COVER_CACHE_ENTRIES = 24
        private const val DEFAULT_PAGE_SIZE = 20
        private const val MAX_CONCURRENT_METADATA_LOOKUPS = 2
        private const val METADATA_RETRY_AGE_MS = 7 * 24 * 60 * 60 * 1000L
        private const val USER_AGENT = "Aletheia/2.28"
        private const val OPDS_ACCEPT = "application/atom+xml, application/xml;q=0.9, */*;q=0.8"
        private const val FLIBUSTA_HOST = "m.flibusta.is"
        private const val MAX_DOWNLOAD_REDIRECTS = 5
        private val REDIRECT_RESPONSE_CODES = setOf(301, 302, 303, 307, 308)
        private val UNKNOWN_AUTHORS = setOf("", "unknown", "unknown author", "неизвестный автор")
        private val NON_METADATA_CHARACTER = Regex("[^\\p{L}\\p{N}]+")
        private val METADATA_WHITESPACE = Regex("\\s+")
    }
}
