// VoiceCatClientSmokeTests — exercises the full connect → TOFU → auth → channels →
// moderation flow purely through the Swift wrapper layer (VoiceCatClient), against a real
// `voicecat-server` (the same binary the C++ ctest suite uses, built by `cmake --preset dev`).
// This is the Swift analog of clients/windows/VoiceCat.Interop.Tests/VoiceCatClientSmokeTests.cs.
//
// Why this exists (same rationale as the C# tests): the C++ ctest suite proves the protocol
// works at the C++ level, but Swift-specific interop bugs — @convention(c) callback lifetime,
// Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct
// field layout — can only be caught by exercising the exact Swift→C boundary. These tests
// catch the same class of bugs the C# P/Invoke tests catch, for Swift.
//
// Prerequisites: `cmake --preset dev && cmake --build --preset dev` (builds voicecat-server
// and voicecat-admin into build/dev/bin/), AND `scripts/build-xcframework.sh` (builds the
// VoiceCatCore.xcframework that the Swift Package links).

import XCTest
import Foundation
@testable import VoiceCatCore

/// Manages a real `voicecat-server` process for the test suite's lifetime. Starts the server
/// on an ephemeral port (--port 0), parses the bound port from stdout, and provisions a known
/// admin account via `voicecat-admin`. Killed + cleaned up in deinit.
private final class ServerHarness {
    let port: UInt16
    private let process: Process
    let tempDir: String

    init() throws {
        let repoRoot = Self.findRepoRoot()
        let serverURL = URL(fileURLWithPath: repoRoot)
            .appendingPathComponent("build/dev/bin/voicecat-server")

        guard FileManager.default.isExecutableFile(atPath: serverURL.path) else {
            throw NSError(domain: "VoiceCatTest", code: 1, userInfo: [
                NSLocalizedDescriptionKey: "voicecat-server not found at \(serverURL.path) — "
                    + "build the dev preset first: cmake --preset dev && cmake --build --preset dev",
            ])
        }

        let tempDir = NSTemporaryDirectory() + "vc_swift_smoke_" + UUID().uuidString
        try FileManager.default.createDirectory(atPath: tempDir, withIntermediateDirectories: true)
        self.tempDir = tempDir

        let p = Process()
        p.executableURL = serverURL
        p.arguments = ["--port", "0", "--data-dir", tempDir, "--name", "SwiftSmokeTest"]

        // Pipe stdout to read the bound port; stderr to /dev/null.
        let stdoutPipe = Pipe()
        p.standardOutput = stdoutPipe
        p.standardError = FileHandle(forWritingAtPath: "/dev/null")
        try p.run()
        self.process = p

        // Parse "[voicecat-server] ... — TCP :<port>  UDP :<port>" from stdout. The server
        // prints several lines before the port line (version, first-run admin box, etc.), so
        // we keep reading until we find a line matching "TCP :<port>". Read with a 10s timeout
        // so a crashed/hung server can't hang the test forever.
        guard let port = Self.readPortWithTimeout(stdoutPipe, timeout: 10) else {
            p.terminate()
            throw NSError(domain: "VoiceCatTest", code: 2, userInfo: [
                NSLocalizedDescriptionKey: "voicecat-server did not report a bound TCP port within 10s",
            ])
        }
        self.port = port

        // Provision a known admin account for moderation/admin tests (M5).
        let adminURL = URL(fileURLWithPath: repoRoot)
            .appendingPathComponent("build/dev/bin/voicecat-admin")
        guard FileManager.default.isExecutableFile(atPath: adminURL.path) else {
            throw NSError(domain: "VoiceCatTest", code: 3, userInfo: [
                NSLocalizedDescriptionKey: "voicecat-admin not found at \(adminURL.path)",
            ])
        }
        let adminProc = Process()
        adminProc.executableURL = adminURL
        adminProc.arguments = ["--data-dir", tempDir, "account", "add", "admin2",
                               "--admin", "--password", "testpassword123"]
        adminProc.standardOutput = FileHandle(forWritingAtPath: "/dev/null")
        adminProc.standardError = FileHandle(forWritingAtPath: "/dev/null")
        try adminProc.run()
        adminProc.waitUntilExit()
        guard adminProc.terminationStatus == 0 else {
            throw NSError(domain: "VoiceCatTest", code: 4, userInfo: [
                NSLocalizedDescriptionKey: "voicecat-admin failed to provision admin2 (exit \(adminProc.terminationStatus))",
            ])
        }
    }

