import Dispatch
import Foundation
import NucleicProtocol
import NucleicRemoteProjection

// Flat renderer ABI (docs/WINDOWS_PORT.md §6). The only values crossing C are opaque retained
// object pointers, integer status codes, and UTF-8 JSON. Swift owns transport/Noise/projection;
// C# owns UI state and copies callback strings before the callback returns.

private let npOK: Int32 = 0
private let npInvalidArgument: Int32 = -1
private let npInvalidState: Int32 = -2
private let npDecodeError: Int32 = -3
private let npUnsupported: Int32 = -4

public typealias NPEventCallback =
    @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<CChar>?) -> Void
public typealias NPStateCallback =
    @convention(c) (UnsafeMutableRawPointer?, UnsafePointer<CChar>?) -> Void

private enum NPClientError: Error, CustomStringConvertible {
    case invalid(String)
    case unsupported(String)

    var description: String {
        switch self {
        case .invalid(let message), .unsupported(let message): message
        }
    }
}

// MARK: - Dedicated callback thread

/// A tiny single-thread executor. A serial DispatchQueue guarantees order but not thread
/// affinity; the C ABI explicitly promises both, because native callbacks commonly back
/// thread-affine interop runtimes.
private final class NPCallbackExecutor: @unchecked Sendable {
    private struct Job: @unchecked Sendable {
        let owner: UUID
        let body: @Sendable () -> Void
    }

    private final class State: @unchecked Sendable {
        let lock = NSLock()
        let available = DispatchSemaphore(value: 0)
        var jobs: [Job] = []
        var stopping = false
    }

    private let state = State()
    private let thread: Thread

    init(label: String) {
        let state = self.state
        thread = Thread {
            while true {
                state.available.wait()
                let job: Job? = state.lock.withLock {
                    if !state.jobs.isEmpty { return state.jobs.removeFirst() }
                    return nil
                }
                if let job {
                    job.body()
                    let finished = state.lock.withLock {
                        state.stopping && state.jobs.isEmpty
                    }
                    if finished { return }
                    continue
                }
                if state.lock.withLock({ state.stopping }) { return }
            }
        }
        thread.name = label
        thread.start()
    }

    func submit(owner: UUID, _ body: @escaping @Sendable () -> Void) {
        let accepted = state.lock.withLock {
            guard !state.stopping else { return false }
            state.jobs.append(Job(owner: owner, body: body))
            return true
        }
        if accepted { state.available.signal() }
    }

    /// Drop callbacks which have not started and wait for an already-running callback from this
    /// handle to finish. This makes `np_client_close` a callback-lifetime barrier, so C# may free
    /// its GCHandle/context as soon as close returns.
    func cancelAndWait(owner: UUID) {
        state.lock.withLock {
            state.jobs.removeAll { $0.owner == owner }
        }
        guard Thread.current !== thread else { return }
        let barrier = DispatchSemaphore(value: 0)
        submit(owner: owner) { barrier.signal() }
        barrier.wait()
    }

    deinit {
        state.lock.withLock { state.stopping = true }
        state.available.signal()
    }
}

/// All client handles share one callback executor, so even a multi-host renderer never receives
/// two native callbacks concurrently.
private let npCallbackExecutor =
    NPCallbackExecutor(label: "dev.nucleic.protocol-callback")

private final class NPEventInvocation: @unchecked Sendable {
    let function: NPEventCallback
    let context: UnsafeMutableRawPointer?
    let json: String

    init(function: NPEventCallback, context: UnsafeMutableRawPointer?, json: String) {
        self.function = function
        self.context = context
        self.json = json
    }

    func invoke() { json.withCString { function(context, $0) } }
}

private final class NPStateInvocation: @unchecked Sendable {
    let function: NPStateCallback
    let context: UnsafeMutableRawPointer?
    let json: String

    init(function: NPStateCallback, context: UnsafeMutableRawPointer?, json: String) {
        self.function = function
        self.context = context
        self.json = json
    }

    func invoke() { json.withCString { function(context, $0) } }
}

// MARK: - Configuration / persistence

private struct NPClientConfiguration: Decodable {
    let identityDir: String
    let hostKeyPins: [String: String]
    let deviceID: String?
    let deviceLabel: String
    let scopeClaim: DeviceScope
    let channel: ReleaseChannel?

