import Foundation

/// Shared, platform-neutral model for **Diplomat Mesh** — the LAN P2P duty-coordination
/// layer. This is the Swift half of the "two front-ends, one brain" split: it decodes
/// the shared `assets/mesh.json` (duty catalog, placement strategies, tier/token model)
/// and the local node's public topology snapshot (`~/.diplomat/mesh/state.json`), exactly
/// as the Python front-end's `szpontnet.config` / `.statefile` do.
///
/// Nothing here opens a socket or spawns a node — it's pure decode + placement logic,
/// so it builds and is unit-testable on Linux alongside the rest of the core. The
/// macOS UI layers a SwiftUI screen (`MeshView`) and a control client (`MeshBridge`)
/// on top; the Linux front-end renders the same model in Qt.

// MARK: - The shared model (assets/mesh.json)

/// The subset of `assets/mesh.json` the UIs render: the duty catalog, placement
/// strategies, and the platform / token / tier vocabularies. Protocol constants and
/// account/accounting knobs are the node's concern and deliberately not decoded here.
public struct MeshCatalog: Decodable, Equatable {
    public struct Platform: Decodable, Equatable {
        public let id: String
        public let title: String
        public let emoji: String
        public let colorHex: String
    }
    public struct Token: Decodable, Equatable {
        public let id: String
        public let title: String
        public let emoji: String
        public let colorHex: String
    }
    public struct Tiers: Decodable, Equatable {
        public let min: Int
        public let max: Int
        /// `default` is a Swift keyword — mapped from the JSON key.
        public let defaultTier: Int
        /// Human words per strength level ("1" → "Very strong", …), optional.
        public let labels: [String: String]?
        enum CodingKeys: String, CodingKey { case min, max, defaultTier = "default", labels }
    }
    /// A trust classification (personal / foreign) — the toggle vocabulary.
    public struct TrustLevel: Decodable, Equatable {
        public let id: String
        public let title: String
        public let linuxGlyph: String?
        public let colorHex: String
    }
    public struct Trust: Decodable, Equatable {
        public let levels: [TrustLevel]
    }
    public struct Strategy: Decodable, Equatable {
        public let id: String
        public let title: String
        public let detail: String
    }
    public struct Duty: Decodable, Equatable {
        public let id: String
        public let title: String
        public let emoji: String
        public let colorHex: String
        public let placement: PlacementDTO
    }
    /// The raw placement policy as it appears in the JSON (all fields optional, so a
    /// gossiped override that only carries a strategy still decodes). Resolved into a
    /// concrete `MeshPlacement` via `MeshPlacement.from`.
    public struct PlacementDTO: Decodable, Equatable {
        public let strategy: String?
        public let tokenAware: Bool?
        public let spread: [SpreadDTO]?

        enum CodingKeys: String, CodingKey { case strategy, tokenAware, spread }

        public init(from decoder: Decoder) throws {
            let c = try decoder.container(keyedBy: CodingKeys.self)
            strategy = try? c.decode(String.self, forKey: .strategy)
            tokenAware = try? c.decode(Bool.self, forKey: .tokenAware)
            // Per-element tolerant spread parse, mirroring `config.Placement._parse_spread`:
            // one malformed spread entry must be SKIPPED, not throw away the whole
            // placement (which — via the all-or-nothing `[String: PlacementDTO]` dict
            // decode in MeshOverrides — used to collapse EVERY duty override to none,
            // silently hiding all operator placement edits in the topology view).
            if let wrapped = try? c.decode([TolerantSpread].self, forKey: .spread) {
                spread = wrapped.compactMap { $0.value }
            } else {
                spread = nil
            }
        }
    }
    public struct SpreadDTO: Decodable, Equatable {
        public let platform: String
        public let count: Int?

        enum CodingKeys: String, CodingKey { case platform, count }