    deinit {
        if process.isRunning { process.terminate() }
        try? FileManager.default.removeItem(atPath: tempDir)
    }

    private static func findRepoRoot() -> String {
        var url = URL(fileURLWithPath: #file)
        while url.path != "/" && !FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) {
            url = url.deletingLastPathComponent()
        }
        guard FileManager.default.fileExists(atPath: url.appendingPathComponent("CMakePresets.json").path) else {
            fatalError("Could not find repo root (CMakePresets.json) above \(#file)")
        }
        return url.path
    }

    /// Read from the server's stdout until a line matching "TCP :<port>" is found, or the
    /// timeout expires. The server prints several lines (version banner, first-run admin box,
    /// etc.) before the port line — see server/src/server.cpp.
    private static func readPortWithTimeout(_ pipe: Pipe, timeout: TimeInterval) -> UInt16? {
        let handle = pipe.fileHandleForReading
        let deadline = Date().addingTimeInterval(timeout)
        var buffer = Data()
        while Date() < deadline {
            let data = handle.availableData
            if !data.isEmpty {
                buffer.append(data)
                // Check each complete line in the buffer for "TCP :<port>".
                while let newlineIdx = buffer.firstIndex(of: 0x0A) {
                    let lineData = buffer.prefix(newlineIdx)
                    buffer = buffer.suffix(from: buffer.index(after: newlineIdx))
                    if let line = String(data: lineData, encoding: .utf8),
                       let port = parsePort(from: line) {
                        return port
                    }
                }
            }
            Thread.sleep(forTimeInterval: 0.05)
        }
        return nil
    }

    private static func parsePort(from line: String) -> UInt16? {
        // Match "TCP :<port>" — see server/src/server.cpp.
        guard let range = line.range(of: #"TCP :(\d+)"#, options: .regularExpression) else { return nil }
        let digits = line[range].split(separator: ":").last ?? ""
        return UInt16(digits.trimmingCharacters(in: .whitespaces))
    }
}

/// XCTest smoke tests against a real voicecat-server, through the Swift VoiceCatClient wrapper.
final class VoiceCatClientSmokeTests: XCTestCase {
    private static var harness: ServerHarness?

    override class func setUp() {
        do {
            harness = try ServerHarness()
        } catch {
            // Store the error so each test fails with a clear message rather than a crash.
            NSLog("ServerHarness setup failed: \(error.localizedDescription)")
            harness = nil
        }
    }

    override class func tearDown() {
        harness = nil
    }

    private var port: UInt16 {
        guard let p = Self.harness?.port else {
            XCTFail("ServerHarness not started — see setUp error in log")
            return 0
        }
        return p
    }

    private var tempDir: String {
        Self.harness?.tempDir ?? NSTemporaryDirectory()
    }

    /// Helper: wait until the predicate is satisfied, running the main runloop to process
    /// dispatched events. The Swift analog of the C# `PumpUntil` helper. Our events are
    /// delivered via DispatchQueue.main.async, which the main runloop processes during
    /// `RunLoop.current.run(until:)`.
    ///
    /// Uses RunLoop polling (not XCTestExpectation) so that the "assert something does NOT
    /// happen within N seconds" pattern works without generating spurious "Asynchronous wait
    /// failed" errors — `wait(for:timeout:)` logs an error when an expectation isn't
    /// fulfilled, which is wrong for negative checks.
    private func waitFor(timeout: TimeInterval = 5, _ predicate: @escaping () -> Bool) -> Bool {
        if predicate() { return true }
        let deadline = Date().addingTimeInterval(timeout)
        while Date() < deadline {
            // Run the main runloop for ~20ms — processes DispatchQueue.main.async blocks
            // (where our events/levels are drained) and timer sources.
            RunLoop.current.run(until: Date().addingTimeInterval(0.02))
            if predicate() { return true }
        }
        return predicate()
    }

    private func requireHarness() -> Bool {
        guard Self.harness != nil else {
            XCTFail("ServerHarness not started — see setUp error in log")
            return false
        }
        return true
    }

    // MARK: - Tests

    func testVersionStringIsNonEmpty() {
        XCTAssertFalse(VoiceCatClient.versionString.isEmpty)
    }

    func testResultStringRoundTrips() {
        XCTAssertFalse(VoiceCatClient.resultString(.ok).isEmpty)
        XCTAssertFalse(VoiceCatClient.resultString(.permissionDenied).isEmpty)
    }