    private enum CodingKeys: String, CodingKey {
        case identityDir, hostKeyPins, deviceID, deviceLabel, scopeClaim, channel
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        identityDir = try c.decode(String.self, forKey: .identityDir)
        hostKeyPins = try c.decodeIfPresent([String: String].self, forKey: .hostKeyPins) ?? [:]
        deviceID = try c.decodeIfPresent(String.self, forKey: .deviceID)
        deviceLabel = try c.decodeIfPresent(String.self, forKey: .deviceLabel)
            ?? "Nucleic for Windows"
        scopeClaim = try c.decodeIfPresent(DeviceScope.self, forKey: .scopeClaim) ?? .control
        channel = try c.decodeIfPresent(ReleaseChannel.self, forKey: .channel)
            ?? NPClientConfiguration.compiledChannel
    }

    private static var compiledChannel: ReleaseChannel {
        #if NUCLEIC_STABLE
        .release
        #elseif NUCLEIC_RC
        .releaseCandidate
        #elseif NUCLEIC_BETA
        .beta
        #elseif NUCLEIC_CANARY
        .canary
        #else
        .local
        #endif
    }
}

private struct NPLocalRendezvous: Decodable {
    let host: String
    let port: Int
    let secret: Data

    private enum CodingKeys: String, CodingKey { case host, port, psk, localPSK }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        host = try c.decodeIfPresent(String.self, forKey: .host) ?? "127.0.0.1"
        port = try c.decode(Int.self, forKey: .port)
        let encoded = try c.decodeIfPresent(String.self, forKey: .psk)
            ?? c.decode(String.self, forKey: .localPSK)
        guard let secret = Data(base64Encoded: encoded), secret.count == 32 else {
            throw NPClientError.invalid("rendezvous psk/localPSK must be 32 base64-encoded bytes")
        }
        self.secret = secret
    }
}

private struct NPRemoteEndpoint: Decodable {
    let transport: String?
    let host: String?
    let port: Int?
    let hostID: String?
    let hostStaticKey: String?
    let psk: String?
    let relayURL: String?
    let membershipToken: String?
}

private enum NPTransportEndpoint: Sendable {
    case tcp(host: String, port: Int)
    case relay(base: URL, membershipToken: String)
}

private struct NPConnectionSpec: Sendable {
    let endpoint: NPTransportEndpoint
    let transport: SyncTransportHint
    let hostStaticKey: Data
    let mode: SyncClient.Mode
}

private final class NPClientHandle: @unchecked Sendable {
    private let lock = NSLock()
    private let callbackOwner = UUID()
    private let configuration: NPClientConfiguration
    private let identityDirectory: URL
    private let identity: DeviceIdentity
    private let deviceID: String

    private var eventCallback: NPEventCallback?
    private var stateCallback: NPStateCallback?
    private var callbackContext: UnsafeMutableRawPointer?
    private var pins: [String: Data]

    private var generation = 0
    private var connectionTask: Task<Void, Never>?
    private var activeChannel: (any FrameChannel)?
    private var activeClient: SyncClient?
    private var intentContinuation: AsyncStream<ClientMsg>.Continuation?
    private var acceptingIntents = false
    private var isClosed = false

    init(configuration: NPClientConfiguration) throws {
        guard !configuration.identityDir.isEmpty else {
            throw NPClientError.invalid("identityDir must not be empty")
        }
        self.configuration = configuration
        identityDirectory = URL(
            fileURLWithPath: configuration.identityDir, isDirectory: true)
        try FileManager.default.createDirectory(
            at: identityDirectory, withIntermediateDirectories: true)

        let identityURL = identityDirectory.appendingPathComponent("device-identity.raw")
        if let data = try? Data(contentsOf: identityURL) {
            identity = try DeviceIdentity(importingRaw: data)
        } else {
            let created = DeviceIdentity()
            try created.exportRaw().write(to: identityURL, options: .atomic)
            identity = created
        }

        let deviceIDURL = identityDirectory.appendingPathComponent("device-id.txt")
        if let configured = configuration.deviceID, !configured.isEmpty {
            deviceID = configured
            try Data(configured.utf8).write(to: deviceIDURL, options: .atomic)
        } else if let stored = try? String(
            contentsOf: deviceIDURL, encoding: .utf8
        ).trimmingCharacters(in: .whitespacesAndNewlines), !stored.isEmpty {
            deviceID = stored
        } else {
            let created = UUID().uuidString.lowercased()
            deviceID = created
            try Data(created.utf8).write(to: deviceIDURL, options: .atomic)
        }

        var loadedPins: [String: Data] = [:]
        let pinsURL = identityDirectory.appendingPathComponent("host-key-pins.json")
        if let data = try? Data(contentsOf: pinsURL),
           let stored = try? ProtocolJSON.decode([String: String].self, from: data) {
            for (id, encoded) in stored {
                if let key = Data(base64Encoded: encoded), key.count == 32,
                   DeviceIdentity.hostID(ofStaticKey: key) == id {
                    loadedPins[id] = key
                }
            }
        }
        for (id, encoded) in configuration.hostKeyPins {
            guard let key = Data(base64Encoded: encoded), key.count == 32 else {
                throw NPClientError.invalid("hostKeyPins[\(id)] is not a 32-byte base64 key")
            }
            guard DeviceIdentity.hostID(ofStaticKey: key) == id else {
                throw NPClientError.invalid("hostKeyPins[\(id)] does not match its hostID")
            }
            loadedPins[id] = key
        }
        pins = loadedPins
        try persistPins()
    }