        public init(from decoder: Decoder) throws {
            let c = try decoder.container(keyedBy: CodingKeys.self)
            // platform is required and non-empty (an entry that names none — missing
            // OR empty — is skipped by the TolerantSpread wrapper, matching
            // `_parse_spread`'s `not platform` guard); a bad/missing count falls back
            // to the schema default 1 downstream (`count ?? 1`).
            let p = try c.decode(String.self, forKey: .platform)
            guard !p.isEmpty else {
                throw DecodingError.dataCorruptedError(
                    forKey: .platform, in: c, debugDescription: "empty platform")
            }
            platform = p
            count = try? c.decode(Int.self, forKey: .count)
        }
    }
    /// Decodes one spread element without ever throwing: an entry that isn't an
    /// object or names no platform becomes `nil` and is dropped, exactly as
    /// `config.Placement._parse_spread` skips it.
    private struct TolerantSpread: Decodable {
        let value: SpreadDTO?
        init(from decoder: Decoder) throws { value = try? SpreadDTO(from: decoder) }
    }

    /// How an edge between two nodes runs: the `lan` (peers that share a network) or
    /// one of the WAN transports (peers that do not). `wan` false marks the LAN entry,
    /// which is never something the operator picks — it is simply where two machines
    /// on one network meet. Optional so an older assets dir still decodes the catalog.
    public struct Transport: Decodable, Equatable {
        public let id: String
        public let title: String
        public let emoji: String
        public let linuxGlyph: String?
        public let colorHex: String
        public let wan: Bool?
        public let detail: String?
    }

    public let platforms: [Platform]
    public let tokens: [Token]
    public let tiers: Tiers
    public let trust: Trust?
    public let transports: [Transport]?
    public let strategies: [Strategy]
    public let duties: [Duty]
    public let defaultStrategy: String

    /// (min, max, default) machine tier, mirroring `config.tier_bounds()`.
    public var tierBounds: (min: Int, max: Int, default: Int) {
        (tiers.min, tiers.max, tiers.defaultTier)
    }

    /// Human word for a strength tier ("Very strong" … "Very light"), mirroring
    /// `config.tier_label`; falls back to `tier N` if unlabelled.
    public func tierLabel(_ tier: Int) -> String {
        tiers.labels?[String(tier)] ?? "tier \(tier)"
    }

    public func platform(_ id: String) -> Platform? { platforms.first { $0.id == id } }
    public func token(_ id: String) -> Token? { tokens.first { $0.id == id } }
    public func duty(_ id: String) -> Duty? { duties.first { $0.id == id } }
    public func trustLevel(_ id: String) -> TrustLevel? { trust?.levels.first { $0.id == id } }
    public func transport(_ id: String) -> Transport? { transports?.first { $0.id == id } }

    /// The transports an operator can pick between — the WAN ones, in model order.
    /// The LAN is not among them: it is where two machines on one network meet, not
    /// a choice. Mirrors the Linux front-end's `_WAN_TRANSPORTS`.
    public var wanTransports: [Transport] { (transports ?? []).filter { $0.wan == true } }

    /// The effective placement for a duty: the gossiped override if present, else the
    /// `assets/mesh.json` default. Mirrors `config.placement_for`.
    public func placement(for dutyID: String, overrides: MeshOverrides?) -> MeshPlacement {
        if let dto = overrides?.duties[dutyID] {
            return MeshPlacement.from(dto, defaultStrategy: defaultStrategy)
        }
        if let duty = duty(dutyID) {
            return MeshPlacement.from(duty.placement, defaultStrategy: defaultStrategy)
        }
        return MeshPlacement(strategy: defaultStrategy, tokenAware: true, spread: [])
    }
}

// MARK: - Placement (per-duty policy)

public struct MeshSpread: Equatable {
    public let platform: String
    public let count: Int
    public init(platform: String, count: Int) {
        self.platform = platform
        self.count = count
    }
}

/// The resolved placement policy for one duty, mirroring `config.Placement`.
public struct MeshPlacement: Equatable {
    public var strategy: String
    public var tokenAware: Bool
    /// [(platform, count)] the duty must cover; empty = any one node.
    public var spread: [MeshSpread]

