package com.fabledsword.minstrel.models

import kotlinx.datetime.Instant
import kotlin.math.roundToInt

/**
 * Kind of Lidarr request being created. Wire form is the lowercase
 * enum name; the helper [wire] keeps that mapping in one place.
 */
enum class LidarrRequestKind {
    ARTIST, ALBUM, TRACK;

    val wire: String get() = name.lowercase()
}

/**
 * Lidarr search hit. Mirrors `flutter_client/lib/models/lidarr.dart`'s
 * `LidarrSearchResult` — `mbid` is the result's own MBID; `artistMbid`
 * and `albumMbid` are filled when the row is an album/track and the
 * UI needs the parent IDs to build the request.
 *
 * `inLibrary` and `requested` together cover the "already-handled"
 * cases — UI greys the row + swaps the Request button for a pill.
 */
data class LidarrSearchResultRef(
    val mbid: String,
    val name: String,
    val secondaryText: String = "",
    val imageUrl: String = "",
    val artistMbid: String = "",
    val albumMbid: String = "",
    val inLibrary: Boolean = false,
    val requested: Boolean = false,
)

/**
 * Per-seed attribution string for a suggestion — "you liked X" /
 * "you played Y". `isLiked = true` discriminates the verb.
 */
data class SeedContributionRef(
    val name: String,
    val isLiked: Boolean,
)

/**
 * Out-of-library artist suggestion from `GET /api/discover/suggestions`.
 * `attributionText` mirrors Flutter's SuggestionFeed.attributionText
 * (Oxford-comma, max 3 seeds) so the subtitle reads naturally.
 */
data class ArtistSuggestionRef(
    val mbid: String,
    val name: String,
    val imageUrl: String = "",
    val attribution: List<SeedContributionRef> = emptyList(),
    /** Taste-profile tags this candidate matches, strongest first (#2377). */
    val matchedTags: List<String> = emptyList(),
) {
    val attributionText: String
        get() {
            if (attribution.isEmpty()) return ""
            val phrases = attribution
                .take(MAX_ATTRIBUTION_PHRASES)
                .map { "${if (it.isLiked) "liked" else "played"} ${it.name}" }
            return when (phrases.size) {
                1 -> "Because you ${phrases[0]}."
                2 -> "Because you ${phrases[0]} and ${phrases[1]}."
                else -> "Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}."
            }
        }

    /**
     * The subtitle line for the card.
     *
     * Prefers the taste-tag reason over seed attribution when we have one,
     * because it describes the MUSIC ("sounds like what you like") rather than
     * the graph ("adjacent to something you played") — the whole point of
     * milestone #268 slice 6. Falls back to attribution, which is the common
     * case: tag coverage for out-of-library artists is partial by nature
     * (#2376), so most candidates have no matched tags.
     *
     * Kept in lockstep with the web client's reasonText() in
     * SuggestionFeed.svelte — same wording, same Oxford comma.
     */
    val reasonText: String
        get() {
            val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
            return when (tags.size) {
                0 -> attributionText
                1 -> "Matches your taste in ${tags[0]}."
                2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
                else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
            }
        }

    companion object {
        private const val MAX_ATTRIBUTION_PHRASES = 3
    }
}

/**
 * A suggestion the user parked with "not right now" (#2374).
 *
 * Deliberately NOT a dislike: it carries no verdict on the artist, expires on
 * its own, and never reaches the taste profile. Anything that treats this as
 * negative preference signal is a bug.
 *
 * [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
 * decides whether a snooze is still in effect — every row the client receives
 * already is — so this is read purely to phrase "back in about 3 months".
 */
data class SuggestionSnoozeRef(
    val mbid: String,
    val name: String,
    val snoozedUntil: String,
) {
    /**
     * Relative return phrase for the manage list. Relative rather than a
     * calendar date because the exact day a 90-day snooze lapses is noise the
     * user never asked for.
     *
     * [nowMs] is injectable so this is testable without freezing the clock.
     * Returns "shortly" for an unparseable or already-past timestamp: the row
     * is on screen, so the server still considers it snoozed, and guessing is
     * better than rendering an empty line.
     */
    fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
        val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
            .getOrNull()?.minus(nowMs)
        // Two ways to have nothing to state: an unparseable timestamp, or one
        // already lapsed by our clock though the server still returned the row
        // (the two disagree). Neither is "today", which would read as a real
        // prediction.
        if (remainingMs == null || remainingMs <= 0) return "shortly"
        val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
        return when {
            days < 1 -> "today"
            days == 1 -> "tomorrow"
            days < DAYS_BEFORE_MONTHS -> "in $days days"
            else -> {
                val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
                if (months == 1) "in about a month" else "in about $months months"
            }
        }
    }

    private companion object {
        const val MILLIS_PER_DAY = 86_400_000.0

        // Below this, days read more naturally than a rounded month count.
        //
        // Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
        // unreachable: a rounded month count of 1 needs 15..44 days, and any
        // threshold above 30 sends all of those down the days branch instead.
        // This was 45 and the singular branch was dead code — the unit test
        // for it is what surfaced that.
        const val DAYS_BEFORE_MONTHS = 30
        const val DAYS_PER_MONTH = 30.0
    }
}
