import Foundation

/// The well-known cast channels and their payload types (mesh casting; `CastMessages.swift`).
/// Lives in NucleicProtocol so publisher (Mac/runner host) and every consumer (Mac UI, iOS)
/// share one set of constants and Codable shapes — channel names and `typeID` strings can
/// never drift between the two renderers.
///
/// A channel here is a *convention*, not a wire requirement: the transport carries any channel
/// string, and unknown channels relay/mirror through old builds untouched (payloads stay
/// opaque). Payload types follow the wire rules: additive fields only, `decodeIfPresent`
/// defaults, raw-string enums so unknown values round-trip.
public enum MeshCastChannel {
    /// Fleet activity feed — notable session events, mesh-wide. Stream.
    public static let activity = "activity"
    /// App-level messages between sessions/devices (host-mediated). Stream.
    public static let messages = "messages"
    /// Runner eligibility/capacity for mesh dispatch, latest per host. State (key = hostID).
    public static let runnerPresence = "runner.presence"
    /// Live composer text for a session — who is typing a follow-up right now, and what.
    /// State (key = sessionID): the latest typer per session; a tombstone clears the key
    /// (sent / went idle), unlocking every watcher's composer.
    public static let composerTyping = "composer.typing"
}

/// Per-channel policy for mesh casting: how a channel converges, who may read it, and how much
/// of it is retained. Retention is **local policy, not wire state** — an origin and a mirror may
/// retain different amounts; the `CastCatchUp.reset`/floor mechanism absorbs the divergence.
/// Shared here (not in NucleicCore) because both renderers consult it: the Mac host enforces it
/// at publish/ingest/serve time, and the phone uses the same caps for its offline cast cache.
public struct CastChannelPolicy: Sendable, Equatable {
    /// The channel's declared mode; nil for an unknown channel (accept the incoming cast's own
    /// stamped mode, so a future feature's channel relays through this build untouched).
    public let mode: CastMode?
    /// The lowest `DeviceScope` a subscriber needs to receive this channel.
    public let minimumScope: DeviceScope
    /// Stream channels: max retained casts per origin (ignored for state).
    public let maxEvents: Int
    /// Stream channels: max retained age (ignored for state).
    public let maxAge: TimeInterval
    /// Publish-time guard — casts are signals, not blobs; big data rides the fetch verbs.
    public let maxPayloadBytes: Int

    public init(
        mode: CastMode?, minimumScope: DeviceScope = .view,
        maxEvents: Int = 500, maxAge: TimeInterval = 7 * 24 * 3600,
        maxPayloadBytes: Int = 64 * 1024
    ) {
        self.mode = mode
        self.minimumScope = minimumScope
        self.maxEvents = maxEvents
        self.maxAge = maxAge
        self.maxPayloadBytes = maxPayloadBytes
    }
}

/// The static channel table. Unknown channels get a conservative stream-ish default so casts
/// from newer builds mirror + re-serve through this one without it understanding them.
public enum CastChannelRegistry {
    public static func policy(for channel: String) -> CastChannelPolicy {
        switch channel {
        case MeshCastChannel.activity:
            return CastChannelPolicy(mode: .stream, maxEvents: 500, maxAge: 14 * 24 * 3600)
        case MeshCastChannel.messages:
            return CastChannelPolicy(mode: .stream, maxEvents: 200, maxAge: 24 * 3600)
        case MeshCastChannel.runnerPresence:
            return CastChannelPolicy(mode: .state)
        case MeshCastChannel.composerTyping:
            // Casts are signals: the streamed draft is truncated at publish time
            // (`ComposerTypingState.maxTextChars`), so the cap stays small.
            return CastChannelPolicy(mode: .state, maxPayloadBytes: 16 * 1024)
        default:
            return CastChannelPolicy(mode: nil)
        }
    }

    /// The lowest scope a subscriber needs for `channel` — deliberately synchronous/pure so the
    /// per-connection fanout filter can consult it without an actor hop.
    public static func minimumScope(for channel: String) -> DeviceScope {
        policy(for: channel).minimumScope
    }
}

/// A cast payload type: paired CBOR encode/decode so publisher and consumer can never disagree
/// on the wire bytes. `decode` is nil-tolerant by design — an unknown `typeID` or an
/// undecodable payload is simply not this feature's cast.
public protocol CastPayload: Codable, Sendable {
    static var typeID: String { get }
}

