import Foundation
import NucleicProtocol

/// One Covalence session this device dispatched onto the mesh, remembered so the sidebar can (1)
/// fold the runner's session under the **local** project it was issued from — a cloud runner clones
/// the repo into its own project id, so the wire summary alone can't be traced back to the local
/// project (docs/COVALENCE_RUNNER.md §3) — and (2) render the row immediately at launch, before the
/// mesh sync server has connected and republished the runner's live sessions (otherwise cloud rows
/// blink in a few seconds late).
///
/// The record is owned by the dispatching device (the one holding the local project). Its
/// `originProjectID` is authored at dispatch time; `summary`/`hostID` are refreshed from live
/// presence so the cached row stays warm for the next cold start. `sessionID` is stable across
/// between-turn rebalancing (session transfer keeps the id), so the fold survives a move between
/// runners.
public struct CovalenceSessionMirror: Sendable, Codable, Equatable {
    /// The dispatched session's stable id (survives rebalancing between runners).
    public let sessionID: SessionID
    /// The local project the chat was dispatched from — the section it folds under.
    public let originProjectID: ProjectID
    /// Last-known cloud runner currently running it.
    public var hostID: String
    /// Last-known wire summary, cached so the row renders at launch before presence lands. `nil`
    /// until the first live presence refresh fills it (e.g. the app was quit in the seconds between
    /// dispatch and the runner's first broadcast) — such a row simply appears once presence lands.
    public var summary: WireSessionSummary?
    /// When this record was last refreshed from live presence (drives TTL pruning of ghosts).
    public var updatedAt: Date

    public init(
        sessionID: SessionID, originProjectID: ProjectID, hostID: String,
        summary: WireSessionSummary?, updatedAt: Date
    ) {
        self.sessionID = sessionID
        self.originProjectID = originProjectID
        self.hostID = hostID
        self.summary = summary
        self.updatedAt = updatedAt
    }
}

/// A Covalence chat the user just started that has no runner yet — the mesh had no free host, so it
/// was parked and (usually) a runner is booting for it (docs/COVALENCE_RUNNER.md §3). Held so the UI
/// can open a session view *immediately* with live provisioning progress (item 5), instead of the old
/// toast-then-wait. Correlated to the durable `MeshDispatchRecord` by `id`; cleared the instant the
/// chat lands on a runner, which then opens the real session.
public struct PendingCovalenceDispatch: Sendable, Identifiable, Equatable {
    /// The parked `MeshDispatchRecord.id` — the stable handle across the park → land transition.
    public let id: String
    /// The local project the chat was issued from (its sidebar section, and the fold target).
    public let projectID: ProjectID
    /// The opening message, shown in the provisioning view so the user sees what's queued.
    public let message: String
    public let createdAt: Date
    /// True when nothing is being provisioned for it right now — the fleet is at its cap (or the
    /// Cloud runner is off), so it simply waits for a host to free up. Drives "Waiting for a
    /// runner…" vs. the active provisioning phases.
    public var waitingForRunner: Bool
    /// A terminal failure reason (provision/pair/join failed), surfaced in the view instead of a
    /// popup. `nil` while still in progress.
    public var failure: String?

    public init(
        id: String, projectID: ProjectID, message: String, createdAt: Date,
        waitingForRunner: Bool = false, failure: String? = nil
    ) {
        self.id = id
        self.projectID = projectID
        self.message = message
        self.createdAt = createdAt
        self.waitingForRunner = waitingForRunner
        self.failure = failure
    }

    /// The first line of the message, for a compact sidebar/title label.
    public var titleLine: String {
        let firstLine = message.split(whereSeparator: \.isNewline).first.map(String.init) ?? ""
        let trimmed = firstLine.trimmingCharacters(in: .whitespaces)
        return trimmed.isEmpty ? "New Covalence chat" : trimmed
    }
}

/// `UserDefaults`-backed table of `CovalenceSessionMirror` records — the same persistence tier the
/// mesh already trusts for the credential tombstones and Git identity bundle, present on macOS and
/// Linux alike, with an injectable suite so tests stay hermetic.
public struct CovalenceSessionMirrorStore: Sendable {
    public static let defaultsKey = "nucleic.covalenceSessionMirror"
    /// A cached row this stale is dropped even if its runner never reconnects to correct it — long
    /// enough to survive an overnight quit, short enough that a reaped runner's ghosts don't linger.
    public static let staleTTL: TimeInterval = 7 * 24 * 60 * 60

    private let suiteName: String?

    /// `suiteName` overrides the standard defaults domain (tests only).
    public init(suiteName: String? = nil) {
        self.suiteName = suiteName
    }

    private var defaults: UserDefaults {
        suiteName.flatMap(UserDefaults.init(suiteName:)) ?? .standard
    }

    /// Every mirrored row on file.
    public func all() -> [CovalenceSessionMirror] {
        guard let data = defaults.data(forKey: Self.defaultsKey),
            let rows = try? JSONDecoder().decode([CovalenceSessionMirror].self, from: data)
        else { return [] }
        return rows
    }

    /// Insert or refresh the record for `record.sessionID`. A no-op write is skipped so a burst of
    /// identical presence broadcasts doesn't thrash the defaults store.
    public func upsert(_ record: CovalenceSessionMirror) {
        var table = Dictionary(uniqueKeysWithValues: all().map { ($0.sessionID, $0) })
        if table[record.sessionID] == record { return }
        table[record.sessionID] = record
        save(Array(table.values))
    }

    /// Forget a session (it ended, its project was deleted, or it exceeded the TTL).
    public func remove(_ sessionID: SessionID) {
        let kept = all().filter { $0.sessionID != sessionID }
        if kept.count != all().count { save(kept) }
    }

    /// Drop every record whose origin project is `origin` (the project is going away).
    public func removeAll(originProjectID origin: ProjectID) {
        let current = all()
        let kept = current.filter { $0.originProjectID != origin }
        if kept.count != current.count { save(kept) }
    }

    /// Replace the whole table (used by reconciliation after a mesh presence pass).
    public func replaceAll(_ rows: [CovalenceSessionMirror]) {
        save(rows)
    }

    private func save(_ rows: [CovalenceSessionMirror]) {
        let sorted = rows.sorted { $0.sessionID.rawValue < $1.sessionID.rawValue }
        if let data = try? JSONEncoder().encode(sorted) {
            defaults.set(data, forKey: Self.defaultsKey)
        }
    }
}