    func setCallbacks(
        event: NPEventCallback?, state: NPStateCallback?, context: UnsafeMutableRawPointer?
    ) {
        // Retiring a context has the same lifetime rule as destroying the handle: clear it before
        // draining so producers either queued before the barrier or observe nil and drop.
        lock.withLock {
            eventCallback = nil
            stateCallback = nil
            callbackContext = nil
        }
        npCallbackExecutor.cancelAndWait(owner: callbackOwner)
        lock.withLock {
            guard !isClosed else { return }
            eventCallback = event
            stateCallback = state
            callbackContext = context
        }
    }

    func connectLocal(_ rendezvous: NPLocalRendezvous) -> Int32 {
        guard (1...65_535).contains(rendezvous.port) else {
            emitFailure("rendezvous port is out of range")
            return npInvalidArgument
        }
        return begin(NPConnectionSpec(
            endpoint: .tcp(host: rendezvous.host, port: rendezvous.port),
            transport: .lan, hostStaticKey: Data(),
            mode: .pair(secret: rendezvous.secret)))
    }

    func connectRemote(_ endpoint: NPRemoteEndpoint) -> Int32 {
        do {
            return begin(try connectionSpec(endpoint))
        } catch let error as NPClientError {
            emitFailure(error.description)
            if case .unsupported = error { return npUnsupported }
            return npInvalidArgument
        } catch {
            emitFailure("\(error)")
            return npDecodeError
        }
    }

    func pair(_ pairingCode: String) -> Int32 {
        do {
            let payload = try PairingPayload(qrString: pairingCode)
            guard payload.protocolVersion == SyncProtocol.version else {
                throw NPClientError.invalid(
                    "pairing code protocol \(payload.protocolVersion) is unsupported")
            }
            let transport = payload.transportHint
            guard let transport else {
                throw NPClientError.unsupported("pairing code names an unknown transport")
            }
            let endpoint: NPTransportEndpoint
            switch transport {
            case .lan:
                guard let host = payload.lanHost, let port = payload.lanPort else {
                    throw NPClientError.invalid("pairing code has no LAN endpoint")
                }
                endpoint = .tcp(host: host, port: Int(port))
            case .tailnet:
                guard let host = payload.tailnetHost, let port = payload.tailnetPort else {
                    throw NPClientError.invalid("pairing code has no tailnet endpoint")
                }
                endpoint = .tcp(host: host, port: Int(port))
            case .relay:
                guard let token = payload.relayMembershipToken else {
                    throw NPClientError.invalid("pairing code has no relay endpoint")
                }
                endpoint = .relay(
                    base: RelayAPI.baseURL(payload.relayURL), membershipToken: token)
            case .direct:
                throw NPClientError.unsupported(
                    "direct is an upgrade path, not a pairing transport")
            }
            guard payload.hostStaticKey.count == 32, payload.pairingSecret.count == 32 else {
                throw NPClientError.invalid("pairing code contains invalid key material")
            }
            return begin(NPConnectionSpec(
                endpoint: endpoint, transport: transport,
                hostStaticKey: payload.hostStaticKey,
                mode: .pair(secret: payload.pairingSecret)))
        } catch let error as NPClientError {
            emitFailure(error.description)
            if case .unsupported = error { return npUnsupported }
            return npInvalidArgument
        } catch {
            emitFailure("invalid pairing code: \(error)")
            return npDecodeError
        }
    }