extension CastPayload {
    /// The payload bytes a `publishCast` carries.
    public func encodedCastPayload() -> Data? { try? CBOREncoder().encode(self) }
    /// Decode a received cast, or nil when it isn't this payload type.
    public static func decode(_ cast: WireCast) -> Self? {
        guard cast.typeID == Self.typeID else { return nil }
        return try? CBORDecoder().decode(Self.self, from: cast.payload)
    }
}

// MARK: - Activity feed ("activity", stream)

/// One fleet-feed item — "session finished on Studio", "needs approval on the runner", …
/// `typeID` `"nucleic.feed.event.v1"`. The *origin* (which host this happened on) is the cast
/// envelope's `originHostID`, not repeated here; `hostName` is baked in only because phones
/// can't always resolve an id to a friendly name offline.
public struct FeedEvent: CastPayload, Equatable {
    public static let typeID = "nucleic.feed.event.v1"

    /// Raw-string-backed so an old build renders an unknown future kind generically instead of
    /// dropping the row.
    public struct Kind: RawRepresentable, Sendable, Codable, Equatable, Hashable {
        public let rawValue: String
        public init(rawValue: String) { self.rawValue = rawValue }

        /// A turn completed with the work done (disposition `.completed`).
        public static let finished = Kind(rawValue: "finished")
        /// A turn completed needing the user (disposition needs-you).
        public static let needsInput = Kind(rawValue: "needsInput")
        /// An approval surfaced (once per `ApprovalID`).
        public static let needsApproval = Kind(rawValue: "needsApproval")
        /// A user answered an approval — approved/denied it, or answered its question
        /// (once per resolved `ApprovalID`). The closing bookend to `needsApproval`.
        public static let responded = Kind(rawValue: "responded")
        /// The session's branch merged/landed.
        public static let shipped = Kind(rawValue: "shipped")
        /// The session moved to another Mac (source side).
        public static let transferredOut = Kind(rawValue: "transferredOut")
        /// The session arrived from another Mac (destination side).
        public static let transferredIn = Kind(rawValue: "transferredIn")
        /// A turn ended in an error (turn-level, not per error event).
        public static let error = Kind(rawValue: "error")
        /// A chat was mesh-dispatched onto this host.
        public static let dispatched = Kind(rawValue: "dispatched")
    }

    public let id: UUID
    public let kind: Kind
    public let sessionID: SessionID
    public let sessionTitle: String
    public let projectName: String
    /// The origin host's friendly name at publish time.
    public let hostName: String
    /// Kind-specific one-liner: approval title, truncated error summary, integrate mode,
    /// transfer destination. Never tool input/diff bytes (feeds are broadcast wide).
    public let detail: String?
    /// The approval to answer, when `kind == .needsApproval` (deep-link payload).
    public let approvalID: ApprovalID?
    /// For transfers/dispatch: the other host involved.
    public let counterpartHostID: String?
    public let counterpartHostName: String?
    public let at: Date

    public init(
        id: UUID = UUID(), kind: Kind, sessionID: SessionID, sessionTitle: String,
        projectName: String, hostName: String, detail: String? = nil,
        approvalID: ApprovalID? = nil, counterpartHostID: String? = nil,
        counterpartHostName: String? = nil, at: Date
    ) {
        self.id = id
        self.kind = kind
        self.sessionID = sessionID
        self.sessionTitle = sessionTitle
        self.projectName = projectName
        self.hostName = hostName
        self.detail = detail
        self.approvalID = approvalID
        self.counterpartHostID = counterpartHostID
        self.counterpartHostName = counterpartHostName
        self.at = at
    }

    private enum CodingKeys: String, CodingKey {
        case id, kind, sessionID, sessionTitle, projectName, hostName, detail
        case approvalID, counterpartHostID, counterpartHostName, at
    }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try c.decode(UUID.self, forKey: .id)
        self.kind = try c.decode(Kind.self, forKey: .kind)
        self.sessionID = try c.decode(SessionID.self, forKey: .sessionID)
        self.sessionTitle = try c.decodeIfPresent(String.self, forKey: .sessionTitle) ?? ""
        self.projectName = try c.decodeIfPresent(String.self, forKey: .projectName) ?? ""
        self.hostName = try c.decodeIfPresent(String.self, forKey: .hostName) ?? ""
        self.detail = try c.decodeIfPresent(String.self, forKey: .detail)
        self.approvalID = try c.decodeIfPresent(ApprovalID.self, forKey: .approvalID)
        self.counterpartHostID = try c.decodeIfPresent(String.self, forKey: .counterpartHostID)
        self.counterpartHostName = try c.decodeIfPresent(String.self, forKey: .counterpartHostName)
        self.at = try c.decode(Date.self, forKey: .at)
    }
}

