import Foundation
import Testing
import NucleicProtocol
import NucleicProtocolC
import NIOCore
import NIOPosix

@Suite(.serialized) struct NucleicProtocolCTests {
    @Test func clientHandleOwnsIdentityAndRejectsIntentBeforeConnect() throws {
        let directory = FileManager.default.temporaryDirectory
            .appendingPathComponent("np-c-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: directory) }

        let config = JSONValue.object([
            "identityDir": .string(directory.path),
            "deviceLabel": .string("ABI test"),
        ]).canonicalString()
        let handle = config.withCString { np_client_create($0) }
        let live = try #require(handle)
        defer { np_client_close(live) }

        let ping = try ProtocolJSON.encodeToString(ClientMsg.ping)
        #expect(ping.withCString { np_client_send_intent(live, $0) } == -2)
        #expect(FileManager.default.fileExists(
            atPath: directory.appendingPathComponent("device-identity.raw").path))
        #expect(FileManager.default.fileExists(
            atPath: directory.appendingPathComponent("device-id.txt").path))
        #expect(FileManager.default.fileExists(
            atPath: directory.appendingPathComponent("host-key-pins.json").path))
    }

    @Test func projectionReturnsMinimalSplices() throws {
        let projection = np_projection_create()
        defer { np_projection_free(projection) }

        let root = URL(fileURLWithPath: #filePath)
            .deletingLastPathComponent()
            .deletingLastPathComponent()
            .deletingLastPathComponent()
        let snapshot = try String(
            contentsOf: root.appendingPathComponent(
                "fixtures/protocol-abi/v1/host/snapshot.json"),
            encoding: .utf8)

        let firstPointer = snapshot.withCString { np_projection_apply(projection, $0) }
        let first = try #require(firstPointer)
        let firstJSON = String(cString: first)
        np_free(first)
        let firstValue = try JSONValue(parsing: firstJSON)
        #expect(firstValue["version"]?.intValue == 1)
        #expect(firstValue["sessionID"]?.stringValue != nil)
        #expect(firstValue["splices"]?.arrayValue?.count == 1)
        #expect(firstValue["splices"]?[0]?["deleteCount"]?.intValue == 0)
        #expect(firstValue["splices"]?[0]?["items"]?.arrayValue?.isEmpty == false)

        // Re-applying the same snapshot is a no-op, not a replace-all.
        let secondPointer = snapshot.withCString { np_projection_apply(projection, $0) }
        let second = try #require(secondPointer)
        let secondJSON = String(cString: second)
        np_free(second)
        #expect(try JSONValue(parsing: secondJSON)["splices"]?.arrayValue?.isEmpty == true)

        let decoded = try ProtocolJSON.decode(HostMsg.self, fromString: snapshot)
        guard case .snapshot(let snapshotValue) = decoded else {
            Issue.record("snapshot fixture did not decode as HostMsg.snapshot")
            return
        }
        let revert = try ProtocolJSON.encodeToString(HostMsg.transcriptReverted(
            TranscriptReverted(sessionID: snapshotValue.summary.sessionID, throughSeq: 0)))
        let revertPointer = revert.withCString { np_projection_apply(projection, $0) }
        let reverted = try #require(revertPointer)
        let revertJSON = String(cString: reverted)
        np_free(reverted)
        let revertSplice = try #require(
            JSONValue(parsing: revertJSON)["splices"]?[0]?.objectValue)
        #expect((revertSplice["deleteCount"]?.intValue ?? 0) > 0)
        #expect(revertSplice["items"]?.arrayValue?.isEmpty == true)

        let pong = try ProtocolJSON.encodeToString(HostMsg.pong)
        let pongPointer = pong.withCString { np_projection_apply(projection, $0) }
        let pongResult = try #require(pongPointer)
        let pongJSON = String(cString: pongResult)
        np_free(pongResult)
        #expect(try JSONValue(parsing: pongJSON)["splices"]?.arrayValue?.isEmpty == true)
    }

    @Test func callbacksUseOneDedicatedThread() async throws {
        let parent = FileManager.default.temporaryDirectory
            .appendingPathComponent("np-c-callback-\(UUID().uuidString)", isDirectory: true)
        defer { try? FileManager.default.removeItem(at: parent) }
        let handles = try (1...2).map { index in
            let directory = parent.appendingPathComponent("host-\(index)", isDirectory: true)
            let config = JSONValue.object([
                "identityDir": .string(directory.path),
            ]).canonicalString()
            return try #require(config.withCString { np_client_create($0) })
        }

        let recorder = StateRecorder()
        let callerThreadID = currentThreadID()
        let context = Unmanaged.passRetained(recorder).toOpaque()
        for handle in handles {
            np_client_set_callbacks(handle, nil, recordState, context)
        }

        let closedPort = try await reserveThenCloseLoopbackPort()
        let rendezvous = JSONValue.object([
            "host": .string("127.0.0.1"),
            // Intentionally closed: the accepted async dial must report
            // connecting → failed → closed through the state callback.
            "port": .number(Double(closedPort)),
            "localPSK": .string(Data(repeating: 7, count: 32).base64EncodedString()),
        ]).canonicalString()
        for handle in handles {
            #expect(rendezvous.withCString { np_client_connect_local(handle, $0) } == 0)
            let ping = try ProtocolJSON.encodeToString(ClientMsg.ping)
            #expect(ping.withCString { np_client_send_intent(handle, $0) } == -2)
        }

        let records = recorder.waitForRecords(count: 6, timeout: 5)
        #expect(records.map(\.state).contains("connecting"))
        #expect(records.map(\.state).contains("failed"))
        #expect(records.map(\.state).contains("closed"))
        #expect(Set(records.map(\.threadID)).count == 1)
        #expect(records.first?.threadID != callerThreadID)

        for handle in handles {
            np_client_close(handle)
        }
        // close is the context-lifetime barrier; releasing immediately afterwards must be safe.
        Unmanaged<StateRecorder>.fromOpaque(context).release()
    }

    @Test func nioTCPChannelFramesBothDirections() async throws {
        let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
        let server = try await ServerBootstrap(group: group)
            .childChannelInitializer { channel in
                channel.pipeline.addHandler(EchoBytesHandler())
            }
            .bind(host: "127.0.0.1", port: 0)
            .get()
        let port = try #require(server.localAddress?.port)
        let client = try await NIOClientFrameChannel.connect(
            host: "127.0.0.1", port: port)

        let payload = Data([0, 1, 2, 3, 255, 42])
        client.send(payload)
        let received = try await firstFrame(from: client, timeout: .seconds(3))
        #expect(received == payload)

        client.close()
        try await server.close().get()
        try await group.shutdownGracefully()
    }
}