    /// Full connect → TOFU → confirm → guest auth → list channels → permissions → guest
    /// ListAccounts rejected. Mirrors the C# `Connect_Tofu_Auth_ListChannels_RoundTrips`.
    func testConnectTofuAuthListChannelsRoundTrips() throws {
        guard requireHarness() else { return }

        var events: [VoiceCatEvent] = []
        let client = VoiceCatClient(config: VoiceCatConfig(
            clientName: "vc-swift-smoke",
            clientVersion: "0.1",
            logLevel: .off,
            tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins.txt")
        ))
        client.onEvent = { events.append($0) }

        XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
        XCTAssertEqual(client.authenticateGuest("SwiftSmoke"), .ok)

        // Wait for VC_EVENT_SERVER_IDENTITY.
        XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } },
                      "did not receive .serverIdentity")
        let identityEvent = try XCTUnwrap(events.first { $0.type == .serverIdentity })
        XCTAssertEqual(identityEvent.tofuStatus, .firstConnect)
        XCTAssertNotNil(identityEvent.text)
        XCTAssertEqual(identityEvent.text?.count, 64, "SHA-256 hex, no separators")

        // Auth must NOT complete before identity is confirmed (800ms, like the C# test).
        XCTAssertFalse(waitFor(timeout: 0.8) { events.contains { $0.type == .authResult } },
                       "auth completed before identity confirmation (should be held open)")

        XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)

        // Wait for VC_EVENT_AUTH_RESULT.
        XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } },
                      "did not receive .authResult after confirming identity")
        let authEvent = try XCTUnwrap(events.first { $0.type == .authResult })
        XCTAssertEqual(authEvent.result, .ok)

        // Wait for VC_EVENT_CHANNEL_LIST.
        XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } },
                      "did not receive .channelList")

        let channels = client.listChannels()
        XCTAssertTrue(channels.contains { $0.id == 1 && $0.name == "Lobby" },
                      "expected Lobby (channel 1) in \(channels.map { $0.name })")

        // M5: permissions getter round-trip.
        let perms = client.getPermissions()
        XCTAssertFalse(perms.isAdmin)
        XCTAssertFalse(perms.canKick)

        // M5: guest ListAccounts is rejected by the server with a GenericResult — proves the
        // moderation wrapper path works end-to-end through the Swift interop layer.
        events.removeAll()
        XCTAssertEqual(client.requestAccountList(), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult } },
                      "did not receive .genericResult for guest ListAccounts")
        let generic = try XCTUnwrap(events.first { $0.type == .genericResult })
        XCTAssertEqual(generic.result, .permissionDenied)

        client.disconnect()
    }

    /// Admin auth → channel CRUD → account CRUD. Mirrors C# `Admin_ChannelCrud_AccountCrud_RoundTrips`.
    func testAdminChannelCrudAccountCrudRoundTrips() throws {
        guard requireHarness() else { return }

        var events: [VoiceCatEvent] = []
        let client = VoiceCatClient(config: VoiceCatConfig(
            clientName: "vc-swift-admin",
            clientVersion: "0.1",
            logLevel: .off,
            tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_admin.txt")
        ))
        client.onEvent = { events.append($0) }

        XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
        XCTAssertEqual(client.authenticateUser("admin2", password: "testpassword123"), .ok)

        XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
        XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
        XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })

        let perms = client.getPermissions()
        XCTAssertTrue(perms.isAdmin || perms.canAdminAccounts)

        // Channel CRUD — create.
        let audioConfig = AudioConfig(stereo: true, bitrateBps: 64000, frameMs: 20,
                                      application: 1, fec: true, expectedPacketLoss: 5, complexity: 10)
        XCTAssertEqual(client.createChannel(ChannelEdit(
            id: 0, parentId: 0, name: "Swift Test Channel", topic: "Created by Swift smoke test",
            passwordProtected: false, password: nil, maxUsers: 42, sortOrder: 0, audio: audioConfig
        )), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
                      "CreateChannel did not succeed")

        var channels = client.listChannels()
        let created = try XCTUnwrap(channels.first { $0.name == "Swift Test Channel" })
        XCTAssertEqual(created.topic, "Created by Swift smoke test")
        XCTAssertFalse(created.passwordProtected)

        // Channel CRUD — edit.
        events.removeAll()
        XCTAssertEqual(client.editChannel(ChannelEdit(
            id: created.id, parentId: created.parentId, name: created.name,
            topic: "Updated topic", passwordProtected: false, password: nil,
            maxUsers: 100, sortOrder: 0, audio: audioConfig
        )), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
                      "EditChannel did not succeed")

        // Channel CRUD — delete.
        events.removeAll()
        XCTAssertEqual(client.deleteChannel(created.id), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
                      "DeleteChannel did not succeed")

        // Account CRUD — create.
        events.removeAll()
        XCTAssertEqual(client.createAccount("swift_smoke_user", password: "initialpw"), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
                      "CreateAccount did not succeed")

        // Account CRUD — list.
        events.removeAll()
        XCTAssertEqual(client.requestAccountList(), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .accountList } },
                      "did not receive .accountList")
        let accounts = client.listAccounts()
        XCTAssertTrue(accounts.contains { $0.username == "swift_smoke_user" })

        // Account CRUD — reset password.
        events.removeAll()
        XCTAssertEqual(client.resetPassword("swift_smoke_user", newPassword: "newpw123"), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
                      "ResetPassword did not succeed")

        // Account CRUD — delete.
        events.removeAll()
        XCTAssertEqual(client.deleteAccount("swift_smoke_user"), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .genericResult && $0.result == .ok } },
                      "DeleteAccount did not succeed")

        client.disconnect()
    }

    /// Screen-audio (SCREEN_AUDIO) stream start/stop through the Swift wrapper. The core's
    /// macOS CoreAudio path starts the StreamAnnounce; this exercises the full
    /// startStream → .streamStarted → stopStream → .streamStopped path through Swift interop.
    /// Mirrors C# `ScreenAudioStream_Starts_And_Stops`.
    func testScreenAudioStreamStartsAndStops() throws {
        guard requireHarness() else { return }

        var events: [VoiceCatEvent] = []
        let client = VoiceCatClient(config: VoiceCatConfig(
            clientName: "vc-swift-screen",
            clientVersion: "0.1",
            logLevel: .off,
            tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_screen.txt")
        ))
        client.onEvent = { events.append($0) }

        XCTAssertEqual(client.connect(host: "127.0.0.1", port: port), .ok)
        XCTAssertEqual(client.authenticateGuest("SwiftScreen"), .ok)

        XCTAssertTrue(waitFor { events.contains { $0.type == .serverIdentity } })
        XCTAssertEqual(client.confirmServerIdentity(accept: true), .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .authResult } })
        XCTAssertEqual(try XCTUnwrap(events.first { $0.type == .authResult }).result, .ok)
        XCTAssertTrue(waitFor { events.contains { $0.type == .channelList } })

        // Give the async UDP binding handshake a moment to land (mirrors vccli's 500ms sleep).
        Thread.sleep(forTimeInterval: 0.5)

        let (startResult, streamId) = client.startStream(
            StreamDescriptor(kind: .screenAudio, label: "Desktop audio")
        )
        XCTAssertEqual(startResult, .ok)
        XCTAssertNotEqual(streamId, 0, "streamId should be non-zero on success")

        // The core emits .streamStarted for the local client too.
        XCTAssertTrue(waitFor(timeout: 5) {
            events.contains { $0.type == .streamStarted && $0.streamId == streamId }
        }, "did not receive .streamStarted for screen-audio stream")

        XCTAssertEqual(client.stopStream(streamId), .ok)
        XCTAssertTrue(waitFor(timeout: 5) {
            events.contains { $0.type == .streamStopped && $0.streamId == streamId }
        }, "did not receive .streamStopped for screen-audio stream")

        client.disconnect()
    }

    /// Per-stream receive-side controls (gain/mute/NR) round-trip through Swift: two clients
    /// in a channel, one publishes a MIC stream, the other setRemoteStream's it then
    /// getRemoteStream's it back. Catches Swift-specific marshaling bugs (field order,
    /// bool-from-int, float precision) that the C++ ctest can't. Mirrors C#
    /// `PerStream_RecvControls_Round_Trip_Through_PInvoke`.
    func testPerStreamRecvControlsRoundTrip() throws {
        guard requireHarness() else { return }

        var eventsA: [VoiceCatEvent] = []
        var eventsB: [VoiceCatEvent] = []

        let a = VoiceCatClient(config: VoiceCatConfig(
            clientName: "vc-swift-mix-a", clientVersion: "0.1", logLevel: .off,
            tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_a.txt")
        ))
        let b = VoiceCatClient(config: VoiceCatConfig(
            clientName: "vc-swift-mix-b", clientVersion: "0.1", logLevel: .off,
            tofuStorePath: (tempDir as NSString).appendingPathComponent("tofu_pins_mix_b.txt")
        ))
        a.onEvent = { eventsA.append($0) }
        b.onEvent = { eventsB.append($0) }

        // Connect + auth A first, then B (staggering avoids concurrent TLS handshakes).
        XCTAssertEqual(a.connect(host: "127.0.0.1", port: port), .ok)
        XCTAssertEqual(a.authenticateGuest("SwiftMixA"), .ok)
        XCTAssertTrue(waitFor { eventsA.contains { $0.type == .serverIdentity } })
        XCTAssertEqual(a.confirmServerIdentity(accept: true), .ok)
        XCTAssertTrue(waitFor { eventsA.contains { $0.type == .authResult } })
        XCTAssertEqual(try XCTUnwrap(eventsA.first { $0.type == .authResult }).result, .ok)
        XCTAssertTrue(waitFor { eventsA.contains { $0.type == .channelList } })

        XCTAssertEqual(b.connect(host: "127.0.0.1", port: port), .ok)
        XCTAssertEqual(b.authenticateGuest("SwiftMixB"), .ok)
        XCTAssertTrue(waitFor { eventsB.contains { $0.type == .serverIdentity } })
        XCTAssertEqual(b.confirmServerIdentity(accept: true), .ok)
        XCTAssertTrue(waitFor { eventsB.contains { $0.type == .authResult } })
        XCTAssertEqual(try XCTUnwrap(eventsB.first { $0.type == .authResult }).result, .ok)
        XCTAssertTrue(waitFor { eventsB.contains { $0.type == .channelList } })

        // Both join Lobby (channel 1) so voice relays between them.
        XCTAssertEqual(a.joinChannel(1), .ok)
        XCTAssertTrue(waitFor { eventsA.contains { $0.type == .joinResult } },
                      "A did not receive .joinResult")
        XCTAssertEqual(b.joinChannel(1), .ok)
        XCTAssertTrue(waitFor { eventsB.contains { $0.type == .joinResult } },
                      "B did not receive .joinResult")

        // UDP binding handshake is async; give it a moment.
        Thread.sleep(forTimeInterval: 0.5)

        // A publishes a MIC stream.
        let (startResult, streamId) = a.startStream(StreamDescriptor(kind: .mic, label: "mix-test-mic"))
        XCTAssertEqual(startResult, .ok)
        XCTAssertNotEqual(streamId, 0)

        // B sees A's stream.
        XCTAssertTrue(waitFor(timeout: 5) {
            eventsB.contains { $0.type == .streamStarted && $0.streamId == streamId }
        }, "B did not see A's .streamStarted")

        // Resolve A's user id from B's user list.
        var aUid: UInt32 = 0
        XCTAssertTrue(waitFor(timeout: 3) {
            aUid = b.listUsers().first { $0.nickname == "SwiftMixA" }?.id ?? 0
            return aUid != 0
        }, "could not resolve A's user id on B")
        XCTAssertNotEqual(aUid, 0)

        // B can enumerate A's stream.
        XCTAssertTrue(waitFor(timeout: 3) {
            b.listUserStreams(aUid).contains { $0.id == streamId }
        }, "B could not enumerate A's stream")
        let bStreams = b.listUserStreams(aUid)
        XCTAssertTrue(bStreams.contains { $0.id == streamId && $0.kind == .mic })

        // Before B ever sets anything, defaults read back (gain 1.0, unmuted, NR off).
        let (r0, st0) = b.getRemoteStream(userId: aUid, streamId: streamId)
        XCTAssertEqual(r0, .ok)
        XCTAssertNotNil(st0)
        XCTAssertEqual(st0?.gain, 1.0)
        XCTAssertFalse(st0?.muted ?? true)
        XCTAssertFalse(st0?.noiseReduction ?? true)

        // B turns A down to 0.5×, mutes, enables NR — then reads it back.
        XCTAssertEqual(b.setRemoteStream(userId: aUid, streamId: streamId,
                                         gain: 0.5, muted: true, noiseReduction: true), .ok)
        let (r1, st1) = b.getRemoteStream(userId: aUid, streamId: streamId)
        XCTAssertEqual(r1, .ok)
        XCTAssertNotNil(st1)
        XCTAssertEqual(st1?.gain, 0.5)
        XCTAssertTrue(st1?.muted ?? false)
        XCTAssertTrue(st1?.noiseReduction ?? false)

        // Unknown stream id on a known user → .invalidArg.
        let (rBad, stBad) = b.getRemoteStream(userId: aUid, streamId: 0xDEADBEEF)
        XCTAssertEqual(rBad, .invalidArg)
        XCTAssertNil(stBad)

        a.disconnect()
        b.disconnect()
    }
}