    public init(strategy: String, tokenAware: Bool, spread: [MeshSpread]) {
        self.strategy = strategy
        self.tokenAware = tokenAware
        self.spread = spread
    }

    public static func from(_ dto: MeshCatalog.PlacementDTO, defaultStrategy: String) -> MeshPlacement {
        MeshPlacement(
            strategy: dto.strategy ?? defaultStrategy,
            tokenAware: dto.tokenAware ?? true,
            // `max(1, …)` mirrors the Python node's `_parse_spread`: a non-positive count
            // normalizes to 1. A spread entry dispatches to >= 1 node, and leaving a
            // gossiped `count: 0`/negative unnormalized would desync the two placement
            // consumers (a duty that looks placed but silently never runs), so both
            // front-ends must resolve it identically.
            spread: (dto.spread ?? []).map { MeshSpread(platform: $0.platform, count: max(1, $0.count ?? 1)) }
        )
    }

    /// A JSON-object representation (for the control-socket `set-overrides` command),
    /// mirroring `config.Placement.to_dict`.
    public func jsonObject() -> [String: Any] {
        [
            "strategy": strategy,
            "tokenAware": tokenAware,
            "spread": spread.map { ["platform": $0.platform, "count": $0.count] },
        ]
    }
}

/// Mesh-wide placement edits, gossiped last-writer-wins — mirrors
/// `config.PlacementOverrides`. `duties` maps duty id → the raw placement policy.
public struct MeshOverrides: Decodable, Equatable {
    public let rev: Int
    public let updatedBy: String
    public let duties: [String: MeshCatalog.PlacementDTO]

    enum CodingKeys: String, CodingKey { case rev, updatedBy, duties }

    /// Decodes one duty's placement VALUE without ever throwing: a value that isn't a
    /// placement object becomes `nil` and that duty is dropped, so a single malformed
    /// duty can't collapse the whole map (mirrors the Python port dropping non-mapping
    /// duty values in `config.PlacementOverrides.from_dict`).
    private struct TolerantPlacement: Decodable {
        let value: MeshCatalog.PlacementDTO?
        init(from decoder: Decoder) throws {
            value = try? MeshCatalog.PlacementDTO(from: decoder)
        }
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        rev = (try? c.decode(Int.self, forKey: .rev)) ?? 0
        updatedBy = (try? c.decode(String.self, forKey: .updatedBy)) ?? ""
        // Per-key tolerant: one malformed duty value (or a malformed spread within one)
        // drops only that duty, never the whole overrides map.
        if let wrapped = try? c.decode([String: TolerantPlacement].self, forKey: .duties) {
            duties = wrapped.compactMapValues { $0.value }
        } else {
            duties = [:]
        }
    }
}

// MARK: - Topology snapshot (~/.diplomat/mesh/state.json)

/// A node's advertised quota accounting (`NodeInfo.stats` in the Python node): the
/// plan it runs on, the derived load figures kept for display, and `surplus` — the
/// burn-down ratio the `surplus-first` strategy ranks by. Absent entirely for nodes
/// that haven't recorded any usage.
public struct MeshStats: Decodable, Equatable {
    public let plan: String
    public let usageAvg: Double
    public let quotaLeft: Double
    /// Budget left divided by the clock left until the quota resets: 1.0 is exactly
    /// on pace, above is flush, below is rationing. Relative by construction, so a
    /// small plan about to reset can out-rank a big idle one. `NEUTRAL_SURPLUS`
    /// (1.0, on the line) for an older peer that advertises only the absolute
    /// `quotaLeft`/`usageAvg` pair — those are a different scale and are NOT
    /// converted. Mirrors `stats.NodeStats.surplus` / `protocol.NodeInfo.surplus`.
    public let surplus: Double

    /// The value an advert with no usable surplus signal ranks as — exactly on the
    /// burn-down line. Mirrors `protocol.NEUTRAL_SURPLUS`.
    public static let neutralSurplus: Double = 1.0