/// One rendered feed row: a decoded `FeedEvent` plus its cast identity (the stable row id and
/// cross-connection dedup key). Shared by both renderers — the Mac bell and the iOS Home feed
/// build the same rows from the same casts.
public struct ActivityFeedItem: Identifiable, Sendable, Equatable {
    public let originHostID: String
    public let castSeq: UInt64
    public let event: FeedEvent

    public var id: String { "\(originHostID)#\(castSeq)" }

    public init(originHostID: String, castSeq: UInt64, event: FeedEvent) {
        self.originHostID = originHostID
        self.castSeq = castSeq
        self.event = event
    }

    /// Decode a feed row from an activity-channel cast; nil for any other channel/payload.
    public static func item(from cast: WireCast) -> ActivityFeedItem? {
        guard cast.channel == MeshCastChannel.activity,
              let event = FeedEvent.decode(cast) else { return nil }
        return ActivityFeedItem(
            originHostID: cast.originHostID, castSeq: cast.castSeq, event: event)
    }

    /// Fold fresh casts into a feed list: decode, drop identities already present, sort by
    /// event time (deterministic identity tiebreak), cap. The one merge both renderers use.
    public static func merged(
        _ existing: [ActivityFeedItem], adding casts: [WireCast], cap: Int = 300
    ) -> [ActivityFeedItem] {
        let held = Set(existing.map(\.id))
        let fresh = casts.compactMap(item(from:)).filter { !held.contains($0.id) }
        guard !fresh.isEmpty else { return existing }
        let all = (existing + fresh).sorted {
            if $0.event.at != $1.event.at { return $0.event.at < $1.event.at }
            return $0.id < $1.id
        }
        return all.count > cap ? Array(all.suffix(cap)) : all
    }
}

// MARK: - Mesh messaging ("messages", stream)

/// One app-level message between sessions/devices, host-mediated (`typeID` `"nucleic.msg.v1"`).
/// Addressing is exactly one of `topic` (broadcast to subscribed sessions) or `toHostID`
/// (+ optional `toSessionID`). "Directed" means *delivery filtering at the receiving host* —
/// the cast still rides the whole mesh, which is one trust domain; it is NOT confidentiality
/// between sessions.
///
/// Sender provenance (`fromSessionID`/`fromSessionTitle`/`fromHostName`) is stamped by the
/// publishing HOST from its own session record — agents can't forge it through tool arguments.
public struct MeshMessage: CastPayload, Equatable {
    public static let typeID = "nucleic.msg.v1"
    /// Publish-time cap on `body` (UTF-8 bytes) — messages are coordination, not payload
    /// transfer; big data rides the fetch verbs.
    public static let maxBodyBytes = 16 * 1024

    public let id: UUID
    /// Nil for a device/app-level sender (no originating session).
    public let fromSessionID: SessionID?
    public let fromSessionTitle: String?
    public let fromHostName: String
    /// Broadcast address: sessions subscribed to this topic receive it.
    public let topic: String?
    /// Directed address: only the host with this id delivers it (optionally to one session).
    public let toHostID: String?
    public let toSessionID: SessionID?
    /// Markdown text, ≤ `maxBodyBytes`.
    public let body: String
    public let at: Date

    public init(
        id: UUID = UUID(), fromSessionID: SessionID? = nil, fromSessionTitle: String? = nil,
        fromHostName: String, topic: String? = nil, toHostID: String? = nil,
        toSessionID: SessionID? = nil, body: String, at: Date
    ) {
        self.id = id
        self.fromSessionID = fromSessionID
        self.fromSessionTitle = fromSessionTitle
        self.fromHostName = fromHostName
        self.topic = topic
        self.toHostID = toHostID
        self.toSessionID = toSessionID
        self.body = body
        self.at = at
    }

