import APIClient
import Foundation
import HostRegistry
import Observation
import SessionCore
import WireProtocol

/// T-iOS-13 · Session list state (merged chooser + dashboard, plan §7):
/// one glance answers "do I need to step in?" — status dot / ⚠ pending badge,
/// telemetry chips with staleness, swipe-to-kill, and the navigation signal
/// into `TerminalScreen`.
///
/// Polling: while the screen is visible, `GET /live-sessions` every
/// `Tunables.listPollInterval` (mirrors public/launcher.ts `REFRESH_MS`),
/// paced on an injected `Clock` (FakeClock in tests — zero real waits).
/// `disappeared()` cancels the poll task; the leak test asserts the parked
/// sleeper is reclaimed.
///
/// Documented decisions:
/// - **Pending ⚠ is an overlay, not a list field**: `LiveSessionInfo`
///   (src/types.ts:246-256) carries NO `pending` — that signal only exists on
///   the WS status side-channel (the server's pendingApprovals), exactly like
///   the web dashboard reads it from attached tabs (public/tabs.ts snapshot()).
///   The T-iOS-15 wiring forwards gate/status events into
///   `setPendingApproval`; this VM owns the merge and the badge PRIORITY.
/// - **Kill is optimistic via a hidden-id set**: rows derive from the last
///   fetch minus `hiddenKilledIds`, so rollback restores the exact original
///   position, and a poll racing the DELETE cannot resurrect the row. A 404
///   on DELETE means "already gone" and counts as success.
/// - **A failed poll never wipes the list**: stale rows + explicit copy beat
///   a blank screen on one dropped packet.
@MainActor
@Observable
final class SessionListViewModel {
    // MARK: - Row model

    /// What the row leads with: the held-approval badge outranks the dot.
    enum StatusIndicator: Equatable, Sendable {
        case pendingApproval
        case status(ClaudeStatus)

        /// Mirrors public/tabs.ts:74-80 `claudeIcon`; ⚠ for a held approval.
        var glyph: String {
            switch self {
            case .pendingApproval: return "⚠"
            case .status(.working): return "⚙"
            case .status(.waiting): return "⏳"
            case .status(.idle): return "✓"
            case .status(.stuck): return "⚠"
            case .status(.unknown): return ""
            }
        }
    }

    /// Immutable render snapshot of one session (rebuilt wholesale per fetch).
    struct SessionRow: Equatable, Identifiable, Sendable {
        let info: LiveSessionInfo
        let isPendingApproval: Bool
        let telemetry: TelemetryChips.Model?
        /// Sanitized OSC title (T-iOS-23) — nil = no title, row falls back to
        /// the cwd-derived name. NEVER raw delegate input (TitleSanitizer).
        let title: String?
        /// `lastOutputAt` strictly newer than the local last-seen watermark
        /// (UnreadLedger; mirrors the web tab dot, public/tabs.ts hasActivity).
        let isUnread: Bool

        var id: UUID { info.id }
        var indicator: StatusIndicator {
            isPendingApproval ? .pendingApproval : .status(info.status)
        }
    }

    /// Navigation signal consumed by the T-iOS-15 wiring. `sessionId == nil`
    /// = "+ New session" (`attach(null)` downstream). A fresh `id` per request
    /// means repeated taps re-fire `onChange` observers.
    struct OpenRequest: Equatable, Sendable, Identifiable {
        let id: UUID
        let host: HostRegistry.Host
        let sessionId: UUID?
    }

    enum EmptyState: Equatable {
        /// No hosts in the store — pairing is the only next step.
        case notPaired
        /// Paired + fetched successfully, but nothing is running.
        case noSessions
    }

    // MARK: - Observable state