    enum CodingKeys: String, CodingKey { case plan, usageAvg, quotaLeft, surplus }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        plan = (try? c.decode(String.self, forKey: .plan)) ?? ""
        usageAvg = (try? c.decode(Double.self, forKey: .usageAvg)) ?? 0
        quotaLeft = (try? c.decode(Double.self, forKey: .quotaLeft)) ?? 0
        surplus = (try? c.decode(Double.self, forKey: .surplus)) ?? MeshStats.neutralSurplus
    }
    public init(plan: String, usageAvg: Double, quotaLeft: Double,
                surplus: Double = MeshStats.neutralSurplus) {
        self.plan = plan; self.usageAvg = usageAvg; self.quotaLeft = quotaLeft
        self.surplus = surplus
    }
}

/// One node's gossiped attributes, common to `self` and each peer.
public struct MeshNode: Decodable, Equatable {
    public let id: String
    public let name: String
    public let platform: String
    public let tier: Int
    public let tokens: String
    /// Whether the tier was auto-detected from hardware (vs pinned by an edit).
    public let strengthAuto: Bool
    /// Whether the token state is auto-derived from real usage (vs a manual pin).
    public let tokensAuto: Bool
    /// Fraction of the token budget still remaining (1.0 = fresh, 0.0 = out) — the
    /// binding value: min(session, week) from the real probe, else the heuristic.
    public let tokensPct: Double
    /// Real remaining fraction of the 5-hour session window (OAuth usage probe);
    /// nil when the node is on the heuristic fallback (or an older build).
    public let tokensSessionPct: Double?
    /// Real remaining fraction of the 7-day week window; nil like `tokensSessionPct`.
    public let tokensWeekPct: Double?
    /// Seconds this node has been running (self view) — nil when absent.
    public let uptimeSecs: Double?
    /// This node's device-key fingerprint (sha256 of the raw Ed25519 pubkey, hex);
    /// `""` when the node runs keyless (`cryptography` not installed).
    public let fingerprint: String
    /// Advertised quota accounting; nil until the node has stats to gossip.
    public let stats: MeshStats?

    enum CodingKeys: String, CodingKey {
        case id, name, platform, tier, tokens, strengthAuto, tokensAuto, tokensPct,
             tokensSessionPct, tokensWeekPct, uptimeSecs, fingerprint, stats
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = (try? c.decode(String.self, forKey: .id)) ?? ""
        name = (try? c.decode(String.self, forKey: .name)) ?? "?"
        platform = (try? c.decode(String.self, forKey: .platform)) ?? "unknown"
        tier = (try? c.decode(Int.self, forKey: .tier)) ?? 3
        tokens = (try? c.decode(String.self, forKey: .tokens)) ?? "ok"
        strengthAuto = (try? c.decode(Bool.self, forKey: .strengthAuto)) ?? true
        tokensAuto = (try? c.decode(Bool.self, forKey: .tokensAuto)) ?? true
        tokensPct = (try? c.decode(Double.self, forKey: .tokensPct)) ?? 1.0
        tokensSessionPct = try? c.decode(Double.self, forKey: .tokensSessionPct)
        tokensWeekPct = try? c.decode(Double.self, forKey: .tokensWeekPct)
        uptimeSecs = try? c.decode(Double.self, forKey: .uptimeSecs)
        fingerprint = (try? c.decode(String.self, forKey: .fingerprint)) ?? ""
        stats = try? c.decode(MeshStats.self, forKey: .stats)
    }

    public init(id: String, name: String, platform: String, tier: Int, tokens: String,
                strengthAuto: Bool = true, tokensAuto: Bool = true, tokensPct: Double = 1.0,
                tokensSessionPct: Double? = nil, tokensWeekPct: Double? = nil,
                uptimeSecs: Double? = nil, fingerprint: String = "", stats: MeshStats? = nil) {
        self.id = id; self.name = name; self.platform = platform
        self.tier = tier; self.tokens = tokens
        self.strengthAuto = strengthAuto; self.tokensAuto = tokensAuto
        self.tokensPct = tokensPct
        self.tokensSessionPct = tokensSessionPct; self.tokensWeekPct = tokensWeekPct
        self.uptimeSecs = uptimeSecs; self.fingerprint = fingerprint
        self.stats = stats
    }