    private enum CodingKeys: String, CodingKey {
        case id, fromSessionID, fromSessionTitle, fromHostName
        case topic, toHostID, toSessionID, body, at
    }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try c.decode(UUID.self, forKey: .id)
        self.fromSessionID = try c.decodeIfPresent(SessionID.self, forKey: .fromSessionID)
        self.fromSessionTitle = try c.decodeIfPresent(String.self, forKey: .fromSessionTitle)
        self.fromHostName = try c.decodeIfPresent(String.self, forKey: .fromHostName) ?? ""
        self.topic = try c.decodeIfPresent(String.self, forKey: .topic)
        self.toHostID = try c.decodeIfPresent(String.self, forKey: .toHostID)
        self.toSessionID = try c.decodeIfPresent(SessionID.self, forKey: .toSessionID)
        self.body = try c.decode(String.self, forKey: .body)
        self.at = try c.decode(Date.self, forKey: .at)
    }
}

// MARK: - Runner presence ("runner.presence", state, key = hostID)

/// A host's mesh-dispatch eligibility card (`typeID` `"nucleic.runner.presence.v1"`), published
/// on change (boot, project add/remove, session start/finish, settings flip) — NOT a heartbeat.
/// Liveness is the dispatcher's own live connection to the candidate; this cast carries the
/// *capability metadata* a dispatcher can't derive from a socket: capacity, backends, and which
/// projects the host holds (matched cross-host by `ProjectDescriptor` identity, the same
/// UUID/root-commit/remote matching session transfer uses).
public struct RunnerPresence: CastPayload, Equatable {
    public static let typeID = "nucleic.runner.presence.v1"

    public let hostName: String
    public let kind: PeerKind
    public let canRunAgents: Bool
    /// The user's "accept mesh-dispatched chats" toggle.
    public let acceptsMeshDispatch: Bool
    public let activeSessionCount: Int
    /// Nil = effectively unbounded (a Mac); a runner reports its pool cap.
    public let maxConcurrent: Int?
    /// Backends this host can actually launch (credentials present).
    public let backends: [BackendID]
    /// Projects this host holds, for cross-host identity matching.
    public let projects: [ProjectDescriptor]
    /// Whether this host accepts `createProject` (dispatch-time acquisition, v1.5+).
    public let canCreateProjects: Bool
    public let updatedAt: Date

    public init(
        hostName: String, kind: PeerKind, canRunAgents: Bool, acceptsMeshDispatch: Bool,
        activeSessionCount: Int, maxConcurrent: Int? = nil, backends: [BackendID],
        projects: [ProjectDescriptor], canCreateProjects: Bool = false, updatedAt: Date
    ) {
        self.hostName = hostName
        self.kind = kind
        self.canRunAgents = canRunAgents
        self.acceptsMeshDispatch = acceptsMeshDispatch
        self.activeSessionCount = activeSessionCount
        self.maxConcurrent = maxConcurrent
        self.backends = backends
        self.projects = projects
        self.canCreateProjects = canCreateProjects
        self.updatedAt = updatedAt
    }

    private enum CodingKeys: String, CodingKey {
        case hostName, kind, canRunAgents, acceptsMeshDispatch, activeSessionCount
        case maxConcurrent, backends, projects, canCreateProjects, updatedAt
    }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.hostName = try c.decodeIfPresent(String.self, forKey: .hostName) ?? ""
        self.kind = try c.decodeIfPresent(PeerKind.self, forKey: .kind) ?? .mac
        self.canRunAgents = try c.decodeIfPresent(Bool.self, forKey: .canRunAgents) ?? false
        self.acceptsMeshDispatch =
            try c.decodeIfPresent(Bool.self, forKey: .acceptsMeshDispatch) ?? false
        self.activeSessionCount =
            try c.decodeIfPresent(Int.self, forKey: .activeSessionCount) ?? 0
        self.maxConcurrent = try c.decodeIfPresent(Int.self, forKey: .maxConcurrent)
        // Lenient: a future backend id this build doesn't know is skipped, not fatal — the rest
        // of the presence card (and its other backends) stays usable.
        let backendRaws = try c.decodeIfPresent([String].self, forKey: .backends) ?? []
        self.backends = backendRaws.compactMap(BackendID.init(rawValue:))
        self.projects = try c.decodeIfPresent([ProjectDescriptor].self, forKey: .projects) ?? []
        self.canCreateProjects =
            try c.decodeIfPresent(Bool.self, forKey: .canCreateProjects) ?? false
        self.updatedAt = try c.decodeIfPresent(Date.self, forKey: .updatedAt) ?? .distantPast
    }
}