    private(set) var hosts: [HostRegistry.Host] = []
    private(set) var activeHost: HostRegistry.Host?
    private(set) var rows: [SessionRow] = []
    /// Last poll failed (stale rows stay visible). Cleared by the next success.
    private(set) var fetchErrorMessage: String?
    /// Last kill failed (row already rolled back).
    private(set) var killErrorMessage: String?
    /// Host store read failed — explicit, never a silent empty list.
    private(set) var hostsErrorMessage: String?
    private(set) var openRequest: OpenRequest?

    var emptyState: EmptyState? {
        if hasAttemptedHostLoad && hosts.isEmpty { return .notPaired }
        if hasLoadedOnce && rows.isEmpty && fetchErrorMessage == nil { return .noSessions }
        return nil
    }

    // MARK: - Dependencies & internal state (not observed)

    @ObservationIgnored private let hostStore: any HostStore
    @ObservationIgnored private let http: any HTTPTransport
    @ObservationIgnored private let clock: any Clock<Duration>
    /// Injected time source for telemetry staleness (ms since epoch).
    @ObservationIgnored private let nowMs: @Sendable () -> Int
    @ObservationIgnored private let unreadStore: any UnreadWatermarkStore
    @ObservationIgnored private var pollTask: Task<Void, Never>?
    @ObservationIgnored private var latestFetched: [LiveSessionInfo] = []
    @ObservationIgnored private var hiddenKilledIds: Set<UUID> = []
    @ObservationIgnored private var pendingSessionIds: Set<UUID> = []
    /// T-iOS-23 · last-seen watermarks (loaded once, persisted per `markSeen`).
    /// NOT pruned to the active host's fetch — other hosts' watermarks must
    /// survive a host switch; `UnreadLedger.maxEntries` bounds growth instead.
    @ObservationIgnored private var unreadLedger: UnreadLedger
    /// T-iOS-23 · sanitized OSC titles by sessionId. In-memory only — replay
    /// re-emits the OSC sequence on next attach, persistence would only keep
    /// stale titles. Other hosts' entries never match a visible row.
    @ObservationIgnored private var sessionTitles: [UUID: String] = [:]
    @ObservationIgnored private var hasAttemptedHostLoad = false
    @ObservationIgnored private var hasLoadedOnce = false

    private var apiClient: APIClient? {
        activeHost.map { APIClient(endpoint: $0.endpoint, http: http) }
    }

    init(
        hostStore: any HostStore,
        http: any HTTPTransport,
        clock: any Clock<Duration>,
        unreadStore: any UnreadWatermarkStore,
        nowMs: @escaping @Sendable () -> Int = SessionListViewModel.currentTimeMs
    ) {
        self.hostStore = hostStore
        self.http = http
        self.clock = clock
        self.unreadStore = unreadStore
        self.nowMs = nowMs
        unreadLedger = UnreadLedger(watermarks: unreadStore.load())
    }

    // MARK: - Visibility lifecycle (poll task owner)

    /// Screen became visible: load hosts, then poll every `listPollInterval`.
    /// Idempotent — a second call while polling is a no-op.
    func appeared() {
        guard pollTask == nil else { return }
        pollTask = Task { [weak self] in
            await self?.runPollLoop()
        }
    }

    /// Screen left: cancel the poll task (its parked sleep throws
    /// `CancellationError` and the loop exits — no leaked timer).
    func disappeared() {
        pollTask?.cancel()
        pollTask = nil
        releaseFetchWaiters()
    }

    private func runPollLoop() async {
        await reloadHosts()
        while !Task.isCancelled {
            await refreshOnce()
            do {
                try await clock.sleep(for: Tunables.listPollInterval, tolerance: nil)
            } catch {
                return // cancelled while parked — the only way sleep throws
            }
        }
    }

    // MARK: - Hosts (multi-host header hook)

    /// (Re)read the paired hosts. Also called by the T-iOS-15 wiring after the
    /// pairing sheet adds one. Keeps `activeHost` if it still exists (picking
    /// up renames), else falls back to the first host.
    func reloadHosts() async {
        do {
            hosts = try await hostStore.loadAll()
            hostsErrorMessage = nil
        } catch {
            hosts = []
            hostsErrorMessage = SessionListCopy.hostsLoadFailed
        }
        hasAttemptedHostLoad = true
        activeHost = hosts.first(where: { $0.id == activeHost?.id }) ?? hosts.first
    }