    /// `uptimeSecs` ticks, and `tokensPct`/`stats` drift with real usage, so all three
    /// are excluded from equality — otherwise the change-detecting poll (see `Store`)
    /// would fire twice a second on self's own uptime. The session/week percentages
    /// ARE compared: they move at most about once a minute (integer-percent probe)
    /// and the quota indicator must repaint when they do. Mirrors `MeshPeer.==`.
    public static func == (a: MeshNode, b: MeshNode) -> Bool {
        a.id == b.id && a.name == b.name && a.platform == b.platform && a.tier == b.tier
            && a.tokens == b.tokens && a.strengthAuto == b.strengthAuto
            && a.tokensAuto == b.tokensAuto && a.fingerprint == b.fingerprint
            && a.tokensSessionPct == b.tokensSessionPct && a.tokensWeekPct == b.tokensWeekPct
    }

    /// The burn-down ratio `surplus-first` ranks by (see `MeshStats.surplus`);
    /// `NEUTRAL_SURPLUS` (1.0) with no stats — mirrors `NodeInfo.surplus()`.
    public var surplus: Double { stats?.surplus ?? MeshStats.neutralSurplus }
}

/// A peer as seen from the local node: its node attributes plus the link state, remote
/// address, liveness, and the trust the local node derived for it.
public struct MeshPeer: Decodable, Equatable {
    public let id: String
    public let name: String
    public let platform: String
    public let tier: Int
    public let tokens: String
    /// "up" | "stale" | "down".
    public let link: String
    public let addr: String?
    public let lastSeenSecsAgo: Double?
    /// Peer ids this peer reports it currently sees (drives the peer↔peer graph edges).
    public let sees: [String]
    public let strengthAuto: Bool
    public let tokensAuto: Bool
    public let tokensPct: Double
    /// Real remaining fractions per rate-limit window (see `MeshNode`); nil on the
    /// heuristic fallback or an older peer build.
    public let tokensSessionPct: Double?
    public let tokensWeekPct: Double?
    /// Seconds the current link has been up ("up 3m") — nil while down.
    public let uptimeSecs: Double?
    /// "personal" | "foreign" | "banned" — the local allowlist's (and ban list's)
    /// verdict on the VERIFIED key (an empty allowlist classes everyone personal,
    /// preserving pre-trust behavior; "banned" = the device accepted a
    /// SzpontRequest and failed to deliver it — see szpontnet-spec/docs/13).
    public let trust: String
    /// The peer's device-key fingerprint (proven if `verified`, else merely claimed;
    /// `""` for keyless peers).
    public let fingerprint: String
    /// True once the peer has proven possession of its device key on this link
    /// (Ed25519 fresh-nonce signature) — advertised identity alone never sets this.
    public let verified: Bool
    /// The peer's advertised surplus (3dp): budget left ÷ clock left to its reset,
    /// the burn-down ratio `surplus-first` ranks by (see `MeshStats.surplus`).
    public let surplus: Double
    /// Advertised quota accounting; nil until the peer gossips stats.
    public let stats: MeshStats?
    /// What the CURRENT link runs over: "lan" (a direct connection on this network)
    /// or a WAN transport name. "lan" while down, and from a node too old to report it.
    public let transport: String
    /// The WAN transport this EDGE agrees on — the most-preferred one both ends run
    /// — or "" when the two share none, meaning this pair cannot re-form once they
    /// leave the network they are on. Distinct from `transport`, which is how the
    /// link runs right now: a peer on the LAN has "lan" there and its off-LAN answer
    /// here.
    public let wan: String

