import Foundation
import Observation
import WireProtocol

/// T-iOS-24 · State for the full-timeline drill-down sheet (`TimelineSheet`).
///
/// One VM per presentation (the container view builds a fresh one on each
/// digest「展开」tap), so every open re-fetches `GET /live-sessions/:id/events`.
/// The fetch closure is injected — production wraps `APIClient.events` via
/// `TerminalSessionController.timelineEventsSource`; tests inject fakes.
///
/// Server data is UNTRUSTED at this boundary (plan §4): the tolerant decode
/// (malformed / unknown-class entries dropped, non-array → `[]`) already
/// happened inside `APIClient.events` → `TimelineEvent.decodeList`. This VM
/// only distinguishes the three USER-visible outcomes:
/// - `[]` → `.empty` — the server replies `[]` both for "no activity yet" and
///   for timeline capture disabled (src/server.ts:589-591); NEVER an error;
/// - thrown fetch → `.failed` — explicit, retryable (`load()` again);
/// - events → `.loaded`, ordered exactly like the web panel.
@MainActor
@Observable
final class TimelineViewModel: Identifiable {
    /// Rendering phase — an explicit enum so the sheet can never show an
    /// error and rows at the same time.
    enum Phase: Equatable {
        case loading
        /// Display-ready rows: capped + newest-first (web parity, see
        /// `presentation(for:)`).
        case loaded([TimelineEvent])
        /// Server returned `[]` (no activity OR timeline disabled host-side).
        case empty
        /// Fetch failed — retryable via `load()`.
        case failed
    }

    /// Web parity: `DEFAULT_MAX_EVENTS` (public/timeline.ts:20).
    static let maxEvents = 50

    /// `.sheet(item:)` identity — a fresh VM per presentation.
    nonisolated let id = UUID()

    private(set) var phase: Phase = .loading

    @ObservationIgnored private let fetch: @Sendable () async throws -> [TimelineEvent]

    init(fetch: @escaping @Sendable () async throws -> [TimelineEvent]) {
        self.fetch = fetch
    }

    /// Assembly seam for the digest「展开」entry point: no adopted sessionId
    /// yet → no sheet (defensive — a digest only arrives after `attached`, so
    /// the id is normally known). Otherwise the id is passed to `source`
    /// verbatim on every load.
    static func forSession(
        _ sessionId: UUID?,
        source: @escaping @Sendable (UUID) async throws -> [TimelineEvent]
    ) -> TimelineViewModel? {
        guard let sessionId else { return nil }
        return TimelineViewModel(fetch: { try await source(sessionId) })
    }

    /// Fetch and present. Also the「重试」path: callable again from `.failed`.
    func load() async {
        phase = .loading
        do {
            let events = try await fetch()
            phase = Self.presentation(for: events)
        } catch {
            phase = .failed // user-facing copy lives in TimelineSheet
        }
    }

    /// Pure presentation reducer, mirroring the web panel's render() pipeline
    /// line for line (public/timeline.ts:174-189): the server returns
    /// oldest-first → `slice(0, maxEvents)` first, THEN reverse, so the sheet
    /// shows the same capped slice newest-first as the web timeline panel.
    static func presentation(for events: [TimelineEvent]) -> Phase {
        guard !events.isEmpty else { return .empty }
        return .loaded(Array(events.prefix(maxEvents).reversed()))
    }
}
