package jp.juggler.subwaytooter.util

import android.os.SystemClock
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootApiClient
import jp.juggler.subwaytooter.api.TootApiResultException
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.EntityId
import jp.juggler.subwaytooter.api.entity.InstanceCapability
import jp.juggler.subwaytooter.api.entity.InstanceType
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.entity.TootPollsType
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.api.entity.TootTag
import jp.juggler.subwaytooter.api.entity.TootVisibility
import jp.juggler.subwaytooter.api.errorApiResult
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.getVisibilityString
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.table.daoTagHistory
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.data.JsonArray
import jp.juggler.util.data.JsonException
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonArray
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.digestSHA256Hex
import jp.juggler.util.data.groupEx
import jp.juggler.util.data.jsonObjectOf
import jp.juggler.util.data.notEmpty
import jp.juggler.util.data.toJsonArray
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.errorString
import jp.juggler.util.log.showToast
import jp.juggler.util.network.MEDIA_TYPE_JSON
import jp.juggler.util.network.toPostRequestBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.atomic.AtomicBoolean

sealed class PostResult {
    class Normal(
        val targetAccount: SavedAccount,
        val status: TootStatus,
    ) : PostResult()

    class Scheduled(
        val targetAccount: SavedAccount,
    ) : PostResult()
}