    enum CodingKeys: String, CodingKey {
        case id, name, platform, tier, tokens, link, addr, lastSeenSecsAgo, sees,
             strengthAuto, tokensAuto, tokensPct, tokensSessionPct, tokensWeekPct,
             uptimeSecs, trust, fingerprint, verified, surplus, stats, transport, wan
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = (try? c.decode(String.self, forKey: .id)) ?? ""
        name = (try? c.decode(String.self, forKey: .name)) ?? "?"
        platform = (try? c.decode(String.self, forKey: .platform)) ?? "unknown"
        tier = (try? c.decode(Int.self, forKey: .tier)) ?? 3
        tokens = (try? c.decode(String.self, forKey: .tokens)) ?? "ok"
        link = (try? c.decode(String.self, forKey: .link)) ?? "down"
        addr = try? c.decode(String.self, forKey: .addr)
        lastSeenSecsAgo = try? c.decode(Double.self, forKey: .lastSeenSecsAgo)
        sees = (try? c.decode([String].self, forKey: .sees)) ?? []
        strengthAuto = (try? c.decode(Bool.self, forKey: .strengthAuto)) ?? true
        tokensAuto = (try? c.decode(Bool.self, forKey: .tokensAuto)) ?? true
        tokensPct = (try? c.decode(Double.self, forKey: .tokensPct)) ?? 1.0
        tokensSessionPct = try? c.decode(Double.self, forKey: .tokensSessionPct)
        tokensWeekPct = try? c.decode(Double.self, forKey: .tokensWeekPct)
        uptimeSecs = try? c.decode(Double.self, forKey: .uptimeSecs)
        trust = (try? c.decode(String.self, forKey: .trust)) ?? "personal"
        fingerprint = (try? c.decode(String.self, forKey: .fingerprint)) ?? ""
        verified = (try? c.decode(Bool.self, forKey: .verified)) ?? false
        surplus = (try? c.decode(Double.self, forKey: .surplus)) ?? MeshStats.neutralSurplus
        stats = try? c.decode(MeshStats.self, forKey: .stats)
        transport = (try? c.decode(String.self, forKey: .transport)) ?? "lan"
        wan = (try? c.decode(String.self, forKey: .wan)) ?? ""
    }

    /// `lastSeenSecsAgo`/`uptimeSecs` tick on every snapshot write, so they're excluded
    /// from equality: a change-detecting poll (see `Store`) must not fire twice a second
    /// on an idle mesh. The genuine signals (`link`, `trust`, token state) are compared.
    /// Mirrors the Python front-end's `_mesh_meaningfully_changed` strip.
    public static func == (a: MeshPeer, b: MeshPeer) -> Bool {
        a.id == b.id && a.name == b.name && a.platform == b.platform && a.tier == b.tier
            && a.tokens == b.tokens && a.link == b.link && a.addr == b.addr && a.sees == b.sees
            && a.strengthAuto == b.strengthAuto && a.tokensAuto == b.tokensAuto
            && a.trust == b.trust && a.verified == b.verified && a.fingerprint == b.fingerprint
            && a.tokensSessionPct == b.tokensSessionPct && a.tokensWeekPct == b.tokensWeekPct
            && a.transport == b.transport && a.wan == b.wan
    }
}

/// One local-allowlist entry as published in the snapshot's `trusted` list — the
/// operator-managed device-key allowlist (`~/.diplomat/mesh/trusted.json`).
public struct MeshTrustedEntry: Decodable, Equatable {
    public let fingerprint: String
    public let label: String

    enum CodingKeys: String, CodingKey { case fingerprint, label }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        fingerprint = (try? c.decode(String.self, forKey: .fingerprint)) ?? ""
        label = (try? c.decode(String.self, forKey: .label)) ?? ""
    }
}