    func send(_ message: ClientMsg) -> Int32 {
        let continuation = lock.withLock {
            isClosed || !acceptingIntents ? nil : intentContinuation
        }
        guard let continuation else { return npInvalidState }
        continuation.yield(message)
        return npOK
    }

    func close() {
        let resources = lock.withLock {
            guard !isClosed else {
                return (Task<Void, Never>?.none, (any FrameChannel)?.none,
                        AsyncStream<ClientMsg>.Continuation?.none)
            }
            isClosed = true
            generation += 1
            let result = (connectionTask, activeChannel, intentContinuation)
            connectionTask = nil
            activeChannel = nil
            activeClient = nil
            intentContinuation = nil
            acceptingIntents = false
            eventCallback = nil
            stateCallback = nil
            callbackContext = nil
            return result
        }
        resources.2?.finish()
        resources.0?.cancel()
        resources.1?.close()
        npCallbackExecutor.cancelAndWait(owner: callbackOwner)
    }

    private func connectionSpec(_ descriptor: NPRemoteEndpoint) throws -> NPConnectionSpec {
        let transport = descriptor.transport.flatMap(SyncTransportHint.init(rawValue:)) ?? .lan
        let key: Data
        if let encoded = descriptor.hostStaticKey,
           let decoded = Data(base64Encoded: encoded), decoded.count == 32 {
            if let hostID = descriptor.hostID,
               DeviceIdentity.hostID(ofStaticKey: decoded) != hostID {
                throw NPClientError.invalid("hostStaticKey does not match endpoint hostID")
            }
            key = decoded
        } else if let hostID = descriptor.hostID,
                  let pinned = lock.withLock({ pins[hostID] }) {
            key = pinned
        } else {
            throw NPClientError.invalid(
                "remote endpoint needs hostStaticKey or a hostID present in hostKeyPins")
        }

        let mode: SyncClient.Mode
        if let encoded = descriptor.psk {
            guard let secret = Data(base64Encoded: encoded), secret.count == 32 else {
                throw NPClientError.invalid("endpoint psk must be 32 base64-encoded bytes")
            }
            mode = .pair(secret: secret)
        } else {
            mode = .reconnect
        }

        let endpoint: NPTransportEndpoint
        switch transport {
        case .lan, .tailnet:
            guard let host = descriptor.host, !host.isEmpty,
                  let port = descriptor.port, (1...65_535).contains(port) else {
                throw NPClientError.invalid("TCP endpoint needs host and port")
            }
            endpoint = .tcp(host: host, port: port)
        case .relay:
            guard let token = descriptor.membershipToken, !token.isEmpty else {
                throw NPClientError.invalid(
                    "relay endpoint needs membershipToken")
            }
            endpoint = .relay(
                base: RelayAPI.baseURL(descriptor.relayURL), membershipToken: token)
        case .direct:
            throw NPClientError.unsupported(
                "direct is negotiated after relay connection, not dialed as an endpoint")
        }
        return NPConnectionSpec(
            endpoint: endpoint, transport: transport, hostStaticKey: key, mode: mode)
    }

    private func begin(_ spec: NPConnectionSpec) -> Int32 {
        let (intents, continuation) =
            AsyncStream<ClientMsg>.makeStream(bufferingPolicy: .unbounded)
        return lock.withLock {
            guard !isClosed, connectionTask == nil else { return npInvalidState }
            generation += 1
            let currentGeneration = generation
            acceptingIntents = false
            intentContinuation = continuation
            connectionTask = Task { [weak self] in
                await self?.run(spec, intents: intents, generation: currentGeneration)
            }
            return npOK
        }
    }