    /// Switch the list to another paired host: reset per-host state and fetch
    /// immediately (the poll loop keeps its own cadence).
    func selectHost(id: UUID) async {
        guard let host = hosts.first(where: { $0.id == id }),
              host.id != activeHost?.id else { return }
        activeHost = host
        latestFetched = []
        hiddenKilledIds = []
        pendingSessionIds = []
        hasLoadedOnce = false
        fetchErrorMessage = nil
        killErrorMessage = nil
        rebuildRows()
        await refreshOnce()
    }

    // MARK: - Fetch (poll tick + pull-to-refresh share one path)

    /// Manual refresh (pull-to-refresh).
    func refresh() async {
        await refreshOnce()
    }

    private func refreshOnce() async {
        defer {
            completedFetchCount += 1
            resumeFetchWaiters()
        }
        guard let client = apiClient else { return }
        do {
            let sessions = try await client.liveSessions()
            let fetchedIds = Set(sessions.map(\.id))
            latestFetched = sessions
            // Prune overlays for sessions the server no longer reports.
            hiddenKilledIds = hiddenKilledIds.intersection(fetchedIds)
            pendingSessionIds = pendingSessionIds.intersection(fetchedIds)
            fetchErrorMessage = nil
            hasLoadedOnce = true
        } catch {
            // Keep the stale rows — a blank list on one dropped poll is worse.
            fetchErrorMessage = SessionListCopy.fetchFailed(Self.errorDetail(error))
        }
        rebuildRows()
    }

    // MARK: - Pending ⚠ overlay (fed by the WS status side-channel, T-iOS-15)

    func setPendingApproval(sessionId: UUID, pending: Bool) {
        pendingSessionIds = pending
            ? pendingSessionIds.union([sessionId])
            : pendingSessionIds.subtracting([sessionId])
        rebuildRows()
    }

    // MARK: - Unread watermarks (T-iOS-23; UnreadLedger + persisted store)

    /// The user just looked at (or is about to look at) this session: stamp
    /// the last-seen watermark NOW and persist it. Called on row tap
    /// (`openSession`) and by the coordinator when a terminal closes — output
    /// that streamed while the user was watching must not relight the dot.
    func markSeen(sessionId: UUID) {
        unreadLedger = unreadLedger.record(seen: sessionId, at: nowMs())
        unreadStore.save(unreadLedger.watermarks)
        rebuildRows()
    }

    // MARK: - OSC title registry (T-iOS-23; fed by TerminalViewModel wiring)

    /// Surface a terminal-reported OSC title on the session's list row.
    /// `title` is UNTRUSTED (host/attacker-controlled OSC payload) — sanitized
    /// again at THIS boundary regardless of upstream (sanitize is idempotent).
    /// Empty after sanitisation clears the entry (web `title.trim() || null`).
    func setSessionTitle(sessionId: UUID, title: String) {
        let sanitized = TitleSanitizer.sanitize(title)
        sessionTitles = sanitized.isEmpty
            ? sessionTitles.filter { $0.key != sessionId }
            : sessionTitles.merging([sessionId: sanitized]) { _, new in new }
        rebuildRows()
    }

    // MARK: - Swipe-to-kill (optimistic + rollback)

    func kill(sessionId: UUID) async {
        guard let client = apiClient,
              latestFetched.contains(where: { $0.id == sessionId }) else { return }
        killErrorMessage = nil
        hiddenKilledIds = hiddenKilledIds.union([sessionId]) // optimistic removal
        rebuildRows()
        do {
            try await client.killSession(id: sessionId)
        } catch APIClientError.sessionNotFound {
            // Already gone (exited/reaped/killed elsewhere) — removal stands.
        } catch {
            hiddenKilledIds = hiddenKilledIds.subtracting([sessionId]) // rollback
            rebuildRows()
            killErrorMessage = SessionListCopy.killFailed(Self.errorDetail(error))
        }
    }