/// One ban-list entry as published in the snapshot's `banned` list — a device this
/// node marked as having accepted a SzpontRequest and failed to deliver it (or one
/// the operator banned manually). Machine-local (`~/.diplomat/mesh/banned.json`),
/// never gossiped; `fingerprint` is empty for a keyless device (then `node` is the
/// best-effort key). See szpontnet-spec/docs/13-foreign-execution.md#the-ban.
public struct MeshBannedEntry: Decodable, Equatable {
    public let fingerprint: String
    public let node: String
    public let label: String
    public let reason: String
    public let bannedAt: Double
    /// The SzpontRequest job whose broken promise triggered the ban ("" for a
    /// manual ban) — lets a UI link the mark back to the undelivered work.
    public let jobId: String

    enum CodingKeys: String, CodingKey { case fingerprint, node, label, reason, bannedAt, jobId }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        fingerprint = (try? c.decode(String.self, forKey: .fingerprint)) ?? ""
        node = (try? c.decode(String.self, forKey: .node)) ?? ""
        label = (try? c.decode(String.self, forKey: .label)) ?? ""
        reason = (try? c.decode(String.self, forKey: .reason)) ?? ""
        bannedAt = (try? c.decode(Double.self, forKey: .bannedAt)) ?? 0
        jobId = (try? c.decode(String.self, forKey: .jobId)) ?? ""
    }
}

public struct MeshShortfall: Decodable, Equatable {
    public let missing: Int
    public let platform: String

    enum CodingKeys: String, CodingKey { case missing, platform }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        missing = (try? c.decode(Int.self, forKey: .missing)) ?? 1
        platform = (try? c.decode(String.self, forKey: .platform)) ?? "?"
    }
}

/// A duty's live routing: the node ids it's assigned to, and any platform slots it
/// can't currently fill.
public struct MeshAssignment: Decodable, Equatable {
    public let assigned: [String]
    public let shortfall: [MeshShortfall]

    enum CodingKeys: String, CodingKey { case assigned, shortfall }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        assigned = (try? c.decode([String].self, forKey: .assigned)) ?? []
        shortfall = (try? c.decode([MeshShortfall].self, forKey: .shortfall)) ?? []
    }
}

/// One WAN transport's state on THIS machine: whether the operator asked for it,
/// whether it is live yet, and the permanent address peers dial it at. `address` is
/// what the operator hands another machine for a manual `connect`; nil until ready
/// (Tor's bootstrap runs into minutes).
public struct MeshWanTransport: Decodable, Equatable {
    public let enabled: Bool
    public let ready: Bool
    public let address: String?

    enum CodingKeys: String, CodingKey { case enabled, ready, address }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        enabled = (try? c.decode(Bool.self, forKey: .enabled)) ?? false
        ready = (try? c.decode(Bool.self, forKey: .ready)) ?? false
        address = try? c.decode(String.self, forKey: .address)
    }
}

/// Reaching machines off this network: the mesh's gossiped transport pick, and what
/// each transport is doing here. Keyed by transport id, so a UI walks the catalog
/// vocabulary rather than naming iroh and tor itself.
public struct MeshWan: Decodable, Equatable {
    public let preferred: String
    public let transports: [String: MeshWanTransport]

    enum CodingKeys: String, CodingKey { case preferred, transports }
    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        preferred = (try? c.decode(String.self, forKey: .preferred)) ?? ""
        transports = (try? c.decode([String: MeshWanTransport].self, forKey: .transports)) ?? [:]
    }

    public func transport(_ id: String) -> MeshWanTransport? { transports[id] }
    /// Transports with a live address right now — what this machine can dial FROM,
    /// and what it has an id to hand out for.
    public var ready: [String] {
        transports.filter { $0.value.address?.isEmpty == false }.map(\.key).sorted()
    }
}