    private func run(
        _ spec: NPConnectionSpec, intents: AsyncStream<ClientMsg>, generation: Int
    ) async {
        emitState(.object(["state": .string("connecting")]))
        do {
            let channel: any FrameChannel
            switch spec.endpoint {
            case .tcp(let host, let port):
                #if canImport(NIOCore) && canImport(NIOPosix)
                channel = try await NIOClientFrameChannel.connect(
                    host: host, port: port, transport: spec.transport)
                #else
                throw NPClientError.unsupported("NIO TCP transport is not built on this platform")
                #endif
            case .relay(let base, let token):
                channel = try await RelayFrameChannel.dial(
                    base: base, membershipToken: token)
            }

            guard install(channel: channel, generation: generation) else {
                channel.close()
                return
            }
            emitState(.object([
                "state": .string("transport"),
                "transport": .string(spec.transport.rawValue),
                "peer": .string(channel.peerDescription),
            ]))

            let client = SyncClient(
                channel: channel, identity: identity, hostStaticKey: spec.hostStaticKey,
                mode: spec.mode, deviceID: deviceID,
                deviceLabel: configuration.deviceLabel,
                scopeClaim: configuration.scopeClaim, releaseChannel: configuration.channel,
                deviceKind: "windows")
            guard install(client: client, generation: generation) else {
                channel.close()
                return
            }

            let intentTask = Task {
                for await message in intents {
                    guard !Task.isCancelled else { break }
                    await client.send(message)
                }
            }
            let events = await client.start()
            for await event in events {
                guard generationIsCurrent(generation) else { break }
                await consume(event, client: client)
            }
            intentTask.cancel()
        } catch is CancellationError {
            // Explicit close/reconnect owns the terminal state notification.
        } catch {
            emitFailure("\(error)")
            emitState(.object(["state": .string("closed")]))
        }
        finish(generation: generation)
    }

    private func consume(_ event: SyncClient.Event, client: SyncClient) async {
        switch event {
        case .connecting:
            // Already emitted before the potentially-slow transport dial.
            break
        case .ready(let welcome):
            let hostKey = await client.hostKey()
            if let hostKey { savePin(hostKey) }
            lock.withLock { acceptingIntents = true }
            var state: [String: JSONValue] = [
                "state": .string("ready"),
                "welcome": Self.json(welcome),
            ]
            if let hostKey {
                state["hostKey"] = .string(hostKey.base64EncodedString())
                state["hostID"] = .string(DeviceIdentity.hostID(ofStaticKey: hostKey))
            }
            emitState(.object(state))
        case .failed(let message):
            lock.withLock { acceptingIntents = false }
            emitFailure(message)
        case .closed:
            lock.withLock { acceptingIntents = false }
            emitState(.object(["state": .string("closed")]))
        default:
            guard let hostMessage = Self.hostMessage(for: event),
                  let json = try? ProtocolJSON.encodeToString(hostMessage) else { return }
            emitEvent(json)
        }
    }

    private func install(channel: any FrameChannel, generation: Int) -> Bool {
        lock.withLock {
            guard !isClosed, self.generation == generation else { return false }
            activeChannel = channel
            return true
        }
    }

    private func install(client: SyncClient, generation: Int) -> Bool {
        lock.withLock {
            guard !isClosed, self.generation == generation else { return false }
            activeClient = client
            return true
        }
    }

    private func generationIsCurrent(_ generation: Int) -> Bool {
        lock.withLock { !isClosed && self.generation == generation }
    }

    private func finish(generation: Int) {
        let continuation = lock.withLock {
            guard self.generation == generation else {
                return AsyncStream<ClientMsg>.Continuation?.none
            }
            connectionTask = nil
            activeChannel = nil
            activeClient = nil
            acceptingIntents = false
            let result = intentContinuation
            intentContinuation = nil
            return result
        }
        continuation?.finish()
    }

    private func savePin(_ key: Data) {
        let hostID = DeviceIdentity.hostID(ofStaticKey: key)
        lock.withLock { pins[hostID] = key }
        try? persistPins()
    }

    private func persistPins() throws {
        let snapshot = lock.withLock {
            pins.mapValues { $0.base64EncodedString() }
        }
        let data = try ProtocolJSON.encode(snapshot)
        try data.write(
            to: identityDirectory.appendingPathComponent("host-key-pins.json"),
            options: .atomic)
    }

    private func emitFailure(_ message: String) {
        emitState(.object([
            "state": .string("failed"),
            "message": .string(message),
        ]))
    }

    private func emitEvent(_ json: String) {
        lock.withLock {
            guard let function = eventCallback else { return }
            let invocation = NPEventInvocation(
                function: function, context: callbackContext, json: json)
            npCallbackExecutor.submit(owner: callbackOwner) { invocation.invoke() }
        }
    }

    private func emitState(_ value: JSONValue) {
        let json = value.canonicalString()
        lock.withLock {
            guard let function = stateCallback else { return }
            let invocation = NPStateInvocation(
                function: function, context: callbackContext, json: json)
            npCallbackExecutor.submit(owner: callbackOwner) { invocation.invoke() }
        }
    }

