package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import wang.yaojia.webterm.designsystem.DesignSpec
import wang.yaojia.webterm.wire.TimelineEvent
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID

/**
 * # TimelineViewModel (A28) — state for the full activity-timeline drill-down sheet.
 *
 * Mirrors the iOS `TimelineViewModel` and the web timeline panel (`public/timeline.ts`): fetches
 * `GET /live-sessions/:id/events` (plan §4.2), then presents rows **newest-first, capped at
 * [MAX_EVENTS]** — line-for-line the web `render()` pipeline (`slice(0, maxEvents)` THEN reverse).
 *
 * One VM per presentation (the away-digest「展开」builds a fresh one), so every open re-fetches. The
 * fetch closure is injected — production wraps `ApiClient.events` via [forSession]; the JVM test
 * injects fakes. A plain presenter (NOT `androidx.lifecycle.ViewModel`, like [DiffViewModel]/
 * [PairingViewModel]) so it runs under `runTest` with no `Dispatchers.Main`.
 *
 * ### Untrusted boundary + lossy decode (plan §4.2 / §8)
 * `ApiClient.events` already dropped malformed JSON entries and mapped a non-array body to `[]`, but
 * it deliberately lets **unknown-class** entries survive shape-decode ("filtered downstream"). This VM
 * is that downstream: [presentation] drops any event whose class the server doesn't currently emit
 * ([TimelineEvent.hasKnownClass]) and keeps the rest — the plan §3.1 "consumers drop unknowns" rule.
 * The class→glyph/color mapping ([TimelineEventStyle]) stays **total** anyway (defense in depth): an
 * unknown class degrades to a neutral glyph/color rather than trapping. Labels are server text →
 * rendered INERT by the sheet (no linkify/markdown, §8).
 *
 * ### Empty is NEVER an error
 * The server replies `[]` both for "no activity yet" AND for timeline capture disabled host-side
 * (`TIMELINE_ENABLED=0`); a thrown fetch is [TimelinePhase.Failed] (explicit, retryable via [load]).
 */
public class TimelineViewModel(
    private val fetch: suspend () -> List<TimelineEvent>,
    private val zone: ZoneId = ZoneId.systemDefault(),
) {
    private val _phase = MutableStateFlow<TimelinePhase>(TimelinePhase.Loading)

    /** The single snapshot [TimelineSheet] renders from. */
    public val phase: StateFlow<TimelinePhase> = _phase.asStateFlow()

    /**
     * Fetch and present. Also the「重试」path — callable again from [TimelinePhase.Failed]. A thrown
     * fetch becomes [TimelinePhase.Failed]; cancellation (a superseding load) propagates so a stale
     * fetch can't overwrite fresh state.
     */
    public suspend fun load() {
        _phase.value = TimelinePhase.Loading
        val outcome = try {
            Result.success(fetch())
        } catch (cancel: CancellationException) {
            throw cancel
        } catch (error: Throwable) {
            Result.failure(error)
        }
        _phase.value = outcome.fold(
            onSuccess = { presentation(it, zone) },
            onFailure = { TimelinePhase.Failed },
        )
    }

    public companion object {
        /** Web parity: `DEFAULT_MAX_EVENTS` (`public/timeline.ts:20`). */
        public const val MAX_EVENTS: Int = 50

        /**
         * Assembly seam for the digest「展开」entry point (plan §5 A28 "wired to away-digest expand").
         * No adopted `sessionId` yet → no sheet (defensive — a digest only arrives after `attached`, so
         * the id is normally known). Otherwise [sessionId] is passed to [source] verbatim on every load;
         * production supplies `ApiClient::events` as [source].
         */
        public fun forSession(
            sessionId: UUID?,
            source: suspend (UUID) -> List<TimelineEvent>,
        ): TimelineViewModel? =
            sessionId?.let { id -> TimelineViewModel(fetch = { source(id) }) }

        /**
         * Pure presentation reducer, mirroring the web `render()` pipeline (`public/timeline.ts:174-189`)
         * plus the Android downstream unknown-class filter: drop unknown-class events (keep the rest),
         * take the first [MAX_EVENTS] (web `slice(0, 50)`), THEN reverse (newest-first). All-empty (or
         * all-unknown) → [TimelinePhase.Empty], never an error.
         */
        public fun presentation(
            events: List<TimelineEvent>,
            zone: ZoneId = ZoneId.systemDefault(),
        ): TimelinePhase {
            val rows = events.asSequence()
                .filter { it.hasKnownClass } // lossy: drop unknown/future classes, keep the rest.
                .take(MAX_EVENTS) // web slice(0, maxEvents) on the filtered list.
                .toList()
                .reversed() // newest-first (server returns oldest-first).
                .mapIndexed { index, event -> TimelineRow.from(index, event, zone) }
            return if (rows.isEmpty()) TimelinePhase.Empty else TimelinePhase.Loaded(rows)
        }
    }
}