/// The public topology snapshot the node rewrites atomically every couple of seconds.
/// Mirrors the shape documented in `szpontnet.statefile`.
public struct MeshSnapshot: Decodable, Equatable {
    /// The node process pid, used for the liveness check.
    public let pid: Int?
    /// The local control endpoint (127.0.0.1:tcpPort) for edits/dispatch.
    public let tcpPort: Int?
    /// This machine's own node info (`self` in the JSON).
    public let selfNode: MeshNode?
    public let peers: [MeshPeer]
    /// The local device-key allowlist, as `[{fingerprint, label}]` — published so
    /// front-ends can render the trust boundary without touching `trusted.json`.
    public let trusted: [MeshTrustedEntry]
    /// The local ban list mirror — who this node marked banned (accepted a
    /// SzpontRequest, failed to deliver) and why. Empty when nobody is.
    public let banned: [MeshBannedEntry]
    /// The trust level applied to an UNKNOWN device (not in `trusted`): "foreign"
    /// (zero-trust default — a new device is untrusted until promoted) or "personal"
    /// (full-trust mesh). Drives the default-trust toggle. Older nodes omit it → "foreign".
    public let defaultTrust: String
    public let assignments: [String: MeshAssignment]
    public let overrides: MeshOverrides?
    /// The origination leases this node currently observes: work key → the id of the
    /// node whose agent owns it (szpontnet-spec/docs/12). Only OWNED keys appear —
    /// an executor holds its key for the agent's lifetime, so a key's presence is
    /// this machine's one view of work running elsewhere, and its disappearance is
    /// the only completion signal a dispatcher ever gets. Read by the Agent-tasks
    /// list to keep a mesh row alive exactly as long as the run behind it.
    public let claims: [String: String]
    /// Peers mid-handshake right now — drives the "linking to N…" scanning banner.
    public let linking: Int
    /// True while EVERY beacon send fails: the node is undiscoverable (typically an
    /// OS privacy gate — macOS Local Network — denying LAN sends). Drives the loud
    /// "device is not discoverable" banner so the mesh doesn't just look empty.
    public let beaconBlocked: Bool
    /// WHY it's blocked, so the banner shows the node's own diagnosis instead of
    /// always blaming Local Network: "local-network" (an OS/firewall gate the operator
    /// can fix) or "network-down" (the stack itself is gone). Empty when up, and empty
    /// from an older node that predates the field → treated as the Local-Network case.
    public let beaconBlockReason: String
    /// Reaching machines off this network — the mesh's preferred transport and this
    /// machine's state on each. nil from a node with no WAN transport support at all.
    public let wan: MeshWan?

    enum CodingKeys: String, CodingKey {
        case pid, tcpPort, selfNode = "self", peers, trusted, banned, defaultTrust,
             assignments, overrides, claims,
             linking, beaconBlocked, beaconBlockReason, wan
    }

    public init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        pid = try? c.decode(Int.self, forKey: .pid)
        tcpPort = try? c.decode(Int.self, forKey: .tcpPort)
        selfNode = try? c.decode(MeshNode.self, forKey: .selfNode)
        peers = (try? c.decode([MeshPeer].self, forKey: .peers)) ?? []
        trusted = (try? c.decode([MeshTrustedEntry].self, forKey: .trusted)) ?? []
        banned = (try? c.decode([MeshBannedEntry].self, forKey: .banned)) ?? []
        defaultTrust = (try? c.decode(String.self, forKey: .defaultTrust)) ?? "foreign"
        assignments = (try? c.decode([String: MeshAssignment].self, forKey: .assignments)) ?? [:]
        overrides = try? c.decode(MeshOverrides.self, forKey: .overrides)
        claims = (try? c.decode([String: String].self, forKey: .claims)) ?? [:]
        linking = (try? c.decode(Int.self, forKey: .linking)) ?? 0
        beaconBlocked = (try? c.decode(Bool.self, forKey: .beaconBlocked)) ?? false
        beaconBlockReason = (try? c.decode(String.self, forKey: .beaconBlockReason)) ?? ""
        wan = try? c.decode(MeshWan.self, forKey: .wan)
    }

    /// Decode a snapshot from raw JSON bytes; nil for garbage/absent, matching
    /// `statefile.read_state`.
    public static func decode(_ data: Data) -> MeshSnapshot? {
        try? JSONDecoder().decode(MeshSnapshot.self, from: data)
    }
}
