package com.fabledsword.minstrel.discover.data

import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.RequestCreatePayload
import com.fabledsword.minstrel.models.ArtistSuggestionRef
import com.fabledsword.minstrel.models.LidarrRequestKind
import com.fabledsword.minstrel.models.LidarrSearchResultRef
import com.fabledsword.minstrel.models.SeedContributionRef
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
import com.fabledsword.minstrel.models.wire.SeedContributionWire
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Outcome of a `createRequest` call — discriminates "server accepted"
 * from "network down, queued for later replay". The UI uses this to
 * pick between "Requested" and "Queued" snackbars.
 */
enum class RequestOutcome { ACCEPTED, QUEUED }

/**
 * Read-through Discover surface: Lidarr search, suggestion feed, and
 * the request-creation write path. Mirrors the Flutter
 * `DiscoverApi` + `mutationQueueProvider` integration —
 * `feedback_offline_first_for_server_writes` says writes never go
 * fire-and-forget, so `createRequest` falls back to the
 * MutationQueue on transport failure.
 *
 * Reads are not cached locally yet — discovery results are inherently
 * transient (60s server-side LRU); the cache wins are minor and
 * unblock no UX. Phase 13's offline pass adds an offline scrollback
 * for the suggestion feed only.
 */
@Singleton
class DiscoverRepository @Inject constructor(
    private val mutationQueue: MutationQueue,
    retrofit: Retrofit,
) {
    private val api: DiscoverApi = retrofit.create()

    suspend fun listSuggestions(): List<ArtistSuggestionRef> =
        api.listSuggestions().map { it.toDomain() }

    suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
        api.listSnoozes().map { it.toDomain() }

    /**
     * Parks a suggestion ("not right now"). Offline-first per rule #100: on
     * transport failure the target state is queued for the replayer rather
     * than dropped.
     *
     * Always reports success to the caller. Unlike a request, a snooze has no
     * meaningful failed state to show — the user asked for a card to go away,
     * and it will, either now or when the queue drains.
     */
    suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
        mbid = mbid,
        name = name,
        desiredSnoozed = true,
    ) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }

    /** Brings a parked suggestion back. Same offline-first contract. */
    suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
        mbid = mbid,
        name = name,
        desiredSnoozed = false,
    ) { api.unsnoozeSuggestion(mbid) }

    private suspend fun toggleSnooze(
        mbid: String,
        name: String,
        desiredSnoozed: Boolean,
        call: suspend () -> Unit,
    ) {
        try {
            call()
        } catch (e: HttpException) {
            // A 4xx is the server's considered answer, not a lost call, so
            // queueing it would be wrong twice over: the replay is guaranteed
            // to fail again, and the enqueue would raise a "will sync when
            // online" snackbar for something already settled. The common case
            // is a 404 from un-snoozing a row that already lapsed — which is
            // the end state the user wanted anyway.
            if (!isPermanent(e.code())) {
                mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
            }
        } catch (
            @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
        ) {
            // Transport failure — intentional swallow, same offline-first
            // rationale as createRequest above. The queue carries the desired
            // STATE, so a later undo supersedes this rather than fighting it
            // on replay.
            mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
        }
    }

    /**
     * Mirrors MutationReplayer's classification so the enqueue decision here
     * and the drop decision there can't disagree: 4xx is permanent except the
     * two "retry me" statuses.
     */
    private fun isPermanent(code: Int): Boolean =
        code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
            code != HTTP_TIMEOUT && code != HTTP_TOO_MANY

    suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
        api.search(query = query, kind = kind.wire).map { it.toDomain() }

    /**
     * Fires `POST /api/requests`; on transport failure enqueues a
     * `RequestCreatePayload` for the Phase-12.2 replayer to drain.
     * Returns [RequestOutcome.ACCEPTED] when the server responded
     * 2xx, [RequestOutcome.QUEUED] when the call was buffered.
     */
    suspend fun createRequest(
        kind: LidarrRequestKind,
        artistMbid: String,
        artistName: String,
        albumMbid: String? = null,
        albumTitle: String? = null,
        trackMbid: String? = null,
        trackTitle: String? = null,
    ): RequestOutcome {
        val payload = RequestCreatePayload(
            kind = kind.wire,
            artistMbid = artistMbid,
            artistName = artistName,
            albumMbid = albumMbid?.ifEmpty { null },
            albumTitle = albumTitle?.ifEmpty { null },
            trackMbid = trackMbid?.ifEmpty { null },
            trackTitle = trackTitle?.ifEmpty { null },
        )
        return try {
            api.createRequest(payload.toBody())
            RequestOutcome.ACCEPTED
        } catch (
            @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
        ) {
            // Intentional swallow — see LikesRepository.toggleLike for
            // the same offline-first rationale.
            mutationQueue.enqueueRequestCreate(payload)
            RequestOutcome.QUEUED
        }
    }

    private companion object {
        const val HTTP_CLIENT_ERR_MIN = 400
        const val HTTP_CLIENT_ERR_MAX = 499
        const val HTTP_TIMEOUT = 408
        const val HTTP_TOO_MANY = 429
    }
}

// ── Mappers (internal — wire types stay out of UI) ──

private fun LidarrSearchResultWire.toDomain(): LidarrSearchResultRef = LidarrSearchResultRef(
    mbid = mbid,
    name = name,
    secondaryText = secondaryText,
    imageUrl = imageUrl,
    artistMbid = artistMbid,
    albumMbid = albumMbid,
    inLibrary = inLibrary,
    requested = requested,
)

private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggestionRef(
    mbid = mbid,
    name = name,
    imageUrl = imageUrl,
    attribution = attribution.map { it.toDomain() },
    matchedTags = matchedTags,
)

private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
    name = name,
    isLiked = isLiked,
)

private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
    mbid = mbid,
    name = name,
    snoozedUntil = snoozedUntil,
)

private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
    kind = kind,
    artistMbid = artistMbid,
    artistName = artistName,
    albumMbid = albumMbid,
    albumTitle = albumTitle,
    trackMbid = trackMbid,
    trackTitle = trackTitle,
)