    private static func json<T: Encodable>(_ value: T) -> JSONValue {
        guard let data = try? ProtocolJSON.encode(value),
              let json = try? JSONValue(parsing: data) else { return .null }
        return json
    }

    private static func hostMessage(for event: SyncClient.Event) -> HostMsg? {
        switch event {
        case .connecting, .ready, .failed, .closed: nil
        case .sessionList(let value): .sessionList(value)
        case .snapshot(let value): .snapshot(value)
        case .events(let value): .events(value)
        case .approvalRequested(let value): .approvalRequested(value)
        case .approvalResolved(let value): .approvalResolved(value)
        case .sessionUpdated(let value): .sessionUpdated(value)
        case .dashboard(let value): .dashboard(value)
        case .sessionDiff(let value): .sessionDiff(value)
        case .peerList(let value): .peerList(value)
        case .meshRoster(let value): .meshRoster(value)
        case .transferAccept(let value): .transferAccept(value)
        case .transferReject(let value): .transferReject(value)
        case .transferReady(let value): .transferReady(value)
        case .transferCommitted(let value): .transferCommitted(value)
        case .transferChunkAck(let value): .transferChunkAck(value)
        case .transcriptChunk(let value): .transcriptChunk(value)
        case .transcriptFetchComplete(let value): .transcriptFetchComplete(value)
        case .transcriptUnavailable(let value): .transcriptUnavailable(value)
        case .carbonHeads(let value): .carbonHeads(value)
        case .carbonHeadAdvanced(let value): .carbonHeadAdvanced(value)
        case .carbonManifestData(let value): .carbonManifestData(value)
        case .carbonManifestUnavailable(let value): .carbonManifestUnavailable(value)
        case .carbonShardData(let value): .carbonShardData(value)
        case .carbonShardUnavailable(let value): .carbonShardUnavailable(value)
        case .carbonLockDecision(let value): .carbonLockDecision(value)
        case .carbonLockLedgerAdvanced(let value): .carbonLockLedgerAdvanced(value)
        case .toolSummaries(let value): .toolSummaries(value)
        case .transcriptReverted(let value): .transcriptReverted(value)
        case .relayMembership(let value): .relayMembership(value)
        case .pairingCode(let value): .pairingCode(value)
        case .macPairRequested(let value): .macPairRequested(value)
        case .macPairResolved(let id, let approved): .macPairResolved(id, approved)
        case .intelligenceRequest(let value): .intelligenceRequest(value)
        case .credentialNeeded(let value): .credentialNeeded(value)
        case .credentialUpdate(let value): .credentialUpdate(value)
        case .credentialRevoked(let value): .credentialRevoked(value)
        case .projectCreated(let value): .projectCreated(value)
        case .runnerPoolCredential(let value): .runnerPoolCredential(value)
        case .chatStarted(let value): .chatStarted(value)
        case .casts(let value): .casts(value)
        case .castCatchUp(let value): .castCatchUp(value)
        case .settings(let value): .settings(value)
        case .agentLoginChallenge(let value): .agentLoginChallenge(value)
        case .agentLoginResult(let value): .agentLoginResult(value)
        case .agentAuthStatus(let value): .agentAuthStatus(value)
        case .directAnswer(let value): .directAnswer(value)
        case .directGo: .directGo
        case .directDecline(let value): .directDecline(value)
        case .wireError(let value): .error(value)
        }
    }
}

// MARK: - Projection handle

private final class NPProjectionHandle {
    private let lock = NSLock()
    private let projection = TranscriptProjectionABI()

    func apply(_ message: HostMsg) throws -> String {
        try lock.withLock { try projection.apply(message) }
    }
}

// MARK: - Exported ABI

@_cdecl("np_client_create")
public func np_client_create(
    _ configJSON: UnsafePointer<CChar>?
) -> UnsafeMutableRawPointer? {
    guard let configJSON else { return nil }
    do {
        let configuration = try ProtocolJSON.decode(
            NPClientConfiguration.self, fromString: String(cString: configJSON))
        let handle = try NPClientHandle(configuration: configuration)
        return Unmanaged.passRetained(handle).toOpaque()
    } catch {
        return nil
    }
}

@_cdecl("np_client_set_callbacks")
public func np_client_set_callbacks(
    _ pointer: UnsafeMutableRawPointer?,
    _ event: NPEventCallback?,
    _ state: NPStateCallback?,
    _ context: UnsafeMutableRawPointer?
) {
    guard let pointer else { return }
    Unmanaged<NPClientHandle>.fromOpaque(pointer).takeUnretainedValue()
        .setCallbacks(event: event, state: state, context: context)
}