/** Rendering phase — explicit so the sheet can never show an error and rows at the same time. */
public sealed interface TimelinePhase {
    /** Before/while the fetch runs. */
    public data object Loading : TimelinePhase

    /** Server returned `[]` (no activity) OR every event was unknown-class after filtering. */
    public data object Empty : TimelinePhase

    /** Fetch threw — retryable via [TimelineViewModel.load]. */
    public data object Failed : TimelinePhase

    /** Display-ready rows: filtered, capped and newest-first. */
    public data class Loaded(val rows: List<TimelineRow>) : TimelinePhase
}

/**
 * One display-ready timeline row: "HH:mm · glyph · label". [colorSpec] is a raw ARGB constant (the
 * A13 timeline/status token value) or `null` for the theme-adaptive secondary-text fallback; the sheet
 * lifts it into a Compose `Color`. [label] is untrusted server text — rendered INERT (§8).
 */
public data class TimelineRow(
    /** Stable list key = position in the displayed list (offset identity; rows are static per load). */
    val key: Int,
    val time: String,
    val glyph: String,
    val colorSpec: Long?,
    val label: String,
) {
    public companion object {
        internal fun from(key: Int, event: TimelineEvent, zone: ZoneId): TimelineRow = TimelineRow(
            key = key,
            time = TimelineRowFormat.timeLabel(event.at, zone),
            glyph = TimelineEventStyle.glyph(event.eventClass),
            colorSpec = TimelineEventStyle.colorSpec(event.eventClass),
            label = event.label,
        )
    }
}

/**
 * class → glyph / color mapping. Glyphs mirror the web `timelineIcon` verbatim
 * (`public/timeline.ts:88-96`); colors are the frozen [DesignSpec] tokens (A13) — the same values as
 * `WebTermColors.timelineTool`/`timelineUser` and the semantic status colors. Total functions: the
 * server is untrusted, so an unknown class degrades to a neutral glyph / theme-secondary color instead
 * of trapping (even though [TimelineViewModel.presentation] already drops unknown classes).
 */
public object TimelineEventStyle {
    /** Neutral glyph for an unknown/future class. */
    public const val FALLBACK_GLYPH: String = "•"

    public fun glyph(eventClass: String): String = when (eventClass) {
        "tool" -> "🔧"
        "waiting" -> "⏳"
        "done" -> "✓"
        "stuck" -> "⚠"
        "user" -> "💬"
        else -> FALLBACK_GLYPH
    }

    /**
     * The ARGB token constant for a class, or `null` to signal "use the theme-adaptive secondary text
     * color" (the unknown fallback — the only theme-dependent case). tool/user use the timeline-specific
     * tokens; waiting/stuck/done reuse the semantic status tokens (same convention as the session list).
     */
    public fun colorSpec(eventClass: String): Long? = when (eventClass) {
        "tool" -> DesignSpec.TIMELINE_TOOL
        "waiting" -> DesignSpec.STATUS_WAITING
        "done" -> DesignSpec.STATUS_WORKING
        "stuck" -> DesignSpec.STATUS_STUCK
        "user" -> DesignSpec.TIMELINE_USER
        else -> null
    }
}

/**
 * Row time formatting — mirrors web `formatHHMM` (`public/timeline.ts:101-106`): 24-hour wall-clock
 * "HH:mm". [Locale.ROOT] keeps the digits fixed regardless of the user's locale; [zone] is injectable
 * for deterministic tests.
 */
public object TimelineRowFormat {
    private val FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)

    public fun timeLabel(atMs: Long, zone: ZoneId = ZoneId.systemDefault()): String =
        Instant.ofEpochMilli(atMs).atZone(zone).format(FORMATTER)
}

/** User-visible copy (named constants — plan engineering standard; empty ≠ error). */
public object TimelineCopy {
    public const val TITLE: String = "活动时间线"
    public const val EMPTY_TITLE: String = "暂无活动"
    public const val EMPTY_DETAIL: String =
        "会话还没有可展示的事件；主机关闭时间线（TIMELINE_ENABLED=0）时也会显示为空。"
    public const val LOAD_FAILED: String = "时间线加载失败"
    public const val LOAD_FAILED_DETAIL: String = "无法从主机获取活动时间线，请检查连接后重试。"
    public const val RETRY: String = "重试"
}