// MARK: - Live composer typing ("composer.typing", state, key = sessionID)

/// One device's live composer draft for a session (`typeID` `"nucleic.composer.typing.v1"`):
/// published (throttled) while the user types a follow-up, tombstoned when they send or go
/// idle. Watchers render `text` live and *lock their own composer* for the session while the
/// entry is fresh — one typer per session at a time (hence key = sessionID, not per device).
///
/// The publishing HOST stamps `deviceID`/`deviceName`: a Mac typer publishes as itself; a
/// phone's typing rides `ClientMsg.composerTyping` and the session's host republishes it on the
/// phone's behalf (casts are HostMsg-only), stamping the authenticated connection's identity —
/// a client can't forge who is typing.
///
/// Liveness is dual-guarded: the typer's side tombstones after `idleTimeout` without
/// keystrokes, and every watcher *also* expires an entry `staleTimeout` after `at` — so a typer
/// that vanishes mid-word (crash, dropped socket) can never leave composers locked mesh-wide.
public struct ComposerTypingState: CastPayload, Equatable {
    public static let typeID = "nucleic.composer.typing.v1"

    /// Typing ends after this long without a keystroke: the typer's device (or the host
    /// relaying a phone) publishes the tombstone that unlocks every watcher.
    public static let idleTimeout: TimeInterval = 5
    /// Watcher-side expiry for an entry that never got its tombstone: `idleTimeout` plus slack
    /// for one publish interval and transit, so a healthy typer is always tombstoned first.
    public static let staleTimeout: TimeInterval = idleTimeout + 3
    /// Publish-time throttle: at most one cast per interval while typing (trailing edge kept,
    /// so watchers always converge on the final text).
    public static let minPublishInterval: TimeInterval = 0.25
    /// Publish-time cap on the streamed draft — a live preview, not a transfer. Truncated,
    /// never dropped, so a long draft still locks watchers.
    public static let maxTextChars = 2000

    public let sessionID: SessionID
    /// The typing device (its hostID / paired deviceID) — watchers compare against their own
    /// identity so the typer never locks itself.
    public let deviceID: String
    /// The typer's friendly name at publish time ("Blake's iPhone", "Studio").
    public let deviceName: String
    /// The draft so far, truncated to `maxTextChars`.
    public let text: String
    public let at: Date

    public init(
        sessionID: SessionID, deviceID: String, deviceName: String, text: String, at: Date
    ) {
        self.sessionID = sessionID
        self.deviceID = deviceID
        self.deviceName = deviceName
        self.text = String(text.prefix(Self.maxTextChars))
        self.at = at
    }

    private enum CodingKeys: String, CodingKey {
        case sessionID, deviceID, deviceName, text, at
    }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.sessionID = try c.decode(SessionID.self, forKey: .sessionID)
        self.deviceID = try c.decodeIfPresent(String.self, forKey: .deviceID) ?? ""
        self.deviceName = try c.decodeIfPresent(String.self, forKey: .deviceName) ?? ""
        self.text = try c.decodeIfPresent(String.self, forKey: .text) ?? ""
        self.at = try c.decodeIfPresent(Date.self, forKey: .at) ?? .distantPast
    }

    /// Whether this entry still locks watchers at `now` (watcher-side crash guard).
    public func isFresh(at now: Date) -> Bool {
        now.timeIntervalSince(at) < Self.staleTimeout
    }
}

/// The client→host report riding `ClientMsg.composerTyping`: what this device's composer for
/// `sessionID` holds right now. `active: false` (send / cleared / went idle) asks the host to
/// tombstone the session's typing entry. The host stamps the sender's identity from the
/// authenticated connection — the struct deliberately carries none.
public struct WireComposerTyping: Sendable, Codable, Equatable {
    public let sessionID: SessionID
    /// The draft so far; ignored when `active` is false.
    public let text: String
    /// True while typing continues; false ends it (send or idle).
    public let active: Bool

    public init(sessionID: SessionID, text: String, active: Bool) {
        self.sessionID = sessionID
        self.text = text
        self.active = active
    }

    private enum CodingKeys: String, CodingKey { case sessionID, text, active }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        self.sessionID = try c.decode(SessionID.self, forKey: .sessionID)
        self.text = try c.decodeIfPresent(String.self, forKey: .text) ?? ""
        self.active = try c.decodeIfPresent(Bool.self, forKey: .active) ?? false
    }
}
