package jp.juggler.subwaytooter.api.entity

import android.annotation.SuppressLint
import android.content.Context
import android.os.SystemClock
import android.text.Spannable
import android.text.SpannableString
import androidx.annotation.StringRes
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.TootAccountMap
import jp.juggler.subwaytooter.api.TootParser
import jp.juggler.subwaytooter.api.entity.TootAccountRef.Companion.tootAccountRef
import jp.juggler.subwaytooter.api.entity.TootAttachment.Companion.tootAttachment
import jp.juggler.subwaytooter.emoji.CustomEmoji
import jp.juggler.subwaytooter.mfm.SpannableStringBuilderEx
import jp.juggler.subwaytooter.pref.PrefB
import jp.juggler.subwaytooter.table.*
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.HTMLDecoder
import jp.juggler.util.*
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import java.lang.ref.WeakReference
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.atomic.AtomicLong
import java.util.regex.Pattern
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min

class FilterTrees(
    val treeHide: WordTrieTree = WordTrieTree(),
    val treeWarn: WordTrieTree = WordTrieTree(),
    val treeAll: WordTrieTree = WordTrieTree(),
)

@Suppress("MemberVisibilityCanPrivate", "LongParameterList")
class TootStatus(
    parser: TootParser,

    val json: JsonObject,

    // A Fediverse-unique resource ID
    // MSP から取得したデータだと uri は提供されずnullになる
    val uri: String,

    // URL to the status page (can be remote)
    // ブーストだとnullになる
    val url: String?,

    // 取得タンスのホスト名。トゥート検索サービスでは提供されずnullになる
    val readerApDomain: Host?,

    // ステータスID。
    // host_access が null の場合は投稿元タンスでのIDかもしれない。
    // 取得に失敗するとINVALID_IDになる
    // Misskeyでは文字列のID。
    val id: EntityId,

    // misskeyではページングIDにRelation ID が別途提供されることがある
    var _orderId: EntityId? = null,

    // The TootAccount which posted the status
    val accountRef: TootAccountRef,

    //The number of reblogs for the status
    // アプリから変更する。検索サービスでは提供されない(null)
    var reblogs_count: Long? = null,

    //The number of favourites for the status
    // アプリから変更する。検索サービスでは提供されない(null)
    var favourites_count: Long? = null,

    //	Whether the authenticated user has reblogged the status
    // アプリから変更する
    var reblogged: Boolean = false,

    //	Whether the authenticated user has favourited the status
    // アプリから変更する
    var favourited: Boolean = false,

    //	Whether the authenticated user has bookmarked the status
    // アプリから変更する
    var bookmarked: Boolean = false,

    // Whether the authenticated user has muted the conversation this status from
    // アプリから変更する
    var muted: Boolean = false,

    // 固定されたトゥート
    // アプリから変更する
    var pinned: Boolean = false,

    //Whether media attachments should be hidden by default
    val sensitive: Boolean,

    // The detected language for the status, if detected
    val language: String?,

    //If not empty, warning text that should be displayed before the actual content
    // アプリ内部では空文字列はCWなしとして扱う
    // マストドンは「null:CWなし」「空じゃない文字列：CWあり」の2種類
    // Pleromaは「空文字列：CWなし」「空じゃない文字列：CWあり」の2種類
    // Misskeyは「CWなし」「空欄CW」「CWあり」の3通り。空欄CWはパース時に書き換えてしまう
    // Misskeyで投稿が削除された時に変更されるため、val変数にできない
    var spoiler_text: String = "",
    var decoded_spoiler_text: Spannable,

    //	Body of the status; this will contain HTML (remote HTML already sanitized)
    var content: String?,
    var decoded_content: Spannable,

    //Application from which the status was posted
    val application: TootApplication?,

    var custom_emojis: MutableMap<String, CustomEmoji>?,

    val profile_emojis: Map<String, NicoProfileEmoji>?,

    //	The time the status was created
    private val created_at: String?,

    //	null or the ID of the status it replies to
    val in_reply_to_id: EntityId?,

    //	null or the ID of the account it replies to
    val in_reply_to_account_id: EntityId?,

    // null or the reblogged Status
    // 投稿の更新が実装されたのでvarになった
    var reblog: TootStatus? = null,

    //One of: public, unlisted, private, direct
    val visibility: TootVisibility,

    private val misskeyVisibleIds: ArrayList<String>?,

    //	An array of Attachments
    val media_attachments: ArrayList<TootAttachmentLike>?,

    //	An array of Mentions
    var mentions: ArrayList<TootMention>? = null,

    //An array of Tags
    var tags: List<TootTag>? = null,

    // public Spannable decoded_tags;
    var decoded_mentions: Spannable = EMPTY_SPANNABLE,

    var enquete: TootPolls? = null,

    //
    var replies_count: Long? = null,

    var viaMobile: Boolean = false,

    var reactionSet: TootReactionSet? = null,

    var reply: TootStatus?,

    val serviceType: ServiceType,

    val deletedAt: String?,

    val time_deleted_at: Long,

    private var localOnly: Boolean = false,

    var myRenoteId: EntityId? = null,

    // reblog,reply された投稿からその外側を参照する
    var reblogParent: TootStatus? = null,

    // quote toot かどうか。
    var isQuoteToot: Boolean = false,

    private var quote_id: EntityId? = null,

    // このstatusがquoteだった場合、ミュート済みかどうか示すフラグ
    var quote_muted: Boolean = false,

    // Misskey 12.3
    var isPromoted: Boolean = false,
    var isFeatured: Boolean = false,

    // Mastodon 3.5.0
    var time_edited_at: Long = 0L,

    // Mastodon 4.0.0
    var filteredV4: List<TootFilterResult>? = null,

    ///////////////////////////////////////////////////////////////////
    // 以下はentityから取得したデータではなく、アプリ内部で使う

    // アプリ内部で使うワークエリア
    var auto_cw: AutoCW? = null,

    // 会話の流れビューで後から追加する
    var card: TootCard? = null,

    var highlightSound: HighlightWord? = null,
    var highlightSpeech: HighlightWord? = null,
    var highlightAny: HighlightWord? = null,

    val time_created_at: Long,

    ) : TimelineItem() {
    // 会話カラムの場合に使う
    var conversationSummary: TootConversationSummary? = null
    var conversation_main: Boolean = false

    // 投稿元タンスのホスト名
    val originalApDomain: Host
        get() = account.apDomain

    val account: TootAccount
        get() = TootAccountMap.find(accountRef.mapId)

    override fun getOrderId() = _orderId ?: id

    class AutoCW(
        var refActivity: WeakReference<Any>? = null,
        var cellWidth: Int = 0,
        var decodedSpoilerText: Spannable? = null,
        var originalLineCount: Int = 0,
    )

    init {
        decoded_mentions = HTMLDecoder.decodeMentions(parser, this) ?: EMPTY_SPANNABLE

        this.reblog?.reblogParent = this
    }

    ///////////////////////////////////////////////////
    // ユーティリティ

    // メディア表示を隠したかどうかのキーに使われる
    // APドメイン名
    val hostAccessOrOriginal: Host
        get() = readerApDomain?.valid() ?: originalApDomain.valid() ?: Host.UNKNOWN

    val busyKey: String
        get() = "${hostAccessOrOriginal.ascii}:$id"

    fun checkMuted(): Boolean {

        // app mute
        if (application?.name?.let { muted_app?.contains(it) } == true) {
            return true
        }

        // word mute
        muted_word?.run {
            if (matchShort(decoded_content)) return true
            if (matchShort(decoded_spoiler_text)) return true
        }

        // reblog
        return true == reblog?.checkMuted()
    }

    fun hasMedia(): Boolean {
        return (media_attachments?.size ?: 0) > 0
    }

    fun canPin(accessInfo: SavedAccount): Boolean =
        reblog == null &&
                accessInfo.isMe(account) &&
                visibility.canPin(accessInfo.isMisskey)

    // 内部で使う
    private var _filteredWord: String? = null

    val filteredWord: String?
        get() = _filteredWord ?: reblog?._filteredWord

    val filtered: Boolean
        get() = filteredWord != null

    private fun hasReceipt(accessInfo: SavedAccount): TootVisibility {
        val fullAcctMe = accessInfo.getFullAcct(account)

        val reply_account = reply?.account
        if (reply_account != null && fullAcctMe != accessInfo.getFullAcct(reply_account)) {
            return TootVisibility.DirectSpecified
        }

        val in_reply_to_account_id = this.in_reply_to_account_id
        if (in_reply_to_account_id != null && in_reply_to_account_id != account.id) {
            return TootVisibility.DirectSpecified
        }

        mentions?.forEach {
            if (fullAcctMe != accessInfo.getFullAcct(it.acct)) {
                return@hasReceipt TootVisibility.DirectSpecified
            }
        }

        return TootVisibility.DirectPrivate
    }

    fun getBackgroundColorType(accessInfo: SavedAccount) =
        when (visibility) {
            TootVisibility.DirectPrivate,
            TootVisibility.DirectSpecified,
            -> hasReceipt(accessInfo)

            else -> visibility
        }

    fun updateKeywordFilteredFlag(
        accessInfo: SavedAccount,
        trees: FilterTrees?,
        matchedFiltersV4: List<TootFilterResult>? = null,
        // フィルタ更新時などは隠すフィルタも含めてチェックする
        checkAll: Boolean = false,
    ) {
        trees ?: return
        val desc = if (accessInfo.isMe(account) || accessInfo.isMe(reblog?.account)) {
            null
        } else {
            val tree = if (checkAll) trees.treeAll else trees.treeWarn
            val m1 = matchKeywordFilter(accessInfo, tree)
            val m2 = reblog?.matchKeywordFilter(accessInfo, tree)

            if (m1.isNullOrEmpty() &&
                m2.isNullOrEmpty() &&
                matchedFiltersV4.isNullOrEmpty()
            ) {
                null
            } else {
                val list = ArrayList<String>()
                fun String.addToList() {
                    if (this.isNotEmpty() && !list.contains(this)) list.add(this)
                }

                fun List<String>.addToList() {
                    for (s in this) s.addToList()
                }

                matchedFiltersV4?.forEach { it.filter?.title?.addToList() }
                m1?.forEach { m ->
                    m.tags?.mapNotNull { (it as? TootFilter)?.title }
                        ?.addToList()
                }
                m2?.forEach { m ->
                    m.tags?.mapNotNull { (it as? TootFilter)?.title }
                        ?.addToList()
                }
                if (list.isEmpty()) {
                    matchedFiltersV4?.forEach { fr ->
                        fr.filter?.keywords?.map { it.keyword }?.addToList()
                    }
                    m1?.forEach { m ->
                        m.word.notEmpty()?.let { list.add(it) }
                    }
                    m2?.forEach { m ->
                        m.word.notEmpty()?.let { list.add(it) }
                    }
                }
                list.joinToString(", ")
            }
        }
        _filteredWord = desc
        reblog?._filteredWord = desc
    }

    fun matchKeywordFilterWithReblog(
        accessInfo: SavedAccount,
        tree: WordTrieTree?,
    ): List<WordTrieTree.Match>? {
        matchKeywordFilter(accessInfo, tree)
            ?.notEmpty()?.let { return it }

        reblog?.matchKeywordFilter(accessInfo, tree)
            ?.notEmpty()?.let { return it }

        return null
    }

    private fun matchKeywordFilter(
        accessInfo: SavedAccount,
        tree: WordTrieTree?,
    ): ArrayList<WordTrieTree.Match>? {
        // フィルタ単語がない、または
        if (tree.isNullOrEmpty() || accessInfo.isMe(account)) return null

        var list: ArrayList<WordTrieTree.Match>? = null

        fun check(t: CharSequence?) {
            if (t.isNullOrEmpty()) return
            val matches = tree.matchList(t) ?: return
            (list ?: ArrayList<WordTrieTree.Match>().also { list = it })
                .addAll(matches)
        }
        check(decoded_spoiler_text)
        check(decoded_content)
        media_attachments?.forEach { check(it.description) }
        return list
    }

    fun updateReactionMastodon(newReactionSet: TootReactionSet?) {
        synchronized(this) {
            this.reactionSet = newReactionSet
        }
    }

    fun updateReactionMastodonByEvent(newReaction: TootReaction) {
        synchronized(this) {
            var reactionSet = this.reactionSet
            if (newReaction.count <= 0) {
                reactionSet?.get(newReaction.name)?.let { reactionSet?.remove(it) }
            } else {
                if (reactionSet == null) {
                    reactionSet = TootReactionSet(isMisskey = false)
                    this.reactionSet = reactionSet
                }

                when (val old = reactionSet[newReaction.name]) {
                    null -> reactionSet.add(newReaction)

                    // 同一オブジェクトならマージは不要
                    newReaction -> {
                    }

                    // 異なるオブジェクトの場合はmeを壊さないようにカウントだけ更新する
                    else -> old.count = newReaction.count
                }
            }
        }
    }

    // return true if updated
    fun increaseReactionMisskey(
        code: String?,
        byMe: Boolean,
        emoji: CustomEmoji? = null,
        caller: String,
    ): Boolean {
        code ?: return false

        synchronized(this) {

            if (emoji != null) {
                if (custom_emojis == null) custom_emojis = HashMap()
                custom_emojis?.put(emoji.mapKey, emoji)
            }

            var reactionSet = this.reactionSet
            if (reactionSet == null) {
                reactionSet = TootReactionSet(isMisskey = true)
                this.reactionSet = reactionSet
            }

            if (byMe) {
                // 自分でリアクションしたらUIで更新した後にストリーミングイベントが届くことがある
                // その場合はカウントを変更しない
                if (reactionSet.any { it.me && it.name == code }) return false
            }

            log.d("increaseReaction noteId=$id byMe=$byMe caller=$caller")

            // カウントを増やす
            val reaction =
                reactionSet[code]?.also { it.count = max(0, it.count + 1L) }
                    ?: TootReaction(name = code, count = 1L).also { reactionSet.add(it) }

            if (byMe) reaction.me = true

            return true
        }
    }

    fun decreaseReactionMisskey(
        code: String?,
        byMe: Boolean,
        caller: String,
    ): Boolean {
        code ?: return false

        synchronized(this) {

            val reactionSet = this.reactionSet ?: return false

            if (byMe) {
                // 自分でリアクションしたらUIで更新した後にストリーミングイベントが届くことがある
                // その場合はカウントを変更しない
                if (reactionSet.any { !it.me && it.name == code }) return false
            }

            log.d("decreaseReaction noteId=$id byMe=$byMe caller=$caller")

            // カウントを減らす
            val reaction = reactionSet[code]
                ?.also { it.count = max(0L, it.count - 1L) }

            if (byMe) reaction?.me = false

            return true
        }
    }

    fun markDeleted(context: Context, deletedAt: Long?): Boolean {

        if (PrefB.bpDontRemoveDeletedToot.value) return false

        var sv = if (deletedAt != null) {
            context.getString(R.string.status_deleted_at, formatTime(context, deletedAt, false))
        } else {
            context.getString(R.string.status_deleted)
        }
        this.content = sv
        this.decoded_content = SpannableString(sv)

        sv = ""
        this.spoiler_text = sv
        this.decoded_spoiler_text = SpannableString(sv)

        return true
    }

    class FindStatusIdFromUrlResult(
        val statusId: EntityId?, // may null
        hostArg: String,
        val url: String,
        val isReference: Boolean = false,
    ) {
        val host = Host.parse(hostArg)
    }

    companion object {

        internal val log = LogCategory("TootStatus")

        @Volatile
        internal var muted_app: Set<String>? = null

        @Volatile
        internal var muted_word: WordTrieTree? = null

        @Volatile
        internal var favMuteSet: Set<Acct>? = null

        private val timeMuteData = AtomicLong(0L)
        private const val MUTE_DATA_EXPIRE = 120_000L

        private fun mergeMentions(
            mentions1: List<TootMention>?,
            mentions2: List<TootMention>?,
        ): ArrayList<TootMention>? {
            val size = (mentions1?.size ?: 0) + (mentions2?.size ?: 0)
            if (size == 0) return null
            val dst = ArrayList<TootMention>(size)
            if (mentions1 != null) dst.addAll(mentions1)
            if (mentions2 != null) dst.addAll(mentions2)
            return dst
        }

        private fun statusMisskey(parser: TootParser, src: JsonObject): TootStatus {
            src["_fromStream"] = parser.fromStream
            val apiHost = parser.apiHost
            val misskeyId = src.string("id")
            val id = EntityId.mayDefault(misskeyId)
            var uri = "https://$apiHost/notes/$misskeyId"
            var url = "https://$apiHost/notes/$misskeyId"
            // リモート投稿には uriが含まれる
            src.string("uri")?.let {
                uri = it
                url = it
            }
            val who = parser.account(src.jsonObject("user"))
                ?: error("missing account")
            val accountRef = tootAccountRef(parser, who)
            val account = accountRef.get()
            val created_at = src.string("createdAt")

            // 絵文字マップはすぐ後で使うので、最初の方で読んでおく
            var custom_emojis: MutableMap<String, CustomEmoji>? =
                when (src.jsonObject("emojis")?.values?.firstOrNull()) {
                    // Misskey13 は ショートコード→URLの単純なマップ
                    is String -> CustomEmoji.decodeMisskey12ReactionEmojis(src.jsonObject("emojis"))
                    // もっと古い形式
                    else -> parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMisskey)
                }

            val reactionEmojis: MutableMap<String, CustomEmoji>? =
                CustomEmoji.decodeMisskey12ReactionEmojis(src.jsonObject("reactionEmojis"))

            if (reactionEmojis != null) {
                custom_emojis = when (custom_emojis) {
                    null -> reactionEmojis
                    else -> (reactionEmojis + custom_emojis).toMutableMap()
                }
            }

            // Misskeyは画像毎にNSFWフラグがある。どれか１枚でもNSFWならトゥート全体がNSFWということにする
            var sensitive = src.optBoolean("sensitive")
            val media_attachments = parseListOrNull(
                src.jsonArray("files") ?: src.jsonArray("media") // v11,v10
            ) {
                @Suppress("USELESS_CAST")
                tootAttachment(parser, it) as TootAttachmentLike
            }
            media_attachments?.forEach {
                if ((it as? TootAttachment)?.isSensitive == true) {
                    sensitive = true
                }
            }
            val spoilerRaw = src.string("cw")?.cleanCW()
            val profile_emojis = null
            val options1 = DecodeOptions(
                parser.context,
                parser.linkHelper,
                short = true,
                decodeEmoji = true,
                emojiMapCustom = custom_emojis,
                emojiMapProfile = profile_emojis,
                attachmentList = media_attachments,
                highlightTrie = parser.highlightTrie,
                mentions = null, // MisskeyはMFMをパースし終わるまでメンションが分からない
                authorDomain = accountRef.get(),
                emojiSizeMode = parser.emojiSizeMode,
            )
            // ハイライト検出のためにDecodeOptionsを作り直す？
            val options2 = DecodeOptions(
                parser.context,
                parser.linkHelper,
                short = true,
                decodeEmoji = true,
                emojiMapCustom = custom_emojis,
                emojiMapProfile = profile_emojis,
                attachmentList = media_attachments,
                highlightTrie = parser.highlightTrie,
                mentions = null, // MisskeyはMFMをパースし終わるまでメンションが分からない
                authorDomain = accountRef.get(),
                emojiSizeMode = parser.emojiSizeMode,
            )

            val content = src.string("text")

            val spoiler_text = when {
                spoilerRaw == null -> "" // CWなし
                spoilerRaw.replace('\u0323', ' ').isBlank() ->
                    parser.context.getString(R.string.blank_cw)

                else -> spoilerRaw
            }

            // Markdownのデコード結果からmentionsを読む
            val decoded_content = options1.decodeHTML(content)
            val mentions1 = (decoded_content as? SpannableStringBuilderEx)?.mentions
            var highlightSound = options1.highlightSound
            var highlightSpeech = options1.highlightSpeech
            var highlightAny = options1.highlightAny

            val decoded_spoiler_text = options2.decodeHTML(spoiler_text)
            val mentions2 = (decoded_spoiler_text as? SpannableStringBuilderEx)?.mentions
            if (highlightSound == null) highlightSound = options2.highlightSound
            if (highlightSpeech == null) highlightSpeech = options2.highlightSpeech
            if (highlightAny == null) highlightAny = options2.highlightAny

            val reply = parser.status(src.jsonObject("reply"))
            val reblog = parser.status(src.jsonObject("renote"))

            val isQuoteToot = when (reblog) {
                // 別の投稿を参照していない
                null -> false

                // 別の投稿を参照して、かつ この投稿自体が何かコンテンツを持つなら引用トゥートである
                else -> content?.isNotEmpty() == true ||
                        spoiler_text.isNotEmpty() ||
                        media_attachments?.isNotEmpty() == true ||
                        src.jsonObject("poll") != null
            }

            val card: TootCard? = when {
                // 引用Renoteにプレビューカードをでっちあげる
                reblog != null && isQuoteToot -> {
                    TootCard.tootCard(parser, reblog)
                }
                // 返信にプレビューカードをでっちあげる
                reply != null -> {
                    TootCard.tootCard(parser, reply)
                }

                else -> null
            }

            // めいめいフォークでは myRenoteIdというものがあるらしい
            // https://github.com/mei23/misskey/blob/mei-m544/src/models/note.ts#L384-L394
            // 直近の一つのrenoteのIdを得られるらしい。
            var reblogged = false
            val myRenoteId = EntityId.mayNull(src.string("myRenoteId"))
            if (myRenoteId != null) reblogged = true
            // しかしTLにRenoteが露出してるならそのIDを使う方が賢明であろう
            // 外側ステータスが自分なら、内側ステータスのmyRenoteIdを設定する
            if (reblog != null && parser.linkHelper.cast<SavedAccount>()
                    ?.isMe(account) == true
            ) {
                reblog.myRenoteId = id
                reblog.reblogged = true
            }
            val deletedAt = src.string("deletedAt")
            val localOnly = src.optBoolean("localOnly")

            // お気に入りカラムなどではパース直後に変更することがある
            return TootStatus(
                // "mentionedRemoteUsers" -> "[{"uri":"https:\/\/mastodon.juggler.jp\/users\/tateisu","username":"tateisu","host":"mastodon.juggler.jp"}]"
                // this.decoded_tags = HTMLDecoder.decodeTags( account,status.tags );
                accountRef = accountRef,
                // auto_cw
                // bookmarked
                card = card,
                // conversationSummary
                // conversation_main
                content = content,
                created_at = created_at,
                custom_emojis = custom_emojis,
                // decoded_mentions
                decoded_content = decoded_content,
                decoded_spoiler_text = decoded_spoiler_text,
                deletedAt = deletedAt,
                favourited = src.optBoolean("isFavorited"),
                favourites_count = 0L,
                // filteredV4
                highlightAny = highlightAny,
                highlightSound = highlightSound,
                highlightSpeech = highlightSpeech,
                id = id,
                in_reply_to_account_id = reply?.account?.id,
                in_reply_to_id = EntityId.mayNull(src.string("replyId")),
                isFeatured = src.string("_featuredId_")?.isNotEmpty() == true,
                isPromoted = src.string("_prId_")?.isNotEmpty() == true,
                isQuoteToot = isQuoteToot,
                json = src,
                language = null,
                localOnly = localOnly,
                media_attachments = media_attachments,
                mentions = mergeMentions(mentions1, mentions2),
                misskeyVisibleIds = parseStringArray(src.jsonArray("visibleUserIds")),
                muted = false,
                myRenoteId = myRenoteId,
                parser = parser,
                pinned = parser.pinned,
                // quote_id 下記
                profile_emojis = profile_emojis,
                quote_muted = src.boolean("quote_muted") ?: false,
                // reactionSet 下記
                readerApDomain = parser.apDomain,
                reblog = reblog,
                // reblogParent
                reblogged = reblogged,
                reblogs_count = src.long("renoteCount") ?: 0L,
                replies_count = src.long("repliesCount") ?: 0L,
                reply = reply,
                sensitive = sensitive,
                serviceType = parser.serviceType,
                spoiler_text = spoiler_text,
                tags = parseMisskeyTags(src.jsonArray("tags")),
                time_created_at = parseTime(created_at),
                time_deleted_at = parseTime(deletedAt),
                // time_edited_at
                uri = uri,
                url = url,
                viaMobile = src.optBoolean("viaMobile"),

                application = parseItem(src.jsonObject("app")) {
                    TootApplication(parser, it)
                },
                reactionSet = TootReactionSet.parseMisskey(
                    src.jsonObject("reactions") ?: src.jsonObject("reactionCounts"),
                    src.string("myReaction")
                ),
                quote_id = when {
                    isQuoteToot -> reblog?.id
                    else -> null
                },
                visibility = TootVisibility.parseMisskey(
                    src.string("visibility"),
                    localOnly
                ) ?: TootVisibility.Unknown,
            ).apply {
                // contentを読んだ後にアンケートのデコード
                enquete = TootPolls.parse(
                    parser,
                    TootPollsType.Misskey,
                    this,
                    media_attachments,
                    src.jsonObject("poll"),
                )
            }
        }

        private fun statusNoteStock(parser: TootParser, src: JsonObject): TootStatus {

            src["_fromStream"] = parser.fromStream

            val apTag = APTag(parser, src.jsonArray("tag"))

            val who = parser.account(src.jsonObject("account"))
            if (who == null) {
                error("missing account")
            }
            val accountRef = tootAccountRef(parser, who)
            val account = accountRef.get()

            val uri = src.string("id") ?: error("missing uri")
            val url = src.string("url") ?: uri

            val quote = when {
                !parser.decodeQuote -> null
                else -> try {
                    parser.decodeQuote = false
                    parser.status(src.jsonObject("quote"))
                } finally {
                    parser.decodeQuote = true
                }
            }
            val quote_id = quote?.id ?: EntityId.mayNull(src.string("quote_id"))

            val apAttachment = APAttachment(src.jsonArray("attachment"))
            val media_attachments = apAttachment.mediaAttachments.notEmpty()

            val custom_emojis = apTag.emojiList.notEmpty()
            val profile_emojis = apTag.profileEmojiList.notEmpty()
            val created_at = src.string("published")
            val mentions = apTag.mentions

            val options1 = DecodeOptions(
                parser.context,
                parser.linkHelper,
                short = true,
                decodeEmoji = true,
                emojiMapCustom = custom_emojis,
                emojiMapProfile = profile_emojis,
                attachmentList = media_attachments,
                highlightTrie = parser.highlightTrie,
                mentions = mentions,
                authorDomain = account,
                unwrapEmojiImageTag = true, // notestockはカスタム絵文字がimageタグになってる
                emojiSizeMode = parser.emojiSizeMode,
            )

            // ハイライト検出のためにDecodeOptionsを作り直す？
            val options2 = DecodeOptions(
                parser.context,
                emojiMapCustom = custom_emojis,
                emojiMapProfile = profile_emojis,
                highlightTrie = parser.highlightTrie,
                mentions = mentions,
                authorDomain = account,
                unwrapEmojiImageTag = true, // notestockはカスタム絵文字がimageタグになってる
                emojiSizeMode = parser.emojiSizeMode,
            )

            val content = src.string("content")
            val decoded_content = options1.decodeHTML(content)
            var highlightSound = options1.highlightSound
            var highlightSpeech = options1.highlightSpeech
            var highlightAny = options1.highlightAny

            val summaryRaw = (src.string("summary") ?: "").cleanCW()
            val spoiler_text = when {
                summaryRaw.isEmpty() -> "" // CWなし
                summaryRaw.isBlank() -> parser.context.getString(R.string.blank_cw)
                else -> summaryRaw
            }
            val decoded_spoiler_text = options2.decodeEmoji(spoiler_text)
            if (highlightSound == null) highlightSound = options2.highlightSound
            if (highlightSpeech == null) highlightSpeech = options2.highlightSpeech
            if (highlightAny == null) highlightAny = options2.highlightAny

            return TootStatus(
                accountRef = accountRef,
                application = null,
                // bookmarked
                card = quote?.let { TootCard.tootCard(parser, it) },
                // conversationSummary
                // conversation_main
                content = content,
                created_at = created_at,
                custom_emojis = custom_emojis,
                decoded_content = decoded_content,
                // decoded_mentions 下記
                decoded_spoiler_text = decoded_spoiler_text,
                deletedAt = null,
                // enquete 下記
                // favourited
                favourites_count = null,
                // filteredV4
                highlightAny = highlightAny,
                highlightSound = highlightSound,
                highlightSpeech = highlightSpeech,
                id = findStatusIdFromUri(uri, url) ?: EntityId.DEFAULT,
                in_reply_to_account_id = null,
                in_reply_to_id = null,
                // isFeatured
                // isPromoted
                //
                isQuoteToot = quote_id != null,
                // localOnly
                json = src,
                language = null,
                media_attachments = media_attachments,
                mentions = mentions,
                misskeyVisibleIds = null,
                muted = false,
                // myRenoteId
                parser = parser,
                pinned = parser.pinned || src.optBoolean("pinned"),
                profile_emojis = profile_emojis,
                quote_id = quote_id,
                quote_muted = src.boolean("quote_muted") ?: false,
                // reactionSet
                readerApDomain = null,
                reblog = null,
                // reblogParent
                // reblogged
                reblogs_count = null,
                replies_count = null,
                reply = null,
                sensitive = src.optBoolean("sensitive"),
                serviceType = parser.serviceType,
                spoiler_text = spoiler_text,
                tags = apTag.hashtags,
                time_created_at = parseTime(created_at),
                time_deleted_at = 0L,
                uri = uri,
                url = url,
                // viaMobile

                visibility = when (src.jsonArray("to")
                    ?.any { it == "https://www.w3.org/ns/activitystreams#Public" }) {
                    true -> TootVisibility.Public
                    else -> TootVisibility.UnlistedHome
                },
            ).apply {
                decoded_mentions =
                    HTMLDecoder.decodeMentions(parser, this)
                        ?: EMPTY_SPANNABLE
                enquete = (src.jsonArray("oneOf") ?: src.jsonArray("anyOf"))?.let {
                    try {
                        TootPolls.tootPolls(
                            TootPollsType.Notestock,
                            parser,
                            this,
                            media_attachments,
                            src,
                            it
                        )
                    } catch (ex: Throwable) {
                        log.e(ex, "TootStatus ctor failed. enquete (NoteStock)")
                        null
                    }
                }
            }
        }

        private fun statusMastodon(parser: TootParser, src: JsonObject): TootStatus {

            src["_fromStream"] = parser.fromStream

            val url = src.string("url") // ブースト等では頻繁にnullになる
            val created_at = src.string("created_at")

            // 絵文字マップはすぐ後で使うので、最初の方で読んでおく
            val custom_emojis = parseMapOrNull(src.jsonArray("emojis"), CustomEmoji::decodeMastodon)

            val profile_emojis = when (val o = src["profile_emojis"]) {
                is JsonArray -> parseMapOrNull(o) { NicoProfileEmoji(it) }
                is JsonObject -> parseProfileEmoji2(o) { j, k -> NicoProfileEmoji(j, k) }
                else -> null
            }

            val mentions = parseListOrNull(src.jsonArray("mentions")) { TootMention(it) }

            val who = parser.account(src.jsonObject("account"))
                ?: error("missing account")
            val accountRef = tootAccountRef(parser, who)
            val account = accountRef.get()

            val readerApDomain: Host?
            val id: EntityId
            val uri: String
            var reblogged = false
            var favourited = false
            var bookmarked = false
            val time_created_at: Long
            var media_attachments: ArrayList<TootAttachmentLike>? = null
            val visibility: TootVisibility
            val sensitive: Boolean
            var filteredV4: List<TootFilterResult>? = null
            when (parser.serviceType) {
                ServiceType.MASTODON -> {
                    readerApDomain = parser.apDomain
                    id = EntityId.mayDefault(src.string("id"))
                    uri = src.string("uri") ?: error("missing uri")
                    reblogged = src.optBoolean("reblogged")
                    favourited = src.optBoolean("favourited")
                    bookmarked = src.optBoolean("bookmarked")

                    time_created_at = parseTime(created_at)
                    media_attachments =
                        parseListOrNull(src.jsonArray("media_attachments")) {
                            tootAttachment(parser, it)
                        }

                    // kmy.blue拡張
                    // https://github.com/kmycode/mastodon/wiki/%E3%83%A1%E3%83%87%E3%82%A3%E3%82%A2%E6%8B%A1%E5%BC%B5API
                    // そのうちなくなるらしい
                    // https://kmy.blue/@askyq/110241555669354847
                    parseListOrNull(src.jsonArray("media_attachments_ex")) {
                        tootAttachment(parser, it)
                    }?.notEmpty()?.let {
                        when (val list = media_attachments) {
                            null -> media_attachments = ArrayList<TootAttachmentLike>(it)
                            else -> list.addAll(it)
                        }
                    }

                    val visibilityString = when {
                        src.boolean("limited") == true -> "limited"
                        else -> src.string("visibility")
                    }
                    visibility = TootVisibility.parseMastodon(visibilityString)
                        ?: TootVisibility.Unknown
                    sensitive = src.optBoolean("sensitive")

                    filteredV4 = TootFilterResult.parseList(src.jsonArray("filtered"))
                }

                ServiceType.TOOTSEARCH -> {
                    readerApDomain = null

                    // 投稿元タンスでのIDを調べる。失敗するかもしれない
                    // XXX: Pleromaだとダメそうな印象
                    uri = src.string("uri") ?: error("missing uri")
                    id = findStatusIdFromUri(uri, url) ?: EntityId.DEFAULT

                    time_created_at = parseTime(created_at)
                    media_attachments = parseList(src.jsonArray("media_attachments")) {
                        tootAttachment(parser, it)
                    }
                    visibility = TootVisibility.Public
                    sensitive = src.optBoolean("sensitive")
                }

                ServiceType.MSP -> {
                    readerApDomain = parser.apDomain

                    // MSPのデータはLTLから呼んだものなので、常に投稿元タンスでのidが得られる
                    id = EntityId.mayDefault(src.string("id"))

                    // MSPだとuriは提供されない。LTL限定なのでURL的なものを作れるはず
                    uri =
                        "https://${account.apiHost}/users/${who.username}/statuses/$id"

                    time_created_at = parseTimeMSP(created_at)
                    media_attachments =
                        TootAttachmentMSP.parseList(src.jsonArray("media_attachments"))
                    visibility = TootVisibility.Public
                    sensitive = src.optInt("sensitive", 0) != 0
                }

                ServiceType.MISSKEY, ServiceType.NOTESTOCK -> error("will not happen")
            }

            val quote = when {
                !parser.decodeQuote -> null
                else -> try {
                    parser.decodeQuote = false
                    parser.status(src.jsonObject("quote"))
                } finally {
                    parser.decodeQuote = true
                }
            }

            val quote_id = quote?.id ?: EntityId.mayNull(src.string("quote_id"))
            val isQuoteToot = quote_id != null
            val quote_muted = src.boolean("quote_muted") ?: false

            // Pinned TL を取得した時にreblogが登場することはないので、reblogについてpinned 状態を気にする必要はない
            // Hostdon QT と通常のreblogが同時に出ることはないので、quoteが既出ならreblogのjsonデータは見ない
            val reblog = quote ?: parser.status(src.jsonObject("reblog"))

            val removeQt = false

            // content
            val content = src.string("content")?.let { sv ->
                when {
                    removeQt -> {
                        log.d("removeQt? $sv")
                        val reQuoteTootRemover =
                            """(?:\s|<br/>)*QT:\s*\[[^]]+]((?:</\w+>)*)\z""".toRegex()
                        sv.replace(reQuoteTootRemover) {
                            it.groupValues.elementAtOrNull(1) ?: ""
                        }.also { after ->
                            log.d("removeQt? after = $after")
                        }
                    }

                    else -> sv
                }
            }

            val options1 = DecodeOptions(
                parser.context,
                parser.linkHelper,
                short = true,
                decodeEmoji = true,
                emojiMapCustom = custom_emojis,
                emojiMapProfile = profile_emojis,
                attachmentList = media_attachments,
                highlightTrie = parser.highlightTrie,
                mentions = mentions,
                authorDomain = account,
                emojiSizeMode = parser.emojiSizeMode,
            )

            val decoded_content = options1.decodeHTML(content)
            var highlightSound = options1.highlightSound
            var highlightSpeech = options1.highlightSpeech
            var highlightAny = options1.highlightAny

            val sv = (src.string("spoiler_text") ?: "").cleanCW()
            val spoiler_text = when {
                sv.isEmpty() -> "" // CWなし
                sv.isBlank() -> parser.context.getString(R.string.blank_cw)
                else -> sv
            }

            // ハイライト検出のためにDecodeOptionsを作り直す？
            val options2 = DecodeOptions(
                parser.context,
                emojiMapCustom = custom_emojis,
                emojiMapProfile = profile_emojis,
                highlightTrie = parser.highlightTrie,
                mentions = mentions,
                authorDomain = account,
                emojiSizeMode = parser.emojiSizeMode,
            )
            val decoded_spoiler_text = options2.decodeEmoji(spoiler_text)
            if (highlightSound == null) highlightSound = options2.highlightSound
            if (highlightSpeech == null) highlightSpeech = options2.highlightSpeech
            if (highlightAny == null) highlightAny = options2.highlightAny

            return TootStatus(
                accountRef = accountRef,
                bookmarked = bookmarked,
                // card 下記
                // conversationSummary
                // conversation_main
                content = content,
                created_at = created_at,
                custom_emojis = custom_emojis,
                decoded_content = decoded_content,
                // decoded_mentions
                decoded_spoiler_text = decoded_spoiler_text,
                deletedAt = null,
                // enquete 下記
                favourited = favourited,
                favourites_count = src.long("favourites_count"),
                filteredV4 = filteredV4,
                highlightAny = highlightAny,
                highlightSound = highlightSound,
                highlightSpeech = highlightSpeech,
                id = id,
                in_reply_to_account_id = EntityId.mayNull(src.string("in_reply_to_account_id")),
                in_reply_to_id = EntityId.mayNull(src.string("in_reply_to_id")),
                // isFeatured
                // isPromoted
                isQuoteToot = isQuoteToot,
                json = src,
                language = src.string("language")?.notEmpty(),
                // localOnly
                media_attachments = media_attachments,
                mentions = mentions,
                misskeyVisibleIds = null,
                muted = src.optBoolean("muted"),
                // myRenoteId
                parser = parser,
                pinned = parser.pinned || src.optBoolean("pinned"),
                profile_emojis = profile_emojis,
                quote_id = quote_id,
                quote_muted = quote_muted,
                // reactionSet 下記
                readerApDomain = readerApDomain,
                reblog = reblog,
                reblogged = reblogged,
                reblogs_count = src.long("reblogs_count"),
                replies_count = src.long("replies_count"),
                reply = null,
                sensitive = sensitive,
                serviceType = parser.serviceType,
                spoiler_text = spoiler_text,
                tags = TootTag.parseListOrNull(parser, src.jsonArray("tags")),
                time_created_at = time_created_at,
                time_deleted_at = 0L,
                time_edited_at = parseTime(src.string("edited_at")),
                uri = uri,
                url = url,
                // viaMobile
                visibility = visibility,

                reactionSet = TootReactionSet.parseFedibird(
                    src.jsonArray("emoji_reactions")
                        ?: src.jsonObject("pleroma")?.jsonArray("emoji_reactions")
                ),
                application = parseItem(src.jsonObject("application")) {
                    TootApplication(parser, it)
                },
            ).apply {
                enquete = try {
                    src.string("enquete")?.notEmpty()?.let {
                        TootPolls.tootPolls(
                            TootPollsType.FriendsNico,
                            parser,
                            this,
                            media_attachments,
                            it.decodeJsonObject(),
                        )
                    } ?: src.jsonObject("poll")?.let {
                        TootPolls.tootPolls(
                            TootPollsType.Mastodon,
                            parser,
                            this,
                            media_attachments,
                            it,
                        )
                    }
                } catch (ex: Throwable) {
                    log.e(ex, "TootStatus ctor failed. enquete")
                    null
                }

                // 2.6.0からステータスにもカード情報が含まれる
                card = parseItem(src.jsonObject("card")) { TootCard.tootCard(it) }
                if (card == null && quote != null) {
                    // 引用Renoteにプレビューカードをでっちあげる
                    card = TootCard.tootCard(parser, quote)
                    // content中のQTの表現が四角括弧の有無とか色々あるみたいだし
                    // 選択してコピーのことを考えたらむしろ削らない方が良い気がしてきた
                    // removeQt = ! PrefB.bpDontShowPreviewCard(Pref.pref(parser.context))
                }

                decoded_mentions =
                    HTMLDecoder.decodeMentions(parser, this)
                        ?: EMPTY_SPANNABLE
            }
        }

        fun tootStatus(parser: TootParser, src: JsonObject): TootStatus =
            when (parser.serviceType) {
                ServiceType.MISSKEY -> statusMisskey(parser, src)
                ServiceType.NOTESTOCK -> statusNoteStock(parser, src)
                else -> statusMastodon(parser, src)
            }

        fun updateMuteData(force: Boolean = false) {
            val now = SystemClock.elapsedRealtime()
            if (force || muted_app == null || muted_word == null ||
                now >= timeMuteData.get() + MUTE_DATA_EXPIRE
            ) {
                timeMuteData.set(now)
                muted_app = daoMutedApp.nameSet()
                muted_word = daoMutedWord.nameSet()
                favMuteSet = daoFavMute.acctSet()
            }
        }

        const val LANGUAGE_CODE_UNKNOWN = "unknown"
        const val LANGUAGE_CODE_DEFAULT = "default"

        val EMPTY_SPANNABLE = SpannableString("")

        // val reHostIdn = TootAccount.reHostIdn

        // OStatus
        private val reTootUriOS = """tag:([^,]*),[^:]*:objectId=([^:?#/\s]+):objectType=Status"""
            .asciiPattern(Pattern.CASE_INSENSITIVE)

        // ActivityPub 1
        private val reTootUriAP1 = """https?://([^/]+)/users/\w+/statuses/([^?#/\s]+)"""
            .asciiPattern()

        // ActivityPub 2
        private val reTootUriAP2 = """https?://([^/]+)/@\w+/([^?#/\s]+)"""
            .asciiPattern()

        // 公開ステータスページのURL マストドン
        private val reStatusPage = """\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)(?:\z|[?#])"""
            .asciiPattern()

        // fedibird ステータスの参照のURL
        private val reStatusWithReference =
            """\Ahttps://([^/]+)/@(\w+)/([^?#/\s]+)/references(?:\z|[?#])"""
                .asciiPattern()

        // 公開ステータスページのURL Misskey
        internal val reStatusPageMisskey =
            """\Ahttps://([^/]+)/notes/([0-9a-f]{24}|[0-9a-z]{10})\b"""
                .asciiPattern(Pattern.CASE_INSENSITIVE)

        // PleromaのStatusのUri
        private val reStatusPageObjects = """\Ahttps://([^/]+)/objects/([^?#/\s]+)(?:\z|[?#])"""
            .asciiPattern()

        // PleromaのStatusの公開ページ
        private val reStatusPageNotice = """\Ahttps://([^/]+)/notice/([^?#/\s]+)(?:\z|[?#])"""
            .asciiPattern()

        // PixelfedのStatusの公開ページ
        // https://pixelfed.tokyo/p/tateisu/84169185147621376
        private val reStatusPagePixelfed =
            """\Ahttps://([^/]+)/p/([A-Za-z0-9_]+)/([^?#/\s]+)(?:\z|[?#])"""
                .asciiPattern()

        // returns null or pair( status_id, host ,url )
        fun String.findStatusIdFromUrl(): FindStatusIdFromUrlResult? {

            // https://fedibird.com/@noellabo/108730353756004469/references
            var m = reStatusWithReference.matcher(this)
            if (m.find()) {
                return FindStatusIdFromUrlResult(
                    EntityId(m.groupEx(3)!!),
                    m.groupEx(1)!!,
                    this,
                    isReference = true,
                )
            }

            // https://mastodon.juggler.jp/@SubwayTooter/(status_id)
            m = reStatusPage.matcher(this)
            if (m.find()) {
                return FindStatusIdFromUrlResult(EntityId(m.groupEx(3)!!), m.groupEx(1)!!, this)
            }

            // https://misskey.xyz/notes/(id)
            m = reStatusPageMisskey.matcher(this)
            if (m.find()) {
                return FindStatusIdFromUrlResult(EntityId(m.groupEx(2)!!), m.groupEx(1)!!, this)
            }

            // https://misskey.xyz/objects/(id)
            m = reStatusPageObjects.matcher(this)
            if (m.find()) {
                return FindStatusIdFromUrlResult(
                    null, // ステータスIDではないのでどのタンスで開くにせよ検索APIを通すことになる
                    m.groupEx(1)!!,
                    this
                )
            }

            // https://pl.telteltel.com/notice/9fGFPu4LAgbrTby0xc
            m = reStatusPageNotice.matcher(this)
            if (m.find()) {
                return FindStatusIdFromUrlResult(
                    EntityId(m.groupEx(2)!!),
                    m.groupEx(1)!!,
                    this
                )
            }

            m = reStatusPagePixelfed.matcher(this)
            if (m.find()) {
                return FindStatusIdFromUrlResult(
                    EntityId(m.groupEx(3)!!),
                    m.groupEx(1)!!,
                    this
                )
            }

            return null
        }

        private val reDate = """\A(\d+\D+\d+\D+\d+)\z"""
            .asciiRegex()

        private val reTime = """\A(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)(?:\D+(\d+))?"""
            .asciiRegex()

        private val reTimeWithZone =
            """\A(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)(?:\D+(\d+))?(?:(Z|[+-])(\d+):?(\d*))?"""
                .asciiRegex()

        private val reMSPTime = """\A(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)\D+(\d+)"""
            .asciiRegex()

        private val tzUtc = TimeZone.getTimeZone("UTC")

        @Throws(Throwable::class)
        fun parseTimeUtc(strTime: String): Long {
            val gv = reTime.find(strTime)?.groupValues
                ?: error("time format not match.")
            return GregorianCalendar.getInstance()
                .apply {
                    timeZone = tzUtc
                    set(
                        gv.elementAtOrNull(1)?.toIntOrNull() ?: 1,
                        (gv.elementAtOrNull(2)?.toIntOrNull() ?: 1) - 1,
                        gv.elementAtOrNull(3)?.toIntOrNull() ?: 1,
                        gv.elementAtOrNull(4)?.toIntOrNull() ?: 0,
                        gv.elementAtOrNull(5)?.toIntOrNull() ?: 0,
                        gv.elementAtOrNull(6)?.toIntOrNull() ?: 0,
                    )
                    set(Calendar.MILLISECOND, (gv.elementAtOrNull(7)?.toIntOrNull() ?: 0))
                }.timeInMillis
        }

        // ISO-8601の Z,[+-]HH:mm,[+-]HHmm,[+-]HH 部分を解釈してオフセット(ミリ秒)を返す
        @Throws(Throwable::class)
        fun parseTimeZoneOffset(sign: String?, hArg: String?, mArg: String?): Long {
            val minutes = when {
                sign == null || sign == "Z" || hArg == null || hArg.isEmpty() -> {
                    // Z or missing hour part
                    0
                }

                mArg != null && mArg.isNotEmpty() -> {
                    // HH:mm or H:m
                    val h = hArg.toInt()
                    val m = mArg.toInt()
                    h * 60 + m
                }

                hArg.length >= 3 -> {
                    // HHmm or Hmm
                    val h = hArg.substring(0, hArg.length - 2).toInt()
                    val m = hArg.substring(hArg.length - 2).toInt()
                    h * 60 + m
                }

                else -> {
                    // HH or H
                    val h = hArg.toInt()
                    h * 60
                }
            }
            return minutes.toLong() * 60000L * (if (sign == "-") -1L else 1L)
        }

        @Throws(Throwable::class)
        fun parseTimeIso8601(strTime: String): Long {
            val gv = reTimeWithZone.find(strTime)?.groupValues
                ?: error("time format not match.")
            return GregorianCalendar.getInstance()
                .apply {
                    timeZone = tzUtc
                    set(
                        gv.elementAtOrNull(1)?.toIntOrNull() ?: 1,
                        (gv.elementAtOrNull(2)?.toIntOrNull() ?: 1) - 1,
                        gv.elementAtOrNull(3)?.toIntOrNull() ?: 1,
                        gv.elementAtOrNull(4)?.toIntOrNull() ?: 0,
                        gv.elementAtOrNull(5)?.toIntOrNull() ?: 0,
                        gv.elementAtOrNull(6)?.toIntOrNull() ?: 0,
                    )
                    set(Calendar.MILLISECOND, (gv.elementAtOrNull(7)?.toIntOrNull() ?: 0))
                }.timeInMillis -
                    parseTimeZoneOffset(
                        gv.elementAtOrNull(8),
                        gv.elementAtOrNull(9),
                        gv.elementAtOrNull(10),
                    )
        }

        // 時刻を解釈してエポック秒(ミリ単位)を返す
        // 解釈に失敗すると0Lを返す
        fun parseTime(strTime: String?): Long {
            if (strTime.isNullOrBlank()) return 0L

            // last_status_at などでは YYYY-MM-DD になることがある
            reDate.find(strTime)?.groupValues?.let { gv ->
                return parseTime("${gv[1]}T00:00:00.000Z")
            }

            // タイムゾーン指定を考慮したパース
            try {
                return parseTimeIso8601(strTime)
            } catch (ex: Throwable) {
                log.w(ex, "parseTime2 failed. $strTime")
            }

            // 古い処理にフォールバック
            try {
                return parseTimeUtc(strTime)
            } catch (ex: Throwable) {
                log.w(ex, "parseTime1 failed. $strTime")
            }

            return 0L
        }

        private fun parseTimeMSP(strTime: String?): Long {
            if (strTime?.isNotBlank() != true) return 0L
            try {
                val gv = reMSPTime.find(strTime)?.groupValues
                    ?: error("time format not match.")
                return GregorianCalendar.getInstance()
                    .apply {
                        timeZone = tzUtc
                        set(
                            gv.elementAtOrNull(1)?.toIntOrNull() ?: 1,
                            (gv.elementAtOrNull(2)?.toIntOrNull() ?: 1) - 1,
                            gv.elementAtOrNull(3)?.toIntOrNull() ?: 1,
                            gv.elementAtOrNull(4)?.toIntOrNull() ?: 0,
                            gv.elementAtOrNull(5)?.toIntOrNull() ?: 0,
                            gv.elementAtOrNull(6)?.toIntOrNull() ?: 0,
                        )
                        set(Calendar.MILLISECOND, 500)
                    }.timeInMillis
            } catch (ex: Throwable) {
                log.w(ex, "parseTimeMSP failed. src=$strTime")
            }
            return 0L
        }

        @SuppressLint("SimpleDateFormat")
        val dateFormatFull = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")

        @SuppressLint("SimpleDateFormat")
        val date_format2 = SimpleDateFormat("yyyy-MM-dd")

        fun formatTime(
            context: Context,
            t: Long,
            bAllowRelative: Boolean,
            onlyDate: Boolean = false,
        ): String {

            val now = System.currentTimeMillis()
            var delta = now - t

            @StringRes val phraseId = when {
                delta >= 0 -> R.string.relative_time_phrase_past
                else -> R.string.relative_time_phrase_future
            }

            fun f(v: Long, unit1: Int, units: Int): String {
                val vi = v.toInt()
                return context.getString(
                    phraseId,
                    vi,
                    context.getString(if (vi <= 1) unit1 else units)
                )
            }

            if (onlyDate) return when {
                delta < 40 * 86400000L -> f(
                    delta / 86400000L,
                    R.string.relative_time_unit_day1,
                    R.string.relative_time_unit_days
                )

                else ->
                    formatDate(t, date_format2, omitZeroSecond = false, omitYear = true)
            }

            if (bAllowRelative && PrefB.bpRelativeTimestamp.value) {

                delta = abs(delta)

                when {
                    delta < 1000L -> return context.getString(R.string.time_within_second)

                    delta < 60000L -> return f(
                        delta / 1000L,
                        R.string.relative_time_unit_second1,
                        R.string.relative_time_unit_seconds
                    )

                    delta < 3600000L -> return f(
                        delta / 60000L,
                        R.string.relative_time_unit_minute1,
                        R.string.relative_time_unit_minutes
                    )

                    delta < 86400000L -> return f(
                        delta / 3600000L,
                        R.string.relative_time_unit_hour1,
                        R.string.relative_time_unit_hours
                    )

                    delta < 40 * 86400000L -> return f(
                        delta / 86400000L,
                        R.string.relative_time_unit_day1,
                        R.string.relative_time_unit_days
                    )
                }
                // fall back to absolute time
            }

            return formatDate(t, dateFormatFull, omitZeroSecond = false, omitYear = false)
        }

        // 告知の開始/終了日付
        private fun formatDate(
            t: Long,
            format: SimpleDateFormat,
            omitZeroSecond: Boolean,
            omitYear: Boolean,
        ): String {
            var dateTarget = format.format(Date(t))

            // 秒の部分を省略する
            if (omitZeroSecond && dateTarget.endsWith(":00")) {
                dateTarget = dateTarget.substring(0, dateTarget.length - 3)
            }

            // 年の部分が現在と同じなら省略する
            if (omitYear) {
                val dateNow = format.format(Date())
                val delm = dateNow.indexOf('-')
                if (delm != -1 &&
                    dateNow.substring(0, delm + 1) == dateTarget.substring(0, delm + 1)
                ) {
                    dateTarget = dateTarget.substring(delm + 1)
                }
            }

            return dateTarget
        }

        fun formatTimeRange(start: Long, end: Long, allDay: Boolean): Pair<String, String> {
            val strStart = when {
                start <= 0L -> ""
                allDay -> formatDate(start, date_format2, omitZeroSecond = false, omitYear = true)
                else -> formatDate(start, dateFormatFull, omitZeroSecond = true, omitYear = true)
            }
            val strEnd = when {
                end <= 0L -> ""
                allDay -> formatDate(end, date_format2, omitZeroSecond = false, omitYear = true)
                else -> formatDate(end, dateFormatFull, omitZeroSecond = true, omitYear = true)
            }
            // 終了日は先頭と同じ部分を省略する
            var skip = 0
            for (i in 0 until min(strStart.length, strEnd.length)) {
                val c = strStart[i]
                if (c != strEnd[i]) break
                if (c.isDigit()) continue
                skip = i + 1
                if (c == ' ') break // 時間以降は省略しない
            }
            return Pair(strStart, strEnd.substring(skip, strEnd.length))
        }

        fun parseStringArray(src: JsonArray?): ArrayList<String>? {
            var rv: ArrayList<String>? = null
            if (src != null) {
                for (i in src.indices) {
                    val s = src.string(i)
                    if (s?.isNotEmpty() == true) {
                        if (rv == null) rv = ArrayList()
                        rv.add(s)
                    }
                }
            }
            return rv
        }

        private fun parseMisskeyTags(src: JsonArray?): ArrayList<TootTag>? {
            var rv: ArrayList<TootTag>? = null
            if (src != null) {
                for (i in src.indices) {
                    val sv = src.string(i)
                    if (sv?.isNotEmpty() == true) {
                        if (rv == null) rv = ArrayList()
                        rv.add(TootTag(name = sv))
                    }
                }
            }
            return rv
        }

        fun validStatusId(src: EntityId?): EntityId? =
            when {
                src == null -> null
                src == EntityId.DEFAULT -> null
                src.toString().startsWith("-") -> null
                else -> src
            }

        private fun String.cleanCW() =
            CharacterGroup.reWhitespace.matcher(this).replaceAll(" ").sanitizeBDI()
        /* 空欄かどうかがCW判定条件に影響するので、trimしてはいけない */

        // 投稿元タンスでのステータスIDを調べる
        fun findStatusIdFromUri(
            uri: String?,
            url: String?,
        ): EntityId? {

            try {
                if (uri?.isNotEmpty() == true) {
                    // https://friends.nico/users/(who)/statuses/(status_id)
                    var m = reTootUriAP1.matcher(uri)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // https://server/@user/(status_id)
                    m = reTootUriAP2.matcher(uri)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // https://misskey.xyz/notes/5b802367744b650030a13640
                    m = reStatusPageMisskey.matcher(uri)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43
                    // tootsearch中の投稿からIDを読めるようにしたい
                    // しかしこのURL中のuuidはステータスIDではないので、無意味
                    // m = reObjects.matcher(uri)
                    // if(m.find()) return EntityId(m.groupEx(2))

                    // https://pl.telteltel.com/notice/9fGFPu4LAgbrTby0xc
                    m = reStatusPageNotice.matcher(uri)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // tag:mstdn.osaka,2017-12-19:objectId=5672321:objectType=Status
                    m = reTootUriOS.matcher(uri)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    log.w("findStatusIdFromUri: unsupported uri. $uri")
                }

                if (url?.isNotEmpty() == true) {

                    // https://friends.nico/users/(who)/statuses/(status_id)
                    var m = reTootUriAP1.matcher(url)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // https://friends.nico/@(who)/(status_id)
                    m = reTootUriAP2.matcher(url)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // https://misskey.xyz/notes/5b802367744b650030a13640
                    m = reStatusPageMisskey.matcher(url)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    // https://pl.at7s.me/objects/feeb4399-cd7a-48c8-8999-b58868daaf43
                    // tootsearch中の投稿からIDを読めるようにしたい
                    // しかしこのURL中のuuidはステータスIDではないので、無意味
                    // m = reObjects.matcher(url)
                    // if(m.find()) return EntityId(m.groupEx(2))

                    // https://pl.telteltel.com/notice/9fGFPu4LAgbrTby0xc
                    m = reStatusPageNotice.matcher(url)
                    if (m.find()) return EntityId(m.groupEx(2)!!)

                    log.w("findStatusIdFromUri: unsupported url. $url")
                }
            } catch (ex: Throwable) {
                log.e(ex, "can't parse status from: $uri,$url")
            }

            return null
        }

        private val supplyEditHistoryKeys = arrayOf(
            "id",
            "uri",
            "url",
            "visibility",
        )

        // 編集履歴のデータはTootStatusとしては不足があるので、srcを元に補う
        fun supplyEditHistory(array: JsonArray?, src: JsonObject?) {
            src ?: return
            array?.objectList()?.forEach {
                for (key in supplyEditHistoryKeys) {
                    if (it.containsKey(key)) continue
                    it[key] = src[key]
                }
            }
        }
    }
}