@_cdecl("np_client_connect_local")
public func np_client_connect_local(
    _ pointer: UnsafeMutableRawPointer?, _ rendezvousJSON: UnsafePointer<CChar>?
) -> Int32 {
    guard let pointer, let rendezvousJSON else { return npInvalidArgument }
    do {
        let rendezvous = try ProtocolJSON.decode(
            NPLocalRendezvous.self, fromString: String(cString: rendezvousJSON))
        return Unmanaged<NPClientHandle>.fromOpaque(pointer).takeUnretainedValue()
            .connectLocal(rendezvous)
    } catch {
        return npDecodeError
    }
}

@_cdecl("np_client_connect_remote")
public func np_client_connect_remote(
    _ pointer: UnsafeMutableRawPointer?, _ endpointJSON: UnsafePointer<CChar>?
) -> Int32 {
    guard let pointer, let endpointJSON else { return npInvalidArgument }
    do {
        let endpoint = try ProtocolJSON.decode(
            NPRemoteEndpoint.self, fromString: String(cString: endpointJSON))
        return Unmanaged<NPClientHandle>.fromOpaque(pointer).takeUnretainedValue()
            .connectRemote(endpoint)
    } catch {
        return npDecodeError
    }
}

@_cdecl("np_client_pair")
public func np_client_pair(
    _ pointer: UnsafeMutableRawPointer?, _ pairingCode: UnsafePointer<CChar>?
) -> Int32 {
    guard let pointer, let pairingCode else { return npInvalidArgument }
    return Unmanaged<NPClientHandle>.fromOpaque(pointer).takeUnretainedValue()
        .pair(String(cString: pairingCode))
}

@_cdecl("np_client_send_intent")
public func np_client_send_intent(
    _ pointer: UnsafeMutableRawPointer?, _ clientMessageJSON: UnsafePointer<CChar>?
) -> Int32 {
    guard let pointer, let clientMessageJSON else { return npInvalidArgument }
    do {
        let message = try ProtocolJSON.decode(
            ClientMsg.self, fromString: String(cString: clientMessageJSON))
        return Unmanaged<NPClientHandle>.fromOpaque(pointer).takeUnretainedValue().send(message)
    } catch {
        return npDecodeError
    }
}

@_cdecl("np_client_close")
public func np_client_close(_ pointer: UnsafeMutableRawPointer?) {
    guard let pointer else { return }
    Unmanaged<NPClientHandle>.fromOpaque(pointer).takeRetainedValue().close()
}

@_cdecl("np_projection_create")
public func np_projection_create() -> UnsafeMutableRawPointer {
    Unmanaged.passRetained(NPProjectionHandle()).toOpaque()
}

@_cdecl("np_projection_apply")
public func np_projection_apply(
    _ pointer: UnsafeMutableRawPointer?, _ hostMessageJSON: UnsafePointer<CChar>?
) -> UnsafeMutablePointer<CChar>? {
    guard let pointer, let hostMessageJSON else { return nil }
    do {
        let message = try ProtocolJSON.decode(
            HostMsg.self, fromString: String(cString: hostMessageJSON))
        let json = try Unmanaged<NPProjectionHandle>.fromOpaque(pointer)
            .takeUnretainedValue().apply(message)
        return npString(json)
    } catch {
        return npString(JSONValue.object([
            "version": .number(1),
            "splices": .array([]),
            "error": .string("\(error)"),
        ]).canonicalString())
    }
}

@_cdecl("np_projection_free")
public func np_projection_free(_ pointer: UnsafeMutableRawPointer?) {
    guard let pointer else { return }
    Unmanaged<NPProjectionHandle>.fromOpaque(pointer).release()
}

/// Free a string previously returned by any `np_*` entry point. Accepts NULL.
@_cdecl("np_free")
public func np_free(_ pointer: UnsafeMutablePointer<CChar>?) {
    guard let pointer else { return }
    pointer.deallocate()
}

private func npString(_ string: String) -> UnsafeMutablePointer<CChar> {
    let utf8 = Array(string.utf8CString)
    let out = UnsafeMutablePointer<CChar>.allocate(capacity: utf8.count)
    out.initialize(from: utf8, count: utf8.count)
    return out
}