    // MARK: - Navigation signals

    /// "+ New session" → `sessionId: nil` (server spawns a fresh PTY).
    func requestNewSession() {
        guard let activeHost else { return }
        openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: nil)
    }

    /// Open a listed session. Unknown ids are refused at the boundary.
    /// Tapping = seeing: the unread watermark is stamped immediately (the
    /// terminal replays everything anyway; the dot must not outlive the tap).
    func openSession(id: UUID) {
        guard let activeHost, rows.contains(where: { $0.id == id }) else { return }
        markSeen(sessionId: id)
        openRequest = OpenRequest(id: UUID(), host: activeHost, sessionId: id)
    }

    // MARK: - Row derivation (immutable rebuild, single owner)

    private func rebuildRows() {
        let visible = latestFetched.filter { !hiddenKilledIds.contains($0.id) }
        let now = nowMs()
        rows = Self.groupingExitedLast(visible).map { info in
            SessionRow(
                info: info,
                isPendingApproval: pendingSessionIds.contains(info.id),
                telemetry: TelemetryChips.Model(telemetry: info.telemetry, nowMs: now),
                title: sessionTitles[info.id],
                isUnread: unreadLedger.isUnread(
                    sessionId: info.id, lastOutputAt: info.lastOutputAt
                )
            )
        }
    }

    /// Keep the server's order (newest-first, src/session/manager.ts:194) but
    /// group `exited:true` at the bottom — order preserved inside each group.
    static func groupingExitedLast(_ sessions: [LiveSessionInfo]) -> [LiveSessionInfo] {
        sessions.filter { !$0.exited } + sessions.filter(\.exited)
    }

    // MARK: - Error copy helpers

    /// `APIClientError` already carries user-facing copy; transport-level
    /// errors fall back to their localized description.
    private static func errorDetail(_ error: any Error) -> String {
        (error as? APIClientError)?.message ?? error.localizedDescription
    }

    private nonisolated static let millisecondsPerSecond = 1_000.0

    nonisolated static func currentTimeMs() -> Int {
        Int(Date().timeIntervalSince1970 * millisecondsPerSecond)
    }

    // MARK: - Deterministic test barriers (same pattern as TerminalViewModel)

    /// Completed fetch attempts (successful or not, including no-host ticks).
    @ObservationIgnored private(set) var completedFetchCount = 0

    private struct CountWaiter {
        let target: Int
        let continuation: CheckedContinuation<Void, Never>
    }

    @ObservationIgnored private var fetchWaiters: [CountWaiter] = []

    /// Suspends until at least `count` fetches have completed.
    func waitUntilFetches(count target: Int) async {
        await withCheckedContinuation { continuation in
            guard completedFetchCount < target else {
                continuation.resume()
                return
            }
            fetchWaiters = fetchWaiters + [CountWaiter(target: target, continuation: continuation)]
        }
    }

    private func resumeFetchWaiters() {
        let satisfied = fetchWaiters.filter { $0.target <= completedFetchCount }
        fetchWaiters = fetchWaiters.filter { $0.target > completedFetchCount }
        for waiter in satisfied {
            waiter.continuation.resume()
        }
    }

    private func releaseFetchWaiters() {
        let all = fetchWaiters
        fetchWaiters = []
        for waiter in all {
            waiter.continuation.resume()
        }
    }
}

/// User-facing session-list copy (plan §4: explicit errors, actionable 话术).
enum SessionListCopy {
    static let hostsLoadFailed = "读取已配对主机失败，请关闭本页后重进。"

    static func fetchFailed(_ detail: String) -> String {
        "刷新会话列表失败：\(detail)"
    }

    static func killFailed(_ detail: String) -> String {
        "结束会话失败：\(detail)"
    }
}