private final class StateRecorder: @unchecked Sendable {
    struct Record {
        let state: String
        let threadID: String
    }

    private let condition = NSCondition()
    private var records: [Record] = []

    func append(json: String) {
        let value = try? JSONValue(parsing: json)
        let state = value?["state"]?.stringValue
        condition.lock()
        records.append(Record(
            state: state ?? "<missing>",
            threadID: String(describing: ObjectIdentifier(Thread.current))))
        condition.broadcast()
        condition.unlock()
    }

    func waitForRecords(count: Int, timeout: TimeInterval) -> [Record] {
        let deadline = Date().addingTimeInterval(timeout)
        condition.lock()
        while records.count < count, condition.wait(until: deadline) {}
        let result = records
        condition.unlock()
        return result
    }
}

private func recordState(
    _ context: UnsafeMutableRawPointer?, _ json: UnsafePointer<CChar>?
) {
    guard let context, let json else { return }
    Unmanaged<StateRecorder>.fromOpaque(context).takeUnretainedValue()
        .append(json: String(cString: json))
}

private func currentThreadID() -> String {
    String(describing: ObjectIdentifier(Thread.current))
}

private final class EchoBytesHandler: ChannelInboundHandler, @unchecked Sendable {
    typealias InboundIn = ByteBuffer
    typealias OutboundOut = ByteBuffer

    func channelRead(context: ChannelHandlerContext, data: NIOAny) {
        context.writeAndFlush(wrapOutboundOut(unwrapInboundIn(data)), promise: nil)
    }
}

private enum TestTimeout: Error { case elapsed }

private func reserveThenCloseLoopbackPort() async throws -> Int {
    let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
    let server = try await ServerBootstrap(group: group)
        .childChannelInitializer { channel in channel.eventLoop.makeSucceededVoidFuture() }
        .bind(host: "127.0.0.1", port: 0)
        .get()
    let port = try #require(server.localAddress?.port)
    try await server.close().get()
    try await group.shutdownGracefully()
    return port
}

private func firstFrame(
    from channel: any FrameChannel, timeout: Duration
) async throws -> Data {
    try await withThrowingTaskGroup(of: Data.self) { tasks in
        tasks.addTask {
            for await frame in channel.frames() { return frame }
            throw TestTimeout.elapsed
        }
        tasks.addTask {
            try await Task.sleep(for: timeout)
            throw TestTimeout.elapsed
        }
        let first = try await tasks.next()!
        tasks.cancelAll()
        return first
    }
}