@Suppress("LongParameterList")
class PostImpl(
    val activity: AppCompatActivity,
    val account: SavedAccount,
    val content: String,

    // nullはCWチェックなしを示す // nullじゃなくてカラならエラー
    val spoilerText: String?,

    val visibilityArg: TootVisibility,
    val bNSFW: Boolean,
    val inReplyToId: EntityId?,
    val attachmentListArg: List<PostAttachment>?,
    enqueteItemsArg: List<String>?,
    val pollType: TootPollsType?,
    val pollExpireSeconds: Int,
    val pollHideTotals: Boolean,
    val pollMultipleChoice: Boolean,
    val scheduledAt: Long,
    val scheduledId: EntityId?,
    val redraftStatusId: EntityId?,
    val editStatusId: EntityId?,
    val emojiMapCustom: HashMap<String, CustomEmoji>?,
    var useQuoteToot: Boolean,
    var lang: String,
) {
    companion object {
        private val log = LogCategory("PostImpl")

        // ハッシュタグ内部の、半角数字以外のASCII文字にマッチする正規表現
        // 単体テストで使うのでpublig
        val reTagAsciiNotNumber = """[\x00-\x2f\x3a-\x7f]""".toRegex()

        // ハッシュタグ内部の、非ASCII文字にマッチする正規表現
        // 単体テストで使うのでpublig
        val reTagNonAscii = """[^\x00-\x7f]""".toRegex()

        private var lastPostTapped: Long = 0L

        private val isPosting = AtomicBoolean(false)
    }

    private val attachmentList = attachmentListArg?.mapNotNull { it.attachment }?.notEmpty()

    // null だと投票を作成しない。空リストはエラー
    private val enqueteItems = enqueteItemsArg?.filter { it.isNotEmpty() }

    private var visibilityChecked: TootVisibility? = null

    private val choiceMaxChars = when {
        account.isMisskey -> 15
        pollType == TootPollsType.FriendsNico -> 15
        else -> 40 // TootPollsType.Mastodon
    }

    private fun preCheckPollItemOne(list: List<String>, idx: Int, item: String) {

        // 選択肢が長すぎる
        val cpCount = item.codePointCount(0, item.length)
        if (cpCount > choiceMaxChars) {
            val over = cpCount - choiceMaxChars
            activity.errorString(R.string.enquete_item_too_long, idx + 1, over)
        }

        // 他の項目と重複している
        if ((0 until idx).any { list[it] == item }) {
            activity.errorString(R.string.enquete_item_duplicate, idx + 1)
        }
    }

    // may null, not error
    private suspend fun getWebVisibility(
        client: TootApiClient,
        parser: TootParser,
        instance: TootInstance,
    ): TootVisibility? = when {
        account.isMisskey -> null
        instance.versionGE(TootInstance.VERSION_1_6) -> null
        else -> {
            val privacy = parser.account(
                client.requestOrThrow("/api/v1/accounts/verify_credentials")
                    .jsonObject
            )?.source?.privacy
                ?: error(R.string.cant_get_web_setting_visibility)
            TootVisibility.parseMastodon(privacy)
        }
    }

    private fun checkServerHasVisibility(
        actual: TootVisibility?,
        extra: TootVisibility,
        instance: TootInstance,
        checkFun: (TootInstance) -> Boolean,
    ) {
        if (actual != extra || checkFun(instance)) return
        val strVisibility = extra.getVisibilityString(account.isMisskey)
        activity.errorApiResult(R.string.server_has_no_support_of_visibility, strVisibility)
    }

    private suspend fun checkVisibility(
        client: TootApiClient,
        parser: TootParser,
        instance: TootInstance,
    ): TootVisibility? {
        val v = when (visibilityArg) {
            TootVisibility.WebSetting -> getWebVisibility(client, parser, instance)
            else -> visibilityArg
        }
        checkServerHasVisibility(
            v,
            TootVisibility.Mutual,
            instance,
            InstanceCapability::visibilityMutual
        )
        checkServerHasVisibility(
            v,
            TootVisibility.Limited,
            instance,
            InstanceCapability::visibilityLimited
        )
        return v
    }

    private suspend fun deleteStatus(client: TootApiClient) {
        val result = if (account.isMisskey) {
            client.request(
                "/api/notes/delete",
                account.putMisskeyApiToken(JsonObject()).apply {
                    put("noteId", redraftStatusId)
                }.toPostRequestBuilder()
            )
        } else {
            client.request(
                "/api/v1/statuses/$redraftStatusId",
                Request.Builder().delete()
            )
        }
        log.d("delete redraft. result=$result")
        delay(2000L)
    }

    private suspend fun deleteScheduledStatus(client: TootApiClient) {
        val result = client.request(
            "/api/v1/scheduled_statuses/$scheduledId",
            Request.Builder().delete()
        )
        log.d("delete old scheduled status. result=$result")
        delay(2000L)
    }

    private suspend fun encodeParamsMisskey(json: JsonObject, client: TootApiClient) {
        account.putMisskeyApiToken(json)

        json["viaMobile"] = true

        json["text"] = EmojiDecoder.decodeShortCode(content, emojiMapCustom = emojiMapCustom)

        spoilerText?.notEmpty()?.let {
            json["cw"] = EmojiDecoder.decodeShortCode(it, emojiMapCustom = emojiMapCustom)
        }

        inReplyToId?.toString()?.let {
            json[if (useQuoteToot) "renoteId" else "replyId"] = it
        }

        when (val visibility = visibilityChecked) {
            null -> Unit
            TootVisibility.DirectSpecified, TootVisibility.DirectPrivate -> {
                val userIds = JsonArray()
                val m = TootAccount.reMisskeyMentionPost.matcher(content)
                while (m.find()) {
                    val username = m.groupEx(1)
                    val host = m.groupEx(2) // may null

                    client.request(
                        "/api/users/show",
                        account.putMisskeyApiToken().apply {
                            username.notEmpty()?.let { put("username", it) }
                            host.notEmpty()?.let { put("host", it) }
                        }.toPostRequestBuilder()
                    )?.let { result ->
                        result.jsonObject?.string("id").notEmpty()?.let { userIds.add(it) }
                    }
                }
                json["visibility"] = when {
                    userIds.isNotEmpty() -> {
                        json["visibleUserIds"] = userIds
                        "specified"
                    }

                    account.misskeyVersion >= 11 -> "specified"
                    else -> "private"
                }
            }

            else -> {
                val localVis = visibility.strMisskey.replace("^local-".toRegex(), "")
                if (localVis != visibility.strMisskey) json["localOnly"] = true
                json["visibility"] = localVis
            }
        }

        if (attachmentList != null) {
            val array = JsonArray()
            for (a in attachmentList) {

                // Misskeyは画像の再利用に問題がないので redraftとバージョンのチェックは行わない
                array.add(a.id.toString())

                // Misskeyの場合、NSFWするにはアップロード済みの画像を drive/files/update で更新する
                if (bNSFW) {
                    client.requestOrThrow(
                        "/api/drive/files/update",
                        account.putMisskeyApiToken().apply {
                            put("fileId", a.id.toString())
                            put("isSensitive", true)
                        }.toPostRequestBuilder()
                    )
                }
            }
            if (array.isNotEmpty()) json["mediaIds"] = array
        }

        if (enqueteItems?.isNotEmpty() == true) {
            val choices = JsonArray().apply {
                for (item in enqueteItems) {
                    val text = EmojiDecoder.decodeShortCode(item, emojiMapCustom = emojiMapCustom)
                    if (text.isEmpty()) continue
                    add(text)
                }
            }
            if (choices.isNotEmpty()) {
                json["poll"] = jsonObjectOf("choices" to choices)
            }
        }
    }

    private fun encodeParamsMastodon(json: JsonObject, instance: TootInstance) {

        when (val lang = lang.trim()) {
            // Web設定に従うなら指定しない
            SavedAccount.LANG_WEB, "" -> Unit
            // 端末の言語コード
            SavedAccount.LANG_DEVICE ->
                json["language"] = Locale.getDefault().language
            // その他
            else ->
                json["language"] = lang
        }

        visibilityChecked?.let { json["visibility"] = it.strMastodon }

        json["status"] = EmojiDecoder.decodeShortCode(content, emojiMapCustom = emojiMapCustom)
        json["sensitive"] = bNSFW
        json["spoiler_text"] =
            EmojiDecoder.decodeShortCode(spoilerText ?: "", emojiMapCustom = emojiMapCustom)

        inReplyToId?.toString()
            ?.let { json[if (useQuoteToot) "quote_id" else "in_reply_to_id"] = it }

        if (attachmentList != null) {
            json["media_ids"] = buildJsonArray {
                for (a in attachmentList) {
                    if (a.redraft && !instance.versionGE(TootInstance.VERSION_2_4_1)) continue
                    add(a.id.toString())
                }
            }
            attachmentList.mapNotNull { a ->
                buildJsonObject {
                    put("id", a.id.toString())
                    a.updateDescription?.let { put("description", it) }
                    a.updateThumbnail?.let { put("thumbnail", it) }
                    a.updateFocus?.let { put("focus", it) }
                }.takeIf { it.keys.size >= 2 }
            }.notEmpty()?.toJsonArray()?.let {
                json["media_attributes"] = it
            }
        }

        if (enqueteItems != null) {
            if (pollType == TootPollsType.Mastodon) {
                json["poll"] = buildJsonObject {
                    put("multiple", pollMultipleChoice)
                    put("hide_totals", pollHideTotals)
                    put("expires_in", pollExpireSeconds)
                    put("options", enqueteItems.map {
                        EmojiDecoder.decodeShortCode(it, emojiMapCustom = emojiMapCustom)
                    }.toJsonArray())
                }
            } else {
                json["isEnquete"] = true
                json["enquete_items"] = enqueteItems.map {
                    EmojiDecoder.decodeShortCode(it, emojiMapCustom = emojiMapCustom)
                }.toJsonArray()
            }
        }

        if (scheduledAt != 0L) {
            if (!instance.versionGE(TootInstance.VERSION_2_7_0_rc1)) {
                activity.errorApiResult(R.string.scheduled_status_requires_mastodon_2_7_0)
            }
            // UTCの日時を渡す
            val c = GregorianCalendar.getInstance(TimeZone.getTimeZone("UTC"))
            c.timeInMillis = scheduledAt
            val sv = String.format(
                Locale.JAPANESE,
                "%d-%02d-%02dT%02d:%02d:%02d.%03dZ",
                c.get(Calendar.YEAR),
                c.get(Calendar.MONTH) + 1,
                c.get(Calendar.DAY_OF_MONTH),
                c.get(Calendar.HOUR_OF_DAY),
                c.get(Calendar.MINUTE),
                c.get(Calendar.SECOND),
                c.get(Calendar.MILLISECOND)
            )
            json["scheduled_at"] = sv
        }
    }

    private fun saveStatusTag(status: TootStatus?) {
        status ?: return

        val s = status.decoded_content
        val spanList = s.getSpans(0, s.length, MyClickableSpan::class.java)
        if (spanList != null) {
            val tagList = ArrayList<String?>(spanList.size)
            for (span in spanList) {
                val start = s.getSpanStart(span)
                val end = s.getSpanEnd(span)
                val text = s.subSequence(start, end).toString()
                if (text.startsWith("#")) {
                    tagList.add(text.substring(1))
                }
            }
            val count = tagList.size
            if (count > 0) {
                daoTagHistory.saveList(System.currentTimeMillis(), tagList, 0, count)
            }
        }
    }

    suspend fun runSuspend(): PostResult {

        if (account.isMisskey) {
            attachmentList
                ?.notEmpty()
                ?.map { it.id.toString() }
                ?.takeIf { it != it.distinct() }
                ?.let {
                    activity.errorString(R.string.post_error_attachments_duplicated)
                }
        }

        if (content.isEmpty() && attachmentList == null) {
            activity.errorString(R.string.post_error_contents_empty)
        }

        // nullはCWチェックなしを示す // nullじゃなくてカラならエラー
        if (spoilerText != null && spoilerText.isEmpty()) {
            activity.errorString(R.string.post_error_contents_warning_empty)
        }

        if (!enqueteItems.isNullOrEmpty()) {
            if (enqueteItems.size < 2) {
                activity.errorString(R.string.enquete_item_is_empty, enqueteItems.size + 1)
            }
            enqueteItems.forEachIndexed { i, v ->
                preCheckPollItemOne(enqueteItems, i, v)
            }
        }

        if (scheduledAt != 0L && account.isMisskey) {
            error("misskey has no scheduled status API")
        }

        if (PrefB.bpWarnHashtagAsciiAndNonAscii.value) {
            TootTag.findHashtags(content, account.isMisskey)
                ?.filter {
                    // タグがASCII文字(半角数字を除く)と非ASCII文字の両方を含むか？
                    val hasAscii = reTagAsciiNotNumber.containsMatchIn(it)
                    val hasNonAscii = reTagNonAscii.containsMatchIn(it)
                    hasAscii && hasNonAscii
                }?.map { "#$it" }
                ?.notEmpty()
                ?.let { badTags ->
                    activity.confirm(
                        R.string.hashtag_contains_ascii_and_not_ascii,
                        badTags.joinToString(", ")
                    )
                }
        }

        val isMisskey = account.isMisskey
        if (!visibilityArg.isTagAllowed(isMisskey)) {
            TootTag.findHashtags(content, isMisskey)
                ?.notEmpty()
                ?.let { tags ->
                    log.d("findHashtags ${tags.joinToString(",")}")
                    activity.confirm(
                        R.string.hashtag_and_visibility_not_match
                    )
                }
        }

        if (redraftStatusId != null) {
            activity.confirm(R.string.delete_base_status_before_toot)
        }

        if (scheduledId != null) {
            activity.confirm(R.string.delete_scheduled_status_before_update)
        }

        activity.confirm(
            activity.getString(
                R.string.confirm_post_from,
                daoAcctColor.getNickname(account)
            ),
            account.confirmPost
        ) { newConfirmEnabled ->
            account.confirmPost = newConfirmEnabled
            daoSavedAccount.save(account)
        }

        // ボタン連打判定
        val now = SystemClock.elapsedRealtime()
        val delta = now - lastPostTapped
        lastPostTapped = now
        if (delta < 1000L) {
            log.e("lastPostTapped within 1 sec!")
            activity.showToast(false, R.string.post_button_tapped_repeatly)
            throw CancellationException("post_button_tapped_repeatly")
        }

        // 投稿中に再度投稿ボタンが押された
        if (isPosting.get()) {
            log.e("other postJob is active!")
            activity.showToast(false, R.string.post_button_tapped_repeatly)
            throw CancellationException("preCheck failed.")
        }
        // 全ての確認を終えたらバックグラウンドでの処理を開始する
        isPosting.set(true)
        return try {
            withContext(AppDispatchers.MainImmediate) {
                val (status, scheduled) = activity.runApiTask2(
                    accessInfo = account,
                    progressSetup = { it.setCanceledOnTouchOutside(false) },
                ) { client ->
                    val instance = TootInstance.getOrThrow(client)

                    if (instance.instanceType == InstanceType.Pixelfed) {
                        // Pixelfedは返信に画像を添付できない
                        if (inReplyToId != null && attachmentList != null) {
                            error(R.string.pixelfed_does_not_allow_reply_with_media)
                        }

                        // Pixelfedの返信ではない投稿は画像添付が必須
                        if (inReplyToId == null && attachmentList == null) {
                            error(R.string.pixelfed_does_not_allow_post_without_media)
                        }
                    }

                    val parser = TootParser(this, account)

                    // may null
                    this@PostImpl.visibilityChecked = checkVisibility(client, parser, instance)

                    if (redraftStatusId != null) {
                        // 元の投稿を削除する
                        deleteStatus(client)
                    } else if (scheduledId != null) {
                        deleteScheduledStatus(client)
                    }

                    val json = JsonObject()
                    try {
                        if (account.isMisskey) {
                            encodeParamsMisskey(json, client)
                        } else {
                            encodeParamsMastodon(json, instance)
                        }
                    } catch (ex: JsonException) {
                        throw IllegalStateException("encoding status failed.", ex)
                    }

                    val bodyString = json.toString()

                    fun createRequestBuilder(isPut: Boolean = false): Request.Builder {
                        val requestBody = bodyString.toRequestBody(MEDIA_TYPE_JSON)
                        return when {
                            isPut -> Request.Builder().put(requestBody)
                            else -> Request.Builder().post(requestBody)
                        }.also {
                            if (!PrefB.bpDontDuplicationCheck.value) {
                                val digest = (bodyString + account.acct.ascii).digestSHA256Hex()
                                it.header("Idempotency-Key", digest)
                            }
                        }
                    }

                    try {
                        val result = when {
                            account.isMisskey -> client.requestOrThrow(
                                "/api/notes/create",
                                createRequestBuilder()
                            )

                            editStatusId != null -> client.requestOrThrow(
                                "/api/v1/statuses/$editStatusId",
                                createRequestBuilder(isPut = true)
                            )

                            else -> client.requestOrThrow(
                                "/api/v1/statuses",
                                createRequestBuilder()
                            )
                        }
                        val jsonObject = result.jsonObject
                        when {

                            // 予約投稿完了
                            scheduledAt != 0L && jsonObject != null -> {
                                // {"id":"3","scheduled_at":"2019-01-06T07:08:00.000Z","media_attachments":[]}
                                Pair(null, true)
                            }

                            // 通常投稿完了
                            else -> {
                                val status = parser.status(
                                    when {
                                        account.isMisskey ->
                                            jsonObject?.jsonObject("createdNote")
                                                ?: jsonObject

                                        else -> jsonObject
                                    }
                                )?.also { saveStatusTag(it) }
                                Pair(status, false)
                            }
                        }
                    } catch (ex: TootApiResultException) {
                        val errorMessage = ex.result?.error
                        when {
                            errorMessage.isNullOrBlank() -> error("(missing error detail)")

                            errorMessage.contains("HTTP 404") ->
                                error("$ex\n${activity.getString(R.string.post_404_desc)}")

                            else -> throw ex
                        }
                    }
                }
                when {
                    scheduled -> PostResult.Scheduled(account)

                    status == null ->
                        error("can't parse status in API result.")

                    // 連投してIdempotency が同じだった場合もエラーにはならず、ここを通る
                    else -> PostResult.Normal(account, status)
                }
            }
        } finally {
            isPosting.set(false)
        }
    }
}
